返回

Web 游戏开发入门指南:从零开始的游戏编程之旅

作为一个 Web 开发者,我一直对游戏开发充满好奇。这篇文章记录了我从零开始探索 Web 游戏编程的过程,包括游戏循环的核心概念以及两个实战项目的开发心得。

游戏开发没有想象中那么遥远

很长一段时间里,我觉得游戏开发是一个离自己很遥远的领域——那是专业游戏引擎和图形学大佬的地盘,普通 Web 开发者最多也就做做页面动画。直到我真正动手尝试,才发现一个事实:Web 游戏的底层结构其实比大多数人想象的要简单得多。

一旦你理解了游戏循环这个核心概念,剩下的就是在这个框架上不断填充具体的游戏逻辑。接下来我会分享自己从零摸索 Web 游戏开发的过程,以及在这个过程中沉淀下来的一些理解。

Web 游戏 vs 原生游戏

在动手之前,可能会有人问:Web 游戏能做到什么程度?这里做个简单对比:

维度Web 游戏原生游戏
开发门槛低,会 JS/TS 就能上手较高,需要学专用引擎和语言
性能上限受浏览器沙箱限制,适合 2D 和轻量 3D直接调用硬件,性能拉满
分发方式链接即玩,零安装需要下载安装包
跨平台天然跨平台,浏览器即运行时需要针对平台分别编译
适合场景小游戏、休闲游戏、互动营销大型游戏、高画质 3D
调试体验浏览器 DevTools,极其方便依赖引擎自带的调试工具

对于大多数 Web 开发者来说,Web 游戏是性价比最高的入门路径——用熟悉的技术栈,做出能跑在浏览器里的游戏,成就感拉满。

一切从游戏循环开始

如果要用一句话概括游戏和普通 Web 应用的本质区别,那就是:游戏需要一个不断运行的循环。

普通的 Web 页面是事件驱动的——用户点一下按钮,触发一个回调,处理完就安静等待下一次交互。但游戏不同,即使玩家什么都不做,敌人还在移动,子弹还在飞,动画还在播放。这就是游戏循环存在的意义:它每一帧都会执行一次,驱动整个游戏世界持续运转。

在实现上,我选择用一个 GameObject 基类来管理所有游戏对象的生命周期。每个游戏对象都遵循同样的节奏:

  • start() —— 出生时执行一次,做初始化工作
  • update() —— 每一帧都被调用,是游戏逻辑的主阵地
  • render() —— 在 update 中调用,负责把状态变化画到屏幕上

这种设计的好处是:不管你的游戏有多少种对象——玩家、敌人、子弹、特效——它们都共享同一套生命周期,互不干扰,又统一被游戏循环驱动。

graph LR
    A[requestAnimationFrame] --> B[计算 timeDelta]
    B --> C[遍历所有 GameObject]
    C --> D["update(timeDelta)"]
    D --> E["render()"]
    E --> A
游戏对象基类完整实现
const GAME_OBJECTS: GameObject[] = [];

export class GameObject {
  timeDelta: number;
  hasCalledStart: boolean;

  constructor() {
    this.timeDelta = 0;
    this.hasCalledStart = false;
    GAME_OBJECTS.push(this);
  }

  start() {}

  update() {}

  beforeDestory() {}

  destory() {
    this.beforeDestory();
    for (let i = 0; i < GAME_OBJECTS.length; i++) {
      if (this === GAME_OBJECTS[i]) {
        GAME_OBJECTS.splice(i, 1);
        break;
      }
    }
  }
}

let lastTimestamp = 0;

function step(timestamp: number) {
  for (const obj of GAME_OBJECTS) {
    if (!obj.hasCalledStart) {
      obj.hasCalledStart = true;
      obj.start();
    }
    else {
      obj.timeDelta = timestamp - lastTimestamp;
      obj.update();
    }
  }
  lastTimestamp = timestamp;
  requestAnimationFrame(step);
}

