yotiky Tech Blog

とあるエンジニアの備忘録

Unity - Editor拡張で使えるUI機能の概要 ( GUI / GUILayout / EditorGUI / EditorGUILayout)

目次

環境

  • Unity 2022.3

概要

  • Editor拡張で使えるUI機能はクラスが4つある
  • GUI、GUILayout はUnityEngine名前空間なので、Editor以外でも動作する
  • EditorGUI、EditorGUILayout はUnityEditor名前空間なので、Editorでのみ動作する
  • Layoutなしは、RectでPositionを指定する必要がある
    • ex) Label の最少パラメータのシグネチャ
      • GUI.Label (Rect position, string text);
      • GUILayout.Label (string text, params GUILayoutOption[] options);
  • Layoutありは、Unityが自動レイアウトを行う
  • Layoutありなしは、レイアウト関連の関数に違いがあるがUIパーツ周りは対で存在する
  • EditorGUI、EditorGUILayout は、GUI、GUILayout よりUIパーツが多く、自由度が高い
    • ex) Toggle はGUI、GUILayout はテキストやイメージのパラメータを省略できないが、EditorGUI、EditorGUILayout はチェックボックス単体で描画できる
      • GUILayout.Toggle (bool value, string text, params GUILayoutOption[] options);
      • EditorGUILayout.Toggle (bool value, params GUILayoutOption[] options);
    • ex) indentLevel はEditorGUIにしか存在せず、GUI、GUILayoutのUIパーツには効果がない

        EditorGUI.indentLevel++;
        GUILayout.Label("GUILayout");
        EditorGUILayout.LabelField("EditorGUILayout");
      

関数一覧

4つのクラスの関数を列挙する。* がついているものはLayoutありなしで同名のメソッドが対で存在するもの。

GUI

BeginGroup グループを開始します。これは最後に EndGroup を呼び出す必要があります
* BeginScrollView スクロールビューを開始します
* Box Create a Box on the GUI Layer.
BringWindowToBack 特定のウィンドウを他のフローティングウィンドウの背面に移動させます
BringWindowToFront 特定のウィンドウを他のフローティングウィンドウの前面に移動させます
* Button ボタン。ユーザーがボタンをクリックするとすぐに何かが起こります
DragWindow ウィンドウをドラッグ可能にします
DrawTexture Rect 内部にテクスチャを描画します
DrawTextureWithTexCoords Draw a texture within a rectangle with the given texture coordinates.
EndGroup グループを終了します
* EndScrollView BeginScrollView で開始されたスクロールビューを終了します
FocusControl コントロール名でキーボードのフォーカスを移動させます
FocusWindow ウィンドウをアクティブにします
GetNameOfFocusedControl フォーカスを持つコントロールの名前を取得します
* HorizontalScrollbar 水平のスクロールバー。スクロールバーは文章をスクロールするのに使用します。ほとんどの場合は代わりにスクロールビューを使用してください。
* HorizontalSlider ユーザーが最小値と最大値の間で値をドラッグで変更できる水平スライダー
* Label スクリーン上のテキストやテクスチャのラベルを作成します
ModalWindow モーダルウィンドウを表示します
* PasswordField パスワードを入力するフィールドを作成します。
* RepeatButton ユーザーがボタンを押し続けている限り true を返すボタン
ScrollTo スクロールビューに囲まれている中で position の位置を表示するようにスクロールします
* SelectionGrid 選択グリッドボタン
SetNextControlName 次のコントロールに設定する名前
* TextArea ユーザーが文字列を編集することができる複数行のテキストエリア
* TextField ユーザーが文字列を編集することができるテキストエリア
* Toggle on/off のトグルボタン
* Toolbar ツールバー
UnfocusWindow ウィンドウのフォーカスを外します
* VerticalScrollbar 垂直のスクロールバー。スクロールバーは文章をスクロールするのに使用します。ほとんどの場合は代わりにスクロールビューを使用してください。
* VerticalSlider ユーザーが最小値と最大値の間で値をドラッグで変更できる垂直スライダー
* Window ポップアップウィンドウ

GUILayout

BeginArea 固定されたスクリーン領域に GUI コントロールの GUILayout ブロックを開始します
BeginHorizontal 水平のコントロールグループを開始します
* BeginScrollView 自動的にレイアウトされるスクロールビューを開始します
BeginVertical 水平のコントロールグループを開始します
* Box 自動レイアウトのボックスを作成します
* Button Make a single press button.
EndArea BeginArea で開始した GUILayout ブロックを閉じます
EndHorizontal BeginHorizontal で開始したグループを閉じます
* EndScrollView BeginScrollView を呼び出して開始したスクロールビューを閉じます
EndVertical BeginVertical で開始したグループを閉じます
ExpandHeight コントロールの垂直方向の拡張を許可/禁止するオプション
ExpandWidth コントロールの水平方向の拡張を許可/禁止するオプション
FlexibleSpace フレキシブルなスペースを挿入します
Height 決められた高さをコントロールに与えるオプション
* HorizontalScrollbar 水平のスクロールバー
* HorizontalSlider ユーザーが最小値と最大値の間で値をドラッグで変更できる水平スライダー
* Label 自動レイアウトのラベル
MaxHeight コントロールの高さの最大値を設定するオプション
MaxWidth コントロールの幅の最大値を設定するオプション
MinHeight コントロールの高さの最小値を設定するオプション
MinWidth Option passed to a control to specify a minimum width.
* PasswordField パスワードを入力するフィールドを作成します。
* RepeatButton リピートボタン。ユーザーがボタンをマウスで押している間は true を返します。
* SelectionGrid 選択グリッドボタン
Space 現在のレイアウトグループにスペースを挿入します
* TextArea ユーザーが文字列を編集することができる複数行のテキストエリア
* TextField ユーザーが文字列を編集することができるテキストエリア
* Toggle on/off のトグルボタン
* Toolbar ツールバー
* VerticalScrollbar 垂直のスクロールバー
* VerticalSlider ユーザーが最小値と最大値の間で値をドラッグで変更できる垂直スライダー
Width 決められた幅をコントロールに与えるオプション
* Window ウィンドウ内のコンテンツが自動でレイアウトされるポップアップウィンドウ

