大家好,我是刘不思
这是 从零开发 H5 游戏引擎 专栏的第二篇文章,本文包括三部分:
- 项目工程目录
- 实现引擎入口类:
NVApp,其中包含游戏帧循环逻辑 - 实现一个继承自
NVApp的游戏类:GameApp
本文涉及的代码托管在 Github
关于项目工程的约定:
- 引擎名称为
NovaEngine,因此引擎的所有类均以NV为前缀。 - 使用
VSCode开发,安装Live Server插件解决跨域问题
工程目录
其实,在远古时期并没有游戏引擎的概念。后来游戏项目开发的多了,大家将一些通用的功能抽取出来,于是形成了引擎。
上一篇文章中,所有的代码都在 index.html 中。现在,我们将工程分为三部分:
- engine 目录:引擎部分
- game 目录:游戏部分
- res 目录:资源
- src 目录:代码
- index.html
具体如下图:
引擎部分是通用的,游戏部分是为了演示引擎的功能。
游戏帧循环
我们从三个基础类逐步搭建整个游戏引擎,分别是:
NVApp:游戏入口,管理整个游戏的生命周期NVScene:场景类,一个游戏可能包含多个场景,例如进入游戏之前的注册登录作为一个场景、主城内的建造经营作为一个场景、野外战场的战斗作为一个场景NVSprite:显示图片
这篇文章我们先从 NVApp 类开始,实现游戏的帧循环。NVApp 类代表整个游戏应用,实现以下几个功能:
- 初始化
- 帧循环
- 更新游戏逻辑
- 绘制
NVApp 类有四个函数,分别对应上述四个功能:
- init:初始化
- gameLoop:帧循环
- update:更新游戏逻辑
- draw:绘制
这四个函数中,init 和 update 通常由游戏部分重载以实现具体的游戏内容,而 gameLoop 和 draw 游戏部分不太关心。
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 打开网页,显示一个深蓝色的游戏区域,这是 canvas 的 style 设置的颜色,游戏本身尚未绘制任何内容。
下面我们逐步实现游戏内容的绘制。
初始化
初始化工作有很多,例如:
- 读取配置
- 设置引擎参数
- 获取窗口信息
- 准备绘制环境
目前,我们只需要响应窗口的 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);
}
将来,我们会添加 preUpdate 和 postUpdate 函数,分别在 update 函数之前、之后被调用。同理,还有 preDraw 和 postDraw。目前,我们先不考虑这部分。
更新游戏逻辑
NVApp 的 update 函数通常为空,由游戏部分重载实现。这里为了演示,我们实现一个简单的逻辑:记录当前时间。
每一帧,都会为成员变量 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);
}
显示结果如下:
至此,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 类
前文提到过,NVApp 的 init 和 update 函数通常由游戏部分重载,以实现具体的功能。
下面创建 GameApp 继承自 NVApp 类,重载了 init 和 update 函数:
- 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>
最终,显示效果如下:
至此,本文结束。下一篇我们会实现游戏中的场景类 Scene。