feat: UI 폰트 시스템 적용 및 HUD 타이포 보정

- 수성바탕체와 마루 부리 기반 UI 폰트 규칙, TMP 에셋 생성, 일괄 적용용 에디터 도구를 추가하고 로비 빌더도 같은 규칙을 따르도록 정리
- 기존 UI 프리팹과 Lobby/Test 씬의 TMP 폰트를 역할별로 교체하고 강조 텍스트와 HUD 계층에 맞는 자간을 반영
- 넥슨 Lv.2 고딕 검토 후 제외하고 액션바, HP/MP, 보스 체력바 숫자와 라벨을 마루 부리 기준으로 재조정
- Assets/_Game/Fonts 경로에 원본 폰트와 TMP 에셋을 정리하고 공유 Obsidian Vault 경로를 AGENTS에 기록
- Unity 리프레시와 HUD 보정 적용 후 콘솔 경고/에러 없는 상태를 확인
This commit is contained in:
2026-03-24 16:43:56 +09:00
parent fe53d90929
commit 829ff77e4b
29 changed files with 51722 additions and 135 deletions

View File

@@ -64,12 +64,12 @@ namespace Colosseum.Editor
vLayout.childForceExpandHeight = false;
connectPanel.AddComponent<ContentSizeFitter>();
CreateLabel(connectPanel.transform, "TitleLabel", "Colosseum Lobby", 36);
CreateLabel(connectPanel.transform, "TitleLabel", "Colosseum Lobby", 36, UIFontRole.Emphasis);
var ipInput = CreateInputField(connectPanel.transform, "IpInput", "Host IP (127.0.0.1)", 300, 50);
var portInput = CreateInputField(connectPanel.transform, "PortInput", "Port (7777)", 300, 50);
var hostBtn = CreateButton(connectPanel.transform, "HostButton", "Host", 200, 50);
var joinBtn = CreateButton(connectPanel.transform, "JoinButton", "Join", 200, 50);
var statusText = CreateLabel(connectPanel.transform, "StatusText", "", 18);
var statusText = CreateLabel(connectPanel.transform, "StatusText", "", 18, UIFontRole.Body);
statusText.color = Color.yellow;
// ── LobbyPanel ────────────────────────────────────────
@@ -87,7 +87,7 @@ namespace Colosseum.Editor
vLayout2.childForceExpandWidth = false;
vLayout2.childForceExpandHeight = false;
CreateLabel(lobbyPanel.transform, "LobbyTitle", "Waiting Room", 32);
CreateLabel(lobbyPanel.transform, "LobbyTitle", "Waiting Room", 32, UIFontRole.Emphasis);
// PlayerList: ScrollView 역할을 하는 VerticalLayout 컨테이너
var playerListGO = new GameObject("PlayerList");
@@ -123,9 +123,10 @@ namespace Colosseum.Editor
slotTmp.text = "Player";
slotTmp.fontSize = 20;
slotTmp.alignment = TextAlignmentOptions.MidlineLeft;
UIFontSetupTool.ApplyRole(slotTmp, UIFontRole.Body);
System.IO.Directory.CreateDirectory(Application.dataPath + "/_Game/Prefabs/UI");
var slotPrefab = PrefabUtility.SaveAsPrefabAsset(slotGO, "Assets/_Game/Prefabs/UI/PlayerSlot.prefab");
var slotPrefab = PrefabUtility.SaveAsPrefabAsset(slotGO, "Assets/_Game/Prefabs/UI/UI_PlayerSlot.prefab");
Object.DestroyImmediate(slotGO);
// ── LobbyUI 연결 ──────────────────────────────────────
@@ -297,6 +298,7 @@ namespace Colosseum.Editor
phTmp.text = placeholder;
phTmp.fontSize = 18;
phTmp.color = new Color(0.5f, 0.5f, 0.5f);
UIFontSetupTool.ApplyRole(phTmp, UIFontRole.Combat);
var txtGO = new GameObject("Text");
txtGO.transform.SetParent(textArea.transform, false);
@@ -307,6 +309,7 @@ namespace Colosseum.Editor
var txt = txtGO.AddComponent<TextMeshProUGUI>();
txt.fontSize = 18;
txt.color = Color.white;
UIFontSetupTool.ApplyRole(txt, UIFontRole.Combat);
input.textViewport = taRect;
input.placeholder = phTmp;
@@ -335,11 +338,12 @@ namespace Colosseum.Editor
tmp.fontSize = 20;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
UIFontSetupTool.ApplyRole(tmp, UIFontRole.Combat);
return go;
}
private static TextMeshProUGUI CreateLabel(Transform parent, string name, string text, int size)
private static TextMeshProUGUI CreateLabel(Transform parent, string name, string text, int size, UIFontRole role = UIFontRole.Body)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
@@ -350,6 +354,7 @@ namespace Colosseum.Editor
tmp.fontSize = size;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
UIFontSetupTool.ApplyRole(tmp, role);
return tmp;
}

