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

431 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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 }
};
})();