From 4a1c66c5feac70741be56d7c91f783d77170ebe8 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 11 Jun 2026 08:42:59 +0900 Subject: [PATCH] =?UTF-8?q?docs(act-maps):=20P4=20=EB=A7=89=EB=B3=84=20?= =?UTF-8?q?=EB=A7=B5=20=EC=A0=84=ED=99=98+=EB=A7=B5=EB=B3=84=20=EC=9D=B8?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=84=B0=20=EC=84=A4=EA=B3=84=C2=B7=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-06-11-act-maps.md | 160 ++++++++++++++++++ .../specs/2026-06-11-act-maps-design.md | 46 +++++ 2 files changed, 206 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-act-maps.md create mode 100644 docs/superpowers/specs/2026-06-11-act-maps-design.md diff --git a/docs/superpowers/plans/2026-06-11-act-maps.md b/docs/superpowers/plans/2026-06-11-act-maps.md new file mode 100644 index 0000000..4756f83 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-act-maps.md @@ -0,0 +1,160 @@ +# 막별 맵 전환 + 맵별 인카운터 (P4) 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. T3·T4는 컨트롤러 직접. + +**Goal:** 보스 클리어 시 다음 막의 맵(map02, map03)으로 텔레포트하고, 각 맵에 테마 몬스터 그룹(combat3/elite2/boss1)을 자동 구성. + +**Architecture:** 신규 `tools/map/gen-map-encounters.mjs`가 map02~11 몬스터를 전면 교체(결정론). 컨트롤러는 `RegisterMonster`에 mapName 차원 추가 + `BuildMonsters` 플레이어 맵 필터 + 보스 클리어 시 `TeleportToActMap`. + +--- + +## 배경 (구현자용) +- 생성물은 단일 소스 규칙(gen-slaydeck → ui/codeblock/common). 맵 파일은 전용 생성기가 직접 패치. 산출물(slaydeck 3종)은 마지막에 일괄, **맵 파일은 T1에서 바로 커밋**. +- 현재 `RegisterMonster(monster, enemyId, group)`(3인자), `BuildMonsters`는 `r.group == g` 필터. `CombatMonster.codeblock`은 `tools/monster/gen-combat-monster.mjs`가 생성(OnBeginPlay에서 3인자 등록). +- gen-maps의 `MONSTER_VARIANTS` 9종(sprite/stand/hit/die RUID)과 `mapGuid(nn, idx)`·`rng(seed)` 패턴 참조: `tools/map/gen-maps.mjs`. +- CheckCombatEnd 보스 분기: `self.Floor = self.Floor + 1 ... self:ShowMap()`. +- JS 상수: writeCodeblocks 안 `ACT_COUNT = 3` 존재. + +## Task 1: gen-map-encounters.mjs (map02~11 인카운터) + +**Files:** Create `tools/map/gen-map-encounters.mjs`; Modify(산출) `map/map02.map`~`map11.map` + +- [ ] **Step 1: 생성기 작성.** `tools/map/gen-maps.mjs`를 READ해 `MONSTER_VARIANTS`(9종 배열 — 그대로 복사)·`rng`·`mapGuid` 패턴을 가져와 아래 구조로 작성: +```js +import { readFileSync, writeFileSync } from 'node:fs'; + +// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성. +// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론). +const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom']; +const ELITE_POOL = ['mushmom', 'modified_snail']; +const BOSS_POOL = ['king_slime', 'slime_boss']; +const LAYOUT = [ + { group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 }, + { group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 }, + { group: 'boss', x: 4.0 }, +]; +const MONSTER_VARIANTS = [ /* gen-maps.mjs에서 9종 그대로 복사 */ ]; + +function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } +function encGuid(nn, idx) { + const n = (nn * 1000 + 500 + idx) >>> 0; // gen-maps의 mapGuid(idx 0~)와 비충돌(+500 오프셋) + return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; +} +const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster'); +const compOf = (e, t) => e.jsonString['@components'].find((c) => c['@type'] === t); + +function pick(rand, pool) { return pool[Math.floor(rand() * pool.length)]; } +function pickN(rand, pool, n) { // 중복 없는 n개(부족하면 순환) + const a = pool.slice(); + const out = []; + for (let i = 0; i < n; i++) { + if (a.length === 0) a.push(...pool); + out.push(a.splice(Math.floor(rand() * a.length), 1)[0]); + } + return out; +} + +function patchMap(nn) { + const tag = String(nn).padStart(2, '0'); + const file = `map/map${tag}.map`; + const map = JSON.parse(readFileSync(file, 'utf8')); + const ents = map.ContentProto.Entities; + const monsters = ents.filter(isMonster); + if (monsters.length === 0) throw new Error(`[gen-map-encounters] ${file} 몬스터 템플릿 없음`); + const template = monsters[0]; + map.ContentProto.Entities = ents.filter((e) => !isMonster(e)); + const rand = rng(nn * 7919 + 17); + const combatIds = pickN(rand, COMBAT_POOL, 3); + const eliteIds = pickN(rand, ELITE_POOL, 2); + const bossId = pick(rand, BOSS_POOL); + const variants = pickN(rand, MONSTER_VARIANTS, 6); + LAYOUT.forEach((slot, idx) => { + const m = JSON.parse(JSON.stringify(template)); + const enemyId = slot.group === 'combat' ? combatIds[idx] : slot.group === 'elite' ? eliteIds[idx - 3] : bossId; + const name = `${slot.group}_${idx + 1}`; + m.id = encGuid(nn, idx); + m.path = `/maps/map${tag}/${name}`; + m.jsonString.path = m.path; + m.jsonString.name = name; + const o = m.jsonString.origin; + if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; } + const tr = compOf(m, 'MOD.Core.TransformComponent'); + if (tr && tr.Position) tr.Position.x = slot.x; + const v = variants[idx]; + const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); + if (sp) sp.SpriteRUID = v.stand; + const sa = compOf(m, 'MOD.Core.StateAnimationComponent'); + if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die }; + let cm = compOf(m, 'script.CombatMonster'); + if (!cm) { + cm = { '@type': 'script.CombatMonster', Enable: true }; + m.jsonString['@components'].push(cm); + const names = (m.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster'); + names.push('script.CombatMonster'); + m.componentNames = names.join(','); + } + cm.EnemyId = enemyId; + cm.Group = slot.group; + map.ContentProto.Entities.push(m); + }); + writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); + return `map${tag}(${combatIds.join('/')}|${eliteIds.join('/')}|${bossId})`; +} + +const made = MAP_NUMBERS.map(patchMap); +console.log('Encounters:', made.join(', ')); +``` +- [ ] **Step 2:** 실행 + 검증: 각 맵 6마리·그룹 3/2/1·EnemyId 전부 enemies.json 존재·dup guid 0: +`node tools/map/gen-map-encounters.mjs && node -e "const en=JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')).enemies;let bad=0;for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const m=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));const g={combat:0,elite:0,boss:0};for(const e of ms){const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');g[c.Group]++;if(!en[c.EnemyId]){bad++;console.log('BAD enemy',t,c.EnemyId);}}if(!(g.combat===3&&g.elite===2&&g.boss===1)){bad++;console.log('BAD groups',t,JSON.stringify(g));}const ids=m.ContentProto.Entities.map(e=>e.id);if(ids.length!==new Set(ids).size){bad++;console.log('DUP guid',t);}}console.log(bad===0?'all maps OK':'BAD:'+bad)"` +2회 실행 동일(결정론) 확인. +- [ ] **Step 3:** Commit: `git add tools/map/gen-map-encounters.mjs map/ && git commit -m "feat(act-maps): map02~11 인카운터 자동 구성 (combat3/elite2/boss1·맵별 테마)"` + +## Task 2: 컨트롤러 — 맵 필터 + 막 텔레포트 + +**Files:** Modify `tools/deck/gen-slaydeck.mjs`, `tools/monster/gen-combat-monster.mjs` + +- [ ] **Step 1 (gen-combat-monster):** OnBeginPlay 등록 호출을 4인자로 — 자기 맵 이름 전달: +``` + local mapName = "" + if self.Entity.CurrentMapName ~= nil then + mapName = self.Entity.CurrentMapName + end + c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName) +``` +(reg 함수 내 기존 RegisterMonster 줄 교체. CurrentMapName 미지원이면 빈 문자열 — BuildMonsters에서 빈 값은 항상 통과시켜 하위 호환.) +- [ ] **Step 2 (gen-slaydeck):** `RegisterMonster`에 4번째 인자 `mapName`(string) 추가, 저장 항목에 `map = mapName`(nil/빈 처리: `local mp = mapName; if mp == nil then mp = "" end`). +- [ ] **Step 3 (gen-slaydeck BuildMonsters):** 그룹 필터 줄을 확장: +``` +local pmap = "" +local lp = _UserService.LocalPlayer +if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end +``` +(reg 수집 루프 앞에 추가) 그리고 필터 조건을 `r.group == g and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap)` 로. +- [ ] **Step 4 (gen-slaydeck 막 전환):** writeCodeblocks에 `const ACT_MAPS = ['map01', 'map02', 'map03'];` 추가(ACT_COUNT 옆). `CheckCombatEnd` 보스 분기의 `self:RenderRun()` 다음, `self:ShowMap()` **앞**에 `self:TeleportToActMap()` 삽입. 신규 메서드: +```js + method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} } +local target = maps[self.Floor] +if target == nil then + return +end +local lp = _UserService.LocalPlayer +if lp == nil then + return +end +if lp.CurrentMapName == target then + return +end +_TeleportService:TeleportToMapPosition(lp, Vector3(-6, 0.03, 0), target)`), +``` +- [ ] **Step 5:** `node --check` 둘 다 → gen-combat-monster 실행(코드블록 재생성+맵 no-clobber 확인) → gen-slaydeck 실행 → codeblock에 TeleportToActMap·4인자 등록·맵 필터 확인 → **slaydeck 산출물 복원**(codeblock/ui/common), CombatMonster.codeblock은 커밋 대상. +- [ ] **Step 6:** Commit: `git add tools/deck/gen-slaydeck.mjs tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ && git commit -m "feat(act-maps): 막별 맵 텔레포트 + 등록 맵 필터"` (map/은 gen-combat-monster 재실행이 기존 맵 값 보존하므로 변화 없을 것 — 변화 있으면 확인 후 포함) + +## Task 3 (컨트롤러 직접): 재생성·검증·커밋 +P2/P3 T5와 동일: gen-slaydeck 재생성→dup0·심볼(TeleportToActMap)·결정성·sim→산출물 커밋. + +## Task 4 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지 +1막 보스 처치(스크립트)→Floor2 텔레포트→map02 도착(스크린샷: 새 배경·새 몬스터들)→전투 진입(combat 그룹 3마리·새 EnemyId/외형)→registered 맵 필터 로그. 통과 후 푸시→PR→머지. + +## Self-Review +- 스펙 §2.1→T2 Step4, §2.2→T2 1~3, §2.3→T1. encGuid +500 오프셋은 gen-maps idx(몬스터 2~)와 비충돌. CombatMonster 값은 T1이 직접 태그(no-clobber 생성기와 호환 — 이미 존재라 keep). CurrentMapName 불확실성은 빈 값 통과 폴백으로 하위 호환(T4 검증). diff --git a/docs/superpowers/specs/2026-06-11-act-maps-design.md b/docs/superpowers/specs/2026-06-11-act-maps-design.md new file mode 100644 index 0000000..688efba --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-act-maps-design.md @@ -0,0 +1,46 @@ +# 막별 맵 전환 + 맵별 인카운터 (P4) — 설계 + +- 날짜: 2026-06-11 +- 상태: 승인됨(사용자 사전 위임). 로드맵 P4/5. + +## 1. 배경/목표 + +map02~11은 SectorConfig에 등록만 되고 게임에서 미사용(모든 전투가 map01). 막(act)이 바뀌어도 같은 맵·같은 몬스터. +**목표**: ① 막별로 다른 물리 맵 사용(맵 차별화가 실제 게임에 보이도록) ② 각 맵에 노드 타입별 몬스터 그룹(combat/elite/boss)을 맵별 테마로 자동 구성. + +## 2. 설계 + +### 2.1 막→맵 매핑 + 텔레포트 +- `ACT_MAPS = ['map01','map02','map03']`(ACT_COUNT=3과 일치, 생성기 상수→Lua 주입). +- `CheckCombatEnd` 보스 클리어(다음 막 진행) 분기에서 `Floor` 증가 후 `self:TeleportToActMap()`: + - `_TeleportService:TeleportToMapPosition(_UserService.LocalPlayer, Vector3(-6, 0.03, 0), ACT_MAPS[self.Floor])` (UILogic 공식 예제의 API; 위치는 map01 플레이어 시작권 좌측 지면 — 메이커 검증으로 조정). +- 새 맵 로드 시 그 맵 몬스터들의 `CombatMonster.OnBeginPlay`가 자기등록(기존 0.1s×50 재시도 — 텔레포트 직후는 맵 화면이라 전투 진입 전 등록 여유 충분). + +### 2.2 등록 풀의 맵 필터 (크로스맵 오염 방지) +- 텔레포트 후 구 맵 몬스터가 언로드되지 않고 등록 풀에 남을 가능성 대비: + - `RegisterMonster(entity, enemyId, group, mapName)` — CombatMonster가 자기 소속 맵 이름을 전달(`self.Entity.CurrentMapName` 우선, nil이면 부모 체인에서 `/maps/` 직계 자식 이름; 구현 검증). + - `BuildMonsters`: `local pmap = _UserService.LocalPlayer.CurrentMapName` — `r.map == pmap`인 등록만 사용(+기존 isvalid·group 필터). + +### 2.3 맵별 인카운터 자동 구성 (`tools/map/gen-map-encounters.mjs` 신규) +- 대상: map02~map11 (map01은 사용자 저작 유지). +- 각 맵: 기존 `script.Monster` 엔티티 전부 제거 → 그 맵의 첫 몬스터 엔티티를 템플릿으로 6마리 생성: + | Group | 수 | x 위치 | EnemyId(맵 번호 순환) | + |---|---|---|---| + | combat | 3 | 2.3 / 3.8 / 5.2 | orange_mushroom·green_mushroom·pig·blue_mushroom 풀에서 3종 | + | elite | 2 | 3.0 / 5.0 | mushmom·modified_snail 중 | + | boss | 1 | 4.0 | king_slime·slime_boss 중 | +- 외형: gen-maps의 `MONSTER_VARIANTS`(공식 수확 9종 sprite/stand/hit/die) 풀에서 맵 시드(`nn*7919`) 결정론 선택(맵마다 다른 조합) — SpriteRenderer/StateAnimation 덮어쓰기. +- `script.CombatMonster` Group/EnemyId 태그 포함, GUID 결정론(`mapGuid` 패턴), idempotent(전체 교체 방식이라 재실행 동일). +- enemies.json 변경 없음(기존 8타입 재사용 — 스탯 일관). + +### 2.4 비범위 +- 4막+ / 맵별 배경·노드 그래프 차별화(이미 배경·타일은 맵별 상이), 이벤트 노드(P5). + +## 3. 검증 +- 생성기 결정론(2회 동일), 각 맵 그룹 구성 JSON 검사(3/2/1·EnemyId·변형 다양성). +- 메이커: 1막 보스 처치→Floor 2→**map02 텔레포트**(카메라/PlayerLock은 전 맵 부착됨)→맵 화면→전투 진입 시 map02 몬스터(combat 3, 새 외형)만 등장·구 맵 미오염→엘리트/보스 노드도 그룹 정상. + +## 4. 리스크 +- `Entity.CurrentMapName`/플레이어 CurrentMapName 형식("map02"?) — 구현 시 메이커 확인, 불일치 시 경로 기반 폴백. +- 텔레포트 직후 카메라/입력 재설정(MapCamera·PlayerLock OnBeginPlay가 맵 로드마다 도는지) — 검증. +- 구 맵 몬스터 isvalid 동작 — 맵 필터가 1차 방어라 비차단.