RUID 수확(컨트롤러)→ApplyCardFace 일원화→손패 프레임→보상/상점/그리드 프레임→재생성→플레이테스트·PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 KiB
메이플 스킬 카드 비주얼 (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는 선별값으로):
{
"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 본문은 구현 시 현재 코드를 읽고 지정 부분만 치환(앵커 명시됨).