一記一遊ブログ

大学生活の中で起こった日々の出来事を日記形式で書き溜めていくブログ。現在2回生。

【ずぼらな人向け】Unityで作り終えたゲームに雑にローカライズを後付けする

これは、KMC Advent Calender 2025の11日目の記事です。

 

こんにちは。KMC48代のcreeamsodaです。前回は、sashiさんのFortran と C++ を Visual Studio で連携する でした。研究室に入るとレガシーと戦う必要もあるのかと思うと少し怖くなります。研究室のことを何も考えていない現状はちょっとまずいかも。

zenn.dev

 

次回(!?)は、noraさんのコード大紹介 - アドヴェントカレンダァァァァァァァ(2025) でした。DTM良いですよね。今まではチャンバラの活動日と被っていたこともあって触れてこなかったのですが、来年度からはDTM練習会が土曜日に移る計画があり、来年はDTM元年になるかもしれません。

kilonova.hatenablog.com

 

さあ、12月11日に公開するはずだったこの記事ですが、課題に追われたり、部内の例会講座の準備をしたり、(学マスの石集めに苦しんだり、)しているうちにあっという間にクリスマスになっていました。師走とはまさにこのこと。

 

前置きはこのぐらいにして、今回はUnityのLocalizationについての話です。僕も最近まで知らなかったのですが、Unityには標準でローカライズ機能が提供されていて、テキストを言語ごとに切り替えたり、アセットを差し替えたりできるようになっています。

 

最初からローカライズを意識して進められればベストですが、なかなかそういうわけにはいかず、もう作り始めたプロジェクトに後付けでローカライズ機能を実装することになる場面も多いのではないでしょうか。今回は、そんな場面でいい感じにローカライズを追加する方法をまとめました。

 

パッケージのインストール

まずは、プロジェクトにLocalizationパッケージを導入します。Unity6では、Window>Package Management>Package ManagerでPackage Managerを開き、Unity Registryのタブ内で検索すると出てくるので、installボタンを押します。

 

使い方

基本的な使い方としては、ネット上にたくさん記事があるので簡単に。

という感じの流れです。Project Settingsをいじったり、String Tableを追加したりするのは簡単なのですが、問題はLocalize String Eventコンポーネントを追加する操作です。Text/TextMeshProコンポーネントを持つオブジェクト全てに追加する必要があるため、手作業で行うのはなかなか大変です。そこで今回は、Editor Scriptを使ってScene上のオブジェクトとPrefabにコンポーネントの追加の追加を行います。

 

Textコンポーネントを検索してローカライズを追加するEditor Script

スクリプトは以下の通りです。GetComponentsInChildrenでTextとTextMeshProを取り出し、Localize String Eventコンポーネントを追加しています。


using System;
using UnityEngine.Events;
using UnityEditor;
using UnityEditor.Events;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using TMPro;
using UnityEngine.Localization.Components;

namespace Editor
{ 
    public static class SceneLocalizeAutoSetup
    {
        [MenuItem("Tools/Localization/Setup LocalizeStringEvent In Scene")]
        static void SetupLocalizeStringEvent()
        {
            int count = 0;

            var scene = SceneManager.GetActiveScene();
            var roots = scene.GetRootGameObjects();

            foreach (var root in roots)
            {
                var texts = root.GetComponentsInChildren<Text>(true);
                Debug.Log("texts cout = "+texts.Length);

                foreach (var t in texts)
                {
                    var go = t.gameObject;
                    // Prefabのインスタンスだった場合は無視する(Prefab側でもLocalizeStringEventを追加した場合、二重に追加してしまうから
                    if (PrefabUtility.IsPartOfAnyPrefab(go)) continue;
                    // --- UGUI Text ---
                    SetupForText(go, t);
                    count++;
                }
                
                var tmpTexts = root.GetComponentsInChildren<TextMeshProUGUI>(true);
                Debug.Log("tmpText count = "+tmpTexts.Length);

                foreach (var t in tmpTexts)
                {
                    // --- TMP ---
                    var go = t.gameObject;
                    // Prefabのインスタンスだった場合は無視する(Prefab側でもLocalizeStringEventを追加した場合、二重に追加してしまうから)
                    if (PrefabUtility.IsPartOfAnyPrefab(go)) continue;
                    SetupForTMP(go, t);
                    count++;
                }
            }

            Debug.Log($"LocalizeStringEvent 設定完了: {count} オブジェクト");
        }

        // =========================
        // UGUI Text
        // =========================
        static void SetupForText(GameObject go, Text text)
        {
            Undo.RecordObject(go, "Add LocalizeStringEvent");

            var localize = go.GetComponent<LocalizeStringEvent>();
            if (localize == null)
            {
                localize = Undo.AddComponent<LocalizeStringEvent>(go);
            }

            // 既存イベントをクリア
            int persistentEventCount = localize.OnUpdateString.GetPersistentEventCount();
            for (int i = persistentEventCount - 1; 0<=i; i--)
            {
                UnityEventTools.RemovePersistentListener(localize.OnUpdateString, i);
            }
            
            var setMethod = typeof(Text).GetProperty("text")?.GetSetMethod();
            if (setMethod != null)
            {
                // Setterを呼ぶUnityActionを作成
                var methodDelegate =
                    (UnityAction<string>)Delegate.CreateDelegate(typeof(UnityAction<string>), text, setMethod);

                // Text.text を永続イベントとして登録
                UnityEventTools.AddPersistentListener(
                    localize.OnUpdateString,
                    methodDelegate
                );
            }

            EditorUtility.SetDirty(localize);
            EditorUtility.SetDirty(text);
        }

        // =========================
        // TMP
        // =========================
        static void SetupForTMP(GameObject go, TextMeshProUGUI tmp)
        {
            Undo.RecordObject(go, "Add LocalizeStringEvent");

            var localize = go.GetComponent<LocalizeStringEvent>();
            if (localize == null)
            {
                localize = Undo.AddComponent<LocalizeStringEvent>(go);
            }

            // 既存イベントをクリア
            int persistentEventCount = localize.OnUpdateString.GetPersistentEventCount();
            for (int i = persistentEventCount - 1; 0<=i; i--)
            {
                UnityEventTools.RemovePersistentListener(localize.OnUpdateString, i);
            }

            // TMP_Text.SetText(string) を永続イベントとして登録
            UnityEventTools.AddPersistentListener(
                localize.OnUpdateString,
                tmp.SetText
            );

            EditorUtility.SetDirty(localize);
            EditorUtility.SetDirty(tmp);
        }
    }
}

