【Unity】エディターフォントが変更できなくなった件

2025年5月現在、Unity6のエディターフォントの変更ができなくなってしまっていたため状況のメモです。


2019年11月: 以下のフォント変更アセットを作成する

https://takap-tech.com/entry/2019/11/30/231258

↓↓↓

2021年ころ: 特殊なファイルを変更することで変更できる

https://baba-s.hatenablog.com/entry/2021/11/22/090000

↓↓↓

2025年1月:

Unity6でフォントが変わらないことに気が付く

https://issuetracker.unity3d.com/issues/editors-font-does-not-change-when-adding-a-new-font-to-fontsettings-dot-txt

どうやら上記の手段が2023.1で使えなくなった模様。Issuesに以下の記載を見つける。

fontsettings.txt によるエディターフォントの変更は公式にはサポートされていませんでした。Unity のテキストシステムを簡素化するために、バージョン 2023.1 以降では TextCore にアップデートしました。これは TextMesh Pro および UI Toolkit と同じテクノロジーに基づいています。これにより、ユーザーがまだアクセスできない別のフォントフォールバックメカニズムが導入されます。今後、カスタマイズオプションを増やす予定です。

うーん、、、残念。

Editor拡張のアセットの方もUnity6で使えなくなった模様。

そして代わりに

preference > Editor Font

から「Inter」と「System」は選べるようになってるけどWindowsのフルHD環境だと文字が掠れて見にくい…一応Systemにすれば少し良くなりますが…

というかUnityはWindowsでエディター上のフォントがにじんで見づらい件を問題とすら認識してないから仕方ないですね。

それにしても一切変えられなくなるとは…

しかも変更不能になったことに気が付かずに自動で変更できるツールを作成してしまった、、、

github.com

【C#】あるフォルダ内のファイルを一つにまとめるツール

あるディレクトリ以下の特定のファイルを一つにまとめるツールを作成しました。

ローカルにあるソースコードをGeminiのような大きいトークン数を扱えるAIにファイルで渡して内容をレビューや改善するために、フォルダ内の特定のファイルを全て1つのファイルにまとめるためのC#のコンソールアプリです。

github.com

使用方法はリポジトリ側に記載しました。

【ここ】から直接ダウンロードできます。

ファイルを解凍して中身のexeをコマンドラインから引数を何も指定せずに実行するとヘルプメッセージが表示されるのでGitと合わせて参照ください。

実際の挙動ですが、例えばあるフォルダに以下のようなファイルが存在する状態で

// ファイルA.cs
hogehoge
aaaaaaaa
bbbbbbb

// ファイルB.js
fugafuga
cccccccc
dddddddd

ツールを次の通り実行すると実行すると、以下のような出力になります。

CombineFiles.exe 1 c:\sample_dir\ .js;.cs bin;obj
// YYYYMMDDhhmmss.txt ファイルの内容

'''
// /ファイルA.cs
hogehoge
aaaaaaaa
bbbbbbb
'''

'''
// /ファイルB.js
fugafuga
cccccccc
dddddddd
'''

