自定义vue渲染器,分离ui逻辑和业务逻辑,基于TDD思想实现一个飞机大战小游戏

2,894 阅读36分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第12天,点击查看活动详情

项目介绍

本篇文章将会通过vue提供的createRenderer接口实现一个自定义渲染器,借助pixi.jscanvas上进行渲染,以及基于ui逻辑和业务逻辑分离的思想,为每一个业务逻辑编写单元测试,通过测试去驱动相关功能的实现,也就是TDD的思想,实现一个飞机大战游戏

通过本项目你能够学到:

  1. 使用vue的自定义渲染器接口createRenderer实现一个在非DOM环境下的渲染器,实现方式参考runtime-dome
  2. 学会pixi.js的基本使用
  3. vitest测试框架的基本使用,基本的API使用,以及如何测试有定时器场景的业务逻辑
  4. TDD测试驱动开发思想,每一个业务逻辑都会有相应的单元测试,并且是先编写单元测试,根据单元测试描述的场景去思考如何实现具体功能,单元测试的描述范围足够小,通过小步快走的方式逐步完善功能,由于步骤足够小,涉及的其他模块几乎没有,方便每个单元测试的调试
  5. ui逻辑和业务逻辑解耦,摆脱以往的实现一个功能就要打开浏览器检查视图的逻辑来验证自己的业务逻辑是否正确的痛苦开发体验,这样还能够在添加新功能时很方便地验证是否有影响到之前实现的功能

项目效果展示: 子弹打中敌机后子弹和敌机都消失.gif 游戏功能和场景不复杂,因为重点不是实现一个好玩的飞机大战游戏,而是要运用解耦ui逻辑和业务逻辑的思想去开发一个项目,如果有兴趣丰富游戏功能的,欢迎读者自行实现

项目源码: github.com/Plasticine-…

好了话不多说,开始开发我们的飞机大战项目吧!

1. 创建项目

通过pnpm create vite创建项目,项目名为plane-wars,使用vue3 + ts开发


2. 集成vitest

基于TDD测试驱动开发的思想,我们需要一款测试框架,本项目会采用vitest测试框架进行测试 首先安装vitest

pnpm add -D vitest

2.1 编写一个单元测试用例

首先编写一个单元测试用例体验一下vitest,也顺便验证一下vitest是否正常安装了,如果你用过jest,那么你使用vitest将会感到十分容易上手,因为很多api的命名都是一样的,但是vitest有一个明显的好处就是!这也是vitest官方的宣传语

Vitest is a blazing fast unit test framework powered by Vite.

src/components目录下创建一个vitest.test.ts文件

import { expect, test } from 'vitest';

test('hello vitest', () => {
  expect('hello vitest').toBe('hello vitest');
});

然后在package.json中添加一个测试脚本

{
  "scripts": {
    "test": "vitest"
  }
}

运行pnpm test看看结果,如果看到如下结果则说明vitest正常运行了 image.png 可以看到vitest默认运行是以watch方式运行的,也就是说测试文件的内容变化时会自动执行测试,不用像jest那样需要我们手动添加-w参数运行,算是一个小人性化的设计吧哈哈哈


3. 集成 pixi.js

pixi.js是一个高性能的图形渲染系统,我们的项目会使用它来进行游戏的开发,相当于是一个游戏引擎了

PixiJS is an open source, web-based rendering system that provides blazing fast performance for games, data visualization, and other graphics intensive projects.

安装pixi.js

pnpm i pixi.js

3.1 体验 pixi.js

先以官网的demo演示一下,体验一下pixi.js 修改src/main.ts的内容如下

// import { createApp } from 'vue'
// import App from './App.vue'

// createApp(App).mount('#app')

import * as PIXI from 'pixi.js';
import logo from './assets/logo.png';

// The application will create a renderer using WebGL, if possible,
// with a fallback to a canvas render. It will also setup the ticker
// and the root stage PIXI.Container
const app = new PIXI.Application();

// The application will create a canvas element for you that you
// can then insert into the DOM
document.body.appendChild(app.view);

// load the texture we need
app.loader.add('bunny', logo).load((loader, resources) => {
  // This creates a texture from a 'bunny.png' image
  const bunny = new PIXI.Sprite(resources.bunny.texture);

  // Setup the position of the bunny
  bunny.x = app.renderer.width / 2;
  bunny.y = app.renderer.height / 2;

  // Rotate around the center
  bunny.anchor.x = 0.5;
  bunny.anchor.y = 0.5;

  // Add the bunny to the scene we are building
  app.stage.addChild(bunny);

  // Listen for frame updates
  app.ticker.add(() => {
    // each frame we spin the bunny around a bit
    bunny.rotation += 0.01;
  });
});

apppixi.js的对象,调用它的view属性可以获取到canvas元素,将这个canvas元素添加到body结点中就可以将其渲染出来了 image.png 后面的代码是添加一个Sprite对象到app.stageSprite通俗来讲就是图片,可以渲染图片出来

Sprites are the simplest and most common renderable object in PixiJS. They represent a single image to be displayed on the screen.

app.stage对象则是一个容器对象,它作为整个canvas的根容器存在 image.png 最终渲染的效果如下: 体验pixi.js.gif 这是通过原生的DOM操作将canvas渲染到浏览器上的,而我们的项目不会使用原生的方式,而是使用vue提供的自定义渲染器,用pixi.js实现createRenderer自定义渲染器需要我们实现的渲染接口,将结果渲染出来,这样就能够使用到vueruntime-core的特性了 image.png


4. 使用 createRenderer 自定义渲染器

自定义渲染器主要实现一下几个接口:

  1. createElement:定义如何创建元素的逻辑
  2. patchProp:定义元素的属性更新时的逻辑
  3. insert:定义新增元素时的逻辑
  4. remove:定义移除元素时的逻辑

接下来我们就要基于pixi.js提供的渲染API实现vue要求我们实现的这几个接口函数,打造一个基于pixi.js进行渲染的自定义渲染器!

4.1 createElement

创建的元素是什么呢?肯定要是pixi.js中的对象,我们不会一次性实现完整个createElement接口,这样也不现实,应当根据需求去实现,需要什么就去实现什么,就以我们前面体验pixi.jsdemo为例,首先肯定需要实现创建容器对象,也就是pixi.jsContainer对象

The Container class provides a simple display object that does what its name implies - collect a set of child objects together.

那么首先就处理一下创建Container元素对象的逻辑

// ============ 实现自定义渲染器 ============
import { createRenderer } from 'vue';
createRenderer({
  createElement(type) {
    let element;

    switch (type) {
      case 'Container':
        element = new Container();
        break;
    }

    return element;
  },
});

type是自定义元素的类型,是一个字符串,这个字符串来自哪里呢?值是什么呢? 当我们在template中写一个标签的时候,标签的名字就是这里的type

<template>
  <Container></Container>
</template>

这里的Container标签会被vue的模板编译器解析成vnode,其中vnodetype属性就是标签的名字,也就是Container,也就是createElement接口接收的第一个type参数的值 这也是自定义渲染器的威力所在,当我们实现了自定义渲染器后,我们就可以直接在vue文件中渲染出pixi.js提供的丰富的API了,是不是很酷!


4.1.1 解决泛型报错问题

现在有一个棘手的问题,目前是会报错的 image.png 出现了类型错误,因为createRenderer是可以接收两个泛型的:

  1. 第一个泛型是RenderNode
  2. 第二个泛型是RenderElement

    The createRenderer function accepts two generic arguments: HostNodeand HostElement, corresponding to Node and Element types in the host environment. For example, for runtime-dom, HostNode would be the DOM Node interface and HostElement would be the DOM Element interface.

1. RenderNode 类型

从官方文档中可以看到,以DOM环境下的渲染器runtime-dom为例,它使用的RenderNodeDOMNode类型,而RenderElementDOMElement类型 那么类似的,pixi.js中是否有类似的NodeElement类型呢?在pixi.js的官方文档中可以看到对Container类型的描述

