10 Commits

Author SHA1 Message Date
6a049def85 카드 슬롯 이미지 적용 구현 계획 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:54:44 +09:00
d1e8723954 5번 카드에 리부트 프로토콜 이미지 카드 적용 (로컬 워크스페이스 스프라이트)
invincible belief.png를 Maker 로컬 워크스페이스 스프라이트로 임포트해
Card5 SpriteGUIRenderer의 ImageRUID로 지정. 클라우드 계정 리소스는
로컬 워크스페이스 플레이에서 로드되지 않아 로컬 임포트 RUID 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:54:33 +09:00
a5f3bf1829 카드 손패 생성기: image 필드 지원 (5번 카드 이미지 적용)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:19:36 +09:00
42e300878d 카드 슬롯 이미지 적용 설계 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:12:16 +09:00
4f9798ec3f 하단 카드 손패 구현 계획 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:58:46 +09:00
c9c761db04 카드 손패를 화면 하단에 배치 (AlignmentOption BottomCenter 교정) + 단색 카드 배경
MSW가 AlignmentOption으로 앵커를 결정하는 점을 반영해 컨테이너를 BottomCenter(6)로,
카드 내부 텍스트는 Center 기준 오프셋으로 교정. 카드 배경은 단색 채움으로 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:58:25 +09:00
700e3ee373 전투 화면 하단에 카드 손패 5장 목업 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:49:34 +09:00
2c390660fb 카드 손패 생성기: UUID 형식 교정 및 중앙정렬 일반화 2026-06-06 01:50:25 +09:00
3b2e6afbcf 카드 손패 생성기: 기존 파일 줄바꿈(CRLF) 보존 2026-06-06 01:42:20 +09:00
af3480d8b6 하단 카드 손패 엔티티 생성 스크립트 추가 2026-06-06 01:38:38 +09:00
7 changed files with 4084 additions and 2 deletions

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://eab37efa7f0d400f94259a2df836eb8a",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/eab37efa7f0d400f94259a2df836eb8a/639163110552472416",
"upload_hash": "AAD8C7C4E500FF8E001E85EAB181F3B19605BA9D8C8368DB28919B419515003D",
"name": "invincible belief",
"resource_guid": "eab37efa7f0d400f94259a2df836eb8a",
"resource_version": "6a238b0f1a7908d59b5d8fe4"
}
}
}

View File

