Drools の 公式 GitHub リポジトリ には、ルールエンジンを活用したゲーム(例:ピンポンゲーム、インベーダーゲーム)の実装例が公開されています。
ゲーム画面は Swing、ロジックは Drools で構築されており、ルールによるプログラミングの例として参考になります。
今回、自分でも Drools を用いてゲームを作成してみました。
ゲーム紹介
題材は スーパーマルバツゲーム (Ultimate Tic-Tac-Toe, 以下 UTTT) です。
UTTT は、通常のマルバツゲーム(3×3 マス)を拡張し、より戦略性を高めたゲームです。
実装したコードは GitHub に公開しています。
(CPU プレイヤーの実装は行っていないため、2人対戦もしくは一人二役で遊んでいただければと思います。 )
UTTT のルール概要
作成したゲーム画面とともに、ゲームルールについて紹介します。
UTTT は、以下のように通常のマルバツゲームを拡張したルールを持ちます。
盤面構成
全体は 9×9 マスで構成されており、これは 3×3 の「グローバル盤面」(※)とみなすこともできます。
(※) 赤枠で囲んだ部分を、1マスととらえています
赤枠内は通常の 3×3 マルバツゲーム(ローカル盤面)としてプレイされます。

手番の制約
プレイヤーが「✕」や「〇」を置いた座標に応じて、対戦相手の指せる座標が決まります。
例:画像のように、ローカル盤面内の (0,1) マスに置いた場合、対戦相手は「グローバル盤面」でみたときの (0,1) マス内のみ指すことができます

勝敗条件
ローカル盤面で縦・横・斜めの3つがそろうと、プレイヤーはその領域を獲得します。

グローバル盤面でも同様に、縦・横・斜めをそろえたプレイヤーが勝利します。

グローバル盤面がそろわないまま記号を置けなくなった場合は引き分けです。