Container is a general-purpose display object that holds children. It also adds built-in support for advanced rendering features like masking and filtering. It is the base class of all display objects that act as a container for other objects, including Graphics and Sprite.

可以看到,这不就和DOM里的Node接口的功能类似吗?都是充当一个“容器”的角色,而且当你打开MDN查看Node接口的描述,会发现它们真的很相似 image.png 同样都是目标渲染环境下的所有对象的父类 再来看看Container的相关API image.png 这不就是我们想要的吗,能够添加子元素,获取子元素,删除子元素,所以我们可以将Container作为RenderNode泛型传入

2. RenderElement 类型

pixi.js中的接口不像DOM中的那样,有一个Element接口继承自Node接口,然后平时我们接触到的所有的DOM API都是基于Element来使用的 pixi.js中直接是让具体的类继承自Container,少了DOMElement这一中间层,那么我们应该怎么填写RenderElement这个泛型呢? 那就直接填Container类型呗,因为它也是所有display object的父类嘛,所以自身当做RenderElement也没有啥大问题

所以最终我们的createRenderer接受的泛型为

createRenderer<Container, Container>({});

4.1.2 解决 undefined 类型报错问题

解决了泛型的问题后,我们又遇到了新的问题 image.png 因为new Container()返回的元素可能是undefined,而createElement需要返回的元素只能是泛型中约束的Container类型,因此产生了报错,那么该如何解决呢? 可以通过抛出异常的方式,当类型不匹配(不是Container)的时候,我们就抛出异常告知用户type的类型不存在或暂未实现其渲染逻辑

createRenderer<Container, Container>({
  createElement(type) {
    let element;

    switch (type) {
      case 'Container':
        element = new Container();
        break;
+     default:
+       throw new Error(
+         `The type of ${type} does not exist or has not implemented its rendering logic`
+       );
    }

    return element;
  },
});

4.1.3 实现后续接口

好家伙现在又有新的报错了,整个函数都是红的 image.png 这是因为createRenderer函数接收的第一个参数RenderOptions中规定的接口中有必须实现的我们还没有实现

export declare interface RendererOptions<
  HostNode = RendererNode,
  HostElement = RendererElement
> {
  patchProp(
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
    isSVG?: boolean,
    prevChildren?: VNode<HostNode, HostElement>[],
    parentComponent?: ComponentInternalInstance | null,
    parentSuspense?: SuspenseBoundary | null,
    unmountChildren?: UnmountChildrenFn
  ): void;
  insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void;
  remove(el: HostNode): void;
  createElement(
    type: string,
    isSVG?: boolean,
    isCustomizedBuiltIn?: string,
    vnodeProps?:
      | (VNodeProps & {
          [key: string]: any;
        })
      | null
  ): HostElement;
  createText(text: string): HostNode;
  createComment(text: string): HostNode;
  setText(node: HostNode, text: string): void;
  setElementText(node: HostElement, text: string): void;
  parentNode(node: HostNode): HostElement | null;
  nextSibling(node: HostNode): HostNode | null;
  querySelector?(selector: string): HostElement | null;
  setScopeId?(el: HostElement, id: string): void;
  cloneNode?(node: HostNode): HostNode;
  insertStaticContent?(
    content: string,
    parent: HostElement,
    anchor: HostNode | null,
    isSVG: boolean,
    start?: HostNode | null,
    end?: HostNode | null
  ): [HostNode, HostNode];
}

可以看到还有patchPropinsert等其他的必须实现的接口等着我们去实现,那么不急,接下来就一个个实现它们 为了先解决掉这烦人的红色报错,我们可以用vscode的快速修复功能,添加这些必须实现但还没有实现的接口 image.png 修复之后就变成了下面这样

createRenderer<Container, Container>({
  createElement(type) {
    let element;

    switch (type) {
      case 'Container':
        element = new Container();
        break;
      default:
        throw new Error(
          `The type of ${type} does not exist or has not implemented its rendering logic`
        );
    }

    return element;
  },
  patchProp: function (
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
    isSVG?: boolean,
    prevChildren?: VNode<HostNode, HostElement, { [key: string]: any }>[],
    parentComponent?: ComponentInternalInstance | null,
    parentSuspense?: SuspenseBoundary | null,
    unmountChildren?: UnmountChildrenFn
  ): void {
    throw new Error('Function not implemented.');
  },
  insert: function (
    el: HostNode,
    parent: HostElement,
    anchor?: HostNode | null
  ): void {
    throw new Error('Function not implemented.');
  },
  remove: function (el: HostNode): void {
    throw new Error('Function not implemented.');
  },
  createText: function (text: string): HostNode {
    throw new Error('Function not implemented.');
  },
  createComment: function (text: string): HostNode {
    throw new Error('Function not implemented.');
  },
  setText: function (node: HostNode, text: string): void {
    throw new Error('Function not implemented.');
  },
  setElementText: function (node: HostElement, text: string): void {
    throw new Error('Function not implemented.');
  },
  parentNode: function (node: HostNode): HostElement | null {
    throw new Error('Function not implemented.');
  },
  nextSibling: function (node: HostNode): HostNode | null {
    throw new Error('Function not implemented.');
  },
});

似乎自动修复并不会帮我们把已传入的泛型给填写上去,而是填写了vue底层源码定义的泛型HostNodeHostElment 没关系,我们手动用替换将HostNodeHostElement全部改成Container即可


4.2 createText/createComment

createText用于创建文本,pixi.js中有一个Text类可以让我们在canvas中渲染文本,因此直接返回Text对象即可

createText: function (text: string): Container {
  return new Text(text);
}

createComment用于创建注释结点,以runtime-dom中的实现为例,当我们在vuetemplate中编写注释的时候,注释是会被解析成一个Comment类型的vnode的 这里我们同样是返回一个Text对象来充当注释

createComment: function (text: string): Container {
  return new Text(text);
}

4.3 parentNode/nextSibling

从名字就知道,我们需要在该接口中返回父节点,Container对象有一个parent属性就是引用父节点的,直接返回它即可

parentNode: function (node: Container): Container | null {
  return node.parent;
}

由于pixi.js并不像DOMNode接口那样提供了一个nextSibling属性指向当前节点同级的下一个节点,所以这里我们就不去实现该接口


4.4 remove

这个接口用于移除当前元素,我们可以参考vueruntime-dom中的实现,打开vue3的源码,找到packages/runtime-dom/src/nodeOps.ts,然后就可以看到相应的实现如下

remove: (child) => {
  const parent = child.parentNode;
  if (parent) {
    parent.removeChild(child);
  }
};

由于pixi.js也有提供类似的接口,所以我们可以参考runtime-dom的实现,依葫芦画瓢

remove: function (el: Container): void {
  const parent = el.parent;
  if (parent) {
    parent.removeChild(el);
  }
}

4.5 insert

还是一样,先看看runtime-dom的实现

insert: (child, parent, anchor) => {
  parent.insertBefore(child, anchor || null);
};

可以看到,直接调用Node接口的insertBefore方法进行插入 这里的anchor是用于定位的,将child插入到anchor结点的前面,pixi.js虽然没有提供根据在具体结点前面插入结点的API,但是有一个addChildAt,能够让我们在指定下标处插入结点 而且也有一个getChildIndex方法,所以我们可以先获取到anchorparent中的下标,然后将新结点插入到该下标处即可

insert: function (
  el: Container,
  parent: Container,
  anchor?: Container | null
): void {
  let index = -1;
  if (anchor) {
    index = parent.getChildIndex(anchor);
  }

  if (index !== -1) parent.addChildAt(el, index);
  else parent.addChild(el);
}

4.6 setText/setElementText

由于pixi.jsContainer没有像Node/Element那样的nodeValue/textContent的属性,所以这两个接口我们选择不实现


