【Unity】タイピングゲームの制作 (入力とマッチング)

というわけで、仕事繁忙期につき全然更新できていませんが、今回は所謂「タイピングゲーム」の作成に関するお話です!
かなり唐突ですが、作っていた自作ゲームの制作に詰まってしまっているので、気分転換の意味合いが強いです。
Unityの新UIもあまり慣れていなかったので、そういった意味でも勉強にはよさそうですね。

今回はタイピングゲームの基盤となる、文字の入力とそのマッチング処理について考えてみました!

文字入力と判定に必要なもの

作り始める前に、「どんなシステムでどんなクラスが必要か」を考えてみます。
システム開発では超重要なことですが、自分の作るゲームはどれも実践していません。
適当でいいので図にしてみると、あれこれ想像がしやすいです。

こういう「ちょっとした図」を書く時にはペンタブが便利です。
細かい部分直す際に苦労をしなくて済む上、レイヤーコピーで差分修正も楽々です。
紙だと取り置きしにくいので尚更です。

ざっくり且つ適当に書き起こしたメモがこちら!

gameimage

あれこれ考えながら書いたので混沌としていますが、今回のメインは「InputField」と「Matching」になります。
前者がUIの制御、後者が対象となる文字列と入力された文字を比較する処理を担います。

実際の処理の流れは、
「マッチング用クラスのインスタンス生成」→「入力文字を取得して比較」→「比較結果に応じた各種処理」
です。
ひとまずこれを目指してコーディングします!

入力欄の作成

当然ですが、プレイヤーが文字を入力する部分が必要です。
その他、日本語名と入力すべきローマ字が表示されている場合が多いです。

shot2ss20170219225624970

uGUIにおける入力欄は「InputField」を使うようです。
その他2つは「Text」で問題ないでしょう。
フォント設定やセンタリング等もお好みで大丈夫ですが、「ContentType」は「Alphanumeric」に設定します。
Alphanumeric にすることで、半角英数字しか入力できなくなります。

shot2ss20170219225706361

後々のことを考えて、1つ空オブジェクトを作成し、その子となるように各種UIを設定します。
スクリプトでの制御も親オブジェクトで行います。

shot2ss20170219225747481

親の空オブジェクトはデフォルト Transform が設定されていますが、UI上で動かすので RectTransform に入れ替えておきます。
「Add Component」から「Rect Transform」を選べば切り替わります。
ここまで出来たらプレハブ化しておきます。

スクリプトによるマッチング処理

ひとまずスクリプトを記載します。
「TargetWord」が先程のUIの親に設定するスクリプトで、「WordMatcher」は TargetWord クラスから生成されるマッチング用クラスです。

【TargetWord】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class TargetWord : MonoBehaviour {

    private WordMatcher matcher;
    private InputField input_field;

    void Start () {
        input_field = this.GetComponentInChildren<InputField>();

        string target_word = "goma";
        matcher = new WordMatcher(target_word);
    }

    /// <summary>
    /// InputFiled変更時のコールバック関数
    /// </summary>
    public void onInput(string text) {
        if (text.Length == 0) {
            return;
        }

        string input_char = text.Substring(text.Length - 1, 1);
        bool result = matcher.matching(input_char);

        // 入力内容と一致しなかった場合は戻す
        if (!result) {
            input_field.text = matcher.getInputtedString();
        }
    }
}

【WordMatcher】

using System.Collections;

/// <summary>
/// 入力対象文字列と入力文字を比較するクラス
/// </summary>
public class WordMatcher {

    // 入力対象の文字列
    private string target_string;

    // 現在の入力文字位置
    private int current_position;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public WordMatcher(string target_word) {
        this.target_string = target_word;
        current_position = 0;
    }

    /// <summary>
    /// 指定された文字と現在位置の文字を比較する
    /// </summary>
    public bool matching(string input_char) {
        if (getCurrentChar() == input_char) {
            current_position += 1;
            return true;
        }
        return false;
    }

    /// <summary>
    /// 現在入力済みの文字列を返す
    /// </summary>
    public string getInputtedString() {
        return (target_string.Substring(0, current_position));
    }

    private string getCurrentChar() {
        return (target_string.Substring(current_position, 1));
    }
}

