今回は「マイセット登録」と護石データの「エクスポート/インポート」機能の追加をしていく。折り良く(悪く?)「FF7リメイク・インターグレード」や「FORTNITEシーズン7」が出たこともあって、そっちをプレイしてたらこちらの開発がちょいと遅くなってしまった……。この辺はちゃっちゃと作ってしまって、早くゲームに戻りたいもんだ……w
 

マイセット登録用テーブルの準備

 まず、ユーザーごとのマイセットの登録については、護石データと同じように基本的にはブラウザのCache Storageとデータベースの両方に保存をするハイブリッド方式で行うことにする。つまりは、データベースへの登録リクエストの内容+レスポンスとして受け取った発番されたIDをマージしたデータをCache Storageに格納する形だ。そして、登録したマイセットを管理するためのマイセット管理画面を準備して、そこではゲーム本編と同じようにマイセットに名前を付けたり、再編集や削除ができるようにする。
 そんなわけで、マイセット管理用のloadoutsテーブルをデータベースに追加することにする。スキーマは下記の通りだ。

カラム(物理名) カラム(論理名) タイプ 属性 補足
id マイセットID int unsigned, auto_increment
name マイセット名 varchar default null 各ユーザにて任意に設定可能
weapon_id 装備武器ID int unsigned, not null
weapon_slots 装備武器スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
head_id 装備頭部防具ID int unsigned, not null
head_lv 装備頭部防具レベル tinyint unsigned, not null, default 1
head_slots 装備頭部防具スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
chest_id 装備胸部防具ID int unsigned, not null
chest_lv 装備胸部防具レベル tinyint unsigned, not null, default 1
chest_slots 装備胸部防具スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
arms_id 装備腕部防具ID int unsigned, not null
arms_lv 装備腕部防具レベル tinyint unsigned, not null, default 1
arms_slots 装備腕部防具スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
waist_id 装備腰部防具ID int unsigned, not null
waist_lv 装備腰部防具レベル tinyint unsigned, not null, default 1
waist_slots 装備腰部防具スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
legs_id 装備脚部防具ID int unsigned, not null
legs_lv 装備脚部防具レベル tinyint unsigned, not null, default 1
legs_slots 装備脚部防具スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
talisman_id 装備護石ID int unsigned, not null
talisman_slots 装備護石スロット内容 json default null {slot1: decoration_id, slot2: decoration_id, slot3: decoration_id}
skills 発動スキル json default null { skillName: level, … }
disabled 無効フラグ bit(1) default b’0′ /^(TRUE|true|True|FALSE|false|False|0|1)?$/
user_info ユーザ識別情報 json default null 当面は使用予定なし
created_at 登録日時 timestamp default current_timestamp
updated_at 更新日時 timestamp default current_timestamp on update current_timestamp

 実際のCREATE TABLE文はこちらのGistを参照して欲しい。

マイセット登録処理の実装

 DBにマイセットを格納できるテーブルが出来たので、このテーブルにデータを格納するための処理をバックエンドに実装して行く。マイセットのデータについてバックエンド側から制御しなければならない機能は「新規登録(INSERT)」と「更新(UPDATE)」、「削除(DELETE)」、そして「参照(SELECT)」となるが、護石データ同様に「削除」については物理削除を行わずに論理削除の仕様とする。なので、実際にはDELETEクエリは使わずに削除時もUPDATEクエリで行うことになる。
 現時点では、各CRUDに対応するDBクエリ用のヘルパーも実装済みなので、新たに作らなければならないのはエンドポイント周りのコントローラ拡張かな……と思ったが、ソース読み返したらコアクラス側は特に何もしなくても動くことが判明した。いやぁ、なかなか汎用性に富む素晴らしい実装をしてたんだと自画自賛したくなったw
 ちゅーわけで、バックエンドのPHP部分には手を入れる必要がなかったので、フロントエンド側のVueアプリ部分を実装に入る。