5. 使用实现的自定义渲染器重新实现 demo

现在我们的自定义渲染器实现好了,赶紧来测试一下是否能够正常工作吧,就以前面的demo为例

5.1 实现创建 Sprite 元素

由于demo中使用到了图片,pixi.js中使用Sprite对象来渲染图片,所以我们先要能够在我们的自定义渲染器中将Sprite对象渲染出来

createElement(type) {
  let element;

  switch (type) {
    case 'Container':
      element = new Container();
      break;
    case 'Sprite':
      element = new Sprite();
      break;
    default:
      throw new Error(
        `The type of ${type} does not exist or has not implemented its rendering logic`
      );
  }

  return element;
}

5.2 处理 Sprite 对象的 texture prop

Sprite对象需要一个texture属性,由于我们实现了自定义渲染器,所以可以直接在模板中以给Sprite传入一个textureprop,它会被vue的运行时处理,添加到Sprite对象上,这也正是我们实现自定义渲染器的意义,能够让我们使用vue的运行时特性 但是我们的自定义渲染器并没有实现任何处理prop的能力,那是因为我们还没有去实现patchProp这个接口 现在去实现它,处理属性名为textureprop

patchProp: function (
  el: Container,
  key: string,
  prevValue: any,
  nextValue: any
): void {
  switch (key) {
    case 'texture':
      // nextValue 是新传入的 prop 值
      (el as Sprite).texture = Texture.from(nextValue);
      break;
  }
}

由于createRenderer中的泛型我们给RenderElement传入的是Container类型,而现在我们要处理的是它的子类Sprite类型的对象,所以我们需要给el进行一下类型断言,将其断言为子类Sprite类型后才能获取到texture属性


5.3 使用自定义渲染器的 createApp

我们的自定义渲染器是实现了,但是main.ts目前还没有创建根组件,也没有挂载到容器上 createRenderer会返回一个renderer对象,这个对象有两个方法,一个是render方法,一个是createApp方法,现在我们需要用到createApp方法去创建和挂载我们的根组件

const app = new Application();
const { createApp } = createRenderer<Container, Container>({});

document.body.appendChild(app.view);
createApp(App).mount(app.stage);

app.stage就是整个pixi.js``app对象的容器,我们需要把我们的根组件挂载到这个容器中 然后在App.vue里面我们直接使用template去编写

<template>
  <Container>
    <Sprite :texture="logo"></Sprite>
  </Container>
</template>

可以看到已经能够渲染出来了 image.png demo中让图片居中并且旋转的功能就不去实现了,有兴趣的读者可以自行通过自定义渲染器实现一下相关逻辑,接下来我们要开始进入我们的正题 -- 实现飞机大战了(没错,这么久了才开始进入正题)


6. 重构自定义渲染器

在进入飞机大战正题之前,我们需要先重构一下我们的自定义渲染器,因为我们现在是直接写在main.ts里面的,这会导致main.ts的代码十分混乱,我们参考runtime-dom的结构进行重构

6.1 抽离 pixi app

首先将pixi app抽离出去,新建src/pixi-app/index.ts

import { Application } from 'pixi.js';

export const pixiApp = new Application();

document.body.appendChild(pixiApp.view);

再在main.ts中导入pixiApp进行挂载

import { pixiApp } from './pixi-app';
createApp(App).mount(pixiApp.stage);

6.2 抽离自定义渲染器

接下来还需要把自定义渲染器的实现代码抽离出去,参考runtime-dom的目录结构,我们创建一个runtime-pixi

// src/runtime-pixi/index.ts
import { Container, Sprite, Text, Texture } from 'pixi.js';
import { createRenderer } from 'vue';

const { createApp } = createRenderer<Container, Container>({
  createElement(type) {
    let element;

    switch (type) {
      case 'Container':
        element = new Container();
        break;
      case 'Sprite':
        element = new Sprite();
        break;
      default:
        throw new Error(
          `The type of ${type} does not exist or has not implemented its rendering logic`
        );
    }

    return element;
  },
  patchProp: function (
    el: Container,
    key: string,
    prevValue: any,
    nextValue: any
  ): void {
    switch (key) {
      case 'texture':
        // nextValue 是新传入的 prop 值
        (el as Sprite).texture = Texture.from(nextValue);
        break;
    }
  },
  insert: function (
    el: Container,
    parent: Container,
    anchor?: Container | null
  ): void {
    let index = -1;
    if (anchor) {
      index = parent.getChildIndex(anchor);
    }

    if (index !== -1) parent.addChildAt(el, index);
    else parent.addChild(el);
  },
  remove: function (el: Container): void {
    const parent = el.parent;
    if (parent) {
      parent.removeChild(el);
    }
  },
  createText: function (text: string): Container {
    return new Text(text);
  },
  createComment: function (text: string): Container {
    return new Text(text);
  },
  setText: function (node: Container, text: string): void {
    throw new Error('Function not implemented.');
  },
  setElementText: function (node: Container, text: string): void {
    throw new Error('Function not implemented.');
  },
  parentNode: function (node: Container): Container | null {
    return node.parent;
  },
  nextSibling: function (node: Container): Container | null {
    throw new Error('Function not implemented.');
  },
});

export { createApp };

然后main.ts中导入createApp并使用即可

// import { createApp } from 'vue'
// import App from './App.vue'

// createApp(App).mount('#app')

import { createApp } from './runtime-pixi';
import { pixiApp } from './pixi-app';
import App from './App.vue';

createApp(App).mount(pixiApp.stage);

现在main.ts中的代码就清爽多了!而且和上面注释掉的原本DOM环境下的渲染逻辑对比,接口的一致性是相当高的


7. ui 逻辑和业务逻辑分离

本项目最重要的特色就是**ui**逻辑和业务逻辑分离 基于当前的场景,ui逻辑就是我们能够在浏览器上看到的飞机在移动 业务逻辑就是根据相关的键盘、鼠标事件改变飞机的坐标 为什么需要让ui逻辑和业务逻辑分离呢?因为这样可以方便我们编写单元测试,加强代码的健壮性 如果不分离的话,实现一个业务功能,我们就需要打开浏览器,通过查看ui逻辑来验证自己编写的业务逻辑是否正确,如果只是简单的小项目这样做或许没什么问题,但是如果随着时间推进,项目越做越大的时候,之后添加了新功能,我们需要确保前面已实现的功能不被影响,难道你要一个个通过人眼去验证一下之前实现的ui逻辑吗?加入之前已经实现了 100 个功能,那就意味着你要用肉眼去看相应的 100 个ui逻辑是否正确,验证没问题后,下次再添加新功能,你又得看 101 个ui逻辑是否正确,这是十分浪费时间的 但是如果能够有单元测试的支持,我们就只需要单元测试通过,保证业务逻辑正常即可,ui逻辑只需要简单地调用业务逻辑就可以实现,不再需要肉眼去一个个验证ui逻辑的问题

讲了这么多,不如实战来得有意义,接下来就会通过移动飞机这一功能来讲述一下什么是ui逻辑和业务逻辑分离


8. 实现我方飞机

8.1 搭建飞机单文件组件

components目录下创建一个Plane.vue,在里面渲染一张飞机的图片

<script setup lang="ts">
import playerPlaneImg from '@/assets/plane/player-plane.png';

defineProps({
  planeImg: {
    type: String,
    default: playerPlaneImg,
  },
});
</script>

<template>
  <Container>
    <Sprite :texture="planeImg"></Sprite>
  </Container>
</template>

该组件有一个prop,接收一个图片的路径,然后将其传给Sprite渲染出来,这正是前面我们要实现自定义渲染器的原因,这样能够让我们像编写普通的vue文件一样使用pixi.js的渲染特性 然后修改App.vue调用该组件

<script setup lang="ts">
import Plane from '@/components/Plane.vue';
</script>

<template>
  <Container>
    <Plane />
  </Container>
</template>