処理は TargetWord クラスの Start() から始まります。
WordMatcher クラスをインスタンス化し、入力対象となる文字列を渡します。(今回は決め打ちです)

次に、入力欄(InputField)に文字が入力されたら処理を行います。
「Inputであれこれ制御すれば・・・」とか深読みしましたが、最近は「UnityEvent」という便利なものがあるようです!

shot2ss20170219225843384

「コールバック先オブジェクト」「スクリプトの関数名」を選択するだけで、入力時に自動的に呼び出されます。
いちいち自分で「入力されたら~」という処理を実装しなくても良い上、入力された文字も送られてきます。
Unity4.1 の OnGUI() で頑張っていた人としては感涙ものです。

shot2ss20170219225926120

注意点として、関数名選択時に「Dynamic String」の方を選ぶ必要があります。
「Static Parameter」では動的に取得してくれず、引数が空っぽになってしまいます。

入力時に onInput() が呼ばれます。
先程「入力された文字が送られてくる」と書きましたが、実際は「現在 InputField に入力されている内容全て」が送られてきます。
なので入力された文字=最も右側の文字のみを取得する処理を入れます。

 
    string input_char = text.Substring(text.Length - 1, 1);
    bool result = matcher.matching(input_char);

String.Substring() の第1引数で開始位置を指定すればOKです。
文字列のインデックスは0から始まるので、text.Length で取得した文字数から1を引いた数値を指定します。

あとはマッチング処理です。
といっても「入力した文字を取得する」が出来ているので、入力対象の文字と1文字ずつ比較するだけです。

 
    /// <summary>
    /// 指定された文字と現在位置の文字を比較する
    /// </summary>
    public bool matching(string input_char) {
        if (getCurrentChar() == input_char) {
            current_position += 1;
            return true;
        }
        return false;
    }

    private string getCurrentChar() {
        return (target_string.Substring(current_position, 1));
    }

アプローチとしては「入力した分だけ入力対象文字を削除する」か「入力された位置を変数で管理する」が浮かびました。
どちらでもいける気がしますが、自分は後者で実装しています。
getCurrentChar() で現在位置の文字を取得、入力された文字と比較し、一致したら位置を1つずらします。
一致しなかったら false を返し、TargetWord クラス側で入力された文字を元通しに戻しています。

ただし、現状では input_field.text を再設定した際、再度 onInput() が呼ばれてしまいます。
どうやらキーボード入力限定ではなく、単に input_field.text に変更があったかどうかだけ見ているようです。
回避方法が分からないので、2回通っても問題ないような処理にしてあります。

shot2ss20170219230035815

実行して挙動を確認しておきましょう。
入力すべき文字以外が来ると元通しになるため、見かけ上は入力されていないように見えます。

まとめ

そんなわけで、タイピングゲームの基盤となる部分を作ってみました!
次は「ワードを打ち終えたら次のワードの出現」といった部分でしょうか。
言葉をどこから取得するかも考える必要があるので、しっかり構想が決まってから手を出してみます。

【Twitter】ランパの台詞botを作成してみる その3

というわけで、今回は「ランパの台詞bot」制作その3です!
少しずつ作っていた台詞集ですが、ゲーム開始からエンディングまでの台詞を集め終わりました!

前回同様、改良点や苦労した点を載せていきます。
ちょっと長めの記事ですが、今回でほぼ完成までいけた・・・かと思います!

前回の記事は↓になります!

【Twitter】ランパの台詞botを作成してみる その2

ツイートした台詞を一定期間再ツイートしないようにする

かなりレパートリーは豊富になりましたが、それでも短期間に連続してツイートしてしまうことがあります。
以下は1月19日のツイートです。

種類が豊富なのに短期間で同じ台詞を言わせるのはいただけないです。
そんなわけで、1回ツイートした台詞は少し期間を空け、連続でツイートしないようにします。

これを実現するにはサーバ側でデータを取っておくか、過去のツイートを取得して判定するしかありみあせん。
幸い、WordPress 用に MySQL が入っているので、それを使ってサーバ側で判定してみます。
当初「台詞をデータベースで管理する」ことは想定していなかったので、台詞ごとにシーケンシャルなIDなんていいものはありません。
当然ながら一意に識別できるコードなんてものもありません。

