【Unity】タイピングゲームの制作 (文字列の取得・設定)

というわけで、前回からかなり間が空いてしまいましたが、気まぐれと勢いで作っているタイピングゲームの制作日記(その2)です。

前回の記事はこちら

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

20170323_01

今回は「入力対象となる文字列の取得・設定」編です!
前回はスクリプト上で決め打ちだったので、タイピングゲームでも何でもありませんでした。
文字列(言葉)の管理と取得、入力すべきアルファベットの認識までやってみます。

ちなみに Unity とタイトルに入れつつ、Unity っぽいことはあまりやっておりません。
C# のスクリプト作成くらいでしょうか。
どうしても裏側の話題になってしまうので、興味がある方だけお付き合い下さい。

入力対象とする文字列の取得

当たり前ですが、入力対象となる文字列は毎回ランダムに設定する必要があります。
平仮名の50音からランダムで取得して組み合わせても言葉にならないので、どこかで言葉をまとめて管理しないと厳しいです。
どのようなタイピングゲームにするのかにも寄りますが、一般的な単語や文章、ことわざ、四字熟語何かが設定されていることが多いです。
キャラゲーよりのタイピングソフトは台詞なんかも混じっていたりしますね。

で、当初は YAML ファイルに追加していくことを考えましたが、言葉が多くなってくると管理が大変になります。
最低でも「画面への表示名」と「入力する文字列」を設定する必要があります。
また、「単語ごとのカテゴリ」とか「入力数」「難易度」とかも設定できるようにしておくと後々役に立ちそう。

・・・という場合、データベースで管理するのが一番よさそうです。
取得時に条件指定やグルーピングが出来るので、言葉の選定に無駄なロジックを挟まなくてもいけそうですね。

本ブログは「さくらのVPS」上に立てており、WordPress を使用しています。
なので VPS 上の MySQL サーバに専用のデータベースを作成し、言葉管理用のテーブルを作成します。

CREATE TABLE words (
    id integer not null AUTO_INCREMENT PRIMARY KEY,
    target_word varchar(200) not null,
    logical_word varchar(200) not null
) ENGINE=InnoDB;

主キーはないよりあったほうが管理しやすいでしょう。
「target_word」が入力対象とする平仮名、「logical_word」が画面上に表示する言葉の文字列です。
適当に単語を挿入しておきます。

INSERT INTO words VALUES (NULL, 'かんがるー', 'カンガルー');
INSERT INTO words VALUES (NULL, 'きりん', 'キリン');
INSERT INTO words VALUES (NULL, 'うさぎ', 'ウサギ');
INSERT INTO words VALUES (NULL, 'あざらし', 'アザラシ');
INSERT INTO words VALUES (NULL, 'はわいもんくあざらし', 'ハワイモンクアザラシ');
INSERT INTO words VALUES (NULL, 'ちちゅうかいもんくあざらし', 'チチュウカイモンクアザラシ');

MySQL サーバと通信して取得する過程は長いので割愛しますが、「Unity MySQL」とかでぐぐると結構ヒットするので問題ないはず。
自分は「WWW クラスでサーバにリクエストを送り」「PHP から PDO で SQL を実行し」「レスポンスの JSON をパースして保存」という手順で実装しました。
C# 単独で MySQL に接続する dll もあるっぽいので、そのあたりはお好みでよさそうです。

1つハマったこととして、データベースから取得したマルチバイト文字が16進表記になってしまう問題が発生しました。
こういう時は大抵文字コードにブレがあったりするものですが、PDO での接続やデータベース・テーブルは UTF-8、PHP の内部エンコーディングも UTF-8 です。

$dsn = 'mysql:host=host;dbname=dbname;charset=utf8mb4';
$pdo = new PDO($dsn, $this->db_user, $this->db_password);

どうも PDO インスタンス生成時の dsn がよろしくなかったようで、charset に「utf8mb4」を指定したら解決しました。
「utf8」では16進になってしまいます。
また、MySQL をあまり使っていない人は「UTF-8」と指定しがちですが、小文字ハイフンなしの「utf8」が正しい指定です。
dsn に無効な charset を渡すと無視される(と思われる)ので注意しましょう。

あとはひたすら台詞を挿入していくだけなのですが、これが非常に面倒です。
ひとまず動作確認に必要な分だけ突っ込んでいますが、本格的に追加していく際は方法を考える必要がありそうです。
とはいえ「平仮名」と「表示名」で分けて保存する関係上、気合で用意するしかないかもしれません。

実際に入力するアルファベットの表示

