Files
maplecontest/docs/superpowers/plans/2026-06-11-combat-feel.md
gahusb 858f9727dd docs(combat-feel): P3 전투 연출 설계+계획 (드래그 타겟·공격 이펙트·개별 차례·팝업)
probe 완료: ScreenTouchEvent/ScreenToUIPosition 실측, UITouchReceiveComponent 드래그 이벤트 확인.

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

15 KiB
Raw Permalink Blame History

전투 연출 (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' 사용 대역: 010·41144(+221224 TargetFrame)·200216. 신규: 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 배열에:

    prop('number', 'DragSlot', '0'),
    prop('boolean', 'FxBusy', 'false'),
    prop('boolean', 'TurnBusy', 'false'),
  • Step 3: BindButtons — 카드 클릭 제거 + 드래그 연결. 기존 for i = 1, 5 do ... PlayCard(i) ... end(카드 ButtonClickEvent 루프)를 다음으로 교체:
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 테이블 굽기):
    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 이전):
  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 추가:
    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:
    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 전체를:
    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 스윕 기준 — 실측 튜닝 가능).