diff --git a/docs/superpowers/plans/2026-06-09-run-loop-core.md b/docs/superpowers/plans/2026-06-09-run-loop-core.md new file mode 100644 index 0000000..41b83c4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-run-loop-core.md @@ -0,0 +1,438 @@ +# 런 루프 코어 (TODO E1+E2) 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:** 단일 전투를 연속 N전투 런으로 확장 — 런 상태(HP/골드/덱) 영속 + 승리 후 카드 1택 보상 + 다음 전투 + N전투 후 "런 클리어". + +**Architecture:** 기존 `SlayDeckController`(gen-slaydeck.mjs 생성)에 런 상태·보상 메서드 추가. StartRun(영속 초기화·버튼 1회 바인딩) vs StartCombat(전투별 초기화, RunDeck에서 드로) 분리. RewardHud UI 생성. + +**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. + +--- + +## File Structure +- Modify: `tools/gen-slaydeck.mjs` — 유일 변경 대상. + - `writeCodeblocks`: 런 상수, 새 속성, OnBeginPlay/StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat. + - `upsertUi`: CombatHud에 Floor/Gold, RewardHud 그룹 생성, 필터 확장, guid 'rwd' 분기. + +MSW Lua 단위 테스트 불가 → 검증은 생성기 문법·재생성·결정성·메이커 Play. + +--- + +### Task 1: 런 상수·속성·StartRun + +**Files:** Modify `tools/gen-slaydeck.mjs` + +- [ ] **Step 1: 런 상수 추가** — `writeCodeblocks()` 함수 본문 첫 줄에 삽입: + +```js + const RUN_LENGTH = 3; + const GOLD_PER_WIN = 15; +``` + +- [ ] **Step 2: 새 속성 추가** — 속성 배열의 `prop('any', 'EnemyName'),` 다음에: + +```js + prop('any', 'RunDeck'), + prop('number', 'Gold', '0'), + prop('number', 'Floor', '0'), + prop('number', 'RunLength', String(RUN_LENGTH)), + prop('any', 'RewardChoices'), + prop('boolean', 'RunActive', 'false'), +``` + +- [ ] **Step 3: OnBeginPlay → StartRun** — `method('OnBeginPlay', \`self:StartCombat()\`),` 를: + +```js + method('OnBeginPlay', `self:StartRun()`), +``` + +- [ ] **Step 4: StartRun 메서드 추가** — OnBeginPlay 다음에 삽입: + +```js + method('StartRun', `self.PlayerMaxHp = 80 +self.PlayerHp = self.PlayerMaxHp +self.Gold = 0 +self.Floor = 0 +self.RunLength = ${RUN_LENGTH} +self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} } +self.RunActive = true +self:BindButtons() +self:StartCombat()`), +``` + +- [ ] **Step 5: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 6: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(E1): 런 상태 속성·StartRun 추가" +``` + +--- + +### Task 2: StartCombat 수정 + BindButtons 수정 + +**Files:** Modify `tools/gen-slaydeck.mjs` + +- [ ] **Step 1: StartCombat 본문 교체** — `method('StartCombat', \`...\`)`의 코드를 아래로(HP 보존·Floor++·RunDeck에서 드로·BindButtons 호출 제거): + +```js + method('StartCombat', `self.MaxEnergy = 3 +self.Turn = 0 +self.Floor = self.Floor + 1 +self.PlayerBlock = 0 +self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)} +self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp} +self.EnemyHp = self.EnemyMaxHp +self.EnemyBlock = 0 +${luaIntentsTable(ACTIVE_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: BindButtons에 보상 버튼 바인딩 추가** — BindButtons 코드 끝(마지막 `end` 다음)에 추가. 현재 마지막 부분: +``` +for i = 1, 5 do + local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) + if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then + cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) + end +end +``` +뒤에 이어붙이도록 BindButtons 코드를 아래 전체로 교체: + +```js + method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton") +if endTurn ~= nil and endTurn.ButtonComponent ~= nil then + if self.EndTurnHandler ~= nil then + endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler) + self.EndTurnHandler = nil + end + self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end) +end +for i = 1, 5 do + local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) + if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then + cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) + end +end +for i = 1, 3 do + local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i)) + if rc ~= nil and rc.ButtonComponent ~= nil then + rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end) + end +end +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`), +``` + +- [ ] **Step 3: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 4: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(E1): StartCombat 런 분리(HP보존·RunDeck드로)·BindButtons 1회+보상버튼" +``` + +--- + +### Task 3: CheckCombatEnd·OfferReward·PickReward·RenderRun + +**Files:** Modify `tools/gen-slaydeck.mjs` + +- [ ] **Step 1: CheckCombatEnd 교체** — 보상/런클리어/패배 분기: + +```js + method('CheckCombatEnd', `if self.EnemyHp <= 0 then + self.CombatOver = true + self.Gold = self.Gold + ${GOLD_PER_WIN} + self:RenderRun() + if self.Floor >= self.RunLength 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 2: OfferReward·ApplyRewardVisual·PickReward·RenderRun 추가** — RenderCombat 메서드 다음에 삽입: + +```js + method('OfferReward', `local pool = {} +for id, _ in pairs(self.Cards) do + table.insert(pool, id) +end +self.RewardChoices = {} +for i = 1, 3 do + self.RewardChoices[i] = pool[math.random(1, #pool)] + self:ApplyRewardVisual(i, self.RewardChoices[i]) +end +local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") +if hud ~= nil then + hud.Enable = true +end`), + method('ApplyRewardVisual', `local c = self.Cards[cardId] +if c == nil then + return +end +local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot) +self:SetText(base .. "/Name", c.name) +self:SetText(base .. "/Cost", tostring(c.cost)) +self:SetText(base .. "/Desc", c.desc) +local e = _EntityService:GetEntityByPath(base) +if e ~= nil and e.SpriteGUIRendererComponent ~= nil then + if 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`, [ + { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, + { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, + ]), + 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:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), + method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength)) +self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`), +``` + +- [ ] **Step 3: RenderCombat 끝에 RenderRun 호출 추가** — RenderCombat 코드의 마지막 줄(`...PlayerBlock...`) 다음에 `\nself:RenderRun()` 추가. 즉 RenderCombat 마지막을: +``` +self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) +self:RenderRun() +``` +로. (Edit: 기존 마지막 줄 끝에 `\nself:RenderRun()` 삽입) + +- [ ] **Step 4: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 5: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(E2): 보상(OfferReward/PickReward)·런 클리어·층/골드 렌더" +``` + +--- + +### Task 4: UI — CombatHud 층/골드 + RewardHud + +**Files:** Modify `tools/gen-slaydeck.mjs` (`upsertUi`, `guid`) + +- [ ] **Step 1: guid 'rwd' 분기 추가** — guid()의 ns 매핑을: + +```js + const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : 0xfe; +``` + +- [ ] **Step 2: 정리 필터 확장** — upsertUi 시작부 필터를: + +```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')); +``` + +- [ ] **Step 3: CombatHud에 Floor·Gold 텍스트 추가** — `const result = entity({` 선언 직전(즉 result 추가 전)에 삽입: + +```js + for (const [suffix, pos, value, color] of [ + ['Floor', { x: -820, y: 480 }, '층 1/3', GOLD], + ['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }], + ]) { + combat.push(entity({ + id: guid('cmb', cmbN++), + path: `/ui/DefaultGroup/CombatHud/${suffix}`, + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 9, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 240, y: 44 }, pos }), + sprite({ color: TRANSPARENT }), + text({ value, fontSize: 26, bold: true, color, alignment: 4 }), + ], + })); + } +``` + +- [ ] **Step 4: RewardHud 그룹 생성** — `ui.ContentProto.Entities.push(...combat);` 직후, `JSON.parse(JSON.stringify(ui));` 직전에 삽입: + +```js + const reward = []; + const rewardHud = entity({ + id: guid('rwd', 0), + path: '/ui/DefaultGroup/RewardHud', + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 6, + 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.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }), + ], + }); + rewardHud.jsonString.enable = false; + reward.push(rewardHud); + reward.push(entity({ + id: guid('rwd', 1), + path: '/ui/DefaultGroup/RewardHud/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: 64 }, pos: { x: 0, y: 300 } }), + sprite({ color: TRANSPARENT }), + text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), + ], + })); + let rwdN = 2; + const rewardXs = [-300, 0, 300]; + for (let i = 1; i <= 3; i++) { + const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`; + reward.push(entity({ + id: guid('rwd', rwdN++), + 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: rewardXs[i - 1], y: 0 } }), + 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 }], + ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }], + ['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }], + ]) { + reward.push(entity({ + id: guid('rwd', rwdN++), + path: `${cardPath}/${suffix}`, + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2, + 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 }), + ], + })); + } + } + reward.push(entity({ + id: guid('rwd', rwdN++), + path: '/ui/DefaultGroup/RewardHud/Skip', + 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: -260 } }), + sprite({ color: DARK, type: 1, raycast: true }), + button(), + text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), + ], + })); + ui.ContentProto.Entities.push(...reward); +``` + +- [ ] **Step 5: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 6: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(E2): CombatHud 층/골드 + RewardHud(보상 카드 3+건너뛰기) UI" +``` + +--- + +### Task 5: 재생성 + 검증 + +**Files:** 생성물 2종 + +- [ ] **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(['StartRun','OfferReward','PickReward','RenderRun','ApplyRewardVisual'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); console.log(u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/RewardHud')&&u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/CombatHud/Gold')?'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: `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 "재생성(E1+E2): 런 루프·보상 UI 반영" +``` + +- [ ] **Step 6: 메이커 Play 수동 검증 (사용자/MCP)** + +reload→Play: 승리 → RewardHud 카드 3장·골드+15·층 표시 → 1택 시 RunDeck+1·다음 전투(HP 유지) → 3전투째 승리 시 "런 클리어!". 패배 시 "패배...". MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출 + 상태 로그로 검증. + +--- + +## Self-Review +- **Spec coverage:** 상수·속성·StartRun(Task1), StartCombat분리·BindButtons1회(Task2), 보상·런클리어·렌더(Task3), 층/골드·RewardHud UI(Task4), 검증(Task5). 스펙 전 항목 매핑. +- **Placeholder scan:** 모든 단계 실제 코드/명령. +- **Type consistency:** 메서드명 `StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat` 정의·호출 일치. UI 경로 `/ui/DefaultGroup/RewardHud/Reward{1..3}/{Name,Cost,Desc}`·`/Skip`·`/CombatHud/{Floor,Gold}`가 codeblock(ApplyRewardVisual/RenderRun/BindButtons)과 생성(Task4) 일치. 속성 `RunDeck/Gold/Floor/RunLength/RewardChoices/RunActive` 정의(Task1)·사용(Task2·3) 일치. diff --git a/docs/superpowers/specs/2026-06-09-run-loop-core-design.md b/docs/superpowers/specs/2026-06-09-run-loop-core-design.md new file mode 100644 index 0000000..06892c6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-run-loop-core-design.md @@ -0,0 +1,68 @@ +# 런 루프 코어 (TODO E1+E2) — 설계 + +> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 E(분해) + SlayDeckController 분석. +> E(로그라이크 메타)의 첫 하위 프로젝트. 선행: B·D 완료. 후속: E3(맵)·E4(상점)·E5(유물)·E6(보스/저장). + +## 문제 + +단일 전투(B)는 닫혔으나 승리 후 보상·다음 전투·덱 성장이 없다(보상 훅 자리만 비어 있음). +전투를 한 "런"으로 확장해야 덱빌딩 로그라이크가 된다. + +## 범위 (이 슬라이스) + +전투를 **연속 N전투 런**으로 확장: 런 상태 영속(HP/골드/덱) + 승리 후 카드 1택 보상 + +다음 전투 연결 + 고정 N전투 후 "런 클리어". **맵 노드·상점·유물·보스·저장은 범위 밖**(후속 E3~E6). +아키텍처: 기존 `SlayDeckController` 확장(별도 RunState 분리는 후속). + +## 설계 + +### 런 파라미터 (생성기 상수 — 향후 외부화) +- `RUN_LENGTH = 3` (런당 전투 수), `GOLD_PER_WIN = 15`. + +### 새 상태 (SlayDeckController 속성) +- `RunDeck`(any) — 보유 카드 id 누적 배열(영속). +- `Gold`(number) — 누적 골드. +- `Floor`(number) — 현재 전투 번호(1-base). +- `RunLength`(number) — 런당 전투 수. +- `RewardChoices`(any) — 현재 제시 중인 보상 카드 id 3개. +- `RunActive`(boolean) — 런 진행 중. +- 플레이어 HP는 전투 간 **유지**(StartCombat에서 리셋 안 함). + +### 메서드 +- `OnBeginPlay` → `self:StartRun()`. +- **`StartRun`**(신규): `PlayerMaxHp=80`, `PlayerHp=PlayerMaxHp`, `Gold=0`, `Floor=0`, + `RunLength=RUN_LENGTH`, `RunDeck = starterDeck 복사`, `RunActive=true` → `BindButtons()`(1회) → `StartCombat()`. +- **`StartCombat`**(수정): `Floor += 1`; 적 데이터(activeEnemy) 세팅; 전투별 리셋(Energy/Turn/Block/ + EnemyHp/EnemyBlock/EnemyIntentIndex/DiscardPile/Hand/CombatOver); `DrawPile = RunDeck 복사` → Shuffle; + `Cards` 테이블 세팅. **HP·Gold·RunDeck 보존, BindButtons 호출 제거.** → RenderCombat → StartPlayerTurn. +- **`BindButtons`**(수정): EndTurn·카드5·**보상카드3·건너뛰기** 버튼을 1회 바인딩(StartRun에서 호출). +- **`CheckCombatEnd`**(수정): + - 적 HP≤0(승리): `Gold += GOLD_PER_WIN`; `CombatOver=true`; + `Floor >= RunLength`이면 `ShowResult("런 클리어!")` + `RunActive=false`; + 아니면 `self:OfferReward()`. + - 플레이어 HP≤0(패배): `CombatOver=true`; `ShowResult("패배...")`; `RunActive=false`. +- **`OfferReward`**(신규): `RewardChoices = 카드풀에서 3개 무작위`(math.random); 각 보상 카드 UI 갱신 + (이름/코스트/설명/색); RewardHud 표시(Enable). +- **`PickReward(slot)`**(신규): `slot`(1~3)이면 `RewardChoices[slot]`을 `RunDeck`에 추가; `slot=0`(건너뛰기)이면 추가 안 함; + RewardHud 숨김 → `StartCombat()`(다음 층). +- **`RenderRun`**(신규): `층 Floor/RunLength`·`골드 Gold` 텍스트 갱신. RenderCombat에서 호출. + +### UI (생성기 신규) +- `RewardHud`(평소 숨김): 제목 "보상 카드 선택" + 보상 카드 3장(UISprite+버튼, 이름/코스트/설명 자식) + "건너뛰기" 버튼. +- HUD 표시 추가: `/ui/DefaultGroup/CombatHud/Floor`("층 1/3"), `/Gold`("골드 0"). +- 보상 카드 클릭 → `PickReward(slot)`, 건너뛰기 → `PickReward(0)`. + +### 버그 예방 +- `BindButtons`가 매 전투(StartCombat)마다 카드 버튼에 `ConnectEvent` → 런에서 핸들러 중첩. + **StartRun에서 1회만 바인딩**으로 이동(StartCombat의 BindButtons 호출 제거). + +## 검증 (메이커 Play) +- 전투 승리 → RewardHud에 카드 3장 표시; 골드 +15·층 표시. +- 보상 1택 → RunDeck +1(다음 전투 손패/덱에 등장 가능), RewardHud 숨김, 다음 전투 시작(HP 유지). +- 건너뛰기 → 덱 변화 없이 다음 전투. +- 3전투째 승리 → "런 클리어!"·런 종료. 도중 패배 → "패배..."·런 종료. +- 카드/보상 버튼 클릭은 런타임(MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출로 검증). +- 생성기 결정적, JSON 유효. + +## 범위 밖 (금지) +- 맵 노드(E3)·상점/휴식(E4)·유물(E5)·보스/층전환/저장(E6). 골드 소비(E4). 보상 풀 확장(메이플 IP 추후).