diff --git a/docs/superpowers/plans/2026-06-11-combat-feel.md b/docs/superpowers/plans/2026-06-11-combat-feel.md new file mode 100644 index 0000000..e9f77c4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-combat-feel.md @@ -0,0 +1,289 @@ +# 전투 연출 (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 스윕 기준 — 실측 튜닝 가능). diff --git a/docs/superpowers/specs/2026-06-11-combat-feel-design.md b/docs/superpowers/specs/2026-06-11-combat-feel-design.md new file mode 100644 index 0000000..520c553 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-combat-feel-design.md @@ -0,0 +1,63 @@ +# 전투 연출 (P3) — 설계 + +- 날짜: 2026-06-11 +- 대상: `tools/deck/gen-slaydeck.mjs`(카드 드래그·연출·적 턴 시퀀스), 생성물 +- 상태: 승인됨(사용자 사전 위임 — P2~P5 일괄 진행 지시). 로드맵 P3/5. + +## 1. 목표 (사용자 요구) + +1. **카드 드래그→몬스터 지정**: 카드를 끌어 특정 몬스터에 놓아 사용(클릭 대체). +2. **공격 모션 후 데미지**: 공격 카드 사용 시 연출(스킬 이펙트) 후 몬스터가 피해. +3. **몬스터 개별 차례**: 적 턴에 몬스터가 한 마리씩 순서대로 행동(행동자 표시). +4. 데미지 숫자 표시·사망 연출 등 게임필 보강. + +## 2. 타당성 (probe 완료) + +- `maker_mouse_input` down → `ScreenTouchEvent` 발화 + `ScreenToUIPosition` 변환 실측 일치(카드2 위치). +- **`MOD.Core.UITouchReceiveComponent`**: UI 엔티티에 부착 시 `UITouchBeginDragEvent`/`UITouchDragEvent`/`UITouchEndDragEvent`/`UITouchDownEvent`/`UITouchUpEvent` 제공(공식, Client). 드래그는 이걸 사용. +- 몬스터 world→screen(`_UILogic:WorldToScreenPosition`)은 P1에서 검증됨 — 드롭 판정에 재사용. + +## 3. 설계 + +### 3.1 카드 드래그 타겟팅 +- 손패 Card1~5 엔티티에 `MOD.Core.UITouchReceiveComponent` 추가(생성기 componentNames+컴포넌트). +- 컨트롤러 상태: `DragSlot`(0=없음), `DragOrigin`(원위치 Vector2), `DragMoved`(boolean). +- `BindButtons`에서 카드별로 connect: + - `UITouchBeginDragEvent` → CombatOver 아니고 손패에 카드 있으면 `DragSlot=i`, 원위치 저장(`CARD_XS[i]` 상수로 복원 가능하므로 저장은 단순화 가능 — 원위치 = (CARD_XS[i], 0)). + - `UITouchDragEvent` → 카드 `anchoredPosition = ScreenToUIPosition(TouchPoint) - CardHandOffset` (CardHand 부모 중심의 UI 좌표 보정값은 런타임 계산: 카드 부모 CardHand의 화면상 중심 = UI(0, -360) → 보정 상수로 굽기). + - `UITouchEndDragEvent` → `ResolveCardDrop(i, TouchPoint)` 후 카드 위치 복원. +- `ResolveCardDrop(slot, screenPoint)`: + - 카드 kind 조회. **Attack**: 생존 몬스터 중 화면 거리(몬스터 world→screen vs screenPoint) 최소이고 임계(예: 200px) 이내인 몬스터 → `SetTarget(그 몬스터)` 후 `PlayCard(slot)`. 임계 밖이면 취소(복귀만). + - **Skill**: 드롭 위치가 손패 위(화면 y 기준 카드 영역 위쪽, 예: screen y > 화면 40%)면 `PlayCard(slot)`, 아니면 취소. +- 기존 카드 ButtonComponent 클릭 `PlayCard` 바인딩 **제거**(드래그와 충돌 방지, 사용은 드래그로 일원화 — STS 방식). 몬스터 슬롯 클릭 SetTarget은 유지(타겟만 바꾸는 보조 수단). + +### 3.2 공격 연출 → 데미지 (PlayCard Attack 시퀀스) +- `CombatHud/SkillFx` 엔티티 1개(96×96 이미지 스프라이트, 평소 숨김). +- PlayCard(Attack) 흐름 변경: 에너지 차감·손패 제거·렌더는 즉시, **데미지는 지연**: + 1. `ShowSkillFx(targetIndex, c.image)`: 타겟 몬스터 world→screen 위치에 SkillFx 표시(ImageRUID=카드 이미지). + 2. 0.35s 타이머 → SkillFx 숨김 + `DealDamageToTarget(damage)` + 데미지 팝업 + RenderCombat + CheckCombatEnd. +- 연출 중 입력 보호: `FxBusy=true` 동안 PlayCard/EndPlayerTurn 무시(0.35s). + +### 3.3 데미지 숫자 팝업 +- `MonsterSlot{i}/DmgPop`(텍스트, 숨김 기본): `ShowDmgPop(slot, amount)` — "-N" 표시 → 0.6s 후 숨김(타이머; 위치 고정 단순화). +- `PlayerPanel/DmgPop` 동일(적 공격 시 "-N", 방어 흡수로 0이면 "막음"). + +### 3.4 적 개별 차례 (EnemyTurn 시퀀스화) +- `EnemyTurn` → 비동기 체인으로 재작성: + - `EnemyActIndex=0`; `EnemyActStep()`: 다음 생존 몬스터 찾기 → 없으면 `FinishEnemyTurn()`. + - 행동 몬스터 슬롯에 `ActFrame`(적색 하이라이트 — TargetFrame과 별도 자식, 156×108 적색 a0.3) 표시 → 0.45s 타이머 → 의도 적용(Attack: DealDamageToPlayer+플레이어 DmgPop / Defend: block+슬롯 의도 갱신) → ActFrame 숨김 → 다음 `EnemyActStep()` (0.15s 간격). + - 플레이어 사망 시 즉시 `FinishEnemyTurn()`. +- `FinishEnemyTurn()`: `CheckCombatEnd` 후 미종료면 0.45s 뒤 `StartPlayerTurn`(기존 EndPlayerTurn 후반부 이동). +- `EndPlayerTurn`: 손패 버림+렌더 후 `EnemyTurn()` 호출로 종료(후속 로직은 FinishEnemyTurn으로 이동). `TurnBusy=true`로 적 턴 중 입력 차단(FxBusy와 함께 가드). + +### 3.5 사망 연출 +- `KillMonster`: 즉시 SetVisible(false) → **0.4s 지연**으로 변경(DmgPop과 겹쳐 보이게), 슬롯 비활성은 즉시 유지. + +## 4. 검증 +- 생성 결정성·dup 0·sim 14/14(규칙 불변 — 연출 지연만 추가, 데미지 계산 동일). +- 메이커: ①카드를 몬스터2에 드래그→타겟 변경+이펙트→데미지 팝업→HP 감소 ②Skill 카드 위로 드래그→방어 ③드롭 취소(빈 곳) ④턴 종료→적들이 한 마리씩 순차 행동(ActFrame 이동)+플레이어 팝업 ⑤전체 처치 승리 정상. + +## 5. 리스크 +- UITouchReceiveComponent와 ButtonComponent 공존(슬롯 클릭/드래그 간섭) — 카드에서 Button 제거하므로 카드는 안전; 몬스터 슬롯은 Button 유지(드래그 없음). +- UITouchDragEvent 빈도/좌표계 — 구현 후 메이커 검증(§4①). 드래그 좌표 보정 상수는 실측 튜닝. +- 비동기 체인 중 상태 변화(연출 중 사망 등) — FxBusy/TurnBusy 가드 + 각 스텝에서 alive/CombatOver 재확인.