ぐーるらいふ

底辺。

【Unity】第18回「回」るーれッとについて【unity1week】

またまたお久しぶりですね。
普段は死体なんですが、unity1weekが始まると起き上がる、
動く死体ぐーるです。




「ついにやりやがった」

今回お題が「回」ということでまぁなんか回せばいいかなと思いました。
で考えたのが花びら回転なんですが、もう死語と。
「死んでいるものを起こすなんてこりゃグール(cool)だぜ、HAHAHA」
みたいなぐーるギャグ…。

るーれッと | フリーゲーム投稿サイト unityroom

今回は

まぁ、単純にえっちなのにしたいなと。

「そういう方面で攻めたい」とずっと言ってきていて、
いい加減有言実行してやりたいなと。

で、単純に「脱衣ルーレット」にしました。
ゲーム性は皆無です。

f:id:ghoul_life:20210228185816g:plain

スケジュールについて

前回は結構コーディング寄りだったのですが、今回はかなりイラストに寄せます。

- 立ち絵 通常1
- 立ち絵 通常2
- 立ち絵 下着1
- 立ち絵 下着2
( + シークレット)

で計5つ。

土曜から作業を開始するとして3h * 5で15hは確保したい。
ざっくり以下のスケジュールで考えました。

- 2/27(土) 0:00 - 20:00 イラスト& UI制作
- 2/27(土) 22:00 - 2/28(日) 12:00 unity実装
- 2/28(日) 13:00 QA&アイコン&公開情報まとめたりなど

作業開始

イラストをひたすら描いていきます。
一つ一つに拘るともう本当に終わりません。

ざっくり描いてざっくり塗って
ジャンジャンじゃんじゃん進めていきます。

f:id:ghoul_life:20210228190158p:plain

シークレットだけは一番時間をかけます。

f:id:ghoul_life:20210228190137p:plain

(見せられないよ!見たい方はゲームプレイしてみてね)

計三枚ほど案を出して入れてみて一番バランスが良さそうなのにして
ちゃんと塗っていきました。

アニメ

タイトルやルーレット回す可愛いアニメでも載せるかと思い立って、
ダバダバダンスなキャラを追加しました。
三枚(1枚使いまわし)で動かしています。

f:id:ghoul_life:20210228184249p:plain

コードについて

また今回もそこまで特筆すべき実装がありません。

イベント進行について

イベントがシーケンシャルに繋がっています。

単純なコールバックにするとネストが深くなりすぎるので、
アニメーションフラグのON/OFFで制御するようなポーリング実装にして
イベントの流れを把握できるようにして途中に挟む、取り外すなどが楽な実装にします。

        var sprite = _charaSprites[stageId];

        _imageFade.ChangeSprite(sprite); // 立ち絵画像変更
        yield return new WaitWhile(() => _imageFade.IsAnimation);
        
        _scrollImage.Show(sprite); // 変更したキャラをスクロール
        yield return new WaitWhile(() => _scrollImage.IsAnimation);

        _textFade.SetText("");
        _textFade.ChangeText("脱ぎました"); // テキスト表示アニメ1
        yield return new WaitWhile(() => _textFade.IsAnimation);

        _textFade.ChangeText("次のルーレットを始めます"); // テキスト表示アニメ2
        yield return new WaitWhile(() => _textFade.IsAnimation);

        _roulette.Initialize();
        _roulette.ShowRoulette(); // ルーレット回す
        yield return new WaitWhile(() => _roulette.IsAnimation);
        _roulette.StartRoulette();

        _textFade.ChangeText("少しずつ速くなっていきます");
        yield return new WaitWhile(() => _textFade.IsAnimation); // ルーレット回しながらトーク続ける

        _textFade.FadeOutText();
        yield return new WaitWhile(() => _textFade.IsAnimation);

        yield return PlayRoulette(); // 操作可能

もうちょっと整理してますが、わかりやすいようにコードを開いて載せてます。
コード的にはこんなもんでしょうか…。特筆すべき所が無い…。

感想

ついにやりました。やってやりました。
いや、本当にBANでもいいので…。

【Unity】第17回「あける」コネクト【unity1week】

お久しぶりです。約三か月ぶりですね。
また書いてます。なんでしょうね、こう辞めれないのは。

f:id:ghoul_life:20201228003111g:plain

unityroom.com

一番大変だったこと

いきなりこっから話します。そして正直に言います。

クリスマス絵

です。

絵を描いているとこういったクリスマス、バレンタイン、年末年始といったイベント時は
絵を描いてUPするのが定番となっています。

これをやらないとグループに残れないレベルで深刻です。
なので12/21~12/24までの間、最優先はクリスマス絵でした。
仕事をしていた方も多いと思く自分も例に漏れませんでしたが、
帰宅後もUnityを開くことは無く、クリスマス絵に100%の力を注いでいました。

こちらは無事に12/24に提出完了しました。

はじめに

どうやらunity1week始まってるらしいな、とnaichiさんのTwitterを見て知りました。
この時点で12/25。

ヤバイですね!

というわけで

作業を始めました。まずはUnityのアップデートをしつつ、考えを巡らします。
「あける」ってどうしようかなぁと思っていたら。

f:id:ghoul_life:20201227235132j:plain

そうこれですよね。そうです。ペルソナです。
あー、これやりたいなぁと思って
「姫にキスしたら目をあける」
「いや、ファンタジー描けないしSF娘にするか」
と後ろ向きな理由で

アンドロイドみたいなロボットが起動する(目をあける)

というゲームにしようと
ざっくり考えました。

そこから

そっからは早かったです。
とりあえずパーツをはめて電源からハートを繋ぐゲームにしようと考え、
素材を準備していきます。

f:id:ghoul_life:20201228000433p:plain

今回のドット描きはEDGEです。
高機能ドット絵エディタ EDGE | TAKABO SOFT

普段はCLIP STUDIOしか使わないんですが、
ちょっと別のツールにも慣れておこうかなと使ってみました。
ウィンドウとかパレット周りにクセがあり、ワークスペース制になってくれると
いいなぁと思いながら使ってました。
まぁ、慣れると結構いけます。
ドットアニメーション周りも全部EDGEです。
(選択アニメーションや背景の線とかも)

コーディング

絵を描きながら頭でイメージコーディングしておきます。

「あ、このパーツはこう使おうとしてるからこんな感じのコードが必要になるな」

という考え方です。
それに合わせて書いていきます。

まぁほとんどピース周りのコーディングです。

public partial class Piece : MonoBehaviour
{
    protected int _pieceId = 0; // 一意に識別するID
    protected bool _isUse = false; // ユーザーに使用中か
    protected bool _isConnect = false; // 接続できているか?
    protected bool _isCalcurate = false; // ルート計算済みならtrue
    protected bool _isUsePool = false; // プールから使われているか?

    protected PieceData _pieceData = null;
    protected RectTransform _rectTransform = null;
    protected System.Action<Piece> _onClickEvent = null;

    private Vector2 _pieceFirstPosition = Vector2.zero;
    private Vector2 _piecePosition = Vector2.zero;

    private bool _isAnimation = false;
    private RectTransform _iconParent = null;

    // Start is called before the first frame update
    public virtual void InitializeEditor(PieceType pieceType , System.Action<Piece> onClickEvent, RectTransform rectTransform)
    {
        _pieceData = new PieceData(pieceType);
        _onClickEvent = onClickEvent;
        _pieceLock.SetLock(false);
        _pieceLock.SetVisible(false);
        _isCalcurate = false;
        _iconParent = rectTransform;
        UpdateIcon();
        SetConnect(false);
        UnUse();
        SetButtonActive(true);
    }
    //~~~ 略 ~~~