そこで使用するのがハッシュ値を計算する md5() です!
これで台詞をハッシュ値に変換し、識別しやすい形に置き換えて保存します。
普通に台詞を格納するのも手ですが、最大130文字近くになる台詞がある上、マルチバイト文字をそのまま入れて比較・・・というのは避けたかったためです。

下準備として、データベースとテーブルを作成しておきます。
端末上から直接作ってしまいましょう。

CREATE DATABASE twitter_bot;

use twitter_bot;

CREATE TABLE rampa_tweet_history (
    id INT PRIMARY KEY AUTO_INCREMENT NOT NULL,
    message TEXT NOT NULL,
    create_date DATETIME NOT NULL
);

データベース名やテーブル名は適当です。
id は不要かもしれませんが、一応作っておきます。
create_date は一定期間以上経過した台詞を弾くために必要です。

前回作成したPHPファイルを修正していきます。
それなりに長くなりましたが、以下がソースになります!

<?php

require_once("twitteroauth-master/autoload.php");
require_once('twitteroauth-master/src/TwitterOAuth.php');

use Abraham\TwitterOAuth\TwitterOAuth;

/**
 * ランパの台詞集から台詞を抽出・ツイートするクラス
 */
class TwitterRampaBot {

    private $customer_key;
    private $customer_secret;
    private $access_token;
    private $access_token_secret;

    private $db_user = 'db_user';
    private $db_password = 'db_password';
    private $interval_spec = 'P3D';

    const file_path = 'rampa_serifu.txt';
    const max_tweet_len = 140;

    /**
     * コンストラクタ
     */
    public function __construct()
    {
        $this->customer_key = "customer_key";
        $this->customer_secret = "customer_secret";
        $this->access_token = "access_token";
        $this->access_token_secret = "access_token_secret";

        mb_internal_encoding('UTF-8');
    }

    /**
     * つぶやきを送信する
     */
    public function tweet()
    {
        $message = $this->getTweetWord();

        // 文字数チェック
        if (self::max_tweet_len < mb_strlen($message)) {
            trigger_error('ツイート可能な文字数を超えています。', E_USER_ERROR);
            exit;
        }

        // サーバにつぶやきを送信
        $connection = new TwitterOAuth($this->customer_key, $this->customer_secret, $this->access_token, $this->access_token_secret);
        $connection->post("statuses/update", array("status" => $message));
    }

    /**
     * ツイート用の文字列を取得・整形する
     *
     * @return string
     */
    private function getTweetWord()
    {
        // テキストファイルから文字列読み込み
        $input_lines = file(self::file_path);
        $words = array();

        // データベース接続
        try {
            $dsn = 'mysql:host=localhost;dbname=twitter_bot;charset=utf8';
            $pdo = new PDO($dsn, $this->db_user, $this->db_password);
        } catch (\Exception $e) {
            trigger_error('データベースへの接続に失敗しました。', E_USER_ERROR);
            exit;
        }

        // 先頭 # の行を取得するまでループ
        while (true) {
            $line_num = rand(0, count($input_lines));

            // 先頭が # で始まる行を抽出
            if (isset($input_lines[$line_num]) && preg_match('/^\# .*$/', $input_lines[$line_num])) {
                $words = array();

                // # 以降を場面として取得
                $scene = $this->excludeLineFeed($input_lines[$line_num]);
                $scene = str_replace('# ', '', $scene);

                // 複数行の台詞を全て取得するまでループ
                for ($i = $line_num + 1; ; $i++) {
                    // 文字列か空or改行コードのみの場合は終了
                    if (empty($input_lines[$i]) || preg_match('/^' . PHP_EOL . '$/', $input_lines[$i])) {
                        break;
                    }

                    $words[] = $this->excludeLineFeed($input_lines[$i]);
                }

                // 台詞と発言場面を設定
                $message = '';
                foreach ($words as $word) {
                    $message .= $word . PHP_EOL;
                }
                $message .= '【' . $scene . '】';

                // 台詞が一定期間内にツイートされているかチェック
                if (!$this->checkMessage($pdo, $message)) {
                    continue;
                }

                // ツイートする台詞を保存
                $this->insertMessage($pdo, $message);

                break;
            }
        }

        $pdo = null;

        return $message;
    }