ポイントは、UnityEventTools.AddPersistentListenerです。Localizationでは、言語の切り替わりのタイミングなどでテキストの更新イベントが走るのですが、コンポーネントを追加しただけではそのイベントを監視する設定ができていない状態です。Editor Script側で自身のテキスト更新メソッドをイベントに追加する必要があります。UnityEventTools.AddPersistentListenerを使うことで、Inspectorウィンドウでよく見るこの部分にメソッドを登録できます。

Inspector上でイベント登録できるやつ

このスクリプトをAssets/Editorフォルダに追加した上で、UnityEditorのツールバーのTools>Localization>Setup LocalizeStringEvent In Sceneを押すと現在開いているシーン上の、TextかTextMeshProコンポーネントがついている全てのGameObjectにLocalize String Eventコンポーネントを追加し、イベントの登録を行います。

 

注意が必要なのが、実行するたびにLocalize String EventコンポーネントのOnUpdateStringを全てクリアしていることです。もし、すでにこのイベントに何かを追加していた場合、それが上書きされてしまいます。

 

Prefabのインスタンスなどは無視するようにしています。次のPrefabにコンポーネントを追加するスクリプトを実行した際に、コンポーネントが二重に追加されるのを防ぐためです。エディタ上で青字で表示されるインスタンスを無視する(実際にはPrefabUtility.IsPartOfAnyPrefabを使って判定しています)ので、Prefabのインスタンスに個別に追加して、Prefab本体には含まれていない子オブジェクトなどにはコンポーネントが追加されます。

 

PrefabにLocalize String Eventコンポーネントを追加するEditor Scriptは以下の通りです。


using System;
using UnityEngine.Events;
using UnityEditor;
using UnityEditor.Events;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.Localization.Components;

namespace Editor
{
public static class PrefabLocalizeAutoSetup
{
    [MenuItem("Tools/Localization/Setup LocalizeStringEvent In Prefabs")]
    static void SetupPrefabs()
    {
        string[] guids = AssetDatabase.FindAssets(
            "t:Prefab",
            new[] { "Assets/Prefabs" }
        );

        int modifiedPrefabCount = 0;
        int targetCount = 0;

        foreach (var guid in guids)
        {
            string path = AssetDatabase.GUIDToAssetPath(guid);
            GameObject root = PrefabUtility.LoadPrefabContents(path);

            bool modified = false;

            var texts = root.GetComponentsInChildren<Text>(true);
            foreach (var text in texts)
            {
                SetupForText(text.gameObject, text);
                targetCount++;
                modified = true;
            }

            var tmps = root.GetComponentsInChildren<TextMeshProUGUI>(true);
            foreach (var tmp in tmps)
            {
                SetupForTMP(tmp.gameObject, tmp);
                targetCount++;
                modified = true;
            }

            if (modified)
            {
                PrefabUtility.SaveAsPrefabAsset(root, path);
                modifiedPrefabCount++;
            }

            PrefabUtility.UnloadPrefabContents(root);
        }

        Debug.Log(
            $"完了: Prefab {modifiedPrefabCount} 個 / 対象 Text・TMP {targetCount} 個"
        );
    }

    // =========================
    // UGUI Text
    // =========================
    static void SetupForText(GameObject go, Text text)
    {
        var localize = go.GetComponent<LocalizeStringEvent>();
        if (localize == null)
        {
            localize = go.AddComponent<LocalizeStringEvent>();
        }

        // 既存イベントをクリア
        int persistentEventCount = localize.OnUpdateString.GetPersistentEventCount();
        for (int i = persistentEventCount - 1; 0<=i; i--)
        {
            UnityEventTools.RemovePersistentListener(localize.OnUpdateString, i);
        }

        var setMethod = typeof(Text).GetProperty("text")?.GetSetMethod();
        if (setMethod != null)
        {
            // Setterを呼ぶUnityActionを作成
            var methodDelegate =
                (UnityAction<string>)Delegate.CreateDelegate(typeof(UnityAction<string>), text, setMethod);

            // Text.text を永続イベントとして登録
            UnityEventTools.AddPersistentListener(
                localize.OnUpdateString,
                methodDelegate
            );
        }

        EditorUtility.SetDirty(localize);
        EditorUtility.SetDirty(text);
    }

    // =========================
    // TMP
    // =========================
    static void SetupForTMP(GameObject go, TextMeshProUGUI tmp)
    {
        var localize = go.GetComponent<LocalizeStringEvent>();
        if (localize == null)
        {
            localize = go.AddComponent<LocalizeStringEvent>();
        }

        // 既存イベントをクリア
        int persistentEventCount = localize.OnUpdateString.GetPersistentEventCount();
        for (int i = persistentEventCount - 1; 0<=i; i--)
        {
            UnityEventTools.RemovePersistentListener(localize.OnUpdateString, i);
        }

        // TMP 用永続イベント
        UnityEventTools.AddPersistentListener(
            localize.OnUpdateString,
            tmp.SetText
        );

        EditorUtility.SetDirty(localize);
        EditorUtility.SetDirty(tmp);
    }
}

}

AssetsDatabaseを起点にPrefabを取得し、あとはコンポーネントをチェックしつつ必要に応じてLocalizeStringEventコンポーネントを追加するという同じ流れを踏んでいます。異なるのは、コンポーネントを追加した後に、PrefabUtility.SaveAsPrefabAssetメソッドを呼んで変更を保存していることぐらいです。

これを実行すると、Assets/Prefabsフォルダ以下に配置されているPrefabで、TextまたはTextMeshProコンポーネントがついたオブジェクトにLocalize String Eventコンポーネントを追加して、テキスト更新イベントへの自身の登録を行います。こちらもすでに登録されていたテキスト更新イベントの登録状況を上書きすることに注意が必要です。

 

