简介:这是一份面向前端初学者与HTML5游戏开发爱好者的单机赛车竞速项目实战资源,聚焦Canvas绘图、JavaScript逻辑控制与基础物理模拟(如速度/加速度/碰撞响应),兼顾休闲娱乐与技术练手双重目标。压缩包共35个文件,含14个核心JS脚本(涵盖游戏主循环、赛道渲染、角色控制、音效管理、漂移特效等模块)、8个MP3音效资源、6个PNG/JPG游戏素材(赛车、背景、UI元素),以及README.md、说明文档和HTML入口文件,整体仅2.57MB,轻量易解压运行。已有52人学习下载,适合快速上手Canvas API、理解游戏帧循环与状态管理机制。资源结构清晰,racingGame-master为主工程目录,内含分层模块化代码(如Drift.js、Scene.js、Track.js),配合lib中引入的Lufylegend等轻量库,便于读者逐模块调试、修改参数或扩展功能,是掌握H5游戏开发流程的优质实践样本。
1. 项目概述:一个能跑起来的H5赛车游戏
最近在整理硬盘,翻出来一个几年前做的H5赛车小游戏项目。当时做这个的初衷很简单,就是想用最纯粹的HTML5 Canvas和JavaScript,不依赖任何游戏引擎,从零开始实现一个能跑起来的竞速游戏。这个项目麻雀虽小,五脏俱全,涵盖了游戏循环、物理模拟、碰撞检测、图像渲染、用户输入处理等核心概念。它既是一个能带来几分钟刺激竞速乐趣的休闲小游戏,也是一个绝佳的前端技术学习案例。如果你对Canvas绘图、游戏开发基础,或者单纯想看看一个完整的、可运行的游戏项目是如何构建的,那这个项目应该能给你不少启发。
整个游戏的核心就是那个<canvas>画布,所有的赛道、赛车、树木、计分板,都是通过JavaScript一笔一笔画出来的。操作也极其简单,键盘的方向键控制左右移动,空格键加速,追求的就是那种简单直接的操控感和在弯道超越的紧张感。代码结构清晰,注释也比较详细,无论是想直接运行玩一玩,还是想深入代码学习,门槛都不高。接下来,我就把这个项目的核心实现逻辑、关键代码片段以及开发中遇到的那些“坑”和解决方案,详细拆解一遍。
2. 游戏核心架构与渲染循环剖析
一个游戏能“动”起来,核心在于其游戏循环。我们这个赛车游戏也不例外,它的心跳就是一个由requestAnimationFrame驱动的循环。
2.1 为什么选择 requestAnimationFrame?
你可能听说过用setInterval或setTimeout来制作动画。但在现代浏览器中,对于动画和游戏,requestAnimationFrame(简称 rAF) 是首选。原因有几个:首先,rAF 会与浏览器的重绘周期同步,通常是每秒60帧,这能保证动画的平滑性,避免丢帧或卡顿。其次,当页面处于非激活标签页时,rAF 会自动暂停,节省 CPU 和电池资源。而setInterval可不会这么“贴心”,它会在后台继续执行,白白消耗性能。
在我们的游戏主入口game.js中,你会看到类似这样的结构:
class Game { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.lastTime = 0; this.init(); // 初始化游戏资源、赛道、车辆等 this.loop(0); // 启动游戏循环 } loop(timestamp) { // 计算时间增量 deltaTime,确保在不同帧率下游戏速度一致 const deltaTime = timestamp - this.lastTime; this.lastTime = timestamp; // 1. 更新游戏状态(位置、速度、碰撞等) this.update(deltaTime); // 2. 清除上一帧画面 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 3. 绘制当前帧 this.draw(); // 4. 请求下一帧 requestAnimationFrame((ts) => this.loop(ts)); } update(deltaTime) { // 更新赛车位置、处理输入、检测碰撞等 this.playerCar.update(deltaTime, this.input); // ... 更新其他游戏对象 } draw() { // 绘制背景、赛道、赛车、UI等 this.drawBackground(); this.drawTrack(); this.playerCar.draw(this.ctx); this.drawUI(); } }这个loop函数就是游戏的大脑。deltaTime是关键,它表示上一帧到当前帧经过的毫秒数。所有基于时间的运动(比如车速)都应该乘以deltaTime的一个系数,这样无论玩家电脑是60帧还是144帧,赛车每秒移动的距离在现实时间中是恒定的,保证了游戏体验的一致性。
2.2 分层渲染与性能考量
直接在一个Canvas上绘制所有元素虽然简单,但在复杂场景下可能引发性能问题。一个常见的优化思路是“分层渲染”。例如,可以将相对静态的背景(如远处的山、天空)绘制在一个离屏Canvas上,而将频繁变化的元素(赛车、粒子效果)绘制在主Canvas上。这样,每一帧就无需重绘静态背景,只需将其图像复制到主Canvas,能有效提升性能。
在我们的项目中,为了保持简洁,采用了单Canvas绘制。但绘制顺序至关重要,必须遵循“从远到近”的原则:先画天空和远山,再画赛道和两侧的草地、护栏,最后画赛车和UI(如速度表、计时器)。否则,后绘制的物体会挡住先绘制的,造成视觉错误。
注意:Canvas的
clearRect操作和绘制大量复杂路径(如曲线赛道)是比较耗性能的。如果发现游戏在低端设备上帧率较低,可以考虑以下优化:1) 减少每帧需要绘制的物体数量;2) 将赛道等复杂图形预渲染为Image对象;3) 使用ctx.save()和ctx.restore()管理状态时注意范围,避免不必要的状态保存与恢复。
3. 赛道生成、车辆物理与碰撞检测实现
这是游戏玩法的核心三要素。赛道决定了游戏的路径,物理赋予了车辆真实感,碰撞检测则保证了游戏的规则和挑战性。
3.1 赛道数据的生成与表示
赛道不是一张图片,而是由程序生成的。我们使用一个中心线数组来表示赛道。每个点包含了其在画布上的坐标(x, y)、赛道的宽度以及可能的其他属性(如路面类型、摩擦系数)。通过连接这些点,就形成了赛道的中心路径。
更高级的玩法是使用“样条曲线”来生成更平滑的赛道。但在本项目中,为了直观,我们可能采用简化的方式:预先定义一组关键点(控制点),然后在这些点之间进行线性插值,生成足够密集的点序列来模拟曲线。
// 简化的赛道数据示例 this.trackPoints = [ {x: 100, y: 500, width: 300}, {x: 200, y: 400, width: 280}, {x: 350, y: 350, width: 250}, // ... 更多点 ]; // 在绘制时,连接这些点形成赛道边界 drawTrack() { this.ctx.strokeStyle = '#666'; this.ctx.lineWidth = this.trackWidth; this.ctx.beginPath(); this.ctx.moveTo(this.trackPoints[0].x, this.trackPoints[0].y); for (let i = 1; i < this.trackPoints.length; i++) { this.ctx.lineTo(this.trackPoints[i].x, this.trackPoints[i].y); } this.ctx.stroke(); }赛道的绘制不仅仅是画一条线。通常需要画出两条线(左右边界)以及中间的虚线分隔线。这需要根据每个中心点坐标和该点的赛道宽度,计算出左右边界的坐标。
3.2 简化车辆物理模型
真实的车辆物理极其复杂。在休闲游戏中,我们采用一个简化的“飞船式”物理模型就足够了。这个模型主要关心几个变量:
- 位置 (x, y):赛车在画布上的坐标。
- 速度 (speed):标量,表示赛车快慢。
- 方向 (angle):赛车车头指向的角度(弧度制)。
- 转向速度 (turnSpeed):按左右键时,角度每秒的变化率。
- 加速度 (acceleration)和减速度 (deceleration):踩油门和松油门时的速度变化率。
更新逻辑大致如下:
update(deltaTime) { const deltaSeconds = deltaTime / 1000; // 转换为秒 // 处理转向 if (this.input.left) this.angle -= this.turnSpeed * deltaSeconds; if (this.input.right) this.angle += this.turnSpeed * deltaSeconds; // 处理加速/减速 if (this.input.accelerate) { this.speed += this.acceleration * deltaSeconds; } else { // 自然减速(模拟阻力) this.speed *= (1 - this.deceleration * deltaSeconds); } this.speed = Math.max(0, Math.min(this.speed, this.maxSpeed)); // 限制速度范围 // 根据速度和角度更新位置 this.x += Math.cos(this.angle) * this.speed * deltaSeconds; this.y += Math.sin(this.angle) * this.speed * deltaSeconds; }为了让手感更好,可以引入“速度影响转向”的机制:车速越快,转向灵敏度越低,模拟高速过弯的困难。这可以通过动态调整turnSpeed来实现。
3.3 基于赛道距离的碰撞检测
碰撞检测是游戏开发中的经典难题。对于赛车和赛道边界的碰撞,一个高效且足够精确的方法是使用“点到线段距离”检测。
核心思想是:将赛车的当前位置,近似到赛道中心线上最近的那个点。然后计算赛车到该最近点的距离。如果这个距离大于该点处赛道宽度的一半,则说明赛车超出了赛道边界,即发生碰撞。
checkTrackCollision(carX, carY) { let closestDistSq = Infinity; // 最近距离的平方 let closestSegmentIndex = -1; // 遍历所有赛道线段(由相邻两个中心点构成) for (let i = 0; i < this.trackPoints.length - 1; i++) { const p1 = this.trackPoints[i]; const p2 = this.trackPoints[i+1]; // 计算点到线段的最短距离平方(省略具体几何计算函数) const distSq = pointToSegmentDistSq(carX, carY, p1, p2); if (distSq < closestDistSq) { closestDistSq = distSq; closestSegmentIndex = i; } } const closestPoint = this.trackPoints[closestSegmentIndex]; const trackHalfWidth = closestPoint.width / 2; const distance = Math.sqrt(closestDistSq); if (distance > trackHalfWidth) { // 发生碰撞! this.handleCollision(); return true; } return false; }发生碰撞后,简单的处理方式是让赛车瞬间减速,或者被“弹回”赛道内侧。更细腻的处理可以模拟碰撞后的滑行和旋转,但这需要更复杂的物理响应。
实操心得:碰撞检测的精度和性能需要权衡。如果赛道点非常密集,逐点计算距离开销很大。一个优化技巧是使用空间划分数据结构,如网格或四叉树,快速排除大部分无关的赛道线段。对于这个小游戏,如果赛道点数量在几百个以内,直接遍历通常是可接受的。另外,计算距离平方 (
distSq) 进行比较,避免开平方根运算,是性能优化中的一个小技巧。
4. 用户输入、游戏状态与UI交互设计
游戏不仅要有内在逻辑,还要有顺畅的交互和清晰的反馈。这部分决定了玩家是否觉得“跟手”和“明白”。
4.1 键盘输入的平滑处理
直接监听keydown和keyup事件来改变一个控制状态对象是最常见的做法。但这里有个细节:浏览器会对持续按下的键进行重复触发(keydown事件连续触发)。为了更精确的控制,我们通常只在keydown和keyup时改变状态。
class InputHandler { constructor() { this.keys = {}; window.addEventListener('keydown', (e) => { this.keys[e.key] = true; // 防止方向键滚动页面 if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)){ e.preventDefault(); } }); window.addEventListener('keyup', (e) => { this.keys[e.key] = false; }); } // 提供更友好的查询接口 get left() { return this.keys['ArrowLeft'] || this.keys['a']; } get right() { return this.keys['ArrowRight'] || this.keys['d']; } get accelerate() { return this.keys['ArrowUp'] || this.keys['w'] || this.keys[' ']; } }在游戏循环的update方法中,我们查询input.left等属性,而不是直接读取事件。这样就将输入处理与游戏状态更新解耦,逻辑更清晰。
4.2 游戏状态管理
一个完整的游戏通常有多个状态:开始菜单、游戏中、暂停、结束等。我们可以用一个简单的状态机来管理。
const GameState = { MENU: 'menu', PLAYING: 'playing', PAUSED: 'paused', FINISHED: 'finished' }; class Game { constructor() { this.state = GameState.MENU; } update(deltaTime) { switch(this.state) { case GameState.PLAYING: this.updateGameplay(deltaTime); break; case GameState.PAUSED: // 什么都不更新,或者只更新UI动画 break; // ... 其他状态 } } draw() { // 根据状态绘制不同的界面 switch(this.state) { case GameState.MENU: this.drawMenu(); break; case GameState.PLAYING: this.drawGameplay(); break; // ... } } }在drawMenu或drawPauseScreen中,我们会绘制按钮。检测鼠标点击是否在按钮区域内,从而触发状态切换(如从 MENU 到 PLAYING)。这就构成了一个完整的交互循环。
4.3 Canvas UI 绘制:速度表与计时器
游戏内的UI,如速度表和计时器,也需要用Canvas绘制。这考验的是基本的2D绘图API运用。
速度表可以画成一个半圆或扇形,用ctx.arc绘制背景,再根据当前速度与最大速度的比值,绘制一个填充的扇形作为指针。
drawSpeedometer(speed, maxSpeed) { const centerX = 80; const centerY = this.canvas.height - 80; const radius = 50; // 绘制背景圆弧 this.ctx.beginPath(); this.ctx.arc(centerX, centerY, radius, Math.PI, 0); // 从180度到0度(底部半圆) this.ctx.lineWidth = 10; this.ctx.strokeStyle = '#333'; this.ctx.stroke(); // 根据速度比例计算当前角度 const speedRatio = speed / maxSpeed; const currentAngle = Math.PI + (Math.PI * speedRatio); // 从底部开始填充 // 绘制速度填充弧 this.ctx.beginPath(); this.ctx.arc(centerX, centerY, radius, Math.PI, currentAngle); this.ctx.lineWidth = 10; this.ctx.strokeStyle = '#0f0'; // 绿色速度条 this.ctx.stroke(); // 绘制速度文本 this.ctx.fillStyle = '#fff'; this.ctx.font = 'bold 20px Arial'; this.ctx.textAlign = 'center'; this.ctx.fillText(`${Math.round(speed)}`, centerX, centerY + 8); }计时器更简单,只需要在每一帧更新一个时间变量,然后用ctx.fillText绘制出来。注意,时间通常以“分:秒.毫秒”的格式显示,需要进行格式化。
update(deltaTime) { if (this.state === GameState.PLAYING) { this.gameTime += deltaTime; // 累加毫秒 } } drawTimer() { const totalSeconds = this.gameTime / 1000; const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); const millis = Math.floor((this.gameTime % 1000) / 10); // 显示两位毫秒 const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${millis.toString().padStart(2, '0')}`; this.ctx.fillStyle = '#fff'; this.ctx.font = '24px Arial'; this.ctx.textAlign = 'right'; this.ctx.fillText(`TIME: ${timeString}`, this.canvas.width - 20, 40); }踩坑记录:在绘制UI时,尤其是文本,很容易忘记重置Canvas的绘图状态。比如,在绘制赛道时你可能修改了
ctx.fillStyle、ctx.font或ctx.textAlign,如果绘制UI前不重新设置,UI就会显示异常。一个良好的习惯是,在绘制一个独立的UI组件前,显式地设置它所需的所有绘图属性。或者,更规范的做法是使用ctx.save()在绘制UI前保存状态,绘制完成后ctx.restore()恢复,这样不会影响后续其他绘制操作。我在早期版本就遇到过速度表数字颜色变成赛道颜色的情况,就是因为这个原因。
5. 资源加载、音效集成与性能调试技巧
一个完整的游戏体验离不开图像、音效等资源。同时,在开发过程中,如何调试和优化性能也是必备技能。
5.1 异步资源加载策略
游戏启动前,需要加载赛车的图片、背景音效、引擎声等。使用Image对象和Audio对象,并监听它们的onload或canplaythrough事件。
class AssetLoader { constructor() { this.images = {}; this.audio = {}; this.loaded = false; } loadImage(key, url) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { this.images[key] = img; resolve(); }; img.onerror = reject; img.src = url; }); } loadAudio(key, url) { return new Promise((resolve, reject) => { const audio = new Audio(); audio.addEventListener('canplaythrough', () => { this.audio[key] = audio; resolve(); }, { once: true }); audio.onerror = reject; audio.src = url; }); } async loadAll() { try { await Promise.all([ this.loadImage('car', 'assets/car.png'), this.loadImage('tree', 'assets/tree.png'), this.loadAudio('engine', 'assets/engine-loop.mp3'), this.loadAudio('crash', 'assets/crash.wav'), ]); this.loaded = true; console.log('所有资源加载完毕'); } catch (error) { console.error('资源加载失败:', error); } } }在游戏初始化时,先显示一个“Loading...”界面,调用assetLoader.loadAll(),等所有Promise完成后再进入游戏主菜单。这样可以避免绘制时图片还未加载完成的空白问题。
5.2 游戏音效的播放与控制
音效能极大增强沉浸感。我们至少需要两种音效:循环播放的引擎声(随车速变化音调或音量)和一次性的碰撞声。
class SoundManager { constructor(assetLoader) { this.assets = assetLoader; this.engineAudio = null; } playEngineSound(speedRatio) { // 如果引擎声还没开始播放,则开始循环播放 if (!this.engineAudio) { this.engineAudio = this.assets.audio['engine'].cloneNode(); // 使用cloneNode以便独立控制 this.engineAudio.loop = true; this.engineAudio.play(); } // 根据速度比例调整播放速率(pitch)或音量 // 注意:playbackRate改变音调,不是所有浏览器都支持平滑变化 this.engineAudio.playbackRate = 0.8 + speedRatio * 0.6; // 从0.8倍速到1.4倍速 this.engineAudio.volume = 0.3 + speedRatio * 0.5; // 音量从0.3到0.8 } stopEngineSound() { if (this.engineAudio) { this.engineAudio.pause(); this.engineAudio.currentTime = 0; this.engineAudio = null; } } playCrashSound() { const crashSound = this.assets.audio['crash'].cloneNode(); crashSound.volume = 0.7; crashSound.play(); } }重要提示:现代浏览器(尤其是Chrome)有自动播放策略。通常要求页面必须先有用户交互(如点击),才能成功播放音频。因此,游戏的背景音乐或循环音效,最好在用户点击“开始游戏”按钮后再初始化播放。直接在主循环里调用
play()很可能会被浏览器静音。
5.3 利用浏览器开发者工具进行性能剖析
当游戏感觉卡顿时,你需要知道瓶颈在哪里。Chrome DevTools 的 Performance 面板是你的好朋友。
- 录制性能:打开DevTools,切换到 Performance 面板,点击录制按钮,然后操作游戏几十秒,停止录制。
- 分析火焰图:你会看到一张详细的时间线图。重点关注:
- FPS(帧率)图表:绿色柱状图,如果经常掉到60以下(出现红色长条),说明有性能问题。
- Main 线程活动:看看哪些函数调用占据了大量时间。是
draw函数?还是某个复杂的update计算? - GPU 内存与使用率:如果Canvas绘制操作非常频繁或画布很大,可能会给GPU带来压力。
- 常见性能瓶颈与优化:
- 过多的
drawImage调用:如果绘制了大量的小图片(如树木、石子),考虑使用“精灵图”合并图片,减少HTTP请求和绘制调用。 - 复杂的路径绘制:赛道如果每一帧都用
lineTo重画很耗性能。如果赛道不变,可以将其绘制到一个离屏Canvas上,每帧只需drawImage这个离屏Canvas。 - 频繁的垃圾回收:注意不要在游戏循环(
update/draw)中频繁创建新对象(如新的数组、对象)。尽量复用对象池。在Performance录制中,如果看到频繁的GC(垃圾回收)活动,就是警示信号。 - 昂贵的碰撞检测:如前所述,优化碰撞检测算法或使用空间索引。
- 过多的
一个简单的帧率显示也能在开发时提供即时反馈:
let frameCount = 0; let lastFpsUpdate = 0; let fps = 0; function updateFPS(currentTime) { frameCount++; if (currentTime - lastFpsUpdate >= 1000) { // 每秒更新一次 fps = frameCount; frameCount = 0; lastFpsUpdate = currentTime; // 可以绘制到Canvas角落 // ctx.fillText(`FPS: ${fps}`, 10, 20); } } // 在游戏循环中调用 updateFPS(timestamp)6. 项目打包、部署与扩展思路
当代码写完、测试完毕后,如何把它变成一个可以分享给别人的“产品”呢?
6.1 代码组织与模块化
原始的代码可能都写在一个巨大的game.js里。为了可维护性,应该按功能拆分:
index.html: 主页面,包含Canvas和脚本引用。js/main.js: 程序入口,初始化游戏。js/game.js: 主游戏类,管理状态和循环。js/car.js: 赛车类,处理物理和绘制。js/track.js: 赛道类,负责生成和碰撞检测。js/input.js: 输入处理类。js/ui.js: UI绘制类。js/assets.js: 资源加载与管理。js/sound.js: 音效管理类。
使用ES6模块的import/export语法来组织它们。对于不支持原生模块的旧环境,可以使用简单的模块打包器如Parcel或Webpack,但对我们这个小项目,直接按顺序在HTML中用<script>标签引入也未尝不可,只要注意依赖顺序。
6.2 适配不同屏幕与响应式
我们的游戏可能在桌面浏览器和手机浏览器上运行。需要做基本的适配。
- Canvas尺寸:不要用CSS硬性设置Canvas的宽高,这会导致拉伸模糊。应该用JavaScript根据窗口大小动态设置Canvas的
width和height属性。function resizeCanvas() { const canvas = document.getElementById('gameCanvas'); // 可以设置一个最大/最小尺寸 const maxWidth = 800; const maxHeight = 600; const scale = Math.min(maxWidth / window.innerWidth, maxHeight / window.innerHeight, 1); canvas.width = window.innerWidth * scale; canvas.height = window.innerHeight * scale; // 同时更新游戏内部的世界坐标转换(如果需要) if (window.game) { game.onResize(canvas.width, canvas.height); } } window.addEventListener('resize', resizeCanvas); resizeCanvas(); // 初始化 - 移动端控制:在手机上,我们需要将方向键和加速键变成屏幕上的虚拟按钮。这需要监听
touchstart,touchmove,touchend事件,并检测触摸点是否落在虚拟按钮的区域内。可以为移动端单独绘制一套UI按钮。
6.3 可能的扩展方向
这个基础项目就像一个骨架,你可以在此基础上添加更多血肉:
- 多关卡与赛道编辑器:设计不同的赛道数据(如更急的弯道、上下坡),让玩家可以切换。甚至可以做一个简单的可视化编辑器,让玩家自己画赛道。
- 对手车辆与AI:增加电脑控制的对手车。最简单的AI可以沿着赛道中心线行驶,并加入一些随机扰动。更复杂的可以实现寻路和超车逻辑。
- 粒子效果:赛车加速时的尾气、碰撞时的火花、漂移时的轮胎烟尘。这些可以用粒子系统来实现,创建大量微小的、有生命周期的图像(小圆点或图片),并让它们按照一定的物理规则运动(如重力、消散)。
- 本地存储与排行榜:使用
localStorage保存玩家的最佳单圈时间或关卡通关记录,并显示一个简单的排行榜。 - 更丰富的视觉效果:为赛道添加纹理(如柏路、草地纹理),为赛车添加简单的光影效果(使用Canvas的渐变或全局复合操作
globalCompositeOperation),让画面更具质感。
这个H5赛车游戏项目,从技术上看,它串联了前端开发中的核心技能:DOM操作、事件处理、Canvas 2D API、面向对象编程、资源管理、性能优化。从产品角度看,它完成了一个最小可玩闭环。无论是作为学习练手,还是作为休闲娱乐的一个小作品,它都提供了一个扎实的起点。我个人的体会是,亲手实现一遍,比看十遍教程理解都要深刻。尤其是在调试碰撞和调整物理参数让“手感”变好的过程中,那种不断试错和最终调通的成就感,正是游戏开发的乐趣所在。
本文还有配套的精品资源,点击获取