    /**
     * 台詞がデータベース内に保存されているかチェックする
     *
     * @param PDO $pdo
     * @param string $message
     */
    private function checkMessage(PDO $pdo, $message)
    {
        $sql = 'SELECT count(id) FROM rampa_tweet_history WHERE message = :message AND create_date > :create_date';
        $date = new DateTime();
        $date->sub(new DateInterval($this->interval_spec));

        $pdo_stat = $pdo->prepare($sql);
        $pdo_stat->bindParam(':message', md5($message));
        $pdo_stat->bindParam(':create_date', $date->format('Y-m-d H:i:s'));
        $pdo_stat->execute();

        // ツイートされていた場合は台詞を再取得
        if (0 < $pdo_stat->fetchColumn()) {
            return false;
        }

        return true;
    }

    /**
     * 台詞をデータベースへ挿入する
     *
     * @param PDO $pdo
     * @param string $message
     */
    private function insertMessage(PDO $pdo, $message)
    {
        $sql = 'INSERT INTO rampa_tweet_history (message, create_date) VALUES (:message, :create_date)';
        $date = new DateTime();

        $pdo_stat = $pdo->prepare($sql);
        $pdo_stat->bindParam(':message', md5($message));
        $pdo_stat->bindParam(':create_date', $date->format('Y-m-d H:i:s'));
        $pdo_stat->execute();
    }

    /**
     * 指定した文字列から改行コードを除外する
     *
     * @param string $str
     * @return string
     */
    private function excludeLineFeed($str)
    {
        return str_replace(PHP_EOL, '', $str);
    }
}

$twitterRampaBot = new TwitterRampaBot();
$twitterRampaBot->tweet();

exit;

?>

※ツイートの取得や送信部分は前回の記事をご参照下さい。

MySQL への接続には PDO_MySQL を使用しました。
明確な理由はありませんが、ガチガチに書くわけでもないので、PDO が手っ取り早いかなーと。
仕事ではDB関連はフレームワークに任せてしまうので、別の意味で苦戦していたりもします。

ポイントは checkMessage() 内の処理です。
一定期間は interval_spec で定義し、P3D を設定しておきます。
これを DateTime::sub() で指定することで、「現在時間から3日前の日時」になります。
このあたりの指定方法は公式マニュアルに書いてあります。
http://php.net/manual/ja/dateinterval.construct.php

「データベース内に台詞が存在するか」だけ欲しいので、実行する SQL は「SELECT COUNT(id)」です。
execute() 実行後、fetchColumn() を実行することでレコード数を取得することができます。
あとは if で評価して真偽を返し、false であれば continue で再度台詞を取得します。true であれば台詞をハッシュ化してデータベースに保存します。

作りたての状態で実行すると、データベース接続が出来なかったり、日付型の比較でしくじったりするので、事前にテストを行うことをおすすめします。
端末上から直接PHPファイルを指定して実行すればOKです。
その場合、「サーバにつぶやきを送信」の部分をコメントアウトし、echo や var_dump() を使用しましょう。
テスト用として別ファイルで作ってしまうと安全です。

乱数の生成方法の変更

そもそもPHPの rand() はいろいろと問題があるようで、これを使用していること自体がまずいみたいです。
より良い乱数生成と数倍の速度を持つという mt_rand() で置き換えます。
使い方は変わらないようなので、本当に置き換えるだけ!

$line_num = mt_rand(0, count($input_lines));

「より良い乱数」になったかどうかはまだ分かりません。
ただ rand() では特定の台詞のみ偏って選出されたりしたので、そのあたりの改善が期待できる・・・かもしれません。

上で記載した「一定期間内にツイートした台詞は除外」機能と合わせれば、少なくとも同じ台詞が続いてしまうことは避けらます。
データベースやPHPのモジュールにもよりますが、気になっている方は試してみてはいかがでしょうか。

苦労したこと

前回に引き続き、bot製作に関して苦労したことを紹介します。

抜けが無く収集できているか心配になる

抜けがないように収集したつもりですが、やはり不安な部分もあります。
例えばランパの変身が可能なエリアで、且つ該当の変身をまだ取得していない場合に特殊な台詞があります。

