From c7d795f8397eaa1097f43056987b8fdac6c364a1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 11 Jun 2026 01:46:10 +0900 Subject: [PATCH] =?UTF-8?q?docs(combat-ui):=20P1=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20(7=EA=B0=9C=20=ED=83=9C=EC=8A=A4=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에너지 오브/턴종료 재배치 → TopBar → 플레이어 패널+SetHpBar(width) → 타겟 프레임·가독성 → ShowState → 재생성·겹침 정적검사 → 플레이테스트. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-11-combat-ui-overhaul.md | 462 ++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md diff --git a/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md b/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md new file mode 100644 index 0000000..3768631 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md @@ -0,0 +1,462 @@ +# 전투 화면 UI/HUD 전면 정비 (P1) 구현 계획 + +> **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:** 전투 화면을 STS2 배치로 재구성해 UI 겹침 0·시각 위계 확립 (기능 불변, 레이아웃·표시 로직만). + +**Architecture:** 모든 UI/컨트롤러는 `tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성(직접 편집 금지, 루트에서 `node tools/deck/gen-slaydeck.mjs`). UI 엔티티는 `upsertUi()`의 `entity()/transform()/sprite()/text()/button()` 헬퍼, 컨트롤러 Lua는 `method(Name, Code, Args?)` 템플릿 문자열(`${...}`=JS 보간). 좌표계: 부모 중심 원점, anchoredPosition. + +**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock, JSON 산출물. + +--- + +## 배경 (구현자용) + +- 화면 1920×1080 중심좌표(±960, ±540). `DeckHud`=하단 1280×330(y180 bottom-center), `CardHand`=카드 5장(y180), `CombatHud`=전체 1920×1080. +- 확인된 겹침: `DeckHud/EndTurnButton`(0,135,170×58) ↔ `DeckHud/Energy`(0,90,220×42) 5px; `AllDeckButton`(470,135)이 버린덱(590,8,132×186)과 5px 간격. +- 기존 가시성: `HideGameHud`(전투 HUD 일괄 off)가 이미 존재(사용자 PR) — ShowState는 이를 재사용해 확장. +- guid 네임스페이스: `guid('cmb', N)` — 기존 사용 대역: 0~10(순차 cmbN), 41~144(슬롯 6종×4). **신규는 200+ 사용**(TopBar 200~209, PlayerPanel 210~219, TargetFrame 221~224). +- 생성기 실행 검증은 매 Task: `node --check` + `node tools/deck/gen-slaydeck.mjs` 성공 + 해당 확인 스크립트. 산출물 커밋은 Task 6에서 일괄(중간 Task는 소스만 커밋하고 산출물은 `git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`로 복원). + +## 파일 구조 +| 파일 | 책임 | +|---|---| +| `tools/deck/gen-slaydeck.mjs` | 전 변경(UI 좌표·신규 엔티티·컨트롤러 표시 로직) | +| 산출물 3종 | Task 6에서 재생성·커밋 | + +--- + +## Task 1: 하단 HUD — 에너지 오브(좌)·턴 종료(우) + +**Files:** Modify `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: Energy 텍스트 엔티티를 EnergyOrb 패널로 교체** + +`upsertUi()`에서 `path: '/ui/DefaultGroup/DeckHud/Energy'` 엔티티 push 블록(`add(entity({ ... '에너지 3/3' ... }))`) 전체를 삭제하고, 그 자리에: +```js + add(entity({ + id: guid('hud', hud.length), + path: '/ui/DefaultGroup/DeckHud/EnergyOrb', + modelId: 'uisprite', + entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 3, + components: [ + transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 96, y: 96 }, pos: { x: -560, y: 130 }, align: ALIGN_CENTER }), + sprite({ color: { r: 0.12, g: 0.2, b: 0.34, a: 0.95 }, type: 1 }), + ], + })); + add(entity({ + id: guid('hud', hud.length), + path: '/ui/DefaultGroup/DeckHud/EnergyOrb/Value', + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 0, + components: [ + transform({ parentW: 96, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 92, y: 48 }, pos: { x: 0, y: 6 } }), + sprite({ color: TRANSPARENT }), + text({ value: '3/3', fontSize: 34, bold: true, color: { r: 0.65, g: 0.92, b: 1, a: 1 }, alignment: 4 }), + ], + })); + add(entity({ + id: guid('hud', hud.length), + path: '/ui/DefaultGroup/DeckHud/EnergyOrb/Label', + modelId: 'uitext', + entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 1, + components: [ + transform({ parentW: 96, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 92, y: 24 }, pos: { x: 0, y: -28 } }), + sprite({ color: TRANSPARENT }), + text({ value: '에너지', fontSize: 14, bold: true, color: { r: 0.55, g: 0.7, b: 0.85, a: 1 }, alignment: 4 }), + ], + })); +``` + +- [ ] **Step 2: EndTurnButton 이동·확대** + +`path: '/ui/DefaultGroup/DeckHud/EndTurnButton'` 엔티티의 transform 줄을 +```js + transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 64 }, pos: { x: 560, y: 130 }, align: ALIGN_CENTER }), +``` +로 교체하고, 같은 엔티티의 `text({ value: '턴 종료', fontSize: 25, ...` 를 `fontSize: 28,` 로. + +- [ ] **Step 3: RenderPiles 에너지 경로/포맷 갱신** + +`method('RenderPiles', ...)` 안의 +``` +self:SetText("/ui/DefaultGroup/DeckHud/Energy", "에너지 " .. string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy)) +``` +를 +``` +self:SetText("/ui/DefaultGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy)) +``` +로 교체. + +- [ ] **Step 4: 검증** — `node --check tools/deck/gen-slaydeck.mjs` 후 실행: +`node tools/deck/gen-slaydeck.mjs && node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('orb:',p.includes('/ui/DefaultGroup/DeckHud/EnergyOrb'),'| oldEnergy gone:',!p.includes('/ui/DefaultGroup/DeckHud/Energy'))"` +Expected: `orb: true | oldEnergy gone: true`. 그 후 산출물 복원: `git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock` + +- [ ] **Step 5: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 에너지 오브(좌)·턴 종료 버튼(우) 재배치"` + +--- + +## Task 2: 상단 TopBar (막·골드·유물·모든덱보기 통합) + +**Files:** Modify `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: 기존 Floor/Gold/Relics 엔티티 제거** + +`upsertUi()` CombatHud 빌드에서 `['Floor', { x: -820, y: 480 }, ...]`/`['Gold', { x: 820, y: 480 }, ...]` 루프 블록과 `path: '/ui/DefaultGroup/CombatHud/Relics'` push 블록을 삭제. + +- [ ] **Step 2: DeckHud의 AllDeckButton 엔티티 제거** + +`path: '/ui/DefaultGroup/DeckHud/AllDeckButton'` push 블록(레이블 텍스트 포함 엔티티 1개) 삭제. + +- [ ] **Step 3: CombatHud에 TopBar 추가** (Result push 이전 위치에): +```js + combat.push(entity({ + id: guid('cmb', 200), + path: '/ui/DefaultGroup/CombatHud/TopBar', + 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: 1200, y: 52 }, pos: { x: 0, y: 486 }, align: ALIGN_CENTER }), + sprite({ color: { r: 0.06, g: 0.07, b: 0.1, a: 0.82 }, type: 1 }), + ], + })); + const topTexts = [ + ['Floor', -520, 160, '막 1/3', GOLD], + ['Gold', -360, 160, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }], + ['Relics', 60, 560, '유물: 없음', { r: 0.8, g: 0.7, b: 0.95, a: 1 }], + ]; + topTexts.forEach(([suffix, x, w, value, color], ti) => { + combat.push(entity({ + id: guid('cmb', 201 + ti), + path: `/ui/DefaultGroup/CombatHud/TopBar/${suffix}`, + modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: ti, + components: [ + transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: w, y: 40 }, pos: { x: x, y: 0 } }), + sprite({ color: TRANSPARENT }), + text({ value, fontSize: suffix === 'Relics' ? 18 : 22, bold: true, color, alignment: 4 }), + ], + })); + }); + combat.push(entity({ + id: guid('cmb', 205), + path: '/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton', + modelId: 'uibutton', entryId: 'UIButton', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', + displayOrder: 3, + components: [ + transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 40 }, pos: { x: 510, y: 0 } }), + sprite({ color: DARK, type: 1, raycast: true }), + button(), + text({ value: '모든덱보기', fontSize: 18, bold: true, color: GOLD, alignment: 0 }), + ], + })); +``` + +- [ ] **Step 4: 컨트롤러 경로 갱신** (정확 치환 3건) +- `RenderRun`: `"/ui/DefaultGroup/CombatHud/Floor"` → `"/ui/DefaultGroup/CombatHud/TopBar/Floor"`, `"/ui/DefaultGroup/CombatHud/Gold"` → `"/ui/DefaultGroup/CombatHud/TopBar/Gold"` +- `RenderRelics`(끝부분 SetText): `"/ui/DefaultGroup/CombatHud/Relics"` → `"/ui/DefaultGroup/CombatHud/TopBar/Relics"` +- `BindButtons`: `"/ui/DefaultGroup/DeckHud/AllDeckButton"` → `"/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton"` + +- [ ] **Step 5: 검증** — `node --check` 후 실행: +`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('topbar:',['/TopBar','/TopBar/Floor','/TopBar/Gold','/TopBar/Relics','/TopBar/AllDeckButton'].every(s=>p.includes('/ui/DefaultGroup/CombatHud'+s)),'| old gone:',!p.includes('/ui/DefaultGroup/CombatHud/Floor')&&!p.includes('/ui/DefaultGroup/CombatHud/Relics')&&!p.includes('/ui/DefaultGroup/DeckHud/AllDeckButton'));const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('paths updated:',cb.includes('TopBar/Floor')&&cb.includes('TopBar/Relics')&&cb.includes('TopBar/AllDeckButton')&&!cb.includes('DeckHud/AllDeckButton'))"` +Expected: 모두 true. 산출물 복원(Task 1과 동일 명령). + +- [ ] **Step 6: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 상단 TopBar (막·골드·유물·모든덱보기 통합)"` + +--- + +## Task 3: 플레이어 패널 + SetHpBar 폭 인자 + +**Files:** Modify `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: SetHpBar에 width 인자 추가** + +`method('SetHpBar', ...)` 전체를 다음으로 교체: +```js + method('SetHpBar', `local e = _EntityService:GetEntityByPath(path) +if e == nil or e.UITransformComponent == nil then + return +end +local ratio = 0 +if maxHp > 0 then ratio = hp / maxHp end +if ratio < 0 then ratio = 0 end +local w = width * ratio +e.UITransformComponent.RectSize = Vector2(w, 14)`, [ + { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' }, + { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' }, + { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' }, + { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' }, + ]), +``` +그리고 `RenderCombat` 안의 기존 호출 `self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp)` 를 `self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})` 로 교체. + +- [ ] **Step 2: PlayerBg/PlayerHp/PlayerBlock 엔티티 제거 → PlayerPanel 추가** + +`upsertUi()`에서 `path: '/ui/DefaultGroup/CombatHud/PlayerBg'` push 블록과 `playerTexts` 배열+루프를 삭제하고, 그 자리에: +```js + const PP = '/ui/DefaultGroup/CombatHud/PlayerPanel'; + combat.push(entity({ + id: guid('cmb', 210), path: PP, 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: 96 }, pos: { x: -760, y: -480 }, align: ALIGN_CENTER }), + sprite({ color: PANEL_BG, type: 1 }), + ], + })); + combat.push(entity({ + id: guid('cmb', 211), path: `${PP}/Name`, modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 0, + components: [ + transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 28 }, pos: { x: 0, y: 28 } }), + sprite({ color: TRANSPARENT }), + text({ value: '플레이어', fontSize: 18, bold: true, color: GOLD, alignment: 4 }), + ], + })); + combat.push(entity({ + id: guid('cmb', 212), path: `${PP}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 1, + components: [ + transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 16 }, pos: { x: 16, y: -6 } }), + sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }), + ], + })); + combat.push(entity({ + id: guid('cmb', 213), path: `${PP}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 2, + components: [ + transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: 220, y: 16 }, pos: { x: -94, y: -6 } }), + sprite({ color: { r: 0.3, g: 0.78, b: 0.36, a: 1 }, type: 1 }), + ], + })); + combat.push(entity({ + id: guid('cmb', 214), path: `${PP}/HpText`, modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 3, + components: [ + transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 24 }, pos: { x: 16, y: -30 } }), + sprite({ color: TRANSPARENT }), + text({ value: '80/80', fontSize: 16, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), + ], + })); + const blockBadge = entity({ + id: guid('cmb', 215), path: `${PP}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 4, + components: [ + transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 44, y: 40 }, pos: { x: -122, y: -12 } }), + sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }), + ], + }); + blockBadge.jsonString.enable = false; + combat.push(blockBadge); + combat.push(entity({ + id: guid('cmb', 216), path: `${PP}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 0, + components: [ + transform({ parentW: 44, parentH: 40, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 44, y: 36 }, pos: { x: 0, y: 0 } }), + sprite({ color: TRANSPARENT }), + text({ value: '0', fontSize: 18, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), + ], + })); +``` + +- [ ] **Step 3: RenderCombat 플레이어부 교체** + +`RenderCombat` 끝의 +``` +self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) +self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) +``` +를 +``` +self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) +self:SetHpBar("/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220) +self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0) +self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock)) +``` +로 교체. + +- [ ] **Step 4: 검증** — `node --check` 후 실행+확인: +`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('panel:',p.includes('/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill'),'| old gone:',!p.includes('/ui/DefaultGroup/CombatHud/PlayerHp'));const cb=JSON.parse(fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const m=cb.ContentProto.Json.Methods.find(x=>x.Name==='SetHpBar');console.log('width arg:',m.Arguments.length===4)"` +Expected: 모두 true. 산출물 복원. + +- [ ] **Step 5: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 플레이어 패널(HP바·방어 뱃지) + SetHpBar 폭 인자"` + +--- + +## Task 4: 타겟 프레임 + 몬스터 슬롯 가독성 + 의도 색상 + +**Files:** Modify `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: 슬롯 루프에 TargetFrame 추가 + 가독성 조정** + +`upsertUi()` 몬스터 슬롯 루프(`for (let i = 1; i <= MAX_MONSTERS; i++)`)에서: +1. 슬롯 컨테이너 push 직후, Name push 이전에 추가: +```js + const targetFrame = entity({ + id: guid('cmb', 220 + i), path: `${base}/TargetFrame`, modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 0, + components: [ + transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 16, y: SLOT_H + 12 }, pos: { x: 0, y: 0 } }), + sprite({ color: { r: 0.95, g: 0.78, b: 0.25, a: 0.28 }, type: 1 }), + ], + }); + targetFrame.jsonString.enable = false; + combat.push(targetFrame); +``` +2. Name/Hp/HpBarBg/HpBarFill/Intent의 `displayOrder`를 각각 1/2/3/4/5로 +1. +3. Name `fontSize: 20` → `22`, Hp `fontSize: 18` → `20`. +4. 파일 상단 `const HP_BAR_W = 120;` → `const HP_BAR_W = 140;` (몬스터 바 폭 확대 — HpBarBg/Fill·RenderCombat 보간이 모두 이 상수 사용). + +- [ ] **Step 2: RenderCombat 몬스터부 — [타겟] 제거·TargetFrame·의도 색상** + +`RenderCombat`의 몬스터 루프 본문에서 +``` + if i == self.TargetIndex then t = "[타겟] " .. t end + self:SetText(base .. "/Intent", t) +``` +를 +``` + self:SetText(base .. "/Intent", t) + self:SetEntityEnabled(base .. "/TargetFrame", i == self.TargetIndex) + local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent") + if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then + if intent.kind == "Attack" then + intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1) + else + intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1) + end + end +``` +로 교체. + +- [ ] **Step 3: 검증** — `node --check` 후 실행+확인: +`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const tf=ui.ContentProto.Entities.filter(e=>e.path.endsWith('/TargetFrame')).length;const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('frames:',tf,'| no [타겟]:',!cb.includes('[타겟]'),'| color:',cb.includes('FontColor = Color(1, 0.45'))"` +Expected: `frames: 4 | no [타겟]: true | color: true`. 산출물 복원. + +- [ ] **Step 4: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 타겟 프레임·몬스터 슬롯 가독성·의도 색상"` + +--- + +## Task 5: ShowState 가시성 통일 + Result 정리 + +**Files:** Modify `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: ShowState 메서드 추가** (`HideGameHud` 메서드 바로 다음에): +```js + method('ShowState', `self:HideGameHud() +self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu") +self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", state == "charselect") +if state == "map" then + self:SetEntityEnabled("/ui/DefaultGroup/MapHud", true) +elseif state == "combat" then + self:SetEntityEnabled("/ui/DefaultGroup/CombatHud", true) + self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", true) + self:SetEntityEnabled("/ui/DefaultGroup/CardHand", true) +elseif state == "shop" then + self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", true) +elseif state == "rest" then + self:SetEntityEnabled("/ui/DefaultGroup/RestHud", true) +end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'state' }]), +``` + +- [ ] **Step 2: 호출부 치환** (각각 정확 치환) +1. `ShowMainMenu`: +``` +self:HideGameHud() +self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", true) +self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", false) +``` +→ `self:ShowState("menu")` +2. `ShowCharacterSelect`: +``` +self:HideGameHud() +self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", false) +self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", true) +``` +→ `self:ShowState("charselect")` +3. `StartNewGame`의 +``` +self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", false) +self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", false) +``` +→ 삭제(StartRun→ShowMap이 ShowState("map")으로 처리). +4. `StartCombat` 첫 4줄 +``` +self:SetEntityEnabled("/ui/DefaultGroup/MapHud", false) +self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", true) +self:SetEntityEnabled("/ui/DefaultGroup/CardHand", true) +self:SetEntityEnabled("/ui/DefaultGroup/CombatHud", true) +``` +→ +``` +self:ShowState("combat") +self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/Result", false) +``` +5. `ShowMap` 첫 3줄(`DeckHud/CardHand/CombatHud` off) → `self:ShowState("map")` (그 아래 `RenderMap`·MapHud enable 블록에서 MapHud enable 부분은 중복되지만 무해 — 기존 `local hud = ...MapHud... hud.Enable = true` 블록은 삭제). +6. `ShowShop` 끝의 ShopHud enable 블록(`local hud = ...ShopHud ... end`) → `self:ShowState("shop")` (RenderShop 호출은 유지). +7. `ShowRest` 끝의 RestHud enable 블록 → `self:ShowState("rest")` (텍스트·RenderCombat 호출 유지). +8. `LeaveNode`는 기존 그대로(ShowMap 경유). + +- [ ] **Step 3: 검증** — `node --check` 후 실행+확인: +`node tools/deck/gen-slaydeck.mjs && node -e "const cb=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const names=cb.ContentProto.Json.Methods.map(m=>m.Name);const s=JSON.stringify(cb);console.log('ShowState:',names.includes('ShowState'),'| StartCombat resets Result:',/ShowState\(\\\"combat\\\"\)[\s\S]{0,200}Result/.test(s))"` +Expected: 둘 다 true. 산출물 복원. + +- [ ] **Step 4: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): ShowState 가시성 통일 + 전투 시작 시 Result 초기화"` + +--- + +## Task 6: 재생성 · 겹침 정적 검사 · 산출물 커밋 + +**Files:** 산출물 3종 + +- [ ] **Step 1: 재생성** — `node tools/deck/gen-slaydeck.mjs` (exit 0) + +- [ ] **Step 2: JSON·중복 id·결정성** +`node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const ids=ui.ContentProto.Entities.map(e=>e.id);console.log('dup:',ids.filter((x,i)=>ids.indexOf(x)!==i).length)"` → `dup: 0` +그리고 `git add -A` 후 `node tools/deck/gen-slaydeck.mjs` 재실행 → `git diff --stat` 비어있음(결정적). + +- [ ] **Step 3: 하단 HUD 겹침 정적 검사** (AABB 페어와이즈): +`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=ui.ContentProto.Entities;const get=p=>E.find(e=>e.path===p);const box=p=>{const e=get(p);const t=e.jsonString['@components'].find(c=>c['@type']==='MOD.Core.UITransformComponent');return {p,x:t.anchoredPosition.x,y:t.anchoredPosition.y,w:t.RectSize.x,h:t.RectSize.y};};const items=['/ui/DefaultGroup/DeckHud/EnergyOrb','/ui/DefaultGroup/DeckHud/EndTurnButton','/ui/DefaultGroup/DeckHud/DrawPile','/ui/DefaultGroup/DeckHud/DiscardPile'].map(box);const hit=(a,b)=>Math.abs(a.x-b.x)*2<(a.w+b.w)&&Math.abs(a.y-b.y)*2<(a.h+b.h);let bad=0;for(let i=0;i)` → 방어 뱃지 표시 +6. 전체 처치 → 보상 → `s:PickReward(1)` → 맵 복귀 +7. 상점(D)·휴식(C) 화면 +겹침·비침 발견 시 좌표 조정 → 재생성 → reload → 재확인 → 산출물 커밋(`fix(combat-ui): 플레이테스트 좌표 튜닝`). + +--- + +## Self-Review 결과 +- **스펙 커버리지**: §3.1→T1, §3.2→T2, §3.3→T3, §3.4→T4, §3.5→T5(HideGameHud 재사용으로 구현 — 스펙 의도 동일), §3.6→T7에서 확인(채팅 숨김 API는 비차단), 검증§6→T6·T7. 전 항목 매핑. +- **플레이스홀더 없음**: 모든 단계 실제 코드/명령 포함. +- **타입/이름 일관성**: `EnergyOrb/Value`·`TopBar/{Floor,Gold,Relics,AllDeckButton}`·`PlayerPanel/{Name,HpBarBg,HpBarFill,HpText,BlockBadge/Value}`·`TargetFrame`·`SetHpBar(path,hp,maxHp,width)`·`ShowState(state)` — Task 간 일치. guid 대역 200~224 기존(0~10·41~144)과 비충돌. +- **주의**: T2 Step 1에서 Floor/Gold 루프 제거 시 그 루프가 쓰던 `cmbN` 증가가 사라져 후속 Relics/Result id가 변함 — 전부 재생성이라 무해. T5의 ShowMap 치환 시 기존 MapHud enable 블록 삭제 누락하면 중복(무해하나 정리).