このツールはAIに読ませる用で出力ルールは以下の通りです。

  • 出力ファイル名はルートフォルダと同じ階層にYYYYMMDDhhmmss.txtで生成
  • 各ファイルがコードブロックに囲われる
  • 各ファイルの相対パスがコードブロックの先頭に記載される
  • 空白行は削除
  • 行頭の空白は削除して左に詰める(文字数圧縮
  • 2つ以上連続するタブと空白は1文字に圧縮

【Unity】無料版のDOTweenでTextMeshProを文字送りする

Unity のアセットの DOTween ですが、有償版のでは TextMeshPro に DOText というメソッドがあって簡単に文字送りできる機能があります。が、無料版にはこの機能はありません。ただし単純な文字送り無料版でも簡単に実装できるので実装方法を紹介したいと思います。

確認環境

  • Unity 2022.3.24f1
  • DOTween 1.2.745

仕様とデモ

以下の仕様で実装します。

  • 対象は TextMeshPro
  • N秒に 1文字ずつ表示していく
  • 1文字表示するごとにイベントを受け取れる
  • 表示が完了した後もイベントを受け取れる

実装結果はこんな感じです。

インスペクターから Play ボタンを押すと文字送りが始まって、Console に1文字表示するごとにコールバックが呼び出されています。

実装コード

DOTweenExtensions.cs

TMP_Text に対して拡張メソッドを以下の通り定義します。

// DOTweenExtensions.cs

using System;
using DG.Tweening;
using DG.Tweening.Core;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// <see cref="TweenParamAsset{T}"/> を拡張します。
/// </summary>
public static class DOTweenExtensions
{
    /// <summary>
    /// 文字送りをDOTWeenで実行します。
    /// </summary>
    /// <param name="target">文字送りをする対象</param>
    /// <param name="text">表示するテキスト</param>
    /// <param name="typingSpeed">1文字ごとの表示速度(秒)</param>
    /// <param name="onUpdate">表示が更新されたときに呼び出されるコールバック</param>
    public static Tween DOFeedText(this TMP_Text target, string text, float typingSpeed, Action<FeedTextArgs> onUpdate = null)
    {
        target.text = text;
        target.maxVisibleCharacters = 0;
        int tempPosition = 0;

        var seq = DOTween.Sequence().SetLink(target.gameObject);
        seq.Append(DOTween.To(() => tempPosition, value =>
        {
            tempPosition = value;
            if (target.maxVisibleCharacters != value)
            {
                target.maxVisibleCharacters = value;

                onUpdate?.Invoke(new FeedTextArgs(target, text, value, false));
            }
        }
        , text.Length
        , text.Length * typingSpeed)
            .SetEase(Ease.Linear));
        seq.AppendInterval(typingSpeed); // これより大きい値は使わない
        seq.AppendCallback(() => onUpdate?.Invoke(new FeedTextArgs(target, text, text.Length, true)));
        return seq;
    }
}

Feed.cs

動作確認用のスクリプトです(申し訳ないですが、UI実装が面倒だったのでインスペクターからボタン操作できるように Odin という外部アセットを使用してます)

using DG.Tweening;
using Sirenix.OdinInspector;
using Takap.Utility;
using TMPro;
using UnityEngine;

public class Feed : MonoBehaviour
{
    Tween _tween;

    [Button]
    public void Play()
    {
        _tween.Kill();

        var text = GetComponent<TMP_Text>();
        if (!text) return;
        
        _tween = text.DOFeedText(text.text, 0.07f, OnTextFeed);
    }

    // 1文字表示された毎に発生する
    void OnTextFeed(FeedTextArgs e)
    {
        Debug.Log($"Position={e.Position}, IsCompleted={e.IsCompleted}", this);
    }

    [Button]
    public void Complete()
    {
        // 最後まで表示する
        if (_tween != null && _tween.IsActive())
        {
            _tween.Complete();
        }
    }
}

これで冒頭のデモのような文字送り、1文字表示ごとにイベント発生が確認できます。

【Unity】DOTweenで自作のジャンプを実装する

今回は Unity のフリーのアセットの DOTween を使ってジャンプ動作を実装例の紹介をしたいと思います。

概要

元々 DOTween にはジャンプ動作を実行するための DOJump と DOLocalJump が存在しますが、割とフワッとした動きなので 2Dゲームで硬質なものが地面に落ちて何度かバウンドするみたいな動作にちょっと使いずらい感じだったりします。

そこで今回はこの DOJump のようなオブジェクトをジャンプさせるための自作の DOJump を実装します。

作例

今回実装する DOJump を使用すると以下のような動作となります。

実装コード

JumpParam.cs

ジャンプパラメーターを表すクラスです。

public readonly struct JumpParam
{
    // 相対的な終了位置
    public readonly Vector3 DeltaPos;
    // ジャンプの高さ
    public readonly float Height;
    // 実行時間
    public readonly float Duration;

    public JumpParam(Vector3 delta, float hiegth, float duration)
    {
        DeltaPos = delta;
        Height = hiegth;
        Duration = duration;
    }

    public JumpParam(float delta_x, float delta_y, float hiegth, float duration)
    {
        DeltaPos = new(delta_x, delta_y);
        Height = hiegth;
        Duration = duration;
    }

    public static JumpParam operator /(JumpParam param, float rate)
    {
        return new JumpParam(param.DeltaPos / rate, 
            param.Height / rate, param.Duration / rate);
    }
    public static JumpParam operator *(JumpParam param, float rate)
    {
        return new JumpParam(param.DeltaPos * rate,
            param.Height * rate, param.Duration * rate);
    }

    /// <summary>
    /// 現在のオブジェクトの値と指定した割合に応じた位置を計算します。
    /// </summary>
    public Vector3 CalcPos(float per)
    {
        // 縦方向
        float frac = Mathf.Clamp01(per);
        float y = Height * 4.0f * frac * (1.0f - frac);
        y += DeltaPos.y * per;

        // 横方向
        float x = DeltaPos.x * per;
        float z = DeltaPos.z * per;

        return new Vector3(x, y, z);
    }

    public override string ToString()
    {
        return $"Vec3={DeltaPos}, Height={Height}, Dration={Duration}";
    }
}

TweenParamExtensions.cs

拡張メソッドを定義します。ここに Trasnform.DOJump を実装します。

public static class TweenParamExtensions
{
    // TrasnformにDOJumpを追加する
    public static Tween DOJump(this Transform target, params JumpParam[] jumpParams)
    {
        if (jumpParams == null || jumpParams.Length == 0)
        {
            throw new ArgumentException($"{nameof(jumpParams)} is null or empty.");
        }

        // 開始位置
        Vector3 startPos = target.localPosition;
        // 現在のジャンプパラメータ
        JumpParam cuurentParam = jumpParams[0];
        // 進行度 0~100% (0~1.0f)
        float per = 0;

        var seq = DOTween.Sequence();
        //.SetLink(target)
        //.SetRelative();

        seq.Append(DOTween.To(() => per
        , value =>
        {
            per = value;
            target.localPosition = startPos + cuurentParam.CalcPos(per);
        }
        , 1
        , cuurentParam.Duration)
            .SetEase(Ease.Linear));

        for (int i = 1; i < jumpParams.Length; i++)
        {
            int pp = i;
            float duration = jumpParams[pp].Duration;
            seq.AppendCallback(() =>
            {
                per = 0;
                cuurentParam = jumpParams[pp];
                startPos = target.localPosition;
            });
            seq.Append(DOTween.To(() => per
            , value =>
            {
                per = value;
                target.localPosition = startPos + cuurentParam.CalcPos(per);
            }
            , 1
            , duration)
                .SetEase(Ease.Linear));
        }

        return seq;
    }
}

使い方

使い方は先にジャンプパラメータを作成してそのパラメーターを DOJump に設定します。

// ジャンプパラメータの作成
JumpParam j1 =
    new JumpParam(
        UniRandom.Range2Inv(0.75f, 1.25f), // x: -0.75~1.25 or 0.75~1.25 の範囲
        UniRandom.Range2Inv(0.25f, 0.75f), // y: -0.05~0.75 or 0.25~0.75 の範囲
        Rand.Range(1.0f, 1.2f),
        Rand.Range(0.5f, 0.7f));
        
// ジャンプの実行
transform.DOJump(j1, j1 * 0.5f, j1 * 0.25f)

ただし、このままだと処理が終わっても削除されない、アニメーションの管理ができないなどの問題があるため実際には以下のように DOTween.Sequence に組み込んで使用します。

Transform _transform;
SpriteRenderer _spriteRenderer;
Tween _tween;

void Awake()
{
    _transform = transform;
    this.SetComponent(ref _spriteRenderer);
}

public void Play()
{
    _spriteRenderer.sprite = _sprites.PickupOne();

    _tween.Kill();
    gameObject.SetActive(true);

    // ジャンプパラメータの作成
    JumpParam j1 =
        new JumpParam(
            UniRandom.Range2Inv(0.75f, 1.25f), // x: -0.75~1.25 or 0.75~1.25 の範囲
            UniRandom.Range2Inv(0.25f, 0.75f), // y: -0.05~0.75 or 0.25~0.75 の範囲
            Rand.Range(1.0f, 1.2f),
            Rand.Range(0.5f, 0.7f));

    var seq = DOTween.Sequence();
    // ジャンプの実行
    seq.Append(_transform.DOJump(j1, j1 * 0.5f, j1 * 0.25f));
    // 少し待機した後にオブジェクトを削除
    seq.AppendInterval(0.5f);
    seq.OnComplete(() => Destroy(gameObject));
    // 
    seq.SetLink(this);
    _tween = seq;
}

これでジャンプ実行後に少し待機してオブジェクトを削除される冒頭の作例のような動作になります。

【Unity】GithubにUnityStructShortcutExtensionsを公開しました

Github に UnityStructShortcutExtensions というライブラリを公開しました。

URL

https://github.com/Taka414/UnityStructShortcutExtensions

このライブラリは Unity の Transform 等のゲームオブジェクトがもつ構造体 (positionなど) の操作のショートカットを提供します。

例えばゲームオブジェクトを移動したい時に position を操作する処理を以下のようにショートカットできます。

// ゲームオブジェクトのXを移動したい時
// When you want to move the X of a game object
Vector3 position = transform.localPosition;
position.x += 1;
transform.localPosition = position
// ↓
transform.AddLocalPos(1);

// 画像の透明度を変更したい時
var sr = GetComponent<SpriteRenderer>();
Color c = sr.color;
c.a = 0;
sr.color = c;
// ↓
sr.SetColorA(0);

transform.Translate を使用すればオブジェクトの移動はできると思いますが、作者がだいたいモバイル向けに 2D アプリの実装を良くしているので 3D環境の事はあまり考えずに、transform.Translate を使うよりこういった操作のほうが作業しやすいという考えで機能を提供しています。

色を変えるメソッドは地味にコード量が減るかもしれません。

サポートする型/Supported types

型/Type プロパティ/Property 説明/Description
Transform position 広範囲なサポート/Extensive support
localPosition 広範囲なサポート/Extensive support
localEulerAngles 広範囲なサポート/Extensive support
localScale 広範囲なサポート/Extensive support
RectTransformExtensions sizeDelta 部分的なサポート/Partial support
rect 部分的なサポート/Partial support
anchoredPosition 部分的なサポート/Partial support
pivot 部分的なサポート/Partial support
offsetMax 部分的なサポート/Partial support
offsetMin 部分的なサポート/Partial support
Graphic color 必要最小限のサポート/Minimal support required
SpriteRenderer color 必要最小限のサポート/Minimal support required

よろしくお願いします。

【Unity】RigidBody2DにAddExplosionForceを追加する

RigidBody には AddExplosionForceというメソッドがあって爆発の表現を簡単に作ることができますが、RigidBody2D という2D向けのクラスには AddExplosionForce が無いため同じような処理を拡張メソッドで追加してみました。

追加した処理の実行結果はこんな感じです。

確認環境

  • Unity 2022.3.14f1
  • Windows11

動作はEditor上のみで確認しています。

実装コード

Rigidbody2DExtensions クラスを作成し Rigidbody2D の拡張メソッド AddExplosionForce を以下のように定義します。

// Rigidbody2DExtensions.cs

using UnityEngine;

public static class Rigidbody2DExtensions
{
    /// <summary>
    /// 指定したオブジェクトに爆発する力を加えます。
    /// </summary>
    /// <param name="self"></param>
    /// <param name="explosionForce">爆発の強さ</param>
    /// <param name="explosionPosition">爆発の中心点</param>
    /// <param name="explosionRadius">爆発の半径</param>
    /// <param name="upwardsModifier">追加の上向きの力</param>
    /// <param name="mode">ForceMode2D</param>
    public static void AddExplosionForce(this Rigidbody2D self, float explosionForce,
        in Vector2 explosionPosition, float explosionRadius, 
            float upwardsModifier = 0, ForceMode2D mode = ForceMode2D.Force)
    {
        Vector2 explosionDirection = self.position - explosionPosition;
        float explosionDistance  = explosionDirection.magnitude;

        if (upwardsModifier == 0f)
        {
            explosionDirection /= explosionDistance ;
        }
        else
        {
            explosionDirection.y += upwardsModifier;
            explosionDirection.Normalize();
        }
        
        Vector2 force = Mathf.Lerp(0, explosionForce, 
            1.0f - explosionDistance / explosionRadius) * explosionDirection;
        
        self.AddForce(force, mode);
    }
    public static void AddExplosionForce(this Rigidbody2D self, float explosionForce,
        in Vector3 explosionPosition, float explosionRadius, float upwardsModifier)
    {
        AddExplosionForce(self, explosionForce, 
            explosionPosition, explosionRadius, upwardsModifier, ForceMode2D.Force);
    }
    public static void AddExplosionForce(this Rigidbody2D self, float explosionForce,
        in Vector3 explosionPosition, float explosionRadius)
    {
        AddExplosionForce(self, explosionForce, explosionPosition, explosionRadius, 0);
    }
}

使い方

使い方は以下の通りです。

タッチした位置のワールド座標の半径 2.5ユニット以内の RigitBody2D を持つオブジェクトを列挙してそれぞれに AddExplorsion を指定します。

爆心地ほど力が強く外側に行くにつれて力が弱くなっていきます。与える数値はだいたい RigitBody の AddExplorsion と同じくらいの感覚で使えると思います。

// 与える力
[SerializeField] float _force = 3;
// 爆発半径
[SerializeField] float _radius = 2.5;
// 追加の上向きの力(今回は必要ないのでゼロ)
[SerializeField] float _upwardsModifier = 0;

readonly Collider2D[] _results = new Collider2D[100];
readonly ContactFilter2D _noFilter = new ContactFilter2D().NoFilter();

void Explosion(in Vector3 worldPos)
{
    _particle.transform.SetPos((Vector2)worldPos);
    _particle.Play();

    // 中心点から一定の距離のオブジェクトをすべて取得する
    int cnt = Physics2D.OverlapCircle(worldPos, _radius, _noFilter, _results);
    Log.Trace("cnt=" + cnt);

    // 各オブジェクトに爆発を設定
    for (int i = 0; i < cnt; i++)
    {
        var item = _results[i];
        var c = item.GetComponent<Rigidbody2D>();
        c.AddExplosionForce(_force, worldPos, _radius, 0, ForceMode2D.Impulse);
    }
}

参考

途中まで自分で考えてましたが検索したら普通に実装例があったのでこちらを参考にしています。

stackoverflow.com

【C#】コマンド実行用のバッファリングキューを実装する

バッファーがいっぱいになるまではデータをバッファリングをしながらバックグランドで1つずつ順番にデータを処理して、バッファーがいっぱいになったら空きができるまで待機となるコマンド実行用のデータキューイングクラスの実装例の紹介です。

確認環境

  • .NET Framwork 4.8.1
  • C# 7.3
  • Visual Studio 2022

IDE上のデバッグ実行で動作を確認。

少し古めの環境で作成したので .NET のほぼすべての環境で使用できると思います。

使い方

先に使い方を紹介したいと思います。このクラスの使用方法は、Init で初期化した後、MaxCapacity でバッファー最大値を指定、ProcItem に要素の処理方法を指定してから EnqueueAsync でデータをバッファリングしながら処理するように指示をします。

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

internal class AppMain
{
    static readonly BufferdQueue<int> _queue = new BufferdQueue<int>();

    private static void Main(string[] args)
    {
        // 使う前に初期化する
        _queue.Init();

        // バッファーの最大容量を50に変更
        _queue.MaxCapacity = 50;

        // バッファリングしたデータに対する処理の登録
        _queue.ProcItem += item =>
        {
            Thread.Sleep(10); // ちょっと遅い処理
            Trace.WriteLine($"Proc={item}");
        };

        RunFireAndforget();

        Console.ReadLine();

        // バッファリング処理を終了するときに呼び出す
        _queue.Terminate();
    }

    // 非同期で処理を実行する
    private static void RunFireAndforget()
    {
        Task.Run(async () =>
        {
            for (int i = 0; i < 500; i++)
            {
                // キューが一杯になるまではどんどんバッファリングされていく
                // いっぱいになると空きができるまで待機になる
                await _queue.EnqueueAsync(i);
            }
        });
    }
}

実装コード

実装コードは以下の通りです。2つの待機ハンドルを使ってバックグラウンドのスレッドの進行制御とユーザーがデータを追加したときの進行制御を行っています。

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

// ユーザー側の処理速度のほうがバックエンドの処理速度より速いときに
// いちいちメソッドをブロックキングで待機すると
// 全体の処理速度が低下するのをバッファリングすることで緩和する事を目的としたクラス
//
// メモ:
// バッファーがいっぱいになると同期実行に切り替わって実行速度が低下するので
// 処理に局所性があるもに対して十分なキャパシティを確保して使用すること
public class BufferdQueue<T>
{
    readonly Queue<T> _queue = new Queue<T>();
    // キューの排他アクセス用
    readonly object _lockObject = new object();
    // バックエンドのスレッド用の待機ハンドル
    readonly EventWaitHandle _ProcActionHandle 
        = new EventWaitHandle(false, EventResetMode.ManualReset);
    // フロントのキュー用の待機ハンドル
    readonly EventWaitHandle _EnqueueHandle
        = new EventWaitHandle(true, EventResetMode.ManualReset);

    // Queueに蓄積できる最大容量
    int _maxCapacity = 1000;
    // Queueがバッファリングを再開する閾値
    int _threthold;
    // 終了要求を受け付けたかどうか
    // true: 受け付けた / false: それ以外
    bool _isTerminate;
    // バックエンドのバッファを処理するスレッド
    Thread _procThread;
    // 中断を受け付けたかどうかのフラグ
    // true: 受け付けた / false: それ以外
    bool _isAbort;

    // キューの最大容量
    public int MaxCapacity
    {
        get => _maxCapacity;
        set
        {
            if (_maxCapacity < 16) // あまりに小さい数値は受け付けない
            {
                throw new InvalidOperationException("Less than 16 cannot be specified.");
            }
            lock (_lockObject)
            {
                _threthold = value - 1;
                _maxCapacity = value;
            }
        }
    }

    // キューから取り出したデータに対する処理を行う時に発生する
    public event Action<T> ProcItem;

    // Constructors - - - - - - - - - -

    public BufferdQueue()
    {
        _threthold = _maxCapacity - 1;
    }

    // Public Methods - - - - - - - - - -

    // 使う前に呼び出すこと
    public void Init()
    {
        _procThread = new Thread(new ThreadStart(ProcAction))
        {
            IsBackground = true
        };
        _procThread.Start();
    }

    // バッファーに余裕があるときはitemをキューイングして即座に終了
    // or
    // バッファーがいっぱいならバッファーに空きが出るまでブロックして待機する
    public void Enqueue(T item)
    {
        CheckInitializedIfThrowException();

        lock (_lockObject)
        {
            _queue.Enqueue(item);
            _ProcActionHandle.Set();
            if (_queue.Count > MaxCapacity)
            {
                _EnqueueHandle.Reset();
                //Trace.WriteLine("Max capacity1");
            }
        }
        _EnqueueHandle.WaitOne(); // バックグラウンドで処理がはけるまで待機
    }

    public async Task EnqueueAsync(T item)
    {
        CheckInitializedIfThrowException();

        await Task.Run(() =>
        {
            lock (_lockObject)
            {
                _queue.Enqueue(item);
                _ProcActionHandle.Set();
                if (_queue.Count >= MaxCapacity)
                {
                    _EnqueueHandle.Reset();
                }
            }
            _EnqueueHandle.WaitOne(); // バックグラウンドで処理がはけるまで待機
        });
    }

    public void Terminate()
    {
        CheckInitializedIfThrowException();
        _isAbort = true;
        _EnqueueHandle.Set();

        while (!_isTerminate)
        {
            Thread.Sleep(1);
        }
    }

    // Private Methods - - - - - - - - - -

    private void CheckInitializedIfThrowException()
    {
        if (_procThread == null)
        {
            throw new InvalidOperationException("Object not initialized.");
        }
    }

    private void ProcAction()
    {
        try
        {
            //Trace.WriteLine("ProcAction Start");

            while (true)
            {
                _ProcActionHandle.WaitOne();

                if (_isAbort)
                {
                    lock (_lockObject)
                    {
                        _queue.Clear();
                    }
                    break;
                }

                T item;
                lock (_lockObject)
                {
                    if (_queue.Count == 0)
                    {
                        _ProcActionHandle.Reset();
                        continue;
                    }
                    else
                    {
                        item = _queue.Dequeue();
                    }

                    if (_queue.Count <= _threthold)
                    {
                        _EnqueueHandle.Set();
                        //Trace.WriteLine($"Free capacity={_queue.Count}");
                    }
                }
                
                try
                {
                    ProcItem?.Invoke(item);
                }
                catch (Exception) { }
            }
            _queue.Clear(); // abortで抜けたら要素は全て解放
        }
        finally
        {
            _isTerminate = true;
            //Trace.WriteLine("ProcAction End");
        }
    }
}

【C#】一定時間経過すると削除されるリストの実装

何の役に立つかはわかりませんが、一定時間経過したら削除されるリストを実装しててみました。

確認環境

  • .NET 6
  • VisualStudio 2022

実装コード

規定では Add(...) した後に、5秒以内に TryGetItemAndRemove() でデータを取り出されなければバックグラウンドのタイマー処理でデータが消去されます。

自動で削除された要素は AutoRemoved イベントで通知されるので後処理が必要ならイベントを購読します。

// 一定時間経過するとデータが消去されるリスト
public class ExpirationTimeItemHolder<TKey, TValue> : IDisposable  
    where TKey : IEquatable<TKey> 
    where TValue : class
{
    readonly System.Timers.Timer _timer;

    readonly List<ItemBug> _list = new List<ItemBug>();

    readonly object _lockObj = new object();
    
    private bool _isDisposed;

    // データの保持期間
    public TimeSpan HoldTime { get; set; } = TimeSpan.FromSeconds(5);

    // 保持期間が過ぎて自動で要素が削除された時に発生します
    public event Action<TValue> AutoRemoved;

    public ExpirationTimeItemHolder()
    {
        _timer = new System.Timers.Timer(100);
        _timer.Elapsed += OnTimerElapsed;
    }

    ~ExpirationTimeItemHolder() => Dispose(false);

    // 一定時間経過したら削除するタイマーハンドラー
    private void OnTimerElapsed(object sender, System.Timers.ElapsedEventArgs e)
    {
        var removeList = new List<ItemBug>();
        DateTime now = DateTime.Now;
        lock (_lockObj)
        {
            for (int i = 0; i < _list.Count; i++)
            {
                var item = _list[i];
                TimeSpan diff = now - item.Time;
                if (diff > HoldTime)
                {
                    removeList.Add(item);
                    Trace.WriteLine($"[diff] {diff.TotalMilliseconds}ms");
                }
            }

            for (int i = 0; i < removeList.Count; i++)
            {
                var removeItem = removeList[i];
                _list.Remove(removeItem);
                AutoRemoved?.Invoke(removeItem.Value);
                Trace.WriteLine($"[timer remove] {removeItem.Key}");
            }

            if (_list.Count == 0)
            {
                _timer.Stop();
                Trace.WriteLine("timer stop");
            }
        }
    }

    // データを追加する
    public void Add(TKey key, TValue value)
    {
        lock (_lockObj)
        {
            _list.Add(new ItemBug(key, DateTime.Now, value));
            Trace.WriteLine($"[add] {key}");
            if (!_timer.Enabled)
            {
                _timer.Start();
            }
        }
    }

    // データを取得してリストから削除する
    public bool TryGetItemAndRemove(TKey key, out TValue resultItem)
    {
        lock (_lockObj)
        {
            resultItem = default;
            ItemBug tempBug = null;

            for (int i = 0; i < _list.Count; i++)
            {
                var item = _list[i];
                if (key.Equals(item.Key))
                {
                    tempBug = item;
                    break;
                }
            }

            if (tempBug != null)
            {
                _list.Remove(tempBug);
                Trace.WriteLine($"[pop] {tempBug.Key}");
            }
            
            if (tempBug != null) resultItem = tempBug.Value;
            return resultItem != null;
        }
    }

    // InnerTypes

    private class ItemBug
    {
        public readonly TKey Key;
        public readonly DateTime Time;
        public readonly TValue Value;
        public ItemBug(TKey key, DateTime time, TValue value)
        {
            Key = key;
            Time = time;
            Value = value;
        }
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_isDisposed)
        {
            if (disposing)
            {
                lock (_lockObj)
                {
                    using (_timer) { }
                    AutoRemoved = null;
                }
            }
            _isDisposed = true;
        }
    }
}