今のボクのチカラじゃここではへんしんできないみたいだ・・・
ぼうけんしてフシギな石を見つけたら、またへんしんできるようになるかも・・・?
気になるところがあるかもしれないけれど、ひとまず今は先にすすんで、あとでここにもどってみようよ

今のボクのチカラじゃここではへんしんできないみたいだ・・・
ぼうけんしてフシギな石を見つけたら、またへんしんできるようになるかも・・・?
ひとまず今はちがうみちをすすんでみて・・・あとでここにもどってみようよ、スタフィー

この際、場所によって微妙に台詞が違ったりします。
自分は後半で気付きましたが、もしかしたら前半の隠しエリアなどにも設定されているかもしれません。
その場合、ストーリーを最初からやり直すことになりかねません。

手動で収集・入力することに限界を感じる瞬間ですが、メインとなるストーリー上のイベントはすべて搭載しているので、台詞botとしては妥協ラインかなーとも思います。
裏を返せばそれだけの台詞パターンがある位、ゲーム自体の作りが丁寧ということですね。
攻略本で変身するエリアだけを確認する等、漏れがないような施策を考えていきたいところです。

かなり長い台詞がある

例として、以下はステージ8-3後半の会話です。
途中キョロスケが1~2回話す以外、ほぼランパのみが喋り続けます。

す、スタフィー・・・なかまが・・・なかまが・・・

みんなっ、しっかりっ!!へんじをしてーーー!!
ダメだ・・・みんなチカラをダイールにすいとられてうごけないみたい・・・

ダイール・・・何てことを・・・
ゆるさない!ゆるさないぞーぉおお!!!
何があっても・・・みんなをもとの元気なすがたにもどすんだ!!!

ボクをダイールからまもるために、みんなはぎせいになった・・・
みんなは・・・なかまは・・・かぞくどうぜんなんだ
ボクには・・・パパもママもいないから・・・みんながかぞくなんだ!

・・・うん、ボクが生まれてすぐ・・・パパもママもしんじゃったんだって
すごく小さかったからよくわからなかったけど
さみしくないようにって、みんながやさしくしてくれた

ボク、こうやってスタフィーたちとであえてわかったんだ
スタフィーも王子、ボクも王子、おなじ王子なのに、スタフィーはいつもボクをたすけてくれた
このままじゃいけないんだ!スタフィーのように、強くやさしく・・・みんなをまもれる王子にならなきゃ!

ねぇ、スタフィー ボクやるよ!
ダイールをたおして・・・なかま・・・かぞくをとりもどすんだ!!!
ぜったいこのままでおわらせるわけにはいかない!さあ!行こう!

台詞自体はランパの過去や成長、決意が見て取れる大変よろしいものです。
ただし、bot的には「ツイート可能な文字数は140まで」という制限に引っ掛かります。
仕方ないので文としての意味合いが切れない程度に分けていますが、それでも140文字以内に収まるか怪しくなる台詞もあります。
上の例で言えば、「ボク、こうやってスタフィーたちと~」から3行で129文字です。

この例に限らず、稀に非常に長い台詞があります。(7-4など)
こればかりはどうしようもないので、やはり文としての意味合いを保ちつつ分けています。
一部強引な分け方をしていますが、「こんな台詞がある」というbotであり、所謂「名言集」ではないので・・・と言い訳しておきます。

余談ですが、その割にはエンディングは超あっさりで、ダイール撃破後にランパが発した明確な台詞は以下の2つのみです。

スタフィー!だいじょうぶ?

スタフィー!ほんとうにありがとう
またあそびにきてね

後者はスタッフロール後の一番最後の台詞で、「また遊んでね!」的な意味合いが含まれていると思われるため、実質1つのみです。
まあエンディングなのでナレーションベースということもありますが、ちょっと驚きました。

「ランパのあつめもの」の存在を忘れる

キョロスケ様のかばんメニューに「あつめもの」があります。
このメニュー、ストーリーの進行具合に応じてランパのコメントがあります。

DSC_0306

が、このメニュー自体の存在をステージ5まで忘れていました。
更新タイミングは(たぶん)ステージクリア時とエンディング後なので、ステージ5からメモっていけばやり直す範囲も抑えられますが、完全にノーマークだったので精神的ダメージが大きかったです。