String Tableへの自動追加を行うEditor Script

前の章ではコンポーネントの追加を行いましたが、これだけでは設定は終わっていません。追加したコンポーネントに対して、翻訳テキストのデータが入ったString Tableを渡した上で、そのTable上でのどのテキストを当てはめるのかを示すkeyを登録する必要があります。

Localize String Eventコンポーネント

このコンポーネントでいう、String ReferenceのTable CollectionにString Tableの名前(ここでは"StringTable")を入れて、Entry Nameに翻訳テキストのkey(ここでは”りんご”)を入れる必要があります。設定できると、String Table内の翻訳文が読み込まれて、写真でいうEnglishやJapaneseのところにappleやりんごが表示されていることがわかります。

 

String Tableはこんな感じになっています。ツールバーのWindow>Asset Management>Localization Tablesから見れます。

String Table

左端の列がkeyで、縦列が言語ごとの列、横列がそのkeyに対する翻訳テキストのバリエーションです。ローカライズを実装する際には、いい感じのkeyを設定した上で、翻訳文を手動でこのString Tableに追加することになります。

 

本来は、keyに"NextButton"や"TutorialText"などいい感じのものをつけたいところですが、後付けずぼらローカライズにはそんな余裕はないので、現在オブジェクトに設定されている日本語のテキストをそのままkeyとしてStringTableに新規登録することにします。

 

Scene上のオブジェクト用のEditor Scriptはこちらです。


using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using TMPro;
using UnityEditor.Localization;
using UnityEngine.Localization;
using UnityEngine.Localization.Components;

namespace Editor
{ 
    public static class SceneLocalizeStringTableAutoSetup
    {
        // string table collectionのパス
        private const string STRING_TABLE_COLLECTION_PATH = "Assets/Localization/StringTable.asset";

        [MenuItem("Tools/Localization/Setup And Add New Entry To String Table In Scene")]
        static void SetupLocalizeStringEvent()
        {
            int count = 0;

            var scene = SceneManager.GetActiveScene();
            var roots = scene.GetRootGameObjects();
            
            var stringTableCollection = AssetDatabase.LoadAssetAtPath<StringTableCollection>(STRING_TABLE_COLLECTION_PATH);
            if (stringTableCollection == null)
            {
                Debug.Log("String Tableが見つかりません");
            }

            foreach (var root in roots)
            {
                var localizeStringEvents = root.GetComponentsInChildren<LocalizeStringEvent>(true);

                foreach (var localize in localizeStringEvents)
                {
                    var go = localize.gameObject;
                    // Prefabのインスタンスだった場合は無視する(Prefab側でもLocalizeStringEventを追加した場合、二重に追加してしまうから
                    if (PrefabUtility.IsPartOfAnyPrefab(go)) continue;
                    
                    Setup(go, localize, stringTableCollection);
                    count++;
                }
            }

            Debug.Log($"LocalizeStringEvent 設定完了: {count} オブジェクト");
        }
        
        static void Setup(GameObject go, LocalizeStringEvent localizeStringEvent, StringTableCollection stringTableCollection)
        {
            Undo.RecordObject(go, "Add Settings to LocalizeStringEvent");
            
            Text text = go.GetComponentInChildren<Text>();
            TextMeshProUGUI tmp = go.GetComponentInChildren<TextMeshProUGUI>();
            if (text == null && tmp == null) return;
            
            if(text != null)
            {
                SetupForText(text, localizeStringEvent, stringTableCollection);
            }else if (tmp != null)
            {
                SetupForTMP(tmp, localizeStringEvent, stringTableCollection);
            }
        }
        
        // =========================
        // Text(Legacy)
        // =========================
        static void SetupForText(Text text, LocalizeStringEvent localizeStringEvent, StringTableCollection stringTableCollection)
        {
            // 新しいエントリーとして、現在の登録テキストをそのままkeyにして追加する
            string key = text.text;
            string EntryInJapanese = text.text;
            AddEntryFromText(stringTableCollection, key, EntryInJapanese);
            
            localizeStringEvent.StringReference = new LocalizedString(
                stringTableCollection.TableCollectionName, key
            );
            
            EditorUtility.SetDirty(localizeStringEvent);
            EditorUtility.SetDirty(text);
        }

        // =========================
        // TMP
        // =========================
        static void SetupForTMP(TextMeshProUGUI tmp, LocalizeStringEvent localizeStringEvent, StringTableCollection stringTableCollection)
        {
            // 新しいエントリーとして、現在の登録テキストをそのままkeyにして追加する
            string key = tmp.text;
            string EntryInJapanese = tmp.text;
            AddEntryFromText(stringTableCollection, key, EntryInJapanese);
            
            localizeStringEvent.StringReference = new LocalizedString(
                stringTableCollection.TableCollectionName, key
            );
            
            EditorUtility.SetDirty(localizeStringEvent);
            EditorUtility.SetDirty(tmp);
        }
        
        static void AddEntryFromText(
            StringTableCollection tables,
            string key,
            string entryInJapanese
        )
        {
            foreach (var table in tables.StringTables)
            {
                if (table.GetEntry(key) != null)
                    continue;

                // 日本語テーブルだけ初期文字列を入れる
                string value =
                    table.LocaleIdentifier.Code == "ja-JP" ? entryInJapanese : ""; 

                table.AddEntry(key, value);
                EditorUtility.SetDirty(table);
            }

            AssetDatabase.SaveAssets();
        }
    }
}

パスを元にAssetDatabaseから手動で作成したStringTableCollectionを取得します。これは、言語ごとの翻訳テキストが入っているStringTableが、その言語で実装されている言語分まとめられたコレクションです。流れとしては、TextまたはTextMeshProコンポーネントに現在入れられているテキストをkeyとしてStringTableを検索し、まだ追加されていなければ新規追加して、日本語の欄にとりあえず現在コンポーネントに設定されているテキストを入れる形です。これをTools>Localization>Setup And Add New Entry To String Table In Sceneから実行すると、現在のテキストをkeyとしつつ、StringTableの日本語のところに現在のテキストが入れられます。他の言語のところは空になります。後から手動で翻訳してStringTableに追加してください。

 

