使用 Typescript 编写游戏

2025-05-24

使用 Typescript 编写游戏

我知道人们用 JavaScript 做各种各样的东西,<canvas>但对我来说,那都是黑魔法。是时候改变这种现状了。这就是我用 JavaScript 构建Attacke!的过程,这是一款简单的 2D 格斗游戏。

在深入探讨之前,我需要澄清一下,本文中的大部分代码片段都被截断到相关的行。完整代码可以在GitHub上找到。

我希望它是一款自上而下的格斗游戏。自上而下的设计尤其适合用来躲避跳跃和重力。我最初的概念验证是横向卷轴游戏,但从那时起,我便转向了一种更简单的方法。目前,游戏将在同一设备上支持两位玩家,但我希望我的代码足够灵活,以便将来能够扩展玩家数量和输入选项。

我还希望游戏的画面能够轻松切换。首发版本只有一个主题,但我计划在未来添加更多主题。

项目设置

我希望它非常简单,并且使用尽可能少的构建步骤和依赖项。但我也想要一些好东西,比如热重载和类型。

这个项目在 CSS 方面会非常轻松,所以我坚持使用原生 CSS 并省去了构建步骤。

默认使用 TypeScript 类型。这意味着我还需要一个构建工具来编译 JavaScript。我最终选择了 ESBuild。我听说 Vite 非常快。Vite 是基于 ESBuild 构建的,所以不用 Vite 应该会更快,对吧?

#!/usr/bin/env node

const watchFlag = process.argv.indexOf("--watch") > -1;

require("esbuild")
    .build({
        entryPoints: ["src/ts/main.ts", "src/ts/sw.ts"],
        bundle: true,
        outdir: "public",
        watch: watchFlag,
    })
    .catch(() => process.exit(1));
Enter fullscreen mode Exit fullscreen mode
➜  attacke git:(main) yarn build
yarn run v1.22.10
$ ./esbuild.js
✨  Done in 0.47s.
Enter fullscreen mode Exit fullscreen mode

惊人的!

HTML基金会

提供此服务的网站<canvas>没什么特别之处。最重要的元素是画布本身。它本身无法获得焦点,需要 TabIndex 才能通过键盘访问。上下移动会使页面上下移动。我需要在画布获得焦点时避免这种情况,否则页面会随着角色的移动而上下跳动。画布的宽度和高度也是固定的。画布可能无法以全高清分辨率显示,但它的尺寸是画布坐标系的端点,需要用来计算画布内的位置。
我还添加了一个加载指示器,以获得更流畅的启动体验。

<div class="loader">
    <progress value="0" max="100"></progress>
</div>
<canvas tabindex="0" id="canvas" width="1920" height="1080"></canvas>
Enter fullscreen mode Exit fullscreen mode

游戏循环

JavaScript 中的实时游戏需要一个游戏循环:一个递归函数,每帧都会调用自身。这意味着,如果我们想保持 60fps 的帧率,渲染一帧的性能预算为 16ms;如果要保持 30fps 的帧率,则需要 33ms。循环本身内部没有游戏逻辑。相反,我会tick为每一帧发送一个事件。游戏的所有其他部分都可以监听该事件,并在需要时执行各自的逻辑。

我的第一次尝试失败了。

export class Renderer {
    ctx: CanvasRenderingContext2D;
    ticker: number;

    constructor(ctx: CanvasRenderingContext2D) {
        this.ctx = ctx;
        this.ticker = setInterval(() => {
            const tick = new Event("tick", {
                bubbles: true,
                cancelable: true,
                composed: false,
            });
            ctx.canvas.dispatchEvent(tick);
        }, 1000 / 60); // aim for 60fps
    }
}
Enter fullscreen mode Exit fullscreen mode

我使用了一个间隔计时器来调用游戏循环。这在 Chrome 上运行良好,但在 Firefox 和 Safari 上却崩溃了。Firefox 的 drawImage() 函数性能很差,而我将用它来绘制精灵。好吧,原因很简单。不过,Safari 即使每帧绘制巨大的图像也能很好地渲染 60fps,但有时也会失败。显然,MacBook 默认启用了省电模式,当电源线未连接时,Safari 的帧率会被限制在 30fps。我花了一段时间才发现这一点。

解决这两个问题的方法是使用requestAnimationFrame而不是setInterval

这张图详细描述了游戏循环的工作原理。顶部有一个方框,标签为

constructor(ctx: CanvasRenderingContext2D, theme: Theme) {
    this.ctx = ctx;
    this.theme = theme;
    this.fps = 60; // aim for 60fps
    this.counter = 0;
    this.initTicker();
}

private initTicker() {
    window.requestAnimationFrame(() => {
        this.tick();
        this.initTicker();
    });
}
Enter fullscreen mode Exit fullscreen mode

现在游戏在每个浏览器中都能流畅运行,但游戏速度仍然会有所不同,因为我每一帧都要计算并执行每个动作(例如移动、攻击等)。30fps 的浏览器会让游戏以一半的速度运行。为了解决这个问题,我打算测量帧间时间,并将跳帧数纳入计算中。

