From 4f9798ec3f3be1dac7b5c221c26d9d1cc5eb46f4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 6 Jun 2026 05:58:46 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=86=90=ED=8C=A8=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-06-bottom-card-hand.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-bottom-card-hand.md diff --git a/docs/superpowers/plans/2026-06-06-bottom-card-hand.md b/docs/superpowers/plans/2026-06-06-bottom-card-hand.md new file mode 100644 index 0000000..600d552 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-bottom-card-hand.md @@ -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장 카드 렌더 확인