読者です 読者をやめる 読者になる 読者になる

Minecraftとタートルと僕

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

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

API Lua Minecraft ルールベースシステム チュートリアル

はじめに

更新が大変遅くなりましたが、その分、内容濃い目でお送りします。

今回は、rule-basedプログラミングの基礎ということで、ルールをどんどん追加していくことで次第にタートルが複雑な作業をできるようになる過程を紹介しましょう。

最終的に以下のような「床張り替えプログラム」をルールベースプログラミングで実現することを目標とします。

  • 床張り替えプログラム
    • 奥行と幅を指定してプログラムを実行すると、指定領域を折り返し往復しながら移動する。
    • このとき、インベントリ内のブロックと真下のブロックを比較し、異なっていたら貼り換える。
    • インベントリ内にはスロット番号1~Nまで、床張り替え用のブロックが必要数だけ入っているものとする。
    • 燃料は必要十分な量がすでに補給されているものとする。

前提知識と注意事項

できれば前回の記事を読んでルールベースシステムについておおよその知識を持った上で、今回の記事を読んでいただきたいのですが、「もう忘れた!」という方のために以下のように簡潔にまとめてみました。

復習がてらにどうぞ。

ルールベースプログラミングのポイント

  • ルールベースシステムを実装した「AI」に、「ルール」を登録することでプログラミングする。
  • 1つの「ルール」には、以下の3つの要素が含まれる
    • ルールのIF部。このルールが発火(実行)するための条件を記述する部分。
    • ルールのTHEN部。ルールが発火したときに実際に行う内容を記述する部分。
    • ルールの優先度。複数のルールが発火可能なときに、この優先度に従って発火するルールが一つ選ばれる。
  • AIは以下のサイクルを1セットとして、発火可能なルールがなくなるまで、あるいはあらかじめ用意された終了ルールが発火するまで繰り返す。
    1. 全てのルールのIF部を調べて発火可能なルールを全てピックアップ
    2. 発火可能なルール群の中から、「競合解消戦略」に従って発火させるルールを一つ選択
    3. 発火ルールのTHEN部を実行
  • 「競合解消戦略」は様々な戦略が提案されているが、本記事では以下の戦略を用いる
    1. ルール優先度が高いものを優先する
    2. 優先度が同じならば、最近発火したものほど優先度を下げる
    3. 発火履歴でも差がない(たとえばどちらも一度も発火してないなど)ときは先に登録したルールを優先

つまりルールベースプログラミングとは、刻々と変わる状況下でどのルールが発火するかを意識しながら後からどんどんルールを追加していくことで複雑な挙動を実現するプログラミング手法なのです。

TurtleAI API を使ったルールベースプログラミング

このAPIを使った時の独自要素、独自用語

  • ルールの発火によってタートルが実際に何らかの作業を行うので、以降では ルールのことをタスクと呼ぶ
  • 優先度の値は低いほど優先する(デフォルトは0)
  • IF部はcanRun(info)関数として記述し、THEN部はrun(ctrl)関数として記述する
  • 以下、テンプレート
os.loadAPI("turtleAI") -- APIの読み込み

-- ##################################################################
-- 新規AIの作成
local ai = turtleAI.newAI() 

-- ##################################################################
-- AIにタスクを追加する
local fwd_task = ai:addTask('fwd',1) -- タスク名'fwd'、優先度1

-- fwd_task の発火条件(IF部)を記述。以下は常に発火可能とする。
-- 引数infoテーブルにはタートルの位置情報などが入っている
function fwd_task.canRun(info)
  return true -- 発火可能かどうか(true/false)を返す
end

-- fwd_task の実行内容(THEN部)を記述。
-- 引数ctrlテーブルにはタートル移動系の関数が入っている
function fwd_task.run(ctrl)
  ctrl:forward() -- 一歩前進する
  return true -- true: タスク実行後に次のターンへ進む。 false: ターンが進まない。
end

-- ##################################################################
-- エンジン部分は ジェネリックfor文によるイテレータ
-- 以下では、50ターンだけサイクルを回す。
for task, turn in ai:generate(50) do
  print("Turn: ",turn) -- 現在のターン
  print(ai:tasksToString(ai.runable_tasks)) -- 実行可能なタスク一覧
  print(" ran-task: ",task.name) -- その中から実際に実行したタスク
  print(ai.info:toString()) -- 現在位置や燃料情報など
end

print('finished!')