EditorGUI

BeginChangeCheck Starts a new code block to check for GUI changes.
BeginDisabledGroup BeginDisabledGroup と EndDisabledGroup で囲んだ GUI グループ内の GUI 要素を操作不可にする場合に使用されます。
* BeginFoldoutHeaderGroup それの左側に折り畳み矢印でラベルを作成します。
BeginProperty SerializedPropertyを GUI で管理しやすくするようにするためのプロパティーのラッパーである GUI グループを作成します
* BoundsField Makes Center and Extents field for entering a Bounds.
* BoundsIntField Makes Position and Size field for entering a BoundsInt.
CanCacheInspectorGUI Get whether a SerializedProperty's inspector GUI can be cached.
* ColorField Makes a field for selecting a Color.
* CurveField Makes a field for editing an AnimationCurve.
* DelayedDoubleField Makes a delayed text field for entering doubles.
* DelayedFloatField Makes a delayed text field for entering floats.
* DelayedIntField Makes a delayed text field for entering integers.
* DelayedTextField Makes a delayed text field.
* DoubleField Makes a text field for entering doubles.
DrawPreviewTexture 矩形内にテクスチャを描画します。
DrawRect 現在の Editor Window 内の指定された位置とサイズに色で塗りつぶした矩形を描画します。
DrawTextureAlpha 矩形内のテクスチャのアルファチャネルを描画します。
* DropdownButton Makes a button that reacts to mouse down, for displaying your own dropdown content.
DropShadowLabel ドロップシャドウ付きのラベルを描画します。
EndChangeCheck Ends a code block and checks for any GUI changes.
EndDisabledGroup BeginDisabledGroup で始まった Disabled group を終了します。
* EndFoldoutHeaderGroup Closes a group started with BeginFoldoutHeaderGroup. See Also: EditorGUILayout.BeginFoldoutHeaderGroup.
EndProperty BeginProperty と開始した Property Wrapper を終了します。
* EnumFlagsField Displays a menu with an option for every value of the enum type when clicked. An option for the value 0 with name "Nothing" and an option for the value ~0 (that is, all bits set) with the name "Everything" are always displayed at the top of the menu. The names for the values 0 and ~0 can be overriden by defining these values in the enum type.
* EnumPopup Makes an enum popup selection field.
* FloatField Makes a text field for entering floats.
FocusTextInControl 名前付きのテキストフィールドにキーボードフォーカスを移動し、コンテンツの編集を開始します。
* Foldout Makes a label with a foldout arrow to the left of it.
GetPropertyHeight PropertyField 制御に必要な高さを取得します。
* GradientField Makes a field for editing a Gradient.
HandlePrefixLabel Makes a label for some control.
* HelpBox Makes a help box with a message to the user.
* InspectorTitlebar Makes an inspector-window-like titlebar.
* IntField Makes a text field for entering integers.
* IntPopup Makes an integer popup selection field.
* IntSlider Makes a slider the user can drag to change an integer value between a min and a max.
* LabelField Makes a label field. (Useful for showing read-only info.)
* LayerField Makes a layer selection field.
* LinkButton Make a clickable link label.
* LongField Makes a text field for entering long integers.
* MaskField Makes a field for masks.
* MinMaxSlider Makes a special slider the user can use to specify a range between a min and a max.
MultiFloatField Makes a multi-control with text fields for entering multiple floats in the same line.
MultiIntField Makes a multi-control with text fields for entering multiple integers in the same line.
MultiPropertyField Makes a multi-control with several property fields in the same line.
* ObjectField Makes an object field. You can assign objects either by drag and drop objects or by selecting an object using the Object Picker.
* PasswordField Makes a text field where the user can enter a password.
* Popup Makes a generic popup selection field.
* PrefixLabel Makes a label in front of some control.
ProgressBar Makes a progress bar.
* PropertyField Use this to make a field for a SerializedProperty in the Editor.
* RectField Makes an X, Y, W, and H field for entering a Rect.
* RectIntField Makes an X, Y, W, and H field for entering a RectInt.
* SelectableLabel Makes a selectable label field. (Useful for showing read-only info that can be copy-pasted.)
* Slider Makes a slider the user can drag to change a value between a min and a max.
* TagField Makes a tag selection field.
* TextArea Makes a text area.
* TextField Makes a text field.
* Toggle Makes a toggle.
* ToggleLeft Makes a toggle field where the toggle is to the left and the label immediately to the right of it.
* Vector2Field Makes an X and Y field for entering a Vector2.
* Vector2IntField Makes an X and Y integer field for entering a Vector2Int.
* Vector3Field Makes an X, Y, and Z field for entering a Vector3.
* Vector3IntField Makes an X, Y, and Z integer field for entering a Vector3Int.
* Vector4Field Makes an X, Y, Z, and W field for entering a Vector4.