接下来我们就要去实现飞机的移动逻辑了,也就是我们的重头戏 -- **ui**逻辑和业务逻辑分离


8.2 业务逻辑 -- 飞机移动

我们把ui逻辑放在Plane.vue中实现,而业务逻辑则放在plane-app/Plane.ts中实现 首先思考一下飞机移动需要用到哪些变量?

  • 飞机的坐标
  • 飞机移动的速度

除了这些变量外,还需要有对应的方法能够触发移动吧?

  • 飞机的上下左右移动方法

因此我们可以用一个类去实现,实现一个Plane

8.2.1 我方飞机类

export class Plane {
  x: number;
  y: number;
  speed: number;

  constructor(x: number, y: number, speed: number) {
    this.x = x;
    this.y = y;
    this.speed = speed;
  }

  moveUp() {
    this.y -= this.speed;
  }

  moveDown() {
    this.y += this.speed;
  }

  moveLeft() {
    this.x -= this.speed;
  }

  moveRight() {
    this.x += this.speed;
  }
}

接下来编写单元测试去测试这四个方法的实现是否正确

8.2.2 单元测试 -- 我方飞机移动

import { describe, expect, it } from 'vitest';
import { Plane } from './Plane';

describe('Plane', () => {
  const { x, y, speed } = <Plane>{
    x: 20,
    y: 20,
    speed: 10,
  };

  const createPlane = () => new Plane(x, y, speed);

  it('moveUp', () => {
    const plane = createPlane();
    plane.moveUp();
    expect(plane.y).toBe(10);
  });

  it('moveDown', () => {
    const plane = createPlane();
    plane.moveDown();
    expect(plane.y).toBe(30);
  });

  it('moveLeft', () => {
    const plane = createPlane();
    plane.moveLeft();
    expect(plane.x).toBe(10);
  });

  it('moveRight', () => {
    const plane = createPlane();
    plane.moveRight();
    expect(plane.x).toBe(30);
  });
});

image.png 看来是没问题了,接下来就去将业务逻辑和ui逻辑对接起来


8.3 ui 逻辑 -- 飞机移动

<script setup lang="ts">
import playerPlaneImg from '@/assets/plane/player-plane.png';
import { onMounted, onUnmounted, reactive, toRefs } from 'vue';
import { Plane } from '@/pixi-game';

defineProps({
  planeImg: {
    type: String,
    default: playerPlaneImg,
  },
});

const plane = reactive(new Plane(0, 0, 10));
const { x, y } = toRefs(plane);

/**
 * @description 处理飞机方向移动
 */
const handleKeydown = (e: KeyboardEvent) => {
  switch (e.code) {
    case 'ArrowUp':
    case 'KeyW':
      plane.moveUp();
      break;
    case 'ArrowDown':
    case 'KeyS':
      plane.moveDown();
      break;
    case 'ArrowLeft':
    case 'KeyA':
      plane.moveLeft();
      break;
    case 'ArrowRight':
    case 'KeyD':
      plane.moveRight();
      break;
  }
};

onMounted(() => {
  // 添加键盘按下事件监听器
  window.addEventListener('keydown', handleKeydown);
});

onUnmounted(() => {
  window.removeEventListener('keydown', handleKeydown);
});
</script>

<template>
  <Container>
    <Sprite :texture="planeImg" :x="x" :y="y" />
  </Container>
</template>

mounted的时候添加方向按键的监听器,调用飞机对象的移动方法,改变了飞机对象内部的坐标数据,由于使用了reactive响应式包裹,并且通过toRefs解构出ref响应式变量给视图使用,从而业务逻辑只负责修改视图需要的数据(也就是飞机的坐标),而视图也只负责将数据展示,将ui逻辑和业务逻辑分离 现在飞机就可以移动了,效果如下 飞机移动.gif


8.4 解决控制台 custom element 警告

现在打开控制台会发现有这样的警告 image.png 这是因为ContainerSprite并不是真正的Vue组件,而是我们通过自定义渲染器去处理的元素,根据vue的提示,我们需要修改compilerOptions.isCustomElement 那么我们就去修改一下vite的配置,修改vue的模板编译器配置项,声明自定义元素的标签名

export default defineConfig({
  resolve: {
    alias: {
      '@': resolve(root, 'src'),
    },
  },
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => {
            const customElements = ['Container', 'Sprite'];
            if (customElements.includes(tag)) return true;

            return false;
          },
        },
      },
    }),
  ],
});

image.png 现在就没有烦人的警告啦


9. 实现子弹

9.1 让子弹能够画出来

首先最基本的我们应当让子弹能够被画出来,也就是实现基本的ui逻辑,新建一个Bullet.vue

<script setup lang="ts">
import bulletImg from '@/assets/bullets/player-bullet.png';
</script>

<template>
  <Container>
    <Sprite :texture="bulletImg" />
  </Container>
</template>

然后将它在App.vue中调用

<script setup lang="ts">
import Plane from '@/components/Plane.vue';
import Bullet from '@/components/Bullet.vue';
</script>

<template>
  <Container>
    <Plane />
    <Bullet />
  </Container>
</template>

image.png 现在它就和飞机一起被渲染出来了,为了方便后面测试飞机发射子弹的ui逻辑,我们将飞机的初始位置放到屏幕中间底部

const planeInitX = APP_WIDTH / 2 - PLANE_WIDTH / 2; // 飞机初始横坐标位于屏幕中间
const planeInitY = APP_HEIGHT * (2 / 3) - PLANE_HEIGHT / 2; // 飞机初始纵坐标位于屏幕 2 / 3 处
const plane = reactive(new Plane(planeInitX, planeInitY, 10));

image.png


9.2 业务逻辑 -- 子弹类的实现

我们的设计是飞机会一直发射子弹,每个子弹都应当是一个单独的对象,有自己的属性和方法,所以我们需要做以下两件事:

  1. 实现子弹类
  2. 维护一个子弹数组,存放我方飞机发射的子弹,这样才能在vue中通过v-for遍历去画出子弹

9.2.1 实现子弹类

新建src/pixi-game/bullet/Bullet.ts

import { randomUUID } from 'crypto';

export const BULLET_WIDTH = 30;
export const BULLET_HEIGHT = 72;

export class Bullet {
  id: string;
  x: number;
  y: number;
  speed: number;

  constructor(x: number, y: number, speed: number) {
    this.id = randomUUID();
    this.x = x;
    this.y = y;
    this.speed = speed;
  }

  move() {
    this.y -= this.speed;
  }
}

id属性用于标识子弹,因为我们需要用v-for对子弹数组进行遍历渲染,id用于作为v-forkey 由于子弹只需要在垂直方向上移动,因此只需要实现一个move方法即可


9.2.2 单元测试 -- 子弹移动

接下来立刻去编写单元测试测试一下子弹的移动逻辑

describe('Bullet', () => {
  const { x: bulletInitX, y: bulletInitY } = getElementCenterInContainer(
    100,
    100,
    BULLET_WIDTH,
    BULLET_HEIGHT
  );

  const createBullet = () => {
    return new Bullet(bulletInitX, bulletInitY, 5);
  };
  it('move', () => {
    const bullet = createBullet();
    bullet.move();
    expect(bullet.y).toBe(bulletInitY - bullet.speed);
  });
});

单元测试描述的场景是在一个100 * 100的容器中,有一个位于容器中间的子弹,当调用了子弹的move方法进行了一次移动之后,它的y坐标发生了变化,变化的数值应当是它变化之前的y坐标减去子弹的speed image.png 看来子弹的移动逻辑是没问题了,接下来应当实现子弹发射,由于子弹是需要不断的射出来的,所以我们可以用setInterval去实现


9.3 渲染子弹

首先我们先测试一下v-for渲染子弹是否成功

