もくじ
やりたいこと
これまで「ゲーム」というものを作ったことはないのだが、何となくゲーム作りに対してあこがれみたいなものがあり、いつかはやってみたいな、と思って生きてきたので、ここで一度、今仕事でよく触る C#
& WPF
で、自分で出来る範囲でゲームを作ってみようと思う。
なので、この記事はゲームを作るにはこうするべし的な内容ではなく、 全然ゲーム作りしらないけど作ってみた的な内容なので、本当にゲームづくりをしたい方はあまり参考になさらないでください...
環境
- Visual Studio 2022
- .NET6.0(WPFアプリ)
- C#10.0
ざっくりイメージ
下記のようなイメージ。
(インベーダーゲームは下に自分がいて、上に向かってタマを出しますが、今回は左に自分、右に向かってタマ、でやりました。(特に意図は無し))
- 操作するキャラクター(自分)は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にすると同時に位置を発射位置にする、とする。
今回作成したコード
上に書いたような考え方、仕様で作成したのが下記のコード。
<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>
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;
}
}
}
}
作った画面
あとがき
ゲームというのもおこがましい出来だが、自分では結構作った後の自分的満足度は高かったりした。
仕事で作ってるアプリも、出来上がったときはとてもうれしいが、それとは違う満足感が(この出来でも)あった。
仕事では、画面上で何かをぐりぐり動かす、ということをしたことがないのだが、できればあまり事前情報を入れず、今の知識だけで作ってみたいと思ったので、ほぼなにも調べずに作成した。
なので、あとで調べるとやっぱりWPFでインベーダーゲームを作った先人がおられた様子。
こちらの方がはるかにすごい、、、
すごいが、方向性としては似ている部分もあるように見える。
(Canvasを置いてその中でいろいろなものをSetTop()、SetLeft()して位置を制御する、とか、タイマーをfpsに応じた間隔にセットして画面表示させる、とか。)
それをみると、私が今回やったやり方もあながち間違ってはない、ありといえばアリなのかな?と思った。
今後
今回、下記のようなことを作りながら疑問に思った。
- そもそもWPFでゲーム作るのは一般的なのか?
- Canvasの中にいろいろFrameworkElementを入れて、Canvas内で動かしてゲームっぽく見せたが、もっと良いやり方あるのでは?
- もっときれいに書けないのか?(敵味方、玉をクラス分けするとか、、、)
とくにこの3.について、最初なにかきれいに書こうとして全然筆がすすまなくなり、結局思いつくままに汚くてもいいからガーっと書こうということで書いた。
なので、敵が9匹いたら9個のRectangleをxamlに書いたのだが、自分で作っていてコレはないな、と思った。
今後時間があるときに、この辺うまいこと作れないか試してみようと思う。