Files
Toolbox/public/tools/thought_lab/labs/retro_pinball/index.html
T
yangxiangyuan 91148fe064 feat: 新增物理弹球实验室及配套功能
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI
2. 新增物理弹球相关调试与单元测试脚本
3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示
4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
2026-08-08 22:31:33 +08:00

948 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ============================================================
思想实验室 · 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>光滑面:圆钉 / 拨片 / 落坑隔板 / 发射道壁 / 弧形导轨摩擦恒为 0,且带防粘附微推力——任何光滑面都<strong>不能存放球</strong>(包括隔板顶、钉顶),球必须滑落。</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>静止判定:速度持续低于阈值且存在<strong>非光滑</strong>接触(坑底横挡等)→ 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片);只接触光滑面的球永不休眠,必然落下。</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, slipContacts: 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, slip: !!s.slip };
}
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, 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;
}
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;
// 光滑面(钉/拨片/隔板等,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;
}
}
// 防粘附推力:光滑面上「正上方平衡」是不稳定平衡,离散求解器可能维持完美对称
// 导致球视觉冻结在钉顶/隔板顶。对法线朝上的光滑接触施加微小水平加速度,必滑落。
var STICK_K = 240;
// 推进一步 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;
b.slipContacts = 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++; if (c.slip) b.slipContacts++; }
}
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 (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);
}
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 || 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);
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));
// 发射道左壁(顶端接弧形导轨右端,底端接底壁封死,杜绝壁底缺口侧逃)
// 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;
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) { 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++) {
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));
// 圆钉阵(交错,间距/尺寸可调)
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;
// 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) });
}
}
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 注释)
// 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;
}
// 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.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;
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>