国内のありとあらゆる川見てる

はじめに

これは 川見てる Advent Calendar 2020 - Adventar の22日目の記事です。

ほんだい

実物を眺めてることだけが「川見てる」だと思ってませんか?

tiles.dammaps.jp

ときどきここで川(の流域)を見ています。あなたも見ましょう。以下は個人の感想なので読まなくても大丈夫です。

北海道

f:id:inonoa:20211201185006p:plain
北海道

石狩川がでかい。北海道の人口の大半が石狩川流域に住んでそう。上川総合振興局のエリアってだいたい石狩川天塩川の上の方なんだなあということがわかる。

東北

f:id:inonoa:20211201185757p:plain
東北

かなりきれいに真ん中(奥羽山脈)で2分割されている。最上川って単一の都道府県を流れる川の中で一番長い川だった気がするけど、マジで山形の大半を覆っていて山形の川だなあと思う(?)。流域が広くて長し最上川

関東・中部

f:id:inonoa:20211201190316p:plain
関東・中部

(なんかいびつな章立てになった)

スクショに名前出てないけど佐渡島国府川流域がそこそこ(離島の川としては)広い。2本の山脈に挟まれてて半盆地みたいになってるからかなあ。離島なのに山に囲まれて平地があって母なる川(島民にそんな認識されてるかはわからない)みたいなのがあるってなんか新鮮。

那須と毛野って流域も分かれてるんだなあ(昔と今で微妙に河道が変わってたりする可能性もあるけど)。

長野県は信濃川天竜川・木曽川に分かれていて、このエリアが長野県になってたり何なら信濃国になっているのがそこそこ不思議。

九頭竜川最上川と似たような感想があるけど九頭竜川は若狭の方までは伸びてない(地形的にそれはそうだけど)。若狭と越前で九頭竜川への愛着みたいなのがガラッと変わってたりしたら面白い。

関西・中国四国

f:id:inonoa:20211201192245p:plain
関西・中国四国

滋賀県の大半が琵琶湖、ではないけど、滋賀県の河川の大半は最終的に一級河川琵琶湖にそそぐと思うと滋賀県の大半は実質琵琶湖といってもいい。滋賀県出身なので、内陸県ってだいたい流れてる川は全部一本にまとまるもんだと思ってた節があるけど、改めて見るとそういう内陸県って滋賀県以外だと群馬県ぐらいしかなさそう。

奈良盆地一帯は大和川流域だけど、奈良盆地の外に出て山に入るとすぐ別の流域になる。流域が広いほど雨水を集めやすいので、田んぼとか町の広がっている部分以外に広く流域を持ってるとそのぶん水を貯えやすいとすると、大和川はそういう貯え方がほとんどできなさそうで心配になる。本当にヤマト王権ここにあった?

江の川流域が結構山陽側まで迫っている。先行河川ってやつ。

四国の吉野川四万十川(渡川)、仁淀川あたり、県境(≒かつての国境)と流域の境が全く一致してない。国境どういう基準で決めたんだろう。まあここに限った話じゃないか……。

九州

f:id:inonoa:20211201195010p:plain
九州

白川流域の東側の丸い部分は阿蘇カルデラの形がくっきり出てる。

屋久島の宮之浦岳(広義九州の最高峰)は沿岸の人里から見えないらしい。例えば利尻島みたいに中央の富士山みたいな山一つで構成されてるような島だと見えるわけで、そうではなく割と複雑な形の山々が集まってできてることを反映してるといえそう。で利尻島屋久島の流域地図を見ると、利尻島は切られすぎたピザみたいな分かれ方してる一方で、屋久島はいくらピザ切るの下手な人でもそうはならんやろ……みたいな形をしていて良い。

f:id:inonoa:20211201200614p:plain
利尻島

f:id:inonoa:20211201200649p:plain
屋久

沖縄(本島)

f:id:inonoa:20211201201227p:plain
沖縄本島