@@ -0,0 +1,402 @@
# 하단 카드 손패 UI 목업 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장이 수평 일렬로 보이는 정적(static) 손패 UI 목업을 `ui/DefaultGroup.ui`에 추가한다.
**Architecture:** 카드 데이터 테이블 + MSW UI 엔티티 템플릿으로 21개 엔티티(컨테이너 1 + 카드 5 + 카드별 텍스트 3×5=15)를 생성하는 일회성 Node 스크립트(`tools/gen-cardhand.mjs`)를 만든다. 스크립트는 기존 엔티티를 변경하지 않고 `ContentProto.Entities` 배열 끝에 새 엔티티 JSON 텍스트만 삽입한다(텍스트 splice, 전체 재직렬화 없음). Maker에서 reload 후 Play 모드 스크린샷으로 시각 검증한다.
**Tech Stack:** MSW Maker `.ui`(JSON) 엔티티, Node.js(ESM, 표준 라이브러리만), MSW Maker MCP(`maker_refresh_workspace`/`maker_play`/`maker_screenshot`/`maker_stop`).
---
## File Structure
- Create: `tools/gen-cardhand.mjs` — 카드 손패 엔티티 생성기. 카드 데이터 + 컴포넌트 빌더(transform/sprite/text) + entity 빌더로 21개 엔티티를 만들고 `ui/DefaultGroup.ui`에 삽입. 멱등(이미 CardHand 있으면 무변경).
- Modify: `ui/DefaultGroup.ui` — 스크립트가 `ContentProto.Entities` 끝에 CardHand 계층을 추가(기존 엔티티 불변).
좌표 공식(기존 `Button_Attack`로 검증 완료):
- `OffsetMin = pos - pivot*size`, `OffsetMax = pos + (1-pivot)*size`
- `Position.x = anchor.x*parentW - parentW/2 + pos.x` (y도 동일, parentH 사용)
- 여기서 `pos`(=anchoredPosition)는 pivot 지점의 앵커 기준 오프셋, `parentW/H`는 **직속 부모**의 크기.
배치 요약:
- CardHand: 부모 DefaultGroup(1920×1080), anchor(0.5,0), pivot(0.5,0), size 1020×280, pos(0,30)
- Card i(0..4): 부모 CardHand(1020×280), anchor(0.5,0.5), pivot(0.5,0.5), size 180×250, pos((-2+i)*200, 0)
- Cost: 부모 Card(180×250), anchor(0,1), pivot(0.5,0.5), size 50×50, pos(32,-32)
- Name: anchor(0.5,1), size 160×50, pos(0,-70)
- Desc: anchor(0.5,0), size 160×80, pos(0,55)
---
### Task 1: 생성 스크립트 작성
**Files:**
- Create: `tools/gen-cardhand.mjs`
- [ ] **Step 1: 스크립트 파일 작성**
`tools/gen-cardhand.mjs`에 아래 내용을 그대로 작성한다.
```js
import { readFileSync, writeFileSync } from 'node:fs';
const FILE = 'ui/DefaultGroup.ui';
// ---- card data ----
const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1.0 };
const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1.0 };
const cards = [
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK },
];
const CARD_BG_RUID = 'cd0560c4fc7f3b14994b90a502f00a21'; // 기존 버튼 스프라이트 재사용
const CARD_W = 180, CARD_H = 250;
// ---- guid helper (deterministic, hex-safe) ----
const guid = (n) =>
`cad0${n.toString(16).padStart(2, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
// ---- component builders ----
function transform({ parentW, parentH, anchor, pivot, size, pos }) {
const offMin = { x: pos.x - pivot.x * size.x, y: pos.y - pivot.y * size.y };
const offMax = { x: pos.x + (1 - pivot.x) * size.x, y: pos.y + (1 - pivot.y) * size.y };
const position = {
x: anchor.x * parentW - parentW / 2 + pos.x,
y: anchor.y * parentH - parentH / 2 + pos.y,
z: 0.0,
};
return {
'@type': 'MOD.Core.UITransformComponent',
ActivePlatform: 255,
AlignmentOption: 0,
AnchorsMax: { x: anchor.x, y: anchor.y },
AnchorsMin: { x: anchor.x, y: anchor.y },
MobileOnly: false,
OffsetMax: offMax,
OffsetMin: offMin,
Pivot: { x: pivot.x, y: pivot.y },
RectSize: { x: size.x, y: size.y },
UIMode: 1,
UIScale: { x: 1.0, y: 1.0, z: 1.0 },
UIVersion: 2,
anchoredPosition: { x: pos.x, y: pos.y },
Position: position,
QuaternionRotation: { x: 0.0, y: 0.0, z: 0.0, w: 1.0 },
Scale: { x: 1.0, y: 1.0, z: 1.0 },
Enable: true,
};
}
function sprite({ dataId = '', color, type = 1, raycast = true }) {
return {
'@type': 'MOD.Core.SpriteGUIRendererComponent',
AnimClipPlayType: 0,
EndFrameIndex: 2147483647,
ImageRUID: { DataId: dataId },
LocalPosition: { x: 0.0, y: 0.0 },
LocalScale: { x: 1.0, y: 1.0 },
OverrideSorting: false,
PlayRate: 1.0,
PreserveSprite: 0,
StartFrameIndex: 0,
Color: color,
DropShadow: false,
DropShadowAngle: 30.0,
DropShadowColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.72 },
DropShadowDistance: 32.0,
FillAmount: 1.0,
FillCenter: true,
FillClockWise: true,
FillMethod: 0,
FillOrigin: 0,
FlipX: false,
FlipY: false,
FrameColumn: 1,
FrameRate: 0,
FrameRow: 1,
Outline: false,
OutlineColor: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
OutlineWidth: 3.0,
RaycastTarget: raycast,
Type: type,
Enable: true,
};
}
function text({ value, fontSize, bold, alignment = 4 }) {
return {
'@type': 'MOD.Core.TextComponent',
Alignment: alignment,
Bold: bold,
DropShadow: false,
DropShadowAngle: 30.0,
DropShadowColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.72 },
DropShadowDistance: 32.0,
Font: 0,
FontColor: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
FontSize: fontSize,
MaxSize: fontSize,
MinSize: 8,
OutlineColor: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
OutlineDistance: { x: 1.0, y: -1.0 },
OutlineWidth: 1.0,
Overflow: 0,
OverrideSorting: false,
Padding: { left: 0, right: 0, top: 0, bottom: 0 },
SizeFit: false,
Text: value,
UseOutLine: true,
Enable: true,
};
}
function entity({ id, path, modelId, entryId, componentNames, components, displayOrder }) {
const parts = path.split('/');
const name = parts[parts.length - 1];
const slashes = '/'.repeat(parts.length - 1);
return {
id,
path,
componentNames,
jsonString: {
name,
path,
nameEditable: true,
enable: true,
visible: true,
localize: true,
displayOrder,
pathConstraints: slashes,
revision: 1,
origin: {
type: 'Model',
entry_id: entryId,
sub_entity_id: null,
root_entity_id: null,
replaced_model_id: null,
},
modelId,
'@components': components,
'@version': 1,
},
};
}
// ---- build entities ----
const TRANSPARENT = { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
const ents = [];
let g = 0;
// CardHand container
ents.push(entity({
id: guid(g++),
path: '/ui/DefaultGroup/CardHand',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0 }, size: { x: 1020, y: 280 }, pos: { x: 0, y: 30 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
],
}));
cards.forEach((c, i) => {
const cardPath = `/ui/DefaultGroup/CardHand/Card${i + 1}`;
// card background
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: CARD_H }, pos: { x: (-2 + i) * 200, y: 0 } }),
sprite({ dataId: CARD_BG_RUID, color: c.tint, type: 0, raycast: true }),
],
}));
// 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, y: 1 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 50, y: 50 }, pos: { x: 32, y: -32 } }),
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: 1 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 50 }, pos: { x: 0, y: -70 } }),
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 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 80 }, pos: { x: 0, y: 55 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.desc, fontSize: 22, bold: false }),
],
}));
});
// ---- splice into file ----
let txt = readFileSync(FILE, 'utf8');
if (txt.includes('/ui/DefaultGroup/CardHand')) {
console.log('CardHand already present — no changes made.');
process.exit(0);
}
const matches = txt.match(/\n {4}\]/g); // Entities 닫는 대괄호(4-space indent)는 파일 내 유일
if (!matches || matches.length !== 1) {
console.error(`Expected exactly one Entities closing bracket, found ${matches ? matches.length : 0}. Aborting.`);
process.exit(1);
}
const blocks = ents
.map((e) => JSON.stringify(e, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
.join(',\n');
txt = txt.replace('\n ]', ',\n' + blocks + '\n ]');
JSON.parse(txt); // 유효성 검증 (실패 시 throw)
writeFileSync(FILE, txt, 'utf8');
console.log(`Inserted ${ents.length} CardHand entities.`);
```
- [ ] **Step 2: 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add tools/gen-cardhand.mjs
git commit -m "하단 카드 손패 엔티티 생성 스크립트 추가"
```
---
### Task 2: 스크립트 실행 및 결과 검증
**Files:**
- Modify: `ui/DefaultGroup.ui` (스크립트가 수정)
- [ ] **Step 1: 스크립트 실행**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-cardhand.mjs
```
Expected 출력:
```
Inserted 21 CardHand entities.
```
- [ ] **Step 2: JSON 유효성 + 엔티티 수 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const c=j.ContentProto.Entities.filter(e=>e.path.includes('CardHand'));console.log('count:',c.length);console.log(c.map(e=>e.path).join('\n'))"
```
Expected: `count: 21` 그리고 경로 목록에 `/ui/DefaultGroup/CardHand`, `.../Card1`~`.../Card5`, 각 카드의 `/Cost`,`/Name`,`/Desc`가 모두 나타남.
- [ ] **Step 3: 멱등성 확인 (재실행 시 무변경)**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-cardhand.mjs
```
Expected 출력:
```
CardHand already present — no changes made.
```
- [ ] **Step 4: 기존 엔티티 불변 확인**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git diff ui/DefaultGroup.ui | findstr /R "^-"
```
Expected: 삭제(`-`)된 줄이 **마지막 엔티티 뒤 `]` 직전 한 줄 외에는 없음** — 즉 기존 엔티티 내용은 그대로이고 끝에만 추가됨. (삭제 라인은 splice 지점의 ` ]` 한 줄뿐이어야 함)
---
### Task 3: Maker 시각 검증
**Files:** (없음 — 검증 전용)
- [ ] **Step 1: 워크스페이스 reload**
MCP 도구 `maker_refresh_workspace` 호출 (edit 모드여야 함). Expected: `status: ok`.
- [ ] **Step 2: Play 모드 진입**
MCP 도구 `maker_play` 호출. (UI는 edit 캔버스가 아닌 Play 렌더에서 보임)
- [ ] **Step 3: 스크린샷 촬영 및 확인**
MCP 도구 `maker_screenshot` 호출 후 반환된 path를 Read로 열어 확인.
Expected: 화면 **하단 중앙에 카드 5장이 수평 일렬**로 보이고, 각 카드에 코스트(1/2)·이름(타격/방어/강타)·설명(피해6/방어도5/피해10)이 표시되며, 공격 카드는 붉은톤·방어 카드는 푸른톤.
문제가 보이면(위치 어긋남/텍스트 안 보임/색 이상) 수치를 조정해 Task 1의 스크립트 파라미터를 고치고, `ui/DefaultGroup.ui`의 CardHand 블록을 되돌린 뒤(아래 명령) Task 2부터 재실행한다.
되돌리기:
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git checkout ui/DefaultGroup.ui
```
- [ ] **Step 4: Play 모드 종료**
MCP 도구 `maker_stop` 호출.
---
### Task 4: 최종 커밋
**Files:**
- `ui/DefaultGroup.ui`
- [ ] **Step 1: 변경 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add ui/DefaultGroup.ui
git commit -m "전투 화면 하단에 카드 손패 5장 목업 추가"
```
---
## 검증 요약
- 스크립트 단위 검증: `node tools/gen-cardhand.mjs` → 21개 삽입, 재실행 시 멱등
- 데이터 검증: `JSON.parse` 성공 + CardHand 경로 21개 + 기존 엔티티 불변(diff)
- 시각 검증: Maker Play 스크린샷에서 하단 5장 카드 렌더 확인

View File

@@ -0,0 +1,235 @@
# 카드 슬롯 이미지 적용 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 확인 + 스크린샷 시각 확인

View File

@@ -0,0 +1,59 @@
# 카드 슬롯에 이미지 카드 적용 설계
- 날짜: 2026-06-06
- 브랜치: feature/sts2-combat-layout
- 대상: `ui/DefaultGroup.ui`, `tools/gen-cardhand.mjs`
- 원본 이미지: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\invincible belief.png`
## 목표
하단 손패 5장 중 **5번 자리(현재 강타)** 의 카드 외형을 `invincible belief.png`(완성된 세로형 카드 이미지 "리부트 프로토콜")로 교체한다.
## 배경
해당 PNG는 단순 아트가 아니라 코스트·이름·타입·등급·아트·설명·플레이버까지 포함한 **완성된 카드 한 장 전체**(세로 약 2:3 비율)다. 따라서 슬롯의 외형 전체를 이 이미지로 대체하고, 그 슬롯에는 우리가 생성하던 텍스트 오버레이(코스트/이름/설명)를 넣지 않는다(이미지에 이미 포함 → 겹치면 중복/지저분).
## 범위
### 포함
- PNG를 MSW 계정 sprite 리소스로 업로드 → RUID 발급
- 생성기에 카드별 선택적 `image`(RUID) 데이터 추가
- `image`가 있는 카드: 단색 배경 대신 해당 RUID 스프라이트(틴트 흰색)로 렌더, 텍스트 자식(Cost/Name/Desc) 생성 안 함
- 5번 카드 크기를 이미지 비율에 맞춰 **180×270** 으로 조정(가로 유지, 세로만 +20), 행 중앙 정렬 유지
- 나머지 4장은 기존 단색 목업 유지
### 제외 (YAGNI)
- 나머지 4장의 이미지화
- 카드 클릭/효과/실제 전투 로직
- 이미지 카드의 동적 데이터 연동
## 구현 방식
### 1. 에셋 업로드 (`asset_create_account_resource_storage_item`, 2단계)
- 1차 호출: `category=sprite`, `subcategory=etc`, `name`/`description` 지정, `contentLength`=파일 바이트 수, `fileUrl` 생략 → `presignedUrl` 수신
- presignedUrl로 PNG를 HTTP PUT(raw 바이너리)
- 2차 호출: `fileUrl=presignedUrl` → 리소스 생성 완료, 응답에서 **RUID(DataId)** 확보
- 업로드 결과 RUID는 재현 가능하도록 생성기 스크립트에 하드코딩한다.
### 2. 생성기 변경 (`tools/gen-cardhand.mjs`)
- `cards[4]`(강타 슬롯)에 `image: '<RUID>'` 필드 추가. (이름/코스트/설명 데이터는 남겨두되 image가 있으면 렌더에 사용하지 않음)
- 카드 빌드 루프 수정:
- 카드 배경 스프라이트: `image`가 있으면 `sprite({ dataId: image, color: {1,1,1,1}, type: 0 })`, 없으면 기존 `sprite({ color: tint, type: 1 })`
- 카드 크기: `image`가 있으면 180×270, 없으면 180×250
- 텍스트 자식(Cost/Name/Desc): `image`가 없을 때만 생성
- 멱등성/줄바꿈 보존/splice 로직은 그대로 유지
### 3. 형상관리
- 이미지는 MSW 클라우드 리소스로 저장되고, `.ui`·스크립트에는 **RUID 문자열만** 포함. PNG 원본은 slaymaple 저장소에 커밋하지 않는다(원본은 `workspace/source/images/maple/`에 유지).
## 검증
1. 업로드 응답에서 유효한 RUID 수신 확인
2. 생성기 재실행 → JSON 유효, 5번 카드가 image 스프라이트 + 텍스트 자식 없음, 나머지 4장 불변
3. Maker `refresh_workspace``play``screenshot`로 5번 자리에 "리부트 프로토콜" 카드 이미지가 왜곡 없이 표시되는지 확인
4. (커밋 전) 디스크 무결성 확인 후 커밋
## 리스크
- 업로드한 sprite가 SpriteGUIRenderer에서 곧바로 렌더되는지(서브카테고리 무관 가정) — 검증 단계에서 확인, 안 되면 subcategory를 item 등으로 재시도
- 카드 크기 180×270이 행 정렬에서 약간 위로 솟음 — 의도된 허용 범위

View File

@@ -1132,9 +1132,9 @@
"b": 0.5019608,
"a": 0.7058824
},
"TemplateRUID": "1cad0eb694dd415dbf11a74e0e91bae1",
"TemplateRUID": "93a3334d007949658766225bb6363c4b",
"Type": 1,
"WebUrl": "",
"WebUrl": "eab37efa7f0d400f94259a2df836eb8a",
"Enable": true
}
],

261
tools/gen-cardhand.mjs Normal file
View File

@@ -0,0 +1,261 @@
import { readFileSync, writeFileSync } from 'node:fs';
const FILE = 'ui/DefaultGroup.ui';
// ---- card data ----
const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1.0 };
const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1.0 };
// image RUID = Maker 로컬 워크스페이스로 임포트한 스프라이트 (RootDesk/MyDesk/invincible belief.sprite).
// 클라우드 계정 리소스는 로컬 워크스페이스 플레이에서 로드되지 않아, 에디터 임포트로 만든 로컬 RUID를 사용한다.
const cards = [
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK, image: 'eab37efa7f0d400f94259a2df836eb8a' },
];
const CARD_W = 180, CARD_H = 250, CARD_SPACING = 200;
// AlignmentType enum: Center=0, TopLeft=4, BottomCenter=6 (MSW가 이 값으로 앵커를 결정)
const ALIGN_CENTER = 0, ALIGN_BOTTOM_CENTER = 6;
// ---- guid helper (deterministic, hex-safe) ----
const guid = (n) =>
`cad000${n.toString(16).padStart(2, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
// ---- component builders ----
function transform({ parentW, parentH, anchor, pivot, size, pos, align = 0 }) {
const offMin = { x: pos.x - pivot.x * size.x, y: pos.y - pivot.y * size.y };
const offMax = { x: pos.x + (1 - pivot.x) * size.x, y: pos.y + (1 - pivot.y) * size.y };
const position = {
x: anchor.x * parentW - parentW / 2 + pos.x,
y: anchor.y * parentH - parentH / 2 + pos.y,
z: 0.0,
};
return {
'@type': 'MOD.Core.UITransformComponent',
ActivePlatform: 255,
AlignmentOption: align,
AnchorsMax: { x: anchor.x, y: anchor.y },
AnchorsMin: { x: anchor.x, y: anchor.y },
MobileOnly: false,
OffsetMax: offMax,
OffsetMin: offMin,
Pivot: { x: pivot.x, y: pivot.y },
RectSize: { x: size.x, y: size.y },
UIMode: 1,
UIScale: { x: 1.0, y: 1.0, z: 1.0 },
UIVersion: 2,
anchoredPosition: { x: pos.x, y: pos.y },
Position: position,
QuaternionRotation: { x: 0.0, y: 0.0, z: 0.0, w: 1.0 },
Scale: { x: 1.0, y: 1.0, z: 1.0 },
Enable: true,
};
}
function sprite({ dataId = '', color, type = 1, raycast = true }) {
return {
'@type': 'MOD.Core.SpriteGUIRendererComponent',
AnimClipPlayType: 0,
EndFrameIndex: 2147483647,
ImageRUID: { DataId: dataId },
LocalPosition: { x: 0.0, y: 0.0 },
LocalScale: { x: 1.0, y: 1.0 },
OverrideSorting: false,
PlayRate: 1.0,
PreserveSprite: 0,
StartFrameIndex: 0,
Color: color,
DropShadow: false,
DropShadowAngle: 30.0,
DropShadowColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.72 },
DropShadowDistance: 32.0,
FillAmount: 1.0,
FillCenter: true,
FillClockWise: true,
FillMethod: 0,
FillOrigin: 0,
FlipX: false,
FlipY: false,
FrameColumn: 1,
FrameRate: 0,
FrameRow: 1,
Outline: false,
OutlineColor: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
OutlineWidth: 3.0,
RaycastTarget: raycast,
Type: type,
Enable: true,
};
}
function text({ value, fontSize, bold, alignment = 4 }) {
return {
'@type': 'MOD.Core.TextComponent',
Alignment: alignment,
Bold: bold,
DropShadow: false,
DropShadowAngle: 30.0,
DropShadowColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.72 },
DropShadowDistance: 32.0,
Font: 0,
FontColor: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
FontSize: fontSize,
MaxSize: fontSize,
MinSize: 8,
OutlineColor: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
OutlineDistance: { x: 1.0, y: -1.0 },
OutlineWidth: 1.0,
Overflow: 0,
OverrideSorting: false,
Padding: { left: 0, right: 0, top: 0, bottom: 0 },
SizeFit: false,
Text: value,
UseOutLine: true,
Enable: true,
};
}
function entity({ id, path, modelId, entryId, componentNames, components, displayOrder }) {
const parts = path.split('/');
const name = parts[parts.length - 1];
const slashes = '/'.repeat(parts.length - 1);
return {
id,
path,
componentNames,
jsonString: {
name,
path,
nameEditable: true,
enable: true,
visible: true,
localize: true,
displayOrder,
pathConstraints: slashes,
revision: 1,
origin: {
type: 'Model',
entry_id: entryId,
sub_entity_id: null,
root_entity_id: null,
replaced_model_id: null,
},
modelId,
'@components': components,
'@version': 1,
},
};
}
// ---- build entities ----
const TRANSPARENT = { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
const ents = [];
let g = 0;
// CardHand container
ents.push(entity({
id: guid(g++),
path: '/ui/DefaultGroup/CardHand',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1020, y: 280 }, pos: { x: 0, y: 180 }, align: ALIGN_BOTTOM_CENTER }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
],
}));
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 }),
],
}));
});
// ---- splice into file ----
let txt = readFileSync(FILE, 'utf8');
if (txt.includes('/ui/DefaultGroup/CardHand')) {
console.log('CardHand already present — no changes made.');
process.exit(0);
}
const eol = txt.includes('\r\n') ? '\r\n' : '\n'; // 기존 파일의 줄바꿈 보존
const splicePoint = `${eol} ]`; // Entities 닫는 대괄호(4-space indent)
const count = txt.split(splicePoint).length - 1;
if (count !== 1) {
console.error(`Expected exactly one Entities closing bracket, found ${count}. Aborting.`);
process.exit(1);
}
const blocks = ents
.map((e) => JSON.stringify(e, null, 2).split('\n').map((l) => ' ' + l).join(eol))
.join(',' + eol);
txt = txt.replace(splicePoint, ',' + eol + blocks + eol + ' ]');
JSON.parse(txt); // 유효성 검증 (실패 시 throw)
writeFileSync(FILE, txt, 'utf8');
console.log(`Inserted ${ents.length} CardHand entities.`);

File diff suppressed because it is too large Load Diff