
マイケル
みなさんこんにちは!
マイケルです!
マイケルです!

エレキベア
クマ〜〜〜〜〜

マイケル
今日は久しぶりにリリースしたアプリについて
反省も踏まえて振り返っていくぜ!
その名も・・・エレキシューティング!!
反省も踏まえて振り返っていくぜ!
その名も・・・エレキシューティング!!


エレキベア
なんかどこかでみたことある気がするクマ

マイケル
そう、これはC++開発記事(第五回)で作っていたシューティングゲームを
Unityに移植したものなんだ!
Unityに移植したものなんだ!

エレキベア
なつかしいクマ〜〜〜
でも見た感じいろいろパワーアップしていそうクマね
でも見た感じいろいろパワーアップしていそうクマね

マイケル
改めてゲームエンジンを使って開発すると便利でいろいろ追加してみたんだ!
そして今回はなんとWebGL版にも対応させているから、このサイトでも遊べるように公開する予定なんだ!
そして今回はなんとWebGL版にも対応させているから、このサイトでも遊べるように公開する予定なんだ!

マイケル
将来的にゲームコーナーカテゴリとして作る予定ですが、暫定で下記に公開しているので、
よかったら遊んでみてくださいね!
よかったら遊んでみてくださいね!

エレキベア
unityroomみたいな感じにするクマね
実装したプロジェクト

マイケル
今回作ったアプリは、Unityプロジェクトを丸ごとGitHubにて公開しているので
よければゲーム作りの参考にしてください!
(Admob等の一部情報は抜いてあります)
よければゲーム作りの参考にしてください!
(Admob等の一部情報は抜いてあります)
GitHub – unity-shooting-game-3d-sample

エレキベア
丸ごと公開は太っ腹クマね〜〜

マイケル
注意点としては、元々GitHubに公開予定だったために外部アセットは使わずになるべくUnity標準機能だけで作るようにしていたことです。
そのため、モダンな技術もあまり使っておらず、ゴリゴリ書いている箇所がたくさんあるため、そこはご了承ください。。
そのため、モダンな技術もあまり使っておらず、ゴリゴリ書いている箇所がたくさんあるため、そこはご了承ください。。

エレキベア
でもDOTweenは使ったのクマね

マイケル
UIアニメーションを付けるのに使わないと辛かったからね・・・。

エレキベア
便利クマからねぇ・・・
全体構造

マイケル
プロジェクトの全体構造としては下記のようになっています!
ディレクトリ名 | 概要 |
├── 2D Assets | 2D素材(主にUI周り) |
├── 3D Assets | 3D素材(3Dモデル関連) |
├── Animator | Animationファイル関連 |
├── Editor | EditorScript関連 |
├── Resources | ゲーム中に読み込むアセット(Prefab、音源、JSONデータ) |
├── Scenes | Sceneファイル関連 |
├── Scripts | Script関連 |
├── Tests | ユニットテスト関連 |
└── Tools | その他使用したツール |

マイケル
相変わらず画像から音源まで全て手作りです!
効果音もフリー音源に頼りそうになりましたがなんとか耐えてシンセで作りました・・・。
効果音もフリー音源に頼りそうになりましたがなんとか耐えてシンセで作りました・・・。

エレキベア
なんというハンドメイド精神クマ・・・
スクリプト構造

マイケル
Scripts配下は下記のように分けています!
各シーンのフォルダを作成し、MVPモデル、アクタ単位ごとにフォルダを分割しました。
各シーンのフォルダを作成し、MVPモデル、アクタ単位ごとにフォルダを分割しました。
ディレクトリ名 | 概要 |
├── Scenes | シーンごとにフォルダを分割 |
│ ├── Common | 全てのシーン共通 |
│ │ ├── Actor | |
│ │ ├── Model | |
│ │ └── View | |
│ ├── Game | Gameシーン |
│ │ ├── Actor | |
│ │ ├── Model | |
│ │ ├── Presenter | |
│ │ └── View | |
│ └── Title | Titleシーン |
│ ├── Presenter | |
│ └── View | |
├── Schema | JSON構造定義 |
├── Services | サービス関連 |
└── Utils | その他共通処理 |

マイケル
Scriptsフォルダ直下の ProjectInitializer.cs がプロジェクト初期化の処理、
各Sceneフォルダ直下にある 〜SceneCtrl.cs が各シーンのエントリーポイントになっています。
各Sceneフォルダ直下にある 〜SceneCtrl.cs が各シーンのエントリーポイントになっています。

エレキベア
ソースの依存関係に合わせてフォルダ階層も切ったわけクマね

マイケル
シーンはタイトルシーン、ゲームシーンの2つで、
ゲームシーンの中ではStateとしてゲーム中、クリアといった状態を持たせています。
ゲームシーンの中ではStateとしてゲーム中、クリアといった状態を持たせています。

↑タイトルシーン

↑ゲームシーン

エレキベア
シンプルな構造クマ
工夫した点

マイケル
それでは作りながら実際に工夫した点や小話等を振り返っていきます!
C++開発時からいくつかの機能を追加

マイケル
今回作ったゲームは、C++の勉強で作ったゲームの延長線として、
Unityに移植したものになります。
作った当時は下記のような画面になっています!
Unityに移植したものになります。
作った当時は下記のような画面になっています!


エレキベア
なつかしいクマ〜〜〜
見比べてみるとショボいクマね
見比べてみるとショボいクマね

