news 2026/6/10 12:40:34

从零手写《超级玛丽》——前端 Canvas 游戏开发与物理引擎

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写《超级玛丽》——前端 Canvas 游戏开发与物理引擎

摘要
本文将带领读者从零开始,使用纯前端技术(HTML5 Canvas + TypeScript + Vite)完整实现一个可玩、可扩展、高性能的《超级玛丽》(Super Mario Bros.)克隆版。文章不仅提供逐行代码解析,更深入剖析平台跳跃游戏的核心系统设计:包括角色状态机、重力与跳跃物理模拟、AABB 碰撞检测、瓦片地图(Tilemap)系统、精灵图(Sprite Sheet)渲染、视口跟随、输入处理等关键技术。同时涵盖前端工程化实践:TypeScript 类型建模、模块化拆分、性能优化(FPS 控制、内存管理)、PWA 离线支持、触屏适配等。最终项目可在手机和 PC 上流畅运行,并开源完整代码。全文约 12,800 字,适合初中级前端开发者系统学习游戏开发。


一、引言:为什么《超级玛丽》是游戏设计的教科书?

1985 年,任天堂在 NES 主机上发布了《超级玛丽兄弟》(Super Mario Bros.)。它不仅拯救了因“雅达利大崩溃”而濒临死亡的北美游戏市场,更重新定义了电子游戏的设计语言

最令人惊叹的是:整个游戏没有任何文字教程。玩家在前 10 秒内就自然学会了:

  • 向右走 → 推进关卡;
  • 跳 → 躲避 Goomba(板栗仔);
  • 顶问号砖 → 获得金币或蘑菇;
  • 进入绿色管道 → 发现隐藏区域。

这种“通过环境教学”(Environmental Storytelling)的设计哲学,至今仍是 UX 设计的黄金标准。

💡对前端开发者的启示
好的产品,应该让用户“无师自通”。我们的 UI 交互,是否也能做到“零文档上手”?

本文目标:不止于复刻像素,更要理解其背后的设计逻辑与技术实现


二、技术选型:为何必须用 Canvas?

虽然现代前端有 React、Vue 等框架,但游戏开发首选仍是 Canvas,原因如下:

能力DOM 方案Canvas 方案
像素级动画卡顿(频繁重排)流畅(直接绘图)
物理模拟难以精确控制可编程重力、速度
碰撞检测依赖 getBoundingClientRect数学计算,高效准确
资源管理多张图片 HTTP 请求多精灵图(Sprite Sheet)单图加载

因此,我们选择:

  • Canvas 2D:轻量、兼容性好、足够实现 2D 平台游戏;
  • TypeScript:强类型避免mario.postion拼写错误;
  • Vite:极速 HMR,提升开发体验;
  • Tiled Map Editor:可视化设计关卡,导出 JSON。

原则:用最合适的工具,解决最核心的问题。


三、项目结构与工程化设计

super-mario/ ├── public/ │ └── assets/ # 静态资源 │ ├── mario-sprites.png # 精灵图 │ ├── tileset.png # 瓦片集 │ └── sfx/ # 音效(jump.wav, coin.wav) ├── src/ │ ├── core/ # 核心游戏逻辑 │ │ ├── Game.ts # 游戏主循环 │ │ ├── Player.ts # 玛丽奥角色 │ │ ├── World.ts # 世界(含 Tilemap) │ │ └── Physics.ts # 物理引擎(重力、碰撞) │ ├── render/ # 渲染系统 │ │ ├── Renderer.ts # Canvas 绘制 │ │ └── Camera.ts # 视口跟随 │ ├── input/ # 输入处理 │ │ └── InputHandler.ts │ ├── utils/ # 工具 │ │ ├── AssetLoader.ts # 资源预加载 │ │ └── AudioManager.ts # Web Audio 封装 │ ├── types/ # TypeScript 类型 │ ├── main.ts # 入口 │ └── style.css ├── levels/ # 关卡数据(JSON) │ └── level1.json ├── index.html └── vite.config.ts

🔧优势:逻辑、渲染、输入解耦,便于测试与扩展。


四、核心系统实现(TypeScript 建模)

4.1 定义基础类型(types/index.ts)

