Slither.io (スリザリオ)のようなオンラインゲームを作りたい(2)の続きです。Playerクラスを定義することでプレイヤーの動作処理が高速にできる方法を考えます。
当初考えていた方法はキューを使った方法です。以下の動画のようにスネークに身体を更生する円の位置は一度確定したら移動することはなく、移動処理は頭の部分を追加、尻尾よりも後ろになる部分は削除するという処理の繰り返しにするというものです。
ところが実際のSlither.ioの動きはこうはなっていません。
これを見ると移動することで回転でできた輪っかが移動によって小さくなっていることがわかります。こうならないと相手を囲んだとき内部でグルグル回転されるといつまで経っても倒せないという問題がおきます。そこでこのような動きを実現するために先頭からn番目の円の中心をn-1番目の円の中心とn+1番目の円の中心の中点に移動させることにします。
また先頭に追加、最後尾から削除という処理を繰り返すのでLinkedListを使います。これだと先頭に追加、最後尾から削除という処理がO(1)でできます(LinkedListはランダムアクセスが苦手なので全体の処理速度は通常のListと似たような結果になるかもしれないが・・・)。
フィールド変数とプロパティ
プレイヤー(NPCを含む)を操作するためにPlayerのクラスを定義します。
まずフィールド変数とプロパティを示します。
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 |
public class Player { static Random _random = new Random(); // 乱数生成用 static long _nextPlayerID = 1; // 次に生成されるPlayerオブジェクトにつけるPlayerID public string ConnectionId = ""; // ASP.NET SignalRで使われる一意の接続ID public long PlayerID { private set; get; } // Playerオブジェクトにつける通し番号 public string PlayerName { private set; get; } // プレイヤーの名前 public string PlayerShortName { private set; get; } // 半角16文字以下のプレイヤーの名前 public LinkedList<Circle> Circles = new LinkedList<Circle>(); // 身体を構成するCircleオブジェクトのリスト public double HeadX { private set; get; } // 頭の座標 public double HeadY { private set; get; } public int HeadNumber { private set; get; } // 頭に相当するCircleオブジェクトの番号 public double Score = 0; // スネークの長さ(配列の長さと混同しないようにScoreという変数名にする) public int KillCount = 0; // 倒したプレイヤーの数 public int Radius { private set; get; } // スネークの太さ public double Angle { private set; get; } // スネークの進行方向 bool _isTurnLeft = false; // ユーザーが左または右に回頭しようとしている bool _isTurnRight = false; bool _isDash = false; // ダッシュしようとしている(速度2倍) public int NpcTurnLeftCount = 0; // 0になるまでNPCは左または右に回頭しつづける public int NpcTurnRightCount = 0; public bool IsNotRequired = false; // trueなら不要なオブジェクトなのでリストから削除される CirclesMap _circlesMap; // 当たり判定でもちいる前述のオブジェクト } |
コンストラクタと初期化
コンストラクタを示します。第一引数はASP.NET SignalRで使われる一意の接続ID、第二引数はプレイヤーの名前、第三引数は当たり判定用のCirclesMapオブジェクト(同一ゲームでは同じオブジェクトを使う)です。
プレイヤーに一意の通し番号をつけます。そのあと渡されたプレイヤー名をPlayerNameにセットします。このときに名前に不適切な文字(改行文字やタブ文字)が含まれていたら除去します。また長い文字列が渡されたときは16文字以内にします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Player { public Player(string connectionId, string playerName, CirclesMap circlesMap) { PlayerID = _nextPlayerID++; // プレイヤーに一意の通し番号をつける ConnectionId = connectionId; _circlesMap = circlesMap; PlayerName = playerName.Replace("\t", "").Replace("\r", "").Replace("\n", ""); if(PlayerName.Length > 16) PlayerName = PlayerName.Substring(0, 16); if (PlayerName == "") // この場合はNPCなので名前をつける PlayerName = "NPC " + PlayerID; PlayerShortName = GetShortName(PlayerName); // 半角16~17文字以下にする } } |
文字列を半角16~17文字以下にする処理を示します。文字コードが128未満なら半角文字、それ以外は全角文字ということにして(厳密にはぜんぜん違うのだが…)16文字数えています。
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 Player { string GetShortName(string str) { string shortName = ""; int count = 0; char[] arr = str.ToArray(); foreach (char c in arr) { if ((int)c < 128) { shortName += c; count += 1; } else { shortName += c; count += 2; } if (count >= 16) break; } return shortName; } } |
プレイヤーの初期化
プレイヤーの初期化(死亡時からの復活)の処理を示します。
ここでやることは以下のとおりです。
次に追加するCiecleオブジェクトの番号(HeadNumber)をリセット
体長、太さ、向き、倒した敵の数のリセット
各フラグ(移動方向など)のクリア
PlayerNameが空文字列ならNPCなのでPlayerIDをつかって名前をつける
最初の頭の座標の設定と頭部のCircleオブジェクトの生成とLinkedListとCirclesMapへの追加
引数は頭の座標です。
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 |
public class Player { public void Init(int x, int y) { // 変数・フラグ等のリセット HeadNumber = 0; Score = Constant.PLAYER_MIN_LENGTH; KillCount = 0; Radius = 0; Angle = 0.0; Circles.Clear(); _isTurnLeft = false; _isTurnRight = false; _isDash = false; NpcTurnLeftCount = 0; NpcTurnRightCount = 0; // 頭部の初期座標と太さを設定する HeadX = x; HeadY = y; Radius = (int)(Math.Log10(Math.Min(Score, 500)) * 4); // 頭部のCircleオブジェクトを生成して追加する Circle head = Circle.Create(); head.X = x; head.Y = y; head.Radius = Radius; head.PlayerID = PlayerID; head.IsHead = true; head.Player = this; head.Number = HeadNumber++; Circles.AddFirst(head); _circlesMap.AddCircle(head); // 移動方向をランダムに決める Angle = 1.0 * _random.Next(62800) / 10000; } } |
ユーザーがページにアクセスしただけの状態では上記のInitメソッドではなくDemoメソッドを実行します。プレイヤーでもNPCでもない状態で当たり判定がない非表示のPlayerオブジェクトとして初期化されます。これは中心付近に存在する他のプレイヤーやNPCの動きを表示させるためのものです。
1 2 3 4 5 6 7 8 |
public class Player { public void Demo() { HeadX = Constant.FIELD_RADUUS; HeadY = Constant.FIELD_RADUUS; } } |
フラグのセットとクリア
以下はユーザーが回頭やダッシュを開始または終了したときに各フラグをセットまたはクリアするための処理です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public class Player { public void TurnLeft(bool b) { _isTurnLeft = b; } public void TurnRight(bool b) { _isTurnRight = b; } public void Dash(bool b) { _isDash = b; } } |
移動処理
スネークを移動させる処理を示します。
頭の向きから次の頭の座標を求めてCircleオブジェクトを生成して追加します。また体長よりもリストのほうが長い場合は最後尾を削除します。
また冒頭に述べた回転でできた輪っかを小さくする処理を行います。ここでは輪っかになっているかどうか無関係に、先頭からn番目の円の中心をn-1番目の円の中心とn+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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
public class Player { void Move() { // 次の頭の座標を求める double nextX = HeadX + Constant.PLAYER_SPEED * Math.Cos(this.Angle); double nextY = HeadY + Constant.PLAYER_SPEED * Math.Sin(this.Angle); // 新しい頭の座標を保存 HeadX = nextX; HeadY = nextY; // 太さを再計算 Radius = (int)(Math.Log10(Math.Min(Score, 500)) * 4); // 現在頭のCircleオブジェクトは頭ではなくなる Circles.First.Value.IsHead = false; // 新しい頭のCircleオブジェクトを生成して追加 Circle head = Circle.Create(); head.X = nextX; head.Y = nextY; head.Radius = Radius; head.PlayerID = PlayerID; head.IsHead = true; head.Player = this; head.Number = HeadNumber++; Circles.AddFirst(head); _circlesMap.AddCircle(head); // 体長よりもリストのほうが長い場合は最後尾を削除(最長 800 とする) int min = Math.Min((int)Score, 800); if (Circles.Count > min) { Circle old = Circles.Last.Value; Circle.Push(old); _circlesMap.RemoveCircle(old); Circles.RemoveLast(); } // 回転でできた輪っかを小さくする処理 List<Circle> list = Circles.ToList(); int count = list.Count; for (int i = 1; i < count - 1; i++) { double mx = (list[i - 1].X + list[i + 1].X) / 2; double my = (list[i - 1].Y + list[i + 1].Y) / 2; _circlesMap.MoveCircle(list[i], mx, my); } } } |
更新処理
更新処理を示します。フラグの状態から移動方向を変更します。そのあとMoveメソッドを呼び出してスネークを移動させます。ダッシュ時はMoveメソッドを2回余分に呼び出します。
NPCの場合は 6回更新に1回の割合で 0.06radだけ右に回転させますが、ときどき変化をもたせるため別の動きもさせます。ただしNpcTurn◯◯Countが0でない場合はその方向に回頭処理をおこないます。
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 |
public class Player { public void Update(long updateCount) { if (Circles.Count == 0) return; // 移動方向を変更する if (this.ConnectionId != "") // ユーザー { if (_isTurnRight) Angle += 0.06; if (_isTurnLeft) Angle -= 0.06; } else // NPC { if (NpcTurnRightCount > 0) { NpcTurnRightCount--; Angle += 0.06; } else if (NpcTurnLeftCount > 0) { NpcTurnLeftCount--; Angle -= 0.06; } else { if (updateCount % 6 == 0) this.Angle += 0.06; if (1 < updateCount % 120 && updateCount % 120 < 20) this.Angle -= 0.06; if (61 < updateCount % 120 && updateCount % 120 < 80) this.Angle += 0.06; } } if (Angle < 0) Angle += Math.PI * 2; else if (Angle >= Math.PI * 2) Angle -= Math.PI * 2; Move(); // ダッシュが選択されていて長さが足りている場合はもう一度移動処理をおこなう if (_isDash && Score > Constant.PLAYER_MIN_LENGTH) { Move(); Score -= 0.05; // ダッシュしたら体長が短くなる // 体長によっては後ろから餌を排出する int min = Math.Min((int)Score, 800); if (Circles.Count > min) { Circle old = Circles.First.Value; Circle.Push(old); _circlesMap.RemoveCircle(old); Circles.RemoveFirst(); Circle food = Circle.Create(); food.Radius = 2; food.PlayerID = -1; food.X = old.X + _random.Next(8) - 4; food.Y = old.Y + _random.Next(8) - 4; _circlesMap.AddCircle(food); } } } } |