マイケル
エンジン部分を作るのがメインだったから仕方がない・・・
移植のため、基本的にインゲーム部分はC++で書いた処理を流用して作っています!
例えば下記の敵アクタの動き等ですね。
移植のため、基本的にインゲーム部分はC++で書いた処理を流用して作っています!
例えば下記の敵アクタの動き等ですね。
var ownerTransform = Owner.transform;
// 中心(0, 0, 0)に近づいたら喜びステートへ遷移
if (Vector3.Distance(ownerTransform.position, Vector3.zero) <= ChangeHappyDistance)
{
StateMachine.ChangeState((int) StateType.Happy);
return;
}
// 出現時の距離分進む
var speed = Owner._speed;
if (_totalMove < AppearDistance)
{
_totalMove += (speed * ownerTransform.forward * Time.deltaTime).magnitude;
}
// 揺れの量を求める
var shakeWidth = Owner._shakeWidth;
var shakeVec = shakeWidth.x * ownerTransform.right + shakeWidth.y * Vector3.up;
shakeVec = Mathf.Sin(Time.frameCount / 10.0f) * shakeVec;
// 位置の更新
var initPosition = Owner._initPosition;
ownerTransform.position = initPosition + (_totalMove * ownerTransform.forward) + shakeVec;
↑カニの動き
マイケル
Unityの実装に合わせてはいますが、基本的な処理は同じです!
使用した3Dモデルも、宇宙船、カニ、背景の星だけという省エネ構成です。
使用した3Dモデルも、宇宙船、カニ、背景の星だけという省エネ構成です。

エレキベア
モデルの素材も変わっていないクマね

マイケル
それではこのゲームに対して追加した機能について
いくつかピックアップしていきます!
いくつかピックアップしていきます!
モバイル端末に対応

マイケル
まずモバイル端末に対応するために、
タッチ、スライドで操作できるようにしたことです!
タッチ、スライドで操作できるようにしたことです!


マイケル
スライド操作に関しては、以前書いたジョイスティック実装をカスタマイズして
使用しています!
使用しています!
using UnityEngine;
using Utils;
namespace Scenes.Common.Model
{
/// <summary>
/// 移動スティックModel
/// </summary>
public class StickMoveModel
{
private const float StickMoveRadiusMax = 80.0f; // 最大移動半径
private const float StickMoveRadiusMin = 40.0f; // 最小移動半径
private Touch _stickTouch; // タッチ情報
private Vector3 _stickBeganTouchPosition; // タッチ開始時の位置
public bool IsPushStick { get; private set; } // 押下されているか?
public Vector3 StickDragDiffPosition { get; private set; } // スティック移動量
public StickMoveModel()
{
// タッチ情報を初期化
IsPushStick = false;
_stickTouch = TouchUtil.NotTouch;
StickDragDiffPosition = Vector3.zero;
_stickBeganTouchPosition = Vector3.zero;
}
/// <summary>
/// スティック情報更新処理
/// </summary>
/// <param name="canvasRectTransform">キャンバスRectTransform</param>
public void OnUpdateStick(RectTransform canvasRectTransform = null)
{
if (!IsPushStick) return;
// タップ座標、ドラッグ座標を設定
var screenTapPosition = _stickBeganTouchPosition;
var screenDragPosition = TouchUtil.GetCurrentTouchPosition(_stickTouch);
// ドラッグ差分を算出
var canvasDiffPosition = TouchUtil.CalculateDragDiffPosition(
screenTapPosition, screenDragPosition,
canvasRectTransform); // 解像度考慮のためキャンバス座標で取得
var canvasDiffMagnitude = canvasDiffPosition.magnitude;
// 最大半径より大きいなら丸める
if (StickMoveRadiusMax < canvasDiffMagnitude)
{
canvasDiffPosition.Normalize();
canvasDiffPosition *= StickMoveRadiusMax;
}
// 最小半径より大きいなら移動量を設定
StickDragDiffPosition = Vector3.zero;
if (StickMoveRadiusMin < canvasDiffMagnitude)
{
StickDragDiffPosition = canvasDiffPosition;
}
}
/// <summary>
/// スティックを押下した時
/// </summary>
public void PushDownStick()
{
// タッチ情報を設定
IsPushStick = true;
_stickTouch = TouchUtil.GetBeganTouch();
_stickBeganTouchPosition = _stickTouch.position;
}
/// <summary>
/// スティックを離した時
/// </summary>
public void PushUpStick()
{
// タッチ情報を初期化
IsPushStick = false;
_stickTouch = TouchUtil.NotTouch;
StickDragDiffPosition = Vector3.zero;
_stickBeganTouchPosition = Vector3.zero;
}
}
}
↑スライド操作で使用したモデル(移動、攻撃方向調整)
マイケル
使用する側は下記のように実装しています。
if (_moveStickMoveModel.StickDragDiffPosition.x > MoveStickMoveMin)
{
var dragDiffPosition = Mathf.Abs(_moveStickMoveModel.StickDragDiffPosition.x);
_player.SetMoveRightVelocity((dragDiffPosition - MoveStickMoveMin) / MoveStickMoveMax);
}
if (_moveStickMoveModel.StickDragDiffPosition.x < -MoveStickMoveMin)
{
var dragDiffPosition = Mathf.Abs(_moveStickMoveModel.StickDragDiffPosition.x);
_player.SetMoveLeftVelocity((dragDiffPosition - MoveStickMoveMin) / MoveStickMoveMax);
}
↑DiffPositionでスライド判定
エレキベア
これはシンプルで使いやすそうクマね
カニ破壊時の振動