結構島の反対側まで迫ってる流域が多い気がする。データの都合かわからないけど、どっちが河口かぱっと見わからないものもある。福地川……

おわりに

2020年のアドベントカレンダーの記事を今までさぼり続けてすみませんでした!!!!!!!!!!!!!!!!!!!!!!

バトル中とかにキャラ固有の非同期な処理をObserverパターン的に差し込みたい(Unity, UniTask)

やりたいこと

ターン制の戦闘を作っていて、ターン終了時に敵キャラ固有の処理を挟みたかった。例えば敵が回復するとか、状態異常が直るとか、攻撃力が一段階上がるとか。

どうやるか

そのプロジェクトでターン終了時がいつかということを把握しているのは、ターンをぐるぐる回しているBattleManagerみたいなクラスだった。だからBattleManagerのいい感じの行に処理を挟めばいいことになる。

ただ、キャラ固有の事情はキャラ固有のクラスでよしなにしたいので、BattleManager内に直接固有の処理を書きたくはない。また、敵キャラ用のインターフェースにOnTurnEnd()のようなメソッドを生やすほど多くの敵キャラがターン終了時に特別な何かをするわけでもない気もした。*1

しかも似たようなことをターン終了時だけでなく開始時、バトル開始時、キャラの死亡時などいろいろなタイミングでやりたくなるだろうから、際限なくメソッドが増える予感がする。しかもそのほとんどが実際には何もしないようなメソッドになる。


ので、Observerパターンみたいなのでやろうとした。具体的には、まずBattleManagerにOnTurnEndみたいな名前のIObservableなフィールドを持たせる。それで、敵キャラのクラスがそれを購読していい感じにする。この場合敵キャラにBattleManagerを渡してやる必要はある*2

ただ、IObservableに渡すObserverの処理(OnNext)は同期関数で、今やりたい処理を同期関数で書くのは厳しい。 例えば回復するときはHPゲージが動いてほしいし、「回復した!」みたいなテキストが出てボタンか何か押してテキストを送ってから次のターンが始まってほしい。同期関数であるうちは、ゲージを動かし始めたりテキストを出したりするまではできるが、ゲージが動ききるまで待ったりテキストを送るまで待ったりする部分はできない。

やったこと

要はObserver側の非同期処理を待てるObservableのようなものがあればいいはずなので、例えば

using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UniRx;

public class AsyncEventsHolder : IAsyncEventsHolder
{
    readonly List<Func<UniTask>> events = new List<Func<UniTask>>();
    
    // Subscribeみたいなの
    public IDisposable Add(Func<UniTask> evt)
    {
        events.Add(evt);
        // Exec中にRemoveされたらやばい気がするが簡単のために……
        return Disposable.Create(() => events.Remove(evt));
    }

    // UniRx.SubjectのOnNextみたいなの
    public async UniTask Exec()
    {
        foreach (Func<UniTask> evt in events)
        {
            await evt.Invoke();
        }
    }
}

// IObservableみたいなの
public interface IAsyncEventsHolder
{
    IDisposable Add(Func<UniTask> evt);
}

こういうクラスを作って、

using Cysharp.Threading.Tasks;

public class BattleManager
{
    // なんやかんやある
    
    readonly AsyncEventsHolder onTurnEnd;
    public IAsyncEventsHolder OnTurnEnd => onTurnEnd;

    public async UniTask Turns()
    {
        while (true)
        {
            // 1ターンごとの処理がはいる

            await onTurnEnd.Exec();
        }
    }
}

BattleManagerにこんな感じで持たせて、

using System;

public class HogeEnemy
{
    public HogeEnemy(BattleManager battleManager)
    {
        IDisposable onTurnEndDisposable = battleManager.OnTurnEnd.Add(async () =>
        {
            // 回復
            // 「回復した!」とか出す
            // ↑のテキストが送られたイベントみたいなのをawaitする
        });
        
        // onTurnEndDisposableを良い感じのタイミングで破棄したい
    }
}

敵からはこういう感じで回復演出とかを差し込めばいい感じになりそう……?

