Vue.jsでFF7風のポートフォリオを作った

#作ったもの
![ff7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317035/1195b85e-9c65-599a-6a1f-9c718dda8ad5.gif)

- ff7風のポートフォリオを作ってみました。(レスポンシブ非対応なので、PCで見てください)
- URLはこちら、https://ryuzo-nakata.github.io/portfolio-ff7/
- ソースはこちら、https://github.com/ryuzo-nakata/portfolio-ff7

#経緯
ff7のリメイクがもうすぐ発売されますね！！！
早くプレイしたいです。

待ち遠しさを紛らわすために、遊び心でポートフォリオとして作成してみました。
マテリアをCSSで記載するなど、しなくてよい努力を詰め込んでますｗ

#使ったもの
- Vue.js
- Nuxt.js
- Vuetify
- Typescript
- Scss

フロントの勉強かつ、Typescriptの練習を兼ねて上記のような構成にしています。

#Top画面の説明
<img width="451" alt="ff7_top.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317035/201e0601-7d87-06be-2768-af82596eb7a0.png">

メニュー画面にマウスカーソルを合わせると、あのお馴染みのカーソルを表示するようにしています。クリックするとページを遷移するように作成していますが、現在は「マテリア」ページのみ作成しております。

###背景色
ff7の背景色は、下記のscssで作成しています。`ff7-card`クラスを指定すれば、あの青い背景色になります。

```scss
$text-color: #eff1ff;
$background-color: #04009d;
$background-color-dark: #06004d;

.ff7-card {
    border: solid 1px #424542;
    box-shadow: 1px 1px #e7dfe7, -1px -1px #e7dfe7, 1px -1px #e7dfe7,
      -1px 1px #e7dfe7, 0 -2px #9c9a9c, -2px 0 #7b757b, 0 2px #424542;
    padding: 5px 10px;
  
    background: $background-color;
    background: -moz-linear-gradient(top, $background-color 0%, $background-color-dark 100%);
    background: -webkit-gradient(
      linear,
      left top,
      left bottom,
      color-stop(0%, $background-color),
      color-stop(100%, $background-color-dark)
    );
    background: -webkit-linear-gradient(top, $background-color 0%, $background-color-dark 100%);
    background: -o-linear-gradient(top, $background-color 0%, $background-color-dark 100%);
    background: -ms-linear-gradient(top, $background-color 0%, $background-color-dark 100%);
    background: linear-gradient(to bottom, $background-color 0%, $background-color-dark 100%);
    filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$background-color', endColorstr='$background-color-dark',GradientType=0 );
  
    -webkit-border-radius: 7px;
    -moz-border-radius: 7px;
    border-radius: 7px;
    * {
        color: $text-color;
        text-shadow: 2px 2px #212421, 1px 1px #212021;
        font-family: Verdana, sans-serif;
        font-weight: normal;    
    }
}
```

###メニューの部分
メニューの部分を説明していきます。

```vue
<template>
  <section>
    <v-container class="top-menu-list ff7-card">
      <v-row
        v-for="(item, index) in topMenuItems"
        :key="index"
        no-gutters
        @mouseleave="menuMouseleave(index)"
        @mouseover="menuMouseover(index)"
      >
        <v-col cols="12">
          <cursor-parts v-if="menuChoice == index" />
          <nuxt-link
            v-if="item.display == true"
            :to="item.path"
            class="top-menu-item"
          >
            {{ item.name }}
          </nuxt-link>
        </v-col>
      </v-row>
    </v-container>
  </section>
</template>
```
`topMenuItems`の内容を表示するようにしています。また、マウスカーソルの操作は`@mouseleave` `@mouseover`で対応しています。クリックした場合`nuxt-link`で指定先に遷移します。

