一年前のコード書き直した(前編)

こんにちは

inonoa (id:inonoa) といいます。主に inonoaアドベントカレンダー2020 に記事を寄稿する*1などの活動をしています。

adventar.org

んで

tekiyoke2 というゲームプロジェクトを進めているのですが、ステージセレクト画面周りをいろいろと改修しないといけない事態になったので書き直しました。ところでこのプロジェクトの開始は2年前です。

StageSelector.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class StageSelector : MonoBehaviour
{   
    #region Objects
    public GameObject draftselect;
    public SpriteRenderer dsRenderer;
    public GameObject waku;
    public SpriteRenderer wakuRenderer;
    public GameObject[] stages;
    public SpriteRenderer[] stRenderer;

    public SpriteRenderer bg;

    //クロスフェード用に背後に映すやつ
    public SpriteRenderer bgbg;

    public SpriteRenderer anmaku;

    public Sprite[] bgs;
    public WakuLightMover wakuLight;

    SoundGroup soundGroup;

    #endregion

    #region States
    enum State{
        Entering, WakuAppearing, Active, Selected
    }
    State state = State.Entering;
    public int selected = 1;

    #endregion

    #region 依存

    IAskedInput input;

    #endregion

    void Start()
    {
        stRenderer = new SpriteRenderer[stages.Length];
        for(int i=0;i<stRenderer.Length;i++){
            stRenderer[i] = stages[i].GetComponent<SpriteRenderer>();
        }
        dsRenderer = draftselect.GetComponent<SpriteRenderer>();
        wakuRenderer = waku.GetComponent<SpriteRenderer>();
        soundGroup = GetComponent<SoundGroup>();

        input = ServicesLocator.Instance.GetInput();
    }

    void Update()
    {
        //選択したステージのUIに近づく
        Vector3 vv = stages[selected-1].transform.position - waku.transform.position;

        //枠の移動
        if(vv.x*vv.x+vv.y*vv.y<5){
            waku.transform.position = new Vector3(stages[selected-1].transform.position.x,stages[selected-1].transform.position.y,-2);
        }else{
            int signX = 1; int signY = 1; if(vv.x<0){signX = -1;} if(vv.y<0){signY = -1;} //Sqrtに負の数は渡せない(それはそう)
            vv = 2* new Vector3(signX *(float)System.Math.Sqrt(vv.x * signX), signY *(float)System.Math.Sqrt(vv.y * signY),0);

            waku.transform.position += vv;
        }

        //背景のクロスフェード
        if(bg.color.a<1){
            bg.color -= new Color(0,0,0,0.05f);
            anmaku.color = new Color(1,1,1,System.Math.Min(bg.color.a,1-bg.color.a));
            if(bg.color.a<=0){
                bg.sprite = bgbg.sprite;
                bg.color = new Color(1,1,1,1);
            }
        }

        switch(state){
            case State.Entering:
                //手作業での位置調整で厳しい(不透明度を徐々に上げていきある程度上がったら次フェイズへ)
                const float targetAlpha = 0.7f;
                for(int i=0;i<stages.Length;i++){
                    stRenderer[i].color += new Color(0,0,0,targetAlpha * 0.1f);
                    stages[i].transform.position -= new Vector3((float)System.Math.Sqrt(stages[i].transform.position.x),0,0);
                }
                dsRenderer.color += new Color(0,0,0,0.1f);

                if(stRenderer[0].color.a >= targetAlpha){
                    state = State.WakuAppearing;
    
                    for(int i=0;i<stages.Length;i++){
                        stRenderer[i].color = new Color(stRenderer[i].color.r,stRenderer[i].color.g,stRenderer[i].color.b,targetAlpha);
                        stages[i].transform.position = new Vector3(0,stages[i].transform.position.y,stages[i].transform.position.z);
                    }
                }
                break;
    
            case State.WakuAppearing:
                wakuRenderer.color += new Color(0,0,0,0.1f);
                if(wakuRenderer.color.a>=1){
                    state = State.Active;
                }
                break;
    
            case State.Active:
                if(input.GetButtonDown(ButtonCode.Up)){
                    if(selected>1){
                        selected--;
                        bgbg.sprite = bgs[selected-1];
                        bg.color = new Color(1,1,1,0.99f);
                        soundGroup.Play("Move");
                    }
                }
                if(input.GetButtonDown(ButtonCode.Down)){
                    if(selected<3){
                        selected++;
                        bgbg.sprite = bgs[selected-1];
                        bg.color = new Color(1,1,1,0.99f);
                        soundGroup.Play("Move");
                    }
                }
                if(input.GetButtonDown(ButtonCode.Enter))
                {
                    state = State.Selected;
                    wakuLight.Stop();
                    SceneTransition.Start2ChangeScene(SceneName(selected), SceneTransition.TransitionType.Normal);
                    soundGroup.Play("Enter");
                }
                break;

            case State.Selected:
                stRenderer[selected-1].color += new Color(0,0,0,0.05f);
                break;
        }

        string SceneName(int stage) => "Draft" + stage;
            
    }   
}   

