◆ Squares ◇ Unityでボクセルパズルアプリを作ろう! 〜塗り絵アプリのその先へ〜

この記事は Unity Advent Calendar 2019 22日目の記事です☆彡

今年、海外で塗り絵アプリが流行ってたので、一歩進んでボクセルパズルアプリを作ってた!
結局、完成には至らなかったんだけど、そこから学んだことをまとめてみる。

きっかけ

去年、塗り絵アプリを試す機会があって、Pixel Artを参考に作ってた。
これが実際作ってみると、Pixel Art、ホントよく出来てて

  • ゲーム性がシンプルで、触り心地がよくて、ひたすら絵を塗りつぶすのにハマる
    - スマホの操作性と相性がいい
  • システム化されてて、一度アプリが完成すればリソースの追加のみで更新できる
    - 開発工数を減らせる
  • 基本無料だけど、全部の絵を解放するには月額課金
    - サブスクリプション

特にサブスクリプションモデルで成功しているのがすごい!
そして、最近だと2Dだけじゃなく3D(ボクセル)の絵も増えてきてて、その流れに便乗してボクセル系のパズルアプリを作れないかと考えた。

Squaresとは

ひとことでいうと『立体ぷよぷよ』です!w
自分が子供の頃に最初にハマったゲームがぷよぷよで(◉◉)
ずっと3Dのぷよぷよを作れないかな〜って考えてたんですよね☆


     f:id:okamura0510:20191216191108g:plain:w256

Squares
WebGL / Android / GitHub
制限時間内にハイスコアを競うゲーム(対戦ゲームの想定だった)
操作 説明
マップタップ スクエアの移動
落下地点ホールド(長押し) スクエアの高速落下
画面スワイプ 視点の操作
補足
スマホ向けに作ってたのでWebGLはあくまで見せる用(操作がスマホ向け)
Androidがインストール簡単なので出力(iOSだと配布に一手間必要なので)
GitHubプロジェクトはソースコードだけ追加(有料アセットを含んでいたので)。なのでUnityで開いてもエラー出ます(一応、後述の使用アセットをインポートすれば動くはず)

動作環境

Unity2018.4.14+
Android4.1+
iOS9.0+

使用アセット

Puzzle Cubes

パズルのSquareに使用。

DOTween

アニメーションに使用。
Unityで超便利なTweenアセット「DOTween」が好き

G2U4S

Excelで作成したゲームデータをScriptableObjectに変換するのに使用。
〝 G2U4S 〟G2Uで読み込んだゲームデータを1クリックでScriptableObjectに変換

GodTouch

タッチはUnityEventでほとんど処理してるんだけど、一部分で使用。
【UnityAsset】GodTouch - Unityエディタ上でタッチの動作を確認できる

UnityWebglResponsiveTemplate

WebGLを9:16のレスポンシブデザインにするのに使用。

M+FONTS

WebGLだとArialフォントで日本語が表示出来ないので、シンプルなM+FONTSさんのを使用。

技術解説

プロジェクト構成(ざっくり)

App

  • App.prefabがアプリ起動時に生成され、ずっと常駐する(Singleton)
  • App.csがグローバルデータを保持するクラス
  • Resource.csSaveData.csはマネージャクラス
  • Scripts/App配下はアプリのグローバルクラス

Scene

  • 1つのシーンに1つのシーンクラスがある(Game.unityGame.cs)
  • シーンクラスはシーンのグローバル処理を扱う

GameData

  • GameData.xlsxがゲームデータ
  • Tools > G2U4SResources/ScriptableObjectsScripts/GameDataに出力
  • GameDataGame.csDataプロパティから取得(Game.Data = GameData)

Game

  • Scripts/Game配下はゲーム固有のクラス
  • Animationは仮で、後でデザイナーさんに依頼予定だった

コーディングスタイル

// using宣言:定数やenumは積極的にusing staticを使用(冗長性の排除)
using UnityEngine;
using static Squares.GameSequence;
using static Squares.SquareColorType;

// 名前空間:自分は基本1つしか作らない(分割しすぎるとusing宣言が悲惨になるから)
namespace Squares
{
    // enum:G2U4SでExcel管理(プランナーさんでも調整できるように)
    //public enum GameSequence { None, UserInput, GameEnd }

    // クラス:コンポーネント指向が好きなのでなるべく継承しない(MonoBehaviourを直接使う)
    public class Game : MonoBehaviour 
    {
        // 定数:G2U4SでExcel管理(プランナーさんでも調整できるように)
        // public const string Version          = "3.2.1";
        // public static readonly bool IsEditor = Application.isEditor;

        // static変数:乱用しない(staticおじさん)
        // static Game instance;

        // public変数:使用しない!(プロパティを使う)
        // public int Freebird = 1;

        // private変数:エディタでの編集のしやすさ順
        // [SerializeField]           : エディタで変更可能
        // [SerializeField, ReadOnly] : エディタで閲覧可能(変更不可)
        // なし                        : エディタで参照不可
        [SerializeField] Map map;
        [SerializeField, ReadOnly] GameSequence sq;
        [SerializeField, ReadOnly] int connectingId;
        Square[,,] squares;

