レイドボス「naichi」登場!というゲームを作りました。

概要

第10回目開催、おめでとうございます!
unityroomさんにはお世話になっていて、リスペクトを表現したく
いつかnaichiさんをラスボスとしたゲームを作りたいなー考えていました。

そして、今回ついに作ることができました。
レイドボス「naichi」登場!

レイドボス「naichi」登場! | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

燃え尽き症候群となったunityroom運営者を
みんなでクリックして褒めて、やる気を出してもらうゲームです。

企画する

以下2つの条件を満たすものを考えていました。

  1. naichiさんがラスボス
  2. unityroomの人ならすごく盛り上がれる何か

実在する1人の人をラスボスにしている時点で、楽しめる人が限られる。
ならいっそもうunityroomに全寄せしてしまおう!と考えました。

で、プラットフォーム系のサービスはそこで活動しているクリエイターさんありき、だと思ったので
じゃあnaichiさんだけじゃなくて他の開発者様にも登場してもらおう!と。

それから開発期間1週間の制限を合わせ、考えついたのが
プレイヤー協力型のクリッカーゲーム「レイドボス「naichi」登場!」です。
「unity1weekに挑戦している」というゆるい繋がりで結ばれたプレイヤー同士で遊んだら
面白いんじゃね?と予想してました。

交渉する

今回の製作で一番難しかったところです。

勝手に名前を使うのはご法度、なので
登場して欲しい開発者様にコンタクトをとりました。
面と向かって話したこともあったこともない人に
コンタクト時何伝えようか悩んだんですが、とにかく「意味が伝わるか?」を色々考え
最終的に4点を意識するようにしました。

  • 自己紹介 (unityroomで動いている人間です)
  • こっちがやりたいこと (ゲームのOPで開発者を登場させたい)
  • ご協力いただく範囲 (Twitterアイコンとこちらで考えたセリフの使用許可)
  • ご協力いただいた際のリターン (クレジットにお名前表示、あれば拡散したいもののURLなど)

コンタクトとった6名の開発者様からは初回の返信で、 「全面OK、またはセリフだけ変えればOK」
でしたので、こちらの意図はうまく伝わったのかなと思います。

リターンについては、こちらが提示できるものが少なく...
この条件でご協力いただいた開発者様に感謝申し上げます。

それから、unityroom開発者の中で誰に依頼するか...なのですが
界隈で活躍されている方の中で、

  • 自分がリツイートとかいいねとかちょいちょいしている人
  • TLをみて、コラボ系の話乗ってくれそうと判断した人
  • TLをみて、すごく忙しくなさそうな人
  • ツイッターアイコンが顔写真じゃない人

であれば、快く承諾してくださるかなーと思ってお声がけしました。
OKをいただいた6名の開発者様の他にも何名か依頼しようとしていたのですが、
DMが解放されていなかったり、話しかけて良いかわからず不安だったりで
最終的にコンタクト取らなかった方もいらっしゃいます。

今回は穏便に行きましたが、1歩間違えると大炎上およびunityroom出禁のリスクがある、
というのを感じてて、非常にドキドキしてました。

まとめ

次回開催時、開発者の名前を使いたい方はぜひ自分にお声がけください。welcomeです。
チーム開発もお待ちしてます(チームで開発したことないですが...)
そして、unityroomに感謝!

おまけの「開発する」

全部かくと大変なので、工夫したところを絞って書きます。

シーン全体で参照したいデータについて

小規模個人開発で使える、シングルトンでデータを持たせる形にしています。
どんなシングルトンかというと。。。

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using CoffeeR.Model;

public class ServiceLocator : SingletonMonoBehaviour<ServiceLocator> {

    /// <summary>
    /// シーン毎に初期化されるコンテナ
    /// </summary>
    List<object> sceneContainer;

    override protected void Awake ()
    {
        if (this != Instance)
        {
            Destroy(this.gameObject);
            Debug.Log(
                typeof(ServiceLocator) +
                " は既に他のGameObjectにアタッチされているため、コンポーネントを破棄しました." +
                " アタッチされているGameObjectは " + Instance.gameObject.name + " です.");
            return;
        }

        Initialize();

        DontDestroyOnLoad(this.gameObject);
    }

