从零构建HTML5物理游戏:手写2D物理引擎与Canvas交互实战
2026/9/5 23:34:26 网站建设 项目流程

简介:这是一款轻量级HTML5拉杆子过关小游戏源码,面向前端初学者、网页开发者及个人网站/游戏站运营者,用于快速集成趣味交互小游戏或作为JavaScript事件与DOM操作的实践范例。资源包仅3个核心文件(1个HTML主页面、1个CSS样式表、1个JS逻辑脚本),总大小仅6KB,结构简洁清晰,便于理解游戏初始化、键盘事件监听、碰撞检测与关卡判定等基础游戏逻辑实现。已有509人学习下载,适合零基础入门HTML+CSS+JS协同开发,或用作教学演示、网页互动模块嵌入。源码已通过在线演示验证(https://haiyong.site/moyu/laganziguoguan/),开箱即用,无需额外依赖,可直接部署至任意静态服务器或本地双击运行。

1. 从零到一:理解“拉杆子过关”的核心玩法与设计动机

如果你在寻找一个能快速上手、又能深入理解HTML5游戏开发核心逻辑的练手项目,那么“拉杆子过关”这个小游戏绝对是个宝藏。乍一听名字,你可能会联想到一些物理益智游戏,比如《桥梁建造师》或者《割绳子》这类。没错,这个游戏的核心玩法精髓就在于“杠杆”与“平衡”。玩家需要通过点击或拖拽,在场景中放置一根或多根“杆子”,搭建出一条能让小球(或其他目标物)从起点安全滚动到终点的路径。杆子之间可以相互支撑,形成稳定的结构,而物理引擎会实时计算重力、碰撞和摩擦力,让整个过程充满动态的趣味性和挑战性。

为什么我要推荐这个项目给想学游戏开发的朋友?首先,它麻雀虽小,五脏俱全。一个完整的“拉杆子过关”游戏,几乎涵盖了2D游戏开发的所有基础模块:Canvas绘图用户交互处理物理引擎模拟(哪怕是简化版的)、游戏状态管理以及关卡设计。其次,它的技术栈极其纯粹,只需要HTML、CSS和JavaScript(也就是我们常说的HTML5),不需要安装任何复杂的开发环境或学习新的编程语言,一个浏览器和一个文本编辑器就能开工。最后,它的可扩展性极强。你可以从最简单的静态杆子开始,逐步加入可旋转的铰链、弹性绳索、不同材质的球体(影响摩擦力和弹力)、风力、甚至可破坏的杆子,把一个简单的原型演变成一个极具深度的创意作品。

我最初接触这个创意,是在一些早期的Flash游戏网站上。如今Flash已成历史,但用HTML5重新实现这类经典玩法,不仅是对技术的致敬,更能让我们深刻体会到现代Web标准在图形和交互能力上的巨大飞跃。通过亲手实现它,你会对requestAnimationFrame动画循环、CanvasdrawImage和路径绘制、向量数学计算以及事件监听有非常扎实的理解。接下来,我将带你从最核心的物理模拟开始,一步步拆解这个游戏的实现,并附上完整的、可运行的源码。我们不止步于“能用”,更要追求“优雅”和“可扩展”。

2. 物理世界的基石:构建一个轻量级的2D物理模拟器

游戏的核心是物理交互。我们当然可以引入成熟的物理库如Matter.jsBox2D,但对于学习而言,自己实现一个简化版的物理引擎,能让你透彻理解背后的原理。我们的物理世界主要需要处理几个对象:刚体(这里主要是杆子和球)、碰撞检测以及力的响应

2.1 定义游戏中的基本物体:杆子与球

我们先从定义类开始。一个杆子Stick,本质上是一条线段,拥有起点(x1, y1)、终点(x2, y2)、长度、角度、颜色等属性。更重要的是,它需要具备物理属性,比如是否固定(作为支点)、质量(影响受力后的运动)等。

class Stick { constructor(x1, y1, x2, y2, isStatic = false) { this.x1 = x1; this.y1 = y1; this.x2 = x2; this.y2 = y2; this.isStatic = isStatic; // 是否固定不动,如支点 this.length = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); this.angle = Math.atan2(y2 - y1, x2 - x1); this.color = isStatic ? '#2c3e50' : '#3498db'; // 静态杆子颜色深一些 this.lineWidth = 6; // 物理属性 this.velocityX = 0; this.velocityY = 0; this.angularVelocity = 0; // 角速度 this.mass = this.length * 0.1; // 质量与长度相关 this.momentOfInertia = (this.mass * this.length ** 2) / 12; // 转动惯量(简化) } // 绘制杆子 draw(ctx) { ctx.save(); ctx.strokeStyle = this.color; ctx.lineWidth = this.lineWidth; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(this.x1, this.y1); ctx.lineTo(this.x2, this.y2); ctx.stroke(); // 如果是静态杆子,在两端画上小圆点表示固定点 if (this.isStatic) { ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(this.x1, this.y1, 4, 0, Math.PI * 2); ctx.arc(this.x2, this.y2, 4, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } }

球体Ball则更简单,它有位置、半径、速度、加速度等属性。

class Ball { constructor(x, y, radius) { this.x = x; this.y = y; this.radius = radius; this.vx = 0; // x方向速度 this.vy = 0; // y方向速度 this.color = '#e74c3c'; this.friction = 0.99; // 地面摩擦系数 this.restitution = 0.7; // 弹性系数 } draw(ctx) { ctx.save(); ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fill(); // 添加一点高光效果,让球更立体 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.beginPath(); ctx.arc(this.x - this.radius * 0.3, this.y - this.radius * 0.3, this.radius * 0.4, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } }

2.2 碰撞检测:线段与圆、圆与圆的精确计算

物理模拟的精度很大程度上取决于碰撞检测。我们需要两种检测:球与杆子的碰撞球与球(如果有多个球)的碰撞

球与线段的碰撞相对复杂。一个实用的方法是计算球心到线段的最短距离。如果这个距离小于球的半径,则发生了碰撞。我们需要用到向量点乘和投影的知识。

// 工具函数:计算点(x, y)到线段(x1,y1)-(x2,y2)的最短距离平方 function pointToLineSegmentDistanceSq(x, y, x1, y1, x2, y2) { const A = x - x1; const B = y - y1; const C = x2 - x1; const D = y2 - y1; const dot = A * C + B * D; const lenSq = C * C + D * D; let param = -1; if (lenSq !== 0) param = dot / lenSq; let xx, yy; if (param < 0) { xx = x1; yy = y1; } else if (param > 1) { xx = x2; yy = y2; } else { xx = x1 + param * C; yy = y1 + param * D; } const dx = x - xx; const dy = y - yy; return dx * dx + dy * dy; } // 在Ball类中添加与杆子的碰撞检测方法 checkCollisionWithStick(stick) { const distSq = pointToLineSegmentDistanceSq(this.x, this.y, stick.x1, stick.y1, stick.x2, stick.y2); if (distSq <= this.radius * this.radius) { // 发生了碰撞,需要计算碰撞点法向量和响应 // ... 这里省略复杂的向量计算,下文详述 return true; } return false; }

球与球的碰撞检测就简单多了,直接判断两球心距离是否小于半径之和。

// 工具函数:球与球碰撞 function checkBallCollision(ball1, ball2) { const dx = ball2.x - ball1.x; const dy = ball2.y - ball1.y; const distance = Math.sqrt(dx * dx + dy * dy); return distance < (ball1.radius + ball2.radius); }

2.3 力的响应与运动积分:让世界动起来

检测到碰撞后,我们需要根据物理定律来更新物体的速度(动量守恒、能量损失)。这是物理引擎最核心的部分。对于球与固定杆子的碰撞,我们可以将球的运动分解为垂直于杆子方向和平行于杆子方向。垂直方向的速度会因为碰撞而反转(并乘以弹性系数),平行方向的速度会受到摩擦力影响。

// 一个简化的球与静态杆子碰撞响应(非完全精确,但视觉效果足够) resolveBallStaticStickCollision(ball, stick) { // 1. 找到线段的方向向量 const stickDx = stick.x2 - stick.x1; const stickDy = stick.y2 - stick.y1; const stickLength = Math.sqrt(stickDx * stickDx + stickDy * stickDy); const unitStickX = stickDx / stickLength; const unitStickY = stickDy / stickLength; // 2. 计算球心到线段起点的向量,并投影到线段方向上 const ballToStickStartX = ball.x - stick.x1; const ballToStickStartY = ball.y - stick.y1; let projectionLength = ballToStickStartX * unitStickX + ballToStickStartY * unitStickY; projectionLength = Math.max(0, Math.min(stickLength, projectionLength)); // 钳制到线段范围内 // 3. 找到线段上距离球心最近的点(碰撞点) const closestPointX = stick.x1 + projectionLength * unitStickX; const closestPointY = stick.y1 + projectionLength * unitStickY; // 4. 计算从碰撞点指向球心的法向量(即碰撞方向),并归一化 let normalX = ball.x - closestPointX; let normalY = ball.y - closestPointY; const normalLength = Math.sqrt(normalX * normalX + normalY * normalY); normalX /= normalLength; normalY /= normalLength; // 5. 将球的速度分解为法向分量和切向分量 const velocityDotNormal = ball.vx * normalX + ball.vy * normalY; // 6. 碰撞响应:法向速度反转并乘以弹性系数,切向速度乘以摩擦系数 // 新的法向速度 = -弹性系数 * 原法向速度 const newNormalVelX = -ball.restitution * velocityDotNormal * normalX; const newNormalVelY = -ball.restitution * velocityDotNormal * normalY; // 切向速度 = 原速度 - 法向速度 const tangentVelX = ball.vx - velocityDotNormal * normalX; const tangentVelY = ball.vy - velocityDotNormal * normalY; // 7. 更新球的速度(切向速度乘以摩擦系数) ball.vx = newNormalVelX + tangentVelX * ball.friction; ball.vy = newNormalVelY + tangentVelY * ball.friction; // 8. 位置修正:将球推出穿透区域 const penetration = ball.radius - normalLength; if (penetration > 0) { ball.x += normalX * penetration; ball.y += normalY * penetration; } }

对于非静态的杆子,情况要复杂得多,需要同时考虑杆子的平动和转动,并应用冲量和角冲量。为了简化,我们的第一个版本可以只处理静态杆子。让杆子动起来是进阶内容。

最后,我们需要一个游戏循环gameLoop,在每一帧中:

  1. 清空画布。
  2. 更新所有物体的位置(应用重力、速度积分)。
  3. 进行广泛的碰撞检测(Broad Phase)和精确的碰撞检测(Narrow Phase)。
  4. 解析所有碰撞,更新速度。
  5. 绘制所有物体。
function gameLoop() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 1. 应用重力到球 ball.vy += GRAVITY; // 2. 更新球的位置 ball.x += ball.vx; ball.y += ball.vy; // 3. 检测与所有杆子的碰撞 for (const stick of sticks) { if (ball.checkCollisionWithStick(stick)) { resolveBallStaticStickCollision(ball, stick); } } // 4. 检测边界碰撞 if (ball.x - ball.radius < 0) { ball.x = ball.radius; ball.vx = -ball.vx * ball.restitution; } if (ball.x + ball.radius > canvas.width) { ball.x = canvas.width - ball.radius; ball.vx = -ball.vx * ball.restitution; } if (ball.y + ball.radius > canvas.height) { ball.y = canvas.height - ball.radius; ball.vy = -ball.vy * ball.restitution; ball.vx *= ball.friction; // 地面摩擦 } // 5. 绘制所有元素 drawBackground(); for (const stick of sticks) stick.draw(ctx); ball.draw(ctx); drawGoal(); // 绘制终点 // 6. 检查是否到达终点 if (checkGoalReached(ball)) { console.log('Level Complete!'); // 进入下一关或显示胜利画面 } requestAnimationFrame(gameLoop); }

注意:这是一个极度简化的物理循环。真实的引擎会使用更稳定的积分方法(如Verlet积分或RK4),并处理更复杂的约束(如杆子之间的铰链连接)。但对我们这个游戏来说,这个简化版本已经能产生非常有趣和可玩的效果了。关键在于调整重力GRAVITY、弹性系数restitution和摩擦系数friction,直到手感你觉得“舒服”为止。

3. 交互与关卡设计:让游戏“可玩”起来

有了物理核心,游戏还缺少灵魂——玩家交互和关卡目标。我们的核心交互是:玩家可以拖拽放置杆子

3.1 实现杆子的拖拽放置

我们需要监听鼠标(或触摸)事件。逻辑是:鼠标按下时,记录起始点;鼠标移动时,实时绘制一根预览杆子;鼠标松开时,根据起始点和结束点创建一根新的Stick对象,并加入到物理世界中。

let isPlacingStick = false; let stickStartX, stickStartY; let previewStick = null; canvas.addEventListener('mousedown', (e) => { const rect = canvas.getBoundingClientRect(); stickStartX = e.clientX - rect.left; stickStartY = e.clientY - rect.top; isPlacingStick = true; // 可以在这里判断是否点击了UI按钮或其他区域 }); canvas.addEventListener('mousemove', (e) => { if (!isPlacingStick) return; const rect = canvas.getBoundingClientRect(); const currentX = e.clientX - rect.left; const currentY = e.clientY - rect.top; // 创建或更新预览杆子 previewStick = { x1: stickStartX, y1: stickStartY, x2: currentX, y2: currentY, color: 'rgba(52, 152, 219, 0.7)' // 半透明蓝色 }; }); canvas.addEventListener('mouseup', (e) => { if (!isPlacingStick) return; isPlacingStick = false; const rect = canvas.getBoundingClientRect(); const endX = e.clientX - rect.left; const endY = e.clientY - rect.top; // 检查杆子长度是否大于最小值,避免误操作 const length = Math.sqrt((endX - stickStartX) ** 2 + (endY - stickStartY) ** 2); if (length < 10) { previewStick = null; return; } // 创建真正的杆子对象,默认是非静态的 const newStick = new Stick(stickStartX, stickStartY, endX, endY, false); sticks.push(newStick); previewStick = null; });

然后在gameLoop的绘制部分,加入绘制预览杆子的代码。

// 在gameLoop的绘制部分 if (previewStick) { ctx.save(); ctx.strokeStyle = previewStick.color; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(previewStick.x1, previewStick.y1); ctx.lineTo(previewStick.x2, previewStick.y2); ctx.stroke(); ctx.restore(); }

3.2 设计游戏关卡与胜利条件

一个关卡需要定义:起点、终点、初始存在的静态结构(支点)、可用的杆子数量(可选)以及球本身。

我们可以用一个简单的JSON对象来定义关卡数据。

const levels = [ { name: "Level 1: The Bridge", start: { x: 50, y: 300 }, goal: { x: 750, y: 300, radius: 25 }, ballRadius: 15, staticSticks: [ { x1: 100, y1: 400, x2: 200, y2: 400, isStatic: true }, // 地面 { x1: 700, y1: 400, x2: 800, y2: 400, isStatic: true }, // 地面 { x1: 350, y1: 200, x2: 350, y2: 250, isStatic: true }, // 空中支点 ], maxSticks: 5 // 最多放置5根杆子 }, // ... 更多关卡 ];

胜利条件就是判断球是否进入了终点区域(一个圆形或矩形区域)。

function checkGoalReached(ball) { const goal = currentLevel.goal; const dx = ball.x - goal.x; const dy = ball.y - goal.y; const distance = Math.sqrt(dx * dx + dy * dy); return distance < (ball.radius + goal.radius); }

你还可以增加更复杂的条件,比如限定时间、要求球以低于某个速度进入终点等。

3.3 添加游戏状态与UI界面

一个完整的游戏需要开始菜单、关卡选择、游戏进行中、胜利/失败等状态。我们可以用一个简单的状态机来管理。

const GameState = { MENU: 'menu', PLAYING: 'playing', LEVEL_COMPLETE: 'level_complete', LEVEL_FAILED: 'level_failed' }; let currentGameState = GameState.MENU; let currentLevelIndex = 0; let placedSticksCount = 0; // 在gameLoop中,根据状态绘制不同内容 function gameLoop() { ctx.clearRect(0, 0, canvas.width, canvas.height); switch (currentGameState) { case GameState.MENU: drawMenu(); break; case GameState.PLAYING: updatePhysics(); drawGameWorld(); drawUI(); // 绘制杆子数量、重试按钮等 break; case GameState.LEVEL_COMPLETE: drawGameWorld(); drawLevelCompleteScreen(); break; // ... 其他状态 } requestAnimationFrame(gameLoop); }

UI界面可以包括:剩余杆子数量、重试按钮、回到菜单按钮等。用CanvasfillText或者叠加一个HTML的div层都可以实现。

4. 性能优化、调试与进阶扩展思路

当游戏基本可玩后,我们会遇到一些实际问题和可以提升的空间。

4.1 性能优化:控制物理计算的复杂度

我们的碰撞检测是O(N*M)的(N个球,M根杆子)。当物体数量增多时,帧率会下降。可以采用一些优化策略:

  1. 空间划分:将画布划分为网格,只检测同一网格或相邻网格内的物体是否可能碰撞。这被称为“空间哈希”或“网格法”。
  2. Broad Phase & Narrow Phase:先使用AABB(轴对称包围盒)进行粗略的碰撞检测,快速排除明显不相交的物体对,再对可能碰撞的对进行精确的几何检测。
  3. 固定时间步长:物理模拟的稳定性依赖于固定的更新时间间隔。使用requestAnimationFrame时,帧间隔是不稳定的。更好的做法是使用固定时间步长进行物理更新,而渲染独立进行。
// 固定时间步长循环示例 let lastTime = 0; const PHYSICS_TIME_STEP = 1000 / 60; // 瞄准60Hz物理更新 function gameLoop(timestamp) { if (!lastTime) lastTime = timestamp; const deltaTime = timestamp - lastTime; lastTime = timestamp; // 累积时间 physicsAccumulator += deltaTime; // 以固定步长更新物理,可能一次循环更新多次 while (physicsAccumulator >= PHYSICS_TIME_STEP) { updatePhysics(PHYSICS_TIME_STEP); // 传入固定的时间步长 physicsAccumulator -= PHYSICS_TIME_STEP; } // 渲染(渲染时可以插值,使动画更平滑,此处简化) render(); requestAnimationFrame(gameLoop); }

4.2 调试技巧:让物理过程可视化

物理调试非常痛苦,因为你看不到力、速度、碰撞法线。一个有效的方法是绘制调试信息。

function drawDebugInfo() { ctx.save(); ctx.font = '12px Arial'; ctx.fillStyle = 'white'; ctx.fillText(`Ball Vel: (${ball.vx.toFixed(2)}, ${ball.vy.toFixed(2)})`, 10, 20); ctx.fillText(`FPS: ${calculateFPS().toFixed(1)}`, 10, 40); // 绘制球的速度向量 ctx.strokeStyle = '#f1c40f'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(ball.x, ball.y); ctx.lineTo(ball.x + ball.vx, ball.y + ball.vy); ctx.stroke(); // 绘制所有杆子的AABB包围盒 ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)'; ctx.lineWidth = 1; for (const stick of sticks) { const minX = Math.min(stick.x1, stick.x2); const maxX = Math.max(stick.x1, stick.x2); const minY = Math.min(stick.y1, stick.y2); const maxY = Math.max(stick.y1, stick.y2); ctx.strokeRect(minX, minY, maxX - minX, maxY - minY); } ctx.restore(); }

在开发阶段,可以通过一个快捷键(比如按‘D’键)来切换调试信息的显示。

4.3 进阶扩展:从原型到精品游戏的路径

一个基础版本完成后,你可以从多个方向深化这个游戏:

  1. 更真实的物理:引入Matter.js。用真正的物理引擎替换我们的简化版,你将立刻获得关节、复合体、更真实的碰撞响应等高级功能。集成步骤通常是:创建引擎、世界,将我们的StickBall类替换为Matter的BodiesConstraints

  2. 多样的交互物体

    • 可旋转的铰链:杆子一端固定,另一端可以自由旋转。
    • 弹性绳索:用DistanceConstraint实现,有最大长度和弹性系数。
    • 不同材质的球:金属球(高弹性、低摩擦)、木球(低弹性、中摩擦)、橡胶球(中弹性、高摩擦)。
    • 风扇/风力区域:在画布特定区域施加持续的力。
    • 可破坏的杆子:杆子有“耐久度”,受到超过阈值的力会断裂。
  3. 关卡编辑器:做一个可视化工具,让玩家(甚至你自己)可以拖动支点、设置起点终点、绘制地形,然后生成关卡JSON。这能极大丰富游戏内容。

  4. 粒子与音效:球滚动时产生灰尘粒子,碰撞时发出“梆”的音效,过关时播放庆祝音乐和粒子特效。这些视听反馈能极大提升游戏质感。可以使用Web Audio APICanvas绘制粒子。

  5. 移动端适配与触摸优化:确保在手机和平板上操作流畅。可能需要调整UI大小,并将鼠标事件改为触摸事件,支持多点触控(比如双指缩放关卡视图)。

// 触摸事件处理示例 canvas.addEventListener('touchstart', (e) => { e.preventDefault(); // 阻止页面滚动 if (e.touches.length === 1) { const touch = e.touches[0]; // 将触摸坐标转换为画布坐标,逻辑与mousedown相同 const rect = canvas.getBoundingClientRect(); stickStartX = touch.clientX - rect.left; stickStartY = touch.clientY - rect.top; isPlacingStick = true; } }); // 类似地处理 touchmove 和 touchend

实现这些扩展功能的过程,就是你从一个HTML5初学者成长为一名有能力解决复杂问题的游戏开发者的路径。每一个功能的添加,都会迫使你去学习新的API、思考新的架构、解决新的BUG,这正是项目驱动的学习方式最大的魅力所在。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询