Files
maplecontest/docs/superpowers/plans/2026-06-11-card-visuals.md
gahusb 4ea9bfe14b docs(card-visuals): P2 구현 계획 (6개 태스크)
RUID 수확(컨트롤러)→ApplyCardFace 일원화→손패 프레임→보상/상점/그리드 프레임→재생성→플레이테스트·PR.

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

15 KiB
Raw Permalink Blame History

메이플 스킬 카드 비주얼 (P2) 구현 계획

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. Task 1·6은 메이커 MCP 인터랙티브 작업 — 컨트롤러가 직접 수행.

Goal: 카드(손패/보상/상점/인스펙터/모든덱)에 메이플 스킬 이미지+프레임을 입히고 이름을 전사 스킬명으로 바꾼다 (효과·밸런스 불변).

Architecture: data/cards.jsonimage(공식 RUID)·새 name. gen-slaydeck.mjs가 카드 자식 엔티티(Art/NamePlate/CostPlate)를 5표면에 생성하고, 런타임 렌더는 새 ApplyCardFace(base, cardId) 단일 헬퍼로 통일(기존 4개 Apply* 함수가 위임). 공식 RUID는 로컬 워크스페이스에서 렌더됨이 실측 검증됨(스펙 §1).

Tech Stack: Node.js ESM 생성기, MSW Lua codeblock, asset_search_resources MCP(수확), maker MCP(선별·검증).


배경 (구현자용)

  • 생성물 3종은 tools/deck/gen-slaydeck.mjs 단일 소스(직접 편집 금지). 루트에서 node tools/deck/gen-slaydeck.mjs. 각 Task 검증 후 산출물 복원(git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock), 산출물 커밋은 T5.
  • 카드 렌더 함수 4종(현재 동일 모양): ApplyCardVisual(손패 /ui/DefaultGroup/CardHand/Card{slot}), ApplyRewardVisual(/RewardHud/Reward{slot}), ApplyInspectCardVisual(/DeckInspectHud/Grid/Card{slot}), ApplyAllDeckCardVisual(/DeckAllHud/Grid/Card{slot}). 각각 Cost/Name/Desc SetText + 루트 색.
  • 카드 크기: 손패/보상/상점 180×250(CARD_W/CARD_H), 그리드(인스펙터/모든덱) 셀 158×214.
  • 색: ATTACK {0.86,0.42,0.38} / DEFEND {0.42,0.55,0.85} / SKILL {0.46,0.68,0.52} (luaCardsTable의 kind 기반).

파일 구조

파일 책임
data/cards.json name 3종 변경 + image RUID 3종 (T1)
tools/deck/gen-slaydeck.mjs luaCardsTable image·ApplyCardFace·4함수 통일(T2), 카드 프레임 엔티티 5표면(T3·T4)
산출물 T5 재생성·커밋

Task 1 (컨트롤러 직접): RUID 수확 + cards.json