おわりに

こういうの、それこそUniRxやUniTaskの機能として存在するかもしれないんですが見つけられず、似たようなことを考える人はいると思うのですがどう検索すれば出てくるかわからない(IObserverとか非同期とか組み合わせて検索しても(Uni)Rx一般の話しか出てこねぇ……)。似たようなのが書いてる記事とかライブラリとかあったら教えてほしいです。あと上記の実装の粗とかそもそもコンセプトから破綻してるとかあったら教えてほしいです。

*1:敵キャラ用のインターフェース一つにメソッドをいっぱい生やすこと自体好ましくなく、OnTurnEnd()だけを宣言したインターフェースを作るのがいい気はしているのですが、そうするとどこかでそのインターフェースを実装しているかどうかで分岐する必要が生じる気がして、それはいいのかどうかよくわからなくなっています

*2:実際にはIBattleManagerのようなインターフェースを噛ませるのが適切

今年の抱負、或いは3度目のunity1weekに出るという誓約

はじめに

これは KMC Advent Calendar 2020 の11日目の記事*1です。
10日目の記事はうたがわききさんの「github-actions-profilerの実装に使ったライブラリを紹介」でした。まずGitHub Actionsって今Unityのプロジェクトに対しても使えたりするんかな……()
12日目の記事は末(matu)さんの「キーボードとマウスを乗せる台をDIYしたような、してないような」でした*2。自分もキーボード類で机がごちゃごちゃして困ってるのでなんとかしたいですよね。

ならびに inonoa Advent Calendar 2020 の4日目の記事でもあります。まだまだ枠空いてるので*3乱入歓迎です!!

本題

もう12月の19日です。タイトルの「今年の抱負」とは来年の抱負の間違いではないのかと思われるかもしれません。結論から言うと今年の抱負であってます。

「Unity1週間ゲームジャム」をご存じでしょうか。

unityroom.com

Unityというゲームエンジンを用いて1週間でゲームを作って遊んでもらったり遊んだりするイベントです。そのまんまですね。前回(お題「ふえる」)投稿された作品数は507にも及び、1本遊ぶのに30分かかるとするとすべて遊ぶのに30*507=15210分≒254時間≒10日半を溶かす計算です。あなたの年末年始の充実は確約されました。もちろん作る側になって充実させるのもいいでしょう。

そんなUnity1週間ゲームジャム(unity1week)ですが、次回はなんと明後日の21日から開催されるらしいです。やるしかないですね。

いのの振り返り

実は自分も過去に2回出たことがあります。宣伝自己紹介もかねて振り返っていきます。

1回目

unityroom.com

「あつめる」回に出しました。一年以上前……

上下左右に大国主大神を動かしてぜんざいを置きまくるゲームです。どんどんペースが上がって追っつかなくなりますが頑張ってください。

良かったところ

  • 出たこと
  • 間に合ったこと
  • 遊べてはいそうなこと
  • シンプルなこと

良くなかったところ

  • 絵がいらすとやしか無い……
  • 工夫のし甲斐がない
    • とにかく速くキーを押すゲーム(たぶん)。
    • とにかくわちゃわちゃ感を楽しめればいいのであればこれでいいような気もするし何もわからないが……
      • わちゃわちゃ感一点突破の場合は個々のアクションの手触りをひたすらいい感じにするべき?
  • 時間切れ時にいきなり画面遷移したり席数増加時に音が鳴らなかったり点数獲得時にうんたらかんたら……
    • 何が起こっているかわかりやすくしよう・リターンを得たときに褒めちぎってあげよう
  • コンセプト・基本のシステムからもうちょっと練り直してみてもよかったかもね……

2回目

unityroom.com

「ふえる」回です。ちなみに1回目の参加から一年以上の空きがあります。一年間何やってたんだ?

ドッペルゲンガーを見たら死ぬので、自分の視界(自分の前3マス)に入れないように進む不思議のダンジョン(?)です。先ほどと打って変わって緊張感のあるゲームのつもりです。

