ASP.NET Core版 タイピングゲームをつくります。今回はサーバーサイドの処理を実装します。
タイピングゲームは他にもありますが、入力するのは半角のアルファベットになっているものが多数です。これだと「ふ」が「hu」か「fu」かの違いでじつはひらがな入力をするのであれば同じなのに誤答扱いになってしまいます。そこでプレイヤーはローマ字入力でもカナ入力でも遊べるようなものとして作ります。
それからこのページの説明は NET 6.0をエックスサーバーにインストールするで示している手順が完了していることを前提としています。
ではさっそく作成することにします。名前空間はTypingとします。C#で書かれたソースはPages\Typingに置きます。
まずゲームに関する処理をおこなうTypingGameクラスを定義します。
Contents
TypingGameクラスの定義
| 1 2 3 4 5 6 | namespace Typing {     public class TypingGame     {     } } | 
以降は名前空間を省略して以下のように書きます。
| 1 2 3 | public class TypingGame { } | 
初期化
TypingGameクラスのコンストラクタを示します。
引数はAspNetCore.SignalRでサーバーサイドに接続したときに付与されるIDです。これをConnectionIdプロパティにセットしています。ConnectionIdプロパティは後述します。またタイマーの初期化もしています。イベントハンドラ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 TypingGame {     const int TIME_LIMIT_MAX = 20 * 1000; // 初期の制限時間 20秒     const int LIFE_MAX = 3; // 3回ミスをしたらゲームオーバー     static bool _isFirstConnection = true;     static System.Timers.Timer _timer = new System.Timers.Timer();     public TypingGame(string connectionId)     {         ConnectionId = connectionId;         // 初めて実行されたときだけタイマーを初期化する         if (_isFirstConnection)         {             _isFirstConnection = false;             _timer.Interval = 1000 / 30;         }         _timer.Elapsed += Timer_Elapsed;         // タイマーが停止しているときは再スタートさせる         if (!_timer.Enabled)             _timer.Start();     } } | 
各プロパティ
| 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 | public class TypingGame {     // AspNetCore.SignalRで接続したときに付与されるID     public string ConnectionId     {         get;     }     // プレイヤー名。最長16文字。空文字がセットされた場合は"名無しさん"にする     string _name = "";     public string Name     {         set         {             _name = value;             if (_name == "")                 _name = "名無しさん";             else             {                 _name = _name.Replace(",", "_");                 _name = _name.Length > 16 ? _name.Substring(0, 16) : _name;             }         }         get { return _name; }     }     // 現在のお題     public string? Question     {         private set;         get;     }     // ひとつのお題あたりの制限時間。ゲームの進行とともにだんだん短くなっていく     public int TimeLimitMax     {         private set;         get;     }     // お題を解くために残された時間。これが0になったらミス     public int TimeLimit     {         private set;         get;     }     // ミスすると1減少する。0になったらゲームオーバー     public int Life     {         private set;         get;     }     // スコア     public int Score     {         private set;         get;     } } | 
ゲーム開始時の処理
ゲーム開始のときに呼び出されるGameStartメソッドをしめします。
ここでは _isGamingをtrueにしてゲームが現在おこなわれていることがわかるようにします。ゲーム開始時はミスによってゲームが中断されている状態ではないので _isStopingはfalseにします。Lifeは最大値に、Scoreは0にします。1問あたりの制限時間を示すTimeLimitMaxプロパティはTIME_LIMIT_MAXにします。
そのあと新しい問題を作成します。後述するCreateQuestionsメソッドでお題の文字列の配列をフィールド変数 _questionsに格納します。そして _indexを0に戻し現在のお題が格納されているQuestionプロパティには _questions[0]を格納します。これを後述するSendQuestionメソッドでクライアントサイドに送信します。
| 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 TypingGame {     bool _isGaming = false; // ゲームは現在おこなわれているか?     bool _isStoping = false; // ミスによってゲームが中断されているときはtrue     string[] _questions; // お題になる文字列が格納される配列     int _index = 0; // いまのお題は_questions[_index]である     public void GameStart()     {         _isGaming = true;         _isStoping = false;         Life = LIFE_MAX;         Score = 0;         TimeLimitMax = TIME_LIMIT_MAX;         _questions = CreateQuestions();         _index = 0;         Question = _questions[_index];         SendQuestion();     } } | 
問題を生成するためのCreateQuestionsメソッドを示します。テキストファイルを読み取り、お題を文字数が少ない順にわけます。そして文字数が同じもの同士の順番をランダムに変更します。この処理で得られた文字列の配列をコンストラクタ内でフィールド変数 _questionsに格納しています。
| 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 TypingGame {     string[] CreateQuestions()     {         System.IO.StreamReader sr = new StreamReader("./type-questions.txt");         string str1 = sr.ReadToEnd();         sr.Close();         str1 = str1.Replace("\r", "");         string[] vs1 = str1.Split('\n', StringSplitOptions.RemoveEmptyEntries);         vs1 = vs1.OrderBy(_ => _.Length).ToArray();         List<IGrouping<int, string>> groups = vs1.GroupBy(x => x.Length).ToList();         List<string> vs2 = new List<string>();         Random random = new Random();         foreach (var group in groups)         {             var key = group.Key;             List<string> vs3 = new List<string>();             foreach (var str2 in group)                 vs3.Add(str2);             while (vs3.Count > 0)             {                 int r = random.Next(vs3.Count);                 string str3 = vs3[r];                 vs3.RemoveAt(r);                 vs2.Add(str3);             }         }         return vs2.ToArray();     } } | 
新しいお題の送信
新しいお題を取得してSendQuestionEventイベントを発生させてこれをクライアントサイドに送信します。お題を解くための残り時間を計算できるように出題した時刻をフィールド変数 _dateTimeに格納しています。
| 1 2 3 4 5 6 7 8 9 10 11 12 | public class TypingGame {     public delegate void GameEventHandler(TypingGame game);     public event GameEventHandler? SendQuestionEvent;     DateTime _dateTime = DateTime.Now;     void SendQuestion()     {         SendQuestionEvent?.Invoke(this);         _dateTime = DateTime.Now;     } } | 
送信された文字列の正誤判定
CheckAnswerメソッドはクライアントサイドから送られてきたお題の答えが正しいかどうかを判定します。正解ならCorrectAnswerEventイベントを送信し、不正解ならIncorrectAnswerEventイベントを送信します。
正解の場合は残り時間から加算する点数を計算(最低10点~最大100点)してScoreプロパティの値を増加させます。そして制限時間を0.3秒短くします。そして次のお題を出題します。配列_questionsの最後の要素までいっているのであれば最初の要素に戻します。
不正解の場合はIncorrectAnswerEventイベントを送信するだけでなにもしません。
| 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 TypingGame {     public event GameEventHandler? CorrectAnswerEvent;     public event GameEventHandler? IncorrectAnswerEvent;     public void CheckAnswer(string str)     {         if (str == Question)         {             CorrectAnswerEvent?.Invoke(this);             _index++;             if(_index >= _questions.Length)                 _index = 0;             DateTime now = DateTime.Now;             TimeSpan ts = now - _dateTime;             int ms = TimeLimitMax - (int)ts.TotalMilliseconds;             int add = (int)Math.Ceiling((1.0 * ms / TimeLimitMax) * 10);             Score += 10 * add;             TimeLimitMax -= 300;             Question = _questions[_index];             SendQuestion();         }         else             IncorrectAnswerEvent?.Invoke(this);     } } | 
更新処理
Timer.Elapsedイベントが発生したらUpdateメソッドを呼び出し、だんだん少なくなっている解答を提出するまでの残り時間をクライアントサイドに送信します。
ゲーム開始前とゲームオーバー後は更新処理はおこないません。
ゲーム中は現在時刻と _dateTimeの差から消費時間を算出し、それとTimeLimitMaxとの差から残り時間を計算します。そしてUpdateEventイベントを送信します。こうすることでクライアントサイドには残り時間を送信することができます。
もし残り時間が0以下であればミス時の処理がおこなわれます。そのときはLifeがデクリメントされ、UpdateEventイベントとMissEventイベントが送信されます。その後、3秒間ゲームの進行が停止します。そのあいだ _isStopingがtrueとなります。
_isStopingがtrueである場合はTimeLimitが0以下でもミスの処理が重ねておこなわれないようにif文で制御しています。3秒間ゲームの進行が開始されますが、そのときにLifeが0になっているとゲームオーバーの処理がおこなわれます。Lifeが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 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | public class TypingGame {     private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)     {         // ゲーム開始前とゲームオーバー後は更新処理はおこなわれない         if(_isGaming)             Update();     }     public event GameEventHandler? UpdateEvent;     public event GameEventHandler? MissEvent;     public event GameEventHandler? ResumeEvent;     public event GameEventHandler? GameOverEvent;     public void Update()     {         // 残り時間を計算する         DateTime now = DateTime.Now;         TimeSpan ts = now - _dateTime;         TimeLimit = TimeLimitMax - (int)ts.TotalMilliseconds;         if (TimeLimit > 0)             UpdateEvent?.Invoke(this);         else         {             // 残り時間が0以下ならミス時の処理をする             // ミスでゲームが中断されているときはミス時の処理が二重に行なわれないように制御する             if (_isStoping)             {                 TimeLimit = 0;                 UpdateEvent?.Invoke(this);                 return;             }             _isStoping = true;  // ミスにともなうゲーム一時中断のフラグをセット             Life--;  // ミス時はLifeを1減らす             UpdateEvent?.Invoke(this); // イベントを送信             MissEvent?.Invoke(this);             Task.Run(async () => {                 await Task.Delay(3000);                 // 3秒後 Life > 0ならゲーム再開                 if (Life > 0)                 {                     ResumeEvent?.Invoke(this); // ゲーム再開を知らせるイベント                     // 次のお題を出題し、クライアントサイドに送信する                     _index++;                     if (_index >= _questions.Length)                         _index = 0;                     Question = _questions[_index];                     SendQuestion();                     _isStoping = false; // ゲーム一時中断のフラグをクリア                 }                 else                 {                     GameOverEvent?.Invoke(this);                     // ゲームオーバーのときは必要ならスコアランキングに登録する                     HiscoreManager.Save("../hiscore-typing.txt", Name, Score);                 }             });         }     } } | 
ユーザー離脱時の処理
ゲームオーバーになる前にユーザーが離脱してした場合は更新処理をする必要はないのでUpdateメソッドが呼び出されないようにします。またイベントハンドラ Timer_Elapsedも外してしまいます。
| 1 2 3 4 5 6 7 8 | public class TypingGame {     public void StopGame()     {         _isGaming = false;         _timer.Elapsed -= Timer_Elapsed;     } } | 
ユーザーが全員離脱してしまった場合はサーバーに負荷をかけないようにタイマーを停止させます。その処理を示します。
| 1 2 3 4 5 6 7 | public class TypingGame {     public void StopTimer()     {         _timer.Stop();     } } | 