これで「表示名」や「入力する文字列」は取得できます。
が、画面上に入力するアルファベットが表示されないのは不親切であり、そのアルファベットをスクリプト側で把握していなければ、入力時のマッチングも行えません。

そこで、平仮名50音と「入力するアルファベット」を対応付ける YAML ファイルを作成します。
単に「入力すべきアルファベットを入れる」の場合、複数通りの打ち方のある文字の制御が厳しくなってしまいます。(「ぁ」は「xa」か「la」など)
後々のことを考えるとアルファベットで直接定義するのは望ましくないです。

また、対応付けのキーとなる平仮名も、そのまま「あ」とか使用するのは避けたいところ。
もし文字コードが異なる環境で動作させた場合、平仮名が文字化けして対応付けが出来ないことになると思われます。
ということで、キーは50音を16進数の文字コードへ変換したものを使います。

実際に作った YAML がこちらです。

E38181: xa,la
E38182: a
E38183: xi,li
E38184: i
E38185: xu,lu
E38186: u
E38187: xe,le
E38188: e
E38189: xo,lo
E3818A: o
E3818B: ka,ca
E3818C: ga
E3818D: ki
E3818E: gi
E3818F: ku,cu
E38190: gu
E38191: ke
E38192: ge
E38193: ko,co
E38194: go
E38195: sa
E38196: za
E38197: si,shi
E38198: zi
E38199: su
E3819A: zu
E3819B: se
E3819C: ze
E3819D: so
E3819E: zo
E3819F: ta
E381A0: da
E381A1: ti
E381A2: di
E381A3: xtu,ltu
E381A4: tu
E381A5: du
E381A6: te
E381A7: de
E381A8: to
E381A9: do
E381AA: na
E381AB: ni
E381AC: nu
E381AD: ne
E381AE: no
E381AF: ha
E381B0: ba
E381B1: pa
E381B2: hi
E381B3: bi
E381B4: pi
E381B5: hu,fu
E381B6: bu
E381B7: pu
E381B8: he
E381B9: be
E381BA: pe
E381BB: ho
E381BC: bo
E381BD: po
E381BE: ma
E381BF: mi
E38280: mu
E38281: me
E38282: mo
E38283: xya,lya
E38284: ya
E38285: xyu,lyu
E38286: yu
E38287: xyo,lyo
E38288: yo
E38289: ra
E3828A: ri
E3828B: ru
E3828C: re
E3828D: ro
E3828E: xwa
E3828F: wa
E38293: nn
E383BC: '-'

50音を濁音・小文字込みで網羅し、伸ばし用のハイフンを入れたマッピングファイルです。
カンマ区切りのものは複数通りの入力が可能なものです。

データベースから取得した平仮名を1文字ずつ判定し、上のマッピングファイルに従って「どのアルファベットを打つか」を設定していきます。
平仮名を16進へ変換する必要があるので、以下のようなユーティリティクラスを作成してみました。

using System;
using System.Collections.Generic;
using System.Text;

public class EncodeUtility {

    /// <summary>
    /// 指定した文字列を16進数コードに変換し、文字ごとに配列へ格納して返す
    /// </summary>
    public static List<string> convertHex(string text) {
        List<string> hexes = new List<string>();

        foreach (char c in text) {
            // 入力文字列のバイトコード取得
            byte[] text_bytes = Encoding.UTF8.GetBytes(c.ToString());

            // バイトコードを16進数に変換
            string hex = BitConverter.ToString(text_bytes);
            hex = hex.Replace("-", "");

            hexes.Add(hex);
        }

        return hexes;
    }
}

平仮名1文字ずつが List で返ってくるので、マッピングファイルから入力するアルファベットを判定していきます。
(長くなるので関数だけ記載します)

/// <summary>
/// 入力対象の16進コード配列をアルファベット文字列に変換する
/// 候補がカンマ区切りで複数ある場合、最初のアルファベットを設定する
/// </summary>
public string getInputAlphabet(List<string> hexes) {
    string alphabet = "";

    foreach (string hex in hexes) {
        // マッピングから16進に一致する値を取得
        string mapping_value = alphabetMapping.getValue(hex);

        // 候補が複数ある場合は最初のアルファベットを取得
        int separate_index = mapping_value.IndexOf(SEPARATER);
        if (0 < separate_index) {
            string value = mapping_value.Substring(separate_index);
            alphabet += mapping_value.Replace(value, "");
        } else {
            alphabet +=  mapping_value;
        }
    }

    return alphabet;
}

かなり端折っていますが、alphabetMapping は List 型で、yaml からパースしたマッピングのキーと値を管理します。
YAML に関するあれこれは以下の記事で書いているので、そちらをご参照下さい。