    void Start () {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    void OnSceneLoaded( Scene scene, LoadSceneMode mode ){
        Initialize();
    }

    void Initialize() {
        sceneContainer = new List<object>();

        var gameState = new GameState();
        var score = new Score();
        var tooltip = new ToolTipModel();
        var todo = new Todo();

        sceneContainer.Add(gameState);
        sceneContainer.Add(score);
        sceneContainer.Add(tooltip);
        sceneContainer.Add(todo);
    }

    public T Get<T>() {

        for(int i = 0; i < sceneContainer.Count; i++){
            if(typeof(T) == sceneContainer[i].GetType()){
                return (T)sceneContainer[i];
            }
        }
        
        throw new System.Exception(typeof(T) + "のインスタンスを取得できませんでした。");
    }
}

シーン全体で使う変数はクラス化、インスタンスを「sceneContainer」に入れてます。
使うときはこんな感じで、Get()だけ使えばよし。

var score = ServiceLocator.Instance.Get<Score>();
var gameState = ServiceLocator.Instance.Get<GameState>();
ボタンのアニメーションについて

押せるボタンについては、アニメーションの処理が記述されたコンポーネントをアタッチしてます。

using UnityEngine;
using UnityEngine.EventSystems;

public class GeneralUIReaction : MonoBehaviour , IPointerEnterHandler, IPointerClickHandler, IPointerExitHandler{

    public void OnPointerClick(PointerEventData eventData){
        // ここにクリックした時のアニメーション処理
    }

    public void OnPointerEnter( PointerEventData eventData ){
        // ここにマウスポインターが入った時のアニメーション処理
    }

    public void OnPointerExit(PointerEventData eventData){
        // ここにマウスポインターが離れた時のアニメーション処理
    }
}

ゲーム上で押せるボタンはどれなのか、プレイヤーに早く学習させたかったので
どの押せるボタンにも、上記のコンポーネントをアタッチしました。

リアルタイムっぽくクリック数を集計する

クリック数はニフクラのモバイルバックエンドから取ってきてます。
で、10秒おきに各プレイヤーのクリック数をsumするという、
日頃DB触っている人にとっては激おこ案件になる恐ろしいことをしています。

// ※UniRxというライブラリを使ってます。

//  10秒おきに、
Observable.Interval(System.TimeSpan.FromSeconds(10))
.Subscribe(_ => {
        // サーバーにある、自身以外のプレイヤー全員のスコアを通信して取得
    Observable.FromCoroutineValue<int>(NCMBClient.LoadOtherPlayersScore)
    .Subscribe(otherScore => {
                 // 通信して取得したデータを、変数に入れておくよ
        score.OtherPlayersScore.Value = otherScore;
        score.OtherPlayersScoreOfLoadTiming = otherScore;
    }).AddTo(this);
}).AddTo(this);

開発中のゲームを展示・試遊会に持っていってやらかしたこと一覧

概要

開発者が集まってゲームを見せ合ったり情報交換する集まりに2回参加し、
会場で開発中のゲームを展示しました。
外部で展示するのは初めての経験だったのですが、
他の展示者と比べて「こうした方が良かった」点があったり、
1回目と2回目の差で色々気づきがあったので、書いていきます。

展示した時の状況
  • PC1台を持ち込み
  • 会場の人数は体感で50人
  • 同様に展示している方が数名
  • 参加者の何割かが外国出身

やらかしたことまとめ

進行不能バグがある

バグったたびにアプリを再起動しなきゃいけない。
この作業中、どんな目でプレイヤーに見られているか不安になり背筋に冷や汗がつたう。
貴重な時間を奪ってしまうし、展示側としては精神衛生上よくない。
バグっている部分は展示では見せない!

プレイされていない時にタイトル画面を表示

通りすがった人が、どんなゲームなのかわからない。
そもそもゲームなのかわからない。
プレイされていない時はトレーラーやデモ動画を流す!

英語が話せない・英語で伝えられる媒体がない

日本語通じない人がいる。。。
操作方法も、コンセプトもうまく伝えることができない。
次回参加時は「どんなゲームか」「操作方法はこうです」といったのが英語まとめられている 立て札を用意して参加してみようと思います。

名刺がない

いろんな人(開発者、作曲、ライター、etc)がきて名刺を渡してくれる中で自分の名刺がない。
いただいたのに返せないというのが精神衛生上よくなかったので
自分の名刺を持つ!

思ったようにコンセプトが話せない

開発作業ばっかに集中しているためか、緊張してしまって
どんなことが体験できるの?ウリなの?がきちんと話せない。
営業トークの練習をしておく!

スマホをいじってしまう

展示会、一人ぼっちで寂しくてスマホ触っていたのですが、
後々考えるとプレイヤーさんが話しかけにくい状況だったかなと思ってます。
スマホいじらない!

無表情

色々初めてのことだらけで精一杯で、振り返ってみると切羽詰まった顔になってたかもしれないです。
ニコニコされている展示者の人がいたのですが、その人の周り結構人集まっていたので
ニコニコする!

ゲームパットで操作できない

周りで展示されているほとんどのコンソールゲー展示者、ゲームパット対応しているのに対し、
自分のゲームはキーボード対応しかしておりませんでした。
展示会の状況とかにも寄るかと思いますが、
プレイ時のインターフェースは他開発者に似せた方が遊びやすいのかなと思いました。
ゲームパット対応する!

アドバイスを聞く姿勢

2回目参加時に、プレイヤーの目の前でメモ書きはじめたところ、
プレイヤーの方がどんどん意見を言ってくれたことがありました。
話聞いてますよ、大事にしますよっていうのが伝わると思ったので
プレイヤーの目の前でメモを取る!

【Unity】LineRendererを使ってお絵かきソフトにある機能を実装する その2

概要

LineRendererを用いての対称定規や円、螺旋の描き方について
実装方法を記載します。

綺麗な円の描き方等がなかなか見つからなかったのですが、
誰かに需要あると信じてまとめます。

実装したもの

f:id:coffee_ryo:20181014005607g:plain

サンプルとして以下を作りました。