実装について
ここからは実装内容について紹介していきます。
実装方針
実装は以下の方針で進めました。
- UI 部分
- Swing を用いて盤面を構築
- ロジック部分
- 勝敗判定などのロジックは Drools (DRL) で実装
- UI 層とはイミュータブルな DTO を介してデータをやり取りし、責務を分離
- JButton など UI 側のオブジェクトは Drools 側に渡さない
ロジック部分を拡張・修正しやすくなるのではと考え、UI とルールエンジンを分離することにしました。
開発環境
- windows 11
- Visual Studio Code 1.103.1
- openjdk 17
- Apache Maven 3.9.6
- Drools 10.0.0
画面実装
Swing を使った画面実装(一部)です。
盤面を 9×9 の JButton で構成しています。
最後のthis.kSession.fireUntilHalt();
の箇所で、ルールエンジンがデータを待ち受けるようになっています。
public class GameUI extends JFrame {
private KieSession kSession;
private JLabel statusLabel = new JLabel("✕ の番です", SwingConstants.CENTER);
private JButton[][] btns = new JButton[9][9];
// 押下済のボタン記録用
private PlaceCmd[][] placeCmds = new PlaceCmd[9][9];
// 先攻は「✕」
private String currentMark = "✕";
private boolean gameOver = false;
// 巨大◯や✕を描画するパネル
private OverlayPanel[][] overlayPanels = new OverlayPanel[3][3];
public GameUI(KieSession kSession) {
this.kSession = kSession;
setTitle("スーパー マルバツゲーム");
setDefaultCloseOperation(EXIT_ON_CLOSE);
setSize(490, 550);
setLocationRelativeTo(null);
// ステータスラベル
this.statusLabel.setFont(new Font("SansSerif", Font.BOLD, 18));
add(statusLabel, BorderLayout.NORTH);
// リセットボタン
JButton resetButton = new JButton("リセット");
resetButton.setFont(new Font("SansSerif", Font.PLAIN, 16));
resetButton.addActionListener(e -> resetGame());
add(resetButton, BorderLayout.SOUTH);
// 盤面
JPanel boardPanel = new JPanel(new GridLayout(9, 9));
boardPanel.setBackground(Color.WHITE);
boardPanel.setBounds(10, 0, 450, 450);
add(boardPanel, BorderLayout.CENTER);
// ボタン
Font buttonFont = new Font("SansSerif", Font.BOLD, 15);
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
JButton btn = new JButton("");
btn.setFont(buttonFont);
btn.setFocusPainted(false);
int row = i, col = j;
btn.addActionListener(e -> place(row, col));
this.btns[row][col] = btn;
boardPanel.add(btn);
}
}
// 盤面の上にオーバーレイを重ねるためのレイヤードペイン
JLayeredPane layeredPane = new JLayeredPane();
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
OverlayPanel overlayPanel = new OverlayPanel(10 + j * 150, i * 150, 150, 150);
layeredPane.add(overlayPanel, JLayeredPane.PALETTE_LAYER);
add(layeredPane, BorderLayout.CENTER);
this.overlayPanels[i][j] = overlayPanel;
}
}
layeredPane.add(boardPanel, JLayeredPane.DEFAULT_LAYER);
// out command受信時の処理
addRuleEventListener(this.kSession);
setVisible(true);
this.kSession.fireUntilHalt();
}
...
画面とルールエンジンとの連携
画面⇒ルールエンジン
KieSession の insert メソッドを使って、操作イベントをルールエンジンに投入しています。
var cmd = new PlaceCmd(row, col, this.currentMark);
this.placeCmds[row][col] = cmd;
this.kSession.insert(cmd);
ルールエンジン⇒画面
Drools のRuleRuntimeEventListener
によって、ルールエンジン内でデータが作成されたイベントをとらえることができます。
これを用いて、「✕の番です」などのラベル更新や、操作可能領域の変更をしています。
private void addRuleEventListener(KieSession kSession) {
kSession.addEventListener(new RuleRuntimeEventListener() {
@Override
public void objectInserted(ObjectInsertedEvent event) {
Object obj = event.getObject();
if (obj instanceof LabelUpdCmd cmd) {
statusLabel.setText(cmd.label());
} else if (obj instanceof GameOverCmd) {
gameOver = true;
deactivateButtons();
} else if (obj instanceof OverlayCmd cmd) {
overlayPanels[cmd.row()][cmd.col()].setWinner(cmd.mark());
overlayPanels[cmd.row()][cmd.col()].fill();
} else if (obj instanceof FieldChangeCmd cmd) {
if(overlayPanels[cmd.localRow()][cmd.localCol()].isFilled()) {
// 移動先が勝敗確定済なら、全ボタンを押下可能にする
resetButtons(false);
return;
}
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
btns[i][j].setEnabled(false);
if (i / 3 == cmd.localRow() && j / 3 == cmd.localCol()) {
// FieldChangeCmdで指定した領域のみ押下可能にする
btns[i][j].setEnabled(true);
}
}
}
}
// System.out.println("Fact inserted: " + obj.getClass());
}
...
今回はデータ取得にRuleRuntimeEventListenerを使用していますが、
公式ドキュメントでは、リスナー呼出がルールエンジンの処理をブロックするため、シンプルな利用にとどめるように記載されています。
DRL
Drools の accumulate (集約構文)を用いて、ローカル盤面やグローバル盤面の勝敗判定を実装しました。
例:ローカル盤面で「同じ記号 3 つで行がそろう」判定
rule "ローカル盤面の勝敗判定_行の記号がそろう"
when
$status : GameStatus( isGameOver == false )
$mark : Mark()
not GlobalField( row == $mark.globalRow, col == $mark.globalCol )
accumulate( Mark( localRow == $mark.localRow, globalRow == $mark.globalRow, globalCol == $mark.globalCol, mark == $mark.mark );
$count : count() ; $count == 3 )
then
insert( new GlobalField($mark.globalRow, $mark.globalCol, $mark.mark) );
insert( new OverlayCmd($mark.globalRow, $mark.globalCol, $mark.mark) );
end
同様に、グローバル盤面での縦・横・斜めの判定も DRL で記述しています。
最後に
Swing に不慣れで手こずりましたが、RuleRuntimeEventListener
なども試すことができて勉強になりました。
Drools (あと UTTT に) 少しでも興味をもった方がいれば幸いです。