ぐーるらいふ

底辺。

【Unity】第11回unity1week「つながる」オブジェクトプールについて

またunity1weekが開催されましたね。
これで11回目になります。すべて参加してまして、
「じょじょにUnityに慣れてきてるんだなぁ、ちょっとずつだけど出来る事が広がっているな」
と自分なりに感じる結果となっています。

f:id:ghoul_life:20190319214902p:plain:w150
(今回作ったアイコン。文字はあれですが、ユニティちゃんはちょっと力作です。)

サイドバーに今までunity1weekで作ったアプリを表示してますので、
良かったら…やっぱ見ないでいいです。

初期のものはコードも酷いし、unityわかってないし、絵も下手くそですごく恥ずかしいですが
恥を忍んで、公開したままにしてます。成長の証って事で…。

お題について

お題もそうなんですが、実は毎回自分に対して
「今回は○○について勉強すること」
という題目を決めて参加してます。

今回は「オブジェクトプーリング」「負荷対策」について
主に取り上げてみようかなと漠然と考えてました。

事件がおこる

なんだこれ。けもみみおーこくすげーな。って事件です。
この辺りではまだ勉強段階で、
「こうやったらこんなこと出来るのかなぁ」
という推測を元にスクラップ&ビルドを繰り返してました。

で、これをゲームにしようと思いました。

つながる要素

前回の最大の反省点は「難しすぎた事」でした。
僕は格闘ゲームとかもプレイしてるのですが、
基本的に「連打をしない」ということが染み付いていまして、
どんなゲームでもリズムよく(タイミングよくポンポンポンと)
押すのが普通になってしまっていました。

ぱふもどきさんの放送を見てたのですが、
凄い連打してたり、位置取りとかあんまり気にしてなかったりと
「ああ、これが普通の人だ。自分が間違っていた…」
とちょっとショックを受けました。

その反省を活かそうと、今回はなるべく複雑な操作、
システムは止めようと漠然と考えていて
コンボが繋がるとスコアがどんどん伸びていく感じがわかりやすいだろう
とシンプルな要素に落ち着かせました。

破壊について

ボロノイ図」とかでググってみてね。
voronoiのアルゴリズムを利用して作ってます。
自分なりにやっちゃってるので、なんちゃってボロノイかもしれませんので割愛。

最初のランダム点配置は Random.insideUnitSphere でやっちゃってます。
これオンラインで同期する場合はRandom.seedの共有も必要になるんだろうな…。

f:id:ghoul_life:20190320230648p:plain

オブジェクトプールと破片について

一ブロックにつき、10~20の破片にランダムで分解されるような仕様にしています。
この各破片は一つ一つがGameObjectになってます。
Rigidbodyで物理演算の影響も受けないとならないため、別々なオブジェクトにしないとなりませんでした。
そして以前から告知している通り、随時動的に計算して作っています。
単純に毎回Instantiateするとプチフリーズの嵐になってしまうので以下のようにプールを作りました。

PartsPool.cs

public void InitializePool()
{
    if (IsPopulated && parts.Length == maxPoolSize) return;

    // Clear old shard pool if one exists.
    if (parts != null)
    {
        foreach (var part in parts)
	{
	    if (part == null) continue;
	    DestroyImmediate(part.gameObject);
	}
    }

    parts = new Part[maxPoolSize];

    for (int i = 0; i < maxPoolSize; i++)
    {
	parts[i] = new GameObject("Part").AddComponent<Part>();
	parts[i].transform.parent = transform;
    }
}

このプールは破片プールです。
各破片はここにあるGameObjectを使いまわします。
800個から1000個ほど生成しておいていました。

Part.cs

void Initialize()
{
    if (GetComponent<Renderer>() == null) gameObject.AddComponent<MeshRenderer>();
    if (GetComponent<Rigidbody>() == null) gameObject.AddComponent<Rigidbody>();

    meshFilter = GetComponent<MeshFilter>() == null ? gameObject.AddComponent<MeshFilter>() : GetComponent<MeshFilter>();
    meshCollider = GetComponent<MeshCollider>() == null ? gameObject.AddComponent<MeshCollider>() : GetComponent<MeshCollider>();
            
    meshCollider.convex = true;
    meshFilter.sharedMesh = new Mesh();
    IsUse = false;
    gameObject.SetActive(false);
}

破片はゲーム内でInstatiateせず、Inspector上で800個用意して、Activeを切っておきます。
また各種必要なコンポーネントを変数に持っておきアクセスを容易にしときます。

Part.cs

public void Use(GameObject parent, Vector3[] newVertices, Vector3[] newNormals, int[] newTriangles, Vector2[] newUVs)
{
    part.name = "Fractured Parts";
    var mesh = new Mesh();
    part.Mesh = mesh;
    part.Mesh.vertices = newVertices;
    part.Mesh.normals = newNormals;
    part.Mesh.uv = newUVs;
    part.Mesh.triangles = newTriangles;
    part.meshCollider.sharedMesh = part.Mesh;

    part.gameObject.SetActive(true);
    part.IsUse = true;

    part.GetComponent<Renderer>().material = parent.GetComponent<Renderer>().material;

    if (parent.GetComponent<Rigidbody>())
    {
        part.GetComponent<Rigidbody>().mass = parent.GetComponent<Rigidbody>().mass;
        part.GetComponent<Rigidbody>().velocity = parent.GetComponent<Rigidbody>().velocity;
    }
}


使う時にActive化して、位置、大きさ、Meshをセットしてあげます。
また、この破片はparentとなる元オブジェクトが管理しており、
設定した時間後に削除されます。この削除は非アクティブ状態にして、Poolに戻るだけにしておきます。

PartsPool.cs

public static void BackPool(GameObject obj , float waitTime)
{
        var part = obj.GetComponent<Part>();
        if (part != null)
        {
            pool.StartCoroutine(pool._BackPool(part , waitTime));
        }
}