<!-- App.vue -->
<script setup lang="ts">
import Plane from '@/components/Plane.vue';
import Bullet from '@/components/Bullet.vue';
import { Bullet as BulletCls } from './pixi-game/bullet';
import { onMounted, provide, reactive } from 'vue';

const playerBullets: BulletCls[] = reactive([]);
const APP_WIDTH = document.documentElement.clientWidth;
const APP_HEIGHT = document.documentElement.clientHeight;

provide('APP_WIDTH', APP_WIDTH);
provide('APP_HEIGHT', APP_HEIGHT);

onMounted(() => {
  setInterval(() => {
    playerBullets.push(
      new BulletCls(Math.random() * APP_WIDTH, Math.random() * APP_HEIGHT, 5)
    );
  }, 300);
});
</script>

<template>
  <Container>
    <Plane />
    <Bullet
      v-for="bullet in playerBullets"
      :key="bullet.id"
      :x="bullet.x"
      :y="bullet.y"
    />
  </Container>
</template>

首先通过随机生成子弹的方式模拟一下子弹的发射,这样方便我们观察子弹是否真的有生成 生成随机子弹.gif 看来是可以生成子弹的,那么我们接下来只需要让子弹动起来就行了,因为子弹的坐标已经和视图绑定了,数据是响应式的,当子弹移动的时候,坐标发生变化,会让视图也更新


9.4 业务逻辑 -- 子弹生成和移动

但是需要考虑一个问题,子弹应当如何生成?真的就这样直接一股脑在App.vue中实现吗?那肯定是不对的,发射子弹是属于飞机这个对象的行为,所以我们应该给飞机添加一个方法,当这个方法被调用的时候就会发射子弹,其次,还应当实现一个方法,让子弹全都移动起来 也就是将子弹生成和子弹移动分为飞机对象的两个方法

  • fire:生成子弹
  • moveBullets:让子弹移动

除了这两个方法之外,还有更重要的一点,我们应当把属于飞机对象自己的子弹存起来呀!不然怎么知道一个子弹对象是属于哪个飞机的呢?所以还需要给飞机对象添加一个bullets属性 基于测试驱动开发的思想,我们应当先去编写相应的单元测试,描述一下具体的场景是怎样的

9.4.1 单元测试 -- 生成飞机的子弹

it('fire', () => {
  const plane = createPlane();
  plane.fire();
  expect(plane.bullets.length).toBe(1);
});

开一次火,属于飞机的子弹数量就应当增加1 现在就去实现它(省略旧的代码)

export class Plane {
  x: number;
  y: number;
  speed: number;
  bullets: Bullet[] = [];
  bulletMoveSpeed: number;

  constructor(x: number, y: number, speed: number, options?: PlaneOptions) {
    this.bulletMoveSpeed = options?.bulletMoveSpeed ?? 5;
  }

  /**
   * @description 开火 -- 生成子弹
   */
  fire() {
    this.bullets.push(new Bullet(this.x, this.y, this.bulletMoveSpeed));
  }
}

子弹出现的初始位置是在飞机的当前位置,还可以定义一个bulletMoveSpeed属性控制子弹的移动速度,如果没有传入该参数,则默认值为 5,即每次帧更新时子弹会移动5px的距离 image.png 单元测试通过则可以继续去实现下一个逻辑


9.4.2 单元测试 -- 让飞机的所有子弹移动

moveBullets方法会让子弹移动,所以我们的单元测试需要遍历飞机的所有子弹,检查它们的坐标,看看是否有移动

it('run', () => {
  const plane = createPlane();

  // 开 10 炮 -- 生成 10 个子弹
  for (let i = 0; i < 10; i++) plane.fire();

  // 让子弹动起来
  plane.moveBullets();

  for (const bullet of plane.bullets) {
    // 子弹如果有移动的话 y 坐标肯定不会和飞机的初始 y 坐标一样
    expect(bullet.y).not.toBe(plane.y);
  }
});

实现的思路也很简单,就是遍历飞机当前的所有子弹,调用它们的move方法让它们移动即可

/**
 * @description 让子弹移动
 */
moveBullets() {
  this.bullets.forEach((bullet) => bullet.move());
}

image.png 单元测试没问题,可以继续实现下一个逻辑了


9.4.3 让飞机一直开炮

要让飞机一直开炮,也就是说需要一直重复firemoveBullets的过程,但是这两个方法的调用时机很有讲究 首先fire会导致飞机的bullets数量增加,也就是说每调用一次firebullets的数量就加1,那也就是说调用fire的频率就等价于子弹发射的频率 而moveBullets只是用于遍历子弹让它们移动,属于动画层面的事情,这就应该交给pixi的帧循环ticker去处理,类似于DOM中的requestAnimationFrame,这也是为什么前面我们需要把子弹生成和移动子弹的逻辑分离开 为了能够控制飞机开炮的频率,我们可以给飞机添加一个属性fireFreq

+ interface PlaneOptions {
+   bulletMoveSpeed: number;
+   /** 开炮的频率 每 fireFreq 毫秒就开 1 炮 */
+   fireFreq: number;
+ }

export class Plane {
  x: number;
  y: number;
  speed: number;
  bullets: Bullet[] = [];
  bulletMoveSpeed: number;
+ fireFreq: number;

  constructor(x: number, y: number, speed: number, options?: PlaneOptions) {
    this.x = x;
    this.y = y;
    this.speed = speed;
    this.bulletMoveSpeed = options?.bulletMoveSpeed ?? 5;
+   this.fireFreq = options?.fireFreq ?? 300
  }
}

默认让它为每300ms开一炮,为了能够利用上fireFreq这个属性,我们应当在飞机对象内部维护开炮的逻辑,实现一个startFire的方法,开启一个定时器,不断重复开炮 如果飞机迟到增强道具,可能会让发射子弹的频率变快,这时候就需要修改fireFreq属性,然后重新开启计时器,所以我们还需要实现一个stopFire方法 这样一来当需要修改开炮频率的时候,先修改开炮频率属性,然后调用stopFire将定时器清除,再调用startFire使用新的频率去开炮 先编写对应的单元测试

1. 单元测试 -- 开始和停止开炮

it('startFire/stopFire', () => {
  const plane = createPlane();
  // 测试发射 testCount 次子弹后 飞机的子弹数
  const testCount = 10;

  // 启用模拟计时器
  vi.useFakeTimers();

  // 让飞机一直开炮
  plane.startFire();

  // 比如飞机开炮频率是每 300ms 开一炮 那么过了 10 * 300 === 3000ms 后,子弹数应为 10 个
  setTimeout(() => {
    // 停止开炮开始检验子弹数
    plane.stopFire();

    // 计时器结束后需要调用它模拟真实的计时器停止行为
    vi.useRealTimers();

    // 断言判断子弹数
    expect(plane.bullets.length).toBe(testCount);
  }, plane.fireFreq * testCount);

  // 调用每个被创建的计时器
  vi.runAllTimers();
});

然后去实现这两个方法 startFire

private fireTimer?: number;

constructor(x: number, y: number, options?: PlaneOptions) {
  this.x = x;
  this.y = y;
  this.speed = options?.speed ?? 10;
  this.bulletMoveSpeed = options?.bulletMoveSpeed ?? 5;
  this.fireFreq = options?.fireFreq ?? 300;
}

/**
 * @description 开启发射子弹
 */
startFire() {
  this.fireTimer = setInterval(() => {
    this.fire();
  }, this.fireFreq) as unknown as number;
}/**
 * @description 开启发射子弹
 */
startFire() {
  setInterval(() => {
    this.fire();
  }, this.fireFreq)
}

stopFire

/**
 * @description 停止发射子弹
 */
stopFire() {
  clearInterval(this.fireTimer);
}

image.png 测试通过,现在可以去对接ui逻辑了


9.5 ui 逻辑 -- 飞机不停地开炮

