Minecraftとタートルと僕

PCゲームMinecraftのMOD「ComputerCraft」の情報を集めたニッチなブログです。

タートルにAIを組み込もう3(rule-basedプログラミングの例)

はじめに

またまた更新が遅くなりましたが、前回の記事を元に実際のコードを示します。

前々回の記事では、
ルールベースプログラミングの基本的な考え方を紹介しました。

そして前回の記事で、
指定した範囲内の床ブロックを選択スロットと比較し、異なっていたら床ブロックを貼り換える
という、「床張り替えプログラム」の作成をめざし、複数のルールを考え、そのコードを示しました。

今回は、このプログラムを実現するソースコード全体を紹介します。

簡単に言うと、前回の記事で紹介したルールコードを中心として、その前後にこまごまとした処理を追加しただけです。

ソースコード

ソースコードの解説

基本的な流れは、冒頭から、「設定項目」→「ルール(タスク)の追加」→「メイン部分(プログラム初期化処理や実際の処理)」となっているので順番に解説します。

設定項目

このプログラムを実行するときには、以下のように引数を与えて実行することを基本とします。

# 奥行き18、幅8としてこの「floor」を実行する
> floor 16 8

とはいえ、すでに決まった値でプログラムを繰り返す必要があるならば、設定項目のMAX_DEPTHMAX_WIDTHの値を設定しておけば、 引数なしでプログラムを実行するとこの値をデフォルト値として使います。

ルール(タスク)追加の部分

以下のような6つのタスクを追加することで、プログラムが動きます。

  • 床材張り替えタスク
    • 「選択スロットと真下のブロックを比較して違うなら張り替える」
  • 選択スロット変更タスク
    • 「選択スロットのアイテムが0なら次のスロットを選択する」
  • 作業終了タスク
    • 「指定座標まできたら終了する」
  • 右へ折り返すタスク
    • 「一番奥まで行ったら右へ折り返す」
  • 左へ折り返すタスク
    • 「戻ってきたら左へ折り返す」
  • 前進タスク
    • 「ひたすら前へ進む」

ここで注意したいのは、特定の状況で実行可能なタスクが複数あるときには、「競合解消戦略」にしたがって実行されるタスクが一つだけ選ばれることです。

本システムに置ける競合解消戦略は以下のとおりです。

  • 「優先度」が高いものを優先する(基準は0、値が小さいほど優先する)
  • 優先度が同じならば、最近発火したものほど優先度を下げる
  • 発火履歴でも差がない(たとえばどちらも一度も発火してないなど)ときは先に登録したタスクを優先

基本的には以下のように、タスク作成時に名前と同時に優先度も指定します。

-- タスク名「harikae」を優先度-1として作成する。
local harikae = ai:addTask('harikae',-1)

タスクが実行可能になる条件をしっかりと記述し、また複数のタスクが同時に実行可能になったとしても正しいタスクが選ばれるよう適切に優先度を指定することが重要です。

特に優先度については、絶対に実行(発火)してほしいタスクに高めの優先度(値が低いほど優先度が高いことに注意)を指定しておくことがポイントです。

メイン部分

まず最初にプログラム実行時に指定する引数の処理を行っています。

引数の数が0のときには、奥行き・幅としてMAX_DEPTH・MAX_WIDTHの値をそのまま使います。
引数が2個の時はそれは奥行き・幅の値であるとしてそれぞれ上記2つの変数に値を代入します。

引数の数がそれ以外の時はエラーとします。

次に実際の推論エンジンを実行する前に、実行履歴の表示の為にログファイルを設定(書き込み用ファイルハンドルを作成)しています。 ターンが進むにつれて、どのような推論を行い、どのようなタスクを実行しているのかを細かく表示していくとなると、画面にログを表示するだけではどんどんスクロールしてしまい内容を追えなくなってしまいます。

ログファイルをあらかじめ設定しておき、推論途中の様々な情報をログファイルに書き込むことにします。

最後に推論エンジンの記述です。ai:generate(ターン数)というジェネレータ関数を使うことによって、汎用for文を回し推論を進めていきます。

