Blazor WebAssembly で "snow catch" ゲームを作ってみる (前編)

# "Snow Catch" ゲームを作ろう

もう 4 年ほど昔の話になるのですが、"Schoo (スクー)" という生放送主体の授業サービスで、Visual Studio Code を使った、HTML や JavaScript のコーディングについての授業がありました (下記リンク)。

- ["Visual Studio アプリ開発超入門 ～ Visual Studio Code と HTML5 でシンプルなゲームを作ろう"](https://schoo.jp/class/3119)

この授業の題材 ("シンプルなゲーム") として、"Snow Catch" という、「ユーザー機である雪だるまを左右に操作して、上から降ってくる雪をキャッチする」ゲームを作っていました。

クリスマスも近いということで、この**クリスマス感溢れる** "Snow Catch" を、Blazor WebAssembly として再実装してみることにしました。

ゲーム画面はこんな感じになります。
![movie-001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/c25ee244-b322-c691-c5ea-1f2180e1ff11.gif)

さて、実際にこうして Qiita に記事化してみたところ、結構な文章量となってしまい、途中で息切れしてしまいました。
(※簡単なゲームなので、大した記事量にならないかと思っていたのですが、読みが甘かったです)

そこでこの「Blazor WebAssembly で "snow catch" ゲームを作ってみる」は、前編・後編の二部構成で投稿します。

# 空の Blazor WebAssembly プロジェクトを作る

まず前提条件として、.NET SDK が開発環境にインストールされている必要があります。
適宜、事前にインストールを済ませておきます。
( .NET SDK のインストールについては、先日投稿した Qiita 記事、"[【とりあえず動かしたい超初心者向け】はじめての Blazor WebAssembly 環境構築"](https://qiita.com/jsakamoto/items/2402cdd6ff45592d3580)  も参考になるかもしれません)

.NET SDK さえインストールされていれば、ターミナル (コマンドプロンプト) から "`dotnet new blazorwasm`" と実行すれば、Blazor WebAssembly アプリプロジェクトが出来上がります...

が、この既定のプロジェクトテンプレートから作られる Blazor WebAssembly アプリは、Bootstrap によるデザインやらルーティングやら共通レイアウトやら、色々とはじめから仕込み済みとなっています。
今回作ろうとしようとしているような、"ペライチ" の極超シンプルな Web アプリの場合は、まずそれら不要な仕掛けやコンテンツを削除していく手間が発生します。

そこで今回は、色々な仕込みなしの、**本当に空っぽな Blazor WebAssembly アプリを作成するプロジェクトテンプレート**を先に別途インストールしておいて、それを使うことにします。

まずはターミナル (コマンドプロンプト) から以下を実行して、その空っぽプロジェクトテンプレートを開発環境にインストールします。

```shell
$ dotnet new --install Toolbelt.AspNetCore.Blazor.Minimum.Templates
```

上記コマンドが成功したら、以後、"`blazorwasmmin`" というテンプレート名で、空っぽ Blazor WebAssembly プロジェクトを生成できます。

ということで、以下の要領で、作業フォルダ "SnowCatch" を作成し、その作業フォルダにカレントフォルダを移動して、`dotnet new ...` コマンドで、空っぽな Blazor WebAssembly プロジェクトを作成しましょう。

```shell
$ mkdir SnowCatch
$ cd SnowCatch
$ dotnet new blazorwasmmin
```

プロジェクトが作成できたら、すかさず、`dotnet watch run` を実行して、ちゃんとビルドされて Web ブラウザで表示されるか確認しておきましょう。

```shell
$ dotnet watch run
```

下図のとおり Web ブラウザが起動すれば成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/1364e271-9720-ce9d-5e45-5b7076d2fb9f.png)

# 実装方針

さて話が前後しますが、実装方針を記しておきます。

まず、この Blazor WebAssembly 内で、"ゲームコンテキスト" と呼ぶことにするクラスインスタンスを作成し、そのゲームコンテキストにて、ゲームの状態 ―― 雪や雪だるまの座標であったり、スコアであったりなど ―― を、保持・更新していくことにします。

そして、そのゲームコンテキストのオブジェクトの内容を、タイマーによる更新にて、Blazor コンポーネントを使って HTML の DOM 要素に "レンダリング" することにします。

雪や雪だるまなどのキャラクタは単純な `<img>` 要素としてそれぞれのキャラクタの画像ファイルを表示し、`position: absolute;` の CSS スタイル指定とあわせて、`px` 単位での座標値指定で配置することにします。

雪、および雪だるまの画像は、自分は Microsoft Word を持っているので、その Icons 画像から拝借しました。

# ゲームコンテキストクラスを作る

ではゲームコンテキストクラスを作りましょう。

これは簡単で、単にプロジェクトフォルダに `GameContext.cs` というファイル名で空ファイルを作り、同名の C# クラスを実装するだけです。
後のことを考えて、コンストラクターまでは実装しておきます。

```csharp:GameContext.cs
public class GameContext
{
    public GameContext()
    {
    }
}
```

# ゲームエリアを定義する

次は "ゲームエリア"、すなわち、雪や雪だるまが動き回れる矩形を定義します。

ここで、座標系を取り扱うデータ型を自分で定義する手間をサボるために、`System.Drawing.Primitives` NuGet パッケージをプロジェクトにインストールして、手を抜きます。

プロジェクトファイル (`SnowCatch.csproj`) があるフォルダをカレントフォルダとして、ターミナル (コマンドプロンプト) から下記のとおり NuGet パッケージ参照の追加のコマンドを実行します。

```shell
$ dotnet add package System.Drawing.Primitives
```

これで、名前空間 `System.Drawing` 配下にある、`Size` や `Rectangle` という構造体が使えるようになりました。

これを踏まえて、ゲームコンテキストクラスのフィールドとして、ゲームエリアを定義する矩形情報を `Rectangle` 構造体で実装します。
ゲームエリアのサイズは、横 320px、縦 480px とします。

```csharp:GameContext.cs
using System.Drawing; // 👈 名前空間の利用を追加

public class GameContext
{
    // 👇 下記フィールド変数を追加
    public Size GameAreaSize = new(width: 320, height: 480);
    ...
```

# ゲームエリアを表示する

次は、こうしてゲームコンテキストクラス上に定義されたゲームエリアのサイズ情報に基づき、ゲームエリアを HTML 上の `<div>` 要素として、ブラウザ画面上に表示されるようにします。

Blazor アプリケーションにおいて HTML の構築を司るのは `.razor` ファイルになります。
プロジェクトテンプレートによって、HTML DOM ツリー構築の起点となる `App.razor` が用意されていますので、この `App.razor` を書き換えていきます。

`App.razor` をテキストエディタで開き、いったん中身を全部削除します。

次いで、`@code { ... }` のコードブロックを形成し、そのコードブロック内で、フィール変数として先ほど実装したゲームコンテキストクラスをインスタンス化して保持するようにします。

```csharp:App.razor
@code {
    private GameContext Context = new();
}
```

そして、このゲームコンテキストクラスのフィールド変数を参照して、幅と高さを CSS スタイルシートで指定した `<div>` 要素をマークアップします。

また、背景色は黒とし、およびこのゲームエリアの子要素として、雪と雪だるまの各 `img` 要素を配置するつもりなので、このゲームエリアが相対座標の起点となるよう、`position: relative` の CSS スタイル指定も付記します。

```html:App.razor
<!-- 👇 先ほどのコードブロックの上に、下記 div 要素のマークアップを追加 -->
<div
    style="width: @(Context.GameAreaSize.Width)px; height: @(Context.GameAreaSize.Height)px; background-color: black; position: relative;">
</div>

@code {
    ...
```

これで下図のように、ゲームエリアがブラウザ画面に表示されるようになりました。
![fig001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/7a9a6c83-348b-ecb4-15fe-77123368887c.png)

# 雪の画像の、サイズと位置を実装する

それでは次は、上から降ってくる雪の作り込みにとりかかります。

実装方針のとおり、まずはゲームコンテキスト上で雪の表示場所を表現し、それを HTML 上に表示反映するようにします。

雪の画像の表示位置とサイズを定義するため、ここでも `System.Drawing` 名前空間に含まれる構造体を流用します。
ゲームエリアとちがって、サイズ (幅と高さ) だけではなくて、雪の画像の表示座標も必要ですので、ここでは `Rectangle` 構造体を使って実装します。

ということで、ゲームコンテキストクラスに、雪の画像の表示位置とサイズを示す、`Rectangle` 型のフィールド変数を、`SnowFlakeRectangle` という名前で追加しましょう。
フィールド変数定義時点では、とりあえず表示位置は x 座標 = 0, y 座標 = 0 としておきます。

```csharp:GameContext.cs
public class GameContext
{
    public Size GameAreaSize = new(width: 320, height: 480);

    // 👇 下記の行を追加
    public Rectangle SnowFlakeRect = new(x: 0, y: 0, width: 48, height: 48);
    ...
```

# 雪の画像を表示する

ゲームコンテキストクラス側で、雪の画像の表示位置とサイズの情報が示されるようになりましたので、これを HTML 上の `<img>` 要素として、ゲームエリア内に表示されるようにします。

まず、雪の画像ファイルを作成し、プロジェクトフォルダ内の `wwwroot` フォルダ内に収録します。
自分は SVG 形式で画像を作成し、`wwwroot/assets/snowflake.svg` として保存しました。

続けて、`App.razor` を編集し、ゲームエリアの `<div>` 要素内に、`<img>` 要素として雪の画像を表示する要素を追記します。

そして、ゲームコンテキストクラスのフィールド変数を参照して、雪の画像の表示位置とサイズを、この `<img>` 要素の CSS スタイルに指定します。

プロジェクトフォルダ内の `wwwroot` フォルダが、Web アプリのルートパス (`/`) にマップされますので、雪の画像の `<img>` 要素の `src` 属性には、`"assets/snowflake.svg"` というように、雪の画像の画像ファイルをsiteします。 

ゲームエリア `<div>` 要素内の相対座標位置に表示しますので、`position: absolute;` の CSS スタイル指定も付けます。

```html:App.Razor
<div... >
    <!-- 👇 ゲームエリアの div 要素内に、下記 img 要素を追記する -->
    <!-- Snow Flake -->
    <img src="assets/snowflake.svg"
        style="left:@(Context.SnowFlakeRect.X)px; top:@(Context.SnowFlakeRect.Y)px; width:@(Context.SnowFlakeRect.Width)px; height:@(Context.SnowFlakeRect.Height); position:absolute;" />
    ...
```
これで Web ブラウザを再読込してみると、雪の画像が表示されるようになりました (下図)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/20ed1f8a-3355-a384-eeb3-280aa8237988.png)