良かったところ

  • 絵作り
    • 最近特に気にしている部分でもあり、それなりに評価してもらえてるのはありがたい
    • 技術的にはだいたいDOTweenとPost Processingが使えればできるものではある(たぶん)
    • 階段降りる演出作った時自分でもにやにやしてたね……

良くなかったところ

  • 難しいっぽい
    • 10Fにもたどりつけない
  • リターンがない
    • 10Fとかにたどり着いたからと言って何があるわけでもない
    • アイテムとか落としておくと最低限の目標ができそう(アイテムをとる→攻略しやすくなる→攻略する→もっといいアイテムをとる→……)
    • そうじゃなくても10Fでいったん終わるとかにしておけばそこで達成感を得られそう
  • 工夫のし甲斐がない
    • デジャブ
    • 慣れてしまえば同じ動きを繰り返すだけ
  • 詰んだら終わる
    • ドッペルゲンガーの配置によってはどうしようもない。挟み撃ちにされるとか
    • そこそこ頻繁に起こってる気がする
    • やっぱりアイテムがあってほしい(視界が狭まる*4とかワープできるとか)
  • 公開を急いでしまった
    • 遅刻提出だったこともありとりあえず基本的なところだけ作って即出してしまったが、もう少し(それこそアイテムとか)磨いてから出してもよさそう
      • 出してからアプデするでもいいか
    • 開発期間終了後はなるべく早く出さないと遊んでもらえないかなという心配が多少あった。おそらく杞憂
  • 音がない
    • 評価のグラフのいびつさが目を引く
    • 評価はともかく雰囲気・手ごたえに響く
  • ドット絵が個人的には粗すぎる
    • ドット絵の文字を読ませようとするとサイズがでかくなってしまう(そもそも文字はドットをあきらめるべきかも)

今年の抱負

前2回の反省を生かしていい感じに作っていきたいと思います。 特に

  • 工夫のし甲斐のあるシステム
  • リターンか達成感のあるシステム

あたりを念頭に*5……?

おわりに

全然関係ないですがKMC(京大マイコンクラブ)ではゲームを作ったりWebサービスを作ったり音楽を作ったり絵を描いたり酒を飲んだり寝ていたりネコになったりしている人がいます。京大生じゃなくても大学生じゃなくても入れるので気になった方は公式サイトTwitterに連絡よろしく!!

www.kmc.gr.jp

*1:二年連続で大遅刻…………

*2:「でした」……?

*3:お前が書け

*4:視界が狭まるのがプラスに働くの面白くないですか?

*5:こう書くとかなり当たり前のことのように思えてきて恥ずかしいんですが、どこまで行っても基本が大事ととるべきか単に自分が未熟だととるべきか……

Unityでメンバをプロパティとしてシュッと書けることとインスペクタに表示することをいい感じに両立させられる方法ってないんですか?

はじめに

これはinonoa advent calendar 2020 の3日目の記事です。そこのあなたも寄稿してみませんか?

adventar.org

本題

C#にはプロパティという機能があります。クラスの実装を隠蔽しつつ実装・使用の煩わしさを緩和してくれる優れものだと思います。たぶん。ここでは主に、getとsetでアクセシビリティを変えたい(値を外から設定することはできないが見ることはできる)という場面での使用を想定します。

public class Hoge
{
    public float Fuga{ get; private set; } = 10;
}

また、Unityでは[SerializeField]という属性をメンバにつけることで、そのメンバがprivateであってもその値をインスペクタで表示できる機能があります。これも中身を(他クラスから)隠蔽しつつ値を調整したりほかのオブジェクトへの参照を与えてやったりできる便利機能です。たぶん。

using UnityEngine;

public class Hoge : MonoBehaviour
{
    [SerializeField] float _Fuga = 10;
}

問題はこれらを両立したいときです。変数を直接外部から設定させたくはない。が値を見たくはある。ついでに細かい調整をインスペクタ上でやりたい。

例えばこのように書けたらいいですね。

