GameCanvas for Unity は、慶應義塾大学『スマートデバイスプログラミング』にて教材として使われている 2Dゲームフレームワーク です。
引用元:https://github.com/sfc-sdp/GameCanvas-Unity
概要
GameCanvas for Unity v7.0.2以前では、Res フォルダに独自フォントを追加した状態でリソース定義を更新すると、InvalidCastException が発生する場合があります。
この問題が発生すると、リソース定義の更新が正常に完了せず、追加したフォントが GcFont.cs に反映されません。
この記事では、発生していた問題、原因、修正内容についてまとめます。
修正方法だけ知りたい方で、尚且つGameCanvas v7.0.2の方は、修正コード全文に飛んでください!
発生条件
Res フォルダに .ttf ファイルを追加した状態で、以下を実行します。
GameCanvas > リソース定義の強制更新
すると、次のようなエラーが発生していました。
InvalidCastException: Specified cast is not valid.
GameCanvas.Editor.GcEditorResourceBuilder.ValidateImages () (at ./Packages/jp.ac.keio.sfc.sdp/Editor/GcEditorResourceBuilder.cs:391)
GameCanvas.Editor.GcEditorResourceBuilder.Build () (at ./Packages/jp.ac.keio.sfc.sdp/Editor/GcEditorResourceBuilder.cs:54)
GameCanvas.Editor.GcEditorMenu.BuildResource () (at ./Packages/jp.ac.keio.sfc.sdp/Editor/GcEditorMenu.cs:55)
問題点
上記のエラーにより、リソース定義の更新処理が途中で止まってしまい、追加したフォントを GcFont.cs に反映できない状態になっていました。
原因は、画像の検証処理内で AssetImporter を直接 TextureImporter にキャストしていたことです。Package/GameCanvas/Editor/GcEditorResourceBuilder.csのValidateImages()の以下の処理部分です。
TextureImporter importer = (TextureImporter)AssetImporter.GetAtPath(assetPath);
Res フォルダには画像だけでなく、フォントファイルなども配置できます。
.ttf ファイルは TextureImporter として扱うことができません。
そのため、.ttf ファイルに対して TextureImporter へのキャストを行うと、InvalidCastException が発生していました。
修正方法
AssetImporter を直接 TextureImporter にキャストしないようにします。
修正前は、次のように直接キャストしていました。
//元コード
TextureImporter importer = (TextureImporter)AssetImporter.GetAtPath(assetPath);
これを、as を使った安全なキャストに変更します。
//修正後
TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
そして、TextureImporter として扱えないAssetは画像ではないため、画像検証処理をスキップします。
//完全版
TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer == null) continue;
これにより、.ttf ファイルなど画像以外のAssetが Res フォルダに含まれていても、画像検証処理で例外が発生しないようになります。
修正コード全文
以下は、GameCanvas for Unity v7.0.2 を前提にした修正版のコード全文です。
Package/GameCanvas/Editor/GcEditorResourceBuilder.csを以下に差し替えてください。
⚠︎環境によって内容が異なる可能性があります。
/*------------------------------------------------------------*/
// <summary>GameCanvas for Unity</summary>
// <author>Seibe TAKAHASHI</author>
// <remarks>
// (c) 2015-2026 Smart Device Programming.
// This software is released under the MIT License.
// http://opensource.org/licenses/mit-license.php
// </remarks>
/*------------------------------------------------------------*/
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.U2D;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.Audio;
using UnityEngine.U2D;
namespace GameCanvas.Editor
{
public sealed class GcEditorResourceBuilder
{
//----------------------------------------------------------
#region 変数
//----------------------------------------------------------
const string k_GcAssetDir = "Assets/GameCanvas";
const string k_GcAutoGeneratedScriptsDir = k_GcAssetDir + "/AutoGeneratedScripts";
const string k_GcResourcesDir = k_GcAssetDir + "/Resources";
const string k_GcSpriteAtlasPath = k_GcAssetDir + "/SpriteAtlas.spriteatlas";
const string k_UserResourceDir = "Assets/Res";
static readonly Regex k_RegBadChars = new(@"[^\w_]");
static readonly Regex k_RegCloneSufix = new(@"\(Clone\)\z");
static readonly Regex k_RegImg = new(@"^Assets/Res/(?<filename>.+\.(gif|png|jpg|jpeg|tga|tif|tiff|bmp|tiff|pict))$", RegexOptions.IgnoreCase);
static readonly Regex k_RegSnd = new(@"^Assets/Res/(?<filename>.+\.(wav|mp3|ogg|aiff|aif))$", RegexOptions.IgnoreCase);
static readonly Regex k_RegTopNumber = new(@"^\d");
static readonly string[] k_SearchDir = new[] { k_UserResourceDir };
#endregion
//----------------------------------------------------------
#region 公開関数
//----------------------------------------------------------
public static void Build()
{
using (GcEditorUtility.ProgressBarScope(nameof(GcEditorResourceBuilder), "ビルド中......"))
{
using (GcEditorUtility.AssetEditingScope())
{
CreateMustAssets();
ValidateImages();
ValidateSounds();
}
using (GcEditorUtility.AssetEditingScope())
{
BuildSprites();
BuildSounds();
BuildFonts();
BuildTexts();
}
}
}
internal static void OnLaunch()
{
CreateMustAssets();
}
internal static bool OnPostprocessAudio(in AudioImporter importer, in AudioClip clip)
{
const string k_AudioImporterLabel = "GameCanvas AudioImporter 1.2";
if (importer == null) return false;
if (!k_RegSnd.IsMatch(importer.assetPath)) return false;
var sampleSettingSE = new AudioImporterSampleSettings()
{
compressionFormat = AudioCompressionFormat.ADPCM,
loadType = AudioClipLoadType.DecompressOnLoad,
sampleRateSetting = AudioSampleRateSetting.OptimizeSampleRate,
preloadAudioData = true
};
var sampleSettingBGM = new AudioImporterSampleSettings()
{
compressionFormat = AudioCompressionFormat.Vorbis,
loadType = AudioClipLoadType.Streaming,
sampleRateSetting = AudioSampleRateSetting.OptimizeSampleRate,
preloadAudioData = true
};
if (importer.userData != k_AudioImporterLabel)
{
var isSE = (clip.length < 3f);
var sampleSetting = isSE ? sampleSettingSE : sampleSettingBGM;
importer.loadInBackground = false;
importer.forceToMono = isSE;
importer.defaultSampleSettings = sampleSetting;
importer.SetOverrideSampleSettings("Standalone", sampleSetting);
importer.SetOverrideSampleSettings("iOS", sampleSetting);
importer.SetOverrideSampleSettings("Android", sampleSetting);
importer.userData = k_AudioImporterLabel;
return true;
}
return false;
}
internal static bool OnPreprocessTexture(in TextureImporter importer)
{
const string k_TextureImporterLabel = "GameCanvas TextureImporter 2.1";
if (importer == null) return false;
if (!k_RegImg.IsMatch(importer.assetPath)) return false;
if (importer.userData != k_TextureImporterLabel)
{
var settings = new TextureImporterSettings();
importer.ReadTextureSettings(settings);
{
settings.filterMode = FilterMode.Point;
settings.mipmapEnabled = false;
settings.readable = false;
settings.spriteAlignment = (int)SpriteAlignment.TopLeft;
settings.spriteMeshType = SpriteMeshType.Tight;
settings.spritePixelsPerUnit = 1f;
settings.textureType = TextureImporterType.Sprite;
}
importer.SetTextureSettings(settings);
importer.maxTextureSize = 2048;
importer.spriteImportMode = SpriteImportMode.Single;
importer.textureCompression = TextureImporterCompression.Uncompressed; // note: 圧縮はスプライトアトラスで設定するため、ここでは非圧縮とすること
importer.userData = k_TextureImporterLabel;
return true;
}
return false;
}
#endregion
//----------------------------------------------------------
#region 内部関数
//----------------------------------------------------------
private static void BuildFonts()
{
const string k_DefaultFontPath = k_GcAssetDir + "/DefaultFont.ttf";
var assetPaths = AssetDatabase.FindAssets($"t:{nameof(Font)}", k_SearchDir)
.Select(AssetDatabase.GUIDToAssetPath)
.Append(k_DefaultFontPath)
.ToArray();
var removeList = AssetDatabase.FindAssets($"t:{nameof(GcReferenceFont)}")
.Select(AssetDatabase.GUIDToAssetPath)
.ToList();
var fontDict = new Dictionary<string, string>();
foreach (var assetPath in assetPaths)
{
var font = AssetDatabase.LoadAssetAtPath<Font>(assetPath);
if (font == null) continue;
var name = GenerateFieldName(font.name);
var path = k_GcResourcesDir + $"/GcFont{name}.asset";
GcReferenceFont.Write(path, font);
fontDict.Add(name, $"GcFont{name}");
removeList.Remove(path);
}
GenerateResourceDefineScript("GcFont", fontDict);
foreach (var path in removeList)
{
AssetDatabase.DeleteAsset(path);
}
}
private static void BuildSounds()
{
const string k_AudioMixerPath = k_GcAssetDir + "/AudioMixer.mixer";
const string k_DstPath = k_GcResourcesDir + "/GcAudioMixer.asset";
var mixer = AssetDatabase.LoadAssetAtPath<AudioMixer>(k_AudioMixerPath);
Assert.IsNotNull(mixer, $"[GameCanvas] {k_AudioMixerPath} is missing!");
GcReferenceMixer.Write(k_DstPath, mixer);
var assetPaths = AssetDatabase.FindAssets($"t:{nameof(AudioClip)}", k_SearchDir)
.Select(AssetDatabase.GUIDToAssetPath)
.ToArray();
var removeList = AssetDatabase.FindAssets($"t:{nameof(GcReferenceSound)}")
.Select(AssetDatabase.GUIDToAssetPath)
.ToList();
var soundDict = new Dictionary<string, string>();
foreach (var assetPath in assetPaths)
{
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(assetPath);
if (clip == null) continue;
var name = GenerateFieldName(clip.name);
var path = k_GcResourcesDir + $"/GcSound{name}.asset";
GcReferenceSound.Write(path, clip);
soundDict.Add(name, $"GcSound{name}");
removeList.Remove(path);
}
GenerateResourceDefineScript("GcSound", soundDict);
foreach (var path in removeList)
{
AssetDatabase.DeleteAsset(path);
}
}
private static void BuildSprites()
{
const string k_DstPath = k_GcResourcesDir + "/GcAtlas.asset";
var atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(k_GcSpriteAtlasPath);
Assert.IsNotNull(atlas, $"[GameCanvas] {k_GcSpriteAtlasPath} is missing!");
GcReferenceAtlas.Write(k_DstPath, atlas);
SpriteAtlasUtility.PackAllAtlases(EditorUserBuildSettings.activeBuildTarget, true);
var sprites = new Sprite[atlas.spriteCount];
atlas.GetSprites(sprites);
var spriteDict = sprites
.Where(sprite => sprite != null)
.ToDictionary(sprite => k_RegCloneSufix.Replace(sprite.name, ""), sprite => sprite.rect);
GenerateResourceDefineScript("GcImage", spriteDict);
}
private static void BuildTexts()
{
var assetPaths = AssetDatabase.FindAssets($"t:{nameof(TextAsset)}", k_SearchDir)
.Select(AssetDatabase.GUIDToAssetPath)
.ToArray();
var removeList = AssetDatabase.FindAssets($"t:{nameof(GcReferenceText)}")
.Select(AssetDatabase.GUIDToAssetPath)
.ToList();
var fontDict = new Dictionary<string, string>();
foreach (var assetPath in assetPaths)
{
var text = AssetDatabase.LoadAssetAtPath<TextAsset>(assetPath);
if (text == null) continue;
var name = GenerateFieldName(text.name);
var path = k_GcResourcesDir + $"/Text{name}.asset";
GcReferenceText.Write(path, text);
fontDict.Add(name, $"Text{name}");
removeList.Remove(path);
}
GenerateResourceDefineScript("GcText", fontDict);
foreach (var path in removeList)
{
AssetDatabase.DeleteAsset(path);
}
}
private static void CreateMustAssets()
{
const string k_AutoGeneratedScriptsAsmref = k_GcAutoGeneratedScriptsDir + "/GameCanvas.asmref";
if (!AssetDatabase.IsValidFolder(k_UserResourceDir))
{
AssetDatabase.CreateFolder("Assets", "Res");
}
if (!AssetDatabase.IsValidFolder(k_GcAssetDir))
{
AssetDatabase.CreateFolder("Assets", "GameCanvas");
}
if (!AssetDatabase.IsValidFolder(k_GcResourcesDir))
{
AssetDatabase.CreateFolder(k_GcAssetDir, "Resources");
}
if (!AssetDatabase.IsValidFolder(k_GcAutoGeneratedScriptsDir))
{
AssetDatabase.CreateFolder(k_GcAssetDir, "AutoGeneratedScripts");
}
if (!File.Exists(k_AutoGeneratedScriptsAsmref))
{
File.WriteAllText(k_AutoGeneratedScriptsAsmref, @"{
""reference"": ""GameCanvas""
}");
AssetDatabase.ImportAsset(k_AutoGeneratedScriptsAsmref, ImportAssetOptions.ForceSynchronousImport);
}
if (!File.Exists(k_GcSpriteAtlasPath))
{
var assetDir = AssetDatabase.LoadAssetAtPath<Object>(k_UserResourceDir);
Assert.IsNotNull(assetDir, $"[GameCanvas] {k_UserResourceDir} is missing!");
var atlas = new SpriteAtlas();
atlas.Add(new[] { assetDir });
AssetDatabase.CreateAsset(atlas, k_GcSpriteAtlasPath);
}
}
private static string GenerateFieldName(in string path)
{
var fieldName = k_RegBadChars.Replace(ObjectNames.NicifyVariableName(path), "");
if (k_RegTopNumber.IsMatch(fieldName))
{
return "__" + fieldName;
}
return fieldName;
}
private static void GenerateResourceDefineScript(string className, in Dictionary<string, string> resourcePaths)
{
const string k_FolderPath = "Assets/GameCanvas/AutoGeneratedScripts";
var defines = resourcePaths
.OrderBy(elem => elem.Key)
.Select(path => $" public static readonly {className} {path.Key} = new(\"{path.Value}\");")
.ToArray();
var filePath = k_FolderPath + $"/{className}.cs";
var text = $@"/*------------------------------------------------------------*/
// <summary>GameCanvas for Unity</summary>
// <author>Seibe TAKAHASHI</author>
// <remarks>
// (c) 2015-2026 Smart Device Programming.
// This software is released under the MIT License.
// http://opensource.org/licenses/mit-license.php
// </remarks>
/*------------------------------------------------------------*/
#nullable enable
namespace GameCanvas
{{
public readonly partial struct {className} : System.IEquatable<{className}>
{{
internal const int __Length__ = {defines.Length};
{string.Join(System.Environment.NewLine, defines)}
}}
}}
";
if (File.Exists(filePath))
{
var old = File.ReadAllText(filePath);
if (text == old) return;
}
File.WriteAllText(filePath, text);
AssetDatabase.ImportAsset(filePath);
}
private static void GenerateResourceDefineScript(string className, in Dictionary<string, Rect> resourcePaths)
{
var defines = resourcePaths
.OrderBy(e => e.Key)
.Select(e => $" public static readonly {className} {GenerateFieldName(e.Key)} = new(\"{e.Key}\", {(int)e.Value.width}, {(int)e.Value.height});")
.ToArray();
var filePath = k_GcAutoGeneratedScriptsDir + $"/{className}.cs";
var text = $@"/*------------------------------------------------------------*/
// <summary>GameCanvas for Unity</summary>
// <author>Seibe TAKAHASHI</author>
// <remarks>
// (c) 2015-2026 Smart Device Programming.
// This software is released under the MIT License.
// http://opensource.org/licenses/mit-license.php
// </remarks>
/*------------------------------------------------------------*/
#nullable enable
namespace GameCanvas
{{
public readonly partial struct {className} : System.IEquatable<{className}>
{{
internal const int __Length__ = {defines.Length};
{string.Join(System.Environment.NewLine, defines)}
}}
}}
";
if (File.Exists(filePath))
{
var old = File.ReadAllText(filePath);
if (text == old) return;
}
File.WriteAllText(filePath, text);
AssetDatabase.ImportAsset(filePath);
}
private static void ValidateImages()
{
var assetPaths = AssetDatabase.FindAssets("t:Texture2D", k_SearchDir)
.Select(AssetDatabase.GUIDToAssetPath)
.ToArray();
foreach (var assetPath in assetPaths)
{
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if(importer == null) continue;
if (OnPreprocessTexture(importer))
{
importer.SaveAndReimport();
}
Resources.UnloadAsset(importer);
}
}
private static void ValidateSounds()
{
var assetPaths = AssetDatabase.FindAssets("t:AudioClip", k_SearchDir)
.Select(AssetDatabase.GUIDToAssetPath)
.ToArray();
foreach (var assetPath in assetPaths)
{
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(assetPath);
var importer = (AudioImporter)AssetImporter.GetAtPath(assetPath);
if (OnPostprocessAudio(importer, clip))
{
importer.SaveAndReimport();
}
Resources.UnloadAsset(importer);
Resources.UnloadAsset(clip);
}
}
#endregion
}
}
あとがき
現在これに関してPRを出しているので、次のバージョンなどでは治ってるかもしれません!