사람·팀원·AI가 공유하는
몬스터 제작 규격
팀원과 AI가 같은 제작 규칙으로 작업을 이어갈 수 있도록 Monster Maker를 만들었습니다. Draft와 Profile로 제작 입력을 관리하고, 미리보기에서 모션과 효과를 확인한 뒤 자산을 생성합니다.
공통 제작 규격
사람이 만든 결과를 AI가 이어받고, 다른 팀원이 다시 검토할 수 있는 환경을 목표로 했습니다. 다양한 몬스터를 공통 입력 형식과 검증·저장 규칙에 맞춰 제작하도록 했습니다.
- 01
- 같은 입력 규격ID·필드·Step·Binding 규칙
- 02
- 같은 검증 단계검증·중복 차단·저장 경계
- 03
- 이어지는 작업사람 → AI → 다른 사람

GUI와 API의 공통 작성 서비스
문제와 해결 방식
문제
작업자마다 몬스터 생성·공격 구성·자산 등록 방식이 달라지면 결과를 이어받을 때마다 구조를 다시 해석해야 했습니다. 수작업 복제는 Runtime 참조 누락·GUID 변경·부분 저장 위험도 함께 만들었습니다.
적용 방식
Draft와 Profile을 공통 입력 계약으로 두고 Preview·Validator·Writer를 한 제작 순서로 연결했습니다. 공격 액티브 영역은 사람용 공방과 AI용 API가 동일한 작성 서비스를 호출하도록 구성했습니다.
다른 방법
문서와 체크리스트만 배포하거나 GUI와 API의 저장 로직을 각각 구현할 수도 있습니다. 규칙 변경 때 두 경로가 어긋날 수 있어, 작성 서비스와 검증 규칙을 코드의 공통 경계로 모았습니다.
몬스터 제작 규칙을 표준화했습니다
공통 규격을 사용하면 다른 작업자가 만든 몬스터도 익숙한 구조로 확인하고 수정할 수 있습니다.
입력 규격
ID, 입력 필드, 공격 Step과 Binding 키를 공통 형식으로 유지합니다.
작업 순서
Draft → Preview → Validate → Writer 순서로 확인과 생산을 연결합니다.
검증 단계
지원 조합, 중복 ID, 소유권, 저장 결과와 Catalog 등록을 검사합니다.
작업 인계
같은 Profile과 Draft를 열어 사람과 AI의 결과를 이어서 조정합니다.
TryCreate / TryUpdateValidate / Create / Update제작 작업Monster Maker는 Draft에서 Runtime 자산을 만들고 Catalog에 등록합니다. JSON API는 공격 액티브 프리셋의 검증·생성·수정을 지원합니다.
- 01Preflight입력·소유권 검사
- 02Capture파일·GUID·복구 범위
- 03Write대상 자산 저장
- 04ReloadDefinition 재검증
- 05RegisterCatalog·GUID 확인
- 06Commit전체 후조건 통과
실패 처리사전 검사에서 오류가 발견되면 쓰기를 중단합니다. 생성 중 오류가 발생하면 확보한 파일의 복구를 시도합니다. 복구도 실패하면 생성 오류와 복구 오류를 함께 예외로 전달합니다.
코드에서 처리한 내용
- Draft와 Profile에 공통 입력 형식과 제작 순서를 고정했습니다.
- 공격 액티브 공방과 AI용 JSON API를 하나의 작성 서비스에 연결했습니다.
- 저장된 Definition의 참조, Catalog 등록 결과와 기존 GUID를 확인합니다.
- 후조건 통과 뒤 Commit하며, 예외와 검증 실패는 캡처된 범위의 복구 경로로 모읍니다.
GUI와 AI API가 같은 작성 서비스를 호출합니다
공격 액티브 공방은 새 프리셋 저장과 현재 프리셋 수정에 MonsterActiveAttackAuthoringService를 사용합니다. AI용 JSON API의 검증·생성·수정도 같은 서비스를 호출합니다. API가 제공하는 계약에는 지원하는 Enum, 요청 예시, Step 규칙과 VFX/SFX 생성 규칙이 포함됩니다.
구현 근거MonsterWorkshopV2Window.cs 보기MonsterActiveAttackAuthoringApi.cs 보기MonsterActiveAttackAuthoringService.cs 보기MonsterActiveAttackAuthoringApi.cs 보기
제작자가 고르는 값과 도구가 확인하는 값
제작자는 모델·모션·타격 Marker·효과를 고릅니다. Writer는 입력값을 받아 Runtime에서 참조할 자산을 만들고 저장 결과를 확인합니다. 운영 경로에서는 기본 산출물 9개와 Catalog 4개를 다루며, 설정에 따라 고유 패시브·액티브 산출물이 추가됩니다.
구현 근거MonsterMakerAssetWriter.cs 보기MonsterMakerAssetWriter.cs 보기
쓰는 범위와 완료 조건
| 경계 | 실제 처리 | 막으려는 문제 |
|---|---|---|
| 쓰기 전 | Draft 소유권·출력 경로를 검사하고 파일·폴더와 기존 GUID를 확보합니다. | 다른 제작 원본의 산출물을 덮어쓰거나 복구 범위를 놓치는 문제 |
| 산출물 저장 후 | Definition을 다시 불러와 Runtime 참조와 필수 값을 검사합니다. | 메모리에서는 연결됐지만 저장 결과에는 누락된 참조 |
| 운영 등록 후 | Catalog별 ID·등급·AI 연결과 기존 GUID를 확인한 뒤 Commit합니다. | 파일만 있고 게임에서는 찾을 수 없는 자산 |
| 중간 실패 | 캡처한 파일·폴더 범위로 Rollback을 시도하고, 복구 실패도 별도 예외로 전달합니다. | 부분 생성 상태를 성공으로 처리하는 문제 |
구현 근거MonsterMakerAssetWriter.cs 보기MonsterMakerAssetWriter.cs 보기
이번에 만든 자산만 저장합니다
BuildAndRegister는 이번 제작 대상 자산만 저장합니다. 다른 작업자의 미저장 변경은 그대로 둡니다.
남은 과제
몬스터 전체 Draft 작성과 Catalog 등록을 API로 연결하는 작업이 남아 있습니다.
담당 내용
Draft·Profile 규격, GUI·API 공용 작성 서비스, Preview, 자산 생성·검사, Catalog 등록과 Rollback을 구현했습니다.
관련 코드
생산 전 검증과 변경 범위 확보MonsterMakerAssetWriter.cs
public static MonsterMakerWriteResult BuildAndRegister(
MonsterMakerDraft draft,
MonsterCatalog catalog = null,
MonsterRarityCatalog rarityCatalog = null)
{
draft?.EditorSyncActiveAttackAuthoring();
var preflight = MonsterMakerValidator.Validate(draft);
if (preflight.HasErrors)
{
throw new InvalidOperationException(BuildIssueText(preflight.Issues));
}
catalog ??= AssetDatabase.LoadAssetAtPath<MonsterCatalog>(MonsterCatalogPath);
rarityCatalog ??= AssetDatabase.LoadAssetAtPath<MonsterRarityCatalog>(MonsterRarityCatalogPath);
if (catalog == null || rarityCatalog == null)
{
throw new InvalidOperationException("MonsterCatalog 또는 MonsterRarityCatalog을 찾을 수 없습니다.");
}
var paths = BuildPaths(draft.MonsterId);
var generatesUniquePassive = draft.UsePassiveSkill &&
draft.RarityPassiveSkill is GenericMonsterPassiveSkill;
var generatesAttackActive = draft.UseActiveSkill &&
draft.Rarity >= MonsterRarity.Legendary &&
draft.HasActiveProfile;
var passivePath = BuildPassivePath(draft.MonsterId);
var extraPaths = new List<string>();
if (generatesUniquePassive) extraPaths.Add(passivePath);
if (generatesAttackActive) extraPaths.Add(BuildActivePath(draft.MonsterId));
var outputPaths = paths.Concat(extraPaths).ToArray();
var dataFolder = DataRoot + "/" + draft.MonsterId;
var artFolder = ArtRoot + "/" + draft.MonsterId;
var catalogPath = RequirePersistentAssetPath(catalog, "MonsterCatalog");
var rarityCatalogPath = RequirePersistentAssetPath(rarityCatalog, "MonsterRarityCatalog");
ValidateProductionDraftOwnership(draft, catalogPath, rarityCatalogPath);
var writesProductionAiCatalog =
string.Equals(catalogPath, MonsterCatalogPath, StringComparison.OrdinalIgnoreCase) &&
string.Equals(rarityCatalogPath, MonsterRarityCatalogPath, StringComparison.OrdinalIgnoreCase);
var transactionPaths = outputPaths.Concat(new[] { catalogPath, rarityCatalogPath });
if (writesProductionAiCatalog)
{
transactionPaths = transactionPaths.Concat(new[]
{
CastleRaidAIProfileCatalogPath,
MainBattleAIProfileCatalogPath
});
}
var transaction = MonsterMakerWriteTransaction.Capture(
transactionPaths,
new[] { dataFolder, artFolder });
try
{
EnsureFolder("Assets/ProjectMT/02_Shared/Unit/Data", "Monsters");
EnsureFolder(DataRoot, draft.MonsterId);
EnsureFolder("Assets/ProjectMT/05_Art", "Monsters");
생성물·Catalog·GUID 검증과 복구MonsterMakerAssetWriter.cs
definition = AssetDatabase.LoadAssetAtPath<MonsterDefinition>(paths[0]);
var outputValidation = MonsterDefinitionValidator.Validate(definition, true);
if (outputValidation.HasErrors)
{
throw new InvalidOperationException(BuildRuntimeIssueText(outputValidation.Issues));
}
RegisterLast(catalog, rarityCatalog, definition, draft, uniquePassive, attackActive);
SaveAssetsIfDirty(catalog, rarityCatalog, castleRaidAiCatalog, mainBattleAiCatalog);
AssetDatabase.Refresh();
if (!catalog.TryGet(draft.MonsterId, out var registered) || registered != definition)
{
throw new InvalidOperationException("생성물 검증 뒤 MonsterCatalog 등록을 확인하지 못했습니다.");
}
if (!rarityCatalog.TryGetRarity(draft.MonsterId, out var rarity) || rarity != draft.Rarity)
{
throw new InvalidOperationException("생성물 검증 뒤 MonsterRarityCatalog 등록을 확인하지 못했습니다.");
}
var assignedAiProfile = castleRaidAiCatalog?.Resolve(draft.MonsterId);
if (castleRaidAiCatalog != null && (assignedAiProfile == null ||
assignedAiProfile.Pattern != draft.CastleRaidAiPattern))
{
throw new InvalidOperationException("생성물 검증 뒤 Hex Castle Raid AI Profile 등록을 확인하지 못했습니다.");
}
if (mainBattleAiCatalog != null &&
(!mainBattleAiCatalog.TryResolve(draft.MonsterId, out var mainBattleProfile) ||
mainBattleProfile.Role != draft.MainBattleRole ||
mainBattleProfile.TargetPriority != draft.MainBattleTargetPriority))
{
throw new InvalidOperationException("생성물 검증 뒤 MainBattle AI Profile 등록을 확인하지 못했습니다.");
}
var guidAfter = outputPaths.ToDictionary(path => path, AssetDatabase.AssetPathToGUID);
for (var index = 0; index < outputPaths.Length; index++)
{
var before = guidBefore[outputPaths[index]];
if (!string.IsNullOrEmpty(before) && !string.Equals(before, guidAfter[outputPaths[index]], StringComparison.Ordinal))
{
throw new InvalidOperationException("기존 Asset GUID가 변경되었습니다: " + outputPaths[index]);
}
}
var result = new MonsterMakerWriteResult(
definition,
updatedExisting,
outputPaths,
guidBefore,
guidAfter,
outputValidation);
transaction.Commit();
return result;
}
catch (Exception buildException)
{
try
{
transaction.Rollback();
}
catch (Exception rollbackException)
{
throw new AggregateException(
"Monster Maker 생성과 원상복구가 모두 실패했습니다.",
buildException,
rollbackException);
}
throw;
몬스터 1종의 기본 산출물 경로MonsterMakerAssetWriter.cs
public static IReadOnlyList<string> BuildPaths(string monsterId)
{
var data = DataRoot + "/" + monsterId;
var art = ArtRoot + "/" + monsterId;
return new[]
{
data + "/MD_" + monsterId + ".asset",
data + "/MB_" + monsterId + ".asset",
data + "/MM_" + monsterId + ".asset",
data + "/MC_" + monsterId + ".asset",
data + "/MA_" + monsterId + ".asset",
data + "/MF_" + monsterId + ".asset",
data + "/MR_" + monsterId + ".asset",
art + "/AC_" + monsterId + ".controller",
art + "/PF_" + monsterId + "_VisualAdapter.prefab"
};
사람용 공방이 호출하는 공통 저장 경계MonsterWorkshopV2Window.cs
private void SaveCurrentAsNew()
{
if (mode == WorkshopMode.Basic) basicSession.SaveAsNew(out _);
else if (mode == WorkshopMode.Attack)
{
attackSerialized.ApplyModifiedProperties();
if (MonsterActiveAttackAuthoringService.TryCreate(attackWorking, out var created, out _, out var error))
{ LoadAttack(created); attackMessage = "새 공격 액티브 프리셋을 저장했습니다."; }
else attackMessage = "오류: " + error;
}
else
{
effectSerialized.ApplyModifiedProperties();
if (MonsterEffectActiveAuthoringService.TryCreate(effectWorking, out var created, out _, out var error))
{ LoadEffect(created); effectMessage = "새 효과형 액티브 프리셋을 저장했습니다."; }
else effectMessage = "오류: " + error;
}
libraryDirty = true;
RebuildCurrent();
}
private void UpdateCurrent()
{
if (mode == WorkshopMode.Basic) basicSession.UpdateLoaded();
else if (mode == WorkshopMode.Attack)
{
attackSerialized.ApplyModifiedProperties();
if (MonsterActiveAttackAuthoringService.TryUpdate(attackWorking, attackLoaded, out var error))
{
var loaded = attackLoaded;
LoadAttack(loaded);
NotifyActiveProfileUpdated();
attackMessage = "현재 공격 액티브 프리셋을 저장했습니다.";
}
else attackMessage = "오류: " + error;
}
AI용 JSON API의 검증·생성·수정 경로MonsterActiveAttackAuthoringApi.cs
public static string ValidateProfileJson(string json)
{
if (!TryBuildProfile(json, out var profile, out var error))
{
return BuildResult(false, "validate", string.Empty, string.Empty, error);
}
try
{
var success = MonsterActiveAttackAuthoringService.TryValidate(profile, null, out error);
return BuildResult(success, "validate", profile.ProfileId, string.Empty, error);
}
finally
{
UnityEngine.Object.DestroyImmediate(profile);
}
}
public static string CreateProfileFromJson(string json)
{
if (!TryBuildProfile(json, out var profile, out var error))
{
return BuildResult(false, "create", string.Empty, string.Empty, error);
}
try
{
var success = MonsterActiveAttackAuthoringService.TryCreate(
profile,
out var createdAsset,
out var assetPath,
out error);
return BuildResult(success, "create", createdAsset != null ? createdAsset.ProfileId : profile.ProfileId,
assetPath, error);
}
finally
{
UnityEngine.Object.DestroyImmediate(profile);
}
}
public static string UpdateProfileFromJson(string assetPath, string json)
{
var target = string.IsNullOrWhiteSpace(assetPath)
? null
: AssetDatabase.LoadAssetAtPath<MonsterActiveAttackProfile>(assetPath);
if (target == null)
{
return BuildResult(false, "update", string.Empty, assetPath,
$"업데이트할 공격 액티브 프로필을 찾지 못했습니다: {assetPath}");
}
if (!TryBuildProfile(json, out var profile, out var error))
{
return BuildResult(false, "update", target.ProfileId, assetPath, error);
}
try
{
var success = MonsterActiveAttackAuthoringService.TryUpdate(profile, target, out error);
return BuildResult(success, "update", target.ProfileId, assetPath, error);
}
finally
{
UnityEngine.Object.DestroyImmediate(profile);
}
}
GUI와 API가 공유하는 검증 서비스MonsterActiveAttackAuthoringService.cs
public static class MonsterActiveAttackAuthoringService // GUI와 선택형 API가 공유하는 단일 프로필 저장 경계
{
public const string ProfileRoot = "Assets/ProjectMT/02_Shared/Unit/Data/ActiveAttackProfiles";
public const string CustomProfileRoot = ProfileRoot + "/Custom";
public static bool TryValidate(
MonsterActiveAttackProfile source,
MonsterActiveAttackProfile excludedProfile,
out string error)
{
if (source == null)
{
error = "공격 액티브 작업 사본이 없습니다.";
return false;
}
if (!source.TryValidate(out error)) return false;
for (var stepIndex = 0; stepIndex < source.Steps.Count; stepIndex++)
{
var step = source.Steps[stepIndex];
if (MonsterActiveAttackBlockContractTemplates.TryValidateCurrent(step, out error)) continue;
error = $"Step {stepIndex + 1:00}의 VFX/SFX 계약이 유효하지 않습니다. {error}";
return false;
}
if (excludedProfile != null && !string.Equals(
source.ProfileId,
excludedProfile.ProfileId,
StringComparison.OrdinalIgnoreCase))
{
error = $"저장된 프리셋의 ID는 바꿀 수 없습니다. 새 ID가 필요하면 새 프리셋으로 분기하세요: {excludedProfile.ProfileId}";
return false;
}
var guids = AssetDatabase.IsValidFolder(ProfileRoot)
? AssetDatabase.FindAssets("t:MonsterActiveAttackProfile", new[] { ProfileRoot })
: Array.Empty<string>();
for (var index = 0; index < guids.Length; index++)
{
var candidate = AssetDatabase.LoadAssetAtPath<MonsterActiveAttackProfile>(
AssetDatabase.GUIDToAssetPath(guids[index]));
if (candidate == null || candidate == excludedProfile) continue;
if (!string.Equals(candidate.ProfileId, source.ProfileId, StringComparison.OrdinalIgnoreCase)) continue;
error = $"같은 프리셋 ID가 이미 있습니다: [{candidate.ProfileId}] {candidate.DisplayName}";
return false;
}
error = string.Empty;
return true;
}
AI가 읽을 수 있는 작성 계약MonsterActiveAttackAuthoringApi.cs
internal sealed class MonsterActiveAttackAuthoringContract
{
public int schemaVersion = 2;
public string scope = "한 번 호출할 때 공격 액티브 프로필 하나만 작성합니다.";
public string stepIdentity = "Step ID는 step_01부터 순서대로, 표시 이름은 공격 형태에서 자동 생성합니다.";
public string vfxSfxContract = "각 Step의 VFX/SFX 공간은 기본공격 공용 계약에서 자동 생성합니다.";
public string dataOwnership = "기본공격 프리셋을 가져와도 독립 복사하며 기본공격 자산을 참조 연결하지 않습니다.";
public string multiHit = "hitCount 0은 공격 형태 기본값, 직접 공격은 1~3타, 왕복 투사체는 2타 고정입니다.";
public string damageRange = "useDamageRange를 켜면 각 Step 실행 때 damageMultiplier~maximumDamageMultiplier를 한 번 독립 추첨합니다.";
public string[] operations;
public string[] patterns;
public string[] progressions;
public string[] targetPolicies;
public string[] projectileFormations;
public string[] instantMagicTargets;
public string[] magicDirections;
public string[] hitEffectTypes;
public MonsterActiveAttackAuthoringRequest example;
}
public static class MonsterActiveAttackAuthoringApi // 폴더째 제거 가능한 AI용 단일 프리셋 어댑터
{
public static string GetContractJson()
{
var contract = new MonsterActiveAttackAuthoringContract
{
operations = new[] { "ValidateProfileJson", "CreateProfileFromJson", "UpdateProfileFromJson" },
patterns = Enum.GetNames(typeof(MonsterActiveAttackPattern)),
progressions = Enum.GetNames(typeof(MonsterActiveAttackProgression)),
targetPolicies = Enum.GetNames(typeof(MonsterActiveTargetPolicy)),
projectileFormations = Enum.GetNames(typeof(MonsterActiveProjectileFormation)),
instantMagicTargets = Enum.GetNames(typeof(MonsterActiveInstantMagicTarget)),
magicDirections = Enum.GetNames(typeof(MonsterActiveMagicDirection)),
hitEffectTypes = Enum.GetNames(typeof(MonsterActiveHitEffectType)),
example = CreateExampleRequest()
};
return JsonUtility.ToJson(contract, true);
}