  • フリーハンド
  • 対称定規
  • 螺旋
  • 正方形っぽい形

ドラッグで線の描画、右クリックでUndoができます。

処理の流れ

マウスの位置情報と線のタイプをインプットとします。
線のタイプによって、線の数の決定や座標変換を行い、
最終的にできた座標をLineRendererに渡して描画します。
ここでの線のタイプは、円とかフリーハンドか対称定規か、などを指します。

f:id:coffee_ryo:20181014011728p:plain

実装サンプルについて

Materialの作成

描画の色を設定する用途で、Projectタブ上にMaterialを作成します。

f:id:coffee_ryo:20181014012258p:plain

Prefabの作成

空のオブジェクトを作成し、LineRendererをアタッチ。
LineRendererのMaterialsに先ほど作成したMaterialを設定したら
オブジェクトをPrefab化します。

f:id:coffee_ryo:20181014012334p:plain

お絵描き用のオブジェクトを作成する

空のオブジェクトを作成し、後述するIllustDrawerコンポーネントをアタッチ。
インスペクター上で先ほど作成したPrefabを設定をしたら、準備完了です。

f:id:coffee_ryo:20181014012932p:plain

ソースコード

IllustDrawer.cs

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

namespace CoffeeR.Paint{
    public class IllustDrawer : MonoBehaviour {

        [Header("LineRendererがついたPrefab指定")]
        [SerializeField]
        LineRenderer lineRendererPrefab;

        [Header("線の太さを指定")]
        [SerializeField]
        [Range(0.05f, 1.0f)]
        float lineWidth;

        [Header("線のタイプを指定")]
        [SerializeField]
        EnumLineType lineType;

        [Header("円等を描く時の中心点を設定")]
        [SerializeField]
        Vector3 centerPosition;

        /// <summary>
        /// 描画コンポーネント群
        /// </summary>
        List<List<LineRenderer>> lineRendererMultipleList;

        void Start () {
            lineRendererMultipleList = new List<List<LineRenderer>>();
        }

        void Update () {
            if(Input.GetMouseButtonDown(1)){
                UndoLine();
            }
            if(Input.GetMouseButtonDown(0)){
                CreateLineRendererObject(lineType);
            }
            if(Input.GetMouseButton(0)){
                var mousePosition = GetPostionOfInput();
                DrawingLine(mousePosition);
            }
        }

        /// <summary>
        /// レンダラー付きのオブジェクトを作成する
        /// </summary>
        void CreateLineRendererObject (EnumLineType type) {
            lineRendererMultipleList.Add(new List<LineRenderer>());

            // 線を作成する個数を設定
            int lineCount = 0;
            switch(type){
                case EnumLineType.FreeHand:
                case EnumLineType.Circle:
                case EnumLineType.Spiral:
                    lineCount = 1;
                    break;
                case EnumLineType.LikeSquare:
                    lineCount = 4;
                    break;
                case EnumLineType.Symmetry:
                    lineCount = 12;
                    break;
                default:
                    Debug.LogError(type.ToString() + "での線の本数が指定されていません。");
                    break;
            }

            for(int i = 0; i < lineCount; i++){
                // 描画コンポーネントがついたオブジェクトを作成
                LineRenderer line = Instantiate(lineRendererPrefab);

                // 太さを設定する
                line.startWidth = lineWidth;
                line.endWidth   = lineWidth;

                // 子オブジェクトに設定
                line.transform.parent = this.transform;

                // 作成した描画コンポーネントをこのクラスにキャッシュする
                lineRendererMultipleList.Last().Add(line);
            }
        }