注意点としては、前半のコンポーネントの追加部分と同様に、Prefabのインスタンスに関しては無視されることと、同じテキストに対しては同じ翻訳文を当てはめることになることです。例えば、タイトル画面の「進む」は"Start Game"に翻訳したいけど、リザルト画面の「進む」は”Back To Title”にする、といったことはできません。手動で頑張ってください。

 

こちらはPrefab用のEditor Scriptになります。

 


namespace Editor
{ 
    using UnityEditor;
    using UnityEngine;
    using UnityEngine.UI;
    using TMPro;
    using UnityEditor.Localization;
    using UnityEngine.Localization;
    using UnityEngine.Localization.Components;

    public static class PrefabLocalizeStringTableAutoSetup
    {
        // string table collectionのパス
        private const string STRING_TABLE_COLLECTION_PATH = "Assets/Localization/StringTable.asset";

        [MenuItem("Tools/Localization/Setup And Add New Entry To String Table In Prefabs")]
        static void SetupLocalizeStringEvent()
        {
            var stringTableCollection = AssetDatabase.LoadAssetAtPath<StringTableCollection>(STRING_TABLE_COLLECTION_PATH);
            if (stringTableCollection == null)
            {
                Debug.Log("String Tableが見つかりません");
            }
            
            string[] guids = AssetDatabase.FindAssets(
                "t:Prefab",
                new[] { "Assets/Prefabs" }
            );

            int modifiedPrefabCount = 0;
            int targetCount = 0;

            foreach (var guid in guids)
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                GameObject root = PrefabUtility.LoadPrefabContents(path);

                bool modified = false;

                var localizeStringEvents = root.GetComponentsInChildren<LocalizeStringEvent>(true);

                foreach (var localize in localizeStringEvents)
                {
                    var go = localize.gameObject;
                    
                    Setup(go, localize, stringTableCollection);
                    targetCount++;
                    modified = true;
                }

                if (modified)
                {
                    PrefabUtility.SaveAsPrefabAsset(root, path);
                    modifiedPrefabCount++;
                }

                PrefabUtility.UnloadPrefabContents(root);
            }

            Debug.Log($"完了: Prefab {modifiedPrefabCount} 個 / 対象 Text・TMP {targetCount} 個");
        }
        
        static void Setup(GameObject go, LocalizeStringEvent localizeStringEvent, StringTableCollection stringTableCollection)
        {
            Undo.RecordObject(go, "Add Settings to LocalizeStringEvent");
            
            Text text = go.GetComponentInChildren<Text>();
            TextMeshProUGUI tmp = go.GetComponentInChildren<TextMeshProUGUI>();
            if (text == null && tmp == null) return;
            
            if(text != null)
            {
                SetupForText(text, localizeStringEvent, stringTableCollection);
            }else if (tmp != null)
            {
                SetupForTMP(tmp, localizeStringEvent, stringTableCollection);
            }
        }
        
        // =========================
        // Text(Legacy)
        // =========================
        static void SetupForText(Text text, LocalizeStringEvent localizeStringEvent, StringTableCollection stringTableCollection)
        {
            // 新しいエントリーとして、現在の登録テキストをそのままkeyにして追加する
            string key = text.text;
            string EntryInJapanese = text.text;
            AddEntryFromText(stringTableCollection, key, EntryInJapanese);
            
            localizeStringEvent.StringReference = new LocalizedString(
                stringTableCollection.TableCollectionName, key
            );
            
            EditorUtility.SetDirty(localizeStringEvent);
            EditorUtility.SetDirty(text);
        }

        // =========================
        // TMP
        // =========================
        static void SetupForTMP(TextMeshProUGUI tmp, LocalizeStringEvent localizeStringEvent, StringTableCollection stringTableCollection)
        {
            // 新しいエントリーとして、現在の登録テキストをそのままkeyにして追加する
            string key = tmp.text;
            string EntryInJapanese = tmp.text;
            AddEntryFromText(stringTableCollection, key, EntryInJapanese);
            
            localizeStringEvent.StringReference = new LocalizedString(
                stringTableCollection.TableCollectionName, key
            );
            
            EditorUtility.SetDirty(localizeStringEvent);
            EditorUtility.SetDirty(tmp);
        }
        
        static void AddEntryFromText(
            StringTableCollection tables,
            string key,
            string entryInJapanese
        )
        {
            foreach (var table in tables.StringTables)
            {
                if (table.GetEntry(key) != null)
                    continue;

                // 日本語テーブルだけ初期文字列を入れる
                string value =
                    table.LocaleIdentifier.Code == "ja-JP" ? entryInJapanese : ""; 

                table.AddEntry(key, value);
                EditorUtility.SetDirty(table);
            }

            AssetDatabase.SaveAssets();
        }
    }
}

これはオブジェクトの取得方法以外はScene上のオブジェクト用のスクリプトと同じなので特に言うことはありません。

 

おわりに

さてさて、今回はすでにたくさんTextコンポーネントが追加されている状態を想定して、自動でコンポーネントの追加やString Tableの設定を行うEditor Scriptを作成しました。僕は制作したゲームにローカライズを追加したことはない(!?)のですが、ゲームをリリースすることがあったらこのスクリプトを使おうと思います。

それでは、メリークリスマス。

MacでSiv3Dを始めたときに苦労したこと

こんにちは。KMC48代のcreeamsodaです。

これはKMC Advent Calendar 2024の13日目の記事です。

昨日はiromさんのポートフォリオサイトを作った話でした。

ir0m.hatenablog.com

実はこの勉強会に僕も参加していて、htmlをいじって色々と試して遊んでいました。まだ外に出せるほどには仕上がっていないので、もう少し手元で動かそうと思います。自分だけの庭を整備しているみたいで楽しいですね。みなさんもぜひやってみてください。

というわけで今回は、「僕がMacでSiv3Dを始めた時に苦労したこと」について書こうと思います。

Siv3Dが何なのか、といった前置きをすっ飛ばしたい方は目次のリンクから本編へジャンプしてください。

 

まず、Siv3D/OpenSiv3Dとは何なのか