```vue
<script lang="ts">
import { Component } from 'vue-property-decorator'
import PortfolioVueEx from '~/logic/vue/PortfolioVueEx'
import CursorParts from '~/components/parts/CursorParts.vue'

@Component({
  components: { CursorParts }
})
export default class TopMenu extends PortfolioVueEx {
  menuChoice: number = -1
  topMenuItems: {
    name: string
    path: string
    display: boolean
  }[] = [
    {
      name: 'アイテム',
      path: '/',
      display: true
    },
    {
      name: 'まほう',
      path: '/',
      display: true
    },
    {
      name: 'マテリア',
      path: 'materia',
      display: true
    },
    {
      name: 'そうび',
      path: '/',
      display: true
    },
    {
      name: 'ステータス',
      path: '/',
      display: true
    },
    {
      name: 'たいけい',
      path: '/',
      display: true
    },
    {
      name: 'リミット',
      path: '/',
      display: true
    },
    {
      name: 'コンフィグ',
      path: '/',
      display: true
    },
    {
      name: 'PHS',
      path: '/',
      display: true
    },
    {
      name: 'セーブ',
      path: '/',
      display: true
    }
  ]

  menuMouseover(i: number) {
    this.menuChoice = i
  }
  menuMouseleave() {
    this.menuChoice = -1
  }
}
</script>

<style scoped lang="scss">
.top-menu-item {
  margin: 10px;
}

.top-menu-list {
  text-align: left;
  width: 143px;
}

a:link {
  text-decoration: none;
  color: white;
}

a:visited {
  text-decoration: none;
  color: white;
}
</style>
```
メニューの内容は、`topMenuItems`にリスト型で保持しています。

###キャラクター

```TopTeam.vue
<template>
  <section>
    <v-container class="top-team-list ff7-card">
      <v-row
        v-for="(item, index) in $store.state.players"
        :key="index"
        align="center"
      >
        <v-col cols="4">
          <v-row justify="center">
            <img :src="item.image" width="75" />
          </v-row>
        </v-col>
        <v-col cols="3">
          {{ item.name }}{{ $store.state.level }}
          <v-row>
            <span class="status-item">LV</span>
            <span class="level-margin status-content">{{ item.level }}</span>
          </v-row>
          <v-row>
            <span class="status-item">HP</span>
            <span class="status-content">
              <span>
                {{ item.hp }}/{{ item.maxHp }}
                <div class="progress-linear">
                  <progress-hp-parts
                    :parent-max.sync="item.maxHp"
                    :parent-value.sync="item.hp"
                  />
                </div>
              </span>
            </span>
          </v-row>
          <v-row>
            <span class="status-item">MP</span>
            <span class="status-content">
              <span class="mp-margin">{{ item.mp }}/</span>
              <span class="mp-margin">{{ item.maxMp }}</span>
              <div class="progress-linear">
                <progress-mp-parts
                  :parent-max.sync="item.maxMp"
                  :parent-value.sync="item.mp"
                />
              </div>
            </span>
          </v-row>
        </v-col>
        <v-col cols="3">
          <v-row>
            <span class="next-level">つぎのレベルまであと</span>
            <progress-parts
              :parent-max.sync="item.maxExp"
              :parent-value.sync="item.exp"
              class="next-level-margin"
            />
          </v-row>
          <br />
          <v-row>
            <span class="limit">リミットレベル {{ item.limitLevel }}</span>
            <progress-parts
              :parent-max.sync="item.maxLimit"
              :parent-value.sync="item.limit"
              class="limit-margin"
            />
          </v-row>
        </v-col>
      </v-row>
    </v-container>
  </section>
</template>

<script lang="ts">
import { Component } from 'vue-property-decorator'
import PortfolioVueEx from '~/logic/vue/PortfolioVueEx'
import ProgressParts from '~/components/parts/ProgressParts.vue'
import ProgressHpParts from '~/components/parts/ProgressHpParts.vue'
import ProgressMpParts from '~/components/parts/ProgressMpParts.vue'

@Component({
  components: {
    ProgressParts,
    ProgressHpParts,
    ProgressMpParts
  }
})
export default class TopTeam extends PortfolioVueEx {
  menuChoice: number = -1
}
</script>

<style scoped lang="scss">
.top-team-list {
  padding: 0px 0px 0px 30px;
  width: 457px;
  height: 360px;
}

.progress-linear {
  margin: -15px 0px 0px 0px;
}

.status-item {
  color: #00ddd6;
  margin-right: 10px;
  font-size: 100%;
}
.status-content {
  font-size: 80%;
}
.level-margin {
  margin-top: 3px;
  margin-left: 18px;
}
.mp-margin {
  margin-left: 6px;
}

.next-level {
  font-size: 60%;
}
.next-level-margin {
  margin-left: 15px;
  margin-right: 0px;
}

.limit {
  font-size: 60%;
}
.limit-margin {
  margin-left: 15px;
  margin-right: 0px;
}
</style>
```