EditorGUILayout

BeginBuildTargetSelectionGrouping Begin a build target grouping and get the selected BuildTargetGroup back.
BeginFadeGroup 非表示/表示 と遷移アニメーションが可能なグループを作成します。
* BeginFoldoutHeaderGroup それの左側に折り畳み矢印でラベルを作成します。
BeginHorizontal 水平グループを開始し、戻る Rect を取得します。
BeginScrollView 自動的にレイアウトされるスクロールビューを開始します
BeginToggleGroup 一度で中のすべてのコントロールを無効か有効にするトグルの垂直グループを開始します。
BeginVertical 垂直グループを開始し、戻る Rect を取得します。
* BoundsField Bounds を入力する Center と Extents フィールドを作成します。
* BoundsIntField Make Position & Size field for entering a BoundsInt.
* ColorField Color を選択するフィールドをを作成します。
* CurveField AnimationCurve を編集するためのフィールドを作成します。
* DelayedDoubleField double を入力する Delayed のフィールドを作成します。
* DelayedFloatField float を入力するために Delayed Text Field を作成します。
* DelayedIntField 整数を入力するための Delayed Text Field を作成します。
* DelayedTextField Delayed Text Field を作成します。
* DoubleField double を入力するフィールドを作成します。
* DropdownButton Make a button that reacts to mouse down, for displaying your own dropdown content.
EditorToolbar Makes a toolbar populated with the specified collection of editor tools.
EditorToolbarForTarget Makes a toolbar populated with the collection of editor tools that match the EditorToolAttribute of the target object.
EndBuildTargetSelectionGrouping Close a group started with BeginBuildTargetSelectionGrouping.
EndFadeGroup BeginFadeGroup で始めたグループを閉じます。
* EndFoldoutHeaderGroup Closes a group started with BeginFoldoutHeaderGroup.
EndHorizontal BeginHorizontal で開始したグループを閉じます
EndScrollView BeginScrollView で開始されたスクロールビューを終了します
EndToggleGroup BeginToggleGroup で始まっていたグループを閉じます。
EndVertical BeginVertical で開始したグループを閉じます
* EnumFlagsField Displays a menu with an option for every value of the enum type when clicked.
* EnumPopup enum をポップアップして選択するフィールドを作成します。
* FloatField float 値を入力するためのフィールドを作成します。
* Foldout それの左側に折り畳み矢印でラベルを作成します。
GetControlRect Editor control のために Rect を取得します。
* GradientField Make a field for editing a Gradient.
* HelpBox ユーザーへのメッセージとヘルプボックスを作成します。
* InspectorTitlebar Inspector Window のような Titlebar を作成します。
* IntField 整数を入力するための Text Field を作成します。
* IntPopup 整数をポップアップして選択するフィールドを作成します。
* IntSlider 最小と最大の間の整数値をユーザーがドラッグして変更するスライダーを作成します。
* LabelField Label Field を作成します (読み取り専用の情報を表示するために便利です)。
* LayerField レイヤー選択フィールドを作成します。
* LinkButton Make a clickable link label.
* LongField long の整数を入力するフィールドを作成します。
* MaskField Mask Field を作成します。
* MinMaxSlider ユーザーが最小と最大の間の範囲を指定して使用できる特別なスライダーを作成します。
* ObjectField 任意のオブジェクトの Type を表示するフィールドを作成します。
* PasswordField パスワードを入力するフィールドを作成します。
* Popup 一般的なポップアップ選択フィールドを作成します。
* PrefixLabel いくつかのコントロールの前にラベルを作成します。
* PropertyField SerializedProperty のフィールドを作成します。
* RectField Rect を入力する X 、 Y 、 W と H のフィールドを作成します。
* RectIntField Make an X, Y, W & H field for entering a RectInt.
* SelectableLabel 選択可能な Label Field を作成します(コピーとペーストできる読み取り専用の情報を表示するために便利です)。
* Slider 最小と最大の間の整数値をユーザーがドラッグして変更するスライダーを作成します。
Space 前のコントロールと次のコントロールの間に小さなスペースを作成します。
* TagField タグを選択するフィールドを作成します。
* TextArea テキストの領域を作成します。
* TextField Text Field を作成します。
* Toggle Toggle を作成します。
* ToggleLeft トグルを左側に、そのすぐ右側にラベルがある Toggle Field を作成します。
ToolContextToolbar Makes a toolbar populated with the specified collection of editor tool contexts.
ToolContextToolbarForTarget Makes a toolbar populated with the collection of EditorToolContext that match the EditorToolContextAttribute.targetType of the target object.
* Vector2Field Vector2 を入力する X と Y のフィールドを作成します。
* Vector2IntField Make an X & Y integer field for entering a Vector2Int.
* Vector3Field Vector3 を入力する X 、 Y と Z のフィールドを作成します。
* Vector3IntField Make an X, Y & Z integer field for entering a Vector3Int.
* Vector4Field Vector4 を入力する X 、 Y 、 Z と W のフィールドを作成します。

