From 91148fe06450e4435ea82061efda7497a1272e24 Mon Sep 17 00:00:00 2001 From: yangxiangyuan Date: Sat, 8 Aug 2026 22:31:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=89=A9=E7=90=86?= =?UTF-8?q?=E5=BC=B9=E7=90=83=E5=AE=9E=E9=AA=8C=E5=AE=A4=E5=8F=8A=E9=85=8D?= =?UTF-8?q?=E5=A5=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑 --- .../debug/debug_physics_balls_dom_smoke.js | 126 +++++ .../debug_physics_balls_preview_match.js | 52 ++ .../debug/debug_physics_balls_stuck_escape.js | 127 +++++ .../debug/debug_physics_balls_volley_loop.js | 47 ++ .../unit/test_physics_balls_physics.js | 236 +++++++++ public/tools/private_clipboard/clipboard.js | 44 +- .../thought_lab/labs/physics_balls/index.html | 173 +++++++ .../labs/physics_balls/pb_audio.js | 59 +++ .../thought_lab/labs/physics_balls/pb_game.js | 430 +++++++++++++++++ .../labs/physics_balls/pb_physics.js | 456 ++++++++++++++++++ .../labs/physics_balls/pb_render.js | 260 ++++++++++ .../labs/physics_balls/pb_rules.js | 204 ++++++++ .../labs/physics_balls/pb_storage.js | 94 ++++ .../thought_lab/labs/physics_balls/pb_ui.js | 317 ++++++++++++ .../thought_lab/labs/retro_pinball/index.html | 57 ++- src/server/private_clipboard.js | 15 + .../labs/physics_balls/config.json | 15 + 17 files changed, 2690 insertions(+), 22 deletions(-) create mode 100644 dev_test_scripts/debug/debug_physics_balls_dom_smoke.js create mode 100644 dev_test_scripts/debug/debug_physics_balls_preview_match.js create mode 100644 dev_test_scripts/debug/debug_physics_balls_stuck_escape.js create mode 100644 dev_test_scripts/debug/debug_physics_balls_volley_loop.js create mode 100644 dev_test_scripts/unit/test_physics_balls_physics.js create mode 100644 public/tools/thought_lab/labs/physics_balls/index.html create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_audio.js create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_game.js create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_physics.js create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_render.js create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_rules.js create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_storage.js create mode 100644 public/tools/thought_lab/labs/physics_balls/pb_ui.js create mode 100644 src/server/thought_lab/labs/physics_balls/config.json 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 @@ + + + + + +物理弹球 · 思想实验室 + + + +
+ + + + +
+ + +
最佳: 0
+ +
物理弹球
+ + + +
+
拖动瞄准
+
+ + + + + + + + + + + +
+
+ + + + + + + + + + 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 +}