feat: 新增物理弹球实验室及配套功能
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
@@ -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 }
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user