ゴマちゃんフロンティアV2へようこそ!

本ブログはゲーム統合開発環境「Unity」を使用したゲーム開発の日記です!
ときどきイラストを描いたり、写真を交えた日記も書いています!

【開発環境】

OS Windows7 SP1 64bit
CPU Intel(R) Core(TM) i7-4770 CPU @ 3.40GHz
メモリ 12GB
グラボ GeForce GTX 645
使用ソフト Microsoft Visual Studio(C#)
Blender
GIMP

旧ブログの記事について

本ブログは2015年9月にFC2ブログから移転しております。
過去記事につきまして、「開発方針が変わっている」「Unityの仕様が現状と異なる」等、そのままでは不適切と判断しました。
なので、知識・技術面で重要そうな記事のみをピックアップし、再編集して新ブログに追加している段階です。
Googleのインデックスの関係上、検索内容とブログ内容に差異が発生する可能性がありますので、ご了承ください。
また「前のブログのこの記事読みたい!」というものがありましたら、ご連絡頂ければ追加致します!

お問い合わせフォームはこちら

【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道場等でやっていたらぜひ参加したいところです。

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

というわけで、最近 Twitter の bot 制作の話ばかりでしたが、今回はゲーム開発のお話です!

テーマは「敵キャラクターとロジックに関するお話」です!
定期的にこんな話は載せていますが、どれも中途半端な実装とロジックなので、もう一度ベストな形を考え直す意味合いです。特に敵キャラクターのロジック部分はしっかり考える必要があります。

結論から言うと、「移動や攻撃などの役割毎にコンポーネントとして分解する」ようにするのが目的です。
なるべくロジックを分解し、パーツとして組み合わせることができるうな形が理想です。

敵キャラクターの作成

自作ゲームである以上、敵キャラクターも全て自分で考えます。
ロジックどうこうの前に、敵キャラクターを用意しないと話になりません。

とはいえ、今まともな敵と言えるものは「バブぴょん」くらいです。
開発初期の方に作ったので設定も何もなく、ロジックもかなり適当です。

shot2ss20170122094910458

敵キャラクターを作っていく場合、相応に種類が必要になるので、ある程度設定を考え、一貫性を持たせたほうがモデリングやモーション入れもラクです。
今考えている設定に「共通で結晶や角がある」「雷と氷を放つ」があるので、とりあえずそれを盛り込んで作りました。
前者は角ばっている方がモデリングが楽だからで、後者の「雷と氷」というのは、「海(≒水)を脅かす属性」として、一番メジャーで妥当かと思ったためです。

そんなわけで、ざっくりと2匹作ってみました!
上記のバブぴょんは「敵っぽくなさ」に定評がありましたが、今回のはそれなりに敵っぽくなっております!

shot2ss20170122093840735

1体は二足歩行の至ってシンプルな見た目です。
槍状の結晶による突きと角からのビームを放ち、敵軍団の主力となるような敵を目指します。
暫定的な名前として「レイドラン」と付けています。

shot2ss20170122093938413

もう1体は地を這う幽霊っぽい見た目です。
動作は遅めですが、両手で薙ぎ払う攻撃と防御を得意とする敵を目指します。
幽霊っぽく、目はパーティクルで表現しています。

ロジック部分の設計について

敵キャラクターが取る行動について、役割ごとにざっくりと洗い出してみます。

【索敵】
・索敵をしない (プレイヤーの行動に影響されない)
・前方を索敵
・周囲を索敵

【移動】
・移動しない
・常に前進
・一定の範囲を往復
・プレイヤーを追跡

【ジャンプ】
・ジャンプしない
・一定間隔でジャンプ
・攻撃時のみジャンプ
・常に浮いている

【攻撃】
・攻撃しない
・常に一定間隔で攻撃
・プレイヤーが索敵範囲内にいる場合に攻撃

概ねこんな感じでしょうか。
このロジック毎にクラスを作成し、敵キャラクターにコンポーネントとして追加するだけで設定が可能な状態を目指します。キャラクターの固有クラスでロジックを作成すると使い回しがきつくなる上、クラスの役割が肥大化するので避けたいところです。
その分クラスが多くなりますが、命名規則を決めたり(頭文字をAttack~、Move~にするなど)、名前空間を指定するなどでカバーします。

この他、キャラ固有の特殊能力などは個別のサブクラスに実装してしまいます。
また、各ステージのボスは専用のロジックを作成する予定です。

あくまで自己流の設計なので、これがベストとは言えないと思いますが、そこまでたくさんキャラクター(とロジック)作るアイデアも時間もないので、これで行ってみます!

まとめ

そんなわけで、敵キャラクターとロジックについて考えてみました!
実装の際のポイントは
・役割とそのロジック毎にクラスを作成
・コンポーネントとして付け替え/設定ができるように
・命名規則と名前空間をしっかり決めておく

あたりでしょうか!

次回から作っていくつもりですが、最近出張やら外泊やらが多いので、そこそこ先の話になってしまうかもしれません。
妥協するところは妥協しないといつまで経っても完成しないので、そのあたりの線引きも大切ですね。

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

というわけで、今回は「ランパの台詞botを作る」第2弾です。
前回の記事はコチラ!

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

今回は台詞集から出力するテキストを抜き出し、ツイートするところまでやってみます!
ついでに作っている上で苦労していることも紹介します。

台詞の入力

とりあえず「オープニング~ステージ3-2」までと、その他回収できた台詞を書き出してみました!

20170116_02

見直してみると結構喋りますね。
ステージ3-2まででも3000文字近くあります。
ハマグリほどではありませんが、主人公が喋らないのでその影響もあるようです。
一方で全く喋らない(≒登場しない)ステージもそれなりにあるので、各ステージ満遍なく喋るというよりは、1ステージでの回数が多いといった印象です。

それにしても、3点リーダーが尋常でなく多いです。

DSC_0281

恐らく会話ウィンドウ的な都合があるのだと思いますが、それを差し置いても多いです。
実際にツイートさせる時は適度に削除しないと見にくくなってしまいます。

ゲーム自体の対象年齢故にひらがなも多いですが、簡単な漢字とカタカナは含まれているので、そこまで読みにくくはないと思います。
ただ句読点が一切ない上、上記の3点リーダーを削除してツイートする兼ね合いもあるため、適度に句読点は入れています。
それ以外は原文ママで問題ないと思われます。

twitterアイコンの作成

デフォルトのたまごアイコンでは味気ないので、アイコン用にランパを描いてみました。
ステージ選択のアイコンを元にしました。

20170116_rampa_icon1

うーん・・・また何とも言えない出来ですが、まあ及第点でしょう。
まあアイコンなんて小さいものなので、割と大雑把なくらいがちょうどよかったりします。

個人ページの背景は・・・後回しにします(ぁ
今の自分のイラスト力では描ける気がしないので・・・。

bot用PHPファイルの修正

前回ほぼコピペだった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;

    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();

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

            // 先頭が # で始まる行を抽出
            if (preg_match('/^\# .*$/', $input_lines[$line_num])) {
                // # 以降を場面として取得
                $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]);
                }
                break;
            }
        }

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

        $message .= '【' . $scene . '】';

        return $message;
    }

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

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

