简介一款基于HTML、CSS和JavaScript的HTML5拉杆子过关小游戏完整源码专为前端初学者、独立站长及游戏爱好者设计可直接运行或二次开发。游戏以拉杆子跨越障碍为核心所有逻辑均集中于JavaScript文件页面结构与样式分别由HTML和CSS承载三者职责清晰方便拆解学习。压缩包内共3个文件包含一个HTML入口文件、一个CSS样式文件和一个JavaScript游戏逻辑脚本整体大小仅6KB轻量且无第三方依赖适合嵌入个人网站、游戏站或导航页作为休闲模块。目前已有511人学习/下载配套的在线演示地址可先预览游戏效果。下载后即可获得完整可运行的源码解压即可部署对学习者而言可通过源码直观理解原生JS游戏开发中的事件监听、动画循环、条件判断与碰撞检测等基础技术是一份小巧实用的练手资源。1. 拉杆子过关小游戏到底在玩什么一按一放就是全部操作打开页面你会看到一个小火柴人站在左侧平台边缘按住鼠标不松手一根杆子就会从平台边沿开始向上生长松手后杆子倒向对岸小人自动走过杆子落进右边平台就是过了一关。这个“按住拉长、松手落杆、小人走杆”的核心循环就是标题里 HTML5 拉杆子过关小游戏的全部玩法。整套源码基于 Canvas 2D 手写不依赖任何游戏引擎适合想练 Canvas 交互逻辑的前端初学者也适合产品想要快速验证“用时长短决定成败”这类玩法的原型验证。接下来我把这套源码的机制、文件结构、核心代码和踩过的坑一次讲完。2. 先拆核心机制杆子长度、间隙宽度与三种结局的数学模型2.1 一关只有三个动作按住、松手、走过去拉杆子过关看起来很直觉但代码层面必须拆成一个清晰的状态机否则输入和动画会互相打架。我一般会分成ready、growing、falling、walking、win、lose六个状态ready表示本关等待玩家开始按下后进入growing杆子按固定速度生长松手后进入falling杆子绕平台边缘倒下杆子倒平后进入walking小人自动往右走最后根据落点位置进入win或lose。这个状态机的好处是每个时刻只有一个变量在变化。growing时只改杆子长度falling时只改杆子角度walking时只改小人的横坐标互不干扰。很多新手喜欢把杆子生长和旋转放在同一个动画循环里同时判断结果就是杆子一边长一边倒手感非常奇怪。我习惯在每个状态的更新逻辑开头先做一次state判断再决定本轮计算哪个参数。这里最容易被忽略的是“游戏过程中有没有人能暂停”。手机浏览器里用户可能随时切后台requestAnimationFrame会暂停但dt如果直接取两帧差值恢复后台的瞬间会出现一个大跳帧。后面避坑部分我会专门说这个但设计状态机时就要先想到任何跟时间相关的计算都应该用时间增量dt而不是帧号。2.2 决定胜负的关键杆子水平落点与平台区间的关系拉杆子玩法本质是一个一维判定问题。杆子绕起点倒下后水平方向的长度等于生长阶段的长度stick.length它的终点在水平线上的坐标就是起点坐标 stick.length。对岸平台的区间是[平台2.x, 平台2.x 平台2.w]于是结果只有三种杆子落点够不到对岸平台左沿小人走到杆子末端后悬空坠落这是“太短”。杆子落点落在平台区间内小人顺利踩上平台过关这是“正好”。杆子落点越过平台右沿小人走到杆子末端时脚下已经没有平台照样坠落这是“太长”。把这段逻辑写成伪代码就非常直观const endX stick.x stick.length; const left platform2.x; const right platform2.x platform2.w; if (endX left endX right) { // 过关 } else { // 坠落 }这里的endX不是小人的位置而是杆子落点。真实游戏中小人会一直走到endX才会面临判定所以在walking状态里需要同时判断两个条件小人是否已经走到杆子末端以及杆子末端是否落在目标平台上。如果只判断小人是否进入平台区域会出现杆子太长时小人明明走出平台右沿却还判定过关的漏洞。2.3 技术选型Canvas 2D requestAnimationFrame不接物理引擎这类游戏完全不需要物理引擎。杆子倒下是匀速旋转小人走路是匀速直线坠落是匀加速三个运动都是初中物理公式手写不到二十行。接物理引擎反而会把杆子的旋转变成一个受重力影响的刚体运动每次参数不同导致玩家手感飘忽很难调到“按多久走多远”这种可预期的手感。绘制层选择 Canvas 2D 的理由也很简单整个画面只有矩形、圆形和简单线条没有复杂贴图。DOM 也能画但每帧要同步多个元素的style.left和style.top在低端安卓机上会出现肉眼可见的抖动。Canvas 本身有硬件加速配合requestAnimationFrame可以稳定跑满 60 帧。如果你做的是更复杂的关卡比如杆子可以拉伸回弹、角色可以跳跃再考虑引入引擎不迟。对于标题这个“过关小游戏”的定位手写引擎反而是最大优势整个代码只有两三百行别人拿到源码很容易读懂。3. HTML5 拉杆子过关小游戏的最小可玩版本三个文件跑起来3.1 工程结构与 index.html 骨架完整源码我习惯按三层组织一个 HTML 入口、一个 CSS 样式文件、一个 JS 游戏逻辑文件。这样比单文件更好维护也方便直接部署到任意静态服务器。目录结构如下stick-game/ ├── index.html ├── css/ │ └── style.css └── js/ └── main.jsindex.html 只负责页面骨架顶部显示分数和最高分中间是 Canvas 画布底部是一行操作提示另外还有一个弹层用来展示失败结果和重开按钮。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno titleHTML5 拉杆子过关小游戏/title link relstylesheet hrefcss/style.css /head body div idheader span idscore分数0/span span idbest最高0/span /div canvas idgame/canvas div idtips按住屏幕长出杆子松手让杆子倒下帮小人走到对岸/div div idoverlay div idresult/div button idrestartBtn再来一局/button /div script srcjs/main.js/script /body /htmlviewport里的user-scalableno是必须的否则手机端双击会触发页面缩放。Canvas的尺寸由 JS 直接设置CSS 只控制它在页面上的显示宽度。有一点要注意canvas的width和height属性决定实际分辨率CSS 里的宽高只是缩放呈现两者不一致会导致画面模糊所以这里让 JS 统一赋值。3.2 style.css把触屏行为和页面布局一次解决CSS 部分除了让页面居中还需要解决一个关键问题Canvas 上的按住操作不能被浏览器的默认手势拦截。* { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; background: #0f172a; font-family: system-ui, -apple-system, Segoe UI, sans-serif; overflow: hidden; } #header { width: 480px; display: flex; justify-content: space-between; padding: 10px 4px; color: #e2e8f0; font-size: 18px; } #game { width: 480px; height: 640px; background: #f0f9ff; border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); cursor: pointer; touch-action: none; } #tips { width: 480px; text-align: center; margin-top: 10px; color: #94a3b8; font-size: 14px; } #overlay { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(15, 23, 42, 0.92); color: #fff; padding: 28px 40px; border-radius: 14px; text-align: center; display: none; z-index: 10; } #overlay.show { display: block; } #result { font-size: 22px; margin-bottom: 16px; } #restartBtn { background: #22c55e; color: #0f172a; border: none; border-radius: 8px; padding: 10px 28px; font-size: 16px; cursor: pointer; }touch-action: none是触屏游戏的关键它告诉浏览器不要处理画布上的滚动和双击缩放。如果漏掉这行手机端玩家在按住杆子生长的过程中浏览器可能把操作识别成滚动页面的手势。页面overflow: hidden也是为了配合这个行为保证用户唯一能做的就是按住和松开。3.3 main.js状态机、绘制与输入绑定游戏逻辑集中在 main.js 里我分三部分写全局参数和关卡初始化、更新逻辑、绘制逻辑。先看最核心的初始化和更新部分(() { const canvas document.getElementById(game); const ctx canvas.getContext(2d); const W canvas.width 480; const H canvas.height 640; const GROUND_Y 520; const STICK_SPEED 300; const STICK_MAX 620; const FALL_SPEED 2.6; const HERO_SPEED 190; const GRAVITY 900; const scoreEl document.getElementById(score); const bestEl document.getElementById(best); const overlay document.getElementById(overlay); const resultEl document.getElementById(result); const restartBtn document.getElementById(restartBtn); let state ready; let score 0; let best Number(localStorage.getItem(stick_best)) || 0; let p1, p2, stick, hero, cameraX 0; let lastTime performance.now(); let handledByTouch false; function createRound(first false) { if (first) { p1 { x: 70, w: 150 Math.random() * 30 }; cameraX 0; } else { p1 p2; cameraX p1.x - 70; } const gap 100 Math.random() * 50; const p2x p1.x p1.w gap; const maxW Math.min(190, W - 40 - (p2x - cameraX)); const w Math.max(80, maxW * (0.78 Math.random() * 0.22)); p2 { x: p2x, w }; stick { x: p1.x p1.w, length: 0, angle: 0 }; hero { x: p1.x p1.w 14, y: GROUND_Y, walk: false, falling: false, vy: 0, phase: 0 }; state ready; } function update(dt) { if (state growing) { stick.length STICK_SPEED * dt; if (stick.length STICK_MAX) stick.length STICK_MAX; } else if (state falling) { stick.angle - FALL_SPEED * dt; if (stick.angle -Math.PI / 2) { stick.angle -Math.PI / 2; state walking; hero.walk true; } } else if (state walking) { hero.x HERO_SPEED * dt; hero.phase dt * 7; const endX stick.x stick.length; if (hero.x p2.x endX p2.x endX p2.x p2.w) { state win; onWin(); } else if (hero.x endX) { state lose; onLose(); } } else if (state lose hero.falling) { hero.vy GRAVITY * dt; hero.y hero.vy * dt; } } })();这块逻辑里有几个参数需要解释。STICK_SPEED 300表示每秒钟杆子生长 300 像素按一秒钟杆子差不多能跨越常见的间隙FALL_SPEED 2.6是杆子倒下的角速度单位是弧度每秒倒下全程大约 0.6 秒太快了玩家看不清落点太慢了影响节奏。HERO_SPEED 190是小人的行走速度它必须要比杆子倒下速度慢一点这样杆子停稳后小人再走视觉上才不突兀。dt是两帧间隔单位为秒所有速度都乘以dt所以无论帧率是 60 还是 30游戏速度都不变。继续看绘制和输入部分function draw() { ctx.clearRect(0, 0, W, H); const grad ctx.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, #bae6fd); grad.addColorStop(1, #e0f2fe); ctx.fillStyle grad; ctx.fillRect(0, 0, W, H); ctx.save(); ctx.translate(-cameraX, 0); [p1, p2].forEach((p) { ctx.fillStyle #334155; ctx.fillRect(p.x, GROUND_Y - 12, p.w, 12); ctx.fillStyle #1e293b; ctx.fillRect(p.x p.w - 6, GROUND_Y - 16, 6, 16); }); ctx.save(); ctx.translate(stick.x, GROUND_Y); if (state growing || state ready) { ctx.fillStyle #b45309; ctx.fillRect(-3, -stick.length, 6, stick.length); } else { ctx.rotate(stick.angle); ctx.fillStyle #b45309; ctx.fillRect(-3, -stick.length, 6, stick.length); } ctx.restore(); ctx.fillStyle #dc2626; ctx.beginPath(); ctx.moveTo(p2.x p2.w 4, GROUND_Y - 44); ctx.lineTo(p2.x p2.w 24, GROUND_Y - 34); ctx.lineTo(p2.x p2.w 4, GROUND_Y - 24); ctx.fill(); ctx.strokeStyle #1e293b; ctx.beginPath(); ctx.moveTo(p2.x p2.w 4, GROUND_Y); ctx.lineTo(p2.x p2.w 4, GROUND_Y - 44); ctx.stroke(); ctx.fillStyle #0f172a; ctx.fillRect(hero.x - 8, hero.y - 30, 16, 20); ctx.beginPath(); ctx.arc(hero.x, hero.y - 38, 8, 0, Math.PI * 2); ctx.fill(); const step hero.walk ? Math.sin(hero.phase) * 6 : 0; ctx.strokeStyle #0f172a; ctx.lineWidth 4; ctx.beginPath(); ctx.moveTo(hero.x - 7, hero.y - 10); ctx.lineTo(hero.x - 7 step, hero.y); ctx.moveTo(hero.x 7, hero.y - 10); ctx.lineTo(hero.x 7 - step, hero.y); ctx.stroke(); ctx.restore(); } function onDown() { if (state ready) state growing; } function onUp() { if (state growing) state falling; } function onTouchStart(e) { e.preventDefault(); handledByTouch true; onDown(); } function onTouchEnd(e) { e.preventDefault(); onUp(); setTimeout(() { handledByTouch false; }, 400); } function onMouseDown() { if (handledByTouch) return; onDown(); } function onMouseUp() { if (handledByTouch) return; onUp(); } canvas.addEventListener(touchstart, onTouchStart, { passive: false }); canvas.addEventListener(touchend, onTouchEnd, { passive: false }); canvas.addEventListener(mousedown, onMouseDown); canvas.addEventListener(mouseup, onMouseUp); function loop(now) { const dt Math.min((now - lastTime) / 1000, 0.05); lastTime now; update(dt); draw(); requestAnimationFrame(loop); } createRound(true); requestAnimationFrame(loop); })();绘制杆子时有一个关键细节杆子生长阶段画的是竖直向上的矩形坐标从GROUND_Y - stick.length开始倒下阶段才使用ctx.rotate(stick.angle)让同一个矩形绕平台边缘倒下。stick.angle从 0 变到-Math.PI / 2正好从竖直转到水平向右。这个角度方向我踩过坑后面避坑章节详细说。输入部分用handledByTouch标记来处理触屏设备上容易出现的“触摸和鼠标事件同时触发”问题。手机浏览器中一次触摸操作后通常还会派发模拟的mouseup如果不做隔离杆子会在一次松手动作中连续收到两个onUp但对这个游戏来说两次onUp只有第一次有效第二次因为状态已经变成falling会被忽略所以问题不大。真正需要隔离的是mousedown因为第二次无效mousedown不会影响游戏但在growing状态下如果先触发了touchstart又触发mousedown也不会改变状态所以这里是一个防御性写法。4. 过关系统怎么让游戏连续打随机平台、镜头平移与计分逻辑4.1 新关卡从哪里来平台继承与镜头偏移一个过关游戏不能每关重新随机生成整个世界否则镜头和坐标系会乱。我这里的做法是过关后把原来的目标平台p2变成下一关的起点平台p1然后从它右边继续生成新的目标平台。同时把cameraX调整到p1.x - 70让起点平台始终停靠在画面左侧同一个位置这样玩家永远面对同一个操作方向。function createRound(first false) { if (first) { p1 { x: 70, w: 150 Math.random() * 30 }; cameraX 0; } else { p1 p2; cameraX p1.x - 70; } const gap 100 Math.random() * 50; const p2x p1.x p1.w gap; const maxW Math.min(190, W - 40 - (p2x - cameraX)); const w Math.max(80, maxW * (0.78 Math.random() * 0.22)); p2 { x: p2x, w }; stick { x: p1.x p1.w, length: 0, angle: 0 }; hero { x: p1.x p1.w 14, y: GROUND_Y, walk: false, falling: false, vy: 0, phase: 0 }; state ready; }这段最需要理解的是p2x - cameraX这是目标平台在屏幕上的视觉横坐标。W - 40 - (p2x - cameraX)确保目标平台的右边缘不会超出画布右边界 40 像素以外。如果直接在世界坐标里随机生成玩到后面会出现平台跑到屏幕外的情况。有一个坑要提醒gap的取值范围只能在 100 到 150 之间不能贪心拉大。间隙越大杆子需要生长的时间越长玩家手感会从“爽快”变成“紧张”。我在测试时把gap拉大到 200结果玩家普遍抱怨杆子生长速度太慢等得心焦。间隙大小和杆子生长速度必须配套。4.2 完美着陆的加分逻辑与重开按钮过关之后只加 1 分太单调我习惯加一个“完美落点”奖励如果杆子落点正好贴近目标平台左边缘或右边缘额外加 1 分。判断条件是endX与平台边缘的绝对差小于 8 像素。function onWin() { score 1; const endX stick.x stick.length; if (Math.abs(endX - p2.x) 8 || Math.abs(endX - (p2.x p2.w)) 8) { score 1; } updateScore(); setTimeout(() { createRound(false); }, 500); } function onLose() { hero.falling true; resultEl.textContent 这局差一点继续挑战; overlay.classList.add(show); } function restartGame() { overlay.classList.remove(show); score 0; updateScore(); createRound(true); } function updateScore() { scoreEl.textContent 分数 score; if (score best) { best score; localStorage.setItem(stick_best, best); bestEl.textContent 最高 best; } } restartBtn.addEventListener(click, restartGame);这里的setTimeout 500是关键体验设计。如果过关瞬间立刻生成下一关玩家还没有看到小人站上平台画面就跳走了会产生“我到底过了没有”的疑惑。给 500 毫秒让玩家看到小人在目标平台站定再开始新关卡节奏刚好。失败后的重开按钮和分数重置都是常规逻辑但localStorage的使用有个隐私模式相关的坑后面专门说。4.3 从源码到页面跑通步骤与参数速查表拿到源码后跑起来的步骤只有三步# 1. 下载或复制源码到本地目录 # 2. 直接用浏览器打开 index.html # 3. 按住鼠标生长杆子松开落下通关后自动进入下一关不需要装任何依赖也不需要启动服务器。如果你后续要加音频、图片资源才需要起一个本地静态服务比如python -m http.server 8080否则浏览器会因安全策略拒绝加载本地文件。下面这张参数表建议贴在源码头部方便后续调整手感参数默认值调整方向STICK_SPEED300越大杆子长得越快操作越跟手STICK_MAX620限制按住的最长时间防止杆子无限生长FALL_SPEED2.6越大倒下越快越小越容易看清落点HERO_SPEED190越大过关节奏越快GRAVITY900控制坠落速度影响失败反馈gap100 到 150间隙越大节奏越紧张这些参数都在 main.js 开头集中定义不要散落在各个函数里。我见过有人把STICK_SPEED写死在 update 函数里后面想调难度得翻整个文件真实血泪经验。5. 避坑记录拉杆子小游戏开发里容易翻车的 5 个细节5.1 杆子倒下方向画反旋转角度的正负不能凭感觉现象杆子生长时正常竖直向上但松手后它不是向右倒而是向左倒小人直接被杆子砸进平台里。原因Canvas 的rotate方法以顺时针为正方向。杆子从竖直向上转到水平向右实际是逆时针旋转 90 度所以角度必须是-Math.PI / 2不是Math.PI / 2。我当时凭直觉写了正角度结果每次倒下都画反。解决把旋转角度初始值设为 0倒下过程用angle - FALL_SPEED * dt到-Math.PI / 2停止。这个方向问题最好在纸上画一下坐标轴不要背公式。5.2 松手瞬间就判定胜负导致“半根杆”误判现象玩家刚松手杆子还在空中旋转游戏就已经提示失败或者过关画面看起来杆子明明还没到位。原因我把胜负判定写在falling状态的入口处也就是玩家松手的那一帧就计算endX。杆子倒下过程中的endX其实一直是同一个值因为stick.length在生长结束后就不变了但视觉效果还没到位玩家会觉得判定太超前。解决把胜负判定移到walking状态里等杆子完全倒平、小人开始走路后才检查endX。这样不仅是视觉和逻辑一致还顺便给了玩家 0.6 秒左右的预判时间体验更自然。另外要注意walking状态里小人达到endX时才算数不是一进walking就算。5.3 平台随机生成把对岸平台推出屏幕外现象玩到后面某一关目标平台完全消失了但小人还能走过杆子然后凭空站在屏幕外的地方判定过关。原因我只在世界坐标里随机生成gap和平台宽度没有考虑镜头偏移后平台在屏幕上的实际位置。玩到十几关后世界坐标不断累积平台视觉位置越来越右最后直接超出画布。解决生成目标平台时用视觉坐标p2x - cameraX做约束确保平台右边缘距离画布右边界至少还有 40 像素。这个约束要和gap、平台宽度放在一起算不能生成完再判断否则容易出现无解关卡。5.4 手机端点击没反应是 touch-action 和 300ms 延迟现象同一个页面在电脑上按住鼠标完全正常换成手机触摸屏幕杆子要么长不出来要么松手后反应慢半拍。原因浏览器对触摸操作有默认行为比如双击缩放和滚动触摸事件被浏览器拦截或延迟。另外旧版安卓浏览器对触摸事件派发有约 300ms 延迟没有显式preventDefault时游戏会明显卡操作。解决在 Canvas 的 CSS 里加上touch-action: none在touchstart和touchend监听器里调用e.preventDefault()并且把监听器选项设成{ passive: false }。这三步缺一不可光写 CSS 不加事件阻止部分浏览器仍然会处理默认手势。5.5 localStorage 存最高分在隐私模式下直接抛异常现象游戏在普通浏览器里能正常读最高分但有人用隐私模式打开时控制台报SecurityError整个游戏脚本中断页面直接白屏。原因主流浏览器在隐私模式下把localStorage的存储空间禁用了任何setItem操作都会抛异常。这个异常发生在 main.js 加载阶段导致后面所有逻辑都没执行。解决读取和写入localStorage之前做一次 try-catch 包裹。如果没有存储权限就让best保持 0游戏照常运行只是退出页面后不保留最高分记录。这一步能避免一个低概率但破坏性极强的故障。6. 进阶玩法与上线前验证怎么判断这套源码值得投入6.1 三个低成本验证帧率、误触、可玩性确认一套游戏源码能不能上线我一般不做复杂测试先看三个硬指标。第一是帧率打开浏览器开发者工具的性能面板录制 10 秒游戏过程如果平均帧率低于 50优先检查绘制逻辑里有没有多余的save和restore配对以及每帧是否创建了太多临时对象。第二是误触把页面放到手机预览模式从不同角度按住画布边缘看是否会出现杆子不生长或松手无效的情况这类问题大多出在事件监听没做防抖。第三是可玩性让五个不同水平的人各玩五分钟如果他们都能轻松过前三关说明难度曲线偏缓可以适当调大gap范围如果每次都卡死在第三关说明随机范围太大需要把gap上限从 150 调回 130。6.2 两个值得做的扩展双杆子模式与步数限制这个玩法目前是“单杆子、无尽关卡”做进阶时我建议优先考虑两个方向。第一个是双杆子模式小人需要连续走过两根杆子才能到达目标平台第一根杆子的落点决定第二根杆子的起点等于把判定逻辑从一维变成二维玩法深度立刻翻倍。第二个是步数限制每关只给玩家 5 根杆子的生长机会超出就失败这会让玩家放弃“慢慢试”主动追求精确落点。这两个扩展都只需要复用现有的状态机不需要改绘制层。6.3 一个收尾习惯把“完美落点阈值”做成变量我在最初版本里把完美落点的 8 像素判断直接写死在onWin里后来发现不同移动设备的屏幕像素密度差异很大同一套判定在部分手机上过松。现在我会把阈值提升到一个常量并集中管理比如const PERFECT_DIST 8;调手感时只需要改一处。这是小游戏开发中一个容易被忽略的工程习惯玩法参数永远比代码逻辑优先。希望帮到你下次你拿到这类源码至少知道该从哪改起、坑大概在哪。本文还有配套的精品资源点击获取