前面说了,我们应当在pixi的帧循环中调用飞机对象的moveBullets方法去渲染子弹移动的动画,而开炮的逻辑则应当在其他地方调用,为了能够在vuetemplate中循环飞机的子弹渲染出来,我们需要让飞机对象是一个响应式的,那么我们可以先实现一个initGame函数,创建飞机对象返回出去

// src/pixi-game/app/index.ts
const initPlane = () => {
  const { x, y } = getElementRelativePositionInContainer(
    APP_WIDTH,
    APP_HEIGHT,
    PLANE_WIDTH,
    PLANE_HEIGHT,
    0.5,
    0.8
  );
  const plane = createPlane(x, y);

  return plane;
};

const initGame = () => {
  // 初始化飞机的渲染逻辑
  const plane = initPlane();

  return {
    plane,
  };
};

vue单文件中调用这个函数就可以得到一个普通的飞机对象,为了能够让子弹数更新的时候,template能够渲染出来,我们需要将飞机对象变成响应式的

<script setup lang="ts">
const { plane: rawPlane } = initGame();
const plane = reactive(rawPlane);
</script>

现在我们有飞机对象了,并且它是响应式的,于是我们就可以在模板中渲染它和它的子弹了

<template>
  <Container>
    <Plane :plane="plane" />
    <Bullet v-for="bullet in plane.bullets" :key="bullet.id" :bullet="bullet" />
  </Container>
</template>

但是还有一个问题,我们并没有调用飞机的startFire方法,所以不会有子弹产生,也没有调用moveBullets方法,所以即便有子弹也不会动 这时候就需要再实现一个startPlane的函数,接收一个飞机对象,调用它的startFire开始开炮,并注册pixi帧循环回调

// src/pixi-game/app/index.ts

/**
 * @description 控制初始化飞机后飞机的行为
 * @param plane 响应式飞机对象
 */
const startPlane = (plane: Plane) => {
  plane.startFire();

  pixiApp.ticker.add(() => {
    plane.moveBullets();
  });
};

然后将响应式对象plane传入即可

<script setup lang="ts">
const { plane: rawPlane } = initGame();
const plane = reactive(rawPlane);

startPlane(plane);
</script>

一定要将响应式对象传入,在响应式对象出现后再调用才行,如果是响应式对象出现之前调用了startFire,会导致一直都是在给原始对象的bullets属性添加子弹,但是并不会影响到代理对象的bullets属性,从而导致子弹渲染不出来的bug 还需要将飞机对象作为Plane.vue组件的prop传入,让我们的键盘事件能够影响到飞机,从而影响到飞机发射子弹时,子弹的初始位置

<script setup lang="ts">
import playerPlaneImg from '@/assets/plane/player-plane.png';
import { onMounted, onUnmounted, PropType } from 'vue';
import { Plane } from '@/pixi-game';

const { plane } = defineProps({
  planeImg: {
    type: String,
    default: playerPlaneImg,
  },
+ plane: {
+   type: Object as PropType<Plane>,
+   required: true,
+ },
});
</script>

飞机持续开炮.gif 现在飞机就可以一直开炮了,还可以测试一下停止开炮的ui逻辑 我们可以在startPlane中设定 3 秒后停止开炮,并且修改开炮的频率后再重新开炮,模拟一下吃到增强道具后的效果

/**
 * @description 控制初始化飞机后飞机的行为
 * @param plane 响应式飞机对象
 */
const startPlane = (plane: Plane) => {
  plane.startFire();

+ setTimeout(() => {
+   plane.stopFire();
+   plane.fireFreq = 100;
+   plane.startFire();
+ }, 3000);

  pixiApp.ticker.add(() => {
    plane.moveBullets();
  });
};

飞机吃到增强道具.gif


10. 实现战斗逻辑

10.1 生成敌军飞机

首先思考一下敌军飞机有什么特性:

  1. 坐标
  2. 宽度和高度
  3. 移动

这里我们为了简单起见,并不考虑实现敌军飞机发射子弹的逻辑,并且飞机只会在垂直方向往y轴正方向移动,因为本项目的重点是运用TDD思想进行项目开发,具体游戏细节不用太在意 宽度和高度是为了和子弹的碰撞进行检测,稍后还需要为子弹也添加宽度和高度的属性并实现相关逻辑

10.1.1 敌军飞机类

1. 单元测试 -- 敌军飞机移动

那么我们就先去实现敌军飞机类,首先编写单元测试,描述敌军飞机的行为

describe('EnemyPlane', () => {
  it('move', () => {
    // create a simple enemyPlane
    const enemyPlane = new EnemyPlane(50, 0, {
      height: 100,
      width: 100,
      speed: 5,
    });

    enemyPlane.move();

    expect(enemyPlane.y).toBe(5);
  });
});

首先是最基本的移动逻辑,和前面差不多,就不多赘述了,接下来就根据测试去完成具体实现

2. 实现敌军飞机移动

// 使用默认的敌军飞机图片宽高
export const ENEMY_PLANE_WIDTH = 133;
export const ENEMY_PLANE_HEIGHT = 93;

export interface EnemyPlaneOptions {
  speed: number;
  width: number;
  height: number;
}

export class EnemyPlane {
  id: string;
  x: number;
  y: number;
  width: number;
  height: number;
  speed: number;

  constructor(x: number, y: number, options?: EnemyPlaneOptions) {
    this.id = uuid4();
    this.x = x;
    this.y = y;
    this.width = options?.width ?? ENEMY_PLANE_WIDTH;
    this.height = options?.height ?? ENEMY_PLANE_HEIGHT;
    this.speed = options?.speed ?? 5;
  }

  move() {
    this.y += this.speed;
  }
}

10.1.2 渲染敌军飞机

首先测试一下能否正常渲染敌军飞机,首先把基本的ui逻辑写好

<script setup lang="ts">
import { EnemyPlane } from '@/pixi-game/plane/EnemyPlane';
import enemyPlaneImg from '@/assets/plane/enemy-plane.png';

defineProps<{ enemyPlane: EnemyPlane }>();
</script>

<template>
  <Container>
    <Sprite :texture="enemyPlaneImg" :x="enemyPlane.x" :y="enemyPlane.y" />
  </Container>
</template>

App.vue中直接调用一下测试一下

<script setup lang="ts">
import { createEnemeyPlane } from '@/pixi-game';
const APP_WIDTH = document.documentElement.clientWidth;
const APP_HEIGHT = document.documentElement.clientHeight;

const enemyPlane = createEnemeyPlane(Math.floor(APP_WIDTH / 2), 0);
</script>

<template>
  <Container>
    <EnemyPlane :enemy-plane="enemyPlane" />
  </Container>
</template>

image.png 已经可以将敌军飞机渲染出来了,但是敌军飞机肯定是不止一架的,我们应当在App.vue中维护一个敌军飞机的数组,遍历渲染出所有的敌军飞机 还需要在pixi的帧循环中遍历所有的敌军飞机,在每一帧循环中都要调用敌军飞机的move方法让它们动起来


10.2 在 initGame 中批量创建敌军飞机

首先封装一个initEnemyPlane函数,能够帮助我们批量创建敌军飞机,并且飞机的初始水平坐标是随机的

/**
 * @description 初始化创建敌军飞机
 * @param count 创建几架敌军飞机
 */
const initEnemyPlane = (count = 3, options?: EnemyPlaneOptions) => {
  const enemyPlanes: EnemyPlane[] = [];

  for (let i = 0; i < count; i++) {
    enemyPlanes.push(createEnemeyPlane(Math.random() * APP_WIDTH, 0, options));
  }

  return enemyPlanes;
};

然后在initGame中调用得到敌军飞机数组后返回出去

const initGame = () => {
  // 初始化飞机的渲染逻辑
  const plane = initPlane();

  // 初始化创建 3 架敌军飞机
  const enemyPlanes = initEnemyPlane();

  return {
    plane,
    enemyPlanes,
  };
};

10.3 帧循环中让敌军飞机移动

