一介のCS人の綴り

トップ > プログラミング > 創作プログラミング
Tweet

今回の主題は?

今回の主題は、Siv3Dを用いて簡単なAI機能を持つ三目並べを作ってみようと思います。 今回用いられている、MinMax法というアルゴリズムは「Siv3Dを遊ぼう」という趣旨に外れるため、今回は細かく述べませんが、今後まとめた記事を別連載で書く予定です。

ここでは、このライブラリに慣れるという趣旨で、簡単なプログラムを記述し実行して楽しんでいこう!ということで、「Siv3Dで遊ぼう!」というタイトルで数回記事を書いていきます。

Siv3Dで遊ぼう! - No.3

→ クリックで開閉

盤上の設定

// 盤上の状態
enum Condition{
	Empty, Com, Player = 4
};
// 盤上の状態の格納
Array<condition> board(9);
// 盤上の直線を全列挙
tuple<int, int, int> lines[8] = {
	make_tuple(0, 1, 2), make_tuple(3, 4, 5), make_tuple(6, 7, 8),
	make_tuple(0, 3, 6), make_tuple(1, 4, 7), make_tuple(2, 5, 8),
	make_tuple(0, 4, 8), make_tuple(2, 4, 6),
};
int comLevel = 1; // Playerが、1:絶対に勝てない -1:絶対に負けない

// 各マス目の盤の中心に対する位置を求める
inline Point calCenter(int x, int y){
	return Window::Center().movedBy(100 * (x - 1), 100 * (y - 1));
}

// 各マスの二次インデックスから一次元インデックスを返す
inline int calIndex(int x, int y){
	return x * 3 + y;
}
盤上の説明図

盤上の状態をConditionという列挙体で管理する。なぜPlayerが4であるかは3 * COM = 3であるからである。 つまり、盤上の各一直線(3マス)の数値の和と碁石の置かれた状態が一体一対応で表現できるためである。
三目並べの盤上は図のような一次元のインデックスと二次元のインデックスを使い分けて管理している。また、各一直線状をlines[8]にて管理している。
また、各マスはウインドウの中心からの相対位置を指定している。

ゲームの勝敗等の状況把握

// 現在のターン数を返す
int calTurn(){
	int notEmpty = 0;
	for (int x = 0; x < 3; x++){
		for (int y = 0; y < 3; y++){
			if (board[calIndex(x, y)] != Empty) notEmpty++;
		}
	}
	return notEmpty;
}
// 勝負がついたかを返す
int judge(){
	// 盤上の各直線について3つ並んでいるかどうか
	for (int i = 0; i < 8; i++){
		int s = board[get<0>(lines[i])] + board[get<1>(lines[i])] + board[get<2>(lines[i])];

		if (s == 3)		return -1;  // COM側が勝利
		if (s == 12)	return 1;	// プレイヤー側が勝利
	}
	return 0; // まだ勝負がついていない
}
// ゲームの勝敗を表示する
void drawResult(int result){
	result++;
	String str[3] = {
		L"You Lose", L"Draw", L"You Win",
	};
	int width = 400;
	int height = 150;
	Rect(Window::Center().x - width / 2, Window::Center().y - height / 2, width, height).draw(Palette:: Lightblue);
	Rect strRegion = Font(50)(str[result]).region();
	Font(50)(str[result]).draw(Window::Center().x - strRegion.w / 2, Window::Center().y - strRegion.h / 2, Palette::Blue);
}

現在が何ターンかは、盤上にいくつ指されているかを数えるだけで把握できる。
また、盤上の各一直線状について、それぞれの状態(Condition)を足し合わせることで、COM側が勝利、つまり状態COMが3つ並んでいる場合は和が3に、 Player側が勝利、つまり状態Playerが3つ並んでいる場合は和が12になるため、そうであるかを全一直線上について確認している。

MinMax法によるCOM側の最適手の推定

