cover
2022年10月24日 - 2023年7月9日

从 0 手撸 canvas 小游戏:圆圆战争 - 帧率计算、垃圾回收篇

上篇我们为圆圆战争增加了鼠标控制射击方向,还增加了子弹与敌人的碰撞检测。本篇我们会为游戏做一些简单的垃圾回收操作,而在游戏中为了随时知道我们游戏的性能,常用的手段就是帧率计算器,也是我们本篇的目标之一。

先看下本章最后的成果:

https://code.juejin.cn/pen/7158076182519349285

帧率计算器

首先制作前我们要先搞清什么是帧率:

帧率(英语:frame rate)是用于测量显示帧数的度量。测量单位为“每秒显示帧数”(frame per second,FPS)或“赫兹”,一般来说 FPS 用于描述视频、电子绘图或游戏每秒播放多少帧。

所以我们只需要测量每秒 loop 执行的次数即可,此处我们直接将其作为一个绘制元素即可,看下代码:

class FrameRater {
    count: number;
    time: number;
    frameRate?: number;
    timer?: ReturnType<typeof setTimeout>;
    duration: number;
    constructor() {
        this.count = 0;
        this.time = Date.now();
        this.duration = 3000;
        this.loop();
    }
    loop() {
        clearTimeout(this.timer);
        this.timer = setTimeout(() => {
            this.compute();
            this.loop();
        }, this.duration);
    }
    compute() {
        const now = Date.now();
        this.frameRate = (this.count / (now - this.time)) * 1000;
        this.time = now;
        this.count = 0;
    }
    action() {
        this.count++;
    }
    draw() {
        if (!this.frameRate) return;
        ctx.font = '14px serif';
        ctx.fillStyle = 'black';
        ctx.shadowBlur = 0;
        ctx.fillText(this.frameRate.toFixed(2) + '', 20, 20);
    }
}

我们在帧率计算器中要使用定时器来定时计算帧率,不过定时器的时间并不能保证,所以在计算时为了准确我们最好记录下前后的事件,使用时间差来计算。每次 action 中我们为帧数加一以此来计算该事件段内的总帧数,要注意定时器的事件,如果使用 1s 可能会导致帧率数字一直刷新反而不方便观看,所以我们将定时器事件设置为 3s。

canvas 绘制文字则比较简单,设置好文字样式后,我们使用 fillText 将帧率进行绘制即可,后面的 20,20 为文字的坐标。当然我们也可以直接添加一个 span,然后在 draw 时设置 spaninnerText 也可以达到同样的效果,并且可以避免每帧绘制,性能更好。

然后我们在 gameloop 中只需要调用 frameRateraction 即可,frameRater 会自行收集帧率信息并更新,然后在进行绘制时调用 frameRaterdraw 即可:

const game = {
    start() {
        // ...
        this.frameRater = new FrameRater();
        this.loop();
    },
    loop() {
        // ...
        this.frameRater.action();
    },
    draw() {
        // ...
        this.frameRater.draw();
    }
};

此处要注意,不同于定时器,requestAnimationFrame 的帧率由浏览器来决定,基本大部分情况下都是 60 帧,所以此处会发现帧率只可能小于等于 60。

垃圾回收

在游戏中我们会创建很多的子弹和敌人对象,然而子弹会离开屏幕并消失,敌人也会因为子弹的攻击而消失,但是我们的对象却未清理,所以此处我们需要手动进行垃圾回收。为了方便管理,我们也顺便将每一帧每个对象要做的事情放到 action 中统一管理,这样就不用每个对象的操作都在 game 对象中管理。

首先我们需要在每一帧中检测子弹是否离开绘制区域,如果离开则将子弹的 state 置为 0,这个比较简单,只需要检测子弹的坐标是否离开坐标系即可,为了避免边界和敌人相撞,我们给坐标留一些 buffer

