ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于原生JavaScript和Canvas的物理台球游戏源码解析与实战

基于原生JavaScript和Canvas的物理台球游戏源码解析与实战 简介网页版台球PCOL游戏源码是一份基于HTML、CSS与JavaScript构建的前端网页游戏项目无需下载安装浏览器打开即可在线体验适合具备HTML5、CSS3和JavaScript基础的前端开发者和游戏爱好者学习或二次开发。资源共54个文件压缩包仅4.48MB其中核心代码包括HTML入口、CSS样式表和JavaScript脚本此外还有16个PNG与16个JPG图片素材、4个HDR环境贴图、2个Babylon三维模型、3个字体文件以及JSON配置等可完整支撑场景渲染与游戏交互。目前已有768人浏览学习。压缩包内附本地运行方法说明文档便于快速启动项目开发者通过研究源码可以了解整个网页游戏的目录结构、3D台球场景搭建、动画与用户输入处理等实践细节也能学习素材组织和代码模块划分从目录结构到脚本逻辑均有清晰的参考价值是前端开发学习者提升综合能力的实用学习范例。 做网页版台球这个项目最初的想法特别朴素我一直想找个能在浏览器里直接打开就玩的台球游戏最好是源码可控、能自己加功能的那种。断断续续看了不少开源项目要么是老旧的Flash时代产物要么物理手感稀烂真正能用原生JavaScript写出来的PCOL方案少得可怜。于是干脆自己动手写了一个也顺手把这套网页版台球游戏源码整理成了可以完整复现的项目。PCOL这个名字我自己理解是“Pool Classic Online”的缩写定位就是一个经典玩法的网页台球游戏。它不需要装任何客户端打开浏览器就能玩支持单人练习、对战AI、双人同屏轮流击球后续还能往网络对战方向扩展。整套代码用原生HTML5 Canvas加自研物理碰撞模块实现没有依赖重型框架因此不管是想学习游戏开发、研究物理模拟还是想快速套壳做个休闲小游戏这套源码都算得上是相当合适的参考样本。这篇文章我会把项目拆开讲透从物理引擎怎么设计、渲染循环怎么写到AI对手怎么实现瞄准再到实战中踩过的坑和调优方案全部摊开说。代码部分给核心实现和关键参数参数怎么来的、为什么这么调都会有解释。1. 项目概述PCOL 网页台球游戏想解决什么问题1.1 核心需求解析与项目定位做游戏之前第一个要想清楚的问题是“做给谁玩”。我给PCOL定的目标是三类用户第一类是纯粹想打发时间、随手打开网页就能搓两杆的休闲玩家第二类是研究前端游戏开发、想看物理模拟怎么落地的开发者第三类是自己有游戏网站、需要一款免费可改源码来填充内容的内容站长。这决定了技术的选择方向。项目不需要3D、不需要写实光影核心就两个词物理真实、操作顺手。台球游戏最忌讳物理假球撞上去像橡皮擦碰棉花这种手感玩家两分钟就关页面。所以在技术选型上我优先保证物理模拟的精度和稳定性渲染则用最轻量的Canvas 2D实现确保中低端设备也能跑满60帧。1.2 技术选型为什么不用现成引擎很多人一上来就问为什么不直接用Phaser、Three.js甚至Unity导出WebGL我的答案很直接不需要。台球是典型的2D平面运动球在桌面上的滚动、碰撞、反弹完全可以用二维向量描述用2D物理就能获得很高的真实度。引入3D引擎反而带来一堆问题坐标系变复杂、碰撞体要用球体而不是圆形、性能开销翻倍。我也试过Matter.js这类通用2D物理库它能处理刚体碰撞但对台球这种需要精细控制摩擦系数、恢复系数、库边反弹的场景通用库的调参灵活性不够而且包体几十KB起跳对追求极致加载速度的网页游戏来说并不划算。最终我决定自己写一个简化的台球物理模块代码量不大两百行以内搞定核心碰撞逻辑但行为完全可控。1.3 整体架构与运行流程PCOL采用经典的单页游戏架构一个入口HTML文件、一个样式文件、若干模块化JavaScript文件。模块划分很清晰桌球配置模块定义球桌尺寸、洞口位置、球的数量与初始布局、材质参数物理引擎模块处理球的运动、碰撞检测、反弹、摩擦衰减渲染模块负责绘制球桌、球、球杆、记分板交互模块监听鼠标操作计算击球方向和力度AI模块针对单机模式生成AI击球决策游戏状态机模块管理发球、击球、进球、换手、胜负判定。运行时逻辑是一个居中调度的游戏循环每帧依次执行“处理输入→更新物理→检测碰撞→渲染画面→更新UI”。关键点在于物理更新使用固定时间步长这一点后面会专门讲。2. 核心系统设计与关键实现2.1 物理引擎设计球的运动与碰撞如何求值物理模块是台球游戏的灵魂。我实现的核心公式有三个球与库边的碰撞、球与球的碰撞、摩擦力与滚动阻力。球与库边碰撞相对简单理想情况下就是镜面反弹。现实台球中库边是弹性体球撞上去会损失部分能量因此我在反弹时乘一个恢复系数e。代码实现很简单// 球撞库边 if (ball.x ball.radius) { ball.x ball.radius; ball.vx Math.abs(ball.vx) * E_COLLISION_WALL; } if (ball.x TABLE_WIDTH - ball.radius) { ball.x TABLE_WIDTH - ball.radius; ball.vx -Math.abs(ball.vx) * E_COLLISION_WALL; } // 上下库边同理恢复系数e的取值我实验中调了很久。真实台球库边的e值大概在0.75到0.85之间我最终取0.82这个值撞起来既有明显的减速感又不至于像软球一样无力。球与球碰撞是重点。两个质量相同的球发生弹性碰撞时速度沿两球连心方向交换垂直于连心方向的速度分量保持不变。具体到代码实现需要先把两球的速度分解到连心方向法线方向和垂直方向切线方向交换法向分量再还原成坐标轴方向的速度const dx ball2.x - ball1.x; const dy ball2.y - ball1.y; const dist Math.hypot(dx, dy); if (dist ball1.radius ball2.radius) { // 归一化法线向量 const nx dx / dist; const ny dy / dist; // 计算法线方向速度分量 const v1n ball1.vx * nx ball1.vy * ny; const v2n ball2.vx * nx ball2.vy * ny; // 质量相等法向速度交换 ball1.vx ball1.vx (v2n - v1n) * nx; ball1.vy ball1.vy (v2n - v1n) * ny; ball2.vx ball2.vx (v1n - v2n) * nx; ball2.vy ball2.vy (v1n - v2n) * ny; // 分离重叠球体防止粘连 const overlap ball1.radius ball2.radius - dist; ball1.x - (overlap / 2) * nx; ball1.y - (overlap / 2) * ny; ball2.x (overlap / 2) * nx; ball2.y (overlap / 2) * ny; }注意这里还做了一层重叠分离处理。为什么需要这一步因为游戏循环是离散的每帧计算一次位置当球速很快时两球在某一帧可能已经重叠。如果不处理重叠下一帧会继续产生重复碰撞导致球被莫名弹开这就是很多人遇到的“球乱跳”问题。摩擦力方面真实台球里球的滚动包含滑动摩擦和滚动阻力的混合效果。我做了简化处理每帧速度乘以一个略小于1的衰减系数同时设置了一个速度阈值低于阈值直接归零模拟球停止滚动的情况ball.vx * (1 - FRICTION_AIR); ball.vy * (1 - FRICTION_AIR); if (Math.hypot(ball.vx, ball.vy) VELOCITY_THRESHOLD) { ball.vx 0; ball.vy 0; }2.2 渲染层设计Canvas绘制与动画循环渲染层用Canvas 2D实现因为台球的画面相对简单一张绿色桌面、六个黑色洞口、若干彩色球体。绘制球体需要做一点渐变处理让球看起来有立体感但不要过度毕竟判断球的精确位置才是游戏的核心。游戏循环用requestAnimationFrame驱动这是浏览器动画的标准做法。但这里有个隐藏的坑requestAnimationFrame的调用频率取决于显示器刷新率60Hz的屏幕上每秒调用60次120Hz的屏幕上就是每秒120次。如果物理更新直接和渲染帧率挂钩那么高刷屏幕上球的运动速度会看起来更快物理表现完全不一致。解决方法是固定时间步长。让物理更新始终以固定的毫秒数推进每帧根据实际经过的时间决定物理更新几次const PHYSICS_STEP 16.667; // 物理步长对应60FPS let accumulator 0; let lastTime performance.now(); function gameLoop(time) { const delta time - lastTime; lastTime time; accumulator delta; while (accumulator PHYSICS_STEP) { updatePhysics(PHYSICS_STEP); accumulator - PHYSICS_STEP; } render(); requestAnimationFrame(gameLoop); }这种写法能保证物理模拟在任何刷新率下保持一致的行为我在120Hz的笔记本上测试过球的运动速度、反弹轨迹和60Hz屏幕完全一致。2.3 击球交互与力度控制击球操作采用经典的“拖拽瞄准”方式玩家点击白球附近的球杆拖动鼠标向相反方向拉拉得越远击球力度越大松开鼠标后白球沿瞄准方向弹出。这样做的好处是操作直观力度和方向在抬手之前完全可控适合键盘鼠标操作。力度映射是整个手感的关键。拖动距离和球的初速度之间不是线性关系线性映射会让力道控制太敏感。我用了指数映射让中低力度的调节更精细const dragDist Math.hypot(mouseEndX - mouseStartX, mouseEndY - mouseStartY); const powerRatio Math.min(dragDist / MAX_DRAG_DISTANCE, 1); const speed POWER_MIN (POWER_MAX - POWER_MIN) * Math.pow(powerRatio, 1.6);打台球的人都知道同样是推杆中杆、高杆、低杆的效果完全不同。做游戏时我做了简化默认中杆按住空格键击球为低杆给白球加反向旋转按住Shift键击球为高杆加向前旋转。在物理实现上低杆会在球前进的方向上叠加一个向后的小初速度这个初速度会在滚动衰减中逐渐消失高杆则叠加一个向前的小初速度让白球在撞球后继续前冲一小段。虽然是个简化模型但实战中打起来感觉非常接近真实台球的“拉杆”和“推杆”这个细节成了很多玩家夸手感好的关键。3. 实操流程从零搭建PCOL游戏核心模块3.1 球桌初始化与球的布局很多新手做台球游戏一上来就纠结球桌尺寸。我直接给一组经过验证的参数你照抄就完事参数值说明桌面宽1120px模拟真实球桌长宽比约2:1桌面高560px不算边框洞口半径36px比球半径大30%左右球半径14px标准台球直径约52.5mm按比例缩放库边厚度24px视觉和物理碰撞边界球的初始布局参照美式8球的三角摆放。摆球时有个细节三角形顶点位于桌面四分之三处也就是球桌发球区的对角位置。代码里用两层循环生成三角形排布function initBalls() { balls []; const startX TABLE_WIDTH * 0.71; const startY TABLE_HEIGHT / 2; const gap BALL_RADIUS * 2 1; // 间隙1px防初始重叠 for (let row 0; row 5; row) { for (let col 0; col row; col) { const x startX row * gap * Math.cos(Math.PI / 6); const y startY (col - row / 2) * gap; balls.push(createBall(x, y, RACK_ORDER[balls.length])); } } }花色顺序RACK_ORDER需要手动确定美式8球规定8号球放中间两个角球一个全色一个花色其余随机填充。这里我直接硬编码了一个符合规则的顺序数组。3.2 球袋判定与进球逻辑球袋判定的坑比想象中多。最简单的方法每帧算每颗球和每个洞口的距离小于洞口半径就判定进球。但这样会误判很多擦边球球明明只是从洞口边缘滑过但在这帧里距离刚好小于判定阈值就被吞进去了。我的解决方案是双阈值判断距离小于洞口半径减8px才算进球同时要求球速大于某个值。这样边擦边跑的球不会误吞真正滚进洞口的球速度一定是够的。进球后要做连锁处理从场上移除进球球、判断是否白球落袋、更新计分板、检查胜负条件。白球落袋也就是玩家常说的“母球进洞”或者“犯规”处理要特殊一些。规则上白球落袋后要重新摆放白球到开球区并且对手获得自由球权。实现上我在进球判定里单独判断了球号是否为0做了分支处理。3.3 AI对手怎么实现瞄准单机模式少不了一个能打的AI不然玩家永远是自己在跟自己玩。AI实现的大方向是每回合遍历所有目标球计算每个球“进哪个袋最有机会”然后反推白球的击球点再选一个得分最高的方案执行。具体来说对每个目标球遍历六个洞口。目标球要进袋它的运动方向必须指向洞口中心。根据碰撞法则目标球受到白球撞击后会沿两球连心方向运动所以白球必须击打在目标球朝洞口方向的反向延长线上。这个点我称之为“击球点”function findHitPoint(targetBall, pocket) { const dirX pocket.x - targetBall.x; const dirY pocket.y - targetBall.y; const dist Math.hypot(dirX, dirY); const nx dirX / dist; const ny dirY / dist; return { x: targetBall.x - nx * BALL_RADIUS * 2, y: targetBall.y - ny * BALL_RADIUS * 2 }; }有了击球点AI需要判断两点白球到击球点的路线是否被其他球阻挡以及击球后白球的走位是否理想。路线阻挡检测就是做一次线段与圆的相交判断代码网上有很多关键是复杂度控制如果每回合对每颗目标球都做完整检测AI决策时间会拉长体验不好。我的方案是先快速剔除明显不可能的目标球只对3到5个候选球做精细计算。AI的难度调节就是在计算误差和决策深度上做文章简单AI瞄准时加5度的随机角度偏差并且只选最近的目标球击打中等AI加2度偏差会避开白球落袋风险高的击球方案困难AI无偏差全面评估进球难度与白球走位模拟高手组合球思路。这个分层设计让不同水平玩家都能找到适合自己的对手强度。3.4 游戏状态机与流程管理台球游戏的核心流程是“击球→判定进球→决定换手还是继续”。状态机的核心状态就五个THINKING等待玩家瞄准、AIMING玩家拖动球杆、SWINGING击球动画、BALLS_MOVING球在运动、GAME_OVER终局。关键规则是在BALLS_MOVING状态下玩家无法操作白球必须等所有球完全静止才进入下一回合这个判断就是前面提到的速度阈值归零。换手逻辑要区分两种情况如果击球方打进了自己的目标球比如美式8球里的全色或花色则继续击球如果没有进球、打进白球或先碰到对手球则换手。这里的实现需要实时跟踪一杆多进球的情况我用了一个事件队列在每次物理更新里收集进球事件等球全部静止后统一处理避免运动中多次触发换手导致状态错乱。4. 常见问题与性能调优实录4.1 球体穿透、卡死与抖动问题做物理模拟遇到的最典型问题就是球体穿透。表现是白球高速撞向另一颗球时两颗球有概率直接重叠甚至穿过。原因是球在单帧内位移距离大于球的直径时碰撞检测可能漏判。最实用的解决方案是限制最大速度碰撞分离。我给球的初速度上限设置为每帧不超过球半径的80%这个速度对应现实击球中非常大力的开球足够用了。同时保留碰撞后的重叠分离逻辑作为兜底双保险之下穿透问题基本绝迹。球的“抖动”是另一个高频问题。具体表现是两颗球停在一起时物理更新不断检测到碰撞、不断执行分离导致球高频震动。我的解法是给速度设定一个更低的自旋阈值当两球相对速度小于某个极小值时直接不执行碰撞响应只做位置分离。这个阈值我调在0.2像素/帧在视觉和物理上都不会被察觉但根治了抖动。4.2 性能瓶颈什么时候需要空间分区初始版本的碰撞检测是O(n²)的每帧遍历所有球对做距离判断。其实休闲模式里球最多16颗O(n²)也就120次距离计算完全不是问题。但如果你要把这套代码扩展成支持多人对战、或者加入多张球桌同时渲染那么空间分区就很有必要。我预留了一个简单的网格分区接口将球桌划分成若干格子每颗球只和同格及相邻格内的球做碰撞检测。在球的总数超过50颗时性能提升明显。实测数据16颗球时两种方案都在0.2ms以内完成物理更新64颗球时O(n²)方案上涨到1.8ms网格分区方案只要0.3ms。如果项目不打算做大场景这部分可以先不优化但接口我建议预留。4.3 浏览器兼容与移动端适配PCOL最核心的兼容性要求是Canvas 2D支持和requestAnimationFrame支持。这两项现代浏览器全都支持包括移动端主流浏览器。但移动端的交互方式和桌面差别很大鼠标拖拽变成了手指触摸需要额外处理touch事件屏幕尺寸小球桌占满屏幕时球太小看不到细节。我的适配方案是双轨交互。检测到触摸设备时击球操作改为“点按球杆→在球杆方向上滑动释放”模式物理参数不变只有交互层做了映射。同时在CSS里加了viewport缩放控制保证球桌在窄屏上也能完整显示。这套适配做完后手机浏览器打开体验明显可用虽然不如桌面舒服但应急玩玩完全够。说实话很多人会忽略移动端觉得网页游戏主要面向PC但这部分适配工作其实花不了多少时间却能极大扩展游戏的适用场景。4.4 源码结构优化与后续扩展方向PCOL的源码我刻意保持模块化独立没有使用打包器每个文件都能单独阅读。代码总规模约1800行注释占了25%以上。之所以这么做是为了让初学者能按模块逐步理解不至于被工具链吓跑。扩展方向上我自己验证过三条路第一双人同屏模式。不需要网络不会额外增加任何服务器成本只要在状态机里加入“当前击球玩家”的标记再控制好视角方向这个功能我和朋友实测玩起来体验相当好是最推荐优先做的扩展。第二网络联机对战。在Node.js环境下用WebSocket配合房间机制客户端只负责发送“击球动作”和接收“球状态快照”服务端担任物理模拟保证两个人看到的物理行为一致。这套架构我已经跑通了基础版本网络延迟在50毫秒内基本无感。第三战绩记录与排行榜。利用localStorage就够单机版使用要上线用户系统则搭配后端数据库。点击记分板左上角可查看最近20局的胜负记录和进球率。我在实际调试中发现最有意思的扩展是结合“打小球练枪”的思路把PCOL的物理引擎单独拆出来做瞄准训练小工具玩家可以在上面调整击球角度和力度实时看白球走位轨迹。这个工具对学习台球基本走位的朋友非常有价值也让一套代码服务了多个场景。每次有人问我“这套源码好不好改”我的回答从来都是物理层别动交互层随便改。碰撞和摩擦参数是我花了大把时间一点点调出来的没有大量测试数据支撑动一个数字手感可能就会崩掉。本文还有配套的精品资源点击获取
返回列表