docs(node-monster-groups): 구현 계획 (5개 태스크)

slots 그룹화 → CombatMonster Group+no-clobber → 슬롯 플러밍 → RegisterMonster(group)+BuildMonsters 필터 → 재생성·플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:32:46 +09:00
parent 0d517617a3
commit 59c699c04b

View File

@@ -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는 기존과 동일(검증됨).