简介:这份JavaScript游戏源代码合集面向想通过实例入门游戏开发的编程学习者,尤其适合已掌握JS基础语法、希望理解交互逻辑如何落地为可运行作品的初中级开发者。压缩包共60个文件、约78KB,以htm页面为主体,配合txt说明文档、ini配置与gif素材,另有少量css样式和db数据文件,每个小游戏基本做到页面与说明一一对应,便于逐个拆解。内容覆盖字母华容道、打蜜蜂、俄罗斯方块、拼图、推箱子、五子棋、乒乓球、勇闯迷宫、射击、反应速度、模拟抽奖等十余款经典小游戏,涉及DOM操作、事件处理、定时器动画、Canvas绘制、碰撞检测、得分统计与本地存储等知识点,读者可对照源码理解游戏循环与状态管理,并在此基础上修改参数、替换素材、扩展玩法。目前已有868人学习,适合作为课程设计参考或自学练手素材。
1. 拿到一份 js 游戏源代码,先别急着双击 index.html
很多 js 游戏源代码压缩包解压后,第一眼看到的就是一堆.js、.html、assets文件夹,新手最容易犯的错是直接双击index.html,结果画面白屏、控制台报一堆CORS或404。我拆过不少这类小游戏源码,它们大多不是「打开就能玩」的单文件,而是一个需要本地静态服务器才能跑起来的前端工程。这份 js 游戏源代码属于典型的浏览器端小游戏合集,技术栈基本围绕原生 JavaScript + Canvas + HTML5,不依赖后端,适合想学游戏循环、碰撞检测、精灵动画、状态机这套东西的开发者拿来练手和二次开发。下面按「它是什么 → 怎么跑起来 → 核心代码怎么读 → 怎么改」的顺序,把这份源码拆开讲清楚。
2. 项目定位与技术栈:为什么它不依赖框架也能跑
2.1 这份源码到底是个什么东西
从目录和文件命名来看,这份 js 游戏源代码是一个纯前端的小游戏集合,每个游戏一个独立目录,共用一套工具函数和资源加载逻辑。它没有用 React、Vue 这类框架,也没有 Webpack、Vite 这类构建工具,靠的是浏览器原生能力:<canvas>负责渲染,requestAnimationFrame负责驱动游戏循环,Audio对象负责音效,localStorage负责存最高分。这种「零构建」的结构对二次开发其实很友好——你改完一个.js文件,刷新浏览器就能看到效果,不需要等编译。
常见做法是每个游戏目录下有一个index.html作为入口,一个game.js或main.js作为主逻辑,一个assets/放图片和音频。根目录往往还有一个README或package.json,但即便有package.json,也多半只是为了起一个本地静态服务器,而不是真的要走 npm 构建。判断方法很简单:看package.json里有没有scripts.build,如果没有,基本就是纯静态。
2.2 技术栈拆解:原生 JS 撑起一个游戏需要哪几块
把这类源码的技术点归一下类,大概是这么几层:
| 层次 | 用到的技术 | 在源码里的体现 |
|---|---|---|
| 渲染层 | Canvas 2D Context | ctx.drawImage、ctx.fillRect、ctx.clearRect |
| 循环层 | requestAnimationFrame | 主循环函数里递归调用自身 |
| 输入层 | 键盘/鼠标/触摸事件 | keydown、mousedown、touchstart监听 |
| 资源层 | Image / Audio 预加载 | new Image()、new Audio()加onload计数 |
| 状态层 | 简单状态机 | gameState = 'ready' / 'playing' / 'over' |
| 存储层 | localStorage | localStorage.setItem('highScore', ...) |
这套组合的好处是没有黑盒,每一行你都能追到浏览器 API。坏处是代码组织全靠作者自觉,有的源码会把所有逻辑塞进一个几千行的game.js,读起来比较费劲。我一般会先看文件行数,超过 800 行的单文件就先用编辑器折叠函数,按「初始化 → 循环 → 输入 → 碰撞 → 渲染」的顺序跳着读。
2.3 目录结构长什么样
典型的目录大概是这样,不同游戏名字会有差异,但层级基本一致:
js-game-source/ ├── index.html # 游戏选择页或直接是某个游戏入口 ├── css/ │ └── style.css # 页面基础样式,通常很薄 ├── js/ │ ├── utils.js # 公共工具:随机数、碰撞检测、资源加载 │ ├── input.js # 键盘/触摸输入封装 │ └── game.js # 主游戏逻辑 ├── assets/ │ ├── images/ # 精灵图、背景图 │ └── sounds/ # 音效文件 └── package.json # 可选,通常只有 start 脚本读源码时先看utils.js,因为碰撞检测和资源加载这两个函数几乎每个游戏都会调用,看懂它们等于拿到了通用钥匙。input.js次之,它决定了你按键后游戏怎么响应。最后再进game.js,这时候你已经有上下文了,不容易迷路。
3. 跑起来:本地静态服务器与入口文件排查
3.1 为什么不能直接双击打开
直接双击index.html时,浏览器地址栏是file://协议,这时候用new Image()加载本地图片、用fetch读 JSON、甚至某些浏览器的Audio播放都会被安全策略拦掉,控制台会出现Access to image at 'file://...' from origin 'null' has been blocked by CORS policy。这不是代码写错了,是协议不对。解决办法就是起一个本地静态服务器,让页面走http://localhost。
最省事的方式是用 Python 自带的服务器,不用装任何东西:
# 在项目根目录执行,端口随便选一个没被占用的 python3 -m http.server 8080 # 然后浏览器打开 http://localhost:8080如果你机器上只有 Node,也可以用npx serve:
# npx 会临时下载 serve 并启动,不需要全局安装 npx serve . -l 8080两条命令的效果一样:把当前目录当成网站根目录,index.html自动成为默认页。启动后别急着玩,先按 F12 打开控制台,看有没有红色报错,尤其是404——那说明某个资源路径写错了,后面会讲怎么修。
3.2 入口文件与资源路径的对应关系
起好服务器后,第一个要确认的是index.html里引用的脚本路径和实际文件是否对得上。常见写法是:
<!-- index.html 片段 --> <canvas id="gameCanvas" width="480" height="640"></canvas> <script src="js/utils.js"></script> <script src="js/input.js"></script> <script src="js/game.js"></script>这里要注意两点:一是canvas的width和height是画布的实际像素尺寸,不是 CSS 显示尺寸,游戏里所有坐标都基于这个值计算;二是三个script的加载顺序不能乱,game.js依赖前面两个文件里定义的全局函数,顺序反了就会报xxx is not defined。如果你把文件挪了位置,记得同步改这里的src。
资源路径同理。game.js里加载图片通常写成相对路径:
// 资源路径相对于当前 HTML 页面,不是相对于 js 文件 const playerImg = new Image(); playerImg.src = 'assets/images/player.png';很多人以为路径是相对game.js所在目录,其实浏览器解析的是相对当前页面 URL。所以只要index.html在根目录,assets/images/player.png就要从根目录往下找。资源 404 的时候,先在浏览器 Network 面板看请求的实际 URL,再对照磁盘上的文件位置,基本一眼就能定位。
3.3 最小可运行验证:先让一个方块动起来
在正式读游戏逻辑之前,我习惯先做一个最小验证:确认 Canvas 能渲染、循环能跑。可以在控制台直接执行,也可以临时在game.js末尾加几行:
// 最小验证:一个方块从左往右移动 const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); let x = 0; function loop() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 清屏,否则会拖影 ctx.fillStyle = '#4a90d9'; ctx.fillRect(x, 100, 40, 40); // 画方块 x = (x + 2) % canvas.width; // 到右边界回绕 requestAnimationFrame(loop); // 下一帧继续 } loop();这段代码跑通,说明 Canvas 上下文、requestAnimationFrame、画布尺寸都没问题,剩下的就是往这个骨架里填游戏逻辑。如果方块不动,先看控制台有没有报错,再看canvas的id是否和 HTML 里一致——这是最常见的翻车点,getElementById拿不到元素时返回null,后面getContext直接抛异常。
4. 核心模块拆解:游戏循环、碰撞检测与状态机
4.1 游戏主循环:一帧里到底发生了什么
所有这类 js 小游戏的骨架都是同一个模式:初始化 → 循环(更新状态 → 渲染)→ 循环。区别只在于更新和渲染里写了多少东西。典型的主循环长这样:
// game.js 主循环骨架 let lastTime = 0; function gameLoop(timestamp) { // 计算两帧之间的时间差,单位毫秒 const deltaTime = timestamp - lastTime; lastTime = timestamp; update(deltaTime); // 更新所有游戏对象的位置、状态 render(); // 把当前状态画到 canvas 上 requestAnimationFrame(gameLoop); // 请求下一帧 } function update(dt) { // dt 用来做「按时间移动」,避免不同刷新率下速度不一致 player.x += player.speed * (dt / 16.67); } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(playerImg, player.x, player.y); } requestAnimationFrame(gameLoop); // 启动循环这里的关键参数是deltaTime。很多新手源码直接写player.x += 5,在 60Hz 屏幕上没问题,到了 144Hz 屏幕上角色就跑得飞快,这就是没做时间归一化的后果。dt / 16.67的意思是「以 60 帧为基准,把这一帧的时间折算成多少帧」,这样无论屏幕刷新率多少,移动速度都一致。读源码时如果看到update接收了dt却没用,那多半是作者留的坑,二次开发时可以顺手补上。
requestAnimationFrame的回调参数timestamp是浏览器给的高精度时间戳,单位毫秒,不要自己用Date.now()代替,前者和屏幕刷新同步,后者精度和时机都不如它。
4.2 碰撞检测:矩形相交是最常用的那一个
小游戏里 90% 的碰撞都是矩形与矩形,因为精灵图本身就是矩形,用包围盒判断足够快也足够准。utils.js里通常会有这么一个函数:
// 轴对齐包围盒(AABB)碰撞检测 function isColliding(a, b) { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; }四个条件分别排除「a 在 b 左边」「a 在 b 右边」「a 在 b 上边」「a 在 b 下边」四种不重叠的情况,剩下的就是重叠。参数a和b都是带x、y、width、height的对象,注意x、y是左上角坐标,不是中心点——如果源码里精灵的坐标存的是中心点,调用前要先换算,否则会出现「看着没碰到却判定碰撞」的玄学现象。
对于圆形物体(比如子弹、球),源码里可能会用距离判断:
// 圆形碰撞:圆心距离小于半径之和即碰撞 function isCircleColliding(a, b) { const dx = a.x - b.x; const dy = a.y - b.y; const dist = Math.sqrt(dx * dx + dy * dy); return dist < a.radius + b.radius; }Math.sqrt在每帧对大量对象调用时有性能开销,优化做法是比较平方值dx*dx + dy*dy < (a.radius + b.radius) ** 2,省掉开方。这类小优化在对象少的时候无所谓,对象一多就是帧率的分水岭。
4.3 状态机:ready / playing / gameover 三态切换
小游戏不需要复杂的状态管理,但一定要有状态,否则「开始前」「游戏中」「结束后」的逻辑会搅在一起。常见写法是用一个字符串变量:
// 游戏状态机 const STATE = { READY: 'ready', PLAYING: 'playing', GAMEOVER: 'gameover' }; let gameState = STATE.READY; function update(dt) { if (gameState === STATE.PLAYING) { // 只有游戏中才更新玩家和敌人 updatePlayer(dt); updateEnemies(dt); checkCollisions(); } } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); if (gameState === STATE.READY) { drawStartScreen(); } else if (gameState === STATE.PLAYING) { drawGameScene(); } else { drawGameOverScreen(); } } // 按键或点击时切换状态 function onStart() { if (gameState === STATE.READY || gameState === STATE.GAMEOVER) { resetGame(); // 重置分数、玩家位置、敌人列表 gameState = STATE.PLAYING; } }状态机的价值在于「隔离」:update里用状态判断挡住不该跑的逻辑,render里用状态决定画哪一屏。二次开发加新状态(比如PAUSED)时,只要在update和render各加一个分支,不用动原有逻辑。注意resetGame一定要把上一局残留的数组清空,否则重开时敌人会叠在一起——这是我在类似源码里见过最多的 bug。
4.4 资源预加载:等图片和音频都就绪再开局
如果不等资源加载完就开局,第一帧会画不出精灵,或者音效播不出来。标准做法是维护一个计数器:
// 资源预加载:全部加载完才启动游戏 const assets = { player: 'assets/images/player.png', enemy: 'assets/images/enemy.png', bgm: 'assets/sounds/bgm.mp3' }; const loaded = {}; let totalCount = Object.keys(assets).length; let loadedCount = 0; function preload(callback) { for (const key in assets) { const src = assets[key]; if (src.endsWith('.png') || src.endsWith('.jpg')) { const img = new Image(); img.onload = () => { loaded[key] = img; if (++loadedCount === totalCount) callback(); }; img.onerror = () => console.error('图片加载失败:', src); img.src = src; } else { const audio = new Audio(); audio.oncanplaythrough = () => { loaded[key] = audio; if (++loadedCount === totalCount) callback(); }; audio.onerror = () => console.error('音频加载失败:', src); audio.src = src; } } } preload(() => { gameState = STATE.READY; // 资源就绪后再允许开始 });onerror一定要写,否则某个资源 404 时计数器永远到不了totalCount,游戏就卡在加载界面,控制台还不一定有明显报错。oncanplaythrough比onloadeddata更适合音频,它表示「可以流畅播放到结束」,避免开局时音效卡顿。
5. 避坑与排查:跑不起来时先看这几处
5.1 白屏但控制台无报错
现象:页面打开一片白,控制台干净。原因多半是canvas尺寸为 0,或者游戏循环根本没启动。检查index.html里<canvas>有没有写width和height属性,只写 CSS 宽高不算数;再确认requestAnimationFrame(gameLoop)这行确实被执行到了,有时候它被包在某个if里,条件不满足就静默跳过。解决:在gameLoop第一行加console.log('frame'),看是否持续输出。
5.2 图片全部 404
现象:Network 面板一片红,精灵不显示。原因通常是路径基准搞错,或者文件名大小写不一致——Linux 和 macOS 默认文件系统对大小写敏感,Player.png和player.png是两个文件。解决:在 Network 面板点开一个 404 请求,看它请求的完整 URL,再对照磁盘实际路径,逐级修正src。批量排查可以用find . -name "*.png"列出所有图片,和代码里的引用对一遍。
5.3 按键没反应
现象:游戏能显示,但按方向键角色不动。原因一般是事件监听绑在了错误的元素上,或者keydown里判断的keyCode和实际按键对不上。老源码常用event.keyCode,新浏览器更推荐event.key(如'ArrowLeft')。解决:在监听函数里先console.log(e.key, e.code),确认拿到的值,再改判断条件。另外注意canvas需要tabindex才能聚焦接收键盘事件,或者干脆把监听绑到window上。
5.4 游戏越玩越卡
现象:开局流畅,玩一两分钟后帧率明显下降。原因通常是数组只增不减——子弹飞出屏幕、敌人被消灭后没有从数组里移除,每帧还在遍历和渲染它们。解决:在update里对对象数组做过滤,bullets = bullets.filter(b => b.y > -50),把出界的清掉。另一个常见原因是每帧new Image()或new Audio(),资源要预加载一次复用,不能在循环里创建。
5.5 音效在部分浏览器不播放
现象:Chrome 正常,某些浏览器静音。原因是自动播放策略:没有用户交互前,音频播放会被拦截。解决:把背景音乐的play()放到第一次点击或按键的回调里触发,而不是页面加载时直接播。代码上就是document.addEventListener('click', () => bgm.play(), { once: true }),once: true保证只绑一次。
6. 二次开发:从改参数到加一个新玩法
6.1 先改「手感参数」,再动逻辑
拿到源码想快速做出自己的版本,最划算的改法是调参数而不是改结构。速度、重力、生成间隔、生命值这些通常集中在文件顶部的常量区:
// 集中配置,改这里就能调手感 const CONFIG = { playerSpeed: 5, // 玩家移动速度,像素/帧 gravity: 0.4, // 重力加速度,跳跃类游戏用 jumpPower: -10, // 跳跃初速度,负值向上 enemySpawnInterval: 1200, // 敌人生成间隔,毫秒 maxLives: 3 // 初始生命 };把这些散落在各处的魔法数字抽成CONFIG对象,是二次开发的第一步,也是我最推荐先做的事。改完刷新就能感受到差异,不用理解全部逻辑。等手感对了,再考虑加新功能。
6.2 加一个新敌人类型的完整路径
假设要在现有游戏里加一种「会追踪玩家」的敌人,改动点其实很固定,按顺序来:
第一步,在资源预加载里加图片:
// assets 对象里加一项 const assets = { // ...原有资源 chaser: 'assets/images/chaser.png' };第二步,在敌人生成逻辑里按概率决定类型:
function spawnEnemy() { const isChaser = Math.random() < 0.3; // 30% 概率生成追踪型 enemies.push({ x: Math.random() * (canvas.width - 40), y: -40, width: 40, height: 40, speed: isChaser ? 1.5 : 3, type: isChaser ? 'chaser' : 'normal' }); }第三步,在updateEnemies里给追踪型加朝向逻辑:
function updateEnemies(dt) { enemies.forEach(e => { if (e.type === 'chaser') { // 朝玩家方向移动,归一化后乘速度 const dx = player.x - e.x; const dy = player.y - e.y; const len = Math.hypot(dx, dy) || 1; e.x += (dx / len) * e.speed; e.y += (dy / len) * e.speed; } else { e.y += e.speed; // 普通敌人直线下落 } }); }第四步,渲染时按type选图。这四步走完,新敌人就接入了原有的碰撞、计分、清理逻辑,不用改主循环。Math.hypot比手写Math.sqrt(dx*dx+dy*dy)可读性好,性能差异在这个量级可以忽略。
6.3 验证改动是否生效的习惯
每改一处,我固定做三件事:刷新页面看控制台有没有新报错;在关键函数入口打console.log确认它被调用;用浏览器 Performance 面板录几秒,看帧率有没有掉。加新敌人后如果游戏变卡,先看enemies数组长度是不是失控,再看追踪逻辑里有没有每帧创建新对象。这套验证流程走下来,大部分改动问题在提交前就能拦住。
6.4 一个容易忽略的改造点:把全局变量收进模块
这类源码常见的问题是所有变量挂在全局,改一个名字可能牵连十几处。二次开发到一定规模,建议用 IIFE 或 ES Module 把每个游戏包起来:
// 用 IIFE 隔离作用域,避免污染全局 (function () { const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); let gameState = 'ready'; // ...所有游戏逻辑 })();这样内部变量外部访问不到,多人协作或加多个游戏时不会互相覆盖。如果源码已经是多文件结构,改成 ES Module 更彻底:每个文件export需要的函数,入口用<script type="module">引入。注意改成 module 后,本地必须走 http 服务器,file://下 module 会被 CORS 拦掉——这也是为什么前面反复强调先起服务器。
我自己的习惯是,每次接手一份新源码,先花十分钟把全局变量列一遍,能收进模块的当场收掉,后面改起来省心得多。从那以后我每次拆这类 js 游戏源代码,都强制先跑通最小方块验证再读业务逻辑,避免在环境问题上浪费半天。希望帮到你。
本文还有配套的精品资源,点击获取