Skip to content

Latest commit

 

History

History
2309 lines (1674 loc) · 77.9 KB

File metadata and controls

2309 lines (1674 loc) · 77.9 KB

三、项目 2——城堡决斗浏览器游戏

在本章中,我们将创建一个完全不同的应用程序——浏览器游戏。它将由两名玩家组成,每个人都指挥一座令人印象深刻的城堡,并试图通过使用行动卡将对手的食物或伤害等级降至零来摧毁另一座城堡。

在本项目和接下来的项目中,我们将把应用程序拆分为可重用组件。这是框架的核心,其所有 API 都是围绕这一理念构建的。我们将了解如何定义和使用组件,以及如何使它们相互通信。结果将是我们的应用程序有一个更好的结构。

游戏规则

以下是我们将在游戏中实施的规则:

  • 两名球员轮流比赛
  • 每位玩家以 10 张健康卡、10 份食物和一张 5 张牌开始游戏
  • 玩家的健康和食物不能超过 10 种
  • 当玩家的食物或健康值为零时,玩家将输球
  • 两名球员在平局中都可能输
  • 在一个玩家的回合中,每个玩家唯一可能的行动是玩一张牌,然后将其放入弃牌堆
  • 每位玩家在回合开始时从抽牌堆中抽一张牌(第一回合除外)
  • 多亏了前面的两条规则,每个玩家在开始他们的回合时手里正好有五张牌
  • 如果玩家在抽牌时抽牌堆为空,则抽牌堆将重新填充弃牌堆
  • 牌可以改变玩家或其对手的健康和食物
  • 有些牌还可以让玩家跳过回合

游戏的玩法是基于这样一个事实:玩家每回合必须玩一张且只能玩一张牌,而且大多数牌都会对他们产生负面影响(最常见的一张牌是失去食物)。你必须在比赛前考虑你的策略。

该应用程序将由两层组成——绘制游戏对象(如风景和城堡)的世界和用户界面。

世界将有两座相互面对的城堡,一个地面,一个天空,有多个活跃的云;每个城堡都有两个横幅——绿色的一个是玩家食物,红色的一个是玩家健康——带有一个小气泡,显示剩余食物或健康的数量:

对于 UI,顶部将有一个酒吧,有一个转身计数器和两名玩家的名字。在屏幕底部,手牌将显示当前玩家的牌。

除此之外,还会定期显示一些覆盖图,以隐藏手。其中一个将显示下一个玩家的姓名:

随后将出现另一张覆盖图,显示对手上一回合所使用的牌。这将允许在同一屏幕上玩游戏(例如,平板电脑)。

第三个叠加只有在游戏结束时才会显示,显示玩家是赢了还是输了。点击此覆盖将重新加载页面,允许玩家开始新游戏。

建立项目

下载chapter 2文件并将项目设置解压缩到一个空文件夹中。您应该具有以下内容:

  • index.html:网页
  • style.css:CSS 文件
  • svg:包含游戏的所有 SVG 图像
  • cards.js:所有卡片数据准备好使用
  • state.js:我们将在哪里整合游戏的主要数据属性
  • utils.js:我们将在那里编写有用的函数
  • banner-template.svg:我们稍后会使用此文件的内容

我们将从主 JavaScript 文件开始——创建一个名为main.js的新文件。

打开index.html文件,在state.js文件之后添加引用新文件的新脚本标记:

<!-- Scripts -->
<script src="utils.js"></script>
<script src="cards.js"></script>
<script src="state.js"></script>
<script src="main.js"></script>

让我们在main.js文件中创建我们应用程序的主实例:

new Vue({
  name: 'game',
  el: '#app',
})

我们现在准备出发了!

暴风雨前的平静

在本节中,我们将介绍一些新的 Vue 功能,这些功能将帮助我们构建游戏,例如组件、道具和事件发射!

模板选项

如果您查看index.html文件,您将看到#app元素已经存在并且是空的。事实上,我们不会在里面写任何东西。相反,我们将直接在定义对象上使用模板选项。让我们用一个哑模板来试试:

new Vue({
  name: 'game',
  el: '#app',

  template: `<div id="#app">
    Hello world!
  </div>`,
})

这里,我们使用了新的 JavaScript 字符串,带有```js 字符(后引号)。除其他外,它允许我们编写跨多行的文本,而无需编写冗长的字符串连接。

现在如果你打开应用程序,你会看到显示的'Hello world!'文本。正如您所猜测的,我们不会在[T1]元素中继续内联模板。

应用程序状态

如前所述,state.js文件将帮助我们将应用程序的主要数据整合到一个地方。这样,编写游戏逻辑函数就更容易了,而不会用很多方法污染定义对象。

  1. state.js文件声明了一个状态变量,我们将其用作应用程序的数据。我们可以直接将其用作数据选项,如下所示:
      new Vue({
        // …
        data: state,
      })
```js

现在,如果打开 devtools,您将看到 state 对象中已声明的唯一数据属性:

![](img/06ba9d2b-5ad4-4ed0-ae51-c1774600dde0.png)

世界比率是一个数字,表示我们应该缩放游戏对象以适应窗口的大小。例如,`.6`意味着世界应该以其原始大小的 60%进行缩放。使用`utils.js`文件中的`getWorldRatio`函数计算。

不过,有一件事遗漏了——调整窗口大小时不会重新计算它。这是我们必须自己实施的事情。在 Vue 实例构造函数之后,将事件侦听器添加到窗口对象,以在调整其大小时进行检测。

2.  在处理程序内部,更新状态的`worldRatio`数据属性。您也可以在模板中显示`worldRatio`:

  new Vue({
    name: 'game',
    el: '#app',

    data: state,

    template: `<div id="#app">
      {{ worldRatio }}
    </div>`,
  })

  // Window resize handling
  window.addEventListener('resize', () => {
    state.worldRatio = getWorldRatio()
  })
尝试水平调整浏览器窗口的大小——`worldRatio`数据属性在 Vue 应用程序中更新。

*但是等等!我们正在修改状态对象,而不是 Vue 实例。。。*

你是对的!但是,我们已经使用`state`对象设置了 Vue 实例`data`属性。这意味着 Vue 已在其上设置了反应性,我们可以更改其属性以更新我们的应用程序,稍后我们将看到这一点。

3.  要确保`state`是应用程序的反应数据,请尝试比较实例数据对象和全局状态对象:
  new Vue({
    // ...
    mounted () {
      console.log(this.$data === state)
    },
  })
这些对象与我们使用“数据”选项设置的对象相同。所以当你这样做的时候:

this.worldRatio = 42

您也在这样做:

this.$data.worldRatio = 42

事实上,这与下文相同:

state.worldRatio = 42

这在使用状态对象更新游戏数据的游戏功能中非常有用。

# 万能部件

组件是构成我们应用程序的构建块——它是 Vue 应用程序的核心概念。它们是视图的一小部分,应该相对较小,可重用,并且尽可能自给自足——用组件构建应用程序将有助于维护和发展它,特别是当它变得较大时。事实上,这正在成为以高效和可管理的方式创建大型 web 应用程序的标准方法。

具体而言,你的应用程序将是一棵由较小组件组成的巨树:

![](img/b20410bf-c9fc-47ea-8474-f65537e325f3.png)

例如,您的应用程序可能有一个表单组件,其中可能包含多个输入组件和按钮组件。每一个都是 UI 的一个非常特定的部分,它们可以在整个应用程序中重用。由于范围很小,它们很容易理解和推理,因此更容易维护(解决问题)或发展。

# 构建用户界面

我们将创建的第一个组件是 UI 层的一部分。将有一个顶部栏,上面有玩家的名字和一个转向计数器,牌上有他们的名字和描述,手上有当前玩家的牌,以及三个叠加。

# 我们的第一个组件-顶部栏

顶部的酒吧,我们的第一个组件,将被放置在页面的顶部,并将显示两名球员的名字和中间的转弯柜台。它还会显示一个箭头,指向当前轮到的玩家的名字。

它将如下所示:

![](img/8739ced8-2ec7-4a72-af0f-fa01f814c080.png)

# 向状态添加一些游戏性数据

在创建组件之前,我们需要一些新的数据属性:

*   `turn`:当前匝数;从 1 点开始
*   `players`:玩家对象的数组
*   `currentPlayerIndex`:当前玩家在`players`数组中的索引

将它们添加到`state.js`文件中的状态:

// The consolidated state of our app var state = { // World worldRatio: getWorldRatio(), // Game turn: 1, players: [ { name: 'Anne of Cleves', }, { name: 'William the Bald', }, ], currentPlayerIndex: Math.round(Math.random()), }

`Math.round(Math.random())` will use `0` or `1` randomly to choose who goes first.

我们将使用这些属性在顶部栏中显示球员姓名和转身计数器。

# 使用和定义组件