    //--------------------------------------------
    // use pool

    public void UsePool()
    {
        if (!_isUsePool)
        {
            _isUsePool = true;
            _canvasGroup.alpha = 1;
            this.gameObject.SetActive(true);
        }
    }

    public void UnUsePool()
    {
        _isUsePool = false;
        BackOverLayIcon();
        PieceCreater.Instance.UnUsePoolPiece(this.gameObject);
        this.gameObject.SetActive(false);
    }

    public bool IsUsePool
    {
        get
        {
            return _isUsePool;
        }
    }

 //~~~~~
}

ここで「おっ」と思った方もいると思いますが、
今回のゲームで使うオブジェクトは全てPoolシステムに乗っかっています。
これもイメージコーディングで

このパーツは大量に必要になるからpoolが必要になるな

と想定して使う前提のコーディングをします。

// ※かなり色々はしょっています。
// 必要な所だけ載せてます
public class PieceCreater : SingletonMonoBehavior<PieceCreater>
{
    public const int COUNT = 100;

    [SerializeField] private Piece _piecePrefab = null;
    [SerializeField] private RectTransform _rectTransform = null;

    private List<Piece> _piecePool = null;
    private bool _isInitialize = false;

    public void Initialize()
    {
        if(!_isInitialize)
        {
            _isInitialize = true;

            _piecePool = new List<Piece>();

            for (var i = 0; i < COUNT; i++)
            {
                var p = Instantiate(_piecePrefab, _rectTransform);
                p.gameObject.name = "Piece_Pool_" + (i + 1);
                p.UnUsePool();
                _piecePool.Add(p);
            }

        }
        else
        {
            if(_piecePool != null)
            {
                foreach (var p in _piecePool)
                {
                    p.UnUsePool();
                }
            }
        }
    }

    public void UnUsePoolPiece(GameObject p)
    {
        if(p != null && _rectTransform != null)
        {
            p.transform.SetParent(_rectTransform);
        }
    }

    public Piece GetPieceByPool()
    {
        if(_piecePool != null)
        {
            var piece = _piecePool.FirstOrDefault(p => !p.IsUsePool);
            if(piece != null)
            {
                piece.RectTransform.pivot = new Vector2(0.5f, 0.5f);
                piece.RectTransform.position = Vector3.zero;
                piece.RectTransform.localPosition = Vector3.zero;
                piece.RectTransform.anchorMax = new Vector2(0.5f, 0.5f);
                piece.RectTransform.anchorMin = new Vector2(0.5f, 0.5f);
                piece.UsePool();

                return piece;
            }
            else
            {
                UnityEngine.Debug.LogError("All Using Piece !!! Please Size Up Pool.");
            }
        }
        else
        {
            UnityEngine.Debug.LogError("Don't create Piece Pool. Please call PieceCreater.getInstance.Initialize()");
        }
        return null;
    }
    // ~~~略~~~
}

こんな感じで全部poolから使うようにして動作の高速化を図ります。

ステージエディタ

面を作るのがとにかく面倒です。
こういうゲームはステージエディタを作ります。
これを作る事によって機能のデバッグにもなります。

f:id:ghoul_life:20201228005843p:plain

容量オーバーでここには上げられなかったのでTwitter経由に
なりますが、動画はこちら。

選択したピースを好きに配置し、レーザーの反射も自由に計算。
そしてjson出力、ロードまで完備してステージクリエイト機能と
各ピースの個別機能のテストも行います。

難しくしすぎないように気を付けてステージを作りました。

レーザーの反射について

ここが今回の開発の急所です。
反射プログラムです。

反射は無理やりMesh変形させて作っていくのもアリなんですが、
単純に本数を増やす事にしました。

つまり一回反射したら二本に、
二回反射したら三本になる
という疑似的な反射にすることにしました。

// Poolシステムに乗っけて、反射の速度に
// ついていけるように高速化は必須です。
public class Laser : MonoBehaviour
{
    private bool _isUsePool = false;
    private const float LASER_SIZE = 50.0f;
    private const float OUT_OF_LASER = 1000.0f;

    public enum LaserDirection
    {
        None = 0, // 通過
        LEFT,
        DOWN,
        RIGHT,
        UP,
        GUARD // 止める
    }

    [SerializeField] private Image _laserImage = null;
    [SerializeField] private RectTransform _rectTransform = null;

    private Piece _startPiece = null;
    private Piece _endPiece = null;
    private Vector2 _startPosition = Vector2.zero;
    private Vector2 _endPosition = Vector2.zero;
    private LaserDirection _laserDirection = LaserDirection.LEFT;

    public void SetUp(Piece piece , LaserDirection laserDirection)
    {
        _startPiece = piece;
        _endPiece = null;
        _startPosition = _startPiece.RectTransform.anchoredPosition;
        _laserDirection = laserDirection;
    }

    public void SetLaserEnd(Piece endPiece)
    {
        _endPiece = endPiece;

        SetSize(_startPiece, _endPiece, _laserDirection);
    }

    /// <summary>
    /// レーザーを画面外で飛ばす
    /// </summary>
    public void SetLaserOutOfBounds()
    {
        var anchorStart = _startPiece.RectTransform.anchoredPosition;
        var anchorEnd = anchorStart;

        switch(_laserDirection)
        {
            case LaserDirection.LEFT:
                anchorEnd.x -= OUT_OF_LASER;
                break;
            case LaserDirection.RIGHT:
                anchorEnd.x += OUT_OF_LASER;
                break;
            case LaserDirection.UP:
                anchorEnd.y += OUT_OF_LASER;
                break;
            case LaserDirection.DOWN:
                anchorEnd.y -= OUT_OF_LASER;
                break;
        }

        SetSize(anchorStart, anchorEnd, _laserDirection);
    }

    //--------------------------------------------
    // set size
    //--------------------------------------------
    private void SetSize(Vector2 anchorStart , Vector2 anchorEnd , LaserDirection direction)
    {
        _rectTransform.localEulerAngles = new Vector3(0, 0, 90 * ((int)direction - 1));

        var sizeDelta = Vector2.zero;
        sizeDelta.y = LASER_SIZE;

        switch (direction)
        {
            case LaserDirection.LEFT:

                sizeDelta.x    = Mathf.Abs(anchorEnd.x - anchorStart.x);
                anchorStart.x -= sizeDelta.x;
                break;
            case LaserDirection.RIGHT:
                sizeDelta.x    = Mathf.Abs(anchorEnd.x - anchorStart.x);
                anchorStart.x += sizeDelta.x;
                break;
            case LaserDirection.UP:
                sizeDelta.x    = Mathf.Abs(anchorEnd.y - anchorStart.y);
                anchorStart.y += sizeDelta.x;
                break;
            case LaserDirection.DOWN:
                sizeDelta.x    = Mathf.Abs(anchorEnd.y - anchorStart.y);
                anchorStart.y -= sizeDelta.x;
                break;
        }
        _rectTransform.sizeDelta = sizeDelta;
        _rectTransform.anchoredPosition = anchorStart;
    }
}


レーザーは全てこのコントローラーが処理を行います。

public class LaserController : MonoBehaviour
{
    private RectTransform _targetRectTransform = null;

    private System.Action _onGameClearEvent = null;
    private List<Laser> _laserList = null;
    public void SetUp(RectTransform rectTransform , System.Action onGameClearEvent)
    {
        _targetRectTransform = rectTransform;
        _onGameClearEvent = onGameClearEvent;
    }

    public void Dispose()
    {
        if(_laserList != null)
        {
            foreach(var l in _laserList)
            {
                l.UnUsePool();
            }
            _laserList.Clear();
            _laserList = null;
        }
    }

