ゲーム開発のためのオブジェクト指向(Interpreter / Visitor によるスクリプトの実装)

投稿者: | 2012年6月16日

1.はじめに

今回は「スクリプト言語の組み込み」をオブジェクト指向でどうやって実装するかを解説します。

2.インタプリタパターン

まず、手順としては、

  1. スクリプトを読み込む
  2. スクリプトを解析する

という手順になると思います。そういった手順に適したのが、Interpreterパターンです。

まずはクラス図です。

それぞれのクラスの役割を説明すると、スクリプトを読み込むのが「Context」クラスで、スクリプトを解析するのが「Node/TerminalNode/NonterminalNode」クラスが担当することになります。

Nodeクラスについて解説します。

なぜ、Nodeを継承して「TerminalNode/NonterminalNode」を実装するのかというと、スクリプトというのは、たいてい2つのパターンから構成されているからです。

例えば、以下のようなスクリプトを採用するとします。とりあえず敵キャラを動かすスクリプトとします。

001: script start
002: left 3
003: down 5
004: repeat 3
005: attack
006: up 2
007: down 2
008: loop
009: script end

各行の命令について細かく説明します。

  • 1行目はスクリプトを開始することを宣言しています
  • 2行目は左に3フレームに移動します
  • 3行目は下に5フレームに移動します
  • 4行目は繰り返しの宣言です。loopがあるところまでの処理を3回繰り返します
  • 5行目は攻撃を行います
  • 6行目は上に2フレームに移動します
  • 7行目は下に2フレームに移動します
  • 8行目繰り返しの終了です
  • 9行目はスクリプトの終了となります

といったようになるのですが、このスクリプトの命令は、「スクリプトそのものを制御する文」(script startやrepeatなど)と「行動を決定するもの」(left, attackなど)からなっているのが分かると思います。

このように、「スクリプトそのものを制御する文」は、実際に敵キャラを動かすものではないので、「NonterminalNode」となり、「行動を決定するもの」は、そこで解析を中断して敵キャラを動かすということで、「Terminal(終着駅)Node」で実装することになります。

実際の処理の流れとしては、

  1. Contextにスクリプトを読み込ませる
  2. NonTerminalNodeにContextを渡す
  3. NonTerminalNodeの中で、現在行のスクリプトを判定する
    1. 「行動を決定するもの」であれば、TerminalNodeにContextを渡し、命令を実行。
    2. 「スクリプトそのものを制御する文」であれば、NonterminalNodeにContextを渡す->3に戻る。

となります。

ここで、b.)の場合には、処理がどんどんネストしていくのが特徴です。

というのが、Interpreterパターンなのですが、2つほど問題があります。

1つは、「行動を決定する命令」であるとき、スクリプトの解析を中断して一度ゲームループに処理を返さなければなりません。これを解決するには、実行履歴を残しておいて解析を中断し、再度呼び出しがあった場合には、そこから解析を再開する必要があります。

もう1つの問題は、TerminalNodeは「行動を決定する」ことを解析するクラスですが、「行動を決定する」という処理そのものを書いてしまうことは好ましくありません。

それを解決する方法がVisitorパターンになります。

3.ビジターパターン

Visitorとは、「訪問者」のことです。

今回の例で言えば、敵オブジェクト(Enemy)が「訪問者」です。そして、その受け入れ先が、構文解析クラス(Node以下)となります。

訪問者のEnemyが受け入れ先のNodeをぐるぐる回りながら、スクリプトの命令を実行する、というようなイメージになります。

クラス図は以下のようになります。

先ほどの図との大きな違いは、

  • ITaskインターフェース、Enemyクラスの追加
  • NondeのparseメソッドのパラメータにEnemyを追加

となります。

ITaskというのは、タスクシステムの「タスク」ですね。これを継承してEnemyクラスを実装します。parseメソッドのパラメータにEnemyがあるのは、Nodeの中をぐるぐる回るためですね。

さて、ほかにもクラスが増えていますが、TopNode/RepeatingNodeは、「NonterminalNode」にあたり、AttackingNode/MovingNode/ExplodingNodeは「TerminalNode」にあたります。

処理の流れとしては、

  1. Enemyクラスの生成
  2. Contextクラスを生成し、スクリプトを読み込ませる
  3. TopNodeクラスを生成し、parseメソッドにContext、thisポインタを渡す。

というのがNodeの入り口までの流れです。

そうしてNodeの中をぐるぐる回っているうちに、「TerminalNode」に達します。そうした場合には、パラメータのenemy.execScriptメソッドに、thisポインタを渡します。これにより、Enemyが動き出すわけです。

なぜ、「TerminalNode」のthisポインタを渡すのかというと、まず「処理の振り分けができる」ということがあります。(まあ、ここらへんは好みの問題ではあります。たとえば、「TerminalNode」を1つにして、if~else if~で分けるという手もあります)

さらに、例えば、攻撃したりする場合には、「どの攻撃方法で攻撃するか?」という情報や、移動する場合には、「どこに移動するのか?」という情報が必要になります。

それを自フィールドに詰めてあげれば、Enemy.execSpript内でパラメータを覗くことができるようになるわけです。

ということで、Visitorパターンでした。

補足

補足になりますが、Enemy.execScriptをprotected属性にしていますが、これはJavaでは、「同一パッケージであれば、protected属性を見ることができ、異なるパッケージであれば、見ることができない」という特性を利用しているものです。

つまり、EnemyとNodeは同一パッケージで実装することになります。