スペースウォー!(Spacewar!)は1962年、当時マサチューセッツ工科大学(MIT)の学生であったスティーブ・ラッセルを中心に開発された、宇宙戦争をモチーフとした対戦型コンピューターゲームです。世界初のシューティングゲームとされています。ゲヱム道館 – Gamedokanさん作成の動画を参考につくってみました。動画ではOpenGLを使用していますが、こちらではC# .NET Frameworkを使います。
当時のハードウェアの仕様で残像が出る部分もそれっぽくつくってみました。
Contents
プレイヤーの宇宙船をつくる
まずプレイヤーの宇宙船をつくります。片方はくさび形、もう一方はニードル型をしています。
ちょっと小さいので拡大すると
これをリソースとして追加しておきます。
単にビットマップを移動させるだけでなく残像になる部分も描画しなければならないのでアルファチャンネルを変えて複数のBitmapを描画します。
もとになるBitmapをつくる
もとになるBitmapを2倍のサイズで表示します。CreatePlayerBitmapでリソースのBitmapを読み込んで、背景を黒で画像ファイルでは黒い部分を白で表示できるようにしています。
拡大処理をするときに色が変化してしまわないようにInterpolationMode.NearestNeighborを指定し、Bitmap.GetPixelメソッドで取得した色がColor.Black.ToArgb()でないときは何も書き込まないようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
public class Player { Bitmap Bitmap; List<Bitmap> AfterimageBitmaps; List<Pen> AfterimagePens; public Player(Bitmap bitmap) { Bitmap = CreatePlayerBitmap(bitmap); Size = Bitmap.Size; AfterimageBitmaps = CreateAfterimageBitmaps(Bitmap); AfterimagePens = CreateAfterimagePens(); } public Size Size { get; private set; } Bitmap CreatePlayerBitmap(Bitmap sourceBitmap) { Size size = new Size(sourceBitmap.Width * 2, sourceBitmap.Height * 2); Bitmap temp = new Bitmap(size.Width, size.Height); Graphics graphics = Graphics.FromImage(temp); graphics.InterpolationMode = InterpolationMode.NearestNeighbor; graphics.DrawImage(sourceBitmap, new Rectangle(0, 0, size.Width, size.Height)); graphics.Dispose(); Bitmap bitmap = new Bitmap(size.Width, size.Height); for (int x = 0; x < temp.Width; x++) { for (int y = 0; y < temp.Height; y++) { Color color = temp.GetPixel(x, y); if (color.ToArgb() == Color.Black.ToArgb()) { bitmap.SetPixel(x, y, Color.White); } } } return bitmap; } } |
残像にあたるBitmapを生成する
次に残像にあたる部分を描画するためのBitmapのリストを生成します。残像は32フレームのあいだ不透明度を減らしながら描画されます。ただしリストへは不透明度が低いものから追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public class Player { int AfterimageCount = 32; List<Bitmap> CreateAfterimageBitmaps(Bitmap bitmap) { List<Bitmap> bitmaps = new List<Bitmap>(); for (int i = 0; i < AfterimageCount; i++) { Bitmap bitmap1 = new Bitmap(bitmap.Width, bitmap.Height); for (int x = 0; x < bitmap.Width; x++) { for (int y = 0; y < bitmap.Height; y++) { Color color = bitmap.GetPixel(x, y); if (color.ToArgb() != 0) { Color newColor = Color.FromArgb(64 * i / AfterimageCount, color.R, color.G, color.B); bitmap1.SetPixel(x, y, newColor); } } } bitmaps.Add(bitmap1); } return bitmaps; } } |
CreateAfterimagePensメソッドは宇宙船後部のロケット噴射を描画するためのものです。これも32段階で不透明度が低いものからPenを生成してリストに追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class Player { List<Pen> CreateAfterimagePens() { List<Pen> pens = new List<Pen>(); for (int i = 0; i < AfterimageCount; i++) { Color newColor = Color.FromArgb(128 * i / AfterimageCount, 255, 255, 255); pens.Add(new Pen(newColor, 2)); } return pens; } } |
宇宙船を描画する
宇宙船を描画する処理を示します。
自機死亡の場合は描画の処理は必要ないのでreturnしています。宇宙船は方向転換するのでアフィン変換をしてGraphics.DrawImageメソッドで描画します。宇宙船の中心座標とサイズから矩形の3点を求め、Matrix.TransformPointsメソッドに中心座標と角度を渡して変換された3点を取得します。配列には5点格納していますが、最後の2点はロケット噴射を描画するためのものです。
残像は古いものから先に描画しないといけないのでDrawメソッドが実行されるたびに新しい座標を先頭に追加して最新の32個を取得します。そして古い順に並べ替えて描画しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
public class Player { public bool IsDead = false; public double CenterX = 0; public double CenterY = 0; int _angle = 0; // 度数法 0以上360以下 public int Angle { get { return _angle; } set { _angle = value; if (_angle > 360) _angle -= 360; else if (_angle < 0) _angle += 360; } } List<Point[]> AfterimagePoints = new List<Point[]>(); Random Random = new Random(); bool IsAccelerating = false; public void Draw(Graphics graphics) { if (IsDead) return; int centerX = (int)CenterX; int centerY = (int)CenterY; int injectionLengrh = 0; if (IsAccelerating) injectionLengrh = Random.Next(20); Point[] points = new Point[] { // 宇宙船本体を描画するための3点 new Point(centerX - Size.Width / 2, centerY - Size.Height / 2) , new Point(centerX + Size.Width / 2, centerY - Size.Height / 2) , new Point(centerX - Size.Width / 2, centerY + Size.Height / 2) , // ロケット噴射を描画するための2点 new Point(centerX, centerY + Size.Height / 2), new Point(centerX, centerY + Size.Height / 2 + injectionLengrh), }; Matrix matrix = new Matrix(); matrix.RotateAt(Angle, new Point(centerX, centerY)); matrix.TransformPoints(points); // リストの最初に追加 AfterimagePoints.Insert(0, points); // 最新のAfterimageCount個のみ取得 AfterimagePoints = AfterimagePoints.Take(AfterimageCount).ToList(); // 古いものから描画したいのでリストのコピーを生成して順番を並べ替える List<Point[]> points1 = new List<Point[]>(AfterimagePoints); points1.Reverse(); // 残像を描画 int count = AfterimagePoints.Count; for (int i = 0; i < count; i++) { graphics.DrawImage(AfterimageBitmaps[i], points1[i].Take(3).ToArray()); graphics.DrawLine(AfterimagePens[i], points1[i][3], points1[i][4]); } // 現在の状態を描画 graphics.DrawImage(Bitmap, points.Take(3).ToArray()); graphics.DrawLine(AfterimagePens.Last(), points[3], points[4]); } } |
移動できるようにする
描画するだけでなく移動できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public class Player { public double VX = 0; public double VY = 0; public void Move() { if (IsDead) return; // 連射に制限を加える // CountUntilCanShootが0以下にならないと弾丸を発射できない CountUntilCanShoot--; // なにもしないと減速する Decelerate(); // 速度に応じて中心座標を変更する CenterX += VX; CenterY += VY; // 端にきたら反対側に移動させる WarpIfNeed(); } } |
ワープと減速の処理を示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public partial class Form1 : Form { public Form1() { InitializeComponent(); FormSize = ClientSize; } // 外部からForm1.ClientSizeを取得できるようにする public static Size FormSize { get; private set; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
public class Player { void WarpIfNeed() { if (CenterX > Form1.FormSize.Width) { CenterX -= Form1.FormSize.Width; CenterY = Form1.FormSize.Height - CenterY; } else if (CenterX < 0) { CenterX += Form1.FormSize.Width; CenterY = Form1.FormSize.Height - CenterY; } if (CenterY > Form1.FormSize.Height) { CenterY -= Form1.FormSize.Height; CenterX = Form1.FormSize.Width - CenterX; } else if (CenterY < 0) { CenterY += Form1.FormSize.Height; CenterX = Form1.FormSize.Width - CenterX; } } void Decelerate() { if (VX > 0) VX -= 0.005; else VX += 0.005; if (VY > 0) VY -= 0.005; else VY += 0.005; } } |
弾丸を発射する
弾丸を発射する処理を示します。弾丸の移動と描画はあとで作成するBurretクラスで行ないます。
連射を無制限にできないようにCountUntilCanShootが0よりも大きいときは発射できない仕様にしています。前述のMoveメソッドのなかで減算され、弾丸を発射するとCountUntilCanShootMaxに戻ります。
弾丸は宇宙船が向いている方向に発射されますが、慣性の法則によって逆方向に進行しているときは弾丸の速度は相殺されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class Player { const int CountUntilCanShootMax = 10; int CountUntilCanShoot = CountUntilCanShootMax; public bool Shot() { if (IsDead) return false; if (CountUntilCanShoot > 0) return false; CountUntilCanShoot = CountUntilCanShootMax; double rad = Math.PI * Angle / 180; double vx = (Math.Sin(rad) * 4); double vy = -(Math.Cos(rad) * 4); Matrix matrix = new Matrix(); matrix.RotateAt(Angle, new PointF((float)CenterX, (float)CenterY)); Point point = new Point((int)CenterX, (int)CenterY - Size.Height / 2 -1); Point[] points = new Point[1]; points[0] = point; matrix.TransformPoints(points); Burret.AddBurret(new Burret(points[0].X, points[0].Y, vx + VX, vy + VY)); return true; } } |
Form1クラスの処理
Form1クラスの処理を示します。
コンストラクタ内でリソースからプレイヤー(自機と敵機)を生成します。そしてタイマーでTickイベントを処理できるようにしておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
public partial class Form1 : Form { Timer Timer = new Timer(); Player Player; Player Enemy; Sun Sun; public Form1() { InitializeComponent(); FormSize = ClientSize; Player = new Player(Properties.Resources.wedge); Enemy = new Player(Properties.Resources.needle); ResetPlayer(); ResetEnemy(); // 太陽(後述) Sun = new Sun(ClientSize.Width / 2, ClientSize.Height / 2); Timer.Interval = 1000 / 60; Timer.Tick += Timer_Tick; Timer.Start(); this.DoubleBuffered = true; this.BackColor = Color.Black; } public void ResetEnemy() { Enemy.CenterX = Form1.FormSize.Width - 50; Enemy.CenterY = Form1.FormSize.Height - 50; Enemy.Angle = 0; Enemy.VX = 0; Enemy.VY = 0; Enemy.IsDead = false; } void ResetPlayer() { Player.CenterX = 50; Player.CenterY = 50; Player.Angle = 180; Player.VX = 0; Player.VY = 0; Player.IsDead = false; } } |
キー操作による自機の操作
キーが押されたら自機を操作することができます。← と → キーは方向転換ができるだけです。↑キーで加速します。減速するためには速度が自然に低下するのを待つか、逆方向を向いて加速するしかありません。操縦はしにくいです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
public partial class Form1 : Form { bool MoveLeft = false; bool MoveRight = false; bool MoveUp = false; protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Left) MoveLeft = true; if (e.KeyCode == Keys.Up) MoveUp = true; if (e.KeyCode == Keys.Right) MoveRight = true; if (e.KeyCode == Keys.Space) Shot(); base.OnKeyDown(e); } protected override void OnKeyUp(KeyEventArgs e) { if (e.KeyCode == Keys.Left) MoveLeft = false; if (e.KeyCode == Keys.Up) MoveUp = false; if (e.KeyCode == Keys.Right) MoveRight = false; base.OnKeyUp(e); } WMPLib.WindowsMediaPlayer MediaPlayer1 = new WMPLib.WindowsMediaPlayer(); void Shot() { if (Player.Shot()) { // 音を鳴らす MediaPlayer1.URL = Application.StartupPath + "\\shot.mp3"; // 最初から再生しようとすると連射したときにまったく音が出ないので途中から再生する MediaPlayer1.controls.currentPosition = 0.1; } } } |
移動と描画
Timer.Tickイベントが発生したらどの方向キーが押されているかを調べて自機の運動を変化させ、Invalidateメソッドを呼び出して描画します。自機を移動させると残像もいい感じに描画されているのがわかります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public partial class Form1 : Form { private void Timer_Tick(object sender, EventArgs e) { if (MoveLeft) Player.Angle -= 4; if (MoveRight) Player.Angle += 4; if (MoveUp) Player.Accelerate(true); else Player.Accelerate(false); Invalidate(); } protected override void OnPaint(PaintEventArgs e) { Player.Draw(e.Graphics); Enemy.Draw(e.Graphics); base.OnPaint(e); } } |