feat: 新增物理弹球实验室及配套功能

1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI
2. 新增物理弹球相关调试与单元测试脚本
3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示
4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
yangxiangyuan
2026-08-08 22:31:33 +08:00
parent 4492b4106a
commit 91148fe064
17 changed files with 2690 additions and 22 deletions
+37 -7
View File
@@ -20,12 +20,32 @@ const state = {
}
}
const isQuickEntryMode = () => state.entryMode === 'quick'
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024
const normalizeError = (error) => {
const text = String(error && error.message ? error.message : error || '').trim()
if (!text) return 'unknown_error'
return text
}
const describeUploadError = (error, file) => {
const code = normalizeError(error)
const name = String(file && file.name || '').trim()
const prefix = name ? `文件“${name}”` : '文件'
if (code === 'unauthorized') return '上传失败:登录状态已失效,请重新登录'
if (code === 'empty_file') return `${prefix}为空,未能上传`
if (code === 'file_too_large' || code === 'upload_failed_413' || code === 'unexpected_response_413') {
return `${prefix}过大,当前上限为 200MB`
}
if (code === 'upload_network_error') return '上传失败:网络中断或服务器无响应'
if (code === 'upload_aborted') return '上传已中断,请重新上传'
if (code === 'upload_failed_400') return '上传失败:请求无效,请重新选择文件后再试'
if (code === 'upload_failed_401') return '上传失败:当前未登录或登录已过期'
if (code === 'upload_failed_403') return '上传失败:当前没有权限执行此操作'
if (code === 'upload_failed_404') return '上传失败:上传接口不存在或访问地址失效'
if (code === 'upload_failed_415') return '上传失败:文件类型或请求格式不被服务器接受'
if (code === 'upload_failed_500' || code === 'upload_failed') return '上传失败:服务器处理文件时出错'
return `上传失败:${code}`
}
const folderDisplayName = (code) => {
const folder = state.folders.find(item => item.code === code)
return folder ? folder.name : '未命名目录'
@@ -400,9 +420,14 @@ const uploadFiles = async (files, { uploadingText = '正在上传...', successMe
try {
let done = 0
for (const file of list) {
await uploadFile(file, groupId, (loaded, totalBytes) => {
setProgress(true, done, list.length, { loaded, total: totalBytes })
})
try {
await uploadFile(file, groupId, (loaded, totalBytes) => {
setProgress(true, done, list.length, { loaded, total: totalBytes })
})
} catch (error) {
if (error && typeof error === 'object' && !error.file) error.file = file
throw error
}
done += 1
setProgress(true, done, list.length)
}
@@ -755,6 +780,10 @@ const resolveFileName = (file) => {
// 使用 XHR 上传,利用 upload.onprogress 获取字节级进度(fetch 不支持上传进度事件)
const uploadFile = (file, groupId, onProgress) => new Promise((resolve, reject) => {
if (!file) { resolve(); return }
if (Number(file.size || 0) > MAX_UPLOAD_BYTES) {
reject(new Error('file_too_large'))
return
}
const name = resolveFileName(file)
const groupPart = Number.isFinite(groupId) ? `&group_id=${encodeURIComponent(String(groupId))}` : ''
const url = `${apiBase}/upload?filename=${encodeURIComponent(name)}&mime=${encodeURIComponent(file.type || 'application/octet-stream')}${groupPart}`
@@ -851,7 +880,8 @@ const handlePaste = async (event) => {
if (stringSaveError) throw stringSaveError
await refreshCurrentView({ successMessage: '已保存到服务器' })
} catch (e) {
setStatus('保存失败', 'error')
const files = items.filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean)
setStatus(files.length ? describeUploadError(e, e && e.file ? e.file : files[0]) : `保存失败:${normalizeError(e)}`, 'error')
} finally {
setProgress(false)
}
@@ -867,7 +897,7 @@ const handleDrop = async (event) => {
try {
await uploadFiles(files)
} catch (e) {
setStatus('上传失败', 'error')
setStatus(describeUploadError(e, e && e.file ? e.file : files[0]), 'error')
}
}
@@ -980,7 +1010,7 @@ const bindDesktopClassicInputs = () => {
await uploadFiles(files, { uploadingText })
input.value = ''
} catch (error) {
setStatus('上传失败', 'error')
setStatus(describeUploadError(error, error && error.file ? error.file : files[0]), 'error')
}
})
}
@@ -1020,7 +1050,7 @@ const bindMobileInputs = () => {
await uploadFiles(files)
inputEl.value = ''
} catch (e) {
setStatus('上传失败', 'error')
setStatus(describeUploadError(e, e && e.file ? e.file : files[0]), 'error')
}
}
}
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>物理弹球 · 思想实验室</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; background: #000; overflow: hidden; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
#pb-wrap { position: relative; height: 100%; max-width: 100vw; aspect-ratio: 420 / 880; margin: 0 auto; touch-action: none; user-select: none; }
@media (min-aspect-ratio: 420/880) { #pb-wrap { height: 100%; } }
#pb-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.hidden { display: none !important; }
button { font-family: inherit; cursor: pointer; border: none; }
/* ---------- HUD ---------- */
#pb-hud { position: absolute; top: 0; left: 0; right: 0; padding: 10px 12px; display: flex; align-items: flex-start; pointer-events: none; }
#pb-pause-btn { pointer-events: auto; background: none; color: #ddd; font-size: 20px; width: 34px; height: 34px; border-radius: 50%; border: 2px solid #888; line-height: 1; }
#pb-time { margin-left: 10px; margin-top: 6px; color: #999; font-size: 13px; }
#pb-balls { margin-left: auto; color: #c89458; font-size: 14px; margin-top: 8px; }
#pb-big { color: #ffd83d; font-size: 12px; border: 1px solid #ffd83d; border-radius: 10px; padding: 2px 8px; margin-left: 10px; margin-top: 6px; }
#pb-score { margin-left: 12px; color: #fff; font-size: 26px; font-weight: 600; }
#pb-score.pop { animation: pb-pop 0.18s ease; }
@keyframes pb-pop { 0% { transform: scale(1.3); color: #ffd83d; } 100% { transform: scale(1); } }
/* ---------- 主菜单(覆盖层不挡拖拽,仅按钮可点) ---------- */
#pb-menu { position: absolute; inset: 0; pointer-events: none; }
#pb-menu button, #pb-daily { pointer-events: auto; }
#pb-gear { position: absolute; top: 10px; left: 10px; width: 34px; height: 34px; border-radius: 50%; background: #f7c81a; color: #4a3600; font-size: 18px; }
#pb-help { position: absolute; top: 10px; right: 10px; width: 34px; height: 34px; border-radius: 50%; background: #333; color: #ddd; font-size: 17px; font-weight: 700; }
#pb-best { position: absolute; top: 52px; right: 14px; color: #f7c81a; font-size: 15px; }
#pb-logo { position: absolute; top: 16%; left: 50%; transform: translateX(-50%); }
#pb-title { position: absolute; top: calc(16% + 130px); left: 0; right: 0; text-align: center; color: #fff; font-size: 22px; letter-spacing: 14px; text-indent: 14px; }
#pb-side-balls { position: absolute; left: 0; top: 52%; background: #ff2f7b; color: #fff; border-radius: 0 22px 22px 0; padding: 8px 16px 8px 10px; font-size: 14px; }
#pb-side-records { position: absolute; left: 0; top: calc(52% + 52px); background: #7b2fff; color: #fff; border-radius: 0 22px 22px 0; padding: 8px 16px 8px 10px; font-size: 14px; }
#pb-daily { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 86px; height: 86px; border-radius: 50%; overflow: hidden; background: linear-gradient(#fff 0 50%, #f7c81a 50% 100%); text-align: center; font-size: 15px; font-weight: 700; color: #222; line-height: 1.5; padding-top: 14px; box-shadow: 0 0 0 3px rgba(255,255,255,0.15); }
#pb-hint-text { position: absolute; bottom: 7%; left: 0; right: 0; text-align: center; color: #ddd; font-size: 15px; }
#pb-hint-line { position: absolute; bottom: 12%; left: 18%; right: 18%; height: 2px; background: #fff; }
/* ---------- 覆盖层通用 ---------- */
.overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.88); display: flex; flex-direction: column; align-items: center; z-index: 5; }
.ov-btn-blue { background: #1478e6; color: #fff; border-radius: 26px; padding: 13px 44px; font-size: 16px; margin-top: 14px; display: flex; align-items: center; gap: 10px; }
.ov-btn-pink { background: #ff2f7b; color: #fff; border-radius: 26px; padding: 13px 40px; font-size: 16px; margin-top: 30px; }
/* ---------- 暂停 ---------- */
#pb-pause h2 { color: #fff; font-size: 26px; letter-spacing: 12px; margin-top: 24%; }
/* ---------- 结束 ---------- */
#pb-over h2 { color: #fff; font-size: 24px; margin-top: 22%; }
#pb-over-score { color: #f7c81a; font-size: 52px; font-weight: 700; margin-top: 12px; }
#pb-over-time { color: #999; margin-top: 6px; }
#pb-over-new { color: #ff2f7b; font-weight: 700; margin-top: 8px; }
#pb-over-best { color: #f7c81a; margin-top: 4px; font-size: 14px; }
/* ---------- 记录 ---------- */
#pb-records { padding-top: 8%; }
#pb-records h2 { color: #fff; font-size: 20px; margin-bottom: 14px; }
#pb-records-list { width: 100%; flex: 1; overflow-y: auto; }
.pb-rec-row { display: flex; align-items: center; padding: 12px 16px; background: #383838; }
.pb-rec-row.alt { background: #262626; }
.pb-rec-rank { width: 40px; font-size: 18px; font-weight: 700; }
.pb-rec-date { font-size: 13px; flex: 1; }
.pb-rec-right { text-align: right; }
.pb-rec-right b { display: block; font-size: 20px; }
.pb-rec-right i { font-style: normal; font-size: 12px; }
.pb-rec-empty { color: #888; text-align: center; margin-top: 40px; }
#pb-records-close { margin: 12px 0 20px; }
/* ---------- 指南 ---------- */
#pb-guide { padding-top: 16%; }
#pb-guide h2 { color: #fff; font-size: 22px; margin-bottom: 18px; }
#pb-guide-demo { background: #000; }
#pb-guide p { color: #eee; font-size: 14px; margin-top: 14px; }
#pb-guide-ok { margin-top: 26px; }
/* ---------- 设置 ---------- */
#pb-settings { padding-top: 30%; }
#pb-settings h2 { color: #fff; font-size: 20px; margin-bottom: 24px; }
.pb-set-row { color: #ddd; font-size: 16px; display: flex; align-items: center; gap: 12px; }
.pb-set-row input[type="checkbox"] { width: 20px; height: 20px; }
.pb-set-slider { margin-top: 22px; }
.pb-set-slider input[type="range"] { width: 170px; accent-color: #f7c81a; }
.pb-set-slider b { color: #f7c81a; min-width: 30px; text-align: right; }
.pb-set-hint { color: #888; font-size: 12px; margin-top: 10px; }
/* ---------- toast ---------- */
#pb-toast { position: absolute; left: 50%; bottom: 16%; transform: translateX(-50%); background: rgba(40,40,40,0.95); color: #eee; font-size: 13px; padding: 9px 18px; border-radius: 20px; opacity: 0; transition: opacity 0.25s; pointer-events: none; z-index: 9; white-space: nowrap; }
#pb-toast.show { opacity: 1; }
</style>
</head>
<body>
<div id="pb-wrap">
<canvas id="pb-canvas" width="420" height="880"></canvas>
<div id="pb-hud" class="hidden">
<button id="pb-pause-btn" title="暂停">❚</button>
<div id="pb-time">时间 00:00</div>
<div id="pb-balls">🥎 0</div>
<div id="pb-big" class="hidden">大球 ×2</div>
<div id="pb-score">0</div>
</div>
<div id="pb-menu">
<button id="pb-gear" title="设置">⚙</button>
<button id="pb-help" title="游戏指南">?</button>
<div id="pb-best">最佳: 0</div>
<canvas id="pb-logo" width="220" height="120"></canvas>
<div id="pb-title">物理弹球</div>
<button id="pb-side-balls">🥎 球 <b id="pb-ball-count">1</b></button>
<button id="pb-side-records">📋 记录</button>
<button id="pb-daily">今天<br>+3 🥎</button>
<div id="pb-hint-line"></div>
<div id="pb-hint-text">拖动瞄准</div>
</div>
<div id="pb-pause" class="overlay hidden">
<h2>暂 停</h2>
<button id="pb-share" class="ov-btn-blue">📋 复制成绩</button>
<button id="pb-restart" class="ov-btn-blue">⟳ 重新开始</button>
<button id="pb-resume" class="ov-btn-pink">▶ 继续游戏</button>
</div>
<div id="pb-over" class="overlay hidden">
<h2>本局结束</h2>
<div id="pb-over-score">0</div>
<div id="pb-over-time">用时 00:00</div>
<div id="pb-over-new">★ 新纪录 ★</div>
<div id="pb-over-best">最佳: 0</div>
<button id="pb-again" class="ov-btn-pink">再来一局</button>
<button id="pb-to-menu" class="ov-btn-blue">返回主菜单</button>
</div>
<div id="pb-records" class="overlay hidden">
<h2>记录</h2>
<div id="pb-records-list"></div>
<button id="pb-records-close" class="ov-btn-blue">返回</button>
</div>
<div id="pb-guide" class="overlay hidden">
<h2>游戏指南</h2>
<canvas id="pb-guide-demo" width="220" height="160"></canvas>
<p>1. 水平移动手指,瞄准数字块。</p>
<p>2. 击中数字块得分(小球+1,大球+2)。</p>
<p>3. 数字块到达顶端,游戏结束。</p>
<p>4. 留意出现的道具助你一臂之力。</p>
<p>5. 球陷入夹角时会全力研磨接触的数字块,快速开出逃逸空间。</p>
<button id="pb-guide-ok" class="ov-btn-blue">知道了!</button>
</div>
<div id="pb-settings" class="overlay hidden">
<h2>设置</h2>
<label class="pb-set-row"><input type="checkbox" id="pb-sound-toggle" checked> 音效</label>
<div class="pb-set-row pb-set-slider">
<span>逃逸强度</span>
<input type="range" id="pb-escape-slider" min="0" max="100" step="5">
<b id="pb-escape-val">50</b>
</div>
<div class="pb-set-hint">越高:卡住的球挣脱越猛,越简单</div>
<button id="pb-settings-close" class="ov-btn-blue">返回</button>
</div>
<div id="pb-toast"></div>
</div>
<script src="pb_physics.js"></script>
<script src="pb_rules.js"></script>
<script src="pb_storage.js"></script>
<script src="pb_audio.js"></script>
<script src="pb_render.js"></script>
<script src="pb_game.js"></script>
<script src="pb_ui.js"></script>
</body>
</html>
@@ -0,0 +1,59 @@
/* ============================================================
* pb_audio.js · 物理弹球 Web 复刻版 · WebAudio 合成音效
* 无外部资源:发射/击中/碎裂/道具/结束 均为振荡器合成。
* 设置页可开关(PBStorage.soundOn)。
* ============================================================ */
var PBAudio = (function () {
'use strict';
var ctx = null;
var lastHit = 0, lastWall = 0; // 分开节流,防砖块命中音被墙壁音吃掉
function ac() {
if (!ctx) {
try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { ctx = null; }
}
if (ctx && ctx.state === 'suspended') { try { ctx.resume(); } catch (e) {} }
return ctx;
}
function enabled() {
try { return PBStorage.soundOn(); } catch (e) { return true; }
}
function tone(freq, dur, type, gain, slideTo) {
var c = ac();
if (!c || !enabled()) return;
try {
var o = c.createOscillator();
var g = c.createGain();
o.type = type || 'sine';
o.frequency.setValueAtTime(freq, c.currentTime);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, c.currentTime + dur);
g.gain.setValueAtTime(gain || 0.08, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
o.connect(g); g.connect(c.destination);
o.start(); o.stop(c.currentTime + dur + 0.02);
} catch (e) {}
}
return {
unlock: function () { ac(); },
fire: function () { tone(520, 0.12, 'triangle', 0.06, 260); },
hit: function (speed) {
var now = Date.now();
if (now - lastHit < 45) return; // 限流防炸音
lastHit = now;
tone(300 + Math.min(500, speed * 0.15), 0.06, 'square', 0.035);
},
wall: function () {
var now = Date.now();
if (now - lastWall < 45) return;
lastWall = now;
tone(180, 0.05, 'sine', 0.03);
},
breakBlock: function () { tone(760, 0.16, 'sawtooth', 0.06, 180); },
item: function () { tone(660, 0.1, 'sine', 0.07, 990); tone(990, 0.16, 'sine', 0.06, 1320); },
over: function () { tone(392, 0.25, 'triangle', 0.08, 196); },
claim: function () { tone(523, 0.09, 'sine', 0.07); tone(659, 0.09, 'sine', 0.07); tone(784, 0.16, 'sine', 0.07); }
};
})();
@@ -0,0 +1,430 @@
/* ============================================================
* pb_game.js · 物理弹球 Web 复刻版 · 游戏状态机
* 回合流程:aim(拖拽瞄准) -> firing(连发) -> fly(等全部回收)
* -> rise(砖块上升+生成新行) -> aim ... 砖块越顶线 = over
* 球回收:滚入底部两角 -> 侧边输送带沿壁推回顶部 -> 回到待发队列
* 计分:小球击中 +1 / 大球击中 +2(同时为砖块伤害)
* ============================================================ */
var PBGame = (function () {
'use strict';
var R = PBRules, P = PBPhysics, F = R.FIELD;
var RISE_PX = 52; // 每回合上升高度
var RISE_TIME = 0.5; // 上升动画时长
var ROW_Y = 792; // 新行生成中心 y
var RECYCLE_V = 950; // 输送带上升速度
var CORNER_X = 34; // 角落回收判定宽
var CORNER_Y = 770; // 角落回收判定 y
function create() {
var g = {
state: 'idle', // idle|aim|firing|fly|rise|over
turn: 1,
score: 0,
secs: 0,
paused: false,
bigTurn: false, // 大球生效中:此后入队的球为大球 ⚽️(伤害2)
queue: [], // 待发队列:每元素为大小标记(false=小球 true=大球⚽️)
queueBonus: 0,
balls: [], // 场内飞行球 body
recycle: [], // {x,y,side,phase}
blocks: [], // body
items: [], // body
walls: [],
particles: [],
pairs: {},
riseT: 0,
fireTimer: 0,
aimAngle: 0, // 相对垂直方向(rad,左负右正)
aiming: false,
overInfo: null,
volley: 0, // 本回合待发快照数(防回收回队造成无限连发)
time: 0
};
buildWalls(g);
return g;
}
// ---- 静态边界:左右壁 + 顶壁 + 漏斗斜壁 + 底部大圆弧 ----
function buildWalls(g) {
var seg = function (x1, y1, x2, y2) {
// 细长按矩形刚体表示,保证多边形碰撞稳定
var dx = x2 - x1, dy = y2 - y1;
var len = Math.sqrt(dx * dx + dy * dy);
var b = P.makePoly({
shape: 'poly',
verts: P.boxVerts(len, 8),
x: (x1 + x2) / 2, y: (y1 + y2) / 2,
angle: Math.atan2(dy, dx),
isStatic: true, friction: 0.1, restitution: 0.3, tag: 'wall'
});
return b;
};
g.walls.push(seg(F.wallL - 4, F.topY, F.wallL - 4, F.H + 40)); // 左壁
g.walls.push(seg(F.wallR + 4, F.topY, F.wallR + 4, F.H + 40)); // 右壁
g.walls.push(seg(F.wallL, F.topY - 4, F.wallR, F.topY - 4)); // 顶壁
var fn = R.funnelSegs();
g.walls.push(seg(fn[0].x1, fn[0].y1, fn[0].x2, fn[0].y2)); // 漏斗左
g.walls.push(seg(fn[1].x1, fn[1].y1, fn[1].x2, fn[1].y2)); // 漏斗右
// 底部大圆弧(静态圆)
g.walls.push(P.makeCircle({ shape: 'circle', x: F.arcCx, y: F.arcCy, r: F.arcR, isStatic: true, friction: 0.15, restitution: 0.25, tag: 'wall' }));
}
// ---- 砖块 / 道具 刚体 ----
function makeBlock(desc) {
var r = R.shapeRadius(desc.shape);
var body;
if (desc.shape === 'circle') {
body = P.makeCircle({ shape: 'circle', x: desc.x, y: desc.y, r: r, isStatic: true, friction: 0.35, restitution: 0.95, tag: 'block' });
} else {
var n = desc.shape === 'box' ? 4 : (desc.shape === 'tri' ? 3 : 5);
var verts = n === 4 ? P.boxVerts(r * 1.7, r * 1.7) : P.regularVerts(n, r, 0);
body = P.makePoly({ shape: 'poly', verts: verts, x: desc.x, y: desc.y, angle: desc.angle || 0, isStatic: true, friction: 0.35, restitution: 0.95, tag: 'block' });
}
body.data = { hp: desc.hp, shape: desc.shape, r: r };
return body;
}
function makeItem(desc) {
// 静态刚体:奖励球固定在卡片中间,不随重力滚落
var body = P.makeCircle({ shape: 'circle', x: desc.x, y: desc.y, r: R.ITEMS[desc.item].r, isStatic: true, friction: 0.3, restitution: 0.2, tag: 'item' });
body.data = { type: desc.item };
return body;
}
function spawnRow(g, n, y) {
// 已有块作为 avoid,保证新行与旧行严格无重叠
var avoid = [];
var i;
for (i = 0; i < g.blocks.length; i++) avoid.push({ x: g.blocks[i].x, y: g.blocks[i].y, cr: R.boundRadius(g.blocks[i].data.shape) });
for (i = 0; i < g.items.length; i++) avoid.push({ x: g.items[i].x, y: g.items[i].y, cr: g.items[i].r });
var descs = R.makeRow(n, y, R.randInt(4, 6), avoid);
for (i = 0; i < descs.length; i++) {
if (descs[i].item) g.items.push(makeItem(descs[i]));
else g.blocks.push(makeBlock(descs[i]));
}
}
// ---- 开局 ----
function startGame(g) {
g.state = 'aim';
g.turn = 1;
g.score = 0;
g.secs = 0;
g.bigTurn = false;
g.queue = [false];
g.queueBonus = 0;
g.balls = [];
g.recycle = [];
g.blocks = [];
g.items = [];
g.particles = [];
g.pairs = {};
g.overInfo = null;
// 开局 4 行(复刻原版开局视觉密度),行距 56
for (var n = 1; n <= 4; n++) spawnRow(g, n, ROW_Y - (n - 1) * 56);
}
// ---- 发射 ----
function fire(g) {
if (g.state !== 'aim' || !g.queue.length || g.paused) return;
g.state = 'firing';
g.fireTimer = 0;
g.volley = g.queue.length; // 快照本回合发射数:期间回收的球存入下回合,不续进本 volley
}
function launchOne(g) {
var big = !!g.queue.shift() || g.bigTurn; // 逐球标记或大球回合徽章,与预览/待发显示同口径
var r = big ? R.BIG_R : R.BALL_R;
var dirX = Math.sin(g.aimAngle), dirY = Math.cos(g.aimAngle);
var b = P.makeCircle({
shape: 'circle',
x: F.arcCx, y: F.checkY - 6,
r: r, friction: 0.08, restitution: 0.95, tag: 'ball'
});
b.vx = dirX * R.FIRE_SPEED;
b.vy = dirY * R.FIRE_SPEED;
b.data = { big: big };
g.balls.push(b);
PBAudio.fire();
}
// ---- 砖块伤害(击中 / 夹角研磨 共用) ----
function damageBlock(g, block, dmg, showFloat) {
block.data.hp -= dmg;
g.score += dmg;
if (showFloat) floatText(g, block.x, block.y - block.data.r - 8, '+' + dmg);
if (block.data.hp <= 0) {
var idx = g.blocks.indexOf(block);
if (idx >= 0) g.blocks.splice(idx, 1);
burst(g, block.x, block.y, PBRules.hpColor(1));
PBAudio.breakBlock();
return true;
}
return false;
}
// ---- 击中处理 ----
function onHit(g, ball, other, speed) {
if (other.tag === 'block') {
damageBlock(g, other, ball.data.big ? 2 : 1, true);
PBAudio.hit(speed);
} else if (other.tag === 'item') {
var j = g.items.indexOf(other);
if (j >= 0) g.items.splice(j, 1);
if (other.data.type === 'ball') {
g.queue.push(!!g.bigTurn); // 大球生效中入队即为 ⚽️
floatText(g, other.x, other.y, '+1 球');
} else {
g.bigTurn = true;
floatText(g, other.x, other.y, '大球!');
}
burst(g, other.x, other.y, '#ffffff');
PBAudio.item();
} else if (other.tag === 'wall') {
PBAudio.wall();
}
}
function burst(g, x, y, color) {
for (var i = 0; i < 10; i++) {
var a = Math.random() * Math.PI * 2;
var sp = 120 + Math.random() * 260;
g.particles.push({
x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
life: 0.5 + Math.random() * 0.3, t: 0,
size: 3 + Math.random() * 5, rot: Math.random() * 6, vr: (Math.random() - 0.5) * 10,
color: color, kind: 'tri'
});
}
}
function floatText(g, x, y, text) {
g.particles.push({ x: x, y: y, vx: 0, vy: -70, life: 0.9, t: 0, size: 13, color: '#ffd83d', kind: 'text', text: text });
}
// ---- 卡球逃脱(只许下落,不许上天) ----
// 球陷砖块缝隙/谷地减速 0.5s 后,朝更开阔的一侧水平推挤,让球滑出缺口继续下落。
// 禁止向上弹射——球唯一的"上去"方式是落到底部后由输送带送回。
function gapDir(g, b) {
var left = b.x - F.wallL, right = F.wallR - b.x;
for (var i = 0; i < g.blocks.length; i++) {
var o = g.blocks[i];
var cr = R.boundRadius(o.data.shape) + b.r + 2;
var dy = Math.abs(o.y - b.y);
if (dy >= cr) continue;
var dx = Math.sqrt(cr * cr - dy * dy);
if (o.x < b.x) left = Math.min(left, b.x - o.x - dx);
else right = Math.min(right, o.x - b.x - dx);
}
if (Math.abs(left - right) < 24) return Math.random() < 0.5 ? -1 : 1; // 势均力敌时随机
return left > right ? -1 : 1;
}
function checkStuck(g, dt) {
for (var i = 0; i < g.balls.length; i++) {
var b = g.balls[i];
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
b.stuckT = sp < 60 ? (b.stuckT || 0) + dt : 0;
if (b.stuckT > 0.5 && b._touchBlock) {
// 夹角(>=2 接触面)交给研磨开空间,不施加横向推力,避免挤压上弹
var surfaces = b._touchBlocks.length + (b._touchWall ? 1 : 0);
if (surfaces >= 2) continue;
var dir = gapDir(g, b);
// 逃逸强度(设置页滑块 0-100):水平推力 250~1750
var hs = 250 + 12 * PBStorage.getEscape() + Math.random() * 300;
b.vx = dir * hs + (Math.random() - 0.5) * 120;
b.vy = (Math.random() - 0.5) * 120; // 仅微小扰动,不向上弹射
b.stuckT = 0;
P.wake(b);
}
}
}
// ---- 夹角研磨:陷入夹角后以最高击碎能力快速消耗接触砖块 ----
// 楔入条件:低速且同时接触 >=2 个面(块+块 或 块+墙)。
// 每 0.1s 对每块接触砖造成高额研磨伤害(随逃逸强度 2~6),
// 快速打碎砖块留出空间,球自然下落逃逸。
var GRIND_TICK = 0.1;
function checkGrind(g, dt) {
var dmg = 2 + Math.round(PBStorage.getEscape() / 25);
for (var i = 0; i < g.balls.length; i++) {
var b = g.balls[i];
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
var surfaces = b._touchBlocks.length + (b._touchWall ? 1 : 0);
var inWedge = sp < 80 && b._touchBlocks.length >= 1 && surfaces >= 2;
if (!inWedge) { b.grindT = 0; continue; }
b.grindT = (b.grindT || 0) + dt;
while (b.grindT >= GRIND_TICK) {
b.grindT -= GRIND_TICK;
for (var k = 0; k < b._touchBlocks.length; k++) {
damageBlock(g, b._touchBlocks[k], dmg, false); // 研磨不飘字,避免刷屏
}
PBAudio.hit(300);
}
}
}
// ---- 回收 ----
// 入角即收;停在底部弧面(不碰砖块)超 1.2s 也收,保证回合能结束
function checkRecycle(g, dt) {
for (var i = g.balls.length - 1; i >= 0; i--) {
var b = g.balls[i];
var left = b.x < F.wallL + CORNER_X;
var right = b.x > F.wallR - CORNER_X;
var bottomRest = (b.stuckT || 0) > 1.2 && b.y > CORNER_Y && !b._touchBlock;
if (((left || right) && b.y > CORNER_Y) || bottomRest) {
var side = left ? 0 : (right ? 1 : (b.x < F.arcCx ? 0 : 1));
g.recycle.push({ x: b.x, y: b.y, side: side, big: b.data.big, phase: 0 });
g.balls.splice(i, 1);
}
}
}
function updateRecycle(g, dt) {
for (var i = g.recycle.length - 1; i >= 0; i--) {
var r = g.recycle[i];
var off = r.big ? 17 : 13; // 大球半径大,输送带偏移相应加大
var laneX = r.side === 0 ? F.wallL + off : F.wallR - off;
if (r.phase === 0) {
// 先水平滑入输送带
var dx = laneX - r.x;
var step = 700 * dt;
r.x += Math.abs(dx) <= step ? dx : Math.sign(dx) * step;
if (r.x === laneX) r.phase = 1;
} else {
r.y -= RECYCLE_V * dt;
}
if (r.phase === 1 && r.y < F.checkY + 6) {
g.recycle.splice(i, 1);
g.queue.push(!!(r.big || g.bigTurn)); // 大球回收仍为大球;小球在大球生效中回队升级
}
}
}
// ---- 回合结束 ----
function endTurn(g) {
g.turn++;
g.bigTurn = false;
g.state = 'rise';
g.riseT = 0;
}
function checkOver(g) {
for (var i = 0; i < g.blocks.length; i++) {
var b = g.blocks[i];
var top = b.shape === 'circle' ? b.y - b.r : aabbTop(b);
if (top < F.checkY) return true;
}
return false;
}
function aabbTop(b) {
var vs = P.worldVerts(b);
var m = 1e9;
for (var i = 0; i < vs.length; i++) if (vs[i].y < m) m = vs[i].y;
return m;
}
function gameOver(g) {
g.state = 'over';
PBAudio.over();
}
// ---- 主更新 ----
function update(g, dt) {
if (g.paused || g.state === 'idle' || g.state === 'over') return;
g.secs += dt;
if (g.state === 'firing') {
g.fireTimer -= dt;
if (g.fireTimer <= 0 && g.volley > 0) {
launchOne(g);
g.volley--;
g.fireTimer = R.FIRE_GAP_MS / 1000;
}
if (g.volley <= 0) g.state = 'fly';
}
if (g.state === 'rise') {
g.riseT += dt;
var k = Math.min(1, g.riseT / RISE_TIME);
var dy = RISE_PX * dt / RISE_TIME;
var i;
for (i = 0; i < g.blocks.length; i++) { g.blocks[i].y -= dy; }
for (i = 0; i < g.items.length; i++) g.items[i].y -= dy; // 静态道具随回合整体上升
if (k >= 1) {
spawnRow(g, g.turn, ROW_Y);
if (checkOver(g)) { gameOver(g); return; }
g.state = 'aim';
}
}
// 物理子步
var bodies = g.walls.concat(g.blocks, g.items, g.balls);
var sub = R.PHYS.subDt;
var acc = dt;
var bi;
for (bi = 0; bi < g.balls.length; bi++) {
g.balls[bi]._touchBlock = false;
g.balls[bi]._touchBlocks = [];
g.balls[bi]._touchWall = false;
}
while (acc > 1e-6) {
var h = Math.min(sub, acc);
var res = P.stepWorld(bodies, R.PHYS, h, g.pairs);
g.pairs = res.pairs;
// 记录本帧球接触面(砖块列表 / 墙标记,供卡球逃脱与夹角研磨)
for (var mi = 0; mi < res.manifolds.length; mi++) {
var mf = res.manifolds[mi];
var bm = mf.a.tag === 'ball' ? mf.a : (mf.b.tag === 'ball' ? mf.b : null);
var om = bm === mf.a ? mf.b : mf.a;
if (!bm) continue;
if (om.tag === 'block') {
bm._touchBlock = true;
if (bm._touchBlocks.indexOf(om) < 0) bm._touchBlocks.push(om);
} else if (om.tag === 'wall') {
bm._touchWall = true;
}
}
for (var e = 0; e < res.events.length; e++) {
var ev = res.events[e];
var ball = ev.a.tag === 'ball' ? ev.a : (ev.b.tag === 'ball' ? ev.b : null);
var other = ball === ev.a ? ev.b : ev.a;
if (ball) onHit(g, ball, other, ev.speed);
}
acc -= h;
}
checkGrind(g, dt);
checkStuck(g, dt);
checkRecycle(g, dt);
updateRecycle(g, dt);
// 粒子
for (var pi = g.particles.length - 1; pi >= 0; pi--) {
var pt = g.particles[pi];
pt.t += dt;
if (pt.t >= pt.life) { g.particles.splice(pi, 1); continue; }
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
if (pt.kind === 'tri') pt.vy += 900 * dt;
if (pt.rot !== undefined) pt.rot += pt.vr * dt;
}
if (checkOver(g)) { gameOver(g); return; }
if (g.state === 'fly' && !g.balls.length && !g.recycle.length) {
endTurn(g);
}
}
return {
create: create,
startGame: startGame,
fire: fire,
update: update,
aabbTop: aabbTop,
constants: { RISE_PX: RISE_PX, ROW_Y: ROW_Y }
};
})();
@@ -0,0 +1,456 @@
/* ============================================================
* pb_physics.js · 物理弹球 Web 复刻版 · 刚体引擎
* 纯函数区:不依赖 DOM / 渲染,可被
* dev_test_scripts/unit/test_physics_balls_physics.js 直接加载做单元测试。
*
* 设计:
* - 刚体两类:circle / 凸多边形 poly(verts 为局部坐标,逆时针)
* - 碰撞检测:circle-circle / circle-poly / poly-poly(SAT 分离轴 +
* 顶点穿透流形,最多 2 接触点,足够砖块堆叠稳定)
* - 求解:序贯冲量 + 累积法向冲量钳制(≥0)+ 库仑摩擦累积钳制 +
* 恢复系数 bias(低速阈值内 e=0 防抖)+ Baumgarte 位置校正
* - 休眠:低速稳定持续一段时间 → sleeping(视同静态);
* 被高速接触/外力唤醒。砖块堆叠靠休眠防抖、省性能
* - 事件:stepWorld 返回 events(新接触对且接近速度超阈值),
* 供游戏层做「击中砖块 +分/扣血」与音效
* 单位:px、s。y 向下。
* ============================================================ */
var PBPhysics = (function () {
'use strict';
var UID = 1;
// ---------- 刚体工厂 ----------
function baseBody(o) {
var b = {
id: UID++,
shape: o.shape,
x: o.x || 0, y: o.y || 0, angle: o.angle || 0,
vx: 0, vy: 0, omega: 0,
fx: 0, fy: 0, // 累积冲量引起的速度变化由求解器直接改 vx/vy
r: o.r || 0,
verts: o.verts || null, // 局部坐标
friction: o.friction != null ? o.friction : 0.2,
restitution: o.restitution || 0,
isStatic: !!o.isStatic,
sleeping: false, sleepTimer: 0,
tag: o.tag || '', // 'ball' | 'block' | 'item' | 'wall'
data: o.data || null,
aabb: { x0: 0, y0: 0, x1: 0, y1: 0 }
};
if (b.isStatic) { b.m = 0; b.invM = 0; b.I = 0; b.invI = 0; }
return b;
}
function makeCircle(o) {
var b = baseBody(o);
b.shape = 'circle';
var m = o.r * o.r * 0.06;
if (!b.isStatic) {
b.m = m; b.invM = 1 / m;
b.I = 0.5 * m * o.r * o.r; b.invI = 1 / b.I;
}
return b;
}
function polyAreaInertia(verts) {
var area = 0, I = 0, i;
for (i = 0; i < verts.length; i++) {
var p1 = verts[i], p2 = verts[(i + 1) % verts.length];
var cross = p1.x * p2.y - p2.x * p1.y;
area += cross / 2;
I += cross / 12 * (p1.x * p1.x + p1.x * p2.x + p2.x * p2.x + p1.y * p1.y + p1.y * p2.y + p2.y * p2.y);
}
return { area: Math.abs(area), I: Math.abs(I) };
}
function makePoly(o) {
var b = baseBody(o);
b.shape = 'poly';
var ai = polyAreaInertia(o.verts);
if (!b.isStatic) {
var m = Math.max(0.5, ai.area * 0.05) * (o.massScale || 1);
b.m = m; b.invM = 1 / m;
b.I = Math.max(1, ai.I * 0.05 * (o.massScale || 1)); b.invI = 1 / b.I;
}
return b;
}
// ---------- 几何工具 ----------
function worldVerts(b) {
var c = Math.cos(b.angle), s = Math.sin(b.angle);
var out = [];
for (var i = 0; i < b.verts.length; i++) {
var v = b.verts[i];
out.push({ x: b.x + v.x * c - v.y * s, y: b.y + v.x * s + v.y * c });
}
return out;
}
function updateAabb(b) {
var a = b.aabb;
if (b.shape === 'circle') {
a.x0 = b.x - b.r; a.y0 = b.y - b.r; a.x1 = b.x + b.r; a.y1 = b.y + b.r;
} else {
var vs = worldVerts(b);
var x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
for (var i = 0; i < vs.length; i++) {
if (vs[i].x < x0) x0 = vs[i].x;
if (vs[i].y < y0) y0 = vs[i].y;
if (vs[i].x > x1) x1 = vs[i].x;
if (vs[i].y > y1) y1 = vs[i].y;
}
a.x0 = x0; a.y0 = y0; a.x1 = x1; a.y1 = y1;
}
}
function aabbOverlap(a, b) {
return a.aabb.x0 <= b.aabb.x1 && a.aabb.x1 >= b.aabb.x0 &&
a.aabb.y0 <= b.aabb.y1 && a.aabb.y1 >= b.aabb.y0;
}
function pointInPoly(px, py, vs) {
var sign = 0;
for (var i = 0; i < vs.length; i++) {
var a = vs[i], b = vs[(i + 1) % vs.length];
var cr = (b.x - a.x) * (py - a.y) - (b.y - a.y) * (px - a.x);
if (cr !== 0) {
var sg = cr > 0 ? 1 : -1;
if (sign === 0) sign = sg;
else if (sg !== sign) return false;
}
}
return true;
}
// ---------- 碰撞检测:返回流形 {a,b,nx,ny,points:[{x,y,pen,bias}] },法线 a→b ----------
function collideCircleCircle(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var R = a.r + b.r;
var d2 = dx * dx + dy * dy;
if (d2 >= R * R) return null;
var d = Math.sqrt(d2) || 1e-9;
var nx = dx / d, ny = dy / d;
return {
a: a, b: b, nx: nx, ny: ny,
points: [{ x: a.x + nx * a.r, y: a.y + ny * a.r, pen: R - d }]
};
}
function closestOnPoly(px, py, vs) {
var best = null, bestD2 = 1e18;
for (var i = 0; i < vs.length; i++) {
var a = vs[i], b = vs[(i + 1) % vs.length];
var dx = b.x - a.x, dy = b.y - a.y;
var L2 = dx * dx + dy * dy || 1e-9;
var t = ((px - a.x) * dx + (py - a.y) * dy) / L2;
t = t < 0 ? 0 : (t > 1 ? 1 : t);
var cx = a.x + dx * t, cy = a.y + dy * t;
var d2 = (px - cx) * (px - cx) + (py - cy) * (py - cy);
if (d2 < bestD2) { bestD2 = d2; best = { x: cx, y: cy }; }
}
return best;
}
function collideCirclePoly(circle, poly, swap) {
var vs = worldVerts(poly);
var inside = pointInPoly(circle.x, circle.y, vs);
var c = closestOnPoly(circle.x, circle.y, vs);
var dx = circle.x - c.x, dy = circle.y - c.y;
var d2 = dx * dx + dy * dy;
var d = Math.sqrt(d2) || 1e-9;
var pnx, pny, pen;
if (inside) {
// 圆心在多边形内:推出方向 = 圆心指向最近边界点
pnx = -dx / d; pny = -dy / d; pen = circle.r + d;
} else {
if (d >= circle.r) return null;
// 多边形→圆 = 最近点指向圆心
pnx = dx / d; pny = dy / d; pen = circle.r - d;
}
// 法线约定 a→b;swap=true 表示 a=circle,b=poly
var m = { points: [{ x: c.x, y: c.y, pen: pen }] };
if (!swap) { m.a = poly; m.b = circle; m.nx = pnx; m.ny = pny; }
else { m.a = circle; m.b = poly; m.nx = -pnx; m.ny = -pny; }
return m;
}
function centroid(vs) {
var cx = 0, cy = 0;
for (var i = 0; i < vs.length; i++) { cx += vs[i].x; cy += vs[i].y; }
return { x: cx / vs.length, y: cy / vs.length };
}
// 第 i 条边的单位外法线(与顶点绕序无关)
function edgeOutNormal(vs, i) {
var a1 = vs[i], a2 = vs[(i + 1) % vs.length];
var nx = -(a2.y - a1.y), ny = a2.x - a1.x;
var L = Math.sqrt(nx * nx + ny * ny) || 1e-9;
nx /= L; ny /= L;
var c = centroid(vs);
var mx = (a1.x + a2.x) / 2, my = (a1.y + a2.y) / 2;
if ((mx - c.x) * nx + (my - c.y) * ny < 0) { nx = -nx; ny = -ny; }
return { x: nx, y: ny };
}
// SAT 最大分离轴:返回 {sep, i}(A 的第 i 条边外法线方向上 B 相对 A 的分离量;sep>0 已分离)
function maxSeparation(va, vb) {
var best = -1e18, bi = 0;
for (var i = 0; i < va.length; i++) {
var n = edgeOutNormal(va, i);
var a1 = va[i];
var minProj = 1e18;
for (var j = 0; j < vb.length; j++) {
var pr = (vb[j].x - a1.x) * n.x + (vb[j].y - a1.y) * n.y;
if (pr < minProj) minProj = pr;
}
if (minProj > best) { best = minProj; bi = i; }
}
return { sep: best, i: bi };
}
function collidePolyPoly(a, b) {
var va = worldVerts(a), vb = worldVerts(b);
var sa = maxSeparation(va, vb);
if (sa.sep > 0) return null;
var sb = maxSeparation(vb, va);
if (sb.sep > 0) return null;
// 流形法线约定 a→b(A→B)
var nx, ny, refPt, refA, refB;
if (sb.sep > sa.sep + 0.01) {
var nB = edgeOutNormal(vb, sb.i); // B 外法线 = B→A
nx = -nB.x; ny = -nB.y; // 取反 = A→B
refPt = vb[sb.i];
refA = vb[sb.i]; refB = vb[(sb.i + 1) % vb.length];
} else {
var nA = edgeOutNormal(va, sa.i); // A 外法线 = A→B
nx = nA.x; ny = nA.y;
refPt = va[sa.i];
refA = va[sa.i]; refB = va[(sa.i + 1) % va.length];
}
// 顶点穿透流形:收集互相穿透的顶点
var pts = [];
var i;
for (i = 0; i < vb.length; i++) {
if (pointInPoly(vb[i].x, vb[i].y, va)) {
// B 顶点穿入 A:在 A→B 法线上投影为负
pts.push({ x: vb[i].x, y: vb[i].y, pen: Math.max(0.1, -((vb[i].x - refPt.x) * nx + (vb[i].y - refPt.y) * ny)) });
}
}
for (i = 0; i < va.length; i++) {
if (pointInPoly(va[i].x, va[i].y, vb)) {
// A 顶点穿入 B:在 A→B 法线上投影为正
pts.push({ x: va[i].x, y: va[i].y, pen: Math.max(0.1, (va[i].x - refPt.x) * nx + (va[i].y - refPt.y) * ny) });
}
}
if (!pts.length) {
// 边-边接触:沿参考边生成 2 个接触点(面接触近似,提供抗旋转扭矩,防堆叠倾覆)
var ex = refB.x - refA.x, ey = refB.y - refA.y;
var el = Math.sqrt(ex * ex + ey * ey) || 1e-9;
ex /= el; ey /= el;
var oc = centroid(sb.sep > sa.sep + 0.01 ? va : vb);
var tMid = (oc.x - refA.x) * ex + (oc.y - refA.y) * ey;
var half = Math.min(el * 0.4, 14);
var pen0 = Math.max(0.1, -Math.max(sa.sep, sb.sep));
pts.push({ x: refA.x + ex * (tMid - half), y: refA.y + ey * (tMid - half), pen: pen0 });
pts.push({ x: refA.x + ex * (tMid + half), y: refA.y + ey * (tMid + half), pen: pen0 });
}
if (pts.length > 2) pts.length = 2;
return { a: a, b: b, nx: nx, ny: ny, points: pts };
}
function collide(a, b) {
if (a.shape === 'circle' && b.shape === 'circle') return collideCircleCircle(a, b);
if (a.shape === 'circle' && b.shape === 'poly') return collideCirclePoly(a, b, true);
if (a.shape === 'poly' && b.shape === 'circle') return collideCirclePoly(b, a, false);
return collidePolyPoly(a, b);
}
// ---------- 求解 ----------
function prepareManifold(m, p) {
// 记录每点初始接近速度,用于恢复系数 bias
for (var i = 0; i < m.points.length; i++) {
var cp = m.points[i];
cp.rax = cp.x - m.a.x; cp.ray = cp.y - m.a.y;
cp.rbx = cp.x - m.b.x; cp.rby = cp.y - m.b.y;
var rvx = (m.b.vx - m.b.omega * cp.rby) - (m.a.vx - m.a.omega * cp.ray);
var rvy = (m.b.vy + m.b.omega * cp.rbx) - (m.a.vy + m.a.omega * cp.rax);
var vn = rvx * m.nx + rvy * m.ny;
cp.bias = (-vn > p.restV) ? -p.e * vn : 0;
cp.accN = 0; cp.accT = 0;
}
m.friction = Math.min(m.a.friction, m.b.friction);
m.rest = p.e;
}
function solveManifold(m, p) {
var a = m.a, b = m.b;
var nx = m.nx, ny = m.ny;
var tx = -ny, ty = nx;
for (var i = 0; i < m.points.length; i++) {
var cp = m.points[i];
// ---- 法向 ----
var rvx = (b.vx - b.omega * cp.rby) - (a.vx - a.omega * cp.ray);
var rvy = (b.vy + b.omega * cp.rbx) - (a.vy + a.omega * cp.rax);
var vn = rvx * nx + rvy * ny;
var raxn = cp.rax * ny - cp.ray * nx;
var rbxn = cp.rbx * ny - cp.rby * nx;
var kn = a.invM + b.invM + raxn * raxn * a.invI + rbxn * rbxn * b.invI;
if (kn > 1e-9) {
var lambda = (cp.bias - vn) / kn;
var newAcc = Math.max(cp.accN + lambda, 0);
lambda = newAcc - cp.accN;
cp.accN = newAcc;
var px_ = nx * lambda, py_ = ny * lambda;
a.vx -= px_ * a.invM; a.vy -= py_ * a.invM;
a.omega -= a.invI * (cp.rax * py_ - cp.ray * px_);
b.vx += px_ * b.invM; b.vy += py_ * b.invM;
b.omega += b.invI * (cp.rbx * py_ - cp.rby * px_);
}
// ---- 切向(库仑摩擦,累积钳制) ----
rvx = (b.vx - b.omega * cp.rby) - (a.vx - a.omega * cp.ray);
rvy = (b.vy + b.omega * cp.rbx) - (a.vy + a.omega * cp.rax);
var vt = rvx * tx + rvy * ty;
var raxt = cp.rax * ty - cp.ray * tx;
var rbxt = cp.rbx * ty - cp.rby * tx;
var kt = a.invM + b.invM + raxt * raxt * a.invI + rbxt * rbxt * b.invI;
if (kt > 1e-9) {
var jt = -vt / kt;
var maxF = m.friction * cp.accN;
var newT = cp.accT + jt;
if (newT > maxF) newT = maxF; else if (newT < -maxF) newT = -maxF;
jt = newT - cp.accT;
cp.accT = newT;
var fx_ = tx * jt, fy_ = ty * jt;
a.vx -= fx_ * a.invM; a.vy -= fy_ * a.invM;
a.omega -= a.invI * (cp.rax * fy_ - cp.ray * fx_);
b.vx += fx_ * b.invM; b.vy += fy_ * b.invM;
b.omega += b.invI * (cp.rbx * fy_ - cp.rby * fx_);
}
}
}
function positionCorrection(m, p) {
var maxPen = 0;
for (var i = 0; i < m.points.length; i++) if (m.points[i].pen > maxPen) maxPen = m.points[i].pen;
var corr = Math.max(maxPen - p.slop, 0) * p.correction;
if (corr <= 0) return;
var tm = m.a.invM + m.b.invM;
if (tm <= 1e-9) return;
m.a.x -= m.nx * corr * (m.a.invM / tm);
m.a.y -= m.ny * corr * (m.a.invM / tm);
m.b.x += m.nx * corr * (m.b.invM / tm);
m.b.y += m.ny * corr * (m.b.invM / tm);
}
// ---------- 休眠 ----------
function updateSleep(b, dt, p) {
if (b.isStatic) return;
// 弹球永不休眠:必须保持可滚动/可被滞回收检测,防回合软锁
if (b.tag === 'ball') return;
var sp2 = b.vx * b.vx + b.vy * b.vy;
if (sp2 < p.sleepV * p.sleepV && Math.abs(b.omega) < p.sleepW) b.sleepTimer += dt;
else b.sleepTimer = 0;
if (!b.sleeping && b.sleepTimer > 0.6) {
b.sleeping = true;
b.vx = 0; b.vy = 0; b.omega = 0;
}
}
function wake(b) {
if (b && b.sleeping) { b.sleeping = false; b.sleepTimer = 0; }
}
// ---------- 世界推进 ----------
// 返回 { manifolds, events };events = 新接触对强碰撞 [{a,b,speed}]
function stepWorld(bodies, p, dt, prevPairs) {
var i, j, b;
var events = [];
var newPairs = {};
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.isStatic || b.sleeping) continue;
b.vy += p.g * dt;
if (p.airK > 0) { var f = Math.exp(-p.airK * dt); b.vx *= f; b.vy *= f; }
if (p.omegaK > 0) b.omega *= Math.exp(-p.omegaK * dt); // 角速度阻尼,防砖块被击飞后永转
var sp2 = b.vx * b.vx + b.vy * b.vy;
if (sp2 > p.maxV * p.maxV) { var s = p.maxV / Math.sqrt(sp2); b.vx *= s; b.vy *= s; }
b.x += b.vx * dt;
b.y += b.vy * dt;
b.angle += b.omega * dt;
}
for (i = 0; i < bodies.length; i++) updateAabb(bodies[i]);
var manifolds = [];
for (i = 0; i < bodies.length; i++) {
var a = bodies[i];
for (j = i + 1; j < bodies.length; j++) {
var c = bodies[j];
if (a.isStatic && c.isStatic) continue;
if (a.sleeping && c.sleeping) continue;
if (!aabbOverlap(a, c)) continue;
var m = collide(a, c);
if (!m) continue;
prepareManifold(m, p);
var key = a.id + '_' + c.id;
newPairs[key] = true;
// 接近速度(质心)
var rvx = c.vx - a.vx, rvy = c.vy - a.vy;
var speed = Math.sqrt(rvx * rvx + rvy * rvy);
// 唤醒:休眠体被高速接触
if (a.sleeping && !c.sleeping && speed > p.wakeV) wake(a);
if (c.sleeping && !a.sleeping && speed > p.wakeV) wake(c);
// 事件:新接触对 + 足够强的接近速度
if (!prevPairs[key] && speed > p.hitV) events.push({ a: a, b: c, speed: speed });
manifolds.push(m);
}
}
for (var it = 0; it < p.iterations; it++) {
for (j = 0; j < manifolds.length; j++) solveManifold(manifolds[j], p);
}
for (j = 0; j < manifolds.length; j++) positionCorrection(manifolds[j], p);
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (!b.isStatic && !b.sleeping) updateSleep(b, dt, p);
}
return { manifolds: manifolds, events: events, pairs: newPairs };
}
// 凸正多边形局部顶点(逆时针)
function regularVerts(n, radius, rot0) {
var vs = [];
for (var i = 0; i < n; i++) {
var a = (rot0 || -Math.PI / 2) + i * 2 * Math.PI / n;
vs.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
}
return vs;
}
// 矩形局部顶点
function boxVerts(w, h) {
return [
{ x: -w / 2, y: -h / 2 },
{ x: w / 2, y: -h / 2 },
{ x: w / 2, y: h / 2 },
{ x: -w / 2, y: h / 2 }
];
}
return {
makeCircle: makeCircle,
makePoly: makePoly,
worldVerts: worldVerts,
collide: collide,
stepWorld: stepWorld,
wake: wake,
regularVerts: regularVerts,
boxVerts: boxVerts,
pointInPoly: pointInPoly
};
})();
@@ -0,0 +1,260 @@
/* ============================================================
* pb_render.js · 物理弹球 Web 复刻版 · Canvas 渲染层
* 纯绘制:场地 / 漏斗 / 输送带箭头 / 砖块(形状+数字+配色) /
* 道具 / 待发队列 / 球(棒球质感) / 粒子 / 轨迹预览(墙+砖块反射)
* ============================================================ */
var PBRender = (function () {
'use strict';
var R = PBRules, F = R.FIELD;
// ---------- 轨迹预览:无头物理仿真 ----------
// 用同一引擎/同初速/同参数克隆一颗球实跑 1.8s,预览线 = 真实轨迹。
// 墙/砖块本就静态;道具克隆为静态参与碰撞(不改动真实道具)。
var pvCache = { key: '', pts: null };
function simulatePath(g) {
var key = g.aimAngle.toFixed(4) + '|' + g.turn + '|' + g.blocks.length + '|' + g.items.length + '|' + ((g.queue[0] || g.bigTurn) ? 1 : 0);
if (pvCache.key === key) return pvCache.pts;
var P = PBPhysics;
var bodies = g.walls.concat(g.blocks);
var i;
for (i = 0; i < g.items.length; i++) {
bodies.push(P.makeCircle({ shape: 'circle', x: g.items[i].x, y: g.items[i].y, r: g.items[i].r, isStatic: true, friction: 0.3, restitution: 0.2, tag: 'item' }));
}
var r = (g.queue[0] || g.bigTurn) ? R.BIG_R : R.BALL_R; // 与 launchOne 口径一致:队首标记或大球回合
var ball = P.makeCircle({
shape: 'circle', x: F.arcCx, y: F.checkY - 6,
r: r, friction: 0.08, restitution: 0.95, tag: 'ball'
});
ball.vx = Math.sin(g.aimAngle) * R.FIRE_SPEED;
ball.vy = Math.cos(g.aimAngle) * R.FIRE_SPEED;
bodies.push(ball);
var pts = [{ x: ball.x, y: ball.y }];
var pairs = {};
var n = Math.floor(1.8 / R.PHYS.subDt);
for (i = 0; i < n; i++) {
var res = P.stepWorld(bodies, R.PHYS, R.PHYS.subDt, pairs);
pairs = res.pairs;
if (i % 4 === 3) pts.push({ x: ball.x, y: ball.y });
if (ball.sleeping) break;
}
pvCache.key = key;
pvCache.pts = pts;
return pts;
}
// ---------- 绘制部件 ----------
function drawBall(ctx, x, y, r, big) {
if (big) {
// 大球 = ⚽️ 图标,直径 2 倍于小球,一眼可辨
ctx.font = Math.round(r * 2) + 'px system-ui, "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('⚽️', x, y + r * 0.08);
return;
}
var grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.2, x, y, r);
grad.addColorStop(0, big ? '#d8a468' : '#c08a52');
grad.addColorStop(1, big ? '#8a5a2c' : '#7a4c24');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
// 棒球缝线
ctx.strokeStyle = 'rgba(255,244,220,0.85)';
ctx.lineWidth = Math.max(1, r * 0.16);
ctx.beginPath();
ctx.arc(x - r * 0.5, y, r * 0.9, -0.6, 0.6);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + r * 0.5, y, r * 0.9, Math.PI - 0.6, Math.PI + 0.6);
ctx.stroke();
}
function drawBlock(ctx, b) {
var d = b.data;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.angle);
ctx.fillStyle = R.hpColor(d.hp);
ctx.beginPath();
if (d.shape === 'circle') {
ctx.arc(0, 0, d.r, 0, Math.PI * 2);
} else {
var vs = d.shape === 'box' ? PBPhysics.boxVerts(d.r * 1.7, d.r * 1.7) : PBPhysics.regularVerts(d.shape === 'tri' ? 3 : 5, d.r, 0);
ctx.moveTo(vs[0].x, vs[0].y);
for (var i = 1; i < vs.length; i++) ctx.lineTo(vs[i].x, vs[i].y);
ctx.closePath();
}
ctx.fill();
ctx.fillStyle = d.hp <= 29 ? '#20180a' : '#ffffff';
ctx.font = 'bold ' + Math.round(d.r * 0.95) + 'px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(d.hp), 0, 1);
ctx.restore();
}
function drawItem(ctx, it, t) {
ctx.save();
ctx.translate(it.x, it.y);
ctx.strokeStyle = '#e8e8e8';
ctx.lineWidth = 1.6;
ctx.setLineDash([4, 4]);
ctx.lineDashOffset = -t * 20;
ctx.beginPath();
ctx.arc(0, 0, it.r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
if (it.data.type === 'ball') {
// +1 球道具:迷你棒球 + 加号
drawBall(ctx, -it.r * 0.12, it.r * 0.12, it.r * 0.36, false);
ctx.strokeStyle = '#e8e8e8';
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(it.r * 0.35, -it.r * 0.55); ctx.lineTo(it.r * 0.8, -it.r * 0.55);
ctx.moveTo(it.r * 0.575, -it.r * 0.775); ctx.lineTo(it.r * 0.575, -it.r * 0.325);
ctx.stroke();
} else {
// 大球道具:⚽ 图标(与实际大球外观一致)
ctx.font = Math.round(it.r * 1.3) + 'px system-ui, "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('⚽️', 0, 1);
}
ctx.restore();
}
function drawField(ctx, t) {
ctx.strokeStyle = '#8a8a8a';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
// 左右壁
ctx.beginPath();
ctx.moveTo(F.wallL, F.topY); ctx.lineTo(F.wallL, F.H - 60);
ctx.moveTo(F.wallR, F.topY); ctx.lineTo(F.wallR, F.H - 60);
ctx.stroke();
// 顶 + 漏斗
var fn = R.funnelSegs();
ctx.beginPath();
ctx.moveTo(F.wallL, F.topY); ctx.lineTo(F.wallR, F.topY);
ctx.moveTo(fn[0].x1, fn[0].y1); ctx.lineTo(fn[0].x2, fn[0].y2);
ctx.moveTo(fn[1].x1, fn[1].y1); ctx.lineTo(fn[1].x2, fn[1].y2);
ctx.stroke();
// 底部弧
ctx.beginPath();
ctx.arc(F.arcCx, F.arcCy, F.arcR, Math.PI * 1.24, Math.PI * 1.76);
ctx.stroke();
// checkline 虚线
ctx.strokeStyle = '#5a5a5a';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(F.wallL, F.checkY); ctx.lineTo(F.wallR, F.checkY);
ctx.stroke();
ctx.setLineDash([]);
// 输送带箭头(两角,向上闪动)
for (var side = 0; side < 2; side++) {
var ax = side === 0 ? F.wallL + 13 : F.wallR - 13;
for (var k = 0; k < 3; k++) {
var ay = F.H - 46 - k * 16 - ((t * 40) % 16);
var alpha = 0.25 + 0.55 * ((k + Math.floor(t * 3)) % 3) / 2;
ctx.strokeStyle = 'rgba(200,200,200,' + alpha.toFixed(2) + ')';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(ax - 7, ay + 6); ctx.lineTo(ax, ay); ctx.lineTo(ax + 7, ay + 6);
ctx.stroke();
}
}
}
function drawQueue(ctx, g) {
for (var i = 0; i < Math.min(g.queue.length, 10); i++) {
var p = R.queuePos(i);
// 大球回合徽章生效中:待发全部按 ⚽️ 显示;否则按逐球标记(回收的大球仍为 ⚽️)
var isBig = !!g.queue[i] || !!g.bigTurn;
drawBall(ctx, p.x, p.y, isBig ? 11 : R.BALL_R, isBig);
}
if (g.queue.length > 10) {
ctx.fillStyle = '#bbb';
ctx.font = '10px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('+' + (g.queue.length - 10), F.arcCx, F.checkY - 22 - 4 * 15 - 10 * 14);
}
}
function drawPreview(ctx, g) {
if (g.state !== 'aim' || !g.queue.length) return;
var dx = Math.sin(g.aimAngle), dy = Math.cos(g.aimAngle);
var pts = simulatePath(g);
ctx.fillStyle = 'rgba(210,160,90,0.9)';
for (var i = 0; i < pts.length; i++) {
var fade = 1 - i / pts.length; // 越远越淡
ctx.globalAlpha = 0.35 + 0.6 * fade;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, 1.6 + 1.8 * fade, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 发射方向小箭头
ctx.fillStyle = '#ffffff';
ctx.save();
ctx.translate(F.arcCx + dx * 26, F.checkY - 6 + dy * 26);
ctx.rotate(Math.atan2(dy, dx) - Math.PI / 2);
ctx.beginPath();
ctx.moveTo(0, 8); ctx.lineTo(-5, -4); ctx.lineTo(5, -4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawParticles(ctx, g) {
for (var i = 0; i < g.particles.length; i++) {
var p = g.particles[i];
var a = 1 - p.t / p.life;
if (p.kind === 'text') {
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.font = 'bold ' + p.size + 'px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(p.text, p.x, p.y);
ctx.globalAlpha = 1;
} else {
ctx.save();
ctx.globalAlpha = a;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.moveTo(0, -p.size);
ctx.lineTo(p.size * 0.9, p.size * 0.7);
ctx.lineTo(-p.size * 0.9, p.size * 0.7);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
}
// ---------- 主绘制 ----------
function draw(ctx, g, t) {
ctx.clearRect(0, 0, F.W, F.H);
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, F.W, F.H);
drawField(ctx, t);
drawPreview(ctx, g);
var i;
for (i = 0; i < g.items.length; i++) drawItem(ctx, g.items[i], t);
for (i = 0; i < g.blocks.length; i++) drawBlock(ctx, g.blocks[i]);
drawQueue(ctx, g);
for (i = 0; i < g.balls.length; i++) drawBall(ctx, g.balls[i].x, g.balls[i].y, g.balls[i].r, g.balls[i].data.big);
ctx.globalAlpha = 0.75; // 输送带上的球半透明,与场内球区分
for (i = 0; i < g.recycle.length; i++) drawBall(ctx, g.recycle[i].x, g.recycle[i].y, g.recycle[i].big ? R.BIG_R : R.BALL_R, g.recycle[i].big);
ctx.globalAlpha = 1;
drawParticles(ctx, g);
}
return { draw: draw, simulatePath: simulatePath, drawBall: drawBall };
})();
@@ -0,0 +1,204 @@
/* ============================================================
* pb_rules.js · 物理弹球 Web 复刻版 · 规则层(纯函数,无 DOM)
* 场地几何 / 砖块形状与配色 / 行生成 / 道具 / 待发队列位置 / 物理参数
* ============================================================ */
var PBRules = (function () {
'use strict';
// ---- 场地逻辑坐标(竖向手机比例) ----
var FIELD = {
W: 420, H: 880,
wallL: 16, wallR: 404, // 左右侧壁 x
topY: 36, // 顶壁
checkY: 128, // 顶部虚线:砖块越线 = 游戏结束
gapHalf: 17, // 漏斗中央出口半宽(球从此落下)
arcCx: 210, arcCy: 1560, arcR: 706, // 底部大圆弧(弧顶 ≈ 854)
laneW: 26 // 侧边回收通道宽(输送带)
};
// 漏斗两斜壁:从顶角收拢到中央出口
function funnelSegs() {
return [
{ x1: FIELD.wallL, y1: FIELD.topY + 4, x2: FIELD.arcCx - FIELD.gapHalf, y2: FIELD.checkY - 10 },
{ x1: FIELD.wallR, y1: FIELD.topY + 4, x2: FIELD.arcCx + FIELD.gapHalf, y2: FIELD.checkY - 10 }
];
}
// ---- 物理参数 ----
var PHYS = {
g: 1500, e: 0.42, iterations: 16, slop: 0.4, correction: 0.55,
restV: 10, airK: 0.008, omegaK: 0.12, maxV: 3600,
sleepV: 14, sleepW: 0.5, wakeV: 90, hitV: 110,
subDt: 1 / 240
};
// ---- 球 ----
var BALL_R = 7; // 小球半径
var BIG_R = 14; // 大球半径 = 小球 2 倍(伤害也 2 倍)
var FIRE_SPEED = 980; // 发射初速
var FIRE_GAP_MS = 130; // 连发间隔
// ---- 砖块形状 ----
var SHAPES = ['circle', 'box', 'tri', 'penta'];
function shapeRadius(shape) {
if (shape === 'circle') return 17;
if (shape === 'box') return 19;
if (shape === 'tri') return 20;
return 19; // penta
}
// ---- 配色:HP 分档(复刻截图观感:黄→橙→绿→蓝→紫) ----
function hpColor(hp) {
if (hp <= 4) return hp % 2 ? '#f7c81a' : '#f7a01a';
if (hp <= 9) return '#f7a01a';
if (hp <= 29) return hp % 2 ? '#37c837' : '#8bd420';
if (hp <= 79) return '#2f7bff';
return '#a02fe0';
}
// ---- 回合 n 的新砖块 HP 区间(前期个位数,后期上百) ----
function hpRangeForTurn(n) {
var lo = Math.max(1, Math.floor(n * 0.8) - 1);
var hi = Math.max(lo + 1, n + 4);
return [lo, hi];
}
function randInt(lo, hi) { return lo + Math.floor(Math.random() * (hi - lo + 1)); }
// ---- 各形状包围圆半径(包围圆 ⊇ 实际形状,用于无重叠放置) ----
function boundRadius(shape) {
if (shape === 'circle') return 17;
if (shape === 'box') return (19 * 1.7 / 2) * Math.SQRT2; // 正方形半对角线 ≈22.8
if (shape === 'tri') return 20;
return 19; // penta 外接圆
}
// ---- 生成一行砖块(返回描述数组,game 层转刚体) ----
// 槽位随机簇团布局 + 严格无重叠放置:中心距 >= 包围圆半径和(允许相邻/点线紧贴,禁止相交)
// 放置冲突时随机偏移重试,仍冲突则放弃该块——保持随机、不整齐
// 两侧随机贴边:每边 65% 概率"封闭"(砖块贴墙,不留沿墙逃生通道),35% 留口
// avoid: 已有块 [{x,y,cr}](上一行等),新块与其也须无重叠
function makeRow(n, y, _count, avoid) {
var out = [];
var placed = [];
avoid = avoid || [];
var [lo, hi] = hpRangeForTurn(n);
var leftOpen = Math.random() < 0.35;
var rightOpen = Math.random() < 0.35;
// 封闭边:块中心距墙 21(贴边形状半径 ≤20,贴墙无缝);开放边留 34 供球通过
var xMin = FIELD.wallL + (leftOpen ? 34 : 21);
var xMax = FIELD.wallR - (rightOpen ? 34 : 21);
var usable = xMax - xMin;
var clampX = function (x) { return Math.max(FIELD.wallL + 23, Math.min(FIELD.wallR - 23, x)); };
function tryPlace(x, yy, cr, desc) {
var i, dx, dy, rr;
for (i = 0; i < placed.length; i++) {
dx = x - placed[i].x; dy = yy - placed[i].y; rr = cr + placed[i].cr;
if (dx * dx + dy * dy < rr * rr) return false; // 相交=拒绝;相等=紧贴=允许
}
for (i = 0; i < avoid.length; i++) {
dx = x - avoid[i].x; dy = yy - avoid[i].y; rr = cr + avoid[i].cr;
if (dx * dx + dy * dy < rr * rr) return false;
}
placed.push({ x: x, y: yy, cr: cr });
out.push(desc);
return true;
}
function mkBlock(x, yy, shape) {
return { shape: shape, hp: randInt(lo, hi), x: x, y: yy, angle: (Math.random() - 0.5) * 0.8 };
}
function placeBlock(x, shape, margin) {
var m = margin || 23; // 常规块留 23(旋转方块半对角 22.8 不出墙)
var cl = function (v) { return Math.max(FIELD.wallL + m, Math.min(FIELD.wallR - m, v)); };
var cr = boundRadius(shape);
x = cl(x);
var yy = y + (Math.random() - 0.5) * 10;
if (tryPlace(x, yy, cr, mkBlock(x, yy, shape))) return;
for (var k = 0; k < 8; k++) { // 冲突:随机偏移重试
var tx = cl(x + (Math.random() - 0.5) * 60);
var ty = y + (Math.random() - 0.5) * 10;
if (tryPlace(tx, ty, cr, mkBlock(tx, ty, shape))) return;
}
}
// 封闭边先放贴墙砖(只选半径 ≤20 的形状,margin 21 贴墙且旋转不出墙)
if (!leftOpen) placeBlock(xMin, ['circle', 'tri', 'penta'][randInt(0, 2)], 21);
if (!rightOpen) placeBlock(xMax, ['circle', 'tri', 'penta'][randInt(0, 2)], 21);
var segments = 4 + randInt(0, 2); // 4-6 个槽位
var segW = usable / segments;
for (var s = 0; s < segments; s++) {
var segCenter = xMin + segW * (s + 0.5);
var r = Math.random();
var nBlocks = r < 0.35 ? 0 : (r < 0.65 ? 1 : (r < 0.85 ? 2 : 3));
var blockGap = 38;
var totalW = (nBlocks - 1) * blockGap;
for (var b = 0; b < nBlocks; b++) {
placeBlock(segCenter - totalW / 2 + b * blockGap + (Math.random() - 0.5) * 8,
SHAPES[randInt(0, SHAPES.length - 1)]);
}
}
// 奖励球混排在卡片中间:锚定本行随机砖块、紧贴其旁(相邻/点线紧贴允许)
if (Math.random() < 0.6) {
var itType = Math.random() < 0.6 ? 'ball' : 'big';
for (var q = 0; q < 10; q++) {
var ix, iy;
if (placed.length && q < 8) {
var anchor = placed[randInt(0, placed.length - 1)];
var aa = Math.random() * Math.PI * 2;
var dist = anchor.cr + 12; // 中心距 = 半径和 → 紧贴
ix = clampX(anchor.x + Math.cos(aa) * dist);
iy = anchor.y + Math.sin(aa) * dist;
} else {
ix = clampX(xMin + usable * Math.random());
iy = y + (Math.random() - 0.5) * 8;
}
if (tryPlace(ix, iy, 12, { item: itType, x: ix, y: iy, angle: 0 })) break;
}
}
return out;
}
// ---- 待发球堆叠位置(漏斗口三角堆) ----
function queuePos(i) {
// 0 在出口,往上三角堆叠
var rows = [
[0],
[-9.5, 9.5],
[-19, 0, 19],
[-28.5, -9.5, 9.5, 28.5]
];
var idx = i;
for (var r = 0; r < rows.length; r++) {
if (idx < rows[r].length) {
return { x: FIELD.arcCx + rows[r][idx], y: FIELD.checkY - 22 - r * 15 };
}
idx -= rows[r].length;
}
return { x: FIELD.arcCx, y: FIELD.checkY - 22 - 4 * 15 - idx * 14 };
}
// ---- 道具 ----
var ITEMS = {
ball: { r: 12, label: '+1' }, // 球数 +1(下回合生效)
big: { r: 12, label: '大' } // 大球回合:球变大,击中 +2 / 伤害 2
};
// ---- 记录行颜色(复刻记录页交替行彩色数字) ----
var RECORD_COLORS = ['#e6c229', '#29abe2', '#b133cc', '#2955e2', '#2980e2', '#29c8e2', '#2955cc', '#33cc55', '#33cc88', '#ccaa29'];
return {
FIELD: FIELD,
funnelSegs: funnelSegs,
PHYS: PHYS,
BALL_R: BALL_R, BIG_R: BIG_R,
FIRE_SPEED: FIRE_SPEED, FIRE_GAP_MS: FIRE_GAP_MS,
SHAPES: SHAPES, shapeRadius: shapeRadius,
hpColor: hpColor, hpRangeForTurn: hpRangeForTurn,
boundRadius: boundRadius,
makeRow: makeRow, queuePos: queuePos,
ITEMS: ITEMS, RECORD_COLORS: RECORD_COLORS,
randInt: randInt
};
})();
@@ -0,0 +1,94 @@
/* ============================================================
* pb_storage.js · 物理弹球 Web 复刻版 · 本地存档层
* 全部 localStorage,key 前缀 pb_,与其他 lab 隔离。
* 内容:最佳分 / 历史记录(按日期倒序,复刻记录页)/ 球数 / 每日+3 / 指南已读 / 音效开关
* ============================================================ */
var PBStorage = (function () {
'use strict';
var NS = 'thought_lab.physics_balls.';
function read(key, def) {
try {
var raw = localStorage.getItem(NS + key);
if (raw === null || raw === undefined) return def;
return JSON.parse(raw);
} catch (e) { return def; }
}
function write(key, val) {
try { localStorage.setItem(NS + key, JSON.stringify(val)); } catch (e) {}
}
// ---- 最佳分 ----
function getBest() { return read('best', 0); }
function setBest(v) { write('best', v); }
// ---- 历史记录:[{ts, date, score, secs}],最多 50 条,日期倒序 ----
function getRecords() { return read('records', []); }
function addRecord(score, secs) {
var list = getRecords();
var d = new Date();
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
list.unshift({
ts: d.getTime(),
date: d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()),
score: score,
secs: secs
});
if (list.length > 50) list.length = 50;
write('records', list);
}
// ---- 球数(跨局保留,原版“球”资产) ----
function getBalls() { return read('balls', 1); }
function addBalls(n) { write('balls', Math.max(0, getBalls() + n)); }
function spendBall() {
var n = getBalls();
if (n <= 0) return false;
write('balls', n - 1);
return true;
}
// ---- 每日 +3:每天首次进入主菜单可领 ----
function dailyKey() {
var d = new Date();
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
function canClaimDaily() { return read('daily', '') !== dailyKey(); }
function claimDaily() {
if (!canClaimDaily()) return false;
write('daily', dailyKey());
addBalls(3);
return true;
}
// ---- 指南已读 ----
function guideSeen() { return read('guide_seen', false); }
function markGuideSeen() { write('guide_seen', true); }
// ---- 音效开关 ----
function soundOn() { return read('sound', true); }
function setSound(on) { write('sound', !!on); }
// ---- 逃逸强度 0-100,默认 50;越高卡球挣脱冲量越大,越简单 ----
function getEscape() {
var v = Number(read('escape', 50));
if (!isFinite(v)) v = 50;
return Math.max(0, Math.min(100, Math.round(v)));
}
function setEscape(v) {
var n = Number(v);
if (!isFinite(n)) n = 50;
write('escape', Math.max(0, Math.min(100, Math.round(n))));
}
return {
getBest: getBest, setBest: setBest,
getRecords: getRecords, addRecord: addRecord,
getBalls: getBalls, addBalls: addBalls, spendBall: spendBall,
canClaimDaily: canClaimDaily, claimDaily: claimDaily,
guideSeen: guideSeen, markGuideSeen: markGuideSeen,
soundOn: soundOn, setSound: setSound,
getEscape: getEscape, setEscape: setEscape
};
})();
@@ -0,0 +1,317 @@
/* ============================================================
* pb_ui.js · 物理弹球 Web 复刻版 · 界面 / 输入 / 主循环
* 屏幕:主菜单(直接拖拽开局) / 指南 / 暂停 / 结束 / 记录 / 设置
* 存档走 PBStorage,音效走 PBAudio。
* ============================================================ */
var PBUi = (function () {
'use strict';
var R = PBRules, F = R.FIELD;
var g = null;
var canvas, ctx;
var lastTs = 0, globalT = 0;
function $(id) { return document.getElementById(id); }
function show(id) { $(id).classList.remove('hidden'); }
function hide(id) { $(id).classList.add('hidden'); }
function fmtSecs(s) {
var m = Math.floor(s / 60), ss = Math.floor(s % 60);
return (m < 10 ? '0' + m : m) + ':' + (ss < 10 ? '0' + ss : ss);
}
// ---- 主菜单演示背景 ----
function toIdleDemo() {
PBGame.startGame(g);
g.state = 'idle';
$('pb-best').textContent = '最佳: ' + PBStorage.getBest().toLocaleString();
$('pb-ball-count').textContent = PBStorage.getBalls();
if (PBStorage.canClaimDaily()) show('pb-daily'); else hide('pb-daily');
show('pb-menu'); hide('pb-hud'); hide('pb-over');
}
// ---- 开局(消耗 1 球;不足时救济 +1,保证可玩) ----
function tryStart() {
if (!PBStorage.spendBall()) {
PBStorage.addBalls(1);
PBStorage.spendBall();
}
PBGame.startGame(g);
lastScore = 0;
hide('pb-menu'); show('pb-hud');
}
function toLogical(e) {
var rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / rect.width * F.W,
y: (e.clientY - rect.top) / rect.height * F.H
};
}
function setAim(p) {
var fx = F.arcCx, fy = F.checkY - 6;
var dx = p.x - fx, dy = p.y - fy;
if (dy < 40) dy = 40;
var ang = Math.atan2(dx, dy);
g.aimAngle = Math.max(-1.25, Math.min(1.25, ang));
}
var downPos = null, pendingStart = false;
function dist(a, b) { var dx = a.x - b.x, dy = a.y - b.y; return Math.sqrt(dx * dx + dy * dy); }
function onDown(e) {
PBAudio.unlock();
if (g.paused || g.state === 'over') return;
var p = toLogical(e);
if (g.state === 'idle') { pendingStart = true; downPos = p; return; } // 主菜单:拖拽超阈值才开局,防误触
if (g.state === 'aim' && g.queue.length) {
g.aiming = true;
downPos = p;
setAim(p);
}
}
function onMove(e) {
var p = toLogical(e);
if (pendingStart && downPos) {
if (dist(p, downPos) > 14) { // 确认是拖拽才开局,并直接进入瞄准
pendingStart = false;
tryStart();
g.aiming = true;
setAim(p);
}
return;
}
if (g.aiming) setAim(p);
}
function onUp(e) {
if (pendingStart) { pendingStart = false; downPos = null; return; } // 主菜单轻点 = 无操作
if (g.aiming) {
g.aiming = false;
var p = e && e.clientX !== undefined ? toLogical(e) : null;
if (p && downPos && dist(p, downPos) < 10) { downPos = null; return; } // 轻点 = 取消瞄准,不发射
downPos = null;
PBGame.fire(g);
}
}
// ---- 结束结算 ----
function onOver() {
PBStorage.addRecord(g.score, Math.floor(g.secs));
var best = PBStorage.getBest();
var isNew = g.score > best;
if (isNew) PBStorage.setBest(g.score);
$('pb-over-score').textContent = g.score.toLocaleString();
$('pb-over-time').textContent = '用时 ' + fmtSecs(g.secs);
$('pb-over-new').style.display = isNew ? '' : 'none';
$('pb-over-best').textContent = '最佳: ' + PBStorage.getBest().toLocaleString();
show('pb-over');
hide('pb-hud');
}
// ---- 记录页 ----
function renderRecords() {
var list = PBStorage.getRecords();
var box = $('pb-records-list');
box.innerHTML = '';
if (!list.length) {
box.innerHTML = '<div class="pb-rec-empty">暂无记录,去打一局吧</div>';
return;
}
for (var i = 0; i < list.length; i++) {
var r = list[i];
var row = document.createElement('div');
row.className = 'pb-rec-row ' + (i % 2 ? 'alt' : '');
var color = R.RECORD_COLORS[i % R.RECORD_COLORS.length];
row.innerHTML =
'<span class="pb-rec-rank" style="color:' + color + '">' + (i + 1) + '.</span>' +
'<span class="pb-rec-date" style="color:' + color + '">' + r.date + '</span>' +
'<span class="pb-rec-right"><b style="color:' + color + '">' + r.score.toLocaleString() + '</b>' +
'<i style="color:' + color + '">' + fmtSecs(r.secs) + '</i></span>';
box.appendChild(row);
}
}
// ---- 指南演示小图 ----
function drawGuideDemo() {
var c = $('pb-guide-demo');
if (!c) return;
var x = c.getContext('2d');
x.clearRect(0, 0, c.width, c.height);
x.strokeStyle = '#8a8a8a'; x.lineWidth = 2;
x.beginPath();
x.moveTo(20, 10); x.lineTo(20, 150);
x.moveTo(200, 10); x.lineTo(200, 150);
x.moveTo(20, 10); x.lineTo(95, 42);
x.moveTo(200, 10); x.lineTo(125, 42);
x.stroke();
x.setLineDash([4, 4]);
x.beginPath(); x.moveTo(20, 48); x.lineTo(200, 48); x.stroke();
x.setLineDash([]);
// 待发球
x.fillStyle = '#a97a45';
[[105, 34], [117, 30], [93, 30], [111, 20]].forEach(function (p) {
x.beginPath(); x.arc(p[0], p[1], 5, 0, 7); x.fill();
});
// 轨迹点
x.fillStyle = '#c89458';
for (var i = 0; i < 9; i++) {
x.beginPath(); x.arc(112 + i * 8, 56 + i * 11, 3, 0, 7); x.fill();
}
// 彩色砖块
var blocks = [
['#f7c81a', 55, 95, '3'], ['#8bd420', 95, 95, '10'], ['#37c837', 130, 95, '8'], ['#f7c81a', 185, 95, '1'],
['#37c837', 90, 122, '13'], ['#2fd4c8', 125, 122, '24'], ['#f7c81a', 172, 122, '6'],
['#2f7bff', 55, 148, '80'], ['#29c8e2', 115, 148, '29'], ['#a02fe0', 185, 148, '100']
];
blocks.forEach(function (b) {
x.fillStyle = b[0];
x.fillRect(b[1] - 11, b[2] - 9, 22, 18);
x.fillStyle = b[3].length > 2 ? '#fff' : '#20180a';
x.font = 'bold 10px sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.fillText(b[3], b[1], b[2] + 1);
});
// 道具
x.strokeStyle = '#eee'; x.setLineDash([3, 3]);
x.beginPath(); x.arc(152, 122, 8, 0, 7); x.stroke();
x.setLineDash([]);
}
// ---- ∞ 三色 logo ----
function drawLogo() {
var c = $('pb-logo');
if (!c) return;
var x = c.getContext('2d');
x.clearRect(0, 0, c.width, c.height);
var colors = ['#f7c81a', '#2f7bff', '#ff2f7b'];
for (var k = 0; k < colors.length; k++) {
x.strokeStyle = colors[k];
x.lineWidth = 5;
x.beginPath();
var off = (k - 1) * 4;
for (var i = 0; i <= 200; i++) {
var t = i / 200 * Math.PI * 2;
var s2 = Math.sin(t) * Math.sin(t);
var px = 110 + 78 * Math.cos(t) / (1 + s2);
var py = 60 + (46 + off) * Math.sin(t) * Math.cos(t) / (1 + s2);
if (i === 0) x.moveTo(px, py); else x.lineTo(px, py);
}
x.stroke();
}
}
function toast(msg) {
var t = $('pb-toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(function () { t.classList.remove('show'); }, 1800);
}
// ---- 分数弹跳反馈 ----
var lastScore = 0;
function popScore() {
var el = $('pb-score');
el.classList.remove('pop');
void el.offsetWidth;
el.classList.add('pop');
}
// ---- 主循环 ----
function loop(ts) {
var dt = Math.min(0.033, (ts - lastTs) / 1000 || 0.016);
lastTs = ts;
globalT += dt;
var prevState = g.state;
PBGame.update(g, dt);
if (g.state === 'over' && prevState !== 'over') onOver();
PBRender.draw(ctx, g, globalT);
// HUD
if (g.state !== 'idle') {
$('pb-time').textContent = '时间 ' + fmtSecs(g.secs);
$('pb-score').textContent = g.score.toLocaleString();
$('pb-balls').textContent = '🥎 ' + (g.queue.length + g.balls.length + g.recycle.length);
$('pb-big').classList.toggle('hidden', !g.bigTurn);
if (g.score !== lastScore) { lastScore = g.score; popScore(); }
}
requestAnimationFrame(loop);
}
function bind() {
canvas = $('pb-canvas');
ctx = canvas.getContext('2d');
canvas.addEventListener('pointerdown', onDown);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
$('pb-pause-btn').addEventListener('click', function () {
if (g.state === 'idle' || g.state === 'over') return;
g.paused = true;
show('pb-pause');
});
$('pb-resume').addEventListener('click', function () { g.paused = false; hide('pb-pause'); });
$('pb-restart').addEventListener('click', function () {
hide('pb-pause');
g.paused = false;
tryStart();
});
$('pb-share').addEventListener('click', function () {
var text = '我在「物理弹球」' + fmtSecs(g.secs) + ' 打了 ' + g.score.toLocaleString() + ' 分,来挑战我!';
try {
navigator.clipboard.writeText(text).then(function () { toast('成绩已复制,去分享吧'); }, function () { toast(text); });
} catch (e) { toast(text); }
});
$('pb-again').addEventListener('click', function () {
hide('pb-over');
tryStart();
});
$('pb-to-menu').addEventListener('click', function () { toIdleDemo(); });
$('pb-help').addEventListener('click', function () { drawGuideDemo(); show('pb-guide'); });
$('pb-guide-ok').addEventListener('click', function () { hide('pb-guide'); PBStorage.markGuideSeen(); });
$('pb-gear').addEventListener('click', function () {
$('pb-sound-toggle').checked = PBStorage.soundOn();
$('pb-escape-slider').value = PBStorage.getEscape();
$('pb-escape-val').textContent = PBStorage.getEscape();
show('pb-settings');
});
$('pb-settings-close').addEventListener('click', function () { hide('pb-settings'); });
$('pb-sound-toggle').addEventListener('change', function (e) {
PBStorage.setSound(e.target.checked);
toast(e.target.checked ? '音效已开启' : '音效已关闭');
});
// 逃逸强度滑块:实时存储,局内立即生效
$('pb-escape-slider').addEventListener('input', function (e) {
PBStorage.setEscape(e.target.value);
$('pb-escape-val').textContent = PBStorage.getEscape();
});
$('pb-side-records').addEventListener('click', function () { renderRecords(); show('pb-records'); });
$('pb-records-close').addEventListener('click', function () { hide('pb-records'); });
$('pb-daily').addEventListener('click', function () {
if (PBStorage.claimDaily()) {
PBAudio.claim();
toast('领取成功 +3 球');
$('pb-ball-count').textContent = PBStorage.getBalls();
hide('pb-daily');
}
});
$('pb-side-balls').addEventListener('click', function () {
toast('当前球数 ' + PBStorage.getBalls() + ';每局消耗 1 球,局内 +1 球道具可补充');
});
}
function init() {
g = PBGame.create();
bind();
drawLogo();
toIdleDemo();
if (!PBStorage.guideSeen()) { drawGuideDemo(); show('pb-guide'); }
requestAnimationFrame(function (ts) { lastTs = ts; requestAnimationFrame(loop); });
}
return { init: init };
})();
window.addEventListener('load', function () { PBUi.init(); });
@@ -88,11 +88,12 @@
<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>静止判定:速度持续低于阈值且存在接触 → 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片)。</li>
<li>静止判定:速度持续低于阈值且存在<strong>非光滑</strong>接触(坑底横挡等)→ 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片);只接触光滑面的球永不休眠,必然落下。</li>
</ul>
</div>
</div>
@@ -115,7 +116,7 @@ var PhysicsCore = (function () {
angle: 0, omega: 0,
m: m, invM: 1 / m,
I: I, invI: 1 / I,
sleeping: false, sleepTimer: 0, contacts: 0
sleeping: false, sleepTimer: 0, contacts: 0, slipContacts: 0
};
}
@@ -136,7 +137,7 @@ var PhysicsCore = (function () {
d2 = dx * dx + dy * dy;
if (d2 >= R * R) return null;
d = Math.sqrt(d2) || 1e-9;
return { nx: dx / d, ny: dy / d, pen: R - d };
return { nx: dx / d, ny: dy / d, pen: R - d, slip: !!s.slip };
}
var c = closestOnSeg(b.x, b.y, s);
dx = b.x - c.x; dy = b.y - c.y;
@@ -147,7 +148,7 @@ var PhysicsCore = (function () {
var R2 = b.r + (s.thk || 0);
if (d2 >= R2 * R2) return null;
d = Math.sqrt(d2) || 1e-9;
var ct = { nx: dx / d, ny: dy / d, pen: R2 - d };
var ct = { nx: dx / d, ny: dy / d, pen: R2 - d, slip: !!s.slip };
// 运动碰撞体(雨刮拨片):按接触点附表面线速度 v=ω×r,供相对速度冲量求解
if (s.mov) { ct.svx = -s.mov.omega * (c.y - s.mov.py); ct.svy = s.mov.omega * (c.x - s.mov.px); }
return ct;
@@ -206,14 +207,19 @@ var PhysicsCore = (function () {
var vt0 = rvx * tx + rvy * ty - a.omega * a.r;
var Kt0 = a.invM + a.r * a.r * a.invI;
var jt0 = -vt0 / Kt0;
var maxF0 = p.mu * jnAcc0;
// 光滑面(钉/拨片/隔板等,slip)摩擦恒为 0:理论上不产生任何切向摩擦冲量
var maxF0 = (c.slip ? 0 : p.mu) * jnAcc0;
if (jt0 > maxF0) jt0 = maxF0; else if (jt0 < -maxF0) jt0 = -maxF0;
a.vx += tx * jt0 * a.invM; a.vy += ty * jt0 * a.invM;
a.omega -= a.r * jt0 * a.invI;
}
}
// 推进一步 dt:积分 -> 收集接触 -> 冲量迭代 -> 位置校正 -> 滚动阻力
// 防粘附推力:光滑面上「正上方平衡」是不稳定平衡,离散求解器可能维持完美对称
// 导致球视觉冻结在钉顶/隔板顶。对法线朝上的光滑接触施加微小水平加速度,必滑落。
var STICK_K = 240;
// 推进一步 dt:积分 -> 收集接触 -> 防粘附 -> 冲量迭代 -> 位置校正 -> 滚动阻力
function stepWorld(balls, statics, p, dt) {
var i, j, b, o, c;
for (i = 0; i < balls.length; i++) {
@@ -227,6 +233,7 @@ var PhysicsCore = (function () {
b.y += b.vy * dt;
b.angle += b.omega * dt;
b.contacts = 0;
b.slipContacts = 0;
}
var contacts = [];
for (i = 0; i < balls.length; i++) {
@@ -234,7 +241,7 @@ var PhysicsCore = (function () {
if (b.sleeping) continue;
for (j = 0; j < statics.length; j++) {
c = contactBallStatic(b, statics[j]);
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; }
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; if (c.slip) b.slipContacts++; }
}
for (j = 0; j < balls.length; j++) {
o = balls[j];
@@ -248,6 +255,13 @@ var PhysicsCore = (function () {
}
}
}
// 防粘附:球坐在光滑面顶部(法线朝上)时施加微小水平推力,打破平衡使其滑落
for (j = 0; j < contacts.length; j++) {
c = contacts[j];
if (!c.slip || c.b || c.ny > -0.6) continue;
var dir = c.nx > 0.02 ? 1 : (c.nx < -0.02 ? -1 : 1);
c.a.vx += dir * STICK_K * dt;
}
for (var it = 0; it < p.iterations; it++) {
for (j = 0; j < contacts.length; j++) solveContact(contacts[j], p);
}
@@ -268,7 +282,8 @@ var PhysicsCore = (function () {
}
for (i = 0; i < balls.length; i++) {
b = balls[i];
if (b.sleeping || b.contacts === 0) continue;
// 全部接触都是光滑面时不加滚动阻力:球必须继续滑动/下落,不能被“磨”停
if (b.sleeping || b.contacts === 0 || b.slipContacts >= b.contacts) continue;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (sp > 1e-6) {
var dec = Math.min(sp, p.rollR * p.g * dt);
@@ -322,8 +337,11 @@ function buildTableStatics(pegR, pegSpacing) {
// 外框四壁
statics.push(seg(L, T, R, T), seg(L, B, R, B), seg(L, T, L, B), seg(R, T, R, B));
// 发射道左壁(顶端接弧形导轨右端,底端接底壁封死,杜绝壁底缺口侧逃)
statics.push(seg(LANE_X, 190, LANE_X, B));
// 弧形导轨(离散为线段链)
// slip:光滑壁,顶端不得存球(它同时是最右坑的右隔板)
var lane = seg(LANE_X, 190, LANE_X, B);
lane.slip = true;
statics.push(lane);
// 弧形导轨(离散为线段链,slip:抛光导轨摩擦为 0,弧顶不存球)
var steps = 40;
var a0 = ARCH.a0 * Math.PI / 180, a1 = ARCH.a1 * Math.PI / 180;
var px = 0, py = 0;
@@ -331,12 +349,17 @@ function buildTableStatics(pegR, pegSpacing) {
var a = a0 + (a1 - a0) * (i / steps);
var x = ARCH.cx + ARCH.r * Math.cos(a);
var y = ARCH.cy + ARCH.r * Math.sin(a);
if (i > 0) statics.push(seg(px, py, x, y));
if (i > 0) { var as = seg(px, py, x, y); as.slip = true; statics.push(as); }
px = x; py = y;
}
// 落球坑:5 根分隔木条(右边界复用发射道左壁,不另设右长条,避免与道壁重叠/留缝)
// slip:隔板摩擦为 0,隔板顶不得存球,球必须落入坑内
var slotW = (SLOT_X1 - SLOT_X0) / SLOT_N;
for (i = 1; i < SLOT_N; i++) statics.push(seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT));
for (i = 1; i < SLOT_N; i++) {
var dv = seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT);
dv.slip = true;
statics.push(dv);
}
// 底部横挡木条(球停在它上面)
statics.push(seg(SLOT_X0, BAR_Y, SLOT_X1, BAR_Y));
// 圆钉阵(交错,间距/尺寸可调)
@@ -351,7 +374,8 @@ function buildTableStatics(pegR, pegSpacing) {
var yy = cy + (row.y - cy) * pegSpacing;
for (var xI = 0; xI < row.xs.length; xI++) {
var xx = cx + (row.xs[xI] - cx) * pegSpacing;
statics.push({ type: 'circle', x: xx, y: yy, r: pegR });
// slip:圆钉摩擦为 0,钉顶不得存球,球必须滑落
statics.push({ type: 'circle', x: xx, y: yy, r: pegR, slip: true });
pegs.push({ x: xx, y: yy, r: pegR, metal: ((rI + xI) % 3 === 2) });
}
}
@@ -378,7 +402,8 @@ function makeWiperSegs() {
var out = [];
for (var i = 0; i < WIPERS.length; i++) {
// thk=4 胶囊厚度:防高速球穿透(见 contactBallStatic 注释)
out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, mov: { px: 0, py: 0, omega: 0 } });
// slip:拨片摩擦为 0,不得存球
out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, slip: true, mov: { px: 0, py: 0, omega: 0 } });
}
return out;
}
@@ -571,7 +596,9 @@ function updateWiperSeg(sg, w, t, spin) {
if (!b.counted && !b.ready && !b.sleeping && b.x < LANE_X - b.r) { b.counted = true; launched++; }
if (b.sleeping) continue;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (sp < 15 && b.contacts > 0) b.sleepTimer += dt; else b.sleepTimer = 0;
// 仅当存在非光滑接触(坑底横挡/外框等)才允许休眠;
// 全光滑接触(钉/拨片/隔板顶)永不休眠,球必须落下
if (sp < 15 && b.contacts > 0 && b.slipContacts < b.contacts) b.sleepTimer += dt; else b.sleepTimer = 0;
if (b.sleepTimer > 0.5) settleBall(b);
if (b.y > B + BALL_R + 8 || b.y > H + 40 || b.x < -40 || b.x > W + 40) { // 出界保护:回到弹簧
b.x = SPRING_X; b.y = SPRING_TOP - BALL_R;