private tick() {
    const timeStamp = performance.now();
    const secondsPassed = (timeStamp - this.oldTimeStamp) / 1000;
    this.oldTimeStamp = timeStamp;

    // Calculate fps
    const fps = Math.round(1 / secondsPassed);
    const frameSkip = clamp(Math.round((60 - fps) / fps), 0, 30);

    // to allow for animations lasting 1s
    if (this.counter >= this.fps * 2) {
        this.counter = 0;
    }

    const tick: TickEvent = new CustomEvent("tick", {
        bubbles: true,
        cancelable: true,
        composed: false,
        detail: {
            frameCount: this.counter,
            frameSkip: frameSkip,
        },
    });
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
    this.ctx.canvas.dispatchEvent(tick);

    this.counter++;
}
Enter fullscreen mode Exit fullscreen mode

可玩角色

每个可玩角色都会在其所属的类实例中被调用character。该类控制玩家的移动、动作、外观和声音。你可能已经猜到它很复杂。我会尝试将其分解成易于理解的部分。

快速行动

作为现实世界中的物体,角色在移动时不会立即从零加速到最大速度。它们会加速或减速。移动时,它们有一定的速度。这在类中体现为:

class Character {
    position: coordinates;
    orientation: number;
    speed: number;
    maxVelocity: number;
    velocity: coordinates;
    obstacle: Obstacle;
    action: {
        movingX: number;
        movingY: number;
    };
    //...
}
Enter fullscreen mode Exit fullscreen mode

当按下移动键时,该action.movingX|Y属性设置为 +-1。当释放该键时,该属性设置为 0。这指示玩家是否应该开始移动或继续移动。

// move left
config.controls[this.player].left.forEach((key: string) => {
    document.addEventListener("keydown", (event: KeyboardEvent) => {
        this.captureEvent(event);
        if (event.code === key && event.repeat === false) {
            this.action.movingX = -1;
        }
    });
    document.addEventListener("keyup", (event: KeyboardEvent) => {
        this.captureEvent(event);
        if (event.code === key) {
            this.action.movingX = 0;
        }
    });
});

// repeat for up, down, and right.
Enter fullscreen mode Exit fullscreen mode

请注意,按键映射config.controls以数组的形式存储在里面,其中包含每个玩家的控制。

我们暂时可以忽略它captureEvent。这只会阻止按下光标键时页面滚动。现在我们知道了角色应该何时开始或停止移动,但什么都没发生。这时我就可以钩住游戏循环了。还记得tick每一帧是如何发送事件的吗?我在这里监听它。每一帧,我都会在重新绘制角色之前更新位置。

private move(): void {
    const { position, velocity, action } = this;
    const newX = position.x + action.movingX * this.speed + velocity.x * this.speed;
    const newY = position.y + action.movingY * this.speed + velocity.y * this.speed;

    position.x = newX;
    position.y = newY;

    if (position.x < 0) {
        position.x = 0;
    } else if (newX > this.ctx.canvas.width - this.size) {
        position.x = this.ctx.canvas.width - this.size;
    }

    if (position.y < 0) {
        position.y = 0;
    } else if (newY > this.ctx.canvas.height - this.size) {
        position.y = this.ctx.canvas.height - this.size;
    }

    this.velocity.x = clamp(
        (action.movingX ? this.velocity.x + action.movingX : this.velocity.x * 0.8) * this.speed,
        this.maxVelocity * -1,
        this.maxVelocity
    );
    this.velocity.y = clamp(
        (action.movingY ? this.velocity.y + action.movingY : this.velocity.y * 0.8) * this.speed,
        this.maxVelocity * -1,
        this.maxVelocity
    );
}
Enter fullscreen mode Exit fullscreen mode

这时速度就派上用场了。速度是一个数值,它会随着玩家按住移动键的时间而不断增加,最高可达maxVelocity,这代表最高速度。当玩家松开移动键时,角色不会突然停止,而是会减速直至完全静止。然后速度会缓慢地再次降至 0。

但角色不仅可以移动,还可以转身。就我而言,我希望他们始终面对面。由于目前只有一个对手,玩家应该专注于把握攻击时机,而不是一直转向唯一的目标。这也有助于保持较高的游戏速度。

private turn(): void {
    const otherPlayer = this.player === 0 ? 1 : 0;
    const orientationTarget: coordinates = this.players[otherPlayer]?.position || { x: 0, y: 0 };
    const angle = Math.atan2(orientationTarget.y - this.position.y, orientationTarget.x - this.position.x);
    this.orientation = angle;
}
Enter fullscreen mode Exit fullscreen mode

我的小格斗游戏现在是一个舞蹈游戏!

两个角色原型互相绕着对方跳舞。一个是黄色方块,另一个是蓝色方块。两者的一侧都有一条宽大的洋红色边框,用来指示脸部。他们始终面对面。

打破事物

角色应该能够互相攻击。为了增加游戏深度,防御攻击也应该成为一种选择。这两项操作都被定义为角色动作,并且各自都有冷却时间,以防止频繁攻击。

