# 카드 슬롯 이미지 적용 Implementation Plan > **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. **Goal:** 하단 손패 5번 슬롯(강타)의 외형을 완성형 카드 이미지 `invincible belief.png`("리부트 프로토콜")로 교체한다. **Architecture:** PNG를 MSW 계정 sprite 리소스로 업로드해 RUID를 발급받고, 그 RUID를 생성기 `gen-cardhand.mjs`의 5번 카드 데이터에 `image` 필드로 넣는다. 생성기는 `image`가 있는 카드를 단색 배경 대신 해당 RUID 스프라이트(흰색 틴트, 180×270)로 만들고 텍스트 자식을 생성하지 않는다. 재생성 후 Maker reload→play 스크린샷으로 검증한다. **Tech Stack:** MSW 에셋 MCP(`asset_create_account_resource_storage_item`, 2단계 업로드), curl PUT, Node.js 생성기, msw-maker-mcp(reload/play/screenshot). --- ## File Structure - Modify: `tools/gen-cardhand.mjs` — 카드 빌드 루프에 `image` 분기 추가, 5번 카드에 RUID 부여. - Modify: `ui/DefaultGroup.ui` — 생성기가 5번 카드를 이미지 스프라이트로 재생성. - 외부: MSW 계정 리소스 스토리지에 PNG 업로드(저장소엔 RUID만 들어감). 원본 이미지: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\invincible belief.png` (세로 약 2:3, 완성형 카드). --- ### Task 1: 이미지 업로드 및 RUID 확보 (컨트롤러/MCP 실행) **Files:** 없음 (외부 리소스 생성) - [ ] **Step 1: 파일 크기 확인** ```bash node -e "console.log(require('fs').statSync('C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png').size)" ``` 출력된 바이트 수를 ``로 사용한다. - [ ] **Step 2: 업로드 1단계 — presigned URL 발급** MCP `asset_create_account_resource_storage_item` 호출: - `category`: `sprite` - `subcategory`: `etc` - `name`: `slaymaple_card_reboot_protocol` - `description`: `SlayMaple 손패 카드 이미지 (리부트 프로토콜)` - `contentLength`: `` - `fileUrl`: 생략 응답에서 `presignedUrl`을 ``로 확보. - [ ] **Step 3: 파일 PUT 업로드** ```bash curl.exe -X PUT --data-binary "@C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png" "" ``` Expected: HTTP 200 (출력 없음 또는 빈 본문). 오류 시 응답 본문 확인. - [ ] **Step 4: 업로드 2단계 — 리소스 생성 완료** MCP `asset_create_account_resource_storage_item` 다시 호출, 이번엔 동일 파라미터 + `fileUrl`: ``. 응답에서 발급된 리소스 **RUID(GUID/DataId)** 를 ``로 확보. - [ ] **Step 5: RUID 검증** MCP `asset_list_account_resources` (`category`: `sprite`, `subcategory`: `etc`, `searchWord`: `reboot`) 호출 → 방금 만든 리소스가 목록에 있고 RUID가 일치하는지 확인. ``를 기록해 Task 2에서 사용. --- ### Task 2: 생성기에 image 분기 추가 **Files:** - Modify: `tools/gen-cardhand.mjs` - [ ] **Step 1: 5번 카드 데이터에 image 필드 추가** `cards` 배열의 마지막 원소(강타)를 다음으로 교체 (``는 Task 1에서 확보한 실제 값): ```js { name: '강타', cost: '2', desc: '피해 10', tint: ATTACK, image: '' }, ``` - [ ] **Step 2: 카드 빌드 루프를 image 분기로 교체** `cards.forEach((c, i) => { ... });` 블록 전체(현재 카드 배경 + cost/name/desc 생성)를 다음으로 교체: ```js cards.forEach((c, i) => { const cardPath = `/ui/DefaultGroup/CardHand/Card${i + 1}`; const cardH = c.image ? 270 : CARD_H; const cardSprite = c.image ? sprite({ dataId: c.image, color: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, type: 0, raycast: true }) : sprite({ color: c.tint, type: 1, raycast: true }); // card background (or full image) ents.push(entity({ id: guid(g++), path: cardPath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: i, components: [ transform({ parentW: 1020, parentH: 280, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: cardH }, pos: { x: (i - (cards.length - 1) / 2) * CARD_SPACING, y: 0 }, align: ALIGN_CENTER }), cardSprite, ], })); // 이미지 카드는 텍스트 오버레이를 만들지 않는다 (이미지에 이미 포함) if (c.image) return; // cost (top-left) ents.push(entity({ id: guid(g++), path: `${cardPath}/Cost`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 50, y: 50 }, pos: { x: -60, y: 95 } }), sprite({ color: TRANSPARENT, type: 1, raycast: false }), text({ value: c.cost, fontSize: 34, bold: true }), ], })); // name (upper-center) ents.push(entity({ id: guid(g++), path: `${cardPath}/Name`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 50 }, pos: { x: 0, y: 50 } }), sprite({ color: TRANSPARENT, type: 1, raycast: false }), text({ value: c.name, fontSize: 28, bold: true }), ], })); // desc (lower-center) ents.push(entity({ id: guid(g++), path: `${cardPath}/Desc`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 2, components: [ transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 80 }, pos: { x: 0, y: -80 } }), sprite({ color: TRANSPARENT, type: 1, raycast: false }), text({ value: c.desc, fontSize: 22, bold: false }), ], })); }); ``` - [ ] **Step 3: 스크립트 커밋** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" git add tools/gen-cardhand.mjs git commit -m "카드 손패 생성기: image 필드 지원 (5번 카드 이미지 적용)" ``` --- ### Task 3: 재생성 및 데이터 검증 **Files:** - Modify: `ui/DefaultGroup.ui` - [ ] **Step 1: 카드 없는 베이스로 되돌린 뒤 재생성** 직전 카드 커밋(`c9c761d`) 이전 베이스에서 ui를 받아 재생성한다. (생성기는 CardHand가 이미 있으면 no-op이므로 베이스가 필요) ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" git checkout 2c39066 -- ui/DefaultGroup.ui node tools/gen-cardhand.mjs ``` Expected: `Inserted 18 CardHand entities.` (컨테이너 1 + 카드 5 + 텍스트 12 = 18) - [ ] **Step 2: 5번 카드 = 이미지, 텍스트 없음 / 나머지 4장 텍스트 유지 검증** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=j.ContentProto.Entities;const card5=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card5');const sp=card5.jsonString['@components'][1];const tr=card5.jsonString['@components'][0];console.log('card5 image:', sp.ImageRUID.DataId);console.log('card5 height:', tr.RectSize.y);console.log('card5 has text children:', E.some(e=>e.path.startsWith('/ui/DefaultGroup/CardHand/Card5/')));console.log('card1 has text children:', E.some(e=>e.path.startsWith('/ui/DefaultGroup/CardHand/Card1/')));console.log('total CardHand entities:', E.filter(e=>e.path.includes('CardHand')).length)" ``` Expected: - `card5 image:` 가 `` 와 일치 - `card5 height: 270` - `card5 has text children: false` - `card1 has text children: true` - `total CardHand entities: 18` - [ ] **Step 3: JSON 유효성 + 기존(우리 외) 엔티티 불변** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log('JSON ok')" ``` Expected: `JSON ok`. (Button_Attack/Jump/UIJoystick/UIChat 4개 기본 엔티티는 splice가 끝에만 추가하므로 불변) --- ### Task 4: Maker 시각 검증 (컨트롤러 실행) **Files:** 없음 - [ ] **Step 1: reload** — msw-maker-mcp `maker_refresh_workspace` (edit 모드). Expected `status: ok`. - [ ] **Step 2: play** — `maker_play`. - [ ] **Step 3: 로드 확인** — `maker_execute_script` (context client): ```lua local c5 = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card5") log("CARD5="..tostring(c5 ~= nil)) if c5 ~= nil then local r = c5.SpriteGUIRendererComponent; log("CARD5_IMG="..tostring(r.ImageRUID.DataId)) end ``` → `maker_logs(kind=normal)` 에서 `CARD5=true`, `CARD5_IMG=` 확인. (이미지 미로드 시 `maker_logs(kind=build)` 도 확인) - [ ] **Step 4: screenshot** — `maker_screenshot` 후 Read로 열어 5번 자리에 "리부트 프로토콜" 카드 이미지가 왜곡 없이 표시되는지 확인. 나머지 4장은 단색 목업 유지. - [ ] **Step 5: stop** — `maker_stop`. 문제 시(이미지 안 보임/깨짐): subcategory를 `item`으로 바꿔 재업로드하거나, 스프라이트 Type/PreserveSprite를 조정. ui 되돌리기: `git checkout ui/DefaultGroup.ui` 후 Task 3부터 재실행. --- ### Task 5: 최종 커밋 **Files:** - `ui/DefaultGroup.ui` - [ ] **Step 1: 디스크 무결성 후 커밋** ```bash cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" git add ui/DefaultGroup.ui git commit -m "5번 카드 슬롯에 리부트 프로토콜 이미지 카드 적용" ``` --- ## 검증 요약 - RUID 발급/검증 (asset_list) - 생성기: `Inserted 18`, 5번=이미지·텍스트없음·270, 나머지 텍스트 유지, JSON 유효 - Maker: Lua로 Card5 이미지 RUID 확인 + 스크린샷 시각 확인