← 文章

Web 游戏开发入门:从游戏循环到两个实践

从游戏循环、状态机与帧率无关运动入手,完成贪吃蛇和拳皇式对战 Demo

文章目录5 节
  1. 游戏开发没有想象中那么遥远
  2. Web 游戏 vs 原生游戏
  3. 一切从游戏循环开始
  4. 从理论到实践:两个项目
  5. 贪吃蛇:经典入门之选
  6. 拳皇格斗:进阶挑战
  7. 不同游戏类型的开发复杂度
  8. 踩过的坑和学到的东西
  9. 写在最后

文章目录

5 节
  1. 游戏开发没有想象中那么遥远
  2. Web 游戏 vs 原生游戏
  3. 一切从游戏循环开始
  4. 从理论到实践:两个项目
  5. 贪吃蛇:经典入门之选
  6. 拳皇格斗:进阶挑战
  7. 不同游戏类型的开发复杂度
  8. 踩过的坑和学到的东西
  9. 写在最后

作为一个 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}——每帧移动的像素不同,但每秒移动的总距离是一致的。

重要

帧率无关运动是游戏开发的基本功。所有跟“速度”相关的计算都应该乘以 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()
  }
}
提示

状态机最大的好处是避免“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,在子弹、粒子等频繁创建销毁的场景中效果尤为明显。

警告

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

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

写在最后

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

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

Footnotes

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