Unity - Path を操作する

目次

検証環境

  • LINQPad で代替

Path を操作する

yotiky.hatenablog.com

Assetsからの相対パス絶対パス

void Main()
{
    //var baseDir = Application.dataPath;
    var baseDir = @"C:\Projects\Sample1\Prj1\Assets";
    var relativeDir = @"C:\Projects\Sample1\Prj2\Assets";

    Path.GetFileName(baseDir).Dump();
    // 結果: Assets

    var relativePath = Path.GetRelativePath(baseDir, relativeDir);
    relativePath.Dump();
    // 結果: ..\..\Prj2\Assets

    Path.GetFullPath(Path.Combine(baseDir, relativePath)).Dump();
    // 結果: C:\Projects\Sample1\Prj2\Assets
}

Assets を含む相対パス

// dataPath = Application.dataPath;
dataPath = @"C:\Projects\Sample1\Prj1\Assets";

private string DefaltDataDir => "Assets";

private string GetRelativePathFromAssetsDir(string targetPath)
{
    var relativePath = Path.GetRelativePath(dataPath, targetPath);
    relativePath = relativePath == "."
        ? DefaltDataDir
        : Path.Combine(DefaltDataDir, relativePath);

    return relativePath;
}

void Main()
{
    var relativeDir = @"C:\Projects\Sample1\Prj2\Assets";

    GetRelativePathFromAssetsDir(dataPath).Dump();
    // 結果: Assets
    
    GetRelativePathFromAssetsDir(Path.Combine(dataPath, @"Data\Master")).Dump();
    // 結果: Assets\Data\Master
    
    GetRelativePathFromAssetsDir(relativeDir).Dump();
    // 結果: Assets\..\..\Prj2\Assets
}

Unity - UPMのローカルパッケージ参照で特殊フォルダーを共有する

TL;DR

動的にロードされるアセットだけが含まれるフォルダー(ResourcesやStreamingAssets)を対象とした場合、AssetPostprocessorやPackage Managerのイベントが利用できる可能性がある。 条件が厳しいのはUPMにアセットを含める関係上、元のGUIDを維持できないため。更に細かい挙動が面倒くさく、正直使い勝手が良いとは言えない。Addressablesが使える場面ではAddressables、または大人しくシンボリックリンクなどを使ったほうが事故が少ないかもしれない。 今回は特殊フォルダーを共有するというテーマだが、他にもパッケージをインストールする際になにか処理を加えたい場合には応用できそう。

目次

開発環境

  • Unity 2022.3.23f1

AssetPostprocessor

AssetPostprocessor を利用するとプロジェクトのアセットに変更が加わった際にスクリプトを実行することができるようになる。 これはAssets配下のみならず、Packages配下にも有効。

docs.unity3d.com

基本的な構造は以下の通り。

public class Postprocessor : AssetPostprocessor
{
    static void OnPostprocessAllAssets(
        string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
    {
        // 新規作成や更新されたAssetは imported
        foreach (var asset in importedAssets)
            Debug.Log("imported:" + asset);
        // 削除されたら deleted
        foreach (var asset in deletedAssets)
            Debug.Log("deleted:" + asset);
        // 移動されたら movedFrom から moved へ
        foreach (var asset in movedAssets)
            Debug.Log("moved:" + asset);
        foreach (var asset in movedFromAssetPaths)
            Debug.Log("movedFromAssetPaths:" + asset);
    }
}

OnPostprocessAllAssetsはすべてのアセットのインポートが完了した後に呼び出される。
他にOnPostprocessAnimationOnPostprocessAudioなど、アセットの種類毎に呼び出されるメソッドも存在する。

パラメータ 内容
importedAssets 新たに追加されたり既存のアセットが更新されたりしたものが入ってくる
deletedAssets 削除されたもの
movedAssets 移動されたもの
movedFromAssetPaths 上記の移動元、移動先&移動元は対になる

特殊フォルダーを共有する場合

注意する点は以下の通り。

  • UPMで共有した場合、Assets配下にコピーすると元のアセット(Packages配下)と同じものが2重で存在することになる
  • File.CopyしてもmetaファイルのGUIDは新しい値に更新される
  • GUIDが維持できないため、GUIDを参照するようなものはAddressablesかシンボリックリンクにするのが良さそう
  • インストールするパッケージに同梱した場合、実行順序が定まらないのでAssetPostprocessorが認識される前にインポートされたアセットはイベントをフックできない
  • 何かしらエラーでコケた場合、最初からや続きからの再実行ができない
  • アセットの変更に随時反応するのでエディタが重くなる

以上を踏まえサンプルは以下の仕様で実装されている。