マイセットの新規登録機能

 まず必要なのがマイセットを新たに登録する機能だ。具体的にはメイン画面で「マイセット登録」ボタンを押した時の処理である。この機能を作る前に、Vuexストアに保存したマイセットデータをキャッシュする項目$store.state.loadoutsと、現在装備中のマイセットIDを引き当てるための項目$store.state.now_loadoutsを追加する。前者のストア項目にはデータベースに登録成功したマイセットのデータ本体を格納し、後者の項目に値がない場合は新規登録、値がある場合は更新の処理を行うという建付けだ。そして、メイン画面のコンポーネントを拡張してマイセット登録・更新用のUIを追加する。

マイセットの登録・更新

 登録・更新は確認ダイアログを出力して処理をするようにした。マイセット名を変更できるように専用ダイアログのLoadoutsEditor.vueコンポーネントを追加している。コミット時の処理内容はほぼ前回開発した護石登録と同じ感じで実装している。

マイセットの管理機能

 次に、登録したマイセットを管理する機能だ。ここでは、マイセットの削除や装備を行えるようにする。ここはルーターに管理画面用のルート/loadoutsを新たに追加している。

マイセット管理

 さらに、マイセットのプレビュー機能を追加して、装備する前にマイセットの内容が確認できるようにした。なお、削除関連の処理は護石管理のを転用することで実装は容易だった。新たに追加した処理としてはマイセットを装備させる機能であるが、これも今まで実装したVuexのゲッターやアクションを組み合わせるだけで苦も無く実装できた。
 いやはや、あらためてVue.jsは生産性が高いうえに処理作るのも楽しい、素晴らしいフレームワークだねぇ……w

Cache Storage制御の拡張

 今回、マイセットデータを新たにCache Storageに格納することになったことで、Cache Storageからのデータの取得を選別する仕組みが必要になった。今まではCache Storageには護石データだけが格納されていたので、キャッシュされた全データを一括で取得してVuexの護石ストアに格納しても問題なかったが、これからは護石データとマイセットデータをそれぞれ分別して取得してそれぞれのストアに格納する必要が出て来たのだ。
 Cache APIにはキャッシュ検索系のメソッドとしてmatch()が用意されているが、これオンリーではキャッシュ・キーが一致するものしか引き当てられないので、正規表現による部分一致キーの検索に対応させるには新たに処理を追加しなければならない。そんなわけで、Cache APIのラッパーであるuserCacheプラグイン(/src/plugins/userCache.js)のメソッドを下記のように拡張した。

const userCache  = {
  install(Vue, options) {
    Vue.prototype.$userCache = {
      (...中略)
      get: async function (request, callback=null) {
        return await this.open()
        .then(cache => cache.match(request))
        .then(response => response.json())
        .then(data => {
          if (callback && typeof callback === 'function') {
            callback(data)
          } else {
            return Promise.reject()
          }
          return Promise.resolve()
        })
        .catch(error => console.log('', error))
      },
      find: async function (pattern, callback=null) {
        const re = new RegExp(pattern, 'i')
        const matchRequest = await this.keys()
        .then(allKeys => {
          let matchRequest = []
          allKeys.forEach(key => {
            if (re.test(key)) {
              matchRequest.push(key)
            }
          })
          return matchRequest
        })
        let cacheData = []
        if (matchRequest.length > 0) {
          matchRequest.forEach(async request => {
            await this.get(request, (data) => {
              cacheData.push(data)
            })
          })
        }
        if (callback && typeof callback === 'function') {
          callback(cacheData)
        }
      },
      keys: async function () {
        return await this.open()
        .then(cache => cache.keys())
        .then(keys => {
          let cacheNames = []
          keys.forEach(request => {
            cacheNames.push(request.url.replace(window.location.origin, ''))
          })
          return cacheNames
        })
        .catch(error => console.log(error))
      },
      (...中略)
    }
  }
}
export default userCache

 指定されたキーに一致するキャッシュを取得するmatch()のラッパーとして$userCache.get()と、キャッシュの全てのキーを取得するkeys()のラッパーとして$userCache.keys()、そして正規表現パターンでキーを指定して該当するキャッシュのみを一括で取得する$userCache.find()のインスタンスメソッドを追加してある。
 なお、$userCache.find()ではPromiseをforEachで回しているところがあるので、後続のcallback処理が完全同期しないというアルゴリズム的なバグがあるんだが、現在のシステムに影響がないので放置してある……将来的に必要性が出たら修正する予定だ。
 これによって、護石データとマイセットデータのキャッシュをそれぞれ選別して取得できるようになったので、全キャッシュ読み込み用に準備したミックスインの共通処理は次のように拡張している。

