feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本

1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
yangxiangyuan
2026-08-07 17:49:24 +08:00
parent 0f8fe2c2e2
commit 4492b4106a
22 changed files with 6935 additions and 34 deletions
@@ -0,0 +1,100 @@
/**
* 调试:retro_pinball 拱形导轨几何搜索
* 目的:找到一组 ARCH(cx,cy,r,a0,a1),让满力发射的球能绕弧 -> 左侧落入 -> 静止落球区
* 运行:node dev_test_scripts/debug/debug_retro_pinball_arch_search.js
*/
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
const html = fs.readFileSync(HTML_PATH, 'utf-8')
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b)
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
return html.slice(s, e)
}
const code =
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
const sandbox = { Math, console }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const PC = sandbox.PhysicsCore
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, LANE_X, buildTableStatics, slotIndexAt, SLOT_X0, SLOT_X1 } = sandbox
const DT = 1 / 360
function defaultParams() {
return {
g: G_PX * Math.sin(14 * Math.PI / 180),
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
}
// 三点定圆
function fitCircle(p1, p2, p3) {
const [x1, y1] = p1, [x2, y2] = p2, [x3, y3] = p3
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
if (Math.abs(d) < 1e-9) return null
const ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
const uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
const r = Math.hypot(x1 - ux, y1 - uy)
return { cx: ux, cy: uy, r: r }
}
const deg = a => a * 180 / Math.PI
function angleOf(c, p) { return deg(Math.atan2(p[1] - c.cy, p[0] - c.cx)) }
// 候选:三点(左出口 / 弧顶 / 右端),右端放在发射道上方,让球顺势切入
// laneTop = 发射道左壁顶 (LANE_X,190);右端在右外壁 x=442 附近
const candidates = []
function addCandidate(leftExit, top, rightEnd, label) {
const c = fitCircle(leftExit, top, rightEnd)
if (!c) return
// 角度范围:从右端 -> 顶部 -> 左出口(逆时针,y 向下故角度递减)
let aR = angleOf(c, rightEnd)
let aT = angleOf(c, top)
let aL = angleOf(c, leftExit)
// 归一化:弧要从左出口经弧顶(-90°)扫到右端,需 aL < -90 < aR(角度递增)
while (aL > aR) aL -= 360
candidates.push({ label, arch: { cx: c.cx, cy: c.cy, r: c.r, a0: aL, a1: aR }, _top: aT, c })
}
// 右端扫过发射道:从 (442,180) 到 (442,230),左出口落在钉阵左侧,顶部留 >=30 余量
addCandidate([56, 330], [230, 42], [442, 180], 'C1 右端(442,180)')
addCandidate([56, 330], [235, 45], [442, 200], 'C2 右端(442,200)')
addCandidate([60, 340], [235, 48], [442, 210], 'C3 右端(442,210)')
addCandidate([60, 340], [240, 50], [442, 220], 'C4 右端(442,220)')
addCandidate([50, 320], [225, 40], [442, 190], 'C5 右端(442,190)')
addCandidate([56, 335], [230, 44], [440, 195], 'C6 右端(440,195)')
function simulate(arch, v0, seconds, seedVx) {
sandbox.ARCH = arch
const table = buildTableStatics(5, 1)
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
b.vy = -v0; b.vx = seedVx || 0
const tr = { minY: 1e9, enteredLeft: false, nan: false }
for (let t = 0; t < seconds; t += DT) {
PC.stepWorld([b], table.statics, defaultParams(), DT)
tr.minY = Math.min(tr.minY, b.y)
if (b.x < 90 && b.y > 200 && b.y < 450) tr.enteredLeft = true
if (!isFinite(b.x) || !isFinite(b.y)) { tr.nan = true; break }
}
tr.b = b
tr.sp = Math.hypot(b.vx, b.vy)
tr.inField = b.x >= SLOT_X0 && b.x <= SLOT_X1
tr.slot = slotIndexAt(b.x, b.y)
return tr
}
for (const cd of candidates) {
const a = cd.arch
const tr = simulate(a, 2900, 10, 5)
const topOk = tr.minY < 90
const ok = !tr.nan && topOk && tr.enteredLeft && tr.inField && tr.sp < 30
console.log(`${ok ? '★' : ' '} ${cd.label} cx=${a.cx.toFixed(0)} cy=${a.cy.toFixed(0)} r=${a.r.toFixed(0)} a:[${a.a0.toFixed(0)}..${a.a1.toFixed(0)}] topAng=${cd._top.toFixed(0)}`)
console.log(` minY=${tr.minY.toFixed(0)} enteredLeft=${tr.enteredLeft} final=(${tr.b.x.toFixed(0)},${tr.b.y.toFixed(0)}) sp=${tr.sp.toFixed(1)} inField=${tr.inField} slot=${tr.slot} nan=${tr.nan}`)
}
@@ -0,0 +1,83 @@
/**
* 调试:retro_pinball 拱形候选的力度扫描 + 精确参数输出
* 运行:node dev_test_scripts/debug/debug_retro_pinball_arch_sweep.js
*/
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
const html = fs.readFileSync(HTML_PATH, 'utf-8')
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b)
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
return html.slice(s, e)
}
const code = grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' + grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
const sandbox = { Math, console }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const PC = sandbox.PhysicsCore
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, LANE_X, buildTableStatics, slotIndexAt, SLOT_X0, SLOT_X1 } = sandbox
const DT = 1 / 360
function params() {
return { g: G_PX * Math.sin(14 * Math.PI / 180), e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25, restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200 };
}
function fitCircle(p1, p2, p3) {
const [x1, y1] = p1, [x2, y2] = p2, [x3, y3] = p3
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
const ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
const uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
return { cx: ux, cy: uy, r: Math.hypot(x1 - ux, y1 - uy) }
}
const deg = a => a * 180 / Math.PI
function makeArch(leftExit, top, rightEnd) {
const c = fitCircle(leftExit, top, rightEnd)
let aR = deg(Math.atan2(rightEnd[1] - c.cy, rightEnd[0] - c.cx))
let aL = deg(Math.atan2(leftExit[1] - c.cy, leftExit[0] - c.cx))
while (aL > aR) aL -= 360
return { cx: c.cx, cy: c.cy, r: c.r, a0: aL, a1: aR }
}
function simulate(arch, v0, seedVx) {
sandbox.ARCH = arch
const table = buildTableStatics(5, 1)
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
b.vy = -v0; b.vx = seedVx || 0
let minY = 1e9, enteredLeft = false, nan = false
for (let t = 0; t < 10; t += DT) {
PC.stepWorld([b], table.statics, params(), DT)
minY = Math.min(minY, b.y)
if (b.x < 90 && b.y > 200 && b.y < 450) enteredLeft = true
if (!isFinite(b.x) || !isFinite(b.y)) { nan = true; break }
}
const sp = Math.hypot(b.vx, b.vy)
return { minY, enteredLeft, nan, x: b.x, y: b.y, sp, inField: b.x >= SLOT_X0 && b.x <= SLOT_X1, slot: slotIndexAt(b.x, b.y), fellBackLane: b.x > LANE_X };
}
// 聚焦最优候选 C4,输出精确值
const CHOSEN = { leftExit: [60, 340], top: [240, 50], rightEnd: [442, 220] }
const arch = makeArch(CHOSEN.leftExit, CHOSEN.top, CHOSEN.rightEnd)
console.log('选中拱形精确参数:')
console.log(` cx=${arch.cx.toFixed(3)} cy=${arch.cy.toFixed(3)} r=${arch.r.toFixed(3)} a0=${arch.a0.toFixed(3)} a1=${arch.a1.toFixed(3)}`)
console.log(` 左出口=(${CHOSEN.leftExit}) 弧顶=(${CHOSEN.top}) 右端=(${CHOSEN.rightEnd})`)
console.log('')
console.log('力度扫描(seedVx=5):')
const powers = [900, 1200, 1500, 1800, 2100, 2400, 2700, 2900, 3200]
for (const v0 of powers) {
const r = simulate(arch, v0, 5)
const tag = r.nan ? 'NaN!' : r.fellBackLane ? '落回发射道' : r.enteredLeft ? '绕弧左侧落入' : '其他'
console.log(` v0=${v0} minY=${r.minY.toFixed(0)} ${tag} final=(${r.x.toFixed(0)},${r.y.toFixed(0)}) sp=${r.sp.toFixed(0)} slot=${r.slot}`)
}
console.log('')
console.log('满力微扰扫描(v0=2900,落点分布):')
const slots = {}
for (let sv = -8; sv <= 8; sv += 2) {
const r = simulate(arch, 2900, sv)
slots[r.slot] = (slots[r.slot] || 0) + 1
console.log(` seedVx=${sv} 落点=(${r.x.toFixed(0)},${r.y.toFixed(0)}) slot=${r.slot} enteredLeft=${r.enteredLeft}`)
}
console.log(' 落坑分布:', JSON.stringify(slots))
@@ -0,0 +1,70 @@
/**
* 调试:retro_pinball 满力发射轨迹追踪
* 目的:定位能量增益 bug(理论上满力发射到不了顶部,实测 minY=28)
* 运行:node dev_test_scripts/debug/debug_retro_pinball_launch.js
*/
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html')
const html = fs.readFileSync(HTML_PATH, 'utf-8')
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b)
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a)
return html.slice(s, e)
}
const code =
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */')
const sandbox = { Math, console }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
const PC = sandbox.PhysicsCore
const { BALL_R, G_PX, SPRING_X, SPRING_TOP, buildTableStatics } = sandbox
const DT = 1 / 360
function defaultParams() {
return {
g: G_PX * Math.sin(14 * Math.PI / 180),
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
}
const gEff = G_PX * Math.sin(14 * Math.PI / 180)
console.log('g_eff =', gEff.toFixed(1), 'px/s^2')
const table = buildTableStatics(5, 1)
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R)
b.vy = -2900
b.vx = 5
const balls = [b]
let maxSp = 0, maxSpAt = ''
let minY = 1e9, minYAt = ''
let maxEnergy = -1e9, maxEnergyAt = ''
const startEnergy = 0.5 * (b.vx * b.vx + b.vy * b.vy) + gEff * b.y
console.log('初始动能+势能基准 E0 =', startEnergy.toFixed(0))
for (let i = 0; i < 360 * 3; i++) {
PC.stepWorld(balls, table.statics, defaultParams(), DT)
const sp = Math.hypot(b.vx, b.vy)
const t = ((i + 1) / 360).toFixed(2)
// 机械能 = 动能 + 势能(y 向下为正,势能取 g*y,越高 y 越小能量越小)
const E = 0.5 * sp * sp + gEff * b.y
if (sp > maxSp) { maxSp = sp; maxSpAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)})` }
if (b.y < minY) { minY = b.y; minYAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) sp=${sp.toFixed(0)}` }
if (E > maxEnergy) { maxEnergy = E; maxEnergyAt = `t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) sp=${sp.toFixed(0)}` }
if (i % 36 === 0) {
console.log(`t=${t} pos=(${b.x.toFixed(1)},${b.y.toFixed(1)}) v=(${b.vx.toFixed(0)},${b.vy.toFixed(0)}) sp=${sp.toFixed(0)} E=${E.toFixed(0)}`)
}
}
console.log('----')
console.log('maxSp =', maxSp.toFixed(1), '@', maxSpAt)
console.log('minY =', minY.toFixed(1), '@', minYAt)
console.log('maxE =', maxEnergy.toFixed(0), '@', maxEnergyAt, '(E0=' + startEnergy.toFixed(0) + ')')