/* ============================================================ * 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 } }; })();