loadUserData: function() {
  this.$userCache.find('talismans', cacheData => {
    this.$store.dispatch('initData', {property: 'talismans', data: cacheData})
  })
  this.$userCache.find('loadouts', cacheData => {
    this.$store.dispatch('initData', {property: 'loadouts', data: cacheData})
  })
}

エクスポート/インポートの実装

 さて、仕上げは護石データとマイセットデータのエクスポート/インポートの機能を実装していく。具体的な仕様としては、Vuexストアに格納されている護石/マイセットのデータをそのままJSONファイルとしてローカルにダウンロードするエクスポートの処理と、そのダウンロードしたJSONファイルをアップロードしてCache StorageおよびVuexストアに格納するインポートの処理になる。
 どちらの機能もデータベースは絡まないので、フロントエンド側のVue.jsに処理を追加する。取り扱うデータが護石だろうがマイセットだろうが基本的な処理は一緒になるので、共通処理としてミックスインにインスタンス・メソッドとして実装してしまうのが良いだろう。

JSONファイルのエクスポート

 JSONファイルのエクスポートは簡単で、ミックスインに下記のメソッドを追加するだけである。

export: function(dataType) {
  const data = JSON.stringify(this.$store.state[dataType]),
        blob = new Blob([data], {type: 'text/plain'}),
        evnt = document.createEvent('MouseEvents'),
        a = document.createElement('a')
  a.download = `mhrss-${dataType}.json`
  a.href = window.URL.createObjectURL(blob)
  a.dataset.downloadurl = ['application/json', a.download, a.href].join(':')
  evnt.initEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null)
  a.dispatchEvent(evnt)
},

 あとは、エクスポートを実行する時にthis.export('talismans')のようにダウンロードしたいVuexストアの項目名を引数で与えてやればOKだ。

JSONファイルのインポート

 JSONファイルのインポートの方は、エクスポートよりひと手間かかる。ファイルアップローダのUIが必要になるからだ。ファイルアップロード用のフォームコンポーネントを用意して、そのフォームからの入力ファイルをキャッチする必要があるのだ。まぁ、キャッチさえしてしまえば、あとはVuexのアクションやCache APIを使ってJSONデータをJavaScriptのオブジェクトデータとして登録するだけである。

<v-file-input
  v-model="inputFile"
  accept=".json, .jsonp, text/plain, application/json"
  show-size
  label="アップロードファイル"
  outlined
  dense
></v-file-input>
<v-btn
  text
  :disabled="!inputFile"
  @click="doSubmit()"
>インポートする</v-btn>

 例えば、ファイルアップローダのVuetifyコンポーネントを上記のように準備する。これでJSON形式のファイルのみを受け付けるアップローダになる。次に、「インポートする」をコミットした時のインスタンス・メソッドを実装する。

methods: {
  doSubmit: function() {
    const reader = new FileReader()
    let data = null
    reader.readAsText(this.inputFile)
    reader.onload = () => {
      data = JSON.parse(reader.result)
      this.dialog = false
      this.import(this.dataType, data)
    }
  },
},

 アップロードされたファイルはVuetifyの方でFileオブジェクトとしてv-modelにバインドしてくれるので、初段のコミット処理にてFileReaderでテキスト文字列として拾い、それをJSONにパースすることで、後続処理にオブジェクト変数として受け渡せるようになるのだ。
 そして、後続のインポート処理は下記のようになる。