View File

@@ -0,0 +1,579 @@
using System;
using System.IO;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TextCore.LowLevel;
using UnityEngine.UI;
namespace Colosseum.Editor
{
/// <summary>
/// UI 폰트 역할
/// </summary>
public enum UIFontRole
{
Emphasis,
Body,
Combat,
}
/// <summary>
/// 프로젝트 UI 폰트 에셋 생성 및 적용 도구
/// </summary>
public static class UIFontSetupTool
{
private const string InitialCharacterSet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 " +
".,:;!?+-=*/%()[]{}<>#&_'\"|~" +
"검투사콜로세움보스체력마나준비게임시작나가기대기실승리패배연결호스트참가플레이어";
public const string FontRootPath = "Assets/_Game/Fonts";
public const string FontAssetRootPath = "Assets/_Game/Fonts/TMP";
public const string MaruSourceFontPath = FontRootPath + "/MaruBuri-Regular.ttf";
public const string SuseongSourceFontPath = FontRootPath + "/SuseongBatang.ttf";
public const string MaruFontAssetPath = FontAssetRootPath + "/TMP_MaruBuri.asset";
public const string SuseongFontAssetPath = FontAssetRootPath + "/TMP_SuseongBatang.asset";
[MenuItem("Colosseum/UI/TMP 폰트 에셋 생성")]
public static void GenerateFontAssetsMenu()
{
EnsureFontAssets(true);
}
[MenuItem("Colosseum/UI/UI 폰트 규칙 적용")]
public static void ApplyFontRulesMenu()
{
if (!EnsureFontAssets(true))
{
return;
}
int changedPrefabCount = ApplyPrefabFontRules();
int changedSceneCount = ApplySceneFontRules();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[UIFontSetupTool] UI 폰트 규칙 적용 완료. Prefab {changedPrefabCount}개, Scene {changedSceneCount}개를 갱신했습니다.");
}
[MenuItem("Colosseum/UI/HUD 타이포 보정 적용")]
public static void ApplyHudTypographyTuningMenu()
{
if (!EnsureFontAssets(true))
{
return;
}
int changedPrefabCount = ApplyHudPrefabTuning();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[UIFontSetupTool] HUD 타이포 보정 완료. Prefab {changedPrefabCount}개를 갱신했습니다.");
}
/// <summary>
/// 역할에 맞는 TMP 폰트를 텍스트에 적용합니다.
/// </summary>
public static bool ApplyRole(TMP_Text text, UIFontRole role)
{
if (text == null || !TryGetFontAsset(role, out TMP_FontAsset fontAsset))
{
return false;
}
bool changed = false;
if (text.font != fontAsset)
{
text.font = fontAsset;
changed = true;
}
if (text.fontSharedMaterial != fontAsset.material)
{
text.fontSharedMaterial = fontAsset.material;
changed = true;
}
float targetCharacterSpacing = role == UIFontRole.Emphasis ? GetEmphasisCharacterSpacing(text) : 0f;
if (!Mathf.Approximately(text.characterSpacing, targetCharacterSpacing))
{
text.characterSpacing = targetCharacterSpacing;
changed = true;
}
if (changed)
{
EditorUtility.SetDirty(text);
}
return changed;
}
/// <summary>
/// 역할에 맞는 TMP 폰트 에셋을 반환합니다.
/// </summary>
public static bool TryGetFontAsset(UIFontRole role, out TMP_FontAsset fontAsset)
{
fontAsset = role switch
{
UIFontRole.Emphasis => AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(SuseongFontAssetPath),
UIFontRole.Body => AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(MaruFontAssetPath),
UIFontRole.Combat => AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(MaruFontAssetPath),
_ => null,
};
return fontAsset != null;
}
private static bool EnsureFontAssets(bool logResult)
{
EnsureFolder(FontRootPath);
EnsureFolder(FontAssetRootPath);
bool success = true;
success &= CreateFontAssetIfNeeded(MaruSourceFontPath, MaruFontAssetPath, "TMP_MaruBuri", logResult);
success &= CreateFontAssetIfNeeded(SuseongSourceFontPath, SuseongFontAssetPath, "TMP_SuseongBatang", logResult);
if (success)
{
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
return success;
}
private static bool CreateFontAssetIfNeeded(string sourceFontPath, string fontAssetPath, string assetName, bool logResult)
{
TMP_FontAsset existingFontAsset = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(fontAssetPath);
if (existingFontAsset != null && IsFontAssetUsable(existingFontAsset))
{
WarmUpFontAsset(existingFontAsset);
return true;
}
if (existingFontAsset != null)
{
AssetDatabase.DeleteAsset(fontAssetPath);
}
Font sourceFont = AssetDatabase.LoadAssetAtPath<Font>(sourceFontPath);
if (sourceFont == null)
{
if (logResult)
{
Debug.LogError($"[UIFontSetupTool] 원본 폰트를 찾을 수 없습니다: {sourceFontPath}");
}
return false;
}
TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(
sourceFont,
90,
9,
GlyphRenderMode.SDFAA,
1024,
1024,
AtlasPopulationMode.Dynamic,
true);
fontAsset.name = assetName;
AssetDatabase.CreateAsset(fontAsset, fontAssetPath);
AttachSubAssets(fontAsset, fontAssetPath);
WarmUpFontAsset(fontAsset);
if (logResult)
{
Debug.Log($"[UIFontSetupTool] TMP 폰트 에셋 생성: {fontAssetPath}");
}
return true;
}
private static void WarmUpFontAsset(TMP_FontAsset fontAsset)
{
if (fontAsset == null)
{
return;
}
fontAsset.ReadFontAssetDefinition();
fontAsset.TryAddCharacters(InitialCharacterSet, out _);
EditorUtility.SetDirty(fontAsset);
}
private static void AttachSubAssets(TMP_FontAsset fontAsset, string fontAssetPath)
{
if (fontAsset == null)
{
return;
}
if (fontAsset.material != null && AssetDatabase.GetAssetPath(fontAsset.material) != fontAssetPath)
{
AssetDatabase.AddObjectToAsset(fontAsset.material, fontAsset);
}
Texture2D[] atlasTextures = fontAsset.atlasTextures;
if (atlasTextures == null)
{
return;
}
foreach (Texture2D atlasTexture in atlasTextures)
{
if (atlasTexture == null || AssetDatabase.GetAssetPath(atlasTexture) == fontAssetPath)
{
continue;
}
AssetDatabase.AddObjectToAsset(atlasTexture, fontAsset);
}
}
private static bool IsFontAssetUsable(TMP_FontAsset fontAsset)
{
if (fontAsset == null || fontAsset.material == null)
{
return false;
}
Texture2D[] atlasTextures = fontAsset.atlasTextures;
return atlasTextures != null && atlasTextures.Length > 0 && atlasTextures[0] != null;
}
private static int ApplyPrefabFontRules()
{
string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/_Game/Prefabs/UI" });
int changedCount = 0;
foreach (string guid in prefabGuids)
{
string prefabPath = AssetDatabase.GUIDToAssetPath(guid);
GameObject root = PrefabUtility.LoadPrefabContents(prefabPath);
try
{
if (ApplyFontRules(root))
{
PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
changedCount++;
}
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
return changedCount;
}
private static int ApplyHudPrefabTuning()
{
string[] prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/_Game/Prefabs/UI" });
int changedCount = 0;
foreach (string guid in prefabGuids)
{
string prefabPath = AssetDatabase.GUIDToAssetPath(guid);
GameObject root = PrefabUtility.LoadPrefabContents(prefabPath);
try
{
if (TuneHudTypography(root))
{
PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
changedCount++;
}
}
finally
{
PrefabUtility.UnloadPrefabContents(root);
}
}
return changedCount;
}
private static int ApplySceneFontRules()
{
string originalScenePath = SceneManager.GetActiveScene().path;
string[] sceneGuids = AssetDatabase.FindAssets("t:Scene", new[] { "Assets/Scenes" });
int changedCount = 0;
foreach (string guid in sceneGuids)
{
string scenePath = AssetDatabase.GUIDToAssetPath(guid);
Scene scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
bool changed = false;
foreach (GameObject rootObject in scene.GetRootGameObjects())
{
changed |= ApplyFontRules(rootObject);
}
if (changed)
{
EditorSceneManager.SaveScene(scene);
changedCount++;
}
}
if (!string.IsNullOrEmpty(originalScenePath) && File.Exists(originalScenePath))
{
EditorSceneManager.OpenScene(originalScenePath, OpenSceneMode.Single);
}
return changedCount;
}
private static bool ApplyFontRules(GameObject root)
{
bool changed = false;
TMP_Text[] texts = root.GetComponentsInChildren<TMP_Text>(true);
foreach (TMP_Text text in texts)
{
UIFontRole role = DetermineRole(text);
changed |= ApplyRole(text, role);
}
return changed;
}
private static bool TuneHudTypography(GameObject root)
{
if (root == null || ContainsIgnoreCase(root.name, "Lobby"))
{
return false;
}
bool changed = false;
TMP_Text[] texts = root.GetComponentsInChildren<TMP_Text>(true);
foreach (TMP_Text text in texts)
{
string hierarchyPath = GetHierarchyPath(text.transform);
if (root.name == "UI_ActionBar_Item" || root.name == "UI_ActionBar_EvadeItem")
{
changed |= TuneActionBarTypography(text, hierarchyPath);
continue;
}
if (root.name == "UI_PlayerResources")
{
changed |= TunePlayerResourcesTypography(text, hierarchyPath);
continue;
}
if (root.name == "UI_HealthBar" || root.name == "UI_ManaBar")
{
changed |= SetTextStyle(text, 30f, FontStyles.Bold, -1f);
continue;
}
if (root.name == "UI_BossHealthBar")
{
changed |= TuneBossBarTypography(text, hierarchyPath);
}
}
return changed;
}
private static UIFontRole DetermineRole(TMP_Text text)
{
string objectName = text.gameObject.name;
string hierarchyPath = GetHierarchyPath(text.transform);
if (IsEmphasisText(objectName, hierarchyPath))
{
return UIFontRole.Emphasis;
}
if (IsCombatText(text, objectName, hierarchyPath))
{
return UIFontRole.Combat;
}
return UIFontRole.Body;
}
private static bool IsEmphasisText(string objectName, string hierarchyPath)
{
return ContainsIgnoreCase(objectName, "Title")
|| ContainsIgnoreCase(objectName, "BossName")
|| ContainsIgnoreCase(objectName, "Victory")
|| ContainsIgnoreCase(objectName, "GameOver")
|| ContainsIgnoreCase(hierarchyPath, "/Title")
|| ContainsIgnoreCase(hierarchyPath, "/BossName");
}
private static bool IsCombatText(TMP_Text text, string objectName, string hierarchyPath)
{
return text.GetComponentInParent<Button>(true) != null
|| text.GetComponentInParent<TMP_InputField>(true) != null
|| ContainsIgnoreCase(objectName, "HP")
|| ContainsIgnoreCase(objectName, "MP")
|| ContainsIgnoreCase(objectName, "XP")
|| ContainsIgnoreCase(objectName, "Cooldown")
|| ContainsIgnoreCase(objectName, "Key")
|| ContainsIgnoreCase(objectName, "Duration")
|| ContainsIgnoreCase(objectName, "Status")
|| ContainsIgnoreCase(hierarchyPath, "ActionBar")
|| ContainsIgnoreCase(hierarchyPath, "HealthBar")
|| ContainsIgnoreCase(hierarchyPath, "ManaBar")
|| ContainsIgnoreCase(hierarchyPath, "PlayerResources")
|| ContainsIgnoreCase(hierarchyPath, "Abnormality")
|| ContainsIgnoreCase(hierarchyPath, "BossHealthBar");
}
private static float GetEmphasisCharacterSpacing(TMP_Text text)
{
string objectName = text.gameObject.name;
if (ContainsIgnoreCase(objectName, "GameOver") || ContainsIgnoreCase(objectName, "Victory"))
{
return 5f;
}
if (ContainsIgnoreCase(objectName, "BossName"))
{
return 3f;
}
return 4f;
}
private static bool TuneActionBarTypography(TMP_Text text, string hierarchyPath)
{
if (text == null)
{
return false;
}
if (ContainsIgnoreCase(hierarchyPath, "Label_CooldownTime"))
{
return SetTextStyle(text, 24f, FontStyles.Bold, -1f);
}
if (ContainsIgnoreCase(hierarchyPath, "/Input/Input"))
{
return SetTextStyle(text, 20f, FontStyles.Bold, 0f);
}
return false;
}
private static bool TunePlayerResourcesTypography(TMP_Text text, string hierarchyPath)
{
if (text == null || !ContainsIgnoreCase(hierarchyPath, "Label_XP"))
{
return false;
}
float fontSize = ContainsIgnoreCase(hierarchyPath, "/HUD_XPBar/") ? 24f : 28f;
return SetTextStyle(text, fontSize, FontStyles.Bold, -1f);
}
private static bool TuneBossBarTypography(TMP_Text text, string hierarchyPath)
{
if (text == null)
{
return false;
}
if (ContainsIgnoreCase(hierarchyPath, "Label_HP"))
{
return SetTextStyle(text, 30f, FontStyles.Bold, -1f);
}
if (ContainsIgnoreCase(hierarchyPath, "Label_BossName"))
{
return SetTextStyle(text, 34f, FontStyles.Normal, 3f);
}
return false;
}
private static bool SetTextStyle(TMP_Text text, float fontSize, FontStyles fontStyle, float characterSpacing)
{
bool changed = false;
if (!Mathf.Approximately(text.fontSize, fontSize))
{
text.fontSize = fontSize;
changed = true;
}
if (text.fontStyle != fontStyle)
{
text.fontStyle = fontStyle;
changed = true;
}
if (!Mathf.Approximately(text.characterSpacing, characterSpacing))
{
text.characterSpacing = characterSpacing;
changed = true;
}
if (changed)
{
EditorUtility.SetDirty(text);
}
return changed;
}
private static string GetHierarchyPath(Transform transform)
{
string path = transform.name;
while (transform.parent != null)
{
transform = transform.parent;
path = $"{transform.name}/{path}";
}
return path;
}
private static bool ContainsIgnoreCase(string source, string value)
{
return source?.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static void EnsureFolder(string path)
{
if (AssetDatabase.IsValidFolder(path))
{
return;
}
string[] parts = path.Split('/');
string currentPath = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string nextPath = $"{currentPath}/{parts[i]}";
if (!AssetDatabase.IsValidFolder(nextPath))
{
AssetDatabase.CreateFolder(currentPath, parts[i]);
}
currentPath = nextPath;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c686d635a98a6b4baaad969bc633ca3