236 lines
9.9 KiB
Markdown
236 lines
9.9 KiB
Markdown
# 카드 슬롯 이미지 적용 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)"
|
||
```
|
||
출력된 바이트 수를 `<BYTES>`로 사용한다.
|
||
|
||
- [ ] **Step 2: 업로드 1단계 — presigned URL 발급**
|
||
|
||
MCP `asset_create_account_resource_storage_item` 호출:
|
||
- `category`: `sprite`
|
||
- `subcategory`: `etc`
|
||
- `name`: `slaymaple_card_reboot_protocol`
|
||
- `description`: `SlayMaple 손패 카드 이미지 (리부트 프로토콜)`
|
||
- `contentLength`: `<BYTES>`
|
||
- `fileUrl`: 생략
|
||
|
||
응답에서 `presignedUrl`을 `<PRESIGNED_URL>`로 확보.
|
||
|
||
- [ ] **Step 3: 파일 PUT 업로드**
|
||
|
||
```bash
|
||
curl.exe -X PUT --data-binary "@C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png" "<PRESIGNED_URL>"
|
||
```
|
||
Expected: HTTP 200 (출력 없음 또는 빈 본문). 오류 시 응답 본문 확인.
|
||
|
||
- [ ] **Step 4: 업로드 2단계 — 리소스 생성 완료**
|
||
|
||
MCP `asset_create_account_resource_storage_item` 다시 호출, 이번엔 동일 파라미터 + `fileUrl`: `<PRESIGNED_URL>`.
|
||
응답에서 발급된 리소스 **RUID(GUID/DataId)** 를 `<RUID>`로 확보.
|
||
|
||
- [ ] **Step 5: RUID 검증**
|
||
|
||
MCP `asset_list_account_resources` (`category`: `sprite`, `subcategory`: `etc`, `searchWord`: `reboot`) 호출 → 방금 만든 리소스가 목록에 있고 RUID가 일치하는지 확인. `<RUID>`를 기록해 Task 2에서 사용.
|
||
|
||
---
|
||
|
||
### Task 2: 생성기에 image 분기 추가
|
||
|
||
**Files:**
|
||
- Modify: `tools/gen-cardhand.mjs`
|
||
|
||
- [ ] **Step 1: 5번 카드 데이터에 image 필드 추가**
|
||
|
||
`cards` 배열의 마지막 원소(강타)를 다음으로 교체 (`<RUID>`는 Task 1에서 확보한 실제 값):
|
||
|
||
```js
|
||
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK, image: '<RUID>' },
|
||
```
|
||
|
||
- [ ] **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:` 가 `<RUID>` 와 일치
|
||
- `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=<RUID>` 확인. (이미지 미로드 시 `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 확인 + 스크린샷 시각 확인
|