TYPRISとは
こちらから遊ぶことができます。
リポジトリはこちらにあります。
ルール
画面に表示された英単語をタイピングすることで、ブロックの操作を行います。
また、5000点ごとに落下速度が少し早くなります。
また、同時消しすることで、スコアにボーナスが付きますので、ぜひ狙ってみてください。
(私のハイスコアは10800点でした...)
背景
今年度からSIerからWeb系にフロントエンドエンジニアとして転職しました。
元々Vue-CLIを使ってVueのプロジェクトを作成することが多かったのですが、転職先では主にNuxt.js+TypeScriptが用いられていたので、NuxtとTypeScriptを使って何か作りたいなと考えていました。
そんなとき、この記事をみました。
Vue + Vuex + TypeScript でテトリスを作った話
記事の最後の方で、
だいぶ後になってから気がつきましたが, せっかく属性に値を bind できるのだから SVG で描画したほうが良かったかもしれません. こんど何か図形を描画して動かすものを作るときは SVG の利用を検討しようと思います.
...いっちょやってみるか。
しかしながら、ただテトリスだけ作っても二番煎じなので、プラスアルファでタイピングゲームを付け足してみました。
プロジェクト作成
このプロジェクトの特徴はこんな感じです。
- Nuxt.js v2.8系
- スクリプト部分 -> TypeScript
- HTML部分 -> Pug
- スタイル部分 -> SASS
- Vuex -> TypeScript + vuex-module-decorators
上記プロジェクトを作成するときに役に立ったサイトは以下の通りです。下記のサイトを参考にすることで、Nuxt.js + TypeScriptの環境が出来上がるかと思います。
- TypeScriptサポート
- Nuxt.js v2.6でTypeScriptが書ける環境を構築する
- nuxt.js(v2)でpug/stylusを利用する
- vuex + typescriptをvuex-module-decoratorsで無敵になる
Nuxt.jsがどんなものとか、Vuexが何かとかは、他によりよく説明してくれているサイトがあるため、特に必要がない限り記載しません。
ここでは、Nuxt.js + TypeScriptで得られた知見をみていこうと思います。
vuex-module-decoratorsをNuxt.jsのモジュールモードで使用する
vuex-module-decoratorsの例を載せているサイトではVue-CLIもしくは一からVue.jsの環境を構築している方がほとんどでした。
同じようにNuxt.jsに適用させようとした場合、Vuexのモジュールモードが鬼門でした。
モジュールモードでは、storeフォルダにjs/tsを突っ込めば勝手にVuexが使えるようになります。
そうすると、普通にプロジェクト作成すればvuex-module-decoratorsのModuleで設定すべきstoreがありません。
import { Mutation, Action, VuexModule, getModule, Module } from "vuex-module-decorators";
import store from "@/store/store"; // <- 何をimportすればいい?
@Module({ dynamic: true, store, name: "example", namespaced: true }) // <-ここのstoreに何を設定すればいいの?
class Example extends VuexModule {
...
対処策として、Vuexのstoreをexportするだけのファイルを作成し、それをimportしてModuleに設定することにしました。
import Vuex from 'vuex'
export default new Vuex.Store({})
テトリスのアルゴリズム
盤面の用意
テトリスの盤面のため、幅10 + 2、高さ20 + 2(+2は、壁として用意)の2次元配列を用意しました。これは単なるnumberの2次元配列ではなく、オブジェクトです。理由は後述します。
export interface PlayState {
confirm: boolean // 盤面に着地したかどうか
mino: number // その座標に埋まっているブロックの種類
}
private playArea: PlayState[][] // テトリスの盤面
ブロックの種類の管理
今回は-1 ~ 7までの値を用いて、盤面にどのようなブロックが埋まっているかを判定します。
- -1: 壁
- 0: 何もない
- 1: Iの形のブロック
- 2: ロの形のブロック...
それぞれのブロックの形の管理はVue + Vuex + TypeScript でテトリスを作った話を参考にさせていただきました。
ブロック1つの型を定義し、それをブロックの種類の数だけ配列で保持します。
export default interface Mino {
name: string
color: string
stroke: string
blocks: number[][][] //回転する場合のブロックの形も記述
}
import Mino from '@/types/MinoType'
export const MinoTemplates: Mino[] = [
{
name: 'I',
color: '#D50000',
stroke: '#B71C1C',
blocks: [[[1, 1, 1, 1]], [[1], [1], [1], [1]]]
},
...
}
例えば、盤面上に「I」のブロックを表示する場合は以下のようになります
-1 | -1 | -1 | -1 | -1 | -1 | -1 | -1 | -1 | -1 | -1 | -1 |
---|---|---|---|---|---|---|---|---|---|---|---|
-1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | -1 |
-1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | -1 |
-1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | -1 |
-1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | -1 |
-1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | -1 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
ブロックを移動・回転する
これは単純に、「移動先にブロックが存在するか否か」を見るだけです。
つまり、移動先の座標に、描画したいブロックを重ねたとき、盤面が全て「0」で埋まっているかどうかを見ます。
ここで1点だけ注意があります。
例えば右に1マスだけ移動する場合、描画先の盤面が全て0かどうかを見るのですがこのままでは、移動元のブロックが描画されているため、移動不可と判定されてしまいます。
この対策が、前述した、「盤面をオブジェクト」にした理由です。
『移動先の盤面が全て「0」ある場合、もしくは「0」では無いがそれは操作中のブロックである場合』は、移動可能とすることで、操作中ブロックと着地ずみブロックの区別をつけたまま移動判定が行えます。
移動可能かどうかを見るコードを以下に示します。
/**
* 現在操作中のミノが指定した座標へ移動可能かどうかを判定する
* @param moveTo 移動先座標(絶対)
*/
@Action
canMove(moveTo: Point) {
const currentMinoTemplate = MinoTemplates[this.currentMino.minoType] //操作中のブロックの形を取得
const minoBlock = currentMinoTemplate.blocks[this.currentMino.rotate] //操作中のブロックの現在の回転向きを取得します
const yLength = minoBlock.length
for (let y = 0; y < yLength; y++) {
const xLength = minoBlock[y].length
for (let x = 0; x < xLength; x++) {
if (minoBlock[y][x] === 0) {
continue
}
const point = this.playArea[moveTo.y + y][moveTo.x + x]
if (point.confirm && point.mino !== 0) { // 盤面が着地ずみであれば移動不可
return false
}
}
}
return true
}
ブロックを実際に画面上に描画する
Vueコンポーネントへ、盤面の2次元配列を渡すだけで自動的に描画されるようにしています。
1つのブロックサイズが30pxと設定し、rectをフル活用することでブロックを表現しています。
templateタグを重ねることで、2次元配列をSVGで描画できます。
しかし、ループで普通に描画してしまうと、空マスとブロックマスの罫線が重なってしまい、レイアウトに違和感が出てしまいます。
そこで、まず下地として壁と空マスを描画しきってから、その上にブロックを描画することで、ブロックの罫線がしっかり表示されるようになりました。
SVGを用いることで、HTML要素を弄るように簡単にプログラムから描画することができるので、大変便利です。
(しかもきれい)
2次元配列を回していき、値が-1なら茶色を、0なら白、1なら赤を...という風に描画していきます。
<template lang="pug">
.play-area.d-flex.flex-column.align-items-center
svg(xmlns="http://www.w3.org/2000/svg"
:width="playAreaWidth" // 横幅(10 + 2) * ブロックサイズ
:height="playAreaHeight" // 縦幅(20 + 2) * ブロックサイズ
:viewBox="`0 0 ${playAreaWidth} ${playAreaHeight}`")
template(v-for="(row, i) in playArea")
template(v-for="(point, j) in row")
//- 空列の描画
rect(v-if="point.mino!==-1"
:y="i * blockSize"
:x="j * blockSize"
:width="blockSize"
:height="blockSize"
:fill="colColor[(j - 1) % colColor.length].fill"
stroke="white"
:stroke-width="strokeWidth")
//- 壁の描画
rect(v-else
:y="i * blockSize"
:x="j * blockSize"
:width="blockSize"
:height="blockSize"
fill="#795548"
stroke="#3E2723"
:stroke-width="strokeWidth")
template(v-for="(row, i) in playArea")
template(v-for="(point, j) in row")
//- ミノの描画
//- 後から描画しないと、罫線同士が重なりレイアウトに違和感が出る。
rect(v-if="point.mino > 0"
:y="i * blockSize"
:x="j * blockSize"
:width="blockSize"
:height="blockSize"
:fill="fillColorList[point.mino]"
:stroke="strokeColorList[point.mino]"
:stroke-width="strokeWidth")
</template>
タイピングゲーム部分を作る
今回は、英単語を入力するタイピングゲームとしています。具体的にはキーバインドによって入力したキーを取得し、画面上に表示されているどの英単語と一致するかを調べます。
UIの話
最初、列ごとに英単語を割り振り、列に対応する英単語を入力すると、その列にブロックを移動させるUIにしていました。
しかし、そうすると2点ほど問題がありました
- 単純に盤面の上下に英単語を描画するスペースがない。
- 比較的スペースに余裕のある画面右に描画すると、列と英単語を結びつけるために毎回目線を左右に振るため、こんがらがる。
- 「(画面左の盤面を見ながら)右から2列目に移動させたいな。それに対応するのは(画面右に目線を移し)〜〜〜だな。入力しよう」をブロックごとに繰り返す。
- 列10行+ホールドと落下を合わせて12個の英単語を画面に表示しなければならないため、めちゃくちゃ見辛い。
目線移動が比較的抑えるために、現在のUIの様にしています。
単一コンポーネントで、ロジックと画面描画が切り離すのが簡単なので、ちょっと動かして気に入らないUIであればすぐ修正できるのが良いですね。
ローマ字日本語入力の苦労
最初の時点では、日本語入力によるタイピングゲームを想定していました。しかし、それを実現する上での最大の難所は「ローマ字入力」でした。
例えば、ひとくちに「ん」の入力と言っても、「n入力->子音字」「nn」の2パターンが考えられます。(私は「nn」派です)
人によって入力方法は様々なので、どちらのパターンが来ても正確に「ん」と判定しなければなりません。
では、ローマ字入力ではなく、ひらがな入力を受け付ければ良いかと考えましたが、それは却下しました。
英数/かな切り替えをユーザに強制させるのは億劫ですし、Macのライブ変換によって強制的に変換させられることを考慮すると、実装に時間がかかりそうです。
タイピングゲームのためにはまず、タイピング用に英単語を用意します。
export const SentenceTemplates: string[][] = [
['ability', '能力'],
['accept', '受け入れる'],
['accompany', '伴う'],
['according to', '~によると'],
...
英単語リスト自体をアレコレするよりも、英単語リストのインデックスをアレコレした方がはるかに楽そうなので、
英単語リストのインデックスを管理する配列を用意します。
// 0 ~ SentenceTemplates.lengthまで連番で初期化する。
private shuffleSentences = [...Array(SentenceTemplates.length).keys()]
英単語インデックスはキューとして管理します。
そして、インデックス配列をシャッフルし、今回タイピングによって行う操作の数だけ英単語インデックスを取り出します。
- HOLD
- ROTATE
- 右移動
- 左移動
- 真下移動
これを初期化処理として、Vuexに記述します。
@Mutation
private INIT_TYPING() {
shuffle(this.shuffleSentences)
this.sentenceList = this.shuffleSentences.splice(0, this.choicesLength)
}
@Action
initTyping() {
this.INIT_TYPING()
}
次にキーバインドによってキーを拾い、英単語と一致判定を行います。
一致した英単語のインデックスと、ブロック操作のインデックスを対応させています。
data() {
return {
inputKeys: '',
correct: 0,
reg: new RegExp(/^[a-zA-Z0-9!-/:-@¥[-`{-~\s]*$/),
choices: [
// 英単語と合致した時に行う操作名と、実際の操作をdataに格納
{
operate: 'HOLD',
fill: '#E91E63',
callback: async () => {
await PlayModule.setHold()
}
},
{
operate: 'ROTATE',
fill: '#2196F3',
callback: async () => {
await PlayModule.rotate()
}
},
/** 省略 */
mounted() {
window.addEventListener('keydown', this.handleKeyDown)
},
methods: {
handleKeyDown(event) {
const key = event.key
switch (key) {
case 'Backspace': // バックスペースで、入力キーを1文字消す
this.inputKeys = this.inputKeys.slice(0, -1)
return
}
// Altなど、関係のないキーはスルーする。
// regは、半角英数記号スペースのみを許可する正規表現
// reg = new RegExp(/^[a-zA-Z0-9!-/:-@¥[-`{-~\s]*$/),
if (key.length !== 1 || !this.reg.test(key)) {
return
}
this.inputKeys += key
// sentenceListには、画面に描画される英単語が格納されている。
const correct = this.sentenceList.findIndex(el => {
return this.inputKeys === this.senTemps[el][0]
})
if (correct < 0) {
return
}
// 合致する英単語が存在した場合、入力をリセットし、次の英単語を描画する。
this.inputKeys = ''
TypingModule.nextTyping(correct)
TypingModule.countTypeWord()
this.choices[correct].callback()
}
}
タイピングと英単語が一致したら、次の英単語を取り出します。
一致した英単語の場所にだけ、次の英単語を取り出し格納します。
待機しているシャッフルした英単語が空になれば補充します。
単純にMath.randomを使わないのは、同じ英単語が連続で出てくることを抑制するためです。
(英単語を取り出し切らないと、同じ英単語は出てこない)
@Mutation
private SET_NEXT_SENTENCE(correctIndex: number) {
this.sentenceList[correctIndex] = this.shuffleSentences[0]
this.shuffleSentences.shift()
if (this.shuffleSentences.length === 0) { // 空になれば補充
this.shuffleSentences = [...Array(SentenceTemplates.length).keys()]
shuffle(this.shuffleSentences)
}
}
@Action
nextTyping(correctIndex: number) {
this.SET_NEXT_SENTENCE(correctIndex)
}
主に苦労したロジックは以上となります。
今後の展望
- 日本語入力に対応したタイピングゲームにしたい。
- vuex-module-decoratorsで、ちょっとした共通ロジックを作りたい場合は、クラス外に書くしかない...?
- Vue3.0が来たら書き直そう。
終わりに
コードを抜粋して見ていったため、わかりづらい部分があるので、詳細が知りたい方はぜひリポジトリをご覧ください。
まだまだ駆け出しのフロントエンジニアのため、拙いコードかと思いますが、優しく見守っていただけると幸いです。
ありがとうございました。