    private Laser CreateLaser(Piece piece , Laser.LaserDirection laserDirection)
    {
        if(_laserList == null)
        {
            _laserList = new List<Laser>();
        }
        var laser = PieceCreater.Instance.GetLaserByPool();
        laser.RectTransform.SetParent(_targetRectTransform);
        laser.SetUp(piece, laserDirection); // 開始位置と方向
        _laserList.Add(laser);

        return laser;
    }

    public void LaserShot(List<Piece> pieceList)
    {
        Dispose();

        var startPiece = pieceList.FirstOrDefault(p => p.PieceData.PieceType == PieceType.Start);
        if(startPiece != null)
        {
            var sp = startPiece.GetComponent<StartPiece>();

            var laser = CreateLaser(sp, sp.LasetDirection);
            var boardPosition = startPiece.PieceData.BoardPosition;

            if (LaserBeamExecute(pieceList, laser, ref boardPosition))
            {
            }
            else
            {
                // 計算終了
            }
        }

    }

    private bool LaserBeamExecute(List<Piece> pieceList, Laser laser, ref Vector2Int pos)
    {
        var piece = SearchHitPiece(pieceList, laser.LaserDirectionValue, ref pos);
        if (piece != null)
        {
            var laserCollision = piece.LaserCollision(laser);

            // 通過を見つけた時は次へ
            if (laserCollision == Laser.LaserDirection.None)
            {
                return LaserBeamExecute(pieceList, laser, ref pos);
            }
            else if(laserCollision == Laser.LaserDirection.GUARD)
            {
                // ガードが返ってきた時はそこで止める
                laser.SetLaserEnd(piece);

                if(piece.PieceData.PieceType == PieceType.Goal)
                {
                    AudioManager.Instance.PlaySE("robot-startup1");
                    piece.OnGoal();
                    if(_onGameClearEvent != null)
                    {
                        _onGameClearEvent.Invoke();
                    }
                }
                return true;
            }
            else
            {
                // ここが来たら反射している
                laser.SetLaserEnd(piece); // このレーザーはここまで
                
                // 次のレーザーを生成する
                var nextLaser = CreateLaser(piece , laserCollision);
                var nextPos = piece.PieceData.BoardPosition;
                return LaserBeamExecute(pieceList, nextLaser, ref nextPos);
            }
        }

        laser.SetLaserOutOfBounds();
        // 見つからなかったらもう画面外に行く
        return false;
    }

    // 画面端に到達するかpiece見つかるまで直進する
    private Piece SearchHitPiece(List<Piece> pieceList, Laser.LaserDirection direction, ref Vector2Int pos)
    {
        NextPos(direction, ref pos);

        var x = pos.x;
        var y = pos.y;

        var nextPiece = pieceList.FirstOrDefault(p => p.IsBoardPosition(x , y));
        if (nextPiece != null)
        {
            return nextPiece;
        }
        else
        {
            if(
                (x > -1 && x < Define.BOARD_COUNT_X) &&
                (y > -1 && y < Define.BOARD_COUNT_Y))
            {
                return SearchHitPiece(pieceList, direction, ref pos);
            }
            else
            {
                // 画面端到達
                return null;
            }
        }
    }


    private void NextPos(Laser.LaserDirection direction , ref Vector2Int pos)
    {
        switch (direction)
        {
            case Laser.LaserDirection.LEFT:
                pos.x -= 1;
                break;
            case Laser.LaserDirection.RIGHT:
                pos.x += 1;
                break;
            case Laser.LaserDirection.UP:
                pos.y += 1;
                break;
            case Laser.LaserDirection.DOWN:
                pos.y -= 1;
                break;
        }
    }
}

こんな感じのプログラムでレーザーの反射を計算しています。
思っていたよりシンプルでしたでしょうか?
まぁ、そんなに難しいものではない、と思っていただければと。

最悪の事態

Unity2020.2.0f1で開発していたんですが、
WebGLの出力が上手くいかず冷や汗を書きました。
これに気づいたのが日曜日の18:00。あと2時間しかありません。

慌てました。

昔使ってた2019.3.0f1にプロジェクトを差し替えて難を逃れ
この時点で19:00。ラスト1時間でアイコンとか作ったり、
チュートリアル差し込んだり、ツイートしたり。
本当にギリギリでした。

感想

今回も遅刻せず提出する事が出来ました。
もうツイート機能もランキング機能も要りません。
あっても無くても変わらないので。

時間がもうちょっとあったら思いっきりR-18な作品出して
レギュレーション違反でアカBANされたりしたいです。
今回描いたキャラを修正する時間がなくてもうちょっと描きなおしたかったなぁ。と。

ではまた次回(?)。
お疲れ様でした。

【Unity】第16回「ふえる」私のアクアリウムについて【unity1week】

お疲れ様です。ぐーるです。
Unity1Weekの時だけ浮上してくる稀有な存在です。

今回は開催に全く気づかなくて木曜日に気付いて「あ、やべぇ」って冷や汗かきました。

毎回参加した記事をこのブログに書いてますが、実は読まれた回数50回もいきません。
やるだけ時間の無駄なのかもしれませんが、止める機会を失いダラダラと続けています。

お題について

xxxして人口を増やすゲーム!みたいなR-18オーバーキルするゲーム
を思いつきましたが、流石にヤバイだろうということで早々に諦めました。

私のアクアリウムについて

今回のゲームはとてもシンプルなゲームです。
謎の生物にエサを与えてその反応を見るゲームとしました。
少しずつ増えていく図鑑を眺めたり、ドット絵のアニメーションを眺めるのんびりとしたゲーム。
それだけです。

比較gif

開発当初のversion
f:id:ghoul_life:20200817171606g:plain

最終version
f:id:ghoul_life:20200817172227g:plain

クイズ番組のコンテンツみたいですね。
こうして見るとわかりやすいでしょうか。
細かくアニメーションを追加してたり、全体的なスピードを上げてたりしてます。
(それでもマッタリしたゲームですが)

ホラーゲーム?

最初はそんな設定なかったです。
「全て揃ったら終わり」それだけでしたが、
エンディングを用意することにしました。

見て下さった方はわかるでしょうが、
もう何もかも壊したい。そういう黒い感情です。

開発について

正直シンプルすぎてシステム面で話すことは少ないのですが、簡単に。

ランダムタイマーシステム

水槽の計器類であったり、背景のモニター、手のアニメーションの制御、
モンスターのアニメーションタイミングなどなど、色んな事を担うタイマーを用意しました。

public class RandomTimer : MonoBehaviour
{
    private float _waitTime = 0f;
    private float _nextTime = 0f;

    private float _randomMin = 0f;
    private float _randomMax = 0f;

    private System.Action _timerEvent = null;

    public void StartTimer(System.Action timerEvent , float min , float max)
    {
        _timerEvent = timerEvent;
        _randomMin = min;
        _randomMax = max;
    }

    void Update()
    {
        _waitTime += Time.deltaTime;

        if (_waitTime >= _nextTime)
        {
            _waitTime = 0;
            _nextTime = Random.Range(_randomMin, _randomMax);

            if(_timerEvent != null)
            {
                _timerEvent.Invoke();
            }
        }
    }

    void Destroy()
    {
        _timerEvent = null;
    }
}

使う側はこんな感じの一例です。

/// <summary>
/// ランダムな時間で点滅する
/// </summary>
[RequireComponent(typeof(RandomTimer))]
public class Blink : MonoBehaviour
{
    [SerializeField] Image _image = null;
    [SerializeField] RandomTimer _randomTimer = null;

    private bool _isOn = true;

