feat: 新增物理弹球实验室及配套功能
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_dom_smoke.js · 物理弹球 · 前端 DOM 桩冒烟
|
||||
* 无浏览器环境下用最小 DOM/Canvas 桩加载全部前端脚本,
|
||||
* 驱动主循环 + 模拟拖拽发射/暂停/记录等交互,捕获运行时错误。
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_dom_smoke.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const FILES = ['pb_physics.js', 'pb_rules.js', 'pb_storage.js', 'pb_audio.js', 'pb_render.js', 'pb_game.js', 'pb_ui.js'];
|
||||
|
||||
function makeCtx() {
|
||||
const store = {};
|
||||
return new Proxy(store, {
|
||||
get(t, k) {
|
||||
if (k === 'createRadialGradient') return () => ({ addColorStop() {} });
|
||||
if (k === 'measureText') return () => ({ width: 10 });
|
||||
if (k in t) return t[k];
|
||||
return function () {};
|
||||
},
|
||||
set(t, k, v) { t[k] = v; return true; }
|
||||
});
|
||||
}
|
||||
|
||||
function makeEl(id) {
|
||||
const listeners = {};
|
||||
return {
|
||||
id,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
children: [],
|
||||
_html: '', _text: '',
|
||||
set innerHTML(v) { this._html = v; this.children = []; },
|
||||
get innerHTML() { return this._html; },
|
||||
set textContent(v) { this._text = String(v); },
|
||||
get textContent() { return this._text; },
|
||||
appendChild(c) { this.children.push(c); },
|
||||
addEventListener(t, f) { (listeners[t] = listeners[t] || []).push(f); },
|
||||
dispatch(t, ev) { (listeners[t] || []).forEach(f => f(ev || {})); },
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 420, height: 880 }; },
|
||||
getContext() { return makeCtx(); },
|
||||
checked: true,
|
||||
width: 420, height: 880
|
||||
};
|
||||
}
|
||||
|
||||
const els = {};
|
||||
function getEl(id) { if (!els[id]) els[id] = makeEl(id); return els[id]; }
|
||||
|
||||
let rafCb = null;
|
||||
const winListeners = {};
|
||||
const sandbox = {
|
||||
console, Math, Date, setTimeout, clearTimeout,
|
||||
JSON, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
navigator: { clipboard: { writeText: () => ({ then: (a) => a && a() }) } },
|
||||
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
|
||||
document: {
|
||||
getElementById: getEl,
|
||||
createElement: (tag) => makeEl('dyn_' + tag)
|
||||
},
|
||||
addEventListener: (t, f) => { (winListeners[t] = winListeners[t] || []).push(f); },
|
||||
dispatchWin: (t, ev) => { (winListeners[t] || []).forEach(f => f(ev || {})); },
|
||||
window: null
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
|
||||
for (const f of FILES) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB_DIR, f), 'utf-8'), sandbox, { filename: f });
|
||||
}
|
||||
|
||||
function frames(n, t0) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cb = rafCb; rafCb = null;
|
||||
if (!cb) throw new Error('rAF chain broken');
|
||||
cb(t0 + i * 16.7);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('== 物理弹球 DOM 冒烟 ==');
|
||||
// init(load 事件不会在 vm 里触发,手动调)
|
||||
sandbox.PBUi.init();
|
||||
frames(30, 0);
|
||||
console.log(' ✓ init + 30 帧主菜单渲染');
|
||||
|
||||
// 模拟拖拽发射(pointermove/up 注册在 window 级)
|
||||
const canvas = getEl('pb-canvas');
|
||||
canvas.dispatch('pointerdown', { clientX: 260, clientY: 600 });
|
||||
frames(5, 600);
|
||||
sandbox.dispatchWin('pointermove', { clientX: 300, clientY: 650 });
|
||||
sandbox.dispatchWin('pointerup', {});
|
||||
frames(60 * 6, 700);
|
||||
console.log(' ✓ 拖拽发射 + 6s 模拟');
|
||||
|
||||
// 暂停 / 继续
|
||||
getEl('pb-pause-btn').dispatch('click');
|
||||
getEl('pb-resume').dispatch('click');
|
||||
frames(60, 60 * 7 * 16.7);
|
||||
console.log(' ✓ 暂停/继续');
|
||||
|
||||
// 记录页 / 指南 / 设置 / 每日领取
|
||||
getEl('pb-side-records').dispatch('click');
|
||||
getEl('pb-records-close').dispatch('click');
|
||||
getEl('pb-help').dispatch('click');
|
||||
getEl('pb-guide-ok').dispatch('click');
|
||||
getEl('pb-gear').dispatch('click');
|
||||
getEl('pb-settings-close').dispatch('click');
|
||||
getEl('pb-daily').dispatch('click');
|
||||
console.log(' ✓ 记录/指南/设置/每日领取交互');
|
||||
|
||||
// 长时模拟 60s,覆盖回合推进/上升/回收
|
||||
for (let s = 0; s < 60; s++) {
|
||||
if (s % 7 === 0) {
|
||||
canvas.dispatch('pointerdown', { clientX: 150 + (s * 13) % 200, clientY: 620 });
|
||||
sandbox.dispatchWin('pointermove', { clientX: 190 + (s * 13) % 160, clientY: 660 });
|
||||
sandbox.dispatchWin('pointerup', {});
|
||||
}
|
||||
frames(60, (10 + s) * 1000);
|
||||
}
|
||||
console.log(' ✓ 60s 长时模拟(含多次发射)');
|
||||
console.log('\n冒烟通过,无运行时异常');
|
||||
@@ -0,0 +1,52 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_preview_match.js · 物理弹球 · 预览=实际轨迹验证
|
||||
* 清空砖块后以固定角度发射,对比 simulatePath 预览点与真实球位置:
|
||||
* 瞄准折线必须与球实际运动线路一致(误差 < 2px)。
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_preview_match.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const ctx = {
|
||||
Math,
|
||||
console,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {}, claim() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js', 'pb_render.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
|
||||
}
|
||||
|
||||
const G = ctx.PBGame, RD = ctx.PBRender;
|
||||
|
||||
let ok = true;
|
||||
for (const ang of [0, 0.35, -0.6, 1.0]) {
|
||||
const g = G.create();
|
||||
G.startGame(g);
|
||||
g.blocks = []; g.items = []; // 清空砖块,保证对比确定
|
||||
g.aimAngle = ang;
|
||||
|
||||
const pts = RD.simulatePath(g); // 预览点:pts[k] 对应 t = k/60 s(subDt=1/240,每 4 子步采样)
|
||||
|
||||
G.fire(g);
|
||||
let maxErr = 0, checked = 0;
|
||||
for (let f = 1; f <= 30; f++) { // 前 0.5s,每帧 4 子步与预览采样对齐
|
||||
G.update(g, 1 / 60);
|
||||
if (!g.balls.length) break;
|
||||
if (f % 2) continue;
|
||||
if (f >= pts.length) break;
|
||||
const b = g.balls[0];
|
||||
const err = Math.hypot(b.x - pts[f].x, b.y - pts[f].y);
|
||||
maxErr = Math.max(maxErr, err);
|
||||
checked++;
|
||||
}
|
||||
const pass = checked >= 10 && maxErr < 2;
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + 'angle=' + ang + ' checked=' + checked + ' maxErr=' + maxErr.toFixed(2) + 'px');
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,127 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_stuck_escape.js · 物理弹球 · 卡球逃脱验证
|
||||
* 规则:球陷缝隙 0.5s 后横向滑出缺口继续下落;禁止向上弹射
|
||||
* ("必须落下后才能上去",唯一上升途径是底部输送带)。
|
||||
* 验证:1) 卡球能逃脱并下落/回收 2) 上升幅度 <=120px 3) 左右方向有随机性
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_stuck_escape.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const ctx = {
|
||||
Math,
|
||||
console,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
|
||||
}
|
||||
|
||||
const P = ctx.PBPhysics, G = ctx.PBGame;
|
||||
|
||||
// 跑 8s:成功 = 被回收 或 下落到 startY+200 以下;同时记录最小 y(上升幅度)
|
||||
function runScenario(setup, startY) {
|
||||
const g = G.create();
|
||||
g.state = 'fly';
|
||||
setup(g);
|
||||
let minY = 1e9, maxX = 0, minX = 1e9, outcome = 'stuck';
|
||||
for (let i = 0; i < 60 * 8; i++) {
|
||||
G.update(g, 1 / 60);
|
||||
if (!g.balls.length) { outcome = 'recycled'; break; }
|
||||
const b = g.balls[0];
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x);
|
||||
minX = Math.min(minX, b.x);
|
||||
if (b.y > startY + 200) { outcome = 'fell'; break; }
|
||||
}
|
||||
return { outcome, minY, spread: maxX - minX, g };
|
||||
}
|
||||
|
||||
let ok = true;
|
||||
function check(name, res, startY) {
|
||||
const escaped = res.outcome !== 'stuck';
|
||||
const noLaunch = res.minY > startY - 120; // 不允许向上弹射超过 120px
|
||||
const pass = escaped && noLaunch;
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + name +
|
||||
' outcome=' + res.outcome + ' minY=' + (res.minY === 1e9 ? 'recycled' : res.minY.toFixed(1)) +
|
||||
' hSpread=' + res.spread.toFixed(0));
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
// rest 默认 0.2:隔离"逃脱冲量本身",排除高弹块自然反弹的干扰
|
||||
const mkCircle = (g, x, y, r, rest) => {
|
||||
const b = P.makeCircle({ shape: 'circle', x, y, r, isStatic: true, friction: 0.35, restitution: rest === undefined ? 0.2 : rest, tag: 'block' });
|
||||
b.data = { hp: 99, shape: 'circle', r };
|
||||
g.blocks.push(b);
|
||||
};
|
||||
const mkBall = (g, x, y) => {
|
||||
const ball = P.makeCircle({ shape: 'circle', x, y, r: 7, friction: 0.08, restitution: 0.95, tag: 'ball' });
|
||||
ball.data = { big: false };
|
||||
g.balls.push(ball);
|
||||
};
|
||||
|
||||
// 场景1:球静止放在两圆块谷地 → 应横向滑出后下落,不上天
|
||||
check('谷地卡球逃脱', runScenario((g) => {
|
||||
mkCircle(g, 180, 400, 22); mkCircle(g, 224, 400, 22);
|
||||
mkBall(g, 202, 381);
|
||||
}, 381), 381);
|
||||
|
||||
// 场景2:球静止放在单块顶部 → 应被推离块顶后下落;且单接触不触发研磨(块 HP 不变)
|
||||
{
|
||||
const res = runScenario((g) => {
|
||||
mkCircle(g, 200, 400, 22);
|
||||
mkBall(g, 200, 371);
|
||||
}, 371);
|
||||
check('块顶停球逃脱', res, 371);
|
||||
const intact = res.g.blocks.length === 1 && res.g.blocks[0].data.hp === 99;
|
||||
console.log((intact ? 'PASS ' : 'FAIL ') + '单接触不研磨 hp=' + (res.g.blocks[0] ? res.g.blocks[0].data.hp : 'gone'));
|
||||
ok = intact && ok;
|
||||
}
|
||||
|
||||
// 场景5:球楔入两低血块夹角 → 研磨快速打碎砖块、留出空间后下落
|
||||
{
|
||||
const res = runScenario((g) => {
|
||||
mkCircle(g, 180, 400, 22); mkCircle(g, 224, 400, 22);
|
||||
g.blocks.forEach(b => { b.data.hp = 8; });
|
||||
mkBall(g, 202, 381);
|
||||
}, 381);
|
||||
const ground = res.g.blocks.length === 0;
|
||||
const pass = ground && res.outcome !== 'stuck';
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + '夹角研磨开空间 outcome=' + res.outcome + ' blocksLeft=' + res.g.blocks.length);
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
// 场景3:球停底部弧面(无砖块接触)→ 1.2s 后应被回收
|
||||
check('底部停球回收', runScenario((g) => {
|
||||
mkBall(g, 210, 790);
|
||||
}, 790), 790);
|
||||
|
||||
// 场景4:单接触停球逃脱,横向方向应有随机性(左右都出现)
|
||||
{
|
||||
const dirs = [];
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const g = G.create();
|
||||
g.state = 'fly';
|
||||
mkCircle(g, 200, 400, 22);
|
||||
mkBall(g, 200 + (Math.random() * 4 - 2), 371); // 真实落点总有微小偏移
|
||||
let firstVx = null;
|
||||
for (let i = 0; i < 60 * 3; i++) {
|
||||
G.update(g, 1 / 60);
|
||||
if (!g.balls.length) break;
|
||||
const b = g.balls[0];
|
||||
if (firstVx === null && Math.abs(b.vx) > 200) { firstVx = b.vx; break; }
|
||||
}
|
||||
if (firstVx !== null) dirs.push(firstVx > 0 ? 'R' : 'L');
|
||||
}
|
||||
const hasL = dirs.includes('L'), hasR = dirs.includes('R');
|
||||
const pass = dirs.length >= 6 && hasL && hasR;
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + '逃逸方向随机性 ' + dirs.join(''));
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,47 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_volley_loop.js · 物理弹球 · 大连发死循环回归
|
||||
* 复现场景:队列 15 球连发。旧 bug:连发期间回收球回队使 queue 永不空,
|
||||
* 状态永远停在 firing(无限连发循环,回合不推进)。
|
||||
* 断言:1) firing 在 ~3s 内结束 2) 60s 内回合推进(turn>=2 或 over)
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_volley_loop.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const ctx = {
|
||||
Math,
|
||||
console,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
|
||||
}
|
||||
|
||||
const G = ctx.PBGame;
|
||||
let ok = true;
|
||||
|
||||
for (const n of [15, 25]) {
|
||||
const g = G.create();
|
||||
G.startGame(g);
|
||||
g.queue = Array(n).fill(true); // 15/25 个大球,复现截图局面
|
||||
g.aimAngle = 0.2;
|
||||
G.fire(g);
|
||||
|
||||
let firingEnd = -1, turnAdvanced = false;
|
||||
for (let i = 1; i <= 60 * 60; i++) {
|
||||
G.update(g, 1 / 60);
|
||||
if (firingEnd < 0 && g.state !== 'firing') firingEnd = i / 60;
|
||||
if (g.turn >= 2 || g.state === 'over') { turnAdvanced = true; break; }
|
||||
}
|
||||
const p1 = firingEnd > 0 && firingEnd <= n * 0.13 + 1.5;
|
||||
console.log((p1 ? 'PASS ' : 'FAIL ') + n + '球连发 firing 结束于 ' + (firingEnd < 0 ? '未结束(死循环)' : firingEnd.toFixed(1) + 's'));
|
||||
console.log((turnAdvanced ? 'PASS ' : 'FAIL ') + n + '球连发 回合推进 turn=' + g.turn + ' state=' + g.state + ' queue=' + g.queue.length);
|
||||
ok = p1 && turnAdvanced && ok;
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,236 @@
|
||||
/* ============================================================
|
||||
* test_physics_balls_physics.js · 物理弹球 Web 复刻版 · 物理核心单元测试
|
||||
* 运行:node dev_test_scripts/unit/test_physics_balls_physics.js
|
||||
* 覆盖:静止不沉 / 能量不增 / 堆叠稳定 / 高速不穿透 / 休眠 / 碰撞流形 / 整局模拟无 NaN
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
|
||||
function load(name, sandbox) {
|
||||
const src = fs.readFileSync(path.join(LAB_DIR, name), 'utf-8');
|
||||
vm.runInContext(src, sandbox, { filename: name });
|
||||
}
|
||||
|
||||
// pb_game 运行时依赖 PBAudio,注入静音桩
|
||||
const sandbox = {
|
||||
console, Math, Date,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, breakBlock() {}, item() {}, over() {}, claim() {}, unlock() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
load('pb_physics.js', sandbox);
|
||||
load('pb_rules.js', sandbox);
|
||||
load('pb_game.js', sandbox);
|
||||
const P = sandbox.PBPhysics;
|
||||
const R = sandbox.PBRules;
|
||||
const G = sandbox.PBGame;
|
||||
const P2 = P, G2 = G, R2 = R;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function t(name, fn) {
|
||||
try { fn(); pass++; console.log(' ✓ ' + name); }
|
||||
catch (e) { fail++; console.error(' ✗ ' + name + ' -> ' + e.message); }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assert failed'); }
|
||||
function near(a, b, eps, msg) { if (Math.abs(a - b) > eps) throw new Error((msg || 'near') + `: ${a} vs ${b}`); }
|
||||
|
||||
const PHYS = R.PHYS;
|
||||
|
||||
console.log('== 物理弹球 物理核心单元测试 ==');
|
||||
|
||||
t('球落在静态地面上:5s 后静止且不沉陷', () => {
|
||||
const ground = P.makeCircle({ shape: 'circle', x: 200, y: 1500, r: 1000, isStatic: true, friction: 0.3 });
|
||||
const ball = P.makeCircle({ shape: 'circle', x: 200, y: 400, r: 7, friction: 0.2, restitution: 0.3 });
|
||||
for (let i = 0; i < 360 * 5; i++) P.stepWorld([ground, ball], PHYS, 1 / 360, {});
|
||||
const surf = 1500 - 1000;
|
||||
assert(Math.abs(ball.y - (surf - 7)) < 3, 'ball y=' + ball.y + ' expect ~' + (surf - 7));
|
||||
assert(ball.sleeping, 'ball should sleep');
|
||||
});
|
||||
|
||||
t('能量不增:自由落体反弹后机械能 <= 初始', () => {
|
||||
const ground = P.makeCircle({ shape: 'circle', x: 200, y: 1600, r: 1000, isStatic: true });
|
||||
const ball = P.makeCircle({ shape: 'circle', x: 200, y: 300, r: 7, restitution: 0.5 });
|
||||
const E0 = PHYS.g * (1600 - 1000 - 7 - 300);
|
||||
let maxE = -1e9;
|
||||
for (let i = 0; i < 360 * 3; i++) {
|
||||
P.stepWorld([ground, ball], PHYS, 1 / 360, {});
|
||||
const h = (600 - 7) - ball.y;
|
||||
const E = PHYS.g * h + 0.5 * (ball.vx * ball.vx + ball.vy * ball.vy);
|
||||
if (E > maxE) maxE = E;
|
||||
}
|
||||
assert(maxE <= E0 * 1.05, 'energy grew: ' + maxE + ' > ' + E0);
|
||||
});
|
||||
|
||||
t('静态砖块受球撞击后位置不变(锁死固定)', () => {
|
||||
const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 200, y: 300, isStatic: true, friction: 0.35, restitution: 0.7 });
|
||||
const ball = P.makeCircle({ shape: 'circle', x: 200, y: 250, r: 7, restitution: 0.7 });
|
||||
ball.vy = 800;
|
||||
let pairs = {};
|
||||
for (let i = 0; i < 360; i++) pairs = P.stepWorld([box, ball], PHYS, 1 / 360, pairs).pairs;
|
||||
assert(Math.abs(box.x - 200) < 0.01 && Math.abs(box.y - 300) < 0.01, 'static block moved');
|
||||
// 球可能多次反弹后停在砖块顶部(物理正确),但不应穿透
|
||||
assert(ball.y > 250, 'ball should not penetrate block, y=' + ball.y);
|
||||
});
|
||||
|
||||
t('高速球不穿透薄墙(3600px/s)', () => {
|
||||
const wall = P.makePoly({ shape: 'poly', verts: P.boxVerts(8, 400), x: 16, y: 400, angle: 0, isStatic: true });
|
||||
const ball = P.makeCircle({ shape: 'circle', x: 300, y: 400, r: 7 });
|
||||
ball.vx = -3600;
|
||||
let pairs = {};
|
||||
for (let i = 0; i < 360 * 2; i++) pairs = P.stepWorld([wall, ball], PHYS, 1 / 360, pairs).pairs;
|
||||
assert(ball.x > 16 + 4, 'ball tunneled: x=' + ball.x);
|
||||
});
|
||||
|
||||
t('circle-poly 流形法线方向正确(圆在多边形上方)', () => {
|
||||
const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(60, 20), x: 100, y: 100, isStatic: true });
|
||||
const ball = P.makeCircle({ shape: 'circle', x: 100, y: 85, r: 7 });
|
||||
const m = P.collide(ball, box);
|
||||
assert(m, 'should collide');
|
||||
// 法线 a→b:ball 是 a,box 是 b,法线应向下(ny>0)
|
||||
assert(m.ny > 0.9, 'normal ny=' + m.ny);
|
||||
});
|
||||
|
||||
t('poly-poly 接触产生流形(两方块叠放)', () => {
|
||||
const a = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 100, y: 100, isStatic: true });
|
||||
const b = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 100, y: 71 });
|
||||
const m = P.collide(a, b);
|
||||
assert(m, 'should collide');
|
||||
assert(m.points.length >= 1, 'manifold points');
|
||||
});
|
||||
|
||||
t('新接触事件:球撞静态块触发一次 event', () => {
|
||||
const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 200, y: 300, isStatic: true });
|
||||
const ball = P.makeCircle({ shape: 'circle', x: 200, y: 100, r: 7 });
|
||||
ball.vy = 800;
|
||||
let pairs = {}, events = 0;
|
||||
for (let i = 0; i < 360; i++) {
|
||||
const res = P.stepWorld([box, ball], PHYS, 1 / 360, pairs);
|
||||
pairs = res.pairs;
|
||||
events += res.events.length;
|
||||
}
|
||||
assert(events >= 1, 'no hit event');
|
||||
});
|
||||
|
||||
t('规则层:makeRow 的 hp 在回合区间内', () => {
|
||||
for (let n = 1; n <= 30; n++) {
|
||||
const [lo, hi] = R.hpRangeForTurn(n);
|
||||
const row = R.makeRow(n, 700, 5);
|
||||
row.forEach(d => {
|
||||
if (d.item) return;
|
||||
assert(d.hp >= lo && d.hp <= hi, 'hp out of range n=' + n);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
t('规则层:两侧随机贴边(有封边也有留口,块不穿墙)', () => {
|
||||
const WL = R.FIELD.wallL, WR = R.FIELD.wallR;
|
||||
let leftClosed = 0, rightClosed = 0, leftOpen = 0, rightOpen = 0;
|
||||
for (let n = 1; n <= 80; n++) {
|
||||
const row = R.makeRow(n, 700, 5);
|
||||
const hasL = row.some(d => !d.item && d.x <= WL + 26);
|
||||
const hasR = row.some(d => !d.item && d.x >= WR - 26);
|
||||
if (hasL) leftClosed++; else leftOpen++;
|
||||
if (hasR) rightClosed++; else rightOpen++;
|
||||
row.forEach(d => {
|
||||
if (d.item) return;
|
||||
assert(d.x >= WL + 19.5 && d.x <= WR - 19.5, 'block out of wall x=' + d.x);
|
||||
});
|
||||
}
|
||||
assert(leftClosed > 10 && rightClosed > 10, 'sides never closed');
|
||||
assert(leftOpen > 5 && rightOpen > 5, 'sides never open');
|
||||
});
|
||||
|
||||
t('规则层:同行块严格无重叠(允许紧贴,含道具)', () => {
|
||||
for (let n = 1; n <= 60; n++) {
|
||||
const row = R.makeRow(n, 700, 5);
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
for (let j = i + 1; j < row.length; j++) {
|
||||
const a = row[i], b = row[j];
|
||||
const ca = a.item ? 12 : R.boundRadius(a.shape);
|
||||
const cb = b.item ? 12 : R.boundRadius(b.shape);
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
assert(d >= ca + cb - 1e-6, 'overlap d=' + d.toFixed(1) + ' need>=' + (ca + cb).toFixed(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t('规则层:相邻行块也无重叠(行距52,含抖动)', () => {
|
||||
for (let round = 0; round < 10; round++) {
|
||||
const avoid = [], all = [];
|
||||
for (let n = 1; n <= 6; n++) {
|
||||
const y = 792 - (n - 1) * 52;
|
||||
const row = R.makeRow(n, y, 5, avoid);
|
||||
row.forEach(d => {
|
||||
const cr = d.item ? 12 : R.boundRadius(d.shape);
|
||||
avoid.push({ x: d.x, y: d.y, cr });
|
||||
all.push({ x: d.x, y: d.y, cr });
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < all.length; i++)
|
||||
for (let j = i + 1; j < all.length; j++) {
|
||||
const d = Math.hypot(all[i].x - all[j].x, all[i].y - all[j].y);
|
||||
assert(d >= all[i].cr + all[j].cr - 1e-6, 'cross-row overlap');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t('规则层:奖励球紧贴混排在卡片旁(>=80% 相邻)', () => {
|
||||
let items = 0, adjacent = 0;
|
||||
for (let n = 1; n <= 60; n++) {
|
||||
const row = R.makeRow(n, 700, 5);
|
||||
const blocks = row.filter(d => !d.item);
|
||||
row.forEach(d => {
|
||||
if (!d.item) return;
|
||||
items++;
|
||||
if (blocks.some(b => Math.hypot(b.x - d.x, b.y - d.y) < R.boundRadius(b.shape) + 12 + 8)) adjacent++;
|
||||
});
|
||||
}
|
||||
assert(items > 10, 'too few items: ' + items);
|
||||
assert(adjacent / items >= 0.8, 'adjacent ratio ' + (adjacent / items).toFixed(2));
|
||||
});
|
||||
|
||||
t('游戏层:奖励球静态不滚落(3s 位置不变)', () => {
|
||||
const g = G2.create();
|
||||
G2.startGame(g);
|
||||
const before = g.items.map(it => it.x.toFixed(1) + ',' + it.y.toFixed(1)).join('|');
|
||||
for (let i = 0; i < 180; i++) G2.update(g, 1 / 60);
|
||||
const after = g.items.map(it => it.x.toFixed(1) + ',' + it.y.toFixed(1)).join('|');
|
||||
assert(before === after, 'items moved: ' + after);
|
||||
});
|
||||
|
||||
t('整局模拟 20s:发射/回收/回合推进无 NaN 无异常', () => {
|
||||
const g = G2.create();
|
||||
G2.startGame(g);
|
||||
g.aimAngle = 0.3;
|
||||
let fired = 0;
|
||||
for (let i = 0; i < 60 * 40; i++) {
|
||||
if (g.state === 'aim' && g.queue.length && i % 60 === 0) { g.aimAngle = (Math.random() - 0.5) * 1.6; G2.fire(g); fired++; }
|
||||
G2.update(g, 1 / 60);
|
||||
if (g.state === 'over') break;
|
||||
}
|
||||
assert(fired >= 3, 'should fire several turns, fired=' + fired);
|
||||
const all = g.blocks.concat(g.items, g.balls);
|
||||
assert(all.every(b => isFinite(b.x) && isFinite(b.y) && isFinite(b.angle)), 'NaN bodies');
|
||||
assert(g.turn >= 3, 'turns should advance, turn=' + g.turn);
|
||||
});
|
||||
|
||||
t('回收机制:角落球进入输送带并回到队列', () => {
|
||||
const g = G2.create();
|
||||
G2.startGame(g);
|
||||
// 手动塞一个球到左角落
|
||||
const b = P2.makeCircle({ shape: 'circle', x: R2.FIELD.wallL + 10, y: 800, r: 7, tag: 'ball' });
|
||||
b.data = { big: false };
|
||||
g.balls.push(b);
|
||||
g.state = 'fly';
|
||||
for (let i = 0; i < 60 * 3; i++) G2.update(g, 1 / 60);
|
||||
assert(g.recycle.length === 0 && g.balls.length === 0, 'ball should be recycled back to queue');
|
||||
assert(g.queue.length >= 1, 'queue should get ball back');
|
||||
});
|
||||
|
||||
console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -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>红区(>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;
|
||||
|
||||
@@ -221,6 +221,7 @@ const groupItemsFromRows = (rows) => {
|
||||
const AUTH_COOKIE = 'private_clipboard_auth'
|
||||
const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 // 1天
|
||||
const AUTH_HMAC_SECRET = process.env.PRIVATE_CLIPBOARD_AUTH_SECRET || 'pc-auth-secret-change-me'
|
||||
const UPLOAD_LIMIT_MB = 200
|
||||
|
||||
const signAuthPayload = (username) => {
|
||||
const payload = { sub: username, iat: Date.now() }
|
||||
@@ -660,6 +661,20 @@ const createRouter = () => {
|
||||
}
|
||||
})
|
||||
|
||||
router.use((err, req, res, next) => {
|
||||
if (!err) return next()
|
||||
if (req && req.path === '/upload') {
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({ ok: false, error: 'file_too_large', limit_mb: UPLOAD_LIMIT_MB })
|
||||
}
|
||||
if (err.type === 'request.aborted') {
|
||||
return res.status(400).json({ ok: false, error: 'upload_aborted' })
|
||||
}
|
||||
return res.status(500).json({ ok: false, error: 'upload_failed' })
|
||||
}
|
||||
return next(err)
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "思想实验室 · 物理弹球(Web 复刻版):竖向场地 + 顶部漏斗待发球 + 拖拽瞄准连发弹球 + 数字砖块刚体堆叠 + 侧边输送带回收 + 回合上升机制。学术研究用途,玩法复刻自同名手游。多文件前端:pb_physics(刚体引擎)/pb_rules(规则)/pb_render(渲染)/pb_game(状态机)/pb_ui(界面)/pb_storage(存档)/pb_audio(音效)。本 lab 完全独立;未来若需要后台逻辑,统一放在本文件夹内。",
|
||||
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
|
||||
"id": "physics_balls",
|
||||
"name_comment": "首页卡片显示名称",
|
||||
"name": "物理弹球",
|
||||
"icon_comment": "首页卡片图标(emoji)",
|
||||
"icon": "🎾",
|
||||
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
|
||||
"desc": "打砖块式弹球 · 刚体物理 · 拖拽瞄准连发 · 回合上升",
|
||||
"sort_order_comment": "卡片排序,数值越小越靠前",
|
||||
"sort_order": 97,
|
||||
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
|
||||
"enabled": true
|
||||
}
|
||||
Reference in New Issue
Block a user