Laravel+Livewireで簡易タイピングゲームを作ってみる

# はじめに
Laravelで開発する際に、非同期処理の部分で困ることが多々あるので（JavaScriptの知識は基礎構文程度）、Livewireの練習がてら超簡易的なタイピングゲームをつくってみる。
プログラミング歴も乏しく、Livewireも初めての試みのため、ひとまず動けばいいや、、、というモチベーション。

動作環境
- macOS Ventura 13.2.1
- php v8.2.4
- laravel v9.19
- livewire v2.12


# 準備
Laravelのインストール
```zsh
% composer create-project laravel/laravel:^9.0 [app name]
```
Livewireのインストール
```zsh
% composer require livewire/livewire
```
composer.jsonが以下のようになればOK。
![Screenshot 2023-04-02 at 18.50.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3099827/3f0647da-5be2-44dd-ee1c-ad8f95abfaec.png)
welcome.blade.phpをコピーして、index.blade.phpに名称変更。
不要な部分は削除し、今回はtailwindを使うためheadタグの最後にscriptを追記。
```html:index.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Laravel</title>

    <!-- Fonts -->
    <link href="https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Nunito', sans-serif;
        }
    </style>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="antialiased">
    <h1 class="text-red-700">test</h1>
</body>
</html>
```
ルート情報もwelcomeの部分をindexに変更しておく。
```php:web.php
Route::get('/', function () {
    return view('index');
});
```
下記コマンドで簡易サーバーを立ち上げ、画像のように文字色にtailwindが反映されていれば準備完了。
```zsh
$ php artisan serve
```
![Screenshot 2023-04-02 at 18.59.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3099827/c023549e-b89b-2ed3-cd73-4bb8f4996b65.png)

# コンポーネントの準備
下記コマンドによりコンポーネントを作成。
```zsh
% php artisan make:livewire game
```
app/Http/Livewire/Game.php
resources/views/livewire/game.blade.php
が作成されていればOK。
index.blade.phpのheadタグの最後及びbodyタグの最後に読み込み用のタグを追記。
また、コンポーネントの呼び出し用のタグも追記しておく。
```html:index.blade.php<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Laravel</title>

    <!-- Fonts -->
    <link href="https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Nunito', sans-serif;
        }
    </style>
    <script src="https://cdn.tailwindcss.com"></script>
    @livewireStyles <!-- 追記 -->
</head>
<body class="antialiased">
    <livewire:game /> <!-- コンポーネントの呼び出し -->
    @livewireScripts <!-- 追記 -->
</body>
</html>
```
game.blade.phpを下記のように編集。
```html:game.blade.php
<div class="w-6/12 rounded-lg border border-gray-300 mx-auto mt-40 p-8">
    <!-- After start -->
        <div class="text-center">
            <span class="bg-gray-700 text-gray-300 text-lg font-medium px-2.5 py-0.5 rounded">Question 1</span>
            <h1 class="my-4">Here Question</h1>
            <input type="text" class="w-8/12 mx-auto bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 mb-4" autofocus required>
        </div>
        <!-- Before start -->
        <div class="text-center">
            <h1  class="text-2xl font-bold mb-4">My Typing</h1>
            <button class="bg-red-500 text-white hover:bg-transparent hover:text-red-500 font-bold py-2 px-32 rounded-lg border border-red-500">Start</button>
        </div>
</div>
```
この時点で以下のようになっていればOK。
初めはタイトルとstartボタンのみ表示されていて、startをクリックすると画面が切り替わる仕様に編集していく。
![Screenshot 2023-04-02 at 19.25.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3099827/46c2a25f-2ad3-6925-b3d8-91ce8a934b94.png)

# ここから本題
## スタートボタンの作成
start()を作成し、startボタンにwire:clickで紐づける。
blade側にif文を追加して、statusの状況に応じて表示の切り替え。
```php:Game.php
    public $status = 'wating';
    public function start()
    {
        $this->status = 'trying';
    }
```
```html:game.blade.php
<div class="w-6/12 rounded-lg border border-gray-300 mx-auto mt-40 p-8">
    @if ($this->status === 'trying')
        <!-- After start -->
        <div class="text-center">
            <span class="bg-gray-700 text-gray-300 text-lg font-medium px-2.5 py-0.5 rounded">Question 1</span>
            <h1 class="my-4">Here Question</h1>
            <input type="text" class="w-8/12 mx-auto bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 mb-4" autofocus required>
        </div>
    @elseif ($this->status === 'wating')
        <!-- Before start -->
        <div class="text-center">
            <h1  class="text-2xl font-bold mb-4">My Typing</h1>
            <button wire:click="start" class="bg-red-500 text-white hover:bg-transparent hover:text-red-500 font-bold py-2 px-32 rounded-lg border border-red-500">Start</button>
        </div>
    @endif
</div>
```

