# P13 — 커스텀 카드 프레임 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-card-frames-design.md` **Goal:** 사용자 제작 프레임 이미지(직업×등급)를 카드 UI 전체에 적용하고 등급을 보상 확률에 반영. **Architecture:** 단일 소스(`data/*.json` + `gen-slaydeck.mjs`) → 산출물 재생성. 카드 배경 스프라이트를 프레임 ImageRUID로 교체(A안), `ApplyCardFace` 중앙 함수에서 class×rarity 조회. **Tech Stack:** Node.js 생성기, MSW Lua, node --test. ### Task 1: 리소스 커밋 - [ ] `.sprite` 9종 커밋: `git add RootDesk/MyDesk/*.sprite && git commit -m "feat(card-frames): 카드 프레임 스프라이트 9종 로컬 임포트 (warior·mage·bandit × normal·unique·legend)"` ### Task 2: 데이터 — rarity + cardframes.json - [ ] `data/cardframes.json` 신설 (설계서 JSON 그대로) - [ ] `data/cards.json` 32종에 `"rarity"` 추가 (설계서 표 그대로 — node 스크립트로 일괄 주입 권장) - [ ] 커밋: `feat(card-frames): 카드 등급 배정·프레임 RUID 매핑 데이터` ### Task 3: 생성기 — 프레임 렌더링 - [ ] `CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json'))` 로드, 카드별 검증(throw): rarity ∈ {normal,unique,legend}, class ∈ classToFrame - [ ] `luaCardsTable`: `fields.push(\`rarity = ${luaStr(c.rarity)}\`)` - [ ] OnBeginPlay 주입(luaCardsTable 옆): `luaFramesTable()` — `self.CardFrames = {...}` + `self.ClassToFrame = {...}` / `prop('any','CardFrames')`·`prop('any','ClassToFrame')` 선언 - [ ] `ApplyCardFace` Lua: kind 틴트 분기 → 프레임 적용 ```lua local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"] local ruid = frames ~= nil and frames[c.rarity or "normal"] or nil if ruid ~= nil then e.SpriteGUIRendererComponent.ImageRUID = ruid e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1) end ``` - [ ] `cardFaceLayout(W)` 헬퍼 신설(s=W/180): Cost pos(-68s,103s)/size 44s/font 26s · Name pos(4s,97s)/size(150s,26s)/font 18s · Art pos(0,16s)/size 110s · Desc pos(0,-85s)/size(152s,64s)/font 16s - [ ] 카드 생성 5곳(upsertUi 손패 ~523 · 조회 ~787 · 전체덱 ~928 · 보상 ~1443 · 상점 ~1660)에 헬퍼 적용, NamePlate/CostPlate 생성 제거, 카드 스프라이트 type 0·흰색·프리뷰 프레임 RUID - [ ] CardHand 잔존 단색판 제거: upsertUi 초입 필터에 `/ui/DefaultGroup/CardHand/Card\d+/(NamePlate|CostPlate)` 경로 제거 추가 - [ ] 커밋: `feat(card-frames): 생성기 — 프레임 렌더링·레이아웃 통합` ### Task 4: 보상 가중 추첨 (TDD) - [ ] `tools/balance/sim-balance.test.mjs`에 실패 테스트: `rarityForRoll(70)==='normal'`, `(71)==='unique'`, `(95)==='unique'`, `(96)==='legend'` → 실행해 FAIL 확인 - [ ] `tools/balance/sim-balance.mjs`: `export function rarityForRoll(roll){ if (roll > 95) return 'legend'; if (roll > 70) return 'unique'; return 'normal'; }` → PASS 확인 - [ ] `OfferReward` Lua 교체: ```lua local pool = self:CardPool() local byRarity = {} for _, id in ipairs(pool) do local r = self.Cards[id].rarity or "normal" if byRarity[r] == nil then byRarity[r] = {} end table.insert(byRarity[r], id) end self.RewardChoices = {} for i = 1, 3 do local roll = math.random(1, 100) local want = "normal" if roll > 95 then want = "legend" elseif roll > 70 then want = "unique" end local bucket = byRarity[want] if bucket == nil or #bucket == 0 then bucket = pool end self.RewardChoices[i] = bucket[math.random(1, #bucket)] self:ApplyRewardVisual(i, self.RewardChoices[i]) end ``` - [ ] 커밋: `feat(card-frames): 보상 등급 가중 추첨 70/25/5 (+JS 미러 테스트)` ### Task 5: 재생성·검증·산출물 커밋 - [ ] `node tools/deck/gen-slaydeck.mjs` → `grep -c "CardFrames" RootDesk/MyDesk/SlayDeckController.codeblock` ≥1, `grep -c "4bb57ef88ef449fdaf958f6cf37fe44b" ui/DefaultGroup.ui` ≥1 - [ ] `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs` 전건 통과 - [ ] 커밋: `feat(card-frames): 산출물 재생성` ### Task 6: 메이커 검증·튜닝 - [ ] maker_refresh_workspace → 빌드 콘솔 0에러 → 플레이: 손패 프레임·등급 구분, `_ResourceService` 로드 확인, 보상·덱 조회 스크린샷 - [ ] 텍스트/아트 위치 어긋나면 `cardFaceLayout` 수치 조정 → 재생성 → 재확인 (수정 시 커밋) ### Task 7: PR·머지·메모리 - [ ] push → `node tools/git/gitea-pr.mjs create ` → merge → main pull → 메모리 갱신 (slaymaple-build-status에 P13 추가) ## Self-Review - 설계 전 항목에 대응 Task 존재 ✓ / 코드 블록 placeholder 없음 ✓ / CardFrames·ClassToFrame·rarityForRoll 명칭 일관 ✓ / maker_save 덮어쓰기 주의(설계서 '주의' 절) Task 6에서 refresh만 사용 ✓