  • 動的にロードされるアセットが含まれるフォルダーを対象とする
    • Resources
    • StreamingAssets
  • アセットに変更があった場合は、ディレクトリ単位で洗い替える
  • 再実行可能なようにEditor拡張などを用意しておく
  • 総じて使い勝手は微妙

サンプルコード

public class Importer : AssetPostprocessor
{
    private static readonly string packageName = "com.xxx.yyy";
    private static readonly string packageRootDir = $"Packages/{packageName}";

    private static readonly Dictionary<string, string> targets = new()
    {
        //{ $"{packageRootDir}/Plugins", $"Assets/Plugins/{packageName}" },
        { $"{packageRootDir}/StreamingAssets", $"Assets/StreamingAssets/{packageName}" },
        { $"{packageRootDir}/Resources", $"Assets/Resources/{packageName}" },
    };
    
    static void OnPostprocessAllAssets(
        string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
    {
        var copied = new List<string>();

        Action<string> replace = path =>
        {
            if (!path.StartsWith(packageRootDir)) return;

            var kvp = targets.FirstOrDefault(x => path.StartsWith(x.Key));
            if (kvp.Key != null && !copied.Contains(kvp.Key))
            {
                ReplaceDirectory(kvp.Key, kvp.Value, true);
                copied.Add(kvp.Key);
                AssetDatabase.Refresh();
            }
        };

        foreach (var asset in importedAssets)
            replace(asset);
        foreach (var asset in deletedAssets)
            replace(asset);
        foreach (var asset in movedAssets)
            replace(asset);
    }

    static void ReplaceDirectory(string sourceDir, string destinationDir, bool recursive)
    {
        DeleteDirectory(destinationDir);
        CopyDirectory(sourceDir, destinationDir, recursive);
    }

    static void DeleteDirectory(string path)
    {
        var dir = new DirectoryInfo(path);
        if (dir.Exists)
            dir.Delete(true);
    }

    static void CopyDirectory(string sourceDir, string destinationDir, bool recursive)
    {
        // Get information about the source directory
        var dir = new DirectoryInfo(sourceDir);

        // Check if the source directory exists
        if (!dir.Exists)
            throw new DirectoryNotFoundException($"Source directory not found: {dir.FullName}");

        // Cache directories before we start copying
        DirectoryInfo[] dirs = dir.GetDirectories();

        // Create the destination directory
        Directory.CreateDirectory(destinationDir);

        // Get the files in the source directory and copy to the destination directory
        foreach (FileInfo file in dir.GetFiles())
        {
            string targetFilePath = Path.Combine(destinationDir, file.Name);
            file.CopyTo(targetFilePath);
        }

        // If recursive and copying subdirectories, recursively call this method
        if (recursive)
        {
            foreach (DirectoryInfo subDir in dirs)
            {
                string newDestinationDir = Path.Combine(destinationDir, subDir.Name);
                CopyDirectory(subDir.FullName, newDestinationDir, true);
            }
        }
    }

    public static void CopyDirectoryAll()
    {
        foreach (var kvp in targets)
        {
            if (Directory.Exists(kvp.Key))
                ReplaceDirectory(kvp.Key, kvp.Value, true);
        }

        AssetDatabase.Refresh();
    }
}

エディタ拡張。

public class ReplaceResourceFolder : Editor
{
    [MenuItem("PackageSample/ReplaceResourceFolder ")]
    static void Execute()
    {
        Importer.CopyDirectoryAll();
    }
}

Package Manager イベント

Package Manager では、EventsクラスにregisteringPackagesregisteredPackagesの2つのイベントが用意されている。

docs.unity3d.com

docs.unity3d.com

ノート: Package Manager はパッケージ内のストリーミングアセットをサポートしていません。代わりに Addressable パッケージをご利用ください。

registeringPackagesはPackage Managerが依存関係のリストを変更する前に発生する。 基本的な構造は、Events.registeringPackagesイベントハンドラーを登録するだけ。

public class EventSubscribingExample_RegisteringPackages
{
    public EventSubscribingExample_RegisteringPackages()
    {
        Events.registeringPackages += RegisteringPackagesEventHandler;
    }

    void RegisteringPackagesEventHandler(PackageRegistrationEventArgs packageRegistrationEventArgs)
    {
        Debug.Log("The list of registered packages is about to change!");

        // Install時
        foreach (var asset in packageRegistrationEventArgs.added)
            Debug.Log("added:" + asset.name);
        // Remove時
        foreach (var asset in packageRegistrationEventArgs.removed)
            Debug.Log("removed:" + asset.name);
        // バージョン変更時(Up/Down)、FromとToがペアになる
        foreach (var asset in packageRegistrationEventArgs.changedFrom)
            Debug.Log("changedFrom:" + asset.name);
        foreach (var asset in packageRegistrationEventArgs.changedTo)
            Debug.Log("changedTo:" + asset.name);
    }
}

registeredPackagesはインポートが終わってコンパイルされた後に発生する。 こちらはInitializeOnLoadMethod属性が必要になる。

public class EventSubscribingExample_RegisteredPackages 
{
    // イベントをサブスクライブするには '[InitializeOnLoadMethod]' か '[InitializeOnLoad]' を使う必要があります
    [InitializeOnLoadMethod]
    static void SubscribeToEvent()
    {
        Events.registeredPackages += RegisteredPackagesEventHandler;
    }

