PCOL算法揭秘:网页台球游戏物理模拟与碰撞检测全解析

发布时间:2026/9/8 3:41:28
PCOL算法揭秘:网页台球游戏物理模拟与碰撞检测全解析 简介这是一套基于HTML/CSS/JavaScript构建的网页版台球PCOL游戏完整前端源码面向Web前端初中级开发者也适合作为在线休闲游戏的参考项目。整体压缩包共54个文件大小约4.48MB包含HTML入口、CSS样式和JavaScript逻辑同时集成png/jpg图片素材、hdr环境贴图、fx特效、woff2字体以及babylon场景文件目录结构较完整。作为纯浏览器端游戏免安装即可在线体验并具备跨平台运行特性源码中的index.html入口、本地运行说明和babylon相关配置能帮助学习者理解网页游戏从页面搭建、样式布局到交互实现的完整路径也可在此基础上调整台球物理参数、界面风格或扩展新玩法便于二次开发与复用。目前已有768人学习浏览适合希望深入前端游戏开发、提升复杂Web应用实践能力的人群。 不知道多少人跟我一样一看到网页版台球 PCOL 游戏源码这个标题第一反应是先愣一下PCOL 是个什么玩意儿是某个框架的缩写还是拼写错误的游戏名先直接说结论这不是一个普通的浏览器小游戏 demo。把它拆开看真正的核心不在台球两个字上而在于 PCOL——我倾向于把它理解为 Potential Collision潜在碰撞检测的缩写。在台球这类刚体碰撞游戏里这是决定手感和物理真实感的分水岭。这篇文章我想顺着一条从零手写网页台球游戏的完整思路把这个标题背后真正值钱的东西拆开讲透物理模型怎么搭、碰撞检测怎么算、PCOL 到底在解决什么问题、以及你拿到一份源码之后该怎么下手改。如果你是想用现成物理引擎比如 Matter.js、Planck.js五分钟糊一个台球 demo 出来的人这篇可能不太适合你。但如果你正准备手写一个网页版台球项目或者拿到了一份开源 PCOL 源码但看得一头雾水那我踩过的坑、查过的资料、最后沉淀下来的方案应该能帮你省掉不少瞎折腾的时间。1. PCOL 到底在解决什么问题网页台球开发的第一个分水岭1.1 先搞清楚 PCOL 的核心定位所有做网页台球游戏的人第一次撞墙几乎都撞在同一面墙上球与球碰撞瞬间的表现不对。轻则两球重叠后弹开重则直接穿透更诡异的是有时候白球撞到目标球之后目标球飞出去的方向看起来毫无规律。为什么会这样因为台球本质上是多个刚体在同一平面上的连续碰撞过程如果你想靠每帧检查位置重叠→反推速度方向这种最朴素的做法去实现那碰撞的一瞬间你永远会晚一步。而 PCOL 的思路恰恰相反在每一帧的物理更新开始之前先遍历场上所有球预测未来会发生哪些碰撞、碰撞点在哪儿、碰撞发生在什么时间然后按时间顺序依次处理。这就是潜在碰撞检测这六个字的真正含义。它不是一个渲染层面的功能而是一个物理模拟层面的前置算法。整个台球游戏能不能给人这球推得干净利落的感觉八成取决于这一层写得对不对。1.2 网页版选择 2D 还是 3D直接影响 PCOL 的复杂度另一个容易在开场就埋雷的决策是网页台球到底做 2D 还是 3D。市面上很多网页台球 demo 直接上了 Three.js把球台、球杆、灯光、阴影全做出来观感确实唬人。但如果你去看它的物理代码大概率会发现它内部还是把三维坐标压到了二维平面上去算碰撞因为台球的物理发生在台呢平面上球只在滚动方向上有意义垂直方向的跳动在非跳球规则下完全可以忽略。我的建议是如果你想要的是 PCOL 算法本身的落地而不是炫渲染效果那就坚定做 2D。等物理和手感全部调到位了再考虑加一层 3D 表现层。这不只是为了省事而是因为在 2D 平面里做潜在碰撞检测数学上只需要处理圆周之间的相交代码逻辑和调试成本都比 3D 低一个数量级。下面所有讨论也都基于 2D 平面模型。1.3 拿到源码后的第一件事先跑通再改最后才是读顺带回答一下我有源码了该怎么用这个问题。不管你是从 GitHub 上找的还是别人发给你的我建议的执行顺序永远是第一步本机起一个静态服务器把它跑起来第二步随便改几个参数球的半径、摩擦系数、击球力度观察效果变化第三步再带着问题去读源码。直接一头扎进代码里逐行细读效率反而最低。我见过太多人拿到源码第一步就去找生成瞄准线的那段代码然后被各种物理参数绕晕。记住源码里的 Magic Number 往往不是 bug而是前人反复调出来的手感值先动它们看效果远比你试图从逻辑上推断它们从哪里来要快得多。2. 物理建模怎么让球在浏览器里真实地滑行2.1 球体参数的设定所有手感问题的源头物理模拟的第一步不是写代码是把所有参数明确下来。我给出一个经过实测、手感比较接近真实台球的参数表你可以直接抄参数数值说明球半径0.028575 m约 28.575 mm美式台球标准直径 57.15 mm球质量0.17 kg标准美式台球重量台面长度2.54 m美式九球台标准长度台面宽度1.27 m美式九球台标准宽度恢复系数0.94 ~ 0.96球与球碰撞的弹性保持率库边恢复系数0.7 ~ 0.8球碰库边时能量损失更大滚动摩擦系数0.01 ~ 0.02球在台呢上的减速效果库边摩擦系数0.2碰库时产生少量切向减速这个表不是随手写的它决定了你后续所有计算的比例感。比如如果你把球半径设成 50 像素而台面长度只给了 800 像素那球的物理尺寸比例就失真了打出来的走位也会怪怪的。2.2 运动方程的离散化从连续物理到逐帧计算真实世界里台球的运动用连续微分方程描述但浏览器里我们只能按帧计算。标准做法是把每个球的状态记录为一个对象包含位置、速度、角速度、是否静止等多个字段class Ball { constructor(id, x, y, color) { this.id id; this.pos { x, y }; this.vel { x: 0, y: 0 }; this.radius 0.028575; this.mass 0.17; this.isStatic false; this.isPocketed false; } }每帧更新时我记得最牢的一点是不要直接写pos vel * dt就完事。因为台球的减速非常依赖速度分层哪怕简单处理也要让摩擦力先作用再更新位置function updateBall(ball, dt) { if (ball.isStatic || ball.isPocketed) return; const speed Math.hypot(ball.vel.x, ball.vel.y); if (speed 0) { // 滚动摩擦与速度方向相反大小恒定简化模型 const frictionDecel 0.015; // m/s^2接近真实台呢 const decel Math.min(speed / dt, frictionDecel); const factor 1 - (decel / speed); ball.vel.x * factor; ball.vel.y * factor; // 速度低于阈值直接置零避免微抖假象 if (Math.hypot(ball.vel.x, ball.vel.y) 0.001) { ball.vel.x 0; ball.vel.y 0; } } ball.pos.x ball.vel.x * dt; ball.pos.y ball.vel.y * dt; }注意那个速度低于阈值直接置零的操作这是很多新手容易漏掉的细节。如果没有这一行球会在几乎停止的时候出现肉眼可见的颤抖非常毁手感。2.3 库边碰撞能量损失的直觉把握台球桌的库边不是刚性墙。真实的库边有橡胶内衬球撞上去会先压缩再弹回因此能量损失比球与球碰撞大得多。这就是为什么上表里库边恢复系数只有 0.75 左右而球与球之间是 0.95。处理库边碰撞时判断很简单球心坐标超出库边内沿减去半径就触发反弹。关键是反弹后的速度处理——法向速度乘上恢复系数取反方向切向速度乘上摩擦系数function collideWithCushion(ball, table) { const r ball.radius; if (ball.pos.x - r 0) { ball.pos.x r; ball.vel.x -ball.vel.x * 0.75; } else if (ball.pos.x r table.width) { ball.pos.x table.width - r; ball.vel.x -ball.vel.x * 0.75; } // 上下库边同理 }这里只处理了水平库边的法向反弹实际写的时候还要注意球是可能在角落同时碰两条库边的进袋口的那个瞬间这种边角情况最好留给专门的边界处理函数统一判断。3. PCOL 核心算法从撞上了再处理到提前知道会撞3.1 为什么不能等到撞了再反应在逐帧物理里如果你只在每一帧结束的时候检测球与球是否重叠然后强行把它们分开那你一定会遇到两个问题。第一个叫隧道效应球速足够快时一帧之内它可能从另一个球的左侧直接穿到右侧这一帧检测时两个球根本没有重叠于是碰撞被完全跳过。第二个叫碰撞次序错误一帧之内同时发生两次碰撞但你随机先处理了后发生的那次球的走向就会跟真实物理打架。PCOL 算法的价值就在这它把检测位置重叠变成了计算碰撞时间 t让物理引擎按照碰撞发生的时间先后顺序处理所有事件。3.2 两球碰撞时间的数学推导假设球 A 和球 B 在时刻 t0 时的位置分别是 Pa、Pb速度分别是 Va、Vb半径分别是 Ra、Rb。我们希望求出未来某个时刻 t两球中心距离恰好等于 RaRb。两球在 t 时刻的中心距离向量是D(t) (Pb - Pa) (Vb - Va) * t令相对位置向量为 d0 Pb - Pa相对速度向量为 v Vb - Va两球刚好接触的条件是|d0 v * t| Ra Rb两边平方展开后是一个关于 t 的一元二次方程|v|² * t² 2 * (d0 · v) * t (|d0|² - (RaRb)²) 0用求根公式解如果判别式小于 0说明两球永远不会碰撞如果大于等于 0取最小的正实数根就是最早的碰撞时间。这段推导看着是数学但落到代码里非常直观。我贴一段可以直接用的判断和求解函数function findFirstCollisionTime(ballA, ballB) { const dx ballB.pos.x - ballA.pos.x; const dy ballB.pos.y - ballA.pos.y; const dvx ballB.vel.x - ballA.vel.x; const dvy ballB.vel.y - ballA.vel.y; const a dvx * dvx dvy * dvy; if (a 0) return -1; // 相对速度为零永远不会撞 const b 2 * (dx * dvx dy * dvy); const c dx * dx dy * dy - Math.pow(ballA.radius ballB.radius, 2); const discriminant b * b - 4 * a * c; if (discriminant 0) return -1; const sqrtDisc Math.sqrt(discriminant); const t1 (-b - sqrtDisc) / (2 * a); const t2 (-b sqrtDisc) / (2 * a); if (t1 0) return t1; if (t2 0) return t2; return -1; }有了这个函数PCOL 的主流程就清晰了对场上每一对球调用它拿到最小的碰撞时间 t_min然后把所有球按 t_min 的步长移动处理这一对球的碰撞再从碰撞时刻开始重新扫描。循环往复直到一帧结束。3.3 潜在碰撞检测的完整循环这里给出我最常用的 PCOL 主循环伪代码。每帧的物理更新不再是所有球走一步而是嵌套的找最近碰撞→移动→处理碰撞循环function stepPhysics(balls, dt) { let remainingTime dt; while (remainingTime 0) { let minTime remainingTime; let collisionPair null; // 1. 扫描所有球对找到最早的潜在碰撞 for (let i 0; i balls.length; i) { for (let j i 1; j balls.length; j) { const t findFirstCollisionTime(balls[i], balls[j]); if (t 0 t minTime) { minTime t; collisionPair [balls[i], balls[j]]; } } } // 2. 所有球先移动 minTime 对应的时间 for (const ball of balls) { moveBall(ball, minTime); } remainingTime - minTime; // 3. 如果这段时间内确实有碰撞处理它 if (collisionPair) { resolveCollision(collisionPair[0], collisionPair[1]); } // 4. 处理库边碰撞这部分通常等移动完再统一做 for (const ball of balls) { collideWithCushion(ball, table); } } }看到这里你应该能理解为什么 PCOL 是潜在了它永远在问接下来最可能发生什么而不是刚才发生了什么。3.4 碰撞响应公式动量守恒 恢复系数找到碰撞对之后怎么计算碰撞后的速度这里的标准解法是把速度分解为法向分量和切向分量。法向分量用动量守恒和恢复系数求解切向分量保持不变。设碰撞法线方向为单位向量 n从球 A 球心指向球 B 球心两球质量分别为 mA、mB碰撞前速度分别沿法向的分量为 vAn、vBn恢复系数为 e。碰撞后法向速度 vAn、vBn 满足vBn - vAn -e * (vBn - vAn) mA * vAn mB * vBn mA * vAn mB * vBn联立解得vAn (mA * vAn mB * vBn mB * e * (vBn - vAn)) / (mA mB) vBn (mA * vAn mB * vBn mA * e * (vAn - vBn)) / (mA mB)代码实现如下function resolveCollision(ballA, ballB) { const dx ballB.pos.x - ballA.pos.x; const dy ballB.pos.y - ballA.pos.y; const dist Math.hypot(dx, dy); if (dist 0) return; // 法向单位向量 const nx dx / dist; const ny dy / dist; // 速度在法向的投影 const vAn ballA.vel.x * nx ballA.vel.y * ny; const vBn ballB.vel.x * nx ballB.vel.y * ny; const e 0.95; // 恢复系数 const mA ballA.mass; const mB ballB.mass; const vAnNew (mA * vAn mB * vBn mB * e * (vBn - vAn)) / (mA mB); const vBnNew (mA * vAn mB * vBn mA * e * (vAn - vBn)) / (mA mB); // 更新速度保留切向分量替换法向分量 ballA.vel.x (vAnNew - vAn) * nx; ballA.vel.y (vAnNew - vAn) * ny; ballB.vel.x (vBnNew - vBn) * nx; ballB.vel.y (vBnNew - vBn) * ny; // 轻微分离防止浮点误差导致的重叠残留 const overlap ballA.radius ballB.radius - dist; if (overlap 0) { ballA.pos.x - overlap * 0.5 * nx; ballA.pos.y - overlap * 0.5 * ny; ballB.pos.x overlap * 0.5 * nx; ballB.pos.y overlap * 0.5 * ny; } }注意最后一步轻微分离。即便有了 PCOL在计算到碰撞时刻并移动后浮点误差仍可能让两球之间还有一丁点重叠或者一丁点间隙这步能把视觉上的粘连感彻底消掉。4. 源码工程的落地目录结构、渲染选型与交互手感4.1 一份可直接开始改的源码目录设计很多人拿到源码后难以快速上手很大原因是文件结构太乱。我自己维护的 PCOL 台球项目目录是这样的你可以直接照搬pool-game/ ├── index.html # 入口页面画布容器和 UI ├── css/ │ └── style.css # 页面样式 ├── js/ │ ├── main.js # 游戏初始化、动画循环、事件绑定 │ ├── ball.js # 球的类定义和渲染 │ ├── table.js # 球台边界、袋口定义 │ ├── physics.js # 运动更新、库边碰撞 │ ├── pcol.js # 潜在碰撞检测主循环 │ ├── collision.js # 碰撞响应公式 │ └── input.js # 鼠标瞄准、力度控制 └── assets/ └── sounds/ # 撞球音效index.html 里最关键的是一个全屏 Canvas 和一个力度进度条元素。main.js 里有一个固定的 requestAnimationFrame 循环但注意物理更新和渲染更新是分离的物理用固定时间步长后面细说渲染则每帧都执行。4.2 渲染选型Canvas 2D 为什么够用台球游戏的渲染对网页端来说 Canvas 2D 其实比 WebGL 更省心。球的数量最多 16 个每个球就是一个带渐变的圆加一点阴影和序号贴图就足够。球的绘制我一般先ctx.beginPath()然后ctx.arc()再叠两层渐变制造高光最后画序号。性能和画质都能兼顾不需要上纹理。真正的渲染难点其实是球杆瞄准线和击球力度反馈。瞄准线本质上是 PCOL 的一次可视化解法从白球出发沿着当前瞄准方向做一次射线检测找到第一个碰撞点然后画一条从白球到碰撞点的虚线再画一条从碰撞点沿目标球方向延伸的短虚线。做出来之后玩家立刻就能感受到 PCOL 的价值。4.3 输入交互的三个坑输入这块新手最容易踩的坑有三个第一个是用 mousedown 和 mouseup 之间的距离来决定力度导致鼠标一抖球就飞了。正确做法是记录鼠标从按下到抬起的时间差或记录拖动距离映射到力度区间并且要做非线性映射const power Math.min(Math.max(dragDistance / maxDragDistance, 0), 1); const finalPower power * power * maxPower;为什么要做power * power因为人的手对鼠标位移非常敏感直接线性映射会让小力度区间在手感上完全不可控平方映射相当于把低力度区间放大了打长距离球时更加细腻。第二个坑是击球方向搞反。台球的瞄准方向是从白球射向目标球的方向但玩家拖动鼠标的方向可能习惯上与其相反。建议在实现时默认鼠标从白球向外拉是瞄准方向也就是鼠标移向目标球方向松开击球。你要是做了个反向老玩家三秒就会骂娘。第三个坑是白球进袋后的处理。规则上白球进袋后需要拿回自由球区重新放置源码里如果只把白球从数组里删掉游戏就废了。记得保留白球对象进袋后重置位置到球台开球区并清空速度。5. 调试台球物理时必踩的四个大坑5.1 球速过快导致碰撞穿透的隧道效应即使做了 PCOL极端情况下仍可能穿透。原因在于 PCOL 扫描的是当前帧初始时刻的相对速度如果球在某帧内被外力比如一次强击加速到极快那么一次扫描的 t_min 可能仍然大于这一帧的剩余时间而实际碰撞时刻可能比 t_min 更早因为碰撞检测的前提是速度恒定恒速假设在变速时失效。解决办法有两个一是限制最大球速超过某个阈值就做速度钳制二是把物理步长调小比如一帧拆成四个 1/240 秒的小步每个小步做一次 PCOL 扫描。实测下来钳制最大速度到 4 m/s 左右配合 120Hz 刷新率穿透问题基本绝迹。5.2 帧率不稳定导致的物理忽快忽慢这是网页游戏的头号公敌。如果动画循环里直接dt (now - lastTime) / 1000然后把这个 dt 传给物理引擎那么 30Hz 和 60Hz 的机器上球的减速快慢和碰撞结果都会不一样。正确的做法是固定时间步长累积let accumulator 0; const fixedStep 1 / 240; function loop(timestamp) { requestAnimationFrame(loop); const deltaTime Math.min((timestamp - lastTime) / 1000, 0.1); // 防跳帧 lastTime timestamp; accumulator deltaTime; while (accumulator fixedStep) { stepPhysics(balls, fixedStep); accumulator - fixedStep; } render(); }这里把最大帧间隔钳制在 0.1 秒防止页面切后台回来时物理引擎突然狂算一堆帧导致球乱飞。5.3 物理参数调了半天手感还是很塑料如果你把恢复系数、摩擦系数、质量全按标准值设了打起来还是觉得假十有八九问题出在声音和视觉反馈上。人对物理真实感的判断视觉只占一部分听觉参与度极高。给碰撞加上和力度相关的音效反馈球的实心感立刻就能提升一大截。音效不用真录用 Web Audio API 合成一个短促的哒音即可关键是根据碰撞速度调节音量function playCollisionSound(speed) { const volume Math.min(speed / 3, 1); const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.frequency.value 800 Math.random() * 200; gain.gain.value volume * 0.5; osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(0.05); }这个合成音加上 Canvas 里轻微的速度模糊效果在球运动方向上拖一条半透明残影整个游戏的物理质感会上升一个档次而且代码量几乎为零。5.4 PCOL 扫描的性能瓶颈最后一个坑是性能。虽然台球只有 16 个球理论上球对只有 120 对但如果你的 PCOL 主循环在每对球上调用findFirstCollisionTime成千上万次手机端还是会卡。这时候可以加一个简单的空间哈希网格把球台分成若干格子只检测同格或相邻格子的球对。由于台球数量少哈希网格的收益不太明显但配合固定时间步长后帧率稳定性会有可感知的提升。另外每帧物理结束后把所有球的速度平方加起来做一次能量检查如果总能量突然比上一帧暴增说明碰撞处理出了 bug这比肉眼盯球的轨迹更早发现问题。我调试时经常把能量变化打印在 Canvas 角上定位问题非常高效。最后聊点个人经验。这套 PCOL 方案我从最开始的每一帧检查重叠改到现在的找最早碰撞时间代码量翻了一倍但物理真实感完全是两个量级。如果你手头正卡在台球手感调不好的问题上别急着堆渲染特效先把 PCOL 的碰撞预测循环跑通再回来调力气曲线你会发现自己离网页版台球的真家伙又近了一步。本文还有配套的精品资源点击获取

相关新闻