# 분기 맵 노드 진행 (TODO E3) 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:** 플레이어가 작성된 분기 맵(DAG)에서 다음 노드를 선택해 전투/엘리트/보스로 진행, 보스 클리어 시 "런 클리어". **Architecture:** `data/map.json`(그래프)·`data/enemies.json`(다중 적)을 `gen-slaydeck.mjs`가 로드·주입. SlayDeckController에 맵 상태·네비게이션 메서드 추가, MapHud UI 생성. 자동 진행 대신 ShowMap→PickNode→StartCombat→보상→ShowMap 루프. **Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. --- ## File Structure - Create: `data/map.json` — 분기 맵. - Modify: `data/enemies.json` — slime_elite·slime_boss 추가. - Modify: `tools/gen-slaydeck.mjs` — 맵/적 로드·검증·직렬화 헬퍼, method() returnType, 속성·메서드·MapHud UI. 검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. --- ### Task 1: 데이터 + 로드·검증·직렬화 헬퍼 **Files:** Create `data/map.json`; Modify `data/enemies.json`, `tools/gen-slaydeck.mjs` - [ ] **Step 1: `data/map.json` 작성** ```json { "start": ["A", "B"], "nodes": { "A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] }, "B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["D", "E"] }, "C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] }, "D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] }, "E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] }, "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] } } } ``` - [ ] **Step 2: `data/enemies.json`에 엘리트·보스 추가** — `slime` 항목 다음에: ```json "slime_elite": { "name": "정예 슬라임", "maxHp": 70, "intents": [ { "kind": "Attack", "value": 14 }, { "kind": "Attack", "value": 8 }, { "kind": "Defend", "value": 10 } ] }, "slime_boss": { "name": "슬라임 킹", "maxHp": 120, "intents": [ { "kind": "Attack", "value": 18 }, { "kind": "Defend", "value": 12 }, { "kind": "Attack", "value": 10 }, { "kind": "Attack", "value": 22 } ] } ``` - [ ] **Step 3: 생성기 상단에 map 로드·검증·헬퍼 추가** — `const ACTIVE_ENEMY = ...;` 다음에: ```js const MAP = JSON.parse(readFileSync('data/map.json', 'utf8')); for (const id of MAP.start) { if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`); } for (const [id, n] of Object.entries(MAP.nodes)) { if (!ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`); for (const nx of n.next) { if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`); } } const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row)); function luaIntentsArray(intents) { return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }'; } function luaEnemiesTable(enemies) { const lines = Object.entries(enemies).map(([id, e]) => `\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`); return `self.Enemies = {\n${lines.join('\n')}\n}`; } function luaMapNodesTable(nodes) { const lines = Object.entries(nodes).map(([id, n]) => { const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }'; return `\t${id} = { type = ${luaStr(n.type)}, enemy = ${luaStr(n.enemy)}, row = ${n.row}, col = ${n.col}, next = ${nx} },`; }); return `self.MapNodes = {\n${lines.join('\n')}\n}`; } function luaStartArray(start) { return 'self.MapStart = { ' + start.map(luaStr).join(', ') + ' }'; } ``` - [ ] **Step 4: method()에 ReturnType 파라미터 추가** — 기존 method 함수를: ```js function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') { return { Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name, }; } ``` - [ ] **Step 5: JSON·문법 검사** Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs` Expected: `JSON OK` + 오류 없음 - [ ] **Step 6: 커밋** ```bash git add data/map.json data/enemies.json tools/gen-slaydeck.mjs git commit -m "data(E3): 분기 맵 map.json·엘리트/보스 적 + 직렬화 헬퍼" ``` --- ### Task 2: 맵 속성 + StartRun(맵 빌드·ShowMap) **Files:** Modify `tools/gen-slaydeck.mjs` - [ ] **Step 1: 맵 상태 속성 추가** — `prop('boolean', 'RunActive', 'false'),` 다음에: ```js prop('any', 'Enemies'), prop('any', 'MapNodes'), prop('any', 'MapStart'), prop('string', 'CurrentNodeId', '""'), prop('string', 'CurrentEnemyId', '""'), ``` - [ ] **Step 2: StartRun 교체** — 맵 빌드 + ShowMap: ```js method('StartRun', `self.PlayerMaxHp = 80 self.PlayerHp = self.PlayerMaxHp self.Gold = 0 self.Floor = 0 self.RunLength = ${MAX_ROW} self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} } self.RunActive = true ${luaEnemiesTable(ENEMIES.enemies)} ${luaMapNodesTable(MAP.nodes)} ${luaStartArray(MAP.start)} self.CurrentNodeId = "" self.CurrentEnemyId = "" self:BindButtons() self:ShowMap()`), ``` - [ ] **Step 3: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 4: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E3): 맵 상태 속성·StartRun 맵 빌드/ShowMap" ``` --- ### Task 3: StartCombat·CheckCombatEnd·PickReward (맵 연동) **Files:** Modify `tools/gen-slaydeck.mjs` - [ ] **Step 1: StartCombat 교체** — 적을 self.Enemies에서 로드, Floor=노드 row: ```js method('StartCombat', `self.MaxEnergy = 3 self.Turn = 0 local node = self.MapNodes[self.CurrentNodeId] if node ~= nil then self.Floor = node.row end local enemy = self.Enemies[self.CurrentEnemyId] self.PlayerBlock = 0 self.EnemyName = enemy.name self.EnemyMaxHp = enemy.maxHp self.EnemyHp = self.EnemyMaxHp self.EnemyBlock = 0 self.EnemyIntents = enemy.intents self.EnemyIntentIndex = 1 self.CombatOver = false self.DiscardPile = {} self.Hand = {} ${luaCardsTable(CARDS.cards)} self.DrawPile = {} for i = 1, #self.RunDeck do self.DrawPile[i] = self.RunDeck[i] end self:Shuffle(self.DrawPile) self:RenderCombat() self:StartPlayerTurn()`), ``` - [ ] **Step 2: CheckCombatEnd 교체** — 보스 노드면 런 클리어: ```js method('CheckCombatEnd', `if self.EnemyHp <= 0 then self.CombatOver = true self.Gold = self.Gold + ${GOLD_PER_WIN} self:RenderRun() local node = self.MapNodes[self.CurrentNodeId] if node ~= nil and node.type == "boss" then self:ShowResult("런 클리어!") self.RunActive = false else self:OfferReward() end elseif self.PlayerHp <= 0 then self.CombatOver = true self:ShowResult("패배...") self.RunActive = false end`), ``` - [ ] **Step 3: PickReward 마지막을 ShowMap으로** — PickReward 코드의 마지막 `self:StartCombat()`를 `self:ShowMap()`로 교체. (그 외 동일) ```js method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then return end if slot ~= 0 and self.RewardChoices ~= nil then local id = self.RewardChoices[slot] if id ~= nil then table.insert(self.RunDeck, id) end end local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") if hud ~= nil then hud.Enable = false end self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), ``` - [ ] **Step 4: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 5: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E3): StartCombat 적 데이터화·보스 런클리어·보상후 맵복귀" ``` --- ### Task 4: ShowMap·IsReachable·PickNode·RenderMap + BindButtons **Files:** Modify `tools/gen-slaydeck.mjs` - [ ] **Step 1: 맵 메서드 추가** — PickReward 메서드 다음(마지막 `]);` 직전)에 삽입: ```js method('ShowMap', `self:RenderMap() local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") if hud ~= nil then hud.Enable = true end`), method('IsReachable', `local list if self.CurrentNodeId == "" then list = self.MapStart else local node = self.MapNodes[self.CurrentNodeId] if node == nil then return false end list = node.next end for i = 1, #list do if list[i] == id then return true end end return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'), method('RenderMap', `for id, node in pairs(self.MapNodes) do local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. id) if e ~= nil then local reachable = self:IsReachable(id) if e.SpriteGUIRendererComponent ~= nil then if reachable then e.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1) else e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) end end if e.ButtonComponent ~= nil then e.ButtonComponent.Enable = reachable end end end`), method('PickNode', `if self.RunActive ~= true then return end if self:IsReachable(id) ~= true then return end self.CurrentNodeId = id self.CurrentEnemyId = self.MapNodes[id].enemy local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") if hud ~= nil then hud.Enable = false end self:StartCombat()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), ``` - [ ] **Step 2: BindButtons에 맵 노드 버튼 바인딩 추가** — BindButtons 코드의 마지막 `end`(skip 바인딩) 다음에 추가. BindButtons 끝부분의 skip 블록 다음에 붙이도록, skip 블록을 아래로 교체: ```js local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip") if skip ~= nil and skip.ButtonComponent ~= nil then skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end) end local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} } for i = 1, #mapNodeIds do local nid = mapNodeIds[i] local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid) if mn ~= nil and mn.ButtonComponent ~= nil then mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end) end end`), ``` (BindButtons 전체에서 기존 skip 블록 `local skip = ... end`)` 부분을 위 블록으로 교체) - [ ] **Step 3: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 4: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E3): ShowMap/IsReachable/PickNode/RenderMap·맵 노드 바인딩" ``` --- ### Task 5: MapHud UI 생성 **Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`) - [ ] **Step 1: guid 'map' 분기** — ns 매핑에 추가: ```js const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : 0xfe; ``` - [ ] **Step 2: 필터 확장** — upsertUi 필터에 MapHud 추가: ```js ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud')); ``` - [ ] **Step 3: MapHud 그룹 생성** — `ui.ContentProto.Entities.push(...reward);` 다음에 삽입: ```js const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스' }; const map = []; const mapHud = entity({ id: guid('map', 0), path: '/ui/DefaultGroup/MapHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 7, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.9 }, type: 1, raycast: true }), ], }); mapHud.jsonString.enable = false; map.push(mapHud); map.push(entity({ id: guid('map', 1), path: '/ui/DefaultGroup/MapHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 510 } }), sprite({ color: TRANSPARENT }), text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }), ], })); let mapN = 2; for (const [id, node] of Object.entries(MAP.nodes)) { const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`; const pos = { x: node.col * 180, y: node.row * 170 - 80 }; map.push(entity({ id: guid('map', mapN++), path: nodePath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', displayOrder: node.row, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 80 }, pos }), sprite({ color: { r: 0.3, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }), button(), ], })); map.push(entity({ id: guid('map', mapN++), path: `${nodePath}/Label`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 150, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 144, y: 72 }, pos: { x: 0, y: 0 } }), sprite({ color: TRANSPARENT }), text({ value: `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}`, fontSize: 20, bold: true }), ], })); } ui.ContentProto.Entities.push(...map); ``` - [ ] **Step 4: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 5: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E3): MapHud 노드 맵 UI 생성" ``` --- ### Task 6: 재생성 + 검증 **Files:** 생성물 - [ ] **Step 1: 생성** Run: `node tools/gen-slaydeck.mjs` Expected: `Slay deck UI and combat codeblocks generated.` - [ ] **Step 2: 메서드·UI·적 주입 확인** Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowMap','PickNode','IsReachable','RenderMap'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/slime_boss/.test(sc)&&/슬라임 킹/.test(sc)?'ENEMIES OK':'NO ENEMIES'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/MapHud')&&has('/ui/DefaultGroup/MapHud/Node_BOSS')&&has('/ui/DefaultGroup/MapHud/Node_A/Label')?'UI OK':'UI MISSING')"` Expected: `METHODS OK` / `ENEMIES OK` / `UI OK` - [ ] **Step 3: 결정성** Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` Expected: `DETERMINISTIC` - [ ] **Step 4: git status** Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` Expected: `data/*`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). - [ ] **Step 5: 생성물 커밋** ```bash git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock git commit -m "재생성(E3): 분기 맵·다중 적 반영" ``` - [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** reload→Play: StartRun → MapHud(A·B만 클릭 가능) → PickNode("A") → 슬라임 전투 → 승리 → 보상 → 맵(C·D 활성) → PickNode("C") → 정예 슬라임(HP70) → ... → BOSS → 슬라임킹(HP120) → 승리 → "런 클리어!". 도달 불가 노드 PickNode → 무시. MCP는 `PickNode`/`PlayCard`/`PickReward` 직접 호출 + 상태 로그로 검증. --- ## Self-Review - **Spec coverage:** map.json/적(Task1), 맵 상태·StartRun(Task2), StartCombat 적데이터·보스클리어·보상후맵(Task3), Show/Pick/Reachable/RenderMap·바인딩(Task4), MapHud UI(Task5), 검증(Task6). 스펙 전 항목 매핑. - **Placeholder scan:** 모든 단계 실제 코드/명령. - **Type consistency:** 메서드 `StartRun/ShowMap/IsReachable/PickNode/RenderMap/StartCombat/CheckCombatEnd/PickReward` 정의·호출 일치. 속성 `Enemies/MapNodes/MapStart/CurrentNodeId/CurrentEnemyId` 정의(Task2)·사용(Task3·4) 일치. UI 경로 `/ui/DefaultGroup/MapHud/Node_{id}`·`/Label`가 codeblock(RenderMap/PickNode/BindButtons)·생성(Task5)에서 동일(노드 id는 map.json 키). `IsReachable`는 boolean 반환(method returnType param, Task1). enemy 필드 `name/maxHp/intents`가 데이터·luaEnemiesTable·StartCombat에서 일치.