我们将在新文件中编写 UI 组件:

1.  创建一个`components`文件夹,并在其中创建一个新的`ui.js`文件。将其包含在主`index.html`页面中,就在主脚本之前:
  <!-- Scripts -->
  <script src="utils.js"></script>
  <script src="cards.js"></script>
  <script src="state.js"></script>
  <script src="components/ui.js"></script>
  <script src="main.js"></script>
在这个文件中,我们将注册我们的组件,因此重要的是,主 Vue 实例是在之后而不是之前创建的。否则,我们会发现找不到组件的错误。

要注册一个组件,我们可以使用全局`Vue.component()`函数。它有两个论点;注册组件时使用的名称及其定义对象,该对象使用的选项与我们已知的 Vue 实例完全相同。

2.  让我们在`ui.js`文件中创建`top-bar`组件:

Vue.component('top-bar', { template: <div class="top-bar"> Top bar </div>, })

现在,我们可以在模板中使用`top-bar`组件,就像其他 HTML 标记一样,例如`<top-bar>`

3.  在主模板中,添加一个新的`top-bar`标记:
  new Vue({
    // ...
    template: `<div id="#app">
      <top-bar/>
    </div>`,
  })
这个模板将创建一个新的`top-bar`组件,并使用我们刚刚定义的定义对象在`#app`元素中呈现它。如果打开 devtools,您将看到两个条目:

![](img/8ea8c881-2a6b-4eb0-be9e-4c711323bb9c.png)

每一个都是一个 Vue 实例——Vue 实际上使用我们为顶栏组件提供的定义创建了第二个实例。

# 利用道具进行亲子沟通

正如我们在万能组件部分中看到的,我们基于组件的应用程序将有一个组件树,我们需要它们相互通信。现在,我们将只关注降序、家长对孩子的沟通。这是通过“道具”实现的。

我们的`top-bar`组件需要知道玩家是谁,当前正在玩哪一个,以及当前的回合数是多少。所以,我们需要三个道具--`players``currentPlayerIndex``turn`

要向组件定义添加道具,请使用`props`选项。现在,我们只列出道具的名称。但是,您应该知道,有一种更详细的对象表示法,我们将在下一章中介绍。

1.  让我们将道具添加到我们的组件中:
  Vue.component('top-bar', {
    // ...
    props: ['players', 'currentPlayerIndex', 'turn'],
  })
在父组件(根应用程序)中,我们可以使用与 HTML 属性完全相同的方式设置 props 值。

2.  继续并使用`v-bind`速记将道具值与主模板中的应用程序数据连接起来:
  <top-bar :turn="turn" :current-player-index="currentPlayerIndex"         
  :players="players" />
Note that since HTML is case-insensitive and by convention, it is recommended to use the kebab-case (with dashes) names of our props, and the camel-case names in the JavaScript code.

现在,我们可以像使用数据属性一样使用`top-bar`组件中的道具。例如,您可以这样写:

Vue.component('top-bar', { // ... created () { console.log(this.players) }, })

这将在浏览器控制台中打印父组件(我们的应用程序)发送的`players`数组。

# 模板中的道具

现在我们将使用我们在`top-bar`组件模板中创建的道具。

1.  更改`top-bar`模板,用`players`道具显示玩家姓名:
  template: `<div class="top-bar">
    <div class="player p0">{{ players[0].name }}</div>
    <div class="player p1">{{ players[1].name }}</div>
  </div>`,
正如您在前面的代码中所看到的,我们也在使用道具,就像在模板中使用属性一样。你应该会看到应用程序中显示的玩家姓名。

2.  继续使用`turn`道具在`players`之间转动计数器:
  template: `<div class="top-bar">
    <div class="player p0">{{ players[0].name }}</div>
    <div class="turn-counter">
    <div class="turn">Turn {{ turn }}</div>
    </div>
    <div class="player p1">{{ players[1].name }}</div>
    </div>`,
除了标签,我们还想显示一个面向当前玩家的大箭头,使其更加明显。

3.  `.turn-counter`元素中添加箭头图像,并使用[ 2 ](2.html)*Markdown 笔记本*中使用的`currentPlayerIndex`道具和`v-bind`速记添加动态类:
  template: `<div class="top-bar" :class="'player-' + 

currentPlayerIndex">

{{ players[0].name }}
Turn {{ turn }}
{{ players[1].name }}
`,

现在,该应用程序应该显示功能齐全的顶部栏,两名玩家、姓名以及他们之间的转向计数器。您可以通过在浏览器控制台中键入以下命令来测试 Vue 自动反应:

state.currentPlayerIndex = 1 state.currentPlayerIndex = 0

您应该看到箭头转向正确的玩家姓名,这一点会被强调:

![](img/a667ef7b-5325-4e1a-bf9f-4555b64d5b38.png)

# 展示卡片

所有卡片都在`cards.js`文件中声明的卡片定义对象中描述。您可以打开它,但不必修改其内容。每个卡定义都有以下字段:

*   `id`:每张卡唯一
*   `type`:更改颜色背景以帮助区分卡片
*   `title`:卡片的显示名称
*   `description`:解释卡片功能的 HTML 文本
*   `note`:可选的味道文本,也是 HTML 格式
*   `play`:打牌时调用的函数

我们需要一个新的组件来显示任何一张牌,无论是在玩家的手牌中还是在叠加牌中,它描述了对手上一回合所玩的牌。它将如下所示:

![](img/bdcb0ac6-585b-4c0f-a2e3-fdbe7c28385b.png)

1.  `components/ui.js`文件中,创建一个新的`card`组件:

Vue.component('card', { // Definition here })

2.  该组件将接收一个`def`道具,该道具将是我们上面描述的卡定义对象。使用`props`选项声明它,就像我们对`top-bar`组件所做的那样:
  Vue.component('card', {
    props: ['def'],
  })
3.  现在,我们可以添加模板了。从主`div`元素开始,使用`card`类:
  Vue.component('card', {
    template: `<div class="card">
    </div>`,
    props: ['def'],
  })
4.  要根据卡片类型更改背景颜色,请添加使用卡片对象的`type`属性的动态 CSS 类:
  <div class="card" :class="'type-' + def.type">
例如,如果卡片具有`'attack'`类型,则元素将获得`type-attack`类。然后,它将有一个红色的背景。

5.  现在,将卡片的标题添加到相应的类别中:
  <div class="card" :class="'type-' + def.type">
    <div class="title">{{ def.title }}</div>
  </div>
6.  添加分隔符图像,它将在卡片标题和描述之间显示一些行:
  <div class="title">{{ def.title }}</div>
  <img class="separator" src="svg/card-separator.svg" />
在图像之后,附加 description 元素。

Note that since the `description` property of the card object is an HTML-formatted text, we need to use the special `v-html` directive introduced in the [Chapter 2](2.html), *Markdown Notebook.*

7.  使用`v-html`指令显示说明:
  <div class="description"><div v-html="def.description"></div>             
  </div>
You may have noted that we added a nested `div` element, which will contain the description text. This is to center the text vertically using CSS flexbox.

8.  最后,添加卡片注释(也是 HTML 格式的文本)。请注意,有些卡片没有便笺,因此我们必须在此处使用[T0]指令:
  <div class="note" v-if="def.note"><div v-html="def.note"></div>        
  </div>
卡组件现在应如下所示:

Vue.component('card', { props: ['def'], template: `

{{ def.title }}

`, }) ```js

现在,我们可以在主应用程序组件中尝试我们的新卡组件。

  1. 按如下方式编辑主模板,并在顶栏后添加一个card组件:
      template: `<div id="#app">
        <top-bar :turn="turn" :current-player-             
         index="currentPlayerIndex" :players="players" />
        <card :def="testCard" />
      </div>`,
```js

10.  我们还需要定义一个临时计算属性:

computed: { testCard () { return cards.archers }, },

现在,您应该看到一张红色攻击卡,上面有标题、描述和味道文本:

![](img/6b8c4676-e72a-41bb-915f-24b2ed0c0023.png)

# 侦听组件上的本机事件

让我们尝试在卡上添加单击事件处理程序:

<card :def="testCard" @click="handlePlay" />

在主组件中使用哑方法:

methods: { handlePlay () { console.log('You played a card!') } }

如果在浏览器中进行测试,您可能会惊讶于它没有按预期工作。没有任何内容输出到控制台。。。

这是因为 Vue 有自己的组件事件系统,称为“自定义事件”,我们将在稍后了解。此系统与浏览器事件是分开的,因此在这里 Vue 需要一个自定义的`'click'`事件,而不是浏览器事件。因此,不调用`handler`方法。

为了避免这种情况,您应该在[T1]指令上使用[T0]修饰符,如下所示:

<card :def="testCard" @click.native="handlePlay" />

现在,当你点击卡片时,`handlePlay`方法被调用,正如预期的那样。

# 与自定义事件的子级到父级通信

以前,我们使用道具从父组件到其子组件进行通信。现在,我们想做相反的事情,从一个子组件到其父组件进行通信。对于我们的卡组件,我们希望告诉父组件,当玩家单击该卡时,该卡正由玩家播放。我们不能在这里使用道具,但我们可以使用自定义事件。在我们的组件中,我们可以通过`$emit`特殊方法发出父组件可以捕获的事件。它接受一个强制参数,即事件类型:

this.$emit('play')

我们可以通过`$on`特殊方法监听同一 Vue 实例中的自定义事件:

this.$on('play', () => { console.log('Caught a play event!') })

`$emit`方法还向父组件发送`'play'`事件。我们可以使用`v-on`指令在父组件模板中收听它,就像我们之前所做的那样:
```js

您也可以使用v-bind速记:

<card @play="handlePlay" />
```js

我们还可以添加任意数量的参数,这些参数将传递给处理程序方法:

this.$emit('play', 'orange', 42)

在这里,我们发出了一个带有以下两个参数的`'play'`事件--`'orange'``42`

在句柄中,我们可以通过参数获取它们,如下所示:

handlePlay (color, number) { console.log('handle play event', 'color=', color, 'number=', number) }

`color`参数将具有`'orange'`值,`number`参数将具有`42`值。

Like we saw in the preceding section, custom events are completely separate from the browser event system. The special methods--`$on` and `$emit`--are not aliases to the standard `addEventListener` and `dispatchEvent`. That explains why we need the `.native` modifier on components to listen to browser events such as `'click'`.

回到我们的卡组件,我们只需要发出一个非常简单的事件来告诉父组件正在玩卡:

1.  首先,添加将发出事件的方法:

methods: { play () { this.$emit('play') }, },

2.  我们希望在用户单击卡时调用此方法。只需聆听主卡`div`元素上的浏览器点击事件:
  <div class="card" :class="'type-' + def.type" @click="play">
3.  我们已经完成了卡组件。要测试这一点,请收听主组件模板中的`'play'`自定义事件:
  <card :def="testCard" @play="handlePlay" />
现在,只要发出`'play'`事件,就会调用`handlePlay`方法。

We could just have listened to a native click event instead, but it's in most cases a good idea to use custom events to communicate between components. For example, we could also emit the `'play'` event when the user uses another method, such as using the keyboard to select the card and pressing *Enter*; we won't implement that method in this book though.

# 

我们的下一个组件将是当前玩家的手牌,持有他们拥有的五张牌。它将通过 3D 转换设置动画,并且还将负责卡牌动画(当绘制卡牌时,以及在播放卡牌时)。

1.  `components/ui.js`文件中,添加一个具有`'hand'`ID 的组件注册和一个基本模板,其中包含两个`div`元素:

Vue.component('hand', { template: <div class="hand"> <div class="wrapper"> <!-- Cards --> </div> </div>, })

The wrapper element will help us position and animate the cards.

手中的每张牌都将由一个对象表示。目前,它将具有以下属性:

*   `id`:卡片定义唯一标识符
*   `def`:卡片定义对象

As a reminder, all the card definitions are declared in the `cards.js` file.

2.  我们的手牌组件将通过名为`cards`的新阵列道具接收代表玩家手牌的这些卡牌对象:
  Vue.component('hand', {
    // ...
    props: ['cards'],
  })
3.  我们现在可以使用`v-for`指令添加卡组件:
  <div class="wrapper">
    <card v-for="card of cards" :def="card.def" />
  </div>
4.  为了测试我们的手动组件,我们将在应用程序状态中创建一个名为`testHand`的临时属性(在`state.js`文件中):
  var state = {
    // ...
    testHand: [],
  }
5.  `main.js`文件的主组件中增加`createTestHand`方法:
  methods: {
    createTestHand () {
      const cards = []
      // Get the possible ids
      const ids = Object.keys(cards)

      // Draw 5 cards
      for (let i = 0; i < 5; i++) {
        cards.push(testDrawCard())
      }

      return cards
    },
  },
6.  为了测试手牌,我们还需要这个模拟随机抽牌的临时`testDrawCard`方法:
  methods: {
    // ...
    testDrawCard () {
      // Choose a card at random with the ids
      const ids = Object.keys(cards)
      const randomId = ids[Math.floor(Math.random() * ids.length)]
      // Return a new card with this definition
      return {
        // Unique id for the card
        uid: cardUid++,
        // Id of the definition
        id: randomId,
        // Definition object
        def: cards[randomId],
      }
    }
  }
7.  使用`created`生命周期挂钩初始化手:

created () { this.testHand = this.createTestHand() },

`cardUid` is a unique identifier on cards drawn by the players that will be useful to identify each of the cards in the hand, because many cards can share the exact same card definition, and we will need a way to differentiate them.

8.  在主模板中,添加手部组件:
  template: `<div id="#app">
    <top-bar :turn="turn" :current-player-           
     index="currentPlayerIndex" :players="players" />
    <hand :cards="testHand" />
  </div>`,
浏览器中的结果应如下所示:

![](img/928716a8-049d-4deb-a1f5-c3827a3f597f.png)

# 使用变换设置手的动画

在游戏中,当显示任何叠加时,手将被隐藏。为了使应用程序更漂亮,我们将在将手添加到 DOM 或从 DOM 中删除手时为其设置动画。为此,我们将使用 CSS 转换和一个强大的 Vue 工具——特殊的`<transition>`组件。当使用[T1][T2]指令添加或删除元素时,它将帮助我们处理 CSS 转换。

1.  首先,在`state.js`文件的 app 状态中添加一个新的`activeOverlay`数据属性:
  // The consolidated state of our app
  var state = {
    // UI
    activeOverlay: null,
    // ...
  }
2.  在主模板中,由于[T1]指令,仅当[T0]未定义时,我们才会显示手部组件:
  <hand :cards="testHand" v-if="!activeOverlay" />
3.  现在,如果在浏览器控制台中将[T0]更改为任何真实值,则手将消失:
  state.activeOverlay = 'player-turn'
4.  此外,如果将其设置回`null`,则手将再次显示:
  state.activeOverlay = null
5.  若要在`v-if``v-show`指令添加或删除组件时应用转换,请使用如下转换组件将其包围:
  <transition>
    <hand v-if="!activeOverlay" />
  </transition>
请注意,这也适用于 HTML 元素:

Title

```js

The <transition> special component will not appear in the DOM, like the <template> tag we used in Chapter 2, Markdown Notebook.

将元素添加到 DOM(进入阶段)时,转换组件将自动将以下 CSS 类应用于元素:

  • v-enter-active:在 enter 转换激活时应用该类。该类在将元素插入 DOM 之前添加,并在动画完成时删除。您应该在这个类中添加一些transitionCSS 属性,并定义它们的持续时间。
  • v-enter:元素的起始状态。该类在插入图元之前添加,在插入图元一帧后删除。例如,您可以在此类中将不透明度设置为0
  • v-enter-to:元素的目标状态。该类在插入元素后添加一帧,同时删除v-enter。动画完成后,它将被删除。

当元素从 DOM 中移除时(离开阶段),它们将被以下内容替换:

  • v-leave-active:请假转换激活时应用。这个类是在离开转换触发时添加的,并且在元素从 DOM 中移除后被移除。您应该在这个类中添加一些transitionCSS 属性,并定义它们的持续时间。
  • v-leave:元件拆卸时的起始状态。该类也在离开转换触发时添加,并在一帧后删除。
  • v-leave-to:元素的目标状态。该类在离开转换触发器后添加一帧,同时删除v-leave。当元素从 DOM 中移除时,它将被移除。

During the leave phase, the element is not immediately removed from the DOM. It will be removed when the transition finishes to allow the user to see the animation.

下面是一个模式,总结了两个进入和离开阶段,以及相应的 CSS 类:

The transition component will automatically detect the duration of the CSS transitions applied on the element.

  1. 我们需要编写一些 CSS 来制作动画。创建新的transitions.css文件并将其包含在网页中:
      <link rel="stylesheet" href="transitions.css" />
```js

让我们先尝试一个基本的淡入淡出动画。我们希望在不透明度 CSS 属性上应用 CSS 转换 1 秒。

7.  为此,请同时使用`v-enter-active`和`v-leave-active`类,因为它们将是相同的动画:

  .hand.v-enter-active,
  .hand.v-leave-active {
    transition: opacity 1s;
  }
当从 DOM 中添加或删除手时,我们希望它的不透明度为`0`(因此它将是完全透明的)。

8.  使用`v-enter``v-leave-to`类应用此完全透明:
  .hand.v-enter,
  .hand.v-leave-to {
    opacity: 0;
  }
9.  回到主模板,用过渡专用组件环绕手部组件:
  <transition>
    <hand v-if="!activeOverlay" :cards="testHand" />
  </transition>
现在,当您隐藏或显示手时,它将淡入淡出。

10.  由于我们可能需要重新使用此动画,因此应为其命名:
  <transition name="fade">
    <hand v-if="!activeOverlay" :cards="testHand" />
  </transition>
我们必须更改 CSS 类,因为 Vue 现在将使用`fade-enter-active`而不是`v-enter-active`

11.  `transition.css`文件中,修改 CSS 选择器以匹配此更改:
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 1s;
  }

  .fade-enter,
  .fade-leave-to {
    opacity: 0;
  }
现在,我们可以使用[T0]在任何元素上重用此动画。

# 更漂亮的动画

我们现在将制作一个更复杂但更好的动画,带有一些 3D 效果。除了手,我们还将为`.wrapper`元素(用于 3D 翻转)和`.card`元素设置动画。卡片将开始堆积,并逐渐扩展到手中的预期位置。最后,它将设置动画,就好像玩家正在从桌上拿起牌一样。

1.  首先用`'hand'`名称而不是`'fade'`创建新的转换 CSS 类:
  .hand-enter-active,
  .hand-leave-active {
    transition: opacity .5s;
  }

  .hand-enter,
  .hand-leave-to {
    opacity: 0;
  }
2.  在主模板中也更改转换名称:
  <transition name="hand">
    <hand v-if="!activeOverlay" :cards="testHand" />
  </transition>
3.  让我们为包装器元素设置动画。使用 CSS transform 属性将三维变换应用于元素:
  .hand-enter-active .wrapper,
  .hand-leave-active .wrapper {
    transition: transform .8s cubic-bezier(.08,.74,.34,1);
    transform-origin: bottom center;
  }

  .hand-enter .wrapper,
  .hand-leave-to .wrapper {
    transform: rotateX(90deg);
  }
右旋转轴为水平旋转轴,为`x`。这将使这些卡片具有动画效果,就像它们被玩家拾取一样。请注意,定义了一个立方贝塞尔缓和函数,以使动画更平滑。

4.  最后,通过设置负水平边距使卡片看起来像是堆积起来,从而使卡片本身具有动画效果:
  .hand-enter-active .card,
  .hand-leave-active .card {
    transition: margin .8s cubic-bezier(.08,.74,.34,1);
  }

  .hand-enter .card,
  .hand-leave-to .card {
    margin: 0 -100px;
  }
现在,如果您像以前一样使用浏览器控制台隐藏和显示手,它将有一个很好的动画。

# 打牌

现在,我们需要处理手牌组件中的`'play'`事件,当用户点击卡片时,我们会在卡片中发出该事件,并向主组件发出一个新的`'card-play'`事件,该事件带有一个附加参数——正在讨论的扑克牌。

1.  首先,创建一个名为`handlePlay`的新方法。它接受一个`card`参数并向父组件发出新事件:
  methods: {
    handlePlay (card) {
      this.$emit('card-play', card)
    },
  },
2.  然后,为`'play'`事件向我们的卡片添加一个侦听器:
  <card v-for="card of cards" :def="card.def" 
  @play="handlePlay(card) />
As you can see here, we directly use the iterator variable `card` of the `v-for` loop. That way, we don't need the card component to emit its `card` item since we already know what it is.

为了测试纸牌游戏,我们现在只将其从手上移除。

3.  在`main.js`文件的主组件中创建一个名为`testPlayCard`的新临时方法:
  methods: {
    // ...
    testPlayCard (card) {
      // Remove the card from player hand
      const index = this.testHand.indexOf(card)
      this.testHand.splice(index, 1)
    }
  },
4.  在主模板的`hand`组件上添加`'card-play'`事件的事件监听器:
  <hand v-if="!activeOverlay" :cards="testHand" @card-play="testPlayCard" />
如果你点击一张卡片,它现在应该向手部组件发出`'play'`事件,然后手部组件将向主组件发出`'card-play'`事件。反过来,它将从手上取出卡片,使其消失。为了帮助您调试此类用例,devtools 有一个事件选项卡:

![](img/32fa4cee-0203-43fe-b413-eafcccce1ea5.png)

# 设置卡片列表的动画

我们的手牌缺少三个动画——当玩家的手牌被添加或移除时,以及当它被移动时。当回合开始时,玩家将抽一张牌。这意味着我们将在手牌列表中添加一张牌,它将从右侧滑入手牌。当一张牌被打出时,我们希望它上升并变大。

要为元素列表设置动画,我们需要另一个特殊组件--`<transition-group>`。当添加、删除和移动子对象时,它会设置子对象的动画。在模板中,它如下所示:
```js

<transition>元素不同,默认情况下,转换组将作为<span>元素出现在 DOM 中。您可以使用tag属性更改 HTML 元素:

<transition-group tag="ul">
  <li v-for="item of items" />
</transition-group>
```js

在我们的`hand`组件模板中,将卡片组件封装在一个转换组中,指定我们将调用的转换名称`"card"`,并添加`"cards"`CSS 类:

在此模式中,我们删除列表中的第三项,即c。但是,第三个div元素不会被销毁——它将与列表中的第四个项目d一起重用。实际上,这是被摧毁的第四个div元素。

幸运的是,我们可以告诉 Vue 如何识别每个元素,以便它可以重用和重新排序它们。为此,我们需要使用key特殊属性指定一个唯一标识符。例如,我们的每个项目都可以有一个唯一的 ID,我们将其用作密钥:

在这里,我们指定键,以便 Vue 知道应该销毁第三个div元素,并移动第四个 div 元素。

The key special attribute works like a standard attribute, so we need to use the v-bind directive if we want to assign a dynamic value to it.

回到我们的卡,我们可以使用卡上的唯一标识符作为密钥:

<card v-for="card of cards" :def="card.def" :key="card.uid" @play="handlePlay(card) />
```js

现在,如果我们在 JavaScript 中添加、移动或删除卡片项,它将以正确的顺序反映在 DOM 中。

# CSS 转换

与之前一样,我们的一次性电脑上有以下六个 CSS 类,前缀为我们的组转换名称`'card'`:`card-enter-active`、`card-enter`、`card-enter-to`、`card-leave-active`、`card-leave`和`card-leave-to`。它们将应用于组转换的直接子级,即我们的卡组件。

1.  组转换有一个应用于移动项目的附加类--`v-move`。Vue 将对项目使用 CSS`transform`属性使其移动,因此我们只需在其上应用 CSS 转换,至少持续一段时间:

  .card-move {
    transition: transform .3s;
  }      
现在,当你点击一张牌玩它时,它应该消失,剩下的牌将移动到新的位置。你也可以在手上增加卡片。

2.   Vue devtools 中选择主组件,并在浏览器控制台中执行此操作:
  state.testHand.push($vm.testDrawCard())
Selecting a component in the devtools exposes it in the browser console as `$vm`.

就像我们对手牌所做的那样,我们还将在牌进入手牌和玩牌(从而离开手牌)时为牌添加动画。

3.  由于我们需要始终以相同的时间转换卡上的多个 CSS 属性(休假转换期间除外),因此我们将更改刚才写入其中的`.card-move`规则:

.card { /* Used for enter, move and mouse over animations */ transition: all .3s; }

4.  对于“输入动画”,指定转换开始时卡的状态:

.card-enter { opacity: 0; /* Slide from the right */ transform: scale(.8) translateX(100px); }

5.  “离开”动画需要更多的规则,因为扑克牌动画更复杂,需要向上缩放扑克牌:

.card-leave-active { /* We need different timings for the leave transition / transition: all 1s, opacity .5s .5s; / Keep it in the same horizontal position / position: absolute !important; / Make it painted over the other cards / z-index: 10; / Unclickable during the transition */ pointer-events: none; }

  .card-leave-to {
    opacity: 0;
    /* Zoom the card upwards */
    transform: translateX(-106px) translateY(-300px) scale(1.5);
  }
这足以使你的卡都正确地设置动画。您可以尝试再次玩牌并将牌添加到手上,以查看结果。

# 覆盖层

我们需要的最后一个 UI 元素是覆盖层。以下是其中三项:

*   当轮到当前玩家时,“新回合”覆盖显示当前玩家的姓名。点击“新回合”玩家切换到“最后一场”覆盖。
*   “最后一场比赛”覆盖图向玩家展示了他们的对手之前所做的事情。它显示以下任一项:
    *   对手在前一回合中打出的牌
    *   提醒他们该轮到他们了
*   当一个玩家或两个玩家都输了时,“游戏结束”覆盖显示。它用短语“获胜”或“被击败”显示球员的姓名。点击“游戏结束”覆盖重新加载游戏。

所有这些覆盖都有两个共同点——当用户点击它们时,它们会做一些事情,并且它们具有相似的布局设计。所以,我们在这里应该很聪明,在有意义的地方尽可能多地重用代码来构造我们的组件。这里的想法是创建一个通用的覆盖组件,它将负责单击事件和布局,并为我们需要的每个覆盖创建三个特定的覆盖内容组件。

开始之前,在`state.js`文件中的应用程序状态中添加一个新的`activeOverlay`属性:

// The consolidated state of our app var state = { // UI activeOverlay: null, // ... }

这将保存当前显示的覆盖图的名称,如果没有显示覆盖图,则为`null`

# 带插槽的内容分发

如果我们可以将内容放在主模板的覆盖组件中,这将非常方便,如下所示:
```js

我们将在overlay组件中封装额外的布局和逻辑,同时仍然能够将任何内容放入其中。这是通过一个特殊的元素<slot>实现的。

  1. 让我们用两个div元素创建overlay组件:
 Vue.component('overlay', {
        template: `<div class="overlay">
          <div class="content">
            <!-- Our slot will be there -->
          </div>
        </div>`,
      })
```js

2.  在`.overlay`div 上添加一个点击事件监听器,该监听器调用`handleClick`方法:

  <div class="overlay" @click="handleClick">
3.  然后,添加前面提到的方法,我们在其中发出一个自定义`'close'`事件:
  methods: {
    handleClick () {
      this.$emit('close')
    },
  },
此事件有助于了解在转弯开始时何时从一个叠加切换到下一个叠加。

4.  现在,将一个`<slot>`元素放入`.content`div 中:
  template: `<div class="overlay" @click="handleClick">
    <div class="content">
      <slot />
    </div>
  </div>`,
现在,如果我们在使用我们的组件时在`overlay`标记之间放置一些东西,它将包含在 DOM 中并替换`<slot>`标记。例如,我们可以这样做:
Hello world! ```js

此外,它将在页面中呈现如下所示:

<div class="overlay">
  <div class="content">
    Hello world!
  </div>
</div>
```js

It works with anything, so you can also put HTML or Vue components, and it will still work the same way!

5.  该组件已准备好在主模板中使用,因此请在末尾添加它:

  <overlay>
    Hello world!
  </overlay>
三个覆盖内容中的每一个都将是一个单独的组件:

*   `overlay-content-player-turn`显示该回合的开始
*   `overlay-content-last-play`显示对手最后一张牌
*   `overlay-content-game-over`游戏结束时显示

在深入研究这些问题之前,我们需要更多关于我们州两名球员的数据。

6.  返回`state.js`文件,为每个玩家添加以下属性:
  // Starting stats
  food: 10,
  health: 10,
  // Is skipping is next turn
  skipTurn: false,
  // Skiped turn last time
  skippedTurn: false,
  hand: [],
  lastPlayedCardId: null,
  dead: false,
你现在应该在`players`数组中有两个属性相同的项目,除了玩家名称。

# “玩家回合”覆盖

第一个叠加将向当前玩家显示两条不同的消息,这取决于玩家是否跳过他们的回合。玩家道具将接收当前玩家,以便我们可以访问其数据。我们将使用`v-if`指令与`v-else`指令以及刚才添加到播放器中的`skipTurn`属性配对:

Vue.component('overlay-content-player-turn', { template: <div> <div class="big" v-if="player.skipTurn">{{ player.name }}, <br>your turn is skipped!</div> <div class="big" v-else>{{ player.name }},<br>your turn has come!</div> <div>Tap to continue</div> </div>, props: ['player'], })

# “最后一场”覆盖图

这个有点复杂。我们需要一个新函数来获取玩家最后一张玩过的牌。在`utils.js`文件中新增`getLastPlayedCard`功能:

function getLastPlayedCard (player) { return cards[player.lastPlayedCardId] }

我们现在可以通过传递`opponent`属性在`lastPlayedCard`计算属性中使用此函数:

Vue.component('overlay-content-last-play', { template: `

{{ opponent.name }} turn was skipped!
{{ opponent.name }} just played:

`, props: ['opponent'], computed: { lastPlayedCard () { return getLastPlayedCard(this.opponent) }, }, }) ```js

请注意,我们直接重用了前面制作的显示卡的card组件。

“游戏结束”覆盖图

对于这一个,我们将创建另一个名为player-result的组件,它将显示玩家是胜利还是失败。我们将显示通过道具的玩家的姓名。我们将使用 computed 属性计算该播放器的结果,我们还将使用该属性作为动态 CSS 类:

Vue.component('player-result', {
  template: `<div class="player-result" :class="result">
    <span class="name">{{ player.name }}</span> is
    <span class="result">{{ result }}</span>
  </div>`,
  props: ['player'],
  computed: {
    result () {
      return this.player.dead ? 'defeated' : 'victorious'
    },
  },
})
```js

现在,我们可以通过循环`players`道具并使用`player-result`组件来创建覆盖游戏:

Vue.component('overlay-content-game-over', { template: `

Game Over

`, props: ['players'], }) ```js

动态分量

现在,是时候将所有这些放到我们的覆盖组件中,并使用我们前面定义的activeOverlay属性了。

  1. 添加组件并在主模板中以activeOverlay对应值显示:
      <overlay v-if="activeOverlay">
        <overlay-content-player-turn
          v-if="activeOverlay === 'player-turn'" />
        <overlay-content-last-play
          v-else-if="activeOverlay === 'last-play'" />
        <overlay-content-game-over
          v-else-if="activeOverlay === 'game-over'" />
      </overlay>
```js

We will remove the overlay completely if the `activeOverlay` property is equal to `null`.

在添加道具之前,我们需要使用一些 getter 修改`state.js`文件中的应用程序状态。

2.  第一个将从`currentPlayerIndex`属性返回`player`对象:

  get currentPlayer () {
    return state.players[state.currentPlayerIndex]
  },
3.  第二个将返回相反的`player`索引:
  get currentOpponentId () {
    return state.currentPlayerIndex === 0 ? 1 : 0
  },
4.  最后,第三个将返回相应的 player 对象:
  get currentOpponent () {
    return state.players[state.currentOpponentId]
  },
5.  现在,我们可以将道具添加到覆盖内容中:
  <overlay v-if="activeOverlay">
    <overlay-content-player-turn
      v-if="activeOverlay === 'player-turn'"
      :player="currentPlayer" />
    <overlay-content-last-play
      v-else-if="activeOverlay === 'last-play'"
      :opponent="currentOpponent" />
    <overlay-content-game-over
      v-else-if="activeOverlay === 'game-over'"
      :players="players" />
  </overlay>
您可以通过在浏览器控制台中设置`activeOverlay`属性来测试覆盖:

state.activeOverlay = 'player-turn' state.activeOverlay = 'last-play' state.activeOverlay = 'game-over' state.activeOverlay = null

If you want to test the `last-play` overlay, you need to specify a valid value to the player `lastPlayedCardId` property, such as `'catapult'` or `'farm'`.

我们的代码开始变得混乱,有三个条件。谢天谢地,有一个特殊的组件可以将自己转换成任何组件——它是`component`组件。您只需将其`is`属性设置为组件名称、组件定义对象甚至 HTML 标记,它就会变形为:

Title

它和其他任何东西一样是一个道具,因此我们可以使用`v-bind`指令,通过 JavaScript 表达式动态更改组件的本质。如果我们用我们的`activeOverlay`财产来做这件事呢?我们的覆盖内容组件是否方便地使用相同的`'over-content-'`前缀命名?看一看:
```js

就这样。现在,通过更改activeOverlay属性的值,我们将更改覆盖中显示的组件。

  1. 添加回道具后,覆盖层在主模板中应如下所示:
      <overlay v-if="activeOverlay">
        <component :is="'overlay-content-' + activeOverlay"
          :player="currentPlayer" :opponent="currentOpponent"
          :players="players" />
      </overlay>
```js

Don't worry, unused props won't interfere with the different overlays workings.

# 叠加动画

就像我们用手做的那样,我们将使用过渡来设置覆盖动画。

1.  围绕覆盖组件添加一个名为“缩放”的过渡:

  <transition name="zoom">
    <overlay v-if="activeOverlay">
      <component :is="'overlay-content-' + activeOverlay"                    
      :player="currentPlayer" :opponent="currentOpponent"                      
      :players="players" />
    </overlay>
  </transition>
2.  `transition.css`文件中添加以下 CSS 规则:
  .zoom-enter-active,
  .zoom-leave-active {
    transition: opacity .3s, transform .3s;
  }

  .zoom-enter,
  .zoom-leave-to {
    opacity: 0;
    transform: scale(.7);
  }
这是一个简单的动画,可以在淡出覆盖的同时缩小覆盖。

# 关键属性

目前,如果您在浏览器中尝试动画,它应该只在两种情况下工作:

*   当您没有显示任何覆盖时,请设置一个覆盖
*   当显示覆盖图时,将[T0]设置为[T1]以隐藏它

如果在覆盖之间切换,动画将无法工作。这是因为 Vue 更新 DOM 的方式;正如我们在前面的*关键特殊属性*部分中所看到的,它将尽可能地重用 DOM 元素以优化性能。在这种情况下,我们需要使用 key-special 属性来提示 Vue,我们希望将不同的覆盖层视为单独的元素。因此,当我们从一个覆盖过渡到另一个覆盖时,两者都将出现在 DOM 中,并且可以播放动画。

让我们将该键添加到覆盖组件中,以便 Vue 在更改[T0]值时将其视为多个单独的元素:
```js

现在,如果我们将activeOverlay设置为'player-turn',覆盖将有一个'player-turn'键。然后,如果我们将activeOverlay设置为'last-play',将创建一个全新的覆盖,关键点为'last-play',我们可以设置两者之间的过渡动画。您可以通过将不同的值设置为state.activeOverlay在浏览器中尝试此操作。

覆盖背景

在这一点上,缺少了一些东西——覆盖背景。我们不能将它包含在覆盖组件中,因为它在动画中会被缩放——这将非常尴尬。相反,我们将使用我们已经创建的简单fade动画。

在主模板中,添加一个新的div元素,该元素在zoom转换之前具有overlay-background类和overlay组件:

<transition name="fade">
  <div class="overlay-background" v-if="activeOverlay" />
</transition>
```js

使用`v-if`指令,仅当显示任何叠加时才会显示。

# 游戏世界与风景

我们主要完成了 UI 元素,因此现在可以进入游戏场景组件。我们将有一些新的组件要做——玩家城堡,每一个都有一个健康和食物泡泡,背景中有一些动画云。

在`components`文件夹中创建一个新的`world.js`文件,并将其包含在页面中:

<script src="components/ui.js"></script> <script src="components/world.js"></script> <script src="main.js"></script>
我们将从城堡开始。

# 城堡

这一个实际上非常简单,因为它只包含两个图像和一个城堡横幅组件,将负责健康和食品展示:

1.  `world.js`文件中,创建一个包含两个图像的 NewCastle 组件,这两个图像接受`players``index`道具:

Vue.component('castle', { template: <div class="castle" :class="'player-' + index"> <img class="building" :src="'svg/castle' + index + '.svg'" /> <img class="ground" :src="'svg/ground' + index + '.svg'" /> <!-- Later, we will add a castle-banners component here --> </div>, props: ['player', 'index'], })

For this component, there is a castle and a ground image for each player; that means four images in total. For example, for the player at index `0`, there are `castle0.svg` and the `ground0.svg` images.

2.  在主模板中,在`top-bar`组件下方,使用`world`CSS 类创建一个新的`div`元素,在播放器上循环以显示两个城堡,并使用`land`类添加另一个`div`元素:
  <div class="world">
    <castle v-for="(player, index) in players" :player="player"                 
     :index="index" />
    <div class="land" />
  </div>
在浏览器中,您应该看到每个玩家对应一座城堡,如下所示:

![](img/fb8e96b4-1277-46dd-b2e7-8fe28baddcad.png)

# 城堡旗帜

城堡横幅将显示城堡的健康和食物。`castle-banners`组件内部将有两个组件:

*   一种垂直横幅,其高度根据统计数据的大小而变化
*   显示实际数字的气泡

它将如下所示:

![](img/9673ab25-0535-4f51-a154-facc7e9087d3.png)

1.  首先,创建一个新的`castle-banners`组件,其中只包含 stat 图标和`player`道具:

Vue.component('castle-banners', { template: `

      <!-- Health -->
      <img class="health-icon" src="svg/health-icon.svg" />
      <!-- Bubble here -->
      <!-- Banner bar here -->
    </div>`,
    props: ['player'],
  })
2.  我们还需要两个计算属性来计算健康和食物比率:
  computed: {
    foodRatio () {
      return this.player.food / maxFood
    },
    healthRatio () {
      return this.player.health / maxHealth
    },
  }
The `maxFood` and `maxHealth` variables are defined at the beginning of the `state.js` file.

3.  `castle`组件中,添加新的`castle-banners`组件:
  template: `<div class="castle" :class="'player-' + index">
    <img class="building" :src="'svg/castle' + index + '.svg'" />
    <img class="ground" :src="'svg/ground' + index + '.svg'" />
    <castle-banners :player="player" />
  </div>`,
# 食品和健康泡沫

此组件包含一个图像和一个文本,显示城堡食物或健康的当前数量。它的位置会根据这个数量而变化——它会随着数量的减少而上升,当它补充时会下降。

这个组件需要三个道具:

*   `type`不是食物就是健康;它将用于 CSS 类和图像路径
*   `value`是气泡中显示的量
*   `ratio`是金额除以最大金额

我们还需要一个计算属性来计算带有`ratio`道具的气泡的垂直位置。位置范围为 40  260 像素。因此,位置值将由以下表达式给出:

(this.ratio * 220 + 40) * state.worldRatio + 'px'

Remember to multiply every position or size with the `worldRatio` value, so the game takes into account the window size (it gets bigger if the window is bigger, or vice versa).

1.  让我们编写新的`bubble`组件:

Vue.component('bubble', { template: <div class="stat-bubble" :class="type + '-bubble'" :style="bubbleStyle"> <img :src="'svg/' + type + '-bubble.svg'" /> <div class="counter">{{ value }}</div> </div>, props: ['type', 'value', 'ratio'], computed: { bubbleStyle () { return { top: (this.ratio * 220 + 40) * state.worldRatio + 'px', } }, }, })

它有一个带有`stat-bubble`CSS 类的根`div`元素,一个动态类(根据`type`属性值,`'food-bubble'``'health-bubble'`加上我们用`bubbleStyle`计算属性设置的动态 CSS 样式。

它包含一个 SVG 图像,这与食品和健康不同,还有一个带有显示数量的`counter`类的`div`元素。

2.  `castle-banners`成分中添加食物和健康泡泡:
  template: `<div class="banners">
    <!-- Food -->
    <img class="food-icon" src="svg/food-icon.svg" />
    <bubble type="food" :value="player.food" :ratio="foodRatio" />
    <!-- Banner bar here -->

    <!-- Health -->
    <img class="health-icon" src="svg/health-icon.svg" />
    <bubble type="health" :value="player.health"             
  :ratio="healthRatio" />
    <!-- Banner bar here -->
  </div>`,
# 横幅栏

我们需要的另一个组件是悬挂在城堡塔楼上的竖直旗帜。其长度将根据食物量或健康状况而变化。这次,我们将创建一个动态 SVG 模板,以便修改横幅的高度。

1.  首先,使用两个道具(颜色和比率)和`height`计算属性创建组件:

Vue.component('banner-bar', { props: ['color', 'ratio'], computed: { height () { return 220 * this.ratio + 40 }, }, })

现在,我们用两种不同的方式定义模板——要么使用页面的 HTML,要么在组件的`template`选项中设置一个字符串。我们将使用另一种编写组件模板的方法——HTML 中的特殊脚本标记。它的工作原理是使用唯一 ID 将模板写入此脚本标记中,并在定义组件时引用此 ID。

2.  打开`banner-template.svg`文件,其中包含我们将用作动态模板的横幅图像的 SVG 标记。复制文件的内容。
3.  `index.html`文件中,在`<div id="app">`元素后添加`text/x-template`类型和`banner`ID `script`标签,并将`svg`内容粘贴在里面:
  <script type="text/x-template" id="banner">
    <svg viewBox="0 0 20 260">
      <path :d="`m 0,0 20,0 0,${height} -10,-10 -10,10 z`"                    
      :style="`fill:${color};stroke:none;`" />
    </svg>
  </script>
As you can see, this is a standard template with all the syntax and directives available to use. Here, we use the `v-bind` directive shorthand twice. Note that you can use SVG markup inside all of your Vue templates.

4.  现在,回到我们的组件定义中,添加`template`选项,其中脚本标记模板的 ID 前面有一个 hashtag:
  Vue.component('banner-bar', {
    template: '#banner',
    // ...
  })
完成!组件现在将在页面中查找具有[T0]ID  scrip 标记模板,并将其用作模板。

5.  `castle-banners`组件中,将剩余的两个`banner-bar`组件添加到相应的颜色和比率中:
  template: `<div class="banners">
    <!-- Food -->
    <img class="food-icon" src="svg/food-icon.svg" />
    <bubble type="food" :value="player.food" :ratio="foodRatio" />
    <banner-bar class="food-bar" color="#288339" :ratio="foodRatio"        
    />

    <!-- Health -->
    <img class="health-icon" src="svg/health-icon.svg" />
    <bubble type="health" :value="player.health"                   
    :ratio="healthRatio" />
    <banner-bar class="health-bar" color="#9b2e2e"                         
   :ratio="healthRatio" />
  </div>`,
你现在应该看到悬挂在城堡上的横幅,如果你改变了食物和健康价值观,这些横幅就会缩小。

# 设置值的动画

如果我们能在这些横幅收缩或生长时为它们制作动画,它们会更漂亮。我们不能依赖 CSS 转换,因为我们需要动态更改 SVG 路径,所以我们需要另一种方法——我们将为模板中使用的`height`属性的值设置动画。

1.  首先,让我们将模板计算属性重命名为`targetHeight`
  computed: {
    targetHeight () {
      return 220 * this.ratio + 40
    },
  },
`targetHeight`属性仅在比率变化时计算一次。

2.  添加一个新的`height`数据属性,我们将能够在`targetHeight`每次更改时设置该属性的动画:

data () { return { height: 0, } },

3.  创建组件时,将`height`的值初始化为`targetHeight`的值。在`created`挂钩中执行此操作:

created () { this.height = this.targetHeight },

要设置高度值的动画,我们将使用流行的`**TWEEN.js**`库,它已经包含在`index.html`文件中。该库通过创建一个新的`Tween`对象来工作,该对象接受起始值、缓和函数和结束值。它提供了诸如`onUpdate`之类的回调,我们将使用这些回调从动画中更新`height`属性。

4.  我们希望在`targetHeight`属性更改时启动动画,因此添加一个具有以下动画代码的观察者:

watch: { targetHeight (newValue, oldValue) { const vm = this new TWEEN.Tween({ value: oldValue }) .easing(TWEEN.Easing.Cubic.InOut) .to({ value: newValue }, 500) .onUpdate(function () { vm.height = this.value.toFixed(0) }) .start() }, },

The `this` context in the `onUpdate` callback is the `Tween` object and not the Vue component instance. That's why we need a good old temporary variable to hold the component instance `this` (here, that is the `vm` variable).

5.  我们需要做最后一件事,使我们的动画工作。在`main.js`文件中,通过浏览器的`requestAnimationFrame`功能,请求浏览器的画框勾选`TWEEN.js`库:
  // Tween.js
  requestAnimationFrame(animate);

  function animate(time) {
    requestAnimationFrame(animate);
    TWEEN.update(time);
  }
If the tab is in the background, the `requestAnimationFrame` function will wait for the tab to become visible again. This means the animations won't play if the user doesn't see the page, saving the computer resources and battery. Note that it is also the case for CSS transitions and animations.

现在,当你改变玩家的食物或健康状况时,横幅会逐渐缩小或增大。

# 活跃的云彩

为了给游戏世界增添一些活力,我们将创造一些云彩在天空中滑动。它们的位置和动画持续时间将是随机的,它们将从窗口的左侧转到右侧。

1.  `world.js file`中,添加云动画的最小和最大持续时间:
  const cloudAnimationDurations = {
    min: 10000, // 10 sec
    max: 50000, // 50 sec
  }
2.  然后,使用图像和`type`道具创建云组件:

Vue.component('cloud', { template: <div class="cloud" :class="'cloud-' + type" > <img :src="'svg/cloud' + type + '.svg'" /> </div>, props: ['type'], })

There will be five different clouds, so the `type` prop will range from 1 to 5.

3.  我们需要更改具有反应性`style`数据属性的组件的`z-index``transform`CSS 属性:

data () { return { style: { transform: 'none', zIndex: 0, }, } },

4.  使用[T0]指令应用这些样式属性:
  <div class="cloud" :class="'cloud-' + type" :style="style">
5.  让我们创建一个方法,使用`transform`CSS 属性设置云组件的位置:
  methods: {
    setPosition (left, top) {
      // Use transform for better performance
      this.style.transform = `translate(${left}px, ${top}px)`
    },
  }
6.  加载图像时,我们需要初始化云的水平位置,使其位于视口之外。创建一个使用`setPosition`方法的新`initPosition`
  methods: {
    // ...
    initPosition () {
      // Element width
      const width = this.$el.clientWidth
      this.setPosition(-width, 0)
    },
  }
7.  使用[T0]指令速记在图像上添加一个事件侦听器,用于侦听[T1]事件并调用[T2]方法:
  <img :src="'svg/cloud' + type + '.svg'" @load="initPosition" />
# 动画

现在,让我们转到动画本身。就像我们对城堡旗帜所做的那样,我们将使用`TWEEN.js`图书馆:

1.  首先,创建一个新的`startAnimation`方法,该方法计算随机动画持续时间并接受延迟参数:
  methods: {
    // ...

    startAnimation (delay = 0) {
      const vm = this

      // Element width
      const width = this.$el.clientWidth

      // Random animation duration
      const { min, max } = cloudAnimationDurations
      const animationDuration = Math.random() * (max - min) + min

      // Bing faster clouds forward
      this.style.zIndex = Math.round(max - animationDuration)

      // Animation will be there
    },
  }
The faster a cloud is, the lower its animation duration will be. Faster clouds will be displayed before slower clouds, thanks to the `z-index` CSS property.

2.  `startAnimation`方法中,计算云的随机垂直位置,然后创建`Tween`对象。它将延迟设置水平位置的动画,并在每次更新时设置云的位置。完成后,我们将启动另一个随机延迟的动画:
  // Random position
  const top = Math.random() * (window.innerHeight * 0.3)

  new TWEEN.Tween({ value: -width })
    .to({ value: window.innerWidth }, animationDuration)
    .delay(delay)
    .onUpdate(function () {
      vm.setPosition(this.value, top)
    })
    .onComplete(() => {
      // With a random delay
      this.startAnimation(Math.random() * 10000)
    })
    .start()
3.  在组件的`mounted`钩子中,调用`startAnimation`方法开始初始动画(随机延迟):

mounted () { // We start the animation with a negative delay // So it begins midway this.startAnimation(-Math.random() *
cloudAnimationDurations.min) },

我们的云组件已经准备好了。

4.  `world`元素的主模板中添加一些云:
  <div class="clouds">
    <cloud v-for="index in 10" :type="(index - 1) % 5 + 1" />
  </div>
Be careful to pass a value to the `type` prop ranging from 1 to 5\. Here, we use the `%` operator to return the division remainder for 5.

下面是它的外观:

![](img/52782d12-755c-4eb2-bbe8-811741339acf.png)

# 游戏性

我们所有的组件都完成了!我们只需要添加一些游戏逻辑,应用程序就可以玩了。当游戏开始时,每位玩家都会抽出他们的第一手牌。

然后,每个玩家的回合遵循以下步骤:

1.  显示`player-turn`覆盖图,以便玩家知道该轮到他们了。
2.  `last-play`覆盖图向他们展示了另一名球员在最后一轮比赛中的表现。
3.  玩家通过点击一张牌来玩它。
4.  这张牌将从他们手中移除,并应用其效果。
5.  我们等待一点,以便玩家可以看到这些效果的行动。
6.  然后,回合结束,我们将当前玩家切换到另一个玩家。

# 绘图卡

在绘制卡片之前,我们需要在`state.js`文件中的应用程序状态中添加两个属性:

var state = { // ... drawPile: pile, discardPile: {}, }

`drawPile`属性是玩家可以抽出的一堆牌。使用`cards.js`文件中定义的`pile`对象初始化。每个键都是卡定义的 ID,值是该类型的卡在堆栈中的数量。

`discardPile`属性等同于`drawPile`属性,但其用途不同——玩家所玩的所有牌都将从手中移出并放入弃牌堆。在某些情况下,如果抽取堆是空的,则会使用丢弃堆(将清空)重新填充。

# 第一手

在游戏开始时,每位玩家都抽几张牌。

1.  `utils.js`文件中,有一个函数可以吸引玩家的手:
  drawInitialHand(player)
2.  `main.js`文件中,添加一个新的`beginGame`函数,为每个玩家调用`drawInitialHand`函数:
  function beginGame () {
    state.players.forEach(drawInitialHand)
  }
3.  `main.js`文件中我们的主要组件的`mounted`钩子内调用此函数,当应用程序准备就绪时:

mounted () { beginGame() },

# 

要显示当前玩家手中的牌,我们需要在应用程序状态下使用新的 getter:

1.  `currentHand`getter 添加到`state.js`文件中的`state`对象:
  get currentHand () {
    return state.currentPlayer.hand
  },
2.  我们现在可以删除`testHand`属性,并在主模板中将其替换为`currentHand`
  <hand v-if="!activeOverlay" :cards="currentHand" @card-            
  play="testPlayCard" />
3.  您还可以移除`createTestHand`方法和我们在主要组件上写的`created`钩子,以进行测试:
  created () {
    this.testHand = this.createTestHand()
  },
# 打牌

打牌分为以下三个步骤:

1.  我们从玩家的手上取出卡片,并将其添加到牌堆中。这将触发卡动画。
2.  我们等待卡片动画完成。
3.  我们应用卡的效果。

# 不允许作弊

玩的时候不允许作弊。在编写游戏逻辑时,我们应该记住以下几点:

1.  让我们首先在`state.js`文件的应用程序状态中添加一个新的`canPlay`属性:
  var state = {
    // ...
    canPlay: false,
  }
这将阻止玩家玩一张牌,如果这张牌在他们的回合中已经玩过了——我们有很多动画和等待,所以我们不希望他们作弊。

我们将在玩家玩牌时使用它来检查他们是否已经玩过一张牌,并且在 CSS 中禁用手牌上的鼠标事件。

2.  因此,在主组件中添加一个`cssClass`computed 属性,如果`canPlay`属性为 true,该属性将添加`can-play`CSS 类:
  computed: {
    cssClass () {
      return {
        'can-play': this.canPlay,
      }
    },
  },
3.  并在主模板的根`div`元素上添加一个动态 CSS 类:
  <div id="#app" :class="cssClass">
# 从手上取出卡

打牌时,应将其从当前玩家手上移除;请按照以下步骤执行此操作:

1.  `main.js`文件中创建一个新的`playCard`函数,该函数将一张牌作为参数,检查玩家是否可以玩一张牌,然后使用`addCardToPile`函数(在`utils.js`文件中定义)将牌从手中取出,放入弃牌堆:
  function playCard (card) {
    if (state.canPlay) {
      state.canPlay = false
      currentPlayingCard = card

      // Remove the card from player hand
      const index = state.currentPlayer.hand.indexOf(card)
      state.currentPlayer.hand.splice(index, 1)

      // Add the card to the discard pile
      addCardToPile(state.discardPile, card.id)
    }
  }
We store the card the player played in the `currentPlayingCard` variable, because we need to apply its effect later.

2.  在主组件中,将`testPlayCard`方法替换为调用`playCard`函数的新`handlePlayCard`方法:
  methods: {
    handlePlayCard (card) {
      playCard(card)
    },
  },
3.  不要忘记更改主模板中`hand`组件上的事件侦听器:
  <hand v-if="!activeOverlay" :cards="currentHand" @card- 

play="handlePlayCard" />

# 等待卡转换结束

当牌被播放时,这意味着从手牌列表中移除,它会触发一个离开动画。我们希望在继续之前等待它完成。幸运的是,`transition``transition-group`组件发出事件。

我们在这里需要的是`'after-leave'`事件,但是还有其他事件对应于转换的每个阶段--`'before-enter'``'enter'``'after-enter'`等等。

1.  `hand`组件中,添加`'after-leave'`类型的事件侦听器:
  <transition-group name="card" tag="div" class="cards" @after- 

leave="handleLeaveTransitionEnd">

2.  创建向主模板发出`'card-leave-end'`事件的对应方法:
  methods: {
    // ...
    handleLeaveTransitionEnd () {
      this.$emit('card-leave-end')
    },
  },
3.  在主模板中,在`hand`组件上添加`'card-leave-end'`类型的新事件监听器:
  <hand v-if="!activeOverlay" :cards="currentHand" @card-                
  play="handlePlayCard" @card-leave-end="handleCardLeaveEnd" />
4.  创建相应的方法:
  methods: {
    // ...

    handleCardLeaveEnd () {
      console.log('card leave end')
    },
  }
稍后我们将编写它的逻辑。

# 应用卡片效应

播放动画后,将对玩家应用卡的效果。例如,它可以增加当前玩家的食物或降低对手的健康。

1.  `main.js`文件中,添加`applyCard`函数,该函数使用`utils.js`文件中定义的`applyCardEffect`
  function applyCard () {
    const card = currentPlayingCard

    applyCardEffect(card)
  }
然后,我们将等待一段时间,以便玩家可以看到正在应用的效果并了解正在发生的事情。然后,我们将检查是否至少有一名玩家死亡,以结束游戏(感谢`utils.js`中定义的`checkPlayerLost`功能)或继续下一回合。

2.  `applyCard`功能中,添加以下相应逻辑:
  // Wait a bit for the player to see what's going on
  setTimeout(() => {
    // Check if the players are dead
    state.players.forEach(checkPlayerLost)

    if (isOnePlayerDead()) {
      endGame()
    } else {
      nextTurn()
    }
  }, 700)
3.  现在,将空的`nextTurn``endGame`函数添加到`applyCard`函数之后:
  function nextTurn () {
    // TODO
  }

  function endGame () {
    // TODO
  }
4.  我们现在可以更改主组件中的`handleCardLeaveEnd`方法来调用我们刚刚创建的`applyCard`函数:
  methods: {
    // ...

    handleCardLeaveEnd () {
      applyCard()
    },
  }
# 下一轮

`nextTurn`功能非常简单——我们将增加一个回合计数器,更改当前玩家,并显示玩家回合叠加。

`nextTurn`功能中添加相应的代码:

function nextTurn () { state.turn ++ state.currentPlayerIndex = state.currentOpponentId state.activeOverlay = 'player-turn' }

# 新一轮

我们还需要一些逻辑,当覆盖层之后开始转弯时:

1.  首先是隐藏任何活动覆盖的`newTurn`函数;它或者因为一张牌而跳过当前玩家的回合,或者开始回合:
  function newTurn () {
    state.activeOverlay = null
    if (state.currentPlayer.skipTurn) {
      skipTurn()
    } else {
      startTurn()
    }
  }
如果一个玩家的`skipTurn`属性为 true,则该玩家将跳过该回合——该属性将由一些牌设置。他们还有一个`skippedTurn`属性,我们需要向下一个玩家显示他们的对手跳过了`last-play`覆盖中的最后一个回合。

2.  创建将`skippedTurn`设置为`true``skipTurn`属性设置为`false``skipTurn`函数,并直接进入下一轮:
  function skipTurn () {
    state.currentPlayer.skippedTurn = true
    state.currentPlayer.skipTurn = false
    nextTurn()
  }
3.  创建`startTurn`功能,重置玩家的`skippedTurn`属性,并在第二回合时让他们抽一张牌(这样他们在回合开始时总是有五张牌):
  function startTurn () {
    state.currentPlayer.skippedTurn = false
    // If both player already had a first turn
    if (state.turn > 2) {
      // Draw new card
      setTimeout(() => {
        state.currentPlayer.hand.push(drawCard())
        state.canPlay = true
      }, 800)
    } else {
      state.canPlay = true
    }
  }
在这个时刻,我们可以允许玩家使用`canPlay`属性玩一张牌。

# 叠加闭合动作

现在,我们需要处理用户单击每个覆盖时触发的操作。我们将创建一个映射,其中键是覆盖类型和触发操作时函数调用的值。

1.  将其添加到`main.js`文件中:
  var overlayCloseHandlers = {
    'player-turn' () {
      if (state.turn > 1) {
        state.activeOverlay = 'last-play'
      } else {
        newTurn()
      }
    },

    'last-play' () {
      newTurn()
    },
    'game-over' () {
      // Reload the game
      document.location.reload()
    },
  }
For the player-turn overlay, we only switch to the `last-play` overlay if it's the second or more turn, since at the start of the very first turn, the opponent does not play any card.

2.  在主组件中,添加`handleOverlayClose`方法,该方法使用`activeOverlay`属性调用当前活动覆盖对应的动作函数:
  methods: {
    // ...
    handleOverlayClose () {
      overlayCloseHandlers[this.activeOverlay]()
    },
  },
3.  在覆盖组件上,添加一个`'close'`类型的事件监听器,当用户单击覆盖时将触发该监听器:
  <overlay v-if="activeOverlay" :key="activeOverlay"                  
  @close="handleOverlayClose">
# 游戏结束!

最后,在`endGame`函数中将`activeOverlay`属性设置为`'game-over'`

function endGame () { state.activeOverlay = 'game-over' }


如果至少有一名玩家死亡,将显示`game-over`覆盖图。

# 总结

我们的纸牌游戏结束了。我们看到了 Vue 提供的许多新功能,这些功能使我们能够轻松创建丰富的交互体验。然而,我们在本章中介绍和使用的最重要的一点是基于组件的 web 应用程序开发方法。这有助于我们开发更大的应用程序,方法是将前端逻辑拆分为小型、独立且可重用的组件。我们介绍了如何使组件相互通信,从父组件到子组件(使用道具)以及从子组件到父组件(使用自定义事件)。我们还为游戏添加了动画和过渡(带有`<transition>`和`<transition-group>`特殊组件),使其更加生动。我们甚至在模板中操纵 SVG,并动态显示带有特殊`<component>`组件的组件。

在下一章中,我们将使用 Vue 组件文件和其他功能设置更高级的应用程序,这些功能将帮助我们构建更大的应用程序。