Editor拡張で悶えてみる

 こんなんできましたけどー

f:id:himatsubushi-industry:20180916194856p:plain

何が出来たかというと、前回グダグダ言うてたプレイヤーである。

himatsubushi-industry.hatenablog.com

”Universal Sound FX"専用であったり、手抜き感満載のUIだったりと、まだまだ改善の余地はあるが、クッソ多いサウンドを手軽に選んで聴くという最低限度の目的は達せられたので、ちょっと紹介してみたい。

というわけで、今週もユニティちゃんは動きません。

調べなならん技術範囲はわりと多い

深くはないがね。

簡単に挙げると、こんな感じ。

  • エディタ拡張
  • ファイル操作
  • 音声再生

ややこしい処理は殆どフレームワーク任せなので、コーディング量はそれほどでもないが、約束事が多いので初心者にはハードルが高い内容だと思う。

プログラミングに明るくない人がいきなり見てもワケワカランと思うが、とりあえず一つずつ挙げていこう。

Editorフォルダに突っ込むとエディタ拡張スクリプトになるという仕様

"Project"において、"Assets"フォルダ下であれば、どんなにネストしてても最終的に"Editor"と名付けたフォルダに配置されたスクリプトはエディタ拡張として機能する。らしい。

らしいというのは、どこまでエディタ拡張として認識してくれるかがワカランからだが、普通に考えてアホみたいにネストした先に"Editor"フォルダ置くアホもおらんやろと思うので、考えないこととする。

逆に言えば、そんなフォルダ構成はプロジェクトの透明性を損ねるのでやめれ。

さて、単純にスクリプトを置けばいいかと言えば、そんなわけでもない。
エディタ拡張用に記述すべき作法がある。

では具体的にどう記述すれば良いのかというと、書くと長くなるのでこちらを参照されたい。

qiita.com

UIとかは、

anchan828.github.io

↑ここの、第6章 EditorGUI (EdirotGUILayout) - エディター拡張入門を読めばまぁ分かるだろう。

ファイルは何処にあるねん!

"Eeditor" 以外にもフォルダ構成のルールはいくつかある。

qiita.com

目的は"Assets"下の”Universal Sound FX"を漁る事なので、このための手段が必要だ。

"Assets"以下を漁る場合は、"AssetDatabase"という静的クラスが存在するので、これを使えば"Assets"以下のファイルは何でも読み放題である。

docs.unity3d.com

尚、"Resources"以下のフォルダに置かれたファイルアクセスのための"Resources"という静的クラスも存在する。
こちらは、一括読み出しとかいうなかなか豪快な機能がついている。

docs.unity3d.com

しかし、上記の通りランタイム用の機能であるので、今回はこれ以上触れない。
ゲーム制作時のお楽しみである。

したがって、"AssetDatabase"をいじくることになるのだが、残念ながら"AssetDatabase"に全フォルダ、全ファイルを取得する手段はない。

ではどうするのか?

"Application"静的クラスの”dataPath”メンバに"Assets"(Editor時)までの絶対パスが返されるので、これを使って.NETでフォルダを走査することとなる。
普通にWindowsアプリである。

docs.unity3d.com

ちなみに使用するのは"DirectoryInfo"クラスと"FileInfo"クラスである。

