맵 10개 추가: 맵별 배경·타일·몬스터 다양화 + StS2 전투 배치 #5

Merged
gahusb merged 7 commits from feature/maps-batch into main 2026-06-06 14:31:27 +09:00
16 changed files with 67320 additions and 2 deletions

View File

@@ -7,7 +7,7 @@
"Usage": 0, "Usage": 0,
"UsePublish": 1, "UsePublish": 1,
"UseService": 0, "UseService": 0,
"CoreVersion": "1.21.0.0", "CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0", "StudioVersion": "0.1.0.0",
"DynamicLoading": 0, "DynamicLoading": 0,
"ContentProto": { "ContentProto": {
@@ -19,7 +19,17 @@
"name": "sector01", "name": "sector01",
"maxUserNo": 16, "maxUserNo": 16,
"entries": [ "entries": [
"map://map01" "map://map01",
"map://map02",
"map://map03",
"map://map04",
"map://map05",
"map://map06",
"map://map07",
"map://map08",
"map://map09",
"map://map10",
"map://map11"
] ]
} }
], ],

View File

@@ -0,0 +1,217 @@
# 맵 개선(다양한 몬스터 + 타일셋 + StS2 배치) 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:** map02~map11에 공식 맵에서 수확한 다양한 몬스터 2종(기존 4종 미사용)을 StS2 우측 배치로, 맵마다 다른 타일셋으로 재생성한다.
**Architecture:** 공식 맵 import로 몬스터 변형 `{sprite,stand,hit,die}`과 타일셋 RUID를 수확(배경 수확과 동일 기법) → `tools/gen-maps.mjs``MONSTER_VARIANTS`/`TILESETS`에 반영 → 몬스터 선택을 "서로 다른 2종 + 정적 베이스 + StS2 우측 고정위치"로, TileSetRUID를 맵별로 교체 → map02~map11 재생성. map02 스파이크로 렌더 검증 후 확대.
**Tech Stack:** Node.js, MSW `.map` JSON, msw-maker-mcp(import/save/play/screenshot/execute_script), msw-mcp.
---
## File Structure
- Modify: `tools/gen-maps.mjs``MONSTER_VARIANTS`/`TILESETS` 데이터 + 몬스터 선택/배치 로직 + TileSetRUID 교체.
- Modify(재생성): `map/map02.map`~`map11.map`.
기준 사실:
- 몬스터 엔티티: `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet{stand,hit,die}`. 정적 베이스로 쓸 템플릿은 path에 `Static` 포함(StaticMonsterTemplate, 배회 안 함).
- 타일: 맵의 `/TileMap` 엔티티 `TileMapComponent.TileSetRUID.DataId`. map01 기본 `9dfea3808bbd49a5877d8624df21b1c7`.
- 배경: 기존 `BACKGROUNDS` 10종 유지.
- import는 현재 맵(map02, 재생성 가능)을 교체 → save → 파일에서 추출.
---
### Task 1: 몬스터 변형 + 타일셋 수확 (컨트롤러/MCP, 스파이크 포함)
**목표:** `MONSTER_VARIANTS`(≥12종 `{sprite,stand,hit,die}`) + `TILESETS`(10종 RUID) 확정.
- [ ] **Step 1: 몬스터 엔티티 구조 스파이크**
몬스터가 있는 공식 **필드맵** 1개를 import(`maker_import_maplestory_map`) → `maker_save``map/map02.map`에서 `script.Monster`를 포함하는 엔티티를 찾아 `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet`(stand/hit/die)가 존재하는지 확인.
- 존재 → 그 형태로 변형 추출.
- 부재(구조 다름) → 폴백: `SpriteRUID`만 추출하고 `ActionSheet`는 map01 템플릿 유지(생성기에서 변형에 stand/hit/die가 없으면 ActionSheet 미변경하도록 처리).
필드맵 후보 id는 `maker_list_maplestory_maps`로 탐색(영문/지역명). 몬스터가 있는 사냥/필드맵을 고른다.
- [ ] **Step 2: 변형 ≥12종 수확**
필드맵 여러 개를 import→save→추출 반복. 각 맵의 몬스터 엔티티들에서 `{sprite, stand, hit, die}`를 모아 **중복 sprite 제거**해 ≥12종 확보. map01의 4종 sprite(`8ef238e0…`,`6c7130f5…`,`3e76c89a…`,`6d381bea…`,`c96c11f9…`)는 **제외**.
- [ ] **Step 3: 타일셋 10종 수확**
import한 맵들의 `TileMapComponent.TileSetRUID.DataId`를 수집해 **distinct 10종**(map01의 `9dfea380…` 제외). (배경 수확 때처럼 import 1회로 타일셋+몬스터 동시 수확 가능)
- [ ] **Step 4: 결과 정리**
`MONSTER_VARIANTS = [{sprite,stand,hit,die}, ...]`(≥12)와 `TILESETS = [ruid, ...]`(10)를 Task 2에 넘길 형태로 기록. (코드 변경 없음; 데이터 산출)
---
### Task 2: 생성기 로직·데이터 갱신
**Files:** Modify `tools/gen-maps.mjs`
- [ ] **Step 1: TILESETS 상수 추가**
`BACKGROUNDS = [...]` 정의 바로 아래에 추가(값은 Task 1 결과):
```js
// 공식 맵에서 수확한 타일셋 RUID 10종 (맵마다 다르게). map01 기본(9dfea380…) 제외.
const TILESETS = [
// Task 1에서 수확한 10개 RUID
];
```
- [ ] **Step 2: MONSTER_VARIANTS 채우기**
기존 `const MONSTER_VARIANTS = [];` 를 Task 1에서 수확한 ≥12종으로 교체:
```js
// 공식 맵에서 수확한 몬스터 변형 (기존 map01 4종 미사용).
const MONSTER_VARIANTS = [
// { sprite: '...', stand: '...', hit: '...', die: '...' }, ... (≥12종)
];
```
- [ ] **Step 3: 몬스터 배치 로직 교체 (서로 다른 2종 + StS2 + 정적 베이스)**
`buildMap` 안의 몬스터 추가 루프(`const ents = ...` 이후 `for (let i = 0; i < 2; i++) { ... }` 블록 전체)를 다음으로 교체:
```js
const ents = map.ContentProto.Entities.filter((e) => !isMonster(e));
// 정적 베이스(StS2 위치 고정 — 배회 방지). 변형이 sprite/animation을 덮어쓰므로 외형은 베이스와 무관.
const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0];
// 서로 다른 변형 2종 선택 (맵 내 중복 금지)
const vi = Math.floor(rand() * MONSTER_VARIANTS.length);
const vj = (vi + 1 + Math.floor(rand() * (MONSTER_VARIANTS.length - 1))) % MONSTER_VARIANTS.length;
const chosen = [MONSTER_VARIANTS[vi], MONSTER_VARIANTS[vj]];
const STS2_X = [3.5, 5.5]; // 화면 우측 전투 포메이션
for (let i = 0; i < 2; i++) {
const m = JSON.parse(JSON.stringify(base));
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 = STS2_X[i];
const v = chosen[i];
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = v.sprite;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa && v.stand) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die };
ents.push(m);
}
```
(`v.stand`가 없으면 ActionSheet를 유지 → 폴백 호환)
- [ ] **Step 4: TileSetRUID 교체 추가**
`buildMap`의 경로/배경 설정 루프 `for (const e of ents) { ... }` 안, 배경 설정 블록 다음에 추가:
```js
if ((e.path || '').endsWith('/TileMap')) {
const tm = compOf(e, 'MOD.Core.TileMapComponent');
if (tm && TILESETS.length > 0) tm.TileSetRUID = { DataId: TILESETS[(nn - 2) % TILESETS.length] };
}
```
- [ ] **Step 5: 구문 확인 + 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node --check tools/gen-maps.mjs
git add tools/gen-maps.mjs
git commit -m "맵 생성기: 수확한 다양한 몬스터 2종(StS2 배치) + 맵별 타일셋 교체"
```
---
### Task 3: map02 스파이크 — 재생성 + Maker 검증
**Files:** Modify `map/map02.map`
- [ ] **Step 1: map02 재생성**
수확 import로 오염된 map02를 깨끗이 재생성:
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git checkout map/map01.map # 혹시 모를 보호(템플릿). map01은 변경 대상 아님
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;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);const xs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.TransformComponent').Position.x);const tm=E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId;console.log('monsters:',ms.length);console.log('sprites:',sprs.join(','));console.log('distinct sprites:',new Set(sprs).size===2);console.log('no old sprite:',sprs.every(s=>!old.includes(s)));console.log('positions x:',xs.join(','));console.log('tileset:',tm,'changed:',tm!=='9dfea3808bbd49a5877d8624df21b1c7')"
```
Expected: `monsters: 2`, 2개 sprite distinct, `no old sprite: true`, positions x = `3.5,5.5`, tileset이 `9dfea380…`이 아님(교체됨).
- [ ] **Step 3: Maker 렌더 검증 (컨트롤러)**
1. `maker_refresh_workspace`
2. map02가 활성인지 확인(`maker_get_current_map`). 아니면 사용자에게 map02 열기 요청.
3. `maker_play``maker_screenshot` → Read로 확인: 몬스터 2마리가 **수확된(기존과 다른) 외형**으로 **우측에** 보이고, **타일 텍스처가 바뀌었는지**.
4. `maker_execute_script`(client)로 확인:
```lua
local m1=_EntityService:GetEntityByPath("/maps/map02/Monster1")
local m2=_EntityService:GetEntityByPath("/maps/map02/Monster2")
if m1 then log("M1 spr="..tostring(m1.SpriteRendererComponent.SpriteRUID).." x="..tostring(m1.TransformComponent.Position.x)) end
if m2 then log("M2 spr="..tostring(m2.SpriteRendererComponent.SpriteRUID).." x="..tostring(m2.TransformComponent.Position.x)) end
```
→ `maker_logs(normal)`로 sprite/x 확인.
5. `maker_stop`.
- [ ] **Step 4: 게이트 판정**
- 몬스터 외형 변경 + 우측 배치 + 타일 변경 정상 → Task 4.
- 몬스터가 흰박스/안 보임 → 변형 sprite/animation 로드 문제 → Task 1 폴백(SpriteRUID만, ActionSheet 유지) 적용 후 재생성.
- 타일이 깨져 보임 → 해당 타일셋 제외하거나 호환 타일셋으로 교체(`TILESETS` 조정) 후 재생성.
---
### Task 4: 전체 재생성 + 검증
**Files:** Modify `map/map02.map`~`map11.map`, `Global/SectorConfig.config`
- [ ] **Step 1: 전체 재생성**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-maps.mjs
```
Expected: `Generated: map02 … map11`, `SectorConfig entries: 11`.
- [ ] **Step 2: 전체 데이터 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const fs=require('fs');const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];let ids=new Set(),dup=false,ts=new Set(),bad=false;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;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));if(ms.length!==2)throw new Error('monsters '+t);const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);if(new Set(sprs).size!==2)bad=true;if(sprs.some(s=>old.includes(s)))bad=true;ts.add(E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId);for(const e of E){if(ids.has(e.id))dup=true;ids.add(e.id);}}console.log('cross-map id dup:',dup);console.log('any old/dup-in-map sprite:',bad);console.log('distinct tilesets:',ts.size)"
```
Expected: `cross-map id dup: false`, `any old/dup-in-map sprite: false`, `distinct tilesets: 10`.
- [ ] **Step 3: Maker 표본 검증 (컨트롤러)**
`maker_refresh_workspace` 후 표본 맵(map05, map09)을 각각 열어(사용자 협조) `maker_play`→`maker_screenshot`로 몬스터 외형·타일이 맵마다 다른지 확인. `maker_stop`.
---
### Task 5: 최종 커밋
- [ ] **Step 1: 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add tools/gen-maps.mjs Global/SectorConfig.config 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
git commit -m "맵 10개: 다양한 몬스터 2종(StS2 우측 배치) + 맵별 타일셋 적용"
```
---
## 검증 요약
- 수확: 몬스터 변형 ≥12 / 타일셋 10 (스파이크로 구조 확인)
- map02 스파이크: 데이터(2 distinct sprite·old 미사용·x=3.5/5.5·타일셋 교체) + Maker 렌더
- 전체: cross-map id 무중복, old sprite 미사용, 타일셋 10 distinct
- Maker 표본 시각 확인

View File

@@ -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 표본 맵 시각 확인

View File

@@ -0,0 +1,64 @@
# 맵 개선: 다양한 preset 몬스터 + 맵별 타일셋 + StS2 배치 설계
- 날짜: 2026-06-06
- 브랜치: feature/maps-batch (기존 맵 작업 이어서)
- 대상: `tools/gen-maps.mjs`, `map/map02.map`~`map11.map` (재생성)
## 목표
map02~map11 각 맵에서:
1. **다양한 몬스터** 2마리를 배치하되, 기존 map01의 4종(StaticMonster/MoveMonster/ChaseMonster/monster-43) **스프라이트를 재사용하지 않고**, 공식 맵에서 수확한 다양한 몬스터로 채운다.
2. 몬스터를 **Slay the Spire 2 배치**(플레이어 좌측, 몬스터 우측 전투 포메이션)로 둔다.
3. 맵마다 **다른 타일셋**(TileSetRUID)을 적용한다(같은 바닥 지형, 다른 타일 텍스처).
4. 배경은 기존에 수확한 10종(맵별 상이) 유지.
## 범위
### 포함
- 공식 맵 import로 **몬스터 변형 세트 + 타일셋 RUID** 수확
- 생성기에 `MONSTER_VARIANTS`(수확 변형), `TILESETS`(타일셋 풀) 반영
- 맵당 서로 다른 몬스터 2종, StS2 우측 배치
- 맵당 다른 TileSetRUID
- map02~map11 재생성
### 제외 (YAGNI)
- 지형(Tiles/Foothold) 통째 교체 — 타일셋(텍스처)만 교체
- 포털 연결, 카드-전투 로직 연동
- map01 변경
## 수확 (공식 맵 import 기법)
배경 수확과 동일: `maker_import_maplestory_map(id)`가 현재 맵을 그 공식 맵으로 교체 → `maker_save``map/<current>.map`에서 데이터 추출.
- **몬스터 변형** `{ sprite, stand, hit, die }`(RUID): 몬스터가 있는 **필드/사냥맵**을 import해 몬스터 엔티티의 `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet`(stand/hit/die)를 추출. ≥12종 distinct 목표. (타운맵은 몬스터 없을 수 있어 필드맵 선택)
- **타일셋** `TileSetRUID`: import한 맵의 `TileMapComponent.TileSetRUID` 추출. 10종 distinct (map01의 `9dfea380…`과 겹치지 않게).
> **스파이크 선행**: 필드맵 1개를 import해 몬스터 엔티티 구조가 `{sprite, stand, hit, die}` 추출 가능한지 먼저 확인. 구조가 다르면 폴백(아래).
## 생성기 변경 (`tools/gen-maps.mjs`)
- `MONSTER_VARIANTS = [ {sprite, stand, hit, die}, ... ]` — 수확 결과로 채움(≥12종).
- `TILESETS = [ ruid, ... ]` — 수확한 타일셋 10종.
- `buildMap(nn)`:
- 몬스터 2마리: `MONSTER_VARIANTS`에서 **서로 다른 2종**을 맵 시드로 선택(맵 내 중복 금지). 클론 몬스터 엔티티의 `SpriteRUID` + `ActionSheet`를 변형으로 덮어씀. (기존 map01 스프라이트 미사용 보장 — 항상 변형으로 덮어쓰므로)
- 위치: **StS2 배치** — 화면 우측에 2자리 고정(예: Position.x ≈ +3.5, +5.5; y는 map01 몬스터 y값). map01 전투 구도를 기준으로 우측 포메이션.
- 타일셋: `TileMap` 엔티티의 `TileMapComponent.TileSetRUID.DataId``TILESETS[(nn-2)%len]`로 설정.
- 배경: 기존 `BACKGROUNDS` 유지.
- GUID 재발급·경로 치환·SectorConfig 로직은 그대로.
## 검증
1. **스파이크**(map02): reload→play→screenshot + Lua로
- 몬스터 2마리의 `SpriteRUID`가 수확 변형과 일치(= map01 4종 아님), 우측 배치
- `TileMap.TileSetRUID`가 새 타일셋
- 화면상 몬스터 외형·타일 텍스처가 바뀌어 보임
2. 전체: 맵별 몬스터 2종 distinct, 타일셋 distinct, 배경 distinct, 엔티티 id 중복 없음
3. Maker 표본 맵 시각 확인
## 리스크/폴백
- 몬스터 엔티티 구조가 `{sprite,stand,hit,die}`로 안 맞으면 → `SpriteRUID`만 교체하고 `ActionSheet`는 map01 템플릿 유지(최소 시각 변화 보장).
- 타일셋 교체 시 tileIndex 의미 차이로 타일이 어색하면 → 스파이크에서 확인 후 호환 타일셋만 선별하거나 사용자와 상의.
- 수확 시 import는 현재 맵(map02, 재생성 가능)에 적용 → 수확 후 generator로 map02 재생성하여 정리.
## 산출물/형상관리
- `tools/gen-maps.mjs` 갱신, `map/map02.map`~`map11.map` 재생성을 커밋. 수확 RUID는 문자열만 포함(공식 콘텐츠).

View File

@@ -0,0 +1,62 @@
# 맵 10개 생성 (랜덤 배경 + 몬스터 2마리) 설계
- 날짜: 2026-06-06
- 브랜치: feature/maps-batch (신규)
- 대상: `map/map02.map`~`map11.map`(신규), `Global/SectorConfig.config`, `tools/gen-maps.mjs`(신규)
## 목표
`map01`을 템플릿으로 **독립 맵 10개(`map02`~`map11`)** 를 생성한다. 각 맵은 **서로 다른 공식 배경**을 갖고, **몬스터 2마리**가 랜덤 위치에 배치된다.
## 범위
### 포함
- `map02`~`map11` (신규 10개 맵 파일)
- 맵마다 다른 배경(`BackgroundComponent.TemplateRUID`) — 공식 MapleStory 배경 라이브러리에서 10개 서로 다르게
- 맵마다 몬스터 2마리, x 위치 랜덤(바닥 위), y는 바닥 높이 고정
- `SectorConfig.config``map://map02`~`map://map11` 등록
- 재현용 생성기 `tools/gen-maps.mjs`
### 제외 (YAGNI)
- 맵 간 포털/이동 연결 (독립 맵)
- 맵별 다른 타일맵/지형 (map01 타일·바닥 그대로 복제)
- 카드-전투 로직 연동
- map01 변경
## 몬스터 전략 (스파이크 게이트)
사용자 선택: **라이브러리에서 다양한 몬스터**. 단, 리소스 검색이 RUID만 반환하고 action(stand/hit/die) 그룹핑·이름을 주지 않아 "완결된 몬스터" 조립이 불확실하다. 따라서:
- **A. 라이브러리 다양 몬스터 (1차 시도)**: 라이브러리에서 완결된 몬스터 2~3종(스프라이트 + stand/hit/die 애니메이션 RUID 세트)을 조립한다. **먼저 1개 맵으로 스파이크** → Maker Play에서 로드·렌더 검증.
- **게이트**: 스파이크에서 라이브러리 몬스터가 정상 렌더되면 → 10개 맵에 A로 확대. 조립/로드 실패 시 → **B로 폴백**.
- **B. 폴백 — 기존 몬스터 변형**: 이미 정상 로딩되는 기존 템플릿(StaticMonster/MoveMonster/ChaseMonster/monster-43)의 검증된 RUID 세트에서 맵당 랜덤 2종 + 랜덤 위치. 다양성은 ~4종으로 제한되지만 확실히 동작.
> 핵심 리스크: 이전에 **사용자 업로드(계정) 리소스는 로컬 워크스페이스 플레이에서 로드 실패**했다. 공식 라이브러리 리소스(배경/몬스터)는 shipped 콘텐츠라 로드될 것으로 보지만(기존 배경·몬스터 RUID가 정상 로딩 중), **확정 전 스파이크로 검증**한다.
## 구현 방식
### 생성기 `tools/gen-maps.mjs`
1. `map/map01.map`을 텍스트로 읽어 JSON 파싱(템플릿)
2. 배경 RUID 풀(10개, 공식 라이브러리에서 확보), 몬스터 정의 풀(A: 라이브러리 세트 / B: 기존 템플릿 세트)을 데이터로 보유
3. `NN = 02..11` 각각:
- 엔티티 deep-copy, **모든 엔티티 `id` GUID 재발급**(oldId→newId 매핑). `root_entity_id`/`sub_entity_id`가 엔티티 id를 가리키면 함께 치환. (component 안의 리소스 RUID — SpriteRUID, ActionSheet, TemplateRUID, CollisionGroup.Id, DamageSkinId, 타일셋 id — 는 엔티티 id가 아니므로 유지)
- `EntryKey``map://mapNN`, 모든 `path``/maps/map01``/maps/mapNN`, `name``mapNN`로 치환
- `Background` 엔티티의 `TemplateRUID``backgrounds[NN]`로 설정
- 기존 몬스터 엔티티들을 제거하고, 선택된 몬스터 2종을 랜덤 x 위치로 추가(각 몬스터는 템플릿 몬스터 엔티티를 복제하고 SpriteRUID/ActionSheet[A] 또는 그대로[B] + Position.x 랜덤)
- `map/mapNN.map`로 기록(원본 줄바꿈/포맷 보존 방식은 가능하면, 아니면 표준 JSON 직렬화)
4. `Global/SectorConfig.config``Sectors[0].entries``map://map02`~`map11` 추가(중복 방지)
랜덤은 결정론을 위해 인덱스 기반 시드(맵 번호로 위치/선택 산출) 사용 — 재실행 시 동일 결과.
### GUID 재발급 주의
- 엔티티 id 충돌 방지를 위해 맵마다 고유 GUID 필요. 자기참조(`root_entity_id`==자기 id)는 매핑으로 일관되게 치환.
## 검증
1. **스파이크(A)**: map02 1개만 생성 → reload→play→screenshot + Lua로 몬스터 엔티티/스프라이트 로드 확인. 실패 시 B로 전환.
2. 전체 생성 후: 각 맵(또는 표본)에서 reload→play(해당 맵)→screenshot으로 배경 상이·몬스터 2마리 확인. 맵 전환은 Maker에서 해당 맵을 열거나 sector 이동으로.
3. JSON 유효성(JSON.parse) + SectorConfig 10개 등록 확인 + 엔티티 id 중복 없음 확인.
## 산출물/형상관리
- 신규 파일 `map/map02.map`~`map11.map`, `tools/gen-maps.mjs`, `SectorConfig.config` 변경을 커밋.
- 배경/몬스터는 공식 라이브러리 RUID(문자열)만 참조 — 별도 리소스 파일 불필요(공식 콘텐츠). (단 A가 로컬 임포트를 요구하면 그 리소스 파일도 포함)