【C#】Fisher-Yatesを使って配列/リストをシャッフルする

Fisher-Yates(フィッシャーイェーツ)というアルゴリズムを使って配列やリストを並び替えたいと思います。

アルゴリズムの考え方ですが N 個の要素数があったとして

  • 一番最後の要素 (N) をそれ以外の前方の要素とランダムに交換する
  • 一番最後から -1個目を前方の要素とランダムに交換する
  • -2個目を前方の要素とランダムに交換する
  • N-1個目まで繰り返す

と、後ろから前方に範囲を狭めながら要素を好感していき処理が終わると配列の内容が全てシャッフルされるアルゴリズムです。配列に対してこの処理を実行すると内容が不可逆にシャッフルされるのでそれだけ注意しましょう。

処理回数は O(N) だと思うんので計算量はかなり少ないほうだと思います。

実装内容

通常の .NET 向けの処理と Unity 向けの処理を #if で切り替えています。

両環境このままコピペすれば動くはずです。

public static class RandomUtil
{
    //
    // Unity 向けの実装
    // 

#if UNITY_5_3_OR_NEWER

    /// <summary>
    /// Fisher-Yates(フィッシャー・イェーツ) アルゴリズムでコレクションをシャッフルします。
    /// </summary>
    /// <remarks>
    /// 指定した配列の順序が不可逆に変更されます。
    /// </remarks>
    public static void Shuffle<T>(IList<T> collection)
    {
        int n = collection.Count;
        for (int i = n - 1; i > 0; i--)
        {
            int j = UnityEngine.Random.Range(0, i + 1);
            T tmp = collection[i];
            collection[i] = collection[j];
            collection[j] = tmp;
        }
    }