    static void RegisteredPackagesEventHandler(PackageRegistrationEventArgs packageRegistrationEventArgs)
    {
        // ここで実行されるコードは、エディターが一連の新しいパッケージのコンパイルを完了したと安全に想定できます。
        Debug.Log("The list of registered packages has changed!");
        
        // ローカルのパッケージの中身が更新されただけだと呼ばれない
        // Install時
        foreach (var asset in packageRegistrationEventArgs.added)
            Debug.Log("added:" + asset.name);
        // Remove時
        foreach (var asset in packageRegistrationEventArgs.removed)
            Debug.Log("removed:" + asset.name);
        // バージョン変更時(Up/Down)、FromとToがペアになる
        foreach (var asset in packageRegistrationEventArgs.changedFrom)
            Debug.Log("changedFrom:" + asset.name);
        foreach (var asset in packageRegistrationEventArgs.changedTo)
            Debug.Log("changedTo:" + asset.name);
    }
}
PackageRegistrationEventArgsのプロパティ 内容
added Package ManagerでInstallした時
removed Removeした時
changedFrom バージョンが変更(Up/Down)した時の前のパッケージ情報
changedTo 上記の変更後のパッケージ情報、前後は対で提供される

特殊フォルダーを共有する場合

基本的にパッケージレベルの話なので、アセット(ディレクトリ)毎にどうのこうのの話ではなさそう。

注意する点は以下の通り。

  • UPMのローカルパッケージ参照の場合、パッケージのアップデートをしなくても中身は更新されるが、そのような状況ではイベントは発生しない
  • そのため使い勝手がだいぶ悪い
  • インストールするパッケージに同梱した場合、registeredPackagesであればインストールの最後に走るので誤爆はなさそう
  • パッケージをいじらなければ実行されないのでエディタが重いということはない
  • その他GUIDやエラーでコケた場合などはAssetPostprocessorと同様
  • AssetPostprocessorと一緒に覚えておくと何かに使えるか

シンボリックリンクを使用する

PluginsのようにプロジェクトのSerializeFieldなどでGUIDを参照してしまうようなものは、これまでの方法は使えない。 そもそもローカルパッケージを参照するために、ディレクトリの相対パスを持っているはずなので、同様にシンボリックリンクを貼ってしまう方が楽かもしれない。

Windowsの場合。

mklink /D destDir srcDir

その他参考。

yotiky.hatenablog.com

まとめ

UPMのローカルパッケージ参照で特殊フォルダーを共有する方法を探ってきた。 GUIDを必要としない、動的にロードされるアセットだけが含まれるフォルダー(ResourcesやStreamingAssets)を対象とした場合に限り、AssetPostprocessorやPackage Managerのイベントが利用できる可能性がある。 正直使い勝手が良いとは言えないため、Addressablesが使える場面ではAddressables、または大人しくシンボリックリンクなどを使ったほうが事故が少ないかもしれない。 今回は特殊フォルダーを共有するというテーマだったが、他にもパッケージをインストールする際になにか処理を加えたい場合には応用できそうである。

C# - シンボリックリンクを操作する

TL;DR

.NET 6 でDirectoryクラスにCreateSymbolikLinkメソッドが追加されたため、6以降ではバッチやProcessを使わなくても C# で直接シンボリックリンクを作成できるようになりました。
この記事はディレクトリを対象としていますが、ファイルを対象としたFileクラスとFileInfoクラスも同じように操作可能です。

.NET 5 以前は以下を参照。

yotiky.hatenablog.com

目次

適用対象

  • .NET 6 以降

シンボリックリンクの操作

作成する

DirectoryクラスのCreateSymbolicLinkを使用する。

    var src =   @"C:\Workspace\SymLinkWork\folder1";
    var dest2 = @"C:\Workspace\SymLinkWork\folder2";

    Directory.CreateSymbolicLink(dest2, src);

learn.microsoft.com

ほかに、DiretoryInfoクラスからCreateAsSymbolickLinkを使う方法もある。

    var info = new DirectoryInfo(dest2);
    info.CreateAsSymbolicLink(src);

リンク先を取得する

DirectoryクラスのResolveLinkTargetを使用する。

    var src =   @"C:\Workspace\SymLinkWork\folder1";
    var dest2 = @"C:\Workspace\SymLinkWork\folder2";
    var dest3 = @"C:\Workspace\SymLinkWork\folder3";

    Directory.CreateSymbolicLink(dest2, src);
    Directory.CreateSymbolicLink(dest3, dest2);

    Directory.ResolveLinkTarget(dest3 , false).Dump();
    Directory.ResolveLinkTarget(dest3 , true).Dump();

上がfalse、下がtrueの結果。

returnFinalTargetfalse にすると、直接のリンク先を取得する。 true を指定すると、リンク先がシンボリックリンクだった場合、リンクをたどって最後のリンク先を取得する。

learn.microsoft.com

ほかに、DiretoryInfoクラスからResolveLinkTargetを使う方法もある。

    var info = new DirectoryInfo(dest2);
    info.ResolveLinkTarget(true);

削除する

ディレクトリを消すだけ。

    Directory.Delete(dest2);

指定したディレクトリ配下のシンボリックリンクを確認にする