ちなみに「今日のゲスト」の各台詞と、クリア後にあつめものを見た際の「ランパのにちじょう」シリーズはすべて回収しました。

DSC_0326

ただし、「ランパのにちじょう (海)」だけ異常に出現率が低く逃しそうになりました。
内容は上画像の通りですが、さらっとすごいことを言っているので意図的に低確率なのかもしれません。
隕石を弾き返すほどの回転をランパが習得できるとは思えませんが・・・。

「キョロスケ様のキャラ診断」なるフラッシュがある

公式サイトに「キョロスケ様のキャラ診断」なるものがあります。
公式自体見たことがなかったので、海外のフォーラムで初めて知ったりもします。

現在でも公式サイトにリンクがありますが、画面下部にひっそりと表示されているだけです。
気合でランパっぽくなるような選択肢を選びます。

starfy_sindan_rampa2

やはりというか、キャラクター毎のコメントがあります。
ゲーム外の台詞ですが、「ゲーム内の」と限定して作っているわけでもないので、とりあえず台詞集に追加しておきました。
ちなみにハマグリ曰く、「真面目で素直な優等生タイプ」らしいです。

まとめ

そんなわけで、ランパの台詞bot作成(ほぼ)完了です!
プレイしていると、キャラクターやストーリーが懐かしく感じて、意外とハマってしまったりします。

現在の台詞パターンは193種類です!
1アクションゲームとしてみると相当な量ではないでしょうか。
あとは上述した「あつめもの」をステージ1~4分入れれば完成かと思います!

【開発メモ】敵キャラクターとロジックの作成に関する考察 その2

というわけで、前回に引き続き、敵キャラクターとロジックに関するお話です!
出張が多かったので開発が滞っていますが、やっとロジッククラスが作成できたので記載します!

shot2ss20170205204248559

前回の記事はこちら!

【開発メモ】敵キャラクター作成とロジックに関する考察

今回は上記の記事内で考察した中の、移動ロジックと攻撃ロジックについて考えてみます。

ロジッククラス作成の準備

ロジッククラス作成の前に下準備をしておきます。
まずはロジッククラスを共通化させるためのインタフェースを定義します。

【IEnemyMoveLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface IEnemyMoveLogic {
    /// <summary>
    /// 移動する速度を取得する
    /// </summary>
    Vector3 getMoveVelocity();
}

【IEnemyAttackLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public interface IEnemyAttackLogic {

    /// <summary>
    /// 攻撃を行うか判断する
    /// </summary>
    bool attackDetermine();

    /// <summary>
    /// 攻撃判定を取得する
    /// </summary>
    GameObject getAttackHitObject();

    /// <summary>
    /// 攻撃の種類を取得する
    /// </summary>
    int getAttackType();

    /// <summary>
    /// 攻撃時に再生するモーションを取得する
    /// </summary>
    string getAttackMotion();
}

次に各ロジッククラスのスーパークラスとなる抽象クラスを作成します。
以下は大元となる「AbstractLogic」クラスです。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic {
    public abstract class AbstractLogic : MonoBehaviour {

        protected Enemy enemy;

        protected virtual void Start () {
            enemy = this.GetComponent<Enemy>();
        }
    }
}

更に、移動・攻撃ロジック用に AbstractLogic を継承した抽象クラスを作成します。
共通処理として、Enemy クラスにロジックを設定します。

【AbstractMoveLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Move {
    public abstract class AbstractMoveLogic : AbstractLogic, IEnemyMoveLogic {
        public float move_speed;

        protected override void Start () {
            base.Start();

            enemy.setMoveLogic(this);
        }

        public abstract Vector3 getMoveVelocity();
    }
}

【AbstractAttackLogic】

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Attack {
    public abstract class AbstractAttackLogic : AbstractLogic, IEnemyAttackLogic {
        public const int CLOSE_RANGE_HIT_TYPE = 1;
        public const int RANGE_HIT_TYPE = 2;

        public GameObject attackHitObject;
        public int attack_type;
        public string attack_motion;

        protected override void Start () {
            base.Start();

            enemy.addAttackLogic(this);
        }

        public abstract bool attackDetermine();
        public abstract GameObject getAttackHitObject();
        public abstract int getAttackType();
        public abstract string getAttackMotion();
    }
}