using UnityEngine;

public class Hoge : MonoBehaviour
{
    [SerializeField] public float Fuga{ get; private set; } = 10;
}

しかしこうは書けません。というよりインスペクタに表示されません。というのも[SerializeField]は変数しかシリアライズできないらしく、あくまで表記上変数に見えるだけであるプロパティは対象外みたいです。

解決法1

思いついた解決法の一つは下記です。

using UnityEngine;

public class Hoge : MonoBehaviour
{
    [SerializeField] float _Fuga = 10;
    public float Fuga => _Fuga;
}

ほかのクラスから見える部分はプロパティであるFugaで、getしか実装していないので値の設定もされ得ません。 また、インスペクタでは_Fugaが表示されるので((インスペクタ上ではアンダーバーが省略されて表示されるようで、単に Fuga を表示しているように見えます。))、微調整も可能です。

しかし、イメージとしては一つのものを指しているはずのFugaを表現するのに2識別子*1を使うのは個人的には奇妙に感じます。内部で値を参照したいときに_FugaFugaかどっち使うか迷ったり、いい感じのエディタを使う場合は補完に2つ出てきて??になったりしそうです(潔癖症すぎるかも……)。

解決法1.5

ところで、

    public float Fuga{ get; private set; } = 10;

このように使用したプロパティは単にgetやsetとしか書かれておらず実装を何も書いていません。C#ではこのように書いた場合、自動的に内部に変数を作ってその値を返したり設定したりするようにしてくれているらしいです。 [SerializeField]は変数にしか使えないと書きましたが、自動的に生成されるその変数に[SerializeField]をつけるようなことができればいい感じになるのでは……!

ufcpp.net

C#7.3*2からそのような変数(backing field)に対して属性をつけられるようになったらしい!

using UnityEngine;

public class Hoge : MonoBehaviour
{
    [field: SerializeField] public float Fuga{ get; private set; } = 10;
}

これで当初の目的は達成されました。Fuga一つで中身は隠され、しかし見ることはでき、インスペクタ上での設定も可能!めでたしめでたし。

f:id:inonoa:20201214050555p:plain

うーんこの…… 表示はできたものの、変数はプロパティFugaとは別物であって、インスペクタが表示する名前はC#が内部的に決めている変数名になるようです。 頭にFugaとはついていて一応何を指しているかはわかるので、見た目が多少美しくなくても割り切って使うこともできそう……?

解決法2

こちらが解になります。

baba-s.hatenablog.com

こちらを導入することでインスペクタでの表示を上書きすることができるようです。つまりこうなります。

using UnityEngine;

public class Hoge : MonoBehaviour
{
    // ͡コガネブログ様では[]を2つ並べる表記だったがこのように,で並べることもできる
    [field: SerializeField, RenameField("Fuga")] public float Fuga{ get; private set; } = 10;
}

大団円です。

蛇足

このままではコガネブログ様のステマ以上の意味がないので少し追加します。

Odin

自分のプロジェクトではOdinというアセットを使用していることが多いのですが、そちらの[LabeText]属性も同様にインスペクタでの表記を書き換える機能を持っています(自分ではこっちを使ってます)。ほかにもインスペクタをいい感じにする機能がたくさんあるのでオススメです。

assetstore.unity.com

baba-s.hatenablog.com

つまり結局ステマということになります。

文字列を直書きしない

先ほどの [RenameField("Fuga")]のうち "Fuga" の部分ですが、このように文字列を直接書いてしまうのはなるたけ避けたいです。名前が長いとタイポしそう*3

C#6.0からnameof演算子というものが導入されました。変数名などの識別子名を取得できる機能です。この場合エディタでの補完も利くほか、タイポするとコンパイルエラーになるので気づかずに変な誤字をしているような事態にはならないでしょう。

最終形態

最終的には自分ではこのような形に落ち着いています。

using UnityEngine;
using Sirenix.OdinInspector;

