diff --git a/docs/superpowers/plans/2026-06-09-shop-rest.md b/docs/superpowers/plans/2026-06-09-shop-rest.md new file mode 100644 index 0000000..5f0d081 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-shop-rest.md @@ -0,0 +1,488 @@ +# 상점/휴식 노드 (TODO E4) 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:** 맵에 상점(골드→카드)·휴식(HP 회복) 노드를 추가하고, 진입 시 전투 대신 상점/휴식 UI로 분기. + +**Architecture:** `data/map.json`에 shop/rest 노드 추가(enemy 없음). SlayDeckController에 상점/휴식 메서드, PickNode 타입 분기, ShopHud/RestHud UI. 모두 `gen-slaydeck.mjs`에서 생성. + +**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. + +--- + +## File Structure +- Modify: `data/map.json` — 4행, shop/rest 노드. +- Modify: `tools/gen-slaydeck.mjs` — 검증 완화, enemy 조건부 직렬화, 상수, 속성, PickNode 분기, 상점/휴식 메서드, ShopHud/RestHud UI, MapHud y 중앙정렬. + +검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. + +--- + +### Task 1: 데이터 + 검증완화 + enemy 조건부 직렬화 + 상수·속성 + +**Files:** Modify `data/map.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": ["C", "D"] }, + "C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] }, + "D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] }, + "E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] }, + "F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] }, + "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] } + } +} +``` + +- [ ] **Step 2: 검증 완화 (enemy 선택적)** — 생성기의 맵 검증 루프를 교체: + +```js +for (const [id, n] of Object.entries(MAP.nodes)) { + if (n.enemy && !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}`); + } +} +``` + +- [ ] **Step 3: luaMapNodesTable enemy 조건부** — 함수를 교체: + +```js +function luaMapNodesTable(nodes) { + const lines = Object.entries(nodes).map(([id, n]) => { + const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }'; + const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : ''; + return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`; + }); + return `self.MapNodes = {\n${lines.join('\n')}\n}`; +} +``` + +- [ ] **Step 4: 상수 추가** — `writeCodeblocks()` 안 `const GOLD_PER_WIN = 15;` 다음에: + +```js + const CARD_PRICE = 30; + const REST_HEAL = 30; +``` + +- [ ] **Step 5: 상점 상태 속성 추가** — `prop('string', 'CurrentEnemyId', '""'),` 다음에: + +```js + prop('any', 'ShopChoices'), + prop('any', 'ShopBought'), +``` + +- [ ] **Step 6: JSON·문법 검사** + +Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs` +Expected: `JSON OK` + 오류 없음 + +- [ ] **Step 7: 커밋** + +```bash +git add data/map.json tools/gen-slaydeck.mjs +git commit -m "data(E4): 상점/휴식 노드 맵 + enemy 선택적 검증/직렬화 + 상수/속성" +``` + +--- + +### Task 2: PickNode 분기 + 상점/휴식 메서드 + +**Files:** Modify `tools/gen-slaydeck.mjs` + +- [ ] **Step 1: PickNode 교체 (타입 분기)** + +```js + method('PickNode', `if self.RunActive ~= true then + return +end +if self:IsReachable(id) ~= true then + return +end +self.CurrentNodeId = id +local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") +if hud ~= nil then + hud.Enable = false +end +local node = self.MapNodes[id] +if node.type == "shop" then + self:ShowShop() +elseif node.type == "rest" then + self:ShowRest() +else + self.CurrentEnemyId = node.enemy + self:StartCombat() +end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), +``` + +- [ ] **Step 2: 상점/휴식 메서드 추가** — PickNode 메서드 다음에 삽입: + +```js + method('ShowShop', `local pool = {} +for cid, _ in pairs(self.Cards) do + table.insert(pool, cid) +end +self.ShopChoices = {} +self.ShopBought = { false, false, false } +for i = 1, 3 do + self.ShopChoices[i] = pool[math.random(1, #pool)] +end +self:RenderShop() +local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") +if hud ~= nil then + hud.Enable = true +end`), + method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "골드 " .. string.format("%d", self.Gold)) +for i = 1, 3 do + local cid = self.ShopChoices[i] + local c = self.Cards[cid] + local base = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i) + if c ~= nil then + self:SetText(base .. "/Name", c.name) + self:SetText(base .. "/Cost", tostring(c.cost)) + self:SetText(base .. "/Desc", c.desc) + self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 골드") + local e = _EntityService:GetEntityByPath(base) + if e ~= nil and e.SpriteGUIRendererComponent ~= nil then + if self.ShopBought[i] == true then + e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) + elseif c.kind == "Attack" then + e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) + elseif c.kind == "Skill" then + e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) + else + e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) + end + end + end +end`), + method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then + return +end +if self.Gold < ${CARD_PRICE} then + return +end +self.Gold = self.Gold - ${CARD_PRICE} +table.insert(self.RunDeck, self.ShopChoices[slot]) +self.ShopBought[slot] = true +self:RenderShop() +self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), + method('ShowRest', `local old = self.PlayerHp +self.PlayerHp = self.PlayerHp + ${REST_HEAL} +if self.PlayerHp > self.PlayerMaxHp then + self.PlayerHp = self.PlayerMaxHp +end +local healed = self.PlayerHp - old +self:SetText("/ui/DefaultGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")") +self:RenderCombat() +local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") +if hud ~= nil then + hud.Enable = true +end`), + method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") +if s ~= nil then + s.Enable = false +end +local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") +if r ~= nil then + r.Enable = false +end +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(E4): PickNode 타입 분기·상점(구매)/휴식(회복) 메서드" +``` + +--- + +### Task 3: BindButtons 바인딩 + +**Files:** Modify `tools/gen-slaydeck.mjs` + +- [ ] **Step 1: BindButtons 맵 노드 루프 다음에 상점/휴식 바인딩 추가** — BindButtons 코드의 맵 노드 for-loop(`...PickNode(nid)...end\nend`) 다음, 닫는 백틱 직전에 삽입. 맵 노드 루프 끝 부분을 아래로 교체: + +```js +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 +for i = 1, 3 do + local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i)) + if sc ~= nil and sc.ButtonComponent ~= nil then + sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end) + end +end +local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave") +if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then + shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) +end +local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave") +if restLeave ~= nil and restLeave.ButtonComponent ~= nil then + restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) +end`), +``` + +- [ ] **Step 2: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 3: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(E4): 상점 구매/나가기·휴식 나가기 버튼 바인딩" +``` + +--- + +### Task 4: ShopHud·RestHud UI + MapHud 4행 정렬 + +**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`) + +- [ ] **Step 1: guid 'shp'·'rst' 분기** — ns 매핑에 추가(map 분기 다음): + +```js + const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : 0xfe; +``` + +- [ ] **Step 2: 필터 확장** — upsertUi 필터에 ShopHud·RestHud 추가: + +```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') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud')); +``` + +- [ ] **Step 3: MapHud 노드 y 중앙정렬** — upsertUi의 노드 pos 계산을 교체: + +```js + const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 }; +``` + +- [ ] **Step 4: ShopHud·RestHud 생성** — `ui.ContentProto.Entities.push(...map);` 다음에 삽입: + +```js + const shop = []; + const shopHud = entity({ + id: guid('shp', 0), + path: '/ui/DefaultGroup/ShopHud', + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 8, + 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.92 }, type: 1, raycast: true }), + ], + }); + shopHud.jsonString.enable = false; + shop.push(shopHud); + shop.push(entity({ + id: guid('shp', 1), + path: '/ui/DefaultGroup/ShopHud/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: 400 } }), + sprite({ color: TRANSPARENT }), + text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), + ], + })); + shop.push(entity({ + id: guid('shp', 2), + path: '/ui/DefaultGroup/ShopHud/Gold', + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 1, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }), + sprite({ color: TRANSPARENT }), + text({ value: '골드 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), + ], + })); + let shpN = 3; + const shopXs = [-300, 0, 300]; + for (let i = 1; i <= 3; i++) { + const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`; + shop.push(entity({ + id: guid('shp', shpN++), + path: cardPath, + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', + displayOrder: i, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: shopXs[i - 1], y: 20 } }), + sprite({ color: ATTACK, type: 1, raycast: true }), + button(), + ], + })); + for (const [suffix, cfg] of [ + ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], + ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], + ['Desc', { size: { x: 160, y: 60 }, pos: { x: 0, y: -50 }, value: '', fontSize: 20, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }], + ['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 골드', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }], + ]) { + shop.push(entity({ + id: guid('shp', shpN++), + path: `${cardPath}/${suffix}`, + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : suffix === 'Desc' ? 2 : 3, + components: [ + transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), + sprite({ color: TRANSPARENT }), + text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }), + ], + })); + } + } + shop.push(entity({ + id: guid('shp', shpN++), + path: '/ui/DefaultGroup/ShopHud/Leave', + modelId: 'uibutton', + entryId: 'UIButton', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', + displayOrder: 10, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -300 } }), + sprite({ color: DARK, type: 1, raycast: true }), + button(), + text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), + ], + })); + ui.ContentProto.Entities.push(...shop); + + const rest = []; + const restHud = entity({ + id: guid('rst', 0), + path: '/ui/DefaultGroup/RestHud', + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 9, + 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.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }), + ], + }); + restHud.jsonString.enable = false; + rest.push(restHud); + rest.push(entity({ + id: guid('rst', 1), + path: '/ui/DefaultGroup/RestHud/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: 140 } }), + sprite({ color: TRANSPARENT }), + text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), + ], + })); + rest.push(entity({ + id: guid('rst', 2), + path: '/ui/DefaultGroup/RestHud/Info', + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 1, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }), + sprite({ color: TRANSPARENT }), + text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), + ], + })); + rest.push(entity({ + id: guid('rst', 3), + path: '/ui/DefaultGroup/RestHud/Leave', + modelId: 'uibutton', + entryId: 'UIButton', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', + displayOrder: 2, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -120 } }), + sprite({ color: DARK, type: 1, raycast: true }), + button(), + text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), + ], + })); + ui.ContentProto.Entities.push(...rest); +``` + +- [ ] **Step 5: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 6: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(E4): ShopHud/RestHud UI·MapHud 4행 중앙정렬" +``` + +--- + +### Task 5: 재생성 + 검증 + +**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(['ShowShop','BuyCard','ShowRest','LeaveNode','RenderShop'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); 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/ShopHud/Card1/Price')&&has('/ui/DefaultGroup/RestHud/Info')&&has('/ui/DefaultGroup/MapHud/Node_D')?'UI OK':'UI MISSING')"` +Expected: `METHODS 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/map.json`, `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 "재생성(E4): 상점/휴식 노드·UI 반영" +``` + +- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** + +reload→Play: 맵(4행, 2행에 휴식C·상점D) → PickNode("D")→상점(카드3·골드) → BuyCard(골드≥30이면 -30·RunDeck+1·비활성; 부족하면 무시) → LeaveNode→맵 / PickNode("C")→휴식(HP+30 클램프)→LeaveNode→맵 / 전투·보스·런 클리어 회귀 확인. MCP는 PickNode/BuyCard/LeaveNode 직접 호출 + 로그. + +--- + +## Self-Review +- **Spec coverage:** 맵/검증/직렬화/상수/속성(Task1), PickNode분기·상점·휴식(Task2), 바인딩(Task3), UI·MapHud정렬(Task4), 검증(Task5). 스펙 전 항목 매핑. +- **Placeholder scan:** 모든 단계 실제 코드/명령. +- **Type consistency:** 메서드 `PickNode/ShowShop/RenderShop/BuyCard/ShowRest/LeaveNode` 정의·호출·바인딩 일치. 속성 `ShopChoices/ShopBought` 정의(Task1)·사용(Task2) 일치. UI 경로 `/ui/DefaultGroup/ShopHud/Card{1..3}/{Name,Cost,Desc,Price}`·`/Gold`·`/Leave`, `/RestHud/{Info,Leave}`가 codeblock(RenderShop/ShowRest/BindButtons)·생성(Task4)에서 동일. 상수 `CARD_PRICE/REST_HEAL` Task1 정의·Task2 사용 일치. diff --git a/docs/superpowers/specs/2026-06-09-shop-rest-design.md b/docs/superpowers/specs/2026-06-09-shop-rest-design.md new file mode 100644 index 0000000..7815057 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-shop-rest-design.md @@ -0,0 +1,70 @@ +# 상점/휴식 노드 (TODO E4) — 설계 + +> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E4) + E3 맵 노드 구조. +> 선행: E3(분기 맵) 완료. 후속: E5(유물)·E6(저장). 카드 제거는 덱 보기 UI 필요 → 후속 분리. + +## 문제 + +E3로 분기 맵은 됐으나 모든 노드가 전투다. 골드는 적립만 되고 소비처가 없다. 상점(골드→카드)· +휴식(HP 회복) 노드가 필요하다. + +## 범위 + +맵에 상점/휴식 노드 추가, 진입 시 전투 대신 상점/휴식 UI. 상점 = 카드 구매(골드). 휴식 = HP 회복. +**카드 제거(덱 보기 UI 필요)·유물·저장·휴식 업그레이드는 범위 밖.** + +## 설계 + +### 데이터 (`data/map.json` 교체 — 4행) +```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": ["C", "D"] }, + "C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] }, + "D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] }, + "E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] }, + "F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] }, + "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] } + } +} +``` +- rest/shop 노드는 `enemy` 없음. 생성기 검증을 "`enemy` 있을 때만 ENEMIES 확인"으로 완화. + Lua MapNodes 직렬화도 enemy 있을 때만 `enemy = "..."` 포함. + +### 파라미터 (생성기 상수) +- `CARD_PRICE = 30`, `REST_HEAL = 30`. + +### 상태 추가 +- `ShopChoices`(any) — 상점 제시 카드 id 3개. +- `ShopBought`(any) — 슬롯별 구매 여부 {bool×3}. + +### 메서드 +- `PickNode`(수정): CurrentNodeId 세팅·맵 숨김 후 타입 분기 — + `shop`→`ShowShop`, `rest`→`ShowRest`, 그 외→`CurrentEnemyId=node.enemy`·`StartCombat`. +- `ShowShop`(신규): 카드 풀에서 3개 무작위→ShopChoices, ShopBought 초기화(false), + 각 슬롯 비주얼·가격·골드 갱신, ShopHud 표시. +- `BuyCard(slot)`(신규): ShopBought[slot]==true 또는 Gold