# 맵 10개 생성 (랜덤 배경 + 몬스터 2마리) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `map01`을 템플릿으로 독립 맵 10개(`map02`~`map11`)를 생성하고, 맵마다 다른 공식 배경 + 랜덤 위치 몬스터 2마리를 배치한다. **Architecture:** Node 생성기 `tools/gen-maps.mjs`가 `map/map01.map`을 JSON으로 읽어 맵마다 deep-clone → 경로/EntryKey/name 치환, 전 엔티티 GUID 재발급(자기참조 보정), `Background.TemplateRUID` 교체, 몬스터 2마리 배치 → `map/mapNN.map`(JSON.stringify)로 기록. `SectorConfig.config`에 등록. 몬스터 다양화(A)는 `MONSTER_VARIANTS` 데이터로 주입하며, map02 스파이크로 렌더 검증 후 10개로 확대(실패 시 B=기존 몬스터 폴백). **Tech Stack:** Node.js(ESM, 표준 라이브러리), MSW `.map`(JSON 엔티티), msw-mcp 에셋 검색, msw-maker-mcp reload/play/screenshot/execute_script. --- ## File Structure - Create: `tools/gen-maps.mjs` — 맵 생성기 (템플릿 클론·GUID 재발급·배경/몬스터 주입·SectorConfig 갱신). - Create: `map/map02.map` ~ `map/map11.map` — 생성 결과. - Modify: `Global/SectorConfig.config` — `entries`에 map02~map11 추가. 배경 RUID 풀(공식 라이브러리, 확보 완료, 10개): `79c95db9fdbb4c4796771733d069e3e2`, `1d4a335a5416401f8e289d78a03fd0c3`, `731a9cd1cce045e19d50fdcdc9a20be9`, `695805b1809243fd9376e2bba113ebde`, `454804df4c7e4701997ec8a8de088597`, `01992685f5d147b3a5c18fabf584807f`, `c861e9cb2d0b4d91be5d4d6aedf796b1`, `ee2e13a352d64611906760c1b722df67`, `8e89019c54d14aed875e54f13fa14109`, `fa936edd365f47e4b5622c19b1a80a0c` 맵 구조(map01): 엔티티 `/maps/map01`(Map+Foothold), `/Background`(BackgroundComponent.TemplateRUID), `/MapleMapLayer`, `/TileMap`, `/SpawnLocation`, 몬스터들(componentNames에 `script.Monster` 포함: StaticMonsterTemplate/MoveMonsterTemplate/ChaseMonsterTemplate/monster-43). 엔티티 id는 대시 GUID(8-4-4-4-12), 리소스 RUID는 대시 없는 32hex. --- ### Task 1: 라이브러리 몬스터 변형 후보 조사 (컨트롤러/MCP, 타임박스) **목표:** 완결된 라이브러리 몬스터 변형(스프라이트 + stand/hit/die 액션 RUID 세트)을 ≥3종 확보 시도. 액션 그룹핑/이름을 얻을 수 없으면 **B 폴백**(빈 변형)으로 결정. - [ ] **Step 1: 라이브러리 몬스터 리소스 조사** MCP `asset_search_resources`로 `cat="animationclip"`/`"sprite"`, `source="maplestory"`, `query`로 몬스터 후보를 찾고, 가능하면 `detail=true` 및 메타데이터로 action(stand/hit/die) 식별을 시도한다. - [ ] **Step 2: 변형 세트 확정 또는 폴백 결정** 각 변형을 `{ sprite, stand, hit, die }`(RUID) 형태로 ≥3개 확보하면 → 그 배열을 Task 2의 `MONSTER_VARIANTS`로 사용. 액션 식별이 불가하거나 불확실하면 → `MONSTER_VARIANTS = []`로 두고 **B 폴백**(기존 템플릿 몬스터 그대로 사용)으로 진행한다. 결정 결과를 한 줄로 기록(`log` 또는 보고). > 이 태스크의 산출물은 "MONSTER_VARIANTS 배열(또는 빈 배열) + 결정 사유" 한 가지다. 코드 변경 없음. --- ### Task 2: 생성기 작성 **Files:** Create `tools/gen-maps.mjs` - [ ] **Step 1: 스크립트 작성** `tools/gen-maps.mjs`에 아래를 그대로 작성한다. `MONSTER_VARIANTS`는 Task 1 결과로 채우거나 빈 배열로 둔다(빈 배열 = B 폴백). ```js import { readFileSync, writeFileSync } from 'node:fs'; const TEMPLATE = 'map/map01.map'; const SECTOR = 'Global/SectorConfig.config'; const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; // 공식 라이브러리 배경 RUID 풀 (맵마다 1개씩, 서로 다르게) const BACKGROUNDS = [ '79c95db9fdbb4c4796771733d069e3e2', '1d4a335a5416401f8e289d78a03fd0c3', '731a9cd1cce045e19d50fdcdc9a20be9', '695805b1809243fd9376e2bba113ebde', '454804df4c7e4701997ec8a8de088597', '01992685f5d147b3a5c18fabf584807f', 'c861e9cb2d0b4d91be5d4d6aedf796b1', 'ee2e13a352d64611906760c1b722df67', '8e89019c54d14aed875e54f13fa14109', 'fa936edd365f47e4b5622c19b1a80a0c', ]; // Task 1 결과. 비어 있으면 기존 템플릿 몬스터를 그대로 사용(B 폴백). // 각 항목: { sprite, stand, hit, die } (모두 RUID 문자열) const MONSTER_VARIANTS = []; // 결정론적 시드 RNG (맵 번호 기반) function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } // 결정론적 대시 GUID (맵번호, 인덱스) function mapGuid(nn, idx) { const n = (nn * 1000 + idx) >>> 0; const h8 = n.toString(16).padStart(8, '0'); const h12 = n.toString(16).padStart(12, '0'); return `${h8}-0000-4000-8000-${h12}`; } const isMonster = (e) => (e.componentNames || '').includes('script.Monster'); const compOf = (e, type) => e.jsonString['@components'].find((c) => c['@type'] === type); const template = JSON.parse(readFileSync(TEMPLATE, 'utf8')); const monsterTemplates = template.ContentProto.Entities.filter(isMonster); if (monsterTemplates.length === 0) throw new Error('템플릿에서 몬스터 엔티티를 못 찾음'); function buildMap(nn) { const tag = String(nn).padStart(2, '0'); const rand = rng(nn * 7919); const map = JSON.parse(JSON.stringify(template)); // deep clone map.EntryKey = `map://map${tag}`; // 비-몬스터 엔티티만 유지 const ents = map.ContentProto.Entities.filter((e) => !isMonster(e)); // 몬스터 2마리 추가 (템플릿 몬스터 복제) for (let i = 0; i < 2; i++) { const src = monsterTemplates[Math.floor(rand() * monsterTemplates.length)]; const m = JSON.parse(JSON.stringify(src)); m.jsonString.name = `Monster${i + 1}`; m.path = `/maps/map${tag}/Monster${i + 1}`; m.jsonString.path = m.path; const tr = compOf(m, 'MOD.Core.TransformComponent'); if (tr) tr.Position.x = Math.round((rand() * 8 - 4) * 100) / 100; // -4..4 바닥 위 if (MONSTER_VARIANTS.length > 0) { const v = MONSTER_VARIANTS[Math.floor(rand() * MONSTER_VARIANTS.length)]; const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); if (sp) sp.SpriteRUID = v.sprite; const sa = compOf(m, 'MOD.Core.StateAnimationComponent'); if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die }; } ents.push(m); } // 경로/이름 치환 + 배경 설정 for (const e of ents) { if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', `/maps/map${tag}`); if (e.jsonString) { if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', `/maps/map${tag}`); if (e.jsonString.name === 'map01') e.jsonString.name = `map${tag}`; } if ((e.path || '').endsWith('/Background')) { const bg = compOf(e, 'MOD.Core.BackgroundComponent'); if (bg) bg.TemplateRUID = BACKGROUNDS[(nn - 2) % BACKGROUNDS.length]; } } // GUID 재발급 (자기참조 root/sub_entity_id 보정) ents.forEach((e, idx) => { const oldId = e.id; const newId = mapGuid(nn, 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(`map/map${tag}.map`, JSON.stringify(map, null, 2), 'utf8'); return `map${tag}`; } // 인자: 생성할 맵 번호(미지정 시 전체). 예: node tools/gen-maps.mjs 2 const arg = process.argv[2]; const targets = arg ? [Number(arg)] : MAP_NUMBERS; const made = targets.map(buildMap); console.log('Generated:', made.join(', ')); // SectorConfig 등록 (전체 생성 시에만, 중복 방지) if (!arg) { const sector = JSON.parse(readFileSync(SECTOR, 'utf8')); const entries = sector.ContentProto.Json.Sectors[0].entries; for (const nn of MAP_NUMBERS) { const key = `map://map${String(nn).padStart(2, '0')}`; if (!entries.includes(key)) entries.push(key); } writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8'); console.log('SectorConfig entries:', entries.length); } ``` - [ ] **Step 2: 구문 확인** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node --check tools/gen-maps.mjs ``` Expected: 출력 없음(exit 0). - [ ] **Step 3: 커밋** ```bash git add tools/gen-maps.mjs git commit -m "맵 생성기 추가 (map01 템플릿 복제·배경/몬스터 주입)" ``` --- ### Task 3: map02 스파이크 — 생성 + Maker 렌더 검증 **Files:** Create `map/map02.map` - [ ] **Step 1: map02만 생성** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node tools/gen-maps.mjs 2 ``` Expected: `Generated: map02` - [ ] **Step 2: 데이터 검증** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node -e "const j=JSON.parse(require('fs').readFileSync('map/map02.map','utf8'));const E=j.ContentProto.Entities;console.log('EntryKey:',j.EntryKey);const ids=E.map(e=>e.id);console.log('unique ids:', new Set(ids).size===ids.length);console.log('monsters:', E.filter(e=>(e.componentNames||'').includes('script.Monster')).length);const bg=E.find(e=>(e.path||'').endsWith('/Background'));console.log('bg RUID:', bg.jsonString['@components'].find(c=>c['@type']==='MOD.Core.BackgroundComponent').TemplateRUID);console.log('paths ok:', E.every(e=>!(e.path||'').includes('/maps/map01')))" ``` Expected: `EntryKey: map://map02`, `unique ids: true`, `monsters: 2`, `bg RUID:` 가 배경 풀의 첫 값(`79c95db9...`), `paths ok: true`. - [ ] **Step 3: Maker에서 map02 열어 렌더 검증 (컨트롤러)** 1. `maker_refresh_workspace` (edit) 2. Maker에서 map02를 활성 맵으로 연다(에디터에서 map02 더블클릭). MCP로 직접 맵 전환이 안 되면, 사용자에게 "Maker에서 map02 열기"를 요청한다. 3. `maker_play` → `maker_screenshot` → Read로 확인: **배경이 map01과 다른 배경으로 표시**되고 **몬스터 2마리가 보이는지**. 4. `maker_execute_script`(client)로 몬스터 로드 확인: ```lua local m1 = _EntityService:GetEntityByPath("/maps/map02/Monster1") local m2 = _EntityService:GetEntityByPath("/maps/map02/Monster2") log("M1="..tostring(m1~=nil).." M2="..tostring(m2~=nil)) ``` → `maker_logs(normal)`에서 `M1=true M2=true` 확인. 5. `maker_stop`. - [ ] **Step 4: 게이트 판정** - 배경·몬스터 정상 → 그대로 진행(Task 4). - 배경이 흰/검 박스이거나 몬스터 안 보임: - 배경 문제: 배경 RUID 풀이 로컬 워크스페이스에서 로드 안 됨 → 사용자와 상의(공식 배경 로드 가능 여부). 우선 다른 배경 RUID로 교체 시도. - 몬스터 변형(A) 문제(MONSTER_VARIANTS 사용 중일 때만): `MONSTER_VARIANTS = []`로 비우고(B 폴백) Step 1부터 재실행. - ui 되돌리기 필요 시: `git checkout map/map02.map` 후 재생성. --- ### Task 4: 나머지 맵 생성 + SectorConfig 등록 **Files:** Create `map/map03.map`~`map/map11.map`, Modify `Global/SectorConfig.config` - [ ] **Step 1: 전체 생성** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node tools/gen-maps.mjs ``` Expected: `Generated: map02, map03, ... map11` 와 `SectorConfig entries: 11` - [ ] **Step 2: 전체 데이터 검증** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node -e "const fs=require('fs');let allIds=new Set(),dup=false,bgs=new Set();for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const j=JSON.parse(fs.readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;if(j.EntryKey!=='map://map'+t)throw new Error('EntryKey '+t);if(E.filter(e=>(e.componentNames||'').includes('script.Monster')).length!==2)throw new Error('monsters '+t);for(const e of E){if(allIds.has(e.id))dup=true;allIds.add(e.id);}bgs.add(E.find(e=>(e.path||'').endsWith('/Background')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.BackgroundComponent').TemplateRUID);}const sec=JSON.parse(fs.readFileSync('Global/SectorConfig.config','utf8'));console.log('cross-map id dup:',dup);console.log('distinct backgrounds:',bgs.size);console.log('sector entries:',sec.ContentProto.Json.Sectors[0].entries.length)" ``` Expected: `cross-map id dup: false`, `distinct backgrounds: 10`, `sector entries: 11`. - [ ] **Step 3: Maker 표본 검증 (컨트롤러)** `maker_refresh_workspace` 후, 표본 맵 2~3개(map05, map08, map11)를 각각 열어 `maker_play`→`maker_screenshot`로 배경이 서로 다르고 몬스터 2마리가 보이는지 확인. 맵 전환이 MCP로 안 되면 사용자에게 해당 맵 열기를 요청. 확인 후 `maker_stop`. --- ### Task 5: 최종 커밋 **Files:** `map/map02.map`~`map/map11.map`, `Global/SectorConfig.config` - [ ] **Step 1: 커밋** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" git add map/map02.map map/map03.map map/map04.map map/map05.map map/map06.map map/map07.map map/map08.map map/map09.map map/map10.map map/map11.map Global/SectorConfig.config git commit -m "맵 10개(map02~map11) 생성: 랜덤 배경 + 몬스터 2마리, sector 등록" ``` --- ## 검증 요약 - 생성기 `node --check` 통과 - map02 스파이크: 데이터(고유 id/2몬스터/배경) + Maker 렌더(배경 상이·몬스터 2)로 A/B 게이트 판정 - 전체: cross-map id 중복 없음, 배경 10종 distinct, sector 11개 - Maker 표본 맵 시각 확인