public class Hoge : MonoBehaviour
{
    [field: SerializeField, LabelText(nameof(Fuga))] public float Fuga{ get; private set; } = 10;
}

変数を直接外部から設定させたくはない。が値を見たくはある。ついでに細かい調整をインスペクタ上でやりたい。インスペクタの表示も意図した名前であってほしい。という要求をすべて解決しています。

とはいえかなり長くてごちゃごちゃした見た目なので、もっとシュッと書ける方法があったら教えてください。

*1:識別子の意味これであってる……?

*2:少なくとも自分の使っているUnity2019.4.14などはC#7.3に対応しています

*3:とはいえ1か所にしか書かないし、タイポしてもインスペクタの表示がそうなるだけなので気にしすぎの可能性が高い

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

はじめに

これはKMC Advent Calendar 2020 の11日目の記事*1、並びにinonoa Advent Calendar 2020の2日目の記事です*2。また、 一年前のコード書き直した(前編) の続きでもあります。

はい

一年前のコードとはこちらになります。

StageSelector.cs

読まなくて大丈夫です。

これがこうなりました

StageSelectManager.cs

StageSelectView.cs

StageSelectBGChanger.cs

クラスが三つに分裂して関数も増えてますがそのあたりは前編を参照のこと………

インターフェースで切る

インターフェースをご存じでしょうかって書こうとしたんですが説明は人にぶん投げることにします。今回の場合はStageSelectManager.csの下部のこれです。

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

で、StageSelectManagerのメンバの型に使われています。

public class StageSelectManager : SerializedMonoBehaviour
{
    [SerializeField] IStageSelectView view;

こうするとviewにはIStageSelectViewを継承(実装)しているものなら何でも入れられるわけです。例えば場面によって違うUIからステージ選択をしたい*3としても、ここにそれぞれ違うクラス(IStageSelectViewを継承している)を入れればStageSelectManager自体は使いまわせますし、そもそもどういうUIにするか決まってないけどとりあえずManagerだけ作っておこう、ただメンバのviewがnullだと動かない、というときにも、最低限のViewを素早く作って差し込み、あとからしっかりしたViewを作って取り換えるなんて芸当も可能です。というか実際今仮置きになっています。

f:id:inonoa:20201214030451p:plain
下部のボタンが仮置きというだけでそれ以外は体験版でもこれですしょぼいとか言わないで…………

というかそもそもManagerがViewへの参照を持たなくてすむ方法がある気がしてきましたが考えないことにします。

インターフェースの[SerializeField]………?

まだ説明が不足している部分があります。当たり前のようにStageSelectManagerのメンバに [SerializeField] IStageSelectView view; とか書いてましたが、 [SerializeField] は基本的にはMonoBehaviourやそのほかシリアライズできるクラスにしか対応していません(書いてもインスペクタに出ない)。インターフェースも [SerializeField] を消してしまってもスクリプト上で代入すれば使えはしますが、せっかくだしUnity上でドラッグアンドドロップしたい!そんな方にお勧めなのがOdinです。

assetstore.unity.com

public class StageSelectManager : SerializedMonoBehaviour
{
    [SerializeField] IStageSelectView view;

実は先ほどのStageSelectManagerはMonoBehaviourではなくSirenix.OdinInspector名前空間のSerializedMonoBehaviourを継承しています。これを継承することでなぜだかわかりませんがインターフェースでもインスペクタに表示できるみたいです。めでたしめでたし。

ほかに書くことなかったっけ……?

後編に書こうと思ってたこと少なくとも複数あったはずなんですがこれしか思い浮かばねえ………… とりあえずこれで終わります。思いついたら追記します…………

*1:にしようと思ってたんですが別に書くことにします……

*2:もはや埋める気が感じられない。今はただ埋めてくれた他の人達に感謝を

*3:実際のStageSelectManagerはなんかConfigManagerとかRankingSelectとか混じっててほかに転用しづらいのでそううまくはいきません。誰か直してください。

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

こんにちは

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と似たものを実装すれば導入しなくてもいいですし、多少面倒なもののコルーチンを使えば一か所にまとめることはできると思います。ゲームの完成からは遠のくかもしれないですが……。

京都大学総合人間学部を休学します

f:id:inonoa:20200318152311j:plain
前に授業で国立民族学博物館に行ったときに撮った太陽の塔。休学とは全く関係ないがどこか停滞を感じさせた

はじめに

これはKMC(京大マイコンクラブ)アドベントカレンダー2019 11日目の記事です*1。ちなみに今日は2020年3月18日で、私の誕生日の翌日でもあります。祝ってください。

adventar.org

何?

京大マイコンクラブ/京都大学総合人間学部3回のinonoa/いのと言います。来月から一年間休学します。

休学に必要な手続きですが、(総人の場合は)