## 問題のランダム表示
下記のようにプロパティを追記。
words: 出題項目を配列で保持
currentWord: 現在表示中の項目を格納
solvedWords: 回答済みの項目を格納する配列の初期化

currentWordを生成する関数の内容は以下の通り。
1. words配列の中でsolvedWordsに含まれない要素を、unsolvedWordsに格納
2. array_filterを使った後は、配列のインデックスが飛び飛びになるので、array_values関数でインデックスを振り直す
3. 0からunsolvedWords配列の要素数までの乱数を生成
4. 3で得た乱数を用いて、unsolvedWordsの要素をランダムにcurrentWordに代入
```php:Game.php
    public $words = [
        'dog',
        'cat',
        'horse',
    ];
    public $currentWord;
    public $solvedWords = [];

    public function start()
    {
        $this->status = 'trying';
        $this->generateCurrentWord();
    }

    public function generateCurrentWord()
    {
        $unsolvedWords = array_filter($this->words, function ($word) { // get words that aren't inluded in $solvedWords
            return !in_array($word, $this->solvedWords);
        });
        $unsolvedWords = array_values($unsolvedWords);
        $randomIndex = rand(0, count($unsolvedWords) - 1);
        $this->currentWord = $unsolvedWords[$randomIndex];
    }
```

## テキストボックスのバインド
typingTextプロパティを追加し、blade側のinputフォームにwire:modelを追記することで紐付け。
```php:Game.php
    public $typingText = '';
```
```html:game.blade.php
    <input wire:model="typingText" type="text" class="w-8/12 mx-auto bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 mb-4" autofocus required>
```

## テキストの正誤判定
入力テキストが正解していた場合のみ、インプットボックスを初期化し、次の問題を生成。
間違っていた場合は、色を変更するような処理を加えるのもありかも。
```php:Game.php
    public function render()
    {
        if ($this->typingText === $this->currentWord) {
            array_push($this->solvedWords, $this->currentWord);
            $this->generateCurrentWord();
            $this->typingText = '';
        }
        return view('livewire.game');
    }
```

## 問題番号を動的に変更
算出プロパティを用いて、問題番号を変更する（算出プロパティでなくても出来そうだが、使ってみたかった）。
Livewireでは関数名をgetXXXProperty()とすることで、XXXという算出プロパティを定義できる。
ここでは、最終問題の場合（wordsの要素数とsolvedWordsの要素数が一致）は、番号は変更せず、それ以外の場合は+1をすることで問題番号を変更。
bladeファイルからは通常のプロパティ同様にアクセス可能。
```php:Game.php
    public function getQuestionNumberProperty() // computed property
    {
        if (count($this->words) === count($this->solvedWords)) {
            return count($this->solvedWords);
        } else {
            return count($this->solvedWords) + 1;
        }
    }
```
```html:game.blade.php
    <span class="bg-gray-700 text-gray-300 text-lg font-medium px-2.5 py-0.5 rounded">Question {{ $this->questionNumber }}</span>
```

## クリア画面の表示
現在のままでは、最後の問題入力後にエラーが出てしまうので、クリア画面に遷移させたい。
wordsの要素数とsolvedWordsの要素数が一致した場合、statusをdoneに変更し、問題の生成をストップ。
blade側もstatusに応じてCLEARの文字を画面に表示するように変更。
```php:Game.php
    public function render()
    {
        if ($this->typingText === $this->currentWord) {
            array_push($this->solvedWords, $this->currentWord);
            if (count($this->words) === count($this->solvedWords)) {
                $this->status = 'done';
            } else {
                $this->generateCurrentWord();
            }
            $this->typingText = '';
        }
        return view('livewire.game');
    }
```
```html:game.blade.php
    @elseif ($this->status === 'wating')
        <!-- Before start -->
        <div class="text-center">
            <h1  class="text-2xl font-bold mb-4">My Typing</h1>
            <button wire:click="start" class="bg-red-500 text-white hover:bg-transparent hover:text-red-500 font-bold py-2 px-32 rounded-lg border border-red-500">Start</button>
        </div>
    @elseif ($this->status === 'done') <!-- ここから追記 -->
        <div class="text-center">
            <h1  class="text-2xl font-bold">CLEAR</h1>
        </div>
    @endif
```

# まとめ
JavaScriptを書かずとも、Vue.jsのように簡易タイピングゲームを作成できた。算出プロパティやライフサイクルフックの使い方がまだまだ未知数。

今回参考にさせていただいた記事

https://blog.capilano-fw.com/?p=2707

アドバイスやご指摘等あれば、ご教示いただければ幸いです。