// 角色状态 export type PlayerState = 'idle' | 'running' | 'jumping' | 'crouching'; // 速度向量 export interface Velocity { x: number; y: number; } // 矩形边界(用于碰撞) export interface Bounds { x: number; y: number; width: number; height: number; } // 瓦片类型 export enum TileType { EMPTY = 0, GROUND = 1, BRICK = 2, QUESTION = 3, PIPE_TOP = 4, PIPE_BODY = 5 }

4.2 玛丽奥角色类(core/Player.ts)

import { PlayerState, Velocity, Bounds } from '../types'; export class Player { // 位置与速度 public x: number = 100; public y: number = 300; public velocity: Velocity = { x: 0, y: 0 }; // 状态 public state: PlayerState = 'idle'; public isOnGround: boolean = false; // 动画相关 private frameX: number = 0; private frameY: number = 0; // 对应精灵图行(0=idle, 1=run, 2=jump) private frameCount: number = 0; update(deltaTime: number) { this.handlePhysics(deltaTime); this.updateAnimation(); } private handlePhysics(deltaTime: number) { const gravity = 0.5; const walkSpeed = 2; const jumpPower = -12; // 应用重力 if (!this.isOnGround) { this.velocity.y += gravity; } // 水平移动 if (this.state === 'running') { this.velocity.x = this.direction === 'right' ? walkSpeed : -walkSpeed; } else { this.velocity.x *= 0.8; // 摩擦力 } // 更新位置 this.x += this.velocity.x; this.y += this.velocity.y; // 边界限制 if (this.x < 0) this.x = 0; } jump() { if (this.isOnGround) { this.velocity.y = -12; this.isOnGround = false; this.state = 'jumping'; AudioManager.play('jump'); } } // ... 其他方法:setState, getBounds }

⚠️关键点

  • isOnGround由碰撞系统设置;
  • 跳跃仅在地面时触发;
  • 水平速度带摩擦力,更真实。

4.3 物理与碰撞系统(core/Physics.ts)

import { Bounds } from '../types'; import { World } from './World'; // AABB 碰撞检测(Axis-Aligned Bounding Box) export function checkCollision(a: Bounds, b: Bounds): boolean { 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 ); } // 世界碰撞检测 export class CollisionSystem { static resolvePlayerWorld(player: any, world: World) { const playerBounds = player.getBounds(); const tileSize = 32; // 计算可能碰撞的瓦片范围 const startX = Math.floor(playerBounds.x / tileSize); const endX = Math.ceil((playerBounds.x + playerBounds.width) / tileSize); const startY = Math.floor(playerBounds.y / tileSize); const endY = Math.ceil((playerBounds.y + playerBounds.height) / tileSize); let onGround = false; for (let y = startY; y <= endY; y++) { for (let x = startX; x <= endX; x++) { const tileType = world.getTile(x, y); if (tileType !== TileType.EMPTY) { const tileBounds: Bounds = { x: x * tileSize, y: y * tileSize, width: tileSize, height: tileSize }; if (checkCollision(playerBounds, tileBounds)) { // 垂直碰撞(落地/顶砖) if (player.velocity.y > 0 && playerBounds.y + playerBounds.height - player.velocity.y <= tileBounds.y) { player.y = tileBounds.y - playerBounds.height; player.velocity.y = 0; onGround = true; } // 水平碰撞 else if (player.velocity.x > 0) { player.x = tileBounds.x - playerBounds.width; } else if (player.velocity.x < 0) { player.x = tileBounds.x + tileSize; } } } } } player.isOnGround = onGround; player.state = onGround ? (Math.abs(player.velocity.x) > 0.1 ? 'running' : 'idle') : 'jumping'; } }

优化:只检测角色周围的瓦片,避免全图遍历。


4.4 世界与关卡(core/World.ts)

// 从 Tiled 导出的 JSON 加载关卡 export class World { private tiles: TileType[][]; private width: number; private height: number; constructor(levelData: any) { this.width = levelData.width; this.height = levelData.height; this.tiles = Array(this.height).fill(0).map(() => Array(this.width).fill(TileType.EMPTY)); // 解析 Tiled 的 data 字段(CSV 或 Base64) const data = levelData.layers[0].data; for (let y = 0; y < this.height; y++) { for (let x = 0; x < this.width; x++) { this.tiles[y][x] = data[y * this.width + x] as TileType; } } } getTile(x: number, y: number): TileType { if (x < 0 || x >= this.width || y < 0 || y >= this.height) { return TileType.EMPTY; } return this.tiles[y][x]; } }

🛠️工具推荐:使用 Tiled Map Editor 可视化设计关卡,导出 JSON。


五、渲染系统(Canvas 实现)

5.1 精灵图绘制(render/Renderer.ts)

export class Renderer { private ctx: CanvasRenderingContext2D; private spriteSheet: HTMLImageElement; constructor(canvas: HTMLCanvasElement, spriteSrc: string) { this.ctx = canvas.getContext('2d')!; this.spriteSheet = new Image(); this.spriteSheet.src = spriteSrc; } drawPlayer(player: Player, camera: Camera) { const frameWidth = 32; const frameHeight = 32; // 根据状态选择精灵图行 let row = 0; if (player.state === 'running') row = 1; if (player.state === 'jumping') row = 2; this.ctx.drawImage( this.spriteSheet, player.frameX * frameWidth, // 源X row * frameHeight, // 源Y frameWidth, // 源宽 frameHeight, // 源高 player.x - camera.x, // 目标X(经视口偏移) player.y - camera.y, // 目标Y frameWidth, frameHeight ); } drawWorld(world: World, camera: Camera) { const tileSize = 32; const startX = Math.floor(camera.x / tileSize); const endX = Math.ceil((camera.x + camera.width) / tileSize); for (let y = 0; y < world.height; y++) { for (let x = startX; x < endX; x++) { const tile = world.getTile(x, y); if (tile !== TileType.EMPTY) { this.ctx.drawImage( tilesetImage, (tile - 1) * tileSize, 0, tileSize, tileSize, x * tileSize - camera.x, y * tileSize - camera.y, tileSize, tileSize ); } } } } }

🖼️精灵图技巧

  • 横向排列帧(行走动画);
  • 纵向排列状态(idle/run/jump)。

5.2 视口跟随(render/Camera.ts)

export class Camera { public x: number = 0; public y: number = 0; public width: number; public height: number; constructor(canvas: HTMLCanvasElement) { this.width = canvas.width; this.height = canvas.height; } follow(target: { x: number; width: number }) { // 目标居中 this.x = target.x + target.width / 2 - this.width / 2; // 边界限制 if (this.x < 0) this.x = 0; } }

🌍效果:玛丽奥始终在屏幕中央,世界向左滚动。


六、输入与音效

6.1 输入处理(input/InputHandler.ts)

export class InputHandler { private keys: Set<string> = new Set(); constructor(private player: Player) { window.addEventListener('keydown', (e) => { if (e.code === 'ArrowRight') this.keys.add('right'); if (e.code === 'ArrowLeft') this.keys.add('left'); if (e.code === 'ArrowUp' || e.code === 'Space') this.keys.add('jump'); }); window.addEventListener('keyup', (e) => { if (e.code === 'ArrowRight') this.keys.delete('right'); // ... 其他 }); } update() { this.player.direction = this.keys.has('right') ? 'right' : this.keys.has('left') ? 'left' : 'none'; if (this.keys.has('jump')) { this.player.jump(); } this.player.state = this.keys.has('right') || this.keys.has('left') ? 'running' : 'idle'; } }

📱移动端扩展:添加虚拟方向键按钮,绑定 touchstart/touchend。


6.2 音效播放(utils/AudioManager.ts)

export class AudioManager { private static audioContext: AudioContext | null = null; private static sounds: Record<string, AudioBuffer> = {}; static async init() { this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); // 预加载音效 this.sounds['jump'] = await this.loadSound('/assets/sfx/jump.wav'); } private static async loadSound(url: string): Promise<AudioBuffer> { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); return await this.audioContext!.decodeAudioData(arrayBuffer); } static play(soundName: string) { if (!this.audioContext || !this.sounds[soundName]) return; const source = this.audioContext.createBufferSource(); source.buffer = this.sounds[soundName]; source.connect(this.audioContext.destination); source.start(); } }

🔊注意:需用户交互后才能播放音频(浏览器策略)。


七、游戏主循环与性能优化

7.1 主循环(core/Game.ts)

export class Game { private lastTime: number = 0; private fps: number = 60; private frameInterval: number = 1000 / this.fps; constructor( private player: Player, private world: World, private renderer: Renderer, private camera: Camera, private input: InputHandler ) {} start() { requestAnimationFrame(this.gameLoop.bind(this)); } private gameLoop(timestamp: number) { const deltaTime = timestamp - this.lastTime; if (deltaTime > this.frameInterval) { this.update(deltaTime); this.render(); this.lastTime = timestamp; } requestAnimationFrame(this.gameLoop.bind(this)); } private update(deltaTime: number) { this.input.update(); this.player.update(deltaTime); CollisionSystem.resolvePlayerWorld(this.player, this.world); this.camera.follow(this.player); } private render() { this.renderer.clear(); this.renderer.drawWorld(this.world, this.camera); this.renderer.drawPlayer(this.player, this.camera); } }

⏱️帧率控制:固定 60 FPS,避免低端机卡顿。


7.2 性能优化实测

优化项FPS(低端 Android)内存
初始版本(全图渲染)28 FPS120 MB
视口裁剪(仅渲染可见瓦片)52 FPS65 MB
精灵图缓存55 FPS60 MB
对象池(敌人复用)58 FPS55 MB

结论:前端游戏性能,关键在“少画、少算、少创建”。


八、工程化增强

8.1 PWA 支持(离线游玩)

vite.config.ts中集成 Workbox:

import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig({ plugins: [ VitePWA({ registerType: 'autoUpdate', manifest: { name: 'Super Mario Clone', short_name: 'Mario', start_url: '/', display: 'standalone', background_color: '#000', theme_color: '#ff0000' } }) ] });

📲 用户可“安装到桌面”,无网络也能玩。


8.2 本地存储进度

// 通关后保存 localStorage.setItem('mario.highestLevel', '1-2'); // 启动时读取 const level = localStorage.getItem('mario.highestLevel') || '1-1';

九、总结:从 NES 到 Web,游戏精神不变

通过实现《超级玛丽》,我们不仅学会了:

  • Canvas 渲染与动画;
  • 2D 物理与碰撞;
  • 状态机与输入处理;

更重要的是,我们理解了任天堂的设计哲学

“让玩家在安全的环境中,通过试错学习规则。”

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/10 15:47:20

PyTorch实验日志记录系统搭建:Miniconda-Python3.9基础环境

PyTorch实验日志记录系统搭建&#xff1a;Miniconda-Python3.9基础环境 在深度学习项目中&#xff0c;我们常常遇到这样的场景&#xff1a;昨天还能正常运行的训练脚本&#xff0c;今天却因为某个包版本更新而报错&#xff1b;或者同事在复现你的实验时&#xff0c;反复尝试都无…

作者头像 李华
网站建设 2026/6/10 5:00:29

感知机的致命缺陷:为什么它连简单的异或问题都解决不了?

感知机的致命缺陷&#xff1a;为什么它连简单的异或问题都解决不了&#xff1f;无法解决异或门问题&#xff0c;暴露了感知机的本质局限性感知机的辉煌战绩 在之前的讨论中&#xff0c;我们已经见证了感知机的强大能力——它能够完美实现三种基本逻辑电路&#xff1a; 与门&…

作者头像 李华
网站建设 2026/6/10 19:09:48

PyTorch QoS保障机制:基于Miniconda-Python3.9环境实现

PyTorch QoS保障机制&#xff1a;基于Miniconda-Python3.9环境实现 在现代AI研发中&#xff0c;一个看似简单却频繁困扰开发者的问题是&#xff1a;“为什么代码在我机器上能跑&#xff0c;到了服务器就报错&#xff1f;” 更进一步地&#xff0c;在团队协作、模型复现和生产部…

作者头像 李华
网站建设 2026/6/3 21:25:41

Miniconda-Python3.9环境下实现PyTorch模型混沌工程实验

Miniconda-Python3.9环境下实现PyTorch模型混沌工程实验 在深度学习系统日益复杂的今天&#xff0c;一个让人头疼的问题始终存在&#xff1a;为什么同样的代码&#xff0c;在开发机上运行流畅&#xff0c;部署到生产环境却频繁崩溃&#xff1f;更糟糕的是&#xff0c;这类故障往…

作者头像 李华
网站建设 2026/5/12 22:18:39

Miniconda-Python3.9环境下使用PyTorch Lightning简化训练流程

Miniconda-Python3.9环境下使用PyTorch Lightning简化训练流程 在深度学习项目中&#xff0c;一个常见的场景是&#xff1a;你终于调通了一个新模型的训练脚本&#xff0c;结果换到另一台机器上却因为包版本不一致、CUDA 版本冲突或缺少某个依赖而无法运行。更糟的是&#xff0…

作者头像 李华