[H5游戏引擎]第 2 篇: 实现游戏帧循环

144 阅读5分钟

大家好,我是刘不思

这是 从零开发 H5 游戏引擎 专栏的第二篇文章,本文包括三部分:

  • 项目工程目录
  • 实现引擎入口类:NVApp,其中包含游戏帧循环逻辑
  • 实现一个继承自 NVApp 的游戏类:GameApp

本文涉及的代码托管在 Github

关于项目工程的约定:

  • 引擎名称为 NovaEngine,因此引擎的所有类均以 NV 为前缀。
  • 使用 VSCode 开发,安装 Live Server 插件解决跨域问题

工程目录

其实,在远古时期并没有游戏引擎的概念。后来游戏项目开发的多了,大家将一些通用的功能抽取出来,于是形成了引擎。

上一篇文章中,所有的代码都在 index.html 中。现在,我们将工程分为三部分:

  • engine 目录:引擎部分
  • game 目录:游戏部分
    • res 目录:资源
    • src 目录:代码
  • index.html

具体如下图:

0.png

引擎部分是通用的,游戏部分是为了演示引擎的功能。

游戏帧循环

我们从三个基础类逐步搭建整个游戏引擎,分别是:

  • NVApp:游戏入口,管理整个游戏的生命周期
  • NVScene:场景类,一个游戏可能包含多个场景,例如进入游戏之前的注册登录作为一个场景、主城内的建造经营作为一个场景、野外战场的战斗作为一个场景
  • NVSprite:显示图片

这篇文章我们先从 NVApp 类开始,实现游戏的帧循环。NVApp 类代表整个游戏应用,实现以下几个功能:

  • 初始化
  • 帧循环
  • 更新游戏逻辑
  • 绘制

NVApp 类有四个函数,分别对应上述四个功能:

  • init:初始化
  • gameLoop:帧循环
  • update:更新游戏逻辑
  • draw:绘制

这四个函数中,initupdate 通常由游戏部分重载以实现具体的游戏内容,而 gameLoopdraw 游戏部分不太关心。

export class NVApp {
  canvas = null;
  ctx = null;

  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
  }

  // 初始化
  init() {
  }

  // 运行
  run() {
  }

  // 帧循环
  gameLoop = () => {
  }

  // 更新游戏逻辑
  update() {
  }

  // 绘制
  draw() {
  }
}

使用 Live Server 打开网页,显示一个深蓝色的游戏区域,这是 canvasstyle 设置的颜色,游戏本身尚未绘制任何内容。

1.png

下面我们逐步实现游戏内容的绘制。

初始化

初始化工作有很多,例如:

  • 读取配置
  • 设置引擎参数
  • 获取窗口信息
  • 准备绘制环境

目前,我们只需要响应窗口的 size 变化,设置 canvas 的逻辑宽高。

init() {
  // 设置 canvas 的逻辑宽高
  this.updateCanvasSize();
  window.addEventListener("resize", () => this.updateCanvasSize(), false);
}

// 设置 canvas 的逻辑宽高
updateCanvasSize() {
  const canvasRect = this.canvas.getBoundingClientRect();
  this.canvas.width = canvasRect.width;
  this.canvas.height = canvasRect.height;
}

当我们改变浏览器窗口大小的时候,canvas 的逻辑宽高也会跟着变化。

帧循环

在完成引擎的初始化之后,我们会调用引擎的 run 函数启动引擎,在 run 函数中我们会启动帧循环。

帧循环做三件事:

  • 更新游戏逻辑
  • 绘制
  • 继续下一次帧循环
run() {
  // 开始帧循环
  window.requestAnimationFrame(this.gameLoop);
}

gameLoop = () => {
  // 更新游戏逻辑
  this.update();

  // 绘制
  this.draw();

  window.requestAnimationFrame(this.gameLoop);
}

将来,我们会添加 preUpdatepostUpdate 函数,分别在 update 函数之前、之后被调用。同理,还有 preDrawpostDraw。目前,我们先不考虑这部分。

更新游戏逻辑

NVAppupdate 函数通常为空,由游戏部分重载实现。这里为了演示,我们实现一个简单的逻辑:记录当前时间。

每一帧,都会为成员变量 timeString 重新赋值。

timeString = null;

