[WPF] 簡単なインベーダー的ゲームを作ってみる

もくじ

https://tera1707.com/entry/2022/02/06/144447

# やりたいこと

これまで「ゲーム」というものを作ったことはないのだが、何となくゲーム作りに対してあこがれみたいなものがあり、いつかはやってみたいな、と思って生きてきたので、ここで一度、今仕事でよく触る `C#` ＆` WPF` で、自分で出来る範囲でゲームを作ってみようと思う。


:::note info
なので、この記事はゲームを作るにはこうするべし的な内容ではなく、 全然ゲーム作りしらないけど作ってみた的な内容なので、本当にゲームづくりをしたい方はあまり参考になさらないでください...
:::

# 環境

- Visual Studio 2022
- .NET6.0（WPFアプリ）
- C#10.0

# ざっくりイメージ

下記のようなイメージ。
(インベーダーゲームは下に自分がいて、上に向かってタマを出しますが、今回は左に自分、右に向かってタマ、でやりました。(特に意図は無し))

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352601/4031742d-05d9-2faf-c5de-f51b03d04169.png)

- 操作するキャラクター(自分)は1匹。
- 敵は複数。
- 敵は、自動で動く。
    - 最初下に移動し、画面の端まで行ったら次は上に動く。あとは繰り返し。
- 自分は、キーボードで操作する。
    - 矢印キー(↑↓←→)で、上下左右に操作できる。
- スペースキーを押すと、自分からタマがでる。
- タマは右に飛んでいき、的に当たると敵をやっつけることができる。(玉が当たると敵が消える)
- 敵はタマを打ってこない。
- 敵を全部やっつけると、画面全体に「勝ち」と表示する。
- 自分が敵に当たると、やられてしまう。
- やられると、画面全体に「負け」と表示する。

# ざっくり内部のイメージ

- 画面(xaml)
    - 外側に`<Canvas>`を配置し、ゲームのキャラクターをその中に配置する。
    - キャラクターの位置の制御は、`Canvas.SetTop()`、`Canvas.SetLeft()`で行う。
    - 自分は`<Rectangle>`で赤色で表示する。
    - タマは`<Ellipse>`でいろんな色で表示する。
    - 敵は`<Rectangle>`で黄色で表示する。
    - 勝ったとき、負けたときのMessageは、ViewBoxで画面いっぱいに表示する。
- 制御(xaml.cs)
    - fpsはおよそ30とし、その描画間隔はDispatcherTimerでとる。
    - その描画タイミング到来時に下記をぜんぶやっちゃう。
        - 味方の移動処理
        - タマの移動処理 ＆ 消去判定
        - 敵の移動処理
        - 敵とタマの当たり判定 ＆ 敵の消去処理
        - 自分と敵の当たり判定 ＆ 負け表示
    - キーの入力判定
        - キーボードの矢印キーを押している間、自分が動くようにする
        - キー入力は、Windowsに対する`KeyDown`イベント、`KeyUp`イベントを使う。
    - キャラクターが「やられる」の考え方
        - キャラクターが「生きている」イコール、該当する「ControlのVisibility」がVisibleとする。
        - キャラクターが「やられた」イコール、該当する「ControlのVisibility」がCollapsedとする。
        - タマの発射、イコール、VisibilityをVisibleにすると同時に位置を発射位置にする、とする。

# 今回作成したコード

上に書いたような考え方、仕様で作成したのが下記のコード。