マイケル
また、迫力を少しでも出すために、カニ破壊時にゲージを振動させるようにしています!
これは下記記事で紹介したパーリンノイズの実装になりますね。
これは下記記事で紹介したパーリンノイズの実装になりますね。


エレキベア
これまた懐かしいクマ〜〜〜
ビーム発射

マイケル
もう一つ、ショットだけでは物足りなかったので
派手なビームを放てるようにしました!
ParticleSystemで作りましたがとても楽しかったですね・・・
派手なビームを放てるようにしました!
ParticleSystemで作りましたがとても楽しかったですね・・・



エレキベア
これは中々爽快クマね
スローモーション演出

マイケル
もう一つ工夫した演出として、敵を破壊した時にスローになるようにしてみました!
キン●ダムハーツのボス撃破時のような感じですね!!
キン●ダムハーツのボス撃破時のような感じですね!!


エレキベア
これは中々かっこいいクマね
キン●ダムハーツの倒した時の演出も気持ちいいクマからね〜〜
キン●ダムハーツの倒した時の演出も気持ちいいクマからね〜〜

マイケル
Unityで時間の速度を変化させるのは簡単で、
Time.timeScaleの値を変更するのみ!
ちなみに音の高さは変わらないから注意が必要だ!
Time.timeScaleの値を変更するのみ!
ちなみに音の高さは変わらないから注意が必要だ!
// スローモーション処理
private bool _isSlowMotion;
private const float SlowMotionTimeScale = 0.2f;
private const float SlowMotionAudioPitch = 0.45f;
private const float SlowMotionTime = 0.6f;
private IEnumerator ChangeSlowMotion()
{
// スローモーションフラグをON
_isSlowMotion = true;
// カメラをクリア状態に切り替える
Owner.cameraCtrl.StartClearState(Owner._lastDestroyEnemyPosition);
// BGM停止
ServiceLocator.Resolve<IAudioService>().StopBGM();
// 指定時間分スローモーションにする
Time.timeScale = SlowMotionTimeScale;
ServiceLocator.Resolve<IAudioService>().ChangeSePitch(SlowMotionAudioPitch);
yield return new WaitForSeconds(SlowMotionTime);
// 元に戻す
Time.timeScale = 1.0f;
ServiceLocator.Resolve<IAudioService>().ChangeSePitch(1.0f);
// カメラを元に戻す
Owner.cameraCtrl.EndClearState();
// スローモーションフラグをOFF
_isSlowMotion = false;
// クリア状態に遷移する
StateMachine.ChangeState((int) StateType.GameClear);
}
↑時間の速度を変える
マイケル
カメラの移動は下記の通り!
初期位置とターゲット位置を決めた後、Vector3.MoveTowardsで移動させています!
初期位置とターゲット位置を決めた後、Vector3.MoveTowardsで移動させています!
// ----- clear animation ------
private class StateClear : StateMachine<CameraCtrl>.StateBase
{
// 位置情報
private static readonly Vector3 OffsetPos = new Vector3(5.0f, 5.0f, 10.0f);
private Vector3 _smoothTargetPos;
private const float SmoothSpeed = 5.0f;
private const float InitPosDistance = 5.0f;
// 揺れ情報
private const float ShakeStrength = 1.0f;
private const float ShakeDuration = 0.5f;
private float _totalTime = 0.0f;
public override void OnStart()
{
_totalTime = 0.0f;
// ターゲット位置を設定
var forwardVec = Owner._clearTargetEnemyPos.normalized;
var rightVec = Quaternion.Euler(0.0f, -90.0f, 0.0f) * forwardVec;
var targetPos = Owner._clearTargetEnemyPos;
targetPos += forwardVec * OffsetPos.z;
targetPos += rightVec * OffsetPos.x;
targetPos += Vector3.up * OffsetPos.y;
_smoothTargetPos = targetPos;
// 初期位置を設定
Owner.transform.position = targetPos.normalized * InitPosDistance;
Owner.transform.LookAt(Vector3.zero);
}
public override void OnUpdate()
{
// 終了通知を受け取ったら通常に戻る
if (!Owner.IsClearState)
{
StateMachine.ChangeState((int) StateType.Normal);
}
// ターゲット位置に着いたら何もしない
if (Vector3.Distance(Owner.transform.position, _smoothTargetPos) < 0.5f)
{
return;
}
// 臨場感出すため少し揺らす
var ratio = Mathf.Clamp(1.0f - _totalTime / ShakeDuration, 0.0f, 1.0f);
var randomOffsetX = Random.Range(-ShakeStrength, ShakeStrength);
var randomOffsetY = Random.Range(-ShakeStrength, ShakeStrength);
Owner.transform.position += randomOffsetX * ratio * Owner.transform.right;
Owner.transform.position += randomOffsetY * ratio * Owner.transform.up;
// ターゲット位置に向かって移動
Owner.transform.position = Vector3.MoveTowards(Owner.transform.position, _smoothTargetPos, SmoothSpeed);
Owner.transform.LookAt(Vector3.zero);
}
public override void OnEnd() { }
}
↑カメラの移動処理
マイケル
ついでに移動する時に少し揺らすようにしてみました。

エレキベア
やりたい放題クマね
2種類のモード

