diff --git a/docs/superpowers/plans/2026-06-09-map-nodes.md b/docs/superpowers/plans/2026-06-09-map-nodes.md new file mode 100644 index 0000000..8cf1377 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-map-nodes.md @@ -0,0 +1,493 @@ +# 분기 맵 노드 진행 (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에서 일치. diff --git a/docs/superpowers/specs/2026-06-09-map-nodes-design.md b/docs/superpowers/specs/2026-06-09-map-nodes-design.md new file mode 100644 index 0000000..f97afa9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-map-nodes-design.md @@ -0,0 +1,82 @@ +# 분기 맵 노드 진행 (TODO E3) — 설계 + +> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E3) + SlayDeckController/run-loop-core 분석. +> 선행: E1+E2(런 루프 코어) 완료. 후속: E4(상점/휴식)·E5(유물)·E6(보스 연출/저장). + +## 문제 + +E1+E2는 보상 후 자동으로 다음 전투로 넘어간다(고정 N). 로그라이크는 **플레이어가 맵에서 경로를 +선택**해야 한다. 분기 노드 맵과 노드별 적 차등이 필요하다. + +## 범위 + +플레이어가 **분기 맵(작성된 DAG)** 에서 다음 노드를 선택 → 노드 타입(전투/엘리트/보스)대로 전투 +(적은 데이터로 차등) → 보상 → 맵으로 복귀 → 보스 클리어 시 "런 클리어". **상점/휴식·유물·저장· +절차적 생성·연결선 그리기는 범위 밖**. 맵 스키마는 상점/휴식 타입을 미래 수용. + +## 설계 + +### 데이터 +**`data/map.json`** (분기 DAG): +```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": [] } + } +} +``` +- `type` ∈ {combat, elite, boss} (이 슬라이스). `enemy`는 enemies.json id. `row`(1=시작), `col`(레이아웃 x 단위), `next`(도달 노드 ids). + +**`data/enemies.json`** 확장: +```json +"slime_elite": { "name": "정예 슬라임", "maxHp": 70, + "intents": [ {Attack 14}, {Attack 8}, {Defend 10} ] }, +"slime_boss": { "name": "슬라임 킹", "maxHp": 120, + "intents": [ {Attack 18}, {Defend 12}, {Attack 10}, {Attack 22} ] } +``` +(`activeEnemy`는 유지하되 런은 맵 노드의 enemy로 전투. F 시뮬레이터는 여전히 activeEnemy 기준 — 맵 적 시뮬은 후속.) + +### 상태 (SlayDeckController 속성 추가) +- `Enemies`(any) — 전체 적 테이블(id→정의). 생성기가 enemies.json 전체 주입. +- `MapNodes`(any) — 그래프(id→{type, enemy, row, col, next}). +- `MapStart`(any) — 1행 노드 id 배열. +- `CurrentNodeId`(string) — 현재 위치("" = 시작 전). +- `CurrentEnemyId`(string) — 현재 전투 적 id. + +### 메서드 +- `StartRun`(수정): 런 상태 초기화 + `Enemies`/`MapNodes`/`MapStart` 세팅 + `CurrentNodeId=""` + + BindButtons(맵 노드 버튼 포함, 1회) → `self:ShowMap()` (기존 StartCombat 대신). +- `ShowMap`(신규): 선택 가능 노드 결정(CurrentNodeId=="" 면 MapStart, 아니면 MapNodes[CurrentNodeId].next). + 각 노드 버튼 활성/비활성·라벨 갱신, 전투 UI 가리고 MapHud 표시(Enable). +- `IsReachable(id)`(헬퍼) — 현재 선택 가능 목록에 id 포함 여부. +- `PickNode(id)`(신규): `IsReachable(id)` 아니면 무시. `CurrentNodeId=id`, + `CurrentEnemyId=MapNodes[id].enemy`, MapHud 숨김 → `StartCombat()`. +- `StartCombat`(수정): 적을 `self.Enemies[self.CurrentEnemyId]`에서 로드(이름/HP/의도). Floor 증가 로직 제거. +- `CheckCombatEnd`(수정): 승리 시 골드+15 → 현재 노드 `type=="boss"`면 `ShowResult("런 클리어!")`+RunActive=false; + 아니면 `OfferReward`. 패배 → "패배..."+RunActive=false. +- `PickReward`(수정): 카드 처리 후 `StartCombat` 대신 `self:ShowMap()`. + +### UI (MapHud, 신규) +- 평소 숨김. 풀스크린 모달 배경 + 제목 "다음 노드 선택". +- 노드 버튼 6개: 위치 = (col×스페이싱, 화면중앙+row×행간), 라벨(전투/엘리트/보스 + 적 이름). +- 선택 가능 노드만 밝게·클릭, 나머지 어둡게(반투명). 클릭 → `PickNode(id)`. +- 연결선은 생략(도달성=활성/비활성으로 표현; 연결선 그리기는 후속 폴리시). + +### 단일 소스 +모든 변경은 `tools/gen-slaydeck.mjs`에서 생성. map.json/enemies.json은 데이터 단일 소스. + +## 검증 (메이커 Play) +- StartRun → MapHud, 1행 A·B만 선택 가능(나머지 비활성). +- A 선택 → 슬라임 전투 → 승리 → 보상 → 맵 복귀, 이제 C·D 선택 가능(B쪽 E는 불가). +- 엘리트 노드 → 정예 슬라임(HP 70) 전투. 보스 노드 → 슬라임 킹(HP 120). +- 보스 승리 → "런 클리어!". 패배 → "패배...". 도달 불가 노드 클릭 → 무시. +- 생성기 결정적, JSON 유효. (버튼 클릭은 런타임 — MCP는 PickNode/PlayCard/PickReward 직접 호출로 검증.) + +## 범위 밖 (금지) +- 상점/휴식 노드 동작(E4)·유물(E5)·저장(E6). 절차적 맵·무작위 분기·연결선 그리기. 새 카드.