涉及到动画的逻辑都应当放到帧循环中处理,相关逻辑我们参照我方飞机那样封装到一个startEnemyPlane函数中

/**
 * @description 敌军飞机动画相关的逻辑
 * @param enemyPlanes 敌军飞机数组
 */
const startEnemyPlane = (enemyPlanes: EnemyPlane[]) => {
  // 遍历所有敌军飞机让它们移动
  pixiApp.ticker.add(() => {
    enemyPlanes.forEach((enemyPlane) => {
      enemyPlane.move();
    });
  });
};

现在就可以在vue文件中调用initGame获取到初始的敌军飞机,并调用startEnemyPlane让飞机移动了

<script setup lang="ts">
import { initGame, startEnemyPlane } from '@/pixi-game';

const { enemyPlanes: rawEnemyPlanes } = initGame();
const enemyPlanes = reactive(rawEnemyPlanes);

startEnemyPlane(enemyPlanes);
</script>

<template>
  <Container>
    <EnemyPlane
      v-for="enemyPlane in enemyPlanes"
      :key="enemyPlane.id"
      :enemy-plane="enemyPlane"
    />
  </Container>
</template>

随机生成敌军飞机.gif 可以看到每次刷新生成的飞机的位置都不一样,一局游戏不可能就三架敌军飞机,我们应当用一个定时器一直不断创建敌军飞机,这个也可以在startEnemyPlane中实现

/**
 * @description 敌军飞机相关的逻辑
 * @param enemyPlanes 敌军飞机数组
 */
const startEnemyPlane = (enemyPlanes: EnemyPlane[]) => {
  setInterval(() => {
    // 每次随机生成不超过五架飞机
    const newEnemyPlanes = initEnemyPlane(Math.floor(Math.random() * 5));
    enemyPlanes.push(...newEnemyPlanes);
  }, 2000);

  // 遍历所有敌军飞机让它们移动
  pixiApp.ticker.add(() => {
    enemyPlanes.forEach((enemyPlane) => {
      enemyPlane.move();
    });
  });
};

10.4 业务逻辑 -- 子弹和敌机碰撞的逻辑

要检测子弹和敌机是否碰撞,首先我们需要有两者的宽度和高度,然后计算出它们的上下左右的边界,检测边界是否相交来判断是否有碰撞,如果有碰撞,就让子弹和敌机都消失 实现敌军飞机的时候已经添加了宽度和高度了,但是子弹还没有,现在我们给子弹扩展宽度和高度

10.4.1 给子弹扩展宽度和高度属性

import { v4 as uuid4 } from 'uuid';

export const BULLET_WIDTH = 30;
export const BULLET_HEIGHT = 72;

+ export interface BulletOptions {
+   width?: number;
+   height?: number;
+   speed?: number;
+ }

export class Bullet {
  id: string;
  x: number;
  y: number;
+ width: number;
+ height: number;
  speed: number;

- constructor(x: number, y: number, speed: number) {
+ constructor(x: number, y: number, options?: BulletOptions) {
    this.id = uuid4();
    this.x = x;
    this.y = y;
+   this.width = options?.width ?? BULLET_WIDTH;
+   this.height = options?.height ?? BULLET_HEIGHT;
- 	this.speed = speed;
+   this.speed = options?.speed ?? 10;
  }

  move() {
    this.y -= this.speed;
  }
}

由于属性变得越来越多,就改成了使用options的方式将可选属性放在这里面进行配置,用户没有传入的时候这些可选属性提供一个默认值 由于修改了构造函数,所以别忘了还要把之前使用到了Bullet构造函数的地方也进行修改


10.4.2 分析子弹和敌机何时会碰撞

首先思考一下什么时候会碰撞,无非就是下面这几种情况:

  1. 子弹的上边界y坐标比敌机下边界的y坐标小

image.png

  1. 子弹的下边界y坐标比敌军的上边界的y坐标大

image.png 这种情况虽然不可能发生,因为子弹在第一种情况中发生的时候其实子弹和敌机都应该消失了,但是我们现在思考的只是子弹和敌机碰撞这一小功能的逻辑,不应该结合到游戏的逻辑去考虑,所以要把所有情况考虑到位

  1. 子弹的左边界的x坐标比敌机的右边界x坐标小

image.png