マイケル
遊べるモードとしてはノーマルモード、エンドレスモードの
2種類を用意しています。
2種類を用意しています。



エレキベア
一応やり込めるようにしているクマね

マイケル
2つのモードのメイン処理はどちらもほぼ同じなため、
デザインパターンのTemplate Methodパターンを使用しています。
これはいい例だと思ったので紹介します!
デザインパターンのTemplate Methodパターンを使用しています。
これはいい例だと思ったので紹介します!

マイケル
GameSceneCtrlGameStateTemplate.csにゲーム全体の処理を書いて、
一部処理を各モードで継承できるようにしています。
一部処理を各モードで継承できるようにしています。
今回、各モードで異なるのは
・敵の生成情報
・プレイヤーが破壊された時の処理
・生成した敵を全て破壊した時の処理
の3つになるので、この関数を継承しています。
using JsonSchema.Response;
using Services;
namespace Scenes.Game
{
/// <summary>
/// GameScene管理クラス
/// GameState ノーマルモード用
/// </summary>
public partial class GameSceneCtrl
{
private class StateGameNormalMode : StateGameTemplate
{
/// <summary>
/// ゲーム開始時の敵生成情報取得
/// </summary>
protected override EnemyGenerateInfo GetInitEnemyGenerateInfo()
{
return ServiceLocator.Resolve<IDataAccessService>().GetEnemyGenerateInfo(Owner._gameMode, Owner._gameLevel);
}
/// <summary>
/// プレイヤーが破壊された時の処理
/// </summary>
protected override void PlayerDestroyAction()
{
// ゲームオーバー
Owner.cameraCtrl.ShakeCamera(); // カメラを揺らす
Owner._player.StartStateStop(); // プレイヤーを停止状態にする
StateMachine.ChangeState((int) StateType.GameOver);
}
/// <summary>
/// 敵が全て破壊された時の処理
/// </summary>
protected override void AllEnemyDestroyAction()
{
// ゲームクリア
Owner._player.StartStateStop(); // プレイヤーを停止状態にする
StateMachine.ChangeState((int) StateType.SlowMotion); // クリア前にスローモーション状態に遷移
}
}
}
}
↑ノーマルモードの処理using JsonSchema.Response;
using Services;
using UnityEngine;
using Utils;
namespace Scenes.Game
{
/// <summary>
/// GameScene管理クラス
/// GameState エンドレスモード用
/// </summary>
public partial class GameSceneCtrl
{
private class StateGameEndlessMode : StateGameTemplate
{
private EnemyGenerateInfo _lastGenerateInfo; // 最後に生成した情報
private int _generateLevel; // 敵生成レベル
/// <summary>
/// MAXレベルを超えた場合の追加速度
/// </summary>
private const float OverLevelAddSpeed = 10.0f;
/// <summary>
/// ゲーム開始時の敵生成情報取得
/// </summary>
protected override EnemyGenerateInfo GetInitEnemyGenerateInfo()
{
// 敵生成レベルに1を指定して開始する
_generateLevel = 1;
_lastGenerateInfo = ServiceLocator.Resolve<IDataAccessService>().GetEnemyGenerateInfo(Owner._gameMode, _generateLevel);
return _lastGenerateInfo;
}
/// <summary>
/// プレイヤーが破壊された時の処理
/// </summary>
protected override void PlayerDestroyAction()
{
// ゲーム終了
Owner.cameraCtrl.ShakeCamera(); // カメラを揺らす
Owner._player.StartStateStop(); // プレイヤーを停止状態にする
StateMachine.ChangeState((int) StateType.GameClear);
}
/// <summary>
/// 敵が全て破壊された時の処理
/// </summary>
protected override void AllEnemyDestroyAction()
{
// 敵生成レベルアップ
_generateLevel++;
Debug.Log("generate level: " + _generateLevel);
// レベルアップメッセージを表示
ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeLucky);
Owner._gameInfoPresenter.ShowLevelUpMsg();
// 敵の生成情報を取得
var generateInfo = ServiceLocator.Resolve<IDataAccessService>().GetEnemyGenerateInfo(Owner._gameMode, _generateLevel);
if (generateInfo != null)
{
// 取得できた場合、生成情報を保持
_lastGenerateInfo = generateInfo;
}
else
{
// 取得できなくなった場合(MAXレベルを超えた場合)、
// 最後の生成情報に速度を加えていく
_lastGenerateInfo.minSpeed += OverLevelAddSpeed;
_lastGenerateInfo.maxSpeed += OverLevelAddSpeed;
generateInfo = _lastGenerateInfo;
}
// 追加で敵を生成する
GenerateEnemy(generateInfo, Owner._enemyPrefab, 3.0f); // 3秒待機する
}
}
}
}
↑エンドレスモードの処理
エレキベア
テンプレートの処理を書いて継承するのが
うまくハマったクマね
うまくハマったクマね
UIアニメーション

マイケル
今回、UIにもいくつか動きを付けてみたので紹介します!
DOTweenアニメーション

マイケル
外部アセットはなるべく使わないようにしながらも
DOTweenだけは唯一使っています!
DOTweenだけは唯一使っています!

エレキベア
Tweenアニメーションの定番アセットクマね

マイケル
具体的には
・スコアのカウントアップ
・結果情報のシーケンス処理
・タイトルロゴの動き
等に適用しています!
・スコアのカウントアップ
・結果情報のシーケンス処理
・タイトルロゴの動き
等に適用しています!


