From 59c699c04bca6900e98a4234ddc7ae075f1afd87 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:32:46 +0900 Subject: [PATCH] =?UTF-8?q?docs(node-monster-groups):=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=20(5=EA=B0=9C=20=ED=83=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slots 그룹화 → CombatMonster Group+no-clobber → 슬롯 플러밍 → RegisterMonster(group)+BuildMonsters 필터 → 재생성·플레이테스트. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-10-node-type-monster-groups.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-node-type-monster-groups.md diff --git a/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md b/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md new file mode 100644 index 0000000..2588c72 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md @@ -0,0 +1,361 @@ +# 노드 타입별 몬스터 그룹 구현 계획 + +> **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:** 한 맵에서 노드 타입(combat/elite/boss)에 따라 해당 그룹의 몬스터만 등장시킨다. + +**Architecture:** 각 맵 몬스터의 `script.CombatMonster`에 `Group` 태그를 두고, 전투 시작 시 `BuildMonsters`가 현재 노드 타입으로 필터해 일치 그룹만 표시·전투 구성한다. HP바 슬롯 좌표는 `data/monster-slots.json`에 그룹별로 둔다. Group/EnemyId는 메이커 인스펙터에서 저작하므로 생성기는 값을 덮어쓰지 않는다. + +**Tech Stack:** Node.js ESM 생성기(.mjs), MSW Lua codeblock, JSON(.codeblock/.map/data). + +--- + +## 배경 / 현재 코드 (구현자용) + +- 생성물(`SlayDeckController.codeblock`·`ui/DefaultGroup.ui`·`common.gamelogic`)은 `tools/deck/gen-slaydeck.mjs`에서만, `CombatMonster.codeblock`+맵 패치는 `tools/monster/gen-combat-monster.mjs`에서만 생성한다(직접 편집 금지). 루트에서 `node tools/<폴더>/<파일>.mjs` 실행. +- 현재 `BuildMonsters`는 등록된 몬스터를 노드 타입과 무관하게 전부 사용. 노드 타입은 `self.MapNodes[self.CurrentNodeId].type`로 접근(combat/elite/boss). +- 현재 `data/monster-slots.json`은 평면 배열 `[{x,y}×4]`. `MAX_MONSTERS = 4`(gen-slaydeck.mjs 상수). +- 전투 규칙(타겟/공격/적턴/승리)은 이 기능에서 **변경하지 않는다**. 따라서 sim/테스트 변경 없음(회귀만 확인). + +## 파일 구조 + +| 파일 | 책임 | 변경 | +|------|------|------| +| `data/monster-slots.json` | 그룹별 HP바 슬롯 좌표 | 평면 배열 → `{combat,elite,boss}` 객체 | +| `tools/monster/gen-combat-monster.mjs` | CombatMonster 코드블록 + 맵 부착 | Group 프로퍼티·등록 인자 추가, 부착을 **값 보존(no-clobber)**로 변경 | +| `RootDesk/MyDesk/CombatMonster.codeblock` | 생성물 | Group 프로퍼티·register(group) | +| `tools/deck/gen-slaydeck.mjs` | 컨트롤러 생성 | SLOTS 객체 처리·StartRun 그룹별 주입·`ActiveSlotPos`·`PositionMonsterSlot`·`RegisterMonster(group)`·`BuildMonsters` 필터 | +| 생성물 | `SlayDeckController.codeblock` 재생성 | (ui 변경 없음) | +| `map/map01.map` 외 | 몬스터 그룹 태그 | 메이커 저작(코드 외) | + +--- + +## Task 1: monster-slots.json 그룹별 구조 + +**Files:** Modify `data/monster-slots.json` + +- [ ] **Step 1: 그룹별 좌표 객체로 교체** + +`data/monster-slots.json` 전체를 아래로 교체(각 그룹 4좌표; 추후 플레이테스트로 튜닝): +```json +{ + "combat": [ + { "x": 430, "y": 140 }, + { "x": 600, "y": 140 }, + { "x": 770, "y": 140 }, + { "x": 900, "y": 140 } + ], + "elite": [ + { "x": 430, "y": 160 }, + { "x": 650, "y": 160 }, + { "x": 850, "y": 160 }, + { "x": 980, "y": 160 } + ], + "boss": [ + { "x": 520, "y": 200 }, + { "x": 760, "y": 160 }, + { "x": 940, "y": 150 }, + { "x": 1040, "y": 150 } + ] +} +``` + +- [ ] **Step 2: JSON 유효성 확인** + +Run: `node -e "const s=JSON.parse(require('fs').readFileSync('data/monster-slots.json','utf8'));console.log(['combat','elite','boss'].map(g=>g+':'+s[g].length).join(' '))"` +Expected: `combat:4 elite:4 boss:4` + +- [ ] **Step 3: Commit** +```bash +git add data/monster-slots.json +git commit -m "feat(node-groups): monster-slots.json 을 그룹별 좌표 구조로" +``` + +--- + +## Task 2: CombatMonster 에 Group + 생성기 no-clobber + +**Files:** Modify `tools/monster/gen-combat-monster.mjs` (생성물 `RootDesk/MyDesk/CombatMonster.codeblock`, `map/*.map`) + +- [ ] **Step 1: 코드블록에 Group 프로퍼티 추가** + +`tools/monster/gen-combat-monster.mjs`의 `Properties` 줄을 교체: +```js + Properties: [prop('string', 'EnemyId', '""'), prop('string', 'Group', '"combat"'), prop('number', 'RegTries', '0')], +``` + +- [ ] **Step 2: OnBeginPlay 등록 호출에 Group 전달** + +`OnBeginPlay` Lua의 등록 줄을 교체: +``` + c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group) +``` +(나머지 OnBeginPlay 본문은 그대로) + +- [ ] **Step 3: patchMap 을 값 보존(no-clobber)로 교체** + +`patchMap` 함수 전체를 아래로 교체. 기존 `script.CombatMonster`가 있으면 사용자가 인스펙터에서 설정한 `EnemyId`/`Group`을 보존하고, 없을 때만 기본값으로 부착한다: +```js +function patchMap(nn) { + const tag = String(nn).padStart(2, '0'); + const file = `map/map${tag}.map`; + const map = JSON.parse(readFileSync(file, 'utf8')); + let added = 0, kept = 0; + for (const e of map.ContentProto.Entities.filter(isMonster)) { + const comps = e.jsonString && e.jsonString['@components']; + if (!Array.isArray(comps)) { + console.warn(`[gen-combat-monster] entity "${(e.jsonString && e.jsonString.name) || e.path}" has no @components — skipped`); + continue; + } + const name = (e.jsonString && e.jsonString.name) || ''; + const existing = comps.find((c) => c['@type'] === 'script.CombatMonster'); + if (existing) { + // 사용자가 메이커에서 설정한 값 보존 — 누락된 키만 기본값 채움 + if (existing.Enable === undefined) existing.Enable = true; + if (existing.EnemyId === undefined) existing.EnemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY; + if (existing.Group === undefined) existing.Group = 'combat'; + kept++; + } else { + comps.push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: NAME_TO_ENEMY[name] || DEFAULT_ENEMY, Group: 'combat' }); + added++; + } + const names = (e.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster'); + names.push('script.CombatMonster'); + e.componentNames = names.join(','); + } + writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); + return `map${tag}(+${added}/keep${kept})`; +} +``` + +- [ ] **Step 4: 실행 + 값 보존 검증** + +Run: `node tools/monster/gen-combat-monster.mjs` +Expected: `... patched maps: map01(+0/keep3), map02(+0/keep2), ...` (기존 몬스터는 이미 CombatMonster 보유 → keep, EnemyId 보존 + Group 기본값 주입). + +Run: `node -e "const m=JSON.parse(require('fs').readFileSync('map/map01.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));console.log(ms.map(e=>{const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');return e.jsonString.name+':'+c.EnemyId+'/'+c.Group;}).join(', '))"` +Expected: 각 몬스터 `이름:EnemyId/combat` (기존 EnemyId 보존, Group=combat 주입). + +- [ ] **Step 5: 멱등 + 코드블록 Group 확인** + +Run: `node tools/monster/gen-combat-monster.mjs` (2회차) — map 재실행에도 값 동일(no-clobber). +Run: `node -e "const c=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/CombatMonster.codeblock','utf8'));const p=c.ContentProto.Json.Properties.map(x=>x.Name);console.log('props:',p.join(','),'| register has Group:',/RegisterMonster\(self.Entity, self.EnemyId, self.Group\)/.test(c.ContentProto.Json.Methods[0].Code))"` +Expected: `props: EnemyId,Group,RegTries | register has Group: true` + +- [ ] **Step 6: Commit** +```bash +git add tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ +git commit -m "feat(node-groups): CombatMonster 에 Group + 생성기 값 보존(no-clobber)" +``` + +--- + +## Task 3: gen-slaydeck — SLOTS 객체 플러밍 + +**Files:** Modify `tools/deck/gen-slaydeck.mjs`. **생성기는 Task 5까지 실행 금지**, `node --check`만. + +- [ ] **Step 1: upsertUi 슬롯 단언 교체** + +`upsertUi()` 시작부의 단언을 SLOTS 객체용으로 교체: +```js + for (const g of ['combat', 'elite', 'boss']) { + if (!Array.isArray(SLOTS[g]) || SLOTS[g].length < 1) { + throw new Error(`[gen-slaydeck] monster-slots.json 의 "${g}" 그룹 좌표가 없습니다`); + } + } +``` +(기존 `if (SLOTS.length < MAX_MONSTERS) { throw ... }` 블록을 위 코드로 대체) + +- [ ] **Step 2: StartRun 의 SlotPos 주입을 그룹별로 교체** + +`StartRun` Lua의 다음 줄 +``` +self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} } +``` +을 아래 헬퍼 기반 그룹별 주입으로 교체. 파일 상단(다른 헬퍼 함수 근처)에 헬퍼 추가: +```js +function luaSlotGroup(arr) { + return '{ ' + arr.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ') + ' }'; +} +``` +그리고 StartRun 주입 줄을: +```js +self.SlotPos = { combat = ${luaSlotGroup(SLOTS.combat)}, elite = ${luaSlotGroup(SLOTS.elite)}, boss = ${luaSlotGroup(SLOTS.boss)} } +``` + +- [ ] **Step 3: ActiveSlotPos 프로퍼티 추가** + +prop 목록에서 `prop('any', 'SlotPos'),` 다음 줄에 추가: +```js + prop('any', 'ActiveSlotPos'), +``` + +- [ ] **Step 4: PositionMonsterSlot 이 ActiveSlotPos 를 쓰도록 교체** + +`PositionMonsterSlot` 메서드 본문 첫 줄 `local sp = self.SlotPos` 를 교체: +``` +local sp = self.ActiveSlotPos +``` +(나머지 본문 동일 — `sp[slot]` 사용) + +- [ ] **Step 5: 구문 점검** + +Run: `node --check tools/deck/gen-slaydeck.mjs` → 출력 없음(유효). +Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');console.log('luaSlotGroup:',s.includes('function luaSlotGroup'),'| ActiveSlotPos prop:',s.includes(\"'ActiveSlotPos'\"),'| SlotPos combat=:',s.includes('combat = '))"` +Expected: 모두 true. + +- [ ] **Step 6: Commit** +```bash +git add tools/deck/gen-slaydeck.mjs +git commit -m "feat(node-groups): 그룹별 슬롯 좌표 플러밍 (SlotPos/ActiveSlotPos)" +``` + +--- + +## Task 4: gen-slaydeck — RegisterMonster(group) + BuildMonsters 필터 + +**Files:** Modify `tools/deck/gen-slaydeck.mjs`. 생성기 실행 금지, `node --check`만. + +- [ ] **Step 1: RegisterMonster 에 group 인자 추가** + +기존 +```js + method('RegisterMonster', `if self.Registered == nil then + self.Registered = {} +end +table.insert(self.Registered, { entity = monster, enemyId = enemyId })`, [ + { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }, + { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' }, + ]), +``` +를 아래로 교체: +```js + method('RegisterMonster', `if self.Registered == nil then + self.Registered = {} +end +local g = group +if g == nil or g == "" then g = "combat" end +table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g })`, [ + { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }, + { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' }, + { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'group' }, + ]), +``` + +- [ ] **Step 2: BuildMonsters 를 노드 타입 필터로 교체** + +`BuildMonsters` 메서드 전체를 아래로 교체(현재 노드 타입으로 그룹 결정, 전체 숨김 후 일치 그룹만 표시, 그룹 슬롯 좌표 사용). `${MAX_MONSTERS}`는 JS 상수 보간: +```js + method('BuildMonsters', `self.Monsters = {} +local g = "combat" +local node = self.MapNodes[self.CurrentNodeId] +if node ~= nil and node.type ~= nil then g = node.type end +self.ActiveSlotPos = self.SlotPos[g] +local reg = self.Registered or {} +-- 모든 등록 몬스터 숨김 +for i = 1, #reg do + if reg[i].entity ~= nil and isvalid(reg[i].entity) then + reg[i].entity:SetVisible(false) + end +end +-- 현재 그룹만 추려 월드 x 정렬 +local list = {} +for i = 1, #reg do + local r = reg[i] + if r.entity ~= nil and isvalid(r.entity) and r.group == g then + local x = 0 + if r.entity.TransformComponent ~= nil then + x = r.entity.TransformComponent.WorldPosition.x + end + table.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x }) + end +end +table.sort(list, function(a, b) return a.x < b.x end) +local mult = 1 + (self.Floor - 1) * 0.6 +local n = #list +if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end +for i = 1, n do + local item = list[i] + local e = self.Enemies[item.enemyId] + if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end + local intents = {} + for k = 1, #e.intents do + intents[k] = { kind = e.intents[k].kind, value = math.floor(e.intents[k].value * mult) } + end + local maxHp = math.floor(e.maxHp * mult) + self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name, + hp = maxHp, maxHp = maxHp, block = 0, intents = intents, intentIdx = 1, alive = true, slot = i } + self:ReviveMonsterEntity(item.entity) + self:PositionMonsterSlot(i) +end +self.TargetIndex = 1`), +``` + +- [ ] **Step 3: 구문 점검 + 필터 반영 확인** + +Run: `node --check tools/deck/gen-slaydeck.mjs` → 유효. +Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');const i=s.indexOf(\"'BuildMonsters'\");const seg=s.slice(i,i+1200);console.log('node.type group:',seg.includes('node.type'),'| group filter:',seg.includes('r.group == g'),'| hide all:',seg.includes('SetVisible(false)'),'| ActiveSlotPos:',seg.includes('self.ActiveSlotPos = self.SlotPos[g]'));console.log('RegisterMonster 3 args:',/RegisterMonster[\\s\\S]{0,400}Name: 'group'/.test(s));"` +Expected: 모두 true. + +- [ ] **Step 4: Commit** +```bash +git add tools/deck/gen-slaydeck.mjs +git commit -m "feat(node-groups): RegisterMonster(group) + BuildMonsters 노드 타입 필터" +``` + +--- + +## Task 5: 재생성 · 검증 · 플레이테스트 + +**Files:** 산출 `SlayDeckController.codeblock` 등 + +- [ ] **Step 1: 생성기 재실행** + +Run: +```bash +node tools/monster/gen-combat-monster.mjs +node tools/deck/gen-slaydeck.mjs +``` +Expected: 둘 다 에러 없이 완료. + +- [ ] **Step 2: 산출물 검증** + +Run: `node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const c=JSON.parse(fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const sc=JSON.stringify(c);console.log('SlotPos combat/elite/boss:',sc.includes('combat = {')&&sc.includes('elite = {')&&sc.includes('boss = {'),'| group filter:',sc.includes('r.group == g'));"` +Expected: `SlotPos combat/elite/boss: true | group filter: true` + +- [ ] **Step 3: 결정성** + +Run: `git add -A && node tools/deck/gen-slaydeck.mjs && git diff --stat RootDesk/MyDesk/SlayDeckController.codeblock ui/DefaultGroup.ui Global/common.gamelogic` +Expected: 비어 있음(결정적). + +- [ ] **Step 4: sim 회귀** + +Run: `node --test tools/balance/sim-balance.test.mjs` +Expected: 전체 통과(규칙 불변). + +- [ ] **Step 5: Commit (산출물)** +```bash +git add RootDesk/MyDesk/SlayDeckController.codeblock +git commit -m "feat(node-groups): 컨트롤러 재생성 (그룹 필터·그룹 슬롯)" +``` + +- [ ] **Step 6: 메이커 플레이테스트 (수동/MCP)** + +먼저 map01에 그룹을 저작(메이커): 일반/엘리트/보스 몬스터를 배치하고 각 `CombatMonster`의 Group(combat/elite/boss)·EnemyId 지정. (또는 검증 목적이면 기존 3마리에 Group을 각각 combat/elite/boss로 임시 지정.) +그 후 reload→Play 확인: +1. combat 노드 진입 → Group=combat 몬스터만 표시, 나머지 숨김. +2. elite 노드 → 엘리트(+졸개) 그룹만. +3. boss 노드 → 보스(+졸개) 그룹만. +4. 각 그룹 슬롯(HP바·의도)이 해당 몬스터 위에 표시(좌표 안 맞으면 `data/monster-slots.json` 그룹 좌표 튜닝 → 재생성 → reload). +5. 전투/타겟/승리 흐름 정상. + +> MCP 검증 보조: `execute_script`로 `RegisterMonster` 후 `self.Registered[i].group` 확인, `CurrentNodeId`를 각 타입 노드로 두고 `BuildMonsters()` 호출 → `self.Monsters` 가 해당 그룹만 담는지 로그. + +--- + +## Self-Review 결과 (작성자 점검) + +- **스펙 커버리지**: Group 태그(Task2), 그룹별 슬롯 좌표(Task1·3), no-clobber 저작(Task2), RegisterMonster(group)(Task2·4), BuildMonsters 노드 타입 필터·전체 숨김·그룹 슬롯(Task4), 재생성·검증(Task5) — 전부 매핑됨. sim 불변(Task5 회귀). +- **플레이스홀더**: 슬롯 좌표는 초기값+튜닝 명시. 코드 단계는 실제 코드 포함. 메이커 그룹 저작은 코드 외 수동 단계로 명시. +- **타입/이름 일관성**: `Group`(string), `RegisterMonster(monster, enemyId, group)`, `Registered{entity,enemyId,group}`, `ActiveSlotPos`, `SlotPos.{combat,elite,boss}`, `BuildMonsters`의 `g = node.type` — Task 간 일치. `MAX_MONSTERS` 보간 유지. +- **리스크**: 그룹 좌표 수 < 몬스터 수면 초과 슬롯 미배치(기본 4좌표로 완화). 비combat/rest 노드만 StartCombat 호출되므로 g∈{combat,elite,boss}. MSW 월드 API는 기존과 동일(검증됨).