private System.Collections.IEnumerator _BackPool(Part part, float waitTime)
{
        yield return new WaitForSeconds(waitTime);

        part.IsUse = false;
        part.gameObject.SetActive(false);
        part.transform.parent = transform;
}

(一定時間後に削除する、というのをCoroutineで簡易的に実装してます。)

こうすることによって、poolingを使い回すことが可能です。
これでゲーム中はInstatiateとDestroyを使わずに進めることが可能です。
(IsUseがfalseのものをプールから取ってくればOKですね)

Effect , PopupScoreなども同様にInGame中にはInstantiateせず、Active切り替えで使いまわしてます。

CameraPlay

assetstore.unity.com

これ知らなかったんですが、こんな凄いAssetがあるとは…。
有料なんですが、カメラ演出は効果が大きいので、コストに見合う効果が出ます。

画面を揺らす、集中線を出すといったことが1行でかけます。
便利すぎる。最初はやりまくってたんですが、気持ち悪くならない程度にちょっと抑えました。

AudioManager

同時に複数のオブジェクトを破壊するので、音も大量に一度に鳴ります。
AudioSourceは最終的に10個用意していて、その中で使ってないAudioSourceを探して使うようにしてます。
15でも良かったかも。

NCMB

バカやって変な不具合出してた。別記事にまとめたので、そちらをどうぞ
自分のせいです。これもうちょっと作る必要あるなぁ…。通信中表示とかまだ甘い。

ghoul-life.hatenablog.com

その他色々

最後はほんとに不具合直すのと、違和感を削る作業(調整とも言う)に忙殺されますね。

ゲームプレイ > ここ変だな、これ欲しいな > 直す、作る

これを繰り返しまくる。土日はほとんどこれ。
ここでポップアップやらスコア動かしたり操作説明付けたりなど。
この辺りが今まで甘かったのだけど、少しずつ計算に入れて作業を進めることが
出来るようになってきたなと思う。

お絵かき

前回はバリバリ描きまくりで疲れましたが、
今回はほとんど要らなかったのでアイコンしか描けず。
文字は酷いので外したverを。

f:id:ghoul_life:20190320234606p:plain:w150

割とそれっぽく描けたかな…と思います。リリースギリギリに滑り込み。
元ネタは「何でも言うことを聞いてくれるアカネちゃん」です。

反省点

  • ステージとかもう一つぐらい作れば良かった
  • ボムアイテム作りたかった(どうしてもエフェクトが間に合わず…Blenderに慣れないと…)
  • 敵とか居たらもっと良かったかも。むしろ街中で怪獣とユニティちゃんが戦うゲームにするのはどうだろうか
  • 甘城ブリリアントパークが面白すぎた
  • SEKIROが楽しみすぎてそれどころじゃなかった
  • WebGLの日本語入力これか。時間があったらやってみよう

tsubakit1.hateblo.jp

次回

もちろん参加します。
今回はお絵描きがほとんど出来なかったので、
次はノベル要素強めとかやりたいなと思ってます。
xNode使ってエディタ拡張の勉強かなー。

【Unity】 NCMB 405 エラー Method not Allowed 【オンラインランキング】

お疲れ様です。ぐーるです。unity1weekお疲れ様でした。
その記事を書いているのですが、その前に一個メモしておきたいことが。
お恥ずかしいミスなのですが…。

NCMBって何?

クラウド上に用意された機能をAPIで使用するだけでサーバ開発、運用不要で
バックエンドサービスを利用することが出来るサービスのことをmBaasと言いまして、
niftyが提供しているmBaasを

nifty cloud mobile backend == NCMB

と呼んでいるようです。まぁ楽にサーバ機能を使うことが出来るものです。
unity SDKも用意されていて、簡単に使うことが出来ます。
使い方はググると公式もありますし、沢山記事もあるので割愛します。

mbaas.nifcloud.com

mbaas.nifcloud.com

Method not allowed????

NCMBを利用してデータをテーブルに追加する時に以下のようなコードを書きます。

    public void TestSend(string objectId , string name , int score)
    {
        NCMBObject obj = new NCMBObject("TestTable");
        obj.ObjectId = objectId;
        obj["name"] = name;
        obj["score"] = score;

        obj.SaveAsync((NCMBException e) =>
        {
            if (e == null)
            {
                Debug.Log("success");
            }
            else
            {
                //エラー処理
                Debug.LogError(e);
            }
        });
    }

このSaveAsyncで以下のエラーが出てしまいました。

【url】:https://mb.api.cloud.nifty.com/xxxxxxxxxxxxxxx/TestTable/
【type】:PUT
【content】:{"name":"test user name","score":8307}

【StatusCode】:405
【Error】:NCMB.NCMBException: Method not allowed.
【ResponseData】:

これなんだろう?と。

原因

NCMBを利用してデータを入れる際は以下のように判定されます。

obj.ObjectId = objectId; <- ここ

obj.ObjectId [ null ] == 新規レコードを追加 
obj.ObjectId [ (存在しているObjectId) ] == 該当レコードを更新

となります。
ちなみに、新規レコード追加した際にobj.ObjectIdに値が入って返ってくるため、
その値を利用して次回以降は更新することが出来ます。

ここが以下の値だとエラーになります

obj.ObjectId [ (存在していないObjectId) ] == 404エラー No data available.

【url】:https://mb.api.cloud.nifty.com/xxxxxxxxxxxxxx/TestTable/aaaaa
【type】:PUT
【content】:{"name":"name","score":2595}
UnityEngine.Debug:Log(Object)

【StatusCode】:404
【Error】:NCMB.NCMBException: No data available.
【ResponseData】:
UnityEngine.Debug:Log(Object)
obj.ObjectId [ "" ] == 405エラー Method not allowed.

【url】:https://mb.api.cloud.nifty.com/xxxxxxxxxxxx/TestTable/
【type】:PUT
【content】:{"name":"test user name","score":8307}

【StatusCode】:405
【Error】:NCMB.NCMBException: Method not allowed.
【ResponseData】:

となります。つまりObjectIdが""だと、エラーになってしまう…と。

反省

