docs(card-frames): P13 설계·계획 — 커스텀 카드 프레임(직업×등급)·보상 가중

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:30:05 +09:00
parent 9e162d6e2d
commit a814bf2c4b
2 changed files with 160 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# 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 <spec.json>` → merge → main pull → 메모리 갱신 (slaymaple-build-status에 P13 추가)
## Self-Review
- 설계 전 항목에 대응 Task 존재 ✓ / 코드 블록 placeholder 없음 ✓ / CardFrames·ClassToFrame·rarityForRoll 명칭 일관 ✓ / maker_save 덮어쓰기 주의(설계서 '주의' 절) Task 6에서 refresh만 사용 ✓

View File

@@ -0,0 +1,81 @@
# P13 — 커스텀 카드 프레임 설계
날짜: 2026-06-12 (사용자 승인 완료)
브랜치: `feature/p13-card-frames`
## 범위
사용자 제작 카드 프레임 이미지(직업 3종 × 등급 3종)를 인게임 카드 UI 전체(손패·보상·상점·덱 조회)에 적용한다. 카드에 등급(rarity)을 도입하고 전투 보상 추첨 확률에 반영한다.
## 리소스 (임포트 완료 — RUID 수확됨)
원본: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\card\*.png` (263×366, 카드 비율 180×250과 동일한 0.72)
메이커 로컬 임포트 → `RootDesk/MyDesk/<name>.sprite` 디스크립터 9종 (커밋 대상).
| 프레임 | normal | unique | legend |
|---|---|---|---|
| warior | `4bb57ef88ef449fdaf958f6cf37fe44b` | `4f71c124c8bc4e13b5e9fad392995f68` | `6d741a60c60743cb98ee740a1e2dbfed` |
| mage | `d788d09f6f50467ebc67f01dec45f9e2` | `f5def2e8022b4e59a17d3c16414034fe` | `cff71f2e472041ce80c6fbd296f42e2d` |
| bandit | `9487b06867bc46269ed1d855420f457f` | `b3081fb2fb1445fa90b12b01481a78ef` | `c357d2daf31a489d95b8fa47e50dd879` |
bandit은 RUID 등록만 하고 보류 (도적 클래스 추가 시 사용).
프레임 슬롯 구조: 좌상단 육각 코스트 · 상단 이름 배너 · 중앙 아트 영역 · 하단 설명 박스.
## 데이터
### `data/cardframes.json` (신설)
```json
{
"frames": {
"warrior": { "normal": "4bb57ef88ef449fdaf958f6cf37fe44b", "unique": "4f71c124c8bc4e13b5e9fad392995f68", "legend": "6d741a60c60743cb98ee740a1e2dbfed" },
"magician": { "normal": "d788d09f6f50467ebc67f01dec45f9e2", "unique": "f5def2e8022b4e59a17d3c16414034fe", "legend": "cff71f2e472041ce80c6fbd296f42e2d" },
"bandit": { "normal": "9487b06867bc46269ed1d855420f457f", "unique": "b3081fb2fb1445fa90b12b01481a78ef", "legend": "c357d2daf31a489d95b8fa47e50dd879" }
},
"classToFrame": {
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician"
},
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
}
```
### `data/cards.json` — 전 카드에 `rarity` 추가
| 등급 | 카드 (32종) |
|---|---|
| normal (10) | Strike, Defend, Bash, WarLeap, Threaten, EnergyBolt, MagicGuard, MagicClaw, Teleport, Slow |
| unique (17) | Brandish, ChargedBlow, Enrage, ComboAttack, RisingAttack, ThunderCharge, BlizzardCharge, PowerGuard, Pierce, IronWall, FireArrow, PoisonBreath, ColdBeam, ChillingStep, Heal, Bless, HolyArrow |
| legend (5) | Rage, Berserk, HyperBody, ElementAmp, ThunderBolt |
기준: 시작 덱·기본기 = normal / 강화·2차 전직 주력기 = unique / 파워 카드·전체 공격 = legend.
생성기 검증: `rarity` 누락 또는 normal|unique|legend 외 값이면 throw. 카드 class가 `classToFrame`에 없으면 throw.
## 렌더링 (생성기 — A안: 카드 배경 교체)
- 카드 루트 스프라이트: 단색 틴트(kind별) → 프레임 `ImageRUID`(Type 0, 흰색). NamePlate/CostPlate 단색판 제거 — RewardHud 등 생성 섹션은 생성 중단으로 충분, **CardHand는 .ui에 잔존하므로 upsert 시 경로 매칭으로 명시 제거**.
- `ApplyCardFace`(Lua): kind 틴트 분기 제거 → `self.CardFrames[self.ClassToFrame[c.class]][c.rarity]` 적용. `CardFrames`/`ClassToFrame`는 OnBeginPlay에서 Lua 테이블 주입 + `prop('any', …)` 선언(LIA 1114 예방).
- 자식 레이아웃 공용 헬퍼 `cardFaceLayout(W)` 신설 — 중복 5곳(손패 523·조회 787·전체덱 928·보상 1443·상점 1660 부근) 일괄 적용. 180×250 기준값(스케일 s=W/180):
- Cost: pos(-68, 103)·size 44·font 26 (현 위치와 거의 일치)
- Name: pos(4, 97)·size 150×26·font 18 — 상단 배너로 이동
- Art: pos(0, 16)·size 110 — 중앙 아트 영역 확대
- Desc: pos(0, -85)·size 152×64·font 16 — 하단 박스
- 초깃값이며 메이커 스크린샷으로 미세 튜닝.
- 정적 프리뷰(Card1~5)도 동일 프레임 적용.
## 보상 가중 추첨
`OfferReward`(Lua): 풀을 rarity 버킷으로 분류 후 1~100 롤 — ≤70 normal / ≤95 unique / >95 legend. 해당 버킷이 비면 전체 풀 폴백. 상점·전투 계산은 변경 없음 (sim-balance 전투 미러 무관).
JS 미러: `tools/balance/sim-balance.mjs``rarityForRoll(roll)` export + 경계 테스트(70/71/95/96).
## 검증
재생성 → `grep -c` 카운트(CardFrames·rarity) → 기존 테스트 40건 + 신규 통과 → 메이커 refresh·빌드 0에러 → 플레이 스크린샷(손패 프레임·등급 색 구분·보상·덱 조회) → 텍스트 위치 튜닝.
## 주의 (이번 세션 실측)
- maker_save 시 메이커가 산출물을 재직렬화(0→0.0 등)하고 `Mislocated/`로 엔티티를 옮길 수 있음 → 임포트 후 `.sprite`만 남기고 산출물은 `git restore`로 복원했음. 재발 시 동일 절차.
- sprite RUID는 map01.map에 등록되지 않고 `.sprite` 디스크립터 자체가 등록 메커니즘.