  1. 子弹右边界的x坐标比敌机左边界的x坐标大

image.png 我们只需要考虑这四种情况就可以了,至于四个角的相交碰撞其实也包括在内了 其实这个逻辑不仅仅适用于子弹和敌机的碰撞,也适用于我方飞机和敌军飞机的碰撞,敌方飞机的子弹和我方飞机的碰撞等,所以可以抽象成是两个矩形之间的碰撞检测,这里我只实现我方飞机的子弹和敌军飞机的碰撞逻辑,剩下的逻辑感兴趣的读者可以自行实现,原理都是一样的


10.4.3 单元测试 -- 子弹和敌机碰撞

为了方便描述,站在子弹的角度,将上述四种情况分别描述为上碰、下碰、左碰、右碰 这是为了方便单元测试中编写描述名称 创建src/pixi-game/logic/collision-detect/index.test.ts 上碰

describe('子弹与敌机碰撞 -- 上碰', () => {
  // 创建子弹
  const bullet = new Bullet(10, 40, { width: 30, height: 30, speed: 10 });
  // 创建敌机
  const enemyPlane = new EnemyPlane(0, 0, {
    width: 50,
    height: 50,
    speed: 5,
  });

  it('相交碰撞', () => {
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });

  it('不相交不碰撞', () => {
    bullet.y = 60;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(false);
  });

  it('边界相交碰撞', () => {
    // 边界相交 -- 发生碰撞
    bullet.y = 50;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });
});

下碰

describe('子弹与敌机碰撞 -- 下碰', () => {
  // 创建子弹
  const bullet = new Bullet(10, 0, { width: 30, height: 30, speed: 10 });
  // 创建敌机
  const enemyPlane = new EnemyPlane(0, 20, {
    width: 50,
    height: 50,
    speed: 5,
  });

  it('相交碰撞', () => {
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });

  it('不相交不碰撞', () => {
    enemyPlane.y = 40;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(false);
  });

  it('边界相交碰撞', () => {
    enemyPlane.y = 30;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });
});

左碰

describe('子弹与敌机碰撞 -- 左碰', () => {
  // 创建子弹
  const bullet = new Bullet(40, 10, { width: 30, height: 30, speed: 10 });
  // 创建敌机
  const enemyPlane = new EnemyPlane(0, 0, {
    width: 50,
    height: 50,
    speed: 5,
  });

  it('相交碰撞', () => {
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });

  it('不相交不碰撞', () => {
    bullet.x = 60;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(false);
  });

  it('边界相交碰撞', () => {
    bullet.x = 50;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });
});

右碰

describe('子弹与敌机碰撞 -- 右碰', () => {
  // 创建子弹
  const bullet = new Bullet(0, 10, { width: 30, height: 30, speed: 10 });
  // 创建敌机
  const enemyPlane = new EnemyPlane(20, 0, {
    width: 50,
    height: 50,
    speed: 5,
  });

  it('相交碰撞', () => {
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });

  it('不相交不碰撞', () => {
    enemyPlane.x = 40;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(false);
  });

  it('边界相交碰撞', () => {
    enemyPlane.x = 30;
    const isCollided = collisionDetect(bullet, enemyPlane);
    expect(isCollided).toBe(true);
  });
});

10.4.4 实现

创建src/logic/collision-detect/collisionDetect.ts

interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

const getBoundary = (rect: Rect) => {
  const top = rect.y;
  const bottom = rect.y + rect.height;
  const left = rect.x;
  const right = rect.x + rect.width;

  return {
    top,
    bottom,
    left,
    right,
  };
};

export const collisionDetect = (rect1: Rect, rect2: Rect) => {
  // 计算上下左右边界
  const {
    top: rect1Top,
    bottom: rect1Bottom,
    left: rect1Left,
    right: rect1Right,
  } = getBoundary(rect1);

  const {
    top: rect2Top,
    bottom: rect2Bottom,
    left: rect2Left,
    right: rect2Right,
  } = getBoundary(rect2);

  let topCollided = false,
    bottomCollided = false,
    leftCollided = false,
    rightCollided = false,
    horizontalWrapped = false,
    verticalWrapped = false;

  // 检测上碰
  if (rect1Top <= rect2Bottom && rect1Bottom > rect2Bottom) topCollided = true;
  // 检测下碰
  if (rect1Bottom >= rect2Top && rect1Top < rect2Top) bottomCollided = true;
  // 检测左碰
  if (rect1Left <= rect2Right && rect1Right > rect2Right) leftCollided = true;
  // 检测右碰
  if (rect1Right >= rect2Left && rect1Left < rect2Left) rightCollided = true;

  // 检测是否存在 rect1 在水平方向上包裹住 rect2 或 rect2 包裹住 rect1
  if (
    (rect1Left >= rect2Left && rect1Right <= rect2Right) ||
    (rect1Left <= rect2Left && rect1Right >= rect2Right)
  ) {
    horizontalWrapped = true;
  }

  // 检测是否存在 rect1 在垂直方向上包裹住 rect2 或 rect2 包裹住 rect1
  if (
    (rect1Top >= rect2Top && rect1Bottom <= rect2Bottom) ||
    (rect1Top <= rect2Top && rect1Bottom >= rect2Bottom)
  ) {
    verticalWrapped = true;
  }

  // 上/下 碰撞时 还需要在水平方向上有包裹才算碰撞
  if ((topCollided || bottomCollided) && horizontalWrapped) return true;

  // 左/右 碰撞时 还需要在垂直方向上有包裹才算碰撞
  if ((leftCollided || rightCollided) && verticalWrapped) return true;

  return false;
};

这里我将碰撞检测的对象抽象成了对矩形的检测,因为无论是飞机还是子弹其实都可以看成一个矩形,有自己的坐标和宽高 具体的检测逻辑代码中已有注释写清楚了,不理解的话自己画画图就知道了 image.png 单元测试通过,碰撞逻辑算是实现了,接下来就应该去对接ui逻辑了


10.5 ui 逻辑 -- 子弹和敌军飞机碰撞

应当在每次帧循环的时候遍历所有的我方飞机子弹和所有的敌军飞机,调用collisionDetect函数,遇到碰撞上的就把子弹和敌军飞机删除 实现一个startCollisionDetect函数,调用后会在帧循环中添加碰撞检测功能

/**
 * @description 在帧循环中检测我方飞机的子弹是否和敌军飞机碰撞
 * @param plane 我方飞机
 * @param enemyPlanes 敌军飞机
 */
const startCollisionDetect = (plane: Plane, enemyPlanes: EnemyPlane[]) => {
  pixiApp.ticker.add(() => {
    plane.bullets.forEach((bullet, bIndex) => {
      enemyPlanes.forEach((enemyPlane, eIndex) => {
        const isCollided = collisionDetect(bullet, enemyPlane);
        if (isCollided) {
          // 发生碰撞则将子弹和飞机删除
          plane.bullets.splice(bIndex, 1);
          enemyPlanes.splice(eIndex, 1);
        }
      });
    });
  });
};

然后在App.vue中调用该函数即可

<script setup lang="ts">
import Plane from '@/components/Plane.vue';
import Bullet from '@/components/Bullet.vue';
import { provide, reactive } from 'vue';
import {
  initGame,
  startPlane,
  startEnemyPlane,
  startCollisionDetect,
} from '@/pixi-game';
import EnemyPlane from './components/EnemyPlane.vue';

const APP_WIDTH = document.documentElement.clientWidth;
const APP_HEIGHT = document.documentElement.clientHeight;

provide('APP_WIDTH', APP_WIDTH);
provide('APP_HEIGHT', APP_HEIGHT);

const { plane: rawPlane, enemyPlanes: rawEnemyPlanes } = initGame();
const plane = reactive(rawPlane);
const enemyPlanes = reactive(rawEnemyPlanes);

startPlane(plane);
startEnemyPlane(enemyPlanes);
startCollisionDetect(plane, enemyPlanes);
</script>

<template>
  <Container>
    <Plane :plane="plane" />
    <Bullet v-for="bullet in plane.bullets" :key="bullet.id" :bullet="bullet" />
    <EnemyPlane
      v-for="enemyPlane in enemyPlanes"
      :key="enemyPlane.id"
      :enemy-plane="enemyPlane"
    />
  </Container>
</template>

最终效果如下 子弹打中敌机后子弹和敌机都消失.gif


总结

至此,我们的飞机大战项目就完成啦! 当然,这个飞机大战游戏比较简单,没有复杂的战斗逻辑和游戏功能,不过可以留给热爱学习的读者们去完善它哈哈哈 通过本项目,我们可以学习到ui逻辑和业务逻辑的分离带来的好处:

  1. **ui**逻辑和业务逻辑解耦。可以专注实现业务逻辑,实现完成后只需要简单地和ui逻辑对接即可
  2. 业务逻辑可以编写单独的单元测试去检验逻辑是否正确。这是ui逻辑和业务逻辑耦合时无法做到的,两者耦合的时候,我们的开发体验是写一个功能,就打开浏览器通过人工检查逻辑是否正确,而解耦了之后,就可以简单地执行一条命令,运行单元测试来查看逻辑是否正确(前提是单元测试要具有测试意义)
  3. 增加新功能时,可以很方便地检查是否有影响到之前实现的业务逻辑。如果是耦合的时候,我们添加了一个新功能,又担心是否有影响到之前已实现的功能时,就需要手动运行项目,打开浏览器用人工检查的方式看看是否有问题,有时候出问题了又得打开控制台看看报错信息等等,费时费力,而解耦之后就能够通过单元测试检查之前的测试是否有问题,节省了不少人工审查的时间,毕竟在一个项目中时间才是最宝贵的!
  4. **ui**逻辑和业务逻辑分离的核心是**vue**的响应式系统。通过响应式系统,我们能够做到初始化游戏需要的对象,并将它们转为响应式对象后传给业务逻辑处理函数,这样业务逻辑函数执行的时候就会触发响应式对象的getter进行依赖收集,作为依赖被收集起来,当业务逻辑函数更改了响应式对象的数据时,就会触发依赖,让视图层的内容发生变化(template被编译成生成vnodeh函数,h函数也是依赖,所以本质上是业务逻辑函数执行时,h函数被重新执行),从而实现了业务逻辑和ui逻辑的分离
  5. 要享受**vue**的运行时特性得实现自定义渲染器。因为我们的游戏是基于pixi.js,本质上是在canvas中渲染的,要使用到canvasAPI,但是vue默认只有runtime-dom这一渲染器,只能渲染DOM环境中的内容,如果我们不实现一个canvas环境下的渲染器,将会导致无法使用vueruntime-core运行时特性,也就是说无法在template中使用诸如v-forv-on等特性,自然也无法使用响应式系统去更新视图层的内容,而通过自定义渲染器实现一个canvas环境下的渲染器后就可以解决这一问题,享受到runtime-core的特性,从而实现ui逻辑和业务逻辑的分离

掌握了ui逻辑和业务逻辑分离的思想后,想要给飞机大战游戏扩展更多功能也是很容易的了,比如我需要再实现一个我方飞机和敌方飞机碰撞时,游戏结束的功能 那么可以先编写单元测试,描述一下我方飞机和敌方飞机碰撞时,调用一个游戏结束的逻辑,如果该单元测试通过了,就可以去实现ui逻辑,先将业务逻辑封装到startXxx这样的函数中,然后直接在视图层调用即可,不需要做什么其他的操作,感兴趣的读者可以自行把源码拉下来尝试,源码链接已放在文章开头描述中了