    var dirs = Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories);
    dirs.Select(path => new DirectoryInfo(path))
        .Select(info => new { Path = info.FullName, Symlink = info.LinkTarget != null, Link = info.LinkTarget })
        .Dump("LinkList");

検証に使用したコード全文(LINQPad)

void Main()
{
    var root = @"C:\Workspace\SymLinkWork";
    var src =   @"C:\Workspace\SymLinkWork\folder1";
    var dest2 = @"C:\Workspace\SymLinkWork\folder2";
    var dest3 = @"C:\Workspace\SymLinkWork\folder3";

    CreateSymlink(dest2, src);
    CreateSymlinkByInfo(dest3, dest2);

    Resolve(dest3, false);
    Resolve(dest3, true);
    ResolveByInfo(dest2, true);

    GetSymLinkList(root);

    DeleteSymlink(dest2);
    DeleteSymlink(dest3);

    GetSymLinkList(root);
}

void CreateSymlink(string dest, string src)
{
    Directory.CreateSymbolicLink(dest, src).Dump("Create");
}
void CreateSymlinkByInfo(string dest, string src)
{
    var info = new DirectoryInfo(dest).Dump("Create");
    info.CreateAsSymbolicLink(src);
}

void DeleteSymlink(string path)
{
    Directory.Delete(path);
}
void Resolve(string path, bool finalTarget)
{
    Directory.ResolveLinkTarget(path, finalTarget).Dump("Resolve");
}
void ResolveByInfo(string path, bool finalTarget)
{
    var info = new DirectoryInfo(path);
    info.ResolveLinkTarget(finalTarget).Dump("Resolve");
}
void GetSymLinkList(string root)
{
    var dirs = Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories);
    dirs.Select(path => new DirectoryInfo(path))
        .Select(info => new { Path = info.FullName, Symlink = info.LinkTarget != null, Link = info.LinkTarget })
        .Dump("LinkList");
}

Windows 11 - ショートカットキーでアプリを別の仮想デスクトップに移動する

目次

TL;DR

  • Windows 10 で使用していた MoveToDesktop が Windows 11 では使用できない
  • AutoHotKey を使用して同等の機能を導入する
  • 導入すると現在のウィンドウを [Win + Alt + → or ←] で左右の仮想デスクトップに移動できる

以前の記事

yotiky.hatenablog.com

仮想デスクトップ

仮想デスクトップは、デスクトップ画面を複数作成して開くアプリをそれぞれに配置することで作業画面の使い分けができる機能です。

タスクバーのタスクビューから操作することができます。 [Win+ Tab] でも開けます。

よく使うショートカットキーは以下の通りです。
閉じた仮想デスクトップで開いているアプリは左の仮想デスクトップに移動します。

ショートカットキー 機能
Win + Tab タスクビューを開く
Win + Ctrl + D 仮想デスクトップを追加する
Win + Ctrl + → or ← 仮想デスクトップを切り替える
Win + Ctrl + F4 使用中の仮想デスクトップを閉じる

使っていると、パッと開いたアプリを他のデスクトップに移動したくなるのですが、このショートカットキーは標準では用意されていないようです。

AutoHotKeyの導入

以下のサイトからインストーラーをダウンロードしてインストールします。 スクリプトがv2.0に対応していないため、v1.1を選択します。

www.autohotkey.com

スクリプトファイル

以下のサイトからVD.ahk、_VD.ahkをダウンロードします。

github.com

次に、以下の内容でMoveToDesktop.ahk ファイルを新規作成します。

;#SETUP START
#NoEnv ; Recommended for performance and compatibility with future AutoHotkey releases.
#SingleInstance force
ListLines Off
SetBatchLines -1
SendMode Input ; Recommended for new scripts due to its superior speed and reliability.
SetWorkingDir %A_ScriptDir% ; Ensures a consistent starting directory.
#KeyHistory 0
#WinActivateForce

Process, Priority,, H

SetWinDelay -1
SetControlDelay -1

;include the library
#Include VD.ahk
; VD.init() ;COMMENT OUT `static dummyStatic1 := VD.init()` if you don't want to init at start of script

;you should WinHide invisible programs that have a window.
WinHide, % "Malwarebytes Tray Application"
;#SETUP END

VD.createUntil(3) ;create until we have at least 3 VD

return

#!Left::
n := VD.getCurrentDesktopNum()
if n = 1
{
    Return
}
n -= 1
VD.MoveWindowToDesktopNum("A",n), VD.goToDesktopNum(n)
Return

#!Right::
n := VD.getCurrentDesktopNum()
if n = % VD.getCount()
{
    Return
}
n += 1
VD.MoveWindowToDesktopNum("A",n), VD.goToDesktopNum(n)
Return

任意のフォルダに上記3つのahkファイルを配置して、MoveToDesktop.ahkを実行します。

[Win + Alt + → or ←] で現在のウィンドウを左右の仮想デスクトップに移動することができます。

C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUpMoveToDesktop.ahkのショートカットを追加しておけば、PC起動時に自動的に実行されます。 [Win +R]でshell:startupでも開けます。

Unity - ZLoggerを導入する

目次