```MainWindow.xaml
<Window x:Class="WpfGame.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfGame"
        mc:Ignorable="d"
        Title="MainWindow" Height="500" Width="350">
    <Grid>

        <Canvas Name="MainCanvas">
            <Rectangle Name="Mikata" Width="30" Height="10" Canvas.Left="30" Canvas.Top="30" Stroke="Black" StrokeThickness="2" Fill="Red"/>

            <Ellipse Name="Tama1" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Green"/>
            <Ellipse Name="Tama2" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Blue"/>
            <Ellipse Name="Tama3" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Pink"/>
            <Ellipse Name="Tama4" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Gray"/>
            <Ellipse Name="Tama5" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Yellow"/>
            <Ellipse Name="Tama6" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Red"/>
            <Ellipse Name="Tama7" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="White"/>
            <Ellipse Name="Tama8" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Purple"/>
            <Ellipse Name="Tama9" Visibility="Collapsed" Width="5" Height="5" Canvas.Left="0" Canvas.Top="0" Stroke="Black" StrokeThickness="1" Fill="Orange"/>

            <Rectangle Name="Teki1" Width="20" Height="20" Canvas.Left="200" Canvas.Top="30" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki2" Width="20" Height="20" Canvas.Left="200" Canvas.Top="60" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki3" Width="20" Height="20" Canvas.Left="200" Canvas.Top="90" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki4" Width="20" Height="20" Canvas.Left="250" Canvas.Top="30" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki5" Width="20" Height="20" Canvas.Left="250" Canvas.Top="60" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki6" Width="20" Height="20" Canvas.Left="250" Canvas.Top="90" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki7" Width="20" Height="20" Canvas.Left="300" Canvas.Top="30" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki8" Width="20" Height="20" Canvas.Left="300" Canvas.Top="60" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>
            <Rectangle Name="Teki9" Width="20" Height="20" Canvas.Left="300" Canvas.Top="90" Stroke="Black" StrokeThickness="2" Fill="Yellow"/>

        </Canvas>

        <Viewbox Name="ResultVb" Visibility="Collapsed">
            <TextBlock Name="ResultText" Text="勝ち" />
        </Viewbox>
    </Grid>
</Window>
```