        void DrawingLine (Vector3 mousePosition){

            int positionIndex = 0;

            switch(lineType){
                case EnumLineType.FreeHand:
                    lineRendererMultipleList.Last().Last().positionCount++;
                    positionIndex = lineRendererMultipleList.Last().Last().positionCount;
                    lineRendererMultipleList.Last().Last().SetPosition(positionIndex - 1, mousePosition);
                    break;
                
                case EnumLineType.Circle:
                    lineRendererMultipleList.Last().Last().positionCount++;
                    positionIndex = lineRendererMultipleList.Last().Last().positionCount;

                    if(positionIndex == 1){
                        lineRendererMultipleList.Last().Last().SetPosition(positionIndex - 1, centerPosition + mousePosition);
                    }
                    else{
                        var firstPosition = lineRendererMultipleList.Last().Last().GetPosition(0);
                        lineRendererMultipleList.Last().Last().SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler (0f, 0f, positionIndex * 5) * (firstPosition - centerPosition));
                    }
                    break;
                
                case EnumLineType.Spiral:
                    lineRendererMultipleList.Last().Last().positionCount++;
                    positionIndex = lineRendererMultipleList.Last().Last().positionCount;

                    if(positionIndex == 1){
                        lineRendererMultipleList.Last().Last().SetPosition(positionIndex - 1, centerPosition + mousePosition);
                    }
                    else{
                        var firstPosition = lineRendererMultipleList.Last().Last().GetPosition(0);
                        lineRendererMultipleList.Last().Last().SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler (0f, 0f, positionIndex * 5) * (firstPosition  - centerPosition) / (1 + positionIndex * 0.025f));
                    }
                    break;
                
                case EnumLineType.Symmetry:
                    var degreeIndex = 0;
                    foreach(var line in lineRendererMultipleList.Last()){
                        line.positionCount++;
                        positionIndex = line.positionCount;
                        line.SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler (0f,  0f, degreeIndex * 360 / 12) * (mousePosition - centerPosition));
                        degreeIndex++;
                    }
                    break;
                
                case EnumLineType.LikeSquare:
                    for(int i = 0; i < 4; i++){
                        lineRendererMultipleList.Last()[i].positionCount++;
                    }
                    positionIndex = lineRendererMultipleList.Last().Last().positionCount;


                    if(positionIndex == 1){
                        for(int i = 0; i < 4; i++){
                            lineRendererMultipleList.Last()[i].SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler(0f, 0f,   (i - 1) * 90) * mousePosition);
                        }
                    }else{
                        var vertexPos = lineRendererMultipleList.Last()[0].GetPosition(0) - centerPosition;
                        lineRendererMultipleList.Last()[0].SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler(0f, 0f,   0f)  * vertexPos + positionIndex * Vector3.down  * 0.1f);
                        lineRendererMultipleList.Last()[1].SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler(0f, 0f,  90f)  * vertexPos + positionIndex * Vector3.right * 0.1f);
                        lineRendererMultipleList.Last()[2].SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler(0f, 0f,  180f) * vertexPos + positionIndex * Vector3.up    * 0.1f);
                        lineRendererMultipleList.Last()[3].SetPosition(positionIndex - 1, centerPosition + Quaternion.Euler(0f, 0f,  270f) * vertexPos + positionIndex * Vector3.left  * 0.1f);
                    }
                    break;

                default:
                    Debug.LogError(lineType.ToString() + "での線の描き方が指定されていません。");
                    break;
            }
        }

        /// <summary>
        /// 一つ前の状態に戻す
        /// </summary>
        void UndoLine(){
            try{
                var lastLineRendererList = lineRendererMultipleList.Last();
                foreach(var line in lastLineRendererList){
                    Destroy(line.gameObject);
                }
                lineRendererMultipleList.Remove(lastLineRendererList);
            }catch(System.InvalidOperationException){
                Debug.Log("線がないためUndoされませんでした");
            }
        }

        /// <summary>
        /// 入力位置を返却する
        /// </summary>
        /// <returns></returns>
        Vector2 GetPostionOfInput(){
            Vector3 position = new Vector3(Input.mousePosition.x, Input.mousePosition.y, Camera.main.nearClipPlane + 1.0f);
            return Camera.main.ScreenToWorldPoint(position);
        }
    }

    /// <summary>
    /// 線のタイプ
    /// </summary>
    internal enum EnumLineType{

        /// <summary>
        /// 自由線
        /// </summary>
        FreeHand,
        /// <summary>
        /// 対称定規
        /// </summary>
        Symmetry,
        /// <summary>
        /// 円
        /// </summary>
        Circle,
        /// <summary>
        /// 螺旋
        /// </summary>
        Spiral,
        /// <summary>
        /// 正方形状
        /// </summary>
        LikeSquare
    }
}