NCMBを利用する時に、簡単に考えると
「ObjectIdはPrayerPrefsにでも保存しとくか」
と考えてこんなコードを書いてしまいました。

obj.ObjectId = PlayerPrefs.getString("NCMB_ObjectId");

これで""になってしまってたのが原因…。はずかしー。

ちゃんとデフォルト値を入れとけって事ですね。反省。

obj.ObjectId = PlayerPrefs.GetString("NCMB_ObjectId" , null);

【Unity】xNodeの使い方 初心者向け

お疲れ様です。ぐーるです。
ビルの件の高速化の記事を進めているのですが、めっちゃ長いので、気長にちまちま加筆してます。

というわけで気分転換でxNodeについて書きます。
普段真っ黒いコンソールしか見てない自分としては
ビジュアルスクリプティングというのに興味がありまして、
Playmakerとか触ってみたいなと思ってたんですが、有料なのかぁと二の足を踏んでいました。

先日勉強会でxNodeってのがあるという話を聞きまして、早速触ってみました。
初心者向けですごーく初歩的な内容ですがメモとしてまとめておこうと思います。

xNodeの導入

xNodeはAssetStoreにもありますが、githubで公開されています。
github.com

cloneしても良いですが、unitypackageが公開されているので、それを利用しました。
https://github.com/Siccity/xNode/releases

xNode_1.6.unitypackage

をDLして、unityのプロジェクトで

Assets > Import Package > Custom Package

でimportします。

xNodeの最もシンプルな構成

シンプルに考えるとxNodeは二つのクラスしかありません。

f:id:ghoul_life:20190304173735p:plain

NodeとNodeGraphです。

XNode.Node

これは単一のノードを指します。
f:id:ghoul_life:20190304184225p:plain
値を持ったり、値によって振る舞いを変えたりといった事が出来ます。

XNode.NodeGraph

これはNodeを管理するScriptableObjectを作れるクラスです。

f:id:ghoul_life:20190304185452p:plain

このようにNodeを複数グループ管理するイメージです。
内部ではnodesというリストを持っていて、ここで各ノードを保持しています。

早速作ってみる

今回はNovelゲームを題材にして、そのNovelゲームの会話処理をノード化し、
会話の流れをビジュアル的に捉えやすくするイメージで作ってみます。

NodeGraphの作成

まずxNodeのウィンドウを出すため、NodeGraphを作成します。

using UnityEngine;
using XNode;

[CreateAssetMenu(fileName = "NovelGraph", menuName = "Node Graph/NovelGraph")]
public class NovelGraph : NodeGraph { 
}

menuNameなどは適宜変えてください。
間違いでもなんでもなく、本当にこれだけでOKです。
最小限で良ければ何にも要らないです。

Nodeの作成

ノードを作成します。

f:id:ghoul_life:20190304190706p:plain

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

public class NovelNode : Node {

    public Sprite _novelSprite;

    [TextArea(3, 5)]
    public List<string> _messageList;

    [Input] public NovelNode _beforeNode;
    [Output] public NovelNode _nextNode;
}

表示したい立ち絵スプライト画像と文字列リストを持ちます。
ノード同士を連結させるために前と後ろの参照を持っておきます。
[Input]というAttributeを付与するとノード連結を受け入れることが出来、
[Output]なら逆にノード連結を出力することが出来ます。

[Output] -> [Input]

の関連になります。
f:id:ghoul_life:20190304193526p:plain
コードはこれで準備完了です。

NodeGraphとノードエディタ

f:id:ghoul_life:20190304214330p:plain
(他も見えますが、気にせず)

Project > Create > Node Graph > NovelGraph

でScriptableObjectを生成し、それをダブルクリックします。
f:id:ghoul_life:20190304215104p:plain
xNodeエディタウィンドウが開くので、右クリックでNodeを追加出来ます。
f:id:ghoul_life:20190304215326p:plain

後は好きな値を入れます。
[Input][Output]を繋げる時は直感的にドラッグすればOKです。
f:id:ghoul_life:20190304215912p:plain

NodeGraphの使い方

使う側は簡単です。

public class NovelGraphController : MonoBehaviour
{
    [SerializeField] NovelGraph _novelGraph;
    private int _nodeIndex = 0;
    // ~~
    // nodesに入っているのでそこからアクセス出来ます
    var novelNode = _novelGraph.nodes[_nodeIndex] as NovelNode;

}

こんな感じで自由にアクセス出来ます。
NovelGraphには上で作成したScriptableObjectを紐づければOKです。

ノベルゲーム風に使ってみた

こんな感じでデータを入れて

f:id:ghoul_life:20190304172553p:plain

こんな感じに会話が進む感じにしてみました。

f:id:ghoul_life:20190304222134g:plain

TextFaderというライブラリをちょっとお借りしてカスタムして使用してます。
会話の流れがビジュアル的に捉えやすいですね。
会話の入れ替えやデータのミスの把握などが直感的に行えそうです。

baba-s.hatenablog.com

【Unity】ビル群をランダム生成する

お疲れ様です。ぐーるです。
また間が空いてしまった…。

unity1weekがもうすぐあるので、Unity思い出さないとなぁ~と触っています。

ビル群を自動生成する

こんなにRT&いいねされたのは初。

正直大したことはしていなくて、とっても恐縮。
ランダムでscale決めて、空いている所に配置しているだけです。

早速コード

BuildingMapCreater

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

// ビル一棟ごとのデータ(位置と大きさだけ)
public class BuildingMapObjectData
{
    public int id;
    public Vector3Int position;
    public Vector3Int scale;
}

public class BuildingMapCreater
{

    // もっとマップを広くしたい場合は大きくすればOK
    private int MAP_SIZE_W = 100;
    private int MAP_SIZE_H = 100;

    private int[][] _maps;
    private static BuildingMapCreater _instance;

    private BuildingMapCreater() { }