        // staticプロパティ
        public static GameData Data => App.GameData;

        // インデクサー、プロパティ
        public Square this[int x, int y, int z]
        {
            get
            {
                if(x < 0 || x >= Game.Data.MapWidth)  return null;
                if(y < 0 || y >= Game.Data.MapHeight) return null;
                if(z < 0 || z >= Game.Data.MapDepth)  return null;
                return squares[x, y, z];
            }
        }
        public bool IsPlaying => (sq != None);
        public int ConnectingId { get { return connectingId; } set { connectingId = value; } }
        
        // MonoBehaviourメソッド
        void Start()  { }
        void Update() { }
        
        // イベントメソッド
        public void OnMapTap(int x, int y, int z) { }

        // 通常メソッド
        public Square CreateSquare() => Square.Create(Red, 0, 0, 0, transform);
    }
}

※個人的には、プロジェクト全体で統一されてれば、ぶっちゃけ何でもいいと思う

マネージャークラス

マネージャクラスはメモリ節約のためApp.prefabに集約(GameObjectを節約)。
また、なるべく短く記述したいのでResourceManagerみたいなManager語尾はカット。
そして、関数はstaticで公開。

ResourceManager.instance.LoadPrefab
 ↓
Resource.LoadPrefab

という風に直感的に短く書ける(MonoBehaviourにしてるのは非同期処理も可能なように)。

ReadOnlyAttribute

f:id:okamura0510:20191214003846p:plain:w350

上のSqのようなエディタで閲覧するためだけの読み取り専用属性。
これで「エディタで値を確認したいけど変更はさせたくない」ということが可能。
最近はSerialize可能な変数は積極的にReadOnly属性をつけてる(デバッグしやすいから)。

using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Squares
{
    public class ReadOnlyAttribute : PropertyAttribute { }

#if UNITY_EDITOR
    [CustomPropertyDrawer(typeof(ReadOnlyAttribute))]
    public class ReadOnlyDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            EditorGUI.BeginDisabledGroup(true);
            EditorGUI.PropertyField(position, property, label);
            EditorGUI.EndDisabledGroup();
        }
    }
#endif
}

コンポーネント指向

Unityでのコンポーネント指向のあれこれ

上の記事で書いたけど、コードを読みやすくするためにコンポーネント指向を用いてる。
例えば今回だと下のようなコンポーネントがあって

コンポーネント 役割
Game ゲームの全体フロー
Map スクエアやタイルの操作
Tile 地面
Square パズルのオブジェクト
TouchEventTrigger タッチイベント
  • TileSquareはコンポーネントが独立してるので修正が楽
  • TouchEventTriggerはUnityEventで実現
  • MapTileSquareの操作だけ行う
  • Gameはそれぞれのコンポーネントの組み合わせで作られてる

みたいな。
まぁまだプロトだったからGameに依存しすぎちゃってるけど^^;

視点の操作

f:id:okamura0510:20191124135825p:plain:w350
[Unity3D]プレイヤー中心に回転・拡縮・追従するカメラ

上の記事をそのまま使わせていただきました!
基本エディタ上で設定値をいじるだけで調整できたので、とても楽でした☆

1点だけ、元記事だとLateUpdateで毎フレ更新処理が呼ばれていて、それだと負荷が高かったので、スワイプ操作中だけ更新処理が呼ばれるようにしました。
スワイプ判定はTouchEventTriggerで処理してる。ポイントは、スワイプにはいつでも移行できるようにしてること。こうすることで、シームレスな操作性を実現してる。

ぷよぷよロジック

連結ロジック

public (List<Square> vanishedSquares, List<int> vanishedConnectingCounts) 
        SearchVanishedSquares()
{
    vanishedSquares.Clear();
    vanishedConnectingCounts.Clear();
    foreach(var square in squares)
    {
        if(square == null) continue;
        square.ConnectingId = 0;
    }

    var connectingId = 1;
    foreach(var square in squares)
    {
        if(square == null) continue;

        // 連結IDごとの連結スクエア判定
        connectingSquares.Clear();
        SearchConnectingSquares(
            connectingId, square.ColorType, square.X, square.Y, square.Z);
        if(connectingSquares.Count >= Game.Data.VanishableCount)
        {
            // 連結数ボーナスのために連結数を保存
            vanishedConnectingCounts.Add(connectingSquares.Count);

            // 消去スクエアを保存
            foreach(var connectingSquare in connectingSquares)
            {
                vanishedSquares.Add(connectingSquare);
            }
        }
        connectingId++;
    }
    return (vanishedSquares, vanishedConnectingCounts);
}
        