移動用クラスは IEnemyMoveLogic、攻撃用クラスは IEnemyAttackLogic を実装させます。
これらのインタフェースを介した実装は、「特定のロジックに依存させない」ために重要です。

ロジッククラスの作成

いきなり複雑なロジックは厳しいので、今回は簡単な移動クラスと攻撃クラスを作成してみます。

以下は移動ロジックの、「自分のいる地点から左右に往復移動」のクラスです。
(横スクロール型のアクションで使用することを想定しています)

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Move {
    public class RoundTripMoveLogic : AbstractMoveLogic {

        // 基準点から往復するまでの距離
        public float repeat_dist;

        private Vector3 defaultPosition;

        protected override void Start () {
            base.Start();

            defaultPosition = enemy.transform.position;
        }

        public override Vector3 getMoveVelocity() {
            // 振り向き処理
            if (repeat_dist < Mathf.Abs(enemy.transform.position.x - defaultPosition.x)) {
                Vector3 angle = new Vector3(defaultPosition.x, enemy.transform.position.y, defaultPosition.z);
                enemy.lockRotate(angle);
            }

            float forward_x = enemy.transform.forward.x * move_speed;
            Vector3 newPosition = new Vector3(forward_x, enemy.rb.velocity.y, enemy.rb.velocity.z);

            return newPosition;
        }
    }
}

ロジックに関する深い説明は割愛しますが、「往復する距離まで移動したら逆側に振り向かせる」こと、「敵キャラクターは Rigidbody で動かしているので、Rigidbody.velocity を変更する」ことに注意します。

次は攻撃ロジックの、「一定間隔で攻撃」のクラスです。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EnemyLogic.Attack {

    public class AttackIntervalLogic : AbstractAttackLogic {

        // 攻撃を行う間隔 (フレーム)
        public int attack_interval;

        private int attack_count;

        protected override void Start () {
            base.Start();

            enemy.addAttackLogic(this);
            attack_count = 0;
        }

        public override bool attackDetermine() {
            if (attack_interval < attack_count) {
                attack_count = 0;
                return true;
            }
            attack_count += 1;
            return false;
        }

        public override GameObject getAttackHitObject() {
            return this.attackHitObject;
        }

        public override int getAttackType() {
            return this.attack_type;
        }

        public override string getAttackMotion() {
            return this.attack_motion;
        }
    }
}

主に使用するのは attackDetermine() で、これを Enemy クラスの Update() 内で呼び出し、攻撃を行うかの判定をしています。
その他、インタフェースで定義された関数を実装していますが、ロジック上で必要なパラメータを取得するためのものです。
このあたりはゲームによって実装が異なると思います。

共通して気を付けることは、「ロジッククラスから敵キャラクターを直接操作しない」ことです。
複数のロジッククラスからあれこれ敵キャラクターを操作すると訳が分からなくなります。
ロジッククラスは条件判定後に結果を Enemy クラスへ返したり、Enemy クラスの関数を呼び出して間接的に操作したりします。

敵キャラクター共通のクラス作成

敵キャラクター共通のスーパークラス「Enemy」を作成します。
既にダメージ処理等が入ってしまっていて長いので、ロジックに関する部分のみ紹介します。

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

[RequireComponent (typeof (Rigidbody))]

public abstract class Enemy : MonoBehaviour, ICharacter {

    protected bool isAttack;

    // 攻撃判定の出現位置
    public GameObject attackHitOffset;
    public GameObject bulletHitOffset;

    protected Animator animator;
    protected AnimatorStateInfo stateInfo;

    public Rigidbody rb;
    protected Collider bodyCollider;

    // ロジック制御クラス
    protected IEnemyMoveLogic moveLogic;
    protected List<IEnemyAttackLogic> attackLogics;

    protected virtual void Awake() {
        // ロジック初期化
        attackLogics = new List<IEnemyAttackLogic>();
    }

    protected virtual void Start() {
        isAttack = false;

        // 各コンポーネント取得
        animator = this.GetComponent<Animator>();
        rb = this.GetComponent<Rigidbody>();
        bodyCollider = this.GetComponent<Collider>();

    }