'store'でキャラクターの情報（LV/HP/MPなど）を保持しており、それらを呼び出すようにしています。

ゲージは別途コンポーネントを作成しております。
- `ProgressHpParts`は、HPのゲージ
- `ProgressMpParts`は、MPのゲージ
- `ProgressParts`は、経験値とリミットのゲージ

#マテリア画面の説明
<img width="483" alt="ff7_materia.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317035/56efde53-870d-7509-c5a6-8a17e848bf42.png">

マテリアにマウスカーソルを合わせると、カーソルとマテリアの説明が表示されます。マテリア毎に、自分のスキルを乗せるようにしてみました。
詳細説明とマテリアリストの部分は時間が足りなかったので、今後追加していきます。

ちなみに、マテリアを外すと下記のようになります。マテリア穴までCSSで作成しています。見えないところにもこだわる！
<img width="474" alt="ff7_no_materia.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317035/86b1d983-d9fe-0b33-e54d-b9cf12d854ca.png">

###マテリア
マテリアはコンポーネントとして作成しています。画像でしたらはるかに楽なのですが、CSSで無駄に開発しました。

```MateriaParts.vue
<template>
  <div class="wrapper">
    <div class="materia" :style="{ background: color }" />
    <div class="downlight1" />
    <div class="highlight2" />
    <div class="highlight3" />
  </div>
</template>

<script lang="ts">
import { Component, Prop } from 'vue-property-decorator'
import PortfolioVueEx from '~/logic/vue/PortfolioVueEx'

@Component({})
export default class MateriaParts extends PortfolioVueEx {
  @Prop()
  color: string
}
</script>

<style scoped lang="scss">
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(135deg, #f7f9fc 0%, #e1e7f0 100%);
  display: flex;
  justify-content: center;
  align-items: center;
}

.wrapper {
  width: 20px;
  height: 20px;
  position: relative;
}
.materia {
  width: 20px;
  height: 20px;
  z-index: 0;
  border-radius: 50%;
  background: rgb(43, 100, 21, 1);
}
.downlight1 {
  position: absolute;
  top: 10%;
  left: 15%;
  z-index: 1;
  width: 12px;
  height: 12px;
  background: rgba(0, 0, 0, 1);
  border-radius: 50%;
  filter: blur(2px);
  opacity: 0.6;
}
.highlight2 {
  position: absolute;
  top: 20%;
  left: 25%;
  z-index: 1;
  width: 3px;
  height: 1px;
  border-radius: 50%;
  border-top: 0.5px solid #fff;
  transform: rotate(-70deg) scaleX(0.9) scaleY(1.5) skewY(18deg);
  filter: blur(0.6px);
}
.highlight3 {
  position: absolute;
  top: 0%;
  left: 0%;
  width: 18px;
  height: 18px;
  background-color: transparent;
  box-shadow: inset -3px -6px 0 -3px rgba(255, 255, 255, 1);
  border-radius: 50%;
  filter: blur(1px);
  opacity: 0.15;
}
</style>
```

