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,920 @@
<!-- ============================================================
思想实验室 · labs/retro_pinball · 怀旧弹珠台
老式木制桌面弹珠台(Bagatelle)物理模拟:竖向台面、右下弹簧拉杆发射、
顶部弧形导轨、交错圆钉阵、底部 6 落球坑计分。
物理核心为自研冲量求解器(半隐式欧拉 + 固定子步 + 序贯冲量 + 位置校正),
纯函数、零依赖,可被 dev_test_scripts/unit/test_retro_pinball_physics.js 提取做单元测试。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/retro_pinball/config.json
============================================================ -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>怀旧弹珠台 · 木制 Bagatelle 物理模拟</title>
<style>
:root { --bg:#221a12; --panel:#2e2418; --line:rgba(255,220,160,.16); --txt:#e8dcc8; --dim:#b3a289; --acc:#e0a35c; --warn:#e8b84a; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--txt); font-family:"PingFang SC","Microsoft YaHei",sans-serif; padding:16px; }
h1 { font-size:21px; color:#ffe9c4; }
.sub { color:var(--dim); font-size:12.5px; margin:4px 0 14px; }
.wrap { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start; }
#stage { position:relative; background:#171009; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
canvas#cv { display:block; touch-action:none; user-select:none; -webkit-user-select:none; }
#hud { font-size:12px; line-height:1.7; color:var(--dim); flex-wrap:wrap;
background:rgba(23,16,9,.5); border:1px solid var(--line); border-radius:8px; padding:8px 12px; margin-bottom:14px; }
#hud b { color:#ffe9c4; }
#overlay { position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(20,13,7,.66); }
#overlay.show { display:flex; }
#overlay .card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:22px 34px; text-align:center; }
#overlay .card h2 { color:#ffe9c4; font-size:20px; margin-bottom:6px; }
#overlay .card p { color:var(--dim); font-size:13px; margin-bottom:14px; }
#overlay .card b { color:var(--warn); font-size:22px; }
.panel { width:420px; background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; }
.panel h2 { font-size:14px; color:var(--acc); margin:14px 0 8px; }
.panel h2:first-child { margin-top:0; }
.panel p, .panel li { font-size:12.8px; line-height:1.75; color:#cdbfa8; }
.panel ul { padding-left:20px; }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
.row label { font-size:12.5px; color:var(--dim); white-space:nowrap; min-width:76px; }
button { background:rgba(224,163,92,.14); border:1px solid rgba(224,163,92,.45); color:#ffe2b8;
padding:6px 12px; border-radius:8px; font-size:12.5px; cursor:pointer; }
button:hover { background:rgba(224,163,92,.3); }
input[type=range] { flex:1; accent-color:var(--acc); min-width:120px; }
.mono { font-family:Consolas,monospace; font-size:12px; color:#ffe9c4; min-width:64px; text-align:right; }
code { background:rgba(255,255,255,.08); color:#ffd479; padding:1px 6px; border-radius:4px; font-size:12px; }
</style>
</head>
<body>
<h1>怀旧弹珠台 · 木制 Bagatelle 物理模拟</h1>
<div class="sub">按住空格 / 按住台面蓄力,松开发射玻璃弹珠 · 冲量法刚体物理 · 全部参数实时可调</div>
<div class="wrap">
<div id="stage">
<canvas id="cv"></canvas>
<div id="overlay"><div class="card">
<h2>本局结束</h2>
<p>总得分 <b id="final-score">0</b></p>
<button id="btn-again">再来一局</button>
</div></div>
</div>
<div class="panel">
<div id="hud"></div>
<h2>回合控制</h2>
<div class="row">
<button id="btn-reset">重新开始本局</button>
<button id="btn-clear">清空桌面弹珠</button>
</div>
<h2>物理参数</h2>
<div class="row"><label>台面倾角</label><input type="range" id="p-tilt" min="5" max="30" step="0.5" value="14"><span class="mono" id="v-tilt"></span></div>
<div class="row"><label>碰撞弹性 e</label><input type="range" id="p-rest" min="0.1" max="0.9" step="0.05" value="0.55"><span class="mono" id="v-rest"></span></div>
<div class="row"><label>摩擦系数 μ</label><input type="range" id="p-mu" min="0.02" max="0.6" step="0.02" value="0.12"><span class="mono" id="v-mu"></span></div>
<div class="row"><label>空气阻尼</label><input type="range" id="p-air" min="0" max="0.3" step="0.01" value="0.02"><span class="mono" id="v-air"></span></div>
<div class="row"><label>滚动阻力</label><input type="range" id="p-roll" min="0" max="1" step="0.05" value="0.25"><span class="mono" id="v-roll"></span></div>
<div class="row"><label>最大力度</label><input type="range" id="p-launch" min="2500" max="3400" step="50" value="2900"><span class="mono" id="v-launch"></span></div>
<h2>台面布局</h2>
<div class="row"><label>钉子尺寸</label><input type="range" id="p-pegr" min="3" max="8" step="0.5" value="5"><span class="mono" id="v-pegr"></span></div>
<div class="row"><label>钉子间距</label><input type="range" id="p-pegs" min="0.8" max="1.3" step="0.05" value="1"><span class="mono" id="v-pegs"></span></div>
<p class="sub" style="margin:4px 0 0">修改布局参数会重新开局。</p>
<div class="row"><label>雨刮拨片</label><input type="checkbox" id="p-wipers"><span class="mono" id="v-wipers">关</span><label style="margin-left:12px">转动</label><input type="checkbox" id="p-wiperspin"><span class="mono" id="v-wiperspin">关</span></div>
<p class="sub" style="margin:4px 0 0">勾选拨片后两根摆杆如雨刮器刮动;再勾「转动」改为绕轴连续旋转(两杆反向)。随时开关、不重开局。</p>
<h2>物理原理</h2>
<ul>
<li>积分:半隐式欧拉,固定子步 <code>dt=1/360s</code>,高速发射不穿透薄木条。</li>
<li>碰撞:序贯冲量法。法向冲量 <code>j=-(1+e)·v_n/m⁻¹</code>,低速接触时 <code>e=0</code> 防抖动。</li>
<li>摩擦:切向冲量按库仑摩擦 <code>|j_t| ≤ μ·j_n</code> 钳制,同时驱动角速度,弹珠会真实地「滚动」而非滑动。</li>
<li>倾角:真实玩具斜放在桌面上,沿台面有效重力 <code>g·sinθ</code>,θ 即倾角滑块。</li>
<li>弹簧发射:蓄力压缩量线性映射初速(胡克定律手感),松手即发射;力度不足掉回发射道的球<strong>不消耗机会</strong>,落在弹簧上可重新蓄力再弹——只有真正进入盘面的球才计一次机会。</li>
<li>力度曲线:蓄力 20% 恰好进盘面,20%~100% 线性到最大力,低力度也有用;蓄力条<strong>红区(&gt;85%)</strong>弹簧过压缩——初速随机损耗、横向抖动剧增,<strong>满力≠高分</strong>,甜区约 80%~90%。</li>
<li>雨刮拨片(可选):勾选后两根摆杆像汽车雨刮器一样刮来刮去;再勾「转动」改为绕轴连续旋转(两杆反向)。拨片是<strong>运动碰撞体</strong>,表面线速度 v=ω×r 参与冲量求解,物理上真实地「扇飞」弹珠;拨片带 4px 胶囊厚度,高速球也不会穿透。</li>
<li>静止判定:速度持续低于阈值且存在接触 → 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片)。</li>
</ul>
</div>
</div>
<script>
/* ==PHYSICS_CORE_START== */
// ------------------------------------------------------------
// 物理核心(纯函数区,不依赖 DOM / 渲染,供 node 单元测试提取)
// 单位:px、s。质量 ∝ r²(二维圆盘),转动惯量 I = 0.5·m·r²。
// ------------------------------------------------------------
var PhysicsCore = (function () {
'use strict';
function createBall(x, y, r) {
var m = r * r;
var I = 0.5 * m * r * r;
return {
x: x, y: y, r: r,
vx: 0, vy: 0,
angle: 0, omega: 0,
m: m, invM: 1 / m,
I: I, invI: 1 / I,
sleeping: false, sleepTimer: 0, contacts: 0
};
}
function closestOnSeg(px, py, s) {
var dx = s.x2 - s.x1, dy = s.y2 - s.y1;
var L2 = dx * dx + dy * dy;
var t = L2 > 1e-9 ? ((px - s.x1) * dx + (py - s.y1) * dy) / L2 : 0;
if (t < 0) t = 0; else if (t > 1) t = 1;
return { x: s.x1 + dx * t, y: s.y1 + dy * t };
}
// 接触法线约定:由「静态物」指向球;ball-ball 时由 a 指向 b
function contactBallStatic(b, s) {
var dx, dy, R, d2, d;
if (s.type === 'circle') {
dx = b.x - s.x; dy = b.y - s.y;
R = b.r + s.r;
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 };
}
var c = closestOnSeg(b.x, b.y, s);
dx = b.x - c.x; dy = b.y - c.y;
d2 = dx * dx + dy * dy;
// 胶囊厚度 thk:检测带宽 2*(r+thk)。零厚度线段会让高速球在一个子步内
// 跨过中线导致法线翻转、位置校正反向"助推"穿模;thk>0 使带宽大于最大
// 相对位移(maxV+拨片线速度)/360,数学上杜绝穿透
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 };
// 运动碰撞体(雨刮拨片):按接触点附表面线速度 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;
}
function contactBalls(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;
return { nx: dx / d, ny: dy / d, pen: R - d };
}
// 序贯冲量:法向(含恢复系数与静止阈值)+ 切向库仑摩擦(驱动角速度)
// 法线约定:ball-static 由静态物指向球(vn<0 为接近);ball-ball 由 a 指向 b(vn>0 为接近)
function solveContact(c, p) {
var a = c.a, b = c.b;
var nx = c.nx, ny = c.ny;
var tx = -ny, ty = nx;
var rvx = a.vx, rvy = a.vy;
if (b) { rvx -= b.vx; rvy -= b.vy; }
else { rvx -= (c.svx || 0); rvy -= (c.svy || 0); } // 对运动表面(拨片)取相对速度
var vn = rvx * nx + rvy * ny;
if (b) {
// ---- ball-ball:vn>0 接近 ----
if (vn > 0) {
var e1 = (vn > p.restV) ? p.e : 0;
var invM1 = a.invM + b.invM;
var jn1 = (1 + e1) * vn / invM1;
a.vx -= nx * jn1 * a.invM; a.vy -= ny * jn1 * a.invM;
b.vx += nx * jn1 * b.invM; b.vy += ny * jn1 * b.invM;
c.jn = (c.jn || 0) + jn1;
}
var jnAcc1 = c.jn || 0;
if (jnAcc1 <= 0) return;
var vt1 = rvx * tx + rvy * ty + a.omega * a.r - b.omega * b.r;
var Kt1 = a.invM + a.r * a.r * a.invI + b.invM + b.r * b.r * b.invI;
var jt1 = -vt1 / Kt1;
var maxF1 = p.mu * jnAcc1;
if (jt1 > maxF1) jt1 = maxF1; else if (jt1 < -maxF1) jt1 = -maxF1;
a.vx += tx * jt1 * a.invM; a.vy += ty * jt1 * a.invM;
b.vx -= tx * jt1 * b.invM; b.vy -= ty * jt1 * b.invM;
a.omega += a.r * jt1 * a.invI;
b.omega += b.r * jt1 * b.invI;
} else {
// ---- ball-static:vn<0 接近 ----
if (vn < 0) {
var e0 = (-vn > p.restV) ? p.e : 0;
var jn0 = -(1 + e0) * vn / a.invM;
a.vx += nx * jn0 * a.invM; a.vy += ny * jn0 * a.invM;
c.jn = (c.jn || 0) + jn0;
}
var jnAcc0 = c.jn || 0;
if (jnAcc0 <= 0) return;
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;
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:积分 -> 收集接触 -> 冲量迭代 -> 位置校正 -> 滚动阻力
function stepWorld(balls, statics, p, dt) {
var i, j, b, o, c;
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (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; }
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;
b.contacts = 0;
}
var contacts = [];
for (i = 0; i < balls.length; i++) {
b = balls[i];
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++; }
}
for (j = 0; j < balls.length; j++) {
o = balls[j];
if (o === b) continue;
if (o.sleeping) {
c = contactBallStatic(b, { type: 'circle', x: o.x, y: o.y, r: o.r });
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; }
} else if (j > i) {
c = contactBalls(b, o);
if (c) { c.a = b; c.b = o; c.jn = 0; contacts.push(c); b.contacts++; o.contacts++; }
}
}
}
for (var it = 0; it < p.iterations; it++) {
for (j = 0; j < contacts.length; j++) solveContact(contacts[j], p);
}
for (j = 0; j < contacts.length; j++) {
c = contacts[j];
var corr = Math.max(c.pen - p.slop, 0) * p.correction;
if (corr <= 0) continue;
if (c.b) {
var tm = c.a.invM + c.b.invM;
c.a.x -= c.nx * corr * (c.a.invM / tm);
c.a.y -= c.ny * corr * (c.a.invM / tm);
c.b.x += c.nx * corr * (c.b.invM / tm);
c.b.y += c.ny * corr * (c.b.invM / tm);
} else {
c.a.x += c.nx * corr;
c.a.y += c.ny * corr;
}
}
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (b.sleeping || b.contacts === 0) 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);
var ff = (sp - dec) / sp;
b.vx *= ff; b.vy *= ff;
}
b.omega *= Math.exp(-1.5 * dt);
}
return contacts;
}
return {
createBall: createBall,
closestOnSeg: closestOnSeg,
contactBallStatic: contactBallStatic,
contactBalls: contactBalls,
solveContact: solveContact,
stepWorld: stepWorld
};
})();
/* ==PHYSICS_CORE_END== */
/* ==TABLE_GEOM_START== */
// ------------------------------------------------------------
// 台面几何(纯数据 / 纯函数,不依赖 DOM,供集成测试提取)
// 逻辑坐标,单位 px,y 向下
// ------------------------------------------------------------
var W = 460, H = 800; // 画布逻辑尺寸(含框外拉杆把手)
var FRAME = 18; // 外框厚度
var L = FRAME, R = W - FRAME, T = FRAME, B = 760 - FRAME; // 内场边界 (18..442, 18..742)
var LANE_X = 398; // 发射道左壁 x
var BALL_R = 11;
// 弧由三点解出:左出口(60,340) / 弧顶(240,50) / 右端(442,220)
// 右端覆盖发射道顶部开口,球沿切线切入绕弧、从左侧落入钉阵;弱力则落回发射道
var ARCH = { cx: 242.294, cy: 252.286, r: 202.299, a0: -205.695, a1: -9.183 }; // 顶部弧形导轨(角度制)
var SLOT_TOP = 495, SLOT_BOT = 665, BAR_Y = 660;
// 右边界与发射道左壁(LANE_X)并齐:最右坑右壁即发射道壁,杜绝 20px 卡球缝
var SLOT_X0 = 18, SLOT_X1 = 398, SLOT_N = 6;
var SLOT_VALUES = [100, 50, 25, 25, 50, 100];
var SPRING_X = 420, SPRING_TOP = 706, SPRING_BOT = 742;
var TOTAL_BALLS = 8;
var G_PX = 9.81 * 2000; // 像素尺度:2000 px ≈ 1 m
function seg(x1, y1, x2, y2) { return { type: 'seg', x1: x1, y1: y1, x2: x2, y2: y2 }; }
// 生成整张台面的静态碰撞体与钉子渲染信息
function buildTableStatics(pegR, pegSpacing) {
var statics = [];
var pegs = [];
var i;
// 外框四壁
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));
// 弧形导轨(离散为线段链)
var steps = 40;
var a0 = ARCH.a0 * Math.PI / 180, a1 = ARCH.a1 * Math.PI / 180;
var px = 0, py = 0;
for (i = 0; i <= steps; i++) {
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));
px = x; py = y;
}
// 落球坑:5 根分隔木条(右边界复用发射道左壁,不另设右长条,避免与道壁重叠/留缝)
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));
// 底部横挡木条(球停在它上面)
statics.push(seg(SLOT_X0, BAR_Y, SLOT_X1, BAR_Y));
// 圆钉阵(交错,间距/尺寸可调)
var cx = 205, cy = 412;
var rows = [
{ y: 372, xs: [100, 170, 240, 310] },
{ y: 412, xs: [65, 135, 205, 275, 345] },
{ y: 452, xs: [100, 170, 240, 310] }
];
for (var rI = 0; rI < rows.length; rI++) {
var row = rows[rI];
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 });
pegs.push({ x: xx, y: yy, r: pegR, metal: ((rI + xI) % 3 === 2) });
}
}
return { statics: statics, pegs: pegs };
}
function slotIndexAt(x, y) {
if (x < SLOT_X0 || x > SLOT_X1 || y < SLOT_TOP) return -1;
var w = (SLOT_X1 - SLOT_X0) / SLOT_N;
var idx = Math.floor((x - SLOT_X0) / w);
return idx < 0 ? 0 : (idx >= SLOT_N ? SLOT_N - 1 : idx);
}
// ------------------------------------------------------------
// 雨刮拨片(可选组件,checkbox 开启):两根绕中心摆动的木杆,
// 像汽车雨刮器刮来刮去,球被扫到即改变轨迹,增加随机性。
// px,py=摆心;half=半长;A=摆幅(rad);T=周期(s);ph=相位
// ------------------------------------------------------------
var WIPERS = [
{ px: 140, py: 262, half: 75, A: 0.8, T: 1.5, ph: 0, spinK: 2.2 },
{ px: 298, py: 258, half: 45, A: 0.8, T: 1.15, ph: 1.7, spinK: -1.7 }
];
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 } });
}
return out;
}
// spin=false:雨刮摆动;spin=true:绕摆心连续转动(两杆反向)
function updateWiperSeg(sg, w, t, spin) {
var th, om;
if (spin) { th = w.spinK * t + w.ph; om = w.spinK; }
else {
var k = 2 * Math.PI / w.T;
th = w.A * Math.sin(k * t + w.ph);
om = w.A * k * Math.cos(k * t + w.ph);
}
var c = Math.cos(th), sn = Math.sin(th);
sg.x1 = w.px - c * w.half; sg.y1 = w.py - sn * w.half;
sg.x2 = w.px + c * w.half; sg.y2 = w.py + sn * w.half;
sg.mov.px = w.px; sg.mov.py = w.py; sg.mov.omega = om;
}
/* ==TABLE_GEOM_END== */
// ------------------------------------------------------------
// 游戏 / 渲染层(浏览器专用)
// ------------------------------------------------------------
(function () {
'use strict';
// ---------- 参数(面板实时读取) ----------
var params = {
g: 0, e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
var launchMax = 2900;
function refreshG() {
var tilt = Number(el('p-tilt').value) * Math.PI / 180;
params.g = G_PX * Math.sin(tilt);
}
// ---------- DOM ----------
function el(id) { return document.getElementById(id); }
var cv = el('cv'), ctx = cv.getContext('2d');
var hud = el('hud'), overlay = el('overlay');
// ---------- 状态 ----------
var statics = [];
var pegs = []; // 渲染用钉子信息
var balls = [];
var score = 0, launched = 0; // launched = 已入场(消耗机会)的球数
var spawned = 0; // spawned = 已生成的球数(上限 TOTAL_BALLS,防止在途球未计数时多生球)
var charging = false, charge = 0;
var springAnim = 0;
var roundOver = false;
var wipersOn = false, wiperSpin = false, wiperSegs = [], simT = 0;
// ---------- 台面碰撞体 ----------
function buildTable() {
var t = buildTableStatics(Number(el('p-pegr').value), Number(el('p-pegs').value));
statics = t.statics;
pegs = t.pegs;
wiperSegs = [];
if (wipersOn) {
wiperSegs = makeWiperSegs();
for (var i = 0; i < wiperSegs.length; i++) {
updateWiperSeg(wiperSegs[i], WIPERS[i], simT);
statics.push(wiperSegs[i]);
}
}
renderStaticLayer();
}
// ---------- 回合逻辑 ----------
function readyBall() {
for (var i = 0; i < balls.length; i++) if (balls[i].ready) return balls[i];
return null;
}
function spawnReady() {
// 用 spawned 而非 launched 做上限:在途未入场的球也算已占用名额,
// 否则第 8 球在途时 launched 仍为 7,会多生第 9 个球(剩余变 -1 的根因)
if (roundOver || readyBall() || spawned >= TOTAL_BALLS) return;
var b = PhysicsCore.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.ready = true;
b.sleeping = true; // 待发射球作为静态碰撞体(可被落回的球堆叠)
balls.push(b);
spawned++;
}
function settleBall(b) {
b.sleeping = true;
b.ready = false;
b.vx = 0; b.vy = 0; b.omega = 0;
// 掉回发射道 → 回到弹簧上待发射(未入场,不消耗机会,可重新蓄力)
if (b.x > LANE_X && b.y > 500) {
b.x = SPRING_X; b.y = SPRING_TOP - BALL_R;
b.ready = true; b.trail = [];
return;
}
var idx = slotIndexAt(b.x, b.y);
if (idx >= 0) {
score += SLOT_VALUES[idx];
b.slot = idx;
}
}
function launch() {
var b = readyBall();
if (!b || roundOver) return;
// 过压机理:charge>0.85(蓄力条红区)弹簧过压缩,
// 初速随机损耗、横向抖动剧增 → 满力≠高分,力度要拿捏(甜区约 0.8~0.9)
var over = Math.max(0, charge - 0.85) / 0.15; // 0..1 过压程度
// 力度曲线:0~20% 弱力区(到不了盘面落回,不消耗机会);
// 20% 恰好进盘面(ENTER_V);20%~100% 线性到最大力 —— 低力度也有用,手感区间拉满
var ENTER_V = 2400;
var v0 = charge <= 0.2
? 1000 + (charge / 0.2) * (ENTER_V - 1000)
: ENTER_V + ((charge - 0.2) / 0.8) * (launchMax - ENTER_V);
v0 *= 1 - 0.12 * over * over * Math.random(); // 过压随机损耗初速 0~12%
b.ready = false;
b.sleeping = false;
b.sleepTimer = 0;
b.vy = -v0;
var jitter = 12 + 25 * charge + 150 * over * over; // 横向抖动幅值 px/s
b.vx = (Math.random() - 0.5) * 2 * jitter;
b.trail = [];
// 注意:此处不计数。只有球完全离开发射道进入盘面才计一次机会(见主循环),
// 力度不足落回弹簧不消耗机会,可重新蓄力发射
springAnim = 1;
}
function resetRound() {
balls = [];
score = 0; launched = 0; spawned = 0;
charge = 0; charging = false;
roundOver = false;
overlay.classList.remove('show');
buildTable();
spawnReady();
}
function clearBalls() {
balls = [];
roundOver = false;
overlay.classList.remove('show');
launched = 0; spawned = 0; score = 0;
spawnReady();
}
// ---------- 主循环 ----------
var lastT = 0;
var SUB_DT = 1 / 360;
function frame(t) {
requestAnimationFrame(frame);
if (!lastT) lastT = t;
var dt = Math.min(0.05, (t - lastT) / 1000);
lastT = t;
refreshG();
params.e = Number(el('p-rest').value);
params.mu = Number(el('p-mu').value);
params.airK = Number(el('p-air').value);
params.rollR = Number(el('p-roll').value);
launchMax = Number(el('p-launch').value);
if (charging) charge = Math.min(1, charge + dt / 1.0);
if (springAnim > 0) springAnim = Math.max(0, springAnim - dt / 0.15);
var acc = dt;
while (acc > 1e-6) {
var h = Math.min(SUB_DT, acc);
// 拨片按子步(360Hz)更新端点与角速度:运动体位移逐子步解析,防跨帧跳变
simT += h;
if (wipersOn) for (var wi = 0; wi < wiperSegs.length; wi++) updateWiperSeg(wiperSegs[wi], WIPERS[wi], simT, wiperSpin);
PhysicsCore.stepWorld(activeBalls(), statics, params, h);
acc -= h;
}
// 残影记录:高速运动时采样轨迹点;慢速/静止时逐渐消退
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (!b.trail) b.trail = [];
if (!b.sleeping && !b.ready && (b.vx * b.vx + b.vy * b.vy) > 2500) {
b.trail.push({ x: b.x, y: b.y });
if (b.trail.length > 14) b.trail.shift();
} else if (b.trail.length) b.trail.shift();
}
// 静止判定 / 出界保护 / 入场计数
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 机会计数:球完全越出发射道左壁(进入盘面)才计一次;
// 力度不足落回发射道不计数,回到弹簧可重新蓄力
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 (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;
b.vx = 0; b.vy = 0; b.omega = 0;
b.ready = true; b.sleeping = true; b.trail = [];
}
}
if (!readyBall() && !roundOver) spawnReady();
if (!roundOver && launched >= TOTAL_BALLS) {
var anyActive = false;
for (i = 0; i < balls.length; i++) if (!balls[i].sleeping) { anyActive = true; break; }
if (!anyActive && !readyBall()) {
roundOver = true;
el('final-score').textContent = score;
overlay.classList.add('show');
}
}
draw();
updateHud();
}
function activeBalls() {
// 待发射球不参与积分(sleeping=true 已跳过),直接传全部即可
return balls;
}
// ---------- HUD ----------
function updateHud() {
var counts = [0, 0, 0, 0, 0, 0];
for (var i = 0; i < balls.length; i++) {
if (balls[i].sleeping && balls[i].slot !== undefined) counts[balls[i].slot]++;
}
hud.innerHTML =
'剩余弹珠 <b>' + (TOTAL_BALLS - launched) + '</b> / ' + TOTAL_BALLS +
' ・ 得分 <b>' + score + '</b>' +
' ・ 落坑 ' + counts.map(function (c, i) { return (i + 1) + ':' + c; }).join(' ');
}
// ---------- 渲染 ----------
var scale = 1, staticLayer = null;
function fit() {
var availH = Math.max(480, window.innerHeight - 140);
scale = Math.min(1.4, availH / H);
var dpr = window.devicePixelRatio || 1;
cv.width = Math.round(W * scale * dpr);
cv.height = Math.round(H * scale * dpr);
cv.style.width = Math.round(W * scale) + 'px';
cv.style.height = Math.round(H * scale) + 'px';
renderStaticLayer();
}
function mulberry32(seed) {
return function () {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
var t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function woodGrain(g, x, y, w, h, rnd, n, dark) {
g.strokeStyle = dark ? 'rgba(120,80,40,.18)' : 'rgba(255,240,210,.25)';
g.lineWidth = 1;
for (var i = 0; i < n; i++) {
var yy = y + rnd() * h;
g.beginPath();
g.moveTo(x, yy);
for (var xx = x; xx <= x + w; xx += 20) {
g.lineTo(xx, yy + Math.sin(xx * 0.05 + i) * 1.5 + (rnd() - 0.5) * 2);
}
g.stroke();
}
}
function renderStaticLayer() {
var dpr = window.devicePixelRatio || 1;
staticLayer = document.createElement('canvas');
staticLayer.width = Math.round(W * scale * dpr);
staticLayer.height = Math.round(H * scale * dpr);
var g = staticLayer.getContext('2d');
g.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
var rnd = mulberry32(20260807);
// 外框
var fg = g.createLinearGradient(0, 0, W, 0);
fg.addColorStop(0, '#b98d58'); fg.addColorStop(0.5, '#d3a86e'); fg.addColorStop(1, '#a97e4b');
g.fillStyle = fg;
roundRect(g, 0, 0, W, 760, 10); g.fill();
woodGrain(g, 0, 0, W, 20, rnd, 14, true);
woodGrain(g, 0, 740, W, 20, rnd, 14, true);
woodGrain(g, 0, 0, 20, 760, rnd, 14, true);
woodGrain(g, W - 20, 0, 20, 760, rnd, 14, true);
// 内场底板
var bg = g.createLinearGradient(0, 0, 0, 760);
bg.addColorStop(0, '#e9cf9f'); bg.addColorStop(1, '#dfc08a');
g.fillStyle = bg;
g.fillRect(L, T, R - L, B - T);
woodGrain(g, L, T, R - L, B - T, rnd, 40, false);
// 弧形导轨
g.strokeStyle = '#8a6238'; g.lineWidth = 11; g.lineCap = 'round';
g.beginPath();
g.arc(ARCH.cx, ARCH.cy, ARCH.r, ARCH.a0 * Math.PI / 180, ARCH.a1 * Math.PI / 180);
g.stroke();
g.strokeStyle = '#c89a62'; g.lineWidth = 7;
g.beginPath();
g.arc(ARCH.cx, ARCH.cy, ARCH.r, ARCH.a0 * Math.PI / 180, ARCH.a1 * Math.PI / 180);
g.stroke();
// 发射道左壁(与物理一致,通到底壁)
woodBar(g, LANE_X - 3, 190, 6, B - 190);
// 落球坑木条(右边界即发射道左壁,已随道壁绘制)
var slotW = (SLOT_X1 - SLOT_X0) / SLOT_N;
for (var i = 1; i < SLOT_N; i++) woodBar(g, SLOT_X0 + slotW * i - 3, SLOT_TOP, 6, SLOT_BOT - SLOT_TOP);
// 底部横挡 + 分值
woodBar(g, SLOT_X0, BAR_Y, SLOT_X1 - SLOT_X0, 10);
g.fillStyle = '#6b4a26';
g.font = 'bold 11px Consolas, monospace';
g.textAlign = 'center';
var w = (SLOT_X1 - SLOT_X0) / SLOT_N;
for (i = 0; i < SLOT_N; i++) {
g.fillText(String(SLOT_VALUES[i]), SLOT_X0 + w * (i + 0.5), BAR_Y + 8.5);
}
// 左下楔形斜坡(装饰,同实物)
g.fillStyle = '#cfa76e';
g.beginPath();
g.moveTo(L, B); g.lineTo(L, 696); g.lineTo(SLOT_X1, B); g.closePath();
g.fill();
g.strokeStyle = '#8a6238'; g.lineWidth = 2; g.stroke();
woodGrain(g, L, 700, SLOT_X1 - L, 42, rnd, 8, true);
// 圆钉
for (i = 0; i < pegs.length; i++) {
var p = pegs[i];
if (p.metal) {
var mg = g.createRadialGradient(p.x - 1, p.y - 1, 1, p.x, p.y, p.r);
mg.addColorStop(0, '#e8e8e8'); mg.addColorStop(1, '#8a8a8a');
g.fillStyle = mg;
} else {
g.fillStyle = '#a97e4b';
}
g.beginPath(); g.arc(p.x, p.y, p.r, 0, Math.PI * 2); g.fill();
g.strokeStyle = 'rgba(60,35,10,.5)'; g.lineWidth = 1; g.stroke();
if (p.metal) {
g.strokeStyle = '#555'; g.beginPath();
g.moveTo(p.x - p.r * 0.5, p.y); g.lineTo(p.x + p.r * 0.5, p.y); g.stroke();
}
}
// 弹簧底板
g.fillStyle = '#8a6238';
g.fillRect(SPRING_X - 16, SPRING_BOT - 4, 32, 6);
// 注意:拨片是动态件,必须在 draw() 每帧绘制,不能烘进静态层
}
function roundRect(g, x, y, w, h, r) {
g.beginPath();
g.moveTo(x + r, y);
g.arcTo(x + w, y, x + w, y + h, r);
g.arcTo(x + w, y + h, x, y + h, r);
g.arcTo(x, y + h, x, y, r);
g.arcTo(x, y, x + w, y, r);
g.closePath();
}
function woodBar(g, x, y, w, h) {
g.fillStyle = '#c89a62';
g.fillRect(x, y, w, h);
g.strokeStyle = '#8a6238';
g.lineWidth = 1.5;
g.strokeRect(x, y, w, h);
}
function draw() {
var dpr = window.devicePixelRatio || 1;
ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.drawImage(staticLayer, 0, 0, W, H);
// 雨刮拨片(动态件,每帧按当前摆角/转角绘制,画在球之下)
for (var wi2 = 0; wi2 < wiperSegs.length; wi2++) {
var ws = wiperSegs[wi2];
ctx.strokeStyle = '#7a5230'; ctx.lineWidth = 9; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(ws.x1, ws.y1); ctx.lineTo(ws.x2, ws.y2); ctx.stroke();
ctx.strokeStyle = '#c89a62'; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(ws.x1, ws.y1); ctx.lineTo(ws.x2, ws.y2); ctx.stroke();
ctx.fillStyle = '#5a5a5a';
ctx.beginPath(); ctx.arc(ws.mov.px, ws.mov.py, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#2e2e2e';
ctx.beginPath(); ctx.arc(ws.mov.px, ws.mov.py, 1.8, 0, Math.PI * 2); ctx.fill();
}
// 弹簧 + 压板(蓄力下压 / 释放回弹)
var comp = charging ? charge : springAnim * 0.4;
var headY = SPRING_TOP + comp * 34;
ctx.strokeStyle = '#666';
ctx.lineWidth = 2.5;
ctx.beginPath();
var coils = 6;
for (var i = 0; i <= coils; i++) {
var yy = headY + (SPRING_BOT - headY) * (i / coils);
var xx = SPRING_X + ((i % 2 === 0) ? -9 : 9);
if (i === 0 || i === coils) xx = SPRING_X;
if (i === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.stroke();
ctx.fillStyle = '#8a6238';
ctx.fillRect(SPRING_X - 14, headY - 4, 28, 5);
// 框外拉杆把手
ctx.fillStyle = '#a97e4b';
ctx.fillRect(SPRING_X - 5, 760, 10, 20);
var kg = ctx.createRadialGradient(SPRING_X - 3, 788, 2, SPRING_X, 791, 11);
kg.addColorStop(0, '#d3a86e'); kg.addColorStop(1, '#8a6238');
ctx.fillStyle = kg;
ctx.beginPath(); ctx.arc(SPRING_X, 791, 10, 0, Math.PI * 2); ctx.fill();
// 蓄力条
if (charging || charge > 0) {
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.fillRect(R - 12, 620, 8, 100);
ctx.fillStyle = charge > 0.85 ? '#e05c5c' : '#e0a35c';
var hh = 100 * (charging ? charge : 0);
ctx.fillRect(R - 12, 720 - hh, 8, hh);
}
// 尾部残影(越旧越透明越小,画在球体之下)
for (i = 0; i < balls.length; i++) {
var tb = balls[i];
if (!tb.trail || tb.trail.length < 2) continue;
for (var k = 0; k < tb.trail.length; k++) {
var f = (k + 1) / tb.trail.length;
ctx.fillStyle = 'rgba(140,230,170,' + (0.26 * f * f).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(tb.trail[k].x, tb.trail[k].y, tb.r * (0.3 + 0.6 * f), 0, Math.PI * 2);
ctx.fill();
}
}
// 弹珠(绿色玻璃,纹理随角速度旋转)
for (i = 0; i < balls.length; i++) {
var b = balls[i];
var by = b.y;
if (b.ready) by = SPRING_TOP - BALL_R + comp * 34;
var grd = ctx.createRadialGradient(b.x - 4, by - 4, 2, b.x, by, b.r);
grd.addColorStop(0, '#b8f0c0');
grd.addColorStop(0.35, '#3faf5f');
grd.addColorStop(1, '#0d5c2a');
ctx.fillStyle = grd;
ctx.beginPath(); ctx.arc(b.x, by, b.r, 0, Math.PI * 2); ctx.fill();
// 内部旋纹
ctx.save();
ctx.translate(b.x, by);
ctx.rotate(b.angle);
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, b.r * 0.55, 0.3, 2.6); ctx.stroke();
ctx.beginPath(); ctx.arc(0, 0, b.r * 0.3, 3.4, 5.6); ctx.stroke();
ctx.restore();
// 高光
ctx.fillStyle = 'rgba(255,255,255,.75)';
ctx.beginPath(); ctx.arc(b.x - b.r * 0.35, by - b.r * 0.4, b.r * 0.22, 0, Math.PI * 2); ctx.fill();
}
}
// ---------- 交互 ----------
function pressStart(e) {
if (roundOver) return;
if (!readyBall()) return;
charging = true;
charge = 0;
e.preventDefault();
}
function pressEnd() {
if (!charging) return;
charging = false;
launch();
charge = 0;
}
cv.addEventListener('pointerdown', pressStart);
window.addEventListener('pointerup', pressEnd);
window.addEventListener('pointercancel', pressEnd);
window.addEventListener('keydown', function (e) {
if (e.code === 'Space' && !e.repeat) pressStart(e);
});
window.addEventListener('keyup', function (e) {
if (e.code === 'Space') pressEnd();
});
el('btn-reset').addEventListener('click', resetRound);
el('btn-clear').addEventListener('click', clearBalls);
el('btn-again').addEventListener('click', resetRound);
el('p-pegr').addEventListener('input', function () { el('v-pegr').textContent = el('p-pegr').value + 'px'; resetRound(); });
el('p-pegs').addEventListener('input', function () { el('v-pegs').textContent = 'x' + el('p-pegs').value; resetRound(); });
el('p-wipers').addEventListener('change', function () {
wipersOn = this.checked;
if (!wipersOn && wiperSpin) { // 拨片关 → 转动一并清零
wiperSpin = false;
el('p-wiperspin').checked = false;
el('v-wiperspin').textContent = '关';
}
el('v-wipers').textContent = wipersOn ? '开' : '关';
buildTable(); // 仅增删拨片碰撞体,不重开局
});
el('p-wiperspin').addEventListener('change', function () {
wiperSpin = this.checked;
el('v-wiperspin').textContent = wiperSpin ? '开' : '关';
if (wiperSpin && !wipersOn) { // 勾转动自动启用拨片
wipersOn = true;
el('p-wipers').checked = true;
el('v-wipers').textContent = '开';
buildTable();
}
});
function syncLabels() {
el('v-tilt').textContent = el('p-tilt').value + '°';
el('v-rest').textContent = 'e=' + Number(el('p-rest').value).toFixed(2);
el('v-mu').textContent = 'μ=' + Number(el('p-mu').value).toFixed(2);
el('v-air').textContent = el('p-air').value;
el('v-roll').textContent = el('p-roll').value;
el('v-launch').textContent = el('p-launch').value;
el('v-pegr').textContent = el('p-pegr').value + 'px';
el('v-pegs').textContent = 'x' + el('p-pegs').value;
}
['p-tilt', 'p-rest', 'p-mu', 'p-air', 'p-roll', 'p-launch'].forEach(function (id) {
el(id).addEventListener('input', syncLabels);
});
window.addEventListener('resize', fit);
// ---------- 启动 ----------
syncLabels();
fit();
resetRound();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>