update() {
  this.timeString = '当前时间: ' + new Date().toLocaleTimeString();
}

绘制

在实际开发中,游戏程序员并不需要写 draw 相关的代码。因为每帧更新游戏逻辑后,引擎会遍历游戏场景里的所有节点逐个绘制。

目前,我们还未实现场景管理的逻辑,所以在 draw 函数中添加临时绘制代码。这段代码执行以下逻辑:

  • 清理上一帧绘制的内容
  • 绘制一个浅蓝色的背景
  • 绘制一个深蓝色的矩形
  • 绘制文本:当前窗口的逻辑宽高
  • 绘制文本:当前时间
draw() {
  // 清理前一帧的图像
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  
  // 绘制
  this.ctx.fillStyle = 'lightblue';
  this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
  
  this.ctx.fillStyle = 'blue';
  this.ctx.fillRect(0, 0, this.canvas.width / 2, 100);
  
  this.ctx.fillStyle = 'red';
  this.ctx.font = '20px Arial';
  this.ctx.fillText('size: ' + this.canvas.width + ' : ' + this.canvas.height, 0, 230);
  
  this.ctx.fillText(this.timeString, 0, 260);
}

显示结果如下:

2.png

至此,NVApp 的代码如下:

export class NVApp {
  canvas = null;
  ctx = null;

  timeString = null;

  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
  }

  init() {
    // 设置 canvas 的逻辑宽高
    this.updateCanvasSize();
    window.addEventListener("resize", () => this.updateCanvasSize(), false);
  }

  run() {
    // 开始帧循环
    window.requestAnimationFrame(this.gameLoop);
  }

  gameLoop = () => {
    // 更新游戏逻辑
    this.update();

    // 绘制
    this.draw();

    window.requestAnimationFrame(this.gameLoop);
  }

  update() {
    this.timeString = '当前时间: ' + new Date().toLocaleTimeString();
  }

  draw() {
    // 清理前一帧的图像
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // 绘制
    this.ctx.fillStyle = 'lightblue';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    this.ctx.fillStyle = 'blue';
    this.ctx.fillRect(0, 0, this.canvas.width / 2, 100);

    this.ctx.fillStyle = 'red';
    this.ctx.font = '20px Arial';
    this.ctx.fillText('size: ' + this.canvas.width + ' : ' + this.canvas.height, 0, 230);

    this.ctx.fillText(this.timeString, 0, 260);
  }

  // 设置 canvas 的逻辑宽高
  updateCanvasSize() {
    const canvasRect = this.canvas.getBoundingClientRect();
    this.canvas.width = canvasRect.width;
    this.canvas.height = canvasRect.height;
  }
}

GameApp 类

前文提到过,NVAppinitupdate 函数通常由游戏部分重载,以实现具体的功能。

下面创建 GameApp 继承自 NVApp 类,重载了 initupdate 函数:

  • init:简单调用父类函数
  • update:将 timeString 改为 GameApp 计算的时间,方便看出修改后差别
import { NVApp } from "../../engine/App.js";

export class GameApp extends NVApp {
  constructor(canvas) {
    super(canvas);
  }

  init() {
    super.init();
  }

  update() {
    this.timeString = "GameApp 计算的时间:" + new Date().toLocaleTimeString();
  }
}

同时,修改 index.html 中的 JavaScript 代码,通过 GameApp 进入游戏逻辑:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Lesson_01</title>
  <style>
    html, body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      height: 100%;
      display: flex;
      flex-direction: column; /* 垂直方向布局 */
      justify-content: center; /* 水平居中整体内容 */
      align-items: center; /* 垂直居中整体内容 */
    }
    
    canvas {
      display: block;
      width: 50vw; /* CSS 宽度为屏幕宽度的一半 */
      height: 50vh; /* CSS 高度为屏幕高度的一半 */
      background-color: darkblue;
    }
  </style>
</head>
<body>
  <canvas id="main-canvas"></canvas>
  <script type="module">
    import { GameApp } from "./game/src/GameApp.js";

    const canvas = document.getElementById('main-canvas');
    const gameApp = new GameApp(canvas);
    gameApp.init();
    gameApp.run();
  </script>
</body>
</html>

最终,显示效果如下:

3.png

至此,本文结束。下一篇我们会实现游戏中的场景类 Scene