# 雪の画像の Y 座標値を、上から下へ降ってくるように更新する

次は、この雪の画像を、上から下へ降ってくるようにします。

このために、ゲームコンテキストクラスにタイマーを導入します。
つまり、タイマーで数ミリ秒ごとに、雪の画像の Y 座標を少しずつ増加させます。
この動作が連続して表示されることで、雪の画像が上から下へ移動していくようになります。

早速、以下のように、ゲームコンテキストクラスにタイマーを追加します。
インターバル (タイマー発火の間隔) は 30ミリ秒としてみました。

```csharp:GameContext.cs
using System.Drawing;
using System.Timers; // 👈 この名前空間の利用宣言を追加

public class GameContext
{
    ...
    // 👇 下記フィールド変数としてタイマオブジェクトを追加
    public Timer GameLoopTimer = new(interval: 30);
    ....
```

そしてゲームコンテキストクラスのコンストラクターにて、このタイマの発火イベントをハンドルし (イベントハンドラーのメソッドも追加します)、タイマを始動させるようにします。

```csharp:GameContext.cs
using System; // 👈 この名前空間の利用宣言を追加
...
    public GameContext()
    {
        // 👇 下記2行を追加 (タイマー発火イベントにハンドラを設定し、タイマ開始)
        this.GameLoopTimer.Elapsed += GameLoopTimer_Elapsed;
        this.GameLoopTimer.Start();
    }

    // 👇 下記、タイマー発火イベントのハンドラメソッドを追記
    private void GameLoopTimer_Elapsed(object sender, EventArgs e)
    {
    }
}
```