それぞれの線について

Quaternionを使って座標変換をするのがポイントになります。

マウス入力してからの1フレーム目の、マウスの位置と中心点との間を半径とします。
フレームを進めるごとに、「1フレーム目のマウス位置」を中心点から回転させた位置を描画していきます。

f:id:coffee_ryo:20181014032908p:plain

螺旋

円と類似していますが、フレームを進めるごとに半径を小さくしていきます。

対称定規

複数のLineRendererを動かします。
マウス入力位置を、中心点から一定数回転させた位置をLineRendererに設定します。
ソースコードにてList<List>という構造を使っているのは、これのため。

正方形状

複数のLineRendererを動かします。
マウス入力位置を、中心点から90度ずつ回転させた位置をLineRendererに設定します。

Sprite.Createのパフォーマンスについて

概要

重たい!
どれくらい重たいのか、計測した。

docs.unity3d.com

なかなか時間がかかった。

環境

Unity 2018.2.0f2
MacOS
Intel Corei5 2GHz
8GB メモリ
1つのTextureで最大100KB

時間の計測

1回実行するごとに、0.09秒

System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();

    foreach(var name in nameList){
        var imageData = illustDataIO.LoadIllustTexture2D(name);

        sw.Start();
        var createSprite = Sprite.Create(imageData, new Rect(0,0,imageData.width,imageData.height), new Vector2(0.5f, 0.5f));
        sw.Stop();
                                
        createSprite.name = name;
        spriteList.Add(createSprite);
    }
Debug.Log("画像生成" + nameList.Count);
Debug.Log("time" + sw.ElapsedMilliseconds / nameList.Count);

100枚作ったら、9秒かかる計算

対処した方法

  • ゲーム起動時に必要となる画像を全て生成しておく
  • シングルトンオブジェクトにて、生成した画像をキャッシュ
  • ゲーム進行中に画像が追加されたら、シングルトンにも追加

ビンゴゲーム終了時間シュミレーターを作りました

概要

参加人数や景品数を元に、ビンゴゲームの想定終了時間を算出するツールを作りました。
会社の飲み会や結婚式二次会の幹事の方、よかったら使ってください。

https://coffee-ryo.devel.jp/app_Bingo.html

作った背景

会社の忘年会の幹事頼まれてやったことがあって、タイムスケジュール組むんですが
毎回ビンゴゲームの所要時間がわからなくて困ってたんですね。

確率が絡んでくる以上、予想する時間に幅を持たせなきゃいけないけど、 どれくらい幅を持たせなきゃいけないかとか、よくわからなかったんです。

この時間の見積もりがサクッとできたら便利だなーと思ってて、ついに作ってしまいました。

実力で完全敗北した1週間ゲームジャムに参加した話

f:id:coffee_ryo:20180910151332p:plain

概要

「ゲーム作って遊んでもらってちやほやされたいんや!」

その気持ちで、TOP10%入りを目指し奮闘しました。

今回は以下条件で参加しました。

  • 2人で開発(相手はunity未経験、ゲーム開発未経験)
  • 筆者フル稼働(仕事をやめてた関係で、毎日14時間、total90時間くらい思考&作業できた)

時間もリソースも十分にある中で、TOP10%からは程遠く、もう完全に実力で敗北したと宣言して良いでしょう。
上位に行けなかったと思われる理由を書き残しておきます。

どのゲームジャムに参加したかはこちら⬇︎

何を作ったかはこちら(音量小さめ)⬇︎

Burning Snowman | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

ステージクリア型の2Dアクションゲームです。

よくなかったところ

難しすぎた!

ゲーム公開から1時間、プレイいただいた方のプレイ履歴を追っていたところ
全員クリアまでたどり着けず、1〜8分の内に離脱 / ゲームオーバー回数1~9回 の内に離脱していました。
またステージの1/5しか進んでいませんでした。

初回プレイでクリアできて達成感を感じるくらいの難易度調整であれば、結果は変わっていたかもしれません。

開発者がずっとプレイしていると、感覚がおかしくなって調整が難しいので、
次回はリアルな場で人に見せて、フィードバックをいただくようにしてみます。例えば、#WeeybleGame さんとか!

weeyble-game.connpass.com

クリアまでの時間が長すぎた!

1個のステージで、1プレイ2分、当初想定していたゲームオーバー数と合わせると6分くらいでクリアを考えていましたが、良評価もらっているゲームと比べると必要プレイ時間が長かったと思いました。