汎用for文のtask変数には実行されたタスクが、turn変数は現在のターンが入っています。
汎用for文の内部でログを書き込んでいるのがポイントです。

なおai.runable_tasks変数には現在実行可能なタスク一覧が入っているので、それを直接参照しています。
入っているのはタスクオブジェクトなので見やすくするために、ai:tasksToString(ai.runable_tasks)関数を使ってタスクオブジェクトの名前だけを取り出しています。

このような情報をログとして書き出すことで、以下のようにログがまとめられます。

各ターンで、どのようなタスクが実行可能か、そしてどのタスクが実行されたのか、そして現在のタートルの状況がまとめられています。

Turn:1
 canRun-tasks: harikae fwd
 ran-task: harikae
 x/y/z=0/0/0, dir=0, fuel=5000

Turn:2
 canRun-tasks: fwd
 ran-task: fwd
 x/y/z=0/0/1, dir=0, fuel=4999

Turn:3
 canRun-tasks: harikae fwd
 ran-task: harikae
 x/y/z=0/0/1, dir=0, fuel=4999

(以下、つづく)

自分が想定した順番どおりにタスクが実行されなかったら、タスクの実行条件をより具体的で厳しいものにしたりまたあるいは優先度の値を調整しましょう。

まとめ

紹介したコードはコメントも含めて100行を軽く超えていますが、内容的にはそこまで複雑なことをやっていません。

基本的に、ルール(タスク)を追加するだけでプログラミングしています(実際には初期値の処理など細かい部分の追加がありますがw)。

とはいえ今回の床張りのような簡単な作業ならば、手続き型で普通にプログラミングした方がシンプルであることは事実です。
その点で、ルールベースプログラミングは、冗長で無駄の多いプログラミング手法であると言えるでしょう。

しかし、現在の作業内容にさらに他の作業を付け加えたり、また複雑な条件で作業を切り替えたりなど、「後から機能を追加」する場合を考えてみましょう。

手続き型プログラミングでは、全体との整合性を考えつつ既存のサブ関数に手を加える必要があります。場合によってはサブ関数を廃棄して全体の構成をリファクタリングする必要があるかもしれません。

それに対してルールベースでは、ただ新しいタスクを付け加えるだけで良いのです。

今回のプログラムは床張りでしたが、たとえばこれに正面にある山を削る機能を追加することを考えてみましょう。

つまり、指定した範囲の凸部を削り凹部にフタをするという、本当の意味で「整地」プログラムの実現です。

凸部を削るためのタスクは以下のような4つを追加すればよいでしょう。

  • 真上を掘り1歩上昇するタスク
    • canRun(): 真上または正面にブロックがあるときtrue
    • run():真上を掘り1歩上昇
  • 山の頂上から最初の高さまで下りてくるタスク(その1)
    • canRun():高度(y座標)が0より大きい かつ 奥行きに2歩分以上余裕がある
    • run():1歩前進して、正面と真下の両方を掘りつつ最初の高さまで下りてくる
  • 山の頂上から最初の高さまで下りてくるタスク(その2)
    • canRun():高度(y座標)が0より大きい かつ 奥行きに1歩分の余裕がある
    • run():1歩前進して、真下を掘りつつ最初の高さまで下りてくる
  • 山の頂上から最初の高さまで下りてくるタスク(その3)
    • canRun():高度(y座標)が0より大きい かつ 奥行きに余裕がない(つまり一番奥である)
    • run():そのまま真下に降りてくる

あとは、インベントリ内のアイテムがいっぱいのときにチェストを設置してブロックを詰め込むタスクや、松明を設置するタスクを追加しても良いかもしれませんね。

つまりルールベースプログラミングは、シンプルな作業を記述するには多少冗長という欠点はあるものの、

  • 様々な条件により作業内容を切り替えるような複雑なプログラミングをシンプルに記述する
  • 後から機能をどんどん追加していく

といった点で一般的なプログラミング手法に比べて優位性があります。

ついでに言うと、「タートルにAIを組み込んだYO!」と自慢できるのも一つの利点かもしれませんね:P

広告を非表示にする