    /// <summary>
    /// Fisher-Yates(フィッシャー・イェーツ) アルゴリズムでコレクションをシャッフルします。
    /// </summary>
    /// <remarks>
    /// 指定した配列の順序が不可逆に変更されます。
    /// </remarks>
    public static void Shiffle<T>(T[] array)
    {
        int n = array.Length;
        for (int i = n - 1; i > 0; i--)
        {
            int j = UnityEngine.Random.Range(0, i + 1);
            T tmp = array[i];
            array[i] = array[j];
            array[j] = tmp;
        }
    }
#else
    //
    // .NET 向けの実装
    // 

    static readonly Random _rand = new();

    /// <summary>
    /// Fisher-Yates(フィッシャー・イェーツ) アルゴリズムでコレクションをシャッフルします。
    /// </summary>
    /// <remarks>
    /// 指定した配列の順序が不可逆に変更されます。
    /// </remarks>
    public static void Shiffle<T>(IList<T> collection)
    {
        int n = collection.Count;
        for (int i = n - 1; i > 0; i--)
        {
            int j = _rand.Next(0, i + 1);
            T tmp = collection[i];
            collection[i] = collection[j];
            collection[j] = tmp;
        }
    }

    /// <summary>
    /// Fisher-Yates(フィッシャー・イェーツ) アルゴリズムでコレクションをシャッフルします。
    /// </summary>
    /// <remarks>
    /// 指定した配列の順序が不可逆に変更されます。
    /// </remarks>
    public static void Shiffle<T>(T[] array)
    {
        int n = array.Length;
        for (int i = n - 1; i > 0; i--)
        {
            int j = _rand.Next(0, i + 1);
            T tmp = array[i];
            array[i] = array[j];
            array[j] = tmp;
        }
    }
}
#endif