// 次の手として可能性があるものを全列挙
Array<array<condition> > makeChild(Array<condition> b, Condition turn){
	Array<array<condition> > ans;
	for (int i = 0; i < 9; i++){
		if (b[i] == 0){
			Array<condition> temp = b;
			temp[i] = turn;
			ans.push_back(temp);
		}
	}
	return ans;
}
// 盤上の状態bとなった場合のCOM側の評価点を返す
int calScore(Array<condition> b){
	// c:COM p:Playerとして、盤上の全直線状にいくつそれぞれの状態が存在するか
	// c1,p1: 1つ並んでいる c2,p2: 2つ並んでいる c3,p3: 3つ並んでいる
	int c1, c2, c3, p1, p2, p3;
	c1 = c2 = c3 = p1 = p2 = p3 = 0;
	for (int i = 0; i < 8; i++){
		int s = 0;
		int line[3] = {get<0>(lines[i]), get<1>(lines[i]), get<2>(lines[i])};
		for (int j = 0; j < 3; j++){
			s += b[line[j]];
		}
		switch (s)
		{
			case 1: c1++; break; //●--, -●-, --●
			case 2: c2++; break; //●●-, ●-●, -●●
			case 3: c3++; break; //●●●
			case 4: p1++; break; //○--, -○-, --○
			case 8: p2++; break; //○○-, ○-○, -○○
			case 12: p3++; break; //○○○
		}
	}
	return (-63 * p2 + 31 * c2 - 15 * p1 + 7 * c1 - 1e7 * p3 + 1e7 * c3) * comLevel;
}
// 次のPlayerの手までを考慮したMinMax法を実行し、最適解を返す
Array<condition> min_max(Array<array<condition> > next){
	Array<condition> ans;
	int _max = -1e7;
	for (int i = 0; i < next.size(); i++){
		int _min = 1e7;
		Array<array<condition> > nextnext = makeChild(next[i], Player);
		for (int j = 0; j < nextnext.size(); j++){
			int score = calScore(nextnext[j]);
			_min = min(_min, score);
		}
		if (_max < _min){
			_max = _min;
			ans = next[i];
		}
	}
	return ans;
}

ここでは、COMの手を計算している。現在の盤上の状態から、ある空のマスにCOM側が指した場合、次のPlayerが最善手を打ったと仮定しその際のCOM側における情勢の有利不利を数値によって評価し、 COM側がその手でいいかを判断する。すべての空のマスについて同様に計算し、どの手が最善手か推定する。
また、今回はその評価値を正負反転させることにより、COM側が負けに行く処理を実現している。