    public static BuildingMapCreater Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new BuildingMapCreater();
            }
            return _instance;
        }
    }

    // ビル群のマップを自動で生成する
    public List<BuildingMapObjectData> CreateMap()
    {
        var result = new List<BuildingMapObjectData>();

        
        var i = 0;
        var j = 0;
	var id = 1;
	BuildingMapObjectData mo = null;

	// 初期化(-1)埋め
        _maps= new int[MAP_SIZE_H][];
        for (i = 0; i < MAP_SIZE_H; i++)
        {
            _maps[i] = new int[MAP_SIZE_W];
            for (j = 0; j < MAP_SIZE_W; j++)
            {
                _maps[i][j] = -1;
            }
        }

        i = 0;
        j = 0;
        while (true)
        {
            mo = new BuildingMapObjectData();
            mo.id = id++;

            mo.position = Vector3Int.zero;

	    // ビルの大きさをランダムで適当に
            mo.scale.x = Random.Range(1, 20);
            mo.scale.y = Random.Range(1, 20);
            mo.scale.z = Random.Range(1, 20);

            if (!ExecBuild(mo))
            {
                break;
            }

            result.Add(mo);
        }

        return result;
    }

    // 範囲内に他のビルが重なっていないかチェックする
    private bool ExecBuild(BuildingMapObjectData mo)
    {
        while (true)
        {
            if (IsEmptyMapRect(mo.position.x - 1, mo.position.z - 1, mo.scale.x + 2, mo.scale.z + 2))
            {
                PaintMapRect(0, mo.position.x - 1, mo.position.z - 1, mo.scale.x + 2, mo.scale.z + 2);
                PaintMapRect(mo.id, mo.position.x, mo.position.z, mo.scale.x, mo.scale.z);
                return true;
            }
            else
            {
                if (mo.position.x < MAP_SIZE_W)
                {
                    mo.position.x += 1;
                }
                else if (mo.position.z < MAP_SIZE_H)
                {
                    mo.position.z += 1;
                    mo.position.x = 0;
                }
                else
                {
                    return false;
                }
            }
        }

    }

    // 指定範囲を塗りつぶす
    private void PaintMapRect(int id, int x, int y, int w, int h)
    {
        for (var yy = y; yy < y + h; yy++)
        {
            for (var xx = x; xx < x + w; xx++)
            {
                PaintMap(id, xx, yy);
            }
        }
    }

    // 指定範囲が空いているか調べる
    private bool IsEmptyMapRect(int x, int y, int w, int h)
    {
        for (var yy = y; yy < y + h; yy++)
        {
            for (var xx = x; xx < x + w; xx++)
            {
                if (!IsEmptyMap(xx, yy))
                {
                    return false;
                }
            }
        }
        return true;
    }

    // 指定位置をIDで塗る
    private void PaintMap(int id, int xx, int yy)
    {
        if (yy >= 0 && yy < _maps.Length &&
        xx >= 0 && xx < _maps[yy].Length)
        {
            _maps[yy][xx] = id;
        }
    }

    // 指定位置が空いているかチェック
    private bool IsEmptyMap(int xx, int yy)
    {
        if (yy >= 0 && yy < _maps.Length &&
        xx >= 0 && xx < _maps[yy].Length)
        {
            if (_maps[yy][xx] > 0)
            {
                return false;
            }
            return true;
        }
        return false;
    }

    // デバッグ用出力
    private void DebugOutput()
    {
        for (var i = 0; i < MAP_SIZE_H; i++)
        {
            string output = "";
            for (var j = 0; j < MAP_SIZE_W; j++)
            {
                output += "[" + _maps[i][j] + "]";
            }
            Debug.Log(output);
        }
    }
}

Stage

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

public class Stage : MonoBehaviour
{

    [SerializeField] Building _buildingPrefab;
    private List<Building> _mapObjects;

    public void CreateMap()
    {
        StartCoroutine(_CreateMap());
    }

	
    private IEnumerator _CreateMap()
    {
        if (_mapObjects != null)
        {
            foreach (var m in _mapObjects)
            {
                Destroy(m.gameObject);
            }
            _mapObjects.Clear();
            _mapObjects = null;
        }
        _mapObjects = new List<Building>();
        var mapObjectDatas = BuildingMapCreater.Instance.CreateMap();
        foreach (var mod in mapObjectDatas)
        {
            var fs = Instantiate(_buildingPrefab, this.transform);
            fs.SettingBuildingMapObjectData(mod);
            _mapObjects.Add(fs);
            yield return new WaitForEndOfFrame();
        }
    }
}

Building

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

public class Building : MonoBehaviour
{
    [SerializeField] int _id;
    private bool _isInitialize = false;
    private Vector3 _targetPosition = Vector3.zero;
    private float INIT_TIME = 10.0f;

    // Update is called once per frame
    void Update()
    {
        if (_isInitialize)
        {
            if (Vector3.Distance(this.transform.position, _targetPosition) > 0.01f)
            {
                this.transform.position = Vector3.MoveTowards(this.transform.position, _targetPosition, INIT_TIME * Time.deltaTime);
            }
            else
            {
                this.transform.position = _targetPosition;
                _isInitialize = false;
            }
        }
    }

    public void SettingBuildingMapObjectData(BuildingMapObjectData mapObjectData)
    {
	// dataの位置ではpivot(0,0)で計算されているため、pivot(0.5,0.5)で位置調整
        var x = mapObjectData.position.x + (mapObjectData.scale.x / 2);
        var y = (mapObjectData.scale.y + 1) / 2; // 高さはスケールに合わせる
        var z = mapObjectData.position.z + (mapObjectData.scale.z / 2);
        _id= mapObjectData.id;
        _targetPosition = new Vector3(x, y, z);

	// 最初沈ませておいて、下から迫り上がるような演出をする
        this.transform.position = new Vector3(_targetPosition.x, _targetPosition.y - mapObjectData.scale.y, _targetPosition.z);
        this.transform.localScale = mapObjectData.scale;

	_isInitialize = true;
    }
}

ビル群を生成するイメージ