List<Square> SearchConnectingSquares(
    int connectingId, SquareColorType colorType, int x, int y, int z)
{
    var square = GetSquare(x, y, z);
    if(square == null || square.ConnectingId != 0) return connectingSquares;

    if(square.ColorType == colorType)
    {
        square.ConnectingId = connectingId;
        connectingSquares.Add(square);

        SearchConnectingSquares(connectingId, colorType, x + 1, y,     z);
        SearchConnectingSquares(connectingId, colorType, x - 1, y,     z);
        SearchConnectingSquares(connectingId, colorType, x,     y + 1, z);
        SearchConnectingSquares(connectingId, colorType, x,     y - 1, z);
        SearchConnectingSquares(connectingId, colorType, x,     y,     z + 1);
        SearchConnectingSquares(connectingId, colorType, x,     y,     z - 1);
    }
    return connectingSquares;
}
SearchVanishedSquares 消去スクエア判定メソッド
SearchConnectingSquares 連結スクエア判定メソッド
Square.ConnectingId 連結スクエア判定で使用する連結ID
0:まだ判定してない
1~:判定済み連結ID
vanishedSquares 消去スクエアリスト
vanishedConnectingCounts 消去連結数リスト(連結数ボーナスで使用)

ロジックとしてはシンプルで、全てのスクエアをSearchConnectingSquaresで見て行って、隣り合うスクエアが同色の場合はConnectingIdに同じIDを入れ、すでにIDが判定済み(0以外)の場合は飛ばす。それを再帰で繰り返す。

結果は、消去演出のためのvanishedSquaresと、スコア計算で必要なvanishedConnectingCountsで返す。

スコア計算

public void AddPoint(
    int chainCount, List<Square> vanishedSquares, List<int> vanishedConnectingCounts)
{
    // 消去ポイント(消去スクエアの数 × 10)
    var vanishingPoint = vanishedSquares.Count * Game.Data.ScoreVanishingPointBase;

    // 連鎖ボーナス
    var chainBonus = Game.Data.ScoreChainBonuses[chainCount - 1];
            
    // 連結数ボーナス(上限値あり)
    var connectingBonus   = 0;
    var connectingBonuses = Game.Data.ScoreConnectingBonuses;
    var vanishableCount   = Game.Data.VanishableCount;
    foreach(var connectingCount in vanishedConnectingCounts)
    {
        var idx = Mathf.Max(
            connectingCount - vanishableCount, connectingBonuses.Length - 1);
        connectingBonus += connectingBonuses[idx];
    }
            
    // 色数ボーナス
    for(var i = 0; i < canVanishColors.Length; i++)
    {
        canVanishColors[i] = false;
    }

    var vanishedColorCount = 0;
    foreach(var square in vanishedSquares)
    {
        var colorIdx = (int)square.ColorType;
        if(!canVanishColors[colorIdx])
        {
            canVanishColors[colorIdx] = true;
            vanishedColorCount++;
        }
    }
    var colorBonus = Game.Data.ScoreColorBonuses[vanishedColorCount - 1];

    // 総ボーナス(下限値あり)
    var totalBonus = Mathf.Max(
        chainBonus + connectingBonus + colorBonus, Game.Data.ScoreTotalBonusMin);

    // ポイント追加
    point += vanishingPoint * totalBonus;
    animation.Play(point);
}

スコア計算はまんまぷよぷよの得点計算を使用w
基本は下の計算式通りに計算してるだけ。

ぷよぷよ講座 > 得点
(消えたぷよの数 × 10) × (連鎖ボーナス + 連結数ボーナス + 色数ボーナス)

消えたぷよの数 × 10 消去スクエアの数 * ScoreVanishingPointBase(10)
連鎖ボーナス 連鎖数からゲームデータの値を参照
連結数ボーナス connectingCount - vanishableCount(4)
ボーナスインデックスを求めて、ゲームデータの値を参照
色数ボーナス 消去スクエアから色数を求めて、ゲームデータの値を参照

スコア計算に使用する係数やボーナス値はゲームデータでExcel管理。
後からプランナーさんがバランス調整できるように(今回は意味ないけどねw)。

未完のわけ

なぜ完成しなかったかというと、『面白くならなそうだから』。

・ぷよぷよというゲームが完成されている(アレンジが難しい)
自分が昔からのぷよらーだから尚更なんだけど、作っててなんか違う感が強く。。
それでも演出とかゲーム性を突き詰めていけば、ある程度のクオリティにはなるとは思うんだけど、それは果たして「ぷよぷよではないゲームなのか?」と。

・立体操作がスマホには不向き
スマホで立体操作が面倒なんですよね。プレイしててそれがストレスで・・・。
塗り絵アプリの3Dが微妙なのもこういうところにあるんじゃないかなぁ。

・ディレクターが必要
今回自分1人で作ってて、この他にも試してたんだけど、なかなか良いものが出来ず。。
なんか面白くなりそうな時って、これだ!っていう掴みがあるんですよね。
自分の力だけではダメだ。信頼できる仲間を頼ろう、って思った。

最後に

本当は前日のSAyanada9さんに続いてAR記事を書く予定だったんだけど・・・笑
2020年は、AR・機械学習・GAEに挑戦してみたい!