エレキベア
動きを付けると見ていて気持ちいいクマね

マイケル
カウントアップ等、よく使う処理はUtilクラスとしてまとめて、
各Viewクラス内で使用しています。
やっつけで書いている部分もあるので読み辛いのはご勘弁ください。。
各Viewクラス内で使用しています。
やっつけで書いている部分もあるので読み辛いのはご勘弁ください。。
/// <summary>
/// DOTween共通クラス
/// </summary>
public class DOTweenUtil : MonoBehaviour
{
/// <summary>
/// テキストを加算する
/// </summary>
public static Tween IncrementText(Text text, float current, float target, float duration = 1.0f, UnityAction<float> setAction = null)
{
return DOTween.To( (val) =>
{
current = val;
if (setAction != null)
{
setAction(val);
}
else
{
text.text = Mathf.RoundToInt(val).ToString();
}
}, current, target, duration);
}
/// <summary>
/// Scaleを変更する
/// </summary>
public static Tween DoScaleTransform(Transform transform, Vector3 current, Vector3 target, float duration = 1.0f, Ease ease = Ease.Linear)
{
transform.localScale = current;
return transform.DOScale(target, duration).SetEase(ease);
}
/// <summary>
/// localPositionを変更する
/// </summary>
public static Tween DoLocalMoveTransform(Transform transform, Vector3 current, Vector3 target, float duration = 1.0f, Ease ease = Ease.Linear)
{
transform.localPosition = current;
return transform.DOLocalMove(target, duration).SetEase(ease);
}
/// <summary>
/// Alpha値をフェードする
/// </summary>
public static Tween FeedImageAlpha(Image image, float current, float target, float duration = 1.0f)
{
var tmp = image.color;
tmp.a = current;
image.color = tmp;
return DOTween.ToAlpha(
() => image.color,
color => image.color = color,
target,
duration);
}
}
↑DOTween共通クラス
マイケル
例としてノーマルモードの結果表示は下記のようにシーケンス処理として実装しています。
直列はAppend、並列はJoinを使うことで自由に処理を繋げることができます!
これとコールバックを指定できるAppendCallbackの3つを覚えるだけでもかなり自由にアニメーションが付けれるはずです!
直列はAppend、並列はJoinを使うことで自由に処理を繋げることができます!
これとコールバックを指定できるAppendCallbackの3つを覚えるだけでもかなり自由にアニメーションが付けれるはずです!
// リザルト画面表示
var sequence = DOTween.Sequence();
sequence.AppendCallback(() =>
{
InitializeScoreInfo();
SetActiveResultWindow(true);
SetActiveResultNextButton(false);
});
// 敵の倒した数を表示
var duration = 0.5f;
sequence.Append(DOTweenUtil.IncrementText(resultPerfectCountText, 0, perfectCount, duration));
sequence.Join(DOTweenUtil.IncrementText(resultPerfectScoreText, 0, perfectScore, duration));
sequence.Join(DOTweenUtil.IncrementText(resultGreatCountText, 0, greatCount, duration));
sequence.Join(DOTweenUtil.IncrementText(resultGreatScoreText, 0, greatScore, duration));
sequence.Join(DOTweenUtil.IncrementText(resultGoodCountText, 0, goodCount, duration));
sequence.Join(DOTweenUtil.IncrementText(resultGoodScoreText, 0, goodScore, duration));
sequence.Join(DOTweenUtil.IncrementText(null, 0, 1.0f, duration, (val) =>
{
ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeCount);
}));
// タイムを表示
sequence.AppendInterval(0.5f);
sequence.Append(DOTweenUtil.IncrementText(resultTimeText, 0.0f, time, duration, (val) =>
{
resultTimeText.text = DateUtil.ConvTimeToMmSs(val);
}));
sequence.Join(
DOTweenUtil.IncrementText(resultTimeScaleText, 0.0f, timeScale, duration, (val) =>
{
resultTimeScaleText.text = "x" + val.ToString("f2");
}));
sequence.Join(DOTweenUtil.IncrementText(null, 0, 1.0f, duration, (val) =>
{
ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeCount);
}));
// トータルスコアを表示
var totalDuration = 0.5f;
var totalEase = Ease.InExpo;
sequence.AppendInterval(0.5f);
sequence.AppendCallback(() => resultTotalScoreText.text = Mathf.RoundToInt(totalScore).ToString());
sequence.Join(DOTweenUtil.DoLocalMoveTransform(resultTotalScoreText.rectTransform, new Vector3(-200.0f, 200.0f, 1.0f), resultTotalScoreText.rectTransform.localPosition, totalDuration, totalEase));
sequence.Join(DOTweenUtil.DoScaleTransform(resultTotalScoreText.rectTransform, 10.0f * resultTotalScoreText.rectTransform.localScale, resultTotalScoreText.rectTransform.localScale, totalDuration, totalEase));
sequence.AppendCallback(() => ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeBomb));
// トータルスコアの稲妻を表示
sequence.AppendCallback(() =>
{
resultTotalScoreThunderImage01.gameObject.SetActive(true);
resultTotalScoreThunderImage02.gameObject.SetActive(true);
});
sequence.Join(DOTweenUtil.FeedImageAlpha(resultTotalScoreThunderImage01, 0.0f, 1.0f, 0.5f));
sequence.Join(DOTweenUtil.FeedImageAlpha(resultTotalScoreThunderImage02, 0.0f, 1.0f, 0.5f));
// カニを表示
sequence.AppendInterval(0.3f);
sequence.AppendCallback(() =>
{
resultKaniImage.gameObject.SetActive(true);
resultKaniSpeechImage.gameObject.SetActive(true);
resultKaniSpeechText.text = isHighScore ? "ハイスコアぞ" : "オソレイッタ";
});
sequence.Join(DOTweenUtil.FeedImageAlpha(resultKaniImage, 0.0f, 1.0f, 0.5f));
sequence.Join(DOTweenUtil.FeedImageAlpha(resultKaniSpeechImage, 0.0f, 1.0f, 0.5f));
// コールバック実行
sequence.AppendInterval(0.5f);
sequence.AppendCallback(() =>
{
callback();
});
↑ノーマルモードの結果アニメーション例
エレキベア
DOTweenを使えばアニメーションを付けるのが
かなり楽になりそうクマね
かなり楽になりそうクマね
選択する枠の頂点を揺らす

