ASP.NET Coreで3DのSpaceWar!のような対戦型ゲームをつくります。こんな感じのゲームができます。
以前、WindowsFormsでSpaceWar!を真似たゲームをつくりました。それが下の動画です。
スペースウォー!(Spacewar!)は1962年、当時マサチューセッツ工科大学(MIT)の学生であったスティーブ・ラッセルを中心に開発された、宇宙戦争をモチーフとした対戦型コンピューターゲームです。世界初のシューティングゲームとされています。今回はオンラインで対戦できるような3Dゲームとしてつくってみることにしました。
ゲームの仕様
自機はこれを真後ろから見た状態で中央に表示されます。Spacewar!の大きな特徴として、宇宙船に慣性が働くこと、画面中心の太陽に重力が存在し、宇宙船の動作に影響を与えることが挙げられます。方向転換しただけでは進行方向を変更することはできず、停止するためには移動方向と逆方向を向いて加速しなければなりません。ところがこれをやろうとすると操作性が悪くなりすぎるので採用しないことにしました。
同時に対戦できる数は「8」です。それに満たない場合はNPCで埋め合わせます。
とりあえずの完成品ができたので、いつもお世話になっているT.Umezawaさんに見ていただいたのですが、ここでいくつかの改善案が示されました。
↑キーで下降、↓キーで上昇にできないか?
一発死亡ではなくシールド制にしてはどうか?
レーダーをつけてほしい。
プレイヤー名も表示させる。
そこでこれらにも対応したものを公開します。
SpaceWarGameクラスの定義
名前空間はSpaceWarとします。そしていつもどおりGameクラスを定義します。
1 2 3 4 5 6 |
namespace SpaceWar { public class SpaceWarGame { } } |
以降は名前空間を省略して以下のように書きます。
1 2 3 |
public class SpaceWarGame { } |
定数とフィールド変数
SpaceWarGameで定義するのは各定数とプレイヤーのリストの管理です。定数を定義した部分を示します。
シールド制にすると立て続けにやられてすぐに死亡してしまわないようにダメージをうけたときは約1秒間無敵状態にします。自機死亡後、次の自機が登場したときの無敵時間は約3秒です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class SpaceWarGame { public const int UPDATES_PER_SECOND = 24; // 1秒あたりの更新回数 public const int FIELD_SIZE = 4000; // フィールドの端から端までの長さ public const int SPARK_LIFE_MAX = UPDATES_PER_SECOND / 2; // 爆発の火花を表示する時間(0.5秒) public const int PLAYER_MAX = 8; // プレイヤー数 public const int REST_MAX = 3; // 残機数 public const int LIFE_MAX = 10; // シールド(ダメージをうけると減り0になったら死亡) public const int INVINCIBLE_TIME_MAX = 3 * UPDATES_PER_SECOND; // 次機登場時の無敵の時間(3秒) public const int SHORT_INVINCIBLE_TIME_MAX = UPDATES_PER_SECOND; // ダメージをうけた直後の無敵時間 public const double PLAYER_SPEED = 16; // プレイヤーの移動速度 public const double NPC_SPEED = 8; // NPCの移動速度 // 方向転換の処理は人間よりコンピュータのほうが正確なのでプレイヤーをやや有利にしている } |
Playerオブジェクトを格納するリストを示します。
_playersはサーバーサイドに接続したときに付与されるIDとオブジェクトの辞書です。_npcsはNPC、_playersWithoutNPCはNPCではない普通のプレイヤー、_allPlayersは両方です。_playersと_npcsがあればそこから_playersWithoutNPCと_allPlayersは取得できるのですが、処理に時間がかかると処理落ちしてしまうため、最初にフィールド変数に格納しておき、すぐに結果を返せるようにしています。
1 2 3 4 5 6 7 |
public class SpaceWarGame { Dictionary<string, Player> _players = new Dictionary<string, Player>(); List<Player> _playersWithoutNPC = new List<Player>(); List<Player> _npcs = new List<Player>(); List<Player> _allPlayers = new List<Player>(); } |
初期化の処理
初期化の処理を示します。
コンストラクタは空っぽです。初期化はユーザーが接続したとき接続ユーザー数が1の場合におこないます。_allPlayersと_npcsをクリアして8個のNPCをつくります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class SpaceWarGame { public SpaceWarGame() { } public void Init() { _allPlayers.Clear(); _npcs.Clear(); for (int i = 0; i < PLAYER_MAX; i++) { _npcs.Add(new Player("", i + 1, this)); } _allPlayers = new List<Player>(_npcs); } } |
新しいPlayerを追加と削除
新しいPlayerを追加する処理を示します。
引数はサーバーサイドに接続したときに付与されるIDです。すでに辞書_playersのなかに登録されている場合はなにもしません。NPCが存在しない場合もそのプレイヤーはゲームに参加できないのでなにもしません。
それ以外のときはNPCを1つ_npcsから取り除き、新しいPlayerオブジェクトを生成してから_playersに追加します。そして他のフィールド変数も変更しないといけないのでSetAllPlayersメソッドを呼び出しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class SpaceWarGame { public bool AddPlayer(string id) { if (_players.ContainsKey(id)) return false; if (_npcs.Count == 0) return false; int number = _npcs[0].PlayerNumber; _npcs.RemoveAt(0); Player player = new Player(id, number, this); // Playerクラスの詳細は後述 _players.Add(id, player); SetAllPlayers(); return true; } } |
SetAllPlayersメソッドを示します。新しいプレイヤーがゲームに参加するときと離脱するときに_playersと_npcsだけでなく_playersWithoutNPCと_allPlayersも変更しなければなりません。その処理をおこなっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class SpaceWarGame { public void SetAllPlayers() { // _playersWithoutNPCと_allPlayersに格納するデータを変更する _playersWithoutNPC = _players.Select(p => p.Value).ToList(); _allPlayers = new List<Player>(_playersWithoutNPC); _allPlayers.AddRange(_npcs); // PlayerNumberでソートしておく _allPlayers = _allPlayers.OrderBy(p => p.PlayerNumber).ToList(); } } |
プレイヤーがゲームオーバーになったときとゲームの途中で離脱したときにはRemovePlayerメソッドが実行されます。引数で渡されたキーに対応するPlayerオブジェクトを削除し、代わりにNPCを追加します。そのあとSetAllPlayersメソッドを呼び出します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class SpaceWarGame { public bool RemovePlayer(string id) { if (!_players.ContainsKey(id)) return false; int number = _players[id].PlayerNumber; _players.Remove(id); _npcs.Add(new Player("", number, this)); SetAllPlayers(); return true; } } |
プレイヤーの取得
GetPlayerメソッドは引数で渡されたidに対応するPlayerオブジェクトを返します。
1 2 3 4 5 6 7 8 9 10 |
public class SpaceWarGame { public Player? GetPlayer(string id) { if (_players.ContainsKey(id)) return _players[id]; else return null; } } |
以下のメソッドはとくに説明は不要かと……。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class SpaceWarGame { // 通常のプレイヤーとNPCの双方を返す public List<Player> GetAllPlayers() { return _allPlayers; } // NPCではない通常のプレイヤーを返す public List<Player> GetPlayersWithoutNPC() { return _playersWithoutNPC; } // NPCの数を返す public int GetNpcCount() { return _npcs.Count; } } |
爆発時の処理
爆発時に発生する火花を生成する処理を示します。
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 |
public class SpaceWarGame { static Random _random = new Random(); List<Spark> _sparks = new List<Spark>(); public void SetSparks(int x, int y, int z) { // 周囲に火花を飛ばす for (int i = 0; i < 30; i++) { int r = _random.Next(100); double rad = Math.PI * 2 * r / 100; r = _random.Next(100); double rad2 = Math.PI * 2 * r / 100; double v = 8 + _random.Next(8); _sparks.Add(new Spark(x, y, z, (int)(v * Math.Cos(rad)), (int)(v * Math.Sin(rad)), (int)(v * Math.Cos(rad2)))); } } // 存在する火花(Lifeが0より大きいもの)を返す public List<Spark> Sparks { get { return _sparks.Where(spark => spark.Life > 0).ToList(); } } } |
Sparkクラスは以下のように定義されています。
コンストラクタの最初の3つは発生場所の座標、後ろの3つはXYZ方向への移動速度です。
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 |
namespace SpaceWar { public class Spark { public Spark(int x, int y, int z, int vx, int vy, int vz) { Life = SpaceWarGame.SPARK_LIFE_MAX; X = x; Y = y; Z = z; VelocityX = vx; VelocityY = vy; VelocityZ = vz; } public int X { private set; get; } public int Y { private set; get; } public int Z { private set; get; } int VelocityX { get; } int VelocityY { get; } int VelocityZ { get; } public int Life { private set; get; } public void Update() { X += VelocityX; Y += VelocityY; Z += VelocityZ; Life--; } } } |