
WebGL / Android / GitHub |
---|
マップタップ:スクエアの移動 |
落下地点長押し:スクエアの高速落下 |
画面スワイプ:視点の操作 |
この記事はUnity Advent Calendar 2019 22日目の記事です☆彡
今年、海外で塗り絵アプリが流行ってたので、一歩進んでボクセルパズルを作ってた!
結局、完成には至らなかったんだけど、そこから学んだことをまとめてみる。
目次
きっかけ
去年、塗り絵アプリを試す機会があって、Pixel Artを参考に作ってた。
これが実際作ってみると、Pixel Art、ホントよく出来てて
- ゲーム性がシンプルで、触り心地がよくて、ひたすら絵を塗りつぶすのにハマる
→ スマホの操作性と相性がいい - システム化されてて、一度アプリが完成すればリソースの追加のみで更新できる
→ 開発工数を減らせる - 基本無料だけど、全部の絵を解放するには月額課金
→ サブスクリプション
特にサブスクリプションモデルで成功しているのがすごい!
そして、最近だと2Dだけじゃなく3D(ボクセル)の絵も増えてきてて、その流れに便乗してボクセル系のパズルアプリを作れないかと考えた。
Squaresとは
ひとことでいうと『立体ぷよぷよ』です!w
自分が子供の頃に最初にハマったゲームがぷよぷよで(◉◉)
ずっと3Dのぷよぷよを作れないかな〜って考えてたんですよね☆
- 制限時間内にハイスコアを競うゲーム(対戦ゲームの想定だった)
- スマホ向けに作ってたのでWebGLはあくまで見せる用(操作がスマホ向け)
- Androidがインストール簡単なので出力(iOSだと配布に一手間必要なので)
- GitHubプロジェクトはソースコードだけ追加(有料アセットを含んでいたので)。
なのでUnityで開いてもエラー出ます(一応、後述の使用アセットをインポートすれば動くはず)
動作環境
Unity2018.4.14+
Android4.1+
iOS9.0+
使用アセット
Puzzle Cubes
パズルのSquareに使用。
DOTween
アニメーションに使用。

G2U4S
Excelで作成したゲームデータをScriptableObjectに変換するのに使用。

GodTouch
https://github.com/okamura0510/GodTouch
タッチはUnityEventでほとんど処理してるんだけど、一部分で使用。

UnityWebglResponsiveTemplate
https://github.com/miguel12345/UnityWebglResponsiveTemplate
WebGLを9:16のレスポンシブデザインにするのに使用。
M+FONTS
https://mplus-fonts.osdn.jp/about.html
WebGLだとArialフォントで日本語が表示出来ないので、シンプルなM+FONTSさんのを使用。
技術解説
プロジェクト構成(ざっくり)
App |
---|
App.prefab がアプリ起動時に生成され、ずっと常駐する(Singleton ) |
App.cs がグローバルデータを保持するクラス |
Resource.cs やSaveData.cs はマネージャクラス |
Scripts/App 配下はアプリのグローバルクラス |
Scene |
---|
1つのシーンに1つのシーンクラスがある(Game.unity → Game.cs ) |
シーンクラスはシーンのグローバル処理を扱う |
GameData |
---|
GameData.xlsx がゲームデータ |
Tools > G2U4S でResources/ScriptableObjects とScripts/GameData に出力 |
GameData はGame.cs のData プロパティから取得(Game.Data = GameData ) |
Game |
---|
Scripts/Game 配下はゲーム固有のクラス |
Animation は仮で、後でデザイナーさんに依頼予定だった |
コーディングスタイル
Game.cs
// 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

上のSq
のようなエディタで閲覧するためだけの読み取り専用属性。
これで「エディタで値を確認したいけど変更はさせたくない」ということが可能。
最近はSerialize可能な変数は積極的にReadOnly
属性をつけてる(デバッグしやすいから)。
ReadOnlyAttribute.cs
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
}
コンポーネント指向

上の記事で書いたけど、コードを読みやすくするためにコンポーネント指向を用いてる。
例えば今回だと下のようなコンポーネントがあって
コンポーネント | 役割 |
---|---|
Game | ゲームの全体フロー |
Map | スクエアやタイルの操作 |
Tile | 地面 |
Square | パズルのオブジェクト |
TouchEventTrigger | タッチイベント |
Tile
やSquare
はコンポーネントが独立してるので修正が楽TouchEventTrigger
はUnityEventで実現Map
はTile
やSquare
の操作だけ行うGame
はそれぞれのコンポーネントの組み合わせで作られてる
みたいな(まぁまだプロトだったからGame
に依存しすぎちゃってるけど・・・^^;)。
視点の操作
[Unity3D]プレイヤー中心に回転・拡縮・追従するカメラ
上の記事をそのまま使わせていただきました!
基本エディタ上で設定値をいじるだけで調整できたので、とても楽でした☆
1点だけ、元記事だとLateUpdate
で毎フレ更新処理が呼ばれていて、それだと負荷が高かったので、スワイプ操作中だけ更新処理が呼ばれるようにしました。
スワイプ判定はTouchEventTriggerで処理してる。ポイントは、スワイプにはいつでも移行できるようにしてること。こうすることで、シームレスな操作性を実現してる。
連結ロジック
Map.cs
public (List vanishedSquares, List 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 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
で返す。
スコア計算
Score.cs
public void AddPoint(
int chainCount, List vanishedSquares, List 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に挑戦してみたい!
お次はこちら(。・ω・。)ノUnity VisualEffectGraph -シンプルスォームパーティクル解説-
〜オセロを作りながらゲームのプログラムを学ぼう〜