ちなみに seed 値はできないので再現性は考慮していません。

【C#/Unity】重み付き抽選機能を実装する

よくある重み付きの抽選機能の実装例の紹介です。

重み付き抽選とは要素ごとに選ばれる確率が違う抽選方法です。

例えば以下のように各々確率が違うものをランダムで選びます。

  • Aは50%
  • Bは25%
  • Cは20%
  • Dは5%

確認環境

  • Unity 2022.3.5f1
  • VisualStudio 2022

Editor 上のみで動作を確認

使い方

まずは使い方です

// Sample.cs

// フルーツを重み付き抽選で選ぶ
public void Select()
{
    // 抽選確立のリストを作成する
    List<LotteryItem<Fruit>> items = new();
    items.Add(new LotteryItem<Fruit>(Fruit.Apple, 50.5f));    // 50.5%
    items.Add(new LotteryItem<Fruit>(Fruit.Banana, 25.5f));   // 25.5%
    items.Add(new LotteryItem<Fruit>(Fruit.Orange, 15.0f));   // 15.0%
    items.Add(new LotteryItem<Fruit>(Fruit.Grape, 5.0f));     // 5.0%
    items.Add(new LotteryItem<Fruit>(Fruit.Pineapple, 4.0f)); // 4.0%

    // 抽選する
    Fruit result = RandomUtil.SelectOne(items);
}

public enum Fruit 
{
    Apple, Banana, Orange, Grape, Pineapple
}

実装コード

合計で 100になるようにサンプルを作りましたが重みは100でなくても大丈夫です。100より多くても少なくてもその割合で要素が抽選されます。

// RandomUtil.cs

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

public static class RandomUtil
{
    // 重み付き抽選を行う(配列用)
    public static T SelectOne<T>(LotteryItem<T>[] list)
    {
        float total = 0;
        for (int i = 0; i < list.Length; i++)
        {
            total += list[i].Weight;
        }

        float value = Random.Range(0, total);
        for (int i = 0; i < list.Length; i++)
        {
            value -= list[i].Weight;
            if (value <= 0) return list[i].Value;
        }

        return default;
    }

    // 重み付き抽選を行う(リスト用:ちょっと動作が遅い)
    public static T SelectOne<T>(List<LotteryItem<T>> list)
    {
        float total = 0;
        for (int i = 0; i < list.Count; i++)
        {
            total += list[i].Weight;
        }

        float value = Random.Range(0, total);
        for (int i = 0; i < list.Count; i++)
        {
            value -= list[i].Weight;
            if (value <= 0) return list[i].Value;
        }

        return default;
    }
}

[System.Serializable]
public readonly struct LotteryItem<T>
{
    public readonly T Value;
    public readonly float Weight;

    public LotteryItem(T value, float weight)
    {
        Value = value;
        Weight = weight;
    }
}

余談ですが、この実装は seed の指定がないため起動ごとに適当な seed が使用されるので再現性が無いです。もしかするとデバッグ時に困るケースがあります。

もし、必要なら UnityEngine.Random.state をどこかにもってそれを指定する仕組みが必要です。その場合 static メソッドではなくクラスにしてインスタンスの中に Random.State を持つなど対応しましょう。

【Unity】enumの途中にシンボルを追加しても値が変わらないようにする