上記テンプレートの中で最も重要なのはAIにタスクを登録する箇所です。思いついたタスクを追加していくことでプログラミングしましょう。

それでは今回の「床張り」を実現するためにはどのようなタスクを追加すれば良いでしょうか。

以下では、タスクの選び方、作り方も含めて具体的に解説します。

ルール(タスク)の作り方

最も基本的なタスクを考える

床張りを考える上で、最も基本的でもっとも頻繁に実行されるタスクは「移動」でしょう。

ひたすら1歩ずつ前進していくタスク、そして特定の座標まで来たら方向転換して折り返すタスクが考えられます。

1歩ずつ前進していく fwd タスクは、とてもシンプルです。
発火条件(canRun関数)と実行内容(run関数)を箇条書きでまとめると以下のようになります。

  • fwd タスク
    • 発火条件: 燃料がある限り常にtrue
    • 実行内容: 1歩前進

次に、方向転換して折り返すタスクである、turn_rightturn_left の2つのタスクを考えます。

この2つの方向転換タスクの発火条件は現在の位置座標が関係します。以下の図をご覧ください。

以下の図は、奥行き10幅5の範囲を、手前と奥で折り返し往復しながら移動する様子を示したものです。

f:id:hevohevo:20140908170740p:plain:h400

Tはタートルで、一番奥まで移動したら折り返し戻ってきて、一番手前でまた折り返しというように移動することを示しています。座標の値は、Minecraftの慣習に従い、奥行きをZ座標、幅をX座標としています。また、タートルのスタート位置が(0,0)であることもご注意ください。奥行き10ならばZ座標の最大は9、幅5ならばX座標の最大は4となります。

ここで、折り返す位置に注目しましょう。以下の図を見て折り返すための(タスクを発火させるための)条件を確認して下さい。

f:id:hevohevo:20140908170746p:plain:h500

赤丸が右に回転する turn_right タスクで、紫丸が左に回転する turn_left タスクです。それぞれ発火条件(can_run関数)と実行内容(run関数)を箇条書きでまとめると以下のようになります。

  • turn_right タスク (赤丸)
    • 発火条件: X座標が偶数かつ一番奥にいるときtrue
    • 実行内容: 右回転→前進→右回転
  • turn_left タスク (紫丸)
    • 発火条件: X座標が奇数かつ一番手前(Z座標0)にいるときtrue
    • 実行内容: 左回転→前進→左回転

終了条件を考える

次に、どのような条件でプログラムを終了するかを考えます。以下の図を見て下さい。

左側の図は、奥行き10幅5と指定されたときにどの位置で終了したら良いのか示しています。右側の図は、奥行き10幅4と指定された時の終了位置です。

このように、X座標が偶数か奇数かで、奥で終了するのか手前で終了すれば良いのかが異なります。

f:id:hevohevo:20140908170750p:plain:h500

この終了タスク terminate タスクを箇条書きでまとめると以下のようになります。

  • terminate タスク
    • 発火条件: 以下のどちらか
      • 現在のX座標が最初に指定された幅(最も右側)の位置であり、そのX座標が偶数ならば一番奥にいるとき
      • 現在のX座標が最初に指定された幅(最も右側)の位置であり、そのX座標が奇数ならば一番手前(Z座標0)にいるとき
    • 実行内容: プログラムの終了

箇条書きのタスクをコードに直す

fwdturn_rightturn_leftterminate の4つのタスクをコードに直したものを以下に示します。

-- #################################################
-- ひたすら進む 「fwd」タスク
local fwd = ai:addTask('fwd',1)
 
function fwd:canRun(info)
  return info.getFuelLevel() > 0
end
 
function fwd:run(ctrl)
  while turtle.dig() do end
  ctrl:forward()
  return true
end

-- #################################################
-- 一番奥まで行ったら右へ折り返す 「turn_right」タスク
local turn_turn_right = ai:addTask('turn_right',1)
 
function turn_right:canRun(info)
  return (info.coord.x%2==0) and (info.coord.z == MAX_DEPTH-1)
end
function turn_right:run(ctrl)
  ctrl:turnRight()
  ctrl:forward()
  ctrl:turnRight()
  return true
end
 
-- #################################################
-- 戻ってきたら左へ折り返す 「turn_left」タスク
local turn_left = ai:addTask('turn_left',1)
 
function turn_left:canRun(info)
  return (info.coord.x%2==1) and (info.coord.z == 0)
