diff --git a/docs/superpowers/plans/2026-06-06-ten-maps.md b/docs/superpowers/plans/2026-06-06-ten-maps.md new file mode 100644 index 0000000..5a8ff37 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-ten-maps.md @@ -0,0 +1,273 @@ +# 맵 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 표본 맵 시각 확인