前回の キー操作とタイマーの処理 ランキングを偽装させないゲームをつくる準備(2) ではキー操作とタイマーをつかった処理の実験をしましたが、今回はキー操作とタイマー処理でキャラクタを移動させます。これができるようになるとASP.NET Coreをつかったゲームが作れるようになるのではないでしょうか?
以下のサンプルページにアクセスしてみると、方向キーをおすとキャラクタが移動しますが、交差点でないとキーを押しても方向転換することができないことがわかります。
Contents
Gameクラスを定義する
まずGameTest1という名前空間を定義してそこにGameクラスを定義します。
1 2 3 4 5 6 |
namespace GameTest1 { public class Game { } } |
以下、記事内では名前空間は省略して書きます。理由はインデントが深くなるからです。
Direct列挙体
まずゲームを作ろうとしているのでキャラクタが移動する方向を管理するためにDirectという列挙体を定義します。
1 2 3 4 5 6 7 8 9 10 11 |
namespace GameTest1 { public enum Direct { None, Up, Right, Down, Left, } } |
初期化の処理
ではGameクラスのフィールド変数とコンストラクタを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public class Game { // ↑ ↓ ← → キーが押されているかどうか? public bool IsUpKeyDown = false; public bool IsDownKeyDown = false; public bool IsLeftKeyDown = false; public bool IsRightKeyDown = false; // キャラクタの移動方向 Direct PlayerDirect = Direct.None; // キャラクタの現在位置 public int PlayerX = 0; public int PlayerY = 0; public Game() { // 空 } } |
キー操作にかんする処理
キーが押されたらIs~KeyDownフラグをセットして離されたらクリアします。
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 Game { public void OnKeyDown(string key) { if (key == "ArrowUp") IsUpKeyDown = true; if (key == "ArrowDown") IsDownKeyDown = true; if (key == "ArrowLeft") IsLeftKeyDown = true; if (key == "ArrowRight") IsRightKeyDown = true; } public void OnKeyUp(string key) { if (key == "ArrowUp") IsUpKeyDown = false; if (key == "ArrowDown") IsDownKeyDown = false; if (key == "ArrowLeft") IsLeftKeyDown = false; if (key == "ArrowRight") IsRightKeyDown = false; } } |
キャラクタの方向転換と移動
CanPlayerTurnAroundメソッドは方向転換できるかどうかを判定するためのものです。X座標とY座標が両方とも20の倍数になっているときだけ方向転換ができるものとします(いまは実験なのでテキトーです)。
1 2 3 4 5 6 7 |
public class Game { bool CanPlayerTurnAround() { return (PlayerDirect == Direct.None ||(PlayerX % 20 == 0 && PlayerY % 20 == 0)); } } |
タイマーイベントが発生したらキーが押されているかをチェックし、その位置で方向転換できるかを調べます。できる場合はPlayerDirectを変更します。
そのあとPlayerDirectに格納されている値によってX座標とY座標を変更します。またX座標とY座標が0より小さくなったり80よりも大きくなったら反対方向にワープさせます。
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 Game { public void OnTimer() { if (IsLeftKeyDown && CanPlayerTurnAround()) PlayerDirect = Direct.Left; if (IsRightKeyDown && CanPlayerTurnAround()) PlayerDirect = Direct.Right; if (IsUpKeyDown && CanPlayerTurnAround()) PlayerDirect = Direct.Up; if (IsDownKeyDown && CanPlayerTurnAround()) PlayerDirect = Direct.Down; if (PlayerDirect == Direct.Left) { PlayerX--; if (PlayerX < 0) PlayerX = 80; } if (PlayerDirect == Direct.Right) { PlayerX++; if (PlayerX > 80) PlayerX = 0; } if (PlayerDirect == Direct.Up) { PlayerY--; if (PlayerY < 0) PlayerY = 80; } if (PlayerDirect == Direct.Down) { PlayerY++; if (PlayerY > 80) PlayerY = 0; } } } |
次にSignalRChatを使った処理を考えます。SignalRChat.Hubs名前空間のなかにMoveTest1クラスを定義します。
SignalRChat関連の処理
1 2 3 4 5 6 |
namespace SignalRChat.Hubs { public class MoveTest1 : Hub { } } |
以降はインデントを減らすために名前空間は省略して書きます。
静的フィールド変数としてTimer、ClientProxyMap、TimerElapsedHandlers、Gamesを定義します。
1 2 3 4 5 6 7 8 9 10 |
public class MoveTest1 : Hub { string GroupName = "MoveTest1"; string ConnectionID = ""; static System.Timers.Timer Timer = new System.Timers.Timer(); static Dictionary<string, IClientProxy> ClientProxyMap = new Dictionary<string, IClientProxy>(); static Dictionary<string, ElapsedEventHandler> TimerElapsedHandlers = new Dictionary<string, ElapsedEventHandler>(); static Dictionary<string, GameTest1.Game> Games = new Dictionary<string, GameTest1.Game>(); } |
接続に成功したらそれを伝えるためにクライアントサイドのReceiveMessage関数を呼び出します。SendAsyncメソッドで送るデータは「接続成功」という文字列とConnectionId、現在時刻です。
そのあとClientProxyMapにContext.ConnectionIdをキーにしてClients.Callerを追加します。Clients.Callerをフィールド変数として保存しても意味はありません。静的なフィールド変数として保存して辞書のなかから取得できるようにしておきます。
そして60ミリ秒ごとにイベントハンドラTimer_Elapsedを呼び出せるようにしておきます。切断されたときに追加したイベントハンドラTimer_Elapsedを削除できるようにしなければなりません。これも静的なフィールド変数として保存して辞書のなかから取得できるようにしておきます。最後にタイマーをスタートさせます。
接続時の処理
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 MoveTest1 : Hub { public override async Task OnConnectedAsync() { await Clients.Caller.SendAsync("ReceiveMessage", "接続成功", Context.ConnectionId, GetDateText()); await Groups.AddToGroupAsync(Context.ConnectionId, GroupName); await base.OnConnectedAsync(); ClientProxyMap.Add(Context.ConnectionId, Clients.Caller); ConnectionID = Context.ConnectionId; Timer.Interval = 60; Timer.Elapsed += Timer_Elapsed; Timer.Start(); TimerElapsedHandlers.Add(Context.ConnectionId, Timer_Elapsed); Games.Add(Context.ConnectionId, new GameTest1.Game()); } string GetDateText() { DateTime now = DateTime.Now; return String.Format("{0:0000}-{1:00}-{2:00} {3:00}:{4:00}:{5:00}", now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second); } } |
切断時の処理
切断されたときの処理を示します。
Context.ConnectionIdをキーにしてTimerElapsedHandlersのなかから削除すべきイベントハンドラを探します。そして削除します。そのあとTimerElapsedHandlersとClientProxyMapとGamesから値を削除します。
このときClientProxyMap.Count(TimerElapsedHandlers.CountとかGames.Countでもよいが)が0になった場合はタイマーは停止させてしまっても差し支えないことになります。この場合はサーバーに負荷をかけたくないので停止させてしまいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class MoveTest1 : Hub { public override async Task OnDisconnectedAsync(Exception? exception) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName); await base.OnDisconnectedAsync(exception); Timer.Elapsed -= TimerElapsedHandlers[Context.ConnectionId]; TimerElapsedHandlers.Remove(Context.ConnectionId); ClientProxyMap.Remove(Context.ConnectionId); Games.Remove(Context.ConnectionId); if(ClientProxyMap.Count <= 0) { Timer.Stop(); } } } |
Elapsedイベント時の処理
タイマーでElapsedイベントが発生したときの処理ですが、今度はフィールド変数ConnectionIDに保存している文字列をつかってGamesから対応するGameオブジェクトを探します。この場合はContext.ConnectionIdではなくフィールド変数に保存されているものをつかわないとうまくいきません。
Gameオブジェクトが見つかったら上記で定義したGame.OnTimerメソッドを呼び出してキャラクタ移動の処理をおこなわせます。キャラクタの座標を求めることができたらSendAsyncメソッドで結果をクライアントサイドにおくります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class MoveTest1 : Hub { private void Timer_Elapsed(object? sender, ElapsedEventArgs e) { Task.Run(async () => { GameTest1.Game game = Games[ConnectionID]; game.OnTimer(); await ClientProxyMap[ConnectionID].SendAsync( "ReceiveUpdate", game.PlayerX, game.PlayerY); }); } } |
キー操作に対応する処理
キーが押されたとき、離されたときの処理を示します。この場合はContext.ConnectionIdをキーにしてGamesからGameオブジェクトを探します。そしてGame.OnKeyDownメソッドとGame.OnKeyUpメソッドを呼び出してフラグをセットさせます。
またクライアントサイドから変なデータが送られてきたことを想定して文字列の長さをチェックしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class MoveTest1 : Hub { public void DownKey(string key) { // 長大なデータが送りつけられるかもしれないので対策 if (key.Length > 16) return; GameTest1.Game game = Games[Context.ConnectionId]; game.OnKeyDown(key); } public void UpKey(string key) { if (key.Length > 16) return; GameTest1.Game game = Games[Context.ConnectionId]; game.OnKeyUp(key); } } |
Program.csの編集
Program.csのapp.Run();と書かれている部分の直前に以下の1行を追加しておきます。
1 2 3 4 5 |
// 省略 app.MapHub<MoveTest1>("/MoveTest1"); // この行を追加する app.Run(); |
サーバーサイドの処理は以上です。次にクライアントサイドの処理を考えます。
クライアントサイドの処理
プロジェクトのフォルダにあるPagesフォルダのなかにMoveTest.cshtmlを作成して以下を書きます。
HTML部分
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 |
Pages\MoveTest.cshtml @page @{ ViewData["Title"] = "移動のテスト"; Layout = "_Layout"; string baseurl = Global.BaseUrl; // Global.BaseUrlに関しては 「NET 6.0をエックスサーバーにインストールする」https://lets-csharp.com/xserver-dotnet-core/ の // 目次 「Pages\Shared\_Layout.cshtmlを編集して対応」を参照してください。 } <div class="container"> <p id = "conect-result"></p> <p id = "move"></p> <canvas id="can"></canvas> <img src = "@baseurl/images/player.png" id ="player"> </div> <script src="@baseurl/js/signalr.js"></script> <script> // 後述 </script> |
Canvasとキーが押されたたときの処理
次にJavaScript部分ですが
Pages\MoveTest.cshtmlのscriptタグ内
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<script> "use strict"; // 表示させたいキャラクタのイメージ let imgPlayer = document.getElementById('player'); // canvas let canvas = document.getElementById('can'); let ctx = canvas.getContext('2d'); // canvasの幅と高さ canvas.width = 280; canvas.height = 280; // SignalRで接続する let connection = new signalR.HubConnectionBuilder().withUrl("@baseurl/MoveTest1").build(); </script> |
キーが押されたたときの処理を示します。キーが押しっぱなしになっているときにサーバーサイドに同じキーが押された情報が連続で送られないためにフラグで管理しています。
Pages\MoveTest.cshtmlのscriptタグ内
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 |
<script> // 方向キーがおされているかどうか? let isUpKeyDown = false; let isDownKeyDown = false; let isLeftKeyDown = false; let isRightKeyDown = false; document.onkeydown = function(e){ // 方向キーがおされたときは本来の動作(スクロール)させない if(e.key == "ArrowUp" || e.key == "ArrowDown" || e.key == "ArrowLeft" || e.key == "ArrowRight") e.preventDefault(); // キーが押しっぱなし状態になっている場合の対策 if(e.key == "ArrowUp" && isUpKeyDown) return; if(e.key == "ArrowDown" && isDownKeyDown) return; if(e.key == "ArrowLeft" && isLeftKeyDown) return; if(e.key == "ArrowRight" && isRightKeyDown) return; if(e.key == "ArrowUp") isUpKeyDown = true; if(e.key == "ArrowDown") isDownKeyDown = true; if(e.key == "ArrowLeft") isLeftKeyDown = true; if(e.key == "ArrowRight") isRightKeyDown = true; // 押されているキーをサーバーサイドに送信する connection.invoke("DownKey", e.key) .catch(function (err) { return console.error(err.toString()); }); } </script> |
キーが離されたときの処理を示します。フラグをクリアしているだけです。
Pages\MoveTest.cshtmlのscriptタグ内
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<script> document.onkeyup = function(e){ if(e.key == "ArrowUp") isUpKeyDown = false; if(e.key == "ArrowDown") isDownKeyDown = false; if(e.key == "ArrowLeft") isLeftKeyDown = false; if(e.key == "ArrowRight") isRightKeyDown = false; connection.invoke("UpKey", e.key) .catch(function (err) { return console.error(err.toString()); }); } </script> |
描画に関する処理
描画に関する処理を示します。サーバーサイドでタイマーイベントが発生するたびに座標を1だけ移動させていますが、それでは動きが小さいので移動幅を3倍にしています。それからキャラクタが移動する通路を1ピクセルの白い線で描画しています。
Pages\MoveTest.cshtmlのscriptタグ内
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 |
<script> function DrawPlayer(x, y) { DrawLines(); ctx.drawImage(imgPlayer,x*3,y*3, 36, 36); // キャラクタサイズは36ピクセル } function DrawLines() { ctx.strokeStyle = '#fff'; for(let x = 0; x < canvas.width; x += 20 * 3) { ctx.beginPath(); ctx.moveTo(x + 18, 0); // 通路がキャラクタの真ん中をとおるようにキャラクタサイズの半分を足している ctx.lineTo(x + 18, canvas.height); ctx.stroke(); } for(let y = 0; y < canvas.height; y += 20 * 3) { ctx.beginPath(); ctx.moveTo(0, y + 18); ctx.lineTo(canvas.width, y + 18); ctx.stroke(); } } </script> |
データを受信したときの処理
サーバサイドからデータを受信したときの処理を示します。
Pages\MoveTest.cshtmlのscriptタグ内
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<script> // SignalRで接続したときの処理 connection.start().then(function () { console.log("connection.start()"); }); // SignalRで接続したときサーバサイドから送信されてくる文字列を受信したとき connection.on("ReceiveMessage", function (result, id, datetime) { document.getElementById("conect-result").innerHTML = `${result}:${datetime}<br>ConnectionId:${id}`; }); // サーバサイドでタイマーイベントが発生したときに送信されてくる文字列を受信したとき connection.on("ReceiveUpdate", function (x, y) { document.getElementById("move").textContent = 'プレイヤーの座標:' + x + ", " + y; ctx.fillStyle = '#000'; ctx.fillRect(0,0,canvas.width,canvas.height); DrawPlayer(x, y); }); </script> |
以下のサンプルページにアクセスしてみると、方向キーをおすとキャラクタが移動しますが、交差点でないとキーを押しても方向転換することができないことがわかります。