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>
This commit is contained in:
137
tools/map/gen-lobby-map.mjs
Normal file
137
tools/map/gen-lobby-map.mjs
Normal file
@@ -0,0 +1,137 @@
|
||||
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}`);
|
||||
Reference in New Issue
Block a user