まず、ランダムでビルという名のただのcubeの大きさを適当に決めます。
その後配置するのですが、この時

  • ビル同士が被らない
  • ビルとビルの間に1マス以上の隙間を空ける

この2つを実現させます。

高さは被らないため、除外して考えるので、単純に2次元配列で管理します。

0,0の位置から大きさ分の四角形が入る位置を探します。
この時、-1 , + 2をして道路分を空けるようにします。
(この辺の値を大きくするとさらに道路を広く出来ます。
また、この列や行はビル配置禁止!といった値を決めておくと大通りを作ることが出来ます)

if (IsEmptyMapRect(mo.position.x - 1, mo.position.z - 1, mo.scale.x + 2, mo.scale.z + 2))
{
    PaintMapRect(0, mo.position.x - 1, mo.position.z - 1, mo.scale.x + 2, mo.scale.z + 2);
    PaintMapRect(mo.id, mo.position.x, mo.position.z, mo.scale.x, mo.scale.z);
    return true;
}

入らなければ一マス動かす、で入る位置を探します。

入ったらその位置にIDを書き込んでおき、すでにここにはビルがありますよ、
という情報を残しておきます。

f:id:ghoul_life:20190227194818p:plain

これを繰り返して、MAP内に入り切らなくなったら終了です。

余談

記事書いてて思ったのですが、HITしたらIDからビル情報取ってきて、その大きさ足したほうが良かった

生成したビル情報使う

ビル情報のスケールはそのまま入れますが、positionはpivotの関係上、計算が必要です。

// dataの位置ではpivot(0,0)で計算されているため、pivot(0.5,0.5)で位置調整
var x = mapObjectData.position.x + (mapObjectData.scale.x / 2);
var y = (mapObjectData.scale.y + 1) / 2; // 高さはスケールに合わせる
var z = mapObjectData.position.z + (mapObjectData.scale.z / 2);

プロジェクトとして

  1. 空のGameObjectを生成して、StageスクリプトをAdd Component
  2. CubeをInspectorに生成して、BuildingスクリプトをAdd Componentして、Prefab化
  3. 作ったCubeをStageに紐づけ
  4. 実行して、Stage.CreateMap()をコールすればOK

おまけ

なんとなくビルが下からニュッと生える感じにしたかったので、
目標位置と開始位置を変えて、目標位置に到達するまで移動するようにしてます。
上から降ってくる感じとかEasingとか付けるともっと賑やかになります。
(DoTweenとか使うともっと簡単ですね)

f:id:ghoul_life:20190227180855g:plain

最後に

蓋を開けてみたらとんでもなく初歩的なスクリプト
ほんとこんなんでバズって良いんだろうかという気になってます。
実はみんなFracture側の記事を期待してたりして。

【Unity】Android Nativeプラグイン開発 最小構成でなるべくわかりやすくまとめた

Androidでネイティブプラグイン

Androidでネイティブプラグイン開発を行う時の作業手順をまとめてメモしておきます。

環境

使用した環境は以下になります。

そして、最小構成で作成します。
aarを使わず、より不要な物をそぎ落としたjarで組み込みます。

なるべく処理に不要な物は排除し、本当に処理を行うのに必要なものは
何なのか?という事に着目してまとめてます。
なるべく、わかりやすく。Step By Stepで。

Android Studioを使ってUnityプラグインを作ろう

手順1 プロジェクトを作成

Android Studioを立ち上げ、新規プロジェクトを「Add No Activity」で作成します。

f:id:ghoul_life:20190126020326p:plain:w300
f:id:ghoul_life:20190126020559p:plain:w300

手順2 ライブラリモジュールを作成

プロジェクトが出来たら、ライブラリモジュールを作成します。

Android Studioの上部メニューバーからFile > New > New Module
そして、「Android Library」を選択して作成します。

f:id:ghoul_life:20190126020926p:plain
f:id:ghoul_life:20190126021255p:plain:w300
f:id:ghoul_life:20190126021041p:plain:w300

手順3 必要ないファイルの削除

ライブラリモジュール以外は必要ないため、
作成したプロジェクトフォルダを開き、最初に作られたappを削除します。

f:id:ghoul_life:20190126021732p:plain:w300

そしてsetting.gradleからもappを消します。

include ':app' ':unitypluginsamplelibrary'
->
include ':unitypluginsamplelibrary'

f:id:ghoul_life:20190126021919p:plain:w300

手順4 Unityクラスライブラリの追加

AndroidでNative連携Pluginを作る時、AndroidからUnityの機能を呼びたい事があります。
そういった機能をAndroidから使えるように、Unityが用意しているAndroid用クラスライブラリをPlugin側に組み込む必要があります。
例えば、Unity HubでUnity2018.3.2f1の場合はここにあります。

C:\Program Files\Unity\Hub\Editor\2018.3.2f1\Editor\Data\PlaybackEngines\AndroidPlayer\Variations\mono\Release\Classes

これをモジュールのlibsの中にコピーして配置します。

f:id:ghoul_life:20190126022258p:plain:w300

手順5 ビルドスクリプトの修正

ビルドスクリプト(モジュールの直下にあるbuild.gradle)を以下のように整理修正します。
(compileSdkVersionなどは各環境に合わせて下さい)

apply plugin: 'com.android.library'

android {
    compileSdkVersion 27

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 27
        versionCode 1
        versionName "1.0"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

}

dependencies {
    compileOnly fileTree(dir: 'libs', include: 'classes.jar')
}

// buildしたjarをカレントに持ってきてリネーム
task createJarFile(type: Copy) {
    // 環境によって出力先がよく変わるみたい?intermediates-jarsとかもあるらしい
    from('build/intermediates/packaged-classes/release/')
    into('.')
    include('classes.jar')
    rename('classes.jar', 'UnityPluginSample.jar')
}
createJarFile.dependsOn(build)

新規追加したタスク「createJarFile」はUnityに組み込むjarをビルド後に抜き出し、リネームするタスクです。こういうのを作ると楽になります。

実はこの辺りでちょっと困ったことがあったので、その件についてまとめました。