int main(){→ クリックで開閉

ゲームの設定をするGUI操作

    Graphics::SetBackground(Color(110, 110, 110));
	GUI gui(GUIStyle::Default);
	gui.setTitle(L"3目並べ");
	
	// ラジオボタン
	gui.addln(GUIText::Create(L"絶対に○○ない!3目並べ"));
	gui.add(L"rb1", GUIRadioButton::Create({ L"勝て", L"負け" }, 0u, true));
	// 水平線
	gui.add(L"hr1", GUIHorizontalLine::Create(1));
	gui.horizontalLine(L"hr1").style.color = Color(127);
	gui.addln(GUIText::Create(L"Playerの名前は?"));
	gui.add(L"PlayerName", GUITextField::Create(none));
	// 水平線
	gui.add(L"hr2", GUIHorizontalLine::Create(1));
	gui.horizontalLine(L"hr2").style.color = Color(127);
	gui.addln(GUIText::Create(L"Playerはどちらにしますか?"));
	gui.add(L"rb2", GUIRadioButton::Create({ L"先攻", L"後攻"}, 0u, true));
	// 水平線
	gui.add(L"hr3", GUIHorizontalLine::Create(1));
	gui.horizontalLine(L"hr3").style.color = Color(127);
	gui.add(L"StartButton", GUIButton::Create(L"START"));
	gui.setCenter(Window::Center());
	Condition first = Player; // 先手はどちらか
	String playerName = L"";
	while (System::Update()){
		if (gui.button(L"StartButton").pushed)
		{
			comLevel = (gui.radioButton(L"rb1").checked(0u) ? 1 : -1);
			playerName = gui.textField(L"PlayerName").text;
			first = (gui.radioButton(L"rb2").checked(0u) ? Player : Com);
			break;
		}
	}
	gui.release();
                        
GUI画面

今回のゲームは、AIの評価値の値を正負反転するかどうかで、絶対に勝てないor絶対に負けないのいずれかに設定できる。また、先攻後攻も設定できるようにした。 これらはラジオボタンによって選択できるようにしている。
また、テキストフィールドでPlayer名を入力できるようにしてある。
それらを設定した後、STARTボタンを押すことでゲームを始めることができる。

ゲームの前準備

    Font font(20);
	// 各マス目
	Rect squares[9];
    // 3x3の盤全体
	Rect aroundBoard = Rect(300, 300).setCenter(Window::Center());
    // 各マスの定義
	for (int x = 0; x < 3; x++){
		for (int y = 0; y < 3; y++){
			board[calIndex(x, y)] = Empty;
			squares[calIndex(x, y)] = Rect(100, 100).setCenter(calCenter(x, y));
		}
	}
	// 0:勝敗がついていない 1:Player側の勝利 -1:COM側の勝利
	int end_game = 0;
	// プレイヤー側が先攻ならTrue
	bool isPlayer = (first == Player);
                        

3x3の盤上をウインドウの中心を基準にそれぞれ定義している。また、各マスについてEmptyを代入し、盤を初期化している。
さらに、ゲーム中のフラグである、end_gameとisPlayerを宣言している。

COM側の攻め

        // COMの番なら次の最善手を求め、打つ
		if (!isPlayer){
			Array <array <condition> > next = makeChild(board, Com);
			board = min_max(next);
			isPlayer = true;
		}
                                

COM側の番の場合、上で定義した関数を用いて、空となっているマスの中で最善手と推定されるものを打ち、Player側に交代している。

盤の描画とPlayer側の攻め

        // 盤全体としての外枠を描画
		aroundBoard.drawFrame(0, 5, Palette::Brown);

        // 盤上の描画
		for (int x = 0; x < 3; x++){
			for (int y = 0; y < 3; y++){
                // 各マスについて外枠を描画
				squares[calIndex(x, y)].drawFrame(1, 1, Palette::Black);

                // もし、空のマスなら
				if (board[calIndex(x, y)] == Empty){
                    // マウスオーバーしていたら、薄い緑で、それ以外は緑色でマスを描画
					squares[calIndex(x, y)].draw(squares[calIndex(x, y)].mouseOver ? Palette::Lightgreen : Palette::Green);

					// Playerの番ならクリックされた空のマスに打つ
					if (isPlayer && squares[calIndex(x, y)].mouseOver && Input::MouseL.pressed){
						board[calIndex(x, y)] = Player;
						Circle(calCenter(x, y), 40).draw(Palette::White);
						isPlayer = false;
					}
				}
				else {
					squares[calIndex(x, y)].draw(Palette::Green);
                    // 今まで打たれた目の描画
					Circle(calCenter(x, y), 40).draw(board[calIndex(x, y)] == Com ? Palette::Black : Palette::White);
				}
			}
		}
                                

ここでは、Player側の操作に反映した3x3の盤の描画をしている。また、同時にPlayerの手が示された場合もそれを処理している。
ここで、Player側の番であるとisPlayerでフラグ管理しないと、マウスのクリックの時間内にfor文が回っているため、残りの空のマスすべてにPlayerの手が入力されるという 現象が起こるので注意。

ゲーム途中の状態の表示とゲームの勝敗決定

        const Circle player(Mouse::Pos(), 40);

        // ターン数の表示と勝敗の確認
		int turn = calTurn();
		font(Format(L"ターン数: ", turn)).draw(0, 0);
		font(Format(L"現在の番: ", (isPlayer ? Format(L"Player ", playerName) : L"COM") )).draw(0, Window::Height() * 0.90);
		end_game = judge();
		if (end_game != 0 || turn >= 9){
			drawResult(end_game);
			WaitKey();
			break;
		}
		player.draw(Palette::White);
                                

現在のターン数と勝敗の決着の有無を確認している。また、マウスポインタを白色で描画することにより、Playerが白い碁石を動かしているように描画している。

←} // while (System::Update())
←} // int main()

ソースコードの全容