Unity で enum をインスペクターで使用しているときに、enum の途中にメンバーを追加すると、追加したメンバーより後ろの値を設定した場合値がずれる問題が発生します。

例えば以下のように Fruit 列挙型があったとします。

// Fruit.cs

// もともとの定義
public enum Fruit
{
    Apple,
    Banana,
    Orange,
    Grape
}

そして、インスペクターで Grape を設定している状態とし、そこに以下のように中ほどに Strawberry を追加します。

public enum Fruit
{
    Apple,
    Banana,
    Orange,
    Strawberry, // ★追加
    Grape
}

この変更を行った場合、インスペクターで先ほどまで Grape を選択していた箇所が Strawberry のように変わってしまいます。中身が数字で enum を保存しているのためこのような現象が起きます。

この問題は以下のルールを守れば回避できます。

  • 値を途中に追加せず末尾に追加する
  • 一度定義したら値の順序を変更しない

もしくは

  • enum 個々に値をあらかじめ振っておく
    • 各シンボルランダムな値をそれぞれに振って数値に規則性を持たせない

ただし毎回上記ルールを守る訳にも行かない場合があるため今回はこの回避策を考えてみたいと思います。

確認環境

  • Unity 2022.3.5f1

Editor上のみで動作確認しています。

仕様の説明

今回の実装の要件は以下の通りです

  • (1) enumのメンバーを途中に追加しても値が変わらない
  • (2) enumのメンバーの順序を変えても値が変わらない
  • (3) enumのメンバーが持つ番号を手動で変えても値が変わらない
  • (4) enumのメンバー名が変わっても順序が変わらなければ値を引き継げる

制限事項は以下の通りです。

  • (1) enumのメンバーの順序の変更と同時にメンバー名変更が同時に発生した場合は対応できない
  • (2) 途中で(以降で紹介する)属性を外したときにenumへの変換は手作業が必要
  • (3) 途中で(以降で紹介する)属性を外してstring → enumに型を変更すると値が消滅する
  • (4) 処理の効率性(紹介する実装によるゲーム処理速度の低下)は考慮しない

内部仕様は以下の通りです。

  • enum の値は string として保存し "0:Apple" のように、${enumが持つ数値}:${シンボル名} として unity へシリアライズ・デシリアライズを行う。

使用する側のスクリプトに何度も同じ処理を書かないように、enum を使用している箇所が増えても実装を修正不要としてメタプログラミングと PropertyDrawer を使って実装していたいと思います。

また、申し訳ありませんがこの実装例が実際のプロジェクトで実用に耐えられるか十全に検討できていないためあらかじめご了承ください。

使い方

まずは使い方の紹介です。

enum を使用したいスクリプトに以下のように string のフィールドを定義して CustomEnum(enumの型情報) というように属性を追加します。そしてその string から任意の enum を取得するためのプロパティを作成します。

// MyScript1.cs
using UnityEngine;

// ★使用例(1)
// 毎回値を変換する
//  → シンプルに使えるが処理効率がかなり悪い
public class MyScript1 : MonoBehaviour
{
    [SerializeField] [CustomEnum(typeof(Fruit))] string _type;
    public Fruit Value => SerializeUtil.Restore<Fruit>(_type);
}

表示は以下の通り Fruit のメンバーが表示されます。

使用例その 2です。こっちはパフォーマンス向上のため ISerializationCallbackReceiver でオブジェクトがデシリアライズされたときに enum の値を復元します。頻繁にプロパティにアクセスする場合はこっちのほうがいいかもしれません。

// MyScript2.cs
using UnityEngine;

// ★使用例(2)
// シリアライズされたときに変換する
//  → 処理効率はまぁまぁ良いがこの属性を使用するスクリプトに毎回処理を書かないといけない
public class MyScript2 : MonoBehaviour, ISerializationCallbackReceiver
{
    [SerializeField] [CustomEnum(typeof(Fruit))] string _type;
    public Fruit Value { get; private set; }

    // 何もしない
    public void OnBeforeSerialize() { }
    // stringのフィールドの値をenumに復元する
    public void OnAfterDeserialize()
    {
        Value = (Fruit)SerializeUtil.Restore(typeof(Fruit), _type);
    }
}

実装コード

CustomEnumAttributeクラス

enum を使いたいフィールドに付与する目印の属性の定義です。型情報をコンストラクタの引数に取る属性として実装しています。

using System;
using UnityEngine;

// カスタムEnum型を扱うためのクラス
public class CustomEnumAttribute : PropertyAttribute
{
    public readonly Type Type;
    
    public CustomEnumAttribute(Type enumType) => Type = enumType;

    // 属性に指定されている型がenumかどうかを取得する
    // true: enumである / false: enumでない
    public bool IsEnum => Type != null && Type.IsEnum;
}

EnumEditorクラス

Editor拡張のスクリプトです。

CustomEnumAttribute を持つフィールドに対し PropertyDrawer で表示のカスタマイズと、Unity へのシリアライズ・デシリアライズ方法を定義します。

#if UNITY_EDITOR

using System;
using UnityEditor;
using UnityEngine;

// stringのフィールドをenumリスト表示にするEditor拡張
[CustomPropertyDrawer(typeof(CustomEnumAttribute))]
public class EnumEditor : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent content)
    {
        var att = attribute as CustomEnumAttribute;
        if (property.propertyType != SerializedPropertyType.String)
        {
            EditorGUI.PropertyField(position, property);
            Debug.LogWarning($"stringメンバー以外に{nameof(CustomEnumAttribute)}" +
                $"属性を指定しないでください。" +
                $"オブジェクト={property.serializedObject.targetObject}, 変数名={property.name}");
            return;
        }
        if (!att.IsEnum)
        {
            EditorGUI.PropertyField(position, property);
            Debug.LogWarning($"型情報にenumが指定されていません。" +
                $"オブジェクト={property.serializedObject.targetObject}, 変数名={property.name}");
            return;
        }

        // 文字列で保存されている値をenumに復元する
        Enum value = SerializeUtil.Restore(att.Type, property.stringValue);
        if (value == null)
        {
            value = (Enum)Enum.GetValues(att.Type).GetValue(0);
        }

        var label = new GUIContent(property.displayName);
        Enum selected = null; // 選択結果

#if ODIN_INSPECTOR
        // OdinInspector を使用しているときは拡張検索ボックスを表示する処理
        string typeName =
            $"Sirenix.OdinInspector.Editor.EnumSelector`1" +
            $"[[{att.Type.AssemblyQualifiedName}]], " +
            $"Sirenix.OdinInspector.Editor, Culture=neutral, PublicKeyToken=null";
        Type t = Type.GetType(typeName);

        var m = t.GetMethod("DrawEnumField", new[] { 
            typeof(Rect), typeof(GUIContent), 
            att.Type, typeof(GUIStyle) });
        selected = m.Invoke(null, new object[] { position, label, value, null }) as Enum;
#else
        selected = EditorGUI.EnumPopup(position, property.displayName, value);
#endif
        property.stringValue = SerializeUtil.Convert(selected);
    }
}
#endif

EditorGUI.EnumPopup で enum のメンバーをリストアップして property.stringValue に設定することで string フィールドを enum のような選択式のドロップダウンに変換しています。