ghoul-life.hatenablog.com

aarを使わないこの記事の方針では実はここまでしなくても良かったりします。
興味があれば一読してみてください。

手順6 configurationの設定

Android StudioからcreateJarFileタスクを実行出来るように構成を設定します。
Android StudioのEdit ConfigurationsからGradleを選択して、以下を参考に設定してください。

+ボタン > Add New Configuration > Gradle
Name : 自由に
Gradle Project : プロジェクトフォルダを選択
Tasks : createJarFileと入力

f:id:ghoul_life:20190126023556p:plain:w300
f:id:ghoul_life:20190126023708p:plain:w300

手順7 テスト用ファイルの削除

本来は必要であるべきと思われるのですが、最小構成なので、ここでは省きます。
モジュール作成時に自動で追加されるTestコードを削除します。

f:id:ghoul_life:20190126024030p:plain:w300

手順8 実際に使用するコードを記述する

ここまで来てようやっとコードがかけます。長い。
ソースファイルを新規追加し、以下のようにコードを記述します。
手順4でライブラリを正しくlibsの下に配置していて、手順5でgradleの設定が出来ていれば
com.unity3d.playerパッケージが使えるようになるはずで、エラーが出ないと思われます。
(エラーが出るときはその辺りを見直すとよい)

package com.example.unitypluginsamplelibrary;

import com.unity3d.player.UnityPlayer;
import java.util.Random;

public class HelloAndroidNativePlugin {

    public static void Execute()
    {
        Random r = new Random();
        UnityPlayer.UnitySendMessage("AndroidNativeManager" , "FromAndroid" , "Hello Unity Android Plugin. Rand." + r.nextInt());
    }
}

手順9 ビルドしてJarを作成する

Build VariantsをReleaseにして、ConfigureをcreateJarFileに合わせ、RunすればOKです。
BUILD SUCCESSと出れば、正常にプラグインが作成出来ています。

f:id:ghoul_life:20190126024718p:plain:w300

これでAndroid側の作業は完了です。

UnityでAndroid Pluginを組み込む

こちらはぐっと簡単です。

手順1 作成したプラグインを配置

Assets/Plugins/Android

以下に配置します。上の手順ならjarファイルをそこにポンと置けばOKです。

f:id:ghoul_life:20190126025305p:plain

手順2 プラグインの機能を使用するスクリプトを作成

組み込んだプラグインの機能を使うスクリプトを記述します。
AndroidJavaClassでクラスを指定し、Callで呼びたい関数を指定すればOKです。

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

public class AndroidNativeManager : MonoBehaviour
{
    [SerializeField] Text _androidMessageText;

    public static readonly string ANDROID_NATIVE_PLUGIN_CLASS = "com.example.unitypluginsamplelibrary.HelloAndroidNativePlugin";

    // Start is called before the first frame update
    void Start()
    {
        ResetMessage();
    }

    public void CallAndroidPlugin()
    {
        using (AndroidJavaClass androidJavaClass = new AndroidJavaClass(ANDROID_NATIVE_PLUGIN_CLASS))
        {
            androidJavaClass.CallStatic("Execute");
        }
    }

    public void ResetMessage()
    {
        _androidMessageText.text = "";
    }

    public void FromAndroid(string message)
    {
        _androidMessageText.text = message;
    }
}

apkを作成し、実機で動かしてみる

実際にapkをビルドして、それを実機で動かしてテストします。
自分はAndroid Emuratorで実行しました。

f:id:ghoul_life:20190126030157g:plain

Unity -> Android -> Unity
と問題なく処理が動いているのを確認できました。

【Unity】Android Native連携Pluginを開発してたら has been replaced with 'variant.getPackageLibraryProvider()'.

Androidのネイティブ連携開発

UnityでAndroidのNative連携Pluginの開発をしていたらこんなエラーに出くわした。

WARNING: API 'variantOutput.getPackageLibrary()' is obsolete and has been replaced with 'variant.getPackageLibraryProvider()'.
It will be removed at the end of 2019.
For more information, see https://d.android.com/r/tools/task-configuration-avoidance.
To determine what is calling variantOutput.getPackageLibrary(), use -Pandroid.debug.obsoleteApi=true on the command line to display a stack trace.
Affected Modules: helloplugin

エラー内容を見れば、
「variantOutput.getPackageLibrary()は2019年には消えるから、variant.getPackageLibraryProvider()に
置き換えて下さい」
といった具合なのですが、ちょっと対応に戸惑ったのでメモしておこう。

発生した状況

Android Studio 3.3を利用して素直にプロジェクトを作るとデフォルトで

com.android.tools.build:gradle:3.3.0

を利用することになり、そしたら遭遇した。

前提として

AndroidでNative連携Pluginを作る時、AndroidからUnityの機能を呼びたい事がよくある。
そういった機能を使う時に、Unityが用意しているAndroid用クラスライブラリをPlugin側に
組み込む必要がある。
例えば、Unity HubでUnity2018.3.2f1を利用している場合はここにある。

C:\Program Files\Unity\Hub\Editor\2018.3.2f1\Editor\Data\PlaybackEngines\AndroidPlayer\Variations\mono\Release\Classes

Macとかならこの辺りを参考に
docs.unity3d.com

原因

build.gradleに記載してあるこれが原因。

android.libraryVariants.all { variant ->
    variant.outputs.each { output ->
        output.packageLibrary.exclude('libs/classes.jar')
    }
}

これは、AndroidでUnityの機能を使うためにlibs/classes.jarに配置して使用するが、
Unity側に含めると重複エラーになってしまうので、aarに固める時には省きたい。
その省く処理を行っている。

間違った対応メモ

android.variants.all{ variant ->
    variant.packageLibararyProvider.each { output ->
        output.configure{
            exclude “libs/classes.jar”
        }
    }
}

ストレートに直すとこうだ。
エラーは出なくなるが、これでは上手くいかず、classses.jarがPluginに入ってしまった。