マイケル
そしてもう一点、選択枠の頂点を揺らす処理も実装してみました!
決してペル●ナ5に影響を受けたわけではありません・・・。
決してペル●ナ5に影響を受けたわけではありません・・・。


エレキベア
(絶対ペル●ナ5の影響クマ・・・)

マイケル
コードは長いので省きますが、以前書いた下記記事の処理を応用して実装しています。
こちらもよければご参照ください!
こちらもよければご参照ください!


エレキベア
これだけのためにかなり頑張ったクマね・・・
外部Serviceの切り離し

マイケル
そして外部サービスを使用する処理や、変更の恐れがある共通処理についてはなるべく切り離すようにしました。
例としては
・広告関連
・ランキング関連
・アセット読込処理
・JSONデータ読込処理
等ですね。

エレキベア
呼び出しがコードの各所に散らばっていたら依存度MAXクマからね

マイケル
とはいえ今回はDIライブラリ等も使用していないため、
最低限の切り離しということでサービスロケータを使用しています。
こちらについても下記記事で紹介しているのでご参照ください!
最低限の切り離しということでサービスロケータを使用しています。
こちらについても下記記事で紹介しているのでご参照ください!

エレキベア
DIライブラリも使ってみたいクマね〜〜

マイケル
このようにインタフェースを使って切り離すメリットとして、
差し替えが容易になるという点もあります。
実際、ランキング処理で使用するサービスを途中で
PlayFab→NCMB
に変更したのですが、他のコードには影響を与えずに差し替えることができました!
差し替えが容易になるという点もあります。
実際、ランキング処理で使用するサービスを途中で
PlayFab→NCMB
に変更したのですが、他のコードには影響を与えずに差し替えることができました!

エレキベア
変更を考慮するのは素晴らしいことクマね

マイケル
ちなみにGitHubのプロジェクトに関しては、広告・ランキング関連の処理は除いてあるため、
どちらも処理を行わないDummyクラスとして実装しています。
どちらも処理を行わないDummyクラスとして実装しています。
using UnityEngine;
namespace Services
{
/// <summary>
/// Admob関連Service(Dummy)
/// </summary>
public class AdmobDummyService : MonoBehaviour, IAdmobService
{
public void ShowBanner()
{
// 処理なし
}
public void HideBanner()
{
// 処理なし
}
}
}
↑ダミーの処理
エレキベア
ダミー処理はテスト可能にするのにも使えそうクマね

マイケル
そしてサービスの登録処理に関してはプロジェクト初期化時に行なっています。
プラットフォーム毎のサービス有効、無効の切替なんかも簡単にできますね!
プラットフォーム毎のサービス有効、無効の切替なんかも簡単にできますね!
// サービス登録
var serviceLocator = new GameObject("ServiceLocator");
serviceLocator.AddComponent<DontDestroyObject>();
ServiceLocator.Register<IAudioService>(serviceLocator.AddComponent<AudioService>());
ServiceLocator.Register<IAssetsService>(serviceLocator.AddComponent<AssetsService>());
ServiceLocator.Register<IMonoBehaviorService>(serviceLocator.AddComponent<MonoBehaviorService>());
ServiceLocator.Register<IDataAccessService>(new DataAccessJsonService());
ServiceLocator.Register<IPlayerPrefsService>(new PlayerPrefsService());
// モバイルの場合、Admobを適用し入力検知を無効にする
if (ShootingGameState.IsMobilePlatform)
{
ServiceLocator.Register<IAdmobService>(serviceLocator.AddComponent<AdmobService>());
ServiceLocator.Register<IInputKeyService>(serviceLocator.AddComponent<InputKeyDummyService>());
}
else
{
ServiceLocator.Register<IAdmobService>(serviceLocator.AddComponent<AdmobDummyService>());
ServiceLocator.Register<IInputKeyService>(serviceLocator.AddComponent<InputKeyService>());
}
// 下記は内部でServiceを呼んでいるため最後に呼ぶ
ServiceLocator.Register<IRankingService>(new RankingNCMBService());
ServiceLocator.Register<ILoadingService>(new LoadingService());
ServiceLocator.Register<IDialogService>(new DialogService());
↑サービスの登録
エレキベア
手軽に切り離せるから便利クマね
JSONファイルからのデータ読込

