はじめに
本ゲームを作成するにあたって以下のライブラリを使用しています。細かな技術解説はしていないのでご了承ください。
・UniRx *1
・UniTask *2
・Extenject(Zenject) *3
普段行っているゲームの開発に集中できなくなってきてしまったので、気分転換にUnityRoomで開催されているUnity1Week お題「ふえる」に参加してみることにしました。また、最近あまり勉強できていなかったので気になっていたUnityのマルチシーンエディティング+Extenjectでロジックとビューごとにシーンを分けての開発を試してみることに。結果としてはそこそこいい感じにできたのかな?と思うので開発記録を記事にしてみました。
*2020年9月19日追記unity1week online共有会 #2-Bの発表内容に合わせて大幅に修正しました。
ゲーム概要
今回作成したゲームは、クリックでスライムを増やして出荷することでお金を稼ぐクリッカーゲームです。想定したプレイ時間は1時間とかなり長めですが、他の参加者様のゲームを遊んでいる間は放置して戻ってきたときにスライムが大量に増えているという状態になってくれたらいいなと思っています。
ゲームシステム
本ゲームは、クリックでスライムを増やし、出荷しすることでお金を稼ぐゲームです。
稼いだお金でアイテムを購入したり、牧場をレベルアップすることでスライムの生産効率や出荷効率を上げていくことが本ゲームの目的となっています。Unity1Week1では牧場のレベルを11にすることでクリアできるようになっています。
ゲームデザイン
ゲームを作り始める前に、まずアイデアもとのクリッカーゲームの楽しさが何なのかについて考えます。これは今から作るゲームの本質が何なのか、そして何を一番強調して見せるべきなのかを見極めるためです。
それでは、クリッカーゲームの楽しさは何なのか。私は、数字の変化が大きくなること「効率化」がクリッカーゲームの楽しさだと考えました。つまり一番見せるべきは、数字そのものとその変化量になります。しかしUnity1weekを考えると、この楽しさは相性があまり良くないのではという考えに至りました。それは、数百ものゲームが投稿される中で一つのゲームに10分20分、ましてや1時間もプレイしてくれるユーザがどれだけいるのか。おそらく通常のクリッカーゲームとは何か別の楽しさを見出せなければ殆どのユーザは数分も経たずに離脱してしまうでしょう。
スライムのボイス
まず、今回増やす対象のスライムにキャラクター性を持たせることにしました。本来ならはただの数値としての価値しか持たないスライムに動きと声を与えることで、「スライムを見る」という楽しさを提供できないかと考えました。実装してみると意外と可愛らしく見えたので、今回は楽しむのではなく、「眺めて癒されるゲーム」にすることに決めました。
このスライムの可愛さというアイデアの原点はピクミンを参考にしています。ピクミンはマルチタスクのゲームだと思っているのですが、それ以外にもピクミンそのものが可愛いというのもあると思います。ピクミンをプレイしたことがある方なら1度は、ただ目的もなくピクミンを増やし続ける1日を過ごした方もいるのではないでしょうか。
スライム牧場もピクミン愛のうたの歌詞にある「運ぶ 闘う 増える そして食べられる」にならってデザインされています。
ピクミン | スライム牧場 |
---|---|
運ぶ | なし(イベント) |
闘う | なし(イベント) |
増える | 生産する |
食べられる | 出荷する |
運ぶや闘うに該当する襲撃や仲間を呼んでくる、トロフィーアイテムを拾ってくるなどのスライムの数が増減するイベントを考えていましたが、今回は時間が足りなかったので断念しました。最終的に目指していた形は、アドベンチャーゲームに近い形式で、数が増えることよりも、スライムの生態そのものに注目してもらう予定でした。
今回実装した中で1つ後悔している点が、出荷時にスライムの悲しげなボイスを付けなかったことです。後述する「出荷」というキーワードに重きを置いていたため、ボイスを依頼する段階で思いつかなかったのですが、もしボイスがあれば出荷してスライムがいなくなるという印象が強まったかもしれません。
出荷と2つのパラメータ
クリッカーゲームの特徴として、ある程度進むと放置以外にすることがなくなってしまうということがあります。既存のクリッカーゲームであればトロフィーのような達成目標が用意されていますが、それとは別の魅力を付与しようとしているのに同じことをしてしまうと、設定しようとしているスライムの魅力が薄れてしまいかねません。またトロフィーの内容によっては、「ふえる」ことよりプレイ時間や放置が主題になりお題と逸れてしまう可能性があると考えました。
そこで今回は、スライムに加えてもう1つお金というパラメータを追加することにしました。
このグラフは、スライムとお金が時間に対してどのように増加するかを現したグラフです。スライムは、アイテムを購入していた場合時間に対して比例して増加しています。一方お金は、「出荷」を行った瞬間に急激に増加しそれ以外では一切変化しないパラメータになっています。この2つのパラメータは、効率化して増加量を大きくしていくという本質は同じですが、増やすためにユーザーの操作が介入するか否かという違いがあります。この「出荷」は、このゲームの中で一番行われる操作になっており、出荷時に「出荷よー」という可愛い音声を流すことで強く印象づくようにしています。この「出荷」により、ゲームが進んでもユーザに一定の操作を提供するとともに、ゲームを進めるためには必ずクリックして「ふやす」作業が必要になるようにしています。
しかし、クリックという単純作業を何度も繰り返すとどうしても飽きがきてしまいます。また、他のゲームをプレイしている合間にちょっと触る程度のプレイ感覚という目標があったので、思ったよりはクリックしなくてもクリアできるようにしました。なるべく少ないクリック回数で簡単になりすぎないバランスの追求が難しく今でも調整したほうが、と悩んでいます。
画面遷移
本ゲームで「出荷」の次に行われやすい操作が「牧場」への画面遷移になります。アイテムの購入や牧場のレベルアップ以外にも、値段の確認や手持ち無沙汰を紛らわすためなど想定以上に遷移される可能性を考える必要があります。スマートフォンのソーシャルゲームをプレイしたことがある方なら、スタミナがなくなってしまった時に意味もなくキャラクターのボックスを眺めたりした経験があるのではないでしょうか。そのため、ここの画面遷移は限りなくストレスを感じないようにかつ、見ていて飽きないようなアニメーションを行う必要がりました。
ボタン配置
スライムが生産されるトップ画面では、出荷ボタンと画面遷移のボタンが2つ合計3つのボタンが配置されています。この配置にも理由があり、押されやすい出荷ボタンと牧場画面へ遷移するボタンは下部の距離が近い配置に、滅多に押されない設定画面に遷移するボタンは上部に配置してあります。押されやすいボタンを近い距離に置いてある理由は、マウスの移動量をなるべく少なくするためで、下部に配置しているのはスマートフォンで操作した時に押しやすい配置にするためです。そのため牧場画面の戻るボタンも下部に配置されており、前述した画面遷移をスムーズに行えるように牧場画面へ遷移するボタンと戻るボタンは近い位置に配置してあります。
設定画面に遷移するボタンは上部に配置されている理由は、このボタンが後述する理由でPC用に配置したボタンだからです。設定画面には、音量調整とクレジットしかなくゲーム中にほぼほぼ1度しか押す必要はありません。スマートフォンに至っては本体の音量が気軽に変更できるため押す必要もありません。PCには押しにくい箇所というのはないので配置に気を使う必要もなく、スマートフォンにとっては無駄な画面である設定画面に遷移しにくいように上部に配置してあります。
#開発
Unity1Weekでゲームを作る際のルール
Unity1weekでゲームを作る場合必ず守るルールが2つあります。1つ目は左クリックのみで遊べるゲームにすることです。おそらくほとんどの方は、マウスでブラウザを操作してゲーム画面に来ています。ゲーム内外に書かれている操作説明を読んでキーボードに持ち替えてもらうというのが個人的に気に入らないため直前に持っていたデバイスでそのまま遊べるようにしています。他にもTwitterにツイートされたリンクからゲーム画面にくる方もいると思います。その中にはスマートフォンの方もいるはずで、わざわざ見に来てくださったにもかかわらず遊べないというのはおかしな話だと思います。さらにスマートフォンの方は、開発に全く関係ない一般ユーザーの可能性が高く、そういった方にこそ遊んで欲しいと考えています。一般ユーザーを対象にするからには、操作説明文は読まれないものと考えなければなりません。そのためチュートリアルに注意を払う必要がありますが、今回作成したクリッカーゲームは、ゲーム開始時はスライムを増やすことができず、スライムを増やすと出荷ができるようになり、出荷をするとアイテムの購入がや牧場のレベルアップができるようになるというようにシステムそのものがチュートリアルを含んでいるため今回は特に気を配る必要はありませんでした。
2つ目は、音量調整を入れることです。UnityEditor上では丁度良い音量だったとしてもブラウザでプレイした際は爆音になっていることが頻繁にあります。スマートフォンと違ってPCの音量調整は、そう気軽に行えるものではありません。ゲーム内で調整できたほうがユーザーに親切なので必ずつけるようにしています。
#Domain
ゲームの構想で迷ってしまうと確実に間に合わなくなってしまうので、ゲームの構想は10分程度で大まかに決めて、細かい内容や理由付けは作りながら思いつき次第メモしていきました。今回の1番の目的はマルチシーンエディティング + Extenject(Zenject)でゲームのロジックとViewをシーンごとに分割しチームでの開発をスムーズに行う方法を試してみることだったのでとにかく手を動かすことを優先しました。
最終的に構成は以下のようになりました。ゲームのロジックをLootSceneにUIをUIScene、スライムを生産するシーンをGameSceneに分けられており、それぞれのSceneのSceneContextは、ContactNameによってLootSceneを親、GameSceneとUISceneをそれぞれ子になるようにしています。そのためUISceneとGameSceneはお互いに干渉できなくなっており、UISceneでの入力をGameSceneに反映させるためには必ずLootSceneのロジックの処理を経由しなければならないようになっています。
設計の話でよく出てくるドメインロジックですが、そもそも何がドメインロジックなのかわからず手がつけられないということがあると思います。私の場合ですが、まずゲームの内容から行われる操作を列挙することでで、作るゲームのロジックが何なのか言語化するようにしています。例えば「クリックする」「出荷する」などゲーム内で登場する動詞が該当します。その次に、それらの操作によって変更されるパラメータを列挙します。この変更されるパラメータがEntity、操作をUseCaseとして設計していきます。必ずこのレイヤー通りに分類するわけではなく、UseCaseが存在しない場合やもう1レイヤー増えるなど場合によってレイヤーの数は様々です。重要なのは依存の方向が必ずEntity方向に伸びていることで、UseCase間で依存していたり、EntityからUseCaseに依存するようなことは絶対にありません。
###Entity
例えば上記のスライムの数についてのクラスは以下のようになります。
using System;
using System.Numerics;
using UniRx;
namespace SlimeFarm.Scripts.Domains.Entity
{
public interface ISlimeIncreasable
{
void Increase(BigInteger num);
}
public interface ISlimeDecreasable
{
bool TryDecrease(BigInteger num);
}
public interface ISlimeNum
{
BigInteger Value { get; }
IObservable<BigInteger> OnChangeAsObservable();
}
public class SlimeNumEntity : ISlimeIncreasable, ISlimeDecreasable, ISlimeNum, IDisposable
{
private readonly ReactiveProperty<BigInteger> _reactiveSplitNum = default;
public SlimeNumEntity()
{
_reactiveSplitNum = new ReactiveProperty<BigInteger>();
}
BigInteger ISlimeNum.Value => _reactiveSplitNum.Value;
IObservable<BigInteger> ISlimeNum.OnChangeAsObservable()
{
return _reactiveSplitNum;
}
void ISlimeIncreasable.Increase(BigInteger num)
{
_reactiveSplitNum.Value += num;
}
// 減らす数は別のEntityから与えられる
// スライムの数が負になることはないので減らせなかった場合falseを返しUseCaseで判断させる
bool ISlimeDecreasable.TryDecrease(BigInteger num)
{
if (_reactiveSplitNum.Value < num) return false;
_reactiveSplitNum.Value -= num;
return true;
}
public void Dispose()
{
_reactiveSplitNum?.Dispose();
}
}
}
このようにEntityは1つのパラメータについてのみ関心をもち、他のクラスの状態に左右されません。この他のEntityもそのクラスが管理しているパラメータに関するメソッドしか持たない非常に小さなクラスになっています。なぜこんなふうにしているか気になった方は、凝集度というキーワードで調べてみてください。
###UseCase
前述のEntityを組み合わせてゲーム内の処理にするのがUseCaseの役割です。
using SlimeFarm.Scripts.Domains.Entity;
namespace SlimeFarm.Scripts.Domains.UseCase
{
public interface IShipUseCase
{
bool ShipSlime();
}
public class ShipUseCase : IShipUseCase
{
private readonly IMoneyIncreasable _moneyIncreasable = default;
private readonly ISlimeDecreasable _slimeDecreasable = default;
private readonly IFarmInfo _farmInfo = default;
private readonly IIndexDecrementAble _indexDecrement = default;
private readonly ISlimeNum _slimeNum = default;
public ShipUseCase(
IMoneyIncreasable moneyIncreasable,
ISlimeDecreasable slimeDecreasable,
IFarmInfo farmInfo,
IIndexDecrementAble indexDecrement,
ISlimeNum slimeNum)
{
_moneyIncreasable = moneyIncreasable;
_slimeDecreasable = slimeDecreasable;
_farmInfo = farmInfo;
_indexDecrement = indexDecrement;
_slimeNum = slimeNum;
}
// 出荷完了時に鳴らす音があるため、出荷が成功したか否かのbool値を返す。
bool IShipUseCase.ShipSlime()
{
// IFarmInfoが実装されているFarmInfoEntityは、今牧場が何レベルでその時の出荷に必要なスライム、手に入るお金にしか関心がない
// 実際にスライムを減らせたかどうかはSpawnIndexEntityの結果をみてUseCaseが判断する
if (!_slimeDecreasable.TryDecrease(_farmInfo.CurrentInfo.ShipSlime)) return false;
_moneyIncreasable.Increase(_farmInfo.CurrentInfo.ShipMoney);
_indexDecrement.Decrement(_slimeNum.Num);
return true;
}
}
}
上記は「出荷」の処理を担当するUseCaseで、各Entityに実装されているInterfaceを通じて必要なプロパティ、メソッドを呼び出しています。
UseCaseとEntityは、ほぼ全てLootSceneのSceneContextにBindされており、TimeInstallerのようにまとめられている場合もありますが、今回は面倒だったので適当にLootDomainInstallerに突っ込んであります。
public class LootDomainInstaller : MonoInstaller<LootDomainInstaller>
{
public override void InstallBindings()
{
SignalBusInstaller.Install(Container);
// シーン間のデータのやり取りはEntityの変更、もしくはSignalによってのみ行います。
Container.DeclareSignal<ShipSignal>();
// 本来ならTimeInstallerのようにカテゴリで分けて整頓したほうがいいですが、面倒で煩雑に
TimeInstaller.Install(Container);
// 以下のEntityの中にはGameSceneやUISceneだけでのみ使うのもあるかもしれませんが時間が
Container.BindInterfacesTo<FarmInfoEntity>()
.AsSingle();
Container.BindInterfacesTo<ItemEntity>()
.AsSingle();
Container.BindInterfacesTo<MoneyEntity>()
.AsSingle();
Container.BindInterfacesTo<SlimeNumEntity>()
.AsSingle();
Container.BindInterfacesTo<SpawnIndexEntity>()
.AsSingle();
Container.BindInterfacesTo<VolumeEntity>()
.AsSingle();
Container.BindInterfacesTo<ShipUseCaseUseCase>()
.AsSingle();
Container.BindInterfacesTo<ClickSlimeUseCase>()
.AsSingle();
}
}
Interface
EntityやUsecCaseに実装されているInterfaceは、疎結合や依存の向きを変更するためなどの目的があります。これらのInterfaceは、依存するクラスがInterface内のメソッドをなるべく全て使い切れるように分けられられており、中でも上記のSlimeNumEntityのInterfaceは、とても細かく分けられておりています。何故ここまで細かく分けているかというと、Interfaceの名前から実装されている処理を推測できるようにするためです。例えばISlimeIncreasableというInterfaceからは、少なくとも増えるメソッドが用意されていることが想像できると思います。これがもしISlimeIncreasable、ISlimeDecreasable、ISlimeNumが全て1つになったISlimeNumEntityという1つのInterfaceになっていると、名前が持つ情報が非常に少なくなってしまいます。チームで開発する場合、自分以外の人がクラスを設計することもあるため名前が持つ情報量というのは非常に重要な役割を担っていると考えています。そのため依存する側で使い切れるという「用途」の観点でInterfaceを実装し適切な命名ができるようにしています。また、こうしておくとInterfaceが持つ責務が1つになるので、どの変更がどこに影響するのかが分かりやすくなります。また、よくありがちな実装が漏れにも既存のクラスに無影響のまま追加のみで対応できるようになります。ただし、命名規則やユビキタス言語が厳格に共有されている必要があるので注意が必要です。一言でいうならばインターフェース分離の原則で、検索して見つかる記事だと主にクライアントが依存すべきメソッドという観点で説明されているのですが、私はそれ以上にInterfaceが持つ責務、名前というものが重要だと思っているのでこういった説明をするようにしています。
#Presentation
Presentationは、主にPresenterとViewで構成されておりDomainと同様に場合によってレイヤーを追加したりします。ただしViewレイヤーは、追加のレイヤーが必要になることが非常に多いくいちいちレイヤー名を考えることが面倒なため、ViewからViewへの依存を許容しています。
Presenter
PresenterはViewの入力をDomainへ流す、もしくはDomainで計算された値をViewへ流すことにしか関心がありません。また、ほぼ全ての処理はPresenterによって呼び出されるのでこのレイヤーが全ての処理の起点になっています。
using System;
using SlimeFarm.Scripts.Application.DTO;
using SlimeFarm.Scripts.Application.Enum;
using SlimeFarm.Scripts.Application.Signal;
using SlimeFarm.Scripts.Domains.Entity;
using SlimeFarm.Scripts.Domains.UseCase;
using UniRx;
using Zenject;
namespace SlimeFarm.Scripts.Presentation.Presenter
{
public interface IShipInOutPutPort
{
IObservable<FarmInfo> OnShipAsObservable();
void SetCurrentFarmInfo(FarmInfo farmInfo);
}
public class ShipPresenter : IInitializable, IDisposable
{
private readonly IFarmInfo _farmInfo = default;
private readonly IShipUseCase _shipUseCase = default;
private readonly IShipInOutPutPort _shipInOutPutPort = default;
private readonly SignalBus _signalBus = default;
private readonly CompositeDisposable _disposable = new CompositeDisposable();
public ShipPresenter(
IFarmInfo farmInfo,
IShipUseCase shipUseCase,
IShipInOutPutPort shipInOutPutPort,
SignalBus signalBus)
{
_farmInfo = farmInfo;
_shipUseCase = shipUseCase;
_shipInOutPutPort = shipInOutPutPort;
_signalBus = signalBus;
}
public void Initialize()
{
Bind();
SetEvent();
}
private void Bind()
{
_farmInfo.OnChangeAsObservable()
.Subscribe(_shipInOutPutPort.SetCurrentFarmInfo)
.AddTo(_disposable);
}
private void SetEvent()
{
_shipInOutPutPort.OnShipAsObservable()
.ThrottleFirst(TimeSpan.FromMilliseconds(500))
.Subscribe(farmInfo =>
{
if (!_shipUseCase.ShipSlime()) return;
_signalBus.Fire(new SoundSignal(Sound.Money));
_signalBus.Fire(new ShipSignal(farmInfo.ShipMoney));
}).AddTo(_disposable);
}
public void Dispose()
{
_disposable?.Dispose();
}
}
}
Presenterは、入力と処理の組み合わせの数だけ存在しており、例えば「出荷ボタンが押されたら出荷処理を行う」や「所持金が変わったら表示を変更する」など◯◯したら△△を実現するようになっています。
View
Viewは、値を表示するもしくは入力を受け取る処理しかなく特別な処理がない場合、ButtonコンポーネントやTextコンポーネントをそのまま使う場合もあります。基本的に計算をすることはありませんが、物理特性を計算する場合のみ入力を与えてView側で加速度を計算することがあります。
using System;
using SlimeFarm.Scripts.Application.DTO;
using SlimeFarm.Scripts.Application.Utility;
using SlimeFarm.Scripts.Presentation.Presenter;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.UI;
namespace SlimeFarm.Scripts.Presentation.View
{
public class ShipButton : MonoBehaviour, IShipInOutPutPort
{
[SerializeField] private Button _button = default;
[SerializeField] private TextMeshProUGUI _cost = default;
private FarmInfo _farmInfo = default;
IObservable<FarmInfo> IShipInOutPutPort.OnShipAsObservable()
{
return _button.OnClickAsObservable().Select(_ => _farmInfo);
}
void IShipInOutPutPort.SetCurrentFarmInfo(FarmInfo farmInfo)
{
_farmInfo = farmInfo;
_cost.text = $"{NumberConverter.ConvertToChineseNumber(farmInfo.ShipSlime)}匹";
}
}
}
Presentationで一番重要なのはこのInstallerでDomain側と異なり明確な基準を持って細かく分けられています。Nested Prefabを活用しオブジェクトを可能な限り分割し、そのゲームオブジェクトに実装されているViewとそれの動作に必要なPresenterのみをBindします。また、トラックパッドを操作してると指が擦れて痛くなるので、空のゲームオブジェクトにアタッチするだけどぼほ使える状態になるを目標にRequireComponentを必ず設定してEditor上での操作が少なくなるようにしています。
using SlimeFarm.Scripts.Presentation.Presenter;
using SlimeFarm.Scripts.Presentation.View;
using UnityEngine;
using Zenject;
namespace SlimeFarm.Scripts.Application.Installer
{
[RequireComponent(typeof(ShipButton))]
public class ShipButtonPackage : MonoInstaller<ShipButtonPackage>
{
public override void InstallBindings()
{
Container.BindInterfacesTo<ShipPresenter>()
.AsSingle().NonLazy();
Container.BindInterfacesTo<ShipButton>()
.FromComponentOnRoot();
}
}
}
たとえば画面遷移のトリガーとなるボタンを上記のようなパッケージとしてPrefabにして置いておくことでUISceneの中であればインスペクタからEnumを設定するだけで任意の画面に遷移するボタンを設置することができるようになっています。また、これらのPrefabはDomainありきですが、全て単体で動作するようになっており、テスト用のシーンで動作チェックを容易にできるようにしてあります。
マルチシーンエディティング
一人だったので正直なところ何も書くことがありません。おそらく上記の設計やNested PrefabによるViewの再利用などできていれば問題ないかと思います。
評価
最終的に頂いた評価は以下の通りになりました。自己評価よりかなり高かったので正直驚いています。
評価 | 自己評価 | |
---|---|---|
楽しさ | 3.722 | 3 |
絵作り | 3.917 | 3 |
サウンド | 4.014 | 4 |
操作性 | 3.847 | 3.5 |
雰囲気 | 3.889 | 3 |
斬新さ | 2.833 | 2 |
特に驚いたのが斬新さと楽しさが予想より遥かに高かったことです。正直なところこのゲームに人を惹きつける魅力はなく目立って目新しい点はないと思っていました。閲覧数もお返しプレイのことを考えるとたくさん見られているというわけでもありません。刺さる人には刺さるかなという程度の認識だったのですが、声やキャラクターが持つ魅力が想像以上に大きかったのかもしれません。 | ||
操作性が予想を上回っていたのも収穫でした。マウスをクリックするだけの単純な操作しかありませんが、詰め込んだ工夫の効果があったのかなと思います。 |
おわりに
時間ギリギリになったことでチェックが甘くなりミスによって結果的に時間がかかってしまったりと4日めでかなり時間を無駄にしてしまったような気がします。ですが、マルチシーンエディティング込みの設計などそこそこ気を使いながらコードを書けたので、その点はよかったかなと思います。ただ、Unity1Weekでしっかり設計しようというのはあまりお勧めしません。設計はメンテナンス性を担保するためにあるのでSOLID原則を満たそうとしていれば、採用するアーキテクチャがなんだろうが自己流だろうが構わないと思っています。なんなら使い捨てるコードなら設計する必要すらないと思います。下手にきっちりやろうとすると通常よりかなりコード量が増えてしまうため、慣れていないとゲームが完成しないなんてことになりかねません。できる範囲で気を使える部分だけ気を使う程度の塩梅で、慣れてきたらもうちょっとやってみるくらいが丁度いいと思います。
最後に今回作成したゲームのコード部分をgithubで公開してありますので、タグに設定してある技術に興味のある方や有識者の方はコメントいただけると幸いです。