feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
@@ -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>红区(>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>
|
||||
Reference in New Issue
Block a user