色々と瑣事があり、なかなかNimについて書けませんでしたが、今日はMENACEの時のCBOARDクラスのような、NimゲームのインフラといえるCCOINSクラスについて説明します。
Nimは5から1までのコイン5列の任意のコインを取り合うゲームで、盤面もありませんので、「コイン」と「列」がゲームの基本となります。また、MENACEでは手の戦略的意味が回転や反転をしても変わらなかったように、
〇
〇 〇
〇〇〇〇
と
〇
〇〇
〇〇〇〇
でも変わりません。(「何個の列がいくつあるか」が問題となります。)
そのことから、色々と試行錯誤して、次のようなデータ形と、基礎的な機能を持ったクラスを作りました。いつものように(解説:)でコメントを入れてゆきます。
【CCOINS.h】
////////////////////////////////
// Class of CCOINS
// for use of Nim
// Copyright (c) by Ysama, 2022
////////////////////////////////
/*
【ルール】
(1)5列の5個、4個、3個、2個、1個のコイン群をテーブルに置き、
(2)先手、後手が一回に、一つの列から、1個以上のコインを取ってゆき、
(3)最後のコインを取ることになった方が負け
nimの双対ゲーム(https://ja.wikipedia.org/wiki/%E3%83%8B%E3%83%A0)
*/
//(解説:このルールを備忘の為に入れています。)
//定数定義
#define NOTYET 0 //勝負中
#define FIRST 1 //先手勝利
#define SECOND 2 //後手勝利
//(解説:ここはMENACEと同じにしました。)
////////////////////////
//CCOINSクラスヘッダー
////////////////////////
class CCOINS {
protected:
//メンバー変数
int m_Coins[5]; //5列のコイン数の配列
int m_Player; //先手-1、後手-2
//(解説:列を整数(bcc32では4バイト)の配列にして、その値にコイン数を入れます。先手、後手の区分の為のフラグも入れます。これらは外部からはアクセスできないprotectedメンバーとなっています。)
public:
//メンバー関数
CCOINS(); //コンストラクター(初期化のみ)
void Init(); //初期化(再ゲーム時に必要)
int* GetCoins(); //コインデータのコピーのポインターを渡す
bool TakeCoin(int, int); //指定列の指定数コインを取る(取れた-TRUE、取れない-FALSE)
bool RandomTake(); //乱数でコインを取る(取れた-TRUE、取れない-FALSE)
int IsOver(); //最後のコインが一つ残ったか否かの判定(NOTYETまたは敗者を返す)
int GetNextPlayer() {return m_Player;} //次に手を打つプレーヤーを返す(勝敗決定後は敗者)
virtual void ShowCoins(); //コインデータ表示の仮想関数
};
//(解説:クラスの中で、ゲーム運営上重要なのが、色を付けた関数です。データが遮蔽されているのでそれを参照も加工もすることができない為、現在のデータをスタックに積んで渡す<値渡し>か考えましたが、遅くなるので、データのコピーを作って、そのポインターを渡すことにしました。これで参照元も先読み処理等のためにデータを加工することができます。なお、最後のコイン表示関数を仮想関数にしたのは、ウィンドウズ版では別のShowCoins()関数を作らなければならないからです。結局コイン表示はウィンドウズの方のプログラムに任せ、これは使わないことになります。)
//コンストラクター(初期化のみ)
CCOINS::CCOINS() {
srand(time(NULL)); //乱数の初期化
Init();
}
//(解説:。また乱数を使うので、初期化が一回必要です。)
//初期化(再ゲームに必要)
void CCOINS::CCOINS::Init() {
for(int i = 0; i < 5; i++)
m_Coins[i] = 5 - i; //m_Coins配列を5から1に初期化
m_Player = FIRST; //先手から始まる
}
//(解説:。初期値です。)
//コインデータのコピーのポインターを渡す(一手先のシミュレーション用)
int* CCOINS::GetCoins() {
static int copy[5];
for(int i = 0; i < 5; i++)
copy[i] = m_Coins[i];
return copy;
}
//(解説:。これがコピーデータのポインター渡しです。スタックではなく、メモリー領域を取るためにstatic変数にしています。)
//指定列の指定数コインを取る(取れた-TRUE、取れない-FALSE)
bool CCOINS::TakeCoin(int l, int num) {
if(m_Coins[l] < num) //コイン数よりも多くとろうとしたら
return FALSE; //FALSEを返す
else {
m_Coins[l] -= num;
m_Player = 3 - m_Player; //先手、後手の交代
}
return TRUE;
}
//(解説:コインを取る基本関数です。また、先手、後手のトグルは定番の書式を使っています。)
//乱数でコインを取る(取れた-TRUE、取れない-FALSE)
bool CCOINS::RandomTake() {
int col[5], i, j;
for(i = 0, j = 0; i < 5; i++) { //列配列に0以外の列を入れる
if(m_Coins[i])
col[j++] = i;
}
//(解説:この処理は結構使っていますが、配列で0のところはトリムして、「0以外のデータのある列の配列」に入れなおしています。)
//以下のcase 1とcase 2の明らかな勝利を乱数に任せない為
switch(j) { //列数による分岐
case 0: //すべて0(本来発生しない)
return FALSE;
case 1: //1列だけの場合
if(m_Coins[col[0]] == 1) //1なら既に負け
return FALSE;
else
m_Coins[col[0]] = 1; //1を残してすべて取る
break;
case 2: //2列の場合
if(m_Coins[col[0]] == 1) { //一方が1ならば、
m_Coins[col[1]] = 0; //他方のすべてのコインを取る
break;
}
else if(m_Coins[col[1]] == 1) { //他方が1ならば、
m_Coins[col[0]] = 0; //一方のすベてのコインを取る
break;
} //それ以外はdefault処理へ
//(解説:本Nimは排他的論理和で全て片が付く正統版ではなく、最後だけ評価が反転する双対ゲームであること、また複数コインがある1列が残った場合と一方が1個のコインの2列が残った場合の絶好の必勝手で「悠長に複数コイン列から一個取っていたりすると八百長との批判が出る」ので、ここはプログラマーが介入して一挙に必勝手を採る、というプログラムにしました。従って情報処理すべきゲームは「2個以上のコインがある列が2以上」の状態↓のみとなります。これが重要なのでピンク色としました。)
default: //それ以外の場合
i = rand() % j; //値を持つ列を乱数で抽出
//m_Coins[col[i]]のコイン数から、乱数で定めた値(1~そのコイン数)を差し引く
m_Coins[col[i]] -= ((rand() % m_Coins[col[i]]) + 1);
break;
//(解説:後は乱数でコインのある列の一つを抽出し、そのコイン数の範囲(全てを含む)でとるコイン数を乱数で決めます。)
}
m_Player = 3 - m_Player; //先手、後手の交代
return TRUE;
}
//最後のコインが一つ残ったか否かの判定
int CCOINS::IsOver() {
int col[5], i, j;
for(i = 0, j = 0; i < 5; i++) {
if(m_Coins[i])
col[j++] = i;
}
if(j == 1 && m_Coins[col[0]] == 1) //一列に一つ残っている場合
return m_Player; //敗者(最後の一つを取る者)を返す
return NOTYET; //勝敗がまだついていない(0)
}
void CCOINS::ShowCoins() { //コインデータ表示の仮想関数
for(int j = 5; j > 0; j--) {
for(int i = 0; i < 5; i++) {
if(m_Coins[i] >= j)
cout << "* ";
else
cout << " ";
}
cout << endl; //改行
}
}
//(解説:CUIで開発しますので、CUIのユーザーインターフェースが必要です。)
このクラスの処理が意図通りか確認する為のテストプログラムも参考までに載せます。
【COINS.cpp】
/////////////////////////////
// Test program for CCOINS.h
/////////////////////////////
#include <conio.h> //getch()使用の為
#include <stdlib> //srand()、rand()使用の為
#include <iostream> //cout、cin使用の為
#include <windows.h> //TRUE、FALSE使用の為
using namespace std; //iostream使用の為
//(解説:CUI関係のヘッダーファイル等コメントの通りです。)
#include "CCOINS.h"
//(解説:↑のヘッダーを取り込みます。)
//メイン関数
int main(int argc, char **argv) {
//変数宣言
CCOINS coins;
int l, n, *p;
int you = FIRST; //人間を先手でテストする
//(解説:ここでは先手、後手の選択をすることを省略しました。)
//関数テスト
coins.ShowCoins(); //コイン状況の表示
p = coins.GetCoins();
cout << "コインの状況:" << p[0] << ", " << p[1] << ", " << p[2] << ", " << p[3] << ", " << p[4] << endl;
cout << endl;
//(解説:これはチェック用です。また、まずコインを表示して列と個数を打ち手に見せる必要があります。)
//Test Game
while(!coins.IsOver()) {
//(解説:IsOver関数がループの条件となります。)
if(coins.GetNextPlayer() == you) {
l = n = 0;
while(l < 1 || l > 5) {
cout << "コインを取る列を入力してください:";
cin >> l;
}
l--; //1 - 5 → 0 - 4へ
p = coins.GetCoins(); //コイン状態のコピーを取得
while(n < 1 || n > p[l]) {
cout << "取るコインの数を入力してください:";
cin >> n;
}
cout << endl;
//(解説:コインを取る列と個数の入力が必要になります。)
coins.TakeCoin(l, n); //先手の人間の手
coins.ShowCoins(); //コイン状況の表示
p = coins.GetCoins();
cout << "コインの状況:" << p[0] << ", " << p[1] << ", " << p[2] << ", " << p[3] << ", " << p[4] << endl;
}
//(解説:人が先手ですので、人に手を採らせて、その結果を表示します。)
else {
if(coins.RandomTake()) {//後手のPCの乱数の手
coins.ShowCoins(); //コイン状況の表示
p = coins.GetCoins();
cout << "コインの状況:" << p[0] << ", " << p[1] << ", " << p[2] << ", " << p[3] << ", " << p[4] << endl;
}
//(解説:今度は乱数による手を打たせ、結果を表示します。)
else
//(解説:打つ手がなくなればRandomTakeはFALSEを返しますので、ここで投了です。)
break;
}
}
if(coins.GetNextPlayer() == SECOND)
cout << "先手の勝ち" << endl;
else
cout << "後手の勝ち" << endl;
//(解説:結果の表示です。)
//Pause
getch();
//(解説:定番のDOS窓を保持する文字列入力関数です。)
return 0;
}
単に乱数で打ってくるのですが、それでも結構良い手を打ちます。事実、現在評価関数、経験DBを入れて対戦させても乱数は(若干ですが)勝つときがあります。
先ずはこれで人間側のNimの訓練をしてください。慣れるまでは負けてしまうかもしれませんね。