Files
maplecontest/tools/map/gen-lobby-map.mjs
gahusb a309da2a99 feat(lobby): 로비 전용 맵 + NPC 4종 월드 엔티티 생성기 (P15)
map01 클론→lobby.map(헤네시스 배경), 몬스터 제거, NPC 4종(공식 메이플 NPC 스프라이트)+머리위 마크 배치. 각 NPC에 TouchReceiveComponent+script.LobbyNpc, 루트에 script.LobbyMobility(PlayerLock 제거). SectorConfig map://lobby 등록.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:27:44 +09:00

138 lines
6.8 KiB
JavaScript

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}`);