読まなくて大丈夫です。

これがこうなりました

StageSelectManager.cs

using System;
using Config;
using Sirenix.OdinInspector;
using UniRx;
using UnityEngine;

public class StageSelectManager : SerializedMonoBehaviour
{
    [SerializeField] IStageSelectView view;
    [SerializeField] ConfigManager configManager;
    [SerializeField] RankingsSelectManager rankingsSelectManager;

    void Start()
    {
        view.StageSelected.Subscribe(stage =>
        {
            SceneTransition.Start2ChangeScene(stage.ToString(), SceneTransition.TransitionType.Normal);
        });
        view.OnGoToConfig.Subscribe(_ => configManager.Enter());
        view.OnGoToRankings.Subscribe(_ => rankingsSelectManager.Enter());

        configManager.OnExit.Subscribe(_ => view.Enter());
        rankingsSelectManager.OnExit.Subscribe(_ => view.Enter());
    }
}

public interface IStageSelectView
{
    void Enter();
    IObservable<EDraft> StageSelected { get; }
    IObservable<Unit> OnGoToConfig { get; }
    IObservable<Unit> OnGoToRankings { get; }
}

StageSelectView.cs

using System;
using System.Collections;
using System.Collections.Generic;
using Config;
using DG.Tweening;
using Sirenix.OdinInspector;
using UniRx;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;

public class StageSelectView : SerializedMonoBehaviour, IStageSelectView
{   
    #region Objects
    [SerializeField] Image chooseADraftImage;
    [SerializeField] Image wakuImage;
    [SerializeField] WakuLightMover wakuLight;
    [SerializeField] Image[] stageImages;
    [SerializeField] SoundGroup soundGroup;

    [SerializeField] StageSelectBGChanger bgChanger;

    [SerializeField] Button goToRankingsButton;
    [SerializeField] Button goToConfigButton;

    #endregion

    #region States
    enum State{ Entering, Active, Selected }
    State state = State.Entering;
    EDraft selected = EDraft.Draft1;
    #endregion

    [SerializeField] IAskedInput input;

    
    Subject<EDraft> _StageSelected = new Subject<EDraft>();
    public IObservable<EDraft> StageSelected => _StageSelected;
    
    Subject<Unit> _OnGoToConfig = new Subject<Unit>();
    public IObservable<Unit> OnGoToConfig => _OnGoToConfig;
    
    Subject<Unit> _OnGoToRankings = new Subject<Unit>();
    public IObservable<Unit> OnGoToRankings => _OnGoToRankings;

    void Start()
    {
        goToRankingsButton.onClick.AddListener(() =>
        {
            ExitMain();
            _OnGoToRankings.OnNext(Unit.Default);
        });
        
        goToConfigButton.onClick.AddListener(() =>
        {
            ExitMain();
            _OnGoToConfig.OnNext(Unit.Default);
        });

        DOVirtual.DelayedCall(1f, () =>
        {
            goToRankingsButton.gameObject.SetActive(true);
            goToConfigButton.gameObject.SetActive(true);
        });
        
        FadeIn();
    }

    void ExitMain()
    {
        gameObject.SetActive(false);
        goToConfigButton.gameObject.SetActive(false);
        goToRankingsButton.gameObject.SetActive(false);
    }

    public void Enter()
    {
        gameObject.SetActive(true);
        goToConfigButton.gameObject.SetActive(true);
        goToRankingsButton.gameObject.SetActive(true);
    }