先述の離脱データを見る限り、1分で離脱されてしまう人がいるので
unityroomのゲームジャムで高ランクを狙うには
1分以内に満足いただけるゲームが要求されるみたいです。
最後までプレイすれば面白いゲームでも、最後までプレイしてもらわないと意味がない。。。

ステージクリア型であれば、20秒くらいで終わるステージを複数用意して、
1プレイの時間を短くするアイデアもあったかなと思います。

ビジュアル力が足りなかった!

f:id:coffee_ryo:20180910132527p:plain

閲覧数、上位陣に比べると低かったです。

ツイッターで告知する際の拡散力も全然違うし、絵が可愛かったらSNSシェアしても恥ずかしくないし、
unityroom内のサムネなんかでも判断されてしまうのかなと感じました。

筆者、ドット絵とか2Dイラストとか練習しているんですけど、どうしても絵を書くのが苦痛に感じてしまいしんどいポイントではあります。
苦痛に耐えるしかないかもしれない。。

絵は描けるけどスクリプティングができない方、次回開催時に
もし興味があれば声かけていただけるととっても嬉しいです。

まとめ

なんとか完成までこぎつけられたので、よかったです! 学ぶことも多かった。  

何回か参加しているとだんだん理想が高くなっていくのですが、実力が伴っていませんでした。
非常に残念な結果に終わってしまいましたが、TOP1を取るまで走り続けようと思います。

その他

期間中の動きについて

どのような流れで企画が立ったか、実装面で工夫したところなど。
あと、プレイヤーの行動ログどうやって見たかとかの話など。詳しくはこちら⬇︎

coffee-ryo.hatenablog.com

事前準備

1週間始まる前に準備色々してました。詳しくはこちら⬇︎

coffee-ryo.hatenablog.com

「Burning Snowman」が出来上がるまでの話

概要

雪だるまが松明を持って、町を温めて行くゲームを作りました。

Burning Snowman | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

あらゆるアクションでヒットポイントが減っていくハードなアクションです。 アクションごとにヒットポイントの減る量は異なります。減りの少なさに応じて、プレイヤーのスキルを要求します。

完全敗北した話も書いてます。よかったらこちらもどうぞ。

coffee-ryo.hatenablog.com

こちらの記事では、
どのような流れで企画が立ったか、実装面で工夫したところなどつらつら書き残しておきます。

  • 2人で開発(相手はunity未経験、ゲーム開発未経験)
  • 筆者フル稼働(毎日14時間、total90時間くらい思考&作業)

企画の話

メンバー全員でブレストして、両者いいねってなったものを採用することにしました。

頭の中にあるものを吐き出す
  • ガリガリ君ガリガリ君を食べて暑い中長生きする
  • 太陽で人を焼き払うゲーム
  • 焼き殺す対応vs夜を待つ人間のオンライン対戦

構図があまりわかなかったのでNG
オンライン対戦は、条件が同じプレイヤー出ないと、調整がしんどそう

みんながわかるもの
  • 焼き土下座のクリッカーゲーム
    • 暑さを我慢するこで、謝罪をするクリッカーゲーム
    • 耐えられなかったら再び謝罪

シンプルすぎるからもう少し駆け引き的なものが欲しいとのことでぼつ

ジレンマが起きる構造を元に思考
  • 洞窟の奥底を目指して、コウモリを火炎放射器で焼き進んでいき深さを競うゲーム
    • 火炎放射器を使っていけばコウモリを倒して道を安全に進めるが、使えば使うほど二酸化炭素が増えて死に至る

暑さでダメージを受けるキャラがいいということで思考した結果、ゆきだるまに行き着く。

  • 雪だるまが松明を使って、世界を救うアクションorアドベンチャー
    • 松明を使えば、世界を暖かくできるが暑さで自身も溶けていく
    • あついがテーマなのに、まさか雪国をステージにする開発者いないよねってなって、目立つかなって思った
    • キャラ設定とジレンマによって平和と悲しさがある壮大なストーリーを成立させることもできそうだ

プログラムの話

実装方針
  • クラス名は、何をするかを明確にしておき、クラス名に関係のない(または薄い)挙動は別のクラスで実装
  • uGUI周りはMV(R)Pのアーキテクチャで実装(UniRx使って)
  • コンポーネントやゲームオブジェクトの参照について、インスペクターからさせないように努力(今回は使い方覚える体でZenject)
  • インスペクターから変更できるパラメタはHeaderアトリビュート必須 パラメタ調整を分業できるように

設計については、過去2Dプラットフォームのゲームを作ってた経験から
なんとなくこういう作りにしておけばいいかなっていう当たりがついていたので、特にクラス図などは書きませんでした。
クラス名明確に付けることを心がけたことで、「あの挙動変更したいな〜」とか思ったらどこ変えればいいかの当たりがすぐに付きました。