메이커 MCP 인터랙티브 — subagent 금지, 컨트롤러가 수행.

  • Step 1: asset_search_resources(cat=sprite, source=maplestory)로 후보 수집: 질의 "파워 스트라이크"(이미 10건 확보), "슬래시 블러스트", "아이언 바디". 후보 부족 시 보조 질의("슬래시", "강철", "워리어").
  • Step 2: 메이커 Play→전투 진입 후, 후보 RUID를 Card1 Art 자리(SpriteGUIRendererComponent.ImageRUID, Type=0)에 순회 주입 + 스크린샷으로 스킬당 1개 선별(일러스트 적합성 기준: 식별 가능·단일 컷·과도한 투명 여백 없음).
  • Step 3: data/cards.json을 다음 형태로 갱신(RUID는 선별값으로):
{
  "cards": {
    "Strike": { "name": "파워 스트라이크", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6", "image": "<선별RUID>" },
    "Defend": { "name": "아이언 바디", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5", "image": "<선별RUID>" },
    "Bash": { "name": "슬래시 블러스트", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10", "image": "<선별RUID>" }
  },
  "starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"]
}
  • Step 4: node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8'));console.log('ok')" → ok. sim 회귀: node --test tools/balance/sim-balance.test.mjs → 14/14 (fixture 자체 카드라 무관).
  • Step 5: Commit: git add data/cards.json && git commit -m "feat(card-visuals): 카드를 전사 스킬로 리네임 + 공식 스킬 이미지 RUID"

Task 2: ApplyCardFace 렌더 일원화

Files: Modify tools/deck/gen-slaydeck.mjs

  • Step 1: luaCardsTable에 image 직렬화. function luaCardsTable(cards)의 fields 구성에 추가:
    if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);

(if (c.block != null) ... 줄 다음에.)

  • Step 2: ApplyCardFace 메서드 추가 (ApplyCardVisual 메서드 정의 바로 앞에):
    method('ApplyCardFace', `local c = self.Cards[cardId]
if c == nil then
	c = { name = cardId, cost = 0, desc = "", kind = "Skill" }
end
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
	if c.kind == "Attack" then
		e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
	elseif c.kind == "Skill" then
		e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
	else
		e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
	end
end
self:SetText(base .. "/Cost", string.format("%d", c.cost))
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Desc", c.desc)
local art = _EntityService:GetEntityByPath(base .. "/Art")
if art ~= nil then
	if c.image ~= nil and c.image ~= "" then
		art.Enable = true
		if art.SpriteGUIRendererComponent ~= nil then
			art.SpriteGUIRendererComponent.ImageRUID = c.image
		end
	else
		art.Enable = false
	end
end`, [
      { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' },
      { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
    ]),
  • Step 3: 기존 4개 함수를 위임으로 교체. 각 메서드 본문 전체를:

  • ApplyCardVisual: self:ApplyCardFace("/ui/DefaultGroup/CardHand/Card" .. tostring(slot), cardId)

  • ApplyRewardVisual: self:ApplyCardFace("/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot), cardId)

  • ApplyInspectCardVisual: self:ApplyCardFace("/ui/DefaultGroup/DeckInspectHud/Grid/Card" .. tostring(slot), cardId)

  • ApplyAllDeckCardVisual: 기존 본문에서 카드 면 설정부를 self:ApplyCardFace("/ui/DefaultGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)로 교체(본문에 수량 배지 등 추가 로직이 있으면 그 부분은 유지 — 현재 본문을 읽고 카드면(Cost/Name/Desc/색) 설정부만 위임). (인자 목록은 변경 없음.)

  • Step 4: RenderShop 카드부 위임. RenderShop 본문에서 상점 카드 면 설정부(Card{i}의 Cost/Name/Desc SetText + 색 설정)를 self:ApplyCardFace(base, cid) 호출로 교체(가격(Price) SetText·구매 상태 처리는 유지). 본문을 읽고 해당 부분만 정확히 치환.

  • Step 5: 검증. node --check → OK. node tools/deck/gen-slaydeck.mjs 후: node -e "const cb=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const s=JSON.stringify(cb);const names=cb.ContentProto.Json.Methods.map(m=>m.Name);console.log('face:',names.includes('ApplyCardFace'),'| image in Cards:',s.includes('image = '),'| delegates:',(s.match(/ApplyCardFace\(/g)||[]).length>=5)" Expected: 모두 true. 산출물 복원.

  • Step 6: Commit: git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): ApplyCardFace 렌더 일원화 + Cards image 직렬화"


Task 3: 손패 카드 프레임 (Card1~5)

Files: Modify tools/deck/gen-slaydeck.mjs (upsertUi의 손패 카드 갱신부)

  • Step 1: 현재 손패 카드 빌드부 읽기. upsertUi에서 for (let i = 1; i <= 5; i++)/ui/DefaultGroup/CardHand/Card{i}를 byPath 갱신하고 children(['Cost'...],['Name'...],['Desc'...])을 생성/갱신하는 블록을 찾는다.

  • Step 2: children 배열을 프레임 배치로 교체. 기존 children 항목의 cfg를:

    const children = [
      ['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: cards[i - 1].cost, fontSize: 26, bold: true }],
      ['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: cards[i - 1].name, fontSize: 20, bold: true }],
      ['Desc', { size: { x: 164, y: 70 }, pos: { x: 0, y: -62 }, value: cards[i - 1].desc, fontSize: 18, bold: false }],
    ];

로 교체(기존 child 갱신 분기에서도 cfg의 size/pos를 반영하도록 — 기존 갱신 분기가 Text/FontSize만 갱신한다면 transform도 cfg로 갱신하는 줄 추가:

        const tr0 = child.jsonString['@components'][0];
        tr0.RectSize = cfg.size;
        tr0.anchoredPosition = cfg.pos;
        tr0.OffsetMin = { x: cfg.pos.x - cfg.size.x / 2, y: cfg.pos.y - cfg.size.y / 2 };
        tr0.OffsetMax = { x: cfg.pos.x + cfg.size.x / 2, y: cfg.pos.y + cfg.size.y / 2 };

)

  • Step 3: 프레임 자식 추가(없으면 생성, byPath 패턴 동일). children 루프 뒤에 카드별로:
    const frameKids = [
      ['NamePlate', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 3,
        { size: { x: 168, y: 34 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }],
      ['CostPlate', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 4,
        { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }],
      ['Art', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 5,
        { size: { x: 96, y: 96 }, pos: { x: 0, y: 52 } }, { r: 1, g: 1, b: 1, a: 1 }],
    ];
    for (const [suffix, modelId, entryId, componentNames, dOrder, cfg, color] of frameKids) {
      const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
      if (!byPath.get(fPath)) {
        const fe = entity({
          id: guid('dck', 100 + i * 10 + dOrder),
          path: fPath, modelId, entryId, componentNames,
          displayOrder: dOrder,
          components: [
            transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
            sprite(suffix === 'Art' ? { color, type: 0, raycast: false } : { color, type: 1, raycast: false }),
          ],
        });
        ui.ContentProto.Entities.push(fe);
        byPath.set(fPath, fe);
      }
    }

주의: guid('dck', N) 기존 사용 대역 확인(grep guid('dck') 후 충돌 시 200+로 이동. Cost/Name/Desc 텍스트가 NamePlate/CostPlate 위에 그려지도록 displayOrder 관계 확인(텍스트 0/1/2 < 플레이트 3/4면 플레이트가 위 — 플레이트가 텍스트를 가리면 안 되므로 텍스트 displayOrder를 6/7/8로 올리고 플레이트 3/4·Art 5 유지: Cost→7, Name→6, Desc→8로 child 생성/갱신 분기에서 displayOrder 설정).

  • Step 4: 검증. node --check → 실행 → 확인: node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('art:',[1,2,3,4,5].every(i=>p.includes('/ui/DefaultGroup/CardHand/Card'+i+'/Art')),'| plates:',[1,2,3,4,5].every(i=>p.includes('/ui/DefaultGroup/CardHand/Card'+i+'/NamePlate')))" → 모두 true. dup id 0 확인. 산출물 복원.

  • Step 5: Commit: git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): 손패 카드 프레임(Art·NamePlate·CostPlate)"


Task 4: 보상/상점/그리드 카드 프레임

Files: Modify tools/deck/gen-slaydeck.mjs

  • Step 1: 보상 카드(Reward1~3, 180×250). 보상 카드 빌더(자식 Cost/Name/Desc 생성 루프)에서 자식 cfg를 Task 3 Step 2와 동일 좌표로 바꾸고(Cost 44×44@(-68,103) f26 / Name 168×30@(0,-8) f20 / Desc 164×70@(0,-62) f18), 동일한 NamePlate/CostPlate/Art 3종을 push(부모 RewardHud/Reward{i}, guid('rwd', 100+i*10+dOrder), displayOrder: 텍스트 6/7/8·플레이트 3/4·Art 5).

  • Step 2: 상점 카드(ShopHud/Card1~3, 180×250). 동일 적용하되 Desc는 { size: { x: 164, y: 56 }, pos: { x: 0, y: -58 } } (Price (0,-105)와 1px 간격 — Price·구매로직 불변). guid('shp', 100+i*10+dOrder).

  • Step 3: 그리드 카드(DeckInspectHud/Grid/Card{n}·DeckAllHud/Grid/Card{n}, 158×214). 두 빌더(line≈661, 783)에 비례 축소 프레임: Art 84×84@(0,44) / NamePlate 148×30@(0,-8) / CostPlate 38×38@(-58,86); 텍스트 cfg: Cost 38×38@(-58,86) f22 / Name 148×26@(0,-8) f17 / Desc 144×60@(0,-54) f15. guid는 각 빌더의 기존 네임스페이스 시퀀스 이어쓰기(중복 검증으로 확인).

  • Step 4: 검증. 실행 후: node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=ui.ContentProto.Entities;const cnt=s=>E.filter(e=>e.path.includes(s)&&e.path.endsWith('/Art')).length;console.log('reward:',cnt('/RewardHud/Reward'),'shop:',cnt('/ShopHud/Card'),'inspect:',cnt('/DeckInspectHud/Grid/'),'alldeck:',cnt('/DeckAllHud/Grid/'));const ids=E.map(e=>e.id);console.log('dup:',ids.filter((x,i)=>ids.indexOf(x)!==i).length)" Expected: reward:3 shop:3 inspect:(그리드 수) alldeck:(그리드 수), dup:0. 산출물 복원.

  • Step 5: Commit: git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): 보상·상점·그리드 카드 프레임 적용"


Task 5: 재생성·검증·산출물 커밋

  • Step 1: node tools/deck/gen-slaydeck.mjs → exit 0.
  • Step 2: JSON 3종 파스 + dup 0 + 손패/보상/상점/그리드 Art 존재 + codeblock에 ApplyCardFace·image 직렬화(image = )·새 카드명("파워 스트라이크") 포함 확인: 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 cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('face+image+name:',cb.includes('ApplyCardFace')&&cb.includes('image = ')&&cb.includes('파워 스트라이크'))" → true.
  • Step 3: 결정성(재실행 빈 diff) + sim 14/14.
  • Step 4: Commit: git add ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock && git commit -m "feat(card-visuals): 산출물 재생성"

Task 6 (컨트롤러 직접): 메이커 플레이테스트 + 푸시 + PR

  • refresh → build 0 → play. 4표면 스크린샷: ①손패(전투, 스킬 이미지+프레임+새 이름) ②보상 ③상점 ④모든덱/인스펙터. 이미지 미적용/프레임 깨짐/그리드 셀 영향 발견 시 좌표·RUID 수정 → 재생성 → 재확인.
  • 통과 후: git push -u origin feature/p2-card-visuals(인증 실패 시 1회 재시도) → PR 링크+메시지 제공.

Self-Review 결과

  • 스펙 커버리지: §2→T1, §3→T1, §4→T3·T4, §5→T2, §6→T1~T5, §8→T5·T6. 전부 매핑.
  • 플레이스홀더: T1의 <선별RUID>는 수확 절차의 산출물로 정의됨(절차 명시). 그 외 실제 코드.
  • 일관성: ApplyCardFace(base, cardId) 시그니처·자식명(Art/NamePlate/CostPlate)·displayOrder 규칙(플레이트3/4·Art5·텍스트6/7/8) Task 간 일치. guid 충돌은 각 Task 검증(dup 0)으로 강제.
  • 주의: 손패 byPath 갱신 분기·RenderShop/ApplyAllDeckCardVisual 본문은 구현 시 현재 코드를 읽고 지정 부분만 치환(앵커 명시됨).