```MainWindow.xaml.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;

namespace WpfGame
{
    public partial class MainWindow : Window//, INotifyPropertyChanged
    {
        // 移動量
        private static readonly int MoveAmount = 5;

        DispatcherTimer _posUpdateTimer;
        private Key _previewPressedKey = Key.None;
        private KeyKind _pressedKeyKind = KeyKind.None;

        private List<FrameworkElement> _tamaList = new List<FrameworkElement>();
        private List<FrameworkElement> _tekiList = new List<FrameworkElement>();

        [Flags]
        private enum KeyKind
        {
            None = 0x00,
            Left = 0x01,
            Right = 0x02,
            Up = 0x04,
            Down = 0x08,
        }

        public MainWindow()
        {
            InitializeComponent();

            _tamaList.Add(Tama1);
            _tamaList.Add(Tama2);
            _tamaList.Add(Tama3);
            _tamaList.Add(Tama4);
            _tamaList.Add(Tama5);
            _tamaList.Add(Tama6);
            _tamaList.Add(Tama7);
            _tamaList.Add(Tama8);
            _tamaList.Add(Tama9);

            _tekiList.Add(Teki1);
            _tekiList.Add(Teki2);
            _tekiList.Add(Teki3);
            _tekiList.Add(Teki4);
            _tekiList.Add(Teki5);
            _tekiList.Add(Teki6);
            _tekiList.Add(Teki7);
            _tekiList.Add(Teki8);
            _tekiList.Add(Teki9);

            // 画面更新間隔を設定
            _posUpdateTimer = new DispatcherTimer();
            _posUpdateTimer.Interval = TimeSpan.FromMilliseconds(33);
            _posUpdateTimer.Tick += _posUpdateTimer_Tick;
            _posUpdateTimer.Start();

            KeyDown += MainWindow_KeyDown;
            KeyUp += MainWindow_KeyUp;
        }

        private void _posUpdateTimer_Tick(object? sender, EventArgs e)
        {
            //Debug.WriteLine(_pressedKeyKind.ToString("X"));

            // *********** 味方の移動処理 ***********
            // キー入力を見て、味方の位置を変更する
            var left = Canvas.GetLeft(Mikata);
            var top = Canvas.GetTop(Mikata);

            if (((int)_pressedKeyKind & 0x01) != 0) 
                Canvas.SetLeft(Mikata, left - MoveAmount);
            if (((int)_pressedKeyKind & 0x02) != 0)
                Canvas.SetLeft(Mikata, left + MoveAmount);
            if (((int)_pressedKeyKind & 0x04) != 0)
                Canvas.SetTop(Mikata, top - MoveAmount);
            if (((int)_pressedKeyKind & 0x08) != 0)
                Canvas.SetTop(Mikata, top + MoveAmount);

            // *********** 玉の移動処理/消去処理 ***********
            foreach (var tama in _tamaList)
            {
                if (tama.Visibility == Visibility.Visible)
                {
                    // 移動処理
                    // 見えてる＝発射した玉だけ移動させる
                    var x = Canvas.GetLeft(tama);
                    var y = Canvas.GetTop(tama);
                    // タマはとりあえず右に進むだけにしておく
                    var newX = Canvas.GetLeft(tama) + MoveAmount * 3;
                    var newY = Canvas.GetTop(tama);

                    Canvas.SetLeft(tama, newX);
                    Canvas.SetTop(tama, newY);

                    // 消去処理
                    if (newX < 0 || newX > MainCanvas.ActualWidth
                        || newY < 0 || newY > MainCanvas.ActualHeight)
                    {
                        tama.Visibility = Visibility.Collapsed;
                    }
                }
            }

            // *********** 敵の移動処理/消去処理 *********** 
            // とりあえず画面を上下に行ったり来たりさせようとしたが、下移動して下端まで行ったら次上移動、上端まで行ったら次下、みたいにしたかったが
            // そうすると上移動中/下移動中、みたいな状態を持たせないといけない。それをUIElementでやるのがメンドウだったので、とりあえず下記にした。
            foreach (var teki in _tekiList)
            {
                if (teki.Visibility == Visibility.Visible)
                {
                    // 移動処理
                    // 見えてる＝発射した玉だけ移動させる
                    var x = Canvas.GetLeft(teki);
                    var y = Canvas.GetTop(teki);
                    // X座標が偶数なら上、奇数なら下に移動する
                    var newX = Canvas.GetLeft(teki);
                    var newY = (x % 2 == 0) ? Canvas.GetTop(teki) + MoveAmount : Canvas.GetTop(teki) - MoveAmount;
                    // 敵の上下が画面外まで移動したら、1px前(左)に移動する
                    if (newY < 0 || newY > MainCanvas.ActualHeight) newX -= 1;
                    Canvas.SetLeft(teki, newX);
                    Canvas.SetTop(teki, newY);

                    // 消去処理
                    // 取り合えず無し
                }
            }

            // *********** 玉と敵の当たり判定処理 *********** 
            foreach (var tama in _tamaList)
            {
                foreach (var teki in _tekiList)
                {
                    // 玉の現在位置
                    var tamaX = Canvas.GetLeft(tama);
                    var tamaY = Canvas.GetTop(tama);
                    // 敵の当たり範囲
                    var tekiMinX = Canvas.GetLeft(teki);
                    var tekiMaxX = tekiMinX + teki.ActualWidth;
                    var tekiMinY = Canvas.GetTop(teki);
                    var tekiMaxY = tekiMinY + teki.ActualHeight;

                    // 当たり判定
                    if (tekiMinX < tamaX && tamaX < tekiMaxX
                        && tekiMinY < tamaY && tamaY < tekiMaxY)
                    {
                        // 当たり
                        tama.Visibility = Visibility.Collapsed;
                        teki.Visibility = Visibility.Collapsed;
                    }                    
                }
            }

            // *********** 味方と敵の当たり判定処理 ***********
            foreach (var teki in _tekiList)
            {
                // 玉の現在位置
                var mikataMinX = Canvas.GetLeft(Mikata);
                var mikataMaxX = mikataMinX + teki.ActualWidth;
                var mikataMinY = Canvas.GetTop(Mikata);
                var mikataMaxY = mikataMinY + teki.ActualHeight;
                // 敵の当たり範囲
                var tekiMinX = Canvas.GetLeft(teki);
                var tekiMaxX = tekiMinX + teki.ActualWidth;
                var tekiMinY = Canvas.GetTop(teki);
                var tekiMaxY = tekiMinY + teki.ActualHeight;

                // 当たり判定
                if (tekiMinX < mikataMaxX && mikataMinX < tekiMaxX
                    && tekiMinY < mikataMaxY && mikataMinY < tekiMaxY)
                {
                    // 当たり
                    foreach (var obj in MainCanvas.Children)
                    {
                        ((FrameworkElement)obj).Visibility = Visibility.Collapsed;
                        ResultVb.Visibility = Visibility.Visible;
                    }
                    ResultText.Text = "負け";
                    _posUpdateTimer.Stop();
                    return;
                }
            }            

            // 勝利判定(全部敵がいなくなったら勝ち)
            if (!_tekiList.Any(x => x.Visibility == Visibility.Visible))
            {
                ResultVb.Visibility = Visibility.Visible;
                ResultText.Text = "勝ち";
                _posUpdateTimer.Stop();
                return;
            }
        }

        private void MainWindow_KeyDown(object sender, KeyEventArgs e)
        {
            if (_previewPressedKey == e.Key)
               // return;

            _previewPressedKey = e.Key;

            switch (e.Key)
            {
                case Key.Left:  _pressedKeyKind = (KeyKind)((int)_pressedKeyKind | (int)KeyKind.Left); break;
                case Key.Right: _pressedKeyKind = (KeyKind)((int)_pressedKeyKind | (int)KeyKind.Right); break;
                case Key.Up:    _pressedKeyKind = (KeyKind)((int)_pressedKeyKind | (int)KeyKind.Up); break;
                case Key.Down:　_pressedKeyKind = (KeyKind)((int)_pressedKeyKind | (int)KeyKind.Down); break;
                case Key.Space:
                    // 玉の発射(「表示」にしたら発射したことにする(=表示中のものだけ移動処理をする))
                    foreach (var tama in _tamaList)
                    {
                        if (tama.Visibility != Visibility.Visible)
                        {
                            Canvas.SetLeft(tama, Canvas.GetLeft(Mikata));// 味方と同じ位置にしてから
                            Canvas.SetTop(tama, Canvas.GetTop(Mikata));
                            tama.Visibility = Visibility.Visible;        // 見えるようにする
                            break;
                        }
                    }
                    break;
                default:
                    break;
            }
        }

        private void MainWindow_KeyUp(object sender, KeyEventArgs e)
        {
            switch (e.Key)
            {
                case Key.Left:　_pressedKeyKind = (KeyKind)((int)_pressedKeyKind & ~((int)KeyKind.Left)); break;
                case Key.Right: _pressedKeyKind = (KeyKind)((int)_pressedKeyKind & ~((int)KeyKind.Right)); break;
                case Key.Up:    _pressedKeyKind = (KeyKind)((int)_pressedKeyKind & ~((int)KeyKind.Up)); break;
                case Key.Down:  _pressedKeyKind = (KeyKind)((int)_pressedKeyKind & ~((int)KeyKind.Down)); break;
                default: break;
            }
        }
    }
}
```

