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:
@@ -23,7 +23,8 @@
|
|||||||
"map://map02",
|
"map://map02",
|
||||||
"map://map03",
|
"map://map03",
|
||||||
"map://map04",
|
"map://map04",
|
||||||
"map://map05"
|
"map://map05",
|
||||||
|
"map://lobby"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
6980
map/lobby.map
Normal file
6980
map/lobby.map
Normal file
File diff suppressed because it is too large
Load Diff
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