#include <siv3d.hpp>
#include <tuple>
using namespace std;
// 盤上の状態
enum Condition{
	Empty, Com, Player = 4
};
// 盤上の状態の格納
Array<condition> board(9);
// 盤上の直線を全列挙
tuple<int, int, int> lines[8] = {
	make_tuple(0, 1, 2), make_tuple(3, 4, 5), make_tuple(6, 7, 8),
	make_tuple(0, 3, 6), make_tuple(1, 4, 7), make_tuple(2, 5, 8),
	make_tuple(0, 4, 8), make_tuple(2, 4, 6),
};
int comLevel = 1; // Playerが、1:絶対に勝てない -1:絶対に負けない
// 各マス目の盤の中心に対する位置を求める
inline Point calCenter(int x, int y){
	return Window::Center().movedBy(100 * (x - 1), 100 * (y - 1));
}
// 書くマスの一次元インデックスを返す
inline int calIndex(int x, int y){
	return x * 3 + y;
}
// 現在のターン数を返す
int calTurn(){
	int notEmpty = 0;
	for (int x = 0; x < 3; x++){
		for (int y = 0; y < 3; y++){
			if (board[calIndex(x, y)] != Empty) notEmpty++;
		}
	}
	return notEmpty;
}
// 勝負がついたかを返す
int judge(){
	// 盤上の各直線について3つ並んでいるかどうか
	for (int i = 0; i < 8; i++){
        int s = board[get<0>(lines[i])] + board[get<1>(lines[i])] + board[get<2>(lines[i])];
		if (s == 3)		return -1;  // COM側が勝利
		if (s == 12)	return 1;	// プレイヤー側が勝利
	}
	return 0; // まだ勝負がついていない
}
// 次の手として可能性があるものを全列挙
Array<array<condition> > makeChild(Array<condition> b, Condition turn){
	Array<array<condition> > ans;
	for (int i = 0; i < 9; i++){
		if (b[i] == 0){
			Array<condition> temp = b;
			temp[i] = turn;
			ans.push_back(temp);
		}
	}
	return ans;
}
// 盤上の状態bとなった場合のCOM側の評価点を返す
int calScore(Array<condition> b){
	// c:COM p:Playerとして、盤上の全直線状にいくつそれぞれの状態が存在するか
	// c1,p1: 1つ並んでいる c2,p2: 2つ並んでいる c3,p3: 3つ並んでいる
	int c1, c2, c3, p1, p2, p3;
	c1 = c2 = c3 = p1 = p2 = p3 = 0;
	for (int i = 0; i < 8; i++){
		int s = 0;
		int line[3] = {get<0>(lines[i]), get<1>(lines[i]), get<2>(lines[i])};
		for (int j = 0; j < 3; j++){
			s += b[line[j]];
		}
		switch (s)
		{
			case 1: c1++; break;
			case 2: c2++; break;
			case 3: c3++; break;
			case 4: p1++; break;
			case 8: p2++; break;
			case 12: p3++; break;
		}
	}
	return (-63 * p2 + 31 * c2 - 15 * p1 + 7 * c1 - 1e7 * p3 + 1e7 * c3) * comLevel;
}
// 次のPlayerの手までを考慮したMinMax法を実行し、最適解を返す
Array<condition> min_max(Array<array<condition> > next){
	Array<condition> ans;
	int _max = -1e7;
	for (int i = 0; i < next.size(); i++){
		int _min = 1e7;
		Array<array<condition> > nextnext = makeChild(next[i], Player);
		for (int j = 0; j < nextnext.size(); j++){
			int score = calScore(nextnext[j]);
			_min = min(_min, score);
		}
		if (_max < _min){
			_max = _min;
			ans = next[i];
		}
	}
	return ans;
}
// ゲームの勝敗を表示する
void drawResult(int result){
	result++;
	String str[3] = {
		L"You Lose", L"Draw", L"You Win",
	};
	int width = 400;
	int height = 150;
	Rect(Window::Center().x - width / 2, Window::Center().y - height / 2, width, height).draw(Palette:: Lightblue);
	Rect strRegion = Font(50)(str[result]).region();
	Font(50)(str[result]).draw(Window::Center().x - strRegion.w / 2, Window::Center().y - strRegion.h / 2, Palette::Blue);
}
void Main()
{
	Graphics::SetBackground(Color(110, 110, 110));
	GUI gui(GUIStyle::Default);
	gui.setTitle(L"3目並べ");
	
	// ラジオボタン
	gui.addln(GUIText::Create(L"絶対に○○ない!3目並べ"));
	gui.add(L"rb1", GUIRadioButton::Create({ L"勝て", L"負け" }, 0u, true));
	// 水平線
	gui.add(L"hr1", GUIHorizontalLine::Create(1));
	gui.horizontalLine(L"hr1").style.color = Color(127);
	gui.addln(GUIText::Create(L"Playerの名前は?"));
	gui.add(L"PlayerName", GUITextField::Create(none));
	// 水平線
	gui.add(L"hr2", GUIHorizontalLine::Create(1));
	gui.horizontalLine(L"hr2").style.color = Color(127);
	gui.addln(GUIText::Create(L"Playerはどちらにしますか?"));
	gui.add(L"rb2", GUIRadioButton::Create({ L"先攻", L"後攻"}, 0u, true));
	// 水平線
	gui.add(L"hr3", GUIHorizontalLine::Create(1));
	gui.horizontalLine(L"hr3").style.color = Color(127);
	gui.add(L"StartButton", GUIButton::Create(L"START"));
	gui.setCenter(Window::Center());
	Condition first = Player; // 先手はどちらか
	String playerName = L"";
	while (System::Update()){
		if (gui.button(L"StartButton").pushed)
		{
			comLevel = (gui.radioButton(L"rb1").checked(0u) ? 1 : -1);
			playerName = gui.textField(L"PlayerName").text;
			first = (gui.radioButton(L"rb2").checked(0u) ? Player : Com);
			break;
		}
	}
	gui.release();
	Font font(20);
	// 各マス目
	Rect squares[9];
	Rect aroundBoard = Rect(300, 300).setCenter(Window::Center());
	for (int x = 0; x < 3; x++){
		for (int y = 0; y < 3; y++){
			board[calIndex(x, y)] = Empty;
			squares[calIndex(x, y)] = Rect(100, 100).setCenter(calCenter(x, y));
		}
	}
	// 0:勝敗がついていない 1:Player側の勝利 2:COM側の勝利
	int end_game = 0;
	// プレイヤー側が先攻ならTrue
	bool isPlayer = (first == Player);
	while (System::Update())
	{
		const Circle player(Mouse::Pos(), 40);
		// COMの番なら次の最善手を求め、打つ
		if (!isPlayer){
			Array <array <condition> > next = makeChild(board, Com);
			board = min_max(next);
			isPlayer = true;
		}
		// 盤上の描画
		for (int x = 0; x < 3; x++){
			for (int y = 0; y < 3; y++){
				squares[calIndex(x, y)].drawFrame(1, 1, Palette::Black);
				aroundBoard.drawFrame(0, 5, Palette::Brown);
				if (board[calIndex(x, y)] == Empty){
					squares[calIndex(x, y)].draw(squares[calIndex(x, y)].mouseOver ? Palette::Lightgreen : Palette::Green);
					// Playerの番ならクリックされた空のマスに打つ
					if (isPlayer && squares[calIndex(x, y)].mouseOver && Input::MouseL.pressed){
						board[calIndex(x, y)] = Player;
						Circle(calCenter(x, y), 40).draw(Palette::White);
						isPlayer = false;
					}
				}
				else {
					squares[calIndex(x, y)].draw(Palette::Green);
					Circle(calCenter(x, y), 40).draw(board[calIndex(x, y)] == Com ? Palette::Black : Palette::White);
				}
			}
		}
		// ターン数の表示と勝敗の確認
		int turn = calTurn();
		font(Format(L"ターン数: ", turn)).draw(0, 0);
		font(Format(L"現在の番: ", (isPlayer ? Format(L"Player ", playerName) : L"COM") )).draw(0, Window::Height() * 0.90);
		end_game = judge();
		if (end_game != 0 || turn >= 9){
			drawResult(end_game);
			WaitKey();
			break;
		}
		player.draw(Palette::White);
	}
}
        

実行結果

実行結果1 実行結果2 実行結果3 実行結果4

Comments

Top