Files
maplecontest/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md
gahusb c7d795f839 docs(combat-ui): P1 구현 계획 (7개 태스크)
에너지 오브/턴종료 재배치 → TopBar → 플레이어 패널+SetHpBar(width) → 타겟 프레임·가독성 → ShowState → 재생성·겹침 정적검사 → 플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:48:11 +09:00

26 KiB
Raw Blame History

전투 화면 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) — 기존 사용 대역: 010(순차 cmbN), 41144(슬롯 6종×4). 신규는 200+ 사용(TopBar 200209, PlayerPanel 210219, 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' ... }))) 전체를 삭제하고, 그 자리에:

  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 줄을

      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: Commitgit 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 이전 위치에):
  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: Commitgit 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', ...) 전체를 다음으로 교체:

    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 배열+루프를 삭제하고, 그 자리에:

  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: Commitgit 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 이전에 추가:
    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);
  1. Name/Hp/HpBarBg/HpBarFill/Intent의 displayOrder를 각각 1/2/3/4/5로 +1.
  2. Name fontSize: 2022, Hp fontSize: 1820.
  3. 파일 상단 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: Commitgit 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 메서드 바로 다음에):
    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)
  1. ShowMap 첫 3줄(DeckHud/CardHand/CombatHud off) → self:ShowState("map") (그 아래 RenderMap·MapHud enable 블록에서 MapHud enable 부분은 중복되지만 무해 — 기존 local hud = ...MapHud... hud.Enable = true 블록은 삭제).
  2. ShowShop 끝의 ShopHud enable 블록(local hud = ...ShopHud ... end) → self:ShowState("shop") (RenderShop 호출은 유지).
  3. ShowRest 끝의 RestHud enable 블록 → self:ShowState("rest") (텍스트·RenderCombat 호출 유지).
  4. 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: Commitgit 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 -Anode 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<items.length;i++)for(let j=i+1;j<items.length;j++)if(hit(items[i],items[j])){bad++;console.log('OVERLAP',items[i].p,items[j].p);}console.log(bad===0?'no overlap ✓':'OVERLAPS:'+bad)" Expected: no overlap ✓

  • Step 4: sim 회귀node --test tools/balance/sim-balance.test.mjs → 14/14

  • Step 5: Commitgit add ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock && git commit -m "feat(combat-ui): UI 정비 산출물 재생성"


Task 7: 메이커 플레이테스트 (수동/MCP — 컨트롤러 주도)

maker MCP: refresh → build log 0 → play. execute_script로 상태 전환하며 스크린샷:

  1. 초기(menu): 메인메뉴만, 전투 HUD 비침 없음
  2. s:ShowCharacterSelect() → 캐릭터선택만
  3. s.SelectedClass="warrior"; s:StartNewGame() → 맵(MapHud만)
  4. s:PickNode("A") → 전투: TopBar(막/골드/유물/모든덱보기)·에너지 오브(좌)·턴종료(우)·플레이어 패널(HP바)·타겟 프레임(1번 슬롯 골드 하이라이트)
  5. s:SetTarget(2) → 프레임 이동 확인, s:PlayCard(<방어 슬롯>) → 방어 뱃지 표시
  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 대역 200224 기존(010·41~144)과 비충돌.
  • 주의: T2 Step 1에서 Floor/Gold 루프 제거 시 그 루프가 쓰던 cmbN 증가가 사라져 후속 Relics/Result id가 변함 — 전부 재생성이라 무해. T5의 ShowMap 치환 시 기존 MapHud enable 블록 삭제 누락하면 중복(무해하나 정리).