requestAnimationFrame(step);

值得注意的是代码中的 timeDelta。不同设备的帧率不一样,有的跑 60 帧,有的跑 144 帧,如果移动速度是固定的”每帧 5 像素”,那高帧率的设备上角色就会飞快地移动。用 timeDelta(两帧之间的时间差)来乘以速度,就能保证在任何帧率下,角色的移动速度都是一致的。其核心公式为:

distance=speed×Δt\text{distance} = \text{speed} \times \Delta t

例如,一个速度为 200px/s 的角色,在一帧耗时 16.67ms(60 FPS)时移动距离为 200×0.016673.33px200 \times 0.01667 \approx 3.33\text{px},而在一帧耗时 6.94ms(144 FPS)时移动距离为 200×0.006941.39px200 \times 0.00694 \approx 1.39\text{px}——每帧移动的像素不同,但每秒移动的总距离是一致的。

[!IMPORTANT] 帧率无关运动是游戏开发的基本功。所有跟”速度”相关的计算都应该乘以 timeDelta,包括移动、旋转、动画播放等。忽略这一点,你的游戏在不同设备上表现会完全不同。

浏览器通过 requestAnimationFrame 来驱动游戏循环,它会在每次屏幕刷新前调用回调函数,天然与显示器刷新率同步。相比 setInterval,它能避免不必要的渲染开销,并且在页面不可见时自动暂停,节省资源。

从理论到实践:两个项目

理解了架构之后,最好的巩固方式就是动手写。我用这套游戏循环架构做了两个小项目,复杂度逐步递增。

贪吃蛇:经典入门之选

贪吃蛇游戏截图

选贪吃蛇作为第一个项目,是因为它的规则足够简单,但又涵盖了游戏开发中几个关键的技术点:

  • 方向控制与蛇身移动:蛇的身体是一个坐标数组,每帧在头部添加新坐标、尾部移除旧坐标,就实现了移动效果。这个思路初看有点反直觉,但理解之后会觉得非常巧妙。
  • 碰撞检测:蛇撞墙或撞到自己就游戏结束,本质上就是坐标比较。
  • 食物生成与得分:随机生成食物坐标,蛇头坐标与食物重合时得分,尾部不再移除——蛇就长了一节。

技术栈用的是 Vue + TypeScript,响应式的数据绑定让 UI 更新变得很自然。游戏画面通过 Canvas API 渲染,性能远优于直接操作 DOM

在线体验贪吃蛇游戏预览

源代码cosmoscatts/vue-snakegame

拳皇格斗:进阶挑战

拳皇游戏截图

有了贪吃蛇的基础,我想挑战一个更复杂的项目。格斗游戏之所以更难,是因为它引入了几个新的维度:

  • 精灵动画:角色不是简单的色块了,而是一帧一帧的动画。你需要管理精灵表(sprite sheet),按正确的时间间隔切换帧,才能让角色的站立、行走、出拳看起来流畅自然。
  • 状态机:角色在”待机”、“移动”、“攻击”、“受击”等状态之间切换,每个状态有自己的行为逻辑和动画。用有限状态机来管理这些状态转换,是游戏开发中非常经典的模式。
  • 键盘输入与连招:不只是简单的按键响应,还要处理组合键和按键序列,这比 Web 表单的键盘事件复杂了不少。

理解状态机

状态机的核心思路很简单:角色在任意时刻只能处于一个状态,每个状态定义了自己能转换到哪些状态。用伪代码来表示:

// 每个状态定义入口、帧更新和出口
interface State {
  enter: () => void    // 进入状态时执行
  update: () => void   // 每帧执行
  exit: () => void     // 离开状态时执行
}

// 状态机维护当前状态,处理转换
class StateMachine {
  currentState: State

  transition(newState: State) {
    this.currentState.exit()
    this.currentState = newState
    this.currentState.enter()
  }
}