    void FadeIn()
    {
        const float targetAlpha = 0.8f;
        const float fadeInDuration = 0.4f;
        
        foreach (Image stageImage in stageImages)
        {
            Sequence fadeIn = DOTween.Sequence()
                //リセット
                .Append(stageImage.DOFade(0, 0))
                .Join(stageImage.transform.DOLocalMoveX(100, 0))
                .Join(chooseADraftImage.DOFade(0, 0))
                .Join(chooseADraftImage.transform.DOLocalMoveX(100, 0))
                .Join(wakuImage.DOFade(0, 0))
                .Join(wakuLight.GetComponent<Image>().DOFade(0, 0))
                //ステージ名部分
                .Append(stageImage.DOFade(targetAlpha, fadeInDuration).SetEase(Ease.Linear))
                .Join(stageImage.transform.DOLocalMoveX(0, fadeInDuration).SetEase(Ease.OutCubic))
                //"Choose a draft"
                .Join(chooseADraftImage.DOFade(1, fadeInDuration).SetEase(Ease.Linear))
                .Join(chooseADraftImage.transform.DOLocalMoveX(0, fadeInDuration).SetEase(Ease.OutCubic))
                //枠
                .Append(wakuImage.DOFade(1, 0.2f).SetEase(Ease.Linear))
                .AppendCallback(() => state = State.Active);
        }
    }

    void MoveStage(EDraft dst)
    {
        selected = dst;
        float dstY = stageImages[dst.ToInt()].transform.position.y;
        wakuImage.transform.DOMoveY(dstY, 0.3f);
        bgChanger.OnChangeStage(selected.ToInt());
        soundGroup.Play("Move");
    }

    void OnDetermine()
    {
        state = State.Selected;
        wakuLight.Stop();
        soundGroup.Play("Enter");
        stageImages[selected.ToInt()].DOFade(1, 0.3f).SetEase(Ease.Linear);
        
        _StageSelected.OnNext(selected);
    }
    

    void Update()
    {
        if(state != State.Active) return;
        
        if(input.GetButtonDown(ButtonCode.Up) && selected != EDraft.Draft1)
        {
            MoveStage(selected.Minus1());
        }
        if(input.GetButtonDown(ButtonCode.Down) && selected != EDraft.Draft3)
        {
            MoveStage(selected.Plus1());
        }
        if(input.GetButtonDown(ButtonCode.Enter))
        {
            OnDetermine();
        }
    }
}

StageSelectBGChanger.cs

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;

public class StageSelectBGChanger : MonoBehaviour
{
    [SerializeField] Image bg;
    [SerializeField] Image bgbg; //クロスフェード用に背後に映すやつ
    [SerializeField] Image anmaku;
    [SerializeField] Sprite[] bgSprites;

    [SerializeField] float changeDuration = 0.4f;

    public void OnChangeStage(int stage)
    {
        Debug.Assert(stage == 0 || stage == 1 || stage == 2);

        bgbg.sprite = bgSprites[stage];
        
        bg.DOFade(0, changeDuration)
            .SetEase(Ease.Linear)
            .onComplete += () =>
        {
            bg.sprite = bgbg.sprite;
            bg.DOFade(1, 0);
        };
        anmaku.DOFade(0.5f, changeDuration / 2)
            .onComplete += () =>
        {
            anmaku.DOFade(0, changeDuration / 2);
        };
    }
}

どう変えた?

public -> [SerializeField]

    public GameObject draftselect;
    public SpriteRenderer dsRenderer;
    public GameObject waku;
    public SpriteRenderer wakuRenderer;
    public GameObject[] stages;
    public SpriteRenderer[] stRenderer;

    public SpriteRenderer bg;

    //クロスフェード用に背後に映すやつ
    public SpriteRenderer bgbg;

    public SpriteRenderer anmaku;

    public Sprite[] bgs;
    public WakuLightMover wakuLight;

publicなメンバが並んでいました。publicにしたくない理由の説明はGoogleに譲ります

おそらくインスペクタ*2で他オブジェクトへの参照をシュッと差し込みたかったのだろうと推察されますが、同様のことは [SerializeField] を先頭につければ実現可能です。こんな風に。

    [SerializeField] GameObject draftselect;
    [SerializeField] SpriteRenderer dsRenderer;
    [SerializeField] GameObject waku;
    [SerializeField] SpriteRenderer wakuRenderer;
    [SerializeField] GameObject[] stages;
    [SerializeField] SpriteRenderer[] stRenderer;

    [SerializeField] SpriteRenderer bg;

    //クロスフェード用に背後に映すやつ
    [SerializeField] SpriteRenderer bgbg;

    [SerializeField] SpriteRenderer anmaku;

    [SerializeField] Sprite[] bgs;
    [SerializeField] WakuLightMover wakuLight;

