Unityを使わずにフリスビーを犬に届けよ!を作ってみる(5)の続きです。
フリスビーを犬に届けよ!の元ネタ
Unishar-ユニシャー【Unityでのゲーム開発を手助けするメディア】
Contents
複数のステージをつくる
これではひとつのステージをクリアしたらゲームは終了になりました。今回は複数のステージをつくります。最初のステージをクリアしたら次のステージに移行できるようにします。
FrisbeeHubクラスの修正
ステージクリアしてもこれまでのようにゲームは終了しません。かわりに2秒間待機したあと後述するFrisbeeGame.StageClearメソッドを呼び出して新しいステージを生成します。そしてSendMapメソッドを呼び出してこれをクライアントサイドに送信します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class FrisbeeHub : Hub { private void Player_StageCleared(object? sender, EventArgs e) { SendEventToClient(sender, "StageClearEventToClient"); Player? player = (Player?)sender; if (player != null && Games.ContainsKey(player.ConnectionId)) { Task.Run(async () => { await Task.Delay(2000); Games[player.ConnectionId].StageClear(); await SendMap(player.ConnectionId); }); } } } |
FrisbeeGame.StageClearメソッドは以下のようになっています。StageNumberをインクリメントしたあとInitメソッドを呼び出すと次のステージが生成されます。
1 2 3 4 5 6 7 8 |
public class FrisbeeGame { public void StageClear() { StageNumber++; Init(); } } |
クライアントサイドの処理
次にクライアントサイドの処理ですが、これまではランキングのページにリダイレクトさせていましたが、これをやめます。
1 2 3 4 5 6 7 8 9 10 11 12 |
connection.on("StageClearEventToClient", function () { if (IsSound()) { clearSound.currentTime = 0; clearSound.play(); } // 次のステージがあるのでリダイレクトさせない // setTimeout(() => { // window.location = './hi-score'; // }, 2000); }); |
ステージクリアをしたあとFrisbeeGame.StageClearメソッドとFrisbeeHub.SendMapメソッドが実行されるため、サーバーサイドからクライアントサイドにSendPlayerToClientとSendStartGoalToClientが複数回送信されます。プレイヤーとスタート地点、ゴールの3Dオブジェクトはすでにシーンに追加されているので、2回目以降はシーンに追加せず位置だけ変更します。
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 |
let player = null; connection.on("SendPlayerToClient", function (x, y, radius, thickness) { const material = new THREE.MeshLambertMaterial({ color: 0xff0000 }); const cylgeometry = new THREE.CylinderGeometry(radius, radius, thickness, 128); if (player == null) { player = new THREE.Mesh(cylgeometry, material); scene.add(player); } player.position.x = x; player.position.y = y; player.position.z = 0; }); let start = null; let goal = null; connection.on("SendStartGoalToClient", function (startX, startY, startWidth, startHeight, goalX, goalY, goalWidth, goalHeight) { if (start == null) { const startMaterial = new THREE.MeshLambertMaterial({ color: 0xff8000 }); const startGeometry = new THREE.BoxGeometry(startWidth, startHeight, 128); start = new THREE.Mesh(startGeometry, startMaterial); scene.add(start); } start.position.x = startX; start.position.y = startY; start.position.z = 0; if (goal == null) { let path = base_url + '/frisbee/goal.png'; const goalMaterial = new THREE.SpriteMaterial({ map: new THREE.TextureLoader().load(path), }); goal = new THREE.Sprite(goalMaterial); goal.scale.set(goalWidth, goalHeight, 10); scene.add(goal); } goal.position.x = goalX; goal.position.y = goalY; goal.position.z = 0; }); |
ゲームオーバー判定とスコア
これで複数のステージで遊べるようになったのですが、ゲームオーバーの処理はどうすればいいでしょうか?
残機制?
このゲームはやってみるとわかりますが、ミスばっかりで苦しむことになります。すぐにゲームオーバーになっては面白くないのでこの案はボツにしたいと思います。
クリアまでの時間を競う?
最終の第5ステージは難しくしてあります。クリアできない人がいるかもしれません。
そこで制限時間を設けてそれまでにクリアできなければゲームオーバーとします。
次に問題になるのがスコアです。
クリアしたときに加点するだけではちょっとやってみて「難しい。やーめた」といって離脱する人が続出することが予想されます。だからそれ以外のときも加点される仕様にしたいのです。
そこで3秒ごとに10点追加
ステージクリア時には残りの秒数×30
というのはどうでしょうか?
Gameクラスの修正
これまではステージクリアまでの経過時間を測っていました。今回は残り時間を計測します。またステージクリアのあとは次のステージが存在します。クリアしたステージから次のステージのあいだは計時を止めなければなりません。また新しいステージがはじまったら残り時間をリセットしなければなりません。
そこでFrisbeeGameにClearedプロパティを追加します。ステージをクリアするとtrueになり、新しいステージがはじまるとfalseになります。
またGameStartメソッドのなかでStageNumberプロパティに1を設定しています。ゲームオーバーになったら別のページにリダイレクトするので意味はない処理なのですが、ダイレクトせずに[ゲーム開始]のボタンを出現させて引き続きゲームを続けるようにしたいときも対応できるようにします。
ゲームオーバーになったら別のページにリダイレクトする意味はとくにありません。ゲームのページに誰かがアクセスしているとタイマーが動きっぱなしになること(サーバーへの負荷)を避けたいだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class FrisbeeGame { public bool Cleared { set; get; } public void GameStart() { StageNumber = 1; Player.GameStart(); } } |
Playクラスの修正
スコアを表示させるためにScoreプロパティを追加しています。またステージクリア時に計時をリセットするためにStartTimeに現在時刻を代入しなおしています。
GameOverになったらIsDeadフラグとIsGameOverフラグをセットします。そしてミス時と同様に火花を発生させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Player { public int Score { set; get; } public void StageClear() { IsDead = false; StartTime = DateTime.Now; } public void GameOver() { _game.SetSparks((int)X, (int)Y); IsDead = true; IsGameOver = true; } } |
FrisbeeHubクラスの修正
プレイヤーが生存している時は3秒に1回、10点を追加します。そのためのタイマーを追加します。3秒に1回の割合でTimerForScore_Elapsedを呼び出します。またこのタイマーもユーザーがいないときは停止させます。
OnConnectedAsyncメソッドとOnDisconnectedAsyncメソッドはTimerForScoreを追加した部分以外は完全に同じです。
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 |
public class FrisbeeHub : Hub { protected static bool IsFirstConnection = true; protected static System.Timers.Timer Timer = new System.Timers.Timer(); // 生存時における3秒に1回の点数加算用のタイマーを追加した protected static System.Timers.Timer TimerForScore = new System.Timers.Timer(); protected static Dictionary<string, IClientProxy> ClientProxyMap = new Dictionary<string, IClientProxy>(); protected static Dictionary<string, FrisbeeGame> Games = new Dictionary<string, FrisbeeGame>(); public override async Task OnConnectedAsync() { if (IsFirstConnection) { IsFirstConnection = false; Timer.Interval = 1000 / FrisbeeGame.UPDATES_PER_SECOND; Timer.Elapsed += Timer_Elapsed; TimerForScore.Interval = 3000; TimerForScore.Elapsed += TimerForScore_Elapsed; } await base.OnConnectedAsync(); ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); FrisbeeGame game = new FrisbeeGame(); game.Player.SetConnectionId(Context.ConnectionId); game.Player.StageCleared += Player_StageCleared; game.Player.DeadEvent += Player_PlayerDeadEvent; Games.Add(Context.ConnectionId, game); if (ClientProxyMap.Count == 1) { Timer.Start(); TimerForScore.Start(); } await SendMap(Context.ConnectionId); await Clients.Caller.SendAsync("SuccessfulConnectionToClient", "接続成功", Context.ConnectionId); } public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); if (ClientProxyMap.ContainsKey(Context.ConnectionId)) ClientProxyMap.Remove(Context.ConnectionId); if (Games.ContainsKey(Context.ConnectionId)) Games.Remove(Context.ConnectionId); if (ClientProxyMap.Count == 0) { Timer.Stop(); TimerForScore.Stop(); } try { System.GC.Collect(); } catch { Console.WriteLine("GC.Collect失敗"); } } } |
3秒に1回10点を加点する処理を示します。ゲームオーバーではなくPlayer.IsDeadとFrisbeeGame.Clearedがfalse(ミスのあと復活するまでの時間帯ではないしステージクリア時から次のステージが開始されるまでの時間帯でもない)の場合に10点を加算します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class FrisbeeHub : Hub { private void TimerForScore_Elapsed(object? sender, ElapsedEventArgs e) { foreach (var pair in Games) { var game = pair.Value; if (!game.Player.IsDead && !game.Player.IsGameOver && !game.Cleared) { game.Player.Score += 10; } } } } |
最後のステージは難しいですが、各ステージの制限時間は5分とします。第五ステージをクリアしたあとは同じですが、制限時間が20秒ずつ短くなっていきます(永久にできないように無理ゲー化させる)。
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 |
public class FrisbeeHub : Hub { static TimeSpan Limit = new TimeSpan(0, 5, 0); // 制限時間は5分 static async Task SendUpdateScoreTimeToClient(string id) { if (Games[id].Cleared) return; Player player = Games[id].Player; if (!player.IsGameOver) { TimeSpan ts = GetTimeLeft(id); if (ts.TotalSeconds > 0) { string time = string.Format("{0:00}:{1:00}:{2:00}", ts.Minutes, ts.Seconds, ts.Milliseconds / 10); int score = player.Score; await ClientProxyMap[id].SendAsync("EndUpdateTimeToClient", String.Format("{0:#,0} <span style=\"margin-left:50px;\">{1}</span>", score, time)); } else { player.GameOver(); await ClientProxyMap[id].SendAsync("EndUpdateTimeToClient", "00:00:00"); await ClientProxyMap[id].SendAsync("GameOverEventToClient"); string path = "../hiscore-frisbee-new.txt"; HiscoreManager.Save(path, player.Name, player.Score); } } } // 残り時間を求める static TimeSpan GetTimeLeft(string id) { TimeSpan ts1 = Limit - Games[id].Player.GetElapsedTime(); if (Games[id].StageNumber > 5) { int sec = (Games[id].StageNumber - 5) * 20; TimeSpan ts2 = new TimeSpan(0, 0, sec); ts1 -= ts2; } return ts1; } static async Task SendUpdateToClient(string id) { if (!ClientProxyMap.ContainsKey(id) || !Games.ContainsKey(id)) return; // プレイヤーの位置をクライアントサイドに送信する await SendUpdatePlayerToClient(id); await SendUpdateSparksToClient(id); await SendUpdateStarsToClient(id); await SendUpdateObstaclesToClient(id); await SendUpdateScoreTimeToClient(id); // クライアントサイドへのデータ送信終了 await ClientProxyMap[id].SendAsync("EndUpdateToClient"); } } |
ステージクリア時にはStageClearEventToClientを送信して効果音を出すだけでなく、ボーナスポイントを計算してShowBonusTextを送信してクライアントサイドで表示させます。次のステージが開始されたらHideBonusTextを送信して表示をやめます。
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 |
public class FrisbeeHub : Hub { private void Player_StageCleared(object? sender, EventArgs e) { SendEventToClient(sender, "StageClearEventToClient"); Player? player = (Player?)sender; if (player != null && Games.ContainsKey(player.ConnectionId)) { Task.Run(async () => { Games[player.ConnectionId].Cleared = true; int magnification = 30; TimeSpan ts = Limit - player.GetElapsedTime(); int second = (int)ts.TotalSeconds; player.Score += second * magnification; string str = String.Format("ボーナスポイント {0:#,0}<br>{1} × {2}秒", magnification * second, magnification, second); await ClientProxyMap[player.ConnectionId].SendAsync("ShowBonusText", str); await Task.Delay(2000); await ClientProxyMap[player.ConnectionId].SendAsync("HideBonusText"); Games[player.ConnectionId].StageClear(); await SendMap(player.ConnectionId); Games[player.ConnectionId].Cleared = false; }); } } } |
クライアントサイドにおける処理
クライアントサイドの処理を示します。
ゲームオーバーになったらサーバーサイドからGameOverEventToClientが送信されるので、BGMを停止してゲームオーバー時の効果音を鳴らします(効果音を鳴らす設定の場合)。
そのあとランキングのページにリダイレクトさせます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const gameoverSound = new Audio(base_url + '/frisbee/gameover.mp3'); connection.on("GameOverEventToClient", function () { if (IsSound()) { deadSound.currentTime = 0; deadSound.play(); } isPlaying = false; StopBgm(); setTimeout(() => { if (document.getElementById('sound-checkbox').checked) { gameoverSound.currentTime = 0; gameoverSound.play(); } }, 2000); setTimeout(() => { window.location = './hi-score'; }, 5000); }); |
ステージクリア時にボーナスポイントを表示させる処理を示します。
テキストファイルを生成して追加します。すでに追加されている場合は追加の処理はおこなわずに、すでに生成されているテキストフィールドに文字列を表示させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
let textField2 = null; connection.on("ShowBonusText", function (text) { if (textField2 == null) { let elm = document.getElementById('main'); textField2 = document.createElement('div'); textField2.style.position = 'absolute'; textField2.style.transform = `translate(0px, 0px)`; textField2.style.top = '100px'; textField2.style.left = '50px'; textField2.style.lineHeight = '50px'; textField2.style.color = 'white'; textField2.style.fontSize = '24pt'; elm.appendChild(textField2); } textField2.innerHTML = text; }); |
ボーナスポイントを表示させる必要がなくなったら空文字列を設定します。
1 2 3 4 5 |
connection.on("HideBonusText", function (text) { if (textField2 != null) { textField2.innerHTML = ''; } }); |