资讯动态

原生JS+Canvas游戏源码解析:从主循环到碰撞检测的工程实践

发布时间:2026/10/27 14:19:51 来源:尧图企业网站定制
简介这份JavaScript游戏源代码合集面向JavaScript初学者与游戏开发入门者通过可直接运行的完整小游戏案例帮助读者理解JS在交互逻辑、页面渲染与游戏循环中的实际用法。压缩包共60个文件以htm网页文件为主体配合txt说明、ini配置、gif素材、css样式及db数据文件整体约78KB体积轻巧、结构直观便于逐个打开研究。内容涵盖字母华容道、打蜜蜂、俄罗斯方块、拼图、推箱子、五子棋、乒乓球、勇闯迷宫、射击、反应速度测试、模拟抽奖等多种玩法涉及DOM操作、事件处理、定时器动画、Canvas绘图、碰撞检测、得分系统与本地存储等知识点。已有868人学习适合作为课程设计参考或自学练手素材读者可通过阅读与修改源码掌握游戏状态管理、帧动画与交互响应的实现思路并在此基础上扩展关卡、调整难度或替换素材逐步积累从模仿到独立开发的经验。1. 从一份能直接跑起来的 JS 游戏源码说起前阵子帮一个做前端的朋友看简历项目他手里攒了三四个“游戏 Demo”打开一看全是半成品要么依赖装不上要么点开始就白屏要么代码全塞在一个 2000 行的index.html里改个速度参数得翻半天。后来我给他找了一份结构相对完整的 JS 游戏源代码从入口文件到资源加载、从游戏循环到碰撞检测都拆得比较清楚改起来有抓手。这份源码适合两类人一类是想拿一个能跑的小游戏当练手项目、顺便理解浏览器里游戏是怎么转起来的前端新手另一类是想在现成骨架上加关卡、换素材、调手感的开发者。它不依赖重型引擎纯 JS 加 Canvas 就能跑这也是我愿意花时间拆它的原因——门槛低但该有的工程结构一样不少。2. 项目定位与技术选型为什么是原生 JS 加 Canvas2.1 这份源码解决的是什么问题很多人第一次写游戏容易掉进两个坑一是上来就引 Three.js 或者某游戏引擎结果光配环境就耗掉一周游戏逻辑一行没写二是完全不用结构所有代码堆在一个文件里做到第三关就改不动了。这份 JS 游戏源代码走的是中间路线——用浏览器原生能力把“一个游戏该有的骨架”搭出来包括游戏主循环、状态管理、输入处理、碰撞检测、资源加载这几块每一块都独立成模块彼此通过明确的接口通信。它不是一个商业级游戏而是一个可读、可改、可扩展的教学型工程。你拿到手之后能清楚看到“一帧是怎么走完的”“按键怎么变成角色位移”“两个矩形怎么判断撞上了”。这些是后面换任何引擎都用得上的底层认知。常见做法是把它当模板保留主循环和状态机把渲染层和关卡数据换掉就能快速做出自己的小游戏。技术选型上它没有用构建工具链直接script typemodule加载 ES Module浏览器原生支持省掉了 Webpack、Vite 的配置成本。这对新手友好——双击index.html或者起一个静态服务器就能跑。代价是没有热更新和打包压缩但对一个学习型项目来说这个取舍是合理的。2.2 技术栈与目录结构整个项目围绕几个核心文件组织下面是我整理出来的典型目录不同版本文件名可能略有差异但职责划分基本一致js-game/ ├── index.html # 入口页面挂载 canvas ├── css/ │ └── style.css # 页面与 canvas 样式 ├── js/ │ ├── main.js # 启动入口初始化游戏 │ ├── game.js # 游戏主类主循环与状态管理 │ ├── input.js # 键盘/鼠标输入封装 │ ├── player.js # 玩家实体移动、跳跃、动画 │ ├── enemy.js # 敌人实体巡逻、追击 │ ├── collision.js # 碰撞检测工具函数 │ ├── assetLoader.js # 图片/音频预加载 │ └── level.js # 关卡数据与生成逻辑 └── assets/ ├── images/ └── sounds/这个划分的关键在于“实体”和“系统”分离。player.js、enemy.js只关心自己的状态和绘制collision.js是纯函数工具game.js负责调度。改一个敌人的行为不用动主循环换一套碰撞算法不用碰实体代码。这种解耦是二次开发能不能顺利进行的分水岭。模块职责二次开发时的改动频率main.js初始化、启动低game.js主循环、状态切换中input.js输入映射低player.js/enemy.js实体行为高collision.js碰撞判定中level.js关卡数据高2.3 游戏主循环一帧到底做了什么游戏能“动”起来靠的是requestAnimationFrame驱动的循环。这份源码里game.js的核心结构大致如下// game.js export class Game { constructor(canvas) { this.canvas canvas; this.ctx canvas.getContext(2d); this.lastTime 0; this.running false; this.entities []; this.state ready; // ready | playing | paused | over } start() { this.running true; this.lastTime performance.now(); requestAnimationFrame(this.loop.bind(this)); } loop(now) { if (!this.running) return; // 计算两帧之间的时间差单位秒 const delta Math.min((now - this.lastTime) / 1000, 0.05); this.lastTime now; this.update(delta); this.render(); requestAnimationFrame(this.loop.bind(this)); } update(delta) { if (this.state ! playing) return; for (const e of this.entities) { e.update(delta); } this.checkCollisions(); } render() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); for (const e of this.entities) { e.render(this.ctx); } } }这段代码有几个值得注意的点。delta是帧间隔所有移动速度都乘以它这样在不同刷新率的屏幕上角色速度才一致——这是新手最容易忽略的地方直接写x 5在 144Hz 屏幕上会快得离谱。Math.min(..., 0.05)是给delta封顶防止切到后台再切回来时时间差过大导致角色瞬移这个细节很多教程不讲但实际跑起来一定会遇到。state字段把“准备、进行、暂停、结束”分开update里先判断状态避免暂停时实体还在动。requestAnimationFrame的回调参数是时间戳用它而不是Date.now()能拿到更精确的帧时间。整个循环没有用setInterval因为后者和屏幕刷新不同步容易出现画面撕裂和节奏不稳。2.4 输入处理与实体更新输入模块把浏览器的键盘事件转成游戏能读的状态表// input.js export class Input { constructor() { this.keys {}; window.addEventListener(keydown, (e) { this.keys[e.code] true; }); window.addEventListener(keyup, (e) { this.keys[e.code] false; }); } isDown(code) { return !!this.keys[code]; } }用e.code而不是e.key是因为code对应物理按键位置不受输入法和大小写影响。玩家实体在update里读这个状态// player.js update(delta, input) { const speed 220; // 像素/秒 if (input.isDown(ArrowLeft)) this.x - speed * delta; if (input.isDown(ArrowRight)) this.x speed * delta; if (input.isDown(Space) this.onGround) { this.vy -520; // 跳跃初速度 this.onGround false; } // 重力 this.vy 1400 * delta; this.y this.vy * delta; this.checkGround(); }速度、跳跃初速度、重力加速度这三个参数是调手感的旋钮。speed控制水平移动快慢vy初速度决定跳多高重力决定落多快。我一般会先把重力调到一个“跳起来有下坠感”的值再反过来调跳跃初速度最后微调水平速度。这三个数改起来简单但组合出来的手感差别很大建议一次只动一个。3. 跑起来与核心模块拆解从加载资源到碰撞判定3.1 本地启动的两种方式这份源码是纯静态的理论上双击index.html就能开。但用了 ES Module 之后file://协议下浏览器会因为跨域策略拒绝加载模块控制台会报CORS policy或者Failed to load module script。这是第一个必踩的坑。稳妥的做法是起一个本地静态服务器。如果机器上有 Python# 在项目根目录执行Python 3 python -m http.server 8080然后浏览器访问http://localhost:8080。如果习惯 Node 环境用npx serve也行npx serve .两条命令效果一样都是把当前目录当静态资源根。端口被占用就换一个比如8081。启动后打开开发者工具的 Console确认没有 404——资源路径写错是第二常见的翻车点尤其是assets/下的图片大小写敏感在部分系统上会出问题。3.2 资源预加载别让第一帧空着游戏开始前要把图片和音频加载完否则第一帧画出来是空白玩家会以为卡死了。assetLoader.js的典型实现是维护一个计数器和回调// assetLoader.js export function loadAssets(manifest) { return new Promise((resolve, reject) { const images {}; let loaded 0; const total Object.keys(manifest).length; for (const [key, src] of Object.entries(manifest)) { const img new Image(); img.onload () { images[key] img; loaded; if (loaded total) resolve(images); }; img.onerror () reject(new Error(加载失败: ${src})); img.src src; } }); }manifest是一个“键到路径”的映射比如{ player: assets/images/player.png, bg: assets/images/bg.png }。加载完成后返回images对象渲染时用images.player取。这里用Promise包一层是为了在main.js里能await保证资源就绪后再启动主循环// main.js import { Game } from ./game.js; import { loadAssets } from ./assetLoader.js; const canvas document.getElementById(game); const manifest { player: assets/images/player.png, enemy: assets/images/enemy.png, bg: assets/images/bg.png }; loadAssets(manifest).then((images) { const game new Game(canvas, images); game.start(); }).catch((err) { console.error(err); // 这里可以给页面一个可见的错误提示而不是只打日志 });注意catch里不要只console.error就完事实际部署时用户看不到控制台。我一般会在页面上放一个隐藏的错误提示层加载失败时显示“资源加载失败请刷新重试”比白屏强得多。3.3 碰撞检测AABB 够用但要知道边界2D 平台游戏里最常用的是轴对齐包围盒AABB检测collision.js里通常就是一个纯函数// collision.js export function aabb(a, b) { return ( a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y ); }四个条件分别对应“左不越右、右不越左、上不越下、下不越上”全部满足才算相交。这个函数只回答“撞没撞”不回答“从哪边撞的”。如果要做“踩敌人头顶消灭它”这种逻辑就得进一步判断相对位置// 在 game.js 的碰撞处理里 if (aabb(player, enemy)) { const playerBottom player.y player.h; const enemyTop enemy.y; // 玩家下落中且脚底接近敌人顶部判定为踩踏 if (player.vy 0 playerBottom - enemyTop 16) { enemy.dead true; player.vy -380; // 踩踏反弹 } else { this.state over; } }16这个阈值是容差给玩家一点操作空间太严格会导致“明明踩到了却判定死亡”。这个数没有标准答案得根据角色尺寸和帧率调。AABB 的局限在于它假设所有物体都是矩形斜向移动或者圆形角色会有视觉误差但对这类小游戏完全够用。真要做精确碰撞再考虑分离轴或者圆形距离判定那是后话。3.4 关卡数据与实体生成level.js把关卡抽象成数据而不是硬编码坐标// level.js export const levels [ { bg: bg, platforms: [ { x: 0, y: 520, w: 960, h: 40 }, { x: 200, y: 400, w: 160, h: 20 } ], enemies: [ { x: 500, y: 480, patrolLeft: 400, patrolRight: 700 } ], playerStart: { x: 80, y: 440 } } ];game.js读这份数据生成实体敌人拿到patrolLeft和patrolRight就在区间内来回走。加一关就是往数组里再 push 一个对象不用改任何逻辑代码。这种“数据驱动”的做法是二次开发最省力的地方——想做十关写十份数据就行。平台坐标和敌人位置建议先在纸上画个草图或者用浏览器开发者工具量一下 canvas 尺寸再填不然很容易出现“敌人卡在平台里”或者“平台悬空够不着”的情况。4. 避坑与排查那些跑起来才会暴露的问题4.1 白屏但控制台没报错现象页面打开一片白Console 干净Network 里资源都是 200。原因通常是 canvas 尺寸没设对或者绘制坐标全在可视区外。canvas的width、height属性和 CSS 的width、height是两套东西前者是画布分辨率后者是显示尺寸。如果只写了 CSS 没写属性画布默认 300×150你按 960 的坐标画自然什么都看不到。解决在 HTML 里显式写canvas idgame width960 height540/canvasCSS 里再用max-width: 100%做自适应。4.2 角色移动速度在不同电脑上不一致现象自己机器上跑得正常同事的显示器上角色快得像开了加速。原因就是前面说的移动没乘delta直接按帧加固定值。解决所有位移、速度、重力相关的量都乘以delta并且给delta封顶。改完之后在 60Hz 和 144Hz 屏幕上各跑一遍速度应该肉眼一致。4.3 按键连跳或者跳跃失灵现象按住空格角色一直跳或者明明按了却没反应。原因有两个方向一是keydown事件会重复触发系统按键重复二是跳跃判定只看了“键是否按下”没看“是否在地面”。解决跳跃逻辑里加onGround判断落地时重置如果还想更稳可以在keydown里判断e.repeat直接 return避免重复触发。4.4 切到后台再回来角色瞬移现象切到别的标签页待了几秒切回来角色直接飞出屏幕。原因是requestAnimationFrame在后台会暂停恢复时now - lastTime是一个很大的值delta巨大一帧位移过多。解决delta用Math.min封顶前面代码里的0.05就是干这个的同时在visibilitychange事件里重置lastTime双保险。4.5 图片加载了但画出来是糊的现象角色图片显示模糊边缘发虚。原因是图片原始尺寸和绘制尺寸不匹配或者 canvas 被 CSS 拉伸。解决绘制时用drawImage指定目标宽高保持和原图比例一致如果要做高清屏适配把 canvas 的width、height按devicePixelRatio放大再用ctx.scale缩放回来。这一步不做的话在 Retina 屏上会明显发虚。5. 二次开发换皮、加关卡与手感调优的实操路径拿到这份源码最省力的改造顺序是“先换皮再加内容最后调手感”。换皮只动assets/和manifest把角色、敌人、背景图替换成自己的素材尺寸尽量和原图接近避免改碰撞盒。加内容动level.js往levels数组里追加关卡数据敌人巡逻区间和平台位置照着现有格式填。这两步做完游戏看起来就已经是你的了。真正花时间的是手感调优。我一般会建一个临时调试面板把speed、跳跃初速度、重力三个参数暴露成滑块边拖边试找到满意组合后再写回代码。下面是一个极简的调试注入示例// 仅在开发时挂到 window 上方便控制台实时改 window.__tune { speed: 220, jump: -520, gravity: 1400 }; // player.js 里读取时改成从 __tune 取正式版再换回常量这样在 Console 里敲__tune.jump -600就能立刻看到效果不用反复改代码刷新。调完之后记得把值固化回player.js别把调试对象带进正式版本。还有一个容易被忽略的点实体更新顺序。game.js里entities数组的顺序会影响碰撞判定结果玩家先更新还是敌人先更新在某些边界情况下结果不同。稳妥的做法是固定顺序——先更新玩家再更新敌人最后统一做碰撞检测而不是在各自update里互相检测。这样逻辑清晰出问题也好定位。验证改造是否成功我习惯跑三个检查一是帧率打开浏览器 Performance 面板录一段确认稳定在 60fps 附近没有长任务二是边界把角色走到屏幕最左最右、跳到最高最低看有没有穿模或者卡住三是状态切换暂停、恢复、死亡重开各走一遍确认没有残留状态。这三步走完基本能排除大部分低级问题。从那以后我每次拿到一份游戏源码都先不急着改逻辑而是先把主循环和delta处理看一遍——这两个地方对了后面调什么都顺手这两个地方错了改再多内容也是白搭。希望这份拆解能帮你少走点弯路把这份 JS 游戏源代码真正跑起来、改成自己的东西。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