import { readFileSync, writeFileSync } from 'node:fs'; // 로비 전용 맵 생성기 — map01 템플릿을 클론해 마을(타운) 배경의 로비 맵을 만든다. // · 몬스터 엔티티 전부 제거(전투 없음) // · NPC 4종(모험가/사서/상인/안내원) 월드 엔티티 배치 + 머리 위 마크(근접 시 표시) // · 각 NPC: TouchReceiveComponent(클릭) + script.LobbyNpc(NpcId) // · 맵 루트: script.PlayerLock 제거(이동 허용) + script.LobbyMobility 추가(이동·공격 해제) // · SectorConfig에 map://lobby 등록 // codeblock 로직(LobbyNpc/LobbyMobility)은 tools/player/gen-lobby-npc.mjs가 emit한다. const TEMPLATE = 'map/map01.map'; const OUT = 'map/lobby.map'; const SECTOR = 'Global/SectorConfig.config'; const TOWN_BG = '65c4167ea7484196b890022354e5a4a4'; // Henesys (gen-maps.mjs BACKGROUNDS 풀) const MARK_RUID = 'bd4afdde295f40318fceb4166978ebaa'; // 공식 maplestory balloon (근접 마크) // NPC 4종: x좌표는 정찰 기준 walkable 범위[-5,6.6], 근접 임계 1.2와 분리되게 배치 const NPCS = [ { name: 'NpcRun', id: 'run', x: -3.0, ruid: '122095fd155c4633867b0da4f375bc3c' }, // 모험가 { name: 'NpcCodex', id: 'codex', x: -0.5, ruid: '4c264be6a64f4ac3970b2e6818d04e40' }, // 사서 { name: 'NpcShop', id: 'shop', x: 2.0, ruid: '69987ccdc486423f8bedd786bd6cb5d9' }, // 상인 { name: 'NpcBoard', id: 'board', x: 4.5, ruid: '8a99bd87d667482cb1f3b2193f8a19c1' }, // 안내원 ]; const MARK_DY = 1.6; // NPC 머리 위 오프셋 // NPC/마크는 정적 스프라이트로 만든다 — 몬스터 AI·물리(중력/충돌)·히트 컴포넌트 전부 제거. // (마크는 물리가 있으면 머리 위에서 떨어지고, NPC는 충돌이 있으면 플레이어 통행을 막음) const STRIP = new Set([ 'script.Monster', 'script.MonsterAttack', 'script.CombatMonster', 'MOD.Core.RigidbodyComponent', 'MOD.Core.MovementComponent', 'MOD.Core.KinematicbodyComponent', 'MOD.Core.SideviewbodyComponent', 'MOD.Core.HitComponent', 'MOD.Core.DamageSkinSpawnerComponent', 'MOD.Core.DamageSkinSettingComponent', ]); const compOf = (e, type) => e.jsonString['@components'].find((c) => c['@type'] === type); const isMonster = (e) => (e.componentNames || '').includes('script.Monster'); // 결정론 GUID — 기존 생성기(map: nn*1000+idx, enc: +500)와 충돌 없는 고유 오프셋 function lobbyGuid(idx) { const n = (900000 + idx) >>> 0; return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; } // 몬스터 템플릿 엔티티를 클론해 스프라이트 엔티티(NPC/마크)로 변환 function makeSpriteEntity(base, name, x, y, ruid, withInteract, npcId) { const m = JSON.parse(JSON.stringify(base)); m.jsonString.name = name; m.path = `/maps/lobby/${name}`; m.jsonString.path = m.path; const tr = compOf(m, 'MOD.Core.TransformComponent'); if (tr) { tr.Position.x = x; tr.Position.y = y; } const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); if (sp) sp.SpriteRUID = ruid; const sa = compOf(m, 'MOD.Core.StateAnimationComponent'); if (sa) sa.ActionSheet = { stand: ruid, hit: ruid, die: ruid }; // 항상 stand 스프라이트 고정 // 몬스터 AI·물리·히트 컴포넌트 제거 → 정적 비충돌 스프라이트 m.jsonString['@components'] = m.jsonString['@components'].filter((c) => !STRIP.has(c['@type'])); let names = (m.componentNames || '').split(',').filter((s) => s && !STRIP.has(s)); if (withInteract) { m.jsonString['@components'].push({ '@type': 'MOD.Core.TouchReceiveComponent', Enable: true, AutoFitToSize: true }); m.jsonString['@components'].push({ '@type': 'script.LobbyNpc', Enable: true, NpcId: npcId, MarkName: name + 'Mark' }); names.push('MOD.Core.TouchReceiveComponent', 'script.LobbyNpc'); } m.componentNames = names.join(','); return m; } const template = JSON.parse(readFileSync(TEMPLATE, 'utf8')); const monsterTemplates = template.ContentProto.Entities.filter(isMonster); if (monsterTemplates.length === 0) throw new Error('[gen-lobby-map] 몬스터 템플릿(스프라이트 엔티티) 없음'); const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0]; const baseY = (() => { const tr = compOf(base, 'MOD.Core.TransformComponent'); return tr ? tr.Position.y : 0; })(); const map = JSON.parse(JSON.stringify(template)); // deep clone map.EntryKey = 'map://lobby'; let ents = map.ContentProto.Entities.filter((e) => !isMonster(e)); // 경로/이름 치환 + 배경 + 루트 컴포넌트 조정 for (const e of ents) { if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', '/maps/lobby'); if (e.jsonString) { if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', '/maps/lobby'); if (e.jsonString.name === 'map01') e.jsonString.name = 'lobby'; } if ((e.path || '').endsWith('/Background')) { const bg = compOf(e, 'MOD.Core.BackgroundComponent'); if (bg) bg.TemplateRUID = TOWN_BG; } } const root = ents.find((e) => e.path === '/maps/lobby'); if (!root) throw new Error('[gen-lobby-map] 맵 루트 없음'); // 로비엔 PlayerLock 제거(이동 허용) + LobbyMobility 추가(이동·공격 해제). MapCamera는 유지. root.jsonString['@components'] = root.jsonString['@components'].filter( (c) => !['script.PlayerLock', 'script.LobbyMobility'].includes(c['@type']), ); root.jsonString['@components'].push({ '@type': 'script.LobbyMobility', Enable: true }); { const names = (root.componentNames || '') .split(',') .filter((s) => s && !['script.PlayerLock', 'script.LobbyMobility'].includes(s)); names.push('script.LobbyMobility'); root.componentNames = names.join(','); } // NPC + 마크 엔티티 생성 for (const npc of NPCS) { ents.push(makeSpriteEntity(base, npc.name, npc.x, baseY, npc.ruid, true, npc.id)); ents.push(makeSpriteEntity(base, npc.name + 'Mark', npc.x, baseY + MARK_DY, MARK_RUID, false, '')); } // GUID 전부 재발급 (map01과 충돌 방지 + 자기참조 origin 보정) ents.forEach((e, idx) => { const oldId = e.id; const newId = lobbyGuid(idx); e.id = newId; const o = e.jsonString && e.jsonString.origin; if (o) { if (o.root_entity_id === oldId) o.root_entity_id = newId; if (o.sub_entity_id === oldId) o.sub_entity_id = newId; } }); map.ContentProto.Entities = ents; writeFileSync(OUT, JSON.stringify(map, null, 2), 'utf8'); // SectorConfig 등록 (멱등) const sector = JSON.parse(readFileSync(SECTOR, 'utf8')); const sec0 = sector.ContentProto.Json.Sectors[0]; if (!sec0.entries.includes('map://lobby')) sec0.entries.push('map://lobby'); writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8'); const npcCount = ents.filter((e) => (e.componentNames || '').includes('script.LobbyNpc')).length; console.log(`[gen-lobby-map] lobby.map 생성: NPC ${npcCount}종 + 마크, SectorConfig entries ${sec0.entries.length}`);