マイケル
敵の出現率やスピードといった情報については、マスタデータとしてJSONファイルから読み込むようにしてみました。
{
"generate_infos": [
{
"mode": 1,
"level": 1,
"generateCount": 5,
"minAppearDegree": -45.0,
"maxAppearDegree": 45.0,
"minScale": 1.5,
"maxScale": 3.0,
"minSpeed": 15.0,
"maxSpeed": 30.0,
"addSpeed": 0.02,
"minShakeWidthX": -5.0,
"maxShakeWidthX": 5.0,
"minShakeWidthY": -6.5,
"maxShakeWidthY": 6.5,
"shakeWidthYCount": 3,
"minWaitTime": 6.0,
"maxWaitTime": 8.5,
"appearEachCount": 2
},
{
"mode": 1,
"level": 2,
"generateCount": 15,
"minAppearDegree": -60.0,
"maxAppearDegree": 60.0,
"minScale": 1.0,
"maxScale": 3.0,
"minSpeed": 25.0,
"maxSpeed": 45.0,
"addSpeed": 0.02,
"minShakeWidthX": -7.0,
"maxShakeWidthX": 7.0,
"minShakeWidthY": -8.5,
"maxShakeWidthY": 8.5,
"shakeWidthYCount": 3,
"minWaitTime": 4.0,
"maxWaitTime": 5.5,
"appearEachCount": 3
},
{
"mode": 1,
"level": 3,
"generateCount": 30,
"minAppearDegree": -75.0,
"maxAppearDegree": 75.0,
"minScale": 0.8,
"maxScale": 3.0,
"minSpeed": 25.0,
"maxSpeed": 45.0,
"addSpeed": 0.03,
"minShakeWidthX": -8.0,
"maxShakeWidthX": 8.0,
"minShakeWidthY": -9.0,
"maxShakeWidthY": 9.0,
"shakeWidthYCount": 3,
"minWaitTime": 4.5,
"maxWaitTime": 6.0,
"appearEachCount": 5
},
{
"mode": 2,
"level": 1,
"generateCount": 10,
"minAppearDegree": -60.0,
"maxAppearDegree": 60.0,
"minScale": 1.5,
"maxScale": 3.0,
"minSpeed": 25.0,
"maxSpeed": 45.0,
"addSpeed": 0.02,
"minShakeWidthX": -7.0,
"maxShakeWidthX": 7.0,
"minShakeWidthY": -8.0,
"maxShakeWidthY": 8.0,
"shakeWidthYCount": 3,
"minWaitTime": 4.0,
"maxWaitTime": 5.5,
"appearEachCount": 3
},
{
"mode": 2,
"level": 2,
"generateCount": 10,
"minAppearDegree": -60.0,
"maxAppearDegree": 60.0,
"minScale": 1.2,
"maxScale": 3.0,
"minSpeed": 25.0,
"maxSpeed": 45.0,
"addSpeed": 0.02,
"minShakeWidthX": -7.0,
"maxShakeWidthX": 7.0,
"minShakeWidthY": -8.0,
"maxShakeWidthY": 8.0,
"shakeWidthYCount": 3,
"minWaitTime": 4.0,
"maxWaitTime": 5.5,
"appearEachCount": 4
},
・・・略・・・
]
}
↑敵の出現情報
マイケル
CSVの方が見やすいかなと思いつつも、将来的にDBを立ててAPIで取得する可能性もあるかと思いJSON形式を採用しました。
(絶対ないですが・・・)
(絶対ないですが・・・)

エレキベア
(絶対ないクマ・・・)

マイケル
そしてやっつけですがExcelからJSONファイルを出力するツールも作って調整しやすいようにしています!


エレキベア
一人なのによくやるクマ・・・

マイケル
今後も使うかもしれないからね・・・
(気持ち程度の)ユニットテスト

マイケル
そして最後に、気持ち程度ですが複雑なロジックについては
ユニットテストを作成しながら進めるようにしました。
Unityのテスト実行ツールであるUnity Test Runnerを使用しています。
ユニットテストを作成しながら進めるようにしました。
Unityのテスト実行ツールであるUnity Test Runnerを使用しています。


エレキベア
クマはTDDを推奨するクマ

