四川省に勝ちたくて、いかさまツールを作ってみた話

スポンサードリンク

こんにちは。



ふぁんたです。





突然ですが、ニコ生界隈で、「四川省」なるゲームが流行っております。



四川省とは、麻雀牌を17×8の長方形に並べ、法則に従ってペアを見つけて消していくゲームです。



流行っているゲームでは消したときに得点が入り、1分の制限時間のあと、生主とリスナー合わせてのランキングが発表されます。





で、





これをめちゃくちゃな回数やってるやつらがいる。



https://game.nicovideo.jp/atsumaru/games/gm11123



これを。めちゃくちゃやってるやつらがいる。









なかなか勝てないので、勝てる方法を考えました。



画像認識を使えば、手詰まりになることなく、爆速でペアを見つけ続けることができるのでは?



と。







そこで、画像処理ライブラリ「OpenCVsharp」を使い、



画像認識 → 盤面解析 → わかりやすい指示(もしくはマウス操作)



これをしてしまえば、自分の(プログラム開発の)実力で、四川省erの頂点に立てるのではないか、と考えました。





2日かけてやってきたことを報告します。




盤面の画像認識


まずはこれです。



牌ひとつひとつ切り抜いた画像を用いて、別の盤面の画像認識を行います。



使うのは、画像の中から画像を探す、「テンプレートマッチング」と呼ばれる手法です。



複数の認識を一度に行う方法もあったのですが、やり方が難しく成功しなかったのと、盤面に置かれる牌の枚数は決まっているので、東が5枚あるとかいう結果になってほしくなくてやめました。
        for (int times = 0; times < 4; times++)

{
for (int i = 0; i < 34; i++)
{
Cv2.MatchTemplate(data_forpaint, tiles[i], result, TemplateMatchModes.CCoeffNormed);

double minval, maxval;
OpenCvSharp.Point minloc, maxloc;
Cv2.MinMaxLoc(result, out minval, out maxval, out minloc, out maxloc);

information[fieldcounter] = new field(maxloc.X, maxloc.Y, tilename[i]);
fieldcounter++;

Rect rect = new Rect(maxloc.X, maxloc.Y, tiles[i].Width, tiles[i].Height);

Cv2.Rectangle(data_forpaint, rect, new OpenCvSharp.Scalar(0, 0, 0));
Rect outRect;
Cv2.Rectangle(data_forpaint, rect, new OpenCvSharp.Scalar(0, 0, 0), -1);



}
}

1行目のloopと3行目のloopは、最初は入れ替えていました。



各牌について、「画像の中の最もその牌っぽい部分を取得して塗りつぶす」を4回やれば、全部取得できるという考えだったのです。



入れ替える前の取得結果がこちら。



左側が取得結果、右側が読み込んだ画像です。

一番右上の4pに注目してください。5pだと認識されています。



これは、4pをマッチングさせる時、4pによく似た5p(注目してる4pの3つ左、1つ下)を4pだと判断して消してしまった結果、5pが4pの枠に入ってしまったことが原因だと考えました。



なので、5pを4pに取られる前に、5pがちゃんと5pになってくれるようにした、ということです。



…順番変えたら5pが4pになるような状況は変わってないので、2値化の閾値を変えるなどしたほうが効果的なんでしょうが、とりあえず100%の正解率を叩き出しているのでこのままにしてます。


牌を消すアルゴリズム




①そうやって取得した牌を順番に並び替え、盤面を配列で再現します。



②余白として、盤面の上下左右に1行or1列ずつ「牌がない」場所、いわば外枠を準備します。

最上辺同士などで消せる場合を計算できるようにするためです。



③ある牌を選び、そこから2回曲がるまでにぶつかる牌すべてを深さ優先探索で見つけます。



④それが同じ牌だったら、消せるリストに追加。



⑤ ③と④を盤面のすべての牌に対して行う。



⑥ リスト内の牌を盤面から消す。



⑦ ⑤と⑥を最後までやる。



こうです。


        static List<Tuple<field, field>> searchPairTiles(string[,] board)

