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

1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI
2. 新增物理弹球相关调试与单元测试脚本
3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示
4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
yangxiangyuan
2026-08-08 22:31:33 +08:00
parent 4492b4106a
commit 91148fe064
17 changed files with 2690 additions and 22 deletions
@@ -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
};
})();