SerializeUtilクラス

文字列とenumを相互変換するためのクラスです。

using System;
using UnityEngine;

// 保存した文字列 ⇔ enum の相互変換するためのユーティリティ
public static class SerializeUtil
{
    // enum → 「値:シンボル名」形式に変換する
    public static string Convert(Enum value)
    {
        return string.Format("{0}:{1}", (int)(object)value, value.ToString());
    }

    // 「値:シンボル名」形式 → enumに変換する
    public static T Restore<T>(string value) => (T)(object)Restore(typeof(T), value);

    // 「値:シンボル名」形式 → enumに変換する
    public static Enum Restore(Type enumType, string value)
    {
        if (string.IsNullOrEmpty(value))
        {
            return default; // 読み取れない場合規定値を返す
        }

        // 列挙型の値と名前を取得して値を復元する処理
        string valueText = value;

        // 途中からこの方式に切り替えた時に数値しか入ってない時の対応
        string[] splitTexts = valueText.Split(':');
        if (splitTexts.Length == 1 && TryParseAll(enumType, splitTexts[0], out Enum result1))
        {
            return result1;
        }

        // 2つに分割できる時はシンボル名を優先で復元する
        if (Enum.TryParse(enumType, splitTexts[1], out object result2))
        {
            return (Enum)result2;
        }

        // 後ろのシンボルからenumが復元できなかったら前の数字が一致するものを選択する
        if (TryParseInt(enumType, splitTexts[0], out Enum result3))
        {
            return result3;
        }

        // シンボル名と数値を同時に変更した時、シンボルを削除して戻せない時などにくる
        Debug.LogWarningFormat($"{enumType.Name} の復元に失敗しました。 Source={value}");
        return default;
    }

    // 整数文字列 or enum文字列 → enum の変換
    private static bool TryParseAll(Type enumType, string value, out Enum result)
    {
        if (Enum.TryParse(enumType, value, out object tmpValue1))
        {
            result = (Enum)tmpValue1;
            return true;
        }
        else if (TryParseInt(enumType, value, out Enum tmpValue2))
        {
            result = tmpValue2;
            return true;
        }
        result = default; // どうやっても変換できない
        return false;
    }

    // 整数文字列("0") → enum の変換
    private static bool TryParseInt(Type enumType, string value, out Enum result)
    {
        if (int.TryParse(value, out int tmpInt) && Enum.IsDefined(enumType, tmpInt))
        {
            result = (Enum)Enum.Parse(enumType, tmpInt.ToString());
            return true;
        }
        result = default; // 定義されてない数値の指定は変化できない扱い
        return false;
    }
}

全部コピペして使用方法の通りコピペすると値を途中に追加しても問題が起きない enum が使用できるようになります。

参考サイト

値の保存方法と復元方法は以下を参考にしました。

https://www.urablog.xyz/entry/2018/05/21/081702

関連リンク

takap-tech.com

takap-tech.com

takap-tech.com

【Unity】ExcelのデータをScriptableObjectに取り込む

Excel で管理しているゲームデータを自作のプログラムを使って ScriptableObject にインポートする方法を紹介したいと思います。

これ系は既にライブラリとかアセットが色々と配布されていて、それらを使用したほうが時間の節約になると思いますが、今回はを全て自作してみたいと思います。

ツール類は全て C# で作成し、Excel VBA や Python は使用しません。