そもそもPackageLibraryProviderに代わっているため、流れてくる内容が違う。
中身はTaskProviderでDebug , Releaseになってるような感じだった(ちょっと詳しくはわからないけど)
なので、そこでexcludeとかやっても特に効果が無い。

解決について

ただやりたいことは、

ビルド時にlibs/classes.jarを省きたい

これだけなんだ。

つまりこうすればいい

dependencies{
    compileOnly fileTree(dir:”libs” , file: “classes.jar”)  
    .....
}

(もちろんpackageLibrary~の部分は全部消していい)

前まではprovidedだったが、これも無くなって今はcompileOnlyになった。
これで固める時にclasses.jarを省くことが出来る。

【Unity】第10回unity1week「10」ベルトスクロールアクションシステムについて

お疲れ様です。ぐーるです。
今回はunity1weekで提出した
「School Bag Fight」
f:id:ghoul_life:20181127003802p:plain
School Bag Fight | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう
についての記事にしようと思います。

お題について

また困るやつ…w
勝手に「クリスマス」とか「お正月」
とか思ってたんですが、そんなものではなかった…w
まぁ、とりあえず作りたいものを作って
後から帳尻を合わせようかな、何か作りたいものはあるかな?
と言う所から考えることにしました。

作りたいもの?

挑戦したかったのは自分で描くスプライトアニメーションでした。
でとりあえず描き始めたのが徒歩モーション。

f:id:ghoul_life:20181127211549p:plain

これが完成した辺りで、そうだファイナルファイトみたいな
ベルトスクロールアクションを作ろうかなと思いました。

コアシステムについて

日本ではベルトスクロールと言いますが、
海外ではBeat em upとか言うのかな。

一見複雑に見えます(?)が、実はソースコードはそこまで長くはならなかったです。

四つのシステム

本当のコアの部分は以下の四つのクラスで構成されています。
一部抜粋で冗長になりそうなnullチェックとかrangeチェックとか消してます。

AreaManager
EnemyFactory
Player
Enemy

AreaManager

いきなりですけどコードを。

    public Camera _mainCamera;
    private int _index;
    private bool _isNext;
    private static Area[] _areas = new Area[]
    {
        new Area(-20.0f, -0.5f, -4.0f),
        new Area(-17.0f, -0.5f, -4.0f),
        new Area(-8.0f, -0.5f, -4.0f),
        new Area(-3.0f, -0.5f, -4.0f),
        new Area(2.0f, -0.5f, -4.0f),
        new Area(7.0f, -0.5f, -4.0f),
        new Area(12.0f, -0.5f, -4.0f),
        new Area(18.0f, -0.5f, -4.0f),
        new Area(23.0f, -0.5f, -4.0f),
        new Area(28.0f, -0.5f, -4.0f),
    };
    private Area _area;

    // 次のエリアへ移動可能
    public void Next()
    {
        _index++;
        _isNext = true;
        _area = _areas[_index];
    }

    void Update()
    {
        if (_isNext)
        {
            var p = _mainCamera.transform.position;
            if (p.x >= _area._targetX)
            {
                // 次のエリアへ到達。敵出現開始
                _isNext = false;
                EnemyFactory.Instance.NextStageStart();
            }
        }
    }

まずはAreaという概念から説明します。
ベルトスクロールアクションはよく

進む->止まって敵がワラワラと出てくる->全部倒したら->また次に進めるようになる

というイベントがあります。
これを1Areaとして管理することにしました。

Areaは

・目標X位置
・右端移動可能位置(算出)
・左端移動可能位置(算出)
・上に移動可能な位置
・下に移動可能な位置

とrectと似たようなデータを持つクラスです。
AreaManagerはこれをArea数分持っています。
そしてAreaの切り替わりはCameraの位置によって決めています。

1. 敵を全部倒した
2. AreaManagerの次のエリアデータをセットして、次に進んでいいよフラグを立てる
3. フラグが立っている時は目標X位置にカメラが来るまで待機
4. 目標X位置までカメラが到達したら進んでいいよフラグを降ろし敵を出現させる

という流れになります。

3.の状態の時、横着してまして、
左(Xマイナス方向)に進むことを出来なくしています。
CineMachineとか使ってたら範囲内ならプレイヤーだけ移動、範囲外に出たらカメラごと移動
という良いUXに出来たなーと後でちょっと思いました。今後の課題

EnemyFactory

読んで字のごとく敵を生成するクラスです。

    private List<EnemyScript> _enemyList;
    private List<Bullet> _bullets;
    private int _createCount = 0;
    private int _disposeCount = 0;
    private float _popTimer = 10;
    public bool _running = false;

    // int enemyCount, float moveSpd, float countDownBeforeSpd, float countDownAfterSpd, float popSpd 
    private StageData[] _stages = new StageData[]
    {
        new StageData(1, 100, 1, 10, 5),
        new StageData(3, 5, 2, 50, 3),
        new StageData(5, 5, 2, 50, 1),
        new StageData(1, 100, 1, 30, 3),
        new StageData(5, 10, 3, 30, 2),
        new StageData(10, 10, 2, 30, 1),
        new StageData(7, 3, 5, 50, 1),
        new StageData(7, 3, 4, 50, 0.5f),
        new StageData(10, 10, 7, 50, 2),
        new StageData(1, 100, 10, 100, 1),
    };

    private StageData _stage;

    // ステージを開始
    public void NextStageStart()
    {
        _running = true;
        _stage = _stages[AreaManager.Instance.Index];
    }

    public void CreateEnemy(StageData stage, Vector3 pos){ /* 敵を生成。リストに保存 */ }
    public void CreateBullet(bool fripX, Vector3 pos, Vector3 vector){ /* 弾を生成して発射。リストに保存 */ }

    public void DisposeEnemy(EnemyScript enemy)
    {
        if(enemy != null)
        {
            _disposeCount++;
            if (_enemyList != null)
            {
                _enemyList.Remove(enemy);
            }

            Destroy(enemy.gameObject);
            // 敵を倒したカウントを計測しておいて、出現数に到達したら次のエリアへ
            if(_disposeCount == stage._enemyCount)
            {
                _running = false;
                AreaManager.Instance.Next();
            }
        }
    }

    public void DisposeBullet(Bullet bullet){ /* リストから消してDestroy */}

    void Update () {
        if (_running)
        {
            if(_createCount < _stage._enemyCount)
            {
                _popTimer += Time.deltaTime;

                if (_popTimer > _stage._popSpd)
                {
                    _popTimer = 0;
                    var pos = _player.transform.position;
                    var rand = Random.Range(0, 100);
                    if (rand > 50)
                    {
                        pos.x += 10;
                    }
                    else
                    {
                        pos.x -= 10;
                    }
                    pos.x += Random.Range(-0.5f, 0.5f);
                    pos.y += Random.Range(-0.5f, 0.5f);

                    CreateEnemy(_stage, pos);
                    _createCount++;
                }
            }
        }
    }