    // ちょっとわかりやすく固定値としている。
    private const float RANDOM_WAIT_MIN = 0.5f;
    private const float RANDOM_WAIT_MAX = 3.0f;

    void Start()
    {
        _isOn = true;
        _randomTimer.StartTimer(TimerEvent, RANDOM_WAIT_MIN, RANDOM_WAIT_MAX); 
    }

    private void TimerEvent()
    {
        _isOn = !_isOn;

        if (_isOn)
        {
            _image.color = Color.white;
        }
        else
        {
            _image.color = Color.clear;
        }
    }
}

これを色んな所で組み替えて使ってます。

ドットっぽいアニメーション

昨今ハードウェアも凄く性能が高いので、枚数が多い滑らかなアニメーションでも問題ないのですが、
ドット風のゲームだと違和感が出ます。

位置の移動、ブラックアウトといったドット絵とは関係ないシステム面も
ドットっぽくカクつくような動きになるように補完を計算しないシステムにわざとしています。
移動も似たような感じです。

    // ブラックアウト部分、一部抜粋。
    private void FadeOutExecute()
    {
        if(_nowAlpha < 1)
        {
            _time += Time.deltaTime;
            if (_time >= FADE_TIME)
            {
                _time = 0;
                _nowAlpha += FADE_VALUE;
                SetBlackOutAlpha(_nowAlpha);
            }
        }
        else
        {
            SetBlackOutAlpha(1);
            _blackOutMode = Mode.NONE;

            if(_finishCallback != null)
            {
                _finishCallback.Invoke();
            }
        }
    }

イラストについて

ドット絵を書くならEDGEとかAsepriteとか色々有名なツールがありますが、
自分は慣れているCLIP STUDIOのドットペンで描いてます。

f:id:ghoul_life:20200817212744p:plain

アニメーション関係

ドットアニメーションを作るならCLIP STUDIOでも出来ますが、Pixakiというツールを使ってみてます。

f:id:ghoul_life:20200817174545p:plain
https://rizer.co/jp/pixaki/

めちゃくちゃ高いです。なんとお値段3000円!

ドットアニメーション作成に特化したツールでオニオンスライスや速度変化、コマ別の長さ調整などが簡単にできるようになってます。
レビューコメントではあんまり良い評価ではありませんが…。(色周りは特に使いにくい)
アニメーション作るならまぁまぁ楽が出来るのではないでしょうか。
それにしてもお値段が強気。ガチャ10連爆死したと思って買うとか…。

あと言えることはドット絵アニメーション作るのめっちゃ楽しいです。

f:id:ghoul_life:20200817212759p:plain

リリース直前で焦ったやつ

音が鳴らない

Chromeだけ音が鳴らない!なんで!?
とアワアワしたが、セキュリティの問題だった。

文字が表示されない

「あれ、フォント入れてるのに言葉がヘンだぞ!?」
と思ったらフォントが表示出来ない文字を結構使ってしまっていた。
文字をひらく(漢字をひらがなに変える、別の言葉に変える)で対応

以上

次はエッチなR-18ゲームでも出して
思いっきりBANされようかな…。

【Unity】[ECS][レースシステム]第15回「密」あつまれ恐竜レースの開発について【unity1week】

お疲れ様です。ぐーるです。
最近は別アカの方で活動していてこっちはすっかり低浮上です。

今回も始まりました。unity1week。お題は「密」です。
前回三月にやったので、次は六月ぐらいかなと思っていたのですが、
突然の開催ということでビックリ。
しかも長期休暇直前でインフラや基盤の整備しつつ、
リモートワークしてる人たちをサポートしつつ、
客先とweb会議で打ち合わせしたりと
てんやわんやで中々手が回らない中、参加することにしました。
5/1からw


今回作ったゲームはマルチ対戦レースゲーム
「あつまれ恐竜レース」
です。



レースシステムについて

このゲームはとてもシンプルです。
キー入力回数を速度に変換して移動させ、一定の値を超えたらゴールです。

NPCはこのキー入力を再現させて走っています。

実装的にはもちろんオブジェクト指向そのままで
親クラスがあって処理のほとんどを共通化しています。

入力について

Playerはキー入力を受け取って走りますが、NPCは決められた値に従って走ります。
それを実現するシステムを紹介します。

InputControl

ユーザーのキー入力を変換するシステムです。
キーボードの連打をカウントしており、またDBに送信するための履歴を記録しています。
この履歴をサーバに送信して記録することによりNPCとして走らせることが出来ます。

    private List<KeyCode> _targetKeyCodes = null;

    private float _value = 0;
    private float ADD_VALUE = 1f;

    private bool _isActive = false;

    // input data save
    private List<int> _inputList = null;
    private float _counterTime = 0;
    private const float TIME_SPAN = 1f;

    public void Initialize()
    {
        _value = 0;

        if(_targetKeyCodes == null)
        {
            _targetKeyCodes = new List<KeyCode>();
            for (var key = KeyCode.Backspace; key < KeyCode.Joystick8Button19; key++)
            {
                _targetKeyCodes.Add(key);
            }
        }
        _counterTime = 0;
        _inputList = new List<int>();
        _isActive = false;
    }


    // Update is called once per frame
    void Update()
    {
        if (!_isActive) return;

        if(IsAnyKeyDown())
        {
            _value += ADD_VALUE;
        }

        UpdateInputLog();
    }

    // 入力された回数を規定時間ごとに記録する
    private void UpdateInputLog()
    {
        _counterTime += Time.deltaTime;
        if(_counterTime >= TIME_SPAN)
        {
            _inputList.Add((int)_value);
            _counterTime = 0;
        }
    }

    private bool IsAnyKeyDown()
    {
        if(_targetKeyCodes != null)
        {
            foreach (var k in _targetKeyCodes)
            {
                if (Input.GetKeyDown(k))
                {
                    return true;
                }
            }
        }

        return false;
    }

NPCInputControl

NPCは上で記録した一連の流れを元にスピード基準値を生成します。
単純に配列から記録した値と値の間を補完するように計算して値として
入れていくだけというシンプルなシステムです。

    private bool _isActive = false;
    private float _nowSpeed = 0;
    private float _beforeSpeed = 0;
    private float _nextSpeed = 0;
    private float _timer = 0;
    private int _nowIndex = 0;

    private List<float> _speedArray = new List<float>();
    private const float TIMER_SPEED = 1.0f;

    public void Initialize(List<float> speedArray)
    {
        Debug.Assert(speedArray != null);

        _isActive = false;
        _speedArray = speedArray;
        _nowIndex = 0;
        _beforeSpeed = 0;
        _nowSpeed = 0;
        _nextSpeed = _speedArray[_nowIndex];
        _timer = 0;

    }

    public void SetActive(bool active)
    {
        _isActive = active;
    }

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

    // Update is called once per frame
    void Update()
    {
        if (!_isActive) return;

        _timer = _timer + TIMER_SPEED * Time.deltaTime;

        _nowSpeed = Mathf.Lerp(_beforeSpeed, _nextSpeed, _timer); // 値を補完する

        if(_timer > 1.0f)
        {
            if(_nowIndex < _speedArray.Count - 1)
            {
                _nowIndex++;

            }
            _beforeSpeed = _nextSpeed;
            _nowSpeed = _beforeSpeed;
            _nextSpeed = _speedArray[_nowIndex];
            if(_nowIndex > 5 && _nextSpeed < 100.0f)
            {
                _nextSpeed = 100.0f;
            }

            _timer = 0;
        }
    }

ECS 背景の大量のUnityちゃん

f:id:ghoul_life:20200504225453p:plain