確認環境

  • Windows11 + VisualStudio2022
  • Unity 2022.3.5f1
  • .NET 6(コンソールアプリ作成用
    • ExcelDataReader 3.6.0
    • ExcelDataReader.DataSet 3.6.0
    • Newtonsoft.Json 13.0.3

エディター上で動作を確認しています。

Excelからデータを取り込む流れ

Excel データを Unity に取り込む手順は以下の通りです。

  1. エクセルでデータを作成する
  2. Unity上でデータ構造を定義する
  3. エクセルを ExcelDataReader で JSON ファイルに変換する
  4. Unity上からJSONファイルを読み込む

実装手順

エクセルでデータを作成する

まず Excel でデータを作成します。見た目はこんな感じで Excel を作成しておきます。

保存先 D:\Master\ItemData.json

キー アイテム名 重さ 説明
Item01 アイテム1 0.01 1 1 アイテム1です
Item02 アイテム2 0.02 1 2 アイテム2です
Item03 アイテム3 0.03 2 1 アイテム3です
Item04 アイテム4 0.04 2 2 アイテム4です
Item05 アイテム5 0.05 1 1 アイテム5です
Item06 アイテム6 0.06 1 2 アイテム6です
Item07 アイテム7 0.07 2 1 アイテム7です
Item08 アイテム8 0.08 2 2 アイテム8です
Item09 アイテム9 0.09 1 1 アイテム9です

Unity上でデータ構造を定義する

Unity 上にエクセルデータに対応するデータ構造を ScriptableObject で実装します。

これをマスターデータとしてプロジェクトにアセットとして新規作成します。

// ItemMaster.cs

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

namespace Takap
{
    // マスターデータ保持用のオブジェクト
    [CreateAssetMenu(menuName = "MyData/ItemMaster")]
    public class ItemMaster : ScriptableObject
    {
        [SerializeField] List<ItemData> _itemList;
        public ReadOnlyCollection<ItemData> ItemList
            => new ReadOnlyCollection<ItemData>(_itemList);
    }
}

// ItemData.cs

using System;
using UnityEngine;

namespace Takap
{
    [Serializable]
    public class ItemData
    {
        [SerializeField] string _key = "";
        [SerializeField] string _name = "";
        [SerializeField] float _weight;
        [SerializeField] int _high;
        [SerializeField] int _width;
        [SerializeField] string _text;

        public string Key => _key;
        public string Name => _name;
        public float Weight => _weight;
        public int High => _high;
        public int Width => _width;
        public string Text => _text;

        public ItemData(string key, string name, float weight, int high, int width, string text)
        {
            _key = key;
            _name = name;
            _weight = weight;
            _high = high;
            _width = width;
            _text = text;
        }
    }
}

上記コードを追加するとプロジェクトのコンテキストメニューに以下のように MyData > ItemMaster という項目が追加されるのでそれを選択して、「ItemMaster」という名前でアセットを作成します。

作成すると以下のような状態になっていると思います。

ExcelをExcelDataReaderでJSONファイルに変換する

Excel を JSON に変換するために外部にコンソールアプリを作成します。このプログラムに Excel をドラッグ & ドロップするとJSONが指定したパスに出力されるようにします。

VisualStudio 上からコンソールアプリのプロジェクトを新規作成し .NET 6 を選択してコンソールアプリを新規作成します。

NuGet から以下の 3つをインストールしておきます。

  • ExcelDataReader
  • ExcelDataReader.DataSet
  • Newtonsoft.Json

そして、先ほど Unity 上に実装した ItemData.cs の内容をコンソールアプリ内にコピーし、コンソールアプリを以下のように実装します。

注意:1点制限があって ItemData クラスの読み書きしたい対象に JsonProperty 属性を追加しています。

using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text;
using ExcelDataReader;
using Newtonsoft.Json;

namespace MasterDataToJson
{
    internal class Program
    {
        static readonly ExcelToJson _excelToJson = new();

        static void Main(string[] args)
        {
            // パスを引数で受け取る
            string excelFilePath = args[0];
            _excelToJson.ExecExcelToJson(excelFilePath);
        }
    }

    // ExcelをJSONに変換するためのクラス
    public class ExcelToJson
    {
        static readonly JsonSerializer _jsonSerializer = new();

        public void ExecExcelToJson(string filePath)
        {
            using FileStream stream = 
                    File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

            // 拡張子がxlsx(新しいほう)のファイルの読み取りを指定
            // 古いxlsはCreateBinaryReaderメソッドを使う
            using IExcelDataReader reader = 
                    ExcelReaderFactory.CreateOpenXmlReader(stream, JpEncode());

            DataTable sheet = reader.AsDataSet().Tables[0];
            ItemData[] data = ReadContents(sheet).ToArray(); // オブジェクトに変換
            foreach (var item in data)
            {
                Console.WriteLine($"{item.Key}");
            }

            using TextWriter tw = new StreamWriter(GetOutputPath(sheet), false, Encoding.UTF8);
            _jsonSerializer.Serialize(tw, data);
        }

        // エンコードの設定を追加
        private static ExcelReaderConfiguration JpEncode()
        {
            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
            return new() { FallbackEncoding = Encoding.GetEncoding("Shift_JIS") };
        }

        // 出力先パスの取得
        private string GetOutputPath(DataTable sheet) => sheet.Rows[0][1].ToString();

        // Excelのシートの内容をC#のオブジェクトに変換する
        private IEnumerable<ItemData> ReadContents(DataTable sheet)
        {
            for (int i = 3; i < sheet.Rows.Count; i++) // ヘッダーは読み飛ばす
            {
                DataRow row = sheet.Rows[i];
                yield return new ItemData(
                    row[0].ToString(),
                    row[1].ToString(),
                    float.Parse(row[2].ToString()),
                    int.Parse(row[3].ToString()),
                    int.Parse(row[4].ToString()),
                    row[5].ToString()
                    );
                    
            }
        }
    }

    // コピーしてきたクラス
    [Serializable]
    public class ItemData
    {
        // ★読みたい対象にJsonPropertyを追加する
        [SerializeField, JsonProperty] string _key = "";
        [SerializeField, JsonProperty] string _name = "";
        [SerializeField, JsonProperty] float _weight;
        [SerializeField, JsonProperty] int _high;
        [SerializeField, JsonProperty] int _width;
        [SerializeField, JsonProperty] string _text;

        public string Key => _key;
        public string Name => _name;
        public float Weight => _weight;
        public int High => _high;
        public int Width => _width;
        public string Text => _text;

        public ItemData(string key, string name, float weight, int high, int width, string text)
        {
            _key = key;
            _name = name;
            _weight = weight;
            _high = high;
            _width = width;
            _text = text;
        }
    }

    // Unity外だと存在しないので名前だけ追加しておく
    public class SerializeFieldAttribute : Attribute { }
}

このプログラムをコンパイルして、Excel ファイルをこのプログラムにドラッグ & ドロップすると Excel 内で指定した位置にファイルが出力されます。

中身は整形するとこんな感じになってます(余計な要素も出力されていますが参照しないのでこのまま使用します)

Unity上からJSONファイルを読み込む

最後に JSON をUnity 上から読み取るための処理を実装します。

最初に作成した ScriptableObject をまず以下のように修正します。

// ItemMaster.cs

using System.Collections.Generic;
using System.Collections.ObjectModel;
using Takap.Utility;
using UnityEngine;

namespace Takap
{
    [CreateAssetMenu(menuName = "MyData/ItemMaster")]
    public class ItemMaster : ScriptableObject
    {
        [SerializeField] List<ItemData> _itemList;
        public ReadOnlyCollection<ItemData> ItemList
            => new ReadOnlyCollection<ItemData>(_itemList);

        // ★★★追加ここから --->
        [SerializeField] string _filePath;
        public void ReadJson()
        {
            if (string.IsNullOrEmpty(_filePath) || !System.IO.File.Exists(_filePath))
            {
                Debug.Log("JSONファイルが見つかりません。");
                return;
            }
            // 中身を読み取って処理用のメソッドに渡す
            string json = System.IO.File.ReadAllText(_filePath);
            ReadJson(json);
        }

        // JSON文字列を受け取ってScriptableObjectを更新する
        public void ReadJson(string json)
        {
            ItemData[] items = JsonHelper.FromJson<ItemData>(json);
            _itemList.Clear();
            _itemList.AddRange(items);

#if UNITY_EDITOR
            UnityEditor.EditorUtility.SetDirty(this);
            UnityEditor.AssetDatabase.SaveAssets();
#endif
            Debug.Log("インポートが完了しました。");
        }
        // <--- ★★★追加ここまで
    }
}

JsonHelper.FromJson はメソッドですがルート要素が配列のデータを扱うためのライブラリで以下のリンクから取得して記事中のコードをプロジェクトに取り込んでおいてください。

takap-tech.com

次に、ScriptableObject に Editor 拡張を追加して読み取りボタンを追加して読み取りを実行します。

// ItemMasterEditor.cs

#if UNITY_EDITOR

using UnityEditor;
using UnityEngine;

namespace Takap
{
    [CustomEditor(typeof(ItemMaster))]
    public class ItemMasterEditor : UnityEditor.Editor
    {
        public override void OnInspectorGUI()
        {
            // 通常のインスペクターを表示
            DrawDefaultInspector();

            // 対象のクラスのインスタンスを取得
            ItemMaster targetClass = (ItemMaster)target;

            // ボタンを追加
            if (GUILayout.Button("JSONの読み取り"))
            {
                targetClass.ReadJson();
            }
        }
    }
}
#endif

れこをビルドすると以下のようにJSONの読み取り実行ボタンと FilePath が表示されるので FilePath に Excel に入力したファイルパスを指定します。

これで実行を押すとコンソールに「インポートが完了しました」というメッセージが表示されて、ItemMaster が以下の通り更新されていると思います。

これで Excel のデータを ScriptableObject に取り込むことができました。

この ItemMaster をインスペクターから GameObject に設定するなどしてゲーム中で使用することができます。

記事中の実装はサンプルなので機能がチープです。各自で使いやすいように機能は追加していきましょう。

最後に

この記事で紹介した内容は基本的な処理の流れのサンプルとなります。アイデア次第でもっと便利にすることもできると思います。

例えば、Excel も Unity 内に格納して処理を Unity 内で完結するようにしたり、Excel の更新を監視し、変更があったら JSON ファイルを生成してGit に Push まで自動でする事もできると思います。

JSON さえあればそのデータを ScriptableObject に取り込めるのでデータの管理は JSON にエクスポートさえできれば Excel 以外でも扱えます。Editor の実行前に変更があったらマスターを更新して実行なども考えられると思います。

全て自作は、配布されているツールの他人が考えた仕組みを覚える必要が無くて、ツールでは対応できないワークフローやデータ形式も柔軟に対応することができるのがよい所だと思います。

各自で機能を追加してどんどん便利にしていきましょう。