Files
maplecontest/docs/superpowers/plans/2026-06-06-ten-maps.md
gahusb 989031239b 맵 10개 생성 구현 계획 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:42:19 +09:00

13 KiB

맵 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.mjsmap/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.configentries에 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_resourcescat="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 폴백).

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: 구문 확인
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node --check tools/gen-maps.mjs

Expected: 출력 없음(exit 0).

  • Step 3: 커밋
git add tools/gen-maps.mjs
git commit -m "맵 생성기 추가 (map01 템플릿 복제·배경/몬스터 주입)"

Task 3: map02 스파이크 — 생성 + Maker 렌더 검증

Files: Create map/map02.map

  • Step 1: map02만 생성
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-maps.mjs 2

Expected: Generated: map02

  • Step 2: 데이터 검증
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_playmaker_screenshot → Read로 확인: 배경이 map01과 다른 배경으로 표시되고 몬스터 2마리가 보이는지.
  4. maker_execute_script(client)로 몬스터 로드 확인:
    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: 전체 생성
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-maps.mjs

Expected: Generated: map02, map03, ... map11SectorConfig entries: 11

  • Step 2: 전체 데이터 검증
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_playmaker_screenshot로 배경이 서로 다르고 몬스터 2마리가 보이는지 확인. 맵 전환이 MCP로 안 되면 사용자에게 해당 맵 열기를 요청. 확인 후 maker_stop.


Task 5: 최종 커밋

Files: map/map02.map~map/map11.map, Global/SectorConfig.config

  • Step 1: 커밋
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 표본 맵 시각 확인