一から素材を自作して、アクションゲームを作ってみた
自己紹介
普段はWeb系エンジニアをしながら、週末にUnityでゲーム開発をしております。AffinityDesginerでUI素材を作ったり、MedlyでBGMを作ったりしています。ファミコン世代でゲームを作るのも遊ぶのも好きです。
この記事の内容について
今回の記事の内容ですが、イラストスキルが0の状態から少しずつ素材を作るための試行錯誤について書いています。そのため一週間で一人で作るという内容ではありませんが、同じように個人で作られている方でグラフィック素材について悩まれている方にもしかしたら少し役に立つかもしれないと思い書いてみました。またアクションゲーム作成が未経験だったので、UniRx、UniTaskなどを駆使して試行錯誤しながらつくってみたので、その備忘録も兼ねています。
作ったゲームについて
一週間ゲームジャムのお題「ちゅう」でぷらなりんかねーしょん!というアクションゲームを作ってみました。このゲームはダメージを受けると耐性を持つ少女と、回復能力を持つプラナリアのコンビが閉じ込められた地底から協力して脱出するゲームです。主人公がやられても何度でも復活して、耐性能力で、ダメージが軽減していくので、諦めなければ必ずクリアができるアクションゲームを目指して作ってみました。
作ろうと思った経緯
最初は一週間ゲームジャムのお題「回」で、何回でも復活するというコンセプトのゲームを作ろうと思いました。そこで復活するというとプラナリアが浮かんできたのでプラナリアの性質を持つ主人公のゲームを作ろうと思いました。
アクションゲームの開発も未経験だったこともあり、死にゲーと呼ばれるようなアクションゲームでも何回もリトライするとだんだん難易度を下げていく要素がないかと思い、プラナリアの主人公に耐性を持たせるのはどうだろうかと思い、耐性と再生の力で脱出するアクションゲームを作りたいと考えました。
そうなった時に主人公の見た目はプラナリアというよりも、プラナリアの性質を移植された少女で研究所に拉致されて実験されているような背景が浮かんできたので、少し水生生物をイメージする青髪でプラナリアのような緑の瞳の検査服のようなものを着た少女の姿が浮かんできたので主人公の仮イメージとしてみました。
ただ何となくのイメージが湧いたとしても実際に形に起こすのはアクションゲームの知見もなく、グラフィック素材を作れるスキルはなく、残念ながら一週間ゲームジャムにはどうやっても間に合わないため、この時点では何となくイメージされたゲームが頭の中に産まれて終わりました。BGMはアプリで作曲することができたので、この時点でタイトル、オープニング、ゲーム中BGM(序盤、中盤、終盤)、エンディングなどとりあえずゲームに必要そうなものだけ作っていました。
そこで次にイメージを具現化するために、グラフィック素材を作るために、キャラクターデザインをしてみることにしました。
オリジナルのキャラデザインについて
プラナリアの性質を持つ少女はプラナという名前にしてみました。プラナというキャラを確立させるために試行錯誤していましたが、たまたま視聴していたさいとうなおきさんのチャンネルで誰でもキャラクターデザインができるという動画を目にして、その中で誰がみてもキャラクターが識別できるシルエットを作る必要があると紹介されていて、体の一部分を飛び出させてみるというテクニックが紹介されていました。
今回のキャラクターに置き換えた時に、普通の少女ではシルエットにした時に個性がないため、プラナリアを頭に乗せているキャラクターデザインに変更してみることにしました。
プラナリアが独立したキャラクターとして確立できそうなので、同じような境遇で一緒に脱出するパートナーにしようと思いプラナリアのキャラクターをリアと命名しました。
頭部にリアというもう一人の主人公キャラクターが存在することになったことで、ストーリーも一緒に脱出を図る、回復の役割をプラナリアのリアが担当し、回復できる場所もプラナリアであるリアの生息できる水場に限定するなど、ゲーム性にも具体的なイメージが固めやすくなりました。
また登場人物が二人になった事で、物語も二人の掛け合いをすることで、ステージの説明や進行具合などをプレイヤーに伝えることも自然にできそうな構成になってきたので、だんだんイメージが固まってきました。
キャラクターの個性を出す、個性の部分をピックアップする、ピックアップされた部分にストーリーを持たせたり、動機付けをするということができたのが、今回大きな収穫でした。
2Dアニメーションの作り方
独自デザインされたキャラクターは全ての素材を自前にしろ外注にしろ何らかの手段で作成する必要があります。自分の場合誰かと共作したりした経験がなかったことと自分でもグラフィック素材を作ってみたい気持ちがあったため、2Dアニメーションの素材作成に挑戦してみました。
ここでもさいとうなおきさんのチャンネルが参考になり、まず正面のデフォルメキャラクターを作成して素体を作りました。
動画を参考に顎をあまりとがらせずにすこし潰れたおまんじゅうのような頭を作り、目を強調しながら描き込んでみました。鼻はかなり簡単に、口は表情を活かす程度に留めています。二頭身キャラの場合、胴体は短めにして、足を少し長めに描くことですらっとした印象に仕上がるとのことだったので、それを少し意識しながら描いてみました。
キャラクターの前後の素体を作ったので、次は横向きにグラフィック作成で、こちらは色々資料を見ながら描いてみました。前後の素体を元に、このキャラクターが横向きになったらという想定で、頭や手の位置などを揃えた程度ですが、それでも前後の素体があることで、横向き画像を作る際のイメージが非常に湧きやすくなりました。
またアニメづくりさんのサイトの記事が歩行やまばたきなどのアニメーションを作る上で非常に参考になりました。
どんな動きでも全身を動かすということを意識した上で、歩行や静止状態のアニメーションを作ってみました。また歩行の場合、手に緩急をつける事でメリハリを持たせることができるとのことで、こちらも手の終端部分では動きを小さめにしてみたりしてみました。
最終的に一コマずつ各アニメーションの画像を作っていくという地道な作成ですが、アニメーション画像を作ることができました。
UniRxによるキャラのアニメーション制御
各動作のスクリプトにReactivePropertyを設定してその値に応じてアニメーションを制御するようにしてみました。
またアニメーションの制御にはPlayableAPIを使用してみました。
PlayableAPI参考の際に、テラシュールブログの記事を参考にさせていただきました。
【Unity】アニメーション制御に色々と良さそうな"Playable API"について云々 - テラシュールブログ
アニメーションの種類は静止、歩行、ジャンプアップ、ジャンプダウン、回復、ダメージ、ピンチ、ダウンになります。
各アニメーションの切り替えは歩行の場合は歩行の処理内で歩行の速度をUniRxのReactivePropertyの値を監視する事で実現しています。他のアニメーションについても各動作のアニメーションに関わる値をReactivePropertyで監視するようにして、アニメーションを切り替えるようにしてみました。
例えば横の動きを制御するPlayerLocomotionでは横の動きが発生した時点で、ReactivePropertyの値を更新していきます。PlayerAnimationStatusはその値を監視しているので、その値の変化に合わせて、歩行のアニメーションのステータスを更新します。PlayerAnimationStatusのステータスを監視して、アニメーションを制御するPlayerAnimationでステータスに合わせたアニメーションをPlayerの挙動に合わせたアニメーションを実施できるようになります。
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using UnityEngine.Serialization;
namespace Scripts.Player
{
// 状態の種類のenum
public enum PlayerAnimationState
{
Idle,
Walk,
JumpUp,
JumpDown,
Heal,
Damage,
Pinch,
Down,
}
// 独自UniRxステータス
[SerializeField]
public class PlayerAnimationStateReactiveProperty : ReactiveProperty<PlayerAnimationState>
{
public PlayerAnimationStateReactiveProperty()
{
}
public PlayerAnimationStateReactiveProperty(PlayerAnimationState init) : base(init)
{
}
}
// プレイヤー状態
public class PlayerAnimationStatus : MonoBehaviour
{
[SerializeField] private GameObject gameStatusObject;
[SerializeField] private PlayerLocomotion _playerLocomotion;
[SerializeField] private PlayerJump _playerJump;
[SerializeField] private PlayerHeal _playerHeal;
[SerializeField] private PlayerDamage _playerDamage;
[SerializeField] private PlayerVitalStatus _playerVitalStatus;
// エディタで把握するためのパラメーター
[FormerlySerializedAs("currentPlayerAnimationState")] [SerializeField]
private PlayerAnimationState currentPlayerAnimationState;
public PlayerAnimationState CurrentAnimationState
{
get { return playerAnimationState.Value; }
}
[FormerlySerializedAs("playerAnimationState")]
public PlayerAnimationStateReactiveProperty playerAnimationState = new PlayerAnimationStateReactiveProperty();
private void Start()
{
if (!gameStatusObject)
{
gameStatusObject = GameObject.Find("Managers/GameStatus");
}
if (!_playerLocomotion)
{
_playerLocomotion = GetComponent<PlayerLocomotion>();
}
if (!_playerJump)
{
_playerJump = GetComponent<PlayerJump>();
}
// 静止
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down &&
_playerLocomotion.currentSpeed.Value < 1
&& _playerJump.currentHeight.Value == 0
)
.Subscribe(_ => PlayerIdle());
// 歩行
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down &&
_playerLocomotion.currentSpeed.Value >= 1
&& _playerJump.currentHeight.Value == 0
)
.Subscribe(_ => PlayerWalk());
// ジャンプアップ
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down &&
_playerJump.currentHeight.Value > 0
)
.Subscribe(_ => PlayerJumpUp());
// ジャンプダウン
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down &&
_playerJump.currentHeight.Value < 0
)
.Subscribe(_ => PlayerJumpDown());
// 回復
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down &&
_playerHeal.isHeal.Value
)
.Subscribe(_ => PlayerHeal());
// ダメージ
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down &&
_playerDamage.isDamage.Value
)
.Subscribe(_ => PlayerDamage());
// ダウン
this.UpdateAsObservable()
.Where(_ =>
_playerVitalStatus.playerVitalState.Value == PlayerVitalState.Down
)
.Subscribe(_ => PlayerDown());
}
private void PlayerChangeStatus()
{
if (playerAnimationState.Value == PlayerAnimationState.Down)
{
PlayerDown();
return;
}
// 通常
PlayerIdle();
// エディタで把握するためのパラメーター
currentPlayerAnimationState = playerAnimationState.Value;
}
public void PlayerIdle()
{
if (_playerVitalStatus.playerVitalState.Value == PlayerVitalState.Pinch)
{
playerAnimationState.Value = PlayerAnimationState.Pinch;
}
else if (_playerHeal.isHeal.Value == true)
{
PlayerHeal();
}
else
{
playerAnimationState.Value = PlayerAnimationState.Idle;
}
}
public void PlayerWalk()
{
playerAnimationState.Value = PlayerAnimationState.Walk;
}
public void PlayerJumpUp()
{
playerAnimationState.Value = PlayerAnimationState.JumpUp;
}
public void PlayerJumpDown()
{
// 稀にジャンプダウンのモーションが解除されないため、判定を行う。
if (_playerJump.currentHeight.Value < 0)
{
playerAnimationState.Value = PlayerAnimationState.JumpDown;
}
else
{
PlayerIdle();
}
}
public void PlayerHeal()
{
playerAnimationState.Value = PlayerAnimationState.Heal;
}
public void PlayerDamage()
{
playerAnimationState.Value = PlayerAnimationState.Damage;
}
public void PlayerPinch()
{
playerAnimationState.Value = PlayerAnimationState.Pinch;
}
public void PlayerDown()
{
playerAnimationState.Value = PlayerAnimationState.Down;
}
}
}
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Animations;
using UniRx;
using UnityEngine.Serialization;
namespace Scripts.Player
{
public class PlayerAnimation : MonoBehaviour
{
PlayableGraph graph;
private AnimationClipPlayable currentClipPlayable;
private AnimationPlayableOutput playableOutput;
[SerializeField] AnimationClip animationClipIdle;
[SerializeField] AnimationClip animationClipWalk;
[SerializeField] AnimationClip animationClipJumpUp;
[SerializeField] AnimationClip animationClipJumpDown;
[SerializeField] AnimationClip animationClipHeal;
[SerializeField] AnimationClip animationClipDamage;
[SerializeField] AnimationClip animationClipPinch;
[SerializeField] AnimationClip animationClipDown;
[FormerlySerializedAs("playerVitalStatus")]
public PlayerVitalStateReactiveProperty playerVitalStatus;
[SerializeField] private PlayerVitalStatus _playerVitalStatus;
[SerializeField] private PlayerAnimationStatus _playerAnimationStatus;
void Awake()
{
graph = PlayableGraph.Create();
}
private void Start()
{
// outputを生成して、出力先を自身のAnimatorに設定
playableOutput = AnimationPlayableOutput.Create(graph, "output", GetComponent<Animator>());
PlayerAnimataionIdle();
// 主人公の生命ステータス監視用
if (!_playerVitalStatus)
{
_playerVitalStatus = GetComponent<PlayerVitalStatus>();
}
if (!_playerAnimationStatus)
{
_playerAnimationStatus = GetComponent<PlayerAnimationStatus>();
}
// 主人公やられアニメーション監視
_playerVitalStatus.playerVitalState.Where(x =>
x == PlayerVitalState.Down
)
.Subscribe(_ => PlayerAnimationDown());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.Idle
).Subscribe(_ => PlayerAnimataionIdle());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.Walk
).Subscribe(_ => PlayerAnimationWalk());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.JumpUp
).Subscribe(_ => PlayerAnimationJumpUp());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.JumpDown
).Subscribe(_ => PlayerAnimationJumpDown());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.Heal
).Subscribe(_ => PlayerAnimationHeal());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.Damage
).Subscribe(_ => PlayerAnimationDamage());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.Pinch
).Subscribe(_ => PlayerAnimationPinch());
_playerAnimationStatus.playerAnimationState.Where(x =>
x == PlayerAnimationState.Down
).Subscribe(_ => PlayerAnimationDown());
}
// アニメーション実行
private void PlayAnimation()
{
// playableをoutputに流し込む
playableOutput.SetSourcePlayable(currentClipPlayable);
graph.Play();
}
// 主人公通常時アニメーション
private void PlayerAnimataionIdle()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipIdle);
PlayAnimation();
}
// 主人公通歩行アニメーション
private void PlayerAnimationWalk()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipWalk);
PlayAnimation();
}
// 主人公通ジャンプ上昇アニメーション
private void PlayerAnimationJumpUp()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipJumpUp);
PlayAnimation();
}
// 主人公通ジャンプ下降アニメーション
private void PlayerAnimationJumpDown()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipJumpDown);
PlayAnimation();
}
// 主人公やられアニメーション
private void PlayerAnimationHeal()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipHeal);
PlayAnimation();
}
private void PlayerAnimationDamage()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipDamage);
PlayAnimation();
}
private void PlayerAnimationPinch()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipPinch);
PlayAnimation();
}
private void PlayerAnimationDown()
{
currentClipPlayable = AnimationClipPlayable.Create(graph, animationClipDown);
PlayAnimation();
}
}
}
タイルマップによる回復とダメージ制御
タイルマップを回復地点、ダメージオブジェクトの種類ごとに作成しています。タイルマップにはTilemapCollider2Dコンポーネントを付与して当たり判定をつけています。
回復地点は水のタイルマップなので、WaterTilemapのような命名にして、Healという回復のクラスHealを持たせています。Healクラスで回復値を定義しているので、水のタイルマップに付与したHealクラスの回復値を設定します。
using UnityEngine;
namespace Scripts.Vital
{
public class Heal : MonoBehaviour
{
/// <summary>回復値</summary>
[SerializeField] private int addLife = 1;
/// <summary>回復値のプロパティ</summary>
public int currentAddLife
{
get { return addLife; }
}
}
}
ダメージオブジェクトは棘と炎の2種類の種類があり、それぞれ1〜5のダメージを持たせています。1のダメージを受ける棘の場合は、Thorn1Tilemap、1のダメージを受ける炎の場合は、Fire1Tilemapのように命名し、Damageクラスを持たせて、enumで定義したThorn、Fireなどのダメージの種類とダメージの大きさを指定しています。
using Scripts.Resistance;
using UnityEngine;
namespace Scripts.Vital
{
public class Damage : MonoBehaviour
{
/// <summary>差し引かれるダメージ値</summary>
[SerializeField] private int subtractLife;
/// <summary>ダメージの種類</summary>
[SerializeField] private ResistanceType _resistanceType;
/// <summary>ダメージの種類を取得</summary>
public ResistanceType currentResistanceType
{
get { return _resistanceType; }
}
/// <summary>ダメージ値を取得</summary>
public int currentSubtractLife
{
get { return subtractLife; }
}
}
}
プレイヤー側の回復、ダメージの仕組み
プレイヤーは回復もしくはダメージオブジェクトに触れた時に、それぞれ回復とダメージのクラスを受け取り、そのクラスの値をUniRxのReactivePropertyとしています。その後その値を元に回復の処理もしくはダメージ処理を実施します。
ダメージ処理の場合はDamageクラスに指定されているダメージ値と種類を元に、耐性値に基づいたダメージを計算しています。
using System;
using System.Collections;
using System.Threading;
using Cysharp.Threading.Tasks;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
namespace Scripts.Player
{
public class PlayerVital : MonoBehaviour
{
[SerializeField] private int lifeInitialValue = 10;
[SerializeField] private int maxLifeInitialValue = 10;
[SerializeField] private ReactiveProperty<int> life = new ReactiveProperty<int>(10);
[SerializeField] public IObservable<int> lifeObservable => life;
[SerializeField] private ReactiveProperty<int> maxlife = new ReactiveProperty<int>(10);
[SerializeField] public IObservable<int> maxLifeObservable => maxlife;
[SerializeField] private PlayerHeal _playerHeal;
[SerializeField] private PlayerDamage _playerDamage;
[SerializeField] private int waitTime = 1; // 回復のインターバル
[SerializeField] private PlayerResistance _playerResistance;
private bool isHeal = false;
private bool isDamage = false;
private void Start()
{
maxlife.Value = maxLifeInitialValue;
life.Value = lifeInitialValue;
// ライフフラグを自身の変数に格納
_playerHeal.isHealObservable.Subscribe(x => isHeal = x);
// ライフフラグが有効な場合、回復処理
_playerHeal.isHealObservable.Where(x => x).Subscribe(x =>
{
var cts = new CancellationTokenSource();
LifeUp(cts.Token);
});
// ダメージフラグを自身の変数に格納
_playerDamage.isDamageObservable.Subscribe(x => isDamage = x);
// ダメージフラグが有効な場合、ダメージ処理
_playerDamage.isDamageObservable.Where(x => x).Subscribe(x =>
{
var cts = new CancellationTokenSource();
LifeDown(cts.Token);
});
}
private async UniTask LifeUp(CancellationToken token)
{
// 回復値を元にライフを回復
while (isHeal && life.Value < maxlife.Value)
{
await UniTask.Delay((waitTime * 1000), cancellationToken: token);
if (_playerHeal.currentHeal == null)
{
break;
}
life.Value += _playerHeal.currentHeal.currentAddLife;
if (life.Value >= maxlife.Value)
{
life.Value = maxlife.Value;
}
}
}
public async UniTask LifeDown(CancellationToken token)
{
while (isDamage)
{
// ダメージを取得
var damage = _playerDamage.currentDamage;
// 耐性値からダメージを差し引く
var resistanceDamage = _playerResistance.DamageResistance(damage);
// ライフから計算されたダメージを差し引く
life.Value -= resistanceDamage;
if (life.Value <= 0)
{
life.Value = 0;
}
// ダメージインターバル
await UniTask.Delay((waitTime * 1000), cancellationToken: token);
}
}
}
}
ステージ毎のBGMの切り替え
このゲームでは段階的にステージを分けて、ステージごとにBGMを再生するようにしています。
実現方法については最初はUniTaskのGetAsyncTriggerEnter2DTrigger().OnTriggerEnter2DAsyncを用いて、タイルマップのステージにBGMを設置する方法で実装していましたが、この場合、複数の接触対象(上記の回復、ダメージ、BGMの切り替えの背景)が存在すると、うまく取得できなくなってしまったため、途中からシンプルなOnTriggerEnter2DとOnTriggerExit2Dに切り替えました。
using UnityEngine;
namespace Scripts.BGM
{
public class StageBGM : MonoBehaviour
{
[SerializeField] private AudioSource _audioSource;
private void OnTriggerEnter2D(Collider2D other)
{
if (other.gameObject.GetComponent<PlayerCore>() is var targetPlayer && targetPlayer != null)
{
_audioSource.Play();
}
}
private void OnTriggerExit2D(Collider2D other)
{
if (other.gameObject.GetComponent<PlayerCore>() is var targetExitPlayer && targetExitPlayer != null)
{
_audioSource.Stop();
}
}
}
}
ステージ全体の背景用タイルマップにTilemapCollider2Dをセットして、そこにPlayerが侵入するとBGMが再生され、違うステージに行くと別のBGMが流れるようになりました。ステージ全体を接触対象とした理由ですが、例えばステージ1からステージ2に行き、ステージ2でやられてしまってステージ1に戻ったときにBGMをステージ1のものが流れるようにするためです。
タイトルの要素のFillAmount、Alphaを用いた演出について
タイトルのロゴ、キャラクター、ボタンの表示はFillAmountやAlphaを使って表示制御を行っています。
各UI要素の表示は時間差で表示できるようにUniTaskでdelayをかけられるうようにして制御してみました。
以下はFillAmountを用いた際のソースコードになります。アタッチされたUIに対して最初FillAmountを0にすることで非表示にして、その後UniTaskで設定されたdelayTimeが経過すると、UIのFillAmountの値を増やしていくことで、表示しています。UIが表示されたタイミングで次のUIを表示するように、delayTimeを調整することで連続してUIが表示されているような表現が簡単に作ることができます。
using UnityEngine;
using System.Threading;
using UnityEngine.UI;
using UniRx;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;
namespace Scripts.UI
{
public class UIFillAmount : MonoBehaviour
{
[SerializeField] private Image _image;
[SerializeField] private float filloutSpeed = 0.02f;
[SerializeField] private float delayTime = 1;
public ReactiveProperty<bool> isVisible = new ReactiveProperty<bool>(false); // 表示フラグ
private async void Start()
{
// 画像非表示
_image.fillAmount = 0;
// 待機処理
var token = this.GetCancellationTokenOnDestroy();
isVisible.Value = await UIViewWait(token);
// FillOutが1ではない場合、FillOutを実施
this.UpdateAsObservable()
.Where(_ =>
_image.fillAmount < 1
)
.Subscribe(_ => FillOut());
this.UpdateAsObservable()
.Where(_ =>
isVisible.Value == false &&
_image.fillAmount == 1
)
.Subscribe(_ => isVisible.Value = true);
}
private async UniTask<bool> UIViewWait(CancellationToken token)
{
// 待機処理
await UniTask.Delay((int) (delayTime * 1000), cancellationToken: token);
return true;
}
private void FillOut()
{
_image.fillAmount += filloutSpeed;
}
}
}
シーンのフェードイン、フェードアウトについて
シーンの切り替えはないちさんのライブラリUnity-FadeManagerを使用させていただきました。こちらのライブラリを使用することで、シーンがいきなり切り替わるのではなく、画面が暗転してからシーンが切り替わるようになります。
using UnityEngine;
using UniRx;
namespace Scripts.UI
{
public class ButtonStart : MonoBehaviour
{
/// <summary>ボタンが押されたか監視するためのReactiveProperty</summary>
public ReactiveProperty<bool> isPush = new ReactiveProperty<bool>(false);
/// <summary>遷移速度</summary>
[SerializeField] private float speed = 2.0f;
/// <summary>切り替え後のシーン名</summary>
[SerializeField] private string loadScene;
public void PushedButton()
{
isPush.Value = true;
FadeManager.Instance.LoadScene(loadScene, speed);
}
}
}
Startボタンが押されたとき用のアニメーションを実施するために、UniRxでボタンが押された場合のフラグをつけておくことで、その値を監視させることで、ボタンを押したタイミングで主人公のアニメーションを少しだけ切り替えるなどのちょっとした動きも実現ができました。
using UnityEngine;
using UniRx;
namespace Scripts.UI
{
public class TitlePlanaAnimation : MonoBehaviour
{
[SerializeField] Animator animator;
int hashStatePlanaWink = Animator.StringToHash("PlanaWink");
[SerializeField] private GameObject titleStateObject;
public TitleStateReactiveProperty titleStatus;
private void Start()
{
var gameStatus = titleStateObject.GetComponent<TitleStatus>();
gameStatus.titleState
.Where(x =>
x == TitleState.PushedStartButton)
.Subscribe(_ => Wink());
}
private void Wink()
{
animator.Play(hashStatePlanaWink);
}
}
}
オープニングのストーリーでDOTweenによる遷移について
プロローグの部分はDOTweenを用いたシンプルな遷移のUIにしています。DoozyUIも検討しましたが、そこまで複雑な作りではないため、DOTweenのみにしました。
会話用のTextMeshProで作成したUIをページ分、分割して前後のページUIをページ送りのボタンを押したときにDOTweenで画面外部から表示させ、現在のページUIと入れ替える形で実現しています。
UniTaskとJSONによる会話シーンUIについて
作中で登場人物の掛け合いのようなものを表現したかったため、特定の場所やプレイヤーのライフが0になったときに、会話用のJSONを読み込み、JSONが存在する場合に会話UIが表示されるようにしてみました。
実現の方法としては特定の場所にTalkクラスという会話用クラスを持ったゲームオブジェクトを設置して、それにプレイヤーが触れた時に会話用Classを格納して、それをトリガーにしてUIがせり出すような形になります。
using UnityEngine;
namespace Scripts.Event
{
public class Talk : MonoBehaviour
{
[SerializeField] private string talkFile;
[SerializeField] private string talkText;
public string currentTalkFile
{
get { return talkFile; }
}
public string currentTalkText
{
get { return talkText; }
}
}
}
具体的には、PlayerTalk.csというスクリプトを作り、そこではUniTaskで会話用クラスを持つゲームオブジェクトの接触をするまで待機させていて、接触をした時にTalkクラスをReactiveProperty用の変数に格納します。会話用のUIはUniRxでPlayerTalkを監視していて、Talkクラスを持った変数に変更があった時点で、Talkクラス内にあるJSONデーターをUniTaskを使って、一定時間ごとに読み込みます。その際顔グラフィックを指定するテキストと会話のテキストをUniRxのReactiveProperty用の変数に格納して、それぞれ顔グラフィック表示UI、会話テキスト表示UI側でUniRxを用いた監視をして、切り替えを行っています。
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Triggers;
using Scripts.Interface;
using Scripts.Event;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
namespace Scripts.Player
{
public class PlayerTalk : MonoBehaviour, ITalkable
{
private ReactiveProperty<Talk> talkData = new ReactiveProperty<Talk>(null);
[SerializeField] public IObservable<Talk> IsTalkDataObservable => talkData;
public Talk currentTalk
{
get { return talkData.Value; }
}
private void Start()
{
var ct = this.GetCancellationTokenOnDestroy();
SetTalk(ct).Forget();
}
/**
* 会話エリアでTalkクラスを受け取り、セットする
*/
async UniTask SetTalk(CancellationToken ct)
{
// Talkクラスを持つオブジェクトと接触した場合処理
var target = await this.GetAsyncTriggerEnter2DTrigger().OnTriggerEnter2DAsync(ct);
if (target.gameObject.GetComponent<Talk>() is var targetTalk && targetTalk != null)
{
talkData.Value = targetTalk;
}
if (ct.IsCancellationRequested)
{
throw new OperationCanceledException(ct);
}
SetTalk(ct).Forget();
}
}
}
using System;
using System.IO;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using Scripts.Player;
using UnityEngine;
using UniRx;
using UniRx.Triggers;
using DG.Tweening;
namespace Scripts.UI.Talk
{
public class ReadJson : MonoBehaviour
{
[SerializeField] private string jsonPath;
[SerializeField] private TalkDataList _talkDataList;
[SerializeField] private PlayerTalk _playerTalk;
[SerializeField] private PlayerVitalStatus _playerVitalStatus;
[SerializeField] private Event.Talk downTalk;
[SerializeField] private RectTransform _talkWindow;
[SerializeField] private float _talkWindowCuiinPosition = 120;
[SerializeField] private float _talkWindowCuiinSpeed = 0.25f;
public ReactiveProperty<string> characterImage = new ReactiveProperty<string>();
[SerializeField] public IObservable<string> characterImageObservable => characterImage;
public ReactiveProperty<string> talk = new ReactiveProperty<string>();
[SerializeField] public IObservable<string> talkObservable => talk;
private CancellationToken ct;
/**
* jsonパスをセットする
*/
private void SetPath(string path)
{
jsonPath = Application.dataPath + "/" + path;
}
/**
* json読み込み
*/
private TalkDataList LoadJson()
{
StreamReader reader = new StreamReader(jsonPath);
string jsonData = reader.ReadToEnd();
reader.Close();
return JsonUtility.FromJson<TalkDataList>(jsonData);
}
/**
*
*/
private TalkDataList ReadJsonText(string text)
{
return JsonUtility.FromJson<TalkDataList>(text);
}
private async UniTask Start()
{
// Talkオブジェクトに接触し、Talkをプレイヤーが取得したら実施
_playerTalk.IsTalkDataObservable.DistinctUntilChanged().Where(x => x).Subscribe(x =>
{
ct = this.GetCancellationTokenOnDestroy();
TalkSet(ct, x).Forget();
});
// プレイヤーダウン時
_playerVitalStatus.playerVitalState.DistinctUntilChanged()
.Where(x => x == PlayerVitalState.Down)
.Subscribe(
_ =>
{
ct = this.GetCancellationTokenOnDestroy();
TalkSet(ct, downTalk).Forget();
}
);
}
private async UniTask TalkSet(CancellationToken ct, Event.Talk talkFile)
{
// json 読み込み
_talkDataList = ReadJsonText(talkFile.currentTalkText);
// talk window表示
await _talkWindow.DOLocalMoveY(_talkWindowCuiinPosition, _talkWindowCuiinSpeed).SetEase(Ease.Linear);
_talkWindow.DOLocalMoveY(_talkWindowCuiinPosition, _talkWindowCuiinSpeed).SetEase(Ease.Linear);
// talk 表示
foreach (var talkData in _talkDataList.TalkDatas)
{
await UniTask.Delay(TimeSpan.FromSeconds(1));
characterImage.Value = talkData.characterImage;
talk.Value = talkData.talk;
}
await UniTask.Delay(TimeSpan.FromSeconds(2));
// talk window非表示
_talkWindow.DOLocalMoveY((_talkWindowCuiinPosition * -1), _talkWindowCuiinSpeed).SetEase(Ease.Linear);
characterImage.Value = "";
talk.Value = "";
if (ct.IsCancellationRequested)
{
throw new OperationCanceledException(ct);
}
}
}
}
今回の失敗点としては外部ファイルのJSONはUnityEditor上では読み込めましたが、WebGLの場合パスなどが変わってしまうため、こちらについては一旦テキストを直接エディタ上で指定して、読み込むことにしました。(やり方についてはおそらくもっとよい方法はあると思いますが、この時点ではあまり時間もなかったため、こちらの方法で対応しました。)
また会話中にプレイヤーのライフが0になってしまうと、ダウン時の会話も途中で混じっておかしくなってしまうバグがあります。
FillAmountを用いたライフ、耐性UIの更新について
ライフゲージUIの増減については、二つの画像を重ねて、上に乗せた画像のFillAmountの値を変更して実現しています。上にかぶせるのがライフが表示された画像、下に置くのがライフが空の画像にして、上にかぶせた方の画像をFillAmountのFill MethodをBottomにすることで、ライフが減っている表現が簡単に実現することができます。
同様に耐性の経験値UIも二つの画像を重ねて、Fill MethodをRadial 360にすることで、こちらも比較的簡単に実装ができます。
耐性の仕組みについて
ぷらなりんかねーしょん!では耐性という仕組みを取り入れています。この仕組みを作ったのはアクションゲームがなんどもやられながらプレイヤー自身のスキルが向上して、だんだんと進んでいく俗にいう死にげーというものが多く、アクションゲームが得意な人はだんだんとゲームの腕が向上し、徐々に楽しめる人も多いと思いますが、中にはなかなか上達せずアクションゲームがそれで苦手になってしまう人もいるのではと思いました。そこでダメージを受けるほどに耐性ができて、耐性があることで少しずつダメージが減れば相対的に難易度を下げることができるのではないかと思い、耐性という仕組みの実装方法について考えてみることにしました。
耐性はダメージを受けると耐性経験値が上がり、耐性経験値が一定になると耐性レベルが上がるようになっています。耐性レベルが上がるとその属性に対応するダメージが軽減されるしくみを考えました。
アクションゲームが得意な人は耐性に頼らない遊び方もできて、アクションゲームがあまり得意ではない人は耐性をつけながら、少しずつ進めていくという遊び方をするための耐性という仕組みをより活かすために、ステージをイージーステージ、ハードステージというように分けてみました。
イージーステージは段階的に耐性レベルを上げていくことができるようなステージ構成として、回復地点もある程度多めに設置してみました。またステージも最初は簡単なアクションから少しずつ慣れていき、だんだんと複雑な地形になるようにしていくことで、なるべく挫折しないような構成にしてみました。
反対にハードステージについては、いきなり大ダメージを受けるダメージオブジェクトを数多く設置して、回復地点も少なめに設置しています。耐性経験値はダメージの大小に関わらず一定のため、ダメージが大きいほど耐性経験値獲得の効率が悪くなります。そのためダメージの大きいハードステージは耐性経験値を上げにくくなり、耐性レベルを上げてゴリ押しがしにくい構成になりました。
またこのゲームの回復地点はプレイヤーのライフが0になった時の復帰地点としての役割もあり、ハードステージでは耐性レベルを上げにくくするために、意図的に回復地点とダメージオブジェクトを離しています。そのようにハードステージはあまり耐性を頼らずにプレイヤー自身のスキルでゲームを楽しめるような構成に近づけたと思います。
using Scripts.Resistance;
using Scripts.Vital;
using UnityEngine;
using Scripts.Interface;
namespace Scripts.Player
{
public class PlayerResistance : MonoBehaviour
{
[SerializeField] private BaseResistance thornResistance;
[SerializeField] private BaseResistance fireResistance;
[SerializeField] private BaseResistance impactResistance;
[SerializeField] private PlayerVitalStatus _playerVitalStatus;
public int DamageResistance(Damage damage)
{
int registanceLevel = 0;
var damageType = damage.currentResistanceType;
switch (damageType)
{
case ResistanceType.Thorn:
return CalculationDamage(damage, thornResistance);
break;
case ResistanceType.Fire:
return CalculationDamage(damage, fireResistance);
break;
case ResistanceType.Impact:
return CalculationDamage(damage, impactResistance);
break;
}
// もしいずれの耐性にもヒットしなかった場合は0を返却
return 0;
}
private int CalculationDamage(Damage damage, BaseResistance resistance)
{
int registanceLevel = resistance.CurrentLevel;
// ダメージから耐性レベルを差し引く
int deffensiveDamage = damage.currentSubtractLife - registanceLevel;
if (deffensiveDamage < 0)
{
deffensiveDamage = 0;
}
// ダメージが1以上の場合、経験値取得
if (deffensiveDamage > 0 && _playerVitalStatus.playerVitalState.Value != PlayerVitalState.Down)
{
resistance.GetExperience(damage.currentResistanceType);
}
return deffensiveDamage;
}
}
}
Affinity Designerによる素材作成について
グラフィック作成については本来餅は餅屋ということで長けている人に依頼する方がゲーム全体のクオリティは大きく上がるのは重々承知ですが、今回はその上で自作をする方法を模索してみました。今回使用したツールはAffinityDesignerで、学習コストも低くベクター描画もできるため、拡大縮小はもちろん、ちょっとした位置の微調整なども比較的やりやすいので、個人的にはオススメです。
イラストレーターではないため、素材作成は難航しましたが、いきなり全体を描こうとするのではなく部分的に描きながら全身を仕上げていくようにしてみました。
瞳は虹彩、瞳孔、ハイライトを意識しながら描いていくとキャラクターへの感情移入が強くなっていき描くモチベーションが高まったので、自分は顔の輪郭を描いてから瞳を描くようになりました。
また今回AffinityDesignerで重宝した機能がシンボル、グラデーションマップ、レイヤーマスクでした。
シンボルは片目が完成したらシンボル化すると、もう片方の目はシンボルから複製して反転させると、簡単にもう片方の目を作ることができます。また片目の修正内容も同期されるので便利でした。
AffinityDesignerシンボル
肌を塗るときはグラデーションマップが便利でした。肌のベースの色を薄めのグレーにして、赤系のグラデーションマップを適用すると、肌色のような質感を表現できました。
【伝授】おしゃれな色 1分で塗り終わる方法
髪の毛はレイヤーマスクを作り、髪の色はグラデーションマップで作ると描きやすかったです。
AffinityDesigner レイヤーマスキング
Medlyを用いた今回のゲーム作曲について
BGMについてはMedlyというアプリを使用して作曲してみました。
私には作曲の知識がありませんが、Medlyのテーマにいくつかテンプレートがあり、それをカスタマイズする事で比較的簡単に作曲ができました。テンプレートの音源を別のテーマの音源、テンポ、キーを変更する事で全く違った印象の曲にアレンジができるので、ゲームの戦闘曲のような様々な種類の曲を比較的短期間で作る事ができます。
Medlyについては以前別でまとめた記事がありますので、興味がありましたらご覧いただけますと幸いです。
今回Medlyで作成した曲の一部
ソースコード
今回のぷらなりんかねーしょん!で使用したソースコードをGithubで公開しております。