ズバリ、ゲームを作るためのアプリ(正確にはフレームワーク?)です。ゲームを作るというと、UnityやUnreal Engineなどが有名ですが、siv3Dもそうしたアプリと同じように、いい感じにコードを書いてビルドするとゲームとして遊べるようになります。

詳しくはこちら(公式リファレンス)。

zenn.dev

 

Unityとの違いは?

僕は半年前ぐらいからUnityでゲーム制作を始めたのですが、今回Siv3Dを少し使ってみて気づいた違いを書いておこうと思います。

コードで全てを完結させる

Unityは独自のエディタがあり、そこでボタンをポチポチしたり、パラメータをいじったりして調整することができます。もちろんコードも書きますが、感覚としてはエディタ側で作ったオブジェクトたちにコードで指令を出して動かしている感じ。

Siv3Dではそういったエディタは存在せず、オブジェクトの定義から生成、操作、描画まで全てをコード上で行います。Unityから入った僕としては少し信じがたいものでしたが、実際見てみると本当にコードだけです。

コードだけなので、色々なもの(座標の管理や当たり判定など)を自分で管理する必要があり、そういった意味ではUnityより大変さもあります。ただ、Unityでよくある、「publicにした変数が変な値になっていると思ったらinspectorに書いてある値が使われていたからだった」といった問題からはめでたく解放されます(Siv3Dへの理解度が足りなかったのでこれしか思いつかなかった)。

C++を使う

UnityではC#を使ってスクリプトを書きますが、Siv3DではC++になります。同じCがついてはいるものの、ところどころ違って難しいです。ポインタって何……。Unityから入ると、異なる言語という壁の上に、あらゆるゲームシステムを自分で組み立てるという壁が乗っかっているので苦労しています。

開発者が日本人

Siv3Dを開発したのは、鈴木遼さんという方で、日本人です。それもあって、後でも紹介するSiv3Dのチュートリアルがとても分かりやすいです。Unityを使っているときによく見た、あの機械翻訳っぽくて絶妙によく分からないリファレンスからはおさらばです。

 

Siv3D勉強会(2024年12/15(日))について

さてさて、ここまで軽くSiv3Dについて僕が分かる範囲でご紹介しましたが、そもそもSiv3Dを始めたきっかけがこのSiv3D勉強会です。

connpass.com

KMC45代のsashiさんという先輩がSiv3Dをずっと使っていらっしゃって、そのsashiさんがこの勉強会をおすすめしてくださいました。ちょうど学祭も終わって良いタイミングだったため、何とか勉強会までに1つゲームを作り上げるぞと意気込んでから約2週間。勉強会2日前にして進捗は20%ぐらいです。絶望……。

この勉強会ではSiv3D、またはプログラミングの初心者も対象としていて、基礎的な操作から応用的な技術まで様々な内容を学ぶことができるそうです。なんと先述のSiv3D開発者鈴木遼さんや、バンダイナムコの方もいらっしゃいます! 豪華!

なんと参加費無料で一般の方も含めて参加できるので、予定が空いている方はぜひお越しください。上のリンクから参加登録をお願いします。

 

MacでSiv3Dを始める(本編)

というわけでここからは、実際にSiv3Dを始めた時に苦労したことについて書いていきます。

Siv3Dのインストール

インストールについては公式が出してくれているサイトの通りにやればできます。詳しくはこちらをご覧ください。

siv3d.github.io

大雑把に言うと、

  1. Xcodeをインストールする
  2. Siv3Dのテンプレートプログラムをダウンロードして展開する
  3. Xcodeでテンプレートプログラムを開く

これだけやれば、すぐに実行環境を得ることができます。すごい。

Xcodeのインストール

MacでSiv3Dを使うためには、Xcodeというスクリプトエディタが必要になります。

App Storeの画面

App StoreXcodeと検索するとこれが見つかるのですが、絶妙に評価が2分されていて少し不安になります。天下のApple純正のエディタのはずなんですが……。

いくら星1が多かろうとも、とりあえずパチモンではないことは確かなのでインストールします。それなりにサイズがあるので時間がかかります。じっくり待ちましょう。

 

テンプレートプログラムのダウンロード

これはさっきのサイトからダウンロードするだけです。

 

あとはXcodeでプログラムを開くだけ。簡単ですね。

 

Xcodeでエラー表示が出る

テンプレートプログラムを開いて、Main.cppというプログラムをドラッグ&ドロップして表示してみると、テンプレートプログラムのソースコードを見ることができます。しかし、僕の手元のXcodeでは、そのコードを少しでも編集すると、エラーの表示がされてしまいます。具体的にはCircleやRectといったSiv3D独自のクラスが引っかかっているようです。

ビルドを行うと、エラー表示は消え、正常に動作もするのですが、再び少しでも編集するとこの状態に戻ってしまいます。どうしたものでしょう。

 

プロに質問する

Siv3DにはDiscordサーバーがあり、Siv3Dに関する情報共有が行われています。ここに質問用のチャンネルがあるので、困った時は何でも聞いてみると、プロ級の人もしくは開発者の人が飛んできて答えてくださいます。素晴らしい!

discord.gg

できるだけ詳しく質問することと、お礼の言葉を忘れないようにしましょう。

僕はこの現象について質問を投げてみました。すると、Xcodeで発生している不具合とのことです。残念ながら、明確な解決策はなさそうで、今後出される予定のSiv3D v0.8を待つ形になりそうです。鬱陶しくはあるものの、ビルド自体はできるので諦めます。

 

VSCodeを使う

一度は諦めたものの、やはり毎回赤字で表示されるのは邪魔な上に、たまに本当に間違っているものも出てくるため完全に無視することもできなかったこともあり、別のエディタを使って編集し、ビルドのみXcodeで行ってみることにしました。これならばXcodeを開くのはビルド時だけなので警告は気にならないし、ビルドすると無視したいエラーは消え、本当に起こっているエラーだけ残るので問題は解決できます。

VSCodeを使うにあたり、下の2つのサイトを参考にさせていただき設定をしました。

qiita.com

qiita.com