6654
map/map02.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map03.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map04.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map05.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map06.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map07.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map08.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map09.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map10.map Normal file

File diff suppressed because it is too large Load Diff

6654
map/map11.map Normal file

File diff suppressed because it is too large Load Diff

152
tools/gen-maps.mjs Normal file
View File

@@ -0,0 +1,152 @@
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];
// 공식 맵에서 수확한 Background-타입 RUID 풀 (맵마다 1개씩, 서로 다르게).
// 공식 MapleStory 맵을 import해 각 맵의 BackgroundComponent.TemplateRUID를 수집함.
// (라이브러리 검색 cat=background는 AnimationClip을 반환해 Background 타입이 아니었음 → 사용 불가)
const BACKGROUNDS = [
'794ad8421e2543d8a6d2c70307637450', // 기본 템플릿(문서 예시)
'65c4167ea7484196b890022354e5a4a4', // Henesys
'23801e25ab854f3189454a4fa9d01761', // Edelstein
'f91f8b5b2b1e47208fb18f2cbd2a5e8d', // Fox Point Village
'd84241f17de344a097f5b96ac914f1d2', // Lith Harbor
'60741c3333334297b9f211939e02a4fc', // Ellinia
'0c398bbb2cf6400992532465b9d53024', // Perion
'f9e546932b014c0e867365a796c8dc91', // Kerning City
'2ee96aa776e8480b849c1fb9a4d4c05c', // Orbis
'b7c47cfae79e40e9b1352469a78af0bd', // Ludibrium
];
// 공식 맵에서 수확한 타일셋 RUID (맵마다 다르게). map01 기본(9dfea380…) 제외.
const TILESETS = [
'46701ff2021b4d1fb21fbf5790b1ab14',
'7b6bd117bd0446a5bacec8ea6831c997',
'9bf18287398c44699c20fc5123d1a1ae',
'd6a94bc26c8f43e2a7abfabfae0c4fc4',
'e80a4b6e22d34348837d2ecf30e7cf74',
'23e80224ef624ea5af497cc50aa0e752',
'2667829326dd46de80ef26f6bb7f26ae',
'48afa7d90aa24fadae9c52f30977342e',
'901f885ef94f4a32961bf6cc64e3ec86',
'3ca52bc385574e56aaffa15eea5c23aa',
'df1a1fee05874794a624c2bccbb1574d',
'e1703f6cb2f84969bc54afcd12006b4e',
];
// 공식 맵에서 수확한 몬스터 변형 (기존 map01 4종 미사용). 항목: { sprite, stand, hit, die }
const MONSTER_VARIANTS = [
{ sprite: '96e955c1bf27415e84f96deea200a8f1', stand: '96e955c1bf27415e84f96deea200a8f1', hit: 'aec9504d5dc24aceb5646b79d30abad4', die: '65a2bfb039614f2e9e4ccc354340153d' },
{ sprite: 'f86992ba9c41487c8480fcb893fcbda6', stand: 'f86992ba9c41487c8480fcb893fcbda6', hit: 'd305b942b1704c8084548108ff3b7a6b', die: '5a563e5fd98c4132b61057dc6bb8aaf2' },
{ sprite: 'a2204a21d88942b281d2cac6053ffbaa', stand: 'a2204a21d88942b281d2cac6053ffbaa', hit: 'afc08936b8a64b26bc3dd8c03ead1f26', die: 'fc1c6d9ba9bc413ab53b6dbfae3ac45b' },
{ sprite: 'd8f014043ce8418f96700c2b6c9ebf6c', stand: 'd8f014043ce8418f96700c2b6c9ebf6c', hit: 'c3cf643b618346c7bfa6574187b396f9', die: 'a88d9b3d60f941e4890dc89a6ccaa8ee' },
{ sprite: '17b55730c26f4fd6b8fcfa288da388de', stand: '17b55730c26f4fd6b8fcfa288da388de', hit: 'eac48e84a9fc4580a4018de5cf52ddb3', die: '51c2f4b59a2c413db26035aa57002fc8' },
{ sprite: '48c10437ae8344a9b2a1d3f36185728f', stand: '48c10437ae8344a9b2a1d3f36185728f', hit: '9044063647854f5e9128efcf80e909be', die: 'f414577d18c94cc387c275df4abdbc3b' },
{ sprite: '4ca39dbfa1c6492283ba8bd352d12b0a', stand: '4ca39dbfa1c6492283ba8bd352d12b0a', hit: '7ac78511036e4ebe988b97c35fc275d1', die: '740f3f2b2e7a4b71bec5eac84e8539f9' },
{ sprite: 'ed3908e24d694bb786023fc1ed073489', stand: 'ed3908e24d694bb786023fc1ed073489', hit: '4763c9bebc9245998c9c499b6316aa9f', die: 'b168793b92a844a3a3a6f4ce647a14d2' },
{ sprite: '3109357701ae41a4bcc7543f52f1f4c3', stand: '3109357701ae41a4bcc7543f52f1f4c3', hit: 'ce0269079e884545b5bb6ea075e2a67f', die: 'a5e65650e00e47878cac1be7a5b999a0' },
];
// 결정론적 시드 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));
// 정적 베이스(StS2 위치 고정 — 배회 방지). 변형이 sprite/animation을 덮어쓰므로 외형은 베이스와 무관.
const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0];
// 서로 다른 변형 2종 선택 (맵 내 중복 금지)
const vi = Math.floor(rand() * MONSTER_VARIANTS.length);
const vj = (vi + 1 + Math.floor(rand() * (MONSTER_VARIANTS.length - 1))) % MONSTER_VARIANTS.length;
const chosen = [MONSTER_VARIANTS[vi], MONSTER_VARIANTS[vj]];
const STS2_X = [3.5, 5.5]; // 화면 우측 전투 포메이션
for (let i = 0; i < 2; i++) {
const m = JSON.parse(JSON.stringify(base));
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 = STS2_X[i];
const v = chosen[i];
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = v.sprite;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa && v.stand) 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];
}
if ((e.path || '').endsWith('/TileMap')) {
const tm = compOf(e, 'MOD.Core.TileMapComponent');
if (tm && TILESETS.length > 0) tm.TileSetRUID = { DataId: TILESETS[(nn - 2) % TILESETS.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);
}