検証環境

  • Unity 2022.3.21f1
  • ZLogger 2.4.1
  • ZLogger.Unity 2.4.1
  • CsprojModifier 1.2.1

github.com

github.com

導入

  • NugetForUnityをインストール
  • NugetForUnityで以下をインストール

    • ZLogger

  • Project Settings > Package Manager を設定

    • OpenUPM
    • https://package.openupm.com
    • com.cysharp

  • Package Managerで以下をインストール

    • CsprojManager
    • ZLogger.Unity

  • 次の内容をcsc.rspのファイル名でAssets/直下に作成する

    • asmdefを定義している場合は、asmdefと同じフォルダへ配置
    • Unity 2022.2以降

      -langVersion:10 -nullable
      
    • Unity 2022.3.12f1以降でC#11を使いたい場合

      -langVersion:preview -nullable
      

  • 次の内容をLangVersion.propsのファイル名でルートフォルダに作成する

    • C#11を使う場合は<LangVersion>11</LangVersion>
      <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
          <PropertyGroup>
              <LangVersion>10</LangVersion>
              <Nullable>enable</Nullable>
          </PropertyGroup>
      </Project>
    

  • Project Settings > Editor > C# Project Modifier

    • CsprojManagerは、propsファイルで指定した内容を、登録したcsprojに挿入する
    • Additional project imports に作成したpropsファイルを登録
    • asmdefを定義している場合は、The project to be added for Import に登録

実行

サンプルコードを作って適当なGameObjectに追加する

public class LoggerTest : MonoBehaviour
{
    void Start()
    {
        var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Trace);
            builder.AddZLoggerUnityDebug();
        });

        var logger = loggerFactory.CreateLogger<LoggerTest>();

        var name = "foo";
        logger.ZLogDebug($"Hello {name}!");

        Debug.Log($"Hello bar from Unity");
    }
}

実行結果

参考

Unity - R3 を使った Pure C# のModel層プロジェクトの作成

目次

概要

  • ピュアC# の.NETプロジェクトでModel層を作成する
  • R3 を導入してRxをシームレスに利用する
  • Unity プロジェクトはすでに存在するものとする

環境

  • Unity 2022.3.21f1
  • R3 1.1.11

手順

  • 新しいクラスライブラリプロジェクトを作成する
  • フレームワークは、.NET Standard 2.1 を選択

  • NuGet でR3をインストール
  • Directory.BUild.props を作成
    • Unity側からビルド出力のフォルダが無視されるようにする
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <PropertyGroup>
        <ArtifactsPath>$(MSBuildThisFileDirectory).artifacts</ArtifactsPath>
    </PropertyGroup>
</Project>
  • csproj を開いて編集
    • LangVersion : 9.0
    • None Remove を追加する
      • Unityが生成するUnity向けのファイルを.NETのプロジェクトで無視するようにする
  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
    <ItemGroup>
        <None Remove="**\package.json" />
        <None Remove="**\*.asmdef" />
        <None Remove="**\*.meta" />
    </ItemGroup>
    <ItemGroup>
        <PackageReference Include="R3" Version="1.1.11" />
    </ItemGroup>
  • package.jsonを追加する
{
  "name": "xrproject_vnext.models.shared.unity",
  "displayName": "Models.Shared.Unity",
  "description": "Models.Shared.Unity",
  "version": "0.0.1",
  "unity": "2022.3",
  "author": "yotiky",
  "dependencies": {
    "org.nuget.r3": "1.1.11",
    "org.nuget.microsoft.net.stringtools": "1.0.0"
  }
}
  • asmdefを追加する
{
  "name": "Models.Shared.Unity",
  "references": [],
  "optionalUnityReferences": [],
  "includePlatforms": [],
  "excludePlatforms": [],
  "allowUnsafeCode": false,
  "overrideReferences": false,
  "precompiledReferences": [],
  "autoReferenced": true,
  "defineConstraints": []
}
  • 以降Unityプロジェクトで
  • Add package from disk... で上記package.jsonを追加する
  • Unityプロジェクト側のasmdefに上記asmdefの参照を追加する
  • Packages\manifest.json絶対パス相対パスに修正する
    • "xrproject_vnext.models.shared.unity": "file:../../Models/ClassLibrary1",

サンプル実装

.NET プロジェクト

    public class SampleClass
    {
        private readonly ReactiveProperty<int> _number = new(0);
        public ReadOnlyReactiveProperty<int> Number => _number;

        private Subject<Unit> _onCalled = new Subject<Unit>();
        public Observable<Unit> OnCalled => _onCalled;

        public void Increment()
        {
            _number.Value++;
        }
        public void CallOnNext()
        {
            _onCalled.OnNext(Unit.Default);
        }
    }

Unity プロジェクト - サンプルコードを実装して空のGameObjectに追加する

void Start()
{
        var model = new SampleClass();
        model.OnCalled
            .Subscribe(_ => Debug.Log("OnCalled"))
            .RegisterTo(destroyCancellationToken);
        model.Number
            .Subscribe(x => Debug.Log($"Number changed : {x}"))
            .RegisterTo(destroyCancellationToken);

        model.CallOnNext();
        model.Increment();
        model.CallOnNext();
        model.Increment();
}

実行結果

参考