Files
maplecontest/tools/map/gen-lobby-map.mjs
gahusb 989e3fe000 fix(lobby): 플레이테스트 — 이동 복원에 InputSpeed 필수 + FixedLookAt int 타입 (P15)
메이커 실측으로 확인:
- 이동에는 RigidbodyComponent.WalkAcceleration과 MovementComponent.InputSpeed가 둘 다 양수여야 함(WalkAccel만으론 안 걸림). LobbyMobility에 InputSpeed=5·JumpForce=5 추가.
- pc.FixedLookAt은 boolean이 아니라 int32 → false→0 (빌드 에러 해소).
- PlayerLock에 InputSpeed/JumpForce=0 대칭 재잠금 추가(전투맵 누설 방어).
- NPC 베이스 모델 inheritance 경고는 비치명적이라 proven-good(모델 유지) 결정 주석화.

검증: 로비 이동·점프, NpcCodex 근접(d=1.10<1.2)·↑키→카드 도감, 런 시작→map01 텔레포트+이동 잠금(InputSpeed=0), 로비 복귀→이동 재해제 전부 정상.

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

142 lines
7.3 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;
// NOTE: 베이스 모델(chasemonster)이 script.Monster/MonsterAttack를 inheritance로 끌고 와서
// DuplicateComponent(LobbyNpc와 공존) 경고 + MonsterAttack.OnBeginPlay AnimationClip 에러가 뜬다.
// 둘 다 비치명적(로비엔 전투 컨텍스트가 없어 Monster는 휴면, 에러는 전투맵 몬스터와 공유되는 기존 lint).
// modelId를 비우면 fresh load에서 렌더가 깨질 위험이 있어 proven-good(모델 유지)로 둔다.
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}`);