例えば、プレイヤーオブジェクトについているコンポーネントはこんな感じです。

f:id:coffee_ryo:20180910142524p:plain

移動の挙動を調整したかったらPlayerMoverを見るとか。

Zenjectは使う必要が薄いというか、参照だけ取るのが目的だったら別の実装でよかったかな、と思ってます。
システムを動かす前提条件を差し替えるのを容易にしてくれるのを手伝ってくれる認識ですが、interfaceとか全く切らずに作ってたので全く使いこなせてなかったです。

最適化

そこそこできた段階でそんなに問題になっていなかったのですが、PCのスペックとかで重たかったりするのかなーとか考え、
少しでも快適に遊んでもらえるようにちょっとだけ入れました。
ほとんど効果ないかも。。

  • 文字列連結周り(FastStringで対応)
  • 敵の攻撃弾オブジェクトの使い回し(UniRxのPoolingで対応)
  • コルーチンのyield returnで毎回newしているのは消してGCAllocate抑える

UIの話

ボタンとツールチップ

Modern UI PackというAssetが見栄えの点で優秀だったので使用させていただきました。
ボタンやツールチップはほぼこちらのアセットです。
(エディタ拡張とかがうまく動かなく動作が怪しいので、商用とかには使いにくいかも)

assetstore.unity.com

操作できるUIは、マウスオーバーしたらSEを鳴らすようにしています。
操作できますよ、という合図がわりです。

選択肢が複数ある場面では、ボタンに必ずツールチップを付けるようにしました。
説明の補足っていう意味合いもあるんですが、実のところは「今どの項目を選択しているか」をプレイヤーに理解いただくのが目的です。

ツールチップなし⬇︎

f:id:coffee_ryo:20180910143658p:plain

ツールチップあり⬇︎

f:id:coffee_ryo:20180910143219p:plain

背景ぼかし(Brur)について

モーダルウィンドウを出した際ウィンドウを集中して見て欲しかったので背景をぼかすようにしました。

f:id:coffee_ryo:20180910170839g:plain

ヒットポイントゲージについて

ピンチ感を煽るのが狙いで、被弾していくごとに青色→赤色にアニメーションするようにしました。
それから、ステージの設計と相談して配置位置は画面下にしてます。

イラストとアニメーションの話

メンバー全員が描けない。。。ってなって、いらすとやさんのものを使わせていただきました。
今回はこちらの面で失敗しました。
SNSでの拡散やサムネのことを考えると、ここは力を抜いてはいけない箇所でした。

サウンドの話

「自作するのもいいけど、他に優先することあるよね」ってなって後回しになった結果
各素材サイトの作品を使わせていただくことになりました。

それぞれのSceneではサウンドを変更し、場面転換したことを音で伝えるようにしておきました。

その他

空気感の表現

f:id:coffee_ryo:20180910165623p:plain

背景の山は遠くにあるものと近くにあるものでスクロール量に変化を持たせ、空気感を与えました。
また、雪のパーティクルについては遠くにあるものほど落下速度をゆっくりに・色を白に近づけるようにしました。

松明で明るくなっているプレイヤーを表現するため、ポストプロセスを使って画面四隅を暗くしてます。

会話シーンスキップ

周回プレイをして高スコア目指す人いるよねって話になって、会話スキップ入れたいという話が出ました。

当初ボタンで済ませようかな〜って考えていたのですが、物語見たかったけどマウスクリックしてたら勝手にスキップしたとか、 じゃあ確認画面挟むかってなっても操作1回増えるので、ボタン以外の方法で実装しました。

f:id:coffee_ryo:20180910144849g:plain

会話シーンでマウスやスペースキー長押しでスキップできるようにしてます。
オクトパストラベラー(と、あとツイッターで誰かがつぶやいていたゲームがあったけど、、思い出せない) で使われている手法です。
物語楽しみたい人、スキップしたい人の折衷が取れていると思います。これ考えた人ほんとに天才。。。

ゴール地点を見せる

ここまでたどり着けばゴールですよ、というのを伝えるのを狙いとして取り入れました。
これがないと、どこまで進めば良いのかわからない。

Hollow Knightのようにあえてゴールを見せずに、不安感を煽るアイデアもあるので、全てに当てはまるわけじゃない。

伝える情報を順に出す

聖徳太子のような、複数の情報をまとめて出して回答させるの、疲れちゃうかなと思ったので
できるところは小分けにして順々に伝えるのを意識しました。
例えばクリア画面の例⬇︎

f:id:coffee_ryo:20180910150331g:plain

「遊び方わからない→離脱」を避ける

