今回はASP.NET Coreで3Dっぽいカーレースをつくることに挑戦します。
はじめに
このことはプログラミング実況配信系YouTuberであるT.Umezawaさんに「次回は3Dのカーレースをつくる」と宣言し、作業の進捗状況も報告していました。そして完成。
昨日は配信が開始されるまえにサーバーにアップして新着記事からリンクをはり、後は配信がはじまるのを待つのみ。配信がはじまったら「できたよ!紹介してください!」という予定だったのですが、配信がはじまったのでアクセスするとすでにゲームがはじまっていました。
配信中にT.Umezawaさんだけでなく彼の視聴者さんもプレイしてくれたので、30位まで表示されるスコアランキングはすべて埋まりました。みなさん、ありがとうございました。
ゲームの内容ですが、TypeScript/JavaScriptでつくる3Dカーレースゲームと同じです。違いは新しく作ろうとしているものがASP.NET Coreでつくること、ひとりだけでなく複数で対戦することができるということです。
では作成していきましょう。
コースデータの作成
最初にコースのデータを作成します。
筑波サーキット | わかりやすい モータースポーツ競技規則にコースの画像があったので、これを加工してつくった以下の画像をつかいます。
この各ピクセルの色を調べてBlackであれば1、そうでなければ0として文字列に置き換えます。こうしてできたのが以下のテキストファイルです。
サーバーサイドでおこなうプレイヤーの車がコースアウトしているかの判定はこれを用いておこないます。
Gameクラスの定義
Gameクラスの定義をおこないます。
1 2 3 4 5 6 |
namespace CarRace3d { public class Game { } } |
インデントが深くなるので以降、この記事のなかでは
1 2 3 |
public class Game { } |
と書きます。
初期化
Gameクラスのコンストラクタを示します。Gameクラスのインスタンスは別のクラスにstaticで生成するので、このコンストラクタはアプリが起動したら終了するまで1回しか実行されません。
ここでやることは前述のテキストファイルを読み込み、必要な処理をすぐに返せるように準備をすることです。
1 2 3 4 5 6 7 8 9 |
public class Game { public Game() { CreateMap(); GetBorderPoints(); CreateDictionaries(); } } |
前述のテキストファイルを読み込み、二次元配列 Map に0か1を格納しています。
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 |
public class Game { static int[,] Map = { }; static void CreateMap() { string s = Zero.Properties.Resources.car_race_course; string[] strings = s.Split('\n', StringSplitOptions.RemoveEmptyEntries); int height = strings.Length; int width = strings[0].Length - 1; // 行の最後は改行なので捨てる Map = new int[height, width]; List<Point> insidePoints = new List<Point>(); for (int row = 0; row < height; row++) { char[] vs = strings[row].ToArray(); for (int col = 0; col < width; col++) { if (vs[col] == '1') { Map[row, col] = 1; insidePoints.Add(new Point(col, row)); } } } } } |
GetBorderPointsメソッドはコースの境界線を構成する座標を取得します。この処理は何度もおこなうと時間がかかるので最初の1回だけおこない、結果を配列 Borders1とBorders2 に保存しておきます。
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 81 82 83 84 85 86 87 88 89 90 91 |
public class Game { static Point[] Borders1 = Array.Empty<Point>(); static Point[] Borders2 = Array.Empty<Point>(); static void GetBorderPoints() { string s = Zero.Properties.Resources.car_race_course; string[] strings = s.Split('\n', StringSplitOptions.RemoveEmptyEntries); int height = strings.Length; int width = strings[0].Length - 1; // 行の最後は改行なので捨てる int[,] tempMap = new int[height, width]; for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { if (Map[row, col] == 1) { if (Map[row + 1, col] == 0 || Map[row - 1, col] == 0 || Map[row, col + 1] == 0 || Map[row, col - 1] == 0) tempMap[row, col] = 1; } } } Borders1 = GetPointListContour(tempMap, width, height).ToArray(); Borders2 = GetPointListContour(tempMap, width, height).ToArray(); } // 二次元配列のなかで1の要素とそれを繋がっている要素を取得する // これを2回繰り返せばコースの左右の境界線に対応する要素をすべて取得できることになる static List<Point> GetPointListContour(int[,] tempMap, int width, int height) { GetPointValue1(tempMap, width, height, out int x, out int y); List<Point> points = new List<Point>(); if (x != 0 || y != 0) { while (true) { Point? nextPoint = null; if (tempMap[y, x + 1] == 1) nextPoint = new Point(x + 1, y); else if (tempMap[y, x - 1] == 1) nextPoint = new Point(x - 1, y); else if (tempMap[y - 1, x] == 1) nextPoint = new Point(x, y - 1); else if (tempMap[y + 1, x] == 1) nextPoint = new Point(x, y + 1); else if (tempMap[y + 1, x + 1] == 1) nextPoint = new Point(x + 1, y + 1); else if (tempMap[y - 1, x - 1] == 1) nextPoint = new Point(x - 1, y - 1); else if (tempMap[y - 1, x + 1] == 1) nextPoint = new Point(x + 1, y - 1); else if (tempMap[y + 1, x - 1] == 1) nextPoint = new Point(x - 1, y + 1); // nextPointがnullならひとつながりの連続している要素はすべて取得したと判断できる if (nextPoint == null) break; // 同じものを複数取得しないように一度取得したものは0をセットしている tempMap[(int)nextPoint.Z, (int)nextPoint.X] = 0; points.Add(nextPoint); x = (int)nextPoint.X; y = (int)nextPoint.Z; } } return points; } // 作業用の二次元配列のなかから最初にみつかった要素を返す static void GetPointValue1(int[,] tempMap, int width, int height, out int x, out int y) { for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { if (tempMap[row, col] == 1) { x = col; y = row; return; } } } x = 0; y = 0; } } |
それからPointクラスは以下のようになっています。System.Drawing名前空間のPointクラスではありません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
namespace CarRace3d { public class Point { public Point(double x, double z) { X = x; Z = z; } public double X { get; private set; } public double Z { get; private set; } } } |
辞書で必要なデータをすばやく取得
ゲームの進行上、プレイヤーの各座標において一番近い境界線の座標とその座標において一番近いコースの中央になる座標、最適の方向を取得する処理が短時間で何度も必要になります。そのときにその都度取得していては処理が間に合わないので最初にまとめて取得して辞書に格納しています。これで処理速度の大幅な向上が実現できます。
これを最初はやらなかったため、プレイヤーとは別のライバル車(NPC:non player character)の数を増やすと処理が間に合わずカクツキどころか止まってばかりでゲームにならなかったのですが、これによってNPCを6台まで増やすことができました。
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 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
public class Game { static Dictionary<string, Point> NeerestBorders1 = new Dictionary<string, Point>(); static Dictionary<string, Point> NeerestBorders2 = new Dictionary<string, Point>(); static Dictionary<string, double> IdealRotationYs = new Dictionary<string, double>(); static Dictionary<string, Point> IdealPositions = new Dictionary<string, Point>(); void CreateDictionaries() { string s = Zero.Properties.Resources.car_race_course; string[] strings = s.Split('\n', StringSplitOptions.RemoveEmptyEntries); int height = strings.Length; int width = strings[0].Length - 1; // 行の最後は改行なので捨てる for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { string key = col.ToString() + "," + row.ToString(); List<Point> pts = GetNeerestBorderPoints(new Point(col, row)); // コースから大きく外れた座標では一番近い境界線の座標は取得できないのでその場合は無視する if (pts.Count == 0) continue; // 各座標においてコースを走るうえで最適の方向 double rad = Math.Atan2(pts[1].Z - pts[0].Z, pts[1].X - pts[0].X); // 各座標においてコースを走るうえで最適の方向とコースの中心にあたる座標を辞書に格納する IdealRotationYs.Add(key, -rad + Math.PI); IdealPositions.Add(key, new Point((pts[1].X + pts[0].X) / 2, (pts[1].Z + pts[0].Z) / 2)); // 各座標において一番近い境界線の座標を辞書に格納する NeerestBorders1.Add(key, pts[0]); NeerestBorders2.Add(key, pts[1]); } } } // 各座標において一番近い左右の境界線の座標を取得する static public List<Point> GetNeerestBorderPoints(Point point) { // 引数に対して大きく外れている座標は調べるだけ時間の無駄にしかならないので除外する int minX = (int)point.X - 24; int maxX = (int)point.X + 24; int minZ = (int)point.Z - 24; int maxZ = (int)point.Z + 24; double minDistanceSquared = double.MaxValue; Point? ret1 = null; foreach (Point pt in Borders1) { // 大きく外れている座標は除外する if (pt.X < minX) continue; if (pt.Z < minZ) continue; if (pt.X > maxX) continue; if (pt.Z > maxZ) continue; // 最小値を探す double d = Math.Pow(point.X - pt.X, 2) + Math.Pow(point.Z - pt.Z, 2); if (minDistanceSquared > d) { minDistanceSquared = d; ret1 = pt; } } minDistanceSquared = double.MaxValue; Point? ret2 = null; foreach (Point pt in Borders2) { if (pt.X < minX) continue; if (pt.Z < minZ) continue; if (pt.X > maxX) continue; if (pt.Z > maxZ) continue; double d = Math.Pow(point.X - pt.X, 2) + Math.Pow(point.Z - pt.Z, 2); if (minDistanceSquared > d) { minDistanceSquared = d; ret2 = pt; } } List<Point> ret = new List<Point>(); // 調査対象を限定したためret1とret2がnullの場合がある // その場合はnullが格納された要素を返しても意味がないので空のリストを返す if (ret1 != null && ret2 != null) { ret.Add(ret1); ret.Add(ret2); } return ret; } } |
あとは値を取得する必要があれば辞書を検索して値を返せるようにするだけです。
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 |
public class Game { static public void GetNeerestBorderPoints(string pointText, out Point? ret1, out Point? ret2) { if (NeerestBorders1.ContainsKey(pointText) && NeerestBorders2.ContainsKey(pointText)) { ret1 = NeerestBorders1[pointText]; ret2 = NeerestBorders2[pointText]; } else { ret1 = null; ret2 = null; } } static public double GetIdealRotationY(double x, double z) { string key = ((int)Math.Round(x)).ToString() + "," + ((int)Math.Round(z)).ToString(); return IdealRotationYs[key]; } static public Point GetIdealPosition(double x, double z) { string key = ((int)Math.Round(x)).ToString() + "," + ((int)Math.Round(z)).ToString(); return IdealPositions[key]; } // 座標はコース内か? static public bool IsCourceInside(int x, int y) { return Map[(int)Math.Round(z), (int)Math.Round(x)] == 1; } } |
タイマーのイベントが発生したらプレイヤーとNPCに対する移動の処理をおこないます。
1 2 3 4 5 6 7 8 9 |
public class Game { // Playerクラスに関しては後述 static public void OnTimer(List<Player> players) { foreach (Player player in players) player.Update(); } } |
次回はプレイヤー(NPC含む)の動作に関するサーバサイドの処理を解説します。