docs(card-visuals): P2 구현 계획 (6개 태스크)
RUID 수확(컨트롤러)→ApplyCardFace 일원화→손패 프레임→보상/상점/그리드 프레임→재생성→플레이테스트·PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
212
docs/superpowers/plans/2026-06-11-card-visuals.md
Normal file
212
docs/superpowers/plans/2026-06-11-card-visuals.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# 메이플 스킬 카드 비주얼 (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.json`에 `image`(공식 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는 선별값으로):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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 구성에 추가:
|
||||||
|
```js
|
||||||
|
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
||||||
|
```
|
||||||
|
(`if (c.block != null) ...` 줄 다음에.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: ApplyCardFace 메서드 추가** (`ApplyCardVisual` 메서드 정의 바로 앞에):
|
||||||
|
```js
|
||||||
|
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를:
|
||||||
|
```js
|
||||||
|
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로 갱신하는 줄 추가:
|
||||||
|
```js
|
||||||
|
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 루프 뒤에 카드별로:
|
||||||
|
```js
|
||||||
|
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 본문은 구현 시 현재 코드를 읽고 지정 부분만 치환(앵커 명시됨).
|
||||||
Reference in New Issue
Block a user