  • 教務に紙をもらいに行く
  • 必要事項を記入する(名前とか住所とかぐらい)
  • 指導教員/アドバイザー、学系主任の二人からサインと捺印をもらう
  • 出す

の四点です。親のサインが要らないので親をガン無視して既成事実を作るムーブも可能です*2

学部の便覧には休学するときは一か月前までに言ってね*3と書いてあったのですが、休学を思い至ったのが三月に入ってからなので、無理を言って通しました。ありがとうございます。

誰?

重ねてになりますが京大マイコンクラブ(KMC)/総人の三回生です。時期が時期なだけに京大/KMCの新入生(予備軍)が読んでくださっているかもしれないですが、特に新入生に有益な記事ではないのであしからず(KMCには休学していた/している/しようとしている人が大勢いるので、私にとっても心強いし、休学を考えている人は是非KMCに入部しよう!とだけ言っておきます)。

いつ?

四月から来年三月。22卒になるらしい。復学して卒論書けるのか心配と言えば心配(単位は卒論除いてあと4とかなので取れるはず……)。

どこ?

四月からの下宿を決めてしまったので京都にいます。インターンで東京にいますとか言いたいけど。

なぜ?

ここを長くすると決意が揺らぐ事象が起こる予感がしたので短く書きます。

就職浪人のような感じです。三月時点ですでに就活の状況が芳しくない、目指している業界(ゲーム業界)はそこまで休学・留年等に厳しくないだろう(先輩や同期を見て感じた)、比較的就職につながる実績を残しやすい業界でもありそう、という思いから、一年修行してそれっぽい実績を残して就活しよう、みたいなモチベーションです。休学の事由としては比較的ポジティブな方だと思う、多分……。

どうやって?

非常にありがたいことに、生活費全部稼がなきゃいけない状況にはならなさそうなので、今と変わらないと言えば変わらないです。ただ休学の理由が上述の通り(就職浪人)なので、バイトとかインターンとかで輝かしい華々しい実績を残し、並行していい感じのゲームを作ろう*4、という夢を見ています。

なのでゲーム会社のインターン情報待ってます!!当方C#+Unity(あと前にPygame)でゲーム作ってます、WPFアプリ開発とかUnityでゲームプロトタイプ開発のバイトをしたことがあります、これからシェーダーを頑張るつもりです、あとはGitHubに……

github.com

まとめ

頑張ります。

宣伝

京大マイコンクラブはパソコンで色々なもの(ゲーム/絵/音楽/Webサービス/etc.)を作ったり競技プログラミングをしたり色々勉強会したりぼーっとしたりするサークルです。留年休学退学転学してても、京大生じゃなくても、大学生じゃなくても存在を許されるので、皆入部して許されましょう。

www.kmc.gr.jp

*1:本当に申し訳ありません

*2:俺自身は親の許可は貰いました。よくわからんわがままを許していただきありがとうございます

*3:学部によって違うと思うので休学を考えている人は便覧/KULASIS/その他を良い感じに確認してください

*4:大規模なゲームを作ろうとすると高確率で頓挫するという経験があるので、小さく(2,3ヶ月でできそうなぐらい)て尖った(できれば技術的に強みのある)ゲーム、を作るつもり