From 724cd5a04df00bd9bce7f4846b49b99455fb700b Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 9 Jun 2026 00:04:33 +0900 Subject: [PATCH] =?UTF-8?q?docs(B):=20=EC=B9=B4=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=ED=88=AC=20=ED=86=B5=ED=95=A9=20=EC=84=A4=EA=B3=84=C2=B7?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-08-card-combat-integration.md | 481 ++++++++++++++++++ ...26-06-08-card-combat-integration-design.md | 74 +++ 2 files changed, 555 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-card-combat-integration.md create mode 100644 docs/superpowers/specs/2026-06-08-card-combat-integration-design.md diff --git a/docs/superpowers/plans/2026-06-08-card-combat-integration.md b/docs/superpowers/plans/2026-06-08-card-combat-integration.md new file mode 100644 index 0000000..597f101 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-card-combat-integration.md @@ -0,0 +1,481 @@ +# 카드 전투 통합 (TODO B) 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·플레이어 Block·적 의도·승패에 반영되는 단일 전투 루프를 완성한다. + +**Architecture:** 모든 변경은 `tools/gen-slaydeck.mjs` 단일 생성기에서 만든다. 적/플레이어 전투 상태는 `SlayDeckController` codeblock 내부 속성으로 보유(필드 `Monster.codeblock`과 분리). UI는 `CombatHud` 그룹으로 DeckHud와 별도 생성. 수치(플레이어 80 / 적 45 / 의도 10·6·방8)는 임시 placeholder. + +**Tech Stack:** Node.js ESM 생성기(`gen-slaydeck.mjs`), MSW Lua codeblock, MSW UI JSON. 검증은 `node --check` + 재생성 + sha1 결정성 + 메이커 Play. + +--- + +## File Structure + +- Modify: `tools/gen-slaydeck.mjs` — 유일한 변경 대상. + - `upsertUi()`: `CombatHud` 그룹(적/플레이어 패널·결과 텍스트) 생성 추가, 정리 필터 확장. + - `writeCodeblocks()`: `SlayDeckController` 속성·메서드 추가/수정. +- 생성물(자동, 직접 편집 금지): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`. + +검증 한계: MSW codeblock Lua는 단위 테스트 러너가 없다. 자동 검증은 생성기 문법·재생성·결정성·JSON 유효성까지, 실제 동작은 메이커 Play(사용자)로 확인. + +--- + +### Task 1: 카드 데이터 수치화 (Cards 테이블 + UI 카드 배열) + +**Files:** +- Modify: `tools/gen-slaydeck.mjs` (`upsertUi` 내 `cards` 배열, `writeCodeblocks` 내 `StartCombat`의 `self.Cards`) + +- [ ] **Step 1: `upsertUi`의 카드 배열은 표시용 그대로 두되, codeblock `Cards`에 수치 필드 추가** + +`writeCodeblocks()`의 `StartCombat` 메서드 코드에서 `self.Cards` 정의를 아래로 교체: + +```lua +self.Cards = { + Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 }, + Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 }, + Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 }, +} +``` + +- [ ] **Step 2: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 (출력 없음, exit 0) + +- [ ] **Step 3: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(B): 카드 데이터에 damage/block 수치 필드 추가" +``` + +--- + +### Task 2: 전투 상태 속성 + StartCombat 초기화 + +**Files:** +- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` 속성 배열, `StartCombat` 메서드) + +- [ ] **Step 1: codeblock 속성 추가** + +`codeblock('SlayDeckController', ...)`의 properties 배열 끝에 추가: + +```js + prop('number', 'PlayerHp', '0'), + prop('number', 'PlayerMaxHp', '80'), + prop('number', 'PlayerBlock', '0'), + prop('number', 'EnemyHp', '0'), + prop('number', 'EnemyMaxHp', '45'), + prop('number', 'EnemyBlock', '0'), + prop('number', 'EnemyIntentIndex', '1'), + prop('boolean', 'CombatOver', 'false'), + prop('any', 'EnemyIntents'), + prop('any', 'EnemyName'), +``` + +- [ ] **Step 2: `StartCombat`에 전투 상태 초기화 추가** + +`StartCombat` 코드의 맨 위(`self.MaxEnergy = 3` 직후)에 삽입: + +```lua +self.PlayerMaxHp = 80 +self.PlayerHp = self.PlayerMaxHp +self.PlayerBlock = 0 +self.EnemyName = "슬라임" +self.EnemyMaxHp = 45 +self.EnemyHp = self.EnemyMaxHp +self.EnemyBlock = 0 +self.EnemyIntents = { + { kind = "Attack", value = 10 }, + { kind = "Attack", value = 6 }, + { kind = "Defend", value = 8 }, +} +self.EnemyIntentIndex = 1 +self.CombatOver = false +``` + +그리고 `StartCombat` 끝(`self:StartPlayerTurn()` 직전)에 `self:RenderCombat()` 추가. + +- [ ] **Step 3: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 4: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(B): 플레이어/적 전투 상태 속성·초기화 추가" +``` + +--- + +### Task 3: 전투 헬퍼 메서드 (데미지/적턴/승패/렌더) + +**Files:** +- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` methods 배열에 신규 메서드 추가) + +`SetText`는 엔티티 nil 가드가 있어, 참조하는 UI가 Task 5에서 생성되기 전이어도 안전(no-op). + +- [ ] **Step 1: 신규 메서드들을 methods 배열에 추가 (`Toast` 메서드 정의 뒤)** + +```js + method('DealDamageToEnemy', `local dmg = amount +if self.EnemyBlock > 0 then + local absorbed = math.min(self.EnemyBlock, dmg) + self.EnemyBlock = self.EnemyBlock - absorbed + dmg = dmg - absorbed +end +self.EnemyHp = self.EnemyHp - dmg +if self.EnemyHp < 0 then + self.EnemyHp = 0 +end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), + method('DealDamageToPlayer', `local dmg = amount +if self.PlayerBlock > 0 then + local absorbed = math.min(self.PlayerBlock, dmg) + self.PlayerBlock = self.PlayerBlock - absorbed + dmg = dmg - absorbed +end +self.PlayerHp = self.PlayerHp - dmg +if self.PlayerHp < 0 then + self.PlayerHp = 0 +end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), + method('EnemyTurn', `self.EnemyBlock = 0 +local intent = self.EnemyIntents[self.EnemyIntentIndex] +if intent ~= nil then + if intent.kind == "Attack" then + self:DealDamageToPlayer(intent.value) + elseif intent.kind == "Defend" then + self.EnemyBlock = self.EnemyBlock + intent.value + end +end +self.EnemyIntentIndex = self.EnemyIntentIndex + 1 +if self.EnemyIntentIndex > #self.EnemyIntents then + self.EnemyIntentIndex = 1 +end +self:RenderCombat()`), + method('CheckCombatEnd', `if self.EnemyHp <= 0 then + self.CombatOver = true + self:ShowResult("승리!") + -- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점 +elseif self.PlayerHp <= 0 then + self.CombatOver = true + self:ShowResult("패배...") +end`), + method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text) +local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result") +if entity ~= nil then + entity.Enable = true +end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]), + method('RenderCombat', `self:SetText("/ui/DefaultGroup/CombatHud/EnemyName", self.EnemyName) +self:SetText("/ui/DefaultGroup/CombatHud/EnemyHp", "HP " .. tostring(self.EnemyHp) .. "/" .. tostring(self.EnemyMaxHp)) +self:SetText("/ui/DefaultGroup/CombatHud/EnemyBlock", "방어 " .. tostring(self.EnemyBlock)) +local intent = self.EnemyIntents[self.EnemyIntentIndex] +local intentText = "" +if intent ~= nil then + if intent.kind == "Attack" then + intentText = "의도: 공격 " .. tostring(intent.value) + elseif intent.kind == "Defend" then + intentText = "의도: 방어 " .. tostring(intent.value) + end +end +self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText) +self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. tostring(self.PlayerHp) .. "/" .. tostring(self.PlayerMaxHp)) +self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. tostring(self.PlayerBlock))`), +``` + +- [ ] **Step 2: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 3: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(B): 데미지/적턴/승패/전투렌더 헬퍼 메서드 추가" +``` + +--- + +### Task 4: 턴 흐름 배선 (PlayCard 효과·EndPlayerTurn·StartPlayerTurn) + +**Files:** +- Modify: `tools/gen-slaydeck.mjs` (`StartPlayerTurn`, `EndPlayerTurn`, `PlayCard` 메서드 코드) + +- [ ] **Step 1: `StartPlayerTurn` 교체** + +```lua +self.Turn = self.Turn + 1 +self.Energy = self.MaxEnergy +self.PlayerBlock = 0 +self:DrawCards(5) +self:RenderHand(true) +self:RenderCombat() +``` + +- [ ] **Step 2: `EndPlayerTurn` 교체** + +```lua +if self.CombatOver == true then + return +end +for i = 1, #self.Hand do + table.insert(self.DiscardPile, self.Hand[i]) +end +self.Hand = {} +self:RenderHand(false) +self:RenderPiles() +self:EnemyTurn() +self:CheckCombatEnd() +if self.CombatOver == true then + return +end +_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45) +``` + +- [ ] **Step 3: `PlayCard` 효과 분기 교체** + +`PlayCard` 코드를 아래로 교체(에너지 차감 후 Toast 대신 효과 적용): + +```lua +if self.CombatOver == true then + return +end +if self.Hand == nil then + return +end +local cardId = self.Hand[slot] +if cardId == nil then + return +end +local c = self.Cards[cardId] +if c == nil then + return +end +if self.Energy < c.cost then + self:Toast("에너지가 부족합니다") + return +end +self.Energy = self.Energy - c.cost +if c.kind == "Attack" then + if c.damage ~= nil then + self:DealDamageToEnemy(c.damage) + end +elseif c.kind == "Skill" then + if c.block ~= nil then + self.PlayerBlock = self.PlayerBlock + c.block + end +end +table.remove(self.Hand, slot) +table.insert(self.DiscardPile, cardId) +self:RenderHand(false) +self:RenderPiles() +self:RenderCombat() +self:CheckCombatEnd() +``` + +- [ ] **Step 4: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 5: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(B): PlayCard 효과 분기·적턴·승패 턴흐름 배선" +``` + +--- + +### Task 5: CombatHud UI 엔티티 생성 + +**Files:** +- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`: 정리 필터 확장 + CombatHud 그룹 생성) + +- [ ] **Step 1: 정리 필터 확장** + +`upsertUi()` 시작부의 필터를 CombatHud도 제거하도록 교체: + +```js + ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud')); +``` + +- [ ] **Step 2: DeckHud `hud` push 직후, CombatHud 엔티티 생성 블록 추가** + +`ui.ContentProto.Entities.push(...hud);` 직전에 아래 블록 삽입(헬퍼 `entity`/`transform`/`sprite`/`text`/`guid` 재사용): + +```js + const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 }; + const combat = []; + combat.push(entity({ + id: guid('cmb', 0), + path: '/ui/DefaultGroup/CombatHud', + modelId: 'uiempty', + entryId: 'UIEmpty', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 4, + 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: TRANSPARENT }), + ], + })); + // 적 패널 배경 + combat.push(entity({ + id: guid('cmb', 1), + path: '/ui/DefaultGroup/CombatHud/EnemyBg', + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 0, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 170 }, pos: { x: 0, y: 300 }, align: ALIGN_CENTER }), + sprite({ color: PANEL_BG, type: 1 }), + ], + })); + const enemyTexts = [ + ['EnemyName', { x: 0, y: 58 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD, 1], + ['EnemyHp', { x: 0, y: 16 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }, 2], + ['EnemyBlock', { x: 0, y: -20 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }, 3], + ['EnemyIntent', { x: 0, y: -56 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }, 4], + ]; + let cmbN = 2; + for (const [suffix, pos, size, value, fontSize, bold, color] of enemyTexts) { + 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: enemyTexts.findIndex(([s]) => s === suffix) + 1, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }), + sprite({ color: TRANSPARENT }), + text({ value, fontSize, bold, color }), + ], + })); + } + // 플레이어 패널 배경 + 텍스트 + combat.push(entity({ + id: guid('cmb', cmbN++), + path: '/ui/DefaultGroup/CombatHud/PlayerBg', + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 5, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 110 }, pos: { x: -760, y: -260 }, align: ALIGN_CENTER }), + sprite({ color: PANEL_BG, type: 1 }), + ], + })); + const playerTexts = [ + ['PlayerHp', { x: -760, y: -238 }, { x: 280, y: 44 }, 'HP 80/80', 26, true, { r: 1, g: 1, b: 1, a: 1 }], + ['PlayerBlock', { x: -760, y: -284 }, { x: 280, y: 38 }, '방어 0', 22, false, { r: 0.6, g: 0.8, b: 1, a: 1 }], + ]; + for (const [suffix, pos, size, value, fontSize, bold, color] of playerTexts) { + 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: 6 + playerTexts.findIndex(([s]) => s === suffix), + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }), + sprite({ color: TRANSPARENT }), + text({ value, fontSize, bold, color }), + ], + })); + } + // 결과 텍스트 (기본 숨김) + const result = entity({ + id: guid('cmb', cmbN++), + path: '/ui/DefaultGroup/CombatHud/Result', + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 8, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }), + sprite({ color: TRANSPARENT }), + text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }), + ], + }); + result.jsonString.enable = false; + combat.push(result); + ui.ContentProto.Entities.push(...combat); +``` + +`guid` 프리픽스 `'cmb'`를 위해 `guid()`의 ns 매핑에 분기 추가: + +```js + const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : 0xfe; +``` + +- [ ] **Step 3: 문법 검사** + +Run: `node --check tools/gen-slaydeck.mjs` +Expected: 오류 없음 + +- [ ] **Step 4: 커밋** + +```bash +git add tools/gen-slaydeck.mjs +git commit -m "gen-slaydeck(B): CombatHud(적/플레이어 패널·결과) UI 엔티티 생성" +``` + +--- + +### Task 6: 재생성 + 검증 + +**Files:** 생성물 3종 (생성기 실행 결과) + +- [ ] **Step 1: 생성기 실행** + +Run: `node tools/gen-slaydeck.mjs` +Expected: `Slay deck UI and combat codeblocks generated.` + +- [ ] **Step 2: 생성물 JSON 유효성 확인** + +Run: `node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); console.log('JSON OK')"` +Expected: `JSON OK` + +- [ ] **Step 3: 결정성 확인 (2회 실행 동일)** + +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: CombatHud 엔티티·전투 메서드 생성 확인** + +Run: `grep -c "CombatHud" ui/DefaultGroup.ui; grep -c "DealDamageToEnemy\|EnemyTurn\|RenderCombat" RootDesk/MyDesk/SlayDeckController.codeblock` +Expected: 두 값 모두 > 0 + +- [ ] **Step 5: 의도한 파일만 변경됐는지 확인** + +Run: `git status --short` +Expected: `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (그리고 필요 시 `Global/common.gamelogic`)만 변경. + +- [ ] **Step 6: 생성물 커밋** + +```bash +git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic +git commit -m "재생성(B): 카드 전투 통합 — 적/플레이어 전투 상태·CombatHud 반영" +``` + +- [ ] **Step 7: 메이커 Play 수동 검증 (사용자)** + +메이커에서 로컬 워크스페이스 reload 후 Play: +- 타격 카드 클릭 → 적 HP 감소(적 Block 있으면 먼저 차감). +- 방어 카드 클릭 → 플레이어 `방어` 수치 증가. +- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어, 다음 의도 갱신. +- 적 HP 0 → "승리!" 표시·입력 잠금 / 플레이어 HP 0 → "패배..." 표시·입력 잠금. + +--- + +## Self-Review + +- **Spec coverage:** 전투 상태(Task 2), 카드 수치화(Task 1), 효과 분기(Task 4), 적 의도·적 턴(Task 3·4), 승패(Task 3·4), UI 노출(Task 5) — 스펙 5개 절 모두 태스크로 매핑됨. 검증은 Task 6. +- **Placeholder scan:** 모든 코드 단계에 실제 코드 포함. "TODO(E)"는 의도된 미래 훅 주석(스펙 명시)으로 placeholder 아님. +- **Type consistency:** UI 경로(`/ui/DefaultGroup/CombatHud/EnemyHp` 등)가 codeblock `RenderCombat`/`ShowResult`와 Task 5 생성 경로에서 동일. 메서드명(`DealDamageToEnemy`/`DealDamageToPlayer`/`EnemyTurn`/`CheckCombatEnd`/`ShowResult`/`RenderCombat`)이 호출부(Task 4)와 정의부(Task 3)에서 일치. 카드 필드(`damage`/`block`/`kind`)가 Cards 정의(Task 1)와 PlayCard 사용(Task 4)에서 일치. diff --git a/docs/superpowers/specs/2026-06-08-card-combat-integration-design.md b/docs/superpowers/specs/2026-06-08-card-combat-integration-design.md new file mode 100644 index 0000000..6be3ff9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-card-combat-integration-design.md @@ -0,0 +1,74 @@ +# 카드 전투 통합 (TODO 항목 B) — 설계 + +> 작성: 2026-06-08 / 상태: 승인됨 / 근거: TODO.md 항목 B + 코드 직접 분석. +> 선행 작업: 항목 C(미커밋 노이즈 정리) 완료 — 작업 트리 클린. + +## 문제 + +현재 `SlayDeckController.codeblock`의 `PlayCard`는 에너지만 차감하고 `Toast(log)`만 띄운다. +실제 전투 상태(적 HP/방어, 플레이어 HP/Block, 적 의도, 승패)가 없어 STS식 덱빌딩 루프가 +닫히지 않는다. 필드 액션 몬스터(`Monster.codeblock` — HP·피격·리스폰)는 카드 시스템과 분리돼 있다. + +## 범위 + +**포함**: 단일 적 카드 전투 루프(데미지·방어·적 의도·턴 진행·승패), 카드 수치화, DeckHud UI 노출. +**제외(금지)**: 로그라이크 메타(E), 신규 카드 대량 추가, 전체 데이터 외부화(D — 본 작업은 인라인 수치화까지). + +## 단일 소스 원칙 + +모든 변경은 `tools/gen-slaydeck.mjs`에서 생성한다. `SlayDeckController.codeblock` / +`ui/DefaultGroup.ui` / `Global/common.gamelogic`을 직접 손으로 편집하지 않는다. +변경 = 생성기 수정 → `node tools/gen-slaydeck.mjs` 재실행. + +## 수치는 임시 placeholder + +> 플레이어 수치는 향후 **캐릭터 특성별**, 몬스터 수치는 **몬스터별**로 다르게 설정 예정. +> 본 작업의 값(플레이어 80 / 적 45 / 의도 10·6·방8)은 루프 검증용 임시값이며, +> D(데이터 외부화) 단계에서 캐릭터/몬스터별 데이터로 분리한다. + +## 설계 + +### 1) 전투 상태 (codeblock 속성 추가) +- 플레이어: `PlayerHp`, `PlayerMaxHp`(임시 80), `PlayerBlock` +- 적: `EnemyHp`, `EnemyMaxHp`(임시 45), `EnemyBlock`, `EnemyIntentIndex` +- `CombatOver`(승패 후 입력 잠금) +- 적은 codeblock 내부 상태로 보유(필드 `Monster.codeblock`과 분리). + +### 2) 카드 데이터 수치화 (desc 파싱 폐기) +| id | 이름 | cost | kind | 효과 | +|----|------|------|------|------| +| Strike | 타격 | 1 | Attack | damage 6 | +| Defend | 방어 | 1 | Skill | block 5 | +| Bash | 강타 | 2 | Attack | damage 10 | + +`desc`는 표시용으로만 유지. 효과는 `damage`/`block` 숫자 필드로 처리. +시작 덱: Strike×5, Defend×4, Bash×1 (10장). + +### 3) 적 의도 — 결정적 사이클 (사용자 선택: A안) +- 의도 사이클(3스텝 회전): `[공격 10] → [공격 6] → [방어 8]` +- 매 플레이어 턴 시작 시 **다음 의도를 미리 표시**. +- 결정적이라 F(밸런스 시뮬레이터)에서 동일 규칙 재현 가능. + +### 4) 전투 규칙 (STS 관례) +- 데미지는 **방어도 먼저 차감** 후 잔여만 HP에 적용. +- 플레이어 Block은 **플레이어 턴 시작 시 0 리셋**, 적 Block은 **적 턴 시작 시 리셋**. +- `PlayCard(slot)`: `kind=="Attack"` → 적 HP 감소(적 Block 우선 차감); + `kind=="Skill"` → 플레이어 Block 증가. +- `EndPlayerTurn` → 적 턴: 적 Block 리셋 → 현재 의도 실행(공격이면 플레이어에 피해, + 방어면 적 Block↑) → 의도 인덱스 전진 → 패배 체크 → 다음 플레이어 턴(Block/에너지 리셋·드로우) + → 다음 의도 표시. +- 승패: 적 HP≤0 → 승리 / 플레이어 HP≤0 → 패배. 승패 시 `CombatOver=true`로 입력 잠금 + + 결과 텍스트 표시 + **보상 훅 자리(E용 주석)**. + +### 5) UI — DeckHud 엔티티 추가 (생성기 생성) +- 상단 적 패널: 적 이름 · `HP 45/45` · `방어 0` · `의도: 공격 10` +- 좌측 플레이어 패널: `HP 80/80` · `방어 0` +- 승패 결과 텍스트(중앙, 평소 숨김 → 승패 시 표시). + +## 검증 (메이커 Play) +- 타격 카드 → 적 HP 감소(적 Block 있으면 먼저 차감). +- 방어 카드 → 플레이어 Block 증가. +- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어. +- 적 HP 0 → 승리 / 플레이어 HP 0 → 패배, 입력 잠금. +- `node tools/gen-slaydeck.mjs` 2회 실행 결과 동일(결정적). +- `git status` — 의도한 생성물만 변경.