そうです。みんな大好きEntity Component Systemです。
もー詳しくは説明しません。過去にもやってるし、他に詳しい方いっぱいいるし!w

ghoul-life.hatenablog.com

なのでWebGLで使う際に気を付けた点だけ説明していきます。

ある程度は覚悟しよう

DOTSは三つの要素を組み合わせる事によって最大限のパフォーマンスを引き出すことが出来ます。
その三つとは

  • Entity Component System
  • Job System
  • Burst Compiler

です。

現在WebGLビルドでは

  • Job System
  • Burst Compiler

この二つの恩恵を受けることがほとんどできません。

詳しくはこちら
ghoul-life.hatenablog.com

なので、この二つは使わないように注意してシステムを組みます。

本当にシンプルなものに留める

独自にコンポーネントシステムを組んでいくのがECSを組み上げる際の醍醐味ですが、
まぁ今回は単純に見てるだけでいいし、特別なシステムとかいらんし。
ってことでECS関係のコードこれしかありません。

    private void InitializeECS()
    {
        var entityManager = World.Active.EntityManager;

        _waitArcheType = entityManager.CreateArchetype(
                          // renderer
                          typeof(RenderMesh)
                        , typeof(LocalToWorld)

                        // Transform
                        , typeof(Translation)
                        , typeof(Rotation)
                    );

        NativeArray<Entity> entityArray = new NativeArray<Entity>(ECS_CREATE_COUNT * ECS_ROW, Allocator.Temp);

        entityManager.CreateEntity(_waitArcheType, entityArray);

        for (var i = 0; i < ECS_ROW; i++)
        {
            for (var j = 0; j < ECS_CREATE_COUNT; j++)
            {
                Entity entity = entityArray[ECS_CREATE_COUNT * i + j];

                entityManager.SetComponentData(entity, new Translation { Value = new float3(/* 位置 */)) }); // 追加
                entityManager.SetComponentData(entity, new Rotation { Value = quaternion.LookRotationSafe(new float3(/* 位置 */) , Vector3.up) });
                entityManager.SetSharedComponentData(entity, new RenderMesh
                {
                    mesh = _mesh,
                    material = RandomMaterial(),
                });

                x += distance.x;

            }
            x = defaultX;
            y += distance.y;
            z += distance.z;
        }
        

        entityArray.Dispose();
    }
}

話すべき所と言えば、やはりanimationTextureBakerでしょうか。

AnimationTextureBakerとは

https://github.com/sugi-cho/Animation-Texture-Baker

こちらで公開されている、Texture2Dに法線情報や頂点情報を保存し、
Shaderでアニメーションを計算して表現できるというツールです。

今回の背景に並んでいるunityちゃんはこのanimationTextureBakerで生成したデータを使用しています。
必要なものはMeshとTexture , 頂点Textureと法線Texture、Shaderだけです。
後はShaderを使ったMaterialを作って、ECSに渡すだけでOKです。簡単ですね。

DBについて

DBは簡単に使えるNCMBをお借りしてます。
えっ
「大量に通信したら有料では?」
どうせ人気無いんでトータルでアクセス数1000も行きません。

