ASP.NET Core版 対コンピュータ対戦できるぷよぷよをつくる(5)の続きです。前回はAIの基礎部分を実装しました。今回はそのなかから最適な選択をさせます。
Contents
評価点の計算法
ぷよは現在落下しているものと次回、そして次々回のものがわかっています。またひとつの組ぷよの置き方はどの列に配置するかと回転量をどうするかで全部で22通り存在します。そのため現在落下している組ぷよと次回、次々回のぷよが配置された結果として22の3乗個の結果が存在します。
では評価点はどのようにして計算すればいいでしょうか?
この場合、Bのぷよが消えると上にあるぷよが落ちてきて連鎖が発生します。そこで各ぷよを調べてそのぷよの下に同じ色のぷよが存在する場合は評価点を加点します。またそのぷよの下にあるのは異なる色のぷよではあるが、それが1種類の色しかない場合も加点の対象とします。いずれの場合も同じ色のぷよのさらに下にあるぷよが同じ色である場合はさらに加点することにします。
この場合もBのぷよが消えると上にあるぷよが落ちてきて連鎖が発生します。そこで各ぷよの隣の列のそのぷよの下側に同じ色のぷよが存在する場合は評価点を加点します。同じ色のぷよのさらに下にあるぷよが同じ色である場合はさらに加点することにします。
今回はこの22の3乗個の結果に対して評価点を与え、そのなかで評価点がもっとも高いものを採用することにします。これでデタラメに置くよりも連鎖が発生する確率が高くなることがわかりました。鳩でもわかるC#管理人にはもう太刀打ちできません。しかしぷよぷよが好きな人にとってはそうでもないらしく簡単に撃破されてしまいます。このあたりはもう少し強いアルゴリズムを実装するために研究することにします。
Fieldクラスに機能を追加する
コンピュータ側のアルゴリズムを考えるうえで既存のFieldクラスに機能を追加します。
以下はフィールド上に固定されているぷよの二次元配列のコピーを取得するプロパティです。組ぷよを落としたときのパターンを取得したいのですが、実際に固定されているぷよの二次元配列を直接操作すると状態が変わってしまうのでコピーを取得してこれを操作します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Field { public Puyo[,] CopiedFixPuyos { get { Puyo[,] puyos = new Puyo[ROW_MAX, COL_MAX]; for (int row = 0; row < ROW_MAX; row++) { for (int col = 0; col < COL_MAX; col++) { Puyo puyo = new Puyo(_fieldPuyos[row, col].Type); puyo.Rensa = _fieldPuyos[row, col].Rensa; puyos[row, col] = puyo; } } return puyos; } } } |
GetFallingPuyosメソッドは、現在落下中の組ぷよとネクストぷよを取得するためのものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Field { public void GetFallingPuyos(out FallingPuyo[] fallingPuyos, out FallingPuyo[] nextPuyos1, out FallingPuyo[] nextPuyos2) { fallingPuyos = new FallingPuyo[2]; if(_fallingPuyoMain != null) fallingPuyos[0] = _fallingPuyoMain; if (_fallingPuyoSub != null) fallingPuyos[1] = _fallingPuyoSub; nextPuyos1 = new FallingPuyo[2]; nextPuyos1[0] = _nexts[0]; nextPuyos1[1] = _nexts[1]; nextPuyos2 = new FallingPuyo[2]; nextPuyos2[0] = _nexts[2]; nextPuyos2[1] = _nexts[3]; } } |
落下中のおじゃまぷよのコピーを取得します。
1 2 3 4 5 6 7 |
public class Field { public FallingPuyo[] GetFallingOjamaPuyos() { return _fallingOjamaPuyos.ToArray(); } } |
AiVer1クラスの定義
3組の組みぷよで考えられる組み合わせをすべて取得し、そのなかから最適と思われる行動をするクラスを実装します。
1 2 3 4 5 6 |
namespace Puyo { public class AiVer1 { } } |
以降は名前空間を省略します。
1 2 3 |
public class AiVer1 { } |
コンストラクタ
コンストラクタを示します。引数はふたつあるフィールド(PuyoGame.Fields[0]とPuyoGame.Fields[1])のどちらを担当するかです。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class AiVer1 { AiBasic _aiBasic = new AiBasic(); Field _field; bool _isAiprocessing = false; bool _isCancel = false; public AiVer1(Field field) { _field = field; } } |
ぷよを落とした結果を総当たりで取得する
GetAiResultsメソッドは3つの組ぷよを落とせるところに落とした結果を総当たりで取得します。
ぷよが上まであがってくると組ぷよを落とせる場所が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 |
public class AiVer1 { public List<AiActionResult> GetAiResults() { FallingPuyo[]? fallingPuyos, nextPuyos1, nextPuyos2; _field.GetFallingPuyos(out fallingPuyos, out nextPuyos1, out nextPuyos2); List<AiActionResult> results1 = _aiBasic.GetAiActionResults(_field.CopiedFixPuyos, fallingPuyos); List<AiActionResult> results2 = new List<AiActionResult>(); foreach (AiActionResult before in results1) { List<AiActionResult> results = _aiBasic.GetAiActionResults(before.FixedPuyosList.Last(), nextPuyos1); foreach (AiActionResult ret in results) { AiActionResult clone = before.Clone(); clone.Merge(ret); results2.Add(clone); } } List<AiActionResult> results3 = new List<AiActionResult>(); foreach (AiActionResult before in results2) { List<AiActionResult> results = _aiBasic.GetAiActionResults(before.FixedPuyosList.Last(), nextPuyos2); foreach (AiActionResult ret in results) { AiActionResult clone = before.Clone(); clone.Merge(ret); results3.Add(clone); } } return results3; } } |
負けそうになったときの行動
基本的にぷよを消さずに大連鎖を狙いますが、ぷよが上のほうに上がってきている状態では1連鎖であってもぷよを消すことを優先します。以下はそのような危険な状態にあるのかを判別するメソッドです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class AiVer1 { bool IsDangerous(Puyo[,]? puyos) { if (puyos == null) return true; for (int row = 0; row <= 6; row++) { for (int col = 1; col <= 4; col++) { if (puyos[row, col].Type != PuyoType.None) return true; } } return false; } } |
危険な状態でコンピュータが取るべき行動のリストを取得する処理を示します。
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 AiVer1 { List<AiActionResult> ActionOnDengerous(List<AiActionResult> actionResults) { List<AiActionResult> results; try { // 選択肢が極端に減っている場合は // AiActionResult.IsDeadsの要素数が3ではなくもっと少ないかもしれない // その場合はどうやっても詰んでいる状態なので選択肢はない results = actionResults.Where(_ => !_.IsDeads[0] && !_.IsDeads[1] && !_.IsDeads[2]).ToList(); } catch { results = new List<AiActionResult>(); } if (results.Count == 0) { Console.WriteLine("窒息死は回避できない"); return new List<AiActionResult>(); } // もしぷよを消せるなら消すことができるものを採用する // 消せない場合は適当な場所に積む int rensaMax = results.Max(_ => _.Rensas.Sum()); if (rensaMax > 0) { Console.WriteLine("ぷよを消して危機を回避"); return results.Where(_ => _.Rensas.Sum() == rensaMax).ToList(); } else { Console.WriteLine("ぷよが消せないので窒息しないところに積む"); return results; } } } |
連鎖が発生する場所を探す
少なくとも第二引数回数の連鎖が発生する行動を探す処理を示します。
連鎖する場合をみつけた場合、早い段階で連鎖するものを優先したいのでこのような処理にしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class AiVer1 { List<AiActionResult> SearchRensa(List<AiActionResult> actionResults, int minRensa) { try { // 基本的にAiActionResult.Rensasの要素数は3のはずだがそうではない場合もあるので例外処理をしている List<AiActionResult> results = actionResults.Where(_ => _.Rensas[0] >= minRensa).ToList(); if (results.Count > 0) return results; results = actionResults.Where(_ => _.Rensas[1] >= minRensa).ToList(); if (results.Count > 0) return results; results = actionResults.Where(_ => _.Rensas[2] >= minRensa).ToList(); return results; } catch { return new List<AiActionResult>(); } } } |
コンピュータ側にとって最適な行動を取得する
最適と思われる行動を選択する処理を示します。
すでに窒息している場合は最適の行動は存在しません。それ以外の時はGetAiResultsメソッドを呼び出してとりうるすべての行動のリストを取得したあと、以下の処理をおこないます。
まず現在危険な状態なのかを調べます。危険な場合はActionOnDengerousメソッドを呼び出して危険回避の行動を探します。
それ以外のときは連鎖数が4以上のものがあるか探します。見つからない場合はそこに置いてもぷよが消えず危険な状態にならない場所を探します。それが存在しない場合は窒息しない場所を探します。
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 AiVer1 { public AiActionResult? GetBestAction() { Puyo[,] copiedFixPuyos = _field.CopiedFixPuyos; // すでに窒息している場合は適切な行動自体が存在しない if (copiedFixPuyos[Field.INIT_MAIN_ROW, Field.INIT_MAIN_COL].Type != PuyoType.None) return null; if (IsDangerous(copiedFixPuyos)) { confirmedActions = ActionOnDengerous(actionResults); } else { // 4連鎖以上があるなら探す List<AiActionResult> ret = SearchRensa(actionResults, 4); if (ret.Count > 0) confirmedActions = ret; else { // ぷよを消さない方法を探す List<AiActionResult> retZero = actionResults.Where(_ => !_.Rensas.Any(ren => ren > 0)).ToList(); // 危険な位置にぷよを置かない方法を探す List<AiActionResult> retSafe = actionResults.Where(_ => !_.FixedPuyosList.Any(_ => IsDangerous(_))).ToList(); if (retZero.Intersect(retSafe).ToList().Count > 0) { // ぷよを消さず、危険な位置にぷよを置かない方法を探す confirmedActions = retZero.Intersect(retSafe).ToList(); } else if (retSafe.Count > 0) { // 危険な位置にぷよを置かない方法を探す confirmedActions = retSafe; } else { // 危険な位置にぷよを置かない方法がないなら窒息をしない位置にぷよを置く confirmedActions = actionResults.Where(_ => !_.IsDeads.Any(_=>_ == true)).ToList(); } // どれも見つからない場合はどうすることもできない } } // confirmedActions.Count > 0のときはそのなかから評価点の合計がもっとも高い部分を選ぶ if (confirmedActions.Count > 0) { CalculateEvaluationValues(confirmedActions); // それぞれについて評価値を計算する(後述) int evaluationValuesMax = confirmedActions.Max(_ => _.EvaluationValues.Sum()); return confirmedActions.First(_ => _.EvaluationValues.Sum() == evaluationValuesMax); } else { // 選択できるものがない(すでに詰んでいる) return null; } } } |
評価値の計算
評価値を計算する処理を示します。
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 AiVer1 { void CalculateEvaluationValues(List<AiActionResult> results) { foreach (AiActionResult result in results) result.EvaluationValues.Clear(); foreach (AiActionResult result in results) { List<Puyo[,]> puyosList = result.FixedPuyosList; foreach (Puyo[,] puyos in puyosList) { int evaluationValue = 0; for (int row = 0; row < Field.ROW_MAX; row++) { for (int col = Field.COL_MAX - 1; col >= 0; col--) { if (puyos[row, col].Type == PuyoType.None) continue; if (puyos[row, col].Type == PuyoType.Ojama) continue; // 評価できるぷよがあれば加点する evaluationValue += CalculateEvaluationValue(puyos, row, col, puyos[row, col].Type); } } result.EvaluationValues.Add(evaluationValue); } } } } |
ある位置にあるぷよの下に同じぷよがある場合、異なる色のぷよがあってもその下に同じ色のぷよがあり、間にあるぷよの色が一色の場合は加点します。また下にあるぷよの下にさらに同じ色のぷよがある場合はさらに加点を増やします。
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 |
public class AiVer1 { int CalculateEvaluationValue(Puyo[,] puyos, int puyoRow, int puyoCol, PuyoType puyoType) { if (puyoRow + 1 >= Field.ROW_MAX) return 0; int score = 0; if (puyos[puyoRow + 1, puyoCol].Type == PuyoType.Ojama) return 0; // そのぷよの真下が同じ色のぷよなら評価点を加点する if (puyos[puyoRow + 1, puyoCol].Type == puyoType) { // 真下だけでなくそのひとつ下のぷよも同じ色のぷよなら評価点をさらに大きく加点する if (puyoRow + 2 < Field.ROW_MAX && puyos[puyoRow + 2, puyoCol].Type == puyoType) score += 4; else score += 1; } // そのぷよの斜め下が同じ色のぷよなら評価点を加点する // 斜め下だけでなく斜め下のぷよのひとつ下のぷよも同じ色のぷよなら評価点をさらに大きく加点する if (puyoCol + 1 < Field.COL_MAX && puyos[puyoRow + 1, puyoCol + 1].Type == puyoType) { if (puyoRow + 2 < Field.ROW_MAX && puyos[puyoRow + 2, puyoCol + 1].Type == puyoType) score += 4; else score += 1; } if (puyoCol - 1 >= 0 && puyos[puyoRow + 1, puyoCol - 1].Type == puyoType) { if (puyoRow + 2 < Field.ROW_MAX && puyos[puyoRow + 2, puyoCol - 1].Type == puyoType) score += 4; else score += 1; } // そのぷよの下は異なる色のぷよだが、さらに下に同色のぷよがあり、両者に挟まされているぷよが単色の場合は評価点を加点する // 下にある同色のぷよのひとつ下も同色である場合は評価点をさらに加点する if (puyos[puyoRow + 1, puyoCol].Type != puyoType) { PuyoType downType = puyos[puyoRow + 1, puyoCol].Type; for (int row = puyoRow + 2; row < Field.ROW_MAX; row++) { if (puyos[row, puyoCol].Type == puyoType) { if (row + 1 < Field.ROW_MAX && puyos[row + 1, puyoCol].Type == puyoType) score += 4; else score += 1; break; } if (puyos[row, puyoCol].Type != downType) break; } } // そのぷよの斜め下は異なる色のぷよだが、さらに下に同色のぷよがあり、両者に挟まされているぷよが単色の場合は評価点を加点する // 下にある同色のぷよのひとつ下も同色である場合は評価点をさらに加点する if (puyoCol + 1 < Field.COL_MAX && puyos[puyoRow + 1, puyoCol + 1].Type != puyoType) { PuyoType downType = puyos[puyoRow + 1, puyoCol + 1].Type; for (int row = puyoRow + 2; row < Field.ROW_MAX; row++) { if (puyos[row, puyoCol + 1].Type == puyoType) { if (row + 1 < Field.ROW_MAX && puyos[row + 1, puyoCol + 1].Type == puyoType) score += 4; else score += 1; break; } if (puyos[row, puyoCol + 1].Type != downType) break; } } if (puyoCol - 1 >= 0 && puyos[puyoRow + 1, puyoCol - 1].Type != puyoType) { PuyoType downType = puyos[puyoRow + 1, puyoCol - 1].Type; for (int row = puyoRow + 2; row < Field.ROW_MAX; row++) { if (puyos[row, puyoCol - 1].Type == puyoType) { if (row + 1 < Field.ROW_MAX && puyos[row + 1, puyoCol - 1].Type == puyoType) score += 4; else score += 1; break; } if (puyos[row, puyoCol - 1].Type != downType) break; } } return score; } } |
実際にぷよを移動させて落とす
最適と思われる行動を選択し、実際にそのようにぷよを移動させて落とす処理を示します。
最適解を得るための処理が重いので取得できた3回分の行動をまとめて行なうことで処理が重くなるのを防ぎます。ただサーバー上で動かしてみると動作は重いです。複数のユーザーがプレイしている場合はさらに重くなり、ゲームとしては成り立たなくなっています。この部分は後日改善します。
またコンピュータ側の2手目と3手目がはじまるタイミングでおじゃまぷよが降ってくる場合があります。コンピュータ側の最善手はそのおじゃまぷよは存在しないという前提で計算されているので、この場合はおじゃまぷよが存在を前提に最善手を探す処理をもう一度やり直さなければなりません。そのためCancelメソッドを定義しています。
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 |
public class AiVer1 { bool _isCancel = false; public void Cancel() { _isCancel = true; } public async Task BestAction(System.Timers.Timer timerForPuyoDown) { // 処理中に同じ処理がおこなわれないようにする if (_isAiprocessing) return; _isAiprocessing = true; // 最適解がみつかったら実際に組ぷよを移動・回転させて落とす AiActionResult? aiActionResult = GetBestAction(); if (aiActionResult != null) { for (int i = 0; i < aiActionResult.Rights.Count; i++) { // キャンセルフラグが立っていたらループを抜ける if (_isCancel) { _isCancel = false; break; } if (aiActionResult.Rights[i] > 0) { for (int k = 0; k < aiActionResult.Rights[i]; k++) { _field.MoveRight(); // timerForPuyoDown.Enabled == falseのときはステージクリアが確定したときなので // Task.Delay(50)は実行しないでさっさとループを抜ける if (timerForPuyoDown.Enabled) await Task.Delay(50); } } if (aiActionResult.Rights[i] < 0) { for (int k = 0; k < Math.Abs(aiActionResult.Rights[i]); k++) { _field.MoveLeft(); if (timerForPuyoDown.Enabled) await Task.Delay(50); } } if (aiActionResult.RotateCounts[i] == 1) { _field.Rotate(true); if (timerForPuyoDown.Enabled) await Task.Delay(50); } if (aiActionResult.RotateCounts[i] == 3) { _field.Rotate(false); if (timerForPuyoDown.Enabled) await Task.Delay(50); } if (aiActionResult.RotateCounts[i] == 2) { _field.Rotate(true); if (timerForPuyoDown.Enabled) await Task.Delay(50); _field.Rotate(true); if (timerForPuyoDown.Enabled) await Task.Delay(50); } if (!timerForPuyoDown.Enabled) break; await _field.FallAll(); } _isAiprocessing = false; } else { // 3手以内に窒息することを避ける手段はない // ただし左右が空いている場合はそこに移動させる(=詰んでいる状態ではあるが悪あがきをする) if (_field.CopiedFixPuyos[Field.INIT_MAIN_ROW, Field.INIT_MAIN_COL + 1].Type == PuyoType.None) _field.MoveRight(); if (_field.CopiedFixPuyos[Field.INIT_MAIN_ROW, Field.INIT_MAIN_COL - 1].Type == PuyoType.None) _field.MoveLeft(); if (timerForPuyoDown.Enabled) await Task.Delay(50); await _field.FallAll(); _isAiprocessing = false; Task.Run(async () => { }); } } } |
自作のAIを動作させる
定義したクラスのインスタンスをPuyoGameクラス内に生成します。またField.OjamaFalledにイベントハンドラを追加していますが、これはコンピュータ側のフィールドにおじゃまぷよが降ってきたときに最善手を探す処理をもう一度やり直せるようにするためです。コンピュータ側の2手目と3手目がはじまるタイミングでおじゃまぷよが降ってくるとAIは新しくフィールド上に出現したおじゃまぷよの存在を知ることができないので、このようにしています。
RunAiメソッドのループのなかでSystem.GC.Collect()を呼び出しています。コンピュータ側の手を探すために短時間で大量のAiActionResultオブジェクトを生成しているため、これらを回収するために呼び出しています。最初はこれに気づかなかったため、サーバーのメモリー消費量がヤバいことになっていました。
PuyoGameクラスに追加するメソッドと既存のメソッドで修正する部分を示します。
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 |
public class PuyoGame { AiVer1 _aiVer1_Fields1; public PuyoGame(string connectionId) { // 既存のコードに以下を追加する _aiVer1_Fields0 = new AiVer1(Fields[0]); _aiVer1_Fields1 = new AiVer1(Fields[1]); Fields[0].OjamaFalled += FieldPlayer1_OjamaFalled; Fields[1].OjamaFalled += FieldPlayer2_OjamaFalled; } private void FieldPlayer2_OjamaFalled(bool isPlayer, int count) { // おじゃまぷよが降ってきたら最善手を探す処理をやりなおす _aiVer1_Fields1.Cancel(); } public async Task RunAi() { while (_isPlaying) { try { System.GC.Collect(); } catch { Console.WriteLine("GC.Collect失敗"); } await Task.Delay(500); try { // 第一ステージだけカエル積み、第二ステージ以降は上記のアルゴリズムを採用する if (_stageNumber == 1) await Kaeru(Fields[1]); else if (_stageNumber >= 2) await _aiVer1_Fields1.BestAction(_timerForPuyoDown); } catch (Exception ex) { // 例外発生時はループをぬける Console.WriteLine(ex.Message); Console.WriteLine(ex.ToString()); break; } } } bool _isKaeruProcessing = false; // これまで使ってきたカエル積み async Task Kaeru(Field field) { if (!_isKaeruProcessing) { _isKaeruProcessing = true; while (true) { bool ret; if ( field.CopiedFixPuyos[Field.INIT_MAIN_ROW + 6, Field.INIT_MAIN_COL].Type == PuyoType.None || field.CopiedFixPuyos[Field.INIT_MAIN_ROW + 4, Field.INIT_MAIN_COL + 1].Type == PuyoType.None ) ret = field.MoveRight(); else ret = field.MoveLeft(); await Task.Delay(100); if (!ret) break; } await field.FallAll(); _isKaeruProcessing = false; } } } |
ゲーム開始時に上記メソッドを呼び出します。またコンピュータ側がゲームオーバーになったときはステージクリアであり、これにともなってRunAiメソッドも終了するので再度RunAiメソッドを呼び出します。
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 |
public class PuyoGame { public async Task GameStart(string playerName) { _stageNumber = 1; PlayerName = playerName; await Fields[0].Init(); await Fields[1].Init(); Scores[0] = 0; Scores[1] = 0; OjamaCounts[0] = 0; OjamaCounts[1] = 0; _isPlaying = true; _timerForPuyoDown.Start(); RunAi(); // これを追加 } private void FieldPlayer2_GameOvered(Puyo[,] field, FallingPuyo? mainPuyo, FallingPuyo? subPuyo, List<FallingPuyo> fallingOjamaPuyos) { StageCleared?.Invoke(this, false); _timerForPuyoDown.Stop(); _isPlaying = false; Task.Run(async () => { Fields[0].IsNoWait = true; // ステージクリア以降は連鎖時にひとつひとつ停止させない await Task.Delay(2000); Fields[0].IsNoWait = false; OjamaCounts[0] = 0; OjamaCounts[1] = 0; await Fields[0].Init(); await Fields[1].Init(); _timerForPuyoDown.Start(); _isPlaying = true; Scores[1] = 0; _stageNumber++; _ = RunAi(); }); } } |