クラス分ける

上に貼ったコードでは改修前のものが1ファイルしかないのに改修後には3ファイルになっていました。改修前は StageSelector がステージ選択するUIを動かしたり背景のクロスフェードをやったり選んだステージを読み込んだりいろいろやっていましたが、基本的にはいろいろなことはいろいろなクラスがそれぞれやるといい感じになるので、いい感じに分けていきます。

Viewとそれ以外

実際に分けていく方法の一つとしてView(見た目)の分離があります。UIを動かしたり背景を変えたりは見た目にかかわる処理で、ステージを選ぶ(読み込む)こととは別クラスにできそうです。できそうなのですが、実際のところステージを選ぶ部分は今は一行分の処理しかない*3のであまりいい例とは言えないですね……。

まあとりあえず StageSelectorStageSelectManager *4StageSelectView *5 に分けてターンエンド!

背景

ターンエンドではなくて、UIを動かしたり背景を変えたりしているクラス( StageSelectView )を、UIを動かすクラス(とりあえずViewのメインの処理なので StageSelectView のまま)と背景を変えるクラス( StageSelectBGChanger )に分けることもできそうです。かくしてクラスは三つになりました。

関数分ける

クラスを分けたら関数もいくつかに分けたくなります。関数名を付けることになるのでやってることの要約になる、変数のスコープを狭くできるなどのメリットがあります。

改修前のコードで一番長い関数は StageSelector.Update() でした。

    void Update()
    {
        //選択したステージのUIに近づく
        Vector3 vv = stages[selected-1].transform.position - waku.transform.position;

        //枠の移動
        if(vv.x*vv.x+vv.y*vv.y<5){
            waku.transform.position = new Vector3(stages[selected-1].transform.position.x,stages[selected-1].transform.position.y,-2);
        }else{
            int signX = 1; int signY = 1; if(vv.x<0){signX = -1;} if(vv.y<0){signY = -1;} //Sqrtに負の数は渡せない(それはそう)
            vv = 2* new Vector3(signX *(float)System.Math.Sqrt(vv.x * signX), signY *(float)System.Math.Sqrt(vv.y * signY),0);

            waku.transform.position += vv;
        }

        //背景のクロスフェード
        if(bg.color.a<1){
            bg.color -= new Color(0,0,0,0.05f);
            anmaku.color = new Color(1,1,1,System.Math.Min(bg.color.a,1-bg.color.a));
            if(bg.color.a<=0){
                bg.sprite = bgbg.sprite;
                bg.color = new Color(1,1,1,1);
            }
        }

        switch(state){
            case State.Entering:
                //手作業での位置調整で厳しい(不透明度を徐々に上げていきある程度上がったら次フェイズへ)
                const float targetAlpha = 0.7f;
                for(int i=0;i<stages.Length;i++){
                    stRenderer[i].color += new Color(0,0,0,targetAlpha * 0.1f);
                    stages[i].transform.position -= new Vector3((float)System.Math.Sqrt(stages[i].transform.position.x),0,0);
                }
                dsRenderer.color += new Color(0,0,0,0.1f);

                if(stRenderer[0].color.a >= targetAlpha){
                    state = State.WakuAppearing;
    
                    for(int i=0;i<stages.Length;i++){
                        stRenderer[i].color = new Color(stRenderer[i].color.r,stRenderer[i].color.g,stRenderer[i].color.b,targetAlpha);
                        stages[i].transform.position = new Vector3(0,stages[i].transform.position.y,stages[i].transform.position.z);
                    }
                }
                break;
    
            case State.WakuAppearing:
                wakuRenderer.color += new Color(0,0,0,0.1f);
                if(wakuRenderer.color.a>=1){
                    state = State.Active;
                }
                break;
    
            case State.Active:
                if(input.GetButtonDown(ButtonCode.Up)){
                    if(selected>1){
                        selected--;
                        bgbg.sprite = bgs[selected-1];
                        bg.color = new Color(1,1,1,0.99f);
                        soundGroup.Play("Move");
                    }
                }
                if(input.GetButtonDown(ButtonCode.Down)){
                    if(selected<3){
                        selected++;
                        bgbg.sprite = bgs[selected-1];
                        bg.color = new Color(1,1,1,0.99f);
                        soundGroup.Play("Move");
                    }
                }
                if(input.GetButtonDown(ButtonCode.Enter))
                {
                    state = State.Selected;
                    wakuLight.Stop();
                    SceneTransition.Start2ChangeScene(SceneName(selected), SceneTransition.TransitionType.Normal);
                    soundGroup.Play("Enter");
                }
                break;

            case State.Selected:
                stRenderer[selected-1].color += new Color(0,0,0,0.05f);
                break;
        }

        string SceneName(int stage) => "Draft" + stage;
            
    }   

