diff --git a/dev_test_scripts/debug/debug_physics_balls_dom_smoke.js b/dev_test_scripts/debug/debug_physics_balls_dom_smoke.js
new file mode 100644
index 0000000..caadcee
--- /dev/null
+++ b/dev_test_scripts/debug/debug_physics_balls_dom_smoke.js
@@ -0,0 +1,126 @@
+/* ============================================================
+ * debug_physics_balls_dom_smoke.js · 物理弹球 · 前端 DOM 桩冒烟
+ * 无浏览器环境下用最小 DOM/Canvas 桩加载全部前端脚本,
+ * 驱动主循环 + 模拟拖拽发射/暂停/记录等交互,捕获运行时错误。
+ * 运行:node dev_test_scripts/debug/debug_physics_balls_dom_smoke.js
+ * ============================================================ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const LAB_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
+const FILES = ['pb_physics.js', 'pb_rules.js', 'pb_storage.js', 'pb_audio.js', 'pb_render.js', 'pb_game.js', 'pb_ui.js'];
+
+function makeCtx() {
+ const store = {};
+ return new Proxy(store, {
+ get(t, k) {
+ if (k === 'createRadialGradient') return () => ({ addColorStop() {} });
+ if (k === 'measureText') return () => ({ width: 10 });
+ if (k in t) return t[k];
+ return function () {};
+ },
+ set(t, k, v) { t[k] = v; return true; }
+ });
+}
+
+function makeEl(id) {
+ const listeners = {};
+ return {
+ id,
+ style: {},
+ dataset: {},
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
+ children: [],
+ _html: '', _text: '',
+ set innerHTML(v) { this._html = v; this.children = []; },
+ get innerHTML() { return this._html; },
+ set textContent(v) { this._text = String(v); },
+ get textContent() { return this._text; },
+ appendChild(c) { this.children.push(c); },
+ addEventListener(t, f) { (listeners[t] = listeners[t] || []).push(f); },
+ dispatch(t, ev) { (listeners[t] || []).forEach(f => f(ev || {})); },
+ getBoundingClientRect() { return { left: 0, top: 0, width: 420, height: 880 }; },
+ getContext() { return makeCtx(); },
+ checked: true,
+ width: 420, height: 880
+ };
+}
+
+const els = {};
+function getEl(id) { if (!els[id]) els[id] = makeEl(id); return els[id]; }
+
+let rafCb = null;
+const winListeners = {};
+const sandbox = {
+ console, Math, Date, setTimeout, clearTimeout,
+ JSON, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
+ navigator: { clipboard: { writeText: () => ({ then: (a) => a && a() }) } },
+ requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
+ document: {
+ getElementById: getEl,
+ createElement: (tag) => makeEl('dyn_' + tag)
+ },
+ addEventListener: (t, f) => { (winListeners[t] = winListeners[t] || []).push(f); },
+ dispatchWin: (t, ev) => { (winListeners[t] || []).forEach(f => f(ev || {})); },
+ window: null
+};
+sandbox.window = sandbox;
+sandbox.globalThis = sandbox;
+vm.createContext(sandbox);
+
+for (const f of FILES) {
+ vm.runInContext(fs.readFileSync(path.join(LAB_DIR, f), 'utf-8'), sandbox, { filename: f });
+}
+
+function frames(n, t0) {
+ for (let i = 0; i < n; i++) {
+ const cb = rafCb; rafCb = null;
+ if (!cb) throw new Error('rAF chain broken');
+ cb(t0 + i * 16.7);
+ }
+}
+
+console.log('== 物理弹球 DOM 冒烟 ==');
+// init(load 事件不会在 vm 里触发,手动调)
+sandbox.PBUi.init();
+frames(30, 0);
+console.log(' ✓ init + 30 帧主菜单渲染');
+
+// 模拟拖拽发射(pointermove/up 注册在 window 级)
+const canvas = getEl('pb-canvas');
+canvas.dispatch('pointerdown', { clientX: 260, clientY: 600 });
+frames(5, 600);
+sandbox.dispatchWin('pointermove', { clientX: 300, clientY: 650 });
+sandbox.dispatchWin('pointerup', {});
+frames(60 * 6, 700);
+console.log(' ✓ 拖拽发射 + 6s 模拟');
+
+// 暂停 / 继续
+getEl('pb-pause-btn').dispatch('click');
+getEl('pb-resume').dispatch('click');
+frames(60, 60 * 7 * 16.7);
+console.log(' ✓ 暂停/继续');
+
+// 记录页 / 指南 / 设置 / 每日领取
+getEl('pb-side-records').dispatch('click');
+getEl('pb-records-close').dispatch('click');
+getEl('pb-help').dispatch('click');
+getEl('pb-guide-ok').dispatch('click');
+getEl('pb-gear').dispatch('click');
+getEl('pb-settings-close').dispatch('click');
+getEl('pb-daily').dispatch('click');
+console.log(' ✓ 记录/指南/设置/每日领取交互');
+
+// 长时模拟 60s,覆盖回合推进/上升/回收
+for (let s = 0; s < 60; s++) {
+ if (s % 7 === 0) {
+ canvas.dispatch('pointerdown', { clientX: 150 + (s * 13) % 200, clientY: 620 });
+ sandbox.dispatchWin('pointermove', { clientX: 190 + (s * 13) % 160, clientY: 660 });
+ sandbox.dispatchWin('pointerup', {});
+ }
+ frames(60, (10 + s) * 1000);
+}
+console.log(' ✓ 60s 长时模拟(含多次发射)');
+console.log('\n冒烟通过,无运行时异常');
diff --git a/dev_test_scripts/debug/debug_physics_balls_preview_match.js b/dev_test_scripts/debug/debug_physics_balls_preview_match.js
new file mode 100644
index 0000000..74a1975
--- /dev/null
+++ b/dev_test_scripts/debug/debug_physics_balls_preview_match.js
@@ -0,0 +1,52 @@
+/* ============================================================
+ * debug_physics_balls_preview_match.js · 物理弹球 · 预览=实际轨迹验证
+ * 清空砖块后以固定角度发射,对比 simulatePath 预览点与真实球位置:
+ * 瞄准折线必须与球实际运动线路一致(误差 < 2px)。
+ * 运行:node dev_test_scripts/debug/debug_physics_balls_preview_match.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() {}, claim() {} },
+ PBStorage: { getEscape() { return 50; } }
+};
+vm.createContext(ctx);
+for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js', 'pb_render.js']) {
+ vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
+}
+
+const G = ctx.PBGame, RD = ctx.PBRender;
+
+let ok = true;
+for (const ang of [0, 0.35, -0.6, 1.0]) {
+ const g = G.create();
+ G.startGame(g);
+ g.blocks = []; g.items = []; // 清空砖块,保证对比确定
+ g.aimAngle = ang;
+
+ const pts = RD.simulatePath(g); // 预览点:pts[k] 对应 t = k/60 s(subDt=1/240,每 4 子步采样)
+
+ G.fire(g);
+ let maxErr = 0, checked = 0;
+ for (let f = 1; f <= 30; f++) { // 前 0.5s,每帧 4 子步与预览采样对齐
+ G.update(g, 1 / 60);
+ if (!g.balls.length) break;
+ if (f % 2) continue;
+ if (f >= pts.length) break;
+ const b = g.balls[0];
+ const err = Math.hypot(b.x - pts[f].x, b.y - pts[f].y);
+ maxErr = Math.max(maxErr, err);
+ checked++;
+ }
+ const pass = checked >= 10 && maxErr < 2;
+ console.log((pass ? 'PASS ' : 'FAIL ') + 'angle=' + ang + ' checked=' + checked + ' maxErr=' + maxErr.toFixed(2) + 'px');
+ ok = pass && ok;
+}
+
+process.exit(ok ? 0 : 1);
diff --git a/dev_test_scripts/debug/debug_physics_balls_stuck_escape.js b/dev_test_scripts/debug/debug_physics_balls_stuck_escape.js
new file mode 100644
index 0000000..221d1ef
--- /dev/null
+++ b/dev_test_scripts/debug/debug_physics_balls_stuck_escape.js
@@ -0,0 +1,127 @@
+/* ============================================================
+ * 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);
diff --git a/dev_test_scripts/debug/debug_physics_balls_volley_loop.js b/dev_test_scripts/debug/debug_physics_balls_volley_loop.js
new file mode 100644
index 0000000..19de54d
--- /dev/null
+++ b/dev_test_scripts/debug/debug_physics_balls_volley_loop.js
@@ -0,0 +1,47 @@
+/* ============================================================
+ * debug_physics_balls_volley_loop.js · 物理弹球 · 大连发死循环回归
+ * 复现场景:队列 15 球连发。旧 bug:连发期间回收球回队使 queue 永不空,
+ * 状态永远停在 firing(无限连发循环,回合不推进)。
+ * 断言:1) firing 在 ~3s 内结束 2) 60s 内回合推进(turn>=2 或 over)
+ * 运行:node dev_test_scripts/debug/debug_physics_balls_volley_loop.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 G = ctx.PBGame;
+let ok = true;
+
+for (const n of [15, 25]) {
+ const g = G.create();
+ G.startGame(g);
+ g.queue = Array(n).fill(true); // 15/25 个大球,复现截图局面
+ g.aimAngle = 0.2;
+ G.fire(g);
+
+ let firingEnd = -1, turnAdvanced = false;
+ for (let i = 1; i <= 60 * 60; i++) {
+ G.update(g, 1 / 60);
+ if (firingEnd < 0 && g.state !== 'firing') firingEnd = i / 60;
+ if (g.turn >= 2 || g.state === 'over') { turnAdvanced = true; break; }
+ }
+ const p1 = firingEnd > 0 && firingEnd <= n * 0.13 + 1.5;
+ console.log((p1 ? 'PASS ' : 'FAIL ') + n + '球连发 firing 结束于 ' + (firingEnd < 0 ? '未结束(死循环)' : firingEnd.toFixed(1) + 's'));
+ console.log((turnAdvanced ? 'PASS ' : 'FAIL ') + n + '球连发 回合推进 turn=' + g.turn + ' state=' + g.state + ' queue=' + g.queue.length);
+ ok = p1 && turnAdvanced && ok;
+}
+
+process.exit(ok ? 0 : 1);
diff --git a/dev_test_scripts/unit/test_physics_balls_physics.js b/dev_test_scripts/unit/test_physics_balls_physics.js
new file mode 100644
index 0000000..f2a0f38
--- /dev/null
+++ b/dev_test_scripts/unit/test_physics_balls_physics.js
@@ -0,0 +1,236 @@
+/* ============================================================
+ * test_physics_balls_physics.js · 物理弹球 Web 复刻版 · 物理核心单元测试
+ * 运行:node dev_test_scripts/unit/test_physics_balls_physics.js
+ * 覆盖:静止不沉 / 能量不增 / 堆叠稳定 / 高速不穿透 / 休眠 / 碰撞流形 / 整局模拟无 NaN
+ * ============================================================ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const LAB_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
+
+function load(name, sandbox) {
+ const src = fs.readFileSync(path.join(LAB_DIR, name), 'utf-8');
+ vm.runInContext(src, sandbox, { filename: name });
+}
+
+// pb_game 运行时依赖 PBAudio,注入静音桩
+const sandbox = {
+ console, Math, Date,
+ PBAudio: { fire() {}, hit() {}, wall() {}, breakBlock() {}, item() {}, over() {}, claim() {}, unlock() {} },
+ PBStorage: { getEscape() { return 50; } }
+};
+vm.createContext(sandbox);
+load('pb_physics.js', sandbox);
+load('pb_rules.js', sandbox);
+load('pb_game.js', sandbox);
+const P = sandbox.PBPhysics;
+const R = sandbox.PBRules;
+const G = sandbox.PBGame;
+const P2 = P, G2 = G, R2 = R;
+
+let pass = 0, fail = 0;
+function t(name, fn) {
+ try { fn(); pass++; console.log(' ✓ ' + name); }
+ catch (e) { fail++; console.error(' ✗ ' + name + ' -> ' + e.message); }
+}
+function assert(cond, msg) { if (!cond) throw new Error(msg || 'assert failed'); }
+function near(a, b, eps, msg) { if (Math.abs(a - b) > eps) throw new Error((msg || 'near') + `: ${a} vs ${b}`); }
+
+const PHYS = R.PHYS;
+
+console.log('== 物理弹球 物理核心单元测试 ==');
+
+t('球落在静态地面上:5s 后静止且不沉陷', () => {
+ const ground = P.makeCircle({ shape: 'circle', x: 200, y: 1500, r: 1000, isStatic: true, friction: 0.3 });
+ const ball = P.makeCircle({ shape: 'circle', x: 200, y: 400, r: 7, friction: 0.2, restitution: 0.3 });
+ for (let i = 0; i < 360 * 5; i++) P.stepWorld([ground, ball], PHYS, 1 / 360, {});
+ const surf = 1500 - 1000;
+ assert(Math.abs(ball.y - (surf - 7)) < 3, 'ball y=' + ball.y + ' expect ~' + (surf - 7));
+ assert(ball.sleeping, 'ball should sleep');
+});
+
+t('能量不增:自由落体反弹后机械能 <= 初始', () => {
+ const ground = P.makeCircle({ shape: 'circle', x: 200, y: 1600, r: 1000, isStatic: true });
+ const ball = P.makeCircle({ shape: 'circle', x: 200, y: 300, r: 7, restitution: 0.5 });
+ const E0 = PHYS.g * (1600 - 1000 - 7 - 300);
+ let maxE = -1e9;
+ for (let i = 0; i < 360 * 3; i++) {
+ P.stepWorld([ground, ball], PHYS, 1 / 360, {});
+ const h = (600 - 7) - ball.y;
+ const E = PHYS.g * h + 0.5 * (ball.vx * ball.vx + ball.vy * ball.vy);
+ if (E > maxE) maxE = E;
+ }
+ assert(maxE <= E0 * 1.05, 'energy grew: ' + maxE + ' > ' + E0);
+});
+
+t('静态砖块受球撞击后位置不变(锁死固定)', () => {
+ const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 200, y: 300, isStatic: true, friction: 0.35, restitution: 0.7 });
+ const ball = P.makeCircle({ shape: 'circle', x: 200, y: 250, r: 7, restitution: 0.7 });
+ ball.vy = 800;
+ let pairs = {};
+ for (let i = 0; i < 360; i++) pairs = P.stepWorld([box, ball], PHYS, 1 / 360, pairs).pairs;
+ assert(Math.abs(box.x - 200) < 0.01 && Math.abs(box.y - 300) < 0.01, 'static block moved');
+ // 球可能多次反弹后停在砖块顶部(物理正确),但不应穿透
+ assert(ball.y > 250, 'ball should not penetrate block, y=' + ball.y);
+});
+
+t('高速球不穿透薄墙(3600px/s)', () => {
+ const wall = P.makePoly({ shape: 'poly', verts: P.boxVerts(8, 400), x: 16, y: 400, angle: 0, isStatic: true });
+ const ball = P.makeCircle({ shape: 'circle', x: 300, y: 400, r: 7 });
+ ball.vx = -3600;
+ let pairs = {};
+ for (let i = 0; i < 360 * 2; i++) pairs = P.stepWorld([wall, ball], PHYS, 1 / 360, pairs).pairs;
+ assert(ball.x > 16 + 4, 'ball tunneled: x=' + ball.x);
+});
+
+t('circle-poly 流形法线方向正确(圆在多边形上方)', () => {
+ const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(60, 20), x: 100, y: 100, isStatic: true });
+ const ball = P.makeCircle({ shape: 'circle', x: 100, y: 85, r: 7 });
+ const m = P.collide(ball, box);
+ assert(m, 'should collide');
+ // 法线 a→b:ball 是 a,box 是 b,法线应向下(ny>0)
+ assert(m.ny > 0.9, 'normal ny=' + m.ny);
+});
+
+t('poly-poly 接触产生流形(两方块叠放)', () => {
+ const a = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 100, y: 100, isStatic: true });
+ const b = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 100, y: 71 });
+ const m = P.collide(a, b);
+ assert(m, 'should collide');
+ assert(m.points.length >= 1, 'manifold points');
+});
+
+t('新接触事件:球撞静态块触发一次 event', () => {
+ const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 200, y: 300, isStatic: true });
+ const ball = P.makeCircle({ shape: 'circle', x: 200, y: 100, r: 7 });
+ ball.vy = 800;
+ let pairs = {}, events = 0;
+ for (let i = 0; i < 360; i++) {
+ const res = P.stepWorld([box, ball], PHYS, 1 / 360, pairs);
+ pairs = res.pairs;
+ events += res.events.length;
+ }
+ assert(events >= 1, 'no hit event');
+});
+
+t('规则层:makeRow 的 hp 在回合区间内', () => {
+ for (let n = 1; n <= 30; n++) {
+ const [lo, hi] = R.hpRangeForTurn(n);
+ const row = R.makeRow(n, 700, 5);
+ row.forEach(d => {
+ if (d.item) return;
+ assert(d.hp >= lo && d.hp <= hi, 'hp out of range n=' + n);
+ });
+ }
+});
+
+t('规则层:两侧随机贴边(有封边也有留口,块不穿墙)', () => {
+ const WL = R.FIELD.wallL, WR = R.FIELD.wallR;
+ let leftClosed = 0, rightClosed = 0, leftOpen = 0, rightOpen = 0;
+ for (let n = 1; n <= 80; n++) {
+ const row = R.makeRow(n, 700, 5);
+ const hasL = row.some(d => !d.item && d.x <= WL + 26);
+ const hasR = row.some(d => !d.item && d.x >= WR - 26);
+ if (hasL) leftClosed++; else leftOpen++;
+ if (hasR) rightClosed++; else rightOpen++;
+ row.forEach(d => {
+ if (d.item) return;
+ assert(d.x >= WL + 19.5 && d.x <= WR - 19.5, 'block out of wall x=' + d.x);
+ });
+ }
+ assert(leftClosed > 10 && rightClosed > 10, 'sides never closed');
+ assert(leftOpen > 5 && rightOpen > 5, 'sides never open');
+});
+
+t('规则层:同行块严格无重叠(允许紧贴,含道具)', () => {
+ for (let n = 1; n <= 60; n++) {
+ const row = R.makeRow(n, 700, 5);
+ for (let i = 0; i < row.length; i++) {
+ for (let j = i + 1; j < row.length; j++) {
+ const a = row[i], b = row[j];
+ const ca = a.item ? 12 : R.boundRadius(a.shape);
+ const cb = b.item ? 12 : R.boundRadius(b.shape);
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
+ assert(d >= ca + cb - 1e-6, 'overlap d=' + d.toFixed(1) + ' need>=' + (ca + cb).toFixed(1));
+ }
+ }
+ }
+});
+
+t('规则层:相邻行块也无重叠(行距52,含抖动)', () => {
+ for (let round = 0; round < 10; round++) {
+ const avoid = [], all = [];
+ for (let n = 1; n <= 6; n++) {
+ const y = 792 - (n - 1) * 52;
+ const row = R.makeRow(n, y, 5, avoid);
+ row.forEach(d => {
+ const cr = d.item ? 12 : R.boundRadius(d.shape);
+ avoid.push({ x: d.x, y: d.y, cr });
+ all.push({ x: d.x, y: d.y, cr });
+ });
+ }
+ for (let i = 0; i < all.length; i++)
+ for (let j = i + 1; j < all.length; j++) {
+ const d = Math.hypot(all[i].x - all[j].x, all[i].y - all[j].y);
+ assert(d >= all[i].cr + all[j].cr - 1e-6, 'cross-row overlap');
+ }
+ }
+});
+
+t('规则层:奖励球紧贴混排在卡片旁(>=80% 相邻)', () => {
+ let items = 0, adjacent = 0;
+ for (let n = 1; n <= 60; n++) {
+ const row = R.makeRow(n, 700, 5);
+ const blocks = row.filter(d => !d.item);
+ row.forEach(d => {
+ if (!d.item) return;
+ items++;
+ if (blocks.some(b => Math.hypot(b.x - d.x, b.y - d.y) < R.boundRadius(b.shape) + 12 + 8)) adjacent++;
+ });
+ }
+ assert(items > 10, 'too few items: ' + items);
+ assert(adjacent / items >= 0.8, 'adjacent ratio ' + (adjacent / items).toFixed(2));
+});
+
+t('游戏层:奖励球静态不滚落(3s 位置不变)', () => {
+ const g = G2.create();
+ G2.startGame(g);
+ const before = g.items.map(it => it.x.toFixed(1) + ',' + it.y.toFixed(1)).join('|');
+ for (let i = 0; i < 180; i++) G2.update(g, 1 / 60);
+ const after = g.items.map(it => it.x.toFixed(1) + ',' + it.y.toFixed(1)).join('|');
+ assert(before === after, 'items moved: ' + after);
+});
+
+t('整局模拟 20s:发射/回收/回合推进无 NaN 无异常', () => {
+ const g = G2.create();
+ G2.startGame(g);
+ g.aimAngle = 0.3;
+ let fired = 0;
+ for (let i = 0; i < 60 * 40; i++) {
+ if (g.state === 'aim' && g.queue.length && i % 60 === 0) { g.aimAngle = (Math.random() - 0.5) * 1.6; G2.fire(g); fired++; }
+ G2.update(g, 1 / 60);
+ if (g.state === 'over') break;
+ }
+ assert(fired >= 3, 'should fire several turns, fired=' + fired);
+ const all = g.blocks.concat(g.items, g.balls);
+ assert(all.every(b => isFinite(b.x) && isFinite(b.y) && isFinite(b.angle)), 'NaN bodies');
+ assert(g.turn >= 3, 'turns should advance, turn=' + g.turn);
+});
+
+t('回收机制:角落球进入输送带并回到队列', () => {
+ const g = G2.create();
+ G2.startGame(g);
+ // 手动塞一个球到左角落
+ const b = P2.makeCircle({ shape: 'circle', x: R2.FIELD.wallL + 10, y: 800, r: 7, tag: 'ball' });
+ b.data = { big: false };
+ g.balls.push(b);
+ g.state = 'fly';
+ for (let i = 0; i < 60 * 3; i++) G2.update(g, 1 / 60);
+ assert(g.recycle.length === 0 && g.balls.length === 0, 'ball should be recycled back to queue');
+ assert(g.queue.length >= 1, 'queue should get ball back');
+});
+
+console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
+process.exit(fail ? 1 : 0);
diff --git a/public/tools/private_clipboard/clipboard.js b/public/tools/private_clipboard/clipboard.js
index 9445b40..fddf4f6 100644
--- a/public/tools/private_clipboard/clipboard.js
+++ b/public/tools/private_clipboard/clipboard.js
@@ -20,12 +20,32 @@ const state = {
}
}
const isQuickEntryMode = () => state.entryMode === 'quick'
+const MAX_UPLOAD_BYTES = 200 * 1024 * 1024
const normalizeError = (error) => {
const text = String(error && error.message ? error.message : error || '').trim()
if (!text) return 'unknown_error'
return text
}
+const describeUploadError = (error, file) => {
+ const code = normalizeError(error)
+ const name = String(file && file.name || '').trim()
+ const prefix = name ? `文件“${name}”` : '文件'
+ if (code === 'unauthorized') return '上传失败:登录状态已失效,请重新登录'
+ if (code === 'empty_file') return `${prefix}为空,未能上传`
+ if (code === 'file_too_large' || code === 'upload_failed_413' || code === 'unexpected_response_413') {
+ return `${prefix}过大,当前上限为 200MB`
+ }
+ if (code === 'upload_network_error') return '上传失败:网络中断或服务器无响应'
+ if (code === 'upload_aborted') return '上传已中断,请重新上传'
+ if (code === 'upload_failed_400') return '上传失败:请求无效,请重新选择文件后再试'
+ if (code === 'upload_failed_401') return '上传失败:当前未登录或登录已过期'
+ if (code === 'upload_failed_403') return '上传失败:当前没有权限执行此操作'
+ if (code === 'upload_failed_404') return '上传失败:上传接口不存在或访问地址失效'
+ if (code === 'upload_failed_415') return '上传失败:文件类型或请求格式不被服务器接受'
+ if (code === 'upload_failed_500' || code === 'upload_failed') return '上传失败:服务器处理文件时出错'
+ return `上传失败:${code}`
+}
const folderDisplayName = (code) => {
const folder = state.folders.find(item => item.code === code)
return folder ? folder.name : '未命名目录'
@@ -400,9 +420,14 @@ const uploadFiles = async (files, { uploadingText = '正在上传...', successMe
try {
let done = 0
for (const file of list) {
- await uploadFile(file, groupId, (loaded, totalBytes) => {
- setProgress(true, done, list.length, { loaded, total: totalBytes })
- })
+ try {
+ await uploadFile(file, groupId, (loaded, totalBytes) => {
+ setProgress(true, done, list.length, { loaded, total: totalBytes })
+ })
+ } catch (error) {
+ if (error && typeof error === 'object' && !error.file) error.file = file
+ throw error
+ }
done += 1
setProgress(true, done, list.length)
}
@@ -755,6 +780,10 @@ const resolveFileName = (file) => {
// 使用 XHR 上传,利用 upload.onprogress 获取字节级进度(fetch 不支持上传进度事件)
const uploadFile = (file, groupId, onProgress) => new Promise((resolve, reject) => {
if (!file) { resolve(); return }
+ if (Number(file.size || 0) > MAX_UPLOAD_BYTES) {
+ reject(new Error('file_too_large'))
+ return
+ }
const name = resolveFileName(file)
const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : ''
const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}`
@@ -851,7 +880,8 @@ const handlePaste = async (event) => {
if (stringSaveError) throw stringSaveError
await refreshCurrentView({ successMessage: '已保存到服务器' })
} catch (e) {
- setStatus('保存失败', 'error')
+ const files = items.filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean)
+ setStatus(files.length ? describeUploadError(e, e && e.file ? e.file : files[0]) : `保存失败:${normalizeError(e)}`, 'error')
} finally {
setProgress(false)
}
@@ -867,7 +897,7 @@ const handleDrop = async (event) => {
try {
await uploadFiles(files)
} catch (e) {
- setStatus('上传失败', 'error')
+ setStatus(describeUploadError(e, e && e.file ? e.file : files[0]), 'error')
}
}
@@ -980,7 +1010,7 @@ const bindDesktopClassicInputs = () => {
await uploadFiles(files, { uploadingText })
input.value = ''
} catch (error) {
- setStatus('上传失败', 'error')
+ setStatus(describeUploadError(error, error && error.file ? error.file : files[0]), 'error')
}
})
}
@@ -1020,7 +1050,7 @@ const bindMobileInputs = () => {
await uploadFiles(files)
inputEl.value = ''
} catch (e) {
- setStatus('上传失败', 'error')
+ setStatus(describeUploadError(e, e && e.file ? e.file : files[0]), 'error')
}
}
}
diff --git a/public/tools/thought_lab/labs/physics_balls/index.html b/public/tools/thought_lab/labs/physics_balls/index.html
new file mode 100644
index 0000000..eef8561
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/index.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+物理弹球 · 思想实验室
+
+
+
+
+
+
+
+
+
时间 00:00
+
🥎 0
+
大球 ×2
+
0
+
+
+
+
+
+
暂 停
+
+
+
+
+
+
+
本局结束
+
0
+
用时 00:00
+
★ 新纪录 ★
+
最佳: 0
+
+
+
+
+
+
+
+
游戏指南
+
+
1. 水平移动手指,瞄准数字块。
+
2. 击中数字块得分(小球+1,大球+2)。
+
3. 数字块到达顶端,游戏结束。
+
4. 留意出现的道具助你一臂之力。
+
5. 球陷入夹角时会全力研磨接触的数字块,快速开出逃逸空间。
+
+
+
+
+
设置
+
+
+ 逃逸强度
+
+ 50
+
+
越高:卡住的球挣脱越猛,越简单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_audio.js b/public/tools/thought_lab/labs/physics_balls/pb_audio.js
new file mode 100644
index 0000000..bdbae1d
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_audio.js
@@ -0,0 +1,59 @@
+/* ============================================================
+ * pb_audio.js · 物理弹球 Web 复刻版 · WebAudio 合成音效
+ * 无外部资源:发射/击中/碎裂/道具/结束 均为振荡器合成。
+ * 设置页可开关(PBStorage.soundOn)。
+ * ============================================================ */
+var PBAudio = (function () {
+ 'use strict';
+ var ctx = null;
+ var lastHit = 0, lastWall = 0; // 分开节流,防砖块命中音被墙壁音吃掉
+
+ function ac() {
+ if (!ctx) {
+ try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; }
+ }
+ if (ctx && ctx.state === 'suspended') { try { ctx.resume(); } catch (e) {} }
+ return ctx;
+ }
+
+ function enabled() {
+ try { return PBStorage.soundOn(); } catch (e) { return true; }
+ }
+
+ function tone(freq, dur, type, gain, slideTo) {
+ var c = ac();
+ if (!c || !enabled()) return;
+ try {
+ var o = c.createOscillator();
+ var g = c.createGain();
+ o.type = type || 'sine';
+ o.frequency.setValueAtTime(freq, c.currentTime);
+ if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, c.currentTime + dur);
+ g.gain.setValueAtTime(gain || 0.08, c.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
+ o.connect(g); g.connect(c.destination);
+ o.start(); o.stop(c.currentTime + dur + 0.02);
+ } catch (e) {}
+ }
+
+ return {
+ unlock: function () { ac(); },
+ fire: function () { tone(520, 0.12, 'triangle', 0.06, 260); },
+ hit: function (speed) {
+ var now = Date.now();
+ if (now - lastHit < 45) return; // 限流防炸音
+ lastHit = now;
+ tone(300 + Math.min(500, speed * 0.15), 0.06, 'square', 0.035);
+ },
+ wall: function () {
+ var now = Date.now();
+ if (now - lastWall < 45) return;
+ lastWall = now;
+ tone(180, 0.05, 'sine', 0.03);
+ },
+ breakBlock: function () { tone(760, 0.16, 'sawtooth', 0.06, 180); },
+ item: function () { tone(660, 0.1, 'sine', 0.07, 990); tone(990, 0.16, 'sine', 0.06, 1320); },
+ over: function () { tone(392, 0.25, 'triangle', 0.08, 196); },
+ claim: function () { tone(523, 0.09, 'sine', 0.07); tone(659, 0.09, 'sine', 0.07); tone(784, 0.16, 'sine', 0.07); }
+ };
+})();
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_game.js b/public/tools/thought_lab/labs/physics_balls/pb_game.js
new file mode 100644
index 0000000..1c010c4
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_game.js
@@ -0,0 +1,430 @@
+/* ============================================================
+ * pb_game.js · 物理弹球 Web 复刻版 · 游戏状态机
+ * 回合流程:aim(拖拽瞄准) -> firing(连发) -> fly(等全部回收)
+ * -> rise(砖块上升+生成新行) -> aim ... 砖块越顶线 = over
+ * 球回收:滚入底部两角 -> 侧边输送带沿壁推回顶部 -> 回到待发队列
+ * 计分:小球击中 +1 / 大球击中 +2(同时为砖块伤害)
+ * ============================================================ */
+var PBGame = (function () {
+ 'use strict';
+ var R = PBRules, P = PBPhysics, F = R.FIELD;
+
+ var RISE_PX = 52; // 每回合上升高度
+ var RISE_TIME = 0.5; // 上升动画时长
+ var ROW_Y = 792; // 新行生成中心 y
+ var RECYCLE_V = 950; // 输送带上升速度
+ var CORNER_X = 34; // 角落回收判定宽
+ var CORNER_Y = 770; // 角落回收判定 y
+
+ function create() {
+ var g = {
+ state: 'idle', // idle|aim|firing|fly|rise|over
+ turn: 1,
+ score: 0,
+ secs: 0,
+ paused: false,
+ bigTurn: false, // 大球生效中:此后入队的球为大球 ⚽️(伤害2)
+ queue: [], // 待发队列:每元素为大小标记(false=小球 true=大球⚽️)
+ queueBonus: 0,
+ balls: [], // 场内飞行球 body
+ recycle: [], // {x,y,side,phase}
+ blocks: [], // body
+ items: [], // body
+ walls: [],
+ particles: [],
+ pairs: {},
+ riseT: 0,
+ fireTimer: 0,
+ aimAngle: 0, // 相对垂直方向(rad,左负右正)
+ aiming: false,
+ overInfo: null,
+ volley: 0, // 本回合待发快照数(防回收回队造成无限连发)
+ time: 0
+ };
+ buildWalls(g);
+ return g;
+ }
+
+ // ---- 静态边界:左右壁 + 顶壁 + 漏斗斜壁 + 底部大圆弧 ----
+ function buildWalls(g) {
+ var seg = function (x1, y1, x2, y2) {
+ // 细长按矩形刚体表示,保证多边形碰撞稳定
+ var dx = x2 - x1, dy = y2 - y1;
+ var len = Math.sqrt(dx * dx + dy * dy);
+ var b = P.makePoly({
+ shape: 'poly',
+ verts: P.boxVerts(len, 8),
+ x: (x1 + x2) / 2, y: (y1 + y2) / 2,
+ angle: Math.atan2(dy, dx),
+ isStatic: true, friction: 0.1, restitution: 0.3, tag: 'wall'
+ });
+ return b;
+ };
+ g.walls.push(seg(F.wallL - 4, F.topY, F.wallL - 4, F.H + 40)); // 左壁
+ g.walls.push(seg(F.wallR + 4, F.topY, F.wallR + 4, F.H + 40)); // 右壁
+ g.walls.push(seg(F.wallL, F.topY - 4, F.wallR, F.topY - 4)); // 顶壁
+ var fn = R.funnelSegs();
+ g.walls.push(seg(fn[0].x1, fn[0].y1, fn[0].x2, fn[0].y2)); // 漏斗左
+ g.walls.push(seg(fn[1].x1, fn[1].y1, fn[1].x2, fn[1].y2)); // 漏斗右
+ // 底部大圆弧(静态圆)
+ g.walls.push(P.makeCircle({ shape: 'circle', x: F.arcCx, y: F.arcCy, r: F.arcR, isStatic: true, friction: 0.15, restitution: 0.25, tag: 'wall' }));
+ }
+
+ // ---- 砖块 / 道具 刚体 ----
+ function makeBlock(desc) {
+ var r = R.shapeRadius(desc.shape);
+ var body;
+ if (desc.shape === 'circle') {
+ body = P.makeCircle({ shape: 'circle', x: desc.x, y: desc.y, r: r, isStatic: true, friction: 0.35, restitution: 0.95, tag: 'block' });
+ } else {
+ var n = desc.shape === 'box' ? 4 : (desc.shape === 'tri' ? 3 : 5);
+ var verts = n === 4 ? P.boxVerts(r * 1.7, r * 1.7) : P.regularVerts(n, r, 0);
+ body = P.makePoly({ shape: 'poly', verts: verts, x: desc.x, y: desc.y, angle: desc.angle || 0, isStatic: true, friction: 0.35, restitution: 0.95, tag: 'block' });
+ }
+ body.data = { hp: desc.hp, shape: desc.shape, r: r };
+ return body;
+ }
+
+ function makeItem(desc) {
+ // 静态刚体:奖励球固定在卡片中间,不随重力滚落
+ var body = P.makeCircle({ shape: 'circle', x: desc.x, y: desc.y, r: R.ITEMS[desc.item].r, isStatic: true, friction: 0.3, restitution: 0.2, tag: 'item' });
+ body.data = { type: desc.item };
+ return body;
+ }
+
+ function spawnRow(g, n, y) {
+ // 已有块作为 avoid,保证新行与旧行严格无重叠
+ var avoid = [];
+ var i;
+ for (i = 0; i < g.blocks.length; i++) avoid.push({ x: g.blocks[i].x, y: g.blocks[i].y, cr: R.boundRadius(g.blocks[i].data.shape) });
+ for (i = 0; i < g.items.length; i++) avoid.push({ x: g.items[i].x, y: g.items[i].y, cr: g.items[i].r });
+ var descs = R.makeRow(n, y, R.randInt(4, 6), avoid);
+ for (i = 0; i < descs.length; i++) {
+ if (descs[i].item) g.items.push(makeItem(descs[i]));
+ else g.blocks.push(makeBlock(descs[i]));
+ }
+ }
+
+ // ---- 开局 ----
+ function startGame(g) {
+ g.state = 'aim';
+ g.turn = 1;
+ g.score = 0;
+ g.secs = 0;
+ g.bigTurn = false;
+ g.queue = [false];
+ g.queueBonus = 0;
+ g.balls = [];
+ g.recycle = [];
+ g.blocks = [];
+ g.items = [];
+ g.particles = [];
+ g.pairs = {};
+ g.overInfo = null;
+ // 开局 4 行(复刻原版开局视觉密度),行距 56
+ for (var n = 1; n <= 4; n++) spawnRow(g, n, ROW_Y - (n - 1) * 56);
+ }
+
+ // ---- 发射 ----
+ function fire(g) {
+ if (g.state !== 'aim' || !g.queue.length || g.paused) return;
+ g.state = 'firing';
+ g.fireTimer = 0;
+ g.volley = g.queue.length; // 快照本回合发射数:期间回收的球存入下回合,不续进本 volley
+ }
+
+ function launchOne(g) {
+ var big = !!g.queue.shift() || g.bigTurn; // 逐球标记或大球回合徽章,与预览/待发显示同口径
+ var r = big ? R.BIG_R : R.BALL_R;
+ var dirX = Math.sin(g.aimAngle), dirY = Math.cos(g.aimAngle);
+ var b = P.makeCircle({
+ shape: 'circle',
+ x: F.arcCx, y: F.checkY - 6,
+ r: r, friction: 0.08, restitution: 0.95, tag: 'ball'
+ });
+ b.vx = dirX * R.FIRE_SPEED;
+ b.vy = dirY * R.FIRE_SPEED;
+ b.data = { big: big };
+ g.balls.push(b);
+ PBAudio.fire();
+ }
+
+ // ---- 砖块伤害(击中 / 夹角研磨 共用) ----
+ function damageBlock(g, block, dmg, showFloat) {
+ block.data.hp -= dmg;
+ g.score += dmg;
+ if (showFloat) floatText(g, block.x, block.y - block.data.r - 8, '+' + dmg);
+ if (block.data.hp <= 0) {
+ var idx = g.blocks.indexOf(block);
+ if (idx >= 0) g.blocks.splice(idx, 1);
+ burst(g, block.x, block.y, PBRules.hpColor(1));
+ PBAudio.breakBlock();
+ return true;
+ }
+ return false;
+ }
+
+ // ---- 击中处理 ----
+ function onHit(g, ball, other, speed) {
+ if (other.tag === 'block') {
+ damageBlock(g, other, ball.data.big ? 2 : 1, true);
+ PBAudio.hit(speed);
+ } else if (other.tag === 'item') {
+ var j = g.items.indexOf(other);
+ if (j >= 0) g.items.splice(j, 1);
+ if (other.data.type === 'ball') {
+ g.queue.push(!!g.bigTurn); // 大球生效中入队即为 ⚽️
+ floatText(g, other.x, other.y, '+1 球');
+ } else {
+ g.bigTurn = true;
+ floatText(g, other.x, other.y, '大球!');
+ }
+ burst(g, other.x, other.y, '#ffffff');
+ PBAudio.item();
+ } else if (other.tag === 'wall') {
+ PBAudio.wall();
+ }
+ }
+
+ function burst(g, x, y, color) {
+ for (var i = 0; i < 10; i++) {
+ var a = Math.random() * Math.PI * 2;
+ var sp = 120 + Math.random() * 260;
+ g.particles.push({
+ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
+ life: 0.5 + Math.random() * 0.3, t: 0,
+ size: 3 + Math.random() * 5, rot: Math.random() * 6, vr: (Math.random() - 0.5) * 10,
+ color: color, kind: 'tri'
+ });
+ }
+ }
+
+ function floatText(g, x, y, text) {
+ g.particles.push({ x: x, y: y, vx: 0, vy: -70, life: 0.9, t: 0, size: 13, color: '#ffd83d', kind: 'text', text: text });
+ }
+
+ // ---- 卡球逃脱(只许下落,不许上天) ----
+ // 球陷砖块缝隙/谷地减速 0.5s 后,朝更开阔的一侧水平推挤,让球滑出缺口继续下落。
+ // 禁止向上弹射——球唯一的"上去"方式是落到底部后由输送带送回。
+ function gapDir(g, b) {
+ var left = b.x - F.wallL, right = F.wallR - b.x;
+ for (var i = 0; i < g.blocks.length; i++) {
+ var o = g.blocks[i];
+ var cr = R.boundRadius(o.data.shape) + b.r + 2;
+ var dy = Math.abs(o.y - b.y);
+ if (dy >= cr) continue;
+ var dx = Math.sqrt(cr * cr - dy * dy);
+ if (o.x < b.x) left = Math.min(left, b.x - o.x - dx);
+ else right = Math.min(right, o.x - b.x - dx);
+ }
+ if (Math.abs(left - right) < 24) return Math.random() < 0.5 ? -1 : 1; // 势均力敌时随机
+ return left > right ? -1 : 1;
+ }
+
+ function checkStuck(g, dt) {
+ for (var i = 0; i < g.balls.length; i++) {
+ var b = g.balls[i];
+ var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
+ b.stuckT = sp < 60 ? (b.stuckT || 0) + dt : 0;
+ if (b.stuckT > 0.5 && b._touchBlock) {
+ // 夹角(>=2 接触面)交给研磨开空间,不施加横向推力,避免挤压上弹
+ var surfaces = b._touchBlocks.length + (b._touchWall ? 1 : 0);
+ if (surfaces >= 2) continue;
+ var dir = gapDir(g, b);
+ // 逃逸强度(设置页滑块 0-100):水平推力 250~1750
+ var hs = 250 + 12 * PBStorage.getEscape() + Math.random() * 300;
+ b.vx = dir * hs + (Math.random() - 0.5) * 120;
+ b.vy = (Math.random() - 0.5) * 120; // 仅微小扰动,不向上弹射
+ b.stuckT = 0;
+ P.wake(b);
+ }
+ }
+ }
+
+ // ---- 夹角研磨:陷入夹角后以最高击碎能力快速消耗接触砖块 ----
+ // 楔入条件:低速且同时接触 >=2 个面(块+块 或 块+墙)。
+ // 每 0.1s 对每块接触砖造成高额研磨伤害(随逃逸强度 2~6),
+ // 快速打碎砖块留出空间,球自然下落逃逸。
+ var GRIND_TICK = 0.1;
+ function checkGrind(g, dt) {
+ var dmg = 2 + Math.round(PBStorage.getEscape() / 25);
+ for (var i = 0; i < g.balls.length; i++) {
+ var b = g.balls[i];
+ var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
+ var surfaces = b._touchBlocks.length + (b._touchWall ? 1 : 0);
+ var inWedge = sp < 80 && b._touchBlocks.length >= 1 && surfaces >= 2;
+ if (!inWedge) { b.grindT = 0; continue; }
+ b.grindT = (b.grindT || 0) + dt;
+ while (b.grindT >= GRIND_TICK) {
+ b.grindT -= GRIND_TICK;
+ for (var k = 0; k < b._touchBlocks.length; k++) {
+ damageBlock(g, b._touchBlocks[k], dmg, false); // 研磨不飘字,避免刷屏
+ }
+ PBAudio.hit(300);
+ }
+ }
+ }
+
+ // ---- 回收 ----
+ // 入角即收;停在底部弧面(不碰砖块)超 1.2s 也收,保证回合能结束
+ function checkRecycle(g, dt) {
+ for (var i = g.balls.length - 1; i >= 0; i--) {
+ var b = g.balls[i];
+ var left = b.x < F.wallL + CORNER_X;
+ var right = b.x > F.wallR - CORNER_X;
+ var bottomRest = (b.stuckT || 0) > 1.2 && b.y > CORNER_Y && !b._touchBlock;
+ if (((left || right) && b.y > CORNER_Y) || bottomRest) {
+ var side = left ? 0 : (right ? 1 : (b.x < F.arcCx ? 0 : 1));
+ g.recycle.push({ x: b.x, y: b.y, side: side, big: b.data.big, phase: 0 });
+ g.balls.splice(i, 1);
+ }
+ }
+ }
+
+ function updateRecycle(g, dt) {
+ for (var i = g.recycle.length - 1; i >= 0; i--) {
+ var r = g.recycle[i];
+ var off = r.big ? 17 : 13; // 大球半径大,输送带偏移相应加大
+ var laneX = r.side === 0 ? F.wallL + off : F.wallR - off;
+ if (r.phase === 0) {
+ // 先水平滑入输送带
+ var dx = laneX - r.x;
+ var step = 700 * dt;
+ r.x += Math.abs(dx) <= step ? dx : Math.sign(dx) * step;
+ if (r.x === laneX) r.phase = 1;
+ } else {
+ r.y -= RECYCLE_V * dt;
+ }
+ if (r.phase === 1 && r.y < F.checkY + 6) {
+ g.recycle.splice(i, 1);
+ g.queue.push(!!(r.big || g.bigTurn)); // 大球回收仍为大球;小球在大球生效中回队升级
+ }
+ }
+ }
+
+ // ---- 回合结束 ----
+ function endTurn(g) {
+ g.turn++;
+ g.bigTurn = false;
+ g.state = 'rise';
+ g.riseT = 0;
+ }
+
+ function checkOver(g) {
+ for (var i = 0; i < g.blocks.length; i++) {
+ var b = g.blocks[i];
+ var top = b.shape === 'circle' ? b.y - b.r : aabbTop(b);
+ if (top < F.checkY) return true;
+ }
+ return false;
+ }
+
+ function aabbTop(b) {
+ var vs = P.worldVerts(b);
+ var m = 1e9;
+ for (var i = 0; i < vs.length; i++) if (vs[i].y < m) m = vs[i].y;
+ return m;
+ }
+
+ function gameOver(g) {
+ g.state = 'over';
+ PBAudio.over();
+ }
+
+ // ---- 主更新 ----
+ function update(g, dt) {
+ if (g.paused || g.state === 'idle' || g.state === 'over') return;
+ g.secs += dt;
+
+ if (g.state === 'firing') {
+ g.fireTimer -= dt;
+ if (g.fireTimer <= 0 && g.volley > 0) {
+ launchOne(g);
+ g.volley--;
+ g.fireTimer = R.FIRE_GAP_MS / 1000;
+ }
+ if (g.volley <= 0) g.state = 'fly';
+ }
+
+ if (g.state === 'rise') {
+ g.riseT += dt;
+ var k = Math.min(1, g.riseT / RISE_TIME);
+ var dy = RISE_PX * dt / RISE_TIME;
+ var i;
+ for (i = 0; i < g.blocks.length; i++) { g.blocks[i].y -= dy; }
+ for (i = 0; i < g.items.length; i++) g.items[i].y -= dy; // 静态道具随回合整体上升
+ if (k >= 1) {
+ spawnRow(g, g.turn, ROW_Y);
+ if (checkOver(g)) { gameOver(g); return; }
+ g.state = 'aim';
+ }
+ }
+
+ // 物理子步
+ var bodies = g.walls.concat(g.blocks, g.items, g.balls);
+ var sub = R.PHYS.subDt;
+ var acc = dt;
+ var bi;
+ for (bi = 0; bi < g.balls.length; bi++) {
+ g.balls[bi]._touchBlock = false;
+ g.balls[bi]._touchBlocks = [];
+ g.balls[bi]._touchWall = false;
+ }
+ while (acc > 1e-6) {
+ var h = Math.min(sub, acc);
+ var res = P.stepWorld(bodies, R.PHYS, h, g.pairs);
+ g.pairs = res.pairs;
+ // 记录本帧球接触面(砖块列表 / 墙标记,供卡球逃脱与夹角研磨)
+ for (var mi = 0; mi < res.manifolds.length; mi++) {
+ var mf = res.manifolds[mi];
+ var bm = mf.a.tag === 'ball' ? mf.a : (mf.b.tag === 'ball' ? mf.b : null);
+ var om = bm === mf.a ? mf.b : mf.a;
+ if (!bm) continue;
+ if (om.tag === 'block') {
+ bm._touchBlock = true;
+ if (bm._touchBlocks.indexOf(om) < 0) bm._touchBlocks.push(om);
+ } else if (om.tag === 'wall') {
+ bm._touchWall = true;
+ }
+ }
+ for (var e = 0; e < res.events.length; e++) {
+ var ev = res.events[e];
+ var ball = ev.a.tag === 'ball' ? ev.a : (ev.b.tag === 'ball' ? ev.b : null);
+ var other = ball === ev.a ? ev.b : ev.a;
+ if (ball) onHit(g, ball, other, ev.speed);
+ }
+ acc -= h;
+ }
+
+ checkGrind(g, dt);
+ checkStuck(g, dt);
+ checkRecycle(g, dt);
+ updateRecycle(g, dt);
+
+ // 粒子
+ for (var pi = g.particles.length - 1; pi >= 0; pi--) {
+ var pt = g.particles[pi];
+ pt.t += dt;
+ if (pt.t >= pt.life) { g.particles.splice(pi, 1); continue; }
+ pt.x += pt.vx * dt;
+ pt.y += pt.vy * dt;
+ if (pt.kind === 'tri') pt.vy += 900 * dt;
+ if (pt.rot !== undefined) pt.rot += pt.vr * dt;
+ }
+
+ if (checkOver(g)) { gameOver(g); return; }
+
+ if (g.state === 'fly' && !g.balls.length && !g.recycle.length) {
+ endTurn(g);
+ }
+ }
+
+ return {
+ create: create,
+ startGame: startGame,
+ fire: fire,
+ update: update,
+ aabbTop: aabbTop,
+ constants: { RISE_PX: RISE_PX, ROW_Y: ROW_Y }
+ };
+})();
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_physics.js b/public/tools/thought_lab/labs/physics_balls/pb_physics.js
new file mode 100644
index 0000000..84d6d01
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_physics.js
@@ -0,0 +1,456 @@
+/* ============================================================
+ * pb_physics.js · 物理弹球 Web 复刻版 · 刚体引擎
+ * 纯函数区:不依赖 DOM / 渲染,可被
+ * dev_test_scripts/unit/test_physics_balls_physics.js 直接加载做单元测试。
+ *
+ * 设计:
+ * - 刚体两类:circle / 凸多边形 poly(verts 为局部坐标,逆时针)
+ * - 碰撞检测:circle-circle / circle-poly / poly-poly(SAT 分离轴 +
+ * 顶点穿透流形,最多 2 接触点,足够砖块堆叠稳定)
+ * - 求解:序贯冲量 + 累积法向冲量钳制(≥0)+ 库仑摩擦累积钳制 +
+ * 恢复系数 bias(低速阈值内 e=0 防抖)+ Baumgarte 位置校正
+ * - 休眠:低速稳定持续一段时间 → sleeping(视同静态);
+ * 被高速接触/外力唤醒。砖块堆叠靠休眠防抖、省性能
+ * - 事件:stepWorld 返回 events(新接触对且接近速度超阈值),
+ * 供游戏层做「击中砖块 +分/扣血」与音效
+ * 单位:px、s。y 向下。
+ * ============================================================ */
+var PBPhysics = (function () {
+ 'use strict';
+
+ var UID = 1;
+
+ // ---------- 刚体工厂 ----------
+ function baseBody(o) {
+ var b = {
+ id: UID++,
+ shape: o.shape,
+ x: o.x || 0, y: o.y || 0, angle: o.angle || 0,
+ vx: 0, vy: 0, omega: 0,
+ fx: 0, fy: 0, // 累积冲量引起的速度变化由求解器直接改 vx/vy
+ r: o.r || 0,
+ verts: o.verts || null, // 局部坐标
+ friction: o.friction != null ? o.friction : 0.2,
+ restitution: o.restitution || 0,
+ isStatic: !!o.isStatic,
+ sleeping: false, sleepTimer: 0,
+ tag: o.tag || '', // 'ball' | 'block' | 'item' | 'wall'
+ data: o.data || null,
+ aabb: { x0: 0, y0: 0, x1: 0, y1: 0 }
+ };
+ if (b.isStatic) { b.m = 0; b.invM = 0; b.I = 0; b.invI = 0; }
+ return b;
+ }
+
+ function makeCircle(o) {
+ var b = baseBody(o);
+ b.shape = 'circle';
+ var m = o.r * o.r * 0.06;
+ if (!b.isStatic) {
+ b.m = m; b.invM = 1 / m;
+ b.I = 0.5 * m * o.r * o.r; b.invI = 1 / b.I;
+ }
+ return b;
+ }
+
+ function polyAreaInertia(verts) {
+ var area = 0, I = 0, i;
+ for (i = 0; i < verts.length; i++) {
+ var p1 = verts[i], p2 = verts[(i + 1) % verts.length];
+ var cross = p1.x * p2.y - p2.x * p1.y;
+ area += cross / 2;
+ I += cross / 12 * (p1.x * p1.x + p1.x * p2.x + p2.x * p2.x + p1.y * p1.y + p1.y * p2.y + p2.y * p2.y);
+ }
+ return { area: Math.abs(area), I: Math.abs(I) };
+ }
+
+ function makePoly(o) {
+ var b = baseBody(o);
+ b.shape = 'poly';
+ var ai = polyAreaInertia(o.verts);
+ if (!b.isStatic) {
+ var m = Math.max(0.5, ai.area * 0.05) * (o.massScale || 1);
+ b.m = m; b.invM = 1 / m;
+ b.I = Math.max(1, ai.I * 0.05 * (o.massScale || 1)); b.invI = 1 / b.I;
+ }
+ return b;
+ }
+
+ // ---------- 几何工具 ----------
+ function worldVerts(b) {
+ var c = Math.cos(b.angle), s = Math.sin(b.angle);
+ var out = [];
+ for (var i = 0; i < b.verts.length; i++) {
+ var v = b.verts[i];
+ out.push({ x: b.x + v.x * c - v.y * s, y: b.y + v.x * s + v.y * c });
+ }
+ return out;
+ }
+
+ function updateAabb(b) {
+ var a = b.aabb;
+ if (b.shape === 'circle') {
+ a.x0 = b.x - b.r; a.y0 = b.y - b.r; a.x1 = b.x + b.r; a.y1 = b.y + b.r;
+ } else {
+ var vs = worldVerts(b);
+ var x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
+ for (var i = 0; i < vs.length; i++) {
+ if (vs[i].x < x0) x0 = vs[i].x;
+ if (vs[i].y < y0) y0 = vs[i].y;
+ if (vs[i].x > x1) x1 = vs[i].x;
+ if (vs[i].y > y1) y1 = vs[i].y;
+ }
+ a.x0 = x0; a.y0 = y0; a.x1 = x1; a.y1 = y1;
+ }
+ }
+
+ function aabbOverlap(a, b) {
+ return a.aabb.x0 <= b.aabb.x1 && a.aabb.x1 >= b.aabb.x0 &&
+ a.aabb.y0 <= b.aabb.y1 && a.aabb.y1 >= b.aabb.y0;
+ }
+
+ function pointInPoly(px, py, vs) {
+ var sign = 0;
+ for (var i = 0; i < vs.length; i++) {
+ var a = vs[i], b = vs[(i + 1) % vs.length];
+ var cr = (b.x - a.x) * (py - a.y) - (b.y - a.y) * (px - a.x);
+ if (cr !== 0) {
+ var sg = cr > 0 ? 1 : -1;
+ if (sign === 0) sign = sg;
+ else if (sg !== sign) return false;
+ }
+ }
+ return true;
+ }
+
+ // ---------- 碰撞检测:返回流形 {a,b,nx,ny,points:[{x,y,pen,bias}] },法线 a→b ----------
+ function collideCircleCircle(a, b) {
+ var dx = b.x - a.x, dy = b.y - a.y;
+ var R = a.r + b.r;
+ var d2 = dx * dx + dy * dy;
+ if (d2 >= R * R) return null;
+ var d = Math.sqrt(d2) || 1e-9;
+ var nx = dx / d, ny = dy / d;
+ return {
+ a: a, b: b, nx: nx, ny: ny,
+ points: [{ x: a.x + nx * a.r, y: a.y + ny * a.r, pen: R - d }]
+ };
+ }
+
+ function closestOnPoly(px, py, vs) {
+ var best = null, bestD2 = 1e18;
+ for (var i = 0; i < vs.length; i++) {
+ var a = vs[i], b = vs[(i + 1) % vs.length];
+ var dx = b.x - a.x, dy = b.y - a.y;
+ var L2 = dx * dx + dy * dy || 1e-9;
+ var t = ((px - a.x) * dx + (py - a.y) * dy) / L2;
+ t = t < 0 ? 0 : (t > 1 ? 1 : t);
+ var cx = a.x + dx * t, cy = a.y + dy * t;
+ var d2 = (px - cx) * (px - cx) + (py - cy) * (py - cy);
+ if (d2 < bestD2) { bestD2 = d2; best = { x: cx, y: cy }; }
+ }
+ return best;
+ }
+
+ function collideCirclePoly(circle, poly, swap) {
+ var vs = worldVerts(poly);
+ var inside = pointInPoly(circle.x, circle.y, vs);
+ var c = closestOnPoly(circle.x, circle.y, vs);
+ var dx = circle.x - c.x, dy = circle.y - c.y;
+ var d2 = dx * dx + dy * dy;
+ var d = Math.sqrt(d2) || 1e-9;
+ var pnx, pny, pen;
+ if (inside) {
+ // 圆心在多边形内:推出方向 = 圆心指向最近边界点
+ pnx = -dx / d; pny = -dy / d; pen = circle.r + d;
+ } else {
+ if (d >= circle.r) return null;
+ // 多边形→圆 = 最近点指向圆心
+ pnx = dx / d; pny = dy / d; pen = circle.r - d;
+ }
+ // 法线约定 a→b;swap=true 表示 a=circle,b=poly
+ var m = { points: [{ x: c.x, y: c.y, pen: pen }] };
+ if (!swap) { m.a = poly; m.b = circle; m.nx = pnx; m.ny = pny; }
+ else { m.a = circle; m.b = poly; m.nx = -pnx; m.ny = -pny; }
+ return m;
+ }
+
+ function centroid(vs) {
+ var cx = 0, cy = 0;
+ for (var i = 0; i < vs.length; i++) { cx += vs[i].x; cy += vs[i].y; }
+ return { x: cx / vs.length, y: cy / vs.length };
+ }
+
+ // 第 i 条边的单位外法线(与顶点绕序无关)
+ function edgeOutNormal(vs, i) {
+ var a1 = vs[i], a2 = vs[(i + 1) % vs.length];
+ var nx = -(a2.y - a1.y), ny = a2.x - a1.x;
+ var L = Math.sqrt(nx * nx + ny * ny) || 1e-9;
+ nx /= L; ny /= L;
+ var c = centroid(vs);
+ var mx = (a1.x + a2.x) / 2, my = (a1.y + a2.y) / 2;
+ if ((mx - c.x) * nx + (my - c.y) * ny < 0) { nx = -nx; ny = -ny; }
+ return { x: nx, y: ny };
+ }
+
+ // SAT 最大分离轴:返回 {sep, i}(A 的第 i 条边外法线方向上 B 相对 A 的分离量;sep>0 已分离)
+ function maxSeparation(va, vb) {
+ var best = -1e18, bi = 0;
+ for (var i = 0; i < va.length; i++) {
+ var n = edgeOutNormal(va, i);
+ var a1 = va[i];
+ var minProj = 1e18;
+ for (var j = 0; j < vb.length; j++) {
+ var pr = (vb[j].x - a1.x) * n.x + (vb[j].y - a1.y) * n.y;
+ if (pr < minProj) minProj = pr;
+ }
+ if (minProj > best) { best = minProj; bi = i; }
+ }
+ return { sep: best, i: bi };
+ }
+
+ function collidePolyPoly(a, b) {
+ var va = worldVerts(a), vb = worldVerts(b);
+ var sa = maxSeparation(va, vb);
+ if (sa.sep > 0) return null;
+ var sb = maxSeparation(vb, va);
+ if (sb.sep > 0) return null;
+
+ // 流形法线约定 a→b(A→B)
+ var nx, ny, refPt, refA, refB;
+ if (sb.sep > sa.sep + 0.01) {
+ var nB = edgeOutNormal(vb, sb.i); // B 外法线 = B→A
+ nx = -nB.x; ny = -nB.y; // 取反 = A→B
+ refPt = vb[sb.i];
+ refA = vb[sb.i]; refB = vb[(sb.i + 1) % vb.length];
+ } else {
+ var nA = edgeOutNormal(va, sa.i); // A 外法线 = A→B
+ nx = nA.x; ny = nA.y;
+ refPt = va[sa.i];
+ refA = va[sa.i]; refB = va[(sa.i + 1) % va.length];
+ }
+
+ // 顶点穿透流形:收集互相穿透的顶点
+ var pts = [];
+ var i;
+ for (i = 0; i < vb.length; i++) {
+ if (pointInPoly(vb[i].x, vb[i].y, va)) {
+ // B 顶点穿入 A:在 A→B 法线上投影为负
+ pts.push({ x: vb[i].x, y: vb[i].y, pen: Math.max(0.1, -((vb[i].x - refPt.x) * nx + (vb[i].y - refPt.y) * ny)) });
+ }
+ }
+ for (i = 0; i < va.length; i++) {
+ if (pointInPoly(va[i].x, va[i].y, vb)) {
+ // A 顶点穿入 B:在 A→B 法线上投影为正
+ pts.push({ x: va[i].x, y: va[i].y, pen: Math.max(0.1, (va[i].x - refPt.x) * nx + (va[i].y - refPt.y) * ny) });
+ }
+ }
+ if (!pts.length) {
+ // 边-边接触:沿参考边生成 2 个接触点(面接触近似,提供抗旋转扭矩,防堆叠倾覆)
+ var ex = refB.x - refA.x, ey = refB.y - refA.y;
+ var el = Math.sqrt(ex * ex + ey * ey) || 1e-9;
+ ex /= el; ey /= el;
+ var oc = centroid(sb.sep > sa.sep + 0.01 ? va : vb);
+ var tMid = (oc.x - refA.x) * ex + (oc.y - refA.y) * ey;
+ var half = Math.min(el * 0.4, 14);
+ var pen0 = Math.max(0.1, -Math.max(sa.sep, sb.sep));
+ pts.push({ x: refA.x + ex * (tMid - half), y: refA.y + ey * (tMid - half), pen: pen0 });
+ pts.push({ x: refA.x + ex * (tMid + half), y: refA.y + ey * (tMid + half), pen: pen0 });
+ }
+ if (pts.length > 2) pts.length = 2;
+ return { a: a, b: b, nx: nx, ny: ny, points: pts };
+ }
+
+ function collide(a, b) {
+ if (a.shape === 'circle' && b.shape === 'circle') return collideCircleCircle(a, b);
+ if (a.shape === 'circle' && b.shape === 'poly') return collideCirclePoly(a, b, true);
+ if (a.shape === 'poly' && b.shape === 'circle') return collideCirclePoly(b, a, false);
+ return collidePolyPoly(a, b);
+ }
+
+ // ---------- 求解 ----------
+ function prepareManifold(m, p) {
+ // 记录每点初始接近速度,用于恢复系数 bias
+ for (var i = 0; i < m.points.length; i++) {
+ var cp = m.points[i];
+ cp.rax = cp.x - m.a.x; cp.ray = cp.y - m.a.y;
+ cp.rbx = cp.x - m.b.x; cp.rby = cp.y - m.b.y;
+ var rvx = (m.b.vx - m.b.omega * cp.rby) - (m.a.vx - m.a.omega * cp.ray);
+ var rvy = (m.b.vy + m.b.omega * cp.rbx) - (m.a.vy + m.a.omega * cp.rax);
+ var vn = rvx * m.nx + rvy * m.ny;
+ cp.bias = (-vn > p.restV) ? -p.e * vn : 0;
+ cp.accN = 0; cp.accT = 0;
+ }
+ m.friction = Math.min(m.a.friction, m.b.friction);
+ m.rest = p.e;
+ }
+
+ function solveManifold(m, p) {
+ var a = m.a, b = m.b;
+ var nx = m.nx, ny = m.ny;
+ var tx = -ny, ty = nx;
+ for (var i = 0; i < m.points.length; i++) {
+ var cp = m.points[i];
+ // ---- 法向 ----
+ var rvx = (b.vx - b.omega * cp.rby) - (a.vx - a.omega * cp.ray);
+ var rvy = (b.vy + b.omega * cp.rbx) - (a.vy + a.omega * cp.rax);
+ var vn = rvx * nx + rvy * ny;
+ var raxn = cp.rax * ny - cp.ray * nx;
+ var rbxn = cp.rbx * ny - cp.rby * nx;
+ var kn = a.invM + b.invM + raxn * raxn * a.invI + rbxn * rbxn * b.invI;
+ if (kn > 1e-9) {
+ var lambda = (cp.bias - vn) / kn;
+ var newAcc = Math.max(cp.accN + lambda, 0);
+ lambda = newAcc - cp.accN;
+ cp.accN = newAcc;
+ var px_ = nx * lambda, py_ = ny * lambda;
+ a.vx -= px_ * a.invM; a.vy -= py_ * a.invM;
+ a.omega -= a.invI * (cp.rax * py_ - cp.ray * px_);
+ b.vx += px_ * b.invM; b.vy += py_ * b.invM;
+ b.omega += b.invI * (cp.rbx * py_ - cp.rby * px_);
+ }
+ // ---- 切向(库仑摩擦,累积钳制) ----
+ rvx = (b.vx - b.omega * cp.rby) - (a.vx - a.omega * cp.ray);
+ rvy = (b.vy + b.omega * cp.rbx) - (a.vy + a.omega * cp.rax);
+ var vt = rvx * tx + rvy * ty;
+ var raxt = cp.rax * ty - cp.ray * tx;
+ var rbxt = cp.rbx * ty - cp.rby * tx;
+ var kt = a.invM + b.invM + raxt * raxt * a.invI + rbxt * rbxt * b.invI;
+ if (kt > 1e-9) {
+ var jt = -vt / kt;
+ var maxF = m.friction * cp.accN;
+ var newT = cp.accT + jt;
+ if (newT > maxF) newT = maxF; else if (newT < -maxF) newT = -maxF;
+ jt = newT - cp.accT;
+ cp.accT = newT;
+ var fx_ = tx * jt, fy_ = ty * jt;
+ a.vx -= fx_ * a.invM; a.vy -= fy_ * a.invM;
+ a.omega -= a.invI * (cp.rax * fy_ - cp.ray * fx_);
+ b.vx += fx_ * b.invM; b.vy += fy_ * b.invM;
+ b.omega += b.invI * (cp.rbx * fy_ - cp.rby * fx_);
+ }
+ }
+ }
+
+ function positionCorrection(m, p) {
+ var maxPen = 0;
+ for (var i = 0; i < m.points.length; i++) if (m.points[i].pen > maxPen) maxPen = m.points[i].pen;
+ var corr = Math.max(maxPen - p.slop, 0) * p.correction;
+ if (corr <= 0) return;
+ var tm = m.a.invM + m.b.invM;
+ if (tm <= 1e-9) return;
+ m.a.x -= m.nx * corr * (m.a.invM / tm);
+ m.a.y -= m.ny * corr * (m.a.invM / tm);
+ m.b.x += m.nx * corr * (m.b.invM / tm);
+ m.b.y += m.ny * corr * (m.b.invM / tm);
+ }
+
+ // ---------- 休眠 ----------
+ function updateSleep(b, dt, p) {
+ if (b.isStatic) return;
+ // 弹球永不休眠:必须保持可滚动/可被滞回收检测,防回合软锁
+ if (b.tag === 'ball') return;
+ var sp2 = b.vx * b.vx + b.vy * b.vy;
+ if (sp2 < p.sleepV * p.sleepV && Math.abs(b.omega) < p.sleepW) b.sleepTimer += dt;
+ else b.sleepTimer = 0;
+ if (!b.sleeping && b.sleepTimer > 0.6) {
+ b.sleeping = true;
+ b.vx = 0; b.vy = 0; b.omega = 0;
+ }
+ }
+
+ function wake(b) {
+ if (b && b.sleeping) { b.sleeping = false; b.sleepTimer = 0; }
+ }
+
+ // ---------- 世界推进 ----------
+ // 返回 { manifolds, events };events = 新接触对强碰撞 [{a,b,speed}]
+ function stepWorld(bodies, p, dt, prevPairs) {
+ var i, j, b;
+ var events = [];
+ var newPairs = {};
+
+ for (i = 0; i < bodies.length; i++) {
+ b = bodies[i];
+ if (b.isStatic || b.sleeping) continue;
+ b.vy += p.g * dt;
+ if (p.airK > 0) { var f = Math.exp(-p.airK * dt); b.vx *= f; b.vy *= f; }
+ if (p.omegaK > 0) b.omega *= Math.exp(-p.omegaK * dt); // 角速度阻尼,防砖块被击飞后永转
+ var sp2 = b.vx * b.vx + b.vy * b.vy;
+ if (sp2 > p.maxV * p.maxV) { var s = p.maxV / Math.sqrt(sp2); b.vx *= s; b.vy *= s; }
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ b.angle += b.omega * dt;
+ }
+ for (i = 0; i < bodies.length; i++) updateAabb(bodies[i]);
+
+ var manifolds = [];
+ for (i = 0; i < bodies.length; i++) {
+ var a = bodies[i];
+ for (j = i + 1; j < bodies.length; j++) {
+ var c = bodies[j];
+ if (a.isStatic && c.isStatic) continue;
+ if (a.sleeping && c.sleeping) continue;
+ if (!aabbOverlap(a, c)) continue;
+ var m = collide(a, c);
+ if (!m) continue;
+ prepareManifold(m, p);
+
+ var key = a.id + '_' + c.id;
+ newPairs[key] = true;
+ // 接近速度(质心)
+ var rvx = c.vx - a.vx, rvy = c.vy - a.vy;
+ var speed = Math.sqrt(rvx * rvx + rvy * rvy);
+ // 唤醒:休眠体被高速接触
+ if (a.sleeping && !c.sleeping && speed > p.wakeV) wake(a);
+ if (c.sleeping && !a.sleeping && speed > p.wakeV) wake(c);
+ // 事件:新接触对 + 足够强的接近速度
+ if (!prevPairs[key] && speed > p.hitV) events.push({ a: a, b: c, speed: speed });
+ manifolds.push(m);
+ }
+ }
+
+ for (var it = 0; it < p.iterations; it++) {
+ for (j = 0; j < manifolds.length; j++) solveManifold(manifolds[j], p);
+ }
+ for (j = 0; j < manifolds.length; j++) positionCorrection(manifolds[j], p);
+
+ for (i = 0; i < bodies.length; i++) {
+ b = bodies[i];
+ if (!b.isStatic && !b.sleeping) updateSleep(b, dt, p);
+ }
+ return { manifolds: manifolds, events: events, pairs: newPairs };
+ }
+
+ // 凸正多边形局部顶点(逆时针)
+ function regularVerts(n, radius, rot0) {
+ var vs = [];
+ for (var i = 0; i < n; i++) {
+ var a = (rot0 || -Math.PI / 2) + i * 2 * Math.PI / n;
+ vs.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
+ }
+ return vs;
+ }
+
+ // 矩形局部顶点
+ function boxVerts(w, h) {
+ return [
+ { x: -w / 2, y: -h / 2 },
+ { x: w / 2, y: -h / 2 },
+ { x: w / 2, y: h / 2 },
+ { x: -w / 2, y: h / 2 }
+ ];
+ }
+
+ return {
+ makeCircle: makeCircle,
+ makePoly: makePoly,
+ worldVerts: worldVerts,
+ collide: collide,
+ stepWorld: stepWorld,
+ wake: wake,
+ regularVerts: regularVerts,
+ boxVerts: boxVerts,
+ pointInPoly: pointInPoly
+ };
+})();
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_render.js b/public/tools/thought_lab/labs/physics_balls/pb_render.js
new file mode 100644
index 0000000..24955b7
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_render.js
@@ -0,0 +1,260 @@
+/* ============================================================
+ * pb_render.js · 物理弹球 Web 复刻版 · Canvas 渲染层
+ * 纯绘制:场地 / 漏斗 / 输送带箭头 / 砖块(形状+数字+配色) /
+ * 道具 / 待发队列 / 球(棒球质感) / 粒子 / 轨迹预览(墙+砖块反射)
+ * ============================================================ */
+var PBRender = (function () {
+ 'use strict';
+ var R = PBRules, F = R.FIELD;
+
+ // ---------- 轨迹预览:无头物理仿真 ----------
+ // 用同一引擎/同初速/同参数克隆一颗球实跑 1.8s,预览线 = 真实轨迹。
+ // 墙/砖块本就静态;道具克隆为静态参与碰撞(不改动真实道具)。
+ var pvCache = { key: '', pts: null };
+ function simulatePath(g) {
+ var key = g.aimAngle.toFixed(4) + '|' + g.turn + '|' + g.blocks.length + '|' + g.items.length + '|' + ((g.queue[0] || g.bigTurn) ? 1 : 0);
+ if (pvCache.key === key) return pvCache.pts;
+
+ var P = PBPhysics;
+ var bodies = g.walls.concat(g.blocks);
+ var i;
+ for (i = 0; i < g.items.length; i++) {
+ bodies.push(P.makeCircle({ shape: 'circle', x: g.items[i].x, y: g.items[i].y, r: g.items[i].r, isStatic: true, friction: 0.3, restitution: 0.2, tag: 'item' }));
+ }
+ var r = (g.queue[0] || g.bigTurn) ? R.BIG_R : R.BALL_R; // 与 launchOne 口径一致:队首标记或大球回合
+ var ball = P.makeCircle({
+ shape: 'circle', x: F.arcCx, y: F.checkY - 6,
+ r: r, friction: 0.08, restitution: 0.95, tag: 'ball'
+ });
+ ball.vx = Math.sin(g.aimAngle) * R.FIRE_SPEED;
+ ball.vy = Math.cos(g.aimAngle) * R.FIRE_SPEED;
+ bodies.push(ball);
+
+ var pts = [{ x: ball.x, y: ball.y }];
+ var pairs = {};
+ var n = Math.floor(1.8 / R.PHYS.subDt);
+ for (i = 0; i < n; i++) {
+ var res = P.stepWorld(bodies, R.PHYS, R.PHYS.subDt, pairs);
+ pairs = res.pairs;
+ if (i % 4 === 3) pts.push({ x: ball.x, y: ball.y });
+ if (ball.sleeping) break;
+ }
+ pvCache.key = key;
+ pvCache.pts = pts;
+ return pts;
+ }
+
+ // ---------- 绘制部件 ----------
+ function drawBall(ctx, x, y, r, big) {
+ if (big) {
+ // 大球 = ⚽️ 图标,直径 2 倍于小球,一眼可辨
+ ctx.font = Math.round(r * 2) + 'px system-ui, "Segoe UI Emoji", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('⚽️', x, y + r * 0.08);
+ return;
+ }
+ var grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.2, x, y, r);
+ grad.addColorStop(0, big ? '#d8a468' : '#c08a52');
+ grad.addColorStop(1, big ? '#8a5a2c' : '#7a4c24');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(x, y, r, 0, Math.PI * 2);
+ ctx.fill();
+ // 棒球缝线
+ ctx.strokeStyle = 'rgba(255,244,220,0.85)';
+ ctx.lineWidth = Math.max(1, r * 0.16);
+ ctx.beginPath();
+ ctx.arc(x - r * 0.5, y, r * 0.9, -0.6, 0.6);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(x + r * 0.5, y, r * 0.9, Math.PI - 0.6, Math.PI + 0.6);
+ ctx.stroke();
+ }
+
+ function drawBlock(ctx, b) {
+ var d = b.data;
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.rotate(b.angle);
+ ctx.fillStyle = R.hpColor(d.hp);
+ ctx.beginPath();
+ if (d.shape === 'circle') {
+ ctx.arc(0, 0, d.r, 0, Math.PI * 2);
+ } else {
+ var vs = d.shape === 'box' ? PBPhysics.boxVerts(d.r * 1.7, d.r * 1.7) : PBPhysics.regularVerts(d.shape === 'tri' ? 3 : 5, d.r, 0);
+ ctx.moveTo(vs[0].x, vs[0].y);
+ for (var i = 1; i < vs.length; i++) ctx.lineTo(vs[i].x, vs[i].y);
+ ctx.closePath();
+ }
+ ctx.fill();
+ ctx.fillStyle = d.hp <= 29 ? '#20180a' : '#ffffff';
+ ctx.font = 'bold ' + Math.round(d.r * 0.95) + 'px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(String(d.hp), 0, 1);
+ ctx.restore();
+ }
+
+ function drawItem(ctx, it, t) {
+ ctx.save();
+ ctx.translate(it.x, it.y);
+ ctx.strokeStyle = '#e8e8e8';
+ ctx.lineWidth = 1.6;
+ ctx.setLineDash([4, 4]);
+ ctx.lineDashOffset = -t * 20;
+ ctx.beginPath();
+ ctx.arc(0, 0, it.r, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ if (it.data.type === 'ball') {
+ // +1 球道具:迷你棒球 + 加号
+ drawBall(ctx, -it.r * 0.12, it.r * 0.12, it.r * 0.36, false);
+ ctx.strokeStyle = '#e8e8e8';
+ ctx.lineWidth = 1.8;
+ ctx.beginPath();
+ ctx.moveTo(it.r * 0.35, -it.r * 0.55); ctx.lineTo(it.r * 0.8, -it.r * 0.55);
+ ctx.moveTo(it.r * 0.575, -it.r * 0.775); ctx.lineTo(it.r * 0.575, -it.r * 0.325);
+ ctx.stroke();
+ } else {
+ // 大球道具:⚽ 图标(与实际大球外观一致)
+ ctx.font = Math.round(it.r * 1.3) + 'px system-ui, "Segoe UI Emoji", sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText('⚽️', 0, 1);
+ }
+ ctx.restore();
+ }
+
+ function drawField(ctx, t) {
+ ctx.strokeStyle = '#8a8a8a';
+ ctx.lineWidth = 3;
+ ctx.lineCap = 'round';
+ // 左右壁
+ ctx.beginPath();
+ ctx.moveTo(F.wallL, F.topY); ctx.lineTo(F.wallL, F.H - 60);
+ ctx.moveTo(F.wallR, F.topY); ctx.lineTo(F.wallR, F.H - 60);
+ ctx.stroke();
+ // 顶 + 漏斗
+ var fn = R.funnelSegs();
+ ctx.beginPath();
+ ctx.moveTo(F.wallL, F.topY); ctx.lineTo(F.wallR, F.topY);
+ ctx.moveTo(fn[0].x1, fn[0].y1); ctx.lineTo(fn[0].x2, fn[0].y2);
+ ctx.moveTo(fn[1].x1, fn[1].y1); ctx.lineTo(fn[1].x2, fn[1].y2);
+ ctx.stroke();
+ // 底部弧
+ ctx.beginPath();
+ ctx.arc(F.arcCx, F.arcCy, F.arcR, Math.PI * 1.24, Math.PI * 1.76);
+ ctx.stroke();
+ // checkline 虚线
+ ctx.strokeStyle = '#5a5a5a';
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([6, 6]);
+ ctx.beginPath();
+ ctx.moveTo(F.wallL, F.checkY); ctx.lineTo(F.wallR, F.checkY);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ // 输送带箭头(两角,向上闪动)
+ for (var side = 0; side < 2; side++) {
+ var ax = side === 0 ? F.wallL + 13 : F.wallR - 13;
+ for (var k = 0; k < 3; k++) {
+ var ay = F.H - 46 - k * 16 - ((t * 40) % 16);
+ var alpha = 0.25 + 0.55 * ((k + Math.floor(t * 3)) % 3) / 2;
+ ctx.strokeStyle = 'rgba(200,200,200,' + alpha.toFixed(2) + ')';
+ ctx.lineWidth = 2.5;
+ ctx.beginPath();
+ ctx.moveTo(ax - 7, ay + 6); ctx.lineTo(ax, ay); ctx.lineTo(ax + 7, ay + 6);
+ ctx.stroke();
+ }
+ }
+ }
+
+ function drawQueue(ctx, g) {
+ for (var i = 0; i < Math.min(g.queue.length, 10); i++) {
+ var p = R.queuePos(i);
+ // 大球回合徽章生效中:待发全部按 ⚽️ 显示;否则按逐球标记(回收的大球仍为 ⚽️)
+ var isBig = !!g.queue[i] || !!g.bigTurn;
+ drawBall(ctx, p.x, p.y, isBig ? 11 : R.BALL_R, isBig);
+ }
+ if (g.queue.length > 10) {
+ ctx.fillStyle = '#bbb';
+ ctx.font = '10px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('+' + (g.queue.length - 10), F.arcCx, F.checkY - 22 - 4 * 15 - 10 * 14);
+ }
+ }
+
+ function drawPreview(ctx, g) {
+ if (g.state !== 'aim' || !g.queue.length) return;
+ var dx = Math.sin(g.aimAngle), dy = Math.cos(g.aimAngle);
+ var pts = simulatePath(g);
+ ctx.fillStyle = 'rgba(210,160,90,0.9)';
+ for (var i = 0; i < pts.length; i++) {
+ var fade = 1 - i / pts.length; // 越远越淡
+ ctx.globalAlpha = 0.35 + 0.6 * fade;
+ ctx.beginPath();
+ ctx.arc(pts[i].x, pts[i].y, 1.6 + 1.8 * fade, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ // 发射方向小箭头
+ ctx.fillStyle = '#ffffff';
+ ctx.save();
+ ctx.translate(F.arcCx + dx * 26, F.checkY - 6 + dy * 26);
+ ctx.rotate(Math.atan2(dy, dx) - Math.PI / 2);
+ ctx.beginPath();
+ ctx.moveTo(0, 8); ctx.lineTo(-5, -4); ctx.lineTo(5, -4);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+
+ function drawParticles(ctx, g) {
+ for (var i = 0; i < g.particles.length; i++) {
+ var p = g.particles[i];
+ var a = 1 - p.t / p.life;
+ if (p.kind === 'text') {
+ ctx.globalAlpha = a;
+ ctx.fillStyle = p.color;
+ ctx.font = 'bold ' + p.size + 'px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(p.text, p.x, p.y);
+ ctx.globalAlpha = 1;
+ } else {
+ ctx.save();
+ ctx.globalAlpha = a;
+ ctx.translate(p.x, p.y);
+ ctx.rotate(p.rot);
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.moveTo(0, -p.size);
+ ctx.lineTo(p.size * 0.9, p.size * 0.7);
+ ctx.lineTo(-p.size * 0.9, p.size * 0.7);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+ }
+
+ // ---------- 主绘制 ----------
+ function draw(ctx, g, t) {
+ ctx.clearRect(0, 0, F.W, F.H);
+ ctx.fillStyle = '#000000';
+ ctx.fillRect(0, 0, F.W, F.H);
+
+ drawField(ctx, t);
+ drawPreview(ctx, g);
+
+ var i;
+ for (i = 0; i < g.items.length; i++) drawItem(ctx, g.items[i], t);
+ for (i = 0; i < g.blocks.length; i++) drawBlock(ctx, g.blocks[i]);
+ drawQueue(ctx, g);
+ for (i = 0; i < g.balls.length; i++) drawBall(ctx, g.balls[i].x, g.balls[i].y, g.balls[i].r, g.balls[i].data.big);
+ ctx.globalAlpha = 0.75; // 输送带上的球半透明,与场内球区分
+ for (i = 0; i < g.recycle.length; i++) drawBall(ctx, g.recycle[i].x, g.recycle[i].y, g.recycle[i].big ? R.BIG_R : R.BALL_R, g.recycle[i].big);
+ ctx.globalAlpha = 1;
+ drawParticles(ctx, g);
+ }
+
+ return { draw: draw, simulatePath: simulatePath, drawBall: drawBall };
+})();
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_rules.js b/public/tools/thought_lab/labs/physics_balls/pb_rules.js
new file mode 100644
index 0000000..e0f1570
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_rules.js
@@ -0,0 +1,204 @@
+/* ============================================================
+ * pb_rules.js · 物理弹球 Web 复刻版 · 规则层(纯函数,无 DOM)
+ * 场地几何 / 砖块形状与配色 / 行生成 / 道具 / 待发队列位置 / 物理参数
+ * ============================================================ */
+var PBRules = (function () {
+ 'use strict';
+
+ // ---- 场地逻辑坐标(竖向手机比例) ----
+ var FIELD = {
+ W: 420, H: 880,
+ wallL: 16, wallR: 404, // 左右侧壁 x
+ topY: 36, // 顶壁
+ checkY: 128, // 顶部虚线:砖块越线 = 游戏结束
+ gapHalf: 17, // 漏斗中央出口半宽(球从此落下)
+ arcCx: 210, arcCy: 1560, arcR: 706, // 底部大圆弧(弧顶 ≈ 854)
+ laneW: 26 // 侧边回收通道宽(输送带)
+ };
+
+ // 漏斗两斜壁:从顶角收拢到中央出口
+ function funnelSegs() {
+ return [
+ { x1: FIELD.wallL, y1: FIELD.topY + 4, x2: FIELD.arcCx - FIELD.gapHalf, y2: FIELD.checkY - 10 },
+ { x1: FIELD.wallR, y1: FIELD.topY + 4, x2: FIELD.arcCx + FIELD.gapHalf, y2: FIELD.checkY - 10 }
+ ];
+ }
+
+ // ---- 物理参数 ----
+ var PHYS = {
+ g: 1500, e: 0.42, iterations: 16, slop: 0.4, correction: 0.55,
+ restV: 10, airK: 0.008, omegaK: 0.12, maxV: 3600,
+ sleepV: 14, sleepW: 0.5, wakeV: 90, hitV: 110,
+ subDt: 1 / 240
+ };
+
+ // ---- 球 ----
+ var BALL_R = 7; // 小球半径
+ var BIG_R = 14; // 大球半径 = 小球 2 倍(伤害也 2 倍)
+ var FIRE_SPEED = 980; // 发射初速
+ var FIRE_GAP_MS = 130; // 连发间隔
+
+ // ---- 砖块形状 ----
+ var SHAPES = ['circle', 'box', 'tri', 'penta'];
+ function shapeRadius(shape) {
+ if (shape === 'circle') return 17;
+ if (shape === 'box') return 19;
+ if (shape === 'tri') return 20;
+ return 19; // penta
+ }
+
+ // ---- 配色:HP 分档(复刻截图观感:黄→橙→绿→蓝→紫) ----
+ function hpColor(hp) {
+ if (hp <= 4) return hp % 2 ? '#f7c81a' : '#f7a01a';
+ if (hp <= 9) return '#f7a01a';
+ if (hp <= 29) return hp % 2 ? '#37c837' : '#8bd420';
+ if (hp <= 79) return '#2f7bff';
+ return '#a02fe0';
+ }
+
+ // ---- 回合 n 的新砖块 HP 区间(前期个位数,后期上百) ----
+ function hpRangeForTurn(n) {
+ var lo = Math.max(1, Math.floor(n * 0.8) - 1);
+ var hi = Math.max(lo + 1, n + 4);
+ return [lo, hi];
+ }
+
+ function randInt(lo, hi) { return lo + Math.floor(Math.random() * (hi - lo + 1)); }
+
+ // ---- 各形状包围圆半径(包围圆 ⊇ 实际形状,用于无重叠放置) ----
+ function boundRadius(shape) {
+ if (shape === 'circle') return 17;
+ if (shape === 'box') return (19 * 1.7 / 2) * Math.SQRT2; // 正方形半对角线 ≈22.8
+ if (shape === 'tri') return 20;
+ return 19; // penta 外接圆
+ }
+
+ // ---- 生成一行砖块(返回描述数组,game 层转刚体) ----
+ // 槽位随机簇团布局 + 严格无重叠放置:中心距 >= 包围圆半径和(允许相邻/点线紧贴,禁止相交)
+ // 放置冲突时随机偏移重试,仍冲突则放弃该块——保持随机、不整齐
+ // 两侧随机贴边:每边 65% 概率"封闭"(砖块贴墙,不留沿墙逃生通道),35% 留口
+ // avoid: 已有块 [{x,y,cr}](上一行等),新块与其也须无重叠
+ function makeRow(n, y, _count, avoid) {
+ var out = [];
+ var placed = [];
+ avoid = avoid || [];
+ var [lo, hi] = hpRangeForTurn(n);
+ var leftOpen = Math.random() < 0.35;
+ var rightOpen = Math.random() < 0.35;
+ // 封闭边:块中心距墙 21(贴边形状半径 ≤20,贴墙无缝);开放边留 34 供球通过
+ var xMin = FIELD.wallL + (leftOpen ? 34 : 21);
+ var xMax = FIELD.wallR - (rightOpen ? 34 : 21);
+ var usable = xMax - xMin;
+ var clampX = function (x) { return Math.max(FIELD.wallL + 23, Math.min(FIELD.wallR - 23, x)); };
+
+ function tryPlace(x, yy, cr, desc) {
+ var i, dx, dy, rr;
+ for (i = 0; i < placed.length; i++) {
+ dx = x - placed[i].x; dy = yy - placed[i].y; rr = cr + placed[i].cr;
+ if (dx * dx + dy * dy < rr * rr) return false; // 相交=拒绝;相等=紧贴=允许
+ }
+ for (i = 0; i < avoid.length; i++) {
+ dx = x - avoid[i].x; dy = yy - avoid[i].y; rr = cr + avoid[i].cr;
+ if (dx * dx + dy * dy < rr * rr) return false;
+ }
+ placed.push({ x: x, y: yy, cr: cr });
+ out.push(desc);
+ return true;
+ }
+ function mkBlock(x, yy, shape) {
+ return { shape: shape, hp: randInt(lo, hi), x: x, y: yy, angle: (Math.random() - 0.5) * 0.8 };
+ }
+ function placeBlock(x, shape, margin) {
+ var m = margin || 23; // 常规块留 23(旋转方块半对角 22.8 不出墙)
+ var cl = function (v) { return Math.max(FIELD.wallL + m, Math.min(FIELD.wallR - m, v)); };
+ var cr = boundRadius(shape);
+ x = cl(x);
+ var yy = y + (Math.random() - 0.5) * 10;
+ if (tryPlace(x, yy, cr, mkBlock(x, yy, shape))) return;
+ for (var k = 0; k < 8; k++) { // 冲突:随机偏移重试
+ var tx = cl(x + (Math.random() - 0.5) * 60);
+ var ty = y + (Math.random() - 0.5) * 10;
+ if (tryPlace(tx, ty, cr, mkBlock(tx, ty, shape))) return;
+ }
+ }
+
+ // 封闭边先放贴墙砖(只选半径 ≤20 的形状,margin 21 贴墙且旋转不出墙)
+ if (!leftOpen) placeBlock(xMin, ['circle', 'tri', 'penta'][randInt(0, 2)], 21);
+ if (!rightOpen) placeBlock(xMax, ['circle', 'tri', 'penta'][randInt(0, 2)], 21);
+
+ var segments = 4 + randInt(0, 2); // 4-6 个槽位
+ var segW = usable / segments;
+ for (var s = 0; s < segments; s++) {
+ var segCenter = xMin + segW * (s + 0.5);
+ var r = Math.random();
+ var nBlocks = r < 0.35 ? 0 : (r < 0.65 ? 1 : (r < 0.85 ? 2 : 3));
+ var blockGap = 38;
+ var totalW = (nBlocks - 1) * blockGap;
+ for (var b = 0; b < nBlocks; b++) {
+ placeBlock(segCenter - totalW / 2 + b * blockGap + (Math.random() - 0.5) * 8,
+ SHAPES[randInt(0, SHAPES.length - 1)]);
+ }
+ }
+ // 奖励球混排在卡片中间:锚定本行随机砖块、紧贴其旁(相邻/点线紧贴允许)
+ if (Math.random() < 0.6) {
+ var itType = Math.random() < 0.6 ? 'ball' : 'big';
+ for (var q = 0; q < 10; q++) {
+ var ix, iy;
+ if (placed.length && q < 8) {
+ var anchor = placed[randInt(0, placed.length - 1)];
+ var aa = Math.random() * Math.PI * 2;
+ var dist = anchor.cr + 12; // 中心距 = 半径和 → 紧贴
+ ix = clampX(anchor.x + Math.cos(aa) * dist);
+ iy = anchor.y + Math.sin(aa) * dist;
+ } else {
+ ix = clampX(xMin + usable * Math.random());
+ iy = y + (Math.random() - 0.5) * 8;
+ }
+ if (tryPlace(ix, iy, 12, { item: itType, x: ix, y: iy, angle: 0 })) break;
+ }
+ }
+ return out;
+ }
+
+ // ---- 待发球堆叠位置(漏斗口三角堆) ----
+ function queuePos(i) {
+ // 0 在出口,往上三角堆叠
+ var rows = [
+ [0],
+ [-9.5, 9.5],
+ [-19, 0, 19],
+ [-28.5, -9.5, 9.5, 28.5]
+ ];
+ var idx = i;
+ for (var r = 0; r < rows.length; r++) {
+ if (idx < rows[r].length) {
+ return { x: FIELD.arcCx + rows[r][idx], y: FIELD.checkY - 22 - r * 15 };
+ }
+ idx -= rows[r].length;
+ }
+ return { x: FIELD.arcCx, y: FIELD.checkY - 22 - 4 * 15 - idx * 14 };
+ }
+
+ // ---- 道具 ----
+ var ITEMS = {
+ ball: { r: 12, label: '+1' }, // 球数 +1(下回合生效)
+ big: { r: 12, label: '大' } // 大球回合:球变大,击中 +2 / 伤害 2
+ };
+
+ // ---- 记录行颜色(复刻记录页交替行彩色数字) ----
+ var RECORD_COLORS = ['#e6c229', '#29abe2', '#b133cc', '#2955e2', '#2980e2', '#29c8e2', '#2955cc', '#33cc55', '#33cc88', '#ccaa29'];
+
+ return {
+ FIELD: FIELD,
+ funnelSegs: funnelSegs,
+ PHYS: PHYS,
+ BALL_R: BALL_R, BIG_R: BIG_R,
+ FIRE_SPEED: FIRE_SPEED, FIRE_GAP_MS: FIRE_GAP_MS,
+ SHAPES: SHAPES, shapeRadius: shapeRadius,
+ hpColor: hpColor, hpRangeForTurn: hpRangeForTurn,
+ boundRadius: boundRadius,
+ makeRow: makeRow, queuePos: queuePos,
+ ITEMS: ITEMS, RECORD_COLORS: RECORD_COLORS,
+ randInt: randInt
+ };
+})();
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_storage.js b/public/tools/thought_lab/labs/physics_balls/pb_storage.js
new file mode 100644
index 0000000..d887c3d
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_storage.js
@@ -0,0 +1,94 @@
+/* ============================================================
+ * pb_storage.js · 物理弹球 Web 复刻版 · 本地存档层
+ * 全部 localStorage,key 前缀 pb_,与其他 lab 隔离。
+ * 内容:最佳分 / 历史记录(按日期倒序,复刻记录页)/ 球数 / 每日+3 / 指南已读 / 音效开关
+ * ============================================================ */
+var PBStorage = (function () {
+ 'use strict';
+ var NS = 'thought_lab.physics_balls.';
+
+ function read(key, def) {
+ try {
+ var raw = localStorage.getItem(NS + key);
+ if (raw === null || raw === undefined) return def;
+ return JSON.parse(raw);
+ } catch (e) { return def; }
+ }
+ function write(key, val) {
+ try { localStorage.setItem(NS + key, JSON.stringify(val)); } catch (e) {}
+ }
+
+ // ---- 最佳分 ----
+ function getBest() { return read('best', 0); }
+ function setBest(v) { write('best', v); }
+
+ // ---- 历史记录:[{ts, date, score, secs}],最多 50 条,日期倒序 ----
+ function getRecords() { return read('records', []); }
+ function addRecord(score, secs) {
+ var list = getRecords();
+ var d = new Date();
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
+ list.unshift({
+ ts: d.getTime(),
+ date: d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()),
+ score: score,
+ secs: secs
+ });
+ if (list.length > 50) list.length = 50;
+ write('records', list);
+ }
+
+ // ---- 球数(跨局保留,原版“球”资产) ----
+ function getBalls() { return read('balls', 1); }
+ function addBalls(n) { write('balls', Math.max(0, getBalls() + n)); }
+ function spendBall() {
+ var n = getBalls();
+ if (n <= 0) return false;
+ write('balls', n - 1);
+ return true;
+ }
+
+ // ---- 每日 +3:每天首次进入主菜单可领 ----
+ function dailyKey() {
+ var d = new Date();
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
+ }
+ function canClaimDaily() { return read('daily', '') !== dailyKey(); }
+ function claimDaily() {
+ if (!canClaimDaily()) return false;
+ write('daily', dailyKey());
+ addBalls(3);
+ return true;
+ }
+
+ // ---- 指南已读 ----
+ function guideSeen() { return read('guide_seen', false); }
+ function markGuideSeen() { write('guide_seen', true); }
+
+ // ---- 音效开关 ----
+ function soundOn() { return read('sound', true); }
+ function setSound(on) { write('sound', !!on); }
+
+ // ---- 逃逸强度 0-100,默认 50;越高卡球挣脱冲量越大,越简单 ----
+ function getEscape() {
+ var v = Number(read('escape', 50));
+ if (!isFinite(v)) v = 50;
+ return Math.max(0, Math.min(100, Math.round(v)));
+ }
+ function setEscape(v) {
+ var n = Number(v);
+ if (!isFinite(n)) n = 50;
+ write('escape', Math.max(0, Math.min(100, Math.round(n))));
+ }
+
+ return {
+ getBest: getBest, setBest: setBest,
+ getRecords: getRecords, addRecord: addRecord,
+ getBalls: getBalls, addBalls: addBalls, spendBall: spendBall,
+ canClaimDaily: canClaimDaily, claimDaily: claimDaily,
+ guideSeen: guideSeen, markGuideSeen: markGuideSeen,
+ soundOn: soundOn, setSound: setSound,
+ getEscape: getEscape, setEscape: setEscape
+ };
+})();
diff --git a/public/tools/thought_lab/labs/physics_balls/pb_ui.js b/public/tools/thought_lab/labs/physics_balls/pb_ui.js
new file mode 100644
index 0000000..abaa879
--- /dev/null
+++ b/public/tools/thought_lab/labs/physics_balls/pb_ui.js
@@ -0,0 +1,317 @@
+/* ============================================================
+ * pb_ui.js · 物理弹球 Web 复刻版 · 界面 / 输入 / 主循环
+ * 屏幕:主菜单(直接拖拽开局) / 指南 / 暂停 / 结束 / 记录 / 设置
+ * 存档走 PBStorage,音效走 PBAudio。
+ * ============================================================ */
+var PBUi = (function () {
+ 'use strict';
+ var R = PBRules, F = R.FIELD;
+ var g = null;
+ var canvas, ctx;
+ var lastTs = 0, globalT = 0;
+
+ function $(id) { return document.getElementById(id); }
+ function show(id) { $(id).classList.remove('hidden'); }
+ function hide(id) { $(id).classList.add('hidden'); }
+
+ function fmtSecs(s) {
+ var m = Math.floor(s / 60), ss = Math.floor(s % 60);
+ return (m < 10 ? '0' + m : m) + ':' + (ss < 10 ? '0' + ss : ss);
+ }
+
+ // ---- 主菜单演示背景 ----
+ function toIdleDemo() {
+ PBGame.startGame(g);
+ g.state = 'idle';
+ $('pb-best').textContent = '最佳: ' + PBStorage.getBest().toLocaleString();
+ $('pb-ball-count').textContent = PBStorage.getBalls();
+ if (PBStorage.canClaimDaily()) show('pb-daily'); else hide('pb-daily');
+ show('pb-menu'); hide('pb-hud'); hide('pb-over');
+ }
+
+ // ---- 开局(消耗 1 球;不足时救济 +1,保证可玩) ----
+ function tryStart() {
+ if (!PBStorage.spendBall()) {
+ PBStorage.addBalls(1);
+ PBStorage.spendBall();
+ }
+ PBGame.startGame(g);
+ lastScore = 0;
+ hide('pb-menu'); show('pb-hud');
+ }
+
+ function toLogical(e) {
+ var rect = canvas.getBoundingClientRect();
+ return {
+ x: (e.clientX - rect.left) / rect.width * F.W,
+ y: (e.clientY - rect.top) / rect.height * F.H
+ };
+ }
+
+ function setAim(p) {
+ var fx = F.arcCx, fy = F.checkY - 6;
+ var dx = p.x - fx, dy = p.y - fy;
+ if (dy < 40) dy = 40;
+ var ang = Math.atan2(dx, dy);
+ g.aimAngle = Math.max(-1.25, Math.min(1.25, ang));
+ }
+
+ var downPos = null, pendingStart = false;
+ function dist(a, b) { var dx = a.x - b.x, dy = a.y - b.y; return Math.sqrt(dx * dx + dy * dy); }
+
+ function onDown(e) {
+ PBAudio.unlock();
+ if (g.paused || g.state === 'over') return;
+ var p = toLogical(e);
+ if (g.state === 'idle') { pendingStart = true; downPos = p; return; } // 主菜单:拖拽超阈值才开局,防误触
+ if (g.state === 'aim' && g.queue.length) {
+ g.aiming = true;
+ downPos = p;
+ setAim(p);
+ }
+ }
+ function onMove(e) {
+ var p = toLogical(e);
+ if (pendingStart && downPos) {
+ if (dist(p, downPos) > 14) { // 确认是拖拽才开局,并直接进入瞄准
+ pendingStart = false;
+ tryStart();
+ g.aiming = true;
+ setAim(p);
+ }
+ return;
+ }
+ if (g.aiming) setAim(p);
+ }
+ function onUp(e) {
+ if (pendingStart) { pendingStart = false; downPos = null; return; } // 主菜单轻点 = 无操作
+ if (g.aiming) {
+ g.aiming = false;
+ var p = e && e.clientX !== undefined ? toLogical(e) : null;
+ if (p && downPos && dist(p, downPos) < 10) { downPos = null; return; } // 轻点 = 取消瞄准,不发射
+ downPos = null;
+ PBGame.fire(g);
+ }
+ }
+
+ // ---- 结束结算 ----
+ function onOver() {
+ PBStorage.addRecord(g.score, Math.floor(g.secs));
+ var best = PBStorage.getBest();
+ var isNew = g.score > best;
+ if (isNew) PBStorage.setBest(g.score);
+ $('pb-over-score').textContent = g.score.toLocaleString();
+ $('pb-over-time').textContent = '用时 ' + fmtSecs(g.secs);
+ $('pb-over-new').style.display = isNew ? '' : 'none';
+ $('pb-over-best').textContent = '最佳: ' + PBStorage.getBest().toLocaleString();
+ show('pb-over');
+ hide('pb-hud');
+ }
+
+ // ---- 记录页 ----
+ function renderRecords() {
+ var list = PBStorage.getRecords();
+ var box = $('pb-records-list');
+ box.innerHTML = '';
+ if (!list.length) {
+ box.innerHTML = '暂无记录,去打一局吧
';
+ return;
+ }
+ for (var i = 0; i < list.length; i++) {
+ var r = list[i];
+ var row = document.createElement('div');
+ row.className = 'pb-rec-row ' + (i % 2 ? 'alt' : '');
+ var color = R.RECORD_COLORS[i % R.RECORD_COLORS.length];
+ row.innerHTML =
+ '' + (i + 1) + '.' +
+ '' + r.date + '' +
+ '' + r.score.toLocaleString() + '' +
+ '' + fmtSecs(r.secs) + '';
+ box.appendChild(row);
+ }
+ }
+
+ // ---- 指南演示小图 ----
+ function drawGuideDemo() {
+ var c = $('pb-guide-demo');
+ if (!c) return;
+ var x = c.getContext('2d');
+ x.clearRect(0, 0, c.width, c.height);
+ x.strokeStyle = '#8a8a8a'; x.lineWidth = 2;
+ x.beginPath();
+ x.moveTo(20, 10); x.lineTo(20, 150);
+ x.moveTo(200, 10); x.lineTo(200, 150);
+ x.moveTo(20, 10); x.lineTo(95, 42);
+ x.moveTo(200, 10); x.lineTo(125, 42);
+ x.stroke();
+ x.setLineDash([4, 4]);
+ x.beginPath(); x.moveTo(20, 48); x.lineTo(200, 48); x.stroke();
+ x.setLineDash([]);
+ // 待发球
+ x.fillStyle = '#a97a45';
+ [[105, 34], [117, 30], [93, 30], [111, 20]].forEach(function (p) {
+ x.beginPath(); x.arc(p[0], p[1], 5, 0, 7); x.fill();
+ });
+ // 轨迹点
+ x.fillStyle = '#c89458';
+ for (var i = 0; i < 9; i++) {
+ x.beginPath(); x.arc(112 + i * 8, 56 + i * 11, 3, 0, 7); x.fill();
+ }
+ // 彩色砖块
+ var blocks = [
+ ['#f7c81a', 55, 95, '3'], ['#8bd420', 95, 95, '10'], ['#37c837', 130, 95, '8'], ['#f7c81a', 185, 95, '1'],
+ ['#37c837', 90, 122, '13'], ['#2fd4c8', 125, 122, '24'], ['#f7c81a', 172, 122, '6'],
+ ['#2f7bff', 55, 148, '80'], ['#29c8e2', 115, 148, '29'], ['#a02fe0', 185, 148, '100']
+ ];
+ blocks.forEach(function (b) {
+ x.fillStyle = b[0];
+ x.fillRect(b[1] - 11, b[2] - 9, 22, 18);
+ x.fillStyle = b[3].length > 2 ? '#fff' : '#20180a';
+ x.font = 'bold 10px sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';
+ x.fillText(b[3], b[1], b[2] + 1);
+ });
+ // 道具
+ x.strokeStyle = '#eee'; x.setLineDash([3, 3]);
+ x.beginPath(); x.arc(152, 122, 8, 0, 7); x.stroke();
+ x.setLineDash([]);
+ }
+
+ // ---- ∞ 三色 logo ----
+ function drawLogo() {
+ var c = $('pb-logo');
+ if (!c) return;
+ var x = c.getContext('2d');
+ x.clearRect(0, 0, c.width, c.height);
+ var colors = ['#f7c81a', '#2f7bff', '#ff2f7b'];
+ for (var k = 0; k < colors.length; k++) {
+ x.strokeStyle = colors[k];
+ x.lineWidth = 5;
+ x.beginPath();
+ var off = (k - 1) * 4;
+ for (var i = 0; i <= 200; i++) {
+ var t = i / 200 * Math.PI * 2;
+ var s2 = Math.sin(t) * Math.sin(t);
+ var px = 110 + 78 * Math.cos(t) / (1 + s2);
+ var py = 60 + (46 + off) * Math.sin(t) * Math.cos(t) / (1 + s2);
+ if (i === 0) x.moveTo(px, py); else x.lineTo(px, py);
+ }
+ x.stroke();
+ }
+ }
+
+ function toast(msg) {
+ var t = $('pb-toast');
+ t.textContent = msg;
+ t.classList.add('show');
+ setTimeout(function () { t.classList.remove('show'); }, 1800);
+ }
+
+ // ---- 分数弹跳反馈 ----
+ var lastScore = 0;
+ function popScore() {
+ var el = $('pb-score');
+ el.classList.remove('pop');
+ void el.offsetWidth;
+ el.classList.add('pop');
+ }
+
+ // ---- 主循环 ----
+ function loop(ts) {
+ var dt = Math.min(0.033, (ts - lastTs) / 1000 || 0.016);
+ lastTs = ts;
+ globalT += dt;
+ var prevState = g.state;
+ PBGame.update(g, dt);
+ if (g.state === 'over' && prevState !== 'over') onOver();
+ PBRender.draw(ctx, g, globalT);
+ // HUD
+ if (g.state !== 'idle') {
+ $('pb-time').textContent = '时间 ' + fmtSecs(g.secs);
+ $('pb-score').textContent = g.score.toLocaleString();
+ $('pb-balls').textContent = '🥎 ' + (g.queue.length + g.balls.length + g.recycle.length);
+ $('pb-big').classList.toggle('hidden', !g.bigTurn);
+ if (g.score !== lastScore) { lastScore = g.score; popScore(); }
+ }
+ requestAnimationFrame(loop);
+ }
+
+ function bind() {
+ canvas = $('pb-canvas');
+ ctx = canvas.getContext('2d');
+ canvas.addEventListener('pointerdown', onDown);
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+
+ $('pb-pause-btn').addEventListener('click', function () {
+ if (g.state === 'idle' || g.state === 'over') return;
+ g.paused = true;
+ show('pb-pause');
+ });
+ $('pb-resume').addEventListener('click', function () { g.paused = false; hide('pb-pause'); });
+ $('pb-restart').addEventListener('click', function () {
+ hide('pb-pause');
+ g.paused = false;
+ tryStart();
+ });
+ $('pb-share').addEventListener('click', function () {
+ var text = '我在「物理弹球」' + fmtSecs(g.secs) + ' 打了 ' + g.score.toLocaleString() + ' 分,来挑战我!';
+ try {
+ navigator.clipboard.writeText(text).then(function () { toast('成绩已复制,去分享吧'); }, function () { toast(text); });
+ } catch (e) { toast(text); }
+ });
+
+ $('pb-again').addEventListener('click', function () {
+ hide('pb-over');
+ tryStart();
+ });
+ $('pb-to-menu').addEventListener('click', function () { toIdleDemo(); });
+
+ $('pb-help').addEventListener('click', function () { drawGuideDemo(); show('pb-guide'); });
+ $('pb-guide-ok').addEventListener('click', function () { hide('pb-guide'); PBStorage.markGuideSeen(); });
+
+ $('pb-gear').addEventListener('click', function () {
+ $('pb-sound-toggle').checked = PBStorage.soundOn();
+ $('pb-escape-slider').value = PBStorage.getEscape();
+ $('pb-escape-val').textContent = PBStorage.getEscape();
+ show('pb-settings');
+ });
+ $('pb-settings-close').addEventListener('click', function () { hide('pb-settings'); });
+ $('pb-sound-toggle').addEventListener('change', function (e) {
+ PBStorage.setSound(e.target.checked);
+ toast(e.target.checked ? '音效已开启' : '音效已关闭');
+ });
+ // 逃逸强度滑块:实时存储,局内立即生效
+ $('pb-escape-slider').addEventListener('input', function (e) {
+ PBStorage.setEscape(e.target.value);
+ $('pb-escape-val').textContent = PBStorage.getEscape();
+ });
+
+ $('pb-side-records').addEventListener('click', function () { renderRecords(); show('pb-records'); });
+ $('pb-records-close').addEventListener('click', function () { hide('pb-records'); });
+
+ $('pb-daily').addEventListener('click', function () {
+ if (PBStorage.claimDaily()) {
+ PBAudio.claim();
+ toast('领取成功 +3 球');
+ $('pb-ball-count').textContent = PBStorage.getBalls();
+ hide('pb-daily');
+ }
+ });
+ $('pb-side-balls').addEventListener('click', function () {
+ toast('当前球数 ' + PBStorage.getBalls() + ';每局消耗 1 球,局内 +1 球道具可补充');
+ });
+ }
+
+ function init() {
+ g = PBGame.create();
+ bind();
+ drawLogo();
+ toIdleDemo();
+ if (!PBStorage.guideSeen()) { drawGuideDemo(); show('pb-guide'); }
+ requestAnimationFrame(function (ts) { lastTs = ts; requestAnimationFrame(loop); });
+ }
+
+ return { init: init };
+})();
+
+window.addEventListener('load', function () { PBUi.init(); });
diff --git a/public/tools/thought_lab/labs/retro_pinball/index.html b/public/tools/thought_lab/labs/retro_pinball/index.html
index f90af3c..dd76cb3 100644
--- a/public/tools/thought_lab/labs/retro_pinball/index.html
+++ b/public/tools/thought_lab/labs/retro_pinball/index.html
@@ -88,11 +88,12 @@
积分:半隐式欧拉,固定子步 dt=1/360s,高速发射不穿透薄木条。
碰撞:序贯冲量法。法向冲量 j=-(1+e)·v_n/m⁻¹,低速接触时 e=0 防抖动。
摩擦:切向冲量按库仑摩擦 |j_t| ≤ μ·j_n 钳制,同时驱动角速度,弹珠会真实地「滚动」而非滑动。
+ 光滑面:圆钉 / 拨片 / 落坑隔板 / 发射道壁 / 弧形导轨摩擦恒为 0,且带防粘附微推力——任何光滑面都不能存放球(包括隔板顶、钉顶),球必须滑落。
倾角:真实玩具斜放在桌面上,沿台面有效重力 g·sinθ,θ 即倾角滑块。
弹簧发射:蓄力压缩量线性映射初速(胡克定律手感),松手即发射;力度不足掉回发射道的球不消耗机会,落在弹簧上可重新蓄力再弹——只有真正进入盘面的球才计一次机会。
力度曲线:蓄力 20% 恰好进盘面,20%~100% 线性到最大力,低力度也有用;蓄力条红区(>85%)弹簧过压缩——初速随机损耗、横向抖动剧增,满力≠高分,甜区约 80%~90%。
雨刮拨片(可选):勾选后两根摆杆像汽车雨刮器一样刮来刮去;再勾「转动」改为绕轴连续旋转(两杆反向)。拨片是运动碰撞体,表面线速度 v=ω×r 参与冲量求解,物理上真实地「扇飞」弹珠;拨片带 4px 胶囊厚度,高速球也不会穿透。
- 静止判定:速度持续低于阈值且存在接触 → 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片)。
+ 静止判定:速度持续低于阈值且存在非光滑接触(坑底横挡等)→ 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片);只接触光滑面的球永不休眠,必然落下。
@@ -115,7 +116,7 @@ var PhysicsCore = (function () {
angle: 0, omega: 0,
m: m, invM: 1 / m,
I: I, invI: 1 / I,
- sleeping: false, sleepTimer: 0, contacts: 0
+ sleeping: false, sleepTimer: 0, contacts: 0, slipContacts: 0
};
}
@@ -136,7 +137,7 @@ var PhysicsCore = (function () {
d2 = dx * dx + dy * dy;
if (d2 >= R * R) return null;
d = Math.sqrt(d2) || 1e-9;
- return { nx: dx / d, ny: dy / d, pen: R - d };
+ return { nx: dx / d, ny: dy / d, pen: R - d, slip: !!s.slip };
}
var c = closestOnSeg(b.x, b.y, s);
dx = b.x - c.x; dy = b.y - c.y;
@@ -147,7 +148,7 @@ var PhysicsCore = (function () {
var R2 = b.r + (s.thk || 0);
if (d2 >= R2 * R2) return null;
d = Math.sqrt(d2) || 1e-9;
- var ct = { nx: dx / d, ny: dy / d, pen: R2 - d };
+ var ct = { nx: dx / d, ny: dy / d, pen: R2 - d, slip: !!s.slip };
// 运动碰撞体(雨刮拨片):按接触点附表面线速度 v=ω×r,供相对速度冲量求解
if (s.mov) { ct.svx = -s.mov.omega * (c.y - s.mov.py); ct.svy = s.mov.omega * (c.x - s.mov.px); }
return ct;
@@ -206,14 +207,19 @@ var PhysicsCore = (function () {
var vt0 = rvx * tx + rvy * ty - a.omega * a.r;
var Kt0 = a.invM + a.r * a.r * a.invI;
var jt0 = -vt0 / Kt0;
- var maxF0 = p.mu * jnAcc0;
+ // 光滑面(钉/拨片/隔板等,slip)摩擦恒为 0:理论上不产生任何切向摩擦冲量
+ var maxF0 = (c.slip ? 0 : p.mu) * jnAcc0;
if (jt0 > maxF0) jt0 = maxF0; else if (jt0 < -maxF0) jt0 = -maxF0;
a.vx += tx * jt0 * a.invM; a.vy += ty * jt0 * a.invM;
a.omega -= a.r * jt0 * a.invI;
}
}
- // 推进一步 dt:积分 -> 收集接触 -> 冲量迭代 -> 位置校正 -> 滚动阻力
+ // 防粘附推力:光滑面上「正上方平衡」是不稳定平衡,离散求解器可能维持完美对称
+ // 导致球视觉冻结在钉顶/隔板顶。对法线朝上的光滑接触施加微小水平加速度,必滑落。
+ var STICK_K = 240;
+
+ // 推进一步 dt:积分 -> 收集接触 -> 防粘附 -> 冲量迭代 -> 位置校正 -> 滚动阻力
function stepWorld(balls, statics, p, dt) {
var i, j, b, o, c;
for (i = 0; i < balls.length; i++) {
@@ -227,6 +233,7 @@ var PhysicsCore = (function () {
b.y += b.vy * dt;
b.angle += b.omega * dt;
b.contacts = 0;
+ b.slipContacts = 0;
}
var contacts = [];
for (i = 0; i < balls.length; i++) {
@@ -234,7 +241,7 @@ var PhysicsCore = (function () {
if (b.sleeping) continue;
for (j = 0; j < statics.length; j++) {
c = contactBallStatic(b, statics[j]);
- if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; }
+ if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; if (c.slip) b.slipContacts++; }
}
for (j = 0; j < balls.length; j++) {
o = balls[j];
@@ -248,6 +255,13 @@ var PhysicsCore = (function () {
}
}
}
+ // 防粘附:球坐在光滑面顶部(法线朝上)时施加微小水平推力,打破平衡使其滑落
+ for (j = 0; j < contacts.length; j++) {
+ c = contacts[j];
+ if (!c.slip || c.b || c.ny > -0.6) continue;
+ var dir = c.nx > 0.02 ? 1 : (c.nx < -0.02 ? -1 : 1);
+ c.a.vx += dir * STICK_K * dt;
+ }
for (var it = 0; it < p.iterations; it++) {
for (j = 0; j < contacts.length; j++) solveContact(contacts[j], p);
}
@@ -268,7 +282,8 @@ var PhysicsCore = (function () {
}
for (i = 0; i < balls.length; i++) {
b = balls[i];
- if (b.sleeping || b.contacts === 0) continue;
+ // 全部接触都是光滑面时不加滚动阻力:球必须继续滑动/下落,不能被“磨”停
+ if (b.sleeping || b.contacts === 0 || b.slipContacts >= b.contacts) continue;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (sp > 1e-6) {
var dec = Math.min(sp, p.rollR * p.g * dt);
@@ -322,8 +337,11 @@ function buildTableStatics(pegR, pegSpacing) {
// 外框四壁
statics.push(seg(L, T, R, T), seg(L, B, R, B), seg(L, T, L, B), seg(R, T, R, B));
// 发射道左壁(顶端接弧形导轨右端,底端接底壁封死,杜绝壁底缺口侧逃)
- statics.push(seg(LANE_X, 190, LANE_X, B));
- // 弧形导轨(离散为线段链)
+ // slip:光滑壁,顶端不得存球(它同时是最右坑的右隔板)
+ var lane = seg(LANE_X, 190, LANE_X, B);
+ lane.slip = true;
+ statics.push(lane);
+ // 弧形导轨(离散为线段链,slip:抛光导轨摩擦为 0,弧顶不存球)
var steps = 40;
var a0 = ARCH.a0 * Math.PI / 180, a1 = ARCH.a1 * Math.PI / 180;
var px = 0, py = 0;
@@ -331,12 +349,17 @@ function buildTableStatics(pegR, pegSpacing) {
var a = a0 + (a1 - a0) * (i / steps);
var x = ARCH.cx + ARCH.r * Math.cos(a);
var y = ARCH.cy + ARCH.r * Math.sin(a);
- if (i > 0) statics.push(seg(px, py, x, y));
+ if (i > 0) { var as = seg(px, py, x, y); as.slip = true; statics.push(as); }
px = x; py = y;
}
// 落球坑:5 根分隔木条(右边界复用发射道左壁,不另设右长条,避免与道壁重叠/留缝)
+ // slip:隔板摩擦为 0,隔板顶不得存球,球必须落入坑内
var slotW = (SLOT_X1 - SLOT_X0) / SLOT_N;
- for (i = 1; i < SLOT_N; i++) statics.push(seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT));
+ for (i = 1; i < SLOT_N; i++) {
+ var dv = seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT);
+ dv.slip = true;
+ statics.push(dv);
+ }
// 底部横挡木条(球停在它上面)
statics.push(seg(SLOT_X0, BAR_Y, SLOT_X1, BAR_Y));
// 圆钉阵(交错,间距/尺寸可调)
@@ -351,7 +374,8 @@ function buildTableStatics(pegR, pegSpacing) {
var yy = cy + (row.y - cy) * pegSpacing;
for (var xI = 0; xI < row.xs.length; xI++) {
var xx = cx + (row.xs[xI] - cx) * pegSpacing;
- statics.push({ type: 'circle', x: xx, y: yy, r: pegR });
+ // slip:圆钉摩擦为 0,钉顶不得存球,球必须滑落
+ statics.push({ type: 'circle', x: xx, y: yy, r: pegR, slip: true });
pegs.push({ x: xx, y: yy, r: pegR, metal: ((rI + xI) % 3 === 2) });
}
}
@@ -378,7 +402,8 @@ function makeWiperSegs() {
var out = [];
for (var i = 0; i < WIPERS.length; i++) {
// thk=4 胶囊厚度:防高速球穿透(见 contactBallStatic 注释)
- out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, mov: { px: 0, py: 0, omega: 0 } });
+ // slip:拨片摩擦为 0,不得存球
+ out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, slip: true, mov: { px: 0, py: 0, omega: 0 } });
}
return out;
}
@@ -571,7 +596,9 @@ function updateWiperSeg(sg, w, t, spin) {
if (!b.counted && !b.ready && !b.sleeping && b.x < LANE_X - b.r) { b.counted = true; launched++; }
if (b.sleeping) continue;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
- if (sp < 15 && b.contacts > 0) b.sleepTimer += dt; else b.sleepTimer = 0;
+ // 仅当存在非光滑接触(坑底横挡/外框等)才允许休眠;
+ // 全光滑接触(钉/拨片/隔板顶)永不休眠,球必须落下
+ if (sp < 15 && b.contacts > 0 && b.slipContacts < b.contacts) b.sleepTimer += dt; else b.sleepTimer = 0;
if (b.sleepTimer > 0.5) settleBall(b);
if (b.y > B + BALL_R + 8 || b.y > H + 40 || b.x < -40 || b.x > W + 40) { // 出界保护:回到弹簧
b.x = SPRING_X; b.y = SPRING_TOP - BALL_R;
diff --git a/src/server/private_clipboard.js b/src/server/private_clipboard.js
index ba544d1..aec77d3 100644
--- a/src/server/private_clipboard.js
+++ b/src/server/private_clipboard.js
@@ -221,6 +221,7 @@ const groupItemsFromRows = (rows) => {
const AUTH_COOKIE = 'private_clipboard_auth'
const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 // 1天
const AUTH_HMAC_SECRET = process.env.PRIVATE_CLIPBOARD_AUTH_SECRET || 'pc-auth-secret-change-me'
+const UPLOAD_LIMIT_MB = 200
const signAuthPayload = (username) => {
const payload = { sub: username, iat: Date.now() }
@@ -660,6 +661,20 @@ const createRouter = () => {
}
})
+ router.use((err, req, res, next) => {
+ if (!err) return next()
+ if (req && req.path === '/upload') {
+ if (err.type === 'entity.too.large') {
+ return res.status(413).json({ ok: false, error: 'file_too_large', limit_mb: UPLOAD_LIMIT_MB })
+ }
+ if (err.type === 'request.aborted') {
+ return res.status(400).json({ ok: false, error: 'upload_aborted' })
+ }
+ return res.status(500).json({ ok: false, error: 'upload_failed' })
+ }
+ return next(err)
+ })
+
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10)
diff --git a/src/server/thought_lab/labs/physics_balls/config.json b/src/server/thought_lab/labs/physics_balls/config.json
new file mode 100644
index 0000000..f921e28
--- /dev/null
+++ b/src/server/thought_lab/labs/physics_balls/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · 物理弹球(Web 复刻版):竖向场地 + 顶部漏斗待发球 + 拖拽瞄准连发弹球 + 数字砖块刚体堆叠 + 侧边输送带回收 + 回合上升机制。学术研究用途,玩法复刻自同名手游。多文件前端:pb_physics(刚体引擎)/pb_rules(规则)/pb_render(渲染)/pb_game(状态机)/pb_ui(界面)/pb_storage(存档)/pb_audio(音效)。本 lab 完全独立;未来若需要后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "physics_balls",
+ "name_comment": "首页卡片显示名称",
+ "name": "物理弹球",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "🎾",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "打砖块式弹球 · 刚体物理 · 拖拽瞄准连发 · 回合上升",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 97,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}