少し楽に使えるように自分なりにこんな感じの簡易Wrapperを用意してます。

        private bool _isConnecting;
        private int _tableCount;
        private NCMBObject _sendObject;
        private List<NCMBObject> _selectObjectList;
        private NCMBException _errorException;
        private System.Action _callBackAction;

        private void InitializeConnectionDB(System.Action callBackAction)
        {
            _isConnecting = true;
            _tableCount = 0;
            _sendObject = null;
            _callBackAction = callBackAction;
            _errorException = null;
            if (_selectObjectList != null)
            {
                _selectObjectList.Clear();
            }
            else
            {
                _selectObjectList = new List<NCMBObject>();
            }
            
        }

        /// <summary>
        /// 指定のテーブルからデータを取得する
        /// (ここではOrderByが1方向しかないので、パクるならいい感じに修正すること)
        /// </summary>
        public void SelectAsync(string tableName , string orderByColumn , int limit, System.Action callBackAction)
        {
            if (_isConnecting) return; // 連打対策

            InitializeConnectionDB(callBackAction);

            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>(tableName);
            if (!string.IsNullOrEmpty(orderByColumn))
            {
                query.AddAscendingOrder(orderByColumn);
            }
            if(limit > 0)
            {
                query.Limit = limit;
            }
            query.FindAsync((List<NCMBObject> objList, NCMBException e) =>
            {
                _isConnecting = false;
                //検索成功したら
                if (e == null)
                {
                    _selectObjectList.AddRange(objList);
                    _tableCount = objList.Count;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            }
            );
        }

        /// <summary>
        /// データを挿入する処理
        /// </summary>
        /// <param name="sendObject"></param>
        public void InsertAsync(NCMBObject sendObject, System.Action callBackAction)
        {
            if (_isConnecting) return;  // 連打対策

            InitializeConnectionDB(callBackAction);

            sendObject.SaveAsync((NCMBException e) =>
            {
                _isConnecting = false;
                if (e == null)
                {
                    _sendObject = sendObject;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            });
        }

        /// <summary>
        /// 指定したテーブルのカウントを取得する
        /// </summary>
        /// <param name="tableName"></param>
        public void CountAsync(string tableName, System.Action callBackAction)
        {
            if (_isConnecting) return; // 連打対策

            InitializeConnectionDB(callBackAction);

            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>(tableName);
            query.CountAsync((int count, NCMBException e) => {
                _isConnecting = false;
                if (e == null)
                {
                    _tableCount = count;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            });
        }


        /// <summary>
        /// 現在時刻を指定フォーマットで取得
        /// </summary>
        /// <returns></returns>
        public static string GetNowDate()
        {
            var d = System.DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss");
            return d;
        }

使う側もこんな感じで継承するだけで簡単に使えるようなものを用意。

使いたいテーブル名をGetTableName()で渡して、行いたい処理を各Callbackで行うだけ
でテーブルごとの処理が出来ます。

    /// <summary>
    /// NCMBのテーブルを使う時に継承する
    /// </summary>
    public abstract class MyNcmbTable
    {
        protected NcmbWrapper _ncmbWrapper;

        public abstract string GetTableName();

        public MyNcmbTable()
        {
            _ncmbWrapper = new NcmbWrapper();
        }

        //----------------------------------------------------------------
        // select
        //----------------------------------------------------------------

        public virtual void Select(string orderByColumnName=null, int limit=-1)
        {
            _ncmbWrapper.SelectAsync(GetTableName(), orderByColumnName, limit, SelectCallbackAction);
        }

        public virtual void SelectCallbackAction()
        {

        }

        //----------------------------------------------------------------
        // insert
        //----------------------------------------------------------------
        public virtual void Insert(NCMB.NCMBObject ncmbObject)
        {
            _ncmbWrapper.InsertAsync(ncmbObject, InsertCallbackAction);
        }

        public virtual void InsertCallbackAction()
        {

        }


        //----------------------------------------------------------------
        // count
        //----------------------------------------------------------------
        public virtual void Count()
        {
            _ncmbWrapper.CountAsync(GetTableName(), CountCallbackAction);
        }

        public virtual void CountCallbackAction()
        {

        }

        //----------------------------------------------------------------
        // get value
        //----------------------------------------------------------------
        public bool IsConnecting()
        {
            if(_ncmbWrapper != null)
            {
                return _ncmbWrapper.IsConnecting;
            }
            return false;
        }

    }


こんな感じでテーブルごとにテーブル名と挙動を書いていけばOK

public class Table : MyNcmbTable
{
        public override string GetTableName()
        {
            return "table";
        }
        
        
        public void Insert(TableData tableData)
        {
            var ncmbObject = new NCMBObject(GetTableName());
            ncmbObject.ObjectId = (!string.IsNullOrEmpty(tableData.ObjectId)) ? multiData.ObjectId : null;
            ncmbObject[KEY_PLAYER_NAME] = tableData.PlayerName;
            // 略
            
            ncmbObject[KEY_DATE] = NcmbWrapper.GetNowDate();
            
            base.Insert(ncmbObject);
        }
        
        public override void SelectCallbackAction()
        {
            var selectObjectList = _ncmbWrapper.SelectObjectList;
            if(selectObjectList != null)
            {
                if (_tableDataList != null)
                {
                    _tableDataList.Clear();
                }
                _tableDataList = new List<TableData>();
                
                foreach(var selectObject in selectObjectList)
                {
                    var tableData = new TableData();
                    tableData.ObjectId = selectObject.ObjectId;
                    tableData.PlayerName = selectObject[KEY_PLAYER_NAME];
                    
                    // 略
                    
                    _tableDataList.Add(tableData);
                }
                
            }
            //~~~~~~~~~~~~~
        }
        //~~~~~~~~~~
}

以上

f:id:ghoul_life:20200504230545g:plain

このアニメーション描くのに4時間かかってます…w
元ネタはアレなんですけど、コマ送りして参考にしつつ描きました。
頭とか細かく振れていて一コマ一コマ丁寧に描いてあるんだなーと思いました。
(自分のは雑なんであんまりじーっと見ないでくださいw)

久しぶりの3Dでカメラワークとか楽しかったです。
3Dだとお絵描きほとんど無いんで三日間でちょっと余裕あるぐらいで完成させられますね。
ゲーム的にも凄くシンプルだし。

でもまた次は2Dかなぁ、三か月あればまたちょっと上達してるだろうと。

トラジションの話はいつやるんだー、仕事中にちょっとずつまた書き進めます…。

【Unity】第14回unity1week「逆」自分が面白かったゲームと反省会プラス【unity1week】

ghoul-life.hatenablog.com

気持ちが切り替わらない

なんか色々ありまして。いろんな人のゲームをプレイしたり、
お絵描きしたり、色々してました。

みんなのゲームの中でおすすめを…!

自分なんかがおススメ話すのもアレなんですが、
これ面白かったなぁ~と思ったものを抜粋。
あ、もちろん上位の逆立ちのやつとか梯子のやつとか踊るやつとかも面白かったです!けど
みんな言ってるんで僕から言う必要は無いでしょうという感じで。

ヒイテケス

f:id:ghoul_life:20200308154014p:plain

unityroom.com

これはハマる...!
延々とプレイしてしまいました。

盗賊xダンジョン

f:id:ghoul_life:20200308154334p:plain

unityroom.com

ステージ数が少ないが、それでもかわいくて面白い。
シンプルで操作も分かりやすい、ファイアアンドアイスもこうしたかった。
俺に続きを作らせてほしい!なんてw

End of Fantasia

f:id:ghoul_life:20200308154626p:plain

unityroom.com

バトル入るときのSEを聴いた時、ハルウルというゲームが頭をよぎり懐かしい気持ちになりました。
え、ハルウルを知らない良い子は知らないままの方がいいと思います。
ちなみに神ゲーです。検索とかしちゃダメだよ!

そして今一度自分の作品を振り返る

実況者のプレイをじっと見てみて、とにかく色々考えさせられた。
「一週間"だけ"でどこまでゲームが開発出来るか?」
企画、開発、デザイン、QA、リリース作業までひっくるめて一週間だ。
それ以上はやらないようにしている。そこが自分の実力だからだ。

全体的に難易度が高い

システムがとにかく難しそうだった。

特に「ファイアを凍らせてファイアを二段ジャンプさせる」
これが非常に難しかったみたいだ。

オリジナルなシステムだったが、その分馴染みが無い。

操作がわからない

なんとなくWASD+αでいいかと思っていたのですが、
自分でやってみて、Zキーとかその辺がわかりにくかったんで、
BNMも割り当てたのですが、そちらは気付かれにくい。

解決策としては

  • 画面にずっと操作説明を表示しておく。
  • 多いボタン割り当ては避ける。

この二つが考えられる。また、後にも記述するのだが、重要な機能は画面上のボタンだけではなく、
キーボードボタンでもショートカット出来ると良い。
(多いボタン割り当てと矛盾するようですが...)

Unityが出してる2Dゲームのプロジェクト落として先に参考にしておけばよかった...。

ジャンプのひっかかり

物理演算的にRigidbodyでキャラを操作しているのだけど、これが大失敗だった。
慣れていないものを使ったので、横キーが優先されて引っ掛かってしまうなど、思ってない挙動が頻発していた。
しかも自分は何度も触っているし、変なクセも身体が理解してしまっていたので、そこまで難しさを感じていなかった。

  • Rigidbodyの挙動をもっと勉強するべきだった
  • 勝手に動くのを期待せず、自分で操作しちゃえば良かった。

(そういやスペースキューブんときそうしてたなぁと)

左右移動の床のバグ

物理演算 + AddForceだけではどうも綺麗に動いてくれなかった。
しかも何故か二人目を載せるとうまく動かないというバグ付き。
自分で操作しているとそういう操作はしなくなってしまい、気付かなかった。
(一人ずつしか乗らない)

上のに被るが、物理演算ではなく自分で計算しちゃえばよかった…。
設置判定して、position一緒に足すなど...。

コード量凄く増えそうですが...。これも一緒に研究だね。

岩を押して足場にしてもらう、がわからない

片方を深い所に閉じ込めておいて、その上に押せる岩を用意していた。
「ほら、上に岩があるでしょ、それを上から落としてあげて足場にすればいいんだよ」
と用意していたんですが、無視して下に降りてしまう人が普通にいた。

そして一生懸命出ようとするが、もちろん出られない。

  • ちゃんとステージ上で教えてあげる(岩を落として足場にするんだよというメッセージを置いてあげる)
  • オブジェクトを光らせて注目させる
燭台を凍らせて足場にする、がわからない

上に同じ。
「ここに足場があったらいいなぁ」
とこれ見よがしな所に燭台をそれっぽく配置しておいたのですが、
これも気付いてもらえず…。

  • 匂わせるより、ハッキリ書く
  • てかそもそも燭台凍らせるってのはどうなんだ
  • 凍らせるんだぞ!ってオブジェクトにする
リセット機能について

ステージのオブジェクト位置を初期化する
という機能があるのだけど、これが効かないなんていう問題があった。
ブラウザをリロードしたら直ったが、これは正直予見出来なかった。

誤操作したら困るなと思っていたが、やっぱりキーボードで押せるように用意しておくべき。

ステージが多すぎた

15ステージはちょっと多かった...。
10で止めとくべきだったか。
特に9ステージと13ステージ。

見ていた感じでは1つのまとまりそこそこで
さくっと終わるのがテンポよくて気持ちいい感じだった。

後半に行くにしたがってステージが長くなるようにしていたんですが、やっぱり短めでOKみたい。
ギミックを無理に増やす必要もないしね。

以上

などなど。とにかく難しすぎたみたいだなーと反省してます。

最後に「ファイアアンドアイス」ってタイトルじゃなくて
「アイスアンドファイア」にすればよかったなぁとw
なんか色々とひっかかっちゃうんで。

結果発表は結局箸にも棒にもかからず...。しょんぼりな結果でした。

【Unity】第14回「逆」に参加。ファイアアンドアイスの開発について【unity1week】

今回も参加しました。

unityroom.com

今回のお題は「逆」ということで自分は王道の炎と氷で行くことにすんなり決めました。
みんなのゲーム見て「うおーすげぇ!その発想は無かった!」と毎回驚いています。

実は

実は直前にあほげーという24時間ゲームジャムがありまして、そこにも参加してたんです。
初めての参加でしたが、まぁまぁな結果でした。(喜ぶべき所なのか?w)
もっとこうさらーっと行くかと思いきや好成績でした。

f:id:ghoul_life:20200304002120p:plain

(特にリンクはなし。行きたい方は右のサイドバーからどうぞ)
(こっちやらなくていいんでファイアアンドアイスやってください…w)

実装の基盤について

ここからファイアアンドアイスの話題です。
ファイアアンドアイスは大きく分けて

  • UI(遷移)
  • 会話システム
  • キャラクター
  • ステージ
  • 魔法(固有アクション)


この5つで構成されています。

UI(遷移)

f:id:ghoul_life:20200304004914p:plain


ゲーム全体を管理するManagerがStateManagerを持っていて、
そのStateManagerが画面の切り替えを担当しています。
切替時にTrasitionを経由して自然に遷移するようにしてます。

TrasitionSystemはShader一個でやってまして、
パラメータとして渡したgrayscaleの画像のアルファ値を変化させることによって、
トランジションを表現してます。

f:id:ghoul_life:20200304115432p:plain

また、そのトランジションはalphaパラメータを0.0 - 2.0まで受け取ることが出来るようになっていて、
1.0以降は処理を反転させることにより、逆の表現になります。
(alpha1.0が透けて、0.0が透けなくなるイメージ)

f:id:ghoul_life:20200304115448p:plain

ちょっとわかりにくいですかね…?
詳しくは別の記事でコードごとまるっと紹介する予定。

会話システム

実は中身はM-1ボーイをまるっと持ってきてます。
なので、かなり実装はラクすることが出来ました。

f:id:ghoul_life:20200304113541p:plain

実際に作っていったのは、見た目のガワと、InGame部分のみです。
(多少調整はしてますけど)
まぁここはよくあるやーつなので割愛。
汎用的にしなくても一個だけ用意して共有にしてもよかった。

キャラクター

当初は主人公一人で、炎の魔法と氷の魔法を切り替えて...と考えていました。
そして早い段階で氷の上に乗っかりたいな、とは考えていました。
が、目の前に出す、といったものでは普通すぎるかなと。

「そうだ、キャラクターを凍らせちゃえばいいんじゃね」

と思いました。ここで2人に分裂するのですが…。

f:id:ghoul_life:20200304113956p:plain
(最初のラフ絵)

これが大変だった。切り替えたら操作も分けないとならないし、
カメラも動かさなきゃならないし、いろんな問題が発生。
必要なかったはずのCameraManager , CharacterManagerまで作る羽目に…。


立ち絵は三回書き直してます。が、ラフ線のまま塗ってます…。ペン入れする時間も惜しくて…。
でも一番楽しい時間です。まじで。時間さえあればもう少し線を綺麗にしてもっと塗りたい。

f:id:ghoul_life:20200304114105p:plain
エンディング絵です。見てくれた人もいるでしょうか。
最終日の18:00ぐらいまで描いてました...。

ステージ

今回の難関です。なんてったってステージを量産しないとなりません。
しかもステージだけではなく、ギミックまでないとゲームとしての面白さがありません。


そこでどうしたか?
そう、作るしかないのです。ステージエディタ。

ステージエディタの実装

LWRP(2D)なんで、Tilemapで行こうとは思ってました。
これでぽちぽち配置していって、ちょちょっと必要なものを置いて、ピッと1クリックで出力。
こんなものが無いとダメだなと。


まず、実際に動かすゲームとは別のステージ作成用のシーンを用意しました。

f:id:ghoul_life:20200304104934p:plain

そこにキャラ二人を置き、ギミックをD&Dで配置していって、ゴールを置いて、出力をMenuから押下。
これでStageが焼き上がります。

    [MenuItem("Stage/Export")]
    static void StageExportExecute()
    {
        var stageData = StageData.CreateStageData(); // ScriptableObjectを生成

        // Hierarchy内のオブジェクトを全部なめて目的のオブジェクトを探す
        foreach (GameObject obj in UnityEngine.Object.FindObjectsOfType(typeof(GameObject)))
        {
               // 略. GetComponentでもtagでもなんでもOK
        }

        // 適当な所に出力
        AssetDatabase.CreateAsset(stageData, path);
        AssetDatabase.Refresh();

    }

ちなみにこのシーンを実行すると即座にプレイ出来るようになってます。
まぁ細かいUIとかいらないですもんね。

ステージギミックについて

ただ、マップがあるだけではゲームとしてつまりません。
なので、ステージを彩る各種ギミックを用意しました。

  • 押せる岩
  • 燭台(ライトがわりにもなります)
  • 動く床左右
  • 動く床上下


この5つです。

実際にはこれ以外に

  • キャラ1(アイス)
  • キャラ2(ファイア)
  • ゴール


が入って8種類ですね。

ステージを作る際には「どのギミック」が「どの位置」にあるか?
を伝える事ができればいいので、StageDataはScriptableObjectでこんな感じになりました。
(これもし本当にサービスにするならテキストデータから流し込んだ方が良さげ。
JsonでもMessagePackでもいいんで。このままだとAssetBundle化して毎回ビルド必須みたいになりそ)
今回はWebGL埋め込みなんでScriptableObject直で保持します。

[CreateAssetMenu(menuName = "ScriptableObject/StageData")]
public class StageData : ScriptableObject
{
    [Header("ステージID")]
    public int StageId;

    [Header("プレイヤー1開始位置")]
    public Vector3 Player1StartPosition;

    [Header("プレイヤー2開始位置")]
    public Vector3 Player2StartPosition;

    [Header("ゴール位置")]
    public Vector3 GoalPosition;

    [Header("読み込むステージプレハブ")]
    public GameObject StagePrefab;

    [Header("ステージオブジェクト群")]
    public List<StageObjectData> StageObjectDatas;

    [Header("ステージ開始前会話")]
    public List<TalkData> _talkDatas;

    public static StageData CreateStageData()
    {
        return CreateInstance<StageData>();
    }
}

// ScriptableObjectでクラスを使うにはSerializableの設定が必要
[System.Serializable]
public class StageObjectData
{
    public int StageGimmickType; // どのオブジェクトをロードするか
    public float MoveValue = 3.0f; // 値
    public Vector3 Position; // どの位置に
}


開始前の会話データ(TalkData)もここに入ってます。
これは一つずつ手で打ってます。ここも楽しいw
二人ならどんな感じに話すかな〜と妄想を全力で膨らませられます。

f:id:ghoul_life:20200304105858p:plain

魔法

魔法はMagicManagerが管理します。教科書的なオブジェクトプログラミングです。
魔法クラスがあり、それを継承した氷魔法クラス、炎魔法クラスがあります。
当たった時の挙動をそれぞれオーバーライドで記述する事ができます。

public class Magic : MonoBehaviour
{
    // 発射処理
    public virtual void Shoot(PlayerCharacter playerCharacter)
    {
           // 発射した人を持っておいて、自分自身を移動開始
    }

    // 当たった時の処理
    public void OnTriggerEnter2D(Collider2D collision)
    {
        HitTest(collision);
    }

    // 当たった時の処理
    public void OnTriggerStay2D(Collider2D collision)
    {
        HitTest(collision);
    }

    private void HitTest(Collider2D col)
    {
          // 何かに当たってたら消す、処理を実行するオブジェクトならExecute
    }

    // 当たった時の処理
    public virtual void Execute(GameObject gameObject){}
}
public class FireMagic : Magic
{
    // 魔法によってSEを切り替える
    public override void Shoot(PlayerCharacter playerCharacter)
    {
           AudioManager.Instance.PlaySE("se_fire");
           base.Execute(player);
    }

    public override void Execute(GameObject gameObject)
    {
            // 炎魔法の処理
    }
}
public class IceMagic : Magic
{
    // 魔法によってSEを切り替える
    public override void Shoot(PlayerCharacter playerCharacter)
    {
           AudioManager.Instance.PlaySE("se_ice");
           base.Execute(player);
    }

    public override void Execute(GameObject gameObject)
    {
            // 氷魔法の処理
    }
}

炎魔法は即座に処理をするので良いのですが、
氷魔法はそうもいかないため、MagicManagerを用意して氷オブジェクトの作成と破棄を管理しています。

氷は何が凍ったか?を持っておいて、SetActiveを切っています。
そして壊すアニメを再生して、自分は破棄し、持っておいたオブジェクトをSetActiveしています。


最後に氷を破棄する時に持っておいたオブジェクトの参照をnullにしておくのを忘れないようにすること。
忘れるとメモリリークの原因になります。

おまけのLWRP(2D Light)

以前記事にしたものを使おうと思いました。

ghoul-life.hatenablog.com

ちょっとオシャレな見た目になりますし、やったことないことに
チャレンジしていかないとなりません。

特に特殊な事はやってないんですが、この氷の青い光表現がお気に入りです。

f:id:ghoul_life:20200304101340p:plain

時間が許せば炎の魔法で燭台に火をつけるオブジェクトとか用意したかったなぁ〜。
またGlobal Lightをちょーっとだけつけてます。真っ暗すぎるのも窮屈なんで。

反省点

いーっぱいあります。もう一度全部捨てて作り直したいレベル。

キャラ、オブジェクトの移動はフルKinematicsにすればよかったとか

二人乗るとおかしな挙動になる移動床バグとか

もっといろいろなギミック作りたかったとか

切り替え直後の速度変化が攻略法になっちゃってるとか
(普通にしてたら難しすぎるので切り替えた直後はゆっくり落ちるようにしてるんです。
それを利用して簡単にステージクリアした人も結構いるのではないでしょうか。
攻略法がいくつかあってもいいなーと。気づいた人へのご褒美として。)

お絵描きもっと頑張りたかったとか

ゲーム自体が長すぎるとか

もう少し尖ったアイデア出ないもんかとか…。

感想

今回もどうにか走りきりました。
とにかく時間が足りません。金曜日から半徹夜状態で
土日を駆け抜け日曜の19:00にリリース。

色んなものを削って出してもあんまり注目されずにひっそりと消えていく…。
これが実力だと受け入れなければなりません。
いつも通りですが、現実は厳しいですね。

【Unity】 DOTS(ECS)をWebGLで使う場合は思った以上にパフォーマンス出ないよって話

DOTSをWebGLで...だと?

またまたUnityの話題。DOTS(ECS)のお話だよ。


Unity1weekが近くなってきて、
「流行りのDOTSでいっちょ神ゲー作ってやろう」
なんて考えてるそこのキミに見てほしいです。

結論から言うとWebGL出力だとJob , Burst使っても
思ったよりもパフォーマンス出ないよ、って話です。

実装について

今回の調査では
「オブジェクトを大量に表示しつつRotateだけ行う」
という簡単な動きだけで計測してます。

細かい実装はほぼこれなんでそっちを読んでもらい、RotateSystemだけ載せます。

ghoul-life.hatenablog.com

#define USE_JOB
#define USE_BURST
#if USE_BURST
using Unity.Burst;
#endif
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

#if !USE_JOB

public class RotateSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        var deltaTime = UnityEngine.Time.deltaTime;
        Entities.ForEach((ref Rotation rotation, ref RotateSpeedComponent rotateSpeedComponent) =>
        {
            rotateSpeedComponent._rotationAngle += rotateSpeedComponent._rotationSpeed * deltaTime;

            rotation.Value = quaternion.RotateY(rotateSpeedComponent._rotationAngle);

            if (rotateSpeedComponent._rotationAngle >= 360.0f)
            {
                rotateSpeedComponent._rotationAngle -= 360.0f;
            }
        });
    }
}
#endif

#if USE_JOB
public class RotateSystem : JobComponentSystem
{
#if USE_BURST
    [BurstCompile]
#endif
    struct RotateJob : IJobForEach<Rotation,RotateSpeedComponent>
    {
        public float deltaTime;

        public void Execute(ref Rotation rotation, ref RotateSpeedComponent rotateSpeedComponent)
        {
            rotateSpeedComponent._rotationAngle += rotateSpeedComponent._rotationSpeed * deltaTime;

            rotation.Value = quaternion.RotateY(rotateSpeedComponent._rotationAngle);

            if (rotateSpeedComponent._rotationAngle >= 360.0f)
            {
                rotateSpeedComponent._rotationAngle -= 360.0f;
            }
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new RotateJob() { deltaTime = UnityEngine.Time.deltaTime };
        return job.Schedule(this, inputDeps);
    }
}
#endif

(雑にdefineでJob、Burstの使う使わないを切り替えれるようにしてます。
実際に使うときはこんなん作らないからね。適当です。)

PCでEntityのみの場合(50000object)

まず、PCでEntity + ComponentSystemだけで50000Object出すと
このぐらいのパフォーマンスになります。

vimeo.com

(13~14FPS)

PCでフルセットの場合(50000object)

次にJobSystemとBurstコンパイラを利用した形式に変えて
再度計測してみます。するとこのぐらいのパフォーマンスになります。

vimeo.com
(24~25FPS)


10も上がった!!
いいですね、このぐらい目に見えて良くなると実装しがいがあるというもの。

WebGLはどうなの?

unity1weekで使いたい、サクッとgithubで公開したい、
といった方はWebGLでの出力が候補になります。
50000objectだと重すぎて動かなかったので、10000objectまで下げてます。
また、ブラウザはFireFoxで見てます。

WebGLでEntityのみ(10000obj)

vimeo.com
12FPS

オブジェクト数下げてもこれ。
うーん、苦しいです。最小構成でこれでは
実際のゲームでは使い物にならないでしょう。

WebGLでフルセット(10000obj)

vimeo.com
15FPS

JobSystemとBurstコンパイラを利用した形式に変えてみましたが微増。
これでも苦しい...。

結論として

WebGLでDOTS使いたいなら5000object程度に留めておいた方が良さそうです。

過去作った全てを破壊したいUnityちゃん、というゲームでも
1000object以下で収まるように調整してました。
(WebGLではあれ以上街を広げられない)

unityroom.com

それでも3倍はいけるというDOTSの実力には脱帽ですね。
誰かunity1weekでドドドッと襲いかかってくるゾンビを
ひたすら撃ちまくるゲームとか作ってください。

補足として

偉大な先駆者maoさんがすでにunity1weekでDOTSにチャレンジされています。

unityroom.com

早速遊んでみたのですが僕の環境ではゲームをブラウザで開くと
まともに動かずガクガクで...。
githubにある動画ほどのスムーズさも見えず...。
もしかして自分のPCが弱いのでしょうか?

このぐらいまともに遊べるぜ!って人PCスペック教えてください...。

ソースまで公開されてます!神さま〜〜!
github.com

さらに補足

スーギ・ノウコ自治区さんに教えていただきました!
やはりWebGLは現時点でSIMDとマルチスレッドをサポートしていない様子。

またWebAssemblyの情報まで!

ありがとうございます〜。