背景のクロスフェードは別クラスに移行済みですが、そのほかにも「カーソル( と書かれているもの)の当該ボタンへの移動」「開始時のフェードイン」「入力に応じて移動先を切り替える」「決定キー押した後当該ボタンを目立たせる」などがあります。改修後の StageSelectView には FadeIn() MoveStage(EDraft dst) *6 OnDetermine() 、加えて最低限の Update() (入力部分のみ *7 )に分割されすっきりしました。

DOTweenを使おうな……

ところで改修後の MoveStage() ですが、

    void MoveStage(EDraft dst)
    {
        selected = dst;
        float dstY = stageImages[dst.ToInt()].transform.position.y;
        wakuImage.transform.DOMoveY(dstY, 0.3f);
        bgChanger.OnChangeStage(selected.ToInt());
        soundGroup.Play("Move");
    }

改修前は二か所に分散しています。

        //選択したステージのUIに近づく
        Vector3 vv = stages[selected-1].transform.position - waku.transform.position;

        //枠の移動
        if(vv.x*vv.x+vv.y*vv.y<5){
            waku.transform.position = new Vector3(stages[selected-1].transform.position.x,stages[selected-1].transform.position.y,-2);
        }else{
            int signX = 1; int signY = 1; if(vv.x<0){signX = -1;} if(vv.y<0){signY = -1;} //Sqrtに負の数は渡せない(それはそう)
            vv = 2* new Vector3(signX *(float)System.Math.Sqrt(vv.x * signX), signY *(float)System.Math.Sqrt(vv.y * signY),0);

            waku.transform.position += vv;
        }
                        selected--;
                        bgbg.sprite = bgs[selected-1];
                        bg.color = new Color(1,1,1,0.99f);
                        soundGroup.Play("Move");

(直後に同じような部分がありそれぞれ上への移動と下への移動を指示してます)

前半は毎フレーム少しづつ移動する部分、後半は方向キーが押された時に移動先を指定する部分です。フレームごとに少しづつ動かしていかなければいけない関係上このように分散して書かざるを得ない部分が出てきがちです。

しかしDOTweenというアセットが解決します*8。なぜなら移動させる部分は wakuImage.transform.DOMoveY(dstY, 0.3f); (y座標をdstYまで0.3秒で動かす)の一行で済むからです。

assetstore.unity.com

こうして移動する部分は(入力を除いて)一か所にまとまりました。おそらく読みやすくもなったはずです。

つづく

本当はこの記事で全部説明しようと思ったんですが思ったより面倒くさくなってきた分量が多いので後日に回します(?)。次回はもう少しいい感じの話をします……

*1:つまりこれは1日目の記事ということになります

*2:言い忘れてたんですがこのゲームはUnity製で、インスペクタとはパラメータや他オブジェクトへの参照をGUIで楽にいじれる画面/機能です、基本的にはpublicなメンバの値のみいじれます

*3:というよりSceneTransitionという別のクラスが利用できたので一行で済んだ。加えて、将来はまだ解放されてないステージを選べないとかステージをクリアして戻ってきた場合次のステージを解放するとかのタスクが発生しそうなので、これから行数が増える可能性は大いにあります

*4:HogehogeManagerみたいなクラスができたら気をつけろ、というのもよく言われることですが、あくまで多くのことをそこでやりすぎるのがよくないのであって、本当にmanageすることだけを司っていればばあ大丈夫だろうと自分に言い聞かせることにします

*5:ISgateSelectViewという何かを継承してますがそれは後述

*6:EDraftとはステージの種類を示すenumです。全3ステージと決まっている、intだと初めのステージが0なのか1なのか紛らわしい問題がありそれを解決できる、などの利点があります

*7:気合を入れれば入力を受け取る部分もUpdateから追い出せそうですが、だれか実現してますか?(?)

*8:もちろんDOTweenと似たものを実装すれば導入しなくてもいいですし、多少面倒なもののコルーチンを使えば一か所にまとめることはできると思います。ゲームの完成からは遠のくかもしれないですが……。