【Unity】メッセージやパラメータをYAMLファイルで定義・参照する

マッピングの List から16進文字コードを元にアルファベットを取得、IndexOf() でセパレータごとに区切り、最初の値を設定します。
あとはアルファベットを全部結合したものを返すだけです。

これでアルファベットの設定はできますが、複数通りの入力への対応や、小文字が入る場合の入力方法 (「しゃ」なら「sya」「sixya」など) が出来ていないので、完全とは言い難い代物です。
そのあたりの解決がタイピングソフト実装の最難関かなーと思っています。

まとめ

かなり適当な記事になってしまいましたが、文字列の取得と設定について考えてみました。
今回までで、「入力欄生成」「入力文字のマッチング」「文字列の設定・表示」まで完成しています。
細かい部分はさておき、やっとそれっぽくなってきました!

次回はゲームっぽく、プレイヤーキャラクターと敵キャラクターの制御あたりを作ろうかと考えています。
単に言葉だけを打つなら代わりはいくらでもあるので、「ゲームにする」のは重要ですね。

【Unity】InputFieldにフォーカスした際の全選択を無効化する方法

というわけで、今回は UI の InputField に関するお話です!
InputField にフォーカスした際、全選択状態になってしまうのを何とかする方法について調査してみました!

20170304_01

Unity5.5 では、ActiveInputField() でフォーカスすると起こるようです。
プレイヤーにクリックさせる想定ならともかく、キーボード入力時に全選択状態でフォーカスされても面倒です。
当然ですが、全選択状態のまま入力すると、入力されいた文字が上書きされてしまいます。

結論から言うと、以下のどちらかの方法でいけるようです。
・InputField を継承したクラスで LateUpdate() をオーバーライドし、MoveTextEnd() を実行する
・コルーチン等でフォーカス後のフレームで MoveTextEnd() を実行する

後述しますが、ActiveInputField() 後に MoveTextEnd() を呼んでも上手くいきません。
何れも「フォーカス後に間を置いてから実行する」のがキモのようです。

原因

BitBucket の下記URL に InputField クラスの中身が載っています。

https://bitbucket.org/Unity-Technologies/ui/src/0155c39e05ca5d7dcc97d9974256ef83bc122586/UnityEngine.UI/UI/Core/InputField.cs

それによると、onFocus() 時に SelectAll() が呼ばれているようです。
条件分岐もしていないようなので、設定でどうにか出来るものでもなさそうです。

また上でも触れましたが、以下のようなコードでは解除されません。

input_field = this.GetComponent<InputField>();
input_field.ActivateInputField();
input_field.MoveTextEnd(false);

どうやら同一フレームではラベルのアップデートの関係(?)で動作しないようです。
さくっとスクリプトで制御できるかと思いきや、思わぬ苦戦を強いられることになりました。

解決法

解除自体は MoveTextEnd() を実行するだけで良いので、その実行タイミングの問題のようです。
海外のフォーラムで同じような質問がいくつかありましたが、方法としては「InputFieldを継承したクラスでオーバーライドして実行」か「コルーチン等でフォーカス後のフレームに実行する」のどちらかが多かったです。
自分は前者の「継承してオーバーライド」でやってみました!

using UnityEngine.UI;

public class InputFieldCustom : InputField {
    protected override void LateUpdate() {
        base.LateUpdate();

        MoveTextEnd(false);
    }
}

LateUpdate() をオーバーライドし、スーパークラスを呼び出した後に MoveTextEnd() を実行すれば良いらしいです。
あれこれ処理した後に MoveTextEnd() で選択状態を解除するイメージでしょうか。

その他、コードを見る限りでは OnFocas() をオーバーライドし、SelectAll() が呼ばれないようにしても上手くいきそうな気がします。
試していないので分かりませんが、何れも継承するやり方なら簡単に解決できます。

まとめ

そんなわけで、今回は InputField のフォーカス時の挙動について調査してみました!
UnityEngine のクラスを継承したことはなかったので、今後も選択肢の1つとして覚えておきたいです。

毎回書いたスクリプトを全部載せていましたが、今回からポイントを絞って紹介しようかと思います。
面白みのないコードを載せても記事が長くなってしまうだけなので・・・。
そろそろブログのデザインも作り直そうかと考えているので、しばらく試行錯誤しながら書いてみます。

【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

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

まとめ

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

【開発メモ】敵キャラクターとロジックの作成に関する考察 その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~にするなど)、名前空間を指定するなどでカバーします。

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

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

まとめ

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

あたりでしょうか!

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