    /// <summary>
    /// 毎フレーム実行する処理
    /// </summary>
    protected virtual void Update() {
        if (animator && canAction) {
            stateInfo = animator.GetCurrentAnimatorStateInfo(0);

            // 移動ロジック
            if (moveLogic != null && !isAttack) {
                movement(moveLogic.getMoveVelocity());
            }

            // 攻撃ロジック
            foreach (IEnemyAttackLogic logic in attackLogics) {
                if (logic.attackDetermine()) {
                    GameObject attackHitObject = logic.getAttackHitObject();
                    string attack_motion = logic.getAttackMotion();

                    switch (logic.getAttackType()) {
                        case EnemyLogic.Attack.AbstractAttackLogic.CLOSE_RANGE_HIT_TYPE:
                            // 近距離攻撃処理
                            break;
                        case EnemyLogic.Attack.AbstractAttackLogic.RANGE_HIT_TYPE:
                            // 遠距離攻撃処理
                            break;
                    }
                }
            }
        }
    }

    /// <summary>
    /// 移動処理を行う
    /// </summary>
    public void movement(Vector3 velocity) {
        rb.velocity = velocity;
    }

    /// <summary>
    /// 指定した座標へ振り向かせる
    /// </summary>
    /// <param name="angle"></param>
    public void lockRotate(Vector3 angle) {
        transform.LookAt(angle);
    }

    public void setMoveLogic(IEnemyMoveLogic moveLogic) {
        this.moveLogic = moveLogic;
    }

    public void addAttackLogic(IEnemyAttackLogic attackLogic) {
        this.attackLogics.Add(attackLogic);
    }
}

今までは Update() 内にごりごりロジックを書いていましたが、今回は各ロジッククラスを制御する命令のみに留めます。

キモとなるのは、ロジッククラスの型をインタフェースにしておくことです。
インタフェースに記載された関数の実装が約束されるため、どのロジッククラスにも依存せず共通化できること、Enemy クラスからロジッククラスを見た際に最小限の機能のみを提供するためです。

攻撃クラスのみ List<> で管理しています。
例えば「近距離攻撃と遠距離攻撃を行うキャラクター」や「2種類の近距離攻撃を持つキャラクター」を作成する際、複数のロジックを作成して対応させるためです。
また List<> ならロジックが1つでも複数あっても対応でき、特定のロジックのみを無効化しても問題なく動きます。

実際に使用する際はキャラクターごとに専用のサブクラスを作成し、実際の敵キャラクター用ゲームオブジェクトのコンポーネントとして設定します。
中身はキャラクター固有の特殊能力がある場合に実装し、特にない場合はスーパークラスの関数を呼び出すだけです。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Raidran : Enemy {

    protected override void Start () {
        base.Start();
    }

    protected override void Update() {
        base.Update();
    }
}

今回の敵キャラクター「レイドラン」は特に考えていないため、何も考えずに継承しているだけです。

敵キャラクターへの設定とプレハブ化

上で作った移動ロジックと攻撃ロジックを敵キャラクターのオブジェクトに設定し、その動作を確認してみます。

20170205_01

うーん・・・左右を往復するだけの雑魚っぽいにはなったでしょうか。
本ゲームにはアクションでよくある「接触被ダメージ」がないので微妙かもしれませんが・・・。

ちなみに、Unityのベストプラクティス的にはオブジェクトの種別ごとにプレハブを作るのが良いらしいです。
動的にコンポーネントを足すのもアレなので、ロジックを設定したキャラクター毎にプレハブ化します。
プレハブが多くなって管理が面倒ですが、動的にインスタンスすることを考えればこちらの方が良いです。

まとめ

そんなわけで、ロジック系クラス作成の基盤と簡単なロジックの作成をしてみました!
敵キャラクター制御が使いまわせるようになり、コンポーネント毎に付け替えできるのでいい感じです!
索敵やジャンプのロジックも作れば、更に幅広いアクションを取らせることができそうですね。

ただやっていて思うのは、かなり手探りで自分なりの設計をしているということです。
このあたりのベストプラクティスとかってあるのでしょうか。
ゲームによって異なるとは思いますが、他の方がどのように実装しているのか気になります。
そのあたりのお話をUnity道場等でやっていたらぜひ参加したいところです。