ASP.NET Core版 対人対戦できるぷよぷよをつくる(10)の続きです。
世の中にはゲームがうまい人もいれば壊滅的に下手な人もいます。鳩でもわかるC# 管理人は落ち物系ゲームが苦手です。そこで強い人を相手にしてもそこそこ楽しめるようにハンディキャップ戦ができるようにします。
Contents
どのようなハンディキャップを採用するか?
どのようなハンディキャップにするかですが、おじゃまレートを変更します。おじゃまレートはぷよを消したときにどれだけの数のおじゃまぷよを送り込めるかに影響をあたえます。得点÷おじゃまレートが相手に送り込むことができるおじゃまぷよの数です。少ない値のほうがより多くのおじゃまぷよを送りこめます。通常、おじゃまレートは70で、これまで作ってきたゲームもそのようになっています。
対戦して勝つと勝ち点がつきます。勝ち点が異なるプレイヤー同士が対戦するのであればランク差でおじゃまレートを異なる値に変更します。
具体的には
それ以降は 強い側のレートを5ずつ上げ弱い側のレートは30で変更しません。またハンディキャップ戦ができるのは両方のプレイヤーがログインをしているときだけです。少なくとも片方がログインしていない場合はハンディキャップなしの従来と同じ条件で対戦することになります。
これで強い人と対戦してそれなりに勝負ができるのかはわかりませんが(つまり暫定的)、当面これを採用します。
悲報!!勝てませんでした
初戦だけ相手がハンディキャップの内容を理解していないのをいいことに勝てましたが、それ以降はズタボロにやられました。我ながら弱すぎです。
対戦の長期化を防ぐ
それから弱い人同士だと大連鎖が実現しないのでなかなか勝負がつきません。そこでゲーム開始から時間が一定時間が経過するごとにおじゃまレートを減らしていきます。
以下はぷよぷよフィーバーにおけるマージンタイム到達後のおじゃまぷよレートの変動調査の結果です。これは「ぷよぷよフィーバー」のものなので初期値は70ではなく120です。開始からの経過時間が長くなるとたとえ1連鎖でも大量のおじゃまを送り込むことができてしまいます。
引用元:おじゃまぷよレートの変動調査
まったく同じものにしても面白くないので最初の90秒は初期値のままとし、それ以降は10秒経過するたびに5減らします。またどんなにレートが下がっても10未満にはならないようにします。レートが1まで下がると1連鎖で40個のおじゃまぷよが発生するのはさすがにやりすぎのような気がします。
それではさっそくそのように作りかえてみましょう。
ユーザーのレベルを取得する
まずログインユーザーを管理するクラスにそのプレイヤーはユーザー登録されている人なのか?登録されているのであればその人のレベルはどうなのかを取得するメソッドを追加します。
後日談:さっそくダメ出しが入りました。単純な勝ち数だけでは弱い人同士で何回も対戦すればそれだけでレベルがどこまでも上がってしまう。下げる処理も必要なのではないか?
この記事ではレベルは上がるだけという前提ですすめます。
エントリーしたらレベルに関する情報も必要なので、これを取得するためのメソッドを追加します。引数と一致するユーザーが記録されていないのであればレベルは-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 |
public class LoginModel : PageModel { public static (string token, int level, int totalScore) GetPlayerInfo(string playerName) { (string token, int level, int totalScore) ret; if (System.IO.File.Exists(ResistPath)) { // ResistPathはユーザー登録している人のパスワードや勝ち数が記録されているテキストファイルのパス StreamReader sr = new StreamReader(ResistPath, Encoding.UTF8); while (true) { string? str = sr.ReadLine(); if (str == null) break; string[] strings = str.Split('\t'); if (strings[0] == playerName) { if (strings.Length > 4) { ret.token = strings[2]; ret.level = int.Parse(strings[3]); ret.totalScore = int.Parse(strings[4]); sr.Close(); return ret; } break; } } sr.Close(); } // 引数と同じ名前のユーザーは登録されていない return (token : "", level: -1, totalScore:0); } } |
エントリー時の処理
対戦をするときはエントリーボタンをクリックしますが、そのときにPuyoMatchHub.Enteryメソッドが呼び出されます。このときに上記のLoginModel.GetPlayerInfoメソッドで登録されているユーザーの名前と同じものがあればトークン、レベル、獲得総得点を取得します(ここで必要なのは前2者)。
ファイルの書き込み時と重なって例外が発生するかもしれないので例外処理をしています。例外が発生したらエントリーが失敗した旨を表示します。時間をずらせばできるはずです。
それからエントリーしたプレーヤー名と同じ名前だけどユーザー登録している人とは別人の可能性があります。これはトークンを比較すれば区別できます。ユーザー登録していないプレーヤーのレベルは-1として扱います。
エントリーしようとしているプレーヤーのレベルがわかったらPlayerInfoのコンストラクタにこれらとともに必要な引数を渡します。
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 |
public class PuyoMatchHub : Hub { public void Entery(string id, string playerName, string playerToken) { if (id == null || id == "" || !ClientProxyMap.ContainsKey(id)) return; if (playerName == null || playerName == "") { _ = ClientProxyMap[id].SendAsync("NotifyToClient", "名前は必ず登録してください"); return; } if (WaitingPlayers[0] != null && WaitingPlayers[0]!.ConnectionId == id) { _ = ClientProxyMap[id].SendAsync("NotifyToClient", "すでに登録されています"); return; } (string token, int level, int totalScore) ret = (token: "", level: -1, totalScore: 0); try { // ファイルの書き込み時と重なって例外が発生するかもしれない ret = LoginModel.GetPlayerInfo(playerName); } catch { if (playerName == null || playerName == "") { _ = ClientProxyMap[id].SendAsync("NotifyToClient", "エントリー失敗。もう一度実行してください。"); return; } } // たまたまユーザー登録されている人と同じ名前でログインしているユーザーではないかもしれない int level = ret.token == playerToken ? ret.level : -1; // PlayerInfoのコンストラクタの引数が変更されていることに注意 PlayerInfo player = new PlayerInfo(id, playerName, playerToken, level); Task.Run(async () => { // 登録したユーザーに登録処理がおこなわれたことを通知する if (ClientProxyMap.ContainsKey(id)) await ClientProxyMap[id].SendAsync("EnteredToClient", $"[ {player.EscapedPlayerName} ]でエントリーしました。"); // 自分以外に待機している人がいたらその人と対戦する if (WaitingPlayers.Count > 0) await OnMatchingEstablished(player); else await OnMatchingFailed(player); }); } } |
PlayerInfoクラスにプロパティを追加する
PlayerInfoクラスにレベル関連のプロパティを追加します。またコンストラクタの引数を変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public class PlayerInfo { public PlayerInfo(string CconnectionId, string playerName, string playerToken, int level) { ConnectionId = CconnectionId; PlayerName = playerName; Level = level; // 引数をLevelプロパティにセット if (playerToken != "" && GetPlayerToken(playerName) == playerToken) { IsLoginUser = true; PlayerToken = playerToken; } else { IsLoginUser = false; PlayerToken = ""; } } public int Level { set; get; } } |
おじゃまレートを変動制にする
これまではおじゃまレートは定数70でした。今回はそれを変更するので定数は廃止します。かわりに新たにレートプロパティを追加します。
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 |
public class PuyoMatchGame { // 追加されたプロパティ public int[] PlayerLevels { get; } public int[] OjamaRates { private set; get; } public int[] MaxOjamaRates { private set; get; } public PuyoMatchGame(PlayerInfo[] playerInfos) { // 長いので省略 // 既存のコードに以下を追加 PlayerLevels = new int[2]; PlayerLevels[0] = playerInfos[0].Level; PlayerLevels[1] = playerInfos[1].Level; SetMaxOjamaRates(); } // 双方のレベル差から初期のおじゃまレートをもとめる void SetMaxOjamaRates() { // 片方でもユーザー登録しているプレーヤーでない場合はハンデなし if (PlayerLevels[0] < 0 || PlayerLevels[1] < 0) { MaxOjamaRates[0] = 70; MaxOjamaRates[1] = 70; OjamaRates[0] = MaxOjamaRates[0]; OjamaRates[1] = MaxOjamaRates[1]; return; } if (playerInfos[0].Level < 0 && playerInfos[1].Level < 0) return; int sa = PlayerLevels[0] - PlayerLevels[1]; if (sa == 0) return; int strongRate = sa > 0 ? MaxOjamaRates[0] : MaxOjamaRates[1]; int weakRate = sa > 0 ? MaxOjamaRates[1] : MaxOjamaRates[0]; int weakLevel = sa > 0 ? PlayerLevels[1] : PlayerLevels[0]; strongRate += Math.Abs(sa) * 5; weakRate -= Math.Abs(sa) * 5; if (weakLevel >= 10) // おじゃまレートに下限を設ける { if (weakRate < 30) weakRate = 30; } else if (weakLevel >= 5) // レベルが低いプレーヤーの下限はさらに小さくする { if (weakRate < 20) weakRate = 20; } else { if (weakRate < 15) weakRate = 15; } MaxOjamaRates[0] = sa > 0 ? strongRate : weakRate; MaxOjamaRates[1] = sa > 0 ? weakRate : strongRate; OjamaRates[0] = MaxOjamaRates[0]; OjamaRates[1] = MaxOjamaRates[1]; } } |
長期戦になったらおじゃまレートを変更する
ゲームを開始するときにゲーム開始以降の経過秒数を格納する変数(_elapsedSeconds)に-90をセットします(90秒経過以降に10秒ごとであれば_elapsedSecondsが負数であるあいだはなにもしない)。
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 |
public class PuyoMatchGame { int _elapsedSeconds = 0; const int ELAPSED_SECONDS_MIN = -90; public void GameStart() { _sbToSave.Clear(); // 棋譜の保存に使う文字列を格納するためのStringBuilderをクリアする _elapsedSeconds = ELAPSED_SECONDS_MIN; OjamaRates[0] = MaxOjamaRates[0]; OjamaRates[1] = MaxOjamaRates[1]; // ここから下は変更なし _dtGameStart = DateTime.Now; int seed = (int)(_dtGameStart.Ticks % int.MaxValue); Task.Run(async () => { await Fields[0].Init(seed); await Fields[1].Init(seed); Scores[0] = 0; Scores[1] = 0; OjamaCounts[0] = 0; OjamaCounts[1] = 0; IsPlaying = true; _timerForPuyoDown.Start(); }); } } |
1秒経過するたびに_elapsedSecondsをインクリメントして0以上のときで10で割り切れるときはおじゃまレートを減らします。ただし10以下にはならないようにします。
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 |
public class PuyoMatchGame { private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { Task.Run(async () => { if (!IgnoreTimerForPuyoDowns[0]) await Fields[0].FallOne(); IgnoreTimerForPuyoDowns[0] = false; }); Task.Run(async () => { if (!IgnoreTimerForPuyoDowns[1]) await Fields[1].FallOne(); IgnoreTimerForPuyoDowns[1] = false; }); _elapsedSeconds++; if (_elapsedSeconds >= 0 && _elapsedSeconds % 10 == 0) { if (OjamaRates[0] > 10) OjamaRates[0] -= 5; if (OjamaRates[1] > 10) OjamaRates[1] -= 5; if (OjamaRates[0] < 10) OjamaRates[0] = 10; if (OjamaRates[1] < 10) OjamaRates[1] = 10; } } } |
新たに設定したおじゃまレートから送り込めるおじゃまぷよの数を計算する
連鎖が発生したらそれぞれのおじゃまレートから相手に送り込むことができるおじゃまぷよの数を計算します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class PuyoMatchGame { void CalculateOjamapuyo(int rensa, int addScore, bool isPlayer0) { // 省略 // 現段階の追加点の総計とおじゃまレートから相手に送り込めるおじゃまぷよの数を計算する int puyoCount; if (isPlayer0) { _addScore0 += addScore; puyoCount = _addScore0 / OjamaRates[0]; } else { _addScore1 += addScore; puyoCount = _addScore1 / OjamaRates[1]; } // 省略 } } |
第2戦目以降の処理
ひとつの対戦が終了したら_elapsedSecondsとOjamaRates[]を初期値に戻します。
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 |
public class PuyoMatchGame { public void NextGameStart(bool isScoreRest) { _dtGameStart = DateTime.Now; int seed = (int)(_dtGameStart.Ticks % int.MaxValue); _elapsedSeconds = ELAPSED_SECONDS_MIN; OjamaRates[0] = MaxOjamaRates[0]; OjamaRates[1] = MaxOjamaRates[1]; Task.Run(async () => { await Fields[0].Init(seed); await Fields[1].Init(seed); if (isScoreRest) { Scores[0] = 0; Scores[1] = 0; } OjamaCounts[0] = 0; OjamaCounts[1] = 0; IsPlaying = true; _timerForPuyoDown.Start(); }); } } |
クライアントサイドへのデータの送信
PuyoMatchHub.Updateが呼び出されたら、これまで送信していたものに加えておじゃまレートとプレーヤーのレベルを送信します。
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 PuyoMatchHub : Hub { void Update(PuyoMatchGame game) { if (!game.IsPlaying) return; string puyosString = game.GetPuyosString(); IClientProxy?[] clients = GetClients(game); foreach (IClientProxy? client in clients) { if (client == null) continue; Task.Run(async () => { await client.SendAsync("BeginUpdatedToClient"); await client.SendAsync("FieldPuyoUpdatedToClient", puyosString); await client.SendAsync("ScoreUpdatedToClient", true, game.Scores[0], game.OjamaCounts[0], game.PlayerNames[0]); await client.SendAsync("ScoreUpdatedToClient", false, game.Scores[1], game.OjamaCounts[1], game.PlayerNames[1]); await client.SendAsync("PingUpdatedToClient", true, game.Pings[0]); await client.SendAsync("PingUpdatedToClient", false, game.Pings[1]); await client.SendAsync("WinCountsToClient", true, game.WinCounts[0]); await client.SendAsync("WinCountsToClient", false, game.WinCounts[1]); // おじゃまレートとプレーヤーのレベルを送信する await client.SendAsync("SendRateToClient", true, game.OjamaRates[0]); await client.SendAsync("SendRateToClient", false, game.OjamaRates[1]); await client.SendAsync("SendLevelToClient", true, game.PlayerLevels[0]); await client.SendAsync("SendLevelToClient", false, game.PlayerLevels[1]); await client.SendAsync("EndUpdatedToClient"); }); } } } |
クライアントサイドの処理
サーバーサイドからデータを受信したらこれをグローバル変数に格納します。そして
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
let rate0 = 0; let rate1 = 0; let level0 = 0; let level1 = 0; connection.on("SendRateToClient", SendRateToClientCallBack); function SendRateToClientCallBack(isFirstPlayer, ojamaRate) { if (isThisFirstPlayer && isFirstPlayer || !isThisFirstPlayer && !isFirstPlayer) rate0 = ojamaRate; else rate1 = ojamaRate; } connection.on("SendLevelToClient", SendLevelToClientCallBack); function SendLevelToClientCallBack(isFirstPlayer, level) { if (isThisFirstPlayer && isFirstPlayer || !isThisFirstPlayer && !isFirstPlayer) level0 = level; else level1 = level; } |
Update関数が呼び出されたら保存しておいたグローバル変数を利用して描画処理をおこないます。
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 107 108 109 110 111 112 113 |
function Update() { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); DrawWalls(true); DrawWalls(false); DrawFieldPuyo(true); DrawNextPuyo(true); DrawFieldPuyo(false); DrawNextPuyo(false); // canvas上部の描画 DrawScore(true); DrawScore(false); // canvas下部の描画 DrawPing(true); DrawPing(false); } function DrawScore(isFirstPlayer) { let marginLeft; let marginTop; let score; let yokoku; let offsetCount; let playerName; let winCount; let level = 0; if ((isFirstPlayer && isThisFirstPlayer) || (!isFirstPlayer && !isThisFirstPlayer)) { marginLeft = marginLeftPlayer1; marginTop = marginTopPlayer1; score = score0; yokoku = yokoku0; offsetCount = offsetCount0; playerName = playerName0; winCount = winCount0; level = level0; } else { marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; score = score1; yokoku = yokoku1; offsetCount = offsetCount1; playerName = playerName1; winCount = winCount1; level = level1; } if (level < 0) level = '-'; ctx.font = 'bold 16px MS ゴシック'; if (winCount0 == 0 && winCount1 == 0) { ctx.fillStyle = '#ff0'; ctx.fillText(`${playerName} <Lv.${level}>`, marginLeft + 20, marginTop + 0); } else { ctx.fillStyle = '#f40'; ctx.fillText(`<${winCount}勝>`, marginLeft + 20, marginTop + 0); ctx.fillStyle = '#ff0'; ctx.fillText(`${playerName} <Lv.${level}>`, marginLeft + 70, marginTop + 0); } ctx.fillStyle = '#0ff'; ctx.font = 'bold 32px MS ゴシック'; ctx.fillText(score, marginLeft + 20, marginTop + 40); ctx.fillStyle = '#f40'; ctx.font = 'bold 20px MS ゴシック'; let text = '(' + yokoku + ')'; ctx.fillText(text, marginLeft + 160, marginTop + 30); if (offsetCount != 0) { let offsetText = '(相殺:' + offsetCount + ')'; ctx.fillStyle = '#ff0'; ctx.font = 'bold 20px MS ゴシック'; ctx.fillText(offsetText, marginLeft + 160, marginTop + 55); } ctx.fillStyle = '#000'; } function DrawPing(isFirstPlayer) { let marginLeft; let marginTop; let ping; let ojamaRate; if ((isFirstPlayer && isThisFirstPlayer) || (!isFirstPlayer && !isThisFirstPlayer)) { marginLeft = marginLeftPlayer1; marginTop = marginTopPlayer1; ping = ping0; ojamaRate = rate0; } else { marginLeft = marginLeftPlayer2; marginTop = marginTopPlayer2; ping = ping1; ojamaRate = rate1; } ctx.fillStyle = '#fff'; ctx.font = 'bold 16px MS ゴシック'; ctx.fillText(`Ping = ${ping} ms`, marginLeft + 120, marginTop + 440); ctx.fillText(`Rate = ${ojamaRate}`, marginLeft + 5, marginTop + 440); ctx.fillStyle = '#000'; } |