[魔改造 Judg○] (5)問題の作り方からディープコピーまで

これはなんでしょう?

正解は麻雀の問題です。せっかく麻雀牌を作ったので、どんな問題がいいかな…と当たり牌を当てるゲームを考えてみました。

でもこれ、わずか数行ですぐに問題を抱えていることに気づきました。

Judg〇 というゲームにこの問題は合わない

確かに! 瞬時に判断するゲームにしては問題が難しすぎる気もします。
ただ、この手の問題は足踏み速度を調整するなり、やりようはあるかなー、と。

なにより牌作っちゃったから色々な問題を作ってあとで取捨選択する、これはこれでアリ、ということにしましょう。牌を枠内にちゃんと収まるよう描画するシステムも上手くいったことだし。

問題を作るのが面倒すぎる

驚くほど画竜点睛を欠くいきあたりばったりでこの複雑な問題を「アリ」としましたが、たとえそれでも問題を作るのが面倒すぎるんです。

両面待ちなんていくらでもパターンがあります。
でも、手で書くと以外と時間かかる。1問3分としても、1時間で20問。
かといって、両面待ちの問題を例えば3つしかつくらなければ、そのうち覚えちゃいますよね。
当たりのパターンは3つしか存在しないのですから。

問題があってるかチェックも必要

パッと見の数字の大小と違って、麻雀であがれるかどうかの判断は瞬時にできません。
(少なくとも、わたしは)
ましてやエクセルで文字として書いていた場合、名うての雀士でも厳しいんじゃないでしょうか。

ゲームとして、正解が外れだったり、外れが正解になってしまうのは嫌だ。
でも、人が手で問題を作る限り、どうしたってヒューマンエラーが起こりうる。
問題を作れば作るほど、エラーの入り込む可能性は上がる。
デバッグ会社? 個人の戯れで作ってるのに、そんなの持ち出せない。

需要に供給が追い付いてないせいか中小規模タイトルは割とザルチェックで終わらせてしまうデバッグ会社も多いです。
もちろんそうじゃないこともあります! が、担当者運ですね…相当厳しい目に合うことも…。
「パッチ出せばいいよ」という安心感が裏目に出た現実の一つとも感じます。

そうだ、自動生成しよう

実は、こんな問題も手打ちです。
嘘です。さすがにエクセルのマクロ機能は使ってるので、半手打ちですが、それでも左辺値と右辺値のランダム数字を打つのは面倒でした。

こんな問題、これだけで…。

この1行だけで、問題と答えを無限生成。どう考えてもそっちの方がいい!
問題の難易度はつけるとしても、

1桁+1桁、2桁+1桁、2桁+2桁の 3 つ作りました。
以前の記事で 1000 問作りたい、などといってましたが、これだけで突破ですね!

プログラムで問題を作る

問題をプログラムに作ってもらわなければいけません。
エクセルで {TashizanX_X} とした部分は、正しい問題に書き換えられます。

Question2 -> {TashizanX_X}
Answer -> (空欄)
Miss[0] -> (空欄)

Question2 -> "3+7="
Answer -> 10
Miss[0] -> 8

実際にやってみます。

// 足し算の行
Questions_Table.Row row = Questions.FindRowByID(170);

if (row.Question2 == "{TashizanX_X}")
{
    int a = Rand.Range(1, 10);
    int b = Rand.Range(1, 10);
    int c = Rand.Range(1, 5);
    if (Rand.Range(0, 2) == 1)
    {
        c *= -1;
    }

    row.Question2 = $"{a} + {b} =";
    row.Answer    = $"{a+b}";
    row.Miss[0]   = $"{a+b+c}";
}

左辺1~10、右辺1~10、間違えの答えのゆらぎ値1~5(-1~-5)って感じですね。
Rand Random で置き換えて考えてもらえるといいと思います。

ランダムはゲームトレースや確定で不具合を出したい場合の追跡など、自分の作ったクラスでラップしておくと恩恵が得られることも多いです。

さて、実際にゲームで動かしてみるのですが、このコード、1回しか使えない問題を抱えていました。