具体的には、VSCode拡張機能C/C++」をインストールし、.vscode\c_cpp_properties.jsonファイルをいじる、という操作を行います。僕は設定ファイルをいじるのは初だったので、この部分で苦戦をしました。うまくパスを入力する必要があります。

何もわかっていないままフィーリングでいじっていると、何だか良い感じになりました。

セキュリティを考えて、一応パスは偽物にしています(悪用しないでね)

いじったのは、"includePath"の部分。シンプルにテンプレートプログラムのフォルダの中にあるincludeフォルダと、そのincludeフォルダの中にあるThirdPartyフォルダのパスを書き足せば良かったみたいです。パスはFinderなどから取得できます。

support.apple.com

自分の場合、Siv3Dのバージョン名は0_6_15ではなく0.6.15のように表記するとうまく行きました。この辺りは適当にやってうまくいったというだけなので、有識者の方がいれば教えていただけると幸いです。

ひとまず、ここまでやればVSCode上にコード補完や文法ミスの警告メッセージが表示されるようになり、楽しいSiv3Dライフを送れるようになっているはずです。おめでとうございます。

 

チュートリアルで学ぶ

実際にコードを書いていく時に便利だったのが、公式が出してくれているチュートリアルです。

siv3d.github.io

Siv3Dについて1から学ぶことができ、分かりやすいサンプルコードもついているので、これを見れば色々なことができるようになります。最高です。ここに全てがあります。たまにここには載っていないライブラリなどを使った方がいい時もあるようですが、そのあたりは先ほどのDiscordで聞いてみると良いかもしれません。

 

最後に

苦労したことというタイトルをつけましたが、結局あまり苦労話は出てこなかった気がします。むしろこの後、C++のヘッダーファイルや、参照渡しなどに苦しみ、その度に先輩方に助けを求めることになりましたが、その話はまた機会があれば書こうと思います。今はとにかく勉強会までにゲームを作るのを頑張ります!

 

明日のアドベントカレンダーはgunjouさんの記事です。お楽しみに!

お久しぶりです

みなさんこんにちは。そしてお久しぶりです。

前回の記事が8/14日に書いたものだったので、実に118日ぶりにこのブログを書いていることになる。いやはや、時が経つのは速いものだ。まあ実際そのぐらいの期間、ブログから離れていた自覚はあるけど。

急に更新を止めることになってしまったが、別に病気や怪我ではないのでご安心を。単に怠惰になっただけ。

習慣の力というものは恐ろしく、きちんと毎日書いていた時はリズムに乗れていたのに、テストを挟んでペースが乱れてからは更新がとびとびになり、一度はまた毎日書こうと決意したものの、力尽きてしまったのだ。

これまで入っているサークルのKMCでも、ブログを書いていることを一種のアイデンティティにしていたので、書かなくなったことで何でもない人になってしまった。

ブログからは離れていたが、サークルではこの間も色々と活動をしていた。何本か記事を書いていた、サークルのみんなでゲーム制作をする話は、夏休みからずっと作業を続けて11月に行われた学祭で作ったゲームを展示するところまで進んでいたし、チャンバラサークルの方でも学祭でチャンバラ体験会を開き、老若男女あらゆる人たちと剣を交わしもしていた。要するに忙しかったのである(という言い訳)。

今回ここに戻ってきたのはズバリ、KMCで行っている今年のアドベントカレンダーの記事を書くためである。アドベントカレンダーとは、もともとクリスマスを待ち望む気持ちから、12/1から12/25までの25日間のカレンダーを用意し、ワクワクしながら1日1日を大切に過ごすという習慣のことだそうだ。僕のイメージとしては、カレンダーの日付のところが小さな箱になっていて、1日ずつ箱を開けて中身を取り出していくやつが1番有名な気がする。

KMCでいうアドベントカレンダーは、そのイベントと同じように、25日まで毎日、1つずつブログの記事をアップしていくというものだ。割と情報の分野では色んなところで行われているらしい。

例えば、株式会社はてなに所属するエンジニアの方々によるやつ

qiita.com

 

こっちは生成AIに関する記事を集めたやつ

adventar.org

 

とまあそれなりに盛り上がっているようだ。そしてKMCでも毎年行う恒例行事と化していて、なんと2016年から続いているらしい。というわけで、僕もそれに載せる記事を書くことになったので、この地へ舞い戻ってきたのだ。残念ながら、その記事を書いたらまたしばらくいなくなりそうな気がするが仕方がない。もはや書き続ける体力などないのだよ。

記事ではいきなり技術系の話を始めると思うので、その前に前置きだけ書いておこうと思ったのだが、想定より長くなってしまった。ダメだな。まだ本編は1ミリも書いてないのに深夜だ。ではひとまずこの辺で切り上げておくとしよう。

KMC×はてな交流会

今日は僕が所属するサークル、KMCの部室で行われた株式会社はてなとの交流会に参加した。KMCの部室とはてなのオフィスがそこそこ近いのと、はてなでバイトをしているKMC部員がいるというつながりで、互いに交流を深めようというわけだ。数年前にも同じような交流会が開かれたらしく、前からの付き合いのようだ。

会の中身としてはLT大会だった。LTとは5分から10分程度の短時間のプレゼンのことで、今回はKMC部員から5人、はてなから4人がプレゼンを披露していた。ちなみに僕はほか何人かと共に聞くだけメンバーとして参加した。

プレゼンのテーマは多岐にわたった。個人で取り組んでいた技術的なことを解説している人もいれば、自身の博士課程進学に向けて作った研究説明資料をそのまま持ってきた人、後はプログラミング言語の悪口を言っている人もいたな。それぞれかなり専門的なところの話になるので、この春に情報を学び始めた僕にとっては半分ぐらいちんぷんかんぷんだった。それでも結構楽しめたのは、プレゼンターの話術の賜物か。

ちょうど数日前、夏のコミケが行われていて、KMCも部誌を出品していたのだが、どうやら印刷代やら交通費やらの諸々の経費を差し引くと、結構な赤字になっているらしい。KMCの会長がこのテーマでLTをしていたのだが、そもそも値段設定や販売部数などを考えたときに黒字化はほぼ無理なミッションだということを聞いて、厳しい世界だなあと実感した。部誌の内容を濃くして値上げし、東京まで交通費を浮かせるために徒歩移動してギリなんとかできるレベルらしい。徒歩移動…?

