# 전투 연출 (P3) 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Task 6은 메이커 MCP — 컨트롤러 직접. **Goal:** 카드 드래그→몬스터 지정, 공격 이펙트 후 데미지, 적 개별 차례, 데미지 팝업. **Architecture:** 카드에 `UITouchReceiveComponent`(공식 드래그 이벤트). 연출은 컨트롤러 타이머 체인(`FxBusy`/`TurnBusy` 가드). 모든 변경은 `tools/deck/gen-slaydeck.mjs` 단일 소스. **Tech Stack:** Node ESM 생성기, MSW Lua, UITouchReceive/UILogic/TimerService. --- ## 배경 (구현자용) - 루트에서 `node tools/deck/gen-slaydeck.mjs`. 각 Task: `node --check` → 생성 → 확인 → **산출물 복원**(`git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`) → 소스만 커밋. 산출물 커밋은 T5. - 손패 카드: `/ui/DefaultGroup/CardHand/Card{1..5}`, 원위치 x=`CARD_XS[i]`(-400..400), y=0, 부모 CardHand는 화면 UI좌표 (0,-360) 중심(앵커 bottom-center pos y180). - 몬스터: `self.Monsters[i] = {entity, ..., alive, slot}`; world→screen은 `_UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y+1.4))` 패턴(PositionMonsterSlot 참조). - 기존: `BindButtons`에 카드 클릭 `ConnectEvent(ButtonClickEvent, ... PlayCard(i))` 루프 존재(제거 대상). `PlayCard`는 즉시 `DealDamageToTarget`. `EndPlayerTurn`은 손패 버림→`EnemyTurn()`→`CheckCombatEnd`→타이머로 `StartPlayerTurn`. `KillMonster`는 즉시 `SetVisible(false)`. - guid 'cmb' 사용 대역: 0~10·41~144(+221~224 TargetFrame)·200~216. **신규: SkillFx=230, ActFrame=240+i, DmgPop slot=250+i, player DmgPop=260.** ## Task 1: 카드 드래그 타겟팅 **Files:** Modify `tools/deck/gen-slaydeck.mjs` - [ ] **Step 1: 카드 엔티티에 UITouchReceiveComponent.** upsertUi 손패 카드(byPath 갱신 분기)에서 Card{i} 루트의 componentNames에 `MOD.Core.UITouchReceiveComponent`가 없으면 추가하고 `@components`에 `{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true }` push (기존 ButtonComponent 추가 패턴과 동일한 create-if-missing 방식 — 그 코드를 읽고 모방). - [ ] **Step 2: 드래그 상태 prop 추가.** SlayDeckController prop 배열에: ```js prop('number', 'DragSlot', '0'), prop('boolean', 'FxBusy', 'false'), prop('boolean', 'TurnBusy', 'false'), ``` - [ ] **Step 3: BindButtons — 카드 클릭 제거 + 드래그 연결.** 기존 `for i = 1, 5 do ... PlayCard(i) ... end`(카드 ButtonClickEvent 루프)를 다음으로 교체: ```lua for i = 1, 5 do local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then cardEntity:ConnectEvent(UITouchBeginDragEvent, function(ev) self:OnCardDragBegin(i) end) cardEntity:ConnectEvent(UITouchDragEvent, function(ev) self:OnCardDrag(i, ev.TouchPoint) end) cardEntity:ConnectEvent(UITouchEndDragEvent, function(ev) self:OnCardDragEnd(i, ev.TouchPoint) end) end end ``` - [ ] **Step 4: 드래그 메서드 3종 + ResolveCardDrop 추가** (CARD_XS는 JS 상수 — 보간으로 Lua 테이블 굽기): ```js method('OnCardDragBegin', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then return end if self.Hand == nil or self.Hand[slot] == nil then return end self.DragSlot = slot`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), method('OnCardDrag', `if self.DragSlot ~= slot then return end local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) if e ~= nil and e.UITransformComponent ~= nil then local ui = _UILogic:ScreenToUIPosition(touchPoint) e.UITransformComponent.anchoredPosition = Vector2(ui.x, ui.y + 360) end`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, ]), method('OnCardDragEnd', `if self.DragSlot ~= slot then return end self.DragSlot = 0 local cardXs = { ${CARD_XS.join(', ')} } local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) if e ~= nil and e.UITransformComponent ~= nil then e.UITransformComponent.anchoredPosition = Vector2(cardXs[slot], 0) end self:ResolveCardDrop(slot, touchPoint)`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, ]), method('ResolveCardDrop', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true 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 c.kind == "Attack" then local best = 0 local bestDist = 200 for i = 1, #self.Monsters do local m = self.Monsters[i] if m.alive == true and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then local wp = m.entity.TransformComponent.WorldPosition local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7)) local dx = sp.x - touchPoint.x local dy = sp.y - touchPoint.y local d = math.sqrt(dx * dx + dy * dy) if d < bestDist then bestDist = d best = i end end end if best > 0 then self.TargetIndex = best self:PlayCard(slot) end else local ui = _UILogic:ScreenToUIPosition(touchPoint) if ui.y > -180 then self:PlayCard(slot) end end`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, ]), ``` - [ ] **Step 5: 검증.** node --check → 생성 → `node -e "const cb=require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('drag:',cb.includes('OnCardDragBegin')&&cb.includes('UITouchBeginDragEvent'),'| no card click PlayCard loop:',!/Card\\\" \.\. tostring\(i\)\)[\s\S]{0,220}ButtonClickEvent[\s\S]{0,80}PlayCard\(i\)/.test(cb))"` (두 번째 체크가 어려우면 수동으로 BindButtons에서 카드 ButtonClickEvent 루프 부재 확인). ui에 UITouchReceiveComponent 5장 확인: `node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log(ui.ContentProto.Entities.filter(e=>/CardHand\/Card[1-5]$/.test(e.path)&&e.componentNames.includes('UITouchReceiveComponent')).length)"` → 5. 산출물 복원. - [ ] **Step 6: Commit** `feat(combat-feel): 카드 드래그 타겟팅 (UITouchReceive·ResolveCardDrop)` ## Task 2: 공격 이펙트 → 지연 데미지 - [ ] **Step 1: SkillFx 엔티티** (upsertUi CombatHud, Result 이전): ```js const skillFx = entity({ id: guid('cmb', 230), path: '/ui/DefaultGroup/CombatHud/SkillFx', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 30, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 110, y: 110 }, pos: { x: 0, y: 0 } }), sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }), ], }); skillFx.jsonString.enable = false; combat.push(skillFx); ``` - [ ] **Step 2: PlayCard Attack 분기 교체.** 기존: ``` if c.kind == "Attack" then if c.damage ~= nil then self:DealDamageToTarget(c.damage) end self:ApplyRelics("cardPlayed") ``` → ``` if c.kind == "Attack" then if c.damage ~= nil then self:PlayAttackFx(self.TargetIndex, c.image, c.damage) end self:ApplyRelics("cardPlayed") ``` 그리고 PlayCard 끝의 `self:CheckCombatEnd()`는 유지(Skill 경로용 — Attack은 PlayAttackFx 완료 시 재호출). - [ ] **Step 3: PlayAttackFx 추가:** ```js method('PlayAttackFx', `local m = self.Monsters[targetIndex] if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then self:DealDamageToTarget(damage) self:RenderCombat() self:CheckCombatEnd() return end self.FxBusy = true local fx = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/SkillFx") if fx ~= nil then if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then fx.SpriteGUIRendererComponent.ImageRUID = image end if fx.UITransformComponent ~= nil and m.entity.TransformComponent ~= nil then local wp = m.entity.TransformComponent.WorldPosition local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7)) fx.UITransformComponent.anchoredPosition = _UILogic:ScreenToUIPosition(sp) end fx.Enable = true end _TimerService:SetTimerOnce(function() if fx ~= nil then fx.Enable = false end self.FxBusy = false self:DealDamageToTarget(damage) self:ShowDmgPop(targetIndex, damage) self:RenderCombat() self:CheckCombatEnd() end, 0.35)`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'targetIndex' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' }, ]), ``` 주의: `ShowDmgPop`은 T3에서 추가 — T2 커밋 시점엔 생성기 실행이 깨지지 않음(문자열일 뿐). PlayCard/EndPlayerTurn 시작 가드에 `or self.FxBusy == true or self.TurnBusy == true` 추가(기존 `if self.CombatOver == true then return end`를 확장). - [ ] **Step 4: 검증** (codeblock에 PlayAttackFx·SkillFx 존재, PlayCard에 PlayAttackFx 호출). 산출물 복원, 커밋 `feat(combat-feel): 공격 이펙트 후 지연 데미지 (SkillFx·FxBusy)` ## Task 3: 데미지 팝업 + 사망 지연 - [ ] **Step 1: DmgPop 엔티티.** 몬스터 슬롯 루프에 자식 추가(dOrder 9, guid cmb 250+i): 텍스트 120×30 @(0, 60), fontSize 24 bold, 색 {1,0.35,0.3,1}, value '', enable=false. PlayerPanel에도 동일(guid cmb 260, 경로 `PlayerPanel/DmgPop`, pos (16, 40), 색 {1,0.4,0.35,1}). - [ ] **Step 2: ShowDmgPop / ShowPlayerDmgPop:** ```js method('ShowDmgPop', `local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot) .. "/DmgPop" self:SetText(base, "-" .. string.format("%d", amount)) self:SetEntityEnabled(base, true) _TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [ { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, ]), method('ShowPlayerDmgPop', `local base = "/ui/DefaultGroup/CombatHud/PlayerPanel/DmgPop" if amount > 0 then self:SetText(base, "-" .. string.format("%d", amount)) else self:SetText(base, "막음") end self:SetEntityEnabled(base, true) _TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), ``` - [ ] **Step 3: KillMonster 사망 지연.** `m.entity:SetVisible(false)` 즉시 호출을: ``` local ent = m.entity _TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.4) ``` 로 교체. - [ ] **Step 4: 검증·복원·커밋** `feat(combat-feel): 데미지 팝업·사망 지연` ## Task 4: 적 개별 차례 - [ ] **Step 1: ActFrame 엔티티.** 몬스터 슬롯 루프에 자식(dOrder 0보다 아래는 불가하니 TargetFrame처럼 dOrder 0, guid cmb 240+i — TargetFrame과 별도): 156×108 @(0,0), 색 {0.95,0.3,0.25,0.3}, enable=false. (TargetFrame과 같은 위치 — 적 턴 중에는 TargetFrame 대신 표시됨.) - [ ] **Step 2: EnemyTurn 시퀀스 교체.** `EnemyTurn` 전체를: ```js method('EnemyTurn', `self.TurnBusy = true self:EnemyActStep(1)`), method('EnemyActStep', `local idx = 0 for i = fromIndex, #self.Monsters do if self.Monsters[i].alive == true then idx = i; break end end if idx == 0 or self.PlayerHp <= 0 then self:FinishEnemyTurn() return end local m = self.Monsters[idx] local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx) self:SetEntityEnabled(base .. "/ActFrame", true) _TimerService:SetTimerOnce(function() m.block = 0 local intent = m.intents[m.intentIdx] if intent ~= nil then if intent.kind == "Attack" then local before = self.PlayerHp self:DealDamageToPlayer(intent.value) self:ShowPlayerDmgPop(before - self.PlayerHp) elseif intent.kind == "Defend" then m.block = m.block + intent.value end end m.intentIdx = m.intentIdx + 1 if m.intentIdx > #m.intents then m.intentIdx = 1 end self:RenderCombat() self:SetEntityEnabled(base .. "/ActFrame", false) _TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15) end, 0.45)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromIndex' }]), method('FinishEnemyTurn', `self.TurnBusy = false self:CheckCombatEnd() if self.CombatOver == true then return end _TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)`), ``` - [ ] **Step 3: EndPlayerTurn 후반 정리.** 기존 EndPlayerTurn에서 `self:EnemyTurn()` 호출 이후의 `self:CheckCombatEnd()`/CombatOver 체크/`SetTimerOnce(... StartPlayerTurn ...)` 블록 **삭제**(FinishEnemyTurn이 담당). 가드 확장 확인(T2에서 FxBusy/TurnBusy). - [ ] **Step 4: RenderCombat의 ActFrame 정리.** RenderCombat 몬스터 루프의 else(사망/없음) 분기는 슬롯 통째 비활성이므로 ActFrame 잔존 위험 없음 — 확인만. - [ ] **Step 5: 검증·복원·커밋** `feat(combat-feel): 적 개별 차례 시퀀스 (ActFrame·EnemyActStep)` ## Task 5: 재생성·검증·산출물 커밋 P2 T5와 동일 절차(생성→JSON·dup0·핵심 심볼(OnCardDragBegin/PlayAttackFx/EnemyActStep/DmgPop)·결정성·sim 14/14→산출물 커밋 `feat(combat-feel): 산출물 재생성`). ## Task 6 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지 - mouse_input 드래그로: 공격 카드→몬스터2 드롭(타겟 변경+이펙트+팝업+HP 감소), Skill 카드 위로 드롭(방어), 빈 곳 드롭 취소, 턴 종료→순차 행동(ActFrame)+플레이어 팝업, 전체 처치 승리. 스크린샷 evidence. - 푸시→Gitea API PR(상세 메시지)→머지. ## Self-Review - 요구 4종(드래그/모션 후 데미지/개별 차례/팝업·사망) ↔ T1/T2/T4/T3 매핑 완료. ResolveCardDrop의 `TargetIndex` 직접 대입은 SetTarget(RenderCombat 포함)과 달리 렌더 없이 PlayCard로 직행 — PlayCard가 RenderCombat 수행하므로 OK. - 시그니처 일관: PlayAttackFx(targetIndex,image,damage)·ShowDmgPop(slot,amount)·EnemyActStep(fromIndex). guid 230/240+i/250+i/260 비충돌(기존 0~224). - 리스크는 T6 메이커 검증에서 흡수(드래그 좌표 보정 +360, 거리 임계 200, ui.y>-180 스윕 기준 — 실측 튜닝 가능).