export default {
  methods: {
    (...中略)
    sanitizeData: function(dataType, data) {
      const requiredColumns = {
        'talismans': [
          'name',   'rarity', 'slot1', 'slot2', 'slot3',
          'skills', 'worth',  'id', 'slots', 'skills_text',
        ],
        'loadouts': [
          'id', 'name', 
          'weapon_id',               'weapon_slots',
          'head_id',     'head_lv',  'head_slots',
          'chest_id',    'chest_lv', 'chest_slots',
          'arms_id',     'arms_lv',  'arms_slots',
          'waist_id',    'waist_lv', 'waist_slots',
          'legs_id',     'legs_lv',  'legs_slots',
          'talisman_id',             'talisman_slots',
          'skills',
        ],
      }
      let checked = Object.keys(data).filter(key => requiredColumns[dataType].includes(key))
      if (checked.length == requiredColumns[dataType].length) {
        // Sanitizes valid data
        return checked.reduce((acc, cur) => {
          acc[cur] = data[cur]
          return acc
        }, {})
      } else {
        // Invalid data
        return {}
      }
    },
    import: async function(dataType, data) {
      let notices = {
            title: '通知',
            messages: [],
          }
      if (Array.isArray(data) && data.length > 0) {
        let throughputs = [],
            throughput  = 0
        data.forEach(oneData => {
          let _data = this.sanitizeData(dataType, oneData)
          if (Object.keys(_data).length > 0) {
            throughputs.push({
              request: `index.php?tbl=${dataType}&filters[id]=${_data.id}`,
              response: { status: 200, statusText: 'OK', headers: { 'content-length': JSON.stringify(_data).length, 'content-type': 'application/json; charset=utf-8' } },
              data: _data
            })
          }
        })
        await Promise.all(throughputs.map(async item => await this.$userCache.save(item.request, item.response, item.data).then(() => {
          this.$store.dispatch('upsertData', { property: dataType, data: item.data })
          throughput++
        })))
        if (data.length == throughput) {
          // Fully complete
          notices.messages.push('インポートが完了しました。')
        } else
        if (throughput > 0) {
          // Limited completion
          notices.messages.push('インポートが完了しました。', 'しかし、いくつかのデータのインポートに失敗しました。')
        } else {
          // All failed
          notices.title = 'エラー'
          notices.messages.push('インポートに失敗しました。', 'JSONデータの内容を確認してやり直してください。')
        }
      } else {
        notices.title = 'エラー'
        notices.messages.push('インポート用のデータが不正です。')
      }
      this.$root.$emit('open:notification', notices)
    },
    (...中略)
  }
}

 JSONパースしたオブジェクトのバリデーション(sanitizeData())を行い、有効なデータのみをCache Storageに格納していく。Cache APIではリクエストとレスポンスのペアをキャッシュするので、Cache Storageへの保存時にはダミーのレスポンスを発行している。また、Cache APIの戻り値はPromiseになるので、複数データを更新するためには一度データ登録用の配列を作り、それをArray.map()のコールバックで処理するようにし、全体をawait Promise.all()でラップすることで、すべての登録処理が完了するまで次の処理を待機するようにできる。
 最後に、特定のVuexストアの中身をIDがあれば更新し、なければ追加するためのミューテーションとアクションを追加(下記参照)し、これをCache Storageの更新処理の直後に連繋させることで、Cache StorageとVuexストアの更新を同期させられる。

const mutations = {
  (...中略)
  upsertItemById: (state, payload) => {
    if (Object.prototype.hasOwnProperty.call(payload.data, 'id')) {
      let targetIndex = state[payload.property].findIndex(item => item.id == payload.data.id)
      if (targetIndex >= 0) {
        state[payload.property].splice(targetIndex, 1, payload.data)
      } else {
        state[payload.property].push(payload.data)
      }
    }
  },
}
const actions = {
  (...中略)
  upsertData: ({ commit }, payload) => {
    commit('upsertItemById', payload)
  },
}

 詳しいソースコードはこちらのGitHubを参照して欲しい。


 これで、今回予定していた機能が全て実装された。アプリの初期公開バージョンとして、ほぼ完成形に達したといえるので、近くバージョン1としてリリースしようと思う。さぁ、気合い入れて、武器と防具のデータを登録しなきゃなぁ……w
 とりあえずデータ不足だが、今回のモックは下記のようになっている。

 インライン表示じゃない直接リンクはこちらだ。

 次回は、ボウガンのステータスをシミュレートする機能を実装しようか、武器の派生表を作ろうか、はてまた多国語化の対応をしようか……ちょいと悩み中。Vueアプリの多国語化はまだやったことないので、これをやってみたいところだなぁ。
 おっと、その前にバージョン1の公開をしないとね……w