using System; using System.Collections.Generic; using System.Linq; using Northbound.Data; using UnityEditor; using UnityEngine; namespace Northbound.Editor { /// /// ScriptableObject를 단일 원본으로 사용하는 게임 데이터의 참조와 값 범위를 검사합니다. /// public static class ScriptableObjectDataValidator { private const string DataRoot = "Assets/Data/ScriptableObjects"; [MenuItem("Tools/Northbound/Data/Validate All ScriptableObjects")] public static void ValidateAll() { int errors = 0; int warnings = 0; List towers = LoadAll(); List monsters = LoadAll(); List creeps = LoadAll(); List players = LoadAll(); List upgrades = LoadAll(); List settings = LoadAll(); ValidateUniqueIds(towers, data => data.id, "Tower", ref errors); ValidateUniqueIds(monsters, data => data.id, "Monster", ref errors); ValidateUniqueIds(creeps, data => data.id, "Creep", ref errors); ValidateUniqueIds(players, data => data.id, "Player", ref errors); ValidateUniqueIds(upgrades, data => data.id, "Upgrade", ref errors); ValidateUniqueIds(settings, data => data.id, "DefaultSettings", ref errors); ValidateTowers(towers, ref errors, ref warnings); ValidateUnits(monsters, data => data.id, data => data.maxHp, data => data.moveSpeed, data => data.cost, data => data.weight, "Monster", ref errors); ValidateUnits(creeps, data => data.id, data => data.maxHp, data => data.moveSpeed, data => data.cost, data => data.weight, "Creep", ref errors); ValidatePlayers(players, ref errors); ValidateUpgrades(upgrades, ref errors, ref warnings); ValidateSettings(settings, ref errors, ref warnings); string summary = $"[SO Data Validation] {towers.Count} towers, {monsters.Count} monsters, " + $"{creeps.Count} creeps, {players.Count} players, {upgrades.Count} upgrades, " + $"{settings.Count} settings — {errors} errors, {warnings} warnings"; if (errors > 0) Debug.LogError(summary); else if (warnings > 0) Debug.LogWarning(summary); else Debug.Log(summary); } private static List LoadAll() where T : ScriptableObject { return AssetDatabase.FindAssets($"t:{typeof(T).Name}", new[] { DataRoot }) .Select(AssetDatabase.GUIDToAssetPath) .Select(AssetDatabase.LoadAssetAtPath) .Where(asset => asset != null) .OrderBy(asset => asset.name) .ToList(); } private static void ValidateUniqueIds(IEnumerable assets, Func getId, string dataType, ref int errors) where T : UnityEngine.Object { var ids = new Dictionary(); foreach (T asset in assets) { int id = getId(asset); if (id <= 0) { Error($"{dataType} ID는 1 이상이어야 합니다: {asset.name}", asset, ref errors); continue; } if (ids.TryGetValue(id, out T existing)) Error($"{dataType} ID {id}가 중복됩니다: {existing.name}, {asset.name}", asset, ref errors); else ids.Add(id, asset); } } private static void ValidateTowers(List towers, ref int errors, ref int warnings) { var towerIds = towers.Select(data => data.id).ToHashSet(); foreach (TowerData data in towers) { if (string.IsNullOrWhiteSpace(data.buildingName)) Error($"Tower {data.id}의 표시 이름이 비어 있습니다.", data, ref errors); if (data.prefab == null) Error($"Tower {data.id}의 프리팹이 없습니다.", data, ref errors); if (data.mana < 0 || data.manpower < 0) Error($"Tower {data.id}의 비용은 음수일 수 없습니다.", data, ref errors); if (data.maxHp <= 0) Error($"Tower {data.id}의 체력은 1 이상이어야 합니다.", data, ref errors); if (data.sizeX <= 0 || data.sizeY <= 0 || data.sizeZ <= 0) Error($"Tower {data.id}의 배치 크기는 모두 1 이상이어야 합니다.", data, ref errors); if (data.upgradeTo > 0 && !towerIds.Contains(data.upgradeTo)) Error($"Tower {data.id}의 업그레이드 대상 {data.upgradeTo}가 없습니다.", data, ref errors); if (data.atkDamage > 0 && data.atkRange <= 0) Warn($"Tower {data.id}는 공격력이 있지만 사거리가 없습니다.", data, ref warnings); } } private static void ValidateUnits(IEnumerable units, Func getId, Func getHealth, Func getSpeed, Func getCost, Func getWeight, string dataType, ref int errors) where T : UnityEngine.Object { foreach (T data in units) { int id = getId(data); if (getHealth(data) <= 0) Error($"{dataType} {id}의 체력은 1 이상이어야 합니다.", data, ref errors); if (getSpeed(data) < 0) Error($"{dataType} {id}의 이동 속도는 음수일 수 없습니다.", data, ref errors); if (getCost(data) <= 0 || getWeight(data) <= 0) Error($"{dataType} {id}의 생성 비용과 가중치는 0보다 커야 합니다.", data, ref errors); } } private static void ValidatePlayers(IEnumerable players, ref int errors) { foreach (PlayerData data in players) { if (data.maxHp <= 0 || data.moveSpeed <= 0) Error($"Player {data.id}의 체력과 이동 속도는 0보다 커야 합니다.", data, ref errors); } } private static void ValidateUpgrades(List upgrades, ref int errors, ref int warnings) { var ids = upgrades.Select(data => data.id).ToHashSet(); foreach (UpgradeData data in upgrades) { if (string.IsNullOrWhiteSpace(data.memo)) Warn($"Upgrade {data.id}의 표시용 메모가 비어 있습니다.", data, ref warnings); if (data.mana < 0) Error($"Upgrade {data.id}의 비용은 음수일 수 없습니다.", data, ref errors); if (data.effectStatList.Count != data.effectOpList.Count || data.effectStatList.Count != data.effectValueList.Count) Error($"Upgrade {data.id}의 효과 목록 길이가 서로 다릅니다.", data, ref errors); foreach (int prerequisiteId in data.requireUpgradeId) { if (prerequisiteId == data.id) Error($"Upgrade {data.id}가 자기 자신을 선행 조건으로 참조합니다.", data, ref errors); else if (!ids.Contains(prerequisiteId)) Error($"Upgrade {data.id}의 선행 조건 {prerequisiteId}가 없습니다.", data, ref errors); } } } private static void ValidateSettings(IEnumerable settings, ref int errors, ref int warnings) { var keys = new HashSet(StringComparer.Ordinal); foreach (DefaultSettingsData data in settings) { if (string.IsNullOrWhiteSpace(data.key)) { Warn($"DefaultSettings {data.id}의 키가 비어 있습니다.", data, ref warnings); continue; } string scopedKey = $"{data.scene}:{data.key}"; if (!keys.Add(scopedKey)) Error($"DefaultSettings 키가 중복됩니다: {scopedKey}", data, ref errors); } } private static void Error(string message, UnityEngine.Object context, ref int count) { count++; Debug.LogError($"[SO Data Validation] {message}", context); } private static void Warn(string message, UnityEngine.Object context, ref int count) { count++; Debug.LogWarning($"[SO Data Validation] {message}", context); } } }