LoginSignup
7
7

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-06

もくじ

やりたいこと

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

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

環境

  • Visual Studio 2022
  • .NET6.0(WPFアプリ)
  • C#10.0

ざっくりイメージ

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

image.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 image.png

あとがき

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

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

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

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

今後

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

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

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

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

7
7
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7