# 作った画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352601/c3e63840-bada-3264-b011-5180259d5010.png) ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/352601/c898f9de-0405-4db5-5646-b9ebf72fd3e5.png)


# あとがき

ゲームというのもおこがましい出来だが、自分では結構作った後の自分的満足度は高かったりした。
仕事で作ってるアプリも、出来上がったときはとてもうれしいが、それとは違う満足感が(この出来でも)あった。

仕事では、画面上で何かをぐりぐり動かす、ということをしたことがないのだが、できればあまり事前情報を入れず、今の知識だけで作ってみたいと思ったので、ほぼなにも調べずに作成した。
なので、あとで調べるとやっぱりWPFでインベーダーゲームを作った先人がおられた様子。

https://takap-tech.com/entry/2017/03/17/013628

https://takap-tech.com/entry/2017/03/20/005218

こちらの方がはるかにすごい、、、
すごいが、方向性としては似ている部分もあるように見える。
(Canvasを置いてその中でいろいろなものをSetTop()、SetLeft()して位置を制御する、とか、タイマーをfpsに応じた間隔にセットして画面表示させる、とか。)

それをみると、私が今回やったやり方もあながち間違ってはない、ありといえばアリなのかな？と思った。

# 今後

今回、下記のようなことを作りながら疑問に思った。

1. そもそもWPFでゲーム作るのは一般的なのか？
- Canvasの中にいろいろFrameworkElementを入れて、Canvas内で動かしてゲームっぽく見せたが、もっと良いやり方あるのでは？
- もっときれいに書けないのか？(敵味方、玉をクラス分けするとか、、、)

とくにこの3.について、最初なにかきれいに書こうとして全然筆がすすまなくなり、結局思いつくままに汚くてもいいからガーっと書こうということで書いた。

なので、敵が9匹いたら9個のRectangleをxamlに書いたのだが、自分で作っていてコレはないな、と思った。
今後時間があるときに、この辺うまいこと作れないか試してみようと思う。