球型に、ハイライトを2種類、ダウンライトを1種類を乗せてマテリアを表現しています。マテリアの色は、`Prop`で指定できるようにしています。

###魔法マテリア
各マテリアは、`MateriaParts.vue`に色を指定しています。魔法マテリアは下記になります。

```MagicMateria.vue
<template>
  <materia-parts :color="'rgb(43, 100, 21, 1)'" />
</template>

<script lang="ts">
import { Component } from 'vue-property-decorator'
import PortfolioVueEx from '~/logic/vue/PortfolioVueEx'
import MateriaParts from '~/components/parts/MateriaParts.vue'

@Component({
  components: { MateriaParts }
})
export default class MagicMateria extends PortfolioVueEx {}
</script>
```

###マテリア画面

突貫で作ったので、改良の余地がありまくりです。。。

```materia.vue
<template>
  <v-container>
    <v-row align="center" justify="center">
      <div class="materia-box">
        <div class="character-box ff7-card">
          <v-row align="center">
            <v-col cols="2">
              <v-row justify="center">
                <img :src="$store.state.players[0].image" width="75" />
              </v-row>
            </v-col>
            <v-col cols="3">
              {{ $store.state.players[0].name }}{{ $store.state.level }}
              <v-row>
                <span class="status-item">LV</span>
                <span class="level-margin status-content">
                  {{ $store.state.players[0].level }}
                </span>
              </v-row>
              <v-row>
                <span class="status-item">HP</span>
                <span class="status-content">
                  <span>
                    {{ $store.state.players[0].hp }}/{{
                      $store.state.players[0].maxHp
                    }}
                    <div class="progress-linear">
                      <progress-hp-parts
                        :parent-max.sync="$store.state.players[0].maxHp"
                        :parent-value.sync="$store.state.players[0].hp"
                      />
                    </div>
                  </span>
                </span>
              </v-row>
              <v-row>
                <span class="status-item">MP</span>
                <span class="status-content">
                  <span class="mp-margin">
                    {{ $store.state.players[0].mp }}/
                  </span>
                  <span class="mp-margin">
                    {{ $store.state.players[0].maxMp }}
                  </span>
                  <div class="progress-linear">
                    <progress-mp-parts
                      :parent-max.sync="$store.state.players[0].maxMp"
                      :parent-value.sync="$store.state.players[0].mp"
                    />
                  </div>
                </span>
              </v-row>
            </v-col>
            <v-col cols="7">
              <v-row>
                <span class="attack-margin">
                  <span class="status-item">武器:</span>
                  <span>ノートPC</span>
                </span>
              </v-row>
              <v-row class="equipment-margin">
                <div
                  v-for="(item, index) in attackMaterias"
                  :key="index"
                  no-gutters
                  class="equipment"
                  @mouseleave="menuMouseleave(index)"
                  @mouseover="menuMouseover(item, index)"
                >
                  <cursor-parts v-if="menuChoice == index" />
                  <command-materia v-if="item.type == 1" class="content" />
                  <independent-materia
                    v-else-if="item.type == 2"
                    class="content"
                  />
                  <magic-materia v-else-if="item.type == 3" class="content" />
                  <summon-materia v-else-if="item.type == 4" class="content" />
                  <support-materia v-else-if="item.type == 5" class="content" />
                  <div v-if="item.type !== 0" class="highlight4" />
                </div>
              </v-row>
              <v-row>
                <span class="defence-margin">
                  <span class="status-item">防具:</span>
                  <span>お供のコーヒー</span>
                </span>
              </v-row>
              <v-row class="equipment-margin">
                <div
                  v-for="(item, index) in defenceMaterias"
                  :key="index"
                  no-gutters
                  class="equipment"
                  @mouseleave="menuMouseleave(index + 8)"
                  @mouseover="menuMouseover(item, index + 8)"
                >
                  <cursor-parts v-if="menuChoice == index + 8" />
                  <command-materia v-if="item.type == 1" class="content" />
                  <independent-materia
                    v-else-if="item.type == 2"
                    class="content"
                  />
                  <magic-materia v-else-if="item.type == 3" class="content" />
                  <summon-materia v-else-if="item.type == 4" class="content" />
                  <support-materia v-else-if="item.type == 5" class="content" />
                  <div v-if="item.type !== 0" class="highlight4" />
                </div>
              </v-row>
            </v-col>
          </v-row>
        </div>
        <div class="message-box ff7-card">
          <v-row v-if="selectedMateria !== ''">
            <command-materia class="content" v-if="selectedMateria.type == 1" />
            <independent-materia
              class="content"
              v-else-if="selectedMateria.type == 2"
            />
            <magic-materia
              class="content"
              v-else-if="selectedMateria.type == 3"
            />
            <summon-materia
              class="content"
              v-else-if="selectedMateria.type == 4"
            />
            <support-materia
              class="content"
              v-else-if="selectedMateria.type == 5"
            />
            {{ selectedMateria.name }}
          </v-row>
        </div>
        <div class="materias-box ff7-card"></div>
        <div class="status-box ff7-card">
          {{ description }}
        </div>
        <div class="page-box ff7-card">
          マテリア
        </div>
      </div>
    </v-row>
  </v-container>
</template>

<script lang="ts">
import { Component } from 'vue-property-decorator'
import PageBase from '~/logic/vue/PageBase'
import CommandMateria from '~/components/templates/CommandMateria.vue'
import IndependentMateria from '~/components/templates/IndependentMateria.vue'
import MagicMateria from '~/components/templates/MagicMateria.vue'
import SummonMateria from '~/components/templates/SummonMateria.vue'
import SupportMateria from '~/components/templates/SupportMateria.vue'
import ProgressParts from '~/components/parts/ProgressParts.vue'
import ProgressHpParts from '~/components/parts/ProgressHpParts.vue'
import ProgressMpParts from '~/components/parts/ProgressMpParts.vue'
import CursorParts from '~/components/parts/CursorParts.vue'

@Component({
  components: {
    CommandMateria,
    IndependentMateria,
    MagicMateria,
    SummonMateria,
    SupportMateria,
    ProgressParts,
    ProgressHpParts,
    ProgressMpParts,
    CursorParts
  }
})
export default class Materia extends PageBase {
  selectedMateria: any = ''
  description!: string
  details!: string
  menuChoice: number = -1
  attackMaterias: {
    name: string
    description: string
    details: string
    type: number
  }[] = [
    {
      name: 'Golang',
      description: 'Golang を使えます。',
      details: '',
      type: 3
    },
    {
      name: 'Solidity(Ethereum)',
      description: 'Solidity(Ethereum) を使えます。',
      details: '',
      type: 3
    },
    {
      name: 'Python',
      description: 'Python を使えます。',
      details: '',
      type: 3
    },
    {
      name: 'C言語',
      description: 'C言語を使えます。',
      details: '',
      type: 3
    },
    {
      name: 'Java',
      description: 'Java を使えます。',
      details: '',
      type: 3
    },
    {
      name: 'Mysql',
      description: 'Mysqlを使えます。',
      details: '',
      type: 3
    },
    {
      name: 'Typescript',
      description: 'Typescriptを使えます。',
      details: '',
      type: 1
    },
    {
      name: 'SCSS',
      description: 'SCSS を使えます。',
      details: '',
      type: 1
    }
  ]
  defenceMaterias: {
    name: string
    description: string
    details: string
    type: number
  }[] = [
    {
      name: 'AWS',
      description: 'Amazon Web Services を使えます。',
      details: '',
      type: 4
    },
    {
      name: 'Kubernetes',
      description: 'Kubernetesを使えます。',
      details: '',
      type: 4
    },
    {
      name: 'SpringBoot',
      description: 'SpringBoot のフレームワークを使えます。',
      details: '',
      type: 2
    },
    {
      name: 'Vue.js',
      description: 'Vue.js のフレームワークを使えます。',
      details: '',
      type: 2
    },
    {
      name: 'Nuxt.js',
      description: 'Nuxt.js のフレームワークを使えます。',
      details: '',
      type: 2
    },
    {
      name: 'Vuetify',
      description: 'Vuetify のフレームワークを使えます。',
      details: '',
      type: 2
    },
    {
      name: 'GitHub',
      description: 'GitHubを使えます。',
      details: '',
      type: 5
    },
    {
      name: 'Unity',
      description: 'Unity を使えます。',
      details: '',
      type: 5
    }
  ]

  menuMouseover(item: any, i: number) {
    this.menuChoice = i
    this.selectedMateria = item
    this.description = item.description
  }
  menuMouseleave() {
    this.menuChoice = -1
  }
}
</script>

<style scoped lang="scss">
.materia-box {
  position: relative;
}

.character-box {
  position: relative;
  width: 600px;
  height: 150px;
  margin: 6px 0 6px 0;
}
.message-box {
  position: relative;
  z-index: 2;
  width: 386px;
  height: 300px;
  margin: 0px 0px 0px 0px;
  padding: 60px 0px 0px 20px;
  float: left;
  .content {
    margin: 0px 6px 0px 0px;
  }
}
.materias-box {
  position: relative;
  z-index: 1;
  left: 380px;
  width: 220px;
  height: 300px;
  padding: 60px 0px 0px 15px;
}
.status-box {
  position: relative;
  z-index: 3;
  width: 600px;
  height: 50px;
  top: -300px;
  display: flex;
  align-items: center;
}
.page-box {
  position: relative;
  z-index: 2;
  width: 150px;
  height: 40px;
  top: -506px;
  left: 450px;
  display: flex;
  justify-content: center;
  align-items: center;
}

.progress-linear {
  margin: -15px 0px 0px 0px;
}

.status-item {
  color: #00ddd6;
  margin-right: 10px;
  font-size: 100%;
}
.status-content {
  font-size: 80%;
}
.level-margin {
  margin-top: 3px;
  margin-left: 18px;
}
.mp-margin {
  margin-left: 6px;
}
.attack-margin {
  margin: 6px 0px 6px 0;
}
.defence-margin {
  margin: 6px 0px 6px 0;
}
.equipment {
  margin: 0px 3px 0px 3px;
  width: 24px;
  height: 24px;
  z-index: 0;
  border-radius: 50%;
  background: radial-gradient(
    closest-side at 49% 49%,
    rgb(150, 150, 150) 0%,
    rgb(40, 40, 40) 25%,
    rgb(40, 40, 40) 70%,
    rgb(100, 100, 100) 92%
  );
  background-color: transparent;
  box-shadow: inset 6px 6px 2px -6px rgba(200, 200, 200, 1);
  .content {
    position: relative;
    top: 2.2px;
    left: 1px;
  }
}
.equipment-margin {
  margin-left: 30px;
}
.highlight4 {
  position: relative;
  top: -40%;
  left: 40%;
  width: 4px;
  height: 4px;
  z-index: 2;
  border-radius: 50%;
  background: rgba(150, 150, 150, 1);
  filter: blur(1px);
}
</style>
```

`attackMaterias`、`defenceMaterias`にマテリアを指定しています。`type`でマテリアの種類を指定します。ポートフォリオとして、プログラミングスキルを下記のように当てはめています。

- 0: マテリアなし
- 1: コマンドマテリア→ フロントスキル
- 2: 独立マテリア    → フレームワーク
- 3: 魔法マテリア    → バックエンドスキル
- 4: 召喚マテリア    → インフラ周りのスキル
- 5: サポートマテリア→ ツール類

#おわりに
思い付きで始めたものの、面白くできました。途中感は否めないですが、ひとまず形にはなりました。
何かアイディアある方はコメント頂けると嬉しいです。