キーボードどれ押せばいいの?とかが分からずに、過ぎ去られるのが嫌だったので色々気を使いました。

起動直後の初めの会話シーンは、マウスクリックのアイコンで進めますよアピールをしてます。

f:id:coffee_ryo:20180910151225p:plain

初めの会話シーンが終わった後のタイトル画面では、日本語で何を押せば進めるのか記載しておきました。
これについては、青木ととさんが作られたゲームのUIを参考にしてます。

f:id:coffee_ryo:20180910151332p:plain

青木ととさんのゲームはこちら⬇︎

Out of a Hole | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

それから、チュートリアルステージを用意しました。
理想を入れば、こんなテキストで説明したくない(テキスト読んで理解するのめんどくさい、マリオのように理解させたい)のですが、
キーボード使う関係上入れておきました。
こちらでも、伝える情報を順に出すのを意識してます。

f:id:coffee_ryo:20180910152145p:plain

チュートリアルステージでは敵がすでに配置されているのですが、
「このオブジェクトは敵だ」というのを認識してもらうためにわざと被弾する配置に組んでます。

f:id:coffee_ryo:20180910152225p:plain

初見では多分敵と認識できてないはず、なので被弾します。

f:id:coffee_ryo:20180910152748p:plain

被弾を繰り返すと、下部のゲージが減少し警告色になるようにしてます。この流れで、「こいつと接触したらいけないんだな、ジャンプで回避しよう」という思考に持っていき、ジャンプの使い道を覚えてもらいます。

少し進んだ先では、ジャンプでは乗り越えられない敵を用意しておいてます。

f:id:coffee_ryo:20180910153436p:plain

こういう場面では、松明を使うことで敵を溶かし先に進めることを覚えてもらいます。

各アクションにはダメージ差があるのですが、遊び方の説明で多分きちんとできてなかったです。
ジャンプでは低ダメージ、松明では中ダメージ、それから敵の被弾は大ダメージで組んでます。
ジャンプはhigh risk / low use、松明はlow risk/ high use の関係としてアクションの取捨をしてもらいたかったのですが。。
申し訳程度に、HPが減った時にキャラの上部にダメージ数をテキスト表示させ、ダメージ数に比例してテキストを大きくするのを入れておきました。

ポーズの一工夫

再開するボタンを押したらすぐ始まるのではなく、一呼吸置いてから始まるようにしています。
画面の状態をinputしてもらって、次のアクションどうしようかなっていうのを考える余地を与えるのが狙いです。
これは音ゲーとかのポーズを参考にしました。

f:id:coffee_ryo:20180910154751g:plain

※ポーズしても敵が動くというバグがあったため、あんまり役に立たなかったかも。。

離脱ポイントの記録

テラセネを作られている方のブログを見て、ファネル分析たる存在を知り良さそうだったので、1weekでも取り入れてみました。

テラセネって何?って方はこちら⬇︎

shakeflower.hatenablog.com

今回作ったゲームでは、いくつかチェックポイントが用意されてます。
チェックポイントを通過したらサーバーにリクエストをかけ、プレイヤーの行動ログを記録しておきます。

  • 特定シーン読み込み時
  • 特定オブジェクト接触

記録しているデータはこんな感じです。

f:id:coffee_ryo:20180910160615p:plain

これを見ることで、「〇〇さん、STAGE_Cのログを最後に記録がない。。つまらなくて途中で去ったんだな。。」とかが分かるようになってます。

用途については、
今回のゲームジャムの振り返りをするのに使ったり、月曜日から遊んでくださる方向けにゲームバランス調整するのに使いました。
日曜は疲れてて月曜から遊ぶ!っていう人いるって信じてる。

ニフクラさんのサービスが優秀で、実装も楽でした。(今見たらenum.tostring使っちまってる。。)

public void RequestRecording (EnumCheckPoint checkPoint){
    #if UNITY_EDITOR
        Debug.Log("チェックポイント" + checkPoint.ToString() + "を記録しました。");
        return;
    #endif
    try{
        // クラスのNCMBObjectを作成
        NCMBObject historyClass = new NCMBObject("HistoryData");
        
        // オブジェクトに値を設定
        historyClass["playerId"]   = playerId;
        historyClass["checkPoint"] = checkPoint.ToString();
        
        // データストアへの登録
        historyClass.SaveAsync();
        
    }catch(System.Exception ex){
        Debug.LogError(ex.Message);
    }
}

次回も使えるまとめ

  • 企画については対立がおきたりジレンマが発生する構造から先に考えるとうまくいく
  • 会話場面でのスキップ機能は長押しでやるのが良案
  • プレイヤー行動ログを残しておくと色々と次に活かしやすい