{
//boardは17,8
string[,] board_andOutside = new string[19, 10];
List<Tuple<field, field>> retval = new List<Tuple<field, field>>();

for (int i = 0; i < 19; i++)
{
for (int j = 0; j < 10; j++)
{
board_andOutside[i, j] = "XX";
}
}
for (int i = 0; i < 17; i++)
{
for (int j = 0; j < 8; j++)
{
board_andOutside[i + 1, j + 1] = board[i, j];
}
}
bool isChangedinThisCycle = true;
while (isChangedinThisCycle)
{
List<string> pairTileList = new List<string>();
isChangedinThisCycle = false;
for (int left = 1; left <= 17; left++)
{
for (int right = 1; right <= 8; right++)
{
if (board_andOutside[left, right] == "XX") continue;

//ターゲットに関して、上下左右に1ステップ、いったもの(衝突してもよい)をキューにいれる
state state8 = new state(left, right - 1, board_andOutside[left, right], 8);
state state6 = new state(left + 1, right, board_andOutside[left, right], 6);
state state4 = new state(left - 1, right, board_andOutside[left, right], 4);
state state2 = new state(left, right + 1, board_andOutside[left, right], 2);

Stack<state> tasks = new Stack<state>();
tasks.Push(state8);
tasks.Push(state6);
tasks.Push(state4);
tasks.Push(state2);

while (tasks.Count > 0)
{
//いっこだす
state task = tasks.Pop();

//そのマスが、空間か、牌があるかで分岐
if (board_andOutside[task.x, task.y] == "XX")
{
//空間
for (int i = 0; i < 4; i++)
{
state state = new state();
task.CloneTo(state);
state.changedirection(i * 2 + 2);

if (state.n_canChangeDirection > -1)
{
if (state.x >= 0 && state.x <=18 && state.y >= 0 && state.y <=9)
{//範囲内なら
tasks.Push(state);
}
}
}

}
else
{
//牌がある 探索を終える
if (board_andOutside[task.x, task.y] == board_andOutside[left, right])
{
isChangedinThisCycle = true;

Tuple<field, field> tuple;
//消せるので登録
if (task.x < left)
{
tuple = new Tuple<field, field>(new field(task.x - 1, task.y - 1, board_andOutside[left, right]), new field(left - 1, right - 1, board_andOutside[left, right]));
}
else if (task.x > left)
{
tuple = new Tuple<field, field>(new field(left - 1, right - 1, board_andOutside[left, right]), new field(task.x - 1, task.y - 1, board_andOutside[left, right]));

}
else
{
if (task.y < right)
{
tuple = new Tuple<field, field>(new field(task.x - 1, task.y - 1, board_andOutside[left, right]), new field(left - 1, right - 1, board_andOutside[left, right]));

}
else if (task.y > right)
{
tuple = new Tuple<field, field>(new field(left - 1, right - 1, board_andOutside[left, right]), new field(task.x - 1, task.y - 1, board_andOutside[left, right]));

}
else
{
tuple = new Tuple<field, field>(new field(), new field());
;
//ここに来るのはおかしい
}

}
if (!pairTileList.Contains(tuple.Item1.name))
{
retval.Add(tuple);
pairTileList.Add(tuple.Item1.name);
}

}

}

}
;

}
}
if (!isChangedinThisCycle) break;

for (int i = 0; i < retval.Count; i++)
{
board_andOutside[retval[i].Item1.x + 1, retval[i].Item1.y + 1] = "XX";
board_andOutside[retval[i].Item2.x + 1, retval[i].Item2.y + 1] = "XX";
}
//なかみをここに


for (int i = 1; i <=17; i++)
{
for (int j = 1; j <=8; j++)
{
Console.Write(board_andOutside[i, j] + " ");
}
Console.WriteLine();

}
Console.WriteLine();
}
return retval;
}

きったねぇコードだなぁ…



自分は完全に独学で、関数の名前どうつけるべきとか

ネストの深さとか考える癖とかないんですけど

うーん。名付けにセンスがない。





本当は、1種類の牌の消し方が2通り以上あった場合は、

どっちがより多く消せるか探索を行ったりする必要があると思うんですが、

できてません。もう1段階深い探索をしなきゃならないのかも。




これからの方針




主な問題は2つ。



1.得られたものをどう使うか

2.ところで、なんかに違反してない?







1.については、表示方法についてです。

コンソールアプリケーションしか作ったことない自分にしてみれば、

「得られた情報をどう使うか」の部分がまだ弱い。



ガイドとして画面に表示するか、

はたまた、マウスまで自動で操作しちゃうのか、

考えどころです。実装の可否も含めて。







2.については、

うん。イカサマだし。



なんかこう、運用上まずいのかなーと。



上で上げたURLでは、全体でのランキングがあるし、そこに

技術の粋を集めた(?)プログラムが土足で踏み込んでいいのかな、という

(規約違反とか?)思いがあります。



…生放送で使うぐらいならいいのか?

スポンサードリンク