class Character {
    range: number;
    attackDuration: number;
    blockDuration: number;
    cooldownDuration: number;
    action: {
        attacking: boolean;
        blocking: boolean;
        cooldown: boolean;
    };
    // ...
}
Enter fullscreen mode Exit fullscreen mode

触发这些动作与四处移动相同 - 通过监听键盘事件,然后将动作值设置为true......

// attack
config.controls[this.player].attack.forEach((key: string) => {
    document.addEventListener("keydown", (event: KeyboardEvent) => {
        if (
            this.active &&
            event.code === key &&
            event.repeat === false &&
            !this.action.cooldown
        ) {
            this.action.attacking = true;
        }
    });
});

// block
config.controls[this.player].block.forEach((key: string) => {
    document.addEventListener("keydown", (event: KeyboardEvent) => {
        if (
            this.active &&
            event.code === key &&
            event.repeat === false &&
            !this.action.cooldown
        ) {
            this.action.blocking = true;
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

...最后在游戏循环中执行动作。

private attack(): void {
    if (!this.active || !this.action.attacking || this.action.cooldown) {
        return;
    }

    this.action.cooldown = true;

    // strike duration
    window.setTimeout(() => {
        this.action.attacking = false;
    }, this.attackDuration);

    // cooldown to next attack/block
    window.setTimeout(() => {
        this.action.cooldown = false;
    }, this.cooldownDuration);

    this.strike();
}
Enter fullscreen mode Exit fullscreen mode

攻击只是工作的一半。另一半是确保对手被击中——这意味着他们不能格挡攻击,并且武器在射程内。这在strike()方法中处理。

private strike(): void {
    const otherPlayerId = this.player === 0 ? 1 : 0;
    const otherPlayer: rectangle = this.players[otherPlayerId].obstacle?.getObject();

    const blocked = this.players[otherPlayerId].action.blocking;
    if (blocked) {
        // opponent blocked the attack
        return;
    }

    // attack hits

    const otherPlayerPolygon = new Polygon(new Vector(0, 0), [
        new Vector(otherPlayer.a.x, otherPlayer.a.y),
        new Vector(otherPlayer.b.x, otherPlayer.b.y),
        new Vector(otherPlayer.c.x, otherPlayer.c.y),
        new Vector(otherPlayer.d.x, otherPlayer.d.y),
    ]);

    const weaponPosition = this.getWeaponPosition();
    const weaponPolygon = new Polygon(new Vector(0, 0), [
        new Vector(weaponPosition.a.x, weaponPosition.a.y),
        new Vector(weaponPosition.b.x, weaponPosition.b.y),
        new Vector(weaponPosition.c.x, weaponPosition.c.y),
        new Vector(weaponPosition.d.x, weaponPosition.d.y),
    ]);

    const hit = this.collider.testPolygonPolygon(weaponPolygon, otherPlayerPolygon) as boolean;
    if (hit) {
        // finish this round
        this.finish();
    }
}
Enter fullscreen mode Exit fullscreen mode

这会在玩家周围创建一个攻击范围,该范围会向对手方向延伸 150%。如果武器的攻击范围与对手的攻击范围相撞,则攻击命中,玩家赢得该回合。

但是,Hitbox 到底是什么?向量和多边形又是什么?我们来聊聊……

碰撞检测

碰撞检测并不像我想象的那么简单。首先在画布上绘制两个矩形,然后比较它们的 x 和 y 坐标即可。但一旦旋转矩形,这种方法就失效了。我的下一个尝试是根据矩形的边界线创建线性函数,并检查相交点。但这仍然会遗漏一些边缘情况,而且效率也相当低。

于是我开始用 Google 寻找解决方案。我在 Codepen 上找到了这个,StackOverflow上也有类似的描述:

这个解决方案巧妙、优雅、高效,而且——最重要的是——远远超出了我的几何水平。它也是Collider2D用来检查两个物体相交的方法。就是这样!碰撞检测已经解决了。至少目前,我既不想也不需要处理这个问题。

yarn add collider2d
Enter fullscreen mode Exit fullscreen mode

我在每个相关对象(包括可操作角色、画布边界以及竞技场中可能出现的障碍物)周围添加了碰撞多边形 (Collider Polygons) 作为碰撞箱。这些多边形由描述其周长的矢量 (Vectores) 组成。角色多边形存储在角色类的一个属性中,并在move()turn()strike()方法中更新。

// inside character.strike()
const otherPlayerPolygon = new Polygon(new Vector(0, 0), [
    new Vector(otherPlayer.a.x, otherPlayer.a.y),
    new Vector(otherPlayer.b.x, otherPlayer.b.y),
    new Vector(otherPlayer.c.x, otherPlayer.c.y),
    new Vector(otherPlayer.d.x, otherPlayer.d.y),
]);

const weaponPosition = this.getWeaponPosition();
const weaponPolygon = new Polygon(new Vector(0, 0), [
    new Vector(weaponPosition.a.x, weaponPosition.a.y),
    new Vector(weaponPosition.b.x, weaponPosition.b.y),
    new Vector(weaponPosition.c.x, weaponPosition.c.y),
    new Vector(weaponPosition.d.x, weaponPosition.d.y),
]);

const hit = this.collider.testPolygonPolygon(
    weaponPolygon,
    otherPlayerPolygon
) as boolean;
Enter fullscreen mode Exit fullscreen mode

现在我们第一次看到一些实际的游戏玩法!

两个角色原型正在互相战斗。蓝色角色尝试使用他的盾牌(显示为绿色),然后继续攻击黄色角色,导致新一轮战斗开始。

但角色仍然可以互相穿插。我希望它们之间能够互相碰撞。Collider2D 可以返回一些关于碰撞的信息,例如碰撞向量和位置。这与我的速度解决方案配合得很好。我可以简单地将现有速度指向碰撞偏转的方向:

private collide(): void {
    const obstacles = this.obstacles.filter((obstacle) => obstacle.getId() !== this.obstacle.getId());
    obstacles.forEach((obstacle) => {
        const collision = this.obstacle.collidesWith(obstacle);
        const friction = 0.8;

        if (!collision) {
            return;
        }

        this.velocity.x = (this.velocity.x + collision.overlapV.x * -1) * friction;
        this.velocity.y = (this.velocity.y + collision.overlapV.y * -1) * friction;
    });
}
Enter fullscreen mode Exit fullscreen mode

collide()现在在游戏循环中与 和它们的朋友一起被调用move()turn()所以每一帧都有一个用于碰撞检测的轮询。

两个角色原型互相碰撞。一个是黄色方块,另一个是蓝色方块。两者的一侧都有一条宽阔的洋红色边框,用来显示脸部。

图形

洋红色的舞动方块或许实用,但并不美观。我想要一些个性。我已经很久没用过 Photoshop 了,尤其是我想要的复古像素画风格。我想唤起一些怀旧的 GameBoy 感觉,所以选择了灰绿色的屏幕(后来我把它染成了灰蓝色),并在放大的像素上添加了阴影。

我的角色尺寸为 16x16px。武器射程为 150%,也就是 40x16px。如果要容纳所有精灵,且角色居中,Photoshop 画布的尺寸应该是 64x64px。导出图像时,我会将它们放大到 100x100px 的角色大小。16px 的角色在全高清屏幕上太小了。我按方向将精灵按图层分组排列,因为每个精灵需要八个变体——每个罗盘方向一个。然后将结果乘以动画精灵的帧数。

Photoshop 工作区的屏幕截图。该文档是一个 64x64px 的图像,中间有一个圆圈和一条 16x16px 的标尺线。角色位于 16x16 正方形的正中央。它的剑向东南方向延伸至圆圈边缘。图层窗口显示每个大致方向的文件夹,其中包含每个角色动作的精灵图,例如攻击、格挡和移动。颜色分级图层管理玩家 1 和 2 的不同颜色。

我需要控制每一个像素,而抗锯齿是我的敌人,因为它本质上会影响相邻的像素。我坚持使用钢笔工具而不是画笔,并且每当我需要变换、缩放或旋转某些东西时,我都会使用“像素重复”模式。

导出图片有点麻烦。我需要导出 8 位 PNG 格式的图片。它们带有 Alpha 通道,而且字节大小比 GIF(带硬 g 符号)甚至 WebP 格式都要小。不知何故,Photoshop 的批量导出功能不支持 8 位 PNG 格式。它也无法自动裁剪单个图层。所以只能手动导出。

两位手持剑盾的骑士,左边的脸色为蓝色,右边的脸色为黄色。他们站在一座城堡前,城堡内有两座高塔,大门紧闭,中间插着一面白旗。图像采用像素风格,色彩被淡化为略带蓝色的单色。物体的阴影如同在 GameBoy LCD 屏幕上渲染一样。

主题性

目前我只有一套精灵图,但我计划将来做更多。以后我想每轮加载不同的精灵图。这意味着每套精灵图都需要遵循一套特定的规则。我需要一个主题定义

图示:标有

现在我有一堆 JavaScript 代码和一些 png 图片。我们把它们结合起来吧,顺便实现一些次要目标:

  • 所有精灵都必须能够动画化
  • 所有与主题相关的元素都必须可以互换。我希望以后能够切换整个风格。

在画布中制作精灵动画并不像加载 GIF 动图那么简单。drawImage()它只会绘制第一帧。有一些技术可以在画布中实现 GIF 查看器,但对于我的用例来说过于复杂。我可以直接使用包含各个帧的数组。

declare type Sprite = {
    name: string;
    images: string[];
    animationSpeed: number; // use next image every N frames, max 60
    offset: coordinates;
};
Enter fullscreen mode Exit fullscreen mode

然后编写一个包装器,drawImage()使用合并的精灵并根据帧数切换动画步骤:

public drawSprite(ctx: CanvasRenderingContext2D, name: string, pos: coordinates, frameCount = 0) {
    const sprite = this.sprites.find((x) => x.name === name);
    if (!sprite) {
        return;
    }

    const spriteFrame = Math.floor((frameCount / sprite.animationSpeed) % sprite.images.length);

    const img = this.images.find((x) => x.src.endsWith(`${sprite.images[spriteFrame].replace("./", "")}`));

    if (!img) {
        return;
    }

    ctx.drawImage(img, pos.x + sprite.offset.x, pos.y + sprite.offset.y);
}
Enter fullscreen mode Exit fullscreen mode

太棒了,现在可以动起来了!这玩意儿让我体内释放出大量的内啡肽。

一个手持剑和盾牌的小像素骑士在原地奔跑,在一座有两座塔楼的城堡旁展示着他们的运动动画,城堡的白旗在风中飘扬。

可互换性需要一致性。我正在创建一个主题配置,用于定义要使用哪些精灵以及如何使用。

declare type SpriteSet = {
    n: Sprite; // sprite facing north
    ne: Sprite; // sprite facing north-east
    e: Sprite; // etc
    se: Sprite;
    s: Sprite;
    sw: Sprite;
    w: Sprite;
    nw: Sprite;
};

declare type themeConfig = {
    name: string; // has to match folder name
    scene: Sprite; // scene image, 1920x1080
    obstacles: rectangle[]; // outline obsacles within the scene
    turnSprites?: boolean; // whether to turn sprites with characters
    players: {
        default: SpriteSet; // player when standing still, 100x100
        move: SpriteSet; // player when moving, 100x100
        attack: SpriteSet; // player when attacking, 250x100
        block: SpriteSet; // player when blocking, 100x100
    }[]; // provide sprites for each player, else player 1 sprites will be re-used
};
Enter fullscreen mode Exit fullscreen mode

此配置会被提供给所有需要了解我们正在处理的主题并从中选择所有资源的部分。例如,角色类现在可以像这样绘制主题资源:

this.theme.drawSprite(
    this.ctx,
    "p1_move_s",
    { x: this.size / -2, y: this.size / -2 },
    frameCount
);
Enter fullscreen mode Exit fullscreen mode

还记得我给移动角色添加的旋转功能吗?这对于允许旋转的主题来说可能很有用——比如《小行星》。但我的主题是上下的。旋转精灵看起来会很傻。

蓝色骑士站在城堡旁边。他开始逆时针旋转。

我需要一个方法将精灵图分配给方向值。我需要将 8 个罗盘方向映射到一个完整的方向值圆环上。圆弧段是可行的方法。由于起点和终点正好位于方向的中间,我只需将这个重叠方向分配两次即可——分别作为第一个方向和最后一个方向。

饼图:八个部分,按顺时针方向标记为

private getSprite(): Sprite {
    const directions = ["w", "nw", "n", "ne", "e", "se", "s", "sw", "w"];
    const zones = directions.map((z, i) => ({
        zone: z,
        start: (Math.PI * -1) - (Math.PI / 8) + (i * Math.PI) / 4,
        end: (Math.PI * -1) - (Math.PI / 8) + ((i + 1) * Math.PI) / 4,
    }));

    const direction = zones.find((zone) => this.orientation >= zone.start && this.orientation < zone.end);

    // action refers to moving, attacking, blocking...
    return this.theme.config.players[this.player][action][direction.zone];
}
Enter fullscreen mode Exit fullscreen mode

最后,我将使用this.theme.config.turnSprites角色类在转弯和基于方向的主题之间切换。

蓝色骑士转身。每当他面向新的方向时,就会显示一个新的机械精灵,从而产生角色实际转身的幻觉。

声音

视觉效果只是主题的一个方面。另一个方面是声音。我想要一个特定的攻击、格挡、撞东西的声音,当然还要有一些背景音乐。我的主题是8位音乐风格,所以我找了一些芯片音乐来配合。我的音乐制作技能几乎为零,而且我从未用过芯片音乐做过任何作品,所以我去了itch.io,想找一些真正懂行的人制作的声音。

我以为可以用简单直接的方式,使用<audio>元素。每当需要声音时,创建一个元素,自动播放,然后移除它。

const audio = new Audio("./sound.mp3");
audio.play();
Enter fullscreen mode Exit fullscreen mode

这效果很好,至少在 Chrome 和 Firefox 上是这样。但是 Safari……哦,Safari……声音播放前总是有延迟。我试过预加载文件,试过用 Service Worker 缓存,试过预先创建音频元素,然后在需要时设置播放器位置。Safari 根本就不支持。好吧,那我得用正确的方法了。

就像我为视觉内容设置画布上下文一样,我也AudioContext为声音设置了一个上下文。这个上下文会被游戏的所有其他部分共享,以播放所需的一切内容。

Web Audio API 的构建方式类似于一个真正的模块化合成器。我们需要将一个设备连接到另一个设备。在本例中,我们使用音频文件作为输入源,对其进行缓冲,连接到增益节点来设置音量,最后播放它们。

this.ctx = new (window.AudioContext || window.webkitAudioContext)();

async function play(sound: string): Promise<void> {
    if (this.sounds[this.getAudioUrl(sound)].playing) {
        return;
    }

    this.sounds[this.getAudioUrl(sound)].playing = true;

    const arrayBuffer = await this.getSoundFile(this.getAudioUrl(sound));
    const source = this.ctx.createBufferSource();

    this.ctx.decodeAudioData(arrayBuffer, (audioBuffer) => {
        source.buffer = audioBuffer;
        source.connect(this.vol);
        source.loop = false;
        source.onended = () => {
            this.terminateSound(source);
            this.sounds[this.getAudioUrl(sound)].playing = false;
        };
        source.start();
    });
}
Enter fullscreen mode Exit fullscreen mode

这样我就可以用以下方式记录声音:

// theme config
{
    // ...
    bgAudio: "./assets/bgm.mp3",
    attackAudio: "./assets/attack.mp3",
    blockAudio: "./assets/block.mp3",
    collideAudio: "./assets/bump.mp3",
    winAudio: "./assets/win.mp3",
}
Enter fullscreen mode Exit fullscreen mode

...并使用以下命令调用它们:

this.audio.play(this.theme.config.collideAudio);
Enter fullscreen mode Exit fullscreen mode

最后,甚至 Safari 也只能在我需要时播放声音,而不是在浏览器需要时播放声音。

使用游戏手柄

我喜欢使用一些鲜为人知的浏览器 API。Gamepad API 就是一个绝佳的选择,它可以连接最多四个游戏手柄。

不过,使用它感觉有点不顺手。与键盘和鼠标等更常见的输入方式不同,游戏手柄不会发送事件。相反,一旦网站检测到游戏手柄交互,它就会填充 Gamepad 对象。

interface Gamepad {
    readonly axes: ReadonlyArray<number>;
    readonly buttons: ReadonlyArray<GamepadButton>;
    readonly connected: boolean;
    readonly hapticActuators: ReadonlyArray<GamepadHapticActuator>;
    readonly id: string;
    readonly index: number;
    readonly mapping: GamepadMappingType;
    readonly timestamp: DOMHighResTimeStamp;
}

interface GamepadButton {
    readonly pressed: boolean;
    readonly touched: boolean;
    readonly value: number;
}
Enter fullscreen mode Exit fullscreen mode

每次交互都会改变对象。由于没有发送浏览器原生事件,我需要监听游戏手柄对象的变化。

if (
    this.gamepads[gamepadIndex]?.buttons &&
    gamepadButton.button.value !==
        this.gamepads[gamepadIndex]?.buttons[gamepadButton.index]?.value &&
    gamepadButton.button.pressed
) {
    // send press event
    this.pressButton(gamepadIndex, b.index, gamepadButton.button);
} else if (
    this.gamepads[gamepadIndex]?.buttons &&
    gamepadButton.button.value !==
        this.gamepads[gamepadIndex]?.buttons[gamepadButton.index]?.value &&
    !gamepadButton.button.pressed
) {
    // send release event
    this.releaseButton(gamepadIndex, b.index, gamepadButton.button);
}
Enter fullscreen mode Exit fullscreen mode

pressButtonreleaseButton发送自定义事件,我可以在角色类中使用这些事件并扩展我的输入方法来识别游戏手柄。

一个游戏手柄控制着屏幕上的蓝色角色。用手指移动左侧游戏手柄摇杆,角色就会随之移动。

我只有 Xbox 360 控制器,所以就用它来搭建和测试了。据我了解,PlayStation 控制器的按键映射是一样的。摇杆一和摇杆二分别对应左右摇杆,无论它们的硬件布局如何。Xbox 的按键ABXY映射方式与 PlayStation 的几何形状相同。

Xbox 和 PS5 控制器并排示意图。匹配的按钮和摇杆采用匹配的颜色,并带有标签

我无法让GamepadHapticActuator“震动”(也称为“震动”)功能在我的 360 控制器上正常工作。我不确定 Chrome 和 Firefox 浏览器是否只支持我的控制器,或者根本不支持。我可能需要用更新的控制器来测试一下。我还想用一些不太常用的控制器,比如Nintendo 64 布局的控制器或Steam 控制器,来进一步测试 Gamepad API 。

游戏玩法

咱们把事情整理一下吧。我有一些游戏的零碎,但还没能正常运转。

当我们记录一次命中时,现在什么都没有发生。这游戏太无聊了。我想先给出一些反馈,看看谁赢了,然后重新开始这一轮。由于这游戏节奏很快,回合数很短,所以最好能有分数显示。

我们可以在该方法中确定回合的获胜者character.strike()。调用该方法并记录实际命中的玩家获胜。我将发送一个包含该信息的事件,并触发以下调用:

  • 显示获胜者指示
  • 增加分数计数器
  • 重置角色
  • 开始新一轮的倒计时
declare interface FinishEvent extends Event {
    readonly detail?: {
        winner: number;
    };
}

this.ctx.canvas.addEventListener("countdown", ((e: FinishEvent) => {
    if (typeof e.detail?.winner === "number") {
        this.gui.incrementScore(e.detail.winner);
    }

    this.startCountdown(e.detail?.winner);
    this.togglePlayers(false);
}) as EventListener);

this.ctx.canvas.addEventListener("play", () => {
    this.togglePlayers(true);
});
Enter fullscreen mode Exit fullscreen mode

目前来说,我倒是觉得用一个合适的状态机会更好,但我的事件机制还没复杂到让我费劲重构的地步。只要它能很好地融入图表,应该就不会太糟糕,对吧?

流程图。加载资源事件触发下载,从而启动游戏。当玩家获胜时,游戏将重置,并重新开始倒计时。

加载中

启动游戏并开始第一轮游戏时,它仍然很卡。声音和图像还没加载,在浏览器缓存中不断弹出。我需要一个加载策略。

我很清楚在开始游戏之前需要加载哪些内容。我已经拥有了网站的资源,因为启动游戏时我已经在执行 JavaScript 了。无需考虑这一点。但我确实需要加载所有图形和声音。否则,它们会按需加载,从而增加加载时间。

我可以通过创建一个新的Image原型并为其提供 来加载图像src。浏览器将自动开始获取它。

private loadImage(src: string): Promise<HTMLImageElement> {
    const url = `./themes/${this.config.name}/${src}`;
    return fetch(url).then(() => {
        const img = new Image();
        img.src = url;
        if (!this.images.includes(img)) {
            this.images.push(img);
        }
        return img;
    });
}
Enter fullscreen mode Exit fullscreen mode

现在,我可以遍历主题配置中找到的所有图片并加载它们。承诺的图片存储在一个数组中。

this.config.players.forEach((player) => {
    const spriteSets = ["default", "move", "attack", "block"];
    spriteSets.forEach((spriteSet) => {
        Object.keys(player[spriteSet]).forEach((key: string) => {
            player[spriteSet][key].images.forEach(async (image: string) => {
                const imageResp = await this.loadImage(image);
                if (toLoad.includes(imageResp)) {
                    return;
                }
                imageResp.onload = () => {
                    this.onAssetLoaded(toLoad);
                };
                toLoad.push(imageResp);
            });
            this.sprites.push(player[spriteSet][key]);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

每次加载图片时,我都会检查数组中的所有 Promise 是否都已解析。如果已解析,则所有图片均已加载,然后我可以发送一个事件,告知游戏资源已加载多少。

private onAssetLoaded(assetList: HTMLImageElement[]) {
    const loadComplete = assetList.every((x) => x.complete);
    const progress = Math.floor(
        ((assetList.length - assetList.filter((x) => !x.complete).length) / assetList.length) * 100
    );
    const loadingEvent: LoadingEvent = new CustomEvent("loadingEvent", { detail: { progress } });
    this.ctx.canvas.dispatchEvent(loadingEvent);

    if (loadComplete) {
        this.assetsLoaded = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

进度信息会被映射到一个<progress>元素上。每当进度达到 100% 时,我就会淡入<canvas>并开始游戏。

游戏加载的屏幕截图。最顶部有标题

最后的润色

严格来说,游戏现在已经完成了。但它仍然是一个网站,我应该尽力保持它尽可能快速、兼容和易用。虽然我无法在游戏中添加 a11y 特有的功能(至少就我所知),但我可以专注于网站部分。让我们从一些自动化代码检查开始吧。

Lighthouse 和 Validator

我还没有添加描述<meta>标签。我把画布的 tabindex 设置为 1,而它应该是 0(只是为了让它可聚焦)。我还有一个 SVG favicon,Safari 仍然不支持它(当然),而且我还顺便添加了一个apple-touch-icon。还有一个<label>缺少<input>

Lighthouse 得分在性能、可访问性、最佳实践和 SEO 方面均达到 100 分。

渐进式 Web 应用

还有一个灯塔类别被遗漏了:PWA。将 PWA 功能添加到这个项目中甚至合情合理。博客提供的 PWA 好处最多也只能说是值得怀疑的(我为什么要把你的博客放在主屏幕上?),而游戏绝对应该具备可安装和离线功能。

通过 Lighhouse PWA 检查的图片。图片显示了 PWA 徽标、绿色圆圈内的白色勾号、缩写 PWA 以及文字

第一步是创建清单。清单不需要做太多工作,只需包含必要的图标、颜色和标题字符串,以便在安装时格式化主屏幕图标、启动画面和浏览器 UI。并指定 PWA 以全屏模式运行,从而隐藏所有浏览器 UI 元素。

{
    "theme_color": "#1e212e",
    "background_color": "#1e212e",
    "display": "fullscreen",
    "scope": "/",
    "start_url": "/",
    "name": "Attacke!",
    "short_name": "Attacke!",
    "icons": [
        {
            "src": "assets/icon-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        ...
    ]
}
Enter fullscreen mode Exit fullscreen mode

我希望我的游戏 PWA 只包含游戏本身。任何附加链接,例如积分页面和源代码链接,只要以全屏视图打开,都应该在新的浏览器窗口中打开。虽然应用是在常规浏览器窗口中打开的,但我非常希望让用户自己控制链接的行为。

此代码片段询问浏览器是否处于全屏模式,data-link='external'如果是,则在新选项卡中打开所有标有的链接:

if (window.matchMedia("(display-mode: fullscreen)").matches) {
    document.querySelectorAll("[data-link='external']").forEach((el) => {
        el.setAttribute("target", "_blank");
        el.setAttribute("rel", "noopener noreferrer");
    });
}
Enter fullscreen mode Exit fullscreen mode

离线模式

下一步是 Service Worker。对于有效的 PWA,它只需注册并提供离线请求的响应即可。在本例中,我想创建一个包含所有游戏资源的离线缓存。这在安装时会造成相当大的网络流量,但我认为在安装游戏时这没问题。毕竟应用商店的工作原理也是一样的。

Servcie Worker 功能示意图。一个标有

缓存离线请求相对容易,响应请求也同样容易。关于如何做到这一点的文章有很多。但由于网络传输的资源量很大,我只想在用户安装应用时缓存它们。否则,在需要时将它们流式传输到服务器是更好的选择。由于我所有的主题都遵循相同的架构,我可以遍历它们,然后返回一个资源列表:

export const getGameAssets = (): string[] => {
    const assets = [];

    Object.keys(themes).forEach((theme) => {
        const themeConfig = themes[theme] as themeConfig;

        // add player sprites
        ["p1", "p2"].forEach((player, pi) => {
            ["default", "move", "attack", "block"].forEach((action) => {
                const spriteSet = themeConfig.players[pi][action] as SpriteSet;

                ["n", "ne", "e", "se", "s", "sw", "w", "nw"].forEach(
                    (direction) => {
                        const images = spriteSet[direction].images as string[];
                        const paths = images.map(
                            (image) => `/themes/${theme}/${image}`
                        );
                        assets.push(...paths);
                    }
                );
            });
        });

        // add background sprite
        themeConfig.scene.images.forEach((image) => {
            assets.push(`/themes/${theme}/${image}`);
        });

        // add sounds
        [
            "bgAudio",
            "attackAudio",
            "blockAudio",
            "collideAudio",
            "winAudio",
        ].forEach((audio) => {
            assets.push(`/themes/${theme}/${themeConfig[audio]}`);
        });
    });

    // return uniques only
    return [...new Set(assets)];
};
Enter fullscreen mode Exit fullscreen mode

该函数在 Service Worker 中被调用,并缓存运行全功能游戏所需的一切。

const cacheAssets = () => {
    const assets = [
        "/index.html",
        "/styles.css",
        "/main.js",
        "/assets/PressStart2P.woff2",
        ...getGameAssets(),
    ];

    caches.open(cacheName).then(function (cache) {
        cache.addAll(assets);
    });
};

channel.addEventListener("message", (event) => {
    switch (event.data.message) {
        case "cache-assets":
            cacheAssets();
            break;
    }
});
Enter fullscreen mode Exit fullscreen mode

那是什么?一条cache-assets消息?它从哪里来的?为什么不是install eventListener呢?

这是因为我不喜欢 PWA 安装提示的当前状态。

自定义安装按钮

Android 版 Chrome 会显示一个又大又丑的安装横幅。它与我的设计不符,而且是主动提供的,用户也不清楚它的行为。桌面版 Chrome 也一样,只不过是弹出窗口。Android 版 Firefox 会把它隐藏在浏览器菜单中,尽管至少它清楚地标明了“安装”。最糟糕的是 Safari(没错,又来了)。为什么要把安装按钮隐藏在分享菜单中呢?

至少 Chrome 提供了一种实现我自己的安装用户体验的方法(请注意,这里的所有内容都不符合规范。出于道德原因,你可能不想这样做)。它们的安装提示是由事件监听器触发的,我可以将其挂钩到其中。这样我就可以将提示完全隐藏,并将其事件绑定到一个自定义按钮上。点击该按钮时,PWA 及其所有资源都会随之安装。

window.addEventListener("appinstalled", () => {
    button.setAttribute("hidden", "hidden");
    deferredPrompt = null;
    channel.postMessage({ message: "cache-assets" });
});
Enter fullscreen mode Exit fullscreen mode

没有未经请求的安装提示,没有毫无预警地向用户设备发送大量下载垃圾信息,只有一个老式的安装按钮。史蒂夫·乔布斯会很高兴的

结论

现在有一款游戏,完全用 TypeScript 编写,并以 渲染<canvas>,甚至可以在所有主流浏览器上流畅运行,并且封装在 PWA 中。我未来的计划包括为它添加更多主题、更多玩家以及远程多人游戏支持,这样我就可以借此机会学习一些WebRTC 了

构建游戏逻辑和绘制图形的过程让我乐在其中。我肯定需要多用 Photoshop。弄清楚所有问题可能很费劲(Safari 就是这么来的),但学习的过程本身就值得。

如果您想做出贡献,请访问GitHub 上的 Attacke !。

文章来源:https://dev.to/iamschulz/writing-a-game-in-typescript-13em
PREV
用 CSS 编写逻辑 控制结构 逻辑门技术 总结
NEXT
🎞️ 面向开发者的动画 🎬 动画在 UI 中的作用 ✔️ 何时以及对哪些内容进行动画处理 👮‍♀️ 何时以及不对哪些内容进行动画处理 📚 延伸阅读