end
function turn_left:run(ctrl)
  ctrl:turnLeft()
  ctrl:forward()
  ctrl:turnLeft()
  return true
end

-- #################################################
-- プログラム終了タスク 「terminate」タスク
local terminate = ai:addTask('terminate',-1)
 
function terminate:canRun(info)
  if info.coord.x==MAX_WIDTH-1 then
    if (info.coord.x%2==0) and (info.coord.z==MAX_DEPTH-1) then return true end
    if (info.coord.x%2==1) and (info.coord.z==0) then return true end
  end
  return false
end
 
function terminate:run(ctrl)
  return "quit" -- 文字列"quit"を返すと終了!
end

上記コードではいくつかのポイントがあります。

まず1つ目は、移動系の3つのタスクの優先度を1に統一していることです。

デフォルトの優先度が0なので、これは全体的には優先度を低めにしていることを意味します。
これら移動系のタスクの優先度が高いと、後から追加する作業系タスク(たとえば床ブロックを貼り換えるなど)よりも優先して発火してしまい、プログラムスタートしたら移動だけして終了ということになりかねません。

またひたすら暴走するのも怖いので、プログラム終了タスクの優先度を標準(0)よりも高い-1にしています。これは念のための措置です。

2つ目のポイントは、最初に指定された範囲内で「最も奥」と「最も右側」の表現方法です。
僕のプログラミング慣習では、変数「MAX_WIDTH」と変数「MAX_DEPTH」には、プログラムスタート時に指定された幅と奥行きを入れることにしています。
ただし、タートルのスタート座標が(0,0)から始まるため、MAX_DEPTH-1が最も奥の座標(奥行き10と指定されているときはZ=9が最大)、MAX_WIDTH-1が最も右側の座標となります。

そして3つ目は、終了タスクの作り方です。
run(ctrl)関数は通常、true/falseを返すことになっているのですが、ここで文字列"quit"を返すことによってサイクルを強制的に停止します。

張り替えタスクを考える

次は、本題の床ブロックをインベントリのブロックと比較して貼り換えるタスクです。要件は以下のとおりです。

  • floor タスク
    • 発火条件: 選択スロットのブロックと真下のブロックを比較して異なるならtrue
      • なお上記の条件には選択スロットに何らかのブロックが1個以上あるという暗黙的な条件が含まれています。要注意。
    • 実行内容: 真下のブロックを掘って、真下にブロックを設置

コードに直すと以下の通りです。手持ちブロックとの比較、採掘・設置は標準のturtle APIを使っています。

local floor= ai:addTask('floor',0)
 
function floor:canRun(info)
  return (turtle.getSelectedSlot()>0) and (not turtle.compareDown())
end
function floor:run(ctrl)
  turtle.digDown()
  turtle.placeDown()
  return true
end

インベントリ内の選択スロット切り替え

とりあえずこれだけでも動くのですが、一つだけ問題があります。 それは選択スロットには64個までしかブロックが入らないために、指定範囲が64よりも大きいときにブロックが足りなくなる可能性があることです。

複数のスロットを使えるようにしましょう。イメージとしては、スロット1からスロット10(別に16でも良いですが)までみっしりと張り替え用ブロックを詰め込んでおくイメージです。

選択スロットを切り替えるタスクは以下のような条件、内容です。

  • change_slot タスク
    • 発火条件: 現在の選択スロットのアイテム個数が0である
    • 実行内容: 現在の選択スロットの次のスロットに切り替える

コードに直すと以下の通りです。

local changeSlot = ai:addTask('changeSlot',0)
 
function changeSlot:canRun(info)
  return turtle.getItemCount()==0
end
function changeSlot:run(ctrl)
  local current_slot = turtle.getSelectedSlot()
  local next_slot = current_slot%16 + 1 -- スロット16の次はスロット1に移るけれど、プログラム終了した方がよかったかも?
  turtle.select(next_slot)
  return true
end

ひとまずタスクは全て完成

これで必要なタスクは全て揃いました。

あとは、その他のこまごまとした処理を書くことでプログラムは完成します。

たとえば、プログラム実行時の引数である、奥行き・幅などの初期化処理などですね。

かなり記事が長くなってきたので、今回の記事はいったん終了します。

次回の記事でこのプログラムのダウンロードの仕方、使い方も含めて解説する予定です。

今回の記事の濃さに比べればかなり薄い内容になるので、記事更新はすぐにできる・・・はず。

お楽しみに。

そして今回の記事の感想など待ってます!