問題:{TashizanX_X} が直接書き換えられてしまう

直接書き換えられるとどうなるでしょうか。
そう、2回目に問題を作ろうとしても、

// 2 回目の row.Question2 は 3+7= とかになっているので、通らない
if (row.Question2 == "{TashizanX_X}")

1度書き換えてしまったので、元には戻らないですよね。
出来れば元はそのままで、問題の表示に使うだけの新しい器は用意できないでしょうか。

1番簡単な方法

new で新しいバッファを作って、自分で入れるのが1番お手軽。

Questions_Table.Row newrow = new Questions_Table.Row();
newrow.ID = row.ID;
newrow.Question1 = row.Question1;
newrow.Question2 = row.Question2;
newrow.Answer = row.Answer;
foreach (string val in row.Miss)
{
  newrow.Miss.Add(val);
}
newrow.QuestionCategory = row.QuestionCategory;
newrow.QDispCategory = row.QDispCategory;
newrow.ADispCategory = row.ADispCategory;

いやいやいや、全然お手軽じゃないでしょ!
問題点は見ての通り。自分ですべてのメンバーを手で入れないといけません。
あらゆる箇所で記述する必要がありますし、後でメンバーが増えたりしたら、ほぼ確実に入れ忘れ不具合発生。
確かにロジックは簡単だけど、まずやらないほうがいいでしょう。

コンストラクタを作る

コピーしてくれるようなコンストラクタを作っておけば、少なくとも記述は1か所で済みます。

public class Row
{
    public int               ID;
    public string            Question1;
    public string            Question2;
    public string            Answer;
    public List<string>      Miss = new List<string>();
    public eQuestionCategory QuestionCategory;
    public eQADispCategory   QDispCategory;
    public eQADispCategory   ADispCategory;

    public Row(Row row)
    {
        ID = row.ID;
        Question1 = row.Question1;
        Question2 = row.Question2;
        Answer = row.Answer;
        foreach (string val in row.Miss)
        {
        Miss.Add(val);
        }
        QuestionCategory = row.QuestionCategory;
        QDispCategory = row.QDispCategory;
        ADispCategory = row.ADispCategory;
    }
};

{
	Questions_Table.Row row = Questions.FindRowByID(170);

	Questions_Table.Row newrow = new Questions_Table.Row(row);
}

先ほどに比べて少しはマシですが、結局コンストラクタにコピー内容を全部書かなければいけないので、忘れる可能性がゼロ、とはいきませんね。
とはいえ、初心者かつ、自分のわかる範囲のコードで済ませたい場合、これは選択の余地あり。

ディープコピー

そもそもコピー内容とか書きたくない。丸々コピーしてくれるクラスメソッドとかないの? と思いますが、用意されていません。
が、それを可能にするためのメソッドを作成することは出来ます。作ってみましょう。

/// <summary>
/// deepcopy
/// </summary>
static T deepCopy<T>(T src)
{
    using (var memoryStream = new MemoryStream())
    {
        var binaryFormatter = new BinaryFormatter();

        binaryFormatter.Serialize(memoryStream, src);
        memoryStream.Seek(0, SeekOrigin.Begin);

        return (T)binaryFormatter.Deserialize(memoryStream);
    }
}

{
	Questions_Table.Row row = Questions.FindRowByID(170);

	// 丸々コピーされた新しい器をゲット
	Questions_Table.Row newrow = deepCopy(row);
}

T やら MemoryStream やら急にわけのわからない記述がいっぱい出てきますし、わたしは覚えられないので毎回どっかのサイトから似たようなのをコピペします。
なお、これを可能にするためには、クラスの先頭に必ず [Serializable] を入れる必要があります。
(入れてないとエラーになります)

public class Questions_Table : ScriptableObject
{
    [System.Serializable]
    public class Row
    {
        public int               ID;
        public string            Question1;
        public string            Question2;
        public string            Answer;
    };
}

次は、問題自動生成コードを綺麗に整理する事について考えていきましょう。