そして、30ミリ秒ごとのタイマー発火によって呼び出される、`GameLoopTimer_Elapsed()` メソッド内にて、雪の画像の Y 座標値を加算するようにします。
(Y 座標の追加幅は 10 としてみました)

また、雪の画像の Y 座標をゲームエリア最上部 (より、雪の画像自身の高さぶん上) にリセットするメソッドを追加し、雪の画像の表示がゲームエリア下限の外に出てしまったら、この雪の画像の座標値をリセットするメソッドを呼び出すようにします。
(これで、延々、雪の画像が、上から下へ振り続けるようになります)

```csharp:GameContext.cs
    ...
    private void GameLoopTimer_Elapsed(object sender, EventArgs e)
    {
        // 👇 タイマ発火する度に、雪の画像の Y 座標を +10 加算
        this.SnowFlakeRect.Y += 10;
        // 👇 雪の画像がゲームエリア外に出てしまったら、雪の画像の座標値をリセット
        if (this.SnowFlakeRect.Y > this.GameAreaSize.Height) ResetSnowFlakePos();
    }

    // 👇 実際に、雪の画像の座標値をリセットするメソッドを追加
    private void ResetSnowFlakePos()
    {
        this.SnowFlakeRect.Y = -this.SnowFlakeRect.Height;
    }
}
```