exit;

?>

GitHub から落とした「TwitterOAuth」を使用していますが、バージョンによってやり方に差があるようです。
自分も他サイトのコピペでは全然動きませんでした。

あれこれ考えた結果、ファイルから取得した後、正規表現で取捨選択するのが最も手っ取り早かったです。
テキストファイルから抽出した文字列から、以下の条件で抽出します。
・# から始まる行を発言場面として抽出
・#以下から次の空白行 (実際は改行コード) までを台詞として抽出
・空白行は抽出しない

台詞集となるテキストファイルもそれに則って書いていきます。
サンプルとして、いくつか台詞を記載します。

# ステージ1-3
・・・どうしてこんなところに・・・
それに何でたたかっていたんだ・・・
うう・・・思い出そうとするとアタマがイタイ・・・
もしかして・・・ボクのきおく・・・

# ステージ1-4
あ・・・うん!!
スタフィーのボクをたすけたいって思う気もちが
ボクのへんしんのチカラ、『ドランパ』を思い出させてくれたんだ・・・

# ステージ2-2
あ、そうだ!ボクとチカラをあわせて下からゴォォォーってやってみる?
ドランパで、上ボタンをおしながらゴォォォーってできるよ
ためしてみて!

注意点として、Linux 上から動作させる関係上、改行コードは「LF」にしておく必要があります。
サクラエディタであれば保存時に指定できます。

かなり効率の悪い処理ですが、何度も実行するような処理ではないので、速度はあまり気にしません。
他にもいろいろ考えましたが、.ini は設定ファイルの趣が強く、このためだけに yaml や json のパーサを入れるのも嫌なので、とりあえずこの形でいってみようと思います!

苦労している点

プレイ日記に片足突っ込みつつ、製作している上で苦戦していることを紹介します。

データが消える

ある意味最大の壁です。
本体がダメなのか、ソフトが古いためか、プレイ中に接触不良→次回起動時にデータ消失ということがあります。
特にカートリッジを抜いておくと、次回起動時に差しても高確率で認識しません。

スロットからカートリッジを出さなければ順調なので、差しっぱなしにすることでカバーしていますが、当分の間3DSがスタフィー専用機となることを意味します。
最近3DSはほとんど起動していないので、あまり実害はないと思われますが、MHXX発売までには完成させたいところです。

ステージクリア後にオートセーブ

カートリッジ故にセーブは高速で、普通にプレイする分には問題ありません。
ただ、ランパの台詞を取り逃してしまったり、間違えていた場合、ステージをクリアするまでにリセットしないとアウトです。
ストーリーが進んでしまうと読み直すことができないので、漏れなくニューゲームとなります。
後半のステージでこれをやってしまうと悲惨なので、最も注意すべき人的エラーですね。

会話後に再度会話をすると内容が異なる

ステージの所々に会話イベントがありますが、会話後に再度キャラクターと会話すると、内容が異なる場合があります。
ゲーム的には素晴らしい作り込みですが、台詞をまとめる上では注意が必要です。

DSC_0283

しかも再会話時の台詞は長いものが多いです。
これも上記のオートセーブと同じく、気付かずにストーリーを進めてセーブされると、最初からやり直す羽目になりかねません。

まとめ

そんなわけで、ランパbotの作成日記その2でした!
まだ途中までしか作っていませんが、アカウントを作ってしまった上、完成がいつになるか分からないので、今のレパートリーで試運転してみようと思います。
cron で1日2回、12時間置きにツイートさせるように設定しています。

ひとまず問題なく稼働しているみたいです。
あとはランパの台詞を逃すことなく追加していけば完成すると思われます!