[!TIP] 状态机最大的好处是避免”if-else 地狱”。没有状态机的话,你可能会写出 if (isJumping && !isAttacking && isOnGround && ...) 这样的判断链,维护起来会让人崩溃。

这个项目用的是原生 HTML、CSS 和 JavaScript,没有任何框架。目前只实现了草稚京一个角色,但核心的架构已经可以很方便地扩展更多角色。

源代码cosmoscatts/kof-js

不同游戏类型的开发复杂度

做完这两个项目后,我对不同游戏类型的复杂度有了更直观的感受:

游戏类型核心难点关键技术入门推荐度
贪吃蛇/俄罗斯方块网格逻辑、碰撞检测数组操作、定时器★★★★★
平台跳跃重力物理、关卡设计物理模拟、碰撞系统★★★★
格斗 / 动作状态机、精灵动画有限状态机、帧动画★★★
弹幕射击大量对象管理、性能对象池、空间分区★★★
RTS / 策略AI、寻路、资源管理A*算法、决策树★★

踩过的坑和学到的东西

做完这两个项目,有几点感受比较深:

状态管理比你想的更重要。 在普通 Web 开发中,状态管理已经是个热门话题了,在游戏中这一点被成倍放大。一个角色可能同时受到多个系统的影响——物理系统推着它移动,输入系统改变它的方向,动画系统控制它的外观。如果状态管理混乱,很快就会出现各种诡异的 bug,比如角色在攻击动画还没播完的时候突然开始移动。有限状态机是解决这个问题的第一步。

性能意识要从第一行代码开始。 Web 游戏跑在浏览器里,性能天花板本来就比原生游戏低。每帧都会执行的代码如果有性能问题,影响会被放大到每秒 60 次。

以下是几个立竿见影的性能优化手段:

优化手段解决的问题做法
对象池避免频繁创建/销毁对象导致 GC 卡顿预创建一批对象,用完放回池子复用
精灵表减少 HTTP 请求和绘制调用把多帧合并成一张大图,按坐标裁剪
requestAnimationFrame替代 setInterval,跟屏幕刷新率同步浏览器会在合适的时机调用,避免无效渲染
离屏 Canvas减少重复绘制开销把不变的背景画到离屏 Canvas,每帧直接贴图
空间分区减少碰撞检测计算量只检测同一区域内的对象,而不是两两比较

其中对象池是最常用的优化手段之一1,在子弹、粒子等频繁创建销毁的场景中效果尤为明显。

[!WARNING] 千万别在游戏循环里做 DOM 操作。DOM 读写是出了名的慢,如果每帧都去操作 DOM,帧率会直接崩掉。游戏画面尽量用 Canvas 渲染,UI 信息(比如分数、血量)可以用独立的 DOM 层覆盖在 Canvas 上方。利用 GPU 加速的 Canvas 渲染,性能远超频繁的 DOM 操作。

模块化不是锦上添花,而是生存必需。 游戏天然涉及多个系统——渲染、物理、输入、音频、UI。如果一开始不做好模块划分,代码量一上来就会变成一团无法维护的意大利面条。这一点和大型 Web 应用的架构思路其实是相通的。

写在最后

回头看,Web 游戏开发最大的门槛不是技术本身,而是”觉得自己做不了”的心理障碍。游戏循环、状态机、碰撞检测,这些概念拆开来看,每一个都不算复杂。真正的学习发生在你把它们组合到一起、让一个游戏真正跑起来的过程中。

把这些经验记录下来,一方面是给自己留个参考,另一方面也希望能让同样在观望的同学少一些犹豫。毕竟,最好的学习方式永远是动手去做。

Footnotes

  1. 对象池(Object Pool)是一种常见的性能优化模式,通过复用已创建的对象来避免频繁的内存分配和垃圾回收。在弹幕射击等需要大量创建/销毁对象的游戏中效果尤为明显。参见 Object Pool Pattern - Game Programming Patterns