EnemyFactoryはstageという情報を持っています。
Stageは

・敵の出現数
・敵の移動速度、攻撃速度
・敵のポップ速度

といった難易度パラメータを持っています。
この値とAreaが1:1で繋がっています。
(だったら一つのScriptableObjectにまとめとけ!って後で思いました)

この値に沿って敵を出現させるのですが、
まぁよくある手でListでEnemyもBulletも保持します。
これはOPに戻ったりした時にまとめてリセットをかけるために持ってます。
破棄する時に一緒にListからも外す必要が出てくるのでちょい面倒ではありますが、しょうがない。

Player

Playerは操作感というか
キーを離したらぴたっと止まるようにとか
攻撃速度を早くとかキックしているときだけ若干の硬直を入れてたりといった小細工を入れている程度なんですが、
一つだけ問題が出た所はContinueのフローでした。

f:id:ghoul_life:20181126235255p:plain
こちらPlayerのAnimatorになります。

敵にやられた時、ここのdown状態のStateで止まっていて
コンティニューを選んだ時にwaitに戻るのですが、最初はここをTriggerで管理してました。
一度目は良いのですが、二度目コンティニューをしようとすると即座にwaitに遷移してしまう、

というバグを生み出してしまいました。
なので、ここをfloatに変えて、値が一定以上ならwaitに戻り、戻ったら0で上書きしておく
と修正しました。

f:id:ghoul_life:20181127213507p:plain
死亡した時の画面。ゲーム全体を止めていて
発射モーション途中で止めたいと思ってそこは意識してます。

Enemy

State管理が重要です。

SEARCH
PATROL
ATTACK_BEFORE
SHOT
ATTACK_WAIT

の五つがあります。

        switch (_enemyState)
        {
            case EnemyState.SEARCH:
                _targetPosition = _player.transform.position;
                _targetPosition.x += Random.Range(-0.1f, 0.1f);
                _targetPosition.y += Random.Range(-0.1f, 0.1f);
                _enemyState = EnemyState.PATROL;
                break;
            case EnemyState.PATROL:
                _animator.SetFloat("walk", 1.0f);
                var move = _moveSpeed * Time.deltaTime;
                transform.position = Vector3.MoveTowards(transform.position, _targetPosition, move);
                if(Vector3.Distance(transform.position, _targetPosition) <= STOP_DISTANCE)
                {
                    _animator.SetFloat("walk", 0.0f);
                    _enemyState = EnemyState.ATTACK_BEFORE;
                    /* パラメータセット */
                }
                break;
            case EnemyState.ATTACK_BEFORE:
                if (_countdownTimer > 0)
                {
                    _countdownTimer -= _countdownBefore * Time.deltaTime;
                    // playerが離れすぎたら再度searchへ
                    if(Vector3.Distance(transform.position, _player.transform.position) > 8)
                    {
                        _enemyState = EnemyState.SEARCH;
                    }
                }
                else
                {
                    _enemyState = EnemyState.SHOT;
                    /* パラメータセット */
                }
                break;
            case EnemyState.SHOT:
                _animator.SetTrigger("gun");
                var vector = (_player.transform.position - transform.position).normalized;
                _enemyFactory.CreateBullet(_spriteRenderer.flipX,this.transform.position, vector);
                _enemyState = EnemyState.ATTACK_WAIT;
                /* パラメータセット */
                break;
            case EnemyState.ATTACK_WAIT:
                if (_countdownTimer > 0)
                {
                    _countdownTimer -= _countdownAfter * Time.deltaTime;
                }
                else
                {
                    _enemyState = EnemyState.SEARCH;
                }
                break;
        }
SEARCH

プレイヤーの位置を取得して、若干ランダムで位置をずらして保持します。
その後PATROLに遷移

PATROL

SEARCHで取得した位置に移動します。
移動速度はStageパラメータで設定します。
目標地点に到達したらATTACK_BEFOREに入ります。

ATTACK_BEFORE

攻撃前の待機です。構えと言い換えてもいいです。
10秒のカウントダウンを行い、SHOTに遷移します。
このカウントダウン速度もパラメータで設定。
この時プレイヤーが離れすぎた場合は再度SEARCHに遷移します。

SHOT

弾を発射します。animatorに合わせて微調整入れてます。

ATTACK_WAIT

弾発射後の硬直です。
ここもパラメータ
硬直が解けたら、再度SEARCHに遷移して、プレイヤー位置を上書きします。

その他

後は普通にColliderで当たり判定を作っていたり
画面遷移、_animatorをストップさせて会話パートをゲーム中に挟んでみたりと
といった細かい実装なので割愛。

終わってみて

企画、システム(ゲーム)、デザイン
をわずか一週間で形にする…。これは大変な事です。
ちょっと無謀だったな…というチャレンジでしたが、
なんとか遊べるレベルにまで持っていけてよかったです。
そして評価、コメントまで頂けて本当に嬉しいです。

余談

作業中はよく適当にアニメを流しているんですが、今回は
ガールズアンドパンツァーをTV、OVA、劇場版、最終章と続けて二周ぐらい流してました。
次は戦車にしようかなw