マイケル
下記はスコア最大値のテスト例です!
最大値は通常プレイだと確認が困難なものも多いので、なるべくコードレベルでの確認はしておきたいですね。
最大値は通常プレイだと確認が困難なものも多いので、なるべくコードレベルでの確認はしておきたいですね。
[Test]
public void MaxScoreInfoTest()
{
// 倒した数が99999999を超えないこと
var enemyScore = new JsonSchema.Response.EnemyScore();
enemyScore.perfectScore = 500;
enemyScore.greatScore = 300;
enemyScore.goodScore = 100;
for (var i = 0; i < 1000; i++)
{
_gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypePerfect, enemyScore);
_gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGreat, enemyScore);
_gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGood, enemyScore);
}
var scoreInfo = _gameInfoModel.GetResultScoreInfo();
Assert.AreEqual(999, scoreInfo.PerfectCount);
Assert.AreEqual(999, scoreInfo.GreatCount);
Assert.AreEqual(999, scoreInfo.GoodCount);
Assert.AreEqual(999, scoreInfo.GameTotalCount);
Assert.AreEqual(999, scoreInfo.ResultTotalCount);
Assert.AreEqual(500000, scoreInfo.PerfectTotalScore);
Assert.AreEqual(300000, scoreInfo.GreatTotalScore);
Assert.AreEqual(100000, scoreInfo.GoodTotalScore);
Assert.AreEqual(900000, scoreInfo.GameTotalScore);
Assert.AreEqual(900000, scoreInfo.ResultTotalScore);
// スコアが99999999を超えないこと
enemyScore = new JsonSchema.Response.EnemyScore();
enemyScore.perfectScore = 5000000;
enemyScore.greatScore = 3000000;
enemyScore.goodScore = 1000000;
for (var i = 0; i < 1000; i++)
{
_gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypePerfect, enemyScore);
_gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGreat, enemyScore);
_gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGood, enemyScore);
}
scoreInfo = _gameInfoModel.GetResultScoreInfo();
Assert.AreEqual(999, scoreInfo.PerfectCount);
Assert.AreEqual(999, scoreInfo.GreatCount);
Assert.AreEqual(999, scoreInfo.GoodCount);
Assert.AreEqual(999, scoreInfo.GameTotalCount);
Assert.AreEqual(999, scoreInfo.ResultTotalCount);
Assert.AreEqual(999999, scoreInfo.PerfectTotalScore);
Assert.AreEqual(999999, scoreInfo.GreatTotalScore);
Assert.AreEqual(999999, scoreInfo.GoodTotalScore);
Assert.AreEqual(999999, scoreInfo.GameTotalScore);
Assert.AreEqual(999999, scoreInfo.ResultTotalScore);
}
↑最大スコアの確認
エレキベア
テストを書いておくとリファクタリングを行った際の
セーフティネットにもなるクマからなるべく書いた方がいいクマね
セーフティネットにもなるクマからなるべく書いた方がいいクマね

マイケル
詳しいな・・・
反省点

マイケル
以上が実装の工夫点になりますが、反省点も山ほどあります・・・。
ゲーム自体があまり面白くない・・・

マイケル
まず、一つ目はゲーム自体があまり面白くないことです!

エレキベア
致命的な問題がきたクマ・・・

マイケル
正直、C++勉強の延長で移植しただけだったので、あまり面白くすることに意識が向いていませんでした・・・。
ゲームとしてはしっかりしてきたとは思いますが、これでは作った甲斐がないというものです・・・。
ゲームとしてはしっかりしてきたとは思いますが、これでは作った甲斐がないというものです・・・。

マイケル
そして面白くないものを作るのに長い時間かけるのはマジで苦痛でした・・・。
リリースした時も、嬉しさよりもやっと次のが作れる・・・といった気持ちが大きかったですね!
リリースした時も、嬉しさよりもやっと次のが作れる・・・といった気持ちが大きかったですね!

エレキベア
技術習得も大事クマが、面白いものを作ろうとする姿勢は前提クマよ

マイケル
しかし、リリースまでの工程を改めて通せたのは大きかったし、
やっぱりやり切った感はあります!!
作ってよかった・・・・!!
やっぱりやり切った感はあります!!
作ってよかった・・・・!!

エレキベア
(無理してるクマ・・・)
古い技術をいくつか使用している

マイケル
そしてアセットをなるべく使わないようにしたのも影響していますが、Resourcesからのアセット読込といった非推奨の技術もいくつか使用してしまっていることです!
・DIコンテナを使用した実装
・UniRxを使用したMVP実装
・UniTaskを使用して非同期処理はasync/awaitにする
・Addressable Assets Systemを使用したリソース取得
・URP/HDRPの使用

マイケル
次作る時は最低限、この辺りには挑戦していきたいです!
今後は規模が小さいものをいくつか作りながら使っていこうかと思います!
今後は規模が小さいものをいくつか作りながら使っていこうかと思います!

エレキベア
まだまだ先は長いクマね
シェーダを自作したかった

マイケル
今回は妥協してUnity標準のシェーダを使用しましたが、
雰囲気に合わせてシェーダを自作してみたかったという気持ちもあります・・・
雰囲気に合わせてシェーダを自作してみたかったという気持ちもあります・・・

エレキベア
確かに3Dモデルが少し安っぽいクマからね

マイケル
とはいえシェーダ学習はそれなりに時間がかかりそうなので、
今後の課題として取り組んでいこうかと思います!!
今後の課題として取り組んでいこうかと思います!!
プラットフォームを切り替えるのが面倒

マイケル
そして最後は、プラットフォーム切り替えが非常に面倒であったことです・・・。

エレキベア
WebGL、iOS、Androidの3つに対応するよう作っていたクマからね

マイケル
ビルド自動化の環境はとりあえず作っておいて損はない気がしました・・・。
おわりに

マイケル
というわけでシューティングゲーム開発の振り返りでした!
どうだったかな??
どうだったかな??

エレキベア
面白くないのは問題クマが、
これまでよりしっかりしたのが作れるとは思ったクマ
これまでよりしっかりしたのが作れるとは思ったクマ

マイケル
初めてアプリをリリースしたのが遠い昔のように感じるね・・・

エレキベア
ゴロヤン懐かしいクマ〜〜〜

マイケル
((でもゴーゴーゴロヤンの方が面白かったな・・・!!))

エレキベア
心の声が漏れてるクマ・・・

マイケル
次はもっと楽しめるものを作るぞ!!
やってやろうぜエレキベア!!
やってやろうぜエレキベア!!

エレキベア
クマ〜〜〜〜〜〜〜
↓ダウンロードはこちらから!
【Unity】3Dシューティングゲームをリリース!工夫点や反省点をざっと振り返る 〜完〜
コメント