あるフォルダ以下にあるサブフォルダをすべて取得する - .NET Tips (VB.NET,C#...)

あるフォルダ以下にあるファイルをすべて取得する - .NET Tips (VB.NET,C#...)

これで、フォルダツリーを走査しつつ、ドロップダウンリストに表示すべきネタを登録していく。

" AudioUtility.cs"とかいう神スクリプト

色々説明を端折ったが、あとは音声を再生するだけである。
しかし、エディタ拡張に対して音声再生のための機能はオープンにされていない。

で、オープンにされてないとこじ開ける人もいる。

baba-s.hatenablog.com

内部APIを無理やり呼び出す感じである。

tsubakit1.hateblo.jp

しかし、ループ再生したり、停止させたり、再生停止を認識させたりと、色々やるにはこれだけでは情報が不十分。

てなわけで、さらに情報を漁っていたら、こんなん見つけた。

https://forum.unity.com/threads/reflected-audioutil-class-for-making-audio-based-editor-extensions.308133/

オリジナルの作者は、

assetstore.unity.com

で、

assetstore.unity.com

とか作ってる人であるが、この際それは置いておこう。
前述のワガママを全て満たしてくれるAPIラッパーを用意してくれているのだ。

ここはありがたく使わせていただこう。ドーモ。ロゴ ディジタル=サン。

ソースコード大公開

今回作成したソースコードは以下の通りである。

SoundSelector.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;

[Serializable]
public class SoundSelecter : ScriptableObject
{
    [SerializeField]

    [NonSerialized]
    private List<string> _categories = new List<string>();
    private List<string> _clips = new List<string>();
    private int _selectCategory = -1;
    private int _selectClip = -1;
    private string _soundRoot;

    public List<string> Categories { get { return _categories; } }
    public int SelectCategory
    {
        set
        {
            if ((value < 0) || (_categories.Count < value))
            {
                _selectCategory = -1;
                _clips.Clear();
            }
            else
            {
                _selectCategory = value;
                DirectoryInfo clipFolder = new DirectoryInfo(_soundRoot + "/" + _categories[_selectCategory]);
                FileInfo[] clips = clipFolder.GetFiles("*.wav");
                _clips.Clear();
                foreach (FileInfo clip in clips)
                {
                    _clips.Add(clip.Name);
                }
            }
        }
        get { return _selectCategory; }
    }
    public List<string> Clips { get { return _clips; } }
    public int SelectClip
    {
        set
        {
            _selectClip = ((value < 0) || (_clips.Count < value)) ? -1 : value;
        }
        get { return _selectClip; }
    }
    public string Path
    {
        get
        {
            return ((_selectCategory == -1) || (_selectClip == -1)) ? null :
                _soundRoot + "/" + _categories[_selectCategory] + "/" + _clips[_selectClip];
        }
    }
    public string AssetPath
    {
        get
        {
            return ((_selectCategory == -1) || (_selectClip == -1)) ? null :
                "Assets/Universal Sound FX/" + _categories[_selectCategory] + "/" + _clips[_selectClip];
        }
    }

    private void OnEnable()
    {
        _soundRoot = Application.dataPath + "/Universal Sound FX";
        DirectoryInfo root = new DirectoryInfo(_soundRoot);
        DirectoryInfo[] categories = root.GetDirectories("*");

        foreach (DirectoryInfo category in categories)
        {
            if (category.Name != "#Game Genre References")
            {
                getCategory(category, "");
            }
        }
    }

    private void getCategory(DirectoryInfo category, string parentName)
    {
        DirectoryInfo[] subCategories = category.GetDirectories("*");
        if (subCategories.Length <= 0)
        {
            _categories.Add(parentName + category.Name);
        }
        else
        {
            foreach (DirectoryInfo subCategory in subCategories)
            {
                getCategory(subCategory, parentName + category.Name + "/");
            }
        }
    }
}

UniversalSoundFXPlay.cs

using System;
using System.Reflection;
using UnityEngine;
using UnityEditor;

public class EditorWindowSample : EditorWindow
{
    private SoundSelecter selector = null;
    private string prevPath = "";
    private AudioClip clip;
    private bool loop;
    private bool playRequest;
    private bool playing;

    [MenuItem("Extend/Universal Sound FX Play")]
    private static void Create()
    {
        GetWindow<EditorWindowSample>("Universal Sound FX Play");
    }

    private void OnGUI()
    {
        if (selector == null) {
            selector = ScriptableObject.CreateInstance<SoundSelecter>();
        }
        selector.SelectCategory = EditorGUILayout.Popup("Category", selector.SelectCategory, selector.Categories.ToArray());
        selector.SelectClip = EditorGUILayout.Popup("Clip", selector.SelectClip, selector.Clips.ToArray());
        if (selector.AssetPath != null) {
            if (selector.AssetPath != prevPath) {
                clip = AssetDatabase.LoadAssetAtPath<AudioClip>(selector.AssetPath);
                prevPath = selector.AssetPath;
            }
        }
        EditorGUILayout.BeginHorizontal();
        loop = GUILayout.Toggle(loop, "Loop");
        playRequest = GUILayout.Toggle(playRequest, playRequest ? "Stop" : "Play", "button");
        if (clip == null)
        {
            playRequest = false;
        }
        EditorGUILayout.EndHorizontal();

        if (playRequest == true)
        {
            if (playing == false)
            {
                playing = true;
                AudioUtility.PlayClip(clip, 0, loop);
            }
            else if (AudioUtility.IsClipPlaying(clip) == false)
            {
                playing = false;
                playRequest = false;
            }
        }
        else
        {
            if(playing == true) {
                AudioUtility.StopClip(clip);
            }
            playing = false;
        }
    }
}

精査はしてないので、ゴミがあったらご容赦のほど。
あと、動作保証もサポートもしないので悪しからず。

次回予告

思いのほか使い勝手がいいので、もうちょっと機能拡張してみたいと思う。