はてなの方には、はてなブログに広告を表示するための構造作りについてのお話をされている方がいた。こうして書いているブログにも当然広告が付いているわけだが、広告が付いているということは、ブログページに広告枠があって、なおかつその枠を購入する広告主がいるということである。枠を買ってもらうためには、きちんと枠が機能して広告が実際に表示されていることを保証する必要がある。そのためにads.txtなるものがあるそうなのだが、これをそれぞれのブログページごとに別々のものとして管理するのが大変だったそうだ。たくさんの、別々のブログページを管理する立場にあるブログプラットフォームならではの課題が知れて、なかなか興味深かった。普段使っているサービスの裏側には全く知らない仕組みや工夫があると分かって、はてなブログのすごさの一端に触れた気がする。

はてなの方々はお寿司を差し入れしてくださったのでありがたくいただいた。さすが大人。とても美味かった。決して広くはない部室に賑わいが満ちている。ムンとした熱気を感じたのはエアコンが弱かったからではないはずだ。

アーカイブズ学の期末レポート!

今日はゼミナール形式でのアーカイブズ学の講義の期末レポートを書いていた。せっかくなのでそのレポートをここに貼り付けて今日のブログということにする。

 

 

 世界にはNARA(アメリカ国立公文書記録管理局)、TNA(イギリス国立公文書館)をはじめとする国単位でのアーカイブズから、州単位、県単位といった様々な規模、目的のアーカイブズが存在している。これらの機関では、行政で現用期間を終えた文書や個人が保管していた文書を収集し、損傷、紛失からそれらの文書を長期的に保護、保存し、そして文書を活用できるよう整理や検索システムの構築を行なっている。今回は特にアーカイブズが保管する文書の活用という点に注目し、アーカイブズの機能について考えていく。

 多種多様な行政文書のうち、アーカイブズで長期にわたって保存されるものは、アーキビストによって保存すべき価値があるとして選び出されたものである。その価値判断の基準は何のために文書を保存するのかという点に深く関わっている。保存すること自体が目的なのではなく、文書を保存することでその文書を今後も活用できるようにすることがアーカイブすることの目的である。文書は信頼性、真正性、正確性、認証が保たれていれば、過去の出来事を記録し証明する機能を持つ。そのため、アーカイブされた文書は、訴訟において事実の確認を行なったり、歴史研究において過去にあったことを正確に示したり、また行政において取り組みを進める際に過去の事例として参考にしたりといった役割を持ちうる。つまり、アーカイブズはそのような役割を担うことが期待される文書を選別、保護し、フォンドやシリーズといった階層構造での整理や検索システムの構築といった実際にそれらが活躍できるような環境の整備を行う機関であるといえる。以下ではアーカイブされている実際の資料を取り上げ、アーカイブズの機能について具体的な説明を行う。

 まずは「Oversight Hearing on “How to Manage Large Constrictor Snakes and Other Invasive Species”(締め殺しヘビとその他の外来種の管理方法についての公聴会)」という資料を取り上げる。この資料はNARAに保管されているもので、今回はNARAのオンライン検索システムを用いて閲覧を行った。この資料は、アメリカの第111議会において行われた外来種のヘビへの対策を協議する公聴会で使われたものである。具体的には内務省国立公園局の担当者による証言、想定問答集、そこで言及のある大統領令13112号、国立公園における外来種の現状について説明する資料、鯉に関する現状と対策について書かれた資料、国立公園の利用者による証言、ビルマニシキヘビについての参考資料から構成されている。この公聴会で議題となっているのは、アメリカ南東部フロリダ州にあるエバーグレーズ国立公園に、外来種であるビルマニシキヘビが侵入しているというものである。このヘビは、体長が3〜6mの大型のヘビで、毒はなく、獲物に身体を巻きつけ締め殺してから丸飲みにすることが特徴である。東南アジア、南アジアといった熱帯の暖かく湿った気候の地域に生息し、そのため温暖で湿潤なフロリダで勢力を伸ばしていると考えられている。この公聴会では、現在行われている対策として、ハンターによるヘビの駆除に加えて、レイシー法の有害生物リストへこのヘビを登録することに必要な準備を行っていることが挙げられていた。このリストへ登録されれば、無許可でヘビを輸入、または州間移送することが法で禁じられるため、人の手によってヘビの被害が拡大することを防ぐことができるのだという。しかし、ペットとしても人気があるビルマニシキヘビの州間移送が禁じられた場合、ペットの売り手やペットフードの会社などが不利益を被ることは避けられないということもあり、このリストに登録するためには、登録することによって利害関係者が受ける不利益と、自然環境保護の効果について見積もらなければならないという規定がある。ここにこの資料がアーカイブされている理由があると考える。外来種への対処に伴い、長期的に不利益を被る可能性のある者が存在するため、その不利益を与えることになる行政の判断には、正当な理由が必要となる。この資料のように、行政が判断を下す経緯を示すものは、判断の正当性を主張する際に役立つ可能性があり、ここにこの資料の重要性が見出されるため、この資料がアーカイブされていると考えられる。このように、利害が絡む場面でアーカイブズの活用が行われうるのである。

 次に、日本国内でのアーカイブズにも注目する。昭和60年から62年における「京北町松くい虫特別防除事業」という資料を、京都府立京都学・歴彩館において閲覧した。この資料は、現在では京都市右京区に合併されている京北町という地域において、松くい虫による害が発生し、それに対してヘリコプターによる農薬の空中散布を行って対処した際の町役場の書類がファイルにまとめられたものである。松くい虫とは、マツノマダラカミキリという虫のことで、この虫がマツノザイセンチュウという、松を枯れさせる線虫を松から松へ運ぶことで次々と松が枯れてしまうことが問題となっていた。この資料には、この虫害への対処として、松が生えているのが山の奥地であり農薬の地上散布が難しく、そのため公害のリスクが高まる空中散布を選択したという経緯や、人体や他の昆虫類への影響が出ていないかどうか毎年調査されていた結果についても示されていて、こちらも住民の健康、自然環境保護といった利害が絡む場面で、事実関係を示す証拠としての機能を持ったアーカイブズであるといえる。しかし、この資料には別の特徴もある。昭和62年から、農薬の空中散布による公害を懸念した住民たちが抗議運動を始めたのである。これに伴い、抗議団体とのやりとりの記録や、空中散布時の運営の変化についての書類が作成されている。さらには、増していく散布反対の声に町長が葛藤を示している会議の議事録、そして翌年度以降の空中散布を取りやめる決定をしたことを通知する書類がファイルに含められていた。このように、ただ虫害に対処した記録だけでなく、住民による抗議運動を受け、行政として取る立場を主張しながら、最終的にその立場を変化させるまでの過程が示される資料であるため、珍しい事例を詳細に記録した貴重な資料であるといえる。これは今後行政が似たような事業を行ったり、住民から何らかの抗議運動を受けたりした際に、どのように対応するべきかということについて参考になりうる資料である。このように前例を詳細に示すものとして行政に活用されるということもアーカイブズの活用例の1つとなっている。

 ここまで、アーカイブズの具体例を用いて、アーカイブズの活用について考えてきた。アーカイブズが守る資料の信頼性、真正性、正確性、認証は、法律関係など判断の正当性を証明する必要がある場面や、事例の詳細な過程を記録し、後に同様の事例があったときに参考資料として用いる場面などにおいて大きな役割を担う。私は、アーカイブズは、現用期間を終えた文書を適切に選別、保護、管理し、それらの文書が将来何らかの形で活躍できるようにすることで、文書に第2の生を与える機関であると考える。

 

 