class Bullet {
    check() {
        if (
            this.x > WIDTH + OUTER_RANGE ||
            this.x < 0 - OUTER_RANGE ||
            this.y > HEIGHT + OUTER_RANGE ||
            this.y < 0 - OUTER_RANGE
        ) {
            this.state = 0;
        }
    }
}

然后我们将子弹的所有操作全部集中到 action 中:

class Bullet {
    action(enemies: Enemy[]) {
        this.move();
        this.intersectionDetection(enemies);
        this.check();
    }
}

而垃圾回收则需要 game 对象进行统一管理,为了避免频繁的垃圾回收导致性能问题,我们可以为垃圾回收设定一个 CD

const GC_CD = 60;
const game = {
    gc() {
        this.bullets = this.bullets.filter(bullet => bullet.state);
        this.enemies = this.enemies.filter(enemy => enemy.state);
    },
    cd() {
        if (this.bulletCD-- < 1) {
            this.bullets.push(new Bullet(this.mouse.angle));
            this.bulletCD = BULLET_CD;
        }
        if (this.enemyCD-- < 1) {
            this.enemies.push(new Enemy());
            this.enemyCD = ENEMY_CD;
        }
        if (this.gcCD-- < 1) {
            this.gc();
            this.gcCD = GC_CD;
        }
    }
};

这样我们每 60 帧就会进行一次垃圾回收操作,避免内存无限增长。

而在 game 中我们将操作类代码进行集中化,这样可以让每个方法的职责更为明确:

const game = {
    loop() {
        requestAnimationFrame(() => this.loop());
        this.draw();
        this.action();
        this.cd();
    },
    action() {
        this.enemies.forEach(enemy => enemy.action());
        this.bullets.forEach(bullet => bullet.action());
        this.frameRater.action();
    },
    cd() {
        if (this.bulletCD-- < 1) {
            this.bullets.push(new Bullet(this.mouse.angle));
            this.bulletCD = BULLET_CD;
        }
        if (this.enemyCD-- < 1) {
            this.enemies.push(new Enemy());
            this.enemyCD = ENEMY_CD;
        }
        if (this.gcCD-- < 1) {
            this.gc();
            this.gcCD = GC_CD;
        }
    }
};

敌人随机生成

由于现在敌人就从一条线出现,使得游戏毫无难度,所以我们需要在敌人初始化时从随机位置生成,为了让敌人不要出现的太突兀,我们需要将生成位置控制在屏幕外侧,并且不能太远,为此我们可以直接复用之前子弹离屏计算中的 OUTER_RANGE

const random = (min: number, max: number) => {
    return Math.random() * (max - min) + min;
};
const randomBol = () => {
    return Math.random() < 0.5;
};

const randomXCord = (out?: boolean) => {
    if (out) {
        return randomBol() ? random(-OUTER_RANGE, 0) : random(WIDTH, WIDTH + OUTER_RANGE);
    }
    return random(0, WIDTH);
};
const randomYCord = (out?: boolean) => {
    if (out) {
        return randomBol() ? random(-OUTER_RANGE, 0) : random(HEIGHT, HEIGHT + OUTER_RANGE);
    }
    return random(0, HEIGHT);
};

class Enemy {
    constructor() {
        if (randomBol()) {
            this.x = randomXCord(true);
            this.y = randomYCord();
        } else {
            this.x = randomXCord();
            this.y = randomYCord(true);
        }
        this.angle = computeAngle(this.x - C_X, this.y - C_Y);
    }
    private move() {
        this.x -= Math.cos((this.angle * Math.PI) / 180) * this.speed;
        this.y -= Math.sin((this.angle * Math.PI) / 180) * this.speed;
    }
}

移动时计算同子弹的轨迹计算,只需要将方向进行反转即可。

结语

至此我们完成了敌人随机生成、帧率计算、垃圾回收,已经完成了一个圆圆大战的几个基本要素,可以进行简单的游玩了。完整代码可至顶部 DEMO 查看。下篇计划会进行关卡、分数计算、输赢判断、暂停方面的开发。