Files
maplecontest/docs/superpowers/plans/2026-06-06-bottom-card-hand.md
2026-06-06 05:58:46 +09:00

14 KiB
Raw Blame History

하단 카드 손패 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에 아래 내용을 그대로 작성한다.

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: 커밋
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: 스크립트 실행

cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-cardhand.mjs

Expected 출력:

Inserted 21 CardHand entities.
  • Step 2: JSON 유효성 + 엔티티 수 검증
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: 멱등성 확인 (재실행 시 무변경)
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-cardhand.mjs

Expected 출력:

CardHand already present — no changes made.
  • Step 4: 기존 엔티티 불변 확인
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부터 재실행한다.

되돌리기:

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: 변경 커밋

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장 카드 렌더 확인