diff --git a/tools/monster/lib/monster-model.mjs b/tools/monster/lib/monster-model.mjs new file mode 100644 index 0000000..1b81403 --- /dev/null +++ b/tools/monster/lib/monster-model.mjs @@ -0,0 +1,78 @@ +// 몬스터 종별 모델(.model)과 맵 인스턴스 엔티티의 공용 빌더. +// 단일 소스: data/enemies.json의 appearance. fs 접근 없음(호출자가 skeleton 주입) — 테스트 용이. +const STR_TYPE = 'System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089'; +const SINGLE_TYPE = 'System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089'; +const VEC2_TYPE = 'MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null'; +const DICT_TYPE = 'MOD.Core.MODSyncDictionary`2[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null'; +const native = (type) => ({ $type: 'MODNativeType', type }); +const vec2 = (v) => ({ $type: 'MOD.Core.MODVector2, MOD.Core', x: v.x, y: v.y }); +const DAMAGE_SKIN_ID = '02c22d93421b4038b3c413b3e40b57ec'; + +export function modelEntryId(enemyId) { + return `monster-${enemyId}`; +} + +function requireAppearance(enemyId, enemy) { + if (!enemy?.appearance?.sheet?.stand) throw new Error(`[monster-model] ${enemyId}: appearance.sheet.stand 없음 — data/enemies.json 확인`); + return enemy.appearance; +} + +// .model 파일 전체 객체 생성 — ChaseMonster.model(skeleton)을 골격으로 복제·확장. +export function buildMonsterModel(enemyId, enemy, skeletonJson) { + const app = requireAppearance(enemyId, enemy); + const file = JSON.parse(JSON.stringify(skeletonJson)); // 순수성: 입력 비변형 + const json = file.ContentProto.Json; + file.EntryKey = `model://${modelEntryId(enemyId)}`; + json.Id = modelEntryId(enemyId); + json.Name = enemyId; + json.Components = json.Components + .filter((c) => !c.includes('AIWander') && !c.includes('AIChase')) + .concat(['MOD.Core.DamageSkinSettingComponent', 'script.CombatMonster']); + const setValue = (TargetType, Name, typeStr, Value) => { + json.Values = json.Values.filter((v) => !(v.TargetType === TargetType && v.Name === Name)); + json.Values.push({ TargetType, Name, ValueType: native(typeStr), Value }); + }; + setValue('MOD.Core.SpriteRendererComponent', 'SpriteRUID', STR_TYPE, app.sheet.stand); + setValue('MOD.Core.SpriteRendererComponent', 'SortingLayer', STR_TYPE, 'MapLayer0'); + setValue('MOD.Core.StateAnimationComponent', 'ActionSheet', DICT_TYPE, { ...app.sheet }); // 메이커 미해석 시 이 줄만 제거(런타임은 인스턴스 값 사용) + setValue('MOD.Core.HitComponent', 'BoxSize', VEC2_TYPE, vec2(app.box)); + setValue('MOD.Core.HitComponent', 'ColliderOffset', VEC2_TYPE, vec2(app.off)); + setValue('MOD.Core.MovementComponent', 'InputSpeed', SINGLE_TYPE, 0); + setValue('script.CombatMonster', 'EnemyId', STR_TYPE, enemyId); // 편의 베이크 — 실패해도 무해(인스턴스가 정본) + return file; +} + +// 맵 인스턴스 엔티티 — 현행 맵 몬스터 인스턴스 골격(map01 실측)과 동일 형태. +export function buildMonsterInstance({ enemyId, enemy, name, guid, mapTag, x, group }) { + const app = requireAppearance(enemyId, enemy); + const components = [ + { '@type': 'MOD.Core.TransformComponent', Position: { x, y: 0.03499998, z: 999.999 }, QuaternionRotation: { x: 0, y: 0, z: 0, w: 1 }, Scale: { x: 1, y: 1, z: 1 }, Enable: true }, + { '@type': 'MOD.Core.StateAnimationComponent', ActionSheet: { ...app.sheet }, Enable: true }, + { '@type': 'MOD.Core.SpriteRendererComponent', ActionSheet: {}, EndFrameIndex: 0, RenderSetting: 1, SortingLayer: 'MapLayer0', SpriteRUID: app.sheet.stand, StartFrameIndex: 0, Enable: true }, + { '@type': 'MOD.Core.RigidbodyComponent', MoveVelocity: { x: 0, y: 0 }, RealMoveVelocity: { x: 0, y: 0 }, Enable: true }, + { '@type': 'MOD.Core.MovementComponent', InputSpeed: 0, JumpForce: 6, Enable: false }, + { '@type': 'MOD.Core.StateComponent', IsLegacy: false, Enable: true }, + { '@type': 'MOD.Core.HitComponent', BoxSize: { x: app.box.x, y: app.box.y }, ColliderOffset: { x: app.off.x, y: app.off.y }, IsLegacy: false, Enable: true }, + { '@type': 'MOD.Core.DamageSkinSpawnerComponent', Enable: true }, + { '@type': 'script.Monster', Enable: true, IsDead: false }, + { '@type': 'script.MonsterAttack', Enable: true, SpriteSize: { x: 0, y: 0 }, PositionOffset: { x: 0, y: 0 } }, + { '@type': 'MOD.Core.KinematicbodyComponent', MoveVelocity: { x: 0, y: 0 }, Enable: true }, + { '@type': 'MOD.Core.SideviewbodyComponent', MoveVelocity: { x: 0, y: 0 }, Enable: true }, + { '@type': 'MOD.Core.DamageSkinSettingComponent', DamageSkinId: { DataId: DAMAGE_SKIN_ID }, Enable: true }, + { '@type': 'script.CombatMonster', Enable: true, EnemyId: enemyId, Group: group }, + ]; + const path = `/maps/map${mapTag}/${name}`; + return { + id: guid, + path, + componentNames: components.map((c) => c['@type']).join(','), + jsonString: { + name, path, nameEditable: true, enable: true, visible: true, localize: false, + displayOrder: 4, pathConstraints: '///', revision: 2, + origin: { type: 'Model', entry_id: enemyId, sub_entity_id: null, root_entity_id: guid, replaced_model_id: null }, + modelId: modelEntryId(enemyId), + '@components': components, + '@version': 1, + }, + }; +} diff --git a/tools/monster/monster-model.test.mjs b/tools/monster/monster-model.test.mjs new file mode 100644 index 0000000..92ff633 --- /dev/null +++ b/tools/monster/monster-model.test.mjs @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { buildMonsterModel, buildMonsterInstance, modelEntryId } from './lib/monster-model.mjs'; + +const skeleton = JSON.parse(readFileSync('Global/ChaseMonster.model', 'utf8')); +const enemy = { + name: '슬라임', maxHp: 45, intents: [], + appearance: { sheet: { stand: 'AAAA', hit: 'BBBB', die: 'CCCC' }, box: { x: 0.63, y: 0.58 }, off: { x: 0.045, y: 0.29 } }, +}; + +test('modelEntryId: monster- 접두', () => { + assert.equal(modelEntryId('slime'), 'monster-slime'); +}); + +test('buildMonsterModel: EntryKey/Id/Name 파생', () => { + const m = buildMonsterModel('slime', enemy, skeleton); + assert.equal(m.EntryKey, 'model://monster-slime'); + assert.equal(m.ContentProto.Json.Id, 'monster-slime'); + assert.equal(m.ContentProto.Json.Name, 'slime'); +}); + +test('buildMonsterModel: 외형·EnemyId 베이크 + AI-free + 컴포넌트 확장', () => { + const j = buildMonsterModel('slime', enemy, skeleton).ContentProto.Json; + assert.ok(j.Components.includes('script.CombatMonster')); + assert.ok(j.Components.includes('MOD.Core.DamageSkinSettingComponent')); + assert.ok(!j.Components.some((c) => c.includes('AIWander') || c.includes('AIChase'))); + const val = (t, n) => j.Values.find((v) => v.TargetType === t && v.Name === n)?.Value; + assert.equal(val('MOD.Core.SpriteRendererComponent', 'SpriteRUID'), 'AAAA'); + assert.deepEqual(val('MOD.Core.StateAnimationComponent', 'ActionSheet'), enemy.appearance.sheet); + assert.equal(val('script.CombatMonster', 'EnemyId'), 'slime'); + assert.equal(val('MOD.Core.MovementComponent', 'InputSpeed'), 0); + assert.equal(val('MOD.Core.HitComponent', 'BoxSize').x, 0.63); +}); + +test('buildMonsterModel: 원본 skeleton 비변형(순수 함수)', () => { + const before = JSON.stringify(skeleton); + buildMonsterModel('slime', enemy, skeleton); + assert.equal(JSON.stringify(skeleton), before); +}); + +test('buildMonsterInstance: 모델 연결·컴포넌트 값', () => { + const e = buildMonsterInstance({ enemyId: 'slime', enemy, name: 'slime', guid: '00000bb9-0000-4000-8000-000000000bb9', mapTag: '03', x: 3.4, group: 'elite' }); + assert.equal(e.jsonString.modelId, 'monster-slime'); + assert.equal(e.jsonString.origin.entry_id, 'slime'); + assert.equal(e.jsonString.origin.root_entity_id, e.id); + assert.equal(e.path, '/maps/map03/slime'); + const comp = (t) => e.jsonString['@components'].find((c) => c['@type'] === t); + assert.equal(comp('script.CombatMonster').EnemyId, 'slime'); + assert.equal(comp('script.CombatMonster').Group, 'elite'); + assert.equal(comp('MOD.Core.TransformComponent').Position.x, 3.4); + assert.equal(comp('MOD.Core.SpriteRendererComponent').SpriteRUID, 'AAAA'); + assert.deepEqual(comp('MOD.Core.StateAnimationComponent').ActionSheet, enemy.appearance.sheet); + assert.equal(comp('MOD.Core.MovementComponent').Enable, false); + assert.equal(e.componentNames, e.jsonString['@components'].map((c) => c['@type']).join(',')); +}); + +test('buildMonsterInstance: appearance 없는 적은 에러(fail-fast)', () => { + assert.throws(() => buildMonsterInstance({ enemyId: 'x', enemy: { name: 'x' }, name: 'x', guid: 'g', mapTag: '01', x: 1, group: 'combat' }), /appearance/); +});