# 雪の画像が上から降ってくる様子を表示する

以上までの実装で、ブラウザ画面をリロードしてみると、残念ながら雪の画像は、ゲームエリア上部に初期表示されたまま、動くようには見えません。

これは Blazor の仕組み上、タイマー発火しただけでは Blazor はどのコンポーネントの HTML を再更新すべきなのか判断が付かないため (結果として HTML の再構築が実行されないため) です。

そこでゲームコンテキストクラスのタイマが発火するたびに、`App.razor` 側で `StateHasChanged()` メソッドを呼び出し、Blazor に対して HTML の再構築を発生させるようにします。

ということで、`App.razor` 側でも、ゲームコンテキストクラスのタイマのイベントをハンドルするよう実装します。

```csharp:App.razor
...
@code {
    ...
    // 👇 OnInitialized() 仮想メソッドのオーバーライドを追加し、
    //    このメソッド内でゲームコンテキストのタイマイベントを捕捉する
    protected override void OnInitialized()
    {
        this.Context.GameLoopTimer.Elapsed += GameLoopTimer_Elapsed;
    }

    // 👇 タイマのイベントハンドラメソッドを追加し、
    //    この中で StateHasChanged() を実行する
    private void GameLoopTimer_Elapsed(object sender, EventArgs e)
    {
        this.StateHasChanged();
    }
}
```
これで雪の画像が、延々と上から下へ降り続けるようになりました。
![movie-002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/cca21bdd-e4d7-46b1-6f2b-b0acebc24890.gif)

# 雪の画像の横方向の座標を、ランダムにする

このままだと、雪の画像は常にゲームエリア左端に降ってくるのみです。
これを、横方向はランダムに降ってくるようにします。

雪の画像が降ってくる横方向の座標値 (X座標) は、乱数で決めたいと思いますので、`Random` クラスのインスタンスを、ゲームコンテキストクラスのフィールド変数として用意します。

```csharp:GameContext.cs
...
public class GameContext
{
    ...
    // 👇 下記、Random オブジェクトのフィールド変数を追加
    private Random Random = new();
    ...
```

そして、雪の画像が下にスクロールアウトしたときに Y 座標値をリセットするメソッド内にて、同時に X 座標も、乱数に基づいて設定するようにします。

```csharp:GameContext.cs
    ...
    private void ResetSnowFlakePos()
    {
        this.SnowFlakeRect.Y = -this.SnowFlakeRect.Height;

        // 👇 下記、X 座標を乱数に基づいて設定する行を追加
        this.SnowFlakeRect.X = this.Random.Next(minValue: 0, maxValue: this.GameAreaSize.Width - this.SnowFlakeRect.Width);
    }
}
```

せっかくなので、ゲームコンテキストクラスがインスタンス化されるタイミング、コンストラクターでも、この雪の画像の座標値をリセットするメソッドを呼び出しておきます。

```csharp:GameContext.cs
    ...
    public GameContext()
    {
        // 👇 ゲームコンテキストクラスのコンストラクター内に、
        //    下記、雪の画像の座標値をリセットするメソッドの呼び出しを追加
        this.ResetSnowFlakePos();
        ...
}
```
これで、雪の画像が、横方向の座標値はランダムに降ってくるようになりました。
![movie-003.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/1167f257-3474-ec33-79b2-fe87e2d67a79.gif)

# 前編はここまで

前編はいったんここまでとします。

後編ではいよいよ、雪だるまの画像が登場です。
雪だるまをキーボードで操作できるようにし、雪と雪だるまとの衝突判定など、作り込んでいく予定です。

**_[2020/12/24 追記]_**
[後編](https://qiita.com/jsakamoto/items/6c96274c75c6ea2a77f7)書きました!