とまあこんな感じだ。最後の終わり方を悩んだ結果、何だかそれっぽいことを言っておくことになった。本当にこんなのでいいのかな? もう提出したからどうしようもないけど。

アーカイブズ学の講義は、資料の整理、検索システムの構築のところが情報学に関わるかなと思って選んだのだが、実際はそこまで立ち入った話はなかった。とはいえ、アーカイブズという普通なら一生触れないものに関わり、ましてや古文書を手に取るということまでできたので人生経験として良かったと思う。あらゆるものがデジタル上で行われる現代で、究極のアナログである古文書を守っていくお仕事。時代に逆行するからこそ、そこに唯一無二の価値が生まれるのかもしれない。(またそれっぽいことを言って終わる。)

夏祭り

今日は地域の夏祭りに参加した。昨日の花火に引き続き、夏らしいイベントを消化する。

最初に食券をまとめて購入する形式で、販売所に長い列ができていた。予算の1000円を握りしめ、値段の表を睨みながら買うもののプランニングをする。ボリューム、味の系統、デザートまでの流れ。これら全てを考慮して練り上げたリストは、「ドリンク・焼きそば・フランクフルト・唐揚げ・かき氷」だ。これで1000円ぴったり。我ながら完璧だ。

最初に焼きそばを食べることである程度お腹を満たしておき、そこから味にハズレが少ない唐揚げ、そしてお祭り感も強くジューシーなフランクフルトを経由して、最後は1番食べたかったかき氷で締める。お祭りという年に一度の舞台では失敗は許されない。この夏を最高の思い出とするか、それとも悔やんでも悔やみきれないものとするかは今日にかかっていると言っても過言ではない。

という冗談はさておき、お祭りは割と楽しむことができた。来ていた知り合いの人がかき氷をくれるというラッキーイベントも発生したし。

かき氷のシロップは、イチゴ、ブルーハワイ、メロンあたりが定番になるとは思うが、僕はどれもあまり好きではない。ちょっと作り物すぎる感じがして苦手なのだ。それも含めてかき氷の味という主張も理解できるんだけどね…。うーん。イチゴ味をもらったが、とはいえ食べ始めたらなんだかんだ美味しかった。

そんな僕が心惹かれるラインなのがマンゴー、みぞれ、抹茶あたりだ。マンゴーに関してはこれこそ作り物感つよつよだが、ここまで振りきっているとマンゴーではない別の何かとして受け入れることができる。僕が本物のマンゴーの味を理解していないというのもあるだろうけど。みぞれは何かを再現することを諦めたシロップなので、これもみぞれという名の何かとして受け入れられる。

結局自分で選んだやつは宇治茶味にした。本格的なお茶の苦味とかがあるタイプではなかったが、美味しかった。抹茶系は間違いがないね。

今回の夏祭りはかなりいい動きができたと思う。この夏はいい思い出になりそうだ。

KMCで花火

今日はKMCで花火に行った。テスト終わりのこの時期に夏らしいことをやろうというイベント。ちょうどスペイン語のテストのために大学に行かなければならなかったので、ついでに参加することにした。

場所は部室から近い川の河川敷。大きな三角州となっている場所で、橋から降りてみると既にたくさんのグループがそれぞれ花火をしていた。かなりの人気スポットのようだ。あたりには火薬の匂いが漂っている。雰囲気が高まってきた。

種火となるロウソクを用意して、そこの火から花火に火をつけるのだが、川沿いのため、風が強くて火がすぐに消えてしまう。人を風除けにして火を守ろうともしたが、よく見ると風向きの変化が激しく、うまく風をガードしないとやはり火が消えてしまうのだ。厳しい環境だ。か弱い火を頼りに何とか花火に着火し、いよいよスタートした。

火がつくと一気に明るい光を放ち、鮮やかに火花が散る。何も考えず、ぼーっと見ていられる。花火いいな。

やっぱり種火はすぐに消えてしまうので、消えては点け、消えては点けを繰り返すという懸命な努力が続けられた。最終的には諦めて、火のついている花火から火を移してもらい、それをまた次の花火へつなぐという聖火リレー状態になった。花火をじっくり眺める余裕はなく、火がついたら燃え尽きる前に急いで次につなぐ。もはや何をしに来たのか分からない。

強風で火花が花火を持つ手の方に飛んできたり、ほかの団体がたびたび打ち上げ花火をぶっぱなしたり(もちろん違反)とカオスな夜だったが楽しかった。まだまだ夏はこれから。楽しむぞ。