diff --git a/.gitignore b/.gitignore index 30250d8..219d821 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조) .claude/* !.claude/settings.json +# 개인 스킬(superpowers) 브레인스토밍/계획 산출물 — 로컬 전용, 협업 공유 X (프로젝트 설계 문서 docs/*.md 는 추적 유지) +docs/superpowers/ # === OS / 에디터 잡파일 === Thumbs.db diff --git a/README.md b/README.md index bf565ec..cefcb4e 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ slaymaple/ ├── ui/ # UI 그룹 (DefaultGroup ~6.8MB 산출물 / PopupGroup / ToastGroup) ├── docs/ │ ├── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서 -│ ├── ui-generation-structure.md # UI 생성 구조 문서 -│ └── superpowers/specs|plans/ # 각 기능 설계·구현 계획 문서(P1~P15 + 노드맵 UI·캐릭터 이미지·생성기 모듈화·메이커 파일럿) +│ └── ui-generation-structure.md # UI 생성 구조 문서 +│ └── (superpowers/ — 개인 스킬 브레인스토밍/계획 산출물: `.gitignore` 처리·로컬 전용, 저장소 미포함) ├── RULES.md # 협업·AI 에이전트 하네스 규칙 (토큰 가드·검증·PR 절차) ├── CLAUDE.md # Claude Code 자동 로드 (RULES.md 임포트) └── README.md @@ -91,6 +91,7 @@ slaymaple/ > ⚠️ **`map/*.map` · `ui/DefaultGroup.ui` · `*.codeblock` · `Global/*.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 다음 재생성 때 사라집니다. 게임 변경은 `data/*.json` 또는 `tools/`의 생성기를 고친 뒤 재생성하세요(자세한 규칙은 [`RULES.md`](RULES.md)). > `.mcp.json`, `.codex/` 는 **Authorization 토큰이 포함**되어 있어 git에서 제외됩니다(`.gitignore`). 각자 로컬에서 직접 구성하세요. +> `docs/superpowers/`(개인 스킬 브레인스토밍·계획 산출물)도 `.gitignore` 처리되어 **로컬 전용**입니다 — 프로젝트 설계 문서(`docs/*.md`)만 형상관리합니다. --- @@ -147,7 +148,7 @@ c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1 ``` 밸런스 검증: `node tools/balance/sim-balance.mjs [N] [--seed S]` · 테스트: `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs`. -상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 및 `docs/superpowers/specs/` 참조. +상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 참조. ### 산출물 재생성 ```bash diff --git a/docs/superpowers/plans/2026-06-06-bottom-card-hand.md b/docs/superpowers/plans/2026-06-06-bottom-card-hand.md deleted file mode 100644 index 600d552..0000000 --- a/docs/superpowers/plans/2026-06-06-bottom-card-hand.md +++ /dev/null @@ -1,402 +0,0 @@ -# 하단 카드 손패 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장 카드 렌더 확인 diff --git a/docs/superpowers/plans/2026-06-06-card-image-slot.md b/docs/superpowers/plans/2026-06-06-card-image-slot.md deleted file mode 100644 index 9c2445f..0000000 --- a/docs/superpowers/plans/2026-06-06-card-image-slot.md +++ /dev/null @@ -1,235 +0,0 @@ -# 카드 슬롯 이미지 적용 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 확인 + 스크린샷 시각 확인 diff --git a/docs/superpowers/plans/2026-06-06-maps-monsters-tiles.md b/docs/superpowers/plans/2026-06-06-maps-monsters-tiles.md deleted file mode 100644 index 344dffd..0000000 --- a/docs/superpowers/plans/2026-06-06-maps-monsters-tiles.md +++ /dev/null @@ -1,217 +0,0 @@ -# 맵 개선(다양한 몬스터 + 타일셋 + StS2 배치) 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:** map02~map11에 공식 맵에서 수확한 다양한 몬스터 2종(기존 4종 미사용)을 StS2 우측 배치로, 맵마다 다른 타일셋으로 재생성한다. - -**Architecture:** 공식 맵 import로 몬스터 변형 `{sprite,stand,hit,die}`과 타일셋 RUID를 수확(배경 수확과 동일 기법) → `tools/gen-maps.mjs`의 `MONSTER_VARIANTS`/`TILESETS`에 반영 → 몬스터 선택을 "서로 다른 2종 + 정적 베이스 + StS2 우측 고정위치"로, TileSetRUID를 맵별로 교체 → map02~map11 재생성. map02 스파이크로 렌더 검증 후 확대. - -**Tech Stack:** Node.js, MSW `.map` JSON, msw-maker-mcp(import/save/play/screenshot/execute_script), msw-mcp. - ---- - -## File Structure -- Modify: `tools/gen-maps.mjs` — `MONSTER_VARIANTS`/`TILESETS` 데이터 + 몬스터 선택/배치 로직 + TileSetRUID 교체. -- Modify(재생성): `map/map02.map`~`map11.map`. - -기준 사실: -- 몬스터 엔티티: `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet{stand,hit,die}`. 정적 베이스로 쓸 템플릿은 path에 `Static` 포함(StaticMonsterTemplate, 배회 안 함). -- 타일: 맵의 `/TileMap` 엔티티 `TileMapComponent.TileSetRUID.DataId`. map01 기본 `9dfea3808bbd49a5877d8624df21b1c7`. -- 배경: 기존 `BACKGROUNDS` 10종 유지. -- import는 현재 맵(map02, 재생성 가능)을 교체 → save → 파일에서 추출. - ---- - -### Task 1: 몬스터 변형 + 타일셋 수확 (컨트롤러/MCP, 스파이크 포함) - -**목표:** `MONSTER_VARIANTS`(≥12종 `{sprite,stand,hit,die}`) + `TILESETS`(10종 RUID) 확정. - -- [ ] **Step 1: 몬스터 엔티티 구조 스파이크** - -몬스터가 있는 공식 **필드맵** 1개를 import(`maker_import_maplestory_map`) → `maker_save` → `map/map02.map`에서 `script.Monster`를 포함하는 엔티티를 찾아 `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet`(stand/hit/die)가 존재하는지 확인. -- 존재 → 그 형태로 변형 추출. -- 부재(구조 다름) → 폴백: `SpriteRUID`만 추출하고 `ActionSheet`는 map01 템플릿 유지(생성기에서 변형에 stand/hit/die가 없으면 ActionSheet 미변경하도록 처리). - -필드맵 후보 id는 `maker_list_maplestory_maps`로 탐색(영문/지역명). 몬스터가 있는 사냥/필드맵을 고른다. - -- [ ] **Step 2: 변형 ≥12종 수확** - -필드맵 여러 개를 import→save→추출 반복. 각 맵의 몬스터 엔티티들에서 `{sprite, stand, hit, die}`를 모아 **중복 sprite 제거**해 ≥12종 확보. map01의 4종 sprite(`8ef238e0…`,`6c7130f5…`,`3e76c89a…`,`6d381bea…`,`c96c11f9…`)는 **제외**. - -- [ ] **Step 3: 타일셋 10종 수확** - -import한 맵들의 `TileMapComponent.TileSetRUID.DataId`를 수집해 **distinct 10종**(map01의 `9dfea380…` 제외). (배경 수확 때처럼 import 1회로 타일셋+몬스터 동시 수확 가능) - -- [ ] **Step 4: 결과 정리** - -`MONSTER_VARIANTS = [{sprite,stand,hit,die}, ...]`(≥12)와 `TILESETS = [ruid, ...]`(10)를 Task 2에 넘길 형태로 기록. (코드 변경 없음; 데이터 산출) - ---- - -### Task 2: 생성기 로직·데이터 갱신 - -**Files:** Modify `tools/gen-maps.mjs` - -- [ ] **Step 1: TILESETS 상수 추가** - -`BACKGROUNDS = [...]` 정의 바로 아래에 추가(값은 Task 1 결과): - -```js -// 공식 맵에서 수확한 타일셋 RUID 10종 (맵마다 다르게). map01 기본(9dfea380…) 제외. -const TILESETS = [ - // Task 1에서 수확한 10개 RUID -]; -``` - -- [ ] **Step 2: MONSTER_VARIANTS 채우기** - -기존 `const MONSTER_VARIANTS = [];` 를 Task 1에서 수확한 ≥12종으로 교체: - -```js -// 공식 맵에서 수확한 몬스터 변형 (기존 map01 4종 미사용). -const MONSTER_VARIANTS = [ - // { sprite: '...', stand: '...', hit: '...', die: '...' }, ... (≥12종) -]; -``` - -- [ ] **Step 3: 몬스터 배치 로직 교체 (서로 다른 2종 + StS2 + 정적 베이스)** - -`buildMap` 안의 몬스터 추가 루프(`const ents = ...` 이후 `for (let i = 0; i < 2; i++) { ... }` 블록 전체)를 다음으로 교체: - -```js - const ents = map.ContentProto.Entities.filter((e) => !isMonster(e)); - // 정적 베이스(StS2 위치 고정 — 배회 방지). 변형이 sprite/animation을 덮어쓰므로 외형은 베이스와 무관. - const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0]; - // 서로 다른 변형 2종 선택 (맵 내 중복 금지) - const vi = Math.floor(rand() * MONSTER_VARIANTS.length); - const vj = (vi + 1 + Math.floor(rand() * (MONSTER_VARIANTS.length - 1))) % MONSTER_VARIANTS.length; - const chosen = [MONSTER_VARIANTS[vi], MONSTER_VARIANTS[vj]]; - const STS2_X = [3.5, 5.5]; // 화면 우측 전투 포메이션 - for (let i = 0; i < 2; i++) { - const m = JSON.parse(JSON.stringify(base)); - m.jsonString.name = `Monster${i + 1}`; - m.path = `/maps/map${tag}/Monster${i + 1}`; - m.jsonString.path = m.path; - const tr = compOf(m, 'MOD.Core.TransformComponent'); - if (tr) tr.Position.x = STS2_X[i]; - const v = chosen[i]; - const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); - if (sp) sp.SpriteRUID = v.sprite; - const sa = compOf(m, 'MOD.Core.StateAnimationComponent'); - if (sa && v.stand) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die }; - ents.push(m); - } -``` - -(`v.stand`가 없으면 ActionSheet를 유지 → 폴백 호환) - -- [ ] **Step 4: TileSetRUID 교체 추가** - -`buildMap`의 경로/배경 설정 루프 `for (const e of ents) { ... }` 안, 배경 설정 블록 다음에 추가: - -```js - if ((e.path || '').endsWith('/TileMap')) { - const tm = compOf(e, 'MOD.Core.TileMapComponent'); - if (tm && TILESETS.length > 0) tm.TileSetRUID = { DataId: TILESETS[(nn - 2) % TILESETS.length] }; - } -``` - -- [ ] **Step 5: 구문 확인 + 커밋** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node --check tools/gen-maps.mjs -git add tools/gen-maps.mjs -git commit -m "맵 생성기: 수확한 다양한 몬스터 2종(StS2 배치) + 맵별 타일셋 교체" -``` - ---- - -### Task 3: map02 스파이크 — 재생성 + Maker 검증 - -**Files:** Modify `map/map02.map` - -- [ ] **Step 1: map02 재생성** - -수확 import로 오염된 map02를 깨끗이 재생성: -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -git checkout map/map01.map # 혹시 모를 보호(템플릿). map01은 변경 대상 아님 -node tools/gen-maps.mjs 2 -``` -Expected: `Generated: map02` - -- [ ] **Step 2: 데이터 검증** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "const j=JSON.parse(require('fs').readFileSync('map/map02.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);const xs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.TransformComponent').Position.x);const tm=E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId;console.log('monsters:',ms.length);console.log('sprites:',sprs.join(','));console.log('distinct sprites:',new Set(sprs).size===2);console.log('no old sprite:',sprs.every(s=>!old.includes(s)));console.log('positions x:',xs.join(','));console.log('tileset:',tm,'changed:',tm!=='9dfea3808bbd49a5877d8624df21b1c7')" -``` -Expected: `monsters: 2`, 2개 sprite distinct, `no old sprite: true`, positions x = `3.5,5.5`, tileset이 `9dfea380…`이 아님(교체됨). - -- [ ] **Step 3: Maker 렌더 검증 (컨트롤러)** - -1. `maker_refresh_workspace` -2. map02가 활성인지 확인(`maker_get_current_map`). 아니면 사용자에게 map02 열기 요청. -3. `maker_play` → `maker_screenshot` → Read로 확인: 몬스터 2마리가 **수확된(기존과 다른) 외형**으로 **우측에** 보이고, **타일 텍스처가 바뀌었는지**. -4. `maker_execute_script`(client)로 확인: - ```lua - local m1=_EntityService:GetEntityByPath("/maps/map02/Monster1") - local m2=_EntityService:GetEntityByPath("/maps/map02/Monster2") - if m1 then log("M1 spr="..tostring(m1.SpriteRendererComponent.SpriteRUID).." x="..tostring(m1.TransformComponent.Position.x)) end - if m2 then log("M2 spr="..tostring(m2.SpriteRendererComponent.SpriteRUID).." x="..tostring(m2.TransformComponent.Position.x)) end - ``` - → `maker_logs(normal)`로 sprite/x 확인. -5. `maker_stop`. - -- [ ] **Step 4: 게이트 판정** - -- 몬스터 외형 변경 + 우측 배치 + 타일 변경 정상 → Task 4. -- 몬스터가 흰박스/안 보임 → 변형 sprite/animation 로드 문제 → Task 1 폴백(SpriteRUID만, ActionSheet 유지) 적용 후 재생성. -- 타일이 깨져 보임 → 해당 타일셋 제외하거나 호환 타일셋으로 교체(`TILESETS` 조정) 후 재생성. - ---- - -### Task 4: 전체 재생성 + 검증 - -**Files:** Modify `map/map02.map`~`map11.map`, `Global/SectorConfig.config` - -- [ ] **Step 1: 전체 재생성** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node tools/gen-maps.mjs -``` -Expected: `Generated: map02 … map11`, `SectorConfig entries: 11`. - -- [ ] **Step 2: 전체 데이터 검증** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "const fs=require('fs');const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];let ids=new Set(),dup=false,ts=new Set(),bad=false;for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const j=JSON.parse(fs.readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));if(ms.length!==2)throw new Error('monsters '+t);const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);if(new Set(sprs).size!==2)bad=true;if(sprs.some(s=>old.includes(s)))bad=true;ts.add(E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId);for(const e of E){if(ids.has(e.id))dup=true;ids.add(e.id);}}console.log('cross-map id dup:',dup);console.log('any old/dup-in-map sprite:',bad);console.log('distinct tilesets:',ts.size)" -``` -Expected: `cross-map id dup: false`, `any old/dup-in-map sprite: false`, `distinct tilesets: 10`. - -- [ ] **Step 3: Maker 표본 검증 (컨트롤러)** - -`maker_refresh_workspace` 후 표본 맵(map05, map09)을 각각 열어(사용자 협조) `maker_play`→`maker_screenshot`로 몬스터 외형·타일이 맵마다 다른지 확인. `maker_stop`. - ---- - -### Task 5: 최종 커밋 - -- [ ] **Step 1: 커밋** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -git add tools/gen-maps.mjs Global/SectorConfig.config map/map02.map map/map03.map map/map04.map map/map05.map map/map06.map map/map07.map map/map08.map map/map09.map map/map10.map map/map11.map -git commit -m "맵 10개: 다양한 몬스터 2종(StS2 우측 배치) + 맵별 타일셋 적용" -``` - ---- - -## 검증 요약 -- 수확: 몬스터 변형 ≥12 / 타일셋 10 (스파이크로 구조 확인) -- map02 스파이크: 데이터(2 distinct sprite·old 미사용·x=3.5/5.5·타일셋 교체) + Maker 렌더 -- 전체: cross-map id 무중복, old sprite 미사용, 타일셋 10 distinct -- Maker 표본 시각 확인 diff --git a/docs/superpowers/plans/2026-06-06-ten-maps.md b/docs/superpowers/plans/2026-06-06-ten-maps.md deleted file mode 100644 index 5a8ff37..0000000 --- a/docs/superpowers/plans/2026-06-06-ten-maps.md +++ /dev/null @@ -1,273 +0,0 @@ -# 맵 10개 생성 (랜덤 배경 + 몬스터 2마리) 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:** `map01`을 템플릿으로 독립 맵 10개(`map02`~`map11`)를 생성하고, 맵마다 다른 공식 배경 + 랜덤 위치 몬스터 2마리를 배치한다. - -**Architecture:** Node 생성기 `tools/gen-maps.mjs`가 `map/map01.map`을 JSON으로 읽어 맵마다 deep-clone → 경로/EntryKey/name 치환, 전 엔티티 GUID 재발급(자기참조 보정), `Background.TemplateRUID` 교체, 몬스터 2마리 배치 → `map/mapNN.map`(JSON.stringify)로 기록. `SectorConfig.config`에 등록. 몬스터 다양화(A)는 `MONSTER_VARIANTS` 데이터로 주입하며, map02 스파이크로 렌더 검증 후 10개로 확대(실패 시 B=기존 몬스터 폴백). - -**Tech Stack:** Node.js(ESM, 표준 라이브러리), MSW `.map`(JSON 엔티티), msw-mcp 에셋 검색, msw-maker-mcp reload/play/screenshot/execute_script. - ---- - -## File Structure -- Create: `tools/gen-maps.mjs` — 맵 생성기 (템플릿 클론·GUID 재발급·배경/몬스터 주입·SectorConfig 갱신). -- Create: `map/map02.map` ~ `map/map11.map` — 생성 결과. -- Modify: `Global/SectorConfig.config` — `entries`에 map02~map11 추가. - -배경 RUID 풀(공식 라이브러리, 확보 완료, 10개): -`79c95db9fdbb4c4796771733d069e3e2`, `1d4a335a5416401f8e289d78a03fd0c3`, `731a9cd1cce045e19d50fdcdc9a20be9`, `695805b1809243fd9376e2bba113ebde`, `454804df4c7e4701997ec8a8de088597`, `01992685f5d147b3a5c18fabf584807f`, `c861e9cb2d0b4d91be5d4d6aedf796b1`, `ee2e13a352d64611906760c1b722df67`, `8e89019c54d14aed875e54f13fa14109`, `fa936edd365f47e4b5622c19b1a80a0c` - -맵 구조(map01): 엔티티 `/maps/map01`(Map+Foothold), `/Background`(BackgroundComponent.TemplateRUID), `/MapleMapLayer`, `/TileMap`, `/SpawnLocation`, 몬스터들(componentNames에 `script.Monster` 포함: StaticMonsterTemplate/MoveMonsterTemplate/ChaseMonsterTemplate/monster-43). 엔티티 id는 대시 GUID(8-4-4-4-12), 리소스 RUID는 대시 없는 32hex. - ---- - -### Task 1: 라이브러리 몬스터 변형 후보 조사 (컨트롤러/MCP, 타임박스) - -**목표:** 완결된 라이브러리 몬스터 변형(스프라이트 + stand/hit/die 액션 RUID 세트)을 ≥3종 확보 시도. 액션 그룹핑/이름을 얻을 수 없으면 **B 폴백**(빈 변형)으로 결정. - -- [ ] **Step 1: 라이브러리 몬스터 리소스 조사** - -MCP `asset_search_resources`로 `cat="animationclip"`/`"sprite"`, `source="maplestory"`, `query`로 몬스터 후보를 찾고, 가능하면 `detail=true` 및 메타데이터로 action(stand/hit/die) 식별을 시도한다. - -- [ ] **Step 2: 변형 세트 확정 또는 폴백 결정** - -각 변형을 `{ sprite, stand, hit, die }`(RUID) 형태로 ≥3개 확보하면 → 그 배열을 Task 2의 `MONSTER_VARIANTS`로 사용. -액션 식별이 불가하거나 불확실하면 → `MONSTER_VARIANTS = []`로 두고 **B 폴백**(기존 템플릿 몬스터 그대로 사용)으로 진행한다. 결정 결과를 한 줄로 기록(`log` 또는 보고). - -> 이 태스크의 산출물은 "MONSTER_VARIANTS 배열(또는 빈 배열) + 결정 사유" 한 가지다. 코드 변경 없음. - ---- - -### Task 2: 생성기 작성 - -**Files:** Create `tools/gen-maps.mjs` - -- [ ] **Step 1: 스크립트 작성** - -`tools/gen-maps.mjs`에 아래를 그대로 작성한다. `MONSTER_VARIANTS`는 Task 1 결과로 채우거나 빈 배열로 둔다(빈 배열 = B 폴백). - -```js -import { readFileSync, writeFileSync } from 'node:fs'; - -const TEMPLATE = 'map/map01.map'; -const SECTOR = 'Global/SectorConfig.config'; -const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - -// 공식 라이브러리 배경 RUID 풀 (맵마다 1개씩, 서로 다르게) -const BACKGROUNDS = [ - '79c95db9fdbb4c4796771733d069e3e2', '1d4a335a5416401f8e289d78a03fd0c3', - '731a9cd1cce045e19d50fdcdc9a20be9', '695805b1809243fd9376e2bba113ebde', - '454804df4c7e4701997ec8a8de088597', '01992685f5d147b3a5c18fabf584807f', - 'c861e9cb2d0b4d91be5d4d6aedf796b1', 'ee2e13a352d64611906760c1b722df67', - '8e89019c54d14aed875e54f13fa14109', 'fa936edd365f47e4b5622c19b1a80a0c', -]; - -// Task 1 결과. 비어 있으면 기존 템플릿 몬스터를 그대로 사용(B 폴백). -// 각 항목: { sprite, stand, hit, die } (모두 RUID 문자열) -const MONSTER_VARIANTS = []; - -// 결정론적 시드 RNG (맵 번호 기반) -function rng(seed) { - let s = seed >>> 0; - return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; -} - -// 결정론적 대시 GUID (맵번호, 인덱스) -function mapGuid(nn, idx) { - const n = (nn * 1000 + idx) >>> 0; - const h8 = n.toString(16).padStart(8, '0'); - const h12 = n.toString(16).padStart(12, '0'); - return `${h8}-0000-4000-8000-${h12}`; -} - -const isMonster = (e) => (e.componentNames || '').includes('script.Monster'); -const compOf = (e, type) => e.jsonString['@components'].find((c) => c['@type'] === type); - -const template = JSON.parse(readFileSync(TEMPLATE, 'utf8')); -const monsterTemplates = template.ContentProto.Entities.filter(isMonster); -if (monsterTemplates.length === 0) throw new Error('템플릿에서 몬스터 엔티티를 못 찾음'); - -function buildMap(nn) { - const tag = String(nn).padStart(2, '0'); - const rand = rng(nn * 7919); - const map = JSON.parse(JSON.stringify(template)); // deep clone - map.EntryKey = `map://map${tag}`; - - // 비-몬스터 엔티티만 유지 - const ents = map.ContentProto.Entities.filter((e) => !isMonster(e)); - - // 몬스터 2마리 추가 (템플릿 몬스터 복제) - for (let i = 0; i < 2; i++) { - const src = monsterTemplates[Math.floor(rand() * monsterTemplates.length)]; - const m = JSON.parse(JSON.stringify(src)); - m.jsonString.name = `Monster${i + 1}`; - m.path = `/maps/map${tag}/Monster${i + 1}`; - m.jsonString.path = m.path; - const tr = compOf(m, 'MOD.Core.TransformComponent'); - if (tr) tr.Position.x = Math.round((rand() * 8 - 4) * 100) / 100; // -4..4 바닥 위 - if (MONSTER_VARIANTS.length > 0) { - const v = MONSTER_VARIANTS[Math.floor(rand() * MONSTER_VARIANTS.length)]; - const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); - if (sp) sp.SpriteRUID = v.sprite; - const sa = compOf(m, 'MOD.Core.StateAnimationComponent'); - if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die }; - } - ents.push(m); - } - - // 경로/이름 치환 + 배경 설정 - for (const e of ents) { - if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', `/maps/map${tag}`); - if (e.jsonString) { - if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', `/maps/map${tag}`); - if (e.jsonString.name === 'map01') e.jsonString.name = `map${tag}`; - } - if ((e.path || '').endsWith('/Background')) { - const bg = compOf(e, 'MOD.Core.BackgroundComponent'); - if (bg) bg.TemplateRUID = BACKGROUNDS[(nn - 2) % BACKGROUNDS.length]; - } - } - - // GUID 재발급 (자기참조 root/sub_entity_id 보정) - ents.forEach((e, idx) => { - const oldId = e.id; - const newId = mapGuid(nn, idx); - e.id = newId; - const o = e.jsonString && e.jsonString.origin; - if (o) { - if (o.root_entity_id === oldId) o.root_entity_id = newId; - if (o.sub_entity_id === oldId) o.sub_entity_id = newId; - } - }); - - map.ContentProto.Entities = ents; - writeFileSync(`map/map${tag}.map`, JSON.stringify(map, null, 2), 'utf8'); - return `map${tag}`; -} - -// 인자: 생성할 맵 번호(미지정 시 전체). 예: node tools/gen-maps.mjs 2 -const arg = process.argv[2]; -const targets = arg ? [Number(arg)] : MAP_NUMBERS; -const made = targets.map(buildMap); -console.log('Generated:', made.join(', ')); - -// SectorConfig 등록 (전체 생성 시에만, 중복 방지) -if (!arg) { - const sector = JSON.parse(readFileSync(SECTOR, 'utf8')); - const entries = sector.ContentProto.Json.Sectors[0].entries; - for (const nn of MAP_NUMBERS) { - const key = `map://map${String(nn).padStart(2, '0')}`; - if (!entries.includes(key)) entries.push(key); - } - writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8'); - console.log('SectorConfig entries:', entries.length); -} -``` - -- [ ] **Step 2: 구문 확인** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node --check tools/gen-maps.mjs -``` -Expected: 출력 없음(exit 0). - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-maps.mjs -git commit -m "맵 생성기 추가 (map01 템플릿 복제·배경/몬스터 주입)" -``` - ---- - -### Task 3: map02 스파이크 — 생성 + Maker 렌더 검증 - -**Files:** Create `map/map02.map` - -- [ ] **Step 1: map02만 생성** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node tools/gen-maps.mjs 2 -``` -Expected: `Generated: map02` - -- [ ] **Step 2: 데이터 검증** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "const j=JSON.parse(require('fs').readFileSync('map/map02.map','utf8'));const E=j.ContentProto.Entities;console.log('EntryKey:',j.EntryKey);const ids=E.map(e=>e.id);console.log('unique ids:', new Set(ids).size===ids.length);console.log('monsters:', E.filter(e=>(e.componentNames||'').includes('script.Monster')).length);const bg=E.find(e=>(e.path||'').endsWith('/Background'));console.log('bg RUID:', bg.jsonString['@components'].find(c=>c['@type']==='MOD.Core.BackgroundComponent').TemplateRUID);console.log('paths ok:', E.every(e=>!(e.path||'').includes('/maps/map01')))" -``` -Expected: `EntryKey: map://map02`, `unique ids: true`, `monsters: 2`, `bg RUID:` 가 배경 풀의 첫 값(`79c95db9...`), `paths ok: true`. - -- [ ] **Step 3: Maker에서 map02 열어 렌더 검증 (컨트롤러)** - -1. `maker_refresh_workspace` (edit) -2. Maker에서 map02를 활성 맵으로 연다(에디터에서 map02 더블클릭). MCP로 직접 맵 전환이 안 되면, 사용자에게 "Maker에서 map02 열기"를 요청한다. -3. `maker_play` → `maker_screenshot` → Read로 확인: **배경이 map01과 다른 배경으로 표시**되고 **몬스터 2마리가 보이는지**. -4. `maker_execute_script`(client)로 몬스터 로드 확인: - ```lua - local m1 = _EntityService:GetEntityByPath("/maps/map02/Monster1") - local m2 = _EntityService:GetEntityByPath("/maps/map02/Monster2") - log("M1="..tostring(m1~=nil).." M2="..tostring(m2~=nil)) - ``` - → `maker_logs(normal)`에서 `M1=true M2=true` 확인. -5. `maker_stop`. - -- [ ] **Step 4: 게이트 판정** - -- 배경·몬스터 정상 → 그대로 진행(Task 4). -- 배경이 흰/검 박스이거나 몬스터 안 보임: - - 배경 문제: 배경 RUID 풀이 로컬 워크스페이스에서 로드 안 됨 → 사용자와 상의(공식 배경 로드 가능 여부). 우선 다른 배경 RUID로 교체 시도. - - 몬스터 변형(A) 문제(MONSTER_VARIANTS 사용 중일 때만): `MONSTER_VARIANTS = []`로 비우고(B 폴백) Step 1부터 재실행. -- ui 되돌리기 필요 시: `git checkout map/map02.map` 후 재생성. - ---- - -### Task 4: 나머지 맵 생성 + SectorConfig 등록 - -**Files:** Create `map/map03.map`~`map/map11.map`, Modify `Global/SectorConfig.config` - -- [ ] **Step 1: 전체 생성** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node tools/gen-maps.mjs -``` -Expected: `Generated: map02, map03, ... map11` 와 `SectorConfig entries: 11` - -- [ ] **Step 2: 전체 데이터 검증** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "const fs=require('fs');let allIds=new Set(),dup=false,bgs=new Set();for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const j=JSON.parse(fs.readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;if(j.EntryKey!=='map://map'+t)throw new Error('EntryKey '+t);if(E.filter(e=>(e.componentNames||'').includes('script.Monster')).length!==2)throw new Error('monsters '+t);for(const e of E){if(allIds.has(e.id))dup=true;allIds.add(e.id);}bgs.add(E.find(e=>(e.path||'').endsWith('/Background')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.BackgroundComponent').TemplateRUID);}const sec=JSON.parse(fs.readFileSync('Global/SectorConfig.config','utf8'));console.log('cross-map id dup:',dup);console.log('distinct backgrounds:',bgs.size);console.log('sector entries:',sec.ContentProto.Json.Sectors[0].entries.length)" -``` -Expected: `cross-map id dup: false`, `distinct backgrounds: 10`, `sector entries: 11`. - -- [ ] **Step 3: Maker 표본 검증 (컨트롤러)** - -`maker_refresh_workspace` 후, 표본 맵 2~3개(map05, map08, map11)를 각각 열어 `maker_play`→`maker_screenshot`로 배경이 서로 다르고 몬스터 2마리가 보이는지 확인. 맵 전환이 MCP로 안 되면 사용자에게 해당 맵 열기를 요청. 확인 후 `maker_stop`. - ---- - -### Task 5: 최종 커밋 - -**Files:** `map/map02.map`~`map/map11.map`, `Global/SectorConfig.config` - -- [ ] **Step 1: 커밋** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -git add map/map02.map map/map03.map map/map04.map map/map05.map map/map06.map map/map07.map map/map08.map map/map09.map map/map10.map map/map11.map Global/SectorConfig.config -git commit -m "맵 10개(map02~map11) 생성: 랜덤 배경 + 몬스터 2마리, sector 등록" -``` - ---- - -## 검증 요약 -- 생성기 `node --check` 통과 -- map02 스파이크: 데이터(고유 id/2몬스터/배경) + Maker 렌더(배경 상이·몬스터 2)로 A/B 게이트 판정 -- 전체: cross-map id 중복 없음, 배경 10종 distinct, sector 11개 -- Maker 표본 맵 시각 확인 diff --git a/docs/superpowers/plans/2026-06-08-card-combat-integration.md b/docs/superpowers/plans/2026-06-08-card-combat-integration.md deleted file mode 100644 index 597f101..0000000 --- a/docs/superpowers/plans/2026-06-08-card-combat-integration.md +++ /dev/null @@ -1,481 +0,0 @@ -# 카드 전투 통합 (TODO B) 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:** 카드 사용이 실제 적 HP·플레이어 Block·적 의도·승패에 반영되는 단일 전투 루프를 완성한다. - -**Architecture:** 모든 변경은 `tools/gen-slaydeck.mjs` 단일 생성기에서 만든다. 적/플레이어 전투 상태는 `SlayDeckController` codeblock 내부 속성으로 보유(필드 `Monster.codeblock`과 분리). UI는 `CombatHud` 그룹으로 DeckHud와 별도 생성. 수치(플레이어 80 / 적 45 / 의도 10·6·방8)는 임시 placeholder. - -**Tech Stack:** Node.js ESM 생성기(`gen-slaydeck.mjs`), MSW Lua codeblock, MSW UI JSON. 검증은 `node --check` + 재생성 + sha1 결정성 + 메이커 Play. - ---- - -## File Structure - -- Modify: `tools/gen-slaydeck.mjs` — 유일한 변경 대상. - - `upsertUi()`: `CombatHud` 그룹(적/플레이어 패널·결과 텍스트) 생성 추가, 정리 필터 확장. - - `writeCodeblocks()`: `SlayDeckController` 속성·메서드 추가/수정. -- 생성물(자동, 직접 편집 금지): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`. - -검증 한계: MSW codeblock Lua는 단위 테스트 러너가 없다. 자동 검증은 생성기 문법·재생성·결정성·JSON 유효성까지, 실제 동작은 메이커 Play(사용자)로 확인. - ---- - -### Task 1: 카드 데이터 수치화 (Cards 테이블 + UI 카드 배열) - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`upsertUi` 내 `cards` 배열, `writeCodeblocks` 내 `StartCombat`의 `self.Cards`) - -- [ ] **Step 1: `upsertUi`의 카드 배열은 표시용 그대로 두되, codeblock `Cards`에 수치 필드 추가** - -`writeCodeblocks()`의 `StartCombat` 메서드 코드에서 `self.Cards` 정의를 아래로 교체: - -```lua -self.Cards = { - Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 }, - Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 }, - Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 }, -} -``` - -- [ ] **Step 2: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 (출력 없음, exit 0) - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(B): 카드 데이터에 damage/block 수치 필드 추가" -``` - ---- - -### Task 2: 전투 상태 속성 + StartCombat 초기화 - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` 속성 배열, `StartCombat` 메서드) - -- [ ] **Step 1: codeblock 속성 추가** - -`codeblock('SlayDeckController', ...)`의 properties 배열 끝에 추가: - -```js - prop('number', 'PlayerHp', '0'), - prop('number', 'PlayerMaxHp', '80'), - prop('number', 'PlayerBlock', '0'), - prop('number', 'EnemyHp', '0'), - prop('number', 'EnemyMaxHp', '45'), - prop('number', 'EnemyBlock', '0'), - prop('number', 'EnemyIntentIndex', '1'), - prop('boolean', 'CombatOver', 'false'), - prop('any', 'EnemyIntents'), - prop('any', 'EnemyName'), -``` - -- [ ] **Step 2: `StartCombat`에 전투 상태 초기화 추가** - -`StartCombat` 코드의 맨 위(`self.MaxEnergy = 3` 직후)에 삽입: - -```lua -self.PlayerMaxHp = 80 -self.PlayerHp = self.PlayerMaxHp -self.PlayerBlock = 0 -self.EnemyName = "슬라임" -self.EnemyMaxHp = 45 -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -self.EnemyIntents = { - { kind = "Attack", value = 10 }, - { kind = "Attack", value = 6 }, - { kind = "Defend", value = 8 }, -} -self.EnemyIntentIndex = 1 -self.CombatOver = false -``` - -그리고 `StartCombat` 끝(`self:StartPlayerTurn()` 직전)에 `self:RenderCombat()` 추가. - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(B): 플레이어/적 전투 상태 속성·초기화 추가" -``` - ---- - -### Task 3: 전투 헬퍼 메서드 (데미지/적턴/승패/렌더) - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` methods 배열에 신규 메서드 추가) - -`SetText`는 엔티티 nil 가드가 있어, 참조하는 UI가 Task 5에서 생성되기 전이어도 안전(no-op). - -- [ ] **Step 1: 신규 메서드들을 methods 배열에 추가 (`Toast` 메서드 정의 뒤)** - -```js - method('DealDamageToEnemy', `local dmg = amount -if self.EnemyBlock > 0 then - local absorbed = math.min(self.EnemyBlock, dmg) - self.EnemyBlock = self.EnemyBlock - absorbed - dmg = dmg - absorbed -end -self.EnemyHp = self.EnemyHp - dmg -if self.EnemyHp < 0 then - self.EnemyHp = 0 -end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), - method('DealDamageToPlayer', `local dmg = amount -if self.PlayerBlock > 0 then - local absorbed = math.min(self.PlayerBlock, dmg) - self.PlayerBlock = self.PlayerBlock - absorbed - dmg = dmg - absorbed -end -self.PlayerHp = self.PlayerHp - dmg -if self.PlayerHp < 0 then - self.PlayerHp = 0 -end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), - method('EnemyTurn', `self.EnemyBlock = 0 -local intent = self.EnemyIntents[self.EnemyIntentIndex] -if intent ~= nil then - if intent.kind == "Attack" then - self:DealDamageToPlayer(intent.value) - elseif intent.kind == "Defend" then - self.EnemyBlock = self.EnemyBlock + intent.value - end -end -self.EnemyIntentIndex = self.EnemyIntentIndex + 1 -if self.EnemyIntentIndex > #self.EnemyIntents then - self.EnemyIntentIndex = 1 -end -self:RenderCombat()`), - method('CheckCombatEnd', `if self.EnemyHp <= 0 then - self.CombatOver = true - self:ShowResult("승리!") - -- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점 -elseif self.PlayerHp <= 0 then - self.CombatOver = true - self:ShowResult("패배...") -end`), - method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text) -local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result") -if entity ~= nil then - entity.Enable = true -end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]), - method('RenderCombat', `self:SetText("/ui/DefaultGroup/CombatHud/EnemyName", self.EnemyName) -self:SetText("/ui/DefaultGroup/CombatHud/EnemyHp", "HP " .. tostring(self.EnemyHp) .. "/" .. tostring(self.EnemyMaxHp)) -self:SetText("/ui/DefaultGroup/CombatHud/EnemyBlock", "방어 " .. tostring(self.EnemyBlock)) -local intent = self.EnemyIntents[self.EnemyIntentIndex] -local intentText = "" -if intent ~= nil then - if intent.kind == "Attack" then - intentText = "의도: 공격 " .. tostring(intent.value) - elseif intent.kind == "Defend" then - intentText = "의도: 방어 " .. tostring(intent.value) - end -end -self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText) -self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. tostring(self.PlayerHp) .. "/" .. tostring(self.PlayerMaxHp)) -self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. tostring(self.PlayerBlock))`), -``` - -- [ ] **Step 2: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(B): 데미지/적턴/승패/전투렌더 헬퍼 메서드 추가" -``` - ---- - -### Task 4: 턴 흐름 배선 (PlayCard 효과·EndPlayerTurn·StartPlayerTurn) - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`StartPlayerTurn`, `EndPlayerTurn`, `PlayCard` 메서드 코드) - -- [ ] **Step 1: `StartPlayerTurn` 교체** - -```lua -self.Turn = self.Turn + 1 -self.Energy = self.MaxEnergy -self.PlayerBlock = 0 -self:DrawCards(5) -self:RenderHand(true) -self:RenderCombat() -``` - -- [ ] **Step 2: `EndPlayerTurn` 교체** - -```lua -if self.CombatOver == true then - return -end -for i = 1, #self.Hand do - table.insert(self.DiscardPile, self.Hand[i]) -end -self.Hand = {} -self:RenderHand(false) -self:RenderPiles() -self:EnemyTurn() -self:CheckCombatEnd() -if self.CombatOver == true then - return -end -_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45) -``` - -- [ ] **Step 3: `PlayCard` 효과 분기 교체** - -`PlayCard` 코드를 아래로 교체(에너지 차감 후 Toast 대신 효과 적용): - -```lua -if self.CombatOver == true then - return -end -if self.Hand == nil then - return -end -local cardId = self.Hand[slot] -if cardId == nil then - return -end -local c = self.Cards[cardId] -if c == nil then - return -end -if self.Energy < c.cost then - self:Toast("에너지가 부족합니다") - return -end -self.Energy = self.Energy - c.cost -if c.kind == "Attack" then - if c.damage ~= nil then - self:DealDamageToEnemy(c.damage) - end -elseif c.kind == "Skill" then - if c.block ~= nil then - self.PlayerBlock = self.PlayerBlock + c.block - end -end -table.remove(self.Hand, slot) -table.insert(self.DiscardPile, cardId) -self:RenderHand(false) -self:RenderPiles() -self:RenderCombat() -self:CheckCombatEnd() -``` - -- [ ] **Step 4: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 5: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(B): PlayCard 효과 분기·적턴·승패 턴흐름 배선" -``` - ---- - -### Task 5: CombatHud UI 엔티티 생성 - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`: 정리 필터 확장 + CombatHud 그룹 생성) - -- [ ] **Step 1: 정리 필터 확장** - -`upsertUi()` 시작부의 필터를 CombatHud도 제거하도록 교체: - -```js - ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud')); -``` - -- [ ] **Step 2: DeckHud `hud` push 직후, CombatHud 엔티티 생성 블록 추가** - -`ui.ContentProto.Entities.push(...hud);` 직전에 아래 블록 삽입(헬퍼 `entity`/`transform`/`sprite`/`text`/`guid` 재사용): - -```js - const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 }; - const combat = []; - combat.push(entity({ - id: guid('cmb', 0), - path: '/ui/DefaultGroup/CombatHud', - 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.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), - sprite({ color: TRANSPARENT }), - ], - })); - // 적 패널 배경 - combat.push(entity({ - id: guid('cmb', 1), - path: '/ui/DefaultGroup/CombatHud/EnemyBg', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 0, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 170 }, pos: { x: 0, y: 300 }, align: ALIGN_CENTER }), - sprite({ color: PANEL_BG, type: 1 }), - ], - })); - const enemyTexts = [ - ['EnemyName', { x: 0, y: 58 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD, 1], - ['EnemyHp', { x: 0, y: 16 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }, 2], - ['EnemyBlock', { x: 0, y: -20 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }, 3], - ['EnemyIntent', { x: 0, y: -56 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }, 4], - ]; - let cmbN = 2; - for (const [suffix, pos, size, value, fontSize, bold, color] of enemyTexts) { - combat.push(entity({ - id: guid('cmb', cmbN++), - path: `/ui/DefaultGroup/CombatHud/${suffix}`, - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: enemyTexts.findIndex(([s]) => s === suffix) + 1, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }), - sprite({ color: TRANSPARENT }), - text({ value, fontSize, bold, color }), - ], - })); - } - // 플레이어 패널 배경 + 텍스트 - combat.push(entity({ - id: guid('cmb', cmbN++), - path: '/ui/DefaultGroup/CombatHud/PlayerBg', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 5, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 110 }, pos: { x: -760, y: -260 }, align: ALIGN_CENTER }), - sprite({ color: PANEL_BG, type: 1 }), - ], - })); - const playerTexts = [ - ['PlayerHp', { x: -760, y: -238 }, { x: 280, y: 44 }, 'HP 80/80', 26, true, { r: 1, g: 1, b: 1, a: 1 }], - ['PlayerBlock', { x: -760, y: -284 }, { x: 280, y: 38 }, '방어 0', 22, false, { r: 0.6, g: 0.8, b: 1, a: 1 }], - ]; - for (const [suffix, pos, size, value, fontSize, bold, color] of playerTexts) { - combat.push(entity({ - id: guid('cmb', cmbN++), - path: `/ui/DefaultGroup/CombatHud/${suffix}`, - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 6 + playerTexts.findIndex(([s]) => s === suffix), - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }), - sprite({ color: TRANSPARENT }), - text({ value, fontSize, bold, color }), - ], - })); - } - // 결과 텍스트 (기본 숨김) - const result = entity({ - id: guid('cmb', cmbN++), - path: '/ui/DefaultGroup/CombatHud/Result', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 8, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }), - sprite({ color: TRANSPARENT }), - text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }), - ], - }); - result.jsonString.enable = false; - combat.push(result); - ui.ContentProto.Entities.push(...combat); -``` - -`guid` 프리픽스 `'cmb'`를 위해 `guid()`의 ns 매핑에 분기 추가: - -```js - const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : 0xfe; -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(B): CombatHud(적/플레이어 패널·결과) UI 엔티티 생성" -``` - ---- - -### Task 6: 재생성 + 검증 - -**Files:** 생성물 3종 (생성기 실행 결과) - -- [ ] **Step 1: 생성기 실행** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 생성물 JSON 유효성 확인** - -Run: `node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); console.log('JSON OK')"` -Expected: `JSON OK` - -- [ ] **Step 3: 결정성 확인 (2회 실행 동일)** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: CombatHud 엔티티·전투 메서드 생성 확인** - -Run: `grep -c "CombatHud" ui/DefaultGroup.ui; grep -c "DealDamageToEnemy\|EnemyTurn\|RenderCombat" RootDesk/MyDesk/SlayDeckController.codeblock` -Expected: 두 값 모두 > 0 - -- [ ] **Step 5: 의도한 파일만 변경됐는지 확인** - -Run: `git status --short` -Expected: `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (그리고 필요 시 `Global/common.gamelogic`)만 변경. - -- [ ] **Step 6: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic -git commit -m "재생성(B): 카드 전투 통합 — 적/플레이어 전투 상태·CombatHud 반영" -``` - -- [ ] **Step 7: 메이커 Play 수동 검증 (사용자)** - -메이커에서 로컬 워크스페이스 reload 후 Play: -- 타격 카드 클릭 → 적 HP 감소(적 Block 있으면 먼저 차감). -- 방어 카드 클릭 → 플레이어 `방어` 수치 증가. -- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어, 다음 의도 갱신. -- 적 HP 0 → "승리!" 표시·입력 잠금 / 플레이어 HP 0 → "패배..." 표시·입력 잠금. - ---- - -## Self-Review - -- **Spec coverage:** 전투 상태(Task 2), 카드 수치화(Task 1), 효과 분기(Task 4), 적 의도·적 턴(Task 3·4), 승패(Task 3·4), UI 노출(Task 5) — 스펙 5개 절 모두 태스크로 매핑됨. 검증은 Task 6. -- **Placeholder scan:** 모든 코드 단계에 실제 코드 포함. "TODO(E)"는 의도된 미래 훅 주석(스펙 명시)으로 placeholder 아님. -- **Type consistency:** UI 경로(`/ui/DefaultGroup/CombatHud/EnemyHp` 등)가 codeblock `RenderCombat`/`ShowResult`와 Task 5 생성 경로에서 동일. 메서드명(`DealDamageToEnemy`/`DealDamageToPlayer`/`EnemyTurn`/`CheckCombatEnd`/`ShowResult`/`RenderCombat`)이 호출부(Task 4)와 정의부(Task 3)에서 일치. 카드 필드(`damage`/`block`/`kind`)가 Cards 정의(Task 1)와 PlayCard 사용(Task 4)에서 일치. diff --git a/docs/superpowers/plans/2026-06-08-deck-controller-fixes.md b/docs/superpowers/plans/2026-06-08-deck-controller-fixes.md deleted file mode 100644 index 166a5ff..0000000 --- a/docs/superpowers/plans/2026-06-08-deck-controller-fixes.md +++ /dev/null @@ -1,256 +0,0 @@ -# 덱 컨트롤러 코드리뷰 수정 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:** 코드리뷰 6건(①self바인딩 ②Card5통일 ③카드클릭=사용 ④카드데이터단일화 ⑤매직넘버 ⑥pcall)을 `tools/gen-slaydeck.mjs`에서 수정·재생성한다. - -**Architecture:** 모든 산출물(카드 UI·DeckHud·`SlayDeckController.codeblock`·`common.gamelogic`)을 생성하는 `tools/gen-slaydeck.mjs` 단일 소스를 수정하고 재실행한다. DRY는 카드 정의를 codeblock의 `self.Cards` 테이블 프로퍼티로 단일화하고, 카드 클릭은 카드 엔티티에 `ButtonComponent`를 추가한 뒤 `PlayCard(slot)` 메서드를 클로저로 연결해 구현한다. - -**Tech Stack:** Node.js 생성기, MSW codeblock(MapleScript/Lua), msw-maker-mcp(검증). - ---- - -## File Structure -- Modify: `tools/gen-slaydeck.mjs` — 모든 수정의 단일 소스. -- 재생성(출력): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`. - -기준: codeblock 메서드는 `method('Name', ``, [args])`로 정의되고 끝에서 전부 `ExecSpace=6`로 설정됨. 카드 엔티티(Card1~5)는 `upsertUi`의 루프가 스타일링함. `button()` 헬퍼 존재. - ---- - -### Task 1: 생성기 수정 (① ③ ④ ⑥ + ⑤ 일부) - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: 카드에 ButtonComponent + raycast 추가 (③ 클릭 가능)** - -`upsertUi`의 카드 루프에서 `sp.Color = cards[i - 1].tint;` 줄 바로 다음에 아래를 추가: -```js - sp.RaycastTarget = true; - const comps = card.jsonString['@components']; - if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) { - comps.push(button()); - } - if (!card.componentNames.includes('MOD.Core.ButtonComponent')) { - card.componentNames += ',MOD.Core.ButtonComponent'; - } -``` - -- [ ] **Step 2: `Cards` 프로퍼티 추가 (④ 단일화 준비)** - -`writeCodeblocks`의 properties 배열(`prop('any', 'EndTurnHandler')` 가 있는 배열)에 항목 추가: -```js - prop('any', 'Cards'), -``` - -- [ ] **Step 3: StartCombat 메서드 교체 (④ 카드 테이블 정의)** - -`method('StartCombat', ...)` 의 Lua 본문을 아래로 교체: -``` -self.MaxEnergy = 3 -self.Turn = 0 -self.DiscardPile = {} -self.Hand = {} -self.Cards = { - Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack" }, - Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill" }, - Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack" }, -} -self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" } -self:Shuffle(self.DrawPile) -self:BindButtons() -self:StartPlayerTurn() -``` - -- [ ] **Step 4: BindButtons 교체 (① 클로저 + ③ 카드 클릭 바인딩)** - -`method('BindButtons', ...)` 의 Lua 본문을 아래로 교체: -``` -local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton") -if endTurn ~= nil and endTurn.ButtonComponent ~= nil then - if self.EndTurnHandler ~= nil then - endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler) - self.EndTurnHandler = nil - end - self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end) -end -for i = 1, 5 do - local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) - if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then - cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) - end -end -``` - -- [ ] **Step 5: ApplyCardVisual 교체 (④ self.Cards 사용 + ⑥ pcall 제거)** - -`method('ApplyCardVisual', ...)` 의 Lua 본문을 아래로 교체(인자 slot, cardId 유지): -``` -local c = self.Cards[cardId] -if c == nil then - c = { name = cardId, cost = 0, desc = "", kind = "Skill" } -end -self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Cost", tostring(c.cost)) -self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Name", c.name) -self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Desc", c.desc) -local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) -if cardEntity ~= nil and cardEntity.SpriteGUIRendererComponent ~= nil then - if c.kind == "Attack" then - cardEntity.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) - elseif c.kind == "Skill" then - cardEntity.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) - else - cardEntity.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) - end -end -``` - -- [ ] **Step 6: PlayCard + Toast 메서드 추가 (③)** - -`method('AnimateCardFrom', ...)` 항목 다음(메서드 배열 안)에 두 메서드를 추가: -```js - method('PlayCard', `if self.Hand == nil then - return -end -local cardId = self.Hand[slot] -if cardId == nil then - return -end -local c = self.Cards[cardId] -if c == nil then - return -end -if self.Energy < c.cost then - self:Toast("에너지가 부족합니다") - return -end -self.Energy = self.Energy - c.cost -self:Toast(c.name .. " — " .. c.desc) -table.remove(self.Hand, slot) -table.insert(self.DiscardPile, cardId) -self:RenderHand(false) -self:RenderPiles()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), - method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]), -``` - -(⑤: 손패/슬롯 수 5는 UI 카드 엔티티가 정확히 5개라 고정값으로 둠 — 별도 상수 불필요. 시작 에너지/MaxEnergy는 이미 프로퍼티.) - -- [ ] **Step 7: 구문 확인 + 커밋** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node --check tools/gen-slaydeck.mjs -git add tools/gen-slaydeck.mjs -git commit -m "덱 컨트롤러 생성기: 핸들러 클로저화·카드데이터 단일화·카드클릭 사용·pcall 제거" -``` -Expected: `node --check` 무출력(exit 0). - ---- - -### Task 2: 재생성 + 데이터 검증 - -**Files:** Modify `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic` - -- [ ] **Step 1: 재생성** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node tools/gen-slaydeck.mjs -``` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: codeblock 검증** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const ms=j.ContentProto.Json.Methods;const names=ms.map(m=>m.Name);console.log('has PlayCard:',names.includes('PlayCard'));console.log('has Toast:',names.includes('Toast'));const bind=ms.find(m=>m.Name==='BindButtons').Code;console.log('endturn closure:',bind.includes('function() self:EndPlayerTurn() end'));console.log('card click bind:',bind.includes('function() self:PlayCard(i) end'));const av=ms.find(m=>m.Name==='ApplyCardVisual').Code;console.log('no pcall:',!av.includes('pcall'));console.log('uses self.Cards:',av.includes('self.Cards[cardId]'));const sc=ms.find(m=>m.Name==='StartCombat').Code;console.log('Cards table:',sc.includes('self.Cards ='))" -``` -Expected: 모두 `true`. - -- [ ] **Step 3: UI 검증 (카드 버튼 + Card5 통일)** - -```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;let okBtn=true,okImg=true;for(let i=1;i<=5;i++){const c=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card'+i);if(!c){okBtn=false;continue;}if(!(c.componentNames||'').includes('MOD.Core.ButtonComponent'))okBtn=false;const sp=c.jsonString['@components'].find(x=>x['@type']==='MOD.Core.SpriteGUIRendererComponent');if(sp.ImageRUID.DataId!=='')okImg=false;}const c5=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card5');const hasDesc=E.some(e=>e.path==='/ui/DefaultGroup/CardHand/Card5/Desc');console.log('all cards have Button:',okBtn);console.log('all cards no image (uniform):',okImg);console.log('Card5 has Desc child:',hasDesc)" -``` -Expected: `all cards have Button: true`, `all cards no image (uniform): true`, `Card5 has Desc child: true`. - -- [ ] **Step 4: JSON 유효성 + 커밋** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));JSON.parse(require('fs').readFileSync('Global/common.gamelogic','utf8'));console.log('JSON ok')" -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic -git commit -m "재생성: 카드 클릭 사용·균일 카드·핸들러 수정 반영" -``` -Expected: `JSON ok`. - ---- - -### Task 3: Maker Play 검증 (컨트롤러) - -**Files:** 없음 - -- [ ] **Step 1: reload**: `maker_refresh_workspace`. -- [ ] **Step 2: 시작 맵 활성화 확인**: `maker_get_current_map`. (어느 맵이든 카드 UI는 전역이라 표시됨) -- [ ] **Step 3: play**: `maker_play`. -- [ ] **Step 4: 클릭 시뮬레이션 + 상태 확인**: `maker_execute_script`(client)로 PlayCard 직접 호출해 동작 확인: - ```lua - local ctrl = _EntityService:GetEntityByPath("/common") - -- 초기 상태 - local c = ctrl.SlayDeckController - log("BEFORE energy="..tostring(c.Energy).." hand="..tostring(#c.Hand).." discard="..tostring(#c.DiscardPile)) - c:PlayCard(1) - log("AFTER energy="..tostring(c.Energy).." hand="..tostring(#c.Hand).." discard="..tostring(#c.DiscardPile)) - ``` - → `maker_logs(normal)`에서 카드 사용 후 energy 감소·hand 감소·discard 증가 확인. (또는 `maker_mouse_input`으로 카드 클릭) -- [ ] **Step 5: screenshot**: `maker_screenshot` → Read로 5장 균일·DeckHud(에너지/덱 카운트) 확인. -- [ ] **Step 6: stop**: `maker_stop`. - -문제 시: 핸들러 self·PlayCard 동작 로그로 진단 후 Task 1 수정·재생성. - ---- - -### Task 4: stash 복구 + 무결성 검증 - -**Files:** `map/map02.map`, `map/map05.map`, `map/map06.map`, `map/map07.map`, `map/map10.map`, `map/map11.map` (복구 대상) - -- [ ] **Step 1: stash 적용** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -git stash list -git stash apply 2>&1 | head -20 -``` -(충돌 시 해당 파일은 main 버전 유지하고 stash 변경만 수동 반영하거나, 무의미하면 제외 — 아래 검증으로 판단) - -- [ ] **Step 2: 무결성 검증 (몬스터/타일셋 유지 확인)** - -```bash -cd "C:/Users/jaeoh/Desktop/workspace/slaymaple" -node -e "const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];for(const t of ['02','05','06','07','10','11']){const j=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);const okNoOld=sprs.every(s=>!old.includes(s));const ts=E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId;console.log('map'+t,'monsters='+ms.length,'noOldSprite='+okNoOld,'tileset='+(ts!=='9dfea3808bbd49a5877d8624df21b1c7'))}" -``` -Expected: 각 맵 `monsters=2`, `noOldSprite=true`, `tileset=true`. (= 몬스터/타일셋 작업 유지됨) - -- [ ] **Step 3: 판정 및 커밋** - -- 무결성 OK → 복구분 커밋: - ```bash - git add map/map02.map map/map05.map map/map06.map map/map07.map map/map10.map map/map11.map - git commit -m "Maker 세션 재저장분(맵 02/05/06/07/10/11) 복구 포함" - git stash drop - ``` -- 무결성 실패(작업 되돌려짐/손상) → 복구 취소하고 사용자에게 보고: - ```bash - git checkout -- map/map02.map map/map05.map map/map06.map map/map07.map map/map10.map map/map11.map - ``` - (stash는 보존) - ---- - -## 검증 요약 -- 생성기 `node --check` 통과 -- codeblock: PlayCard/Toast 존재, EndTurn·카드클릭 클로저, self.Cards 사용, pcall 없음 -- UI: Card1~5 ButtonComponent+raycast, 5장 균일(이미지 없음·Desc 존재) -- Maker Play: PlayCard 호출 시 energy↓·hand↓·discard↑, 5장 균일 렌더 -- stash 복구분 무결성(몬스터2·old미사용·타일셋교체) 검증 후 포함 diff --git a/docs/superpowers/plans/2026-06-09-balance-simulator.md b/docs/superpowers/plans/2026-06-09-balance-simulator.md deleted file mode 100644 index d798dbe..0000000 --- a/docs/superpowers/plans/2026-06-09-balance-simulator.md +++ /dev/null @@ -1,475 +0,0 @@ -# AI 전투 시뮬레이터 (TODO F) 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:** `data/*.json`을 입력으로 전투를 몬테카를로 N회 시뮬레이션해 승률·턴·OP 카드 리포트를 출력하는 오프라인 CLI `tools/sim-balance.mjs`. - -**Architecture:** 순수 함수(PRNG·applyDamage·chooseAction·simulateCombat·runBatch)로 분리해 `node:test`로 단위 테스트. CLI main은 직접 실행 시에만 동작. 전투 규칙은 gen-slaydeck.mjs의 Lua를 JS로 미러, 데이터는 D의 JSON 공유. - -**Tech Stack:** Node.js ESM, `node:test`+`node:assert`. 검증은 단위 테스트 + CLI 실행 + 결정성 + 데이터 반영. - ---- - -## File Structure - -- Create: `tools/sim-balance.mjs` — 시뮬레이터(엔진·정책·집계·리포트·CLI). 순수 함수 export. -- Create: `tools/sim-balance.test.mjs` — 단위 테스트(node:test). - -전투 규칙은 `tools/gen-slaydeck.mjs` Lua와 중복 → 파일 상단 동기화 주석. - ---- - -### Task 1: PRNG·applyDamage·loadData (기반 순수 함수) - -**Files:** -- Create: `tools/sim-balance.mjs` -- Create: `tools/sim-balance.test.mjs` - -- [ ] **Step 1: 테스트 작성 `tools/sim-balance.test.mjs`** - -```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mulberry32, applyDamage } from './sim-balance.mjs'; - -test('applyDamage: 방어 우선 차감 후 hp', () => { - assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 }); - assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 }); - assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 }); - assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 }); -}); - -test('mulberry32: 동일 시드 동일 수열', () => { - const a = mulberry32(1), b = mulberry32(1); - assert.equal(a(), b()); - assert.equal(a(), b()); -}); -``` - -- [ ] **Step 2: 테스트 실패 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: FAIL (`Cannot find module './sim-balance.mjs'` 또는 export 없음) - -- [ ] **Step 3: `tools/sim-balance.mjs` 작성(기반부)** - -```js -// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로. -// ⚠️ 전투 규칙은 tools/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것. -import { readFileSync } from 'node:fs'; - -export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치) -export const ENERGY = 3; -export const HAND_SIZE = 5; -export const MAX_TURNS = 100; - -export function mulberry32(seed) { - let a = seed >>> 0; - return function () { - a |= 0; a = (a + 0x6D2B79F5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -export function shuffle(arr, rng) { - const a = arr.slice(); - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]]; - } - return a; -} - -// 방어 우선 차감 후 hp 적용 → { hp, block } -export function applyDamage(hp, block, amount) { - let dmg = amount; - if (block > 0) { - const absorbed = Math.min(block, dmg); - block -= absorbed; - dmg -= absorbed; - } - hp -= dmg; - if (hp < 0) hp = 0; - return { hp, block }; -} - -export function loadData() { - const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8')); - const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8')); - const enemy = enemiesData.enemies[enemiesData.activeEnemy]; - if (!enemy) throw new Error(`activeEnemy 없음: ${enemiesData.activeEnemy}`); - return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, enemy }; -} -``` - -- [ ] **Step 4: 테스트 통과 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: PASS (2 tests) - -- [ ] **Step 5: 커밋** - -```bash -git add tools/sim-balance.mjs tools/sim-balance.test.mjs -git commit -m "sim-balance(F): PRNG·applyDamage·loadData 기반 함수 + 테스트" -``` - ---- - -### Task 2: chooseAction 정책 (휴리스틱 A) - -**Files:** -- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs` - -- [ ] **Step 1: 테스트 추가 (test.mjs 하단)** - -```js -import { chooseAction } from './sim-balance.mjs'; - -const CARDS = { - Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 }, - Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 }, - Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 }, -}; - -test('chooseAction: 치사 가능하면 공격 선택', () => { - // 적 hp 5, block 0, 손패 Strike(6) → 공격(인덱스 0) - const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 }); - assert.equal(idx, 0); -}); - -test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => { - // 적 hp 40(이번 턴 못 죽임), 의도 공격 → Defend(인덱스 1) - const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 }); - assert.equal(idx, 1); -}); - -test('chooseAction: 적 방어 의도면 공격 우선', () => { - const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 }); - assert.equal(idx, 1); -}); - -test('chooseAction: 사용 가능 카드 없으면 -1', () => { - const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 }); - assert.equal(idx, -1); -}); -``` - -- [ ] **Step 2: 테스트 실패 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: FAIL (`chooseAction is not a function`) - -- [ ] **Step 3: 구현 추가 (sim-balance.mjs)** - -```js -// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열. -export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) { - const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy); - const attacks = entries.filter((x) => cards[x.id].kind === 'Attack'); - const skills = entries.filter((x) => cards[x.id].kind === 'Skill'); - const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost; - const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost; - const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0]; - - // 1) 치사: 에너지 한도 내 효율순 공격 데미지 합 >= 적 유효 hp? - let e = energy, lethalDmg = 0; - for (const x of attacks.slice().sort((a, b) => dmgEff(b) - dmgEff(a))) { - if (cards[x.id].cost <= e) { e -= cards[x.id].cost; lethalDmg += cards[x.id].damage || 0; } - } - if (attacks.length && lethalDmg >= enemyHp + enemyBlock) return bestBy(attacks, dmgEff).i; - - // 2) 적 공격 의도면 방어 우선 - if (enemyIntent && enemyIntent.kind === 'Attack' && skills.length) return bestBy(skills, blkEff).i; - - // 3) 공격 우선, 없으면 스킬, 없으면 종료 - if (attacks.length) return bestBy(attacks, dmgEff).i; - if (skills.length) return bestBy(skills, blkEff).i; - return -1; -} -``` - -- [ ] **Step 4: 테스트 통과 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: PASS (6 tests) - -- [ ] **Step 5: 커밋** - -```bash -git add tools/sim-balance.mjs tools/sim-balance.test.mjs -git commit -m "sim-balance(F): 플레이어 휴리스틱 정책 chooseAction + 테스트" -``` - ---- - -### Task 3: simulateCombat 엔진 - -**Files:** -- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs` - -- [ ] **Step 1: 테스트 추가** - -```js -import { simulateCombat, mulberry32 as m32 } from './sim-balance.mjs'; - -const DATA = { - cards: { - Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 }, - Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 }, - Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 }, - }, - starterDeck: ['Strike','Strike','Strike','Strike','Strike','Defend','Defend','Defend','Defend','Bash'], - enemy: { name: '슬라임', maxHp: 45, intents: [ - { kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', value: 8 }, - ] }, -}; - -test('simulateCombat: 결정적 결과(동일 시드)', () => { - const r1 = simulateCombat(DATA, m32(1)); - const r2 = simulateCombat(DATA, m32(1)); - assert.deepEqual(r1, r2); - assert.equal(typeof r1.win, 'boolean'); - assert.ok(r1.turns >= 1); -}); - -test('simulateCombat: 약한 적이면 대체로 승리', () => { - let wins = 0; - for (let i = 0; i < 50; i++) if (simulateCombat(DATA, m32(i + 1)).win) wins++; - assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`); -}); -``` - -- [ ] **Step 2: 테스트 실패 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: FAIL (`simulateCombat is not a function`) - -- [ ] **Step 3: 구현 추가 (sim-balance.mjs)** - -```js -function bump(s, cost, dmg, blk) { - s = s || { plays: 0, energy: 0, damage: 0, block: 0 }; - s.plays++; s.energy += cost; s.damage += dmg; s.block += blk; - return s; -} - -// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적. -// 반환: { win, turns, playerHpRemaining, draw? } -export function simulateCombat(data, rng, stats) { - const { cards, starterDeck, enemy } = data; - let drawPile = shuffle(starterDeck, rng); - let discard = []; - let hand = []; - let pHp = PLAYER_HP, pBlock = 0; - let eHp = enemy.maxHp, eBlock = 0, intentIdx = 0; - let turns = 0; - - function draw(n) { - for (let k = 0; k < n; k++) { - if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; } - if (drawPile.length === 0) break; - hand.push(drawPile.pop()); - } - } - - while (turns < MAX_TURNS) { - turns++; - let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); - while (true) { - const intent = enemy.intents[intentIdx]; - const idx = chooseAction(hand, cards, energy, eHp, eBlock, intent); - if (idx < 0) break; - const id = hand[idx], c = cards[id]; - energy -= c.cost; - if (c.kind === 'Attack') { - const r = applyDamage(eHp, eBlock, c.damage || 0); eHp = r.hp; eBlock = r.block; - if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0); - } else { - pBlock += c.block || 0; - if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0); - } - hand.splice(idx, 1); discard.push(id); - if (eHp <= 0) return { win: true, turns, playerHpRemaining: pHp }; - } - discard.push(...hand); hand = []; - eBlock = 0; - const intent = enemy.intents[intentIdx]; - if (intent.kind === 'Attack') { const r = applyDamage(pHp, pBlock, intent.value); pHp = r.hp; pBlock = r.block; } - else if (intent.kind === 'Defend') { eBlock += intent.value; } - intentIdx = (intentIdx + 1) % enemy.intents.length; - if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; - } - return { win: false, turns, playerHpRemaining: pHp, draw: true }; -} -``` - -- [ ] **Step 4: 테스트 통과 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: PASS (8 tests) - -- [ ] **Step 5: 커밋** - -```bash -git add tools/sim-balance.mjs tools/sim-balance.test.mjs -git commit -m "sim-balance(F): 단일 전투 시뮬 엔진 simulateCombat + 테스트" -``` - ---- - -### Task 4: runBatch·리포트·OP 탐지·CLI - -**Files:** -- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs` - -- [ ] **Step 1: 테스트 추가** - -```js -import { runBatch } from './sim-balance.mjs'; - -test('runBatch: 집계 필드·승률 범위', () => { - const r = runBatch(100, 1); - assert.equal(r.N, 100); - assert.ok(r.winRate >= 0 && r.winRate <= 1); - assert.ok(r.avgTurns > 0); - assert.ok(r.cardStats.Strike.plays > 0); -}); - -test('runBatch: 동일 시드 동일 결과', () => { - assert.deepEqual(runBatch(100, 7), runBatch(100, 7)); -}); -``` - -- [ ] **Step 2: 테스트 실패 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: FAIL (`runBatch is not a function`) - -- [ ] **Step 3: 구현 추가 (sim-balance.mjs)** - -```js -function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; } -function median(a) { - if (!a.length) return 0; - const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2); - return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; -} - -export function runBatch(N, seed) { - const data = loadData(); - const rng = mulberry32(seed); - const cardStats = {}; - let wins = 0, draws = 0; - const turnsArr = [], hpArr = []; - for (let i = 0; i < N; i++) { - const r = simulateCombat(data, rng, cardStats); - if (r.draw) draws++; - if (r.win) { wins++; hpArr.push(r.playerHpRemaining); } - turnsArr.push(r.turns); - } - return { - N, wins, draws, losses: N - wins - draws, - winRate: wins / N, - avgTurns: mean(turnsArr), medianTurns: median(turnsArr), - avgHpOnWin: mean(hpArr), - cardStats, cards: data.cards, enemy: data.enemy, seed, - }; -} - -export function formatReport(r) { - const L = []; - L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`); - L.push(`시뮬 ${r.N}회 (seed=${r.seed})`); - L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`); - L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`); - L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`); - if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`); - L.push(''); - L.push('카드별:'); - // 효율 계산 + kind별 중앙값으로 OP 플래그 - const rows = Object.entries(r.cardStats).map(([id, s]) => { - const kind = r.cards[id].kind; - const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy; - return { id, name: r.cards[id].name, kind, plays: s.plays, eff }; - }); - for (const kind of ['Attack', 'Skill']) { - const kr = rows.filter((x) => x.kind === kind); - if (!kr.length) continue; - const med = median(kr.map((x) => x.eff)); - for (const x of kr) { - const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : ''; - const unit = kind === 'Attack' ? '뎀/E' : '블록/E'; - L.push(` ${x.name}(${id2(x.id)}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`); - } - } - const sorted = rows.slice().sort((a, b) => b.plays - a.plays); - if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`); - return L.join('\n'); -} -function id2(id) { return id; } - -function main() { - const args = process.argv.slice(2); - let N = 2000, seed = 1; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--seed') seed = parseInt(args[++i], 10); - else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10); - } - console.log(formatReport(runBatch(N, seed))); -} - -if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main(); -``` - -- [ ] **Step 4: 테스트 통과 확인** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: PASS (10 tests) - -- [ ] **Step 5: 커밋** - -```bash -git add tools/sim-balance.mjs tools/sim-balance.test.mjs -git commit -m "sim-balance(F): runBatch·리포트·OP 탐지·CLI + 테스트" -``` - ---- - -### Task 5: 검증 (CLI 실행·결정성·데이터 반영) - -**Files:** 없음(실행 검증) - -- [ ] **Step 1: 전체 테스트** - -Run: `node --test tools/sim-balance.test.mjs` -Expected: PASS (10 tests, 0 fail) - -- [ ] **Step 2: CLI 실행 (기본)** - -Run: `node tools/sim-balance.mjs 2000` -Expected: 승률·평균턴·승리시 잔여HP·카드별 효율 리포트 출력. - -- [ ] **Step 3: 결정성 (동일 시드 동일 출력)** - -Run: `node tools/sim-balance.mjs 500 --seed 3 > /tmp/r1.txt && node tools/sim-balance.mjs 500 --seed 3 > /tmp/r2.txt && diff /tmp/r1.txt /tmp/r2.txt && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: 데이터 반영 (강타 데미지↑ → 승률·턴 변동)** - -Run: `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률` (기준값 기록) → `data/cards.json`에서 Bash.damage 10→20으로 임시 변경 → `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률`(변동 확인) → `git checkout -- data/cards.json`(원복). -Expected: 두 승률/턴 수치가 다름(데이터 반영). 원복 후 기준 복귀. - -- [ ] **Step 5: 최종 커밋(있다면 없음 — 검증 전용)** - -검증 전용 태스크. 변경 없음. `git status`로 `data/cards.json` 원복 확인. - ---- - -## Self-Review - -- **Spec coverage:** PRNG·applyDamage·loadData(Task1), 정책(Task2), 엔진(Task3), 집계·리포트·OP·CLI(Task4), 검증·데이터반영(Task5). 스펙 전 항목 매핑. -- **Placeholder scan:** 모든 단계 실제 코드/명령. 동기화 주석은 의도된 문서. -- **Type consistency:** `mulberry32/shuffle/applyDamage/loadData/chooseAction/simulateCombat/runBatch/formatReport` 시그니처가 정의(Task1·2·3·4)와 사용(테스트·CLI)에서 일치. `cardStats` 형태 `{plays,energy,damage,block}`가 `bump`·`runBatch`·`formatReport`에서 일치. 카드 필드 `kind/damage/block/cost`가 데이터·정책·엔진에서 일치. diff --git a/docs/superpowers/plans/2026-06-09-data-externalization.md b/docs/superpowers/plans/2026-06-09-data-externalization.md deleted file mode 100644 index 0ba00cf..0000000 --- a/docs/superpowers/plans/2026-06-09-data-externalization.md +++ /dev/null @@ -1,341 +0,0 @@ -# 카드/적 데이터 외부화 (TODO D) 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:** 카드·적 데이터를 `data/cards.json`·`data/enemies.json`로 분리하고, `gen-slaydeck.mjs`가 읽어 codeblock·UI에 주입한다(데이터만 바꿔 재생성하면 반영). - -**Architecture:** 신규 JSON 2개가 데이터 단일 소스. 생성기는 상단에서 JSON을 로드·검증하고, Lua 직렬화 헬퍼로 `self.Cards`/`self.DrawPile`/적 상태를 만들어 `StartCombat`에 주입한다. DeckHud 카드 미리보기·CombatHud 초기 텍스트도 동일 데이터에서 파생. - -**Tech Stack:** Node.js ESM 생성기, JSON 데이터, MSW Lua codeblock/UI JSON. 검증은 `node --check`+재생성+sha1 결정성+데이터변경 반영 확인+메이커 Play. - ---- - -## File Structure - -- Create: `data/cards.json` — 카드 정의(`cards`) + 시작 덱(`starterDeck`). -- Create: `data/enemies.json` — 적 정의(`enemies`) + 활성 적(`activeEnemy`). -- Modify: `tools/gen-slaydeck.mjs` — JSON 로드·검증·Lua 직렬화 헬퍼, `StartCombat`/`upsertUi`/속성 데이터화. - -검증 한계: MSW Lua 단위 테스트 러너 없음 → 자동 검증은 생성기 문법·재생성·결정성·데이터 반영·JSON 유효성. 실제 동작은 메이커 Play(사용자). - ---- - -### Task 1: 데이터 파일 생성 - -**Files:** -- Create: `data/cards.json` -- Create: `data/enemies.json` - -- [ ] **Step 1: `data/cards.json` 작성** - -```json -{ - "cards": { - "Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" }, - "Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" }, - "Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" } - }, - "starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"] -} -``` - -- [ ] **Step 2: `data/enemies.json` 작성** - -```json -{ - "enemies": { - "slime": { - "name": "슬라임", - "maxHp": 45, - "intents": [ - { "kind": "Attack", "value": 10 }, - { "kind": "Attack", "value": 6 }, - { "kind": "Defend", "value": 8 } - ] - } - }, - "activeEnemy": "slime" -} -``` - -- [ ] **Step 3: JSON 유효성 확인** - -Run: `node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')"` -Expected: `JSON OK` - -- [ ] **Step 4: 커밋** - -```bash -git add data/cards.json data/enemies.json -git commit -m "data(D): 카드/적 데이터 JSON 외부화 파일 추가" -``` - ---- - -### Task 2: 생성기에 JSON 로드·검증·Lua 직렬화 헬퍼 추가 - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (상단 import 직후) - -- [ ] **Step 1: 파일 상단 `import { readFileSync, writeFileSync } from 'node:fs';` 바로 다음에 추가** - -```js -const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8')); -const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8')); - -// 검증 (fail-fast): 잘못된 데이터면 생성 중단 -for (const id of CARDS.starterDeck) { - if (!CARDS.cards[id]) { - throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`); - } -} -if (!ENEMIES.enemies[ENEMIES.activeEnemy]) { - throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`); -} -const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy]; - -// Lua 직렬화 헬퍼 -function luaStr(s) { - return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; -} -function luaCardsTable(cards) { - const lines = Object.entries(cards).map(([id, c]) => { - const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`]; - if (c.damage != null) fields.push(`damage = ${c.damage}`); - if (c.block != null) fields.push(`block = ${c.block}`); - return `\t${id} = { ${fields.join(', ')} },`; - }); - return `self.Cards = {\n${lines.join('\n')}\n}`; -} -function luaDeckTable(deck) { - return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`; -} -function luaIntentsTable(intents) { - const lines = intents.map((it) => `\t{ kind = ${luaStr(it.kind)}, value = ${it.value} },`); - return `self.EnemyIntents = {\n${lines.join('\n')}\n}`; -} -function intentText(it) { - if (it.kind === 'Attack') return `의도: 공격 ${it.value}`; - if (it.kind === 'Defend') return `의도: 방어 ${it.value}`; - return ''; -} -``` - -- [ ] **Step 2: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(D): JSON 로드·검증·Lua 직렬화 헬퍼 추가" -``` - ---- - -### Task 3: StartCombat·EnemyMaxHp 속성을 데이터에서 생성 - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`prop('number', 'EnemyMaxHp', ...)`, `method('StartCombat', ...)`) - -- [ ] **Step 1: EnemyMaxHp 속성 기본값을 데이터로** - -`prop('number', 'EnemyMaxHp', '45'),` 를 아래로 교체: - -```js - prop('number', 'EnemyMaxHp', String(ACTIVE_ENEMY.maxHp)), -``` - -- [ ] **Step 2: `StartCombat` 메서드 본문을 데이터 주입형으로 교체** - -기존 `method('StartCombat', \`...\`)` 호출 전체(아래 "현재" 블록)를 "신규"로 교체. - -현재(교체 대상): -``` -self.MaxEnergy = 3 -self.Turn = 0 -self.PlayerMaxHp = 80 -self.PlayerHp = self.PlayerMaxHp -self.PlayerBlock = 0 -self.EnemyName = "슬라임" -self.EnemyMaxHp = 45 -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -self.EnemyIntents = { - { kind = "Attack", value = 10 }, - { kind = "Attack", value = 6 }, - { kind = "Defend", value = 8 }, -} -self.EnemyIntentIndex = 1 -self.CombatOver = false -self.DiscardPile = {} -self.Hand = {} -self.Cards = { - Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 }, - Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 }, - Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 }, -} -self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" } -self:Shuffle(self.DrawPile) -self:BindButtons() -self:RenderCombat() -self:StartPlayerTurn() -``` - -신규 — `method('StartCombat', ...)`의 코드 인자를 템플릿으로 생성: -```js - method('StartCombat', `self.MaxEnergy = 3 -self.Turn = 0 -self.PlayerMaxHp = 80 -self.PlayerHp = self.PlayerMaxHp -self.PlayerBlock = 0 -self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)} -self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp} -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -${luaIntentsTable(ACTIVE_ENEMY.intents)} -self.EnemyIntentIndex = 1 -self.CombatOver = false -self.DiscardPile = {} -self.Hand = {} -${luaCardsTable(CARDS.cards)} -${luaDeckTable(CARDS.starterDeck)} -self:Shuffle(self.DrawPile) -self:BindButtons() -self:RenderCombat() -self:StartPlayerTurn()`), -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(D): StartCombat·EnemyMaxHp를 데이터에서 생성" -``` - ---- - -### Task 4: DeckHud 카드 미리보기·CombatHud 초기 텍스트를 데이터에서 파생 - -**Files:** -- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`의 `cards` 배열, `enemyTexts` 초기값) - -- [ ] **Step 1: `upsertUi`의 카드 미리보기 배열을 데이터 파생으로 교체** - -기존: -```js - 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 }, - ]; -``` -교체: -```js - const cards = CARDS.starterDeck.slice(0, 5).map((id) => { - const c = CARDS.cards[id]; - return { name: c.name, cost: String(c.cost), desc: c.desc, tint: c.kind === 'Attack' ? ATTACK : DEFEND }; - }); -``` - -- [ ] **Step 2: CombatHud `enemyTexts` 초기값을 데이터에서 파생** - -기존: -```js - const enemyTexts = [ - ['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD], - ['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }], - ['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }], - ['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }], - ]; -``` -교체: -```js - const enemyTexts = [ - ['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, ACTIVE_ENEMY.name, 28, true, GOLD], - ['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, `HP ${ACTIVE_ENEMY.maxHp}/${ACTIVE_ENEMY.maxHp}`, 24, true, { r: 1, g: 1, b: 1, a: 1 }], - ['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }], - ['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, intentText(ACTIVE_ENEMY.intents[0]), 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }], - ]; -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(D): 카드 미리보기·CombatHud 초기 텍스트 데이터 파생" -``` - ---- - -### Task 5: 재생성 + 검증 - -**Files:** 생성물 3종 (생성기 실행 결과) - -- [ ] **Step 1: 생성기 실행** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 생성물이 B와 동치인지 — codeblock에 데이터 값이 반영됐는지 확인** - -Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\".*damage = 6/.test(sc) && /슬라임/.test(sc) && /value = 10/.test(sc) ? 'DATA INJECTED OK' : 'MISMATCH')"` -Expected: `DATA INJECTED OK` - -- [ ] **Step 3: 결정성 확인** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: 데이터 변경이 반영되는지 확인 (D의 핵심 검증)** - -Run: `node -e "const fs=require('fs'); const f='data/cards.json'; const o=JSON.parse(fs.readFileSync(f,'utf8')); o.cards.Strike.damage=9; o.cards.Strike.desc='피해 9'; fs.writeFileSync(f, JSON.stringify(o,null,2));" && node tools/gen-slaydeck.mjs >/dev/null && node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\", cost = 1, desc = \"피해 9\", kind = \"Attack\", damage = 9/.test(sc) ? 'CHANGE REFLECTED' : 'NOT REFLECTED')"` -Expected: `CHANGE REFLECTED` - -- [ ] **Step 5: 변경 되돌리고 재생성 (원복)** - -Run: `git checkout -- data/cards.json && node tools/gen-slaydeck.mjs >/dev/null && echo reverted` -Expected: `reverted` - -- [ ] **Step 6: 잘못된 데이터 fail-fast 확인** - -Run: `node -e "const fs=require('fs'); const o=JSON.parse(fs.readFileSync('data/enemies.json','utf8')); o.activeEnemy='nope'; fs.writeFileSync('/tmp/bad-enemies.json', JSON.stringify(o));" && cp data/enemies.json /tmp/enemies.bak && cp /tmp/bad-enemies.json data/enemies.json; node tools/gen-slaydeck.mjs; echo "exit=$?"; cp /tmp/enemies.bak data/enemies.json` -Expected: 에러 메시지 `activeEnemy가 enemies에 없음: nope` + `exit=1`, 이후 원복 - -- [ ] **Step 7: 최종 재생성 + git status 확인** - -Run: `node tools/gen-slaydeck.mjs >/dev/null; git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` -Expected: `data/*.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`만 변경(내용 동일한 common 제외). - -- [ ] **Step 8: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "재생성(D): 데이터 기반 카드/적 주입 반영" -``` - -- [ ] **Step 9: 메이커 Play 수동 검증 (사용자)** - -메이커 reload→Play: 기존 B 동작과 동일(데이터 동치라 회귀 없음). 적 슬라임 HP 45·의도 공격10, 카드 3종 효과 정상. - ---- - -## Self-Review - -- **Spec coverage:** cards.json/enemies.json 생성(Task1), 로드·검증·직렬화(Task2), StartCombat·속성 데이터화(Task3), UI 파생(Task4), 검증·데이터변경 반영(Task5). 스펙 전 항목 매핑됨. -- **Placeholder scan:** 모든 단계 실제 코드/명령 포함. "TODO(E)"류 미래 훅은 본 작업 범위 아님. -- **Type consistency:** `luaStr`/`luaCardsTable`/`luaDeckTable`/`luaIntentsTable`/`intentText`/`ACTIVE_ENEMY`/`CARDS`/`ENEMIES` 명칭이 정의부(Task2)와 사용부(Task3·4)에서 일치. 카드 필드(`name/cost/kind/damage/block/desc`)가 데이터(Task1)·직렬화(Task2)·검증(Task5)에서 일치. diff --git a/docs/superpowers/plans/2026-06-09-floors.md b/docs/superpowers/plans/2026-06-09-floors.md deleted file mode 100644 index 5cb36e8..0000000 --- a/docs/superpowers/plans/2026-06-09-floors.md +++ /dev/null @@ -1,206 +0,0 @@ -# 다음 층 / 멀티 act (TODO E6a) 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:** 보스 클리어 시 즉시 종료 대신 다음 막으로 진행(적 스케일), 최종 막 보스에서 진짜 런 클리어. - -**Architecture:** `Floor`를 막 카운터로 재정의(1..ACT_COUNT). StartCombat에서 적을 막 배율로 스케일, CheckCombatEnd 보스 승리 시 다음 막(같은 맵 재사용)으로. 모두 `gen-slaydeck.mjs`에서 생성. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock. 검증은 node --check+재생성+결정성+메이커 Play. - ---- - -## File Structure -- Modify: `tools/gen-slaydeck.mjs` — ACT_COUNT 상수, StartRun(Floor=1·RunLength=ACT_COUNT), StartCombat(Floor 제거·적 스케일), CheckCombatEnd(보스 다음 막), RenderRun(막 라벨). - -검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. - ---- - -### Task 1: ACT_COUNT 상수 + StartRun - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: ACT_COUNT 상수** — `const RELIC_PRICE = 60;` 다음에: - -```js - const ACT_COUNT = 3; -``` - -- [ ] **Step 2: StartRun의 Floor·RunLength 변경** — StartRun 코드에서 아래 두 줄을 교체: - -기존: -``` -self.Floor = 0 -self.RunLength = ${MAX_ROW} -``` -신규: -``` -self.Floor = 1 -self.RunLength = ${ACT_COUNT} -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E6a): ACT_COUNT·StartRun 막 카운터 초기화" -``` - ---- - -### Task 2: StartCombat — Floor 제거 + 적 막 스케일 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: Floor=node.row 블록 제거 + 적 스케일 적용** — StartCombat 코드의 아래 블록을 교체: - -기존: -``` -local node = self.MapNodes[self.CurrentNodeId] -if node ~= nil then - self.Floor = node.row -end -local enemy = self.Enemies[self.CurrentEnemyId] -self.PlayerBlock = 0 -self.EnemyName = enemy.name -self.EnemyMaxHp = enemy.maxHp -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -self.EnemyIntents = enemy.intents -self.EnemyIntentIndex = 1 -``` -신규: -``` -local enemy = self.Enemies[self.CurrentEnemyId] -local mult = 1 + (self.Floor - 1) * 0.6 -self.PlayerBlock = 0 -self.EnemyName = enemy.name -self.EnemyMaxHp = math.floor(enemy.maxHp * mult) -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -self.EnemyIntents = {} -for i = 1, #enemy.intents do - self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) } -end -self.EnemyIntentIndex = 1 -``` - -- [ ] **Step 2: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E6a): StartCombat 적 막 스케일·Floor 제거" -``` - ---- - -### Task 3: CheckCombatEnd 보스 다음 막 + RenderRun 막 라벨 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: CheckCombatEnd 보스 분기 교체** — 아래 블록을 교체: - -기존: -``` - if node ~= nil and node.type == "boss" then - self:ShowResult("런 클리어!") - self.RunActive = false - else - self:OfferReward() - end -``` -신규: -``` - if node ~= nil and node.type == "boss" then - if self.Floor < self.RunLength then - self.Floor = self.Floor + 1 - self.CurrentNodeId = "" - self.CurrentEnemyId = "" - self:RenderRun() - self:ShowMap() - else - self:ShowResult("런 클리어!") - self.RunActive = false - end - else - self:OfferReward() - end -``` - -- [ ] **Step 2: RenderRun 막 라벨** — RenderRun의 Floor 텍스트 줄을 교체: - -기존: -``` -self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength)) -``` -신규: -``` -self:SetText("/ui/DefaultGroup/CombatHud/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength)) -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E6a): 보스 승리 다음 막 진행·막 라벨" -``` - ---- - -### Task 4: 재생성 + 검증 - -**Files:** 생성물 - -- [ ] **Step 1: 생성** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 스케일·막 진행 코드 확인** - -Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/mult = 1 \+ \(self.Floor - 1\) \* 0.6/.test(sc)&&/math.floor\(enemy.maxHp \* mult\)/.test(sc)?'SCALE OK':'NO SCALE'); const cc=j.ContentProto.Json.Methods.find(m=>m.Name==='CheckCombatEnd').Code; console.log(/self.Floor = self.Floor \+ 1/.test(cc)?'NEXT-ACT OK':'NO NEXT-ACT')"` -Expected: `SCALE OK` / `NEXT-ACT OK` - -- [ ] **Step 3: 결정성** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: git status** - -Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` -Expected: `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). (data 변경 없음) - -- [ ] **Step 5: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "재생성(E6a): 멀티 act·적 스케일 반영" -``` - -- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** - -reload→Play: 1막 보스(슬라임 킹 120) 처치 → 2막 맵(Floor 2)·적 HP 스케일(슬라임 72·보스 192) → 3막 보스 처치 → "런 클리어!". HP/골드/덱/유물 막 간 유지. MCP는 PickNode/PlayCard/CheckCombatEnd 직접 호출 + 로그. - ---- - -## Self-Review -- **Spec coverage:** ACT_COUNT·StartRun(Task1), StartCombat 스케일·Floor제거(Task2), 보스 다음막·막라벨(Task3), 검증(Task4). 스펙 전 항목 매핑. -- **Placeholder scan:** 모든 단계 실제 코드/명령. -- **Type consistency:** `Floor`(막 카운터)·`RunLength`(=ACT_COUNT)·`mult` 사용 일관. `EnemyIntents` 새 테이블 생성(공유 변형 없음). CheckCombatEnd의 `node`는 기존 정의 사용. ACT_COUNT 상수 Task1 정의·Task1·3 사용. diff --git a/docs/superpowers/plans/2026-06-09-map-camera.md b/docs/superpowers/plans/2026-06-09-map-camera.md deleted file mode 100644 index 53c8c49..0000000 --- a/docs/superpowers/plans/2026-06-09-map-camera.md +++ /dev/null @@ -1,213 +0,0 @@ -# 맵별 고정 카메라 (런타임 설정) 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:** 맵 로드 시 플레이어 카메라를 `data/camera.json`의 고정 framing(현재 map01 값)으로 설정하는 `MapCamera` 스크립트를 11맵에 부착. - -**Architecture:** 새 CameraComponent를 만들지 않고(엔진 소유), `MapCamera.codeblock`이 OnBeginPlay에서 플레이어의 기존 CameraComponent 속성(ZoomRatio·ScreenOffset·ConfineCameraArea)을 설정. `gen-camera.mjs`가 codeblock 생성 + 11맵 루트에 `script.MapCamera` 부착(idempotent). - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/map JSON. 검증은 node --check+재생성+JSON유효+결정성+메이커 Play(카메라 적용 확인). - ---- - -## File Structure -- Create: `data/camera.json` — 카메라 framing 값. -- Create: `tools/gen-camera.mjs` — MapCamera.codeblock 생성 + 11맵 루트에 script.MapCamera 부착(idempotent). -- 생성물: `RootDesk/MyDesk/MapCamera.codeblock`, `map/map01.map`~`map11.map`(패치). - -검증: MSW Lua 단위테스트 불가 → 생성기 문법·JSON유효·결정성·idempotency·메이커 Play. - ---- - -### Task 1: data/camera.json + gen-camera.mjs (codeblock 생성) - -**Files:** Create `data/camera.json`, `tools/gen-camera.mjs` - -- [ ] **Step 1: `data/camera.json`** (현재 map01 추출값) - -```json -{ - "zoomRatio": 100, - "screenOffsetX": 0.5, - "screenOffsetY": 0.655, - "confineCameraArea": true -} -``` - -- [ ] **Step 2: `tools/gen-camera.mjs` 작성 — codeblock 생성 부분** - -```js -import { readFileSync, writeFileSync } from 'node:fs'; - -const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8')); -const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11 - -function method(Name, Code, Arguments = [], ExecSpace = 1) { - return { - Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, - Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name, - }; -} -function prop(Type, Name, DefaultValue = 'nil') { - return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; -} - -function writeCodeblock() { - const cb = { - Id: '', GameId: '', EntryKey: 'codeblock://mapcamera', ContentType: 'x-mod/codeblock', - Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', - StudioVersion: '', DynamicLoading: 0, - ContentProto: { Use: 'Json', Json: { - CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 }, - Description: '', Id: 'MapCamera', Language: 1, Name: 'MapCamera', Type: 1, Source: 0, Target: null, - Properties: [ prop('number', 'CamTries', '0') ], - Methods: [ - method('OnBeginPlay', `self.CamTries = 0 -local eventId = 0 -local function apply() - self.CamTries = self.CamTries + 1 - local cam = nil - local lp = _UserService.LocalPlayer - if lp ~= nil then - cam = lp.CameraComponent - end - if cam == nil then - cam = _CameraService:GetCurrentCameraComponent() - end - if cam ~= nil then - cam.ZoomRatio = ${CAM.zoomRatio} - cam.ScreenOffset = Vector2(${CAM.screenOffsetX}, ${CAM.screenOffsetY}) - cam.ConfineCameraArea = ${CAM.confineCameraArea} - _TimerService:ClearTimer(eventId) - elseif self.CamTries > 30 then - _TimerService:ClearTimer(eventId) - end -end -eventId = _TimerService:SetTimerRepeat(apply, 0.1)`), - ], - EntityEventHandlers: [], - } }, - }; - writeFileSync('RootDesk/MyDesk/MapCamera.codeblock', JSON.stringify(cb, null, 2), 'utf8'); -} -``` - -- [ ] **Step 3: 문법 검사 (맵 패치 전, codeblock만)** — 임시로 `writeCodeblock(); console.log('cb ok');` 호출 추가 후: - -Run: `node --check tools/gen-camera.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add data/camera.json tools/gen-camera.mjs -git commit -m "gen-camera(map-camera): camera.json + MapCamera.codeblock 생성기" -``` - ---- - -### Task 2: gen-camera.mjs — 11맵 루트에 script.MapCamera 부착 - -**Files:** Modify `tools/gen-camera.mjs` - -- [ ] **Step 1: 맵 패치 함수 + 실행부 추가** (Step 2의 writeCodeblock 임시 호출은 제거) - -```js -function patchMap(nn) { - const tag = String(nn).padStart(2, '0'); - const file = `map/map${tag}.map`; - const map = JSON.parse(readFileSync(file, 'utf8')); - const root = map.ContentProto.Entities.find((e) => e.path === `/maps/map${tag}`); - if (!root) throw new Error(`[gen-camera] 맵 루트 없음: ${file}`); - const comps = root.jsonString['@components']; - // idempotent: 기존 script.MapCamera 제거 후 재추가 - root.jsonString['@components'] = comps.filter((c) => c['@type'] !== 'script.MapCamera'); - root.jsonString['@components'].push({ '@type': 'script.MapCamera', Enable: true }); - const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.MapCamera'); - names.push('script.MapCamera'); - root.componentNames = names.join(','); - writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); - return `map${tag}`; -} - -writeCodeblock(); -const patched = MAP_NUMBERS.map(patchMap); -console.log('MapCamera codeblock written; patched maps:', patched.join(', ')); -``` - -- [ ] **Step 2: 문법 검사** - -Run: `node --check tools/gen-camera.mjs` -Expected: 오류 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-camera.mjs -git commit -m "gen-camera(map-camera): 11맵 루트에 script.MapCamera 부착(idempotent)" -``` - ---- - -### Task 3: 실행 + 정적 검증 - -**Files:** 생성물 - -- [ ] **Step 1: 생성기 실행** - -Run: `node tools/gen-camera.mjs` -Expected: `MapCamera codeblock written; patched maps: map01, ..., map11` - -- [ ] **Step 2: codeblock·부착 확인** - -Run: `node -e "const c=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/MapCamera.codeblock','utf8')); console.log(c.ContentProto.Json.Id==='MapCamera'&&/ScreenOffset = Vector2\(0.5, 0.655\)/.test(c.ContentProto.Json.Methods[0].Code)?'CB OK':'CB BAD'); for(const nn of [1,6,11]){const tag=String(nn).padStart(2,'0'); const m=JSON.parse(require('fs').readFileSync('map/map'+tag+'.map','utf8')); const r=m.ContentProto.Entities.find(e=>e.path==='/maps/map'+tag); const has=r.componentNames.includes('script.MapCamera')&&r.jsonString['@components'].some(x=>x['@type']==='script.MapCamera'); console.log('map'+tag, has?'ATTACHED':'MISSING');}"` -Expected: `CB OK`, `map01 ATTACHED`, `map06 ATTACHED`, `map11 ATTACHED` - -- [ ] **Step 3: idempotency (2회 실행 시 중복 부착 없음)** - -Run: `node tools/gen-camera.mjs >/dev/null && node -e "const m=JSON.parse(require('fs').readFileSync('map/map01.map','utf8')); const r=m.ContentProto.Entities.find(e=>e.path==='/maps/map01'); const n=(r.componentNames.match(/script.MapCamera/g)||[]).length; const c=r.jsonString['@components'].filter(x=>x['@type']==='script.MapCamera').length; console.log('componentNames 횟수='+n+' @components 횟수='+c+(n===1&&c===1?' IDEMPOTENT OK':' DUP!'))"` -Expected: `IDEMPOTENT OK` - -- [ ] **Step 4: JSON 유효 + 결정성** - -Run: `for f in map/map*.map; do node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" || echo "BAD $f"; done; node tools/gen-camera.mjs >/dev/null && sha1sum map/map01.map RootDesk/MyDesk/MapCamera.codeblock > /tmp/a.sha && node tools/gen-camera.mjs >/dev/null && sha1sum map/map01.map RootDesk/MyDesk/MapCamera.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: BAD 없음, `DETERMINISTIC` - -- [ ] **Step 5: git status** - -Run: `git status --short` -Expected: `map/map01~11.map`, `RootDesk/MyDesk/MapCamera.codeblock`, `data/camera.json`, `tools/gen-camera.mjs` (+docs). map02~11은 freeze/카메라로 변경될 수 있음. - -- [ ] **Step 6: 생성물 커밋** - -```bash -git add map/*.map RootDesk/MyDesk/MapCamera.codeblock -git commit -m "재생성(map-camera): MapCamera codeblock + 11맵 부착 반영" -``` - ---- - -### Task 4: 메이커 Play 런타임 검증 (ExecSpace 확정) - -**Files:** 없음 (런타임 검증; 필요 시 ExecSpace 조정) - -- [ ] **Step 1: reload → Play → map01 카메라 적용 확인** - -메이커 reload 후 Play. `maker_execute_script`(client)로 현재 카메라 값 확인: -```lua -local cam = _CameraService:GetCurrentCameraComponent() -log("Zoom=" .. tostring(cam.ZoomRatio) .. " SO=(" .. tostring(cam.ScreenOffset.x) .. "," .. tostring(cam.ScreenOffset.y) .. ") Confine=" .. tostring(cam.ConfineCameraArea)) -``` -Expected: Zoom 100·SO(0.5,0.655)·Confine true (MapCamera가 적용한 값). - -- [ ] **Step 2: ExecSpace 검증/조정** — 만약 카메라 값이 적용 안 되면(스크립트가 클라에서 안 돎), `gen-camera.mjs`의 `method()` 기본 `ExecSpace`를 1↔6 등으로 바꿔 재생성·재확인. (gen-slaydeck는 6으로 클라 동작 확인됨, Monster는 클라 메서드 1.) 적용되는 값으로 확정. - -- [ ] **Step 3: (선택) framing 변경 반영 확인** — `data/camera.json`의 zoomRatio를 70으로 임시 변경 → 재생성 → Play에서 Zoom 70 확인 → 원복(`git checkout -- data/camera.json` + 재생성). - ---- - -## Self-Review -- **Spec coverage:** camera.json·codeblock(Task1), 11맵 부착(Task2), 정적검증·idempotency(Task3), 런타임·ExecSpace(Task4). 스펙 항목 매핑. -- **Placeholder scan:** 실제 코드/명령 포함. (Task4 ExecSpace 조정은 런타임 결과 의존 — 의도된 검증 분기.) -- **Type consistency:** `writeCodeblock`/`patchMap`/`method`/`prop` 일관. codeblock Id 'MapCamera' ↔ componentName `script.MapCamera` ↔ EntryKey `codeblock://mapcamera` 일치. camera.json 필드(zoomRatio/screenOffsetX/Y/confineCameraArea)가 codeblock 생성에서 사용됨. -- **리스크:** 맵 루트 스크립트의 client OnBeginPlay 실행/ExecSpace는 런타임 검증으로 확정(Task4). LocalPlayer.CameraComponent 타이밍은 재시도 타이머로 흡수. diff --git a/docs/superpowers/plans/2026-06-09-map-nodes.md b/docs/superpowers/plans/2026-06-09-map-nodes.md deleted file mode 100644 index 8cf1377..0000000 --- a/docs/superpowers/plans/2026-06-09-map-nodes.md +++ /dev/null @@ -1,493 +0,0 @@ -# 분기 맵 노드 진행 (TODO E3) 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:** 플레이어가 작성된 분기 맵(DAG)에서 다음 노드를 선택해 전투/엘리트/보스로 진행, 보스 클리어 시 "런 클리어". - -**Architecture:** `data/map.json`(그래프)·`data/enemies.json`(다중 적)을 `gen-slaydeck.mjs`가 로드·주입. SlayDeckController에 맵 상태·네비게이션 메서드 추가, MapHud UI 생성. 자동 진행 대신 ShowMap→PickNode→StartCombat→보상→ShowMap 루프. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. - ---- - -## File Structure -- Create: `data/map.json` — 분기 맵. -- Modify: `data/enemies.json` — slime_elite·slime_boss 추가. -- Modify: `tools/gen-slaydeck.mjs` — 맵/적 로드·검증·직렬화 헬퍼, method() returnType, 속성·메서드·MapHud UI. - -검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. - ---- - -### Task 1: 데이터 + 로드·검증·직렬화 헬퍼 - -**Files:** Create `data/map.json`; Modify `data/enemies.json`, `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: `data/map.json` 작성** - -```json -{ - "start": ["A", "B"], - "nodes": { - "A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] }, - "B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["D", "E"] }, - "C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] }, - "D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] }, - "E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] }, - "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] } - } -} -``` - -- [ ] **Step 2: `data/enemies.json`에 엘리트·보스 추가** — `slime` 항목 다음에: - -```json - "slime_elite": { - "name": "정예 슬라임", - "maxHp": 70, - "intents": [ - { "kind": "Attack", "value": 14 }, - { "kind": "Attack", "value": 8 }, - { "kind": "Defend", "value": 10 } - ] - }, - "slime_boss": { - "name": "슬라임 킹", - "maxHp": 120, - "intents": [ - { "kind": "Attack", "value": 18 }, - { "kind": "Defend", "value": 12 }, - { "kind": "Attack", "value": 10 }, - { "kind": "Attack", "value": 22 } - ] - } -``` - -- [ ] **Step 3: 생성기 상단에 map 로드·검증·헬퍼 추가** — `const ACTIVE_ENEMY = ...;` 다음에: - -```js -const MAP = JSON.parse(readFileSync('data/map.json', 'utf8')); -for (const id of MAP.start) { - if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`); -} -for (const [id, n] of Object.entries(MAP.nodes)) { - if (!ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`); - for (const nx of n.next) { - if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`); - } -} -const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row)); - -function luaIntentsArray(intents) { - return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }'; -} -function luaEnemiesTable(enemies) { - const lines = Object.entries(enemies).map(([id, e]) => - `\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`); - return `self.Enemies = {\n${lines.join('\n')}\n}`; -} -function luaMapNodesTable(nodes) { - const lines = Object.entries(nodes).map(([id, n]) => { - const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }'; - return `\t${id} = { type = ${luaStr(n.type)}, enemy = ${luaStr(n.enemy)}, row = ${n.row}, col = ${n.col}, next = ${nx} },`; - }); - return `self.MapNodes = {\n${lines.join('\n')}\n}`; -} -function luaStartArray(start) { - return 'self.MapStart = { ' + start.map(luaStr).join(', ') + ' }'; -} -``` - -- [ ] **Step 4: method()에 ReturnType 파라미터 추가** — 기존 method 함수를: - -```js -function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') { - return { - Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, - Arguments, - Code, - Scope: 2, - ExecSpace, - Attributes: [], - Name, - }; -} -``` - -- [ ] **Step 5: JSON·문법 검사** - -Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs` -Expected: `JSON OK` + 오류 없음 - -- [ ] **Step 6: 커밋** - -```bash -git add data/map.json data/enemies.json tools/gen-slaydeck.mjs -git commit -m "data(E3): 분기 맵 map.json·엘리트/보스 적 + 직렬화 헬퍼" -``` - ---- - -### Task 2: 맵 속성 + StartRun(맵 빌드·ShowMap) - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: 맵 상태 속성 추가** — `prop('boolean', 'RunActive', 'false'),` 다음에: - -```js - prop('any', 'Enemies'), - prop('any', 'MapNodes'), - prop('any', 'MapStart'), - prop('string', 'CurrentNodeId', '""'), - prop('string', 'CurrentEnemyId', '""'), -``` - -- [ ] **Step 2: StartRun 교체** — 맵 빌드 + ShowMap: - -```js - method('StartRun', `self.PlayerMaxHp = 80 -self.PlayerHp = self.PlayerMaxHp -self.Gold = 0 -self.Floor = 0 -self.RunLength = ${MAX_ROW} -self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} } -self.RunActive = true -${luaEnemiesTable(ENEMIES.enemies)} -${luaMapNodesTable(MAP.nodes)} -${luaStartArray(MAP.start)} -self.CurrentNodeId = "" -self.CurrentEnemyId = "" -self:BindButtons() -self:ShowMap()`), -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E3): 맵 상태 속성·StartRun 맵 빌드/ShowMap" -``` - ---- - -### Task 3: StartCombat·CheckCombatEnd·PickReward (맵 연동) - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: StartCombat 교체** — 적을 self.Enemies에서 로드, Floor=노드 row: - -```js - method('StartCombat', `self.MaxEnergy = 3 -self.Turn = 0 -local node = self.MapNodes[self.CurrentNodeId] -if node ~= nil then - self.Floor = node.row -end -local enemy = self.Enemies[self.CurrentEnemyId] -self.PlayerBlock = 0 -self.EnemyName = enemy.name -self.EnemyMaxHp = enemy.maxHp -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -self.EnemyIntents = enemy.intents -self.EnemyIntentIndex = 1 -self.CombatOver = false -self.DiscardPile = {} -self.Hand = {} -${luaCardsTable(CARDS.cards)} -self.DrawPile = {} -for i = 1, #self.RunDeck do - self.DrawPile[i] = self.RunDeck[i] -end -self:Shuffle(self.DrawPile) -self:RenderCombat() -self:StartPlayerTurn()`), -``` - -- [ ] **Step 2: CheckCombatEnd 교체** — 보스 노드면 런 클리어: - -```js - method('CheckCombatEnd', `if self.EnemyHp <= 0 then - self.CombatOver = true - self.Gold = self.Gold + ${GOLD_PER_WIN} - self:RenderRun() - local node = self.MapNodes[self.CurrentNodeId] - if node ~= nil and node.type == "boss" then - self:ShowResult("런 클리어!") - self.RunActive = false - else - self:OfferReward() - end -elseif self.PlayerHp <= 0 then - self.CombatOver = true - self:ShowResult("패배...") - self.RunActive = false -end`), -``` - -- [ ] **Step 3: PickReward 마지막을 ShowMap으로** — PickReward 코드의 마지막 `self:StartCombat()`를 `self:ShowMap()`로 교체. (그 외 동일) - -```js - method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then - return -end -if slot ~= 0 and self.RewardChoices ~= nil then - local id = self.RewardChoices[slot] - if id ~= nil then - table.insert(self.RunDeck, id) - end -end -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") -if hud ~= nil then - hud.Enable = false -end -self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), -``` - -- [ ] **Step 4: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 5: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E3): StartCombat 적 데이터화·보스 런클리어·보상후 맵복귀" -``` - ---- - -### Task 4: ShowMap·IsReachable·PickNode·RenderMap + BindButtons - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: 맵 메서드 추가** — PickReward 메서드 다음(마지막 `]);` 직전)에 삽입: - -```js - method('ShowMap', `self:RenderMap() -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") -if hud ~= nil then - hud.Enable = true -end`), - method('IsReachable', `local list -if self.CurrentNodeId == "" then - list = self.MapStart -else - local node = self.MapNodes[self.CurrentNodeId] - if node == nil then - return false - end - list = node.next -end -for i = 1, #list do - if list[i] == id then - return true - end -end -return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'), - method('RenderMap', `for id, node in pairs(self.MapNodes) do - local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. id) - if e ~= nil then - local reachable = self:IsReachable(id) - if e.SpriteGUIRendererComponent ~= nil then - if reachable then - e.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1) - else - e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) - end - end - if e.ButtonComponent ~= nil then - e.ButtonComponent.Enable = reachable - end - end -end`), - method('PickNode', `if self.RunActive ~= true then - return -end -if self:IsReachable(id) ~= true then - return -end -self.CurrentNodeId = id -self.CurrentEnemyId = self.MapNodes[id].enemy -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") -if hud ~= nil then - hud.Enable = false -end -self:StartCombat()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), -``` - -- [ ] **Step 2: BindButtons에 맵 노드 버튼 바인딩 추가** — BindButtons 코드의 마지막 `end`(skip 바인딩) 다음에 추가. BindButtons 끝부분의 skip 블록 다음에 붙이도록, skip 블록을 아래로 교체: - -```js -local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip") -if skip ~= nil and skip.ButtonComponent ~= nil then - skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end) -end -local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} } -for i = 1, #mapNodeIds do - local nid = mapNodeIds[i] - local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid) - if mn ~= nil and mn.ButtonComponent ~= nil then - mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end) - end -end`), -``` -(BindButtons 전체에서 기존 skip 블록 `local skip = ... end`)` 부분을 위 블록으로 교체) - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E3): ShowMap/IsReachable/PickNode/RenderMap·맵 노드 바인딩" -``` - ---- - -### Task 5: MapHud UI 생성 - -**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`) - -- [ ] **Step 1: guid 'map' 분기** — ns 매핑에 추가: - -```js - const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : 0xfe; -``` - -- [ ] **Step 2: 필터 확장** — upsertUi 필터에 MapHud 추가: - -```js - ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud')); -``` - -- [ ] **Step 3: MapHud 그룹 생성** — `ui.ContentProto.Entities.push(...reward);` 다음에 삽입: - -```js - const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스' }; - const map = []; - const mapHud = entity({ - id: guid('map', 0), - path: '/ui/DefaultGroup/MapHud', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 7, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.9 }, type: 1, raycast: true }), - ], - }); - mapHud.jsonString.enable = false; - map.push(mapHud); - map.push(entity({ - id: guid('map', 1), - path: '/ui/DefaultGroup/MapHud/Title', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 510 } }), - sprite({ color: TRANSPARENT }), - text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }), - ], - })); - let mapN = 2; - for (const [id, node] of Object.entries(MAP.nodes)) { - const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`; - const pos = { x: node.col * 180, y: node.row * 170 - 80 }; - map.push(entity({ - id: guid('map', mapN++), - path: nodePath, - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', - displayOrder: node.row, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 80 }, pos }), - sprite({ color: { r: 0.3, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }), - button(), - ], - })); - map.push(entity({ - id: guid('map', mapN++), - path: `${nodePath}/Label`, - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 150, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 144, y: 72 }, pos: { x: 0, y: 0 } }), - sprite({ color: TRANSPARENT }), - text({ value: `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}`, fontSize: 20, bold: true }), - ], - })); - } - ui.ContentProto.Entities.push(...map); -``` - -- [ ] **Step 4: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 5: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E3): MapHud 노드 맵 UI 생성" -``` - ---- - -### Task 6: 재생성 + 검증 - -**Files:** 생성물 - -- [ ] **Step 1: 생성** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 메서드·UI·적 주입 확인** - -Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowMap','PickNode','IsReachable','RenderMap'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/slime_boss/.test(sc)&&/슬라임 킹/.test(sc)?'ENEMIES OK':'NO ENEMIES'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/MapHud')&&has('/ui/DefaultGroup/MapHud/Node_BOSS')&&has('/ui/DefaultGroup/MapHud/Node_A/Label')?'UI OK':'UI MISSING')"` -Expected: `METHODS OK` / `ENEMIES OK` / `UI OK` - -- [ ] **Step 3: 결정성** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: git status** - -Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` -Expected: `data/*`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). - -- [ ] **Step 5: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "재생성(E3): 분기 맵·다중 적 반영" -``` - -- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** - -reload→Play: StartRun → MapHud(A·B만 클릭 가능) → PickNode("A") → 슬라임 전투 → 승리 → 보상 → 맵(C·D 활성) → PickNode("C") → 정예 슬라임(HP70) → ... → BOSS → 슬라임킹(HP120) → 승리 → "런 클리어!". 도달 불가 노드 PickNode → 무시. MCP는 `PickNode`/`PlayCard`/`PickReward` 직접 호출 + 상태 로그로 검증. - ---- - -## Self-Review -- **Spec coverage:** map.json/적(Task1), 맵 상태·StartRun(Task2), StartCombat 적데이터·보스클리어·보상후맵(Task3), Show/Pick/Reachable/RenderMap·바인딩(Task4), MapHud UI(Task5), 검증(Task6). 스펙 전 항목 매핑. -- **Placeholder scan:** 모든 단계 실제 코드/명령. -- **Type consistency:** 메서드 `StartRun/ShowMap/IsReachable/PickNode/RenderMap/StartCombat/CheckCombatEnd/PickReward` 정의·호출 일치. 속성 `Enemies/MapNodes/MapStart/CurrentNodeId/CurrentEnemyId` 정의(Task2)·사용(Task3·4) 일치. UI 경로 `/ui/DefaultGroup/MapHud/Node_{id}`·`/Label`가 codeblock(RenderMap/PickNode/BindButtons)·생성(Task5)에서 동일(노드 id는 map.json 키). `IsReachable`는 boolean 반환(method returnType param, Task1). enemy 필드 `name/maxHp/intents`가 데이터·luaEnemiesTable·StartCombat에서 일치. diff --git a/docs/superpowers/plans/2026-06-09-relics.md b/docs/superpowers/plans/2026-06-09-relics.md deleted file mode 100644 index 4513ac0..0000000 --- a/docs/superpowers/plans/2026-06-09-relics.md +++ /dev/null @@ -1,400 +0,0 @@ -# 유물 (TODO E5) 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:** 훅 기반 유물 패시브 + 3획득 경로(시작/엘리트/상점)를 추가한다. - -**Architecture:** `data/relics.json`을 생성기가 주입(self.Relics). `ApplyRelics(hook)`을 전투시작/턴시작/카드사용/보상 4지점에서 호출. `AddRelic`을 3경로가 공유. ShopHud 유물 슬롯·상단 유물 바 UI. 모두 `gen-slaydeck.mjs`에서 생성. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. - ---- - -## File Structure -- Create: `data/relics.json`. -- Modify: `tools/gen-slaydeck.mjs` — 로드/검증/직렬화, 상수, 속성, 훅 메서드(ApplyRelics/AddRelic/RenderRelics), 4지점 통합, 상점 유물(BuyRelic), UI(유물 바·상점 유물 슬롯). - -검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. - ---- - -### Task 1: 데이터 + 로드/직렬화 + 상수/속성 + 훅 메서드 - -**Files:** Create `data/relics.json`; Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: `data/relics.json` 작성** - -```json -{ - "relics": { - "ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6 }, - "energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1 }, - "vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1 }, - "goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 골드 +10", "hook": "combatReward", "effect": "gold", "value": 10 } - }, - "startingRelic": "ironHeart", - "relicPool": ["energyCore", "vampire", "goldIdol"] -} -``` - -- [ ] **Step 2: 로드·검증·직렬화 헬퍼** — `const MAP = ...` 로드 블록 다음(MAX_ROW 정의 뒤)에 추가: - -```js -const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8')); -if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`); -for (const id of RELICS.relicPool) { - if (!RELICS.relics[id]) throw new Error(`[gen-slaydeck] relicPool에 없는 유물 id: ${id}`); -} -function luaRelicsTable(relics) { - const lines = Object.entries(relics).map(([id, r]) => - `\t${id} = { name = ${luaStr(r.name)}, desc = ${luaStr(r.desc)}, hook = ${luaStr(r.hook)}, effect = ${luaStr(r.effect)}, value = ${r.value} },`); - return `self.Relics = {\n${lines.join('\n')}\n}`; -} -``` - -- [ ] **Step 3: RELIC_PRICE 상수** — `const REST_HEAL = 30;` 다음에: - -```js - const RELIC_PRICE = 60; -``` - -- [ ] **Step 4: 속성 추가** — `prop('any', 'ShopBought'),` 다음에: - -```js - prop('any', 'Relics'), - prop('any', 'RunRelics'), - prop('any', 'RelicPool'), - prop('string', 'ShopRelic', '""'), - prop('boolean', 'ShopRelicBought', 'false'), -``` - -- [ ] **Step 5: 훅 메서드 추가** — PickReward 메서드 다음(ShowMap 앞 아무 곳, 마지막 `]);` 전 임의 위치)에 삽입: - -```js - method('ApplyRelics', `if self.RunRelics == nil then - return -end -for i = 1, #self.RunRelics do - local r = self.Relics[self.RunRelics[i]] - if r ~= nil and r.hook == hook then - if r.effect == "block" then - self.PlayerBlock = self.PlayerBlock + r.value - elseif r.effect == "energy" then - self.Energy = self.Energy + r.value - elseif r.effect == "healOnAttack" then - self.PlayerHp = self.PlayerHp + r.value - if self.PlayerHp > self.PlayerMaxHp then - self.PlayerHp = self.PlayerMaxHp - end - elseif r.effect == "gold" then - self.Gold = self.Gold + r.value - end - end -end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hook' }]), - method('AddRelic', `if self.RunRelics == nil then - self.RunRelics = {} -end -table.insert(self.RunRelics, id) -self:RenderRelics()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), - method('RenderRelics', `local names = "" -if self.RunRelics ~= nil then - for i = 1, #self.RunRelics do - local r = self.Relics[self.RunRelics[i]] - if r ~= nil then - if names == "" then - names = r.name - else - names = names .. ", " .. r.name - end - end - end -end -if names == "" then - names = "없음" -end -self:SetText("/ui/DefaultGroup/CombatHud/Relics", "유물: " .. names)`), -``` - -- [ ] **Step 6: JSON·문법 검사** - -Run: `node -e "JSON.parse(require('fs').readFileSync('data/relics.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs` -Expected: `JSON OK` + 오류 없음 - -- [ ] **Step 7: 커밋** - -```bash -git add data/relics.json tools/gen-slaydeck.mjs -git commit -m "data(E5): 유물 데이터 + 훅 시스템(ApplyRelics/AddRelic/RenderRelics)" -``` - ---- - -### Task 2: 훅 4지점 통합 + 시작/엘리트 획득 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: StartRun에 유물 주입·시작 유물** — StartRun 코드에서 `self.RunActive = true` 다음에 삽입: - -``` -self.RunRelics = {} -${luaRelicsTable(RELICS.relics)} -self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} } -``` -그리고 StartRun의 `self:ShowMap()` **직전**에 삽입: -``` -self:AddRelic("${RELICS.startingRelic}") -``` - -- [ ] **Step 2: StartCombat에 combatStart 훅** — StartCombat 끝 `self:StartPlayerTurn()`를 아래로 교체: - -``` -self:StartPlayerTurn() -self:ApplyRelics("combatStart") -self:RenderCombat() -``` - -- [ ] **Step 3: StartPlayerTurn에 turnStart 훅** — `self.Energy = self.MaxEnergy` 다음 줄에 삽입: - -``` -self:ApplyRelics("turnStart") -``` - -- [ ] **Step 4: PlayCard Attack 분기에 cardPlayed 훅** — PlayCard의 Attack 분기를 교체: - -``` -if c.kind == "Attack" then - if c.damage ~= nil then - self:DealDamageToEnemy(c.damage) - end - self:ApplyRelics("cardPlayed") -elseif c.kind == "Skill" then -``` -(기존: `if c.kind == "Attack" then\n\tif c.damage ~= nil then\n\t\tself:DealDamageToEnemy(c.damage)\n\tend\nelseif c.kind == "Skill" then` 에서 `end` 다음에 `\n\tself:ApplyRelics("cardPlayed")` 추가) - -- [ ] **Step 5: CheckCombatEnd에 combatReward 훅 + 엘리트 유물** — CheckCombatEnd 승리부를 교체: - -``` -if self.EnemyHp <= 0 then - self.CombatOver = true - self.Gold = self.Gold + ${GOLD_PER_WIN} - self:ApplyRelics("combatReward") - self:RenderRun() - local node = self.MapNodes[self.CurrentNodeId] - if node ~= nil and node.type == "elite" then - self:AddRelic(self.RelicPool[math.random(1, #self.RelicPool)]) - end - if node ~= nil and node.type == "boss" then - self:ShowResult("런 클리어!") - self.RunActive = false - else - self:OfferReward() - end -elseif self.PlayerHp <= 0 then - self.CombatOver = true - self:ShowResult("패배...") - self.RunActive = false -end -``` - -- [ ] **Step 6: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 7: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E5): 훅 4지점 통합·시작/엘리트 유물 획득" -``` - ---- - -### Task 3: 상점 유물 (ShowShop/RenderShop/BuyRelic) + 바인딩 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: ShowShop에 유물 선택 추가** — ShowShop의 `self.ShopBought = { false, false, false }` 다음에: - -``` -self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)] -self.ShopRelicBought = false -``` - -- [ ] **Step 2: RenderShop 끝에 유물 슬롯 렌더 + BuyRelic 메서드** — RenderShop 코드의 마지막 카드 for-loop `end` 다음(닫는 백틱 직전)에 추가: - -``` -local rr = self.Relics[self.ShopRelic] -if rr ~= nil then - self:SetText("/ui/DefaultGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc) - self:SetText("/ui/DefaultGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 골드") - local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic") - if e ~= nil and e.SpriteGUIRendererComponent ~= nil then - if self.ShopRelicBought == true then - e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) - else - e.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1) - end - end -end -``` -그리고 RenderShop 메서드 다음에 BuyRelic 메서드 추가: - -```js - method('BuyRelic', `if self.ShopRelicBought == true then - return -end -if self.Gold < ${RELIC_PRICE} then - return -end -self.Gold = self.Gold - ${RELIC_PRICE} -self:AddRelic(self.ShopRelic) -self.ShopRelicBought = true -self:RenderShop() -self:RenderRun()`), -``` - -- [ ] **Step 3: BindButtons에 유물 슬롯 바인딩** — BindButtons의 shopLeave 바인딩 다음(restLeave 앞)에 삽입: - -``` -local shopRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic") -if shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then - shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end) -end -``` - -- [ ] **Step 4: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 5: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E5): 상점 유물 슬롯·BuyRelic·바인딩" -``` - ---- - -### Task 4: UI — 유물 바 + 상점 유물 슬롯 - -**Files:** Modify `tools/gen-slaydeck.mjs` (`upsertUi`) - -- [ ] **Step 1: CombatHud 유물 바 추가** — CombatHud의 Floor/Gold for-loop 다음(`const result = entity({` 앞)에 삽입: - -```js - combat.push(entity({ - id: guid('cmb', cmbN++), - path: '/ui/DefaultGroup/CombatHud/Relics', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 9, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1000, y: 40 }, pos: { x: 0, y: 430 } }), - sprite({ color: TRANSPARENT }), - text({ value: '유물: 없음', fontSize: 22, bold: true, color: { r: 0.8, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }), - ], - })); -``` - -- [ ] **Step 2: ShopHud 유물 슬롯 추가** — ShopHud의 Leave 버튼 push 직전에 삽입: - -```js - shop.push(entity({ - id: guid('shp', shpN++), - path: '/ui/DefaultGroup/ShopHud/Relic', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', - displayOrder: 9, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 560, y: 76 }, pos: { x: 0, y: -190 } }), - sprite({ color: { r: 0.7, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }), - button(), - ], - })); - shop.push(entity({ - id: guid('shp', shpN++), - path: '/ui/DefaultGroup/ShopHud/Relic/Label', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 40 }, pos: { x: 0, y: 12 } }), - sprite({ color: TRANSPARENT }), - text({ value: '유물', fontSize: 22, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), - ], - })); - shop.push(entity({ - id: guid('shp', shpN++), - path: '/ui/DefaultGroup/ShopHud/Relic/Price', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 1, - components: [ - transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }), - sprite({ color: TRANSPARENT }), - text({ value: '60 골드', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), - ], - })); -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E5): 유물 바·상점 유물 슬롯 UI" -``` - ---- - -### Task 5: 재생성 + 검증 - -**Files:** 생성물 - -- [ ] **Step 1: 생성** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 메서드·UI·데이터 확인** - -Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ApplyRelics','AddRelic','RenderRelics','BuyRelic'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sr=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/ironHeart/.test(sr)&&/강철 심장/.test(sr)?'RELICS OK':'NO RELICS'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/CombatHud/Relics')&&has('/ui/DefaultGroup/ShopHud/Relic/Label')?'UI OK':'UI MISSING')"` -Expected: `METHODS OK` / `RELICS OK` / `UI OK` - -- [ ] **Step 3: 결정성** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: git status** - -Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` -Expected: `data/relics.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). - -- [ ] **Step 5: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "재생성(E5): 유물 시스템·UI 반영" -``` - -- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** - -reload→Play: 시작 유물(강철심장)→전투 시작 PlayerBlock 6 / energyCore 보유 시 턴 에너지 4 / vampire 보유 시 공격 HP+1 / goldIdol 승리 골드+25 / 엘리트 승리→유물 획득(바 갱신) / 상점 유물 구매(골드-60). MCP는 AddRelic/BuyRelic/PlayCard/PickNode 직접 호출 + 로그. - ---- - -## Self-Review -- **Spec coverage:** 데이터/로드/훅메서드(Task1), 4지점통합·시작·엘리트(Task2), 상점유물(Task3), UI(Task4), 검증(Task5). 스펙 전 항목 매핑. -- **Placeholder scan:** 모든 단계 실제 코드/명령. -- **Type consistency:** 메서드 `ApplyRelics/AddRelic/RenderRelics/BuyRelic` 정의·호출·바인딩 일치. 속성 `Relics/RunRelics/RelicPool/ShopRelic/ShopRelicBought` 정의(Task1·1)·사용(Task2·3) 일치. UI 경로 `/CombatHud/Relics`·`/ShopHud/Relic/{Label,Price}`가 codeblock(RenderRelics/RenderShop)·생성(Task4)에서 동일. 유물 필드 `name/desc/hook/effect/value` 데이터·luaRelicsTable·ApplyRelics 일치. 상수 `RELIC_PRICE` Task1 정의·Task3 사용. diff --git a/docs/superpowers/plans/2026-06-09-run-loop-core.md b/docs/superpowers/plans/2026-06-09-run-loop-core.md deleted file mode 100644 index 41b83c4..0000000 --- a/docs/superpowers/plans/2026-06-09-run-loop-core.md +++ /dev/null @@ -1,438 +0,0 @@ -# 런 루프 코어 (TODO E1+E2) 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:** 단일 전투를 연속 N전투 런으로 확장 — 런 상태(HP/골드/덱) 영속 + 승리 후 카드 1택 보상 + 다음 전투 + N전투 후 "런 클리어". - -**Architecture:** 기존 `SlayDeckController`(gen-slaydeck.mjs 생성)에 런 상태·보상 메서드 추가. StartRun(영속 초기화·버튼 1회 바인딩) vs StartCombat(전투별 초기화, RunDeck에서 드로) 분리. RewardHud UI 생성. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. - ---- - -## File Structure -- Modify: `tools/gen-slaydeck.mjs` — 유일 변경 대상. - - `writeCodeblocks`: 런 상수, 새 속성, OnBeginPlay/StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat. - - `upsertUi`: CombatHud에 Floor/Gold, RewardHud 그룹 생성, 필터 확장, guid 'rwd' 분기. - -MSW Lua 단위 테스트 불가 → 검증은 생성기 문법·재생성·결정성·메이커 Play. - ---- - -### Task 1: 런 상수·속성·StartRun - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: 런 상수 추가** — `writeCodeblocks()` 함수 본문 첫 줄에 삽입: - -```js - const RUN_LENGTH = 3; - const GOLD_PER_WIN = 15; -``` - -- [ ] **Step 2: 새 속성 추가** — 속성 배열의 `prop('any', 'EnemyName'),` 다음에: - -```js - prop('any', 'RunDeck'), - prop('number', 'Gold', '0'), - prop('number', 'Floor', '0'), - prop('number', 'RunLength', String(RUN_LENGTH)), - prop('any', 'RewardChoices'), - prop('boolean', 'RunActive', 'false'), -``` - -- [ ] **Step 3: OnBeginPlay → StartRun** — `method('OnBeginPlay', \`self:StartCombat()\`),` 를: - -```js - method('OnBeginPlay', `self:StartRun()`), -``` - -- [ ] **Step 4: StartRun 메서드 추가** — OnBeginPlay 다음에 삽입: - -```js - method('StartRun', `self.PlayerMaxHp = 80 -self.PlayerHp = self.PlayerMaxHp -self.Gold = 0 -self.Floor = 0 -self.RunLength = ${RUN_LENGTH} -self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} } -self.RunActive = true -self:BindButtons() -self:StartCombat()`), -``` - -- [ ] **Step 5: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 6: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E1): 런 상태 속성·StartRun 추가" -``` - ---- - -### Task 2: StartCombat 수정 + BindButtons 수정 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: StartCombat 본문 교체** — `method('StartCombat', \`...\`)`의 코드를 아래로(HP 보존·Floor++·RunDeck에서 드로·BindButtons 호출 제거): - -```js - method('StartCombat', `self.MaxEnergy = 3 -self.Turn = 0 -self.Floor = self.Floor + 1 -self.PlayerBlock = 0 -self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)} -self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp} -self.EnemyHp = self.EnemyMaxHp -self.EnemyBlock = 0 -${luaIntentsTable(ACTIVE_ENEMY.intents)} -self.EnemyIntentIndex = 1 -self.CombatOver = false -self.DiscardPile = {} -self.Hand = {} -${luaCardsTable(CARDS.cards)} -self.DrawPile = {} -for i = 1, #self.RunDeck do - self.DrawPile[i] = self.RunDeck[i] -end -self:Shuffle(self.DrawPile) -self:RenderCombat() -self:StartPlayerTurn()`), -``` - -- [ ] **Step 2: BindButtons에 보상 버튼 바인딩 추가** — BindButtons 코드 끝(마지막 `end` 다음)에 추가. 현재 마지막 부분: -``` -for i = 1, 5 do - local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) - if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then - cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) - end -end -``` -뒤에 이어붙이도록 BindButtons 코드를 아래 전체로 교체: - -```js - method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton") -if endTurn ~= nil and endTurn.ButtonComponent ~= nil then - if self.EndTurnHandler ~= nil then - endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler) - self.EndTurnHandler = nil - end - self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end) -end -for i = 1, 5 do - local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) - if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then - cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) - end -end -for i = 1, 3 do - local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i)) - if rc ~= nil and rc.ButtonComponent ~= nil then - rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end) - end -end -local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip") -if skip ~= nil and skip.ButtonComponent ~= nil then - skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end) -end`), -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E1): StartCombat 런 분리(HP보존·RunDeck드로)·BindButtons 1회+보상버튼" -``` - ---- - -### Task 3: CheckCombatEnd·OfferReward·PickReward·RenderRun - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: CheckCombatEnd 교체** — 보상/런클리어/패배 분기: - -```js - method('CheckCombatEnd', `if self.EnemyHp <= 0 then - self.CombatOver = true - self.Gold = self.Gold + ${GOLD_PER_WIN} - self:RenderRun() - if self.Floor >= self.RunLength then - self:ShowResult("런 클리어!") - self.RunActive = false - else - self:OfferReward() - end -elseif self.PlayerHp <= 0 then - self.CombatOver = true - self:ShowResult("패배...") - self.RunActive = false -end`), -``` - -- [ ] **Step 2: OfferReward·ApplyRewardVisual·PickReward·RenderRun 추가** — RenderCombat 메서드 다음에 삽입: - -```js - method('OfferReward', `local pool = {} -for id, _ in pairs(self.Cards) do - table.insert(pool, id) -end -self.RewardChoices = {} -for i = 1, 3 do - self.RewardChoices[i] = pool[math.random(1, #pool)] - self:ApplyRewardVisual(i, self.RewardChoices[i]) -end -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") -if hud ~= nil then - hud.Enable = true -end`), - method('ApplyRewardVisual', `local c = self.Cards[cardId] -if c == nil then - return -end -local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot) -self:SetText(base .. "/Name", c.name) -self:SetText(base .. "/Cost", tostring(c.cost)) -self:SetText(base .. "/Desc", c.desc) -local e = _EntityService:GetEntityByPath(base) -if e ~= nil and e.SpriteGUIRendererComponent ~= nil then - if c.kind == "Attack" then - e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) - elseif c.kind == "Skill" then - e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) - else - e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) - end -end`, [ - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, - ]), - method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then - return -end -if slot ~= 0 and self.RewardChoices ~= nil then - local id = self.RewardChoices[slot] - if id ~= nil then - table.insert(self.RunDeck, id) - end -end -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud") -if hud ~= nil then - hud.Enable = false -end -self:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), - method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength)) -self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`), -``` - -- [ ] **Step 3: RenderCombat 끝에 RenderRun 호출 추가** — RenderCombat 코드의 마지막 줄(`...PlayerBlock...`) 다음에 `\nself:RenderRun()` 추가. 즉 RenderCombat 마지막을: -``` -self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) -self:RenderRun() -``` -로. (Edit: 기존 마지막 줄 끝에 `\nself:RenderRun()` 삽입) - -- [ ] **Step 4: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 5: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E2): 보상(OfferReward/PickReward)·런 클리어·층/골드 렌더" -``` - ---- - -### Task 4: UI — CombatHud 층/골드 + RewardHud - -**Files:** Modify `tools/gen-slaydeck.mjs` (`upsertUi`, `guid`) - -- [ ] **Step 1: guid 'rwd' 분기 추가** — guid()의 ns 매핑을: - -```js - const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : 0xfe; -``` - -- [ ] **Step 2: 정리 필터 확장** — upsertUi 시작부 필터를: - -```js - ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud')); -``` - -- [ ] **Step 3: CombatHud에 Floor·Gold 텍스트 추가** — `const result = entity({` 선언 직전(즉 result 추가 전)에 삽입: - -```js - for (const [suffix, pos, value, color] of [ - ['Floor', { x: -820, y: 480 }, '층 1/3', GOLD], - ['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }], - ]) { - combat.push(entity({ - id: guid('cmb', cmbN++), - path: `/ui/DefaultGroup/CombatHud/${suffix}`, - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 9, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 240, y: 44 }, pos }), - sprite({ color: TRANSPARENT }), - text({ value, fontSize: 26, bold: true, color, alignment: 4 }), - ], - })); - } -``` - -- [ ] **Step 4: RewardHud 그룹 생성** — `ui.ContentProto.Entities.push(...combat);` 직후, `JSON.parse(JSON.stringify(ui));` 직전에 삽입: - -```js - const reward = []; - const rewardHud = entity({ - id: guid('rwd', 0), - path: '/ui/DefaultGroup/RewardHud', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 6, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }), - ], - }); - rewardHud.jsonString.enable = false; - reward.push(rewardHud); - reward.push(entity({ - id: guid('rwd', 1), - path: '/ui/DefaultGroup/RewardHud/Title', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 64 }, pos: { x: 0, y: 300 } }), - sprite({ color: TRANSPARENT }), - text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), - ], - })); - let rwdN = 2; - const rewardXs = [-300, 0, 300]; - for (let i = 1; i <= 3; i++) { - const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`; - reward.push(entity({ - id: guid('rwd', rwdN++), - path: cardPath, - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', - displayOrder: i, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: rewardXs[i - 1], y: 0 } }), - sprite({ color: ATTACK, type: 1, raycast: true }), - button(), - ], - })); - for (const [suffix, cfg] of [ - ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true }], - ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }], - ['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }], - ]) { - reward.push(entity({ - id: guid('rwd', rwdN++), - path: `${cardPath}/${suffix}`, - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2, - components: [ - transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), - sprite({ color: TRANSPARENT }), - text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }), - ], - })); - } - } - reward.push(entity({ - id: guid('rwd', rwdN++), - path: '/ui/DefaultGroup/RewardHud/Skip', - modelId: 'uibutton', - entryId: 'UIButton', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', - displayOrder: 10, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -260 } }), - sprite({ color: DARK, type: 1, raycast: true }), - button(), - text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), - ], - })); - ui.ContentProto.Entities.push(...reward); -``` - -- [ ] **Step 5: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 6: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E2): CombatHud 층/골드 + RewardHud(보상 카드 3+건너뛰기) UI" -``` - ---- - -### Task 5: 재생성 + 검증 - -**Files:** 생성물 2종 - -- [ ] **Step 1: 생성** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 메서드·UI 생성 확인** - -Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['StartRun','OfferReward','PickReward','RenderRun','ApplyRewardVisual'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); console.log(u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/RewardHud')&&u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/CombatHud/Gold')?'UI OK':'UI MISSING')"` -Expected: `METHODS OK` / `UI OK` - -- [ ] **Step 3: 결정성** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: git status (의도 파일만)** - -Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` -Expected: `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). - -- [ ] **Step 5: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "재생성(E1+E2): 런 루프·보상 UI 반영" -``` - -- [ ] **Step 6: 메이커 Play 수동 검증 (사용자/MCP)** - -reload→Play: 승리 → RewardHud 카드 3장·골드+15·층 표시 → 1택 시 RunDeck+1·다음 전투(HP 유지) → 3전투째 승리 시 "런 클리어!". 패배 시 "패배...". MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출 + 상태 로그로 검증. - ---- - -## Self-Review -- **Spec coverage:** 상수·속성·StartRun(Task1), StartCombat분리·BindButtons1회(Task2), 보상·런클리어·렌더(Task3), 층/골드·RewardHud UI(Task4), 검증(Task5). 스펙 전 항목 매핑. -- **Placeholder scan:** 모든 단계 실제 코드/명령. -- **Type consistency:** 메서드명 `StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat` 정의·호출 일치. UI 경로 `/ui/DefaultGroup/RewardHud/Reward{1..3}/{Name,Cost,Desc}`·`/Skip`·`/CombatHud/{Floor,Gold}`가 codeblock(ApplyRewardVisual/RenderRun/BindButtons)과 생성(Task4) 일치. 속성 `RunDeck/Gold/Floor/RunLength/RewardChoices/RunActive` 정의(Task1)·사용(Task2·3) 일치. diff --git a/docs/superpowers/plans/2026-06-09-shop-rest.md b/docs/superpowers/plans/2026-06-09-shop-rest.md deleted file mode 100644 index 5f0d081..0000000 --- a/docs/superpowers/plans/2026-06-09-shop-rest.md +++ /dev/null @@ -1,488 +0,0 @@ -# 상점/휴식 노드 (TODO E4) 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:** 맵에 상점(골드→카드)·휴식(HP 회복) 노드를 추가하고, 진입 시 전투 대신 상점/휴식 UI로 분기. - -**Architecture:** `data/map.json`에 shop/rest 노드 추가(enemy 없음). SlayDeckController에 상점/휴식 메서드, PickNode 타입 분기, ShopHud/RestHud UI. 모두 `gen-slaydeck.mjs`에서 생성. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. - ---- - -## File Structure -- Modify: `data/map.json` — 4행, shop/rest 노드. -- Modify: `tools/gen-slaydeck.mjs` — 검증 완화, enemy 조건부 직렬화, 상수, 속성, PickNode 분기, 상점/휴식 메서드, ShopHud/RestHud UI, MapHud y 중앙정렬. - -검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. - ---- - -### Task 1: 데이터 + 검증완화 + enemy 조건부 직렬화 + 상수·속성 - -**Files:** Modify `data/map.json`, `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: `data/map.json` 교체** - -```json -{ - "start": ["A", "B"], - "nodes": { - "A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] }, - "B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] }, - "C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] }, - "D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] }, - "E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] }, - "F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] }, - "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] } - } -} -``` - -- [ ] **Step 2: 검증 완화 (enemy 선택적)** — 생성기의 맵 검증 루프를 교체: - -```js -for (const [id, n] of Object.entries(MAP.nodes)) { - if (n.enemy && !ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`); - for (const nx of n.next) { - if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`); - } -} -``` - -- [ ] **Step 3: luaMapNodesTable enemy 조건부** — 함수를 교체: - -```js -function luaMapNodesTable(nodes) { - const lines = Object.entries(nodes).map(([id, n]) => { - const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }'; - const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : ''; - return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`; - }); - return `self.MapNodes = {\n${lines.join('\n')}\n}`; -} -``` - -- [ ] **Step 4: 상수 추가** — `writeCodeblocks()` 안 `const GOLD_PER_WIN = 15;` 다음에: - -```js - const CARD_PRICE = 30; - const REST_HEAL = 30; -``` - -- [ ] **Step 5: 상점 상태 속성 추가** — `prop('string', 'CurrentEnemyId', '""'),` 다음에: - -```js - prop('any', 'ShopChoices'), - prop('any', 'ShopBought'), -``` - -- [ ] **Step 6: JSON·문법 검사** - -Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs` -Expected: `JSON OK` + 오류 없음 - -- [ ] **Step 7: 커밋** - -```bash -git add data/map.json tools/gen-slaydeck.mjs -git commit -m "data(E4): 상점/휴식 노드 맵 + enemy 선택적 검증/직렬화 + 상수/속성" -``` - ---- - -### Task 2: PickNode 분기 + 상점/휴식 메서드 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: PickNode 교체 (타입 분기)** - -```js - method('PickNode', `if self.RunActive ~= true then - return -end -if self:IsReachable(id) ~= true then - return -end -self.CurrentNodeId = id -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") -if hud ~= nil then - hud.Enable = false -end -local node = self.MapNodes[id] -if node.type == "shop" then - self:ShowShop() -elseif node.type == "rest" then - self:ShowRest() -else - self.CurrentEnemyId = node.enemy - self:StartCombat() -end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), -``` - -- [ ] **Step 2: 상점/휴식 메서드 추가** — PickNode 메서드 다음에 삽입: - -```js - method('ShowShop', `local pool = {} -for cid, _ in pairs(self.Cards) do - table.insert(pool, cid) -end -self.ShopChoices = {} -self.ShopBought = { false, false, false } -for i = 1, 3 do - self.ShopChoices[i] = pool[math.random(1, #pool)] -end -self:RenderShop() -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") -if hud ~= nil then - hud.Enable = true -end`), - method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "골드 " .. string.format("%d", self.Gold)) -for i = 1, 3 do - local cid = self.ShopChoices[i] - local c = self.Cards[cid] - local base = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i) - if c ~= nil then - self:SetText(base .. "/Name", c.name) - self:SetText(base .. "/Cost", tostring(c.cost)) - self:SetText(base .. "/Desc", c.desc) - self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 골드") - local e = _EntityService:GetEntityByPath(base) - if e ~= nil and e.SpriteGUIRendererComponent ~= nil then - if self.ShopBought[i] == true then - e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) - elseif c.kind == "Attack" then - e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) - elseif c.kind == "Skill" then - e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) - else - e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) - end - end - end -end`), - method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then - return -end -if self.Gold < ${CARD_PRICE} then - return -end -self.Gold = self.Gold - ${CARD_PRICE} -table.insert(self.RunDeck, self.ShopChoices[slot]) -self.ShopBought[slot] = true -self:RenderShop() -self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), - method('ShowRest', `local old = self.PlayerHp -self.PlayerHp = self.PlayerHp + ${REST_HEAL} -if self.PlayerHp > self.PlayerMaxHp then - self.PlayerHp = self.PlayerMaxHp -end -local healed = self.PlayerHp - old -self:SetText("/ui/DefaultGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")") -self:RenderCombat() -local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") -if hud ~= nil then - hud.Enable = true -end`), - method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") -if s ~= nil then - s.Enable = false -end -local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") -if r ~= nil then - r.Enable = false -end -self:ShowMap()`), -``` - -- [ ] **Step 3: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 4: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E4): PickNode 타입 분기·상점(구매)/휴식(회복) 메서드" -``` - ---- - -### Task 3: BindButtons 바인딩 - -**Files:** Modify `tools/gen-slaydeck.mjs` - -- [ ] **Step 1: BindButtons 맵 노드 루프 다음에 상점/휴식 바인딩 추가** — BindButtons 코드의 맵 노드 for-loop(`...PickNode(nid)...end\nend`) 다음, 닫는 백틱 직전에 삽입. 맵 노드 루프 끝 부분을 아래로 교체: - -```js -for i = 1, #mapNodeIds do - local nid = mapNodeIds[i] - local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid) - if mn ~= nil and mn.ButtonComponent ~= nil then - mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end) - end -end -for i = 1, 3 do - local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i)) - if sc ~= nil and sc.ButtonComponent ~= nil then - sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end) - end -end -local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave") -if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then - shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) -end -local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave") -if restLeave ~= nil and restLeave.ButtonComponent ~= nil then - restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) -end`), -``` - -- [ ] **Step 2: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 3: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E4): 상점 구매/나가기·휴식 나가기 버튼 바인딩" -``` - ---- - -### Task 4: ShopHud·RestHud UI + MapHud 4행 정렬 - -**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`) - -- [ ] **Step 1: guid 'shp'·'rst' 분기** — ns 매핑에 추가(map 분기 다음): - -```js - const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : 0xfe; -``` - -- [ ] **Step 2: 필터 확장** — upsertUi 필터에 ShopHud·RestHud 추가: - -```js - ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud')); -``` - -- [ ] **Step 3: MapHud 노드 y 중앙정렬** — upsertUi의 노드 pos 계산을 교체: - -```js - const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 }; -``` - -- [ ] **Step 4: ShopHud·RestHud 생성** — `ui.ContentProto.Entities.push(...map);` 다음에 삽입: - -```js - const shop = []; - const shopHud = entity({ - id: guid('shp', 0), - path: '/ui/DefaultGroup/ShopHud', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 8, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }), - ], - }); - shopHud.jsonString.enable = false; - shop.push(shopHud); - shop.push(entity({ - id: guid('shp', 1), - path: '/ui/DefaultGroup/ShopHud/Title', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 400 } }), - sprite({ color: TRANSPARENT }), - text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), - ], - })); - shop.push(entity({ - id: guid('shp', 2), - path: '/ui/DefaultGroup/ShopHud/Gold', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 1, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }), - sprite({ color: TRANSPARENT }), - text({ value: '골드 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), - ], - })); - let shpN = 3; - const shopXs = [-300, 0, 300]; - for (let i = 1; i <= 3; i++) { - const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`; - shop.push(entity({ - id: guid('shp', shpN++), - path: cardPath, - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', - displayOrder: i, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: shopXs[i - 1], y: 20 } }), - sprite({ color: ATTACK, type: 1, raycast: true }), - button(), - ], - })); - for (const [suffix, cfg] of [ - ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], - ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], - ['Desc', { size: { x: 160, y: 60 }, pos: { x: 0, y: -50 }, value: '', fontSize: 20, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }], - ['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 골드', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }], - ]) { - shop.push(entity({ - id: guid('shp', shpN++), - path: `${cardPath}/${suffix}`, - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : suffix === 'Desc' ? 2 : 3, - components: [ - transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), - sprite({ color: TRANSPARENT }), - text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }), - ], - })); - } - } - shop.push(entity({ - id: guid('shp', shpN++), - path: '/ui/DefaultGroup/ShopHud/Leave', - modelId: 'uibutton', - entryId: 'UIButton', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', - displayOrder: 10, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -300 } }), - sprite({ color: DARK, type: 1, raycast: true }), - button(), - text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), - ], - })); - ui.ContentProto.Entities.push(...shop); - - const rest = []; - const restHud = entity({ - id: guid('rst', 0), - path: '/ui/DefaultGroup/RestHud', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 9, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.05, g: 0.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }), - ], - }); - restHud.jsonString.enable = false; - rest.push(restHud); - rest.push(entity({ - id: guid('rst', 1), - path: '/ui/DefaultGroup/RestHud/Title', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 140 } }), - sprite({ color: TRANSPARENT }), - text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), - ], - })); - rest.push(entity({ - id: guid('rst', 2), - path: '/ui/DefaultGroup/RestHud/Info', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 1, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }), - sprite({ color: TRANSPARENT }), - text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), - ], - })); - rest.push(entity({ - id: guid('rst', 3), - path: '/ui/DefaultGroup/RestHud/Leave', - modelId: 'uibutton', - entryId: 'UIButton', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', - displayOrder: 2, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -120 } }), - sprite({ color: DARK, type: 1, raycast: true }), - button(), - text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), - ], - })); - ui.ContentProto.Entities.push(...rest); -``` - -- [ ] **Step 5: 문법 검사** - -Run: `node --check tools/gen-slaydeck.mjs` -Expected: 오류 없음 - -- [ ] **Step 6: 커밋** - -```bash -git add tools/gen-slaydeck.mjs -git commit -m "gen-slaydeck(E4): ShopHud/RestHud UI·MapHud 4행 중앙정렬" -``` - ---- - -### Task 5: 재생성 + 검증 - -**Files:** 생성물 - -- [ ] **Step 1: 생성** - -Run: `node tools/gen-slaydeck.mjs` -Expected: `Slay deck UI and combat codeblocks generated.` - -- [ ] **Step 2: 메서드·UI 확인** - -Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowShop','BuyCard','ShowRest','LeaveNode','RenderShop'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/ShopHud/Card1/Price')&&has('/ui/DefaultGroup/RestHud/Info')&&has('/ui/DefaultGroup/MapHud/Node_D')?'UI OK':'UI MISSING')"` -Expected: `METHODS OK` / `UI OK` - -- [ ] **Step 3: 결정성** - -Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` -Expected: `DETERMINISTIC` - -- [ ] **Step 4: git status** - -Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` -Expected: `data/map.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). - -- [ ] **Step 5: 생성물 커밋** - -```bash -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "재생성(E4): 상점/휴식 노드·UI 반영" -``` - -- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** - -reload→Play: 맵(4행, 2행에 휴식C·상점D) → PickNode("D")→상점(카드3·골드) → BuyCard(골드≥30이면 -30·RunDeck+1·비활성; 부족하면 무시) → LeaveNode→맵 / PickNode("C")→휴식(HP+30 클램프)→LeaveNode→맵 / 전투·보스·런 클리어 회귀 확인. MCP는 PickNode/BuyCard/LeaveNode 직접 호출 + 로그. - ---- - -## Self-Review -- **Spec coverage:** 맵/검증/직렬화/상수/속성(Task1), PickNode분기·상점·휴식(Task2), 바인딩(Task3), UI·MapHud정렬(Task4), 검증(Task5). 스펙 전 항목 매핑. -- **Placeholder scan:** 모든 단계 실제 코드/명령. -- **Type consistency:** 메서드 `PickNode/ShowShop/RenderShop/BuyCard/ShowRest/LeaveNode` 정의·호출·바인딩 일치. 속성 `ShopChoices/ShopBought` 정의(Task1)·사용(Task2) 일치. UI 경로 `/ui/DefaultGroup/ShopHud/Card{1..3}/{Name,Cost,Desc,Price}`·`/Gold`·`/Leave`, `/RestHud/{Info,Leave}`가 codeblock(RenderShop/ShowRest/BindButtons)·생성(Task4)에서 동일. 상수 `CARD_PRICE/REST_HEAL` Task1 정의·Task2 사용 일치. diff --git a/docs/superpowers/plans/2026-06-10-map-monster-combat.md b/docs/superpowers/plans/2026-06-10-map-monster-combat.md deleted file mode 100644 index 71c59ed..0000000 --- a/docs/superpowers/plans/2026-06-10-map-monster-combat.md +++ /dev/null @@ -1,1064 +0,0 @@ -# 맵 몬스터 카드 전투 구현 계획 - -> **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:** 카드 공격이 추상 슬라임 대신 맵의 실제 몬스터(클릭 타겟)에 적용되고, 맵의 모든 몬스터를 처치하면 전투 승리가 되도록 한다. - -**Architecture:** 전투 상태는 `SlayDeckController`(Lua)가 단일 소유한다. 맵 몬스터는 `script.CombatMonster`(EnemyId 보유)를 달고 BeginPlay 시 컨트롤러에 자기등록한다. 전투 규칙은 `tools/balance/sim-balance.mjs`(JS)로 TDD하고, 동일 규칙을 `tools/deck/gen-slaydeck.mjs`가 생성하는 Lua로 미러링한다. HP바/의도/타겟버튼은 카메라 고정을 활용해 `data/monster-slots.json` 화면좌표로 배치한다. - -**Tech Stack:** Node.js ESM 생성기(.mjs), node:test, MSW Lua codeblock, JSON(.map/.ui/.gamelogic/data). - ---- - -## 배경 / 현재 규칙 (구현자용) - -- 생성물(`ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`)은 **`tools/deck/gen-slaydeck.mjs` 단일 소스**에서만 생성한다(직접 편집 금지). 바꿀 땐 생성기를 고치고 `node tools/deck/gen-slaydeck.mjs` 재실행. -- 모든 생성기는 **저장소 루트에서** 실행한다: `node tools/<폴더>/<파일>.mjs`. -- 현재 전투(단일 적): `SlayDeckController`가 `EnemyHp/EnemyMaxHp/EnemyBlock/EnemyName/EnemyIntents/EnemyIntentIndex`를 갖고, `PlayCard`(Attack)→`DealDamageToEnemy`→`CheckCombatEnd`(EnemyHp<=0 승리). 적 데이터는 `data/enemies.json`, `Floor` 배율 `1+(Floor-1)*0.6`. -- 밸런스 sim(`tools/balance/sim-balance.mjs`)은 이 규칙을 JS로 재현하며 `sim-balance.test.mjs`로 검증한다. **전투 규칙 변경 시 sim과 Lua를 함께 바꾼다.** - -## 파일 구조 - -| 파일 | 책임 | 변경 | -|------|------|------| -| `data/enemies.json` | 적 타입 데이터 | 맵 몬스터 타입 + `simEncounter` 추가 | -| `data/monster-slots.json` | 몬스터 UI 슬롯 화면좌표 | **신규** | -| `tools/balance/sim-balance.mjs` | 전투 규칙 JS 재현 | 멀티 몬스터화 | -| `tools/balance/sim-balance.test.mjs` | 규칙 테스트 | 멀티 몬스터 테스트로 교체 | -| `tools/monster/gen-combat-monster.mjs` | CombatMonster 코드블록 생성 + 맵 몬스터 패치 | **신규** | -| `RootDesk/MyDesk/CombatMonster.codeblock` | 몬스터 적ID 마커+자기등록 | **신규 생성물** | -| `tools/deck/gen-slaydeck.mjs` | 컨트롤러+UI 생성 | 멀티 몬스터 전투 + 몬스터 슬롯 UI | -| `map/map01.map`~`map11.map` | 맵 | 몬스터에 `script.CombatMonster` 부착(생성기 산출) | -| `ui/DefaultGroup.ui` | UI | 몬스터 슬롯 추가(생성기 산출) | -| `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic` | 생성물 | gen-slaydeck 산출 | - ---- - -## Task 1: enemies.json — 맵 몬스터 타입 + simEncounter - -**Files:** -- Modify: `data/enemies.json` - -- [ ] **Step 1: 적 타입과 simEncounter 추가** - -`enemies` 객체 안에 두 타입을 추가하고, 최상위에 `simEncounter`를 추가한다(기존 slime/elite/boss, activeEnemy는 유지): - -```json -{ - "enemies": { - "slime": { "name": "슬라임", "maxHp": 45, "intents": [ { "kind": "Attack", "value": 10 }, { "kind": "Attack", "value": 6 }, { "kind": "Defend", "value": 8 } ] }, - "slime_elite": { "name": "정예 슬라임", "maxHp": 70, "intents": [ { "kind": "Attack", "value": 14 }, { "kind": "Attack", "value": 8 }, { "kind": "Defend", "value": 10 } ] }, - "slime_boss": { "name": "슬라임 킹", "maxHp": 120, "intents": [ { "kind": "Attack", "value": 18 }, { "kind": "Defend", "value": 12 }, { "kind": "Attack", "value": 10 }, { "kind": "Attack", "value": 22 } ] }, - "orange_mushroom": { "name": "주황버섯", "maxHp": 16, "intents": [ { "kind": "Attack", "value": 5 }, { "kind": "Defend", "value": 4 }, { "kind": "Attack", "value": 7 } ] }, - "blue_mushroom": { "name": "파란버섯", "maxHp": 22, "intents": [ { "kind": "Attack", "value": 8 }, { "kind": "Attack", "value": 4 } ] } - }, - "activeEnemy": "slime", - "simEncounter": ["orange_mushroom", "orange_mushroom", "blue_mushroom"] -} -``` - -- [ ] **Step 2: JSON 유효성 확인** - -Run: `node -e "JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('ok')"` -Expected: `ok` - -- [ ] **Step 3: Commit** - -```bash -git add data/enemies.json -git commit -m "feat(combat-data): 맵 몬스터 적 타입(주황/파란버섯) + simEncounter 추가" -``` - ---- - -## Task 2: sim-balance.mjs — 멀티 몬스터 규칙 (TDD) - -전투 규칙을 멀티 몬스터로 바꾸고, 이 sim을 Lua 미러링의 정답지로 삼는다. **테스트 먼저.** - -**Files:** -- Modify: `tools/balance/sim-balance.mjs` -- Test: `tools/balance/sim-balance.test.mjs` (전면 교체) - -- [ ] **Step 1: 실패하는 테스트 작성** - -`tools/balance/sim-balance.test.mjs` 전체를 아래로 교체: - -```js -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, -} from './sim-balance.mjs'; - -test('applyDamage: 방어 우선 차감 후 hp', () => { - assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 }); - assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 }); - assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 }); - assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 }); -}); - -test('mulberry32: 동일 시드 동일 수열', () => { - const a = mulberry32(1), b = mulberry32(1); - assert.equal(a(), b()); - assert.equal(a(), b()); -}); - -const CARDS = { - Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 }, - Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 }, - Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 }, -}; - -test('chooseAction: 공격을 스킬보다 먼저 선택', () => { - const idx = chooseAction(['Defend', 'Strike'], CARDS, 3); - assert.equal(idx, 1); // Strike -}); - -test('chooseAction: 공격 없으면 스킬 선택', () => { - const idx = chooseAction(['Defend'], CARDS, 3); - assert.equal(idx, 0); -}); - -test('chooseAction: 사용 가능 카드 없으면 -1', () => { - const idx = chooseAction(['Bash'], CARDS, 1); - assert.equal(idx, -1); -}); - -test('chooseTarget: 이번 타격으로 처치 가능한 최소 체력 우선', () => { - const mob = [ - { hp: 20, block: 0, alive: true }, - { hp: 5, block: 0, alive: true }, - { hp: 8, block: 0, alive: true }, - ]; - assert.equal(chooseTarget(mob, 6), mob[1]); // 5<=6 처치 가능, 최소 -}); - -test('chooseTarget: 처치 불가면 유효체력 최소 선택', () => { - const mob = [ - { hp: 20, block: 0, alive: true }, - { hp: 12, block: 5, alive: true }, - { hp: 14, block: 0, alive: true }, - ]; - assert.equal(chooseTarget(mob, 6), mob[2]); // 유효 14 < 17 < 20 -}); - -const DATA = { - cards: CARDS, - starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'], - monsters: [ - { name: '주황버섯', maxHp: 16, intents: [{ kind: 'Attack', value: 5 }, { kind: 'Defend', value: 4 }] }, - { name: '파란버섯', maxHp: 12, intents: [{ kind: 'Attack', value: 8 }] }, - ], -}; - -test('simulateCombat: 결정적 결과(동일 시드)', () => { - const r1 = simulateCombat(DATA, mulberry32(1)); - const r2 = simulateCombat(DATA, mulberry32(1)); - assert.deepEqual(r1, r2); - assert.equal(typeof r1.win, 'boolean'); - assert.ok(r1.turns >= 1); -}); - -test('simulateCombat: 모든 몬스터 처치 시 승리', () => { - let wins = 0; - for (let i = 0; i < 50; i++) if (simulateCombat(DATA, mulberry32(i + 1)).win) wins++; - assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`); -}); - -test('simulateCombat: 강한 다수 적이면 패배 가능', () => { - const hard = { - cards: CARDS, - starterDeck: DATA.starterDeck, - monsters: Array.from({ length: 4 }, () => ({ name: '슬라임', maxHp: 60, intents: [{ kind: 'Attack', value: 12 }] })), - }; - let losses = 0; - for (let i = 0; i < 30; i++) if (!simulateCombat(hard, mulberry32(i + 1)).win) losses++; - assert.ok(losses >= 1, `강한 적엔 패배가 나와야 함, 실제 패 ${losses}/30`); -}); - -test('runBatch: 집계 필드·승률 범위', () => { - const r = runBatch(100, 1); - assert.equal(r.N, 100); - assert.ok(r.winRate >= 0 && r.winRate <= 1); - assert.ok(r.avgTurns > 0); - assert.ok(r.cardStats.Strike.plays > 0); -}); - -test('runBatch: 동일 시드 동일 결과', () => { - assert.deepEqual(runBatch(100, 7), runBatch(100, 7)); -}); -``` - -- [ ] **Step 2: 테스트 실패 확인** - -Run: `node --test tools/balance/sim-balance.test.mjs` -Expected: FAIL — `chooseTarget` export 없음 / `chooseAction` 시그니처 불일치 / `simulateCombat` monsters 미사용 등. - -- [ ] **Step 3: sim 멀티 몬스터로 구현** - -`tools/balance/sim-balance.mjs`에서 `loadData`, `chooseAction`, `simulateCombat`, `runBatch`를 아래로 교체하고 `chooseTarget`를 추가한다. `mulberry32`/`shuffle`/`applyDamage`/`bump`/`mean`/`median`/`formatReport`의 시그니처 의존 부분만 맞춘다. - -`chooseAction` 교체(타겟 분리, 공격 우선): - -```js -// 손패에서 낼 카드 인덱스(-1=종료). 공격 우선, 없으면 스킬. -export function chooseAction(hand, cards, energy) { - const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy); - const attacks = entries.filter((x) => cards[x.id].kind === 'Attack'); - const skills = entries.filter((x) => cards[x.id].kind === 'Skill'); - const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost; - const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost; - const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0]; - if (attacks.length) return bestBy(attacks, dmgEff).i; - if (skills.length) return bestBy(skills, blkEff).i; - return -1; -} - -// 공격 타겟 선택: 이번 타격으로 처치 가능한 최소 유효체력, 없으면 유효체력 최소. -export function chooseTarget(aliveMonsters, plannedDamage) { - const eff = (m) => m.hp + m.block; - const killable = aliveMonsters.filter((m) => eff(m) <= plannedDamage); - const pool = killable.length ? killable : aliveMonsters; - return pool.slice().sort((a, b) => eff(a) - eff(b))[0]; -} -``` - -`loadData` 교체(simEncounter → monsters 배열): - -```js -export function loadData() { - const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8')); - const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8')); - const ids = enemiesData.simEncounter || [enemiesData.activeEnemy]; - const monsters = ids.map((id) => { - const e = enemiesData.enemies[id]; - if (!e) throw new Error(`simEncounter 적 없음: ${id}`); - return { name: e.name, maxHp: e.maxHp, intents: e.intents }; - }); - return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, monsters }; -} -``` - -`simulateCombat` 교체(멀티 몬스터): - -```js -export function simulateCombat(data, rng, stats) { - const { cards, starterDeck, monsters } = data; - let drawPile = shuffle(starterDeck, rng); - let discard = []; - let hand = []; - let pHp = PLAYER_HP, pBlock = 0; - const mob = monsters.map((m) => ({ - name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, - intents: m.intents, intentIdx: 0, alive: true, - })); - let turns = 0; - - function draw(n) { - for (let k = 0; k < n; k++) { - if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; } - if (drawPile.length === 0) break; - hand.push(drawPile.pop()); - } - } - const aliveList = () => mob.filter((m) => m.alive); - - while (turns < MAX_TURNS) { - turns++; - let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); - while (true) { - const alive = aliveList(); - if (alive.length === 0) break; - const idx = chooseAction(hand, cards, energy); - if (idx < 0) break; - const id = hand[idx], c = cards[id]; - energy -= c.cost; - if (c.kind === 'Attack') { - const target = chooseTarget(alive, c.damage || 0); - const r = applyDamage(target.hp, target.block, c.damage || 0); - target.hp = r.hp; target.block = r.block; - if (target.hp <= 0) target.alive = false; - if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0); - } else { - pBlock += c.block || 0; - if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0); - } - hand.splice(idx, 1); discard.push(id); - if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp }; - } - discard.push(...hand); hand = []; - for (const m of mob) { - if (!m.alive) continue; - m.block = 0; - const it = m.intents[m.intentIdx]; - if (it) { - if (it.kind === 'Attack') { const r = applyDamage(pHp, pBlock, it.value); pHp = r.hp; pBlock = r.block; } - else if (it.kind === 'Defend') { m.block += it.value; } - } - m.intentIdx = (m.intentIdx + 1) % m.intents.length; - if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; - } - } - return { win: false, turns, playerHpRemaining: pHp, draw: true }; -} -``` - -`runBatch`에서 `data.enemy` 참조를 제거하고 `data.monsters` 기반으로 바꾼다. `formatReport`의 적 이름 표기는 인카운터 요약으로 교체: - -```js -export function runBatch(N, seed) { - const data = loadData(); - const rng = mulberry32(seed); - const cardStats = {}; - let wins = 0, draws = 0; - const turnsArr = [], hpArr = []; - for (let i = 0; i < N; i++) { - const r = simulateCombat(data, rng, cardStats); - if (r.draw) draws++; - if (r.win) { wins++; hpArr.push(r.playerHpRemaining); } - turnsArr.push(r.turns); - } - return { - N, wins, draws, losses: N - wins - draws, - winRate: wins / N, - avgTurns: mean(turnsArr), medianTurns: median(turnsArr), - avgHpOnWin: mean(hpArr), - cardStats, cards: data.cards, monsters: data.monsters, seed, - }; -} -``` - -`formatReport` 첫 줄을 인카운터 요약으로 교체(나머지 카드 통계 로직은 유지): - -```js - L.push(`=== 밸런스 시뮬레이션 (인카운터: ${r.monsters.map((m) => `${m.name}(${m.maxHp})`).join(', ')}) ===`); -``` - -- [ ] **Step 4: 테스트 통과 확인** - -Run: `node --test tools/balance/sim-balance.test.mjs` -Expected: PASS (전체 통과, `tests N / pass N / fail 0`). - -- [ ] **Step 5: CLI 동작 확인** - -Run: `node tools/balance/sim-balance.mjs 500` -Expected: 인카운터 요약 + 승률/턴/카드 통계 출력, 에러 없음. - -- [ ] **Step 6: Commit** - -```bash -git add tools/balance/sim-balance.mjs tools/balance/sim-balance.test.mjs -git commit -m "feat(sim): 전투 규칙을 멀티 몬스터로 (타겟 선택·각자 의도·전체 처치 승리)" -``` - ---- - -## Task 3: CombatMonster 코드블록 + gen-combat-monster.mjs - -맵 몬스터에 `EnemyId`를 부여하고, BeginPlay 시 컨트롤러에 자기등록하는 경량 스크립트를 만든다. - -**Files:** -- Create: `tools/monster/gen-combat-monster.mjs` -- Create(생성물): `RootDesk/MyDesk/CombatMonster.codeblock` -- Modify(산출): `map/map01.map`~`map11.map` - -- [ ] **Step 1: gen-combat-monster.mjs 작성** - -`tools/camera/gen-camera.mjs`의 prop/method/writeCodeblock/patchMap 구조를 따른다. EnemyId 매핑은 이름 기반 + 기본값. - -```js -import { readFileSync, writeFileSync } from 'node:fs'; - -// 맵 몬스터에 적 타입(EnemyId)을 부여하고, BeginPlay 시 /common 컨트롤러에 자기등록하는 마커. -// 카드 전투 시 컨트롤러가 등록 목록으로 인카운터를 구성한다. -const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11 -const NAME_TO_ENEMY = { '주황버섯': 'orange_mushroom', '파란버섯': 'blue_mushroom' }; -const DEFAULT_ENEMY = 'orange_mushroom'; - -function prop(Type, Name, DefaultValue = 'nil') { - return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; -} -function method(Name, Code, Arguments = [], ExecSpace = 6) { - return { - Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, - Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name, - }; -} - -function writeCodeblock() { - const cb = { - Id: '', GameId: '', EntryKey: 'codeblock://combatmonster', ContentType: 'x-mod/codeblock', - Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0, - ContentProto: { Use: 'Json', Json: { - CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 }, - Description: '', Id: 'CombatMonster', Language: 1, Name: 'CombatMonster', Type: 1, Source: 0, Target: null, - Properties: [prop('string', 'EnemyId', '""'), prop('number', 'RegTries', '0')], - Methods: [ - method('OnBeginPlay', `self.RegTries = 0 -local eventId = 0 -local function reg() - self.RegTries = self.RegTries + 1 - local c = _EntityService:GetEntityByPath("/common") - if c ~= nil and c.SlayDeckController ~= nil then - c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId) - _TimerService:ClearTimer(eventId) - elseif self.RegTries > 50 then - _TimerService:ClearTimer(eventId) - end -end -eventId = _TimerService:SetTimerRepeat(reg, 0.1)`), - ], - EntityEventHandlers: [], - } }, - }; - writeFileSync('RootDesk/MyDesk/CombatMonster.codeblock', JSON.stringify(cb, null, 2), 'utf8'); -} - -const isMonster = (e) => (e.componentNames || '').includes('script.Monster'); - -function patchMap(nn) { - const tag = String(nn).padStart(2, '0'); - const file = `map/map${tag}.map`; - const map = JSON.parse(readFileSync(file, 'utf8')); - let count = 0; - for (const e of map.ContentProto.Entities.filter(isMonster)) { - const name = (e.jsonString && e.jsonString.name) || ''; - const enemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY; - const comps = e.jsonString['@components']; - e.jsonString['@components'] = comps.filter((c) => c['@type'] !== 'script.CombatMonster'); - e.jsonString['@components'].push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: enemyId }); - const names = (e.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster'); - names.push('script.CombatMonster'); - e.componentNames = names.join(','); - count++; - } - writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); - return `map${tag}(${count})`; -} - -writeCodeblock(); -const patched = MAP_NUMBERS.map(patchMap); -console.log('CombatMonster codeblock written; patched maps:', patched.join(', ')); -``` - -- [ ] **Step 2: 실행** - -Run: `node tools/monster/gen-combat-monster.mjs` -Expected: `CombatMonster codeblock written; patched maps: map01(N), ...` (map01은 주황버섯 포함 마릿수). - -- [ ] **Step 3: 산출 검증** - -Run: `node -e "const m=JSON.parse(require('fs').readFileSync('map/map01.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));console.log(ms.map(e=>e.jsonString.name+':'+e.jsonString['@components'].find(c=>c['@type']==='script.CombatMonster').EnemyId).join(', '))"` -Expected: 각 몬스터 `이름:enemyId` 출력(주황버섯:orange_mushroom 등). JSON.parse 성공. - -- [ ] **Step 4: 멱등성 확인** - -Run: `node tools/monster/gen-combat-monster.mjs` (재실행) 후 `git diff --stat map/` 변화 없음(2회차 = 1회차 동일). -Expected: 재실행 후 추가 변경 없음. - -- [ ] **Step 5: Commit** - -```bash -git add tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ -git commit -m "feat(monster): CombatMonster 마커(EnemyId·자기등록) + 11맵 몬스터 패치" -``` - ---- - -## Task 4: gen-slaydeck — 멀티 몬스터 상태 + 등록/StartCombat - -여기서부터 `tools/deck/gen-slaydeck.mjs`의 `writeCodeblocks()` 안 `SlayDeckController` 정의를 수정한다. **상수 추가**: 파일 상단 근처(다른 const와 함께)에 `const MAX_MONSTERS = 4;`. - -**Files:** -- Modify: `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: 속성(prop) 교체** - -`SlayDeckController`의 prop 목록에서 단일 적 속성을 멀티로 교체한다. - -제거: `prop('number','EnemyHp','0')`, `prop('number','EnemyMaxHp',...)`, `prop('number','EnemyBlock','0')`, `prop('number','EnemyIntentIndex','1')`, `prop('any','EnemyIntents')`, `prop('any','EnemyName')`. - -추가(같은 위치에): - -```js - prop('any', 'Monsters'), - prop('any', 'Registered'), - prop('number', 'TargetIndex', '1'), - prop('any', 'SlotPos'), -``` - -- [ ] **Step 2: 슬롯 좌표 로드 + 컨트롤러에 주입** - -파일 상단 데이터 로드부(다른 `JSON.parse(readFileSync(...))` 옆)에 추가: - -```js -const SLOTS = JSON.parse(readFileSync('data/monster-slots.json', 'utf8')); -``` - -`StartRun` 메서드 본문(현재 `self.CurrentNodeId = ""` 위쪽 적절한 위치)에 SlotPos 주입을 추가: - -```js -self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} } -``` - -> data/monster-slots.json은 Task 8에서 생성한다. 이 Task를 먼저 구현하더라도 생성기 실행은 Task 8 이후에 한다(실행 순서는 Task 9). - -- [ ] **Step 3: RegisterMonster + StartCombat 작성** - -`StartCombat` 메서드를 아래로 교체한다(단일 적 셋업 → 등록 몬스터 기반 인카운터 구성). 그리고 `RegisterMonster` 메서드를 새로 추가한다. - -`RegisterMonster`: - -```js - method('RegisterMonster', `if self.Registered == nil then - self.Registered = {} -end -table.insert(self.Registered, { entity = monster, enemyId = enemyId })`, [ - { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' }, - ]), -``` - -`StartCombat` 교체: - -```js - method('StartCombat', `self.MaxEnergy = 3 -self.Turn = 0 -self.PlayerBlock = 0 -self.CombatOver = false -self.DiscardPile = {} -self.Hand = {} -${luaCardsTable(CARDS.cards)} -self.DrawPile = {} -for i = 1, #self.RunDeck do - self.DrawPile[i] = self.RunDeck[i] -end -self:Shuffle(self.DrawPile) -self:BuildMonsters() -self:RenderCombat() -self:StartPlayerTurn() -self:ApplyRelics("combatStart") -self:RenderCombat()`), -``` - -`BuildMonsters` 추가(등록 몬스터 → 좌→우 정렬 → 최대 MAX → enemies.json 스탯·막 배율 → 부활·슬롯 배치): - -```js - method('BuildMonsters', `self.Monsters = {} -local reg = self.Registered or {} --- 살아있는(유효) 등록 엔티티만, 월드 x 기준 좌->우 정렬 -local list = {} -for i = 1, #reg do - local r = reg[i] - if r.entity ~= nil and isvalid(r.entity) then - local x = 0 - if r.entity.TransformComponent ~= nil then - x = r.entity.TransformComponent.WorldPosition.x - end - table.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x }) - end -end -table.sort(list, function(a, b) return a.x < b.x end) -local mult = 1 + (self.Floor - 1) * 0.6 -local n = #list -if n > ${'${'}MAX_MONSTERS} then n = ${'${'}MAX_MONSTERS} end -for i = 1, n do - local item = list[i] - local e = self.Enemies[item.enemyId] - if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end - local intents = {} - for k = 1, #e.intents do - intents[k] = { kind = e.intents[k].kind, value = math.floor(e.intents[k].value * mult) } - end - local maxHp = math.floor(e.maxHp * mult) - self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name, - hp = maxHp, maxHp = maxHp, block = 0, intents = intents, intentIdx = 1, alive = true, slot = i } - self:ReviveMonsterEntity(item.entity) - self:PositionMonsterSlot(i) -end -self.TargetIndex = 1`), -``` - -> 위 `${'${'}MAX_MONSTERS}`는 JS 템플릿에서 상수 `MAX_MONSTERS` 값을 박아넣기 위한 것이다. 실제 작성 시 `${MAX_MONSTERS}`로 쓴다(이 문서의 escape 표기 주의). - -`ReviveMonsterEntity` 추가: - -```js - method('ReviveMonsterEntity', `if monster == nil or not isvalid(monster) then - return -end -monster:SetEnable(true) -monster:SetVisible(true) -if monster.StateComponent ~= nil then - monster.StateComponent:ChangeState("IDLE") -end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }]), -``` - -- [ ] **Step 4: 구문 점검(생성기 실행은 Task 9에서)** - -Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` -(이 Task에서는 JS 문법만 깨지지 않았는지 확인. 전체 실행/산출 검증은 Task 8·9에서.) -Expected: `file ok` - -- [ ] **Step 5: Commit** - -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(combat): 컨트롤러 멀티 몬스터 상태 + 등록/BuildMonsters/부활" -``` - ---- - -## Task 5: gen-slaydeck — PlayCard 타겟 공격 + 사망 - -**Files:** -- Modify: `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: PlayCard 공격 분기 교체** - -`PlayCard`에서 Attack 분기의 `self:DealDamageToEnemy(c.damage)`를 타겟 몬스터 공격으로 교체: - -```js -if c.kind == "Attack" then - if c.damage ~= nil then - self:DealDamageToTarget(c.damage) - end - self:ApplyRelics("cardPlayed") -elseif c.kind == "Skill" then - if c.block ~= nil then - self.PlayerBlock = self.PlayerBlock + c.block - end -end -``` - -- [ ] **Step 2: DealDamageToTarget / KillMonster 추가, DealDamageToEnemy 제거** - -`DealDamageToEnemy` 메서드를 삭제하고 아래 두 메서드를 추가: - -```js - method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex] -if m == nil or m.alive ~= true then - m = nil - for i = 1, #self.Monsters do - if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end - end -end -if m == nil then - return -end -local dmg = amount -if m.block > 0 then - local absorbed = math.min(m.block, dmg) - m.block = m.block - absorbed - dmg = dmg - absorbed -end -m.hp = m.hp - dmg -if m.hp <= 0 then - m.hp = 0 - self:KillMonster(m.slot) -end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), - method('KillMonster', `local m = self.Monsters[slot] -if m == nil then - return -end -m.alive = false -if m.entity ~= nil and isvalid(m.entity) then - if m.entity.StateComponent ~= nil then - m.entity.StateComponent:ChangeState("DEAD") - end - local ent = m.entity - _TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.6) -end -self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot), false) --- 다음 생존 타겟 자동 선택 -for i = 1, #self.Monsters do - if self.Monsters[i].alive == true then self.TargetIndex = i; break end -end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), -``` - -- [ ] **Step 3: 구문 점검** - -Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` -Expected: `file ok` - -- [ ] **Step 4: Commit** - -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(combat): PlayCard 타겟 몬스터 공격 + 사망 처리/연출" -``` - ---- - -## Task 6: gen-slaydeck — EnemyTurn 멀티 몬스터 - -**Files:** -- Modify: `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: EnemyTurn 교체** - -기존 단일 적 `EnemyTurn`을 생존 몬스터 각자 행동으로 교체: - -```js - method('EnemyTurn', `for i = 1, #self.Monsters do - local m = self.Monsters[i] - if m.alive == true then - m.block = 0 - local intent = m.intents[m.intentIdx] - if intent ~= nil then - if intent.kind == "Attack" then - self:DealDamageToPlayer(intent.value) - elseif intent.kind == "Defend" then - m.block = m.block + intent.value - end - end - m.intentIdx = m.intentIdx + 1 - if m.intentIdx > #m.intents then - m.intentIdx = 1 - end - end -end -self:RenderCombat()`), -``` - -- [ ] **Step 2: 구문 점검** - -Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` -Expected: `file ok` - -- [ ] **Step 3: Commit** - -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(combat): EnemyTurn 생존 몬스터 각자 행동" -``` - ---- - -## Task 7: gen-slaydeck — CheckCombatEnd · RenderCombat · SetTarget · 바인딩 - -**Files:** -- Modify: `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: CheckCombatEnd 승리 조건 교체** - -`if self.EnemyHp <= 0 then` 을 "생존 몬스터 0"으로 교체(이후 보상/노드/막 분기는 그대로 유지): - -```js - method('CheckCombatEnd', `local anyAlive = false -for i = 1, #self.Monsters do - if self.Monsters[i].alive == true then anyAlive = true; break end -end -if anyAlive == false then - self.CombatOver = true - self.Gold = self.Gold + ${GOLD_PER_WIN} - self:ApplyRelics("combatReward") - self:RenderRun() - local node = self.MapNodes[self.CurrentNodeId] - if node ~= nil and node.type == "elite" then - self:AddRelic(self.RelicPool[math.random(1, #self.RelicPool)]) - end - if node ~= nil and node.type == "boss" then - if self.Floor < self.RunLength then - self.Floor = self.Floor + 1 - self.CurrentNodeId = "" - self.CurrentEnemyId = "" - self:RenderRun() - self:ShowMap() - else - self:ShowResult("런 클리어!") - self.RunActive = false - end - else - self:OfferReward() - end -elseif self.PlayerHp <= 0 then - self.CombatOver = true - self:ShowResult("패배...") - self.RunActive = false -end`), -``` - -- [ ] **Step 2: RenderCombat 교체(몬스터 슬롯 렌더)** - -단일 적 패널 갱신 부분을 몬스터 슬롯 렌더로 교체. 플레이어 패널/런 갱신은 유지: - -```js - method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do - local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) - local m = self.Monsters[i] - if m ~= nil and m.alive == true then - self:SetEntityEnabled(base, true) - self:SetText(base .. "/Name", m.name) - self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp)) - local intent = m.intents[m.intentIdx] - local t = "" - if intent ~= nil then - if intent.kind == "Attack" then t = "공격 " .. tostring(intent.value) - elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value) end - end - if i == self.TargetIndex then t = "[타겟] " .. t end - self:SetText(base .. "/Intent", t) - self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp) - else - self:SetEntityEnabled(base, false) - end -end -self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) -self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) -self:RenderRun()`), -``` - -`SetHpBar` 추가(채움 너비 = 비율 × 기준폭 120): - -```js - method('SetHpBar', `local e = _EntityService:GetEntityByPath(path) -if e == nil or e.UITransformComponent == nil then - return -end -local ratio = 0 -if maxHp > 0 then ratio = hp / maxHp end -if ratio < 0 then ratio = 0 end -local w = 120 * ratio -e.UITransformComponent.RectSize = Vector2(w, 14)`, [ - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' }, - ]), -``` - -`PositionMonsterSlot` 추가(SlotPos 화면좌표로 슬롯 배치): - -```js - method('PositionMonsterSlot', `local sp = self.SlotPos -if sp == nil or sp[slot] == nil then - return -end -local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot)) -if e ~= nil and e.UITransformComponent ~= nil then - e.UITransformComponent.anchoredPosition = Vector2(sp[slot].x, sp[slot].y) -end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), -``` - -`SetTarget` 추가: - -```js - method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then - self.TargetIndex = slot - self:RenderCombat() -end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), -``` - -- [ ] **Step 3: BindButtons에 몬스터 슬롯 타겟 클릭 추가** - -`BindButtons` 메서드 본문 끝부분에 슬롯 버튼 바인딩 추가: - -```js -for i = 1, ${MAX_MONSTERS} do - local ms = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i)) - if ms ~= nil and ms.ButtonComponent ~= nil then - ms:ConnectEvent(ButtonClickEvent, function() self:SetTarget(i) end) - end -end -``` - -- [ ] **Step 4: 잔여 단일 적 참조 제거 확인** - -Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');const bad=['EnemyHp','EnemyMaxHp','EnemyIntentIndex','DealDamageToEnemy'].filter(k=>s.includes(k));console.log(bad.length?('잔여:'+bad.join(',')):'clean')"` -Expected: `clean` (CombatHud의 EnemyName/EnemyHp UI 엔티티를 Task 8에서 슬롯으로 대체하므로, 그 외 단일 적 상태 참조는 모두 사라져야 함). - -> 참고: 기존 `upsertUi()`가 만들던 CombatHud의 `EnemyName/EnemyHp/EnemyBlock/EnemyIntent` 엔티티는 Task 8에서 제거한다. RenderCombat에서 더 이상 참조하지 않으므로 남아 있어도 무해하나, 정리한다. - -- [ ] **Step 5: Commit** - -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(combat): 승리조건(전체 처치)·몬스터 슬롯 렌더·HP바·타겟 클릭" -``` - ---- - -## Task 8: gen-slaydeck — 몬스터 슬롯 UI + monster-slots.json - -**Files:** -- Create: `data/monster-slots.json` -- Modify: `tools/deck/gen-slaydeck.mjs` (`upsertUi()` 내 CombatHud 생성부) - -- [ ] **Step 1: monster-slots.json 생성(초기 좌표, 추후 튜닝)** - -화면 상단을 좌→우로 4등분한 초기값. 메이커 플레이로 몬스터 머리 위에 맞게 튜닝(Task 9). - -```json -[ - { "x": -480, "y": 300 }, - { "x": -160, "y": 300 }, - { "x": 160, "y": 300 }, - { "x": 480, "y": 300 } -] -``` - -- [ ] **Step 2: CombatHud의 단일 적 엔티티 제거 + 몬스터 슬롯 생성** - -`upsertUi()`에서 `EnemyBg`/`EnemyName`/`EnemyHp`/`EnemyBlock`/`EnemyIntent` 엔티티 생성 블록(`enemyTexts` 루프 및 EnemyBg push)을 삭제한다. 대신 `MAX_MONSTERS`개의 MonsterSlot을 생성하는 블록을 `combat` 배열에 추가한다(`PlayerBg` 생성 이전 적당한 위치): - -```js - const SLOT_W = 140, SLOT_H = 96; - for (let i = 1; i <= MAX_MONSTERS; i++) { - const base = `/ui/DefaultGroup/CombatHud/MonsterSlot${i}`; - // 슬롯 컨테이너(투명 + 버튼 = 타겟 클릭 영역) - const slot = entity({ - id: guid('cmb', 40 + i), - path: base, - modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', - displayOrder: 20 + i, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: SLOT_H }, pos: { x: (i - 2.5) * 320, y: 300 } }), - sprite({ color: { r: 0, g: 0, b: 0, a: 0.0001 }, type: 1, raycast: true }), - button(), - ], - }); - slot.jsonString.enable = false; - combat.push(slot); - // 이름 - combat.push(entity({ - id: guid('cmb', 60 + i), path: `${base}/Name`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 30 }, pos: { x: 0, y: 34 } }), - sprite({ color: TRANSPARENT }), - text({ value: '', fontSize: 20, bold: true, color: GOLD, alignment: 4 }), - ], - })); - // HP 텍스트 - combat.push(entity({ - id: guid('cmb', 80 + i), path: `${base}/Hp`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 1, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 26 }, pos: { x: 0, y: 6 } }), - sprite({ color: TRANSPARENT }), - text({ value: '', fontSize: 18, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), - ], - })); - // HP바 배경 - combat.push(entity({ - id: guid('cmb', 100 + i), path: `${base}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 2, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 14 }, pos: { x: 0, y: -14 } }), - sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }), - ], - })); - // HP바 채움(좌측 정렬: pivot x=0) - combat.push(entity({ - id: guid('cmb', 120 + i), path: `${base}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 3, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: 120, y: 14 }, pos: { x: -60, y: -14 } }), - sprite({ color: { r: 0.86, g: 0.35, b: 0.32, a: 1 }, type: 1 }), - ], - })); - // 의도 텍스트 - combat.push(entity({ - id: guid('cmb', 140 + i), path: `${base}/Intent`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 4, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 40, y: 24 }, pos: { x: 0, y: -36 } }), - sprite({ color: TRANSPARENT }), - text({ value: '', fontSize: 17, bold: true, color: { r: 1, g: 0.72, b: 0.5, a: 1 }, alignment: 4 }), - ], - })); - } -``` - -> `guid('cmb', N)` 충돌 주의: 기존 CombatHud는 `cmb` 0~약 15번대를 사용한다. 위에서 40~140+ 대역을 써서 충돌을 피한다. 기존 코드가 더 큰 번호를 쓰면 대역을 올린다(생성 후 `JSON.parse` 검증으로 중복 id 없는지 확인). - -- [ ] **Step 3: upsertUi가 SLOTS 길이와 MAX_MONSTERS 일치 가정 — 검증 주석/단언 추가** - -`upsertUi()` 시작부에 안전 단언 추가: - -```js - if (SLOTS.length < MAX_MONSTERS) { - throw new Error(`[gen-slaydeck] monster-slots.json 항목(${SLOTS.length}) < MAX_MONSTERS(${MAX_MONSTERS})`); - } -``` - -- [ ] **Step 4: 구문 점검** - -Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` -Expected: `file ok` - -- [ ] **Step 5: Commit** - -```bash -git add data/monster-slots.json tools/deck/gen-slaydeck.mjs -git commit -m "feat(combat-ui): 몬스터 슬롯 UI(HP바·의도·타겟버튼) + monster-slots.json" -``` - ---- - -## Task 9: 재생성 · 결정성 검증 · 메이커 플레이테스트 - -**Files:** -- 산출: `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`, `map/*.map` - -- [ ] **Step 1: 전체 생성기 재실행** - -Run(루트에서 순서대로): -```bash -node tools/monster/gen-combat-monster.mjs -node tools/deck/gen-slaydeck.mjs -``` -Expected: 둘 다 에러 없이 완료. gen-slaydeck는 UI/codeblock/common 재생성. - -- [ ] **Step 2: 산출물 JSON 유효성 + 중복 id 없음** - -Run: `node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock']){JSON.parse(fs.readFileSync(f,'utf8'))};const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const ids=ui.ContentProto.Entities.map(e=>e.id);const dup=ids.filter((x,i)=>ids.indexOf(x)!==i);console.log(dup.length?('중복 id:'+dup.join(',')):'ok no dup')"` -Expected: `ok no dup` - -- [ ] **Step 3: 몬스터 슬롯 엔티티 생성 확인** - -Run: `node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log(ui.ContentProto.Entities.filter(e=>e.path.includes('/CombatHud/MonsterSlot')).map(e=>e.path).join('\n'))"` -Expected: MonsterSlot1~4 및 각 하위(Name/Hp/HpBarBg/HpBarFill/Intent) 경로 출력. - -- [ ] **Step 4: 결정성(2회 실행 동일)** - -Run: `node tools/deck/gen-slaydeck.mjs && git diff --stat ui/ Global/ RootDesk/` -Expected: 1회차 대비 추가 변경 없음(결정적). - -- [ ] **Step 5: sim 테스트 재확인** - -Run: `node --test tools/balance/sim-balance.test.mjs` -Expected: PASS. - -- [ ] **Step 6: 메이커 플레이테스트 (수동/MCP)** - -MSW Maker에서 로컬 워크스페이스 reload 후 Play. 확인 항목: -1. 전투 진입 시 맵 몬스터 위에 슬롯(이름·HP바·의도)이 뜨는가. (안 맞으면 `data/monster-slots.json` 좌표 튜닝 → `node tools/deck/gen-slaydeck.mjs` 재실행 → reload) -2. 몬스터 클릭 시 `[타겟]` 표시가 이동하는가. -3. 공격 카드가 현재 타겟의 HP를 깎고, HP 0이면 die 애니 후 사라지는가. -4. 적 턴에 생존 몬스터가 각자 플레이어를 공격하는가. -5. 모든 몬스터 처치 시 승리 → 보상/맵 흐름 진입, 플레이어 HP 0이면 패배. - -> 월드 API 리스크 검증: `TransformComponent.WorldPosition`, 외부 엔티티 `StateComponent:ChangeState`, `SetVisible/SetEnable` 동작 여부. 미동작 시: (a) 정렬용 x를 `Position`으로 대체, (b) 사망 연출을 가시성만으로 처리, (c) 슬롯 위치는 좌표 데이터로 이미 독립. - -- [ ] **Step 7: 좌표 튜닝 반영 후 최종 커밋** - -```bash -git add data/monster-slots.json ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock RootDesk/MyDesk/CombatMonster.codeblock map/ -git commit -m "feat(combat): 맵 몬스터 카드 전투 재생성 + 슬롯 좌표 튜닝" -``` - ---- - -## Self-Review 결과 (작성자 점검) - -- **스펙 커버리지**: 타겟 클릭(Task 7 SetTarget/바인딩), 멀티 HP+의도(Task 2 sim·Task 4~6 Lua), 런 연동(Task 7 CheckCombatEnd 기존 분기 유지), 스탯 enemies.json+막배율(Task 1·4 BuildMonsters), 월드 HP바 표시(Task 7·8), 컨트롤러 단일 소유(Task 4~7), CombatMonster 매핑(Task 3) — 전 항목 매핑됨. 스펙의 "노드 타입 배율(선택)"은 MVP에서 막 배율만 적용(BuildMonsters), 노드 타입 가산은 후속. -- **플레이스홀더**: enemies.json 수치는 의도된 placeholder(§sim으로 튜닝). 슬롯 좌표는 초기값+튜닝 단계 명시. 코드 단계는 실제 코드 포함. -- **타입/이름 일관성**: `Monsters`/`Registered`/`TargetIndex`/`SlotPos`, 메서드 `RegisterMonster`/`BuildMonsters`/`ReviveMonsterEntity`/`DealDamageToTarget`/`KillMonster`/`EnemyTurn`/`CheckCombatEnd`/`RenderCombat`/`SetHpBar`/`PositionMonsterSlot`/`SetTarget` — Task 간 명칭 일치. UI 경로 `/ui/DefaultGroup/CombatHud/MonsterSlot{i}/{Name,Hp,HpBarBg,HpBarFill,Intent}` 일치. -- **알려진 리스크**: MSW 월드 API(WorldPosition/StateComponent/SetVisible)는 Task 9 Step 6에서 검증·폴백. diff --git a/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md b/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md deleted file mode 100644 index 2588c72..0000000 --- a/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md +++ /dev/null @@ -1,361 +0,0 @@ -# 노드 타입별 몬스터 그룹 구현 계획 - -> **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:** 한 맵에서 노드 타입(combat/elite/boss)에 따라 해당 그룹의 몬스터만 등장시킨다. - -**Architecture:** 각 맵 몬스터의 `script.CombatMonster`에 `Group` 태그를 두고, 전투 시작 시 `BuildMonsters`가 현재 노드 타입으로 필터해 일치 그룹만 표시·전투 구성한다. HP바 슬롯 좌표는 `data/monster-slots.json`에 그룹별로 둔다. Group/EnemyId는 메이커 인스펙터에서 저작하므로 생성기는 값을 덮어쓰지 않는다. - -**Tech Stack:** Node.js ESM 생성기(.mjs), MSW Lua codeblock, JSON(.codeblock/.map/data). - ---- - -## 배경 / 현재 코드 (구현자용) - -- 생성물(`SlayDeckController.codeblock`·`ui/DefaultGroup.ui`·`common.gamelogic`)은 `tools/deck/gen-slaydeck.mjs`에서만, `CombatMonster.codeblock`+맵 패치는 `tools/monster/gen-combat-monster.mjs`에서만 생성한다(직접 편집 금지). 루트에서 `node tools/<폴더>/<파일>.mjs` 실행. -- 현재 `BuildMonsters`는 등록된 몬스터를 노드 타입과 무관하게 전부 사용. 노드 타입은 `self.MapNodes[self.CurrentNodeId].type`로 접근(combat/elite/boss). -- 현재 `data/monster-slots.json`은 평면 배열 `[{x,y}×4]`. `MAX_MONSTERS = 4`(gen-slaydeck.mjs 상수). -- 전투 규칙(타겟/공격/적턴/승리)은 이 기능에서 **변경하지 않는다**. 따라서 sim/테스트 변경 없음(회귀만 확인). - -## 파일 구조 - -| 파일 | 책임 | 변경 | -|------|------|------| -| `data/monster-slots.json` | 그룹별 HP바 슬롯 좌표 | 평면 배열 → `{combat,elite,boss}` 객체 | -| `tools/monster/gen-combat-monster.mjs` | CombatMonster 코드블록 + 맵 부착 | Group 프로퍼티·등록 인자 추가, 부착을 **값 보존(no-clobber)**로 변경 | -| `RootDesk/MyDesk/CombatMonster.codeblock` | 생성물 | Group 프로퍼티·register(group) | -| `tools/deck/gen-slaydeck.mjs` | 컨트롤러 생성 | SLOTS 객체 처리·StartRun 그룹별 주입·`ActiveSlotPos`·`PositionMonsterSlot`·`RegisterMonster(group)`·`BuildMonsters` 필터 | -| 생성물 | `SlayDeckController.codeblock` 재생성 | (ui 변경 없음) | -| `map/map01.map` 외 | 몬스터 그룹 태그 | 메이커 저작(코드 외) | - ---- - -## Task 1: monster-slots.json 그룹별 구조 - -**Files:** Modify `data/monster-slots.json` - -- [ ] **Step 1: 그룹별 좌표 객체로 교체** - -`data/monster-slots.json` 전체를 아래로 교체(각 그룹 4좌표; 추후 플레이테스트로 튜닝): -```json -{ - "combat": [ - { "x": 430, "y": 140 }, - { "x": 600, "y": 140 }, - { "x": 770, "y": 140 }, - { "x": 900, "y": 140 } - ], - "elite": [ - { "x": 430, "y": 160 }, - { "x": 650, "y": 160 }, - { "x": 850, "y": 160 }, - { "x": 980, "y": 160 } - ], - "boss": [ - { "x": 520, "y": 200 }, - { "x": 760, "y": 160 }, - { "x": 940, "y": 150 }, - { "x": 1040, "y": 150 } - ] -} -``` - -- [ ] **Step 2: JSON 유효성 확인** - -Run: `node -e "const s=JSON.parse(require('fs').readFileSync('data/monster-slots.json','utf8'));console.log(['combat','elite','boss'].map(g=>g+':'+s[g].length).join(' '))"` -Expected: `combat:4 elite:4 boss:4` - -- [ ] **Step 3: Commit** -```bash -git add data/monster-slots.json -git commit -m "feat(node-groups): monster-slots.json 을 그룹별 좌표 구조로" -``` - ---- - -## Task 2: CombatMonster 에 Group + 생성기 no-clobber - -**Files:** Modify `tools/monster/gen-combat-monster.mjs` (생성물 `RootDesk/MyDesk/CombatMonster.codeblock`, `map/*.map`) - -- [ ] **Step 1: 코드블록에 Group 프로퍼티 추가** - -`tools/monster/gen-combat-monster.mjs`의 `Properties` 줄을 교체: -```js - Properties: [prop('string', 'EnemyId', '""'), prop('string', 'Group', '"combat"'), prop('number', 'RegTries', '0')], -``` - -- [ ] **Step 2: OnBeginPlay 등록 호출에 Group 전달** - -`OnBeginPlay` Lua의 등록 줄을 교체: -``` - c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group) -``` -(나머지 OnBeginPlay 본문은 그대로) - -- [ ] **Step 3: patchMap 을 값 보존(no-clobber)로 교체** - -`patchMap` 함수 전체를 아래로 교체. 기존 `script.CombatMonster`가 있으면 사용자가 인스펙터에서 설정한 `EnemyId`/`Group`을 보존하고, 없을 때만 기본값으로 부착한다: -```js -function patchMap(nn) { - const tag = String(nn).padStart(2, '0'); - const file = `map/map${tag}.map`; - const map = JSON.parse(readFileSync(file, 'utf8')); - let added = 0, kept = 0; - for (const e of map.ContentProto.Entities.filter(isMonster)) { - const comps = e.jsonString && e.jsonString['@components']; - if (!Array.isArray(comps)) { - console.warn(`[gen-combat-monster] entity "${(e.jsonString && e.jsonString.name) || e.path}" has no @components — skipped`); - continue; - } - const name = (e.jsonString && e.jsonString.name) || ''; - const existing = comps.find((c) => c['@type'] === 'script.CombatMonster'); - if (existing) { - // 사용자가 메이커에서 설정한 값 보존 — 누락된 키만 기본값 채움 - if (existing.Enable === undefined) existing.Enable = true; - if (existing.EnemyId === undefined) existing.EnemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY; - if (existing.Group === undefined) existing.Group = 'combat'; - kept++; - } else { - comps.push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: NAME_TO_ENEMY[name] || DEFAULT_ENEMY, Group: 'combat' }); - added++; - } - const names = (e.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster'); - names.push('script.CombatMonster'); - e.componentNames = names.join(','); - } - writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); - return `map${tag}(+${added}/keep${kept})`; -} -``` - -- [ ] **Step 4: 실행 + 값 보존 검증** - -Run: `node tools/monster/gen-combat-monster.mjs` -Expected: `... patched maps: map01(+0/keep3), map02(+0/keep2), ...` (기존 몬스터는 이미 CombatMonster 보유 → keep, EnemyId 보존 + Group 기본값 주입). - -Run: `node -e "const m=JSON.parse(require('fs').readFileSync('map/map01.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));console.log(ms.map(e=>{const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');return e.jsonString.name+':'+c.EnemyId+'/'+c.Group;}).join(', '))"` -Expected: 각 몬스터 `이름:EnemyId/combat` (기존 EnemyId 보존, Group=combat 주입). - -- [ ] **Step 5: 멱등 + 코드블록 Group 확인** - -Run: `node tools/monster/gen-combat-monster.mjs` (2회차) — map 재실행에도 값 동일(no-clobber). -Run: `node -e "const c=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/CombatMonster.codeblock','utf8'));const p=c.ContentProto.Json.Properties.map(x=>x.Name);console.log('props:',p.join(','),'| register has Group:',/RegisterMonster\(self.Entity, self.EnemyId, self.Group\)/.test(c.ContentProto.Json.Methods[0].Code))"` -Expected: `props: EnemyId,Group,RegTries | register has Group: true` - -- [ ] **Step 6: Commit** -```bash -git add tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ -git commit -m "feat(node-groups): CombatMonster 에 Group + 생성기 값 보존(no-clobber)" -``` - ---- - -## Task 3: gen-slaydeck — SLOTS 객체 플러밍 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs`. **생성기는 Task 5까지 실행 금지**, `node --check`만. - -- [ ] **Step 1: upsertUi 슬롯 단언 교체** - -`upsertUi()` 시작부의 단언을 SLOTS 객체용으로 교체: -```js - for (const g of ['combat', 'elite', 'boss']) { - if (!Array.isArray(SLOTS[g]) || SLOTS[g].length < 1) { - throw new Error(`[gen-slaydeck] monster-slots.json 의 "${g}" 그룹 좌표가 없습니다`); - } - } -``` -(기존 `if (SLOTS.length < MAX_MONSTERS) { throw ... }` 블록을 위 코드로 대체) - -- [ ] **Step 2: StartRun 의 SlotPos 주입을 그룹별로 교체** - -`StartRun` Lua의 다음 줄 -``` -self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} } -``` -을 아래 헬퍼 기반 그룹별 주입으로 교체. 파일 상단(다른 헬퍼 함수 근처)에 헬퍼 추가: -```js -function luaSlotGroup(arr) { - return '{ ' + arr.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ') + ' }'; -} -``` -그리고 StartRun 주입 줄을: -```js -self.SlotPos = { combat = ${luaSlotGroup(SLOTS.combat)}, elite = ${luaSlotGroup(SLOTS.elite)}, boss = ${luaSlotGroup(SLOTS.boss)} } -``` - -- [ ] **Step 3: ActiveSlotPos 프로퍼티 추가** - -prop 목록에서 `prop('any', 'SlotPos'),` 다음 줄에 추가: -```js - prop('any', 'ActiveSlotPos'), -``` - -- [ ] **Step 4: PositionMonsterSlot 이 ActiveSlotPos 를 쓰도록 교체** - -`PositionMonsterSlot` 메서드 본문 첫 줄 `local sp = self.SlotPos` 를 교체: -``` -local sp = self.ActiveSlotPos -``` -(나머지 본문 동일 — `sp[slot]` 사용) - -- [ ] **Step 5: 구문 점검** - -Run: `node --check tools/deck/gen-slaydeck.mjs` → 출력 없음(유효). -Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');console.log('luaSlotGroup:',s.includes('function luaSlotGroup'),'| ActiveSlotPos prop:',s.includes(\"'ActiveSlotPos'\"),'| SlotPos combat=:',s.includes('combat = '))"` -Expected: 모두 true. - -- [ ] **Step 6: Commit** -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(node-groups): 그룹별 슬롯 좌표 플러밍 (SlotPos/ActiveSlotPos)" -``` - ---- - -## Task 4: gen-slaydeck — RegisterMonster(group) + BuildMonsters 필터 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs`. 생성기 실행 금지, `node --check`만. - -- [ ] **Step 1: RegisterMonster 에 group 인자 추가** - -기존 -```js - method('RegisterMonster', `if self.Registered == nil then - self.Registered = {} -end -table.insert(self.Registered, { entity = monster, enemyId = enemyId })`, [ - { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' }, - ]), -``` -를 아래로 교체: -```js - method('RegisterMonster', `if self.Registered == nil then - self.Registered = {} -end -local g = group -if g == nil or g == "" then g = "combat" end -table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g })`, [ - { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'group' }, - ]), -``` - -- [ ] **Step 2: BuildMonsters 를 노드 타입 필터로 교체** - -`BuildMonsters` 메서드 전체를 아래로 교체(현재 노드 타입으로 그룹 결정, 전체 숨김 후 일치 그룹만 표시, 그룹 슬롯 좌표 사용). `${MAX_MONSTERS}`는 JS 상수 보간: -```js - method('BuildMonsters', `self.Monsters = {} -local g = "combat" -local node = self.MapNodes[self.CurrentNodeId] -if node ~= nil and node.type ~= nil then g = node.type end -self.ActiveSlotPos = self.SlotPos[g] -local reg = self.Registered or {} --- 모든 등록 몬스터 숨김 -for i = 1, #reg do - if reg[i].entity ~= nil and isvalid(reg[i].entity) then - reg[i].entity:SetVisible(false) - end -end --- 현재 그룹만 추려 월드 x 정렬 -local list = {} -for i = 1, #reg do - local r = reg[i] - if r.entity ~= nil and isvalid(r.entity) and r.group == g then - local x = 0 - if r.entity.TransformComponent ~= nil then - x = r.entity.TransformComponent.WorldPosition.x - end - table.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x }) - end -end -table.sort(list, function(a, b) return a.x < b.x end) -local mult = 1 + (self.Floor - 1) * 0.6 -local n = #list -if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end -for i = 1, n do - local item = list[i] - local e = self.Enemies[item.enemyId] - if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end - local intents = {} - for k = 1, #e.intents do - intents[k] = { kind = e.intents[k].kind, value = math.floor(e.intents[k].value * mult) } - end - local maxHp = math.floor(e.maxHp * mult) - self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name, - hp = maxHp, maxHp = maxHp, block = 0, intents = intents, intentIdx = 1, alive = true, slot = i } - self:ReviveMonsterEntity(item.entity) - self:PositionMonsterSlot(i) -end -self.TargetIndex = 1`), -``` - -- [ ] **Step 3: 구문 점검 + 필터 반영 확인** - -Run: `node --check tools/deck/gen-slaydeck.mjs` → 유효. -Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');const i=s.indexOf(\"'BuildMonsters'\");const seg=s.slice(i,i+1200);console.log('node.type group:',seg.includes('node.type'),'| group filter:',seg.includes('r.group == g'),'| hide all:',seg.includes('SetVisible(false)'),'| ActiveSlotPos:',seg.includes('self.ActiveSlotPos = self.SlotPos[g]'));console.log('RegisterMonster 3 args:',/RegisterMonster[\\s\\S]{0,400}Name: 'group'/.test(s));"` -Expected: 모두 true. - -- [ ] **Step 4: Commit** -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(node-groups): RegisterMonster(group) + BuildMonsters 노드 타입 필터" -``` - ---- - -## Task 5: 재생성 · 검증 · 플레이테스트 - -**Files:** 산출 `SlayDeckController.codeblock` 등 - -- [ ] **Step 1: 생성기 재실행** - -Run: -```bash -node tools/monster/gen-combat-monster.mjs -node tools/deck/gen-slaydeck.mjs -``` -Expected: 둘 다 에러 없이 완료. - -- [ ] **Step 2: 산출물 검증** - -Run: `node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const c=JSON.parse(fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const sc=JSON.stringify(c);console.log('SlotPos combat/elite/boss:',sc.includes('combat = {')&&sc.includes('elite = {')&&sc.includes('boss = {'),'| group filter:',sc.includes('r.group == g'));"` -Expected: `SlotPos combat/elite/boss: true | group filter: true` - -- [ ] **Step 3: 결정성** - -Run: `git add -A && node tools/deck/gen-slaydeck.mjs && git diff --stat RootDesk/MyDesk/SlayDeckController.codeblock ui/DefaultGroup.ui Global/common.gamelogic` -Expected: 비어 있음(결정적). - -- [ ] **Step 4: sim 회귀** - -Run: `node --test tools/balance/sim-balance.test.mjs` -Expected: 전체 통과(규칙 불변). - -- [ ] **Step 5: Commit (산출물)** -```bash -git add RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "feat(node-groups): 컨트롤러 재생성 (그룹 필터·그룹 슬롯)" -``` - -- [ ] **Step 6: 메이커 플레이테스트 (수동/MCP)** - -먼저 map01에 그룹을 저작(메이커): 일반/엘리트/보스 몬스터를 배치하고 각 `CombatMonster`의 Group(combat/elite/boss)·EnemyId 지정. (또는 검증 목적이면 기존 3마리에 Group을 각각 combat/elite/boss로 임시 지정.) -그 후 reload→Play 확인: -1. combat 노드 진입 → Group=combat 몬스터만 표시, 나머지 숨김. -2. elite 노드 → 엘리트(+졸개) 그룹만. -3. boss 노드 → 보스(+졸개) 그룹만. -4. 각 그룹 슬롯(HP바·의도)이 해당 몬스터 위에 표시(좌표 안 맞으면 `data/monster-slots.json` 그룹 좌표 튜닝 → 재생성 → reload). -5. 전투/타겟/승리 흐름 정상. - -> MCP 검증 보조: `execute_script`로 `RegisterMonster` 후 `self.Registered[i].group` 확인, `CurrentNodeId`를 각 타입 노드로 두고 `BuildMonsters()` 호출 → `self.Monsters` 가 해당 그룹만 담는지 로그. - ---- - -## Self-Review 결과 (작성자 점검) - -- **스펙 커버리지**: Group 태그(Task2), 그룹별 슬롯 좌표(Task1·3), no-clobber 저작(Task2), RegisterMonster(group)(Task2·4), BuildMonsters 노드 타입 필터·전체 숨김·그룹 슬롯(Task4), 재생성·검증(Task5) — 전부 매핑됨. sim 불변(Task5 회귀). -- **플레이스홀더**: 슬롯 좌표는 초기값+튜닝 명시. 코드 단계는 실제 코드 포함. 메이커 그룹 저작은 코드 외 수동 단계로 명시. -- **타입/이름 일관성**: `Group`(string), `RegisterMonster(monster, enemyId, group)`, `Registered{entity,enemyId,group}`, `ActiveSlotPos`, `SlotPos.{combat,elite,boss}`, `BuildMonsters`의 `g = node.type` — Task 간 일치. `MAX_MONSTERS` 보간 유지. -- **리스크**: 그룹 좌표 수 < 몬스터 수면 초과 슬롯 미배치(기본 4좌표로 완화). 비combat/rest 노드만 StartCombat 호출되므로 g∈{combat,elite,boss}. MSW 월드 API는 기존과 동일(검증됨). diff --git a/docs/superpowers/plans/2026-06-11-act-maps.md b/docs/superpowers/plans/2026-06-11-act-maps.md deleted file mode 100644 index 4756f83..0000000 --- a/docs/superpowers/plans/2026-06-11-act-maps.md +++ /dev/null @@ -1,160 +0,0 @@ -# 막별 맵 전환 + 맵별 인카운터 (P4) 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. T3·T4는 컨트롤러 직접. - -**Goal:** 보스 클리어 시 다음 막의 맵(map02, map03)으로 텔레포트하고, 각 맵에 테마 몬스터 그룹(combat3/elite2/boss1)을 자동 구성. - -**Architecture:** 신규 `tools/map/gen-map-encounters.mjs`가 map02~11 몬스터를 전면 교체(결정론). 컨트롤러는 `RegisterMonster`에 mapName 차원 추가 + `BuildMonsters` 플레이어 맵 필터 + 보스 클리어 시 `TeleportToActMap`. - ---- - -## 배경 (구현자용) -- 생성물은 단일 소스 규칙(gen-slaydeck → ui/codeblock/common). 맵 파일은 전용 생성기가 직접 패치. 산출물(slaydeck 3종)은 마지막에 일괄, **맵 파일은 T1에서 바로 커밋**. -- 현재 `RegisterMonster(monster, enemyId, group)`(3인자), `BuildMonsters`는 `r.group == g` 필터. `CombatMonster.codeblock`은 `tools/monster/gen-combat-monster.mjs`가 생성(OnBeginPlay에서 3인자 등록). -- gen-maps의 `MONSTER_VARIANTS` 9종(sprite/stand/hit/die RUID)과 `mapGuid(nn, idx)`·`rng(seed)` 패턴 참조: `tools/map/gen-maps.mjs`. -- CheckCombatEnd 보스 분기: `self.Floor = self.Floor + 1 ... self:ShowMap()`. -- JS 상수: writeCodeblocks 안 `ACT_COUNT = 3` 존재. - -## Task 1: gen-map-encounters.mjs (map02~11 인카운터) - -**Files:** Create `tools/map/gen-map-encounters.mjs`; Modify(산출) `map/map02.map`~`map11.map` - -- [ ] **Step 1: 생성기 작성.** `tools/map/gen-maps.mjs`를 READ해 `MONSTER_VARIANTS`(9종 배열 — 그대로 복사)·`rng`·`mapGuid` 패턴을 가져와 아래 구조로 작성: -```js -import { readFileSync, writeFileSync } from 'node:fs'; - -// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성. -// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론). -const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; -const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom']; -const ELITE_POOL = ['mushmom', 'modified_snail']; -const BOSS_POOL = ['king_slime', 'slime_boss']; -const LAYOUT = [ - { group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 }, - { group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 }, - { group: 'boss', x: 4.0 }, -]; -const MONSTER_VARIANTS = [ /* gen-maps.mjs에서 9종 그대로 복사 */ ]; - -function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } -function encGuid(nn, idx) { - const n = (nn * 1000 + 500 + idx) >>> 0; // gen-maps의 mapGuid(idx 0~)와 비충돌(+500 오프셋) - return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; -} -const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster'); -const compOf = (e, t) => e.jsonString['@components'].find((c) => c['@type'] === t); - -function pick(rand, pool) { return pool[Math.floor(rand() * pool.length)]; } -function pickN(rand, pool, n) { // 중복 없는 n개(부족하면 순환) - const a = pool.slice(); - const out = []; - for (let i = 0; i < n; i++) { - if (a.length === 0) a.push(...pool); - out.push(a.splice(Math.floor(rand() * a.length), 1)[0]); - } - return out; -} - -function patchMap(nn) { - const tag = String(nn).padStart(2, '0'); - const file = `map/map${tag}.map`; - const map = JSON.parse(readFileSync(file, 'utf8')); - const ents = map.ContentProto.Entities; - const monsters = ents.filter(isMonster); - if (monsters.length === 0) throw new Error(`[gen-map-encounters] ${file} 몬스터 템플릿 없음`); - const template = monsters[0]; - map.ContentProto.Entities = ents.filter((e) => !isMonster(e)); - const rand = rng(nn * 7919 + 17); - const combatIds = pickN(rand, COMBAT_POOL, 3); - const eliteIds = pickN(rand, ELITE_POOL, 2); - const bossId = pick(rand, BOSS_POOL); - const variants = pickN(rand, MONSTER_VARIANTS, 6); - LAYOUT.forEach((slot, idx) => { - const m = JSON.parse(JSON.stringify(template)); - const enemyId = slot.group === 'combat' ? combatIds[idx] : slot.group === 'elite' ? eliteIds[idx - 3] : bossId; - const name = `${slot.group}_${idx + 1}`; - m.id = encGuid(nn, idx); - m.path = `/maps/map${tag}/${name}`; - m.jsonString.path = m.path; - m.jsonString.name = name; - const o = m.jsonString.origin; - if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; } - const tr = compOf(m, 'MOD.Core.TransformComponent'); - if (tr && tr.Position) tr.Position.x = slot.x; - const v = variants[idx]; - const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); - if (sp) sp.SpriteRUID = v.stand; - const sa = compOf(m, 'MOD.Core.StateAnimationComponent'); - if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die }; - let cm = compOf(m, 'script.CombatMonster'); - if (!cm) { - cm = { '@type': 'script.CombatMonster', Enable: true }; - m.jsonString['@components'].push(cm); - const names = (m.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster'); - names.push('script.CombatMonster'); - m.componentNames = names.join(','); - } - cm.EnemyId = enemyId; - cm.Group = slot.group; - map.ContentProto.Entities.push(m); - }); - writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); - return `map${tag}(${combatIds.join('/')}|${eliteIds.join('/')}|${bossId})`; -} - -const made = MAP_NUMBERS.map(patchMap); -console.log('Encounters:', made.join(', ')); -``` -- [ ] **Step 2:** 실행 + 검증: 각 맵 6마리·그룹 3/2/1·EnemyId 전부 enemies.json 존재·dup guid 0: -`node tools/map/gen-map-encounters.mjs && node -e "const en=JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')).enemies;let bad=0;for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const m=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));const g={combat:0,elite:0,boss:0};for(const e of ms){const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');g[c.Group]++;if(!en[c.EnemyId]){bad++;console.log('BAD enemy',t,c.EnemyId);}}if(!(g.combat===3&&g.elite===2&&g.boss===1)){bad++;console.log('BAD groups',t,JSON.stringify(g));}const ids=m.ContentProto.Entities.map(e=>e.id);if(ids.length!==new Set(ids).size){bad++;console.log('DUP guid',t);}}console.log(bad===0?'all maps OK':'BAD:'+bad)"` -2회 실행 동일(결정론) 확인. -- [ ] **Step 3:** Commit: `git add tools/map/gen-map-encounters.mjs map/ && git commit -m "feat(act-maps): map02~11 인카운터 자동 구성 (combat3/elite2/boss1·맵별 테마)"` - -## Task 2: 컨트롤러 — 맵 필터 + 막 텔레포트 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs`, `tools/monster/gen-combat-monster.mjs` - -- [ ] **Step 1 (gen-combat-monster):** OnBeginPlay 등록 호출을 4인자로 — 자기 맵 이름 전달: -``` - local mapName = "" - if self.Entity.CurrentMapName ~= nil then - mapName = self.Entity.CurrentMapName - end - c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName) -``` -(reg 함수 내 기존 RegisterMonster 줄 교체. CurrentMapName 미지원이면 빈 문자열 — BuildMonsters에서 빈 값은 항상 통과시켜 하위 호환.) -- [ ] **Step 2 (gen-slaydeck):** `RegisterMonster`에 4번째 인자 `mapName`(string) 추가, 저장 항목에 `map = mapName`(nil/빈 처리: `local mp = mapName; if mp == nil then mp = "" end`). -- [ ] **Step 3 (gen-slaydeck BuildMonsters):** 그룹 필터 줄을 확장: -``` -local pmap = "" -local lp = _UserService.LocalPlayer -if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end -``` -(reg 수집 루프 앞에 추가) 그리고 필터 조건을 `r.group == g and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap)` 로. -- [ ] **Step 4 (gen-slaydeck 막 전환):** writeCodeblocks에 `const ACT_MAPS = ['map01', 'map02', 'map03'];` 추가(ACT_COUNT 옆). `CheckCombatEnd` 보스 분기의 `self:RenderRun()` 다음, `self:ShowMap()` **앞**에 `self:TeleportToActMap()` 삽입. 신규 메서드: -```js - method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} } -local target = maps[self.Floor] -if target == nil then - return -end -local lp = _UserService.LocalPlayer -if lp == nil then - return -end -if lp.CurrentMapName == target then - return -end -_TeleportService:TeleportToMapPosition(lp, Vector3(-6, 0.03, 0), target)`), -``` -- [ ] **Step 5:** `node --check` 둘 다 → gen-combat-monster 실행(코드블록 재생성+맵 no-clobber 확인) → gen-slaydeck 실행 → codeblock에 TeleportToActMap·4인자 등록·맵 필터 확인 → **slaydeck 산출물 복원**(codeblock/ui/common), CombatMonster.codeblock은 커밋 대상. -- [ ] **Step 6:** Commit: `git add tools/deck/gen-slaydeck.mjs tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ && git commit -m "feat(act-maps): 막별 맵 텔레포트 + 등록 맵 필터"` (map/은 gen-combat-monster 재실행이 기존 맵 값 보존하므로 변화 없을 것 — 변화 있으면 확인 후 포함) - -## Task 3 (컨트롤러 직접): 재생성·검증·커밋 -P2/P3 T5와 동일: gen-slaydeck 재생성→dup0·심볼(TeleportToActMap)·결정성·sim→산출물 커밋. - -## Task 4 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지 -1막 보스 처치(스크립트)→Floor2 텔레포트→map02 도착(스크린샷: 새 배경·새 몬스터들)→전투 진입(combat 그룹 3마리·새 EnemyId/외형)→registered 맵 필터 로그. 통과 후 푸시→PR→머지. - -## Self-Review -- 스펙 §2.1→T2 Step4, §2.2→T2 1~3, §2.3→T1. encGuid +500 오프셋은 gen-maps idx(몬스터 2~)와 비충돌. CombatMonster 값은 T1이 직접 태그(no-clobber 생성기와 호환 — 이미 존재라 keep). CurrentMapName 불확실성은 빈 값 통과 폴백으로 하위 호환(T4 검증). diff --git a/docs/superpowers/plans/2026-06-11-card-visuals.md b/docs/superpowers/plans/2026-06-11-card-visuals.md deleted file mode 100644 index 35ac2e7..0000000 --- a/docs/superpowers/plans/2026-06-11-card-visuals.md +++ /dev/null @@ -1,212 +0,0 @@ -# 메이플 스킬 카드 비주얼 (P2) 구현 계획 - -> **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. **Task 1·6은 메이커 MCP 인터랙티브 작업 — 컨트롤러가 직접 수행.** - -**Goal:** 카드(손패/보상/상점/인스펙터/모든덱)에 메이플 스킬 이미지+프레임을 입히고 이름을 전사 스킬명으로 바꾼다 (효과·밸런스 불변). - -**Architecture:** `data/cards.json`에 `image`(공식 RUID)·새 `name`. `gen-slaydeck.mjs`가 카드 자식 엔티티(Art/NamePlate/CostPlate)를 5표면에 생성하고, 런타임 렌더는 새 `ApplyCardFace(base, cardId)` 단일 헬퍼로 통일(기존 4개 Apply* 함수가 위임). 공식 RUID는 로컬 워크스페이스에서 렌더됨이 실측 검증됨(스펙 §1). - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock, asset_search_resources MCP(수확), maker MCP(선별·검증). - ---- - -## 배경 (구현자용) - -- 생성물 3종은 `tools/deck/gen-slaydeck.mjs` 단일 소스(직접 편집 금지). 루트에서 `node tools/deck/gen-slaydeck.mjs`. 각 Task 검증 후 산출물 복원(`git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`), 산출물 커밋은 T5. -- 카드 렌더 함수 4종(현재 동일 모양): `ApplyCardVisual`(손패 `/ui/DefaultGroup/CardHand/Card{slot}`), `ApplyRewardVisual`(`/RewardHud/Reward{slot}`), `ApplyInspectCardVisual`(`/DeckInspectHud/Grid/Card{slot}`), `ApplyAllDeckCardVisual`(`/DeckAllHud/Grid/Card{slot}`). 각각 Cost/Name/Desc SetText + 루트 색. -- 카드 크기: 손패/보상/상점 180×250(`CARD_W`/`CARD_H`), 그리드(인스펙터/모든덱) 셀 158×214. -- 색: ATTACK {0.86,0.42,0.38} / DEFEND {0.42,0.55,0.85} / SKILL {0.46,0.68,0.52} (luaCardsTable의 kind 기반). - -## 파일 구조 -| 파일 | 책임 | -|---|---| -| `data/cards.json` | name 3종 변경 + image RUID 3종 (T1) | -| `tools/deck/gen-slaydeck.mjs` | luaCardsTable image·ApplyCardFace·4함수 통일(T2), 카드 프레임 엔티티 5표면(T3·T4) | -| 산출물 | T5 재생성·커밋 | - ---- - -## Task 1 (컨트롤러 직접): RUID 수확 + cards.json - -메이커 MCP 인터랙티브 — subagent 금지, 컨트롤러가 수행. - -- [ ] **Step 1:** `asset_search_resources`(cat=sprite, source=maplestory)로 후보 수집: 질의 "파워 스트라이크"(이미 10건 확보), "슬래시 블러스트", "아이언 바디". 후보 부족 시 보조 질의("슬래시", "강철", "워리어"). -- [ ] **Step 2:** 메이커 Play→전투 진입 후, 후보 RUID를 Card1 Art 자리(`SpriteGUIRendererComponent.ImageRUID`, Type=0)에 순회 주입 + 스크린샷으로 스킬당 1개 선별(일러스트 적합성 기준: 식별 가능·단일 컷·과도한 투명 여백 없음). -- [ ] **Step 3:** `data/cards.json`을 다음 형태로 갱신(RUID는 선별값으로): -```json -{ - "cards": { - "Strike": { "name": "파워 스트라이크", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6", "image": "<선별RUID>" }, - "Defend": { "name": "아이언 바디", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5", "image": "<선별RUID>" }, - "Bash": { "name": "슬래시 블러스트", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10", "image": "<선별RUID>" } - }, - "starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"] -} -``` -- [ ] **Step 4:** `node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8'));console.log('ok')"` → ok. sim 회귀: `node --test tools/balance/sim-balance.test.mjs` → 14/14 (fixture 자체 카드라 무관). -- [ ] **Step 5:** Commit: `git add data/cards.json && git commit -m "feat(card-visuals): 카드를 전사 스킬로 리네임 + 공식 스킬 이미지 RUID"` - ---- - -## Task 2: ApplyCardFace 렌더 일원화 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: luaCardsTable에 image 직렬화.** `function luaCardsTable(cards)`의 fields 구성에 추가: -```js - if (c.image != null) fields.push(`image = ${luaStr(c.image)}`); -``` -(`if (c.block != null) ...` 줄 다음에.) - -- [ ] **Step 2: ApplyCardFace 메서드 추가** (`ApplyCardVisual` 메서드 정의 바로 앞에): -```js - method('ApplyCardFace', `local c = self.Cards[cardId] -if c == nil then - c = { name = cardId, cost = 0, desc = "", kind = "Skill" } -end -local e = _EntityService:GetEntityByPath(base) -if e ~= nil and e.SpriteGUIRendererComponent ~= nil then - if c.kind == "Attack" then - e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) - elseif c.kind == "Skill" then - e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) - else - e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) - end -end -self:SetText(base .. "/Cost", string.format("%d", c.cost)) -self:SetText(base .. "/Name", c.name) -self:SetText(base .. "/Desc", c.desc) -local art = _EntityService:GetEntityByPath(base .. "/Art") -if art ~= nil then - if c.image ~= nil and c.image ~= "" then - art.Enable = true - if art.SpriteGUIRendererComponent ~= nil then - art.SpriteGUIRendererComponent.ImageRUID = c.image - end - else - art.Enable = false - end -end`, [ - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, - ]), -``` - -- [ ] **Step 3: 기존 4개 함수를 위임으로 교체.** 각 메서드 본문 전체를: -- `ApplyCardVisual`: `self:ApplyCardFace("/ui/DefaultGroup/CardHand/Card" .. tostring(slot), cardId)` -- `ApplyRewardVisual`: `self:ApplyCardFace("/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot), cardId)` -- `ApplyInspectCardVisual`: `self:ApplyCardFace("/ui/DefaultGroup/DeckInspectHud/Grid/Card" .. tostring(slot), cardId)` -- `ApplyAllDeckCardVisual`: 기존 본문에서 카드 면 설정부를 `self:ApplyCardFace("/ui/DefaultGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)`로 교체(본문에 수량 배지 등 추가 로직이 있으면 그 부분은 유지 — 현재 본문을 읽고 카드면(Cost/Name/Desc/색) 설정부만 위임). -(인자 목록은 변경 없음.) - -- [ ] **Step 4: RenderShop 카드부 위임.** `RenderShop` 본문에서 상점 카드 면 설정부(Card{i}의 Cost/Name/Desc SetText + 색 설정)를 `self:ApplyCardFace(base, cid)` 호출로 교체(가격(Price) SetText·구매 상태 처리는 유지). 본문을 읽고 해당 부분만 정확히 치환. - -- [ ] **Step 5: 검증.** `node --check` → OK. `node tools/deck/gen-slaydeck.mjs` 후: -`node -e "const cb=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const s=JSON.stringify(cb);const names=cb.ContentProto.Json.Methods.map(m=>m.Name);console.log('face:',names.includes('ApplyCardFace'),'| image in Cards:',s.includes('image = '),'| delegates:',(s.match(/ApplyCardFace\(/g)||[]).length>=5)"` -Expected: 모두 true. 산출물 복원. - -- [ ] **Step 6: Commit:** `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): ApplyCardFace 렌더 일원화 + Cards image 직렬화"` - ---- - -## Task 3: 손패 카드 프레임 (Card1~5) - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` (upsertUi의 손패 카드 갱신부) - -- [ ] **Step 1: 현재 손패 카드 빌드부 읽기.** upsertUi에서 `for (let i = 1; i <= 5; i++)`로 `/ui/DefaultGroup/CardHand/Card{i}`를 byPath 갱신하고 children(['Cost'...],['Name'...],['Desc'...])을 생성/갱신하는 블록을 찾는다. - -- [ ] **Step 2: children 배열을 프레임 배치로 교체.** 기존 children 항목의 cfg를: -```js - const children = [ - ['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: cards[i - 1].cost, fontSize: 26, bold: true }], - ['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: cards[i - 1].name, fontSize: 20, bold: true }], - ['Desc', { size: { x: 164, y: 70 }, pos: { x: 0, y: -62 }, value: cards[i - 1].desc, fontSize: 18, bold: false }], - ]; -``` -로 교체(기존 child 갱신 분기에서도 cfg의 size/pos를 반영하도록 — 기존 갱신 분기가 Text/FontSize만 갱신한다면 transform도 cfg로 갱신하는 줄 추가: -```js - const tr0 = child.jsonString['@components'][0]; - tr0.RectSize = cfg.size; - tr0.anchoredPosition = cfg.pos; - tr0.OffsetMin = { x: cfg.pos.x - cfg.size.x / 2, y: cfg.pos.y - cfg.size.y / 2 }; - tr0.OffsetMax = { x: cfg.pos.x + cfg.size.x / 2, y: cfg.pos.y + cfg.size.y / 2 }; -``` -) - -- [ ] **Step 3: 프레임 자식 추가(없으면 생성, byPath 패턴 동일).** children 루프 뒤에 카드별로: -```js - const frameKids = [ - ['NamePlate', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 3, - { size: { x: 168, y: 34 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }], - ['CostPlate', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 4, - { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }], - ['Art', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 5, - { size: { x: 96, y: 96 }, pos: { x: 0, y: 52 } }, { r: 1, g: 1, b: 1, a: 1 }], - ]; - for (const [suffix, modelId, entryId, componentNames, dOrder, cfg, color] of frameKids) { - const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`; - if (!byPath.get(fPath)) { - const fe = entity({ - id: guid('dck', 100 + i * 10 + dOrder), - path: fPath, modelId, entryId, componentNames, - displayOrder: dOrder, - components: [ - transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), - sprite(suffix === 'Art' ? { color, type: 0, raycast: false } : { color, type: 1, raycast: false }), - ], - }); - ui.ContentProto.Entities.push(fe); - byPath.set(fPath, fe); - } - } -``` -주의: `guid('dck', N)` 기존 사용 대역 확인(grep `guid('dck'`) 후 충돌 시 200+로 이동. Cost/Name/Desc 텍스트가 NamePlate/CostPlate **위에** 그려지도록 displayOrder 관계 확인(텍스트 0/1/2 < 플레이트 3/4면 플레이트가 위 — **플레이트가 텍스트를 가리면 안 되므로** 텍스트 displayOrder를 6/7/8로 올리고 플레이트 3/4·Art 5 유지: Cost→7, Name→6, Desc→8로 child 생성/갱신 분기에서 displayOrder 설정). - -- [ ] **Step 4: 검증.** `node --check` → 실행 → 확인: -`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('art:',[1,2,3,4,5].every(i=>p.includes('/ui/DefaultGroup/CardHand/Card'+i+'/Art')),'| plates:',[1,2,3,4,5].every(i=>p.includes('/ui/DefaultGroup/CardHand/Card'+i+'/NamePlate')))"` → 모두 true. dup id 0 확인. 산출물 복원. - -- [ ] **Step 5: Commit:** `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): 손패 카드 프레임(Art·NamePlate·CostPlate)"` - ---- - -## Task 4: 보상/상점/그리드 카드 프레임 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: 보상 카드(Reward1~3, 180×250).** 보상 카드 빌더(자식 Cost/Name/Desc 생성 루프)에서 자식 cfg를 Task 3 Step 2와 동일 좌표로 바꾸고(Cost 44×44@(-68,103) f26 / Name 168×30@(0,-8) f20 / Desc 164×70@(0,-62) f18), 동일한 NamePlate/CostPlate/Art 3종을 push(부모 RewardHud/Reward{i}, guid('rwd', 100+i*10+dOrder), displayOrder: 텍스트 6/7/8·플레이트 3/4·Art 5). - -- [ ] **Step 2: 상점 카드(ShopHud/Card1~3, 180×250).** 동일 적용하되 Desc는 `{ size: { x: 164, y: 56 }, pos: { x: 0, y: -58 } }` (Price (0,-105)와 1px 간격 — Price·구매로직 불변). guid('shp', 100+i*10+dOrder). - -- [ ] **Step 3: 그리드 카드(DeckInspectHud/Grid/Card{n}·DeckAllHud/Grid/Card{n}, 158×214).** 두 빌더(line≈661, 783)에 비례 축소 프레임: Art 84×84@(0,44) / NamePlate 148×30@(0,-8) / CostPlate 38×38@(-58,86); 텍스트 cfg: Cost 38×38@(-58,86) f22 / Name 148×26@(0,-8) f17 / Desc 144×60@(0,-54) f15. guid는 각 빌더의 기존 네임스페이스 시퀀스 이어쓰기(중복 검증으로 확인). - -- [ ] **Step 4: 검증.** 실행 후: -`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=ui.ContentProto.Entities;const cnt=s=>E.filter(e=>e.path.includes(s)&&e.path.endsWith('/Art')).length;console.log('reward:',cnt('/RewardHud/Reward'),'shop:',cnt('/ShopHud/Card'),'inspect:',cnt('/DeckInspectHud/Grid/'),'alldeck:',cnt('/DeckAllHud/Grid/'));const ids=E.map(e=>e.id);console.log('dup:',ids.filter((x,i)=>ids.indexOf(x)!==i).length)"` -Expected: reward:3 shop:3 inspect:(그리드 수) alldeck:(그리드 수), dup:0. 산출물 복원. - -- [ ] **Step 5: Commit:** `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): 보상·상점·그리드 카드 프레임 적용"` - ---- - -## Task 5: 재생성·검증·산출물 커밋 - -- [ ] **Step 1:** `node tools/deck/gen-slaydeck.mjs` → exit 0. -- [ ] **Step 2:** JSON 3종 파스 + dup 0 + 손패/보상/상점/그리드 Art 존재 + codeblock에 ApplyCardFace·image 직렬화(`image = `)·새 카드명("파워 스트라이크") 포함 확인: -`node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('face+image+name:',cb.includes('ApplyCardFace')&&cb.includes('image = ')&&cb.includes('파워 스트라이크'))"` → true. -- [ ] **Step 3:** 결정성(재실행 빈 diff) + sim 14/14. -- [ ] **Step 4:** Commit: `git add ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock && git commit -m "feat(card-visuals): 산출물 재생성"` - ---- - -## Task 6 (컨트롤러 직접): 메이커 플레이테스트 + 푸시 + PR - -- refresh → build 0 → play. 4표면 스크린샷: ①손패(전투, 스킬 이미지+프레임+새 이름) ②보상 ③상점 ④모든덱/인스펙터. 이미지 미적용/프레임 깨짐/그리드 셀 영향 발견 시 좌표·RUID 수정 → 재생성 → 재확인. -- 통과 후: `git push -u origin feature/p2-card-visuals`(인증 실패 시 1회 재시도) → PR 링크+메시지 제공. - ---- - -## Self-Review 결과 -- **스펙 커버리지**: §2→T1, §3→T1, §4→T3·T4, §5→T2, §6→T1~T5, §8→T5·T6. 전부 매핑. -- **플레이스홀더**: T1의 `<선별RUID>`는 수확 절차의 산출물로 정의됨(절차 명시). 그 외 실제 코드. -- **일관성**: `ApplyCardFace(base, cardId)` 시그니처·자식명(Art/NamePlate/CostPlate)·displayOrder 규칙(플레이트3/4·Art5·텍스트6/7/8) Task 간 일치. guid 충돌은 각 Task 검증(dup 0)으로 강제. -- **주의**: 손패 byPath 갱신 분기·RenderShop/ApplyAllDeckCardVisual 본문은 구현 시 현재 코드를 읽고 지정 부분만 치환(앵커 명시됨). diff --git a/docs/superpowers/plans/2026-06-11-combat-feel.md b/docs/superpowers/plans/2026-06-11-combat-feel.md deleted file mode 100644 index e9f77c4..0000000 --- a/docs/superpowers/plans/2026-06-11-combat-feel.md +++ /dev/null @@ -1,289 +0,0 @@ -# 전투 연출 (P3) 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Task 6은 메이커 MCP — 컨트롤러 직접. - -**Goal:** 카드 드래그→몬스터 지정, 공격 이펙트 후 데미지, 적 개별 차례, 데미지 팝업. - -**Architecture:** 카드에 `UITouchReceiveComponent`(공식 드래그 이벤트). 연출은 컨트롤러 타이머 체인(`FxBusy`/`TurnBusy` 가드). 모든 변경은 `tools/deck/gen-slaydeck.mjs` 단일 소스. - -**Tech Stack:** Node ESM 생성기, MSW Lua, UITouchReceive/UILogic/TimerService. - ---- - -## 배경 (구현자용) -- 루트에서 `node tools/deck/gen-slaydeck.mjs`. 각 Task: `node --check` → 생성 → 확인 → **산출물 복원**(`git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`) → 소스만 커밋. 산출물 커밋은 T5. -- 손패 카드: `/ui/DefaultGroup/CardHand/Card{1..5}`, 원위치 x=`CARD_XS[i]`(-400..400), y=0, 부모 CardHand는 화면 UI좌표 (0,-360) 중심(앵커 bottom-center pos y180). -- 몬스터: `self.Monsters[i] = {entity, ..., alive, slot}`; world→screen은 `_UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y+1.4))` 패턴(PositionMonsterSlot 참조). -- 기존: `BindButtons`에 카드 클릭 `ConnectEvent(ButtonClickEvent, ... PlayCard(i))` 루프 존재(제거 대상). `PlayCard`는 즉시 `DealDamageToTarget`. `EndPlayerTurn`은 손패 버림→`EnemyTurn()`→`CheckCombatEnd`→타이머로 `StartPlayerTurn`. `KillMonster`는 즉시 `SetVisible(false)`. -- guid 'cmb' 사용 대역: 0~10·41~144(+221~224 TargetFrame)·200~216. **신규: SkillFx=230, ActFrame=240+i, DmgPop slot=250+i, player DmgPop=260.** - -## Task 1: 카드 드래그 타겟팅 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: 카드 엔티티에 UITouchReceiveComponent.** upsertUi 손패 카드(byPath 갱신 분기)에서 Card{i} 루트의 componentNames에 `MOD.Core.UITouchReceiveComponent`가 없으면 추가하고 `@components`에 `{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true }` push (기존 ButtonComponent 추가 패턴과 동일한 create-if-missing 방식 — 그 코드를 읽고 모방). - -- [ ] **Step 2: 드래그 상태 prop 추가.** SlayDeckController prop 배열에: -```js - prop('number', 'DragSlot', '0'), - prop('boolean', 'FxBusy', 'false'), - prop('boolean', 'TurnBusy', 'false'), -``` - -- [ ] **Step 3: BindButtons — 카드 클릭 제거 + 드래그 연결.** 기존 `for i = 1, 5 do ... PlayCard(i) ... end`(카드 ButtonClickEvent 루프)를 다음으로 교체: -```lua -for i = 1, 5 do - local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i)) - if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then - cardEntity:ConnectEvent(UITouchBeginDragEvent, function(ev) self:OnCardDragBegin(i) end) - cardEntity:ConnectEvent(UITouchDragEvent, function(ev) self:OnCardDrag(i, ev.TouchPoint) end) - cardEntity:ConnectEvent(UITouchEndDragEvent, function(ev) self:OnCardDragEnd(i, ev.TouchPoint) end) - end -end -``` - -- [ ] **Step 4: 드래그 메서드 3종 + ResolveCardDrop 추가** (CARD_XS는 JS 상수 — 보간으로 Lua 테이블 굽기): -```js - method('OnCardDragBegin', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then - return -end -if self.Hand == nil or self.Hand[slot] == nil then - return -end -self.DragSlot = slot`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), - method('OnCardDrag', `if self.DragSlot ~= slot then - return -end -local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) -if e ~= nil and e.UITransformComponent ~= nil then - local ui = _UILogic:ScreenToUIPosition(touchPoint) - e.UITransformComponent.anchoredPosition = Vector2(ui.x, ui.y + 360) -end`, [ - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, - { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, - ]), - method('OnCardDragEnd', `if self.DragSlot ~= slot then - return -end -self.DragSlot = 0 -local cardXs = { ${CARD_XS.join(', ')} } -local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot)) -if e ~= nil and e.UITransformComponent ~= nil then - e.UITransformComponent.anchoredPosition = Vector2(cardXs[slot], 0) -end -self:ResolveCardDrop(slot, touchPoint)`, [ - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, - { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, - ]), - method('ResolveCardDrop', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then - return -end -local cardId = self.Hand[slot] -if cardId == nil then - return -end -local c = self.Cards[cardId] -if c == nil then - return -end -if c.kind == "Attack" then - local best = 0 - local bestDist = 200 - for i = 1, #self.Monsters do - local m = self.Monsters[i] - if m.alive == true and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then - local wp = m.entity.TransformComponent.WorldPosition - local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7)) - local dx = sp.x - touchPoint.x - local dy = sp.y - touchPoint.y - local d = math.sqrt(dx * dx + dy * dy) - if d < bestDist then - bestDist = d - best = i - end - end - end - if best > 0 then - self.TargetIndex = best - self:PlayCard(slot) - end -else - local ui = _UILogic:ScreenToUIPosition(touchPoint) - if ui.y > -180 then - self:PlayCard(slot) - end -end`, [ - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, - { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, - ]), -``` - -- [ ] **Step 5: 검증.** node --check → 생성 → `node -e "const cb=require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('drag:',cb.includes('OnCardDragBegin')&&cb.includes('UITouchBeginDragEvent'),'| no card click PlayCard loop:',!/Card\\\" \.\. tostring\(i\)\)[\s\S]{0,220}ButtonClickEvent[\s\S]{0,80}PlayCard\(i\)/.test(cb))"` (두 번째 체크가 어려우면 수동으로 BindButtons에서 카드 ButtonClickEvent 루프 부재 확인). ui에 UITouchReceiveComponent 5장 확인: -`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log(ui.ContentProto.Entities.filter(e=>/CardHand\/Card[1-5]$/.test(e.path)&&e.componentNames.includes('UITouchReceiveComponent')).length)"` → 5. 산출물 복원. - -- [ ] **Step 6: Commit** `feat(combat-feel): 카드 드래그 타겟팅 (UITouchReceive·ResolveCardDrop)` - -## Task 2: 공격 이펙트 → 지연 데미지 - -- [ ] **Step 1: SkillFx 엔티티** (upsertUi CombatHud, Result 이전): -```js - const skillFx = entity({ - id: guid('cmb', 230), path: '/ui/DefaultGroup/CombatHud/SkillFx', - modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 30, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 110, y: 110 }, pos: { x: 0, y: 0 } }), - sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }), - ], - }); - skillFx.jsonString.enable = false; - combat.push(skillFx); -``` - -- [ ] **Step 2: PlayCard Attack 분기 교체.** 기존: -``` -if c.kind == "Attack" then - if c.damage ~= nil then - self:DealDamageToTarget(c.damage) - end - self:ApplyRelics("cardPlayed") -``` -→ -``` -if c.kind == "Attack" then - if c.damage ~= nil then - self:PlayAttackFx(self.TargetIndex, c.image, c.damage) - end - self:ApplyRelics("cardPlayed") -``` -그리고 PlayCard 끝의 `self:CheckCombatEnd()`는 유지(Skill 경로용 — Attack은 PlayAttackFx 완료 시 재호출). - -- [ ] **Step 3: PlayAttackFx 추가:** -```js - method('PlayAttackFx', `local m = self.Monsters[targetIndex] -if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then - self:DealDamageToTarget(damage) - self:RenderCombat() - self:CheckCombatEnd() - return -end -self.FxBusy = true -local fx = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/SkillFx") -if fx ~= nil then - if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then - fx.SpriteGUIRendererComponent.ImageRUID = image - end - if fx.UITransformComponent ~= nil and m.entity.TransformComponent ~= nil then - local wp = m.entity.TransformComponent.WorldPosition - local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7)) - fx.UITransformComponent.anchoredPosition = _UILogic:ScreenToUIPosition(sp) - end - fx.Enable = true -end -_TimerService:SetTimerOnce(function() - if fx ~= nil then fx.Enable = false end - self.FxBusy = false - self:DealDamageToTarget(damage) - self:ShowDmgPop(targetIndex, damage) - self:RenderCombat() - self:CheckCombatEnd() -end, 0.35)`, [ - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'targetIndex' }, - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' }, - ]), -``` -주의: `ShowDmgPop`은 T3에서 추가 — T2 커밋 시점엔 생성기 실행이 깨지지 않음(문자열일 뿐). PlayCard/EndPlayerTurn 시작 가드에 `or self.FxBusy == true or self.TurnBusy == true` 추가(기존 `if self.CombatOver == true then return end`를 확장). - -- [ ] **Step 4: 검증** (codeblock에 PlayAttackFx·SkillFx 존재, PlayCard에 PlayAttackFx 호출). 산출물 복원, 커밋 `feat(combat-feel): 공격 이펙트 후 지연 데미지 (SkillFx·FxBusy)` - -## Task 3: 데미지 팝업 + 사망 지연 - -- [ ] **Step 1: DmgPop 엔티티.** 몬스터 슬롯 루프에 자식 추가(dOrder 9, guid cmb 250+i): 텍스트 120×30 @(0, 60), fontSize 24 bold, 색 {1,0.35,0.3,1}, value '', enable=false. PlayerPanel에도 동일(guid cmb 260, 경로 `PlayerPanel/DmgPop`, pos (16, 40), 색 {1,0.4,0.35,1}). -- [ ] **Step 2: ShowDmgPop / ShowPlayerDmgPop:** -```js - method('ShowDmgPop', `local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot) .. "/DmgPop" -self:SetText(base, "-" .. string.format("%d", amount)) -self:SetEntityEnabled(base, true) -_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [ - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, - ]), - method('ShowPlayerDmgPop', `local base = "/ui/DefaultGroup/CombatHud/PlayerPanel/DmgPop" -if amount > 0 then - self:SetText(base, "-" .. string.format("%d", amount)) -else - self:SetText(base, "막음") -end -self:SetEntityEnabled(base, true) -_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), -``` -- [ ] **Step 3: KillMonster 사망 지연.** `m.entity:SetVisible(false)` 즉시 호출을: -``` -local ent = m.entity -_TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.4) -``` -로 교체. -- [ ] **Step 4: 검증·복원·커밋** `feat(combat-feel): 데미지 팝업·사망 지연` - -## Task 4: 적 개별 차례 - -- [ ] **Step 1: ActFrame 엔티티.** 몬스터 슬롯 루프에 자식(dOrder 0보다 아래는 불가하니 TargetFrame처럼 dOrder 0, guid cmb 240+i — TargetFrame과 별도): 156×108 @(0,0), 색 {0.95,0.3,0.25,0.3}, enable=false. (TargetFrame과 같은 위치 — 적 턴 중에는 TargetFrame 대신 표시됨.) -- [ ] **Step 2: EnemyTurn 시퀀스 교체.** `EnemyTurn` 전체를: -```js - method('EnemyTurn', `self.TurnBusy = true -self:EnemyActStep(1)`), - method('EnemyActStep', `local idx = 0 -for i = fromIndex, #self.Monsters do - if self.Monsters[i].alive == true then idx = i; break end -end -if idx == 0 or self.PlayerHp <= 0 then - self:FinishEnemyTurn() - return -end -local m = self.Monsters[idx] -local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx) -self:SetEntityEnabled(base .. "/ActFrame", true) -_TimerService:SetTimerOnce(function() - m.block = 0 - local intent = m.intents[m.intentIdx] - if intent ~= nil then - if intent.kind == "Attack" then - local before = self.PlayerHp - self:DealDamageToPlayer(intent.value) - self:ShowPlayerDmgPop(before - self.PlayerHp) - elseif intent.kind == "Defend" then - m.block = m.block + intent.value - end - end - m.intentIdx = m.intentIdx + 1 - if m.intentIdx > #m.intents then - m.intentIdx = 1 - end - self:RenderCombat() - self:SetEntityEnabled(base .. "/ActFrame", false) - _TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15) -end, 0.45)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromIndex' }]), - method('FinishEnemyTurn', `self.TurnBusy = false -self:CheckCombatEnd() -if self.CombatOver == true then - return -end -_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)`), -``` -- [ ] **Step 3: EndPlayerTurn 후반 정리.** 기존 EndPlayerTurn에서 `self:EnemyTurn()` 호출 이후의 `self:CheckCombatEnd()`/CombatOver 체크/`SetTimerOnce(... StartPlayerTurn ...)` 블록 **삭제**(FinishEnemyTurn이 담당). 가드 확장 확인(T2에서 FxBusy/TurnBusy). -- [ ] **Step 4: RenderCombat의 ActFrame 정리.** RenderCombat 몬스터 루프의 else(사망/없음) 분기는 슬롯 통째 비활성이므로 ActFrame 잔존 위험 없음 — 확인만. -- [ ] **Step 5: 검증·복원·커밋** `feat(combat-feel): 적 개별 차례 시퀀스 (ActFrame·EnemyActStep)` - -## Task 5: 재생성·검증·산출물 커밋 -P2 T5와 동일 절차(생성→JSON·dup0·핵심 심볼(OnCardDragBegin/PlayAttackFx/EnemyActStep/DmgPop)·결정성·sim 14/14→산출물 커밋 `feat(combat-feel): 산출물 재생성`). - -## Task 6 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지 -- mouse_input 드래그로: 공격 카드→몬스터2 드롭(타겟 변경+이펙트+팝업+HP 감소), Skill 카드 위로 드롭(방어), 빈 곳 드롭 취소, 턴 종료→순차 행동(ActFrame)+플레이어 팝업, 전체 처치 승리. 스크린샷 evidence. -- 푸시→Gitea API PR(상세 메시지)→머지. - -## Self-Review -- 요구 4종(드래그/모션 후 데미지/개별 차례/팝업·사망) ↔ T1/T2/T4/T3 매핑 완료. ResolveCardDrop의 `TargetIndex` 직접 대입은 SetTarget(RenderCombat 포함)과 달리 렌더 없이 PlayCard로 직행 — PlayCard가 RenderCombat 수행하므로 OK. -- 시그니처 일관: PlayAttackFx(targetIndex,image,damage)·ShowDmgPop(slot,amount)·EnemyActStep(fromIndex). guid 230/240+i/250+i/260 비충돌(기존 0~224). -- 리스크는 T6 메이커 검증에서 흡수(드래그 좌표 보정 +360, 거리 임계 200, ui.y>-180 스윕 기준 — 실측 튜닝 가능). diff --git a/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md b/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md deleted file mode 100644 index 3768631..0000000 --- a/docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md +++ /dev/null @@ -1,462 +0,0 @@ -# 전투 화면 UI/HUD 전면 정비 (P1) 구현 계획 - -> **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:** 전투 화면을 STS2 배치로 재구성해 UI 겹침 0·시각 위계 확립 (기능 불변, 레이아웃·표시 로직만). - -**Architecture:** 모든 UI/컨트롤러는 `tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성(직접 편집 금지, 루트에서 `node tools/deck/gen-slaydeck.mjs`). UI 엔티티는 `upsertUi()`의 `entity()/transform()/sprite()/text()/button()` 헬퍼, 컨트롤러 Lua는 `method(Name, Code, Args?)` 템플릿 문자열(`${...}`=JS 보간). 좌표계: 부모 중심 원점, anchoredPosition. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock, JSON 산출물. - ---- - -## 배경 (구현자용) - -- 화면 1920×1080 중심좌표(±960, ±540). `DeckHud`=하단 1280×330(y180 bottom-center), `CardHand`=카드 5장(y180), `CombatHud`=전체 1920×1080. -- 확인된 겹침: `DeckHud/EndTurnButton`(0,135,170×58) ↔ `DeckHud/Energy`(0,90,220×42) 5px; `AllDeckButton`(470,135)이 버린덱(590,8,132×186)과 5px 간격. -- 기존 가시성: `HideGameHud`(전투 HUD 일괄 off)가 이미 존재(사용자 PR) — ShowState는 이를 재사용해 확장. -- guid 네임스페이스: `guid('cmb', N)` — 기존 사용 대역: 0~10(순차 cmbN), 41~144(슬롯 6종×4). **신규는 200+ 사용**(TopBar 200~209, PlayerPanel 210~219, TargetFrame 221~224). -- 생성기 실행 검증은 매 Task: `node --check` + `node tools/deck/gen-slaydeck.mjs` 성공 + 해당 확인 스크립트. 산출물 커밋은 Task 6에서 일괄(중간 Task는 소스만 커밋하고 산출물은 `git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`로 복원). - -## 파일 구조 -| 파일 | 책임 | -|---|---| -| `tools/deck/gen-slaydeck.mjs` | 전 변경(UI 좌표·신규 엔티티·컨트롤러 표시 로직) | -| 산출물 3종 | Task 6에서 재생성·커밋 | - ---- - -## Task 1: 하단 HUD — 에너지 오브(좌)·턴 종료(우) - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: Energy 텍스트 엔티티를 EnergyOrb 패널로 교체** - -`upsertUi()`에서 `path: '/ui/DefaultGroup/DeckHud/Energy'` 엔티티 push 블록(`add(entity({ ... '에너지 3/3' ... }))`) 전체를 삭제하고, 그 자리에: -```js - add(entity({ - id: guid('hud', hud.length), - path: '/ui/DefaultGroup/DeckHud/EnergyOrb', - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 3, - components: [ - transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 96, y: 96 }, pos: { x: -560, y: 130 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.12, g: 0.2, b: 0.34, a: 0.95 }, type: 1 }), - ], - })); - add(entity({ - id: guid('hud', hud.length), - path: '/ui/DefaultGroup/DeckHud/EnergyOrb/Value', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 96, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 92, y: 48 }, pos: { x: 0, y: 6 } }), - sprite({ color: TRANSPARENT }), - text({ value: '3/3', fontSize: 34, bold: true, color: { r: 0.65, g: 0.92, b: 1, a: 1 }, alignment: 4 }), - ], - })); - add(entity({ - id: guid('hud', hud.length), - path: '/ui/DefaultGroup/DeckHud/EnergyOrb/Label', - modelId: 'uitext', - entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 1, - components: [ - transform({ parentW: 96, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 92, y: 24 }, pos: { x: 0, y: -28 } }), - sprite({ color: TRANSPARENT }), - text({ value: '에너지', fontSize: 14, bold: true, color: { r: 0.55, g: 0.7, b: 0.85, a: 1 }, alignment: 4 }), - ], - })); -``` - -- [ ] **Step 2: EndTurnButton 이동·확대** - -`path: '/ui/DefaultGroup/DeckHud/EndTurnButton'` 엔티티의 transform 줄을 -```js - transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 64 }, pos: { x: 560, y: 130 }, align: ALIGN_CENTER }), -``` -로 교체하고, 같은 엔티티의 `text({ value: '턴 종료', fontSize: 25, ...` 를 `fontSize: 28,` 로. - -- [ ] **Step 3: RenderPiles 에너지 경로/포맷 갱신** - -`method('RenderPiles', ...)` 안의 -``` -self:SetText("/ui/DefaultGroup/DeckHud/Energy", "에너지 " .. string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy)) -``` -를 -``` -self:SetText("/ui/DefaultGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy)) -``` -로 교체. - -- [ ] **Step 4: 검증** — `node --check tools/deck/gen-slaydeck.mjs` 후 실행: -`node tools/deck/gen-slaydeck.mjs && node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('orb:',p.includes('/ui/DefaultGroup/DeckHud/EnergyOrb'),'| oldEnergy gone:',!p.includes('/ui/DefaultGroup/DeckHud/Energy'))"` -Expected: `orb: true | oldEnergy gone: true`. 그 후 산출물 복원: `git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock` - -- [ ] **Step 5: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 에너지 오브(좌)·턴 종료 버튼(우) 재배치"` - ---- - -## Task 2: 상단 TopBar (막·골드·유물·모든덱보기 통합) - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: 기존 Floor/Gold/Relics 엔티티 제거** - -`upsertUi()` CombatHud 빌드에서 `['Floor', { x: -820, y: 480 }, ...]`/`['Gold', { x: 820, y: 480 }, ...]` 루프 블록과 `path: '/ui/DefaultGroup/CombatHud/Relics'` push 블록을 삭제. - -- [ ] **Step 2: DeckHud의 AllDeckButton 엔티티 제거** - -`path: '/ui/DefaultGroup/DeckHud/AllDeckButton'` push 블록(레이블 텍스트 포함 엔티티 1개) 삭제. - -- [ ] **Step 3: CombatHud에 TopBar 추가** (Result push 이전 위치에): -```js - combat.push(entity({ - id: guid('cmb', 200), - path: '/ui/DefaultGroup/CombatHud/TopBar', - modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 9, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1200, y: 52 }, pos: { x: 0, y: 486 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.06, g: 0.07, b: 0.1, a: 0.82 }, type: 1 }), - ], - })); - const topTexts = [ - ['Floor', -520, 160, '막 1/3', GOLD], - ['Gold', -360, 160, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }], - ['Relics', 60, 560, '유물: 없음', { r: 0.8, g: 0.7, b: 0.95, a: 1 }], - ]; - topTexts.forEach(([suffix, x, w, value, color], ti) => { - combat.push(entity({ - id: guid('cmb', 201 + ti), - path: `/ui/DefaultGroup/CombatHud/TopBar/${suffix}`, - modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: ti, - components: [ - transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: w, y: 40 }, pos: { x: x, y: 0 } }), - sprite({ color: TRANSPARENT }), - text({ value, fontSize: suffix === 'Relics' ? 18 : 22, bold: true, color, alignment: 4 }), - ], - })); - }); - combat.push(entity({ - id: guid('cmb', 205), - path: '/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton', - modelId: 'uibutton', entryId: 'UIButton', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', - displayOrder: 3, - components: [ - transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 40 }, pos: { x: 510, y: 0 } }), - sprite({ color: DARK, type: 1, raycast: true }), - button(), - text({ value: '모든덱보기', fontSize: 18, bold: true, color: GOLD, alignment: 0 }), - ], - })); -``` - -- [ ] **Step 4: 컨트롤러 경로 갱신** (정확 치환 3건) -- `RenderRun`: `"/ui/DefaultGroup/CombatHud/Floor"` → `"/ui/DefaultGroup/CombatHud/TopBar/Floor"`, `"/ui/DefaultGroup/CombatHud/Gold"` → `"/ui/DefaultGroup/CombatHud/TopBar/Gold"` -- `RenderRelics`(끝부분 SetText): `"/ui/DefaultGroup/CombatHud/Relics"` → `"/ui/DefaultGroup/CombatHud/TopBar/Relics"` -- `BindButtons`: `"/ui/DefaultGroup/DeckHud/AllDeckButton"` → `"/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton"` - -- [ ] **Step 5: 검증** — `node --check` 후 실행: -`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('topbar:',['/TopBar','/TopBar/Floor','/TopBar/Gold','/TopBar/Relics','/TopBar/AllDeckButton'].every(s=>p.includes('/ui/DefaultGroup/CombatHud'+s)),'| old gone:',!p.includes('/ui/DefaultGroup/CombatHud/Floor')&&!p.includes('/ui/DefaultGroup/CombatHud/Relics')&&!p.includes('/ui/DefaultGroup/DeckHud/AllDeckButton'));const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('paths updated:',cb.includes('TopBar/Floor')&&cb.includes('TopBar/Relics')&&cb.includes('TopBar/AllDeckButton')&&!cb.includes('DeckHud/AllDeckButton'))"` -Expected: 모두 true. 산출물 복원(Task 1과 동일 명령). - -- [ ] **Step 6: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 상단 TopBar (막·골드·유물·모든덱보기 통합)"` - ---- - -## Task 3: 플레이어 패널 + SetHpBar 폭 인자 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: SetHpBar에 width 인자 추가** - -`method('SetHpBar', ...)` 전체를 다음으로 교체: -```js - method('SetHpBar', `local e = _EntityService:GetEntityByPath(path) -if e == nil or e.UITransformComponent == nil then - return -end -local ratio = 0 -if maxHp > 0 then ratio = hp / maxHp end -if ratio < 0 then ratio = 0 end -local w = width * ratio -e.UITransformComponent.RectSize = Vector2(w, 14)`, [ - { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' }, - { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' }, - ]), -``` -그리고 `RenderCombat` 안의 기존 호출 `self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp)` 를 `self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})` 로 교체. - -- [ ] **Step 2: PlayerBg/PlayerHp/PlayerBlock 엔티티 제거 → PlayerPanel 추가** - -`upsertUi()`에서 `path: '/ui/DefaultGroup/CombatHud/PlayerBg'` push 블록과 `playerTexts` 배열+루프를 삭제하고, 그 자리에: -```js - const PP = '/ui/DefaultGroup/CombatHud/PlayerPanel'; - combat.push(entity({ - id: guid('cmb', 210), path: PP, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 5, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 96 }, pos: { x: -760, y: -480 }, align: ALIGN_CENTER }), - sprite({ color: PANEL_BG, type: 1 }), - ], - })); - combat.push(entity({ - id: guid('cmb', 211), path: `${PP}/Name`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 28 }, pos: { x: 0, y: 28 } }), - sprite({ color: TRANSPARENT }), - text({ value: '플레이어', fontSize: 18, bold: true, color: GOLD, alignment: 4 }), - ], - })); - combat.push(entity({ - id: guid('cmb', 212), path: `${PP}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 1, - components: [ - transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 16 }, pos: { x: 16, y: -6 } }), - sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }), - ], - })); - combat.push(entity({ - id: guid('cmb', 213), path: `${PP}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 2, - components: [ - transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: 220, y: 16 }, pos: { x: -94, y: -6 } }), - sprite({ color: { r: 0.3, g: 0.78, b: 0.36, a: 1 }, type: 1 }), - ], - })); - combat.push(entity({ - id: guid('cmb', 214), path: `${PP}/HpText`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 3, - components: [ - transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 24 }, pos: { x: 16, y: -30 } }), - sprite({ color: TRANSPARENT }), - text({ value: '80/80', fontSize: 16, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), - ], - })); - const blockBadge = entity({ - id: guid('cmb', 215), path: `${PP}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 4, - components: [ - transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 44, y: 40 }, pos: { x: -122, y: -12 } }), - sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }), - ], - }); - blockBadge.jsonString.enable = false; - combat.push(blockBadge); - combat.push(entity({ - id: guid('cmb', 216), path: `${PP}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 44, parentH: 40, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 44, y: 36 }, pos: { x: 0, y: 0 } }), - sprite({ color: TRANSPARENT }), - text({ value: '0', fontSize: 18, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), - ], - })); -``` - -- [ ] **Step 3: RenderCombat 플레이어부 교체** - -`RenderCombat` 끝의 -``` -self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) -self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) -``` -를 -``` -self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) -self:SetHpBar("/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220) -self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0) -self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock)) -``` -로 교체. - -- [ ] **Step 4: 검증** — `node --check` 후 실행+확인: -`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('panel:',p.includes('/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill'),'| old gone:',!p.includes('/ui/DefaultGroup/CombatHud/PlayerHp'));const cb=JSON.parse(fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const m=cb.ContentProto.Json.Methods.find(x=>x.Name==='SetHpBar');console.log('width arg:',m.Arguments.length===4)"` -Expected: 모두 true. 산출물 복원. - -- [ ] **Step 5: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 플레이어 패널(HP바·방어 뱃지) + SetHpBar 폭 인자"` - ---- - -## Task 4: 타겟 프레임 + 몬스터 슬롯 가독성 + 의도 색상 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: 슬롯 루프에 TargetFrame 추가 + 가독성 조정** - -`upsertUi()` 몬스터 슬롯 루프(`for (let i = 1; i <= MAX_MONSTERS; i++)`)에서: -1. 슬롯 컨테이너 push 직후, Name push 이전에 추가: -```js - const targetFrame = entity({ - id: guid('cmb', 220 + i), path: `${base}/TargetFrame`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 0, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 16, y: SLOT_H + 12 }, pos: { x: 0, y: 0 } }), - sprite({ color: { r: 0.95, g: 0.78, b: 0.25, a: 0.28 }, type: 1 }), - ], - }); - targetFrame.jsonString.enable = false; - combat.push(targetFrame); -``` -2. Name/Hp/HpBarBg/HpBarFill/Intent의 `displayOrder`를 각각 1/2/3/4/5로 +1. -3. Name `fontSize: 20` → `22`, Hp `fontSize: 18` → `20`. -4. 파일 상단 `const HP_BAR_W = 120;` → `const HP_BAR_W = 140;` (몬스터 바 폭 확대 — HpBarBg/Fill·RenderCombat 보간이 모두 이 상수 사용). - -- [ ] **Step 2: RenderCombat 몬스터부 — [타겟] 제거·TargetFrame·의도 색상** - -`RenderCombat`의 몬스터 루프 본문에서 -``` - if i == self.TargetIndex then t = "[타겟] " .. t end - self:SetText(base .. "/Intent", t) -``` -를 -``` - self:SetText(base .. "/Intent", t) - self:SetEntityEnabled(base .. "/TargetFrame", i == self.TargetIndex) - local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent") - if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then - if intent.kind == "Attack" then - intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1) - else - intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1) - end - end -``` -로 교체. - -- [ ] **Step 3: 검증** — `node --check` 후 실행+확인: -`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const tf=ui.ContentProto.Entities.filter(e=>e.path.endsWith('/TargetFrame')).length;const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('frames:',tf,'| no [타겟]:',!cb.includes('[타겟]'),'| color:',cb.includes('FontColor = Color(1, 0.45'))"` -Expected: `frames: 4 | no [타겟]: true | color: true`. 산출물 복원. - -- [ ] **Step 4: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 타겟 프레임·몬스터 슬롯 가독성·의도 색상"` - ---- - -## Task 5: ShowState 가시성 통일 + Result 정리 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1: ShowState 메서드 추가** (`HideGameHud` 메서드 바로 다음에): -```js - method('ShowState', `self:HideGameHud() -self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu") -self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", state == "charselect") -if state == "map" then - self:SetEntityEnabled("/ui/DefaultGroup/MapHud", true) -elseif state == "combat" then - self:SetEntityEnabled("/ui/DefaultGroup/CombatHud", true) - self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", true) - self:SetEntityEnabled("/ui/DefaultGroup/CardHand", true) -elseif state == "shop" then - self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", true) -elseif state == "rest" then - self:SetEntityEnabled("/ui/DefaultGroup/RestHud", true) -end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'state' }]), -``` - -- [ ] **Step 2: 호출부 치환** (각각 정확 치환) -1. `ShowMainMenu`: -``` -self:HideGameHud() -self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", true) -self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", false) -``` -→ `self:ShowState("menu")` -2. `ShowCharacterSelect`: -``` -self:HideGameHud() -self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", false) -self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", true) -``` -→ `self:ShowState("charselect")` -3. `StartNewGame`의 -``` -self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", false) -self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", false) -``` -→ 삭제(StartRun→ShowMap이 ShowState("map")으로 처리). -4. `StartCombat` 첫 4줄 -``` -self:SetEntityEnabled("/ui/DefaultGroup/MapHud", false) -self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", true) -self:SetEntityEnabled("/ui/DefaultGroup/CardHand", true) -self:SetEntityEnabled("/ui/DefaultGroup/CombatHud", true) -``` -→ -``` -self:ShowState("combat") -self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/Result", false) -``` -5. `ShowMap` 첫 3줄(`DeckHud/CardHand/CombatHud` off) → `self:ShowState("map")` (그 아래 `RenderMap`·MapHud enable 블록에서 MapHud enable 부분은 중복되지만 무해 — 기존 `local hud = ...MapHud... hud.Enable = true` 블록은 삭제). -6. `ShowShop` 끝의 ShopHud enable 블록(`local hud = ...ShopHud ... end`) → `self:ShowState("shop")` (RenderShop 호출은 유지). -7. `ShowRest` 끝의 RestHud enable 블록 → `self:ShowState("rest")` (텍스트·RenderCombat 호출 유지). -8. `LeaveNode`는 기존 그대로(ShowMap 경유). - -- [ ] **Step 3: 검증** — `node --check` 후 실행+확인: -`node tools/deck/gen-slaydeck.mjs && node -e "const cb=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const names=cb.ContentProto.Json.Methods.map(m=>m.Name);const s=JSON.stringify(cb);console.log('ShowState:',names.includes('ShowState'),'| StartCombat resets Result:',/ShowState\(\\\"combat\\\"\)[\s\S]{0,200}Result/.test(s))"` -Expected: 둘 다 true. 산출물 복원. - -- [ ] **Step 4: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): ShowState 가시성 통일 + 전투 시작 시 Result 초기화"` - ---- - -## Task 6: 재생성 · 겹침 정적 검사 · 산출물 커밋 - -**Files:** 산출물 3종 - -- [ ] **Step 1: 재생성** — `node tools/deck/gen-slaydeck.mjs` (exit 0) - -- [ ] **Step 2: JSON·중복 id·결정성** -`node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const ids=ui.ContentProto.Entities.map(e=>e.id);console.log('dup:',ids.filter((x,i)=>ids.indexOf(x)!==i).length)"` → `dup: 0` -그리고 `git add -A` 후 `node tools/deck/gen-slaydeck.mjs` 재실행 → `git diff --stat` 비어있음(결정적). - -- [ ] **Step 3: 하단 HUD 겹침 정적 검사** (AABB 페어와이즈): -`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=ui.ContentProto.Entities;const get=p=>E.find(e=>e.path===p);const box=p=>{const e=get(p);const t=e.jsonString['@components'].find(c=>c['@type']==='MOD.Core.UITransformComponent');return {p,x:t.anchoredPosition.x,y:t.anchoredPosition.y,w:t.RectSize.x,h:t.RectSize.y};};const items=['/ui/DefaultGroup/DeckHud/EnergyOrb','/ui/DefaultGroup/DeckHud/EndTurnButton','/ui/DefaultGroup/DeckHud/DrawPile','/ui/DefaultGroup/DeckHud/DiscardPile'].map(box);const hit=(a,b)=>Math.abs(a.x-b.x)*2<(a.w+b.w)&&Math.abs(a.y-b.y)*2<(a.h+b.h);let bad=0;for(let i=0;i)` → 방어 뱃지 표시 -6. 전체 처치 → 보상 → `s:PickReward(1)` → 맵 복귀 -7. 상점(D)·휴식(C) 화면 -겹침·비침 발견 시 좌표 조정 → 재생성 → reload → 재확인 → 산출물 커밋(`fix(combat-ui): 플레이테스트 좌표 튜닝`). - ---- - -## Self-Review 결과 -- **스펙 커버리지**: §3.1→T1, §3.2→T2, §3.3→T3, §3.4→T4, §3.5→T5(HideGameHud 재사용으로 구현 — 스펙 의도 동일), §3.6→T7에서 확인(채팅 숨김 API는 비차단), 검증§6→T6·T7. 전 항목 매핑. -- **플레이스홀더 없음**: 모든 단계 실제 코드/명령 포함. -- **타입/이름 일관성**: `EnergyOrb/Value`·`TopBar/{Floor,Gold,Relics,AllDeckButton}`·`PlayerPanel/{Name,HpBarBg,HpBarFill,HpText,BlockBadge/Value}`·`TargetFrame`·`SetHpBar(path,hp,maxHp,width)`·`ShowState(state)` — Task 간 일치. guid 대역 200~224 기존(0~10·41~144)과 비충돌. -- **주의**: T2 Step 1에서 Floor/Gold 루프 제거 시 그 루프가 쓰던 `cmbN` 증가가 사라져 후속 Relics/Result id가 변함 — 전부 재생성이라 무해. T5의 ShowMap 치환 시 기존 MapHud enable 블록 삭제 누락하면 중복(무해하나 정리). diff --git a/docs/superpowers/plans/2026-06-11-system-gaps.md b/docs/superpowers/plans/2026-06-11-system-gaps.md deleted file mode 100644 index 655824d..0000000 --- a/docs/superpowers/plans/2026-06-11-system-gaps.md +++ /dev/null @@ -1,79 +0,0 @@ -# 시스템 갭 보완 (P5) 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. T3·T5는 컨트롤러 직접. - -**Goal:** 경제 밸런스(첫 상점 구매 가능), 신규 카드 2종+복합 효과, 적 패턴 보강, 런 종료 후 메뉴 복귀. - ---- - -## Task 1: 데이터+상수 (경제·적 패턴·신규 카드 골격) - -**Files:** `data/enemies.json`, `data/cards.json`, `tools/deck/gen-slaydeck.mjs`(상수·elite 골드) - -- [ ] enemies.json 의도 패턴 교체(스펙 §2.C 표 그대로; slime 3종·king_slime 유지). -- [ ] cards.json에 추가(이미지는 T3에서 채움 — 일단 필드 생략): -```json - "WarLeap": { "name": "워 리프", "cost": 1, "kind": "Attack", "damage": 4, "block": 3, "desc": "피해 4, 방어도 3" }, - "Brandish": { "name": "브랜디시", "cost": 2, "kind": "Attack", "damage": 13, "desc": "피해 13" } -``` -- [ ] gen-slaydeck: `GOLD_PER_WIN = 15` → `25`; CheckCombatEnd elite 분기(AddRelic 줄 옆)에 `self.Gold = self.Gold + 15` 추가. -- [ ] 검증: JSON 파스, gen-slaydeck 실행 OK(산출물 복원), sim 통과(기존 fixture 무관). -- [ ] Commit: `feat(system-gaps): 경제 상향(승리25·엘리트+15)·적 패턴 보강·신규 카드 2종 데이터` - -## Task 2: 복합 카드 로직 + EndRun 복귀 + sim - -**Files:** `tools/deck/gen-slaydeck.mjs`, `tools/balance/sim-balance.mjs`, `tools/balance/sim-balance.test.mjs` - -- [ ] **sim 테스트 먼저** (test 파일에 추가): -```js -test('simulateCombat: Attack 카드의 block 필드도 적용(복합 카드)', () => { - const data = { - cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 4, block: 3 } }, - starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'], - monsters: [{ name: '적', maxHp: 9, intents: [{ kind: 'Attack', value: 5 }] }], - }; - const r = simulateCombat(data, mulberry32(1)); - assert.equal(r.win, true); // 3코스트 내 3장: 12딤>9 → 1턴 승리(블록 적용 여부와 무관하게 승리하지만) - assert.equal(r.playerHpRemaining, 80); // 피해 받기 전 승리 — 블록 검증은 아래 시나리오로 -}); -test('simulateCombat: 복합 카드 블록이 적 공격을 흡수', () => { - const data = { - cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 1, block: 3 } }, - starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'], - monsters: [{ name: '적', maxHp: 100, intents: [{ kind: 'Attack', value: 9 }] }], - }; - const r = simulateCombat(data, mulberry32(1)); - // 1턴: 3장 사용 → 블록 9 → 적 공격 9 전부 흡수 → 2턴 시작 HP 80 유지 확인 위해 2턴 후 비교 불가(루프) — 간접: MAX_TURNS 도달 draw, hp가 (80 - 0*몇턴)... - // 단순 명제: 블록 미적용이면 매턴 -9 → 100/9≈11턴 내 사망. 블록 적용이면 매턴 9블록=무피해 → draw. - assert.equal(r.draw, true); - assert.equal(r.playerHpRemaining, 80); -}); -``` -- [ ] sim 구현: simulateCombat Attack 분기에 `if (c.block) pBlock += c.block;` 추가(스탯 bump의 block 합산도 `c.block || 0`로). 테스트 통과. -- [ ] gen-slaydeck PlayCard Attack 분기에 추가(PlayAttackFx 호출 다음 줄): -``` - if c.block ~= nil then - self.PlayerBlock = self.PlayerBlock + c.block - end -``` -- [ ] gen-slaydeck EndRun: 신규 메서드 + CheckCombatEnd 두 지점 교체: -```js - method('EndRun', `self:ShowResult(text) -self.RunActive = false -_TimerService:SetTimerOnce(function() self:ShowMainMenu() end, 4)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]), -``` -교체: `self:ShowResult("런 클리어!")` + `self.RunActive = false` → `self:EndRun("런 클리어!")`; `self:ShowResult("패배...")` + `self.RunActive = false` → `self:EndRun("패배...")`. -- [ ] 검증: sim 전체 통과(16개), gen 실행·심볼 확인 후 산출물 복원. -- [ ] Commit: `feat(system-gaps): 복합 카드(피해+방어)·런 종료 후 메뉴 복귀(EndRun)` - -## Task 3 (컨트롤러 직접): 신규 카드 이미지 수확 -- "워 리프"/"브랜디시" 검색→메이커 선별→cards.json image 채움→커밋. - -## Task 4: 재생성·검증·산출물 커밋 (T3 이후) -- 표준 절차 + `node tools/balance/sim-balance.mjs 2000` 결과 기록(참고). - -## Task 5 (컨트롤러 직접): 메이커 검증+푸시+PR+머지 -- 보상/상점 신규 카드(이미지)·복합 카드 효과·엘리트 골드·패배→4s 메뉴 복귀. 스크린샷. - -## Self-Review -- §2.A→T1, §2.B→T1(데이터)+T2(로직)+T3(이미지), §2.C→T1, §2.D→T2. 시그니처 일관(EndRun(text)). 복합 카드 sim 테스트는 블록 적용을 draw/hp로 결정적으로 판별. diff --git a/docs/superpowers/plans/2026-06-12-ascension.md b/docs/superpowers/plans/2026-06-12-ascension.md deleted file mode 100644 index 56c64f2..0000000 --- a/docs/superpowers/plans/2026-06-12-ascension.md +++ /dev/null @@ -1,23 +0,0 @@ -# P11 — 승천 + UserDataStorage 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-ascension-design.md` - -### Task 1: 생성기 — ExecSpace 보존 + 서버 RPC 3종 -- [ ] `for m of Methods: m.ExecSpace = 6` → `if (m.ExecSpace === 0) m.ExecSpace = 6;` (명시값 보존) -- [ ] props: `AscensionLevel`(number 0)·`AscensionUnlocked`(number 0) -- [ ] `ReqLoadAscension(userId)`[ExecSpace 1]·`RecvAscension(n, userId)`[2]·`SaveAscension(n, userId)`[1] — 설계 코드 그대로, OnBeginPlay(6)에서 LocalPlayer.UserId로 ReqLoad -- [ ] 커밋 - -### Task 2: 생성기 — 모디파이어·해금·메뉴 UI -- [ ] 헬퍼 5종(AscHpMult/AscAtkMult/AscEliteBonus/AscGoldMult/AscStartHpPenalty) + StartRun/BuildMonsters/CheckCombatEnd/RenderRun 적용 -- [ ] EndRun 클리어 분기: 해금+1·SaveAscension·"런 클리어! 승천 N 해금!" -- [ ] MainMenu `AscMinus/AscLabel/AscPlus` + `AdjustAscension`/`RenderAscension` + BindMenuButtons 연결 -- [ ] 커밋 - -### Task 3: 재생성·메이커 검증·PR -- [ ] 재생성·테스트 44건 유지·grep -c 카운트 → 커밋 -- [ ] 메이커: 메뉴 승천 라벨/[-][+]·승천2로 런 시작(HP·적 배율 로그 확인)·강제 클리어→해금+1·재플레이 로드 → 스크린샷 -- [ ] push → gitea-pr.mjs PR·머지 → main pull → 메모리 갱신 - -## Self-Review -- RPC 파라미터 any 금지(허용 타입: string/number) 준수 ✓ / RecvAscension 마지막 인자 userId(특정 클라 응답) ✓ / 시뮬 비대상 명시 ✓ diff --git a/docs/superpowers/plans/2026-06-12-buffs-power.md b/docs/superpowers/plans/2026-06-12-buffs-power.md deleted file mode 100644 index 656c0d7..0000000 --- a/docs/superpowers/plans/2026-06-12-buffs-power.md +++ /dev/null @@ -1,377 +0,0 @@ -# P6 — 버프/디버프·Power 카드·적 방어도 UI 구현 계획 - -> **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:** StS 표준 약화·취약·힘 버프 시스템 + Power 카드 kind + 적 방어도 배지 UI를 데이터 주도로 구현. - -**Architecture:** `data/cards.json`·`enemies.json` 스키마 확장 → `tools/deck/gen-slaydeck.mjs`의 Lua 생성부(상태 props·전투 메서드·UI 엔티티) 확장 → 산출물 재생성. 밸런스 시뮬(`tools/balance/sim-balance.mjs`)에 동일 규칙 재현. - -**Tech Stack:** Node.js(생성기·시뮬), MSW Lua(생성물), node:test. - -설계 문서: `docs/superpowers/specs/2026-06-12-buffs-power-design.md` - ---- - -### Task 1: 신규 카드 이미지 RUID 선별 (메이커) - -**Files:** 없음 (RUID 4개 확보가 산출물) - -- [ ] **Step 1**: `asset_search_resources`(cat=sprite, source=maplestory)로 후보 수집 — 쿼리: "차지 블로우", "위협", "인레이지", "분노" (결과 빈약 시 "스킬", "버프" 등 보조 쿼리) -- [ ] **Step 2**: 메이커 Play Test 상태에서 `maker_execute_script`(client)로 후보 RUID를 UIGroup에 격자 배치(아래 패턴) 후 `maker_screenshot`으로 확인, 카드당 1개 선별 - -```lua --- 후보 미리보기 패턴 (client 컨텍스트) -local ruids = { "", "", "..." } -local root = _EntityService:GetEntityByPath("/ui/DefaultGroup") -for i = 1, #ruids do - local e = _SpawnService:SpawnByModelId("model://uisprite", "RuidPreview" .. i, Vector3(0,0,0), root) - e.SpriteGUIRendererComponent.ImageRUID = ruids[i] - e.UITransformComponent.anchoredPosition = Vector2(-600 + ((i-1) % 8) * 160, 200 - math.floor((i-1) / 8) * 160) - e.UITransformComponent.RectSize = Vector2(140, 140) -end -``` - -- [ ] **Step 3**: 미리보기 엔티티 제거(스크립트로 Destroy) 후 플레이 종료 - -### Task 2: 카드·적 데이터 확장 - -**Files:** -- Modify: `data/cards.json` -- Modify: `data/enemies.json` - -- [ ] **Step 1**: `data/cards.json`의 `cards`에 4종 추가 (image는 Task 1 선별값) - -```json -"ChargedBlow": { "name": "차지 블로우", "cost": 2, "kind": "Attack", "damage": 8, "vuln": 2, "desc": "피해 8, 취약 2", "image": "<선별RUID>" }, -"Threaten": { "name": "위협", "cost": 0, "kind": "Skill", "weak": 2, "desc": "약화 2 부여", "image": "<선별RUID>" }, -"Enrage": { "name": "인레이지", "cost": 1, "kind": "Skill", "strength": 2, "desc": "힘 +2", "image": "<선별RUID>" }, -"Rage": { "name": "분노", "cost": 1, "kind": "Power", "powerEffect": "strengthPerTurn", "value": 1, "desc": "매 턴 시작 시 힘 +1", "image": "<선별RUID>" } -``` - -- [ ] **Step 2**: `data/enemies.json` 인텐트에 Debuff 추가 — mushmom에 `{ "kind": "Debuff", "effect": "weak", "value": 2 }` (intents 2번째로 삽입), slime_elite에 `{ "kind": "Debuff", "effect": "weak", "value": 1 }` (마지막), slime_boss·king_slime에 `{ "kind": "Debuff", "effect": "vuln", "value": 2 }` (Defend 다음), modified_snail에 `{ "kind": "Debuff", "effect": "weak", "value": 1 }` (마지막) -- [ ] **Step 3**: 커밋 `feat(buffs-power): 신규 카드 4종·적 디버프 인텐트 데이터` - -### Task 3: 생성기 — 직렬화·상태·전투 규칙 - -**Files:** -- Modify: `tools/deck/gen-slaydeck.mjs` (luaCardsTable ~line 64, props ~1888, StartCombat ~2001, BuildMonsters ~2040, StartPlayerTurn ~2184, EndPlayerTurn ~2191, PlayCard ~2410, DealDamageToTarget ~2520, EnemyActStep ~2599, ApplyCardFace ~2347) - -- [ ] **Step 1**: `luaCardsTable`에 신규 필드 직렬화 추가 - -```js -if (c.strength != null) fields.push(`strength = ${c.strength}`); -if (c.weak != null) fields.push(`weak = ${c.weak}`); -if (c.vuln != null) fields.push(`vuln = ${c.vuln}`); -if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`); -if (c.value != null) fields.push(`value = ${c.value}`); -``` - -- [ ] **Step 2**: props 추가 — `prop('number', 'PlayerStr', '0')`, `prop('number', 'PlayerWeak', '0')`, `prop('number', 'PlayerVuln', '0')`, `prop('any', 'PlayerPowers')` -- [ ] **Step 3**: `StartCombat`에 리셋 추가 (`self.PlayerBlock = 0` 다음 줄) - -```lua -self.PlayerStr = 0 -self.PlayerWeak = 0 -self.PlayerVuln = 0 -self.PlayerPowers = {} -``` - -- [ ] **Step 4**: `BuildMonsters`의 몬스터 테이블 생성에 `str = 0, weak = 0, vuln = 0` 필드 추가 (기존 `block = 0` 자리 옆) -- [ ] **Step 5**: 플레이어 공격 피해 헬퍼 `CalcPlayerAttack` 메서드 신설 + `PlayCard`의 Attack 분기를 수정 — `c.damage`에 힘·약화 적용한 값을 `PlayAttackFx`에 전달. 버프 필드 공통 처리(Attack/Skill 양쪽): `strength`/`weak`/`vuln` 적용 - -```lua --- method CalcPlayerAttack(base) → number -local dmg = base + self.PlayerStr -if self.PlayerWeak > 0 then - dmg = math.floor(dmg * 0.75) -end -return dmg -``` - -```lua --- PlayCard 내 교체 (Attack 분기) -if c.kind == "Attack" then - if c.damage ~= nil then - self:PlayAttackFx(self.TargetIndex, c.image, self:CalcPlayerAttack(c.damage)) - end - if c.block ~= nil then - self.PlayerBlock = self.PlayerBlock + c.block - end - self:ApplyRelics("cardPlayed") -elseif c.kind == "Skill" then - if c.block ~= nil then - self.PlayerBlock = self.PlayerBlock + c.block - end -elseif c.kind == "Power" then - if c.powerEffect ~= nil then - table.insert(self.PlayerPowers, cardId) - end -end --- 공통 버프/디버프 적용 (kind 분기 아래, table.remove 위) -if c.strength ~= nil then - self.PlayerStr = self.PlayerStr + c.strength -end -if c.weak ~= nil or c.vuln ~= nil then - local tm = self.Monsters[self.TargetIndex] - if tm ~= nil and tm.alive == true then - if c.weak ~= nil then tm.weak = tm.weak + c.weak end - if c.vuln ~= nil then tm.vuln = tm.vuln + c.vuln end - end -end -``` - -- [ ] **Step 6**: Power 소멸 처리 — `PlayCard`의 `table.insert(self.DiscardPile, cardId)`를 조건부로 - -```lua -table.remove(self.Hand, slot) -if c.kind ~= "Power" then - table.insert(self.DiscardPile, cardId) -end -``` - -- [ ] **Step 7**: `DealDamageToTarget`에 취약 배수 (block 차감 **이전**) - -```lua -local dmg = amount -if m.vuln > 0 then - dmg = math.floor(dmg * 1.5) -end -``` - -- [ ] **Step 8**: `EnemyActStep` — Debuff 인텐트 처리 + 적 공격 피해 공식 + 행동 후 적 디버프 감소 - -```lua -if intent.kind == "Attack" then - local atk = intent.value + m.str - if m.weak > 0 then - atk = math.floor(atk * 0.75) - end - if self.PlayerVuln > 0 then - atk = math.floor(atk * 1.5) - end - local before = self.PlayerHp - self:DealDamageToPlayer(atk) - self:ShowPlayerDmgPop(before - self.PlayerHp) -elseif intent.kind == "Defend" then - m.block = m.block + intent.value -elseif intent.kind == "Debuff" then - if intent.effect == "weak" then - self.PlayerWeak = self.PlayerWeak + intent.value - elseif intent.effect == "vuln" then - self.PlayerVuln = self.PlayerVuln + intent.value - end -end --- intentIdx 갱신 직후 -if m.weak > 0 then m.weak = m.weak - 1 end -if m.vuln > 0 then m.vuln = m.vuln - 1 end -``` - -- [ ] **Step 9**: `EndPlayerTurn`에 플레이어 디버프 감소 (`self:EnemyTurn()` 직전) - -```lua -if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end -if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end -``` - -- [ ] **Step 10**: `StartPlayerTurn`에 파워 발동 (`self:ApplyRelics("turnStart")` 다음) - -```lua -if self.PlayerPowers ~= nil then - for i = 1, #self.PlayerPowers do - local pc = self.Cards[self.PlayerPowers[i]] - if pc ~= nil and pc.powerEffect == "strengthPerTurn" then - self.PlayerStr = self.PlayerStr + pc.value - end - end -end -``` - -- [ ] **Step 11**: `ApplyCardFace` kind 색 분기에 `elseif c.kind == "Power" then` → `Color(0.46, 0.68, 0.52, 1)` 명시 (기존 else를 Power로) -- [ ] **Step 12**: 커밋 `feat(buffs-power): 버프/디버프·Power 전투 규칙 (생성기)` - -### Task 4: 생성기 — UI (적 방어도 배지·버프 라인·인텐트) - -**Files:** -- Modify: `tools/deck/gen-slaydeck.mjs` (몬스터 슬롯 UI 생성부 ~line 916-1015, PlayerPanel ~1015-1100, RenderCombat ~2688) - -- [ ] **Step 1**: 몬스터 슬롯 루프에 BlockBadge(guid 270+i)·Value(guid 280+i)·Buffs(guid 290+i) 엔티티 추가 (DmgPop 추가 코드 다음) - -```js -const mBlockBadge = entity({ - id: guid('cmb', 270 + i), path: `${base}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 6, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 36 }, pos: { x: -HP_BAR_W / 2 - 30, y: -14 } }), - sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }), - ], -}); -mBlockBadge.jsonString.enable = false; -combat.push(mBlockBadge); -combat.push(entity({ - id: guid('cmb', 280 + i), path: `${base}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 0, - components: [ - transform({ parentW: 40, parentH: 36, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 32 }, pos: { x: 0, y: 0 } }), - sprite({ color: TRANSPARENT }), - text({ value: '0', fontSize: 17, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), - ], -})); -combat.push(entity({ - id: guid('cmb', 290 + i), path: `${base}/Buffs`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 7, - components: [ - transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 60, y: 22 }, pos: { x: 0, y: -58 } }), - sprite({ color: TRANSPARENT }), - text({ value: '', fontSize: 15, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }), - ], -})); -``` - -- [ ] **Step 2**: PlayerPanel에 Buffs 텍스트(guid 217) 추가 (BlockBadge/Value 다음) - -```js -combat.push(entity({ - id: guid('cmb', 217), path: `${PP}/Buffs`, modelId: 'uitext', entryId: 'UIText', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', - displayOrder: 6, - components: [ - transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 22 }, pos: { x: 0, y: -44 } }), - sprite({ color: TRANSPARENT }), - text({ value: '', fontSize: 14, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }), - ], -})); -``` - -- [ ] **Step 3**: 버프 문자열 헬퍼 메서드 `BuffsLabel` 신설 (Lua, str/weak/vuln → "힘+2 약화1 취약2") - -```lua --- method BuffsLabel(str, weak, vuln) → string -local parts = {} -if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end -if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end -if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end -return table.concat(parts, " ") -``` - -- [ ] **Step 4**: `RenderCombat` 확장 — 몬스터 루프 내(SetHpBar 다음) - -```lua -self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0) -self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block)) -self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln)) -``` - -인텐트 분기 교체 (Attack은 최종 예상치·Debuff 추가): - -```lua -local t = "" -if intent ~= nil then - if intent.kind == "Attack" then - local atk = intent.value + m.str - if m.weak > 0 then atk = math.floor(atk * 0.75) end - if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end - t = "공격 " .. tostring(atk) - elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value) - elseif intent.kind == "Debuff" then - if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여" - else t = "취약 " .. tostring(intent.value) .. " 부여" end - end -end -``` - -인텐트 색: Attack 빨강(기존), Defend 파랑(기존 else), Debuff 보라 `Color(0.8, 0.5, 1, 1)` 분기 추가. - -플레이어 표시 (기존 BlockBadge 갱신 다음): - -```lua -local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln) -if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then - local names = {} - for i = 1, #self.PlayerPowers do - local pc = self.Cards[self.PlayerPowers[i]] - if pc ~= nil then table.insert(names, pc.name) end - end - if pb ~= "" then pb = pb .. " · " end - pb = pb .. table.concat(names, " ") -end -self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Buffs", pb) -``` - -- [ ] **Step 5**: 커밋 `feat(buffs-power): 적 방어도 배지·버프 라인·디버프 인텐트 UI (생성기)` - -### Task 5: 밸런스 시뮬 동기화 + 테스트 - -**Files:** -- Modify: `tools/balance/sim-balance.mjs` -- Test: `tools/balance/sim-balance.test.mjs` - -- [ ] **Step 1**: 실패 테스트 먼저 추가 — 약화·취약·힘 계산 + Debuff 인텐트 + Power 동작 - -```js -test('simulateCombat: 취약이 플레이어 공격을 1.5배로', () => { - const data = { - cards: { Vuln: { name: '취약기', cost: 1, kind: 'Skill', vuln: 9 }, Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 10 } }, - starterDeck: ['Vuln', 'Hit', 'Hit', 'Hit', 'Hit'], - monsters: [{ name: '적', maxHp: 100, intents: [{ kind: 'Defend', value: 0 }] }], - }; - const r = simulateCombat(data, mulberry32(1)); - // 1턴: 공격 우선 휴리스틱 → Hit×3 (취약 미부여, 30) — 그래도 30+α로 수치 검증은 별도 단위 함수로 - assert.equal(typeof r.win, 'boolean'); -}); - -test('calcAttack: 힘·약화·취약 공식', () => { - assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2 - assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75) - assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5) - assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5)=13 -}); - -test('simulateCombat: 적 Debuff 인텐트로 플레이어 약화 → 받는 피해 감소 검증', () => { - const data = { - cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } }, - starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'], - monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }], - }; - const r = simulateCombat(data, mulberry32(1)); - assert.equal(r.playerHpRemaining, 80); // Debuff만 하는 적 → 피해 0 -}); - -test('simulateCombat: Power(매턴 힘) 누적', () => { - const data = { - cards: { - Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 }, - Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 }, - }, - starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'], - monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }], - }; - const r = simulateCombat(data, mulberry32(1)); - assert.equal(r.win, true); - assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}턴`); -}); -``` - -- [ ] **Step 2**: `node --test tools/balance/sim-balance.test.mjs` → 신규 테스트 FAIL 확인 -- [ ] **Step 3**: `sim-balance.mjs` 구현 — `calcAttack(base, str, weak, vulnOnTarget)` export 신설, `simulateCombat`에 pStr/pWeak/pVuln/powers·몬스터 str/weak/vuln 상태 추가, 규칙 재현(부여→감소 타이밍 Lua와 동일), `chooseAction` 확장(Attack 우선 유지, 잔여 에너지로 Power→버프 Skill 사용), Debuff 인텐트 처리. `formatReport`의 kind 루프에 'Power' 포함(효율 계산은 plays만 표시). -- [ ] **Step 4**: `node --test tools/balance/sim-balance.test.mjs` → 전체 PASS -- [ ] **Step 5**: 커밋 `feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화` - -### Task 6: 산출물 재생성·시뮬 확인·푸시·PR·머지 - -**Files:** -- Regen: `RootDesk/MyDesk/SlayDeckController.codeblock`, `ui/DefaultGroup.ui`, `Global/common.gamelogic` - -- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` 실행 성공 확인 -- [ ] **Step 2**: `node tools/balance/sim-balance.mjs` — 승률 0%/100% 극단 아님 확인 (참고용 리포트 기록) -- [ ] **Step 3**: 커밋 `feat(buffs-power): 산출물 재생성 (버프/디버프·Power·적 방어도 UI)` -- [ ] **Step 4**: `git push -u origin feature/p6-buffs-power` -- [ ] **Step 5**: Gitea API로 PR 생성 → 머지 (기존 자동화 패턴: `curl -s -X POST .../repos/gahusb/maplecontest/pulls`, 토큰은 `.mcp.json` 참조 금지 — `git credential` 또는 기존 사용 토큰 경로) - -## Self-Review 결과 - -- 설계 요구 전 항목(버프 3종·Power·적 방어도 배지·예시 카드 4종·적 디버프 인텐트·시뮬 동기화) 태스크 매핑 확인 -- 타입/이름 일관성: `CalcPlayerAttack`·`BuffsLabel`·`PlayerStr/Weak/Vuln`·`PlayerPowers`·`m.str/weak/vuln` 통일 확인 -- 플레이스홀더: 카드 image RUID만 Task 1 산출물에 의존 (의도된 순서) diff --git a/docs/superpowers/plans/2026-06-12-card-frames.md b/docs/superpowers/plans/2026-06-12-card-frames.md deleted file mode 100644 index a1654c9..0000000 --- a/docs/superpowers/plans/2026-06-12-card-frames.md +++ /dev/null @@ -1,79 +0,0 @@ -# P13 — 커스텀 카드 프레임 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-card-frames-design.md` - -**Goal:** 사용자 제작 프레임 이미지(직업×등급)를 카드 UI 전체에 적용하고 등급을 보상 확률에 반영. - -**Architecture:** 단일 소스(`data/*.json` + `gen-slaydeck.mjs`) → 산출물 재생성. 카드 배경 스프라이트를 프레임 ImageRUID로 교체(A안), `ApplyCardFace` 중앙 함수에서 class×rarity 조회. - -**Tech Stack:** Node.js 생성기, MSW Lua, node --test. - -### Task 1: 리소스 커밋 -- [ ] `.sprite` 9종 커밋: `git add RootDesk/MyDesk/*.sprite && git commit -m "feat(card-frames): 카드 프레임 스프라이트 9종 로컬 임포트 (warior·mage·bandit × normal·unique·legend)"` - -### Task 2: 데이터 — rarity + cardframes.json -- [ ] `data/cardframes.json` 신설 (설계서 JSON 그대로) -- [ ] `data/cards.json` 32종에 `"rarity"` 추가 (설계서 표 그대로 — node 스크립트로 일괄 주입 권장) -- [ ] 커밋: `feat(card-frames): 카드 등급 배정·프레임 RUID 매핑 데이터` - -### Task 3: 생성기 — 프레임 렌더링 -- [ ] `CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json'))` 로드, 카드별 검증(throw): rarity ∈ {normal,unique,legend}, class ∈ classToFrame -- [ ] `luaCardsTable`: `fields.push(\`rarity = ${luaStr(c.rarity)}\`)` -- [ ] OnBeginPlay 주입(luaCardsTable 옆): `luaFramesTable()` — `self.CardFrames = {...}` + `self.ClassToFrame = {...}` / `prop('any','CardFrames')`·`prop('any','ClassToFrame')` 선언 -- [ ] `ApplyCardFace` Lua: kind 틴트 분기 → 프레임 적용 - -```lua -local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"] -local ruid = frames ~= nil and frames[c.rarity or "normal"] or nil -if ruid ~= nil then - e.SpriteGUIRendererComponent.ImageRUID = ruid - e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1) -end -``` - -- [ ] `cardFaceLayout(W)` 헬퍼 신설(s=W/180): Cost pos(-68s,103s)/size 44s/font 26s · Name pos(4s,97s)/size(150s,26s)/font 18s · Art pos(0,16s)/size 110s · Desc pos(0,-85s)/size(152s,64s)/font 16s -- [ ] 카드 생성 5곳(upsertUi 손패 ~523 · 조회 ~787 · 전체덱 ~928 · 보상 ~1443 · 상점 ~1660)에 헬퍼 적용, NamePlate/CostPlate 생성 제거, 카드 스프라이트 type 0·흰색·프리뷰 프레임 RUID -- [ ] CardHand 잔존 단색판 제거: upsertUi 초입 필터에 `/ui/DefaultGroup/CardHand/Card\d+/(NamePlate|CostPlate)` 경로 제거 추가 -- [ ] 커밋: `feat(card-frames): 생성기 — 프레임 렌더링·레이아웃 통합` - -### Task 4: 보상 가중 추첨 (TDD) -- [ ] `tools/balance/sim-balance.test.mjs`에 실패 테스트: `rarityForRoll(70)==='normal'`, `(71)==='unique'`, `(95)==='unique'`, `(96)==='legend'` → 실행해 FAIL 확인 -- [ ] `tools/balance/sim-balance.mjs`: `export function rarityForRoll(roll){ if (roll > 95) return 'legend'; if (roll > 70) return 'unique'; return 'normal'; }` → PASS 확인 -- [ ] `OfferReward` Lua 교체: - -```lua -local pool = self:CardPool() -local byRarity = {} -for _, id in ipairs(pool) do - local r = self.Cards[id].rarity or "normal" - if byRarity[r] == nil then byRarity[r] = {} end - table.insert(byRarity[r], id) -end -self.RewardChoices = {} -for i = 1, 3 do - local roll = math.random(1, 100) - local want = "normal" - if roll > 95 then want = "legend" elseif roll > 70 then want = "unique" end - local bucket = byRarity[want] - if bucket == nil or #bucket == 0 then bucket = pool end - self.RewardChoices[i] = bucket[math.random(1, #bucket)] - self:ApplyRewardVisual(i, self.RewardChoices[i]) -end -``` - -- [ ] 커밋: `feat(card-frames): 보상 등급 가중 추첨 70/25/5 (+JS 미러 테스트)` - -### Task 5: 재생성·검증·산출물 커밋 -- [ ] `node tools/deck/gen-slaydeck.mjs` → `grep -c "CardFrames" RootDesk/MyDesk/SlayDeckController.codeblock` ≥1, `grep -c "4bb57ef88ef449fdaf958f6cf37fe44b" ui/DefaultGroup.ui` ≥1 -- [ ] `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs` 전건 통과 -- [ ] 커밋: `feat(card-frames): 산출물 재생성` - -### Task 6: 메이커 검증·튜닝 -- [ ] maker_refresh_workspace → 빌드 콘솔 0에러 → 플레이: 손패 프레임·등급 구분, `_ResourceService` 로드 확인, 보상·덱 조회 스크린샷 -- [ ] 텍스트/아트 위치 어긋나면 `cardFaceLayout` 수치 조정 → 재생성 → 재확인 (수정 시 커밋) - -### Task 7: PR·머지·메모리 -- [ ] push → `node tools/git/gitea-pr.mjs create ` → merge → main pull → 메모리 갱신 (slaymaple-build-status에 P13 추가) - -## Self-Review -- 설계 전 항목에 대응 Task 존재 ✓ / 코드 블록 placeholder 없음 ✓ / CardFrames·ClassToFrame·rarityForRoll 명칭 일관 ✓ / maker_save 덮어쓰기 주의(설계서 '주의' 절) Task 6에서 refresh만 사용 ✓ diff --git a/docs/superpowers/plans/2026-06-12-combat-motion.md b/docs/superpowers/plans/2026-06-12-combat-motion.md deleted file mode 100644 index e8cb3b2..0000000 --- a/docs/superpowers/plans/2026-06-12-combat-motion.md +++ /dev/null @@ -1,18 +0,0 @@ -# P12 — 전투 모션 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-combat-motion-design.md` - -### Task 1: 아바타 액션 프로브 (메이커) -- [ ] play 상태에서 `AvatarBodyActionSelectorComponent` 존재·`MapleAvatarBodyActionState.swingO1`(및 stabO1) 대입 pcall 성공 여부 로그 → 성공 멤버 베이크 / 전부 실패 시 런지 폴백만 사용 - -### Task 2: 생성기 — 모션 메서드 4종 + 훅 -- [ ] `PlayerAttackMotion`(아바타 ActionState pcall+복귀 / 폴백 런지) · `PlayerHitMotion`(넉백 틱) · `MonsterLunge(idx)` · `MonsterHitMotion(slot)`(hitClip 캐시 사용·stand 복귀·흔들림 폴백, `m.motionBusy` 가드) -- [ ] BuildMonsters: `hitClip`/`standClip` pcall 캐시 + `motionBusy=false` -- [ ] 훅 연결: PlayCard(공격)·EnemyActStep(런지·넉백·독틱)·DealDamageToTarget·PlayAoeFx·체인메일 반사 -- [ ] 커밋 - -### Task 3: 재생성·메이커 검증·PR -- [ ] 재생성·테스트 40건 유지 → refresh·빌드 0에러 → 플레이테스트(공격 스윙/몬스터 hit 클립/런지·넉백/독 틱) → 커밋·push → gitea-pr.mjs PR·머지 → 메모리 갱신 - -## Self-Review -- 모든 복귀 타이머 isvalid/alive 가드 ✓ / 시뮬 비대상 명시 ✓ / 산출물 검증 카운트만 ✓ diff --git a/docs/superpowers/plans/2026-06-12-job-advancement.md b/docs/superpowers/plans/2026-06-12-job-advancement.md deleted file mode 100644 index 342c165..0000000 --- a/docs/superpowers/plans/2026-06-12-job-advancement.md +++ /dev/null @@ -1,89 +0,0 @@ -# P9 — 전직 시스템 코어 + 전사 2차 구현 계획 - -> **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:** 카드 클래스 모델·전직 선택 흐름·전사 2차 3직업(전용 카드 9종 + 신규 메커니즘 4종). - -**Architecture:** cards.json `class`/`hits`/`pierce`/`selfVuln` 스키마 확장 → gen-slaydeck.mjs (직렬화·CardPool 필터·전투 메커니즘·JobChoiceHud/JobSelectHud·ContinueAfterBoss 추출) → sim-balance 동기화. RULES.md 하네스 준수 (산출물 검증은 grep -c). - -설계: `docs/superpowers/specs/2026-06-12-job-advancement-design.md` (승인 완료) - ---- - -### Task 1: 카드 이미지 RUID 9종 선별 (메이커) - -- [ ] **Step 1**: asset_search(source=maplestory, sprite) 쿼리 — "콤보", "버서크", "라이징", "썬더", "블리자드", "파워 가드", "창", "철벽", "하이퍼" (빈약 시 보조 쿼리) -- [ ] **Step 2**: SkillFx 복제 격자 미리보기 → 9종 확정 → 정리·종료 (기존 절차) - -### Task 2: 데이터 — cards.json 확장 - -- [ ] **Step 1**: 기존 카드 9종 전부에 `"class": "warrior"` 추가 -- [ ] **Step 2**: 신규 9종 추가 (설계 표 그대로, image=Task 1 선별값): - -```json -"ComboAttack": { "name": "콤보 어택", "cost": 1, "kind": "Attack", "class": "fighter", "damage": 5, "hits": 2, "desc": "피해 5 × 2회", "image": "" }, -"Berserk": { "name": "버서크", "cost": 2, "kind": "Power", "class": "fighter", "powerEffect": "energyPerTurn", "value": 1, "selfVuln": 1, "desc": "매턴 에너지 +1, 취약 1 자가", "image": "" }, -"RisingAttack": { "name": "라이징 어택", "cost": 2, "kind": "Attack", "class": "fighter", "damage": 12, "desc": "피해 12", "image": "" }, -"ThunderCharge": { "name": "썬더 차지", "cost": 1, "kind": "Attack", "class": "page", "damage": 7, "weak": 1, "desc": "피해 7, 약화 1", "image": "" }, -"BlizzardCharge": { "name": "블리자드 차지", "cost": 1, "kind": "Attack", "class": "page", "damage": 7, "vuln": 1, "desc": "피해 7, 취약 1", "image": "" }, -"PowerGuard": { "name": "파워 가드", "cost": 1, "kind": "Skill", "class": "page", "block": 10, "desc": "방어도 10", "image": "" }, -"Pierce": { "name": "피어스", "cost": 1, "kind": "Attack", "class": "spearman", "damage": 9, "pierce": true, "desc": "피해 9, 방어 무시", "image": "" }, -"IronWall": { "name": "아이언 월", "cost": 2, "kind": "Skill", "class": "spearman", "block": 12, "desc": "방어도 12", "image": "" }, -"HyperBody": { "name": "하이퍼 바디", "cost": 1, "kind": "Power", "class": "spearman", "powerEffect": "blockPerTurn", "value": 3, "desc": "매턴 방어도 +3", "image": "" } -``` - -- [ ] **Step 3**: 커밋 `feat(job): 전사 2차 카드 9종·클래스 필드 데이터` - -### Task 3: 생성기 — 직렬화·전투 메커니즘 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1**: luaCardsTable에 `class`(필수 — 누락 시 throw)·`hits`·`pierce`·`selfVuln` 직렬화 -- [ ] **Step 2**: prop `PlayerJob`(string "") 추가, StartRun에 `self.PlayerJob = ""` 리셋 -- [ ] **Step 3**: PlayCard Attack 분기 — 다단히트·pierce·selfVuln: - -```lua -if c.kind == "Attack" then - if c.damage ~= nil then - local total = 0 - local n = c.hits or 1 - for h = 1, n do - total = total + self:CalcPlayerAttack(c.damage) - end - self:PlayAttackFx(self.TargetIndex, c.image, total, c.pierce == true) - end - ... -end --- 공통부 (버프 적용 옆): if c.selfVuln ~= nil then self.PlayerVuln = self.PlayerVuln + c.selfVuln end -``` - -- [ ] **Step 4**: `PlayAttackFx(targetIndex, image, damage, pierce)` / `DealDamageToTarget(amount, pierce)` 시그니처 확장 — pierce면 block 차감 생략. 기존 호출부(물약 화염병 포함) `false` 전달 -- [ ] **Step 5**: StartPlayerTurn 파워 루프 확장 — `energyPerTurn`→Energy, `blockPerTurn`→PlayerBlock (ClayBlockNext 처리 뒤) -- [ ] **Step 6**: 커밋 `feat(job): 다단히트·방어무시·자가취약·파워 2종 (생성기)` - -### Task 4: 생성기 — 풀 필터·전직 흐름·UI - -- [ ] **Step 1**: `CardPool()` 헬퍼 (정렬된 id 배열 반환 — class 필터), OfferReward·ShowShop이 사용 -- [ ] **Step 2**: CheckCombatEnd 보스 분기 → `ContinueAfterBoss()` 추출. 분기: `PlayerJob == "" and Floor < RunLength` → `ShowJobChoice()`, else 유물+`ContinueAfterBoss()` -- [ ] **Step 3**: `ShowJobChoice`/`PickJobReward(kind)` (relic→유물+Continue / job→ShowJobSelect), `ShowJobSelect`/`SetJob(jobId)` (PlayerJob·대표 카드 지급·토스트·Continue), `JobLabel()` 헬퍼 (전사/파이터/페이지/스피어맨) -- [ ] **Step 4**: UI — guid 'job'=0xe4: `JobChoiceHud`(타이틀+버튼 2)·`JobSelectHud`(3패널: 직업명·설명·대표 카드명). HideGameHud·BindButtons 등록 -- [ ] **Step 5**: PlayerPanel/Name 갱신 — StartCombat·SetJob에서 `JobLabel()` -- [ ] **Step 6**: 커밋 `feat(job): 클래스 풀 필터·전직 선택 흐름·전직 HUD (생성기)` - -### Task 5: 시뮬 동기화 (TDD) - -- [ ] **Step 1**: 실패 테스트 — hits 합산(힘 타격마다)·pierce(블록 무시)·selfVuln·energyPerTurn·blockPerTurn 5건 -- [ ] **Step 2**: sim-balance.mjs 재현 → 전체 PASS (기존 21+5, rogue-map 9) -- [ ] **Step 3**: 커밋 `feat(job): 시뮬 신규 메커니즘 동기화` - -### Task 6: 재생성·메이커 검증·PR - -- [ ] **Step 1**: 재생성 + `grep -c "PlayerJob\|JobChoiceHud" 산출물` 카운트 확인 + 전체 테스트 -- [ ] **Step 2**: 메이커 refresh→빌드 0에러→플레이테스트: 보스 클리어→선택 화면→전직(파이터)→전용 카드 풀 편입·직업명 표기·콤보/피어스 동작 스크린샷 -- [ ] **Step 3**: 커밋·푸시 → `gitea-pr.mjs`로 PR(UTF-8 spec)·머지 → main pull - -## Self-Review - -- 설계 전 항목 매핑 ✓ (클래스 모델 T2/T4, 전직 흐름 T4, 카드 9종 T1/T2, 메커니즘 T3/T5, 표기 T4) -- 시그니처 일관성: PlayAttackFx/DealDamageToTarget pierce 전 호출부 갱신 명시 ✓ -- 하네스: 산출물 검증 카운트만 ✓ diff --git a/docs/superpowers/plans/2026-06-12-magician.md b/docs/superpowers/plans/2026-06-12-magician.md deleted file mode 100644 index 8ff6c25..0000000 --- a/docs/superpowers/plans/2026-06-12-magician.md +++ /dev/null @@ -1,38 +0,0 @@ -# P10 — 법사 클래스 구현 계획 - -> **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 구문. - -**Goal:** 법사 클래스(1차 5종 + 2차 3계열 9종)·신규 메커니즘 4종(독/AoE/회복/드로)·캐릭터 선택 오픈·전직 화면 동적화. - -설계: `docs/superpowers/specs/2026-06-12-magician-design.md` - -### Task 1: 이미지 RUID 10종 선별 (4종은 기존 후보 재사용) -- [ ] 재사용 확정: FireArrow=78b9be4e(큰 불꽃)·ThunderBolt=c6685d33(낙뢰)·ColdBeam=e8f7c148(얼음)·ChillingStep=b2a7274d(빙수림) -- [ ] 검색(마법/독/회복/빛/포털/정령) → 메이커 격자 미리보기 → EnergyBolt·MagicGuard·MagicClaw·Teleport·Slow·PoisonBreath·ElementAmp·Heal·Bless·HolyArrow 확정 - -### Task 2: 데이터 — cards.json -- [ ] `starterDeck` → `starterDecks{warrior, magician}` (마법사: EnergyBolt×5·MagicGuard×4·MagicClaw×1), 생성기 검증 갱신 -- [ ] 신규 14종 추가 (설계 표 그대로: class=magician/firepoison/icelightning/cleric, draw/heal/poison/aoe 필드) → 커밋 - -### Task 3: 생성기 — 메커니즘 (Lua) -- [ ] 직렬화: draw·heal·poison·aoe + starterDecks 주입(StartRun 클래스 분기: MaxHp 80/70·RunDeck) -- [ ] PlayCard: `aoe` → `PlayAoeFx(image, total)` (단일 대상 로직과 동일 합산, 0.35s 후 전 생존 적에 각자 취약/방어 적용·슬롯별 팝업·KillMonster·CheckCombatEnd) / 공통부: heal(상한 클램프)·draw(`DrawCards`)·poison(타겟 `tm.poison += N`) -- [ ] BuildMonsters `poison = 0` 초기화, EnemyActStep 행동 타이머 시작부에 독 틱(피해 팝업·사망 시 행동 생략 후 체인 계속), BuffsLabel 4번째 인자 poison(`독N`) — RenderCombat 호출부 갱신(플레이어는 0) -- [ ] 커밋 - -### Task 4: 생성기 — 클래스 선택·전직 동적화 -- [ ] classCards Mage 활성화(enabled·tint·desc '마법 원거리 딜러'), BindMenuButtons MageButton→`SelectClass("magician")`, RenderCharacterSelect 2클래스 하이라이트·상태 텍스트, StartNewGame 가드 warrior|magician -- [ ] JobSelectHud 패널 경로 `Job_slot{1..3}` 범용화, `ShowJobSelect`(JOBS 상수→JobOpts prop, 슬롯 텍스트 채움) 신설 — PickJobReward("job")가 호출, 바인딩은 슬롯 인덱스→`SetJob(self.JobOpts[i].id)` -- [ ] SetJob 대표 카드 매핑(JOBS 테이블에 starter 포함: firepoison→FireArrow·icelightning→ThunderBolt·cleric→Heal), JobLabel 확장(마법사·위자드(불·독)·위자드(썬·콜)·클레릭) -- [ ] 커밋 - -### Task 5: 시뮬 동기화 (TDD) -- [ ] 실패 테스트: poison 틱·사망 / aoe 전체 피해 / heal 클램프 / draw / 법사 시작 덱은 시뮬 무관(주석) → 구현 → 전체 PASS → 커밋 - -### Task 6: 재생성·메이커 검증·PR -- [ ] 재생성 + grep -c 카운트 + 전체 테스트 → 커밋 -- [ ] 메이커: 법사 선택 시작(HP70·시작 덱), 전직 화면 마법사 3직업 표기, 클레릭 전직→힐 동작, 독/AoE 실측 → 스크린샷 -- [ ] push → gitea-pr.mjs PR·머지 → main pull - -## Self-Review -- 설계 전 항목 매핑 ✓ / JobSelect 동적화로 P9 고정 경로 제거 명시 ✓ / BuffsLabel 시그니처 변경 시 호출부(몬스터·플레이어) 동시 갱신 명시 ✓ diff --git a/docs/superpowers/plans/2026-06-12-potions-relics.md b/docs/superpowers/plans/2026-06-12-potions-relics.md deleted file mode 100644 index 685fed4..0000000 --- a/docs/superpowers/plans/2026-06-12-potions-relics.md +++ /dev/null @@ -1,285 +0,0 @@ -# P7 — 물약 시스템·유물 강화 구현 계획 - -> **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:** StS 풀세트 물약 6종 + 유물 19종(메이플 장비 외형·StS 효과) + 아이콘 행·마우스오버 툴팁 UI. - -**Architecture:** `data/potions.json` 신설·`relics.json` 확장(icon RUID) → `gen-slaydeck.mjs` 생성부 확장(상태·효과 훅·물약 로직·TopBar 아이콘 UI·툴팁) → 산출물 재생성. 시뮬 변경 없음(물약·유물은 시뮬 범위 밖 — 기존 정책 동일). - -**Tech Stack:** Node.js 생성기, MSW Lua, UITouchReceiveComponent(UITouchEnter/Exit/Down). - -설계 문서: `docs/superpowers/specs/2026-06-12-potions-relics-design.md` - ---- - -### Task 1: 아이콘 RUID 선별 (메이커, 유물 19 + 물약 6) - -- [ ] **Step 1**: `asset_search_resources`(cat=sprite, source=maplestory) 검색어별 후보 5개: 투구/방패/벨트/목걸이/갑옷/반지/부츠/도끼/가방/부적/깃털/망치/심장/송곳니/우상/포션/엘릭서/병 -- [ ] **Step 2**: P6과 동일한 SkillFx 복제 격자 미리보기로 스크린샷 → 유물 19·물약 6 아이콘 확정 (모자란 항목은 보조 검색어로 보충) -- [ ] **Step 3**: 미리보기 정리, 플레이 종료 - -### Task 2: 데이터 — potions.json 신설·relics.json 확장 - -**Files:** Create `data/potions.json`, Modify `data/relics.json` - -- [ ] **Step 1**: `data/potions.json` 작성 (icon은 Task 1 선별값) - -```json -{ - "potions": { - "redPotion": { "name": "빨간 포션", "desc": "HP 20 회복", "effect": "heal", "value": 20, "icon": "" }, - "firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "" }, - "warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "" }, - "guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "" }, - "manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "" }, - "cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "" } - }, - "dropChance": 0.4, - "baseSlots": 3, - "beltSlots": 5, - "shopPrice": 20 -} -``` - -- [ ] **Step 2**: `data/relics.json` — 기존 4종에 icon 추가 + 신규 15종 (설계 표의 hook/effect/value, 전부 icon 포함). relicPool = 기존 3종 + 신규 15종 (ironHeart는 시작 유물). - -```json -"potionBelt": { "name": "장인의 벨트", "desc": "물약 슬롯이 5칸으로 늘어난다", "hook": "passive", "effect": "potionSlots", "value": 5 }, -"burningBlood": { "name": "자쿰의 투구", "desc": "전투 승리 시 HP 6 회복", "hook": "combatEnd", "effect": "healOnWin", "value": 6 }, -"vajra": { "name": "미스릴 액스", "desc": "전투 시작 시 힘 +1", "hook": "combatStart", "effect": "strength", "value": 1 }, -"anchor": { "name": "메이플 실드", "desc": "첫 턴 방어도 +10", "hook": "combatStart", "effect": "block", "value": 10 }, -"bagOfPrep": { "name": "모험가의 배낭", "desc": "첫 턴 드로우 +2", "hook": "combatStart", "effect": "draw", "value": 2 }, -"bloodVial": { "name": "피의 목걸이", "desc": "전투 시작 시 HP 2 회복", "hook": "combatStart", "effect": "heal", "value": 2 }, -"bronzeScales": { "name": "브론즈 체인메일", "desc": "피격 시 공격자에게 3 반사", "hook": "onPlayerDamaged", "effect": "thorns", "value": 3 }, -"strawberry": { "name": "건강의 반지", "desc": "획득 시 최대 HP +7", "hook": "passive", "effect": "maxHp", "value": 7 }, -"penNib": { "name": "황금 깃펜", "desc": "10번째 공격마다 피해 2배", "hook": "attackCalc", "effect": "penNib", "value": 10 }, -"boot": { "name": "브론즈 부츠", "desc": "5 미만 공격 피해가 5로", "hook": "attackCalc", "effect": "boot", "value": 5 }, -"akabeko": { "name": "황소 투구", "desc": "전투 첫 공격 피해 +8", "hook": "attackCalc", "effect": "akabeko", "value": 8 }, -"centennialPuzzle": { "name": "백년의 부적", "desc": "전투 첫 피격 시 드로우 3", "hook": "onPlayerDamaged", "effect": "firstLossDraw", "value": 3 }, -"meatOnBone": { "name": "고기 망치", "desc": "승리 시 HP 50% 이하면 12 회복","hook": "combatEnd", "effect": "healIfLow", "value": 12 }, -"selfFormingClay": { "name": "점토 갑옷", "desc": "피해를 받으면 다음 턴 방어 +3","hook": "onPlayerDamaged", "effect": "clayBlock", "value": 3 }, -"championBelt": { "name": "챔피언 벨트", "desc": "취약 부여 시 약화 1 추가", "hook": "cardDebuff", "effect": "vulnAddsWeak", "value": 1 } -``` - -- [ ] **Step 3**: JSON 파싱 확인 + 커밋 `feat(potions-relics): 물약 6종·유물 15종 데이터 (아이콘 RUID 포함)` - -### Task 3: 생성기 — 로드·직렬화·상태 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1**: 상단에 potions 로드·검증 (RELICS 로드 다음) - -```js -const POTIONS = JSON.parse(readFileSync('data/potions.json', 'utf8')); -for (const [pid, p] of Object.entries(POTIONS.potions)) { - if (!p.name || !p.effect || p.value == null) throw new Error(`[gen-slaydeck] potion 필드 누락: ${pid}`); -} -function luaPotionsTable(potions) { - const lines = Object.entries(potions).map(([id, p]) => - `\t${id} = { name = ${luaStr(p.name)}, desc = ${luaStr(p.desc)}, effect = ${luaStr(p.effect)}, value = ${p.value}, icon = ${luaStr(p.icon || '')} },`); - return `self.Potions = {\n${lines.join('\n')}\n}`; -} -``` - -- [ ] **Step 2**: `luaRelicsTable`에 `icon = ${luaStr(r.icon || '')}` 필드 추가 -- [ ] **Step 3**: props 추가 — `prop('any', 'Potions')`, `prop('any', 'RunPotions')`, `prop('number', 'PotionSlots', '3')`, `prop('string', 'ShopPotion', '""')`, `prop('boolean', 'ShopPotionBought', 'false')`, `prop('number', 'FightAttackCount', '0')`, `prop('boolean', 'FirstHpLossDone', 'false')`, `prop('number', 'ClayBlockNext', '0')`, `prop('number', 'PotionMenuSlot', '0')` -- [ ] **Step 4**: `StartRun`에 `self.RunPotions = {}` `self.PotionSlots = ${POTIONS.baseSlots}` `${luaPotionsTable(POTIONS.potions)}` 추가 (RunRelics 초기화 옆) + `self:RenderPotions()` (BindButtons 후) -- [ ] **Step 5**: `StartCombat`에 `self.FightAttackCount = 0` `self.FirstHpLossDone = false` `self.ClayBlockNext = 0` 리셋 추가 - -### Task 4: 생성기 — 유물 효과 로직 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1**: `HasRelic` 헬퍼 신설 (boolean 반환) - -```lua -if self.RunRelics == nil then - return false -end -for i = 1, #self.RunRelics do - if self.RunRelics[i] == id then - return true - end -end -return false -``` - -- [ ] **Step 2**: `ApplyRelics` 확장 — 기존 effect에 추가: `strength`(PlayerStr += v), `heal`(HP 회복), `draw`(DrawCards(v) + RenderHand(false)), `healOnWin`(HP 회복), `healIfLow`(HP ≤ 50%면 회복) -- [ ] **Step 3**: `AddRelic` 확장 — passive 즉시 적용 - -```lua -local r = self.Relics[id] -if r ~= nil and r.hook == "passive" then - if r.effect == "potionSlots" then - self.PotionSlots = r.value - self:RenderPotions() - elseif r.effect == "maxHp" then - self.PlayerMaxHp = self.PlayerMaxHp + r.value - self.PlayerHp = self.PlayerHp + r.value - end -end -``` - -- [ ] **Step 4**: `PickNewRelic` 신설 — 미보유 풀 추첨, 없으면 골드 +25 후 빈 문자열 반환. elite/boss 보상부의 `self:AddRelic(self.RelicPool[...])`를 `local nid = self:PickNewRelic() if nid ~= "" then self:AddRelic(nid) end`로 교체, boss 분기에도 동일 추가 -- [ ] **Step 5**: `CalcPlayerAttack` 유물 보정 — 공격 카드에서만 호출되므로 내부에서 카운트 - -```lua -local base2 = base -self.FightAttackCount = self.FightAttackCount + 1 -if self.FightAttackCount == 1 and self:HasRelic("akabeko") then - base2 = base2 + 8 -end -local dmg = base2 + self.PlayerStr -if self:HasRelic("penNib") and self.FightAttackCount % 10 == 0 then - dmg = dmg * 2 -end -if self.PlayerWeak > 0 then - dmg = math.floor(dmg * 0.75) -end -if dmg > 0 and dmg < 5 and self:HasRelic("boot") then - dmg = 5 -end -if dmg < 0 then - dmg = 0 -end -return dmg -``` - -- [ ] **Step 6**: `DealDamageToPlayer`에 attacker 인자 추가 + onPlayerDamaged 유물 (HP 실손실 시) - -```lua --- 시그니처: (amount, attackerSlot) — EnemyActStep 호출부에 idx 전달 -local dmg = amount -if self.PlayerBlock > 0 then - local absorbed = math.min(self.PlayerBlock, dmg) - self.PlayerBlock = self.PlayerBlock - absorbed - dmg = dmg - absorbed -end -if dmg > 0 then - self.PlayerHp = self.PlayerHp - dmg - if self:HasRelic("bronzeScales") and attackerSlot ~= nil and attackerSlot > 0 then - local am = self.Monsters[attackerSlot] - if am ~= nil and am.alive == true then - am.hp = am.hp - 3 - if am.hp <= 0 then am.hp = 0 self:KillMonster(am.slot) end - end - end - if self:HasRelic("selfFormingClay") then - self.ClayBlockNext = self.ClayBlockNext + 3 - end - if self:HasRelic("centennialPuzzle") and self.FirstHpLossDone == false then - self.FirstHpLossDone = true - self:DrawCards(3) - self:RenderHand(false) - end -end -if self.PlayerHp < 0 then - self.PlayerHp = 0 -end -``` - -- [ ] **Step 7**: `StartPlayerTurn` — `self.PlayerBlock = 0` 직후 `if self.ClayBlockNext > 0 then self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext self.ClayBlockNext = 0 end` -- [ ] **Step 8**: `PlayCard` 디버프 적용부 — championBelt: `if c.vuln ~= nil and self:HasRelic("championBelt") then tm.weak = tm.weak + 1 end` -- [ ] **Step 9**: `CheckCombatEnd` 승리 분기 — `self:ApplyRelics("combatReward")` 앞에 `self:ApplyRelics("combatEnd")`, 뒤에 물약 드랍(Task 5의 `MaybeDropPotion`) -- [ ] **Step 10**: 커밋 `feat(potions-relics): 유물 15종 효과 훅 (생성기)` - -### Task 5: 생성기 — 물약 로직 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1**: 메서드 신설 — `AddPotion(pid)` (슬롯 검사·토스트), `MaybeDropPotion()` (`math.random() <= dropChance` 시 랜덤 지급), `RenderPotions()` (슬롯 5칸: 아이콘/빈칸/잠금), `OpenPotionMenu(slot)`/`ClosePotionMenu()`, `UsePotion()`, `TossPotion()` - -```lua --- AddPotion(pid) -if self.RunPotions == nil then self.RunPotions = {} end -if #self.RunPotions >= self.PotionSlots then - self:Toast("물약 슬롯이 가득 찼습니다") - return false -end -table.insert(self.RunPotions, pid) -self:RenderPotions() -return true -``` - -```lua --- MaybeDropPotion() -if math.random() > ${POTIONS.dropChance} then - return -end -local keys = {} -for pid, _ in pairs(self.Potions) do table.insert(keys, pid) end -table.sort(keys) -local pid = keys[math.random(1, #keys)] -if self:AddPotion(pid) == true then - local p = self.Potions[pid] - self:Toast("물약 획득: " .. p.name) -end -``` - -```lua --- UsePotion() — PotionMenuSlot 대상. 전투 중이 아니면 무시. -local combat = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud") -if combat == nil or combat.Enable ~= true or self.CombatOver == true then - self:Toast("전투 중에만 사용할 수 있습니다") - return -end -local pid = self.RunPotions[self.PotionMenuSlot] -if pid == nil then return end -local p = self.Potions[pid] -if p == nil then return end -if p.effect == "heal" then - self.PlayerHp = math.min(self.PlayerHp + p.value, self.PlayerMaxHp) -elseif p.effect == "damage" then - self:DealDamageToTarget(p.value) - self:ShowDmgPop(self.TargetIndex, p.value) -elseif p.effect == "strength" then - self.PlayerStr = self.PlayerStr + p.value -elseif p.effect == "block" then - self.PlayerBlock = self.PlayerBlock + p.value -elseif p.effect == "energy" then - self.Energy = self.Energy + p.value -elseif p.effect == "weak" then - local tm = self.Monsters[self.TargetIndex] - if tm ~= nil and tm.alive == true then - tm.weak = tm.weak + p.value - end -end -table.remove(self.RunPotions, self.PotionMenuSlot) -self:ClosePotionMenu() -self:RenderPotions() -self:RenderPiles() -self:RenderCombat() -self:CheckCombatEnd() -``` - -- [ ] **Step 2**: 상점 — `ShowShop`에 `self.ShopPotion = <정렬 키 랜덤>` `self.ShopPotionBought = false`, `RenderShop`에 Potion 라벨/가격/색, `BuyPotion` (가격 ${POTIONS.shopPrice}, AddPotion 실패 시 환불 없음 방지 — 슬롯 검사 먼저) -- [ ] **Step 3**: 커밋 `feat(potions-relics): 물약 사용·드랍·상점 로직 (생성기)` - -### Task 6: 생성기 — UI (아이콘 행·물약 슬롯·툴팁·물약 메뉴·상점) - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1**: TopBar — `Relics` 텍스트 항목 제거(topTexts에서 삭제), `RelicSlot1..10` (UISprite 40×40, x = -240 + (i-1)*48, guid cmb 300+i, UITouchReceiveComponent 포함, 기본 비표시 색), `RelicOverflow` 텍스트(guid cmb 311, 10번째 칸 위치), `PotionSlot1..5` (UISprite 40×40, x = 270 + (i-1)*44, guid cmb 320+i, UITouchReceiveComponent) -- [ ] **Step 2**: `TooltipBox` (guid cmb 330: bg 280×76 + Name + Desc, displayOrder 20, enable=false, CombatHud 직속) -- [ ] **Step 3**: `PotionMenu` 팝업 (guid cmb 340대: bg 320×180 중앙 + Title + [사용][버리기][닫기] 버튼 3개, enable=false) -- [ ] **Step 4**: ShopHud — Relic 블록 뒤 `Potion` 엔티티(Label/Price 동일 패턴, y=-270, Leave는 y=-360으로 이동) -- [ ] **Step 5**: `BindButtons` — RelicSlot/PotionSlot에 UITouchEnter/Exit(툴팁), PotionSlot UITouchDown(OpenPotionMenu), PotionMenu 버튼 3개, ShopHud/Potion 클릭(BuyPotion) 연결. `ShowTooltip`/`HideTooltip` 메서드 신설 -- [ ] **Step 6**: `RenderPotions`/`RenderRelics`(아이콘 행으로 재작성 — names 텍스트 제거) 구현 확인 -- [ ] **Step 7**: 커밋 `feat(potions-relics): 유물 아이콘 행·물약 슬롯·툴팁·물약 메뉴 UI (생성기)` - -### Task 7: 산출물 재생성·검증 - -- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` 성공, `node --test tools/balance/sim-balance.test.mjs` 21건 통과 유지 -- [ ] **Step 2**: 메이커 refresh → 빌드 콘솔 0 에러 → 플레이테스트: 유물 아이콘 표시·툴팁 hover·물약 지급(`AddPotion`)·사용·벨트 5칸 (`AddRelic("potionBelt")`) 스크립트 확인 + 스크린샷 -- [ ] **Step 3**: 커밋 `feat(potions-relics): 산출물 재생성 (물약·유물·툴팁)` - -### Task 8: 푸시·PR·머지 - -- [ ] **Step 1**: `git push -u origin feature/p7-potions-relics` -- [ ] **Step 2**: Gitea API PR 생성(종합 메시지) → 머지 → main pull - -## Self-Review 결과 - -- 설계 전 항목 매핑: 물약 6종·드랍·상점·사용/버리기(Task 2/5/6), 유물 15종 효과(Task 4), 아이콘+툴팁(Task 1/6), 벨트 5칸(Task 3 props + Task 4 passive) ✓ -- 이름 일관성: `RunPotions`/`PotionSlots`/`FightAttackCount`/`ClayBlockNext`/`HasRelic`/`PickNewRelic`/`MaybeDropPotion` 통일 ✓ -- 의존 순서: 아이콘 RUID(Task 1) → 데이터(Task 2) → 로직(3~5) → UI(6) → 검증(7) ✓ diff --git a/docs/superpowers/plans/2026-06-12-rogue-map.md b/docs/superpowers/plans/2026-06-12-rogue-map.md deleted file mode 100644 index c79f552..0000000 --- a/docs/superpowers/plans/2026-06-12-rogue-map.md +++ /dev/null @@ -1,71 +0,0 @@ -# P8 — 로그라이크 절차 생성 맵·층 시스템·유물 방 구현 계획 - -> **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:** 막마다 8층×4열 맵을 Lua 런타임 절차 생성, 층별 타입 규칙·점선 맵 UI·유물 방(상자 연출) 추가. - -**Architecture:** `data/map.json` 정적 주입 제거 → `GenerateMap` Lua 메서드(StS 경로-걷기 4개) + JS 미러(`tools/map/rogue-map.mjs`, node:test). MapHud는 정적 그리드(28노드+보스+도트 192)로 재작성, RenderMap이 런타임 토글. TreasureHud 신설(타이머 체인 흔들림 + RUID 교체). - -**Tech Stack:** Node.js 생성기, MSW Lua, mulberry32(JS 테스트 전용 — Lua는 math.random). - -설계: `docs/superpowers/specs/2026-06-12-rogue-map-design.md` (사용자 승인 완료) - ---- - -### Task 1: JS 미러 + 단위 테스트 (TDD) - -**Files:** Create `tools/map/rogue-map.mjs`, Create `tools/map/rogue-map.test.mjs` - -- [ ] **Step 1**: 테스트 먼저 작성 — `generateMap(rng)` import, 케이스: ①동일 시드 결정성 ②모든 노드가 시작점에서 BFS 도달 + 모든 노드에서 boss 도달 ③1~2행 combat만 ④elite·treasure는 4행부터 ⑤간선은 row+1·|Δcol|≤1 (boss 제외) ⑥elite 부모를 가진 노드는 elite 아님 ⑦boss는 row8 단일·7행 노드 전부 boss로 연결 ⑧MapStart ≥ 2개 -- [ ] **Step 2**: `node --test tools/map/rogue-map.test.mjs` → FAIL 확인 -- [ ] **Step 3**: `rogue-map.mjs` 구현 — 설계 알고리즘 그대로 (시작열 셔플 앞2 + 랜덤2, 경로 4개 걷기, 행 오름차순 가중 타입 배정·elite 부모 금지). 가중치 표는 설계 문서와 동일. ⚠️ 주석에 "Lua GenerateMap과 동기화 유지" 명시 -- [ ] **Step 4**: 테스트 PASS → 커밋 `feat(rogue-map): 절차 생성 알고리즘 JS 미러 + 테스트` - -### Task 2: 생성기 — 정적 맵 제거 + GenerateMap(Lua) + 층 시스템 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs`, Delete `data/map.json` - -- [ ] **Step 1**: `MAP` 로드(16행)·`MAX_ROW`(26행)·`luaMapNodesTable`·`luaStartArray` 제거. `StartRun`의 `${luaMapNodesTable(...)}`/`${luaStartArray(...)}` → `self:GenerateMap()` 호출로 교체. `data/map.json` 삭제 (`git rm`) -- [ ] **Step 2**: props 추가 — `prop('number', 'Depth', '0')`, `prop('any', 'VisitedNodes')` -- [ ] **Step 3**: `GenerateMap` 메서드 신설 (설계 알고리즘의 Lua 구현 — MapNodes/MapStart/VisitedNodes/Depth 리셋, 경로 4개, 행 3~7 가중 타입 배정+elite 부모 금지, boss 노드) -- [ ] **Step 4**: `PickNode` — `VisitedNodes` 추가·`Depth = node.row`·`RenderRun()`·`treasure → ShowTreasure` 분기·`self.CurrentEnemyId = node.enemy` → `""` -- [ ] **Step 5**: `RenderRun`의 Floor 텍스트 → `"막 F/3 · D층"` (`self.Depth`) -- [ ] **Step 6**: `CheckCombatEnd` 보스 클리어 분기에 `self:GenerateMap()` 추가 (Floor++ 후, TeleportToActMap 전) -- [ ] **Step 7**: `BindButtons`의 `mapNodeIds` 정적 배열 → 그리드 28개+boss 루프 생성으로 교체 -- [ ] **Step 8**: 커밋 `feat(rogue-map): GenerateMap 런타임 절차 생성 + 층 시스템 (생성기)` - -### Task 3: 생성기 — MapHud 그리드·점선 UI + RenderMap 재작성 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` (MapHud 섹션 ~1449행, RenderMap ~3492행) - -- [ ] **Step 1**: MapHud 섹션 재작성 — 기존 `MAP.nodes` 루프 삭제, 정적 생성: - - `Node_r{r}c{c}` (r=1..7, c=1..4): 56×56 uisprite+button, pos x=-270+(c-1)*180, y=-330+(r-1)*105, 기본 enable=false, `Label` 자식(타입명, fontSize 16) - - `Node_boss`: 72×72, pos (0, 405), `Label` "보스" - - 도트: r=1..6, c=1..4, c'∈{c-1,c,c+1}∩[1,4] → `Dot_r{r}c{c}_{c'}_{k}` k=1..3 (8×8 uisprite, 노드 중심 보간 t=k/4, enable=false) + r=7 → `Dot_r7c{c}_b_{k}` (boss로) - - guid('map') 인덱스는 결정적 루프 순서로 재배정 (섹션 전체 교체라 충돌 없음) -- [ ] **Step 2**: `RenderMap` 재작성 — 타입색 헬퍼(전투/엘리트/상점/휴식/보물/보스), 상태 4단(현재=골드·방문=어둡게·도달가능=타입색+버튼 활성·잠김=45% 어둡게+비활성), 도트 토글(간선 존재)·현재 노드 발신 간선 골드 -- [ ] **Step 3**: `node tools/deck/gen-slaydeck.mjs` 성공 확인 → 커밋 `feat(rogue-map): 맵 그리드·점선 도트 UI + RenderMap 상태 4단 (생성기)` - -### Task 4: 상자 RUID 선별 + TreasureHud + 메소 표기 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1**: `asset_search_resources`("보물상자"/"상자", source=maplestory) → 메이커 격자 미리보기(기존 패턴) → 닫힘/열림 RUID 2종 확정. 생성기 상수 `CHEST_CLOSED_RUID`/`CHEST_OPEN_RUID` -- [ ] **Step 2**: guid 맵에 `'trs': 0xe3` 추가, TreasureHud 섹션 신설 — root(hidden 패널)·Title("보물 상자")·`Chest`(160×160 uisprite+button, 닫힘 RUID, y=40)·`Reward` 텍스트(hidden, y=-120)·`Leave` 버튼(y=-260). `emit('TreasureHud', ...)` -- [ ] **Step 3**: `HideGameHud`에 TreasureHud 추가, `ShowState`에 `elseif state == "treasure"` 분기 -- [ ] **Step 4**: 메서드 — `ShowTreasure`(ChestOpened 리셋·닫힘 RUID·Reward 숨김·ShowState), `OpenChest`(1회 가드 → 흔들림 타이머 체인 ±8px 0.08s×6 → 0.55s 후 열림 RUID + 메소 40+random(0..20) + `PickNewRelic` 유물/소진 시 메소+30 + Reward 표시), prop `ChestOpened` -- [ ] **Step 5**: `BindButtons` — Chest 클릭→`OpenChest`, TreasureHud/Leave→`LeaveNode` -- [ ] **Step 6**: 메소 표기 — 표시 문자열 전수 교체: TopBar/ShopHud "골드 N"→"메소 N", 가격 "N 골드"→"N 메소", PickNewRelic 토스트 "골드 +25"→"메소 +25" (내부 prop Gold 유지) -- [ ] **Step 7**: 커밋 `feat(rogue-map): 유물 방 상자 연출·TreasureHud·메소 표기 (생성기)` - -### Task 5: 재생성·검증·푸시·PR·머지 - -- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` + `node --test tools/map/rogue-map.test.mjs tools/balance/sim-balance.test.mjs` 전체 PASS -- [ ] **Step 2**: 커밋 `feat(rogue-map): 산출물 재생성` → 메이커 refresh → 빌드 0에러 → 플레이테스트: 맵 생성(점선·상태색)·노드 진행(층 증가)·유물 방(흔들림→열림→보상)·보스 → 다음 막 새 맵, 스크린샷 확보 -- [ ] **Step 3**: push → Gitea API PR(종합 메시지) → 머지 → main pull → 메모리 갱신 - -## Self-Review 결과 - -- 설계 전 항목 매핑: 절차 생성(T1/T2)·층 시스템(T2)·점선 UI+상태 4단(T3)·유물 방+상자 모션(T4)·메소(T4) ✓ -- 이름 일관성: `GenerateMap`/`Depth`/`VisitedNodes`/`ShowTreasure`/`OpenChest`/`ChestOpened`/`Dot___` 통일 ✓ -- 리스크: MapHud 섹션 전체 교체로 guid('map') 재배정 — 섹션 단위 emit이라 안전. RenderMap pairs 순회 제거(그리드 고정 루프) ✓ diff --git a/docs/superpowers/plans/2026-06-14-lobby-map-npc.md b/docs/superpowers/plans/2026-06-14-lobby-map-npc.md deleted file mode 100644 index 60267f1..0000000 --- a/docs/superpowers/plans/2026-06-14-lobby-map-npc.md +++ /dev/null @@ -1,524 +0,0 @@ -# P15 — 로비 맵 + 월드 NPC 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md`. 산출물(`map/*.map`,`ui/DefaultGroup.ui`,`*.codeblock`,`Global/*`)은 Read/Edit 금지 — 생성기 소스(`tools/`)만 수정 후 재생성. 검증은 `grep -c`(카운트)와 메이커 플레이테스트. - -**Goal:** UI 패널 로비를 폐기하고, 전용 물리 맵 `lobby`에 공식 메이플 NPC 4종을 월드 엔티티로 배치해 근접(↑키)·클릭으로 기능을 열며, 이동·공격 모션은 로비 맵에서만 풀린다. - -**Architecture:** 단일 소스(`tools/*` 생성기 + `data/*.json`) → 산출물 재생성. 신규 생성기 2개(`gen-lobby-map.mjs`=맵+NPC 엔티티, `gen-lobby-npc.mjs`=LobbyNpc+LobbyMobility codeblock) + `gen-slaydeck.mjs`(흐름·UI) + `gen-player-lock.mjs`(전투맵 이동 재잠금 보강) 수정. 기존 기능 패널(CharacterSelect/Codex/SoulShop/Board)·전투 흐름 재사용. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua(codeblock), MSW MCP(플레이테스트·asset). - -**확정 사실(조사):** -- gen-slaydeck 편집 지점: OnBeginPlay `2830-2840`, ShowLobby `2986-2993`, LobbyHud npcs배열 `2469-2474`+버튼루프 `2475-2524`, lobTexts `2433-2439`, Asc버튼 `2454-2468`, BindLobbyButtons `2997-3014`, ShowState `2906-2922`, StartRun `3199-3232`, EndRun `4391-4403`, TeleportToActMap `4373-4385`, PlayerAttackMotion `4491-4500`, guid prefix `244-245`, ACT_MAPS `2745`. -- **1막 텔레포트 공백**: StartRun(`3199-3232`)에 map01 텔레포트가 없음 → `self:TeleportToActMap()` 추가 필요(`RenderPotions` 다음, `ShowMap` 직전). `TeleportToActMap`은 `maps[self.Floor]` 사용 + 가드 `if lp.CurrentMapName==target then return`(멱등). -- **NPC 공식 RUID**(maplestory, 흰박스 위험 없음): 모험가 `122095fd155c4633867b0da4f375bc3c`, 사서 `4c264be6a64f4ac3970b2e6818d04e40`, 상인 `69987ccdc486423f8bedd786bd6cb5d9`, 안내원 `8a99bd87d667482cb1f3b2193f8a19c1`. -- **MSW API**: 월드 클릭 = 엔티티에 `TouchReceiveComponent` + `self.Entity:ConnectEvent(TouchEvent, fn)`. 키 = `_InputService:ConnectEvent(KeyDownEvent, fn)` + `KeyboardKey.UpArrow`(273)/`Space`(32)/`LeftControl`. 거리 = `Vector2.Distance(Vector2(a.x,a.y),Vector2(b.x,b.y))`. 이동복원 = `pc.Enable=true; pc.FixedLookAt=false; mv.InputSpeed=; mv.JumpForce=`(client 공간). 표시토글 = `entity:SetVisible(bool)`. -- **맵 생성 패턴**(gen-maps.mjs): `JSON.parse(readFileSync('map/map01.map'))` → deep clone → 경로 `/maps/map01`→`/maps/lobby` 치환 → GUID 재발급(+origin fixup) → `compOf(e,'MOD.Core.X')`로 컴포넌트 접근 → `writeFileSync('map/lobby.map', JSON.stringify(map,null,2))`. 배경=`/Background`의 `BackgroundComponent.TemplateRUID`, 타일=`/TileMap`의 `TileMapComponent.TileSetRUID={DataId}`. 컴포넌트 부착=`@components` push + `componentNames` CSV 둘 다. SectorConfig=`Sectors[0].entries`에 `map://lobby` push. -- **codeblock 패턴**(gen-combat-monster.mjs): `prop()/method()` 팩토리 + 봉투(`CoreVersion:'26.5.0.0'`, `EntryKey:'codeblock://x'`) → `writeFileSync('RootDesk/MyDesk/X.codeblock', JSON.stringify(cb,null,2))`. 컨트롤러 호출=`_EntityService:GetEntityByPath("/common").SlayDeckController:Method(...)`. 폴 idiom=`_TimerService:SetTimerRepeat(fn,0.1)`+try카운트 가드+`:ClearTimer(id)`. - ---- - -### Task 0: 메이커 사전 정찰 (이동값·키·바디 컴포넌트·스폰좌표 확정) - -**목적:** LobbyMobility의 이동 복원 수치·공격 키·바디 컴포넌트 종류·로비 스폰 좌표를 추측이 아니라 실측으로 확정. 산출물 작성 전 선행. - -- [ ] **Step 1:** 메이커가 켜져 있는지 확인하고 현재 빌드 플레이. `mcp__msw-maker-mcp__maker_play` → `maker_screenshot`로 현재 화면(UI 로비) 확인. - -- [ ] **Step 2:** execute_script로 LocalPlayer 컴포넌트·이동값·바디 종류 덤프: - -```lua -local lp = _UserService.LocalPlayer -local s = "pc="..tostring(lp.PlayerControllerComponent ~= nil) -local mv = lp.MovementComponent -if mv ~= nil then s = s.." InputSpeed="..tostring(mv.InputSpeed).." JumpForce="..tostring(mv.JumpForce) end -s = s.." Rigidbody="..tostring(lp.RigidbodyComponent ~= nil) -s = s.." Sideviewbody="..tostring(lp.SideviewbodyComponent ~= nil) -local p = lp.TransformComponent.WorldPosition -s = s.." pos=("..tostring(p.x)..","..tostring(p.y)..","..tostring(p.z)..")" -s = s.." map="..tostring(lp.CurrentMapName) -log(s) -return s -``` - -Run via `maker_execute_script`. 기대: 현재 InputSpeed/JumpForce(0일 것), 어떤 바디 컴포넌트가 존재하는지(Rigidbody vs Sideviewbody), 현재 맵 이름·좌표. - -- [ ] **Step 3:** 이동 복원값 실측 — execute_script로 직접 켜 보고 걸어지는지 확인: - -```lua -local lp = _UserService.LocalPlayer -lp.PlayerControllerComponent.Enable = true -lp.PlayerControllerComponent.FixedLookAt = false -lp.MovementComponent.InputSpeed = 5 -lp.MovementComponent.JumpForce = 5 -return "applied: try walking with arrow keys" -``` - -`maker_keyboard_input`로 방향키를 눌러 실제 이동 여부 확인(screenshot 비교). 걸으면 InputSpeed 값 후보 = 5. 안 걸으면 RigidbodyComponent.WalkSpeed/WalkJump 등도 set해보고(아래) 동작하는 최소 set을 기록. - -```lua -local rb = _UserService.LocalPlayer.RigidbodyComponent -if rb ~= nil then rb.Enable = true end -``` - -- [ ] **Step 4:** 공격 키 enum 확정 — `mlua_api_retriever`(이미 검증됨: UpArrow=273, Space=32)에서 공격용 키 `LeftControl`의 정확한 enum 멤버명 확인(예: `KeyboardKey.LeftControl`). 확인 안 되면 공격 키를 `KeyboardKey.Space`로 폴백(이동 점프는 MSW 기본 Alt 가정). - -- [ ] **Step 5:** 결정 기록 — 이 plan 파일 하단 "정찰 결과" 섹션에 확정값 적기: - - `WALK_SPEED` = (Step3에서 걸어진 InputSpeed), `JUMP_FORCE` = (걸어진 JumpForce), `BODY_KIND` = Rigidbody|Sideviewbody|none, 추가 바디 set 필요 여부, `ATTACK_KEY` = LeftControl|Space, `LOBBY_SPAWN` = 적당한 지면 좌표(현재 map 좌표 참고, 예 `Vector3(0, 0.03, 0)`). - - 이후 Task에서 이 값을 JS 상수로 사용. - -- [ ] **Step 6:** `maker_stop`으로 플레이 종료(상태 churn 방지). - ---- - -### Task 1: `gen-lobby-map.mjs` — 로비 맵 + NPC 엔티티 생성 - -**Files:** -- Create: `tools/map/gen-lobby-map.mjs` -- Output(산출물, 직접 편집 금지): `map/lobby.map`, `Global/SectorConfig.config`(갱신) - -NPC 4종 + `!` 마크 4종을 월드 엔티티로 배치. 마크는 자식이 아니라 **형제 엔티티**(NPC 위 고정 위치, 정적이라 무방). 각 NPC에 `TouchReceiveComponent` + `script.LobbyNpc`(NpcId), 맵 루트에 `script.LobbyMobility` 부착. - -- [ ] **Step 1:** `tools/map/gen-maps.mjs`를 참고 헤더로 새 파일 생성. 상수: - -```js -import { readFileSync, writeFileSync } from 'node:fs'; - -const TEMPLATE = 'map/map01.map'; -const OUT = 'map/lobby.map'; -const SECTOR = 'Global/SectorConfig.config'; -const TOWN_BG = ''; // Task1 Step2에서 확정 -const NPCS = [ - { name: 'NpcRun', id: 'run', x: -4.5, ruid: '122095fd155c4633867b0da4f375bc3c' }, - { name: 'NpcCodex', id: 'codex', x: -1.5, ruid: '4c264be6a64f4ac3970b2e6818d04e40' }, - { name: 'NpcShop', id: 'shop', x: 1.5, ruid: '69987ccdc486423f8bedd786bd6cb5d9' }, - { name: 'NpcBoard', id: 'board', x: 4.5, ruid: '8a99bd87d667482cb1f3b2193f8a19c1' }, -]; -const MARK_RUID = ''; -const NPC_Y = 0.0; // 지면 (Task0 좌표 참고로 조정) -const MARK_DY = 1.6; // NPC 머리 위 오프셋 - -function compOf(e, type) { return e.jsonString['@components'].find((c) => c['@type'] === type); } -function lobbyGuid(idx) { - const n = (900000 + idx) >>> 0; // 기존 생성기와 충돌 없는 고유 오프셋 - return `${n.toString(16).padStart(8,'0')}-0000-4000-8000-${n.toString(16).padStart(12,'0')}`; -} -``` - -- [ ] **Step 2:** TOWN_BG·MARK_RUID 확정 — `gen-maps.mjs`를 열어 `BACKGROUNDS` 배열에서 타운 느낌 RUID 하나 골라 `TOWN_BG`에 박는다. MARK_RUID는 메이커 MCP `asset_search_resources`(source=maplestory, query "느낌표"/"balloon"/"emotion")로 1개 확정(못 찾으면 `!` 대신 작은 화살표/별 공식 스프라이트, 최후엔 NPC RUID 재사용+tint). - -- [ ] **Step 3:** 맵 로드·클론·정리(몬스터 제거)·배경: - -```js -const map = JSON.parse(JSON.stringify(JSON.parse(readFileSync(TEMPLATE, 'utf8')))); -map.EntryKey = 'map://lobby'; -let ents = map.ContentProto.Entities; -const isMonster = (e) => typeof e.componentNames === 'string' && (e.componentNames.includes('script.Monster') || e.componentNames.includes('script.CombatMonster')); -// 경로/이름 치환 -for (const e of ents) { - if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', '/maps/lobby'); - if (e.jsonString) { - if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', '/maps/lobby'); - if (e.jsonString.name === 'map01') e.jsonString.name = 'lobby'; - } - if ((e.path || '').endsWith('/Background')) { const bg = compOf(e, 'MOD.Core.BackgroundComponent'); if (bg) bg.TemplateRUID = TOWN_BG; } -} -// 몬스터 엔티티 제거 + PlayerLock/MapCamera는 유지(로비엔 PlayerLock 불필요하니 루트에서 제거) -ents = ents.filter((e) => !isMonster(e)); -const root = ents.find((e) => e.path === '/maps/lobby'); -if (!root) throw new Error('[gen-lobby-map] 맵 루트 없음'); -// 로비엔 PlayerLock 컴포넌트가 있으면 제거(이동 잠금 방지) -root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.PlayerLock'); -{ const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.PlayerLock'); root.componentNames = names.join(','); } -``` - -- [ ] **Step 4:** NPC 엔티티 + 마크 엔티티 생성(몬스터 템플릿을 클론해 몬스터 컴포넌트 제거 후 재사용). 몬스터 템플릿은 클론 전에 원본 ents(`map.ContentProto.Entities`)에서 확보: - -```js -const orig = JSON.parse(readFileSync(TEMPLATE, 'utf8')).ContentProto.Entities; -const tmpl = orig.find((e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster')); -if (!tmpl) throw new Error('[gen-lobby-map] 몬스터 템플릿(스프라이트 엔티티) 없음'); -let gi = 1; -function makeSpriteEntity(name, x, y, ruid, extraComps, extraNames, visible) { - const m = JSON.parse(JSON.stringify(tmpl)); - m.id = lobbyGuid(gi++); - m.path = `/maps/lobby/${name}`; - m.jsonString.path = m.path; - m.jsonString.name = name; - const o = m.jsonString.origin; if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; } - const tr = compOf(m, 'MOD.Core.TransformComponent'); if (tr) { tr.Position.x = x; tr.Position.y = y; } - const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); if (sp) sp.SpriteRUID = ruid; - // 몬스터/전투 컴포넌트 전부 제거 - m.jsonString['@components'] = m.jsonString['@components'].filter((c) => !['script.Monster','script.CombatMonster'].includes(c['@type'])); - let names = (m.componentNames || '').split(',').filter((s) => s && !['script.Monster','script.CombatMonster'].includes(s)); - // StateAnimationComponent가 있으면 die/hit 시트 제거(정적 stand) - for (const [comp, props] of extraComps) { m.jsonString['@components'].push({ '@type': comp, Enable: true, ...props }); names.push(comp); } - names = names.concat(extraNames).filter(Boolean); - m.componentNames = names.join(','); - // 마크 숨김은 Enable=false 금지(SetVisible가 안 먹음). codeblock OnBeginPlay가 SetVisible(false)로 숨기므로 - // 여기선 별도 처리 안 함. (한 프레임 깜빡임 우려 시 SpriteRendererComponent.Visible=false 시도 — 필드 확인 후.) - void visible; - return m; -} -const added = []; -for (const npc of NPCS) { - // NPC: TouchReceiveComponent(자동맞춤) + script.LobbyNpc(NpcId) - added.push(makeSpriteEntity(npc.name, npc.x, NPC_Y, npc.ruid, - [['MOD.Core.TouchReceiveComponent', { AutoFitToSize: true }], ['script.LobbyNpc', { NpcId: npc.id, Tries: 0, InRange: false, MarkName: npc.name + 'Mark' }]], - ['MOD.Core.TouchReceiveComponent', 'script.LobbyNpc'], true)); - // 마크: NPC 위, 기본 숨김 - added.push(makeSpriteEntity(npc.name + 'Mark', npc.x, NPC_Y + MARK_DY, MARK_RUID, [], [], false)); -} -ents = ents.concat(added); -``` - -> 주: `script.LobbyNpc` props(NpcId/MarkName 등)는 Task2의 codeblock 속성 정의와 **이름이 정확히 일치**해야 한다. - -- [ ] **Step 5:** 맵 루트에 `script.LobbyMobility` 부착 + 쓰기 + SectorConfig 등록: - -```js -root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.LobbyMobility'); -root.jsonString['@components'].push({ '@type': 'script.LobbyMobility', Enable: true, Tries: 0 }); -{ const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.LobbyMobility'); names.push('script.LobbyMobility'); root.componentNames = names.join(','); } -map.ContentProto.Entities = ents; -writeFileSync(OUT, JSON.stringify(map, null, 2), 'utf8'); -// SectorConfig: map://lobby 등록(멱등) + 시작 섹터를 lobby로 -const sector = JSON.parse(readFileSync(SECTOR, 'utf8')); -const sec0 = sector.ContentProto.Json.Sectors[0]; -if (!sec0.entries.includes('map://lobby')) sec0.entries.push('map://lobby'); -writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8'); -console.log('[gen-lobby-map] lobby.map 생성 + SectorConfig 등록 완료'); -``` - -- [ ] **Step 6:** 실행 + 카운트 검증(내용 출력 금지): - -```bash -node tools/map/gen-lobby-map.mjs -grep -c "script.LobbyNpc" map/lobby.map # 4 기대 -grep -c "script.LobbyMobility" map/lobby.map # 1 기대 -grep -c "TouchReceiveComponent" map/lobby.map # 4(+ 템플릿 잔존 가능) 기대 -grep -lc "map://lobby" Global/SectorConfig.config -node tools/verify/count.mjs 2>/dev/null || true -``` - -기대: LobbyNpc=4, LobbyMobility=1. 어긋나면 생성기 수정. - -- [ ] **Step 7:** 커밋: - -```bash -git add tools/map/gen-lobby-map.mjs map/lobby.map Global/SectorConfig.config -git commit -m "feat(lobby): 로비 전용 맵 + NPC 4종 월드 엔티티 생성기 (P15)" -``` - ---- - -### Task 2: `gen-lobby-npc.mjs` — LobbyNpc + LobbyMobility codeblock - -**Files:** -- Create: `tools/player/gen-lobby-npc.mjs` -- Output(산출물): `RootDesk/MyDesk/LobbyNpc.codeblock`, `RootDesk/MyDesk/LobbyMobility.codeblock` - -`gen-combat-monster.mjs`의 `prop()/method()`/봉투 패턴을 그대로 복사. **Lua 문자열은 실제 탭 들여쓰기 사용**(RULES.md 메모리: 실탭↔`\t` 혼재 금지 — 템플릿 리터럴 안 실제 탭). - -- [ ] **Step 1:** 헤더·팩토리(gen-combat-monster.mjs:9-17 복사) + 봉투 함수: - -```js -import { writeFileSync } from 'node:fs'; -const WALK_SPEED = /* Task0 정찰값 */ 5; -const JUMP_FORCE = /* Task0 정찰값 */ 5; -const ATTACK_KEY = /* Task0: 'LeftControl' 또는 'Space' */ 'LeftControl'; - -function prop(Type, Name, DefaultValue = 'nil') { return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; } -function method(Name, Code, Arguments = [], ExecSpace = 6) { - return { Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name }; -} -function writeCodeblock(id, name, properties, methods) { - const cb = { Id: '', GameId: '', EntryKey: `codeblock://${id.toLowerCase()}`, ContentType: 'x-mod/codeblock', Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0, - ContentProto: { Use: 'Json', Json: { CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 }, Description: '', Id: name, Language: 1, Name: name, Type: 1, Source: 0, Target: null, Properties: properties, Methods: methods, EntityEventHandlers: [] } } }; - writeFileSync(`RootDesk/MyDesk/${name}.codeblock`, JSON.stringify(cb, null, 2), 'utf8'); -} -``` - -- [ ] **Step 2:** LobbyNpc codeblock — 근접 폴링 + 마크 토글 + Touch/Key → Interact. (아래 Lua의 들여쓰기는 실제 탭으로 입력) - -```js -const npcInteract = method('Interact', `local c = _EntityService:GetEntityByPath("/common") -if c ~= nil and c.SlayDeckController ~= nil then - c.SlayDeckController:OnLobbyNpcInteract(self.NpcId) -end`); - -const npcBegin = method('OnBeginPlay', `self.Tries = 0 -self.InRange = false -local mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName) -if mark ~= nil then mark:SetVisible(false) end -self.Entity:ConnectEvent(TouchEvent, function(e) self:Interact() end) -_InputService:ConnectEvent(KeyDownEvent, function(e) - if self.InRange and e.key == KeyboardKey.UpArrow then self:Interact() end -end) -local eventId = 0 -local function tick() - local lp = _UserService.LocalPlayer - if lp == nil then return end - local a = lp.TransformComponent.WorldPosition - local b = self.Entity.TransformComponent.WorldPosition - local d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y)) - local near = d < 1.8 - if near ~= self.InRange then - self.InRange = near - if mark ~= nil then mark:SetVisible(near) end - end -end -eventId = _TimerService:SetTimerRepeat(tick, 0.15)`); - -writeCodeblock('LobbyNpc', 'LobbyNpc', [ - prop('string', 'NpcId', '""'), - prop('string', 'MarkName', '""'), - prop('boolean', 'InRange', 'false'), - prop('number', 'Tries', '0'), -], [npcBegin, npcInteract]); -``` - -- [ ] **Step 3:** LobbyMobility codeblock — 이동 복원 + 공격 키. (들여쓰기 실제 탭) - -```js -const mobBegin = method('OnBeginPlay', `self.Tries = 0 -local eventId = 0 -local function apply() - self.Tries = self.Tries + 1 - local lp = _UserService.LocalPlayer - if lp ~= nil and lp.PlayerControllerComponent ~= nil then - local pc = lp.PlayerControllerComponent - pc.Enable = true - pc.FixedLookAt = false - local mv = lp.MovementComponent - if mv ~= nil then - mv.InputSpeed = ${WALK_SPEED} - mv.JumpForce = ${JUMP_FORCE} - end - local rb = lp.RigidbodyComponent - if rb ~= nil then rb.Enable = true end - _TimerService:ClearTimer(eventId) - elseif self.Tries > 50 then - _TimerService:ClearTimer(eventId) - end -end -eventId = _TimerService:SetTimerRepeat(apply, 0.1) -_InputService:ConnectEvent(KeyDownEvent, function(e) - if e.key == KeyboardKey.${ATTACK_KEY} then - local c = _EntityService:GetEntityByPath("/common") - if c ~= nil and c.SlayDeckController ~= nil then - c.SlayDeckController:PlayerAttackMotion() - end - end -end)`); - -writeCodeblock('LobbyMobility', 'LobbyMobility', [prop('number', 'Tries', '0')], [mobBegin]); -console.log('[gen-lobby-npc] LobbyNpc/LobbyMobility codeblock 생성 완료'); -``` - -- [ ] **Step 4:** 실행 + 카운트 검증: - -```bash -node tools/player/gen-lobby-npc.mjs -grep -c "OnLobbyNpcInteract" RootDesk/MyDesk/LobbyNpc.codeblock # >=1 -grep -c "PlayerAttackMotion" RootDesk/MyDesk/LobbyMobility.codeblock # >=1 -ls -la RootDesk/MyDesk/LobbyNpc.codeblock RootDesk/MyDesk/LobbyMobility.codeblock -``` - -- [ ] **Step 5:** 커밋: - -```bash -git add tools/player/gen-lobby-npc.mjs RootDesk/MyDesk/LobbyNpc.codeblock RootDesk/MyDesk/LobbyMobility.codeblock -git commit -m "feat(lobby): LobbyNpc(근접·클릭 상호작용)·LobbyMobility(이동·공격 해제) codeblock (P15)" -``` - ---- - -### Task 3: `gen-player-lock.mjs` — 전투맵 이동 재잠금 보강 (방어) - -**Files:** Modify `tools/player/gen-player-lock.mjs` - -로비에서 푼 이동이 텔레포트 후 전투맵에 누설돼도, 전투맵 PlayerLock이 런타임으로 MovementComponent를 0으로 재설정해 확실히 잠그도록 보강. - -- [ ] **Step 1:** `gen-player-lock.mjs`의 PlayerLock Lua에서 `pc.Enable = false` 직후 라인을 추가(생성기 내 해당 Lua 템플릿 리터럴, 실제 탭 들여쓰기): - -```lua -pc.Enable = false -local mv = lp.MovementComponent -if mv ~= nil then mv.InputSpeed = 0; mv.JumpForce = 0 end -``` - -(정확한 삽입 지점은 `gen-player-lock.mjs`에서 `pc.Enable`가 들어간 Lua 문자열. `LocalPlayer.PlayerControllerComponent`를 `lp`로 잡는 변수명이 기존 코드와 일치하는지 확인 — 다르면 기존 변수명 사용.) - -- [ ] **Step 2:** 재생성 + 카운트: - -```bash -node tools/player/gen-player-lock.mjs -grep -c "InputSpeed = 0" RootDesk/MyDesk/PlayerLock.codeblock # >=1 기대(파일명은 생성기 출력명 확인) -``` - -- [ ] **Step 3:** 커밋: - -```bash -git add tools/player/gen-player-lock.mjs RootDesk/MyDesk/PlayerLock.codeblock map/map0*.map -git commit -m "fix(lobby): 전투맵 PlayerLock에 이동값 런타임 0 재설정 보강 (P15)" -``` - ---- - -### Task 4: `gen-slaydeck.mjs` — 흐름·UI 통합 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** guid prefix 등록(`244-245`) — 신규 prefix 불필요(LobbyHud 슬림화만, 기존 `lob` 재사용). 확인만. - -- [ ] **Step 2:** ACT_MAPS 아래(`2745`)에 로비 상수 추가: - -```js -const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05']; -const LOBBY_MAP = 'lobby'; -const LOBBY_SPAWN = 'Vector3(0, 0.03, 0)'; // Task0 정찰 좌표로 조정 -``` - -- [ ] **Step 3:** LobbyHud 슬림화 — `npcs` 배열(`2469-2474`)과 버튼 생성 루프(`2475-2524`) **삭제**. `lobTexts`(`2433-2439`)는 SoulLabel/AscLabel + 안내문(Hint)만 남기고 Title/Subtitle은 "마을" 정도로 축소 or 제거. AscMinus/AscPlus(`2454-2468`)는 유지. → LobbyHud가 상단 정보바(영혼/승천)만 남음. - -- [ ] **Step 4:** BindLobbyButtons(`2997-3014`) — NPC 4개 `bindClick` 라인 **삭제**(NpcRun/NpcCodex/NpcShop/NpcBoard). AscMinus/AscPlus/BoardHud.Close/SoulShopHud.Close bindClick은 유지. - -- [ ] **Step 5:** ShowLobby(`2986-2993`) — 끝에 로비 맵 텔레포트 추가: - -```js -method('ShowLobby', `self.SelectedClass = "" -self:RenderAscension() -self:RenderSoulLabel() -self:ShowState("lobby") -self:SetEntityEnabled("/ui/DefaultGroup/BoardHud", false) -self:SetEntityEnabled("/ui/DefaultGroup/SoulShopHud", false) -self:BindLobbyButtons() -self:BindMenuButtons() -self:GoLobbyMap()`), -``` - -- [ ] **Step 6:** 신규 method `GoLobbyMap`(ShowLobby 근처에 추가, ExecSpace 기본): - -```js -method('GoLobbyMap', `self.LobbyTpTries = 0 -local eventId = 0 -local function go() - self.LobbyTpTries = self.LobbyTpTries + 1 - local lp = _UserService.LocalPlayer - if lp ~= nil then - if lp.CurrentMapName ~= "${LOBBY_MAP}" then - _TeleportService:TeleportToMapPosition(lp, ${LOBBY_SPAWN}, "${LOBBY_MAP}") - end - _TimerService:ClearTimer(eventId) - elseif self.LobbyTpTries > 50 then - _TimerService:ClearTimer(eventId) - end -end -eventId = _TimerService:SetTimerRepeat(go, 0.1)`), -``` - -- [ ] **Step 7:** 신규 method `OnLobbyNpcInteract`(인자 id) — NPC codeblock이 호출: - -```js -method('OnLobbyNpcInteract', `if self.RunActive == true then return end -if id == "run" then - self:ShowCharacterSelect() -elseif id == "codex" then - self:ShowCodex() -elseif id == "shop" then - self:ShowSoulShop() -elseif id == "board" then - self:ShowBoard() -end`, [{ Type: 'string', DefaultValue: '""', SyncDirection: 0, Attributes: [], Name: 'id' }]), -``` - -(인자 객체 형태는 기존 `EndRun`의 `text` 인자/`ShowState`의 `state` 인자 정의를 참고해 동일 구조로.) - -- [ ] **Step 8:** StartRun(`3199-3232`) — `RenderPotions()` 다음, `ShowMap()` 직전에 1막 텔레포트 추가: - -```js -// ... self:RenderPotions() (기존) 다음 줄에 -self:TeleportToActMap() -// ... self:ShowMap() (기존) -``` - -(StartRun의 Lua 문자열 내부에 `self:TeleportToActMap()` 한 줄 삽입. Floor=1이 이미 세팅돼 map01 타깃.) - -- [ ] **Step 9:** EndRun(`4391-4403`) 복귀 — 기존 타이머 `self:ShowLobby()`가 GoLobbyMap을 호출하므로 **별도 변경 불필요**(ShowLobby가 로비 맵 텔레포트 포함). 확인만. - -- [ ] **Step 10:** 재생성 + 카운트 검증: - -```bash -node tools/deck/gen-slaydeck.mjs -grep -c "OnLobbyNpcInteract" RootDesk/MyDesk/SlayDeckController.codeblock # >=1 (이 파일엔 정의만; 호출은 LobbyNpc.codeblock) -grep -c "GoLobbyMap" RootDesk/MyDesk/SlayDeckController.codeblock # >=2 (정의+ShowLobby 호출) -grep -c "TeleportToActMap" RootDesk/MyDesk/SlayDeckController.codeblock # >=3 (정의+ContinueAfterBoss+StartRun) -grep -c "NpcRun" ui/DefaultGroup.ui # 0 기대(버튼-행 제거됨) -``` - -- [ ] **Step 11:** 커밋: - -```bash -git add tools/deck/gen-slaydeck.mjs ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic map/lobby.map map/map0*.map -git commit -m "feat(lobby): 로비 맵 흐름 통합 — OnBeginPlay/EndRun 텔레포트·NPC 상호작용 디스패치·StartRun map01 텔레포트·LobbyHud 슬림화 (P15)" -``` - ---- - -### Task 5: 미러/회귀 테스트 - -전투 규칙·맵 그래프 알고리즘 미변경 → 미러 동기화 불필요. 기존 테스트 회귀만 확인. - -- [ ] **Step 1:** 기존 테스트 실행: - -```bash -node --test tools/balance/sim-balance.test.mjs -node --test tools/map/rogue-map.test.mjs -``` - -기대: 전부 PASS(이번 변경은 전투/맵그래프 무관이라 회귀 없어야 함). - -- [ ] **Step 2:** `git status --short`로 의도치 않은 산출물 변경 없는지 확인(산출물 diff는 보지 않음). - ---- - -### Task 6: 메이커 플레이테스트 검증 - -- [ ] **Step 1:** git 상태 정리 후 메이커에서 **로컬 워크스페이스 refresh**(RULES.md §5 — 안 하면 stale 상태가 디스크 덮어씀). `maker_refresh_workspace` → 빌드 콘솔 0 에러 확인(`maker_logs`). - -- [ ] **Step 2:** `maker_play` → `maker_screenshot`. 검증 시나리오(스크린샷·로그로): - 1. 월드 시작 → **로비 맵에 스폰**(타운 배경, NPC 4명 보임), 방향키로 **이동됨**, 공격 키로 **공격 모션** 나옴. - 2. NPC 근접 → 머리 위 `!` 표시 → `↑`키로 기능 패널 오픈. NPC `maker_mouse_input` 클릭으로도 오픈(버튼 클릭 불가 메모리 주의 — 월드 엔티티 TouchEvent라 mouse_input 좌표 클릭 시도, 안 되면 ↑키 경로로 검증). - 3. 모험가→직업선택→런 시작 → **map01로 텔레포트**, 이동/공격 **잠김**. 1막 전투 몬스터 정상 등장(CurrentMapName 필터 통과). - 4. 사서→도감, 상인→영혼상점, 안내원→게시판 각각 오픈/닫기. - 5. 런 종료(빠른 패배 유도: execute_script로 `c.Combat.PlayerHp=0` 등 or 정상 진행) → 4초 후 **로비 맵 복귀**, 이동/공격 재해제. - 6. 상단 미니 HUD에 영혼/승천 표시 정상. - -- [ ] **Step 2b:** 실패 시 디버깅 — 이동 안 됨→Task0 값 재확認/RigidbodyComponent 추가 set, 클릭 안 됨→TouchReceiveComponent 필드/근접↑키 폴백, 몬스터 안 나옴→StartRun 텔레포트·spawn 좌표 확인. 생성기 수정→재생성→refresh→재플레이. - -- [ ] **Step 3:** `maker_stop`. 스크린샷을 사용자에게 공유. - ---- - -### Task 7: PR - -- [ ] **Step 1:** push: - -```bash -git push -u origin feature/p15-lobby-map-npc -``` - -- [ ] **Step 2:** PR spec JSON(UTF-8) 작성 후 `node tools/git/gitea-pr.mjs create ` (RULES.md §4 — 인라인 curl 한글 금지). 제목 예: "feat: P15 — 로비 맵 + 월드 NPC(근접·클릭) + 로비 전용 이동·공격". 본문에 변경 요약·검증 결과·스크린샷 언급. - -- [ ] **Step 3:** 사용자에게 PR 번호 보고 + 머지 여부 확인. - ---- - -## 정찰 결과 (Task0 실측 완료) -- **이동 레버 = `RigidbodyComponent.WalkAcceleration` (freeze가 0으로 만든 값). 복원값 0.7로 이동·점프 정상 확인** (InputSpeed/JumpForce는 무관 — WalkSpeed=1.4·WalkJump=1.23는 freeze가 안 건드림). - - 이동 해제 = `pc.Enable=true; pc.FixedLookAt=false; rb.WalkAcceleration=0.7` (rb.Enable는 이미 true). -- BODY_KIND = Rigidbody가 구동(Sideviewbody도 존재하나 WalkSpeed=nil). 추가 바디 set 불필요. -- ATTACK_KEY = `LeftControl` (KeyboardKey.LeftControl 유효, PlayerAttackMotion() 호출 정상). -- 상호작용 키 = `UpArrow` 유효. 클릭 = TouchReceiveComponent+TouchEvent. -- 현재 플레이어 위치 map01 (-5,-0.039,0) → LOBBY_SPAWN = `Vector3(-5, 0.03, 0)`. NPC x = -3 / -0.5 / 2 / 4.5, 근접 임계 1.2. -- TOWN_BG = Task1에서 gen-maps BACKGROUNDS 풀에서 선택, MARK_RUID = Task1 asset 검색. diff --git a/docs/superpowers/plans/2026-06-15-node-map-ui.md b/docs/superpowers/plans/2026-06-15-node-map-ui.md deleted file mode 100644 index 53e6ec8..0000000 --- a/docs/superpowers/plans/2026-06-15-node-map-ui.md +++ /dev/null @@ -1,227 +0,0 @@ -# 노드 맵 UI 강화 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `docs/superpowers/specs/2026-06-15-node-map-ui-design.md`. 산출물(`ui/DefaultGroup.ui`·`*.codeblock`)은 Read/Edit 금지 — `tools/deck/gen-slaydeck.mjs` 소스·`data/*.json`만 수정 후 재생성. 검증은 `node tools/verify/count.mjs`(카운트)와 메이커 플레이테스트. - -**Goal:** 맵 노드 선택 화면(MapHud)을 단색 박스+텍스트 → 공식 메이플 아이콘 노드 + 배경 이미지로 강화하고, 아이콘/배경 RUID를 `data/nodeicons.json`로 외부화해 교체를 쉽게 한다. - -**Architecture:** 단일 소스(`data/nodeicons.json` + `tools/deck/gen-slaydeck.mjs`) → 산출물 재생성. 노드 = 아이콘 스프라이트(타입별 ImageRUID 런타임 주입, 상태는 Color 틴트), 배경 = MapHud 루트 이미지 + 반투명 오버레이. 절차 랜덤 배치·간선·버튼 바인딩 불변. - -**Tech Stack:** Node.js ESM 생성기, MSW Lua(codeblock). - -**확정 RUID** (공식 maplestory, 썸네일 검수): combat=`f98db6823e894a4f90308d61f75894ac`, elite=`793ed8a757534b89a82f460747d2df24`, boss=`423056cdbbc04f4da131b9721c404d96`, shop=`da37e1fac55d455b9ade08569f09f798`, rest=`b86c1b0568bd45f3ae4a4b97e1b4a594`, treasure=`f8a6d58e20f54e2ca899485055df1ce4`, background=`d84241f17de344a097f5b96ac914f1d2`. - -**현재 코드 기준선**(gen-slaydeck.mjs): MapHud emit `1662~1763`(루트 `1664`, pushMapNode `1696`, 그리드 `1727`, 도트 displayOrder 1), RenderMapNode `5615~5677`, luaFramesTable `72`, OnBeginPlay 주입 `2906`, StartRun 주입 `3361`, CardFrames prop `2854`, CHEST 상수 `84`, sprite 헬퍼 `297`(dataId→ImageRUID, type 0=이미지). - ---- - -### Task 1: `data/nodeicons.json` + 생성기 로드·검증·직렬화 - -**Files:** Create `data/nodeicons.json` · Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** `data/nodeicons.json` 생성: - -```json -{ - "icons": { - "combat": "f98db6823e894a4f90308d61f75894ac", - "elite": "793ed8a757534b89a82f460747d2df24", - "boss": "423056cdbbc04f4da131b9721c404d96", - "shop": "da37e1fac55d455b9ade08569f09f798", - "rest": "b86c1b0568bd45f3ae4a4b97e1b4a594", - "treasure": "f8a6d58e20f54e2ca899485055df1ce4" - }, - "background": "d84241f17de344a097f5b96ac914f1d2" -} -``` - -- [ ] **Step 2:** `gen-slaydeck.mjs` CHEST 상수(`85`) 아래에 로드+검증 추가: - -```js -// 노드 맵 아이콘/배경 (공식 maplestory RUID, data/nodeicons.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성) -const NODEICONS = JSON.parse(readFileSync('data/nodeicons.json', 'utf8')); -for (const t of ['combat', 'elite', 'boss', 'shop', 'rest', 'treasure']) { - if (!/^[0-9a-f]{32}$/.test((NODEICONS.icons || {})[t] || '')) throw new Error(`[gen-slaydeck] nodeicons.json icons.${t} RUID 누락/형식오류`); -} -if (!/^[0-9a-f]{32}$/.test(NODEICONS.background || '')) throw new Error('[gen-slaydeck] nodeicons.json background RUID 누락/형식오류'); -``` - -- [ ] **Step 3:** `luaFramesTable`(`77`) 직후에 직렬화 헬퍼 추가: - -```js -function luaNodeIconsTable() { - const rows = Object.entries(NODEICONS.icons).map(([t, ruid]) => `\t${t} = ${luaStr(ruid)},`).join('\n'); - return `self.NodeIcons = {\n${rows}\n}`; -} -``` - -- [ ] **Step 4:** prop 선언 추가 — `prop('any', 'CardFrames'),`(`2854`) 아래에 `prop('any', 'NodeIcons'),`. - -- [ ] **Step 5:** OnBeginPlay 주입 — `2906`의 `${luaFramesTable()}` 줄 **아래**에 `${luaNodeIconsTable()}` 추가. StartRun 주입(`3361`)의 `${luaFramesTable()}` 아래에도 동일 추가. - -- [ ] **Step 6:** 로드 검증(아직 산출물 미변경이라 생성만 확인): - -```bash -node -e "const n=require('./data/nodeicons.json'); console.log('icons',Object.keys(n.icons).join(','),'| bg',n.background.length)" -``` -기대: `icons combat,elite,boss,shop,rest,treasure | bg 32` - -- [ ] **Step 7:** 커밋: - -```bash -git add data/nodeicons.json tools/deck/gen-slaydeck.mjs -git commit -m "feat(node-map): nodeicons.json 외부화 + 생성기 로드·검증·NodeIcons 직렬화" -``` - ---- - -### Task 2: MapHud emit — 배경 이미지 + 오버레이 + 아이콘 노드 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** MapHud 루트 sprite(`1673`)를 **배경 이미지**로 변경: - -```js - sprite({ dataId: NODEICONS.background, color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }), -``` - -- [ ] **Step 2:** 루트 push(`1677` `map.push(mapHud);`) 직후, Title push 앞에 **반투명 오버레이 자식** 추가: - -```js - map.push(entity({ - id: guid('map', 990), - path: '/ui/DefaultGroup/MapHud/Overlay', - modelId: 'uisprite', entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 0, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.04, g: 0.05, b: 0.09, a: 0.5 }, type: 1, raycast: true }), - ], - })); -``` -(guid 'map',990 은 노드 그리드·도트가 쓰는 mapN(2~약189)보다 충분히 높아 충돌 없음. 빌드 끝 id 유일성 검증이 잡아줌.) - -- [ ] **Step 3:** Title displayOrder를 오버레이(0) 위로 — Title 엔티티(`1684` `displayOrder: 0,`)를 `displayOrder: 2,`로 변경. - -- [ ] **Step 4:** `pushMapNode`(`1696~1726`) — 노드 본체를 **아이콘**으로 + Label 자식 제거: - - 본체 sprite(`1707`)를 `sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),`로 변경(단색 박스 → 이미지, 런타임에 ImageRUID 주입). - - Label 자식 push 블록(`1713~1725`, `map.push(entity({ ... /Label ... }))` 전체)을 **삭제**. - -- [ ] **Step 5:** 노드 크기 키움 — 그리드 호출(`1729`)의 `{ x: 56, y: 56 }`을 `{ x: 64, y: 64 }`로, 보스 호출(`1732`)의 `{ x: 72, y: 72 }`을 `{ x: 88, y: 88 }`로 변경. - -- [ ] **Step 6:** 커밋(아직 RenderMapNode 미수정 — 다음 Task와 함께 재생성/검증): - -```bash -git add tools/deck/gen-slaydeck.mjs -git commit -m "feat(node-map): MapHud 배경 이미지+오버레이, 노드 아이콘화(라벨 제거·확대)" -``` - ---- - -### Task 3: RenderMapNode Lua — ImageRUID + 상태 틴트 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** `RenderMapNode` 메서드 본문(`5615~5677`)을 아래로 **교체**(타입별 박스색/라벨 → 아이콘 ImageRUID + 상태 틴트). Lua 들여쓰기는 기존과 동일하게 실제 탭: - -```lua -local base = "/ui/DefaultGroup/MapHud/Node_" .. id -local e = _EntityService:GetEntityByPath(base) -if e == nil then - return -end -local node = self.MapNodes[id] -if node == nil then - e.Enable = false - return -end -e.Enable = true -local ruid = self.NodeIcons[node.type] -if ruid == nil then - ruid = self.NodeIcons["combat"] -end -if e.SpriteGUIRendererComponent ~= nil and ruid ~= nil then - e.SpriteGUIRendererComponent.ImageRUID = ruid -end -local reachable = self:IsReachable(id) -local visited = false -if self.VisitedNodes ~= nil then - for i = 1, #self.VisitedNodes do - if self.VisitedNodes[i] == id then visited = true end - end -end -if e.SpriteGUIRendererComponent ~= nil then - if id == self.CurrentNodeId then - e.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1) - elseif visited == true then - e.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.9) - elseif reachable == true then - e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1) - else - e.SpriteGUIRendererComponent.Color = Color(0.4, 0.4, 0.45, 0.45) - end -end -if e.ButtonComponent ~= nil then - e.ButtonComponent.Enable = reachable -end -``` -(메서드 시그니처 `[{Type:'string',...,Name:'id'}]`는 유지. `self:SetText(base.."/Label", ...)` 호출은 라벨 제거로 사라짐 — RenderMapDots/RenderMap는 불변.) - -- [ ] **Step 2:** 재생성: - -```bash -node tools/deck/gen-slaydeck.mjs -``` -기대: "Slay deck UI and combat codeblocks generated." - -- [ ] **Step 3:** 카운트 검증(내용 출력 금지, node fs): - -```bash -node -e "const fs=require('fs');const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');const ui=fs.readFileSync('ui/DefaultGroup.ui','utf8');const c=(s,p)=>(s.match(new RegExp(p,'g'))||[]).length;console.log('NodeIcons inject:',c(cb,'self.NodeIcons ='),'(>=2: OnBeginPlay+StartRun)','| ImageRUID in RenderMapNode:',c(cb,'NodeIcons\\\\[node.type\\\\]'),'| UI MapHud/Overlay:',c(ui,'MapHud/Overlay'),'(1)','| UI Label nodes(0 기대):',c(ui,'Node_r1c1/Label'),'| bg RUID:',c(ui,'d84241f17de344a097f5b96ac914f1d2'));" -``` -기대: NodeIcons inject ≥2, ImageRUID ≥1, Overlay 1, Label 0, bg RUID ≥1. - -- [ ] **Step 4:** 커밋: - -```bash -git add tools/deck/gen-slaydeck.mjs ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "feat(node-map): RenderMapNode 아이콘 ImageRUID+상태 틴트, 재생성" -``` - ---- - -### Task 4: 미러/회귀 테스트 - -- [ ] **Step 1:** 전투/맵그래프 미러 미변경 확인 — 테스트 실행: - -```bash -node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs -``` -기대: 전부 PASS(이 변경은 UI만, 전투/맵그래프 무관). - -- [ ] **Step 2:** `git status --short`로 의도치 않은 산출물 변경 없는지 확인. - ---- - -### Task 5: 메이커 플레이테스트 - -- [ ] **Step 1:** `maker_refresh_workspace` → `maker_logs build`로 빌드 에러 0 확인(기존 BuySoulUnlock Info 경고는 무관). - -- [ ] **Step 2:** `maker_play` → 런 시작(`SelectClass`+`StartNewGame`) → 맵 화면 `maker_screenshot`. 검증: - - 배경 이미지(리스항구) + 어두운 오버레이 위에 노드들. - - 노드가 **타입별 아이콘**(주황버섯/골렘/발록/돈주머니/모닥불/상자)으로 표시, 라벨 텍스트 없음. - - 상태 틴트: 현재=금색, 도달가능=원색(밝게), 잠김=어둡고 흐릿. - - 도달 가능 노드 클릭 시 진행(`PickNode`/마우스). 랜덤 배치 정상. - - 아이콘 잘림/왜곡 점검(특히 보스 발록·골렘). 잘리면 해당 노드 size 또는 아이콘 RUID 조정. - -- [ ] **Step 2b:** 실패 시 디버깅 — 흰박스→RUID/리로드 확인, 아이콘 안 뜸→ImageRUID 주입·NodeIcons 시드 확인, 가독성→오버레이 알파/틴트 튜닝. 생성기 수정→재생성→refresh→재플레이. - -- [ ] **Step 3:** `maker_stop`. 스크린샷 사용자 공유. - ---- - -### Task 6: PR - -- [ ] **Step 1:** `git push -u origin feature/node-map-ui`(인증 실패 시 `GCM_INTERACTIVE=never GIT_TERMINAL_PROMPT=0 git push`로 재시도). -- [ ] **Step 2:** UTF-8 spec JSON 작성 후 `node tools/git/gitea-pr.mjs create `. 제목 "feat: 노드 맵 UI 강화 — 아이콘 노드 + 배경 이미지(nodeicons.json 외부화)". -- [ ] **Step 3:** 사용자에게 PR 번호 보고. (변경 용이성: `data/nodeicons.json` RUID만 바꾸고 `node tools/deck/gen-slaydeck.mjs` 재실행하면 교체됨을 명시.) diff --git a/docs/superpowers/plans/2026-06-16-charselect-images-back.md b/docs/superpowers/plans/2026-06-16-charselect-images-back.md deleted file mode 100644 index 21a7df6..0000000 --- a/docs/superpowers/plans/2026-06-16-charselect-images-back.md +++ /dev/null @@ -1,205 +0,0 @@ -# 직업 선택 캐릭터 이미지 + 뒤로가기 — 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스. - -**Goal:** CharacterSelectHud의 단색 박스를 캐릭터 이미지 카드로 바꾸고(선택 시 금색 테두리), 뒤로가기 버튼으로 로비 복귀를 추가한다. - -**Architecture:** 단일 생성기 `tools/deck/gen-slaydeck.mjs` 수정 + `data/characters.json` 신설(초상화 RUID 단일 소스). 이미지는 생성 시 `sprite({dataId})`로 주입, 선택 표시는 기존 `RenderCharacterSelect`의 Button Color를 금색으로. 뒤로가기는 ShopHud 나가기 패턴 재사용 → `ShowLobby()`. 산출물(ui/codeblock) 재생성. - -**Tech Stack:** Node ESM 생성기, MSW Lua codeblock, MSW UI JSON. 검증=카운트+메이커 플레이테스트(이 저장소는 단위테스트 대신 카운트/플레이테스트). - -**확정 RUID (메이커 임포트 완료, `.sprite`에서 추출):** -- warrior `28c88fdc5ab44f34a8b3fc1e19d4ce78` -- magician `3b9ea1f066a744bb859df47fef817277` -- bandit `efa920e58d31426486ef974106e7dc8b` - ---- - -### Task 1: `data/characters.json` + 생성기 로드·검증 - -**Files:** -- Create: `data/characters.json` -- Modify: `tools/deck/gen-slaydeck.mjs:91-96` 인접(NODEICONS 로드 블록 뒤) - -- [ ] **Step 1:** `data/characters.json` 작성 -```json -{ - "portraits": { - "warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78", - "magician": "3b9ea1f066a744bb859df47fef817277", - "bandit": "efa920e58d31426486ef974106e7dc8b" - } -} -``` - -- [ ] **Step 2:** gen-slaydeck.mjs NODEICONS 검증 블록(`:96`) 바로 뒤에 로드+fail-fast 검증 추가 -```js -// 캐릭터 선택 초상화 (메이커 임포트 RUID, data/characters.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성) -const CHARS = JSON.parse(readFileSync('data/characters.json', 'utf8')); -for (const c of ['warrior', 'magician', 'bandit']) { - if (!/^[0-9a-f]{32}$/.test((CHARS.portraits || {})[c] || '')) throw new Error(`[gen-slaydeck] characters.json portraits.${c} RUID 누락/형식오류`); -} -``` - -- [ ] **Step 3:** 생성기 실행해 에러 없는지 확인(아직 UI 미사용이라 출력 동일) -``` -node tools/deck/gen-slaydeck.mjs -``` -Expected: 성공 메시지 1줄, throw 없음. - ---- - -### Task 2: CharacterSelectHud — 카드 이미지화 (classCards 루프) - -**Files:** Modify `tools/deck/gen-slaydeck.mjs:2516-2540` (Portrait/Desc 블록), `:2503-2515` (Name) - -카드 본체 `{key}Button`(2490-2502)·DeckButton(2567-2580)·StartButton·click 바인딩 경로는 **불변**. `cls.tint`/`cls.desc`는 더는 안 쓰이나 배열 정의는 그대로 둬도 무방. - -- [ ] **Step 1:** `Name`(2503-2515) 위치를 하단으로 — `transform`의 `pos: { x: 0, y: 108 }` → `pos: { x: 0, y: -137 }`. (displayOrder 0 유지) — 텍스트는 그대로(금색). - -- [ ] **Step 2:** `Portrait` 엔티티(2516-2527)를 **`Art` 이미지로 교체**. 경로·guid·sprite 변경: -```js - select.push(entity({ - id: guid('menu', 200 + i), - path: `${base}/Art`, - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 0, - components: [ - transform({ parentW: 270, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 258, y: 318 }, pos: { x: 0, y: 0 } }), - sprite({ dataId: CHARS.portraits[cls.classId], color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }), - ], - })); -``` -(258×318, 6px 인셋 → 부모 Button 색이 테두리로 보임. type:0=이미지 풀, raycast off=클릭은 부모 Button으로.) - -- [ ] **Step 3:** `Desc` 엔티티(2528-2540) **삭제**(emit 안 함). - -- [ ] **Step 4:** `Name` 뒤에 반투명 하단 배너 `NameBanner` 추가(displayOrder 1, Art 위·Name 아래). Name의 displayOrder를 2로 올림. -```js - select.push(entity({ - id: guid('menu', 210 + i), - path: `${base}/NameBanner`, - modelId: 'uisprite', - entryId: 'UISprite', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', - displayOrder: 1, - components: [ - transform({ parentW: 270, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 258, y: 60 }, pos: { x: 0, y: -137 } }), - sprite({ color: { r: 0, g: 0, b: 0, a: 0.55 }, type: 1, raycast: false }), - ], - })); -``` -그리고 Name 엔티티의 `displayOrder: 0` → `displayOrder: 2`로. - -- [ ] **Step 5:** 생성 + 카운트 검증 -``` -node tools/deck/gen-slaydeck.mjs -node tools/verify/count.mjs ui "CharacterSelectHud/WarriorButton/Art" "CharacterSelectHud/MageButton/Art" "CharacterSelectHud/ThiefButton/Art" -grep -c "28c88fdc5ab44f34a8b3fc1e19d4ce78" ui/DefaultGroup.ui # warrior RUID 1 -``` -Expected: Art 3개 존재, RUID 등장. (count.mjs 없으면 `grep -c '/Art"' ui/DefaultGroup.ui`.) - ---- - -### Task 3: RenderCharacterSelect — 선택 = 금색 테두리 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs:3362-3394` - -- [ ] **Step 1:** 선택 시 색을 금색으로. 세 군데 `Color(0.28, 0.36, 0.46, 1)` → `Color(1, 0.82, 0.3, 1)` (미선택 `Color(0.16, 0.2, 0.26, 1)`는 유지). Status 텍스트 로직 불변. - - `gen-slaydeck.mjs`에서 `Color(0.28, 0.36, 0.46, 1)` 를 `Color(1, 0.82, 0.3, 1)` 로 (RenderCharacterSelect 내 3회) 치환. - -- [ ] **Step 2:** 생성 + 확인 -``` -node tools/deck/gen-slaydeck.mjs -grep -c "Color(1, 0.82, 0.3, 1)" RootDesk/MyDesk/SlayDeckController.codeblock # 증가 확인(기존 사용처 + 3) -``` - ---- - -### Task 4: 뒤로가기 버튼 + 바인딩 - -**Files:** Modify `tools/deck/gen-slaydeck.mjs` — CharacterSelectHud emit(StartButton 뒤 `:2595` 직후), BindMenuButtons(`:3158` 뒤), prop 선언부 - -- [ ] **Step 1:** StartButton emit(2582-2595) 직후에 BackButton emit 추가(StartButton 패턴 복제, 좌상단 배치) -```js - select.push(entity({ - id: guid('menu', 230), - path: '/ui/DefaultGroup/CharacterSelectHud/BackButton', - modelId: 'uibutton', - entryId: 'UIButton', - componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', - displayOrder: 22, - components: [ - transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 180, y: 56 }, pos: { x: -800, y: 430 }, align: ALIGN_CENTER }), - sprite({ color: { r: 0.15, g: 0.2, b: 0.26, a: 1 }, type: 1, raycast: true }), - button(), - text({ value: '← 뒤로', fontSize: 26, bold: true, color: GOLD, alignment: 0 }), - ], - })); -``` - -- [ ] **Step 2:** BindMenuButtons(StartGameHandler 블록 `:3151-3158` 뒤)에 BackButton 바인딩 추가 -```lua -local charBack = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/BackButton") -if charBack ~= nil and charBack.ButtonComponent ~= nil then - if self.CharBackHandler ~= nil then - charBack:DisconnectEvent(ButtonClickEvent, self.CharBackHandler) - self.CharBackHandler = nil - end - self.CharBackHandler = charBack:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end) -end -``` -(이 Lua는 BindMenuButtons 메서드 본문 문자열 끝에 삽입. 실제 탭/`\t` 스타일은 해당 메서드 본문 규칙을 따른다 — BindMenuButtons는 실탭 사용.) - -- [ ] **Step 3:** prop `CharBackHandler` 선언 추가. 기존 핸들러 prop 목록(예: `StartGameHandler`/`NewGameHandler` 등 `prop('any','...')` 선언부)을 grep으로 찾아 같은 형식으로 `CharBackHandler` 추가. -``` -grep -n "StartGameHandler" tools/deck/gen-slaydeck.mjs # prop 선언 위치 확인 -``` - -- [ ] **Step 4:** 생성 + 검증 -``` -node tools/deck/gen-slaydeck.mjs -node tools/verify/count.mjs ui "CharacterSelectHud/BackButton" # 1 -grep -c "CharBackHandler" RootDesk/MyDesk/SlayDeckController.codeblock # ≥2 (선언+바인딩+해제) -``` - ---- - -### Task 5: 산출물 재생성 커밋 + .sprite 커밋 + 플레이테스트 - -**Files:** `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`(재생성), `RootDesk/MyDesk/*.sprite`(임포트) - -- [ ] **Step 1:** 최종 재생성 + git status로 의도 외 변경 없는지 확인 -``` -node tools/deck/gen-slaydeck.mjs -git status --short -``` -Expected: 변경 = gen-slaydeck.mjs, data/characters.json, ui/DefaultGroup.ui, SlayDeckController.codeblock (+ common.gamelogic은 churn이면 내용 동일 시 git checkout 복원). untracked = 임포트 .sprite. - -- [ ] **Step 2:** 소스 커밋(생성기+데이터) → 산출물 커밋(재생성 명시) → .sprite 커밋 분리 -``` -git add tools/deck/gen-slaydeck.mjs data/characters.json -git commit -m "feat(charselect): 직업 카드 캐릭터 이미지 + 뒤로가기 (소스)" -git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock -git commit -m "chore: 산출물 재생성 (charselect 이미지+뒤로가기)" -git add "RootDesk/MyDesk/warrior.sprite" "RootDesk/MyDesk/mage.sprite" "RootDesk/MyDesk/bandit.sprite" -git commit -m "chore(assets): 캐릭터 초상화 스프라이트 임포트(전사/법사/도적)" -``` -(2차전직 아트 12종 .sprite는 별도 — 향후 2차 전직 선택 이미지용. 사용자 의사 확인 후 커밋/보류.) - -- [ ] **Step 3:** 메이커 플레이테스트(사용자 워크스페이스 reload 후): 로비 NPC→직업 선택 진입→3 카드에 캐릭터 이미지 표시→클릭 시 금색 테두리·Status 갱신→시작 시 그 직업으로 런→뒤로가기 시 로비 복귀. 빌드 콘솔 0 에러. - - 이미지 비율 왜곡/잘림 보이면 Art size(258×318) 조정. - - 뒤로가기 시 재텔레포트 jolt 보이면 BackButton 바인딩을 `self:ShowState("lobby")`로 축소. - -- [ ] **Step 4:** push + PR (`node tools/git/gitea-pr.mjs create `, UTF-8). - ---- - -## Self-Review - -- **스펙 커버리지**: 이미지 적용(T1,T2) · 선택→진행 연결(기존 SelectClass/StartNewGame 불변, T2가 클릭경로 보존) · 선택 금색 테두리(T3) · 뒤로가기→로비(T4) · characters.json 단일소스(T1) · 검증/플레이테스트(T5). 누락 없음. -- **플레이스홀더**: RUID·좌표·색·Lua 전부 구체값. count.mjs 부재 시 grep 대체 명시. -- **타입 일관성**: `CHARS.portraits[classId]`(classId=warrior/magician/bandit, classCards.classId와 일치). 핸들러 `CharBackHandler` 일관. Art/NameBanner guid(200+i/210+i/230) 미사용 번호. -- **리스크**: 이미지 비율(T5 Step3 조정), ShowLobby 재텔레포트(T5 Step3 폴백 ShowState), 메이커 reload 필수(산출물 디스크 반영). diff --git a/docs/superpowers/plans/2026-06-16-charselect-maker-pilot.md b/docs/superpowers/plans/2026-06-16-charselect-maker-pilot.md deleted file mode 100644 index 8e29ba6..0000000 --- a/docs/superpowers/plans/2026-06-16-charselect-maker-pilot.md +++ /dev/null @@ -1,106 +0,0 @@ -# Phase 2 — 캐릭터 선택 메이커 저작 파일럿 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. - -**Goal:** charselect를 생성중단→stock화(메이커 편집)하고, 캐릭터 이미지를 컨트롤러가 런타임 경로 주입(ClassPortraits)하도록 바꿔 패턴 (b)를 검증한다. - -**Architecture:** ① 이미지 런타임 주입 추가(ClassPortraits + luaCharsTable + RenderCharacterSelect) → ② charselect 생성 중단(GENERATED_UI_SECTIONS/emit 제거 → 기존 엔티티 stock 보존). 컨트롤러는 경로 구동 유지. - -**Tech Stack:** Node ESM 생성기, MSW Lua. 검증 = **count(동작 검증)** + 메이커 플레이테스트(바이트동일 아님 — codeblock·ui 의도적 변경). - -**의존:** Phase 1b(#71) 위 스택(`feature/charselect-maker-pilot`). #70·#71 머지 후 main 리타겟. - ---- - -## 검증 메모 -Phase 2는 codeblock·ui를 **의도적으로 변경**(diffcheck-IDENTICAL 아님). 게이트: -- `node tools/deck/gen-slaydeck.mjs` 성공(throw 없음). -- `node tools/verify/count.mjs cb ClassPortraits 'ImageRUID = self.ClassPortraits'` → 주입 코드 존재. -- `node tools/verify/count.mjs ui CharacterSelectHud/WarriorButton/Art` → charselect 엔티티 ui 잔류(stock). -- 미러 테스트 무영향(회귀 확인차 실행). -- **최종**: 사용자 메이커 플레이테스트. - ---- - -### Task 1: `luaCharsTable()` 신설 (lib/data.mjs) - -**Files:** Modify `tools/deck/lib/data.mjs` - -- [ ] **Step 1:** `luaNodeIconsTable`(:78-81) 바로 뒤에 추가: -```js -function luaCharsTable() { - const rows = Object.entries(CHARS.portraits).map(([c, ruid]) => `\t${c} = ${luaStr(ruid)},`).join('\n'); - return `self.ClassPortraits = {\n${rows}\n}`; -} -``` -- [ ] **Step 2:** `export { ... }`에 `luaCharsTable` 추가. -- [ ] **Step 3:** 커밋(아직 미사용 — import 시 검증). - ---- - -### Task 2: ClassPortraits 시드 + prop - -**Files:** Modify `tools/deck/cb/boot.mjs`, `tools/deck/cb/run.mjs`, `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** `cb/boot.mjs`·`cb/run.mjs`의 import에 `luaCharsTable` 추가(`luaNodeIconsTable` 옆, `from '../lib/data.mjs'`). -- [ ] **Step 2:** `cb/boot.mjs:8`(`${luaNodeIconsTable()}`) 다음 줄에 `${luaCharsTable()}` 추가. `cb/run.mjs:34` 동일. -- [ ] **Step 3:** `gen-slaydeck.mjs:311`(`prop('any', 'NodeIcons'),`) 다음 줄에 `prop('any', 'ClassPortraits'),` 추가. -- [ ] **Step 4:** `node tools/deck/gen-slaydeck.mjs` 성공 + `node tools/verify/count.mjs cb ClassPortraits` → ≥2(시드 2회). -- [ ] **Step 5:** 산출물 churn 복원(`git checkout --`) — codeblock은 이 시점 변경됨(ClassPortraits 추가)이므로 **복원 안 함**, ui/common만 churn이면 복원. 커밋(소스 + 재생성 codeblock 분리 또는 함께 "산출물 재생성" 명시). - ---- - -### Task 3: RenderCharacterSelect 이미지 런타임 주입 - -**Files:** Modify `tools/deck/cb/charselect.mjs:13`(RenderCharacterSelect) - -- [ ] **Step 1:** RenderCharacterSelect 본문 **맨 앞**에 3 Art 주입 추가(Python 치환 — 실탭). classId: Warrior→warrior, Mage→magician, Thief→bandit: -```lua -local warriorArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/WarriorButton/Art") -if warriorArt ~= nil and warriorArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["warrior"] ~= nil then - warriorArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["warrior"] -end -local mageArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/MageButton/Art") -if mageArt ~= nil and mageArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["magician"] ~= nil then - mageArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["magician"] -end -local thiefArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/ThiefButton/Art") -if thiefArt ~= nil and thiefArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["bandit"] ~= nil then - thiefArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["bandit"] -end -``` -(기존 border/status 로직 앞에 prepend. RenderCharacterSelect는 ShowCharacterSelect/SelectClass에서 호출 → 열림·선택 시 멱등 주입.) -- [ ] **Step 2:** `node tools/deck/gen-slaydeck.mjs` + `node tools/verify/count.mjs cb 'ImageRUID = self.ClassPortraits'` → 3. -- [ ] **Step 3:** 커밋(소스 + 재생성 codeblock). - ---- - -### Task 4: charselect 생성 중단 → stock - -**Files:** Modify `tools/deck/lib/ui-helpers.mjs`, `tools/deck/gen-slaydeck.mjs`; Delete `tools/deck/hud/charselect.mjs` - -- [ ] **Step 1:** `lib/ui-helpers.mjs`의 `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER` 두 배열에서 `'CharacterSelectHud',` 줄 제거(2곳). -- [ ] **Step 2:** `gen-slaydeck.mjs`에서 `import { buildCharSelect } from './hud/charselect.mjs';`(:38)와 `emit('CharacterSelectHud', buildCharSelect());`(:229) 제거. -- [ ] **Step 3:** `git rm tools/deck/hud/charselect.mjs` (부트스트랩 완료, git 이력에 레퍼런스 잔존). -- [ ] **Step 4:** `node tools/deck/gen-slaydeck.mjs` 성공 + `node tools/verify/count.mjs ui CharacterSelectHud/WarriorButton/Art` → **>0**(charselect 엔티티가 stock으로 ui에 잔류). `git status`로 ui 변경 확인(charselect가 생성→stock 전환, 위치 이동 가능 — 정상). -- [ ] **Step 5:** 커밋(소스 + 재생성 산출물, 메시지에 "charselect 생성 중단·stock화" 명시). - ---- - -### Task 5: 마무리 — RULES·경로계약·회귀·PR - -**Files:** Modify `RULES.md` - -- [ ] **Step 1:** RULES §1에 한 줄: charselect는 **메이커 저작(stock)**이라 생성 안 함 — 컨트롤러가 `ClassPortraits`로 이미지 런타임 주입, 메이커 편집 시 §스펙 경로 유지. (다른 화면은 여전히 hud/cb 생성.) -- [ ] **Step 2:** 회귀: `node --test tools/balance/sim-balance.test.mjs` · `node --test tools/map/rogue-map.test.mjs` (exit 0). -- [ ] **Step 3:** push → PR(`node tools/git/gitea-pr.mjs create `, base=`feature/cb-modularization`, 한국어). -- [ ] **Step 4:** **사용자 메이커 플레이테스트**(워크스페이스 reload 후): 로비→직업선택→3 이미지 컨트롤러 주입 표시→클릭 금색테두리·Status→시작 그 직업→**메이커에서 카드 위치 이동·저장 후 `node gen-slaydeck` 재생성해도 charselect 유지**(stock 비파괴) 확인. 이미지 비표시 시 ClassPortraits 시드/주입 경로 점검. - ---- - -## Self-Review -- **스펙 커버리지**: ①stock화(T4) ②런타임주입(T1-3: luaCharsTable·시드·prop·RenderCharacterSelect) ③경로구동 유지(무변경) ④경로계약(T5·스펙). 누락 없음. -- **플레이스홀더**: luaCharsTable·주입 Lua·제거 라인 구체. 검증=count+playtest(바이트동일 아님 명시). -- **타입 일관성**: `self.ClassPortraits`(prop)↔`luaCharsTable`(self.ClassPortraits=)↔RenderCharacterSelect 참조 일치. classId Warrior→warrior/Mage→magician/Thief→bandit 일관. -- **순서**: 추가(주입 T1-3) 먼저 → 중단(stock T4). 중단 전엔 생성+주입 공존(무해), 중단 후 stock+주입. -- **리스크**: 메이커 경로 변경 시 계약 깨짐(isvalid 가드로 크래시 방지·해당부 미동작). stock 전환 시 ui 위치 이동(렌더 무관). diff --git a/docs/superpowers/plans/2026-06-16-codeblock-modularization.md b/docs/superpowers/plans/2026-06-16-codeblock-modularization.md deleted file mode 100644 index da77b10..0000000 --- a/docs/superpowers/plans/2026-06-16-codeblock-modularization.md +++ /dev/null @@ -1,127 +0,0 @@ -# Phase 1b — codeblock 메서드 모듈화 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스. - -**Goal:** `gen-slaydeck.mjs` `writeCodeblocks()`의 메서드 161개를 연속-런 모듈 `cb/*.mjs`로 분리하되 출력 `SlayDeckController.codeblock`은 바이트 동일. - -**Architecture:** 단방향 의존 orchestrator→cb→lib. method/prop/codeblock 헬퍼+공유상수를 `lib/codeblock.mjs`로. 메서드는 **원본 순서 보존**을 위해 기능 버킷이 아닌 **연속 구간**으로 나눠 `writeCodeblocks`가 순서대로 spread-concat. prop 103개는 오케스트레이터 유지. - -**Tech Stack:** Node ESM. 검증 = `diffcheck`(codeblock 바이트 동일) + 미러 `node --test`. - -**의존:** Phase 1(#70)의 모듈 gen-slaydeck 위에 스택(`feature/cb-modularization`). #70 머지 후 main에 리베이스. - ---- - -## 🔑 검증 게이트 (모든 Task 공통) -각 추출 후: -``` -node tools/deck/gen-slaydeck.mjs -node tools/verify/diffcheck.mjs -``` -**합격**: `RootDesk/MyDesk/SlayDeckController.codeblock`(+ui·common) **IDENTICAL**. 워킹트리 ` M`은 autocrlf churn → `git checkout --`로 복원, **산출물은 커밋 안 함**(소스만). - ---- - -### Task 1: `lib/codeblock.mjs` — 헬퍼 + 공유 상수 추출 - -**Files:** Create `tools/deck/lib/codeblock.mjs`; Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** `lib/codeblock.mjs` 생성. gen-slaydeck.mjs에서 이동: - - 함수: `prop`·`method`·`codeblock` (정의 본문 그대로). - - `writeCodeblocks` 지역 상수 9개(현 `:292-300`): `RUN_LENGTH`(5) `GOLD_PER_WIN`(25) `CARD_PRICE`(30) `REST_HEAL`(30) `RELIC_PRICE`(60) `ACT_COUNT`(5) `ACT_MAPS`(['map01'..'map05']) `LOBBY_MAP`('lobby') `LOBBY_SPAWN`('Vector3(-5, 0.03, 0)'). → writeCodeblocks 본문에서 제거(import로 대체). - - 끝에 `export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };` - - ⚠️ `prop`/`method`/`codeblock`이 다른 헬퍼(없음 — 순수)·데이터를 참조하지 않는지 확인. 참조 시 함께 import. - -- [ ] **Step 2:** gen-slaydeck.mjs에 `import { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from './lib/codeblock.mjs';` 추가(기존 lib import 옆). - -- [ ] **Step 3:** 검증 게이트 → codeblock IDENTICAL → churn 복원. - -- [ ] **Step 4:** 커밋 -``` -git add tools/deck/lib/codeblock.mjs tools/deck/gen-slaydeck.mjs -git commit -m "refactor(cb): lib/codeblock.mjs로 헬퍼·상수 추출 (출력 바이트 동일)" -``` - ---- - -### 메서드 추출 공통 레시피 (Task 2~의 각 런) - -`writeCodeblocks`의 `const combat = codeblock('SlayDeckController','SlayDeckController', [], [\n method('OnBeginPlay', …),\n method('ReqLoadAscension', …),\n … 161개 …\n])` 에서 메서드 배열을 런별로 분리: -1. `tools/deck/cb/.mjs` 생성: -```js -import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs'; -import { /* lib/data.mjs 전체 — 자동 파생 */ } from '../lib/data.mjs'; -import { /* lib/ui-helpers.mjs 전체 — 자동 파생 */ } from '../lib/ui-helpers.mjs'; - -export const Methods = [ - method('', `…`, …), - …, // ← 원본 method() 호출 verbatim 이동 - method('', `…`, …), -]; -``` -2. writeCodeblocks의 해당 런 method() 호출 스팬을 제거하고, methods 배열을 spread로 교체: - `codeblock(…, [], [ ...bootMethods, ...stateMethods, …, ...shopMethods ])`. -3. **검증 게이트**(codeblock IDENTICAL) → churn 복원 → 커밋. -4. ⚠️ 메서드가 writeCodeblocks 지역변수/다른 메서드를 **JS레벨로 참조**하면(드묾) undefined throw/diffcheck로 노출 → 그 변수도 lib로 옮기거나 인자화. -- import 이름은 lib export에서 **자동 파생**(누락 방지). 메서드 본문은 Lua 문자열이라 보간(`${RUN_LENGTH}`·`${luaCardsTable(...)}`)만 JS평가. - -### 런 → 모듈 경계 (원본 순서, 161개) -| 모듈 | export | 첫 메서드 → 끝 메서드 | 수 | -|---|---|---|---| -| `cb/boot.mjs` | `bootMethods` | `OnBeginPlay` → `AscStartHpPenalty` | 11 | -| `cb/state.mjs` | `stateMethods` | `HideGameHud` → `CloseBoard` | 12 | -| `cb/soul.mjs` | `soulMethods` | `ShowSoulShop` → `ApplySoulUnlocks` | 11 | -| `cb/charselect.mjs` | `charSelectMethods` | `ShowCharacterSelect` → `SetEntityEnabled` | 5 | -| `cb/run.mjs` | `runMethods` | `StartRun` → `ReviveMonsterEntity` | 6 | -| `cb/deckturn.mjs` | `deckTurnMethods` | `Shuffle` → `RenderPiles` | 8 | -| `cb/deckview.mjs` | `deckViewMethods` | `OpenDeckInspect` → `ApplyAllDeckCardVisual` | 12 | -| `cb/hand.mjs` | `handMethods` | `GetHandSlotX` → `SelectDiscardSlot` | 18 | -| `cb/combat.mjs` | `combatMethods` | `PlayCard` → `ContinueAfterBoss` | 20 | -| `cb/jobs.mjs` | `jobMethods` | `ShowJobChoice` → `SetJob` | 5 | -| `cb/runend.mjs` | `runEndMethods` | `TeleportToActMap` → `EndRun` | 3 | -| `cb/render.mjs` | `renderMethods` | `BuffsLabel` → `RenderRun` | 12 | -| `cb/reward.mjs` | `rewardMethods` | `CardPool` → `PickReward` | 4 | -| `cb/items.mjs` | `itemMethods` | `HasRelic` → `RenderRelics` | 12 | -| `cb/tooltip.mjs` | `tooltipMethods` | `BuildCardKeywordTooltip` → `HideTooltip` | 6 | -| `cb/map.mjs` | `mapMethods` | `ShowMap` → `PickNode` | 7 | -| `cb/shop.mjs` | `shopMethods` | `ShowShop` → `OpenChest` | 9 | - -최종 concat 순서(= 원본): `[ ...bootMethods, ...stateMethods, ...soulMethods, ...charSelectMethods, ...runMethods, ...deckTurnMethods, ...deckViewMethods, ...handMethods, ...combatMethods, ...jobMethods, ...runEndMethods, ...renderMethods, ...rewardMethods, ...itemMethods, ...tooltipMethods, ...mapMethods, ...shopMethods ]`. - ---- - -### Task 2: 런 추출 배치 A (말단부터 — 위험 낮은 순) -**Files:** Create `cb/shop.mjs map.mjs tooltip.mjs items.mjs reward.mjs`; Modify gen-slaydeck.mjs - -- [ ] 레시피로 **shop → map → tooltip → items → reward** 추출·검증·커밋(런 1개당 또는 묶음당 게이트 통과 필수). - -### Task 3: 런 추출 배치 B -**Files:** Create `cb/render.mjs runend.mjs jobs.mjs combat.mjs`; Modify gen-slaydeck.mjs -- [ ] 레시피로 **render → runend → jobs → combat** 추출·검증·커밋. - -### Task 4: 런 추출 배치 C -**Files:** Create `cb/hand.mjs deckview.mjs deckturn.mjs run.mjs`; Modify gen-slaydeck.mjs -- [ ] 레시피로 **hand → deckview → deckturn → run** 추출·검증·커밋. - -### Task 5: 런 추출 배치 D (앞부분 — 마지막) -**Files:** Create `cb/charselect.mjs soul.mjs state.mjs boot.mjs`; Modify gen-slaydeck.mjs -- [ ] 레시피로 **charselect → soul → state → boot** 추출·검증·커밋. 완료 후 writeCodeblocks는 props 배열 + `[ ...17 spreads ]` + write만 남아야 함. - ---- - -### Task 6: 마무리 — RULES + 회귀 + PR - -**Files:** Modify `RULES.md` - -- [ ] **Step 1:** RULES §1의 gen-slaydeck 모듈 설명에 `tools/deck/cb/*.mjs`(메서드)·`tools/deck/lib/codeblock.mjs`(헬퍼·상수) 추가. 단일소스 표/보조 생성기 일관성 유지. -- [ ] **Step 2:** 회귀: `node --test tools/balance/sim-balance.test.mjs` · `node --test tools/map/rogue-map.test.mjs` (exit 0). -- [ ] **Step 3:** 최종 재생성 + 검증 게이트(누적 codeblock IDENTICAL). `git status --short` 산출물 변경 없음. -- [ ] **Step 4:** RULES 커밋 → push → PR(`node tools/git/gitea-pr.mjs create `, UTF-8). PR 제목·본문 한국어(RULES §4). - ---- - -## Self-Review -- **스펙 커버리지**: lib/codeblock(T1) · 메서드 17런 모듈화(T2~5) · prop 유지(범위 명시) · 바이트동일 게이트(공통) · RULES(T6) · 미러회귀(T6). 누락 없음. -- **플레이스홀더**: 런 경계는 첫/끝 메서드로 구체 지정(161개 합), 상수 9개 명시, import 자동 파생. "verbatim 이동"은 리팩터 특성(바이트 검증이 정확성 보장). -- **타입 일관성**: export명(`xMethods`)↔concat spread 일치. lib/codeblock export↔orchestrator/cb import 일치. -- **리스크**: 메서드 JS레벨 외부참조 → diffcheck/throw 즉시 노출, 증분으로 범위 최소. 단방향 의존 순환 없음. diff --git a/docs/superpowers/plans/2026-06-16-generator-modularization.md b/docs/superpowers/plans/2026-06-16-generator-modularization.md deleted file mode 100644 index aaacccc..0000000 --- a/docs/superpowers/plans/2026-06-16-generator-modularization.md +++ /dev/null @@ -1,169 +0,0 @@ -# 생성기 모듈화 (Phase 1) 구현 계획 - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스. - -**Goal:** `tools/deck/gen-slaydeck.mjs`(~6,200줄)의 공유 인프라와 UI emit 16종을 `lib/`·`hud/` 모듈로 분리하되 출력 산출물은 바이트 동일로 유지한다. - -**Architecture:** 단방향 의존 — `gen-slaydeck.mjs`(오케스트레이터) → `hud/*.mjs`(HUD별 build 함수) → `lib/*.mjs`(헬퍼·상수·데이터). `guid(prefix,n)`가 순수 함수라 모듈화해도 emit 순서만 보존하면 출력 불변. codeblock 메서드는 이번 범위 제외. - -**Tech Stack:** Node ESM. 검증 = **바이트 동일 재생성**(git diff 빈 결과) + 미러 `node --test`. - ---- - -## 🔑 검증 게이트 (모든 Task 공통) - -각 추출 후 반드시: -``` -node tools/deck/gen-slaydeck.mjs # 성공 메시지 1줄, throw 없음 -git status --short -``` -**합격 기준**: `ui/DefaultGroup.ui`·`RootDesk/MyDesk/SlayDeckController.codeblock`이 **변경 안 됨**(git status에 안 뜸). `Global/common.gamelogic`만 ` M`이면 LF churn → `git checkout -- Global/common.gamelogic`. -- 만약 ui/codeblock이 ` M`로 뜨면 **추출 중 실수**(참조 누락/순서 변경) → `git diff --stat`로 어느 산출물인지 보고 되돌려 원인 수정. (RULES상 산출물 content는 안 봄 — 소스 diff로 원인 파악.) - ---- - -## 파일 구조 (목표) -``` -tools/deck/ - gen-slaydeck.mjs # 오케스트레이터: import → 데이터 로드(lib) → upsertUi(hud 호출) → writeCodeblocks → patchCommon - lib/ - data.mjs # 데이터 로드·검증·luaXxxTable·frameRuid·게임상수 - ui-helpers.mjs # guid/transform/sprite/button/text/entity/scrollLayoutGroup/cardFaceLayout/applySortingOverride - # + UI 상수 + uiPath/sectionRoot/isGeneratedUiEntity/appendUiSection - hud/ - deckhud.mjs deckinspect.mjs deckall.mjs combat.mjs reward.mjs map.mjs - shop.mjs rest.mjs treasure.mjs jobchoice.mjs jobselect.mjs mainmenu.mjs - charselect.mjs lobby.mjs board.mjs soulshop.mjs -``` - ---- - -### Task 1: `lib/data.mjs` — 데이터·게임상수·lua 테이블 추출 - -**Files:** Create `tools/deck/lib/data.mjs`; Modify `tools/deck/gen-slaydeck.mjs`(상단 데이터/lua 블록 → import) - -- [ ] **Step 1:** `lib/data.mjs` 생성. gen-slaydeck.mjs에서 아래를 **잘라 이동**(정의 본문 그대로): - - 데이터 로드+검증: `CARDS`(:3) `ENEMIES`(:4) `CLASSES`(:7~17) `JOBS`(:19~40) `SOUL_UNLOCKS`(:42~47) `CARDFRAMES`+검증(:57~68) `RARITIES`(:58) `NODEICONS`+검증(:92~96) `CHARS`+검증(:99~103) `CAM`(:105) `RELICS`+검증(:107~) `POTIONS`+검증(:118~) - - 게임 상수: `MAP_ROWS`(:84) `MAP_COLS`(:85) `CHEST_CLOSED_RUID`(:88) `CHEST_OPEN_RUID`(:89) - - 함수: `luaSoulShopTable`(:48) `frameRuid`(:69) `luaFramesTable`(:72) `luaNodeIconsTable`(:78) `luaRelicsTable` `luaPotionsTable` `luaIntentsArray` `luaEnemiesTable` `luaStr` `luaJobsTable` `luaCardsTable` `luaDeckTable` - - 맨 위 `import { readFileSync } from 'node:fs';`, 맨 끝 `export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, NODEICONS, CHARS, CAM, RELICS, POTIONS, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };` - - ⚠️ `luaNodeIconsTable`는 `luaStr`를 쓰므로 `luaStr`도 같이 이동. `luaFramesTable`도 `luaStr` 사용. 상호 참조는 같은 모듈 내라 OK. - -- [ ] **Step 2:** gen-slaydeck.mjs 상단에 `import { CARDS, ENEMIES, ... , luaDeckTable } from './lib/data.mjs';` 추가(이동한 정의 위치에). - -- [ ] **Step 3:** 검증 게이트 실행 → ui/codeblock 0 변경 확인 → common.gamelogic churn 복원. - -- [ ] **Step 4:** 커밋 -``` -git add tools/deck/lib/data.mjs tools/deck/gen-slaydeck.mjs -git commit -m "refactor(gen): lib/data.mjs로 데이터·lua 테이블 추출 (출력 불변)" -``` - ---- - -### Task 2: `lib/ui-helpers.mjs` — UI 헬퍼·상수 추출 - -**Files:** Create `tools/deck/lib/ui-helpers.mjs`; Modify `tools/deck/gen-slaydeck.mjs` - -- [ ] **Step 1:** `lib/ui-helpers.mjs` 생성. gen-slaydeck.mjs에서 이동: - - UI 상수: `UI_FILE`(:190) `COMMON_FILE`(:191) `UI_ROOT`(:192) `GENERATED_UI_SECTIONS`(:193) `UI_APPEND_ORDER`(:211) `DISABLED_STOCK_CONTROLS`(:229) `TRANSPARENT DARK GOLD ATTACK DEFEND SKILL`(:231~236) `DAMAGE_DIGIT_RUIDS`(:237) `DAMAGE_POP_*`(:249~252) `MAX_MONSTERS`(:254) `HEAD_OFFSET_Y`(:255) `HP_BAR_W`(:257) `WHITE`(:258) `CARD_NAME_TEXT CARD_DESC_TEXT`(:259~260) `CARD_W CARD_H CARD_SPACING CARD_XS`(:276~279) `ALIGN_CENTER ALIGN_BOTTOM_CENTER`(:281~282) - - 헬퍼: `cardFaceLayout`(:264) `guid`(:284) `transform`(:292) `sprite`(:317) `button`(:353) `text`(:378) `scrollLayoutGroup`(:405) `popupLayerFor`(:437) `uiOrderFor`(:443) `displayOrderFor`(:452) `applySortingOverride`(:456) `entity`(:472) `uiPath`(:504) `sectionRoot`(:508) `isGeneratedUiEntity`(:512) `appendUiSection`(:516) - - `export { ... }` 전부. - - ⚠️ COMMON_FILE은 patchCommon(:6125)도 사용 → export 필요. UI_APPEND_ORDER·GENERATED_UI_SECTIONS는 upsertUi가 사용. - -- [ ] **Step 2:** gen-slaydeck.mjs에 `import { ... } from './lib/ui-helpers.mjs';` 추가. - -- [ ] **Step 3:** 검증 게이트 → 0 변경 → churn 복원. - -- [ ] **Step 4:** 커밋 `git commit -m "refactor(gen): lib/ui-helpers.mjs로 UI 헬퍼·상수 추출 (출력 불변)"` - ---- - -### HUD 추출 공통 레시피 (Task 3~6에 반복 적용) - -각 HUD는 현재 `upsertUi()` 안에서 `const = []; const add = (e) => .push(e); add(entity(...)); …; emit('', );` 형태다. 추출 절차: -1. `tools/deck/hud/.mjs` 생성: `import { guid, entity, transform, sprite, button, text, GOLD, ... } from '../lib/ui-helpers.mjs';` + 필요한 데이터는 `'../lib/data.mjs'`. `export function build() { const e = []; const add = (x)=>e.push(x); add(entity(...)); …; return e; }` — **본문은 기존 라인 그대로 이동**(emit 호출 줄만 제외). -2. upsertUi에서 해당 블록을 `emit('', build());` 한 줄로 치환. -3. **검증 게이트**(ui/codeblock 0 변경) → churn 복원 → 커밋. -4. ⚠️ 옮긴 블록이 upsertUi 지역변수(`byPath`/`ui`/`cards`/`previewIds`)를 참조하면 안 됨(HUD 섹션은 헬퍼·데이터만 씀이 확인됨). 참조 시 바이트 diff로 즉시 드러남 → 그 변수를 인자로 받도록 조정. - -추출 대상(순서·소스 라인·emit명·모듈): - -| 모듈 | emit | 소스(블록 시작~emit줄) | -|---|---|---| -| `hud/deckhud.mjs` → `buildDeckHud` | DeckHud | `:693`(`const hud=[]`)~`:808` | -| `hud/deckinspect.mjs` → `buildDeckInspect` | DeckInspectHud | `:810`~`:942` | -| `hud/deckall.mjs` → `buildDeckAll` | DeckAllHud | `:944`~`:1097` | -| `hud/combat.mjs` → `buildCombat` | CombatHud | `:1100`~`:1587` | -| `hud/reward.mjs` → `buildReward` | RewardHud | `:1589`~`:1681` | -| `hud/map.mjs` → `buildMap` | MapHud | `:1684`~`:1839` | -| `hud/shop.mjs` → `buildShop` | ShopHud | `:1841`~`:2038` | -| `hud/rest.mjs` → `buildRest` | RestHud | `:2040`~`:2095` | -| `hud/treasure.mjs` → `buildTreasure` | TreasureHud | `:2098`~`:2181` | -| `hud/jobchoice.mjs` → `buildJobChoice` | JobChoiceHud | `:2184`~`:2229` | -| `hud/jobselect.mjs` → `buildJobSelect` | JobSelectHud | `:2231`~`:2314` | -| `hud/mainmenu.mjs` → `buildMainMenu` | MainMenu | `:2316`~`:2616` | -| `hud/charselect.mjs` → `buildCharSelect` | CharacterSelectHud | `:2437`~`:2617`(`select[0]…enable=false` 포함) | -| `hud/lobby.mjs` → `buildLobby` | LobbyHud | `:2620`~`:2672` | -| `hud/board.mjs` → `buildBoard` | BoardHud | `:2675`~`:2727` | -| `hud/soulshop.mjs` → `buildSoulShop` | SoulShopHud | `:2729`~`:2814` | - -⚠️ MainMenu/CharacterSelectHud는 `const menu=[]`(:2316)·`const select=[]`(:2437)로 인접 정의 후 `emit('MainMenu', menu); emit('CharacterSelectHud', select);`가 :2616~2617에 연속. 각각 별 모듈로 분리, emit 두 줄로. `select[0].jsonString.enable=false`(:2596)는 buildCharSelect 내부에서 `e[0].jsonString.enable=false`로. -⚠️ **CardHand 스톡카드 in-place upsert(`:557~691`)는 추출 안 함** — 기존 .ui 엔티티를 변형하는 특수 로직이라 upsertUi에 잔류(import만 정리). - ---- - -### Task 3: HUD 추출 배치 A (말단부터 — 위험 낮은 순) -**Files:** Create `hud/soulshop.mjs board.mjs lobby.mjs charselect.mjs`; Modify gen-slaydeck.mjs - -- [ ] **Step 1~4:** 레시피로 **soulshop → board → lobby → charselect** 순서로 하나씩 추출·검증·커밋(HUD 1개당 커밋 1개 권장, 배치로 묶어도 무방). 각 추출 후 검증 게이트 통과 필수. - - charselect는 P15/이번 작업으로 검증된 화면이라 패턴 안정. `e[0].jsonString.enable=false` 처리 확인. - ---- - -### Task 4: HUD 추출 배치 B -**Files:** Create `hud/mainmenu.mjs jobselect.mjs jobchoice.mjs treasure.mjs rest.mjs`; Modify gen-slaydeck.mjs - -- [ ] **Step 1~4:** 레시피로 **mainmenu → jobselect → jobchoice → treasure → rest** 추출·검증·커밋. - ---- - -### Task 5: HUD 추출 배치 C -**Files:** Create `hud/shop.mjs map.mjs reward.mjs`; Modify gen-slaydeck.mjs - -- [ ] **Step 1~4:** 레시피로 **shop → map → reward** 추출·검증·커밋. - ---- - -### Task 6: HUD 추출 배치 D (대형 — 마지막) -**Files:** Create `hud/combat.mjs deckall.mjs deckinspect.mjs deckhud.mjs`; Modify gen-slaydeck.mjs - -- [ ] **Step 1~4:** 레시피로 **deckhud → deckinspect → deckall → combat** 추출·검증·커밋. combat(~487줄)이 가장 크니 마지막. 추출 후 upsertUi는 데이터 준비 + CardHand upsert + `emit('X', buildX())` 16줄 + 병합만 남아야 함. - ---- - -### Task 7: 마무리 — RULES 동기화 + 회귀 + PR - -**Files:** Modify `RULES.md` - -- [ ] **Step 1:** RULES.md §1 보조 생성기/단일소스 표에 반영: `tools/deck/gen-slaydeck.mjs`(오케스트레이터)·`tools/deck/lib/*.mjs`(공유)·`tools/deck/hud/*.mjs`(HUD별)가 함께 `ui/DefaultGroup.ui`·`SlayDeckController.codeblock`의 단일 소스임을 명시. - -- [ ] **Step 2:** 회귀 테스트(무영향 확인) -``` -node --test tools/balance/sim-balance.test.mjs -node --test tools/map/rogue-map.test.mjs -``` -Expected: 전부 pass(37/0, 9/0). - -- [ ] **Step 3:** 최종 재생성 + 전체 검증 게이트 → ui/codeblock 0 변경(누적) 최종 확인. `git status --short`에 산출물 변경 없음. - -- [ ] **Step 4:** RULES 커밋 → push → PR(`node tools/git/gitea-pr.mjs create `, UTF-8). - ---- - -## Self-Review - -- **스펙 커버리지**: lib/data·ui-helpers(T1,T2) · HUD 16종 모듈화(T3~6) · 바이트 동일 게이트(공통 게이트, 매 Task) · codeblock 제외(범위 명시) · RULES 동기화(T7) · 미러 회귀(T7). 누락 없음. -- **플레이스홀더**: 이동 대상은 라인 범위로 구체 지정, 검증 명령·합격기준 명시, export/import 목록 구체. "본문 그대로 이동"은 리팩터 특성상 코드 재타이핑 대신 정확한 소스 위치 지정(바이트 검증이 정확성 보장). -- **타입 일관성**: build 함수명·emit명·모듈 경로 표로 고정. data.mjs/ui-helpers.mjs export ↔ gen-slaydeck import 일치. -- **리스크**: 상수/헬퍼 참조 누락 → 바이트 diff 또는 throw로 즉시 노출. 증분 추출로 실패 범위 최소화. 단방향 의존(orchestrator→hud→lib)로 순환 없음. diff --git a/docs/superpowers/specs/2026-06-06-bottom-card-hand-design.md b/docs/superpowers/specs/2026-06-06-bottom-card-hand-design.md deleted file mode 100644 index 997f04b..0000000 --- a/docs/superpowers/specs/2026-06-06-bottom-card-hand-design.md +++ /dev/null @@ -1,96 +0,0 @@ -# 하단 카드 손패 UI 목업 설계 (Slay the Spire 2 스타일) - -- 날짜: 2026-06-06 -- 브랜치: feature/sts2-combat-layout -- 대상 파일: `ui/DefaultGroup.ui` - -## 목표 - -전투 화면 **하단에 카드 5장이 수평 일렬로 보이는** 시각 결과를 만든다. -Slay the Spire 2 처럼 손패가 화면 아래쪽에 펼쳐진 느낌을 정적(static) 목업으로 구현한다. - -## 범위 - -### 포함 -- `DefaultGroup.ui`에 카드 손패 UI 엔티티 추가 -- 카드 5장을 하단 중앙에 수평 일렬 배치 -- 각 카드는 "풀 카드 면": 에너지 코스트(좌상단) + 카드 이름(상단 중앙) + 설명(하단) -- 샘플 손패 내용으로 채움 (정적) - -### 명시적 제외 (YAGNI) -- 클릭/터치 동작, 에너지 소모, 실제 카드 사용 로직 -- `SlayCombatManager` / `SlayCardCatalog` / `SlayRunState` 등 전투 로직 구현 -- 부채꼴(fan) 회전·곡선 배치 -- 드래그 앤 드롭, 호버 확대, 애니메이션 -- 데이터 기반 동적 손패 (드로우/버림에 따른 카드 수 변화) - -이들은 이후 "데이터 연동" 단계에서 별도 스펙으로 다룬다. - -## 구현 방식 - -`DefaultGroup.ui`의 `ContentProto.Entities` 배열에 신규 엔티티를 직접 추가한다. -기존 MSW UI 엔티티 패턴(`uisprite`, `uitext`)을 그대로 따르고, 각 엔티티에 새 UUID를 부여한다. - -이유: -- 이 프로젝트는 로컬 워크스페이스 + git 방식이므로 `.ui` 파일 직접 편집이 형상관리와 일치 -- 결정론적·재현 가능하며 diff로 변경 내역이 명확히 남음 -- 작업 후 Maker에서 워크스페이스 reload로 반영 - -(대안 — Maker MCP 라이브 조작, 런타임 Lua 생성 — 은 재현성/범위 측면에서 부적합하여 제외) - -## 엔티티 구조 - -``` -/ui/DefaultGroup/CardHand 컨테이너 (uiempty, 하단 중앙 앵커) - ├ Card1 (uisprite, 카드 면) - │ ├ Cost (uitext, 좌상단 코스트) - │ ├ Name (uitext, 상단 중앙 이름) - │ └ Desc (uitext, 하단 설명) - ├ Card2 … Card5 (Card1과 동일한 하위 구조) -``` - -## 배치 수치 - -- 좌표 공간: `DefaultGroup` 기준 1920 × 1080 (기존 UITransform과 동일) -- `CardHand` 컨테이너: 하단 중앙 앵커 (AnchorsMin/Max = {0.5, 0}), RectSize 약 1020 × 300, 바닥에서 위로 약 30px 띄움 -- 카드 크기: 180 × 250 (폭 × 높이) -- 카드 간격: 20px -- 5장 총폭: `5 × 180 + 4 × 20 = 980px` → CardHand 내부에서 중앙 정렬 -- 카드 i의 x 중심 (컨테이너 중앙 기준): `(-2 + i) × 200` (i = 0..4) → -400, -200, 0, 200, 400 - -### 카드 내부 텍스트 배치 (카드 로컬 좌표, 180×250 기준) -- Cost: 좌상단, RectSize 약 48×48, 카드 좌상단 모서리 부근 -- Name: 상단 중앙, FontSize 약 28 -- Desc: 하단, FontSize 약 22 - -## 비주얼 - -- 카드 면 배경: 기존 버튼 스프라이트 RUID `cd0560c4fc7f3b14994b90a502f00a21` 재사용 -- 카드 타입별 색 틴트 (SpriteGUIRendererComponent.Color): - - 공격 카드(타격/강타): 붉은톤 (예: r 0.9, g 0.55, b 0.5) - - 방어 카드(방어): 푸른톤 (예: r 0.55, g 0.7, b 0.95) -- 텍스트 컴포넌트는 기존 `PopupMessage` TextComponent 스키마를 템플릿으로 사용 (FontColor, OutlineColor 등 기본값 유지) - -## 샘플 손패 5장 - -| # | 이름 | 코스트 | 설명 | 타입 틴트 | -|---|------|--------|----------|-----------| -| 1 | 타격 | ① | 피해 6 | 공격(붉은) | -| 2 | 타격 | ① | 피해 6 | 공격(붉은) | -| 3 | 방어 | ① | 방어도 5 | 방어(푸른) | -| 4 | 방어 | ① | 방어도 5 | 방어(푸른) | -| 5 | 강타 | ② | 피해 10 | 공격(붉은) | - -(StS 시작덱 느낌의 대표 손패. 코스트 표기는 텍스트 "1"/"2"로 입력) - -## 검증 - -1. 파일 저장 후 Maker에서 로컬 워크스페이스 reload -2. `maker_screenshot`으로 전투 화면 하단에 카드 5장이 수평 일렬로 렌더되는지 확인 -3. 각 카드에 코스트·이름·설명 텍스트가 보이는지, 공격/방어 색 구분이 되는지 확인 - -## 후속 단계 (이 스펙 밖) - -- 데이터 연동: `SlayCardCatalog` / `SlayCombatManager` 구현 후 손패를 동적 렌더링 -- 카드 클릭 → 카드 사용, 에너지 소모, 손패 수 변화 -- 부채꼴 배치·호버·드래그 등 인터랙션 고도화 diff --git a/docs/superpowers/specs/2026-06-06-card-image-slot-design.md b/docs/superpowers/specs/2026-06-06-card-image-slot-design.md deleted file mode 100644 index 29e7af6..0000000 --- a/docs/superpowers/specs/2026-06-06-card-image-slot-design.md +++ /dev/null @@ -1,59 +0,0 @@ -# 카드 슬롯에 이미지 카드 적용 설계 - -- 날짜: 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: ''` 필드 추가. (이름/코스트/설명 데이터는 남겨두되 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이 행 정렬에서 약간 위로 솟음 — 의도된 허용 범위 diff --git a/docs/superpowers/specs/2026-06-06-maps-monsters-tiles-design.md b/docs/superpowers/specs/2026-06-06-maps-monsters-tiles-design.md deleted file mode 100644 index 6cf944c..0000000 --- a/docs/superpowers/specs/2026-06-06-maps-monsters-tiles-design.md +++ /dev/null @@ -1,64 +0,0 @@ -# 맵 개선: 다양한 preset 몬스터 + 맵별 타일셋 + StS2 배치 설계 - -- 날짜: 2026-06-06 -- 브랜치: feature/maps-batch (기존 맵 작업 이어서) -- 대상: `tools/gen-maps.mjs`, `map/map02.map`~`map11.map` (재생성) - -## 목표 - -map02~map11 각 맵에서: -1. **다양한 몬스터** 2마리를 배치하되, 기존 map01의 4종(StaticMonster/MoveMonster/ChaseMonster/monster-43) **스프라이트를 재사용하지 않고**, 공식 맵에서 수확한 다양한 몬스터로 채운다. -2. 몬스터를 **Slay the Spire 2 배치**(플레이어 좌측, 몬스터 우측 전투 포메이션)로 둔다. -3. 맵마다 **다른 타일셋**(TileSetRUID)을 적용한다(같은 바닥 지형, 다른 타일 텍스처). -4. 배경은 기존에 수확한 10종(맵별 상이) 유지. - -## 범위 - -### 포함 -- 공식 맵 import로 **몬스터 변형 세트 + 타일셋 RUID** 수확 -- 생성기에 `MONSTER_VARIANTS`(수확 변형), `TILESETS`(타일셋 풀) 반영 -- 맵당 서로 다른 몬스터 2종, StS2 우측 배치 -- 맵당 다른 TileSetRUID -- map02~map11 재생성 - -### 제외 (YAGNI) -- 지형(Tiles/Foothold) 통째 교체 — 타일셋(텍스처)만 교체 -- 포털 연결, 카드-전투 로직 연동 -- map01 변경 - -## 수확 (공식 맵 import 기법) - -배경 수확과 동일: `maker_import_maplestory_map(id)`가 현재 맵을 그 공식 맵으로 교체 → `maker_save` → `map/.map`에서 데이터 추출. - -- **몬스터 변형** `{ sprite, stand, hit, die }`(RUID): 몬스터가 있는 **필드/사냥맵**을 import해 몬스터 엔티티의 `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet`(stand/hit/die)를 추출. ≥12종 distinct 목표. (타운맵은 몬스터 없을 수 있어 필드맵 선택) -- **타일셋** `TileSetRUID`: import한 맵의 `TileMapComponent.TileSetRUID` 추출. 10종 distinct (map01의 `9dfea380…`과 겹치지 않게). - -> **스파이크 선행**: 필드맵 1개를 import해 몬스터 엔티티 구조가 `{sprite, stand, hit, die}` 추출 가능한지 먼저 확인. 구조가 다르면 폴백(아래). - -## 생성기 변경 (`tools/gen-maps.mjs`) - -- `MONSTER_VARIANTS = [ {sprite, stand, hit, die}, ... ]` — 수확 결과로 채움(≥12종). -- `TILESETS = [ ruid, ... ]` — 수확한 타일셋 10종. -- `buildMap(nn)`: - - 몬스터 2마리: `MONSTER_VARIANTS`에서 **서로 다른 2종**을 맵 시드로 선택(맵 내 중복 금지). 클론 몬스터 엔티티의 `SpriteRUID` + `ActionSheet`를 변형으로 덮어씀. (기존 map01 스프라이트 미사용 보장 — 항상 변형으로 덮어쓰므로) - - 위치: **StS2 배치** — 화면 우측에 2자리 고정(예: Position.x ≈ +3.5, +5.5; y는 map01 몬스터 y값). map01 전투 구도를 기준으로 우측 포메이션. - - 타일셋: `TileMap` 엔티티의 `TileMapComponent.TileSetRUID.DataId`를 `TILESETS[(nn-2)%len]`로 설정. - - 배경: 기존 `BACKGROUNDS` 유지. -- GUID 재발급·경로 치환·SectorConfig 로직은 그대로. - -## 검증 - -1. **스파이크**(map02): reload→play→screenshot + Lua로 - - 몬스터 2마리의 `SpriteRUID`가 수확 변형과 일치(= map01 4종 아님), 우측 배치 - - `TileMap.TileSetRUID`가 새 타일셋 - - 화면상 몬스터 외형·타일 텍스처가 바뀌어 보임 -2. 전체: 맵별 몬스터 2종 distinct, 타일셋 distinct, 배경 distinct, 엔티티 id 중복 없음 -3. Maker 표본 맵 시각 확인 - -## 리스크/폴백 -- 몬스터 엔티티 구조가 `{sprite,stand,hit,die}`로 안 맞으면 → `SpriteRUID`만 교체하고 `ActionSheet`는 map01 템플릿 유지(최소 시각 변화 보장). -- 타일셋 교체 시 tileIndex 의미 차이로 타일이 어색하면 → 스파이크에서 확인 후 호환 타일셋만 선별하거나 사용자와 상의. -- 수확 시 import는 현재 맵(map02, 재생성 가능)에 적용 → 수확 후 generator로 map02 재생성하여 정리. - -## 산출물/형상관리 -- `tools/gen-maps.mjs` 갱신, `map/map02.map`~`map11.map` 재생성을 커밋. 수확 RUID는 문자열만 포함(공식 콘텐츠). diff --git a/docs/superpowers/specs/2026-06-06-ten-maps-design.md b/docs/superpowers/specs/2026-06-06-ten-maps-design.md deleted file mode 100644 index 16ae56d..0000000 --- a/docs/superpowers/specs/2026-06-06-ten-maps-design.md +++ /dev/null @@ -1,62 +0,0 @@ -# 맵 10개 생성 (랜덤 배경 + 몬스터 2마리) 설계 - -- 날짜: 2026-06-06 -- 브랜치: feature/maps-batch (신규) -- 대상: `map/map02.map`~`map11.map`(신규), `Global/SectorConfig.config`, `tools/gen-maps.mjs`(신규) - -## 목표 - -`map01`을 템플릿으로 **독립 맵 10개(`map02`~`map11`)** 를 생성한다. 각 맵은 **서로 다른 공식 배경**을 갖고, **몬스터 2마리**가 랜덤 위치에 배치된다. - -## 범위 - -### 포함 -- `map02`~`map11` (신규 10개 맵 파일) -- 맵마다 다른 배경(`BackgroundComponent.TemplateRUID`) — 공식 MapleStory 배경 라이브러리에서 10개 서로 다르게 -- 맵마다 몬스터 2마리, x 위치 랜덤(바닥 위), y는 바닥 높이 고정 -- `SectorConfig.config`에 `map://map02`~`map://map11` 등록 -- 재현용 생성기 `tools/gen-maps.mjs` - -### 제외 (YAGNI) -- 맵 간 포털/이동 연결 (독립 맵) -- 맵별 다른 타일맵/지형 (map01 타일·바닥 그대로 복제) -- 카드-전투 로직 연동 -- map01 변경 - -## 몬스터 전략 (스파이크 게이트) - -사용자 선택: **라이브러리에서 다양한 몬스터**. 단, 리소스 검색이 RUID만 반환하고 action(stand/hit/die) 그룹핑·이름을 주지 않아 "완결된 몬스터" 조립이 불확실하다. 따라서: - -- **A. 라이브러리 다양 몬스터 (1차 시도)**: 라이브러리에서 완결된 몬스터 2~3종(스프라이트 + stand/hit/die 애니메이션 RUID 세트)을 조립한다. **먼저 1개 맵으로 스파이크** → Maker Play에서 로드·렌더 검증. -- **게이트**: 스파이크에서 라이브러리 몬스터가 정상 렌더되면 → 10개 맵에 A로 확대. 조립/로드 실패 시 → **B로 폴백**. -- **B. 폴백 — 기존 몬스터 변형**: 이미 정상 로딩되는 기존 템플릿(StaticMonster/MoveMonster/ChaseMonster/monster-43)의 검증된 RUID 세트에서 맵당 랜덤 2종 + 랜덤 위치. 다양성은 ~4종으로 제한되지만 확실히 동작. - -> 핵심 리스크: 이전에 **사용자 업로드(계정) 리소스는 로컬 워크스페이스 플레이에서 로드 실패**했다. 공식 라이브러리 리소스(배경/몬스터)는 shipped 콘텐츠라 로드될 것으로 보지만(기존 배경·몬스터 RUID가 정상 로딩 중), **확정 전 스파이크로 검증**한다. - -## 구현 방식 - -### 생성기 `tools/gen-maps.mjs` -1. `map/map01.map`을 텍스트로 읽어 JSON 파싱(템플릿) -2. 배경 RUID 풀(10개, 공식 라이브러리에서 확보), 몬스터 정의 풀(A: 라이브러리 세트 / B: 기존 템플릿 세트)을 데이터로 보유 -3. `NN = 02..11` 각각: - - 엔티티 deep-copy, **모든 엔티티 `id` GUID 재발급**(oldId→newId 매핑). `root_entity_id`/`sub_entity_id`가 엔티티 id를 가리키면 함께 치환. (component 안의 리소스 RUID — SpriteRUID, ActionSheet, TemplateRUID, CollisionGroup.Id, DamageSkinId, 타일셋 id — 는 엔티티 id가 아니므로 유지) - - `EntryKey`를 `map://mapNN`, 모든 `path`의 `/maps/map01`→`/maps/mapNN`, `name`을 `mapNN`로 치환 - - `Background` 엔티티의 `TemplateRUID`를 `backgrounds[NN]`로 설정 - - 기존 몬스터 엔티티들을 제거하고, 선택된 몬스터 2종을 랜덤 x 위치로 추가(각 몬스터는 템플릿 몬스터 엔티티를 복제하고 SpriteRUID/ActionSheet[A] 또는 그대로[B] + Position.x 랜덤) - - `map/mapNN.map`로 기록(원본 줄바꿈/포맷 보존 방식은 가능하면, 아니면 표준 JSON 직렬화) -4. `Global/SectorConfig.config`의 `Sectors[0].entries`에 `map://map02`~`map11` 추가(중복 방지) - -랜덤은 결정론을 위해 인덱스 기반 시드(맵 번호로 위치/선택 산출) 사용 — 재실행 시 동일 결과. - -### GUID 재발급 주의 -- 엔티티 id 충돌 방지를 위해 맵마다 고유 GUID 필요. 자기참조(`root_entity_id`==자기 id)는 매핑으로 일관되게 치환. - -## 검증 - -1. **스파이크(A)**: map02 1개만 생성 → reload→play→screenshot + Lua로 몬스터 엔티티/스프라이트 로드 확인. 실패 시 B로 전환. -2. 전체 생성 후: 각 맵(또는 표본)에서 reload→play(해당 맵)→screenshot으로 배경 상이·몬스터 2마리 확인. 맵 전환은 Maker에서 해당 맵을 열거나 sector 이동으로. -3. JSON 유효성(JSON.parse) + SectorConfig 10개 등록 확인 + 엔티티 id 중복 없음 확인. - -## 산출물/형상관리 -- 신규 파일 `map/map02.map`~`map11.map`, `tools/gen-maps.mjs`, `SectorConfig.config` 변경을 커밋. -- 배경/몬스터는 공식 라이브러리 RUID(문자열)만 참조 — 별도 리소스 파일 불필요(공식 콘텐츠). (단 A가 로컬 임포트를 요구하면 그 리소스 파일도 포함) diff --git a/docs/superpowers/specs/2026-06-08-card-combat-integration-design.md b/docs/superpowers/specs/2026-06-08-card-combat-integration-design.md deleted file mode 100644 index 6be3ff9..0000000 --- a/docs/superpowers/specs/2026-06-08-card-combat-integration-design.md +++ /dev/null @@ -1,74 +0,0 @@ -# 카드 전투 통합 (TODO 항목 B) — 설계 - -> 작성: 2026-06-08 / 상태: 승인됨 / 근거: TODO.md 항목 B + 코드 직접 분석. -> 선행 작업: 항목 C(미커밋 노이즈 정리) 완료 — 작업 트리 클린. - -## 문제 - -현재 `SlayDeckController.codeblock`의 `PlayCard`는 에너지만 차감하고 `Toast(log)`만 띄운다. -실제 전투 상태(적 HP/방어, 플레이어 HP/Block, 적 의도, 승패)가 없어 STS식 덱빌딩 루프가 -닫히지 않는다. 필드 액션 몬스터(`Monster.codeblock` — HP·피격·리스폰)는 카드 시스템과 분리돼 있다. - -## 범위 - -**포함**: 단일 적 카드 전투 루프(데미지·방어·적 의도·턴 진행·승패), 카드 수치화, DeckHud UI 노출. -**제외(금지)**: 로그라이크 메타(E), 신규 카드 대량 추가, 전체 데이터 외부화(D — 본 작업은 인라인 수치화까지). - -## 단일 소스 원칙 - -모든 변경은 `tools/gen-slaydeck.mjs`에서 생성한다. `SlayDeckController.codeblock` / -`ui/DefaultGroup.ui` / `Global/common.gamelogic`을 직접 손으로 편집하지 않는다. -변경 = 생성기 수정 → `node tools/gen-slaydeck.mjs` 재실행. - -## 수치는 임시 placeholder - -> 플레이어 수치는 향후 **캐릭터 특성별**, 몬스터 수치는 **몬스터별**로 다르게 설정 예정. -> 본 작업의 값(플레이어 80 / 적 45 / 의도 10·6·방8)은 루프 검증용 임시값이며, -> D(데이터 외부화) 단계에서 캐릭터/몬스터별 데이터로 분리한다. - -## 설계 - -### 1) 전투 상태 (codeblock 속성 추가) -- 플레이어: `PlayerHp`, `PlayerMaxHp`(임시 80), `PlayerBlock` -- 적: `EnemyHp`, `EnemyMaxHp`(임시 45), `EnemyBlock`, `EnemyIntentIndex` -- `CombatOver`(승패 후 입력 잠금) -- 적은 codeblock 내부 상태로 보유(필드 `Monster.codeblock`과 분리). - -### 2) 카드 데이터 수치화 (desc 파싱 폐기) -| id | 이름 | cost | kind | 효과 | -|----|------|------|------|------| -| Strike | 타격 | 1 | Attack | damage 6 | -| Defend | 방어 | 1 | Skill | block 5 | -| Bash | 강타 | 2 | Attack | damage 10 | - -`desc`는 표시용으로만 유지. 효과는 `damage`/`block` 숫자 필드로 처리. -시작 덱: Strike×5, Defend×4, Bash×1 (10장). - -### 3) 적 의도 — 결정적 사이클 (사용자 선택: A안) -- 의도 사이클(3스텝 회전): `[공격 10] → [공격 6] → [방어 8]` -- 매 플레이어 턴 시작 시 **다음 의도를 미리 표시**. -- 결정적이라 F(밸런스 시뮬레이터)에서 동일 규칙 재현 가능. - -### 4) 전투 규칙 (STS 관례) -- 데미지는 **방어도 먼저 차감** 후 잔여만 HP에 적용. -- 플레이어 Block은 **플레이어 턴 시작 시 0 리셋**, 적 Block은 **적 턴 시작 시 리셋**. -- `PlayCard(slot)`: `kind=="Attack"` → 적 HP 감소(적 Block 우선 차감); - `kind=="Skill"` → 플레이어 Block 증가. -- `EndPlayerTurn` → 적 턴: 적 Block 리셋 → 현재 의도 실행(공격이면 플레이어에 피해, - 방어면 적 Block↑) → 의도 인덱스 전진 → 패배 체크 → 다음 플레이어 턴(Block/에너지 리셋·드로우) - → 다음 의도 표시. -- 승패: 적 HP≤0 → 승리 / 플레이어 HP≤0 → 패배. 승패 시 `CombatOver=true`로 입력 잠금 + - 결과 텍스트 표시 + **보상 훅 자리(E용 주석)**. - -### 5) UI — DeckHud 엔티티 추가 (생성기 생성) -- 상단 적 패널: 적 이름 · `HP 45/45` · `방어 0` · `의도: 공격 10` -- 좌측 플레이어 패널: `HP 80/80` · `방어 0` -- 승패 결과 텍스트(중앙, 평소 숨김 → 승패 시 표시). - -## 검증 (메이커 Play) -- 타격 카드 → 적 HP 감소(적 Block 있으면 먼저 차감). -- 방어 카드 → 플레이어 Block 증가. -- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어. -- 적 HP 0 → 승리 / 플레이어 HP 0 → 패배, 입력 잠금. -- `node tools/gen-slaydeck.mjs` 2회 실행 결과 동일(결정적). -- `git status` — 의도한 생성물만 변경. diff --git a/docs/superpowers/specs/2026-06-08-deck-controller-fixes-design.md b/docs/superpowers/specs/2026-06-08-deck-controller-fixes-design.md deleted file mode 100644 index 4e1a71d..0000000 --- a/docs/superpowers/specs/2026-06-08-deck-controller-fixes-design.md +++ /dev/null @@ -1,37 +0,0 @@ -# 덱 컨트롤러 코드리뷰 수정 설계 - -- 날짜: 2026-06-08 -- 브랜치: feature/deck-controller-fixes (main 기준) -- 대상: `tools/gen-slaydeck.mjs` (단일 소스) → 재생성으로 `ui/DefaultGroup.ui`·`RootDesk/MyDesk/SlayDeckController.codeblock`·`Global/common.gamelogic` 갱신 - -## 배경 - -PR #6의 `SlayDeckController` 코드 리뷰에서 6건을 발견. 모든 산출물(카드 UI·DeckHud·codeblock·common 패치)은 `tools/gen-slaydeck.mjs` 한 곳에서 생성되므로, 이 생성기를 고치고 재실행하면 전부 반영된다. - -## 수정 항목 - -- **① [Important] EndTurn 핸들러 self 바인딩**: `buttonEntity:ConnectEvent(ButtonClickEvent, self.EndPlayerTurn)` → `ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)`. 메서드 직접 전달 시 self가 event로 잘못 바인딩되는 문제 제거. (타이머는 이미 클로저 사용 — 일관성) -- **② [Important] Card5 이미지 충돌**: 이미 `gen-slaydeck.upsertUi`가 Card1~5를 동일 텍스트 카드로 통일(ImageRUID='', 틴트, Cost/Name/Desc 추가)하므로 재생성으로 해결됨. 추가 코드 변경 없음 — 검증만. -- **③ [기능] 카드 클릭 = 사용**: - - `upsertUi`의 카드 스타일 루프에서 Card1~5에 `ButtonComponent` 추가 + 카드 스프라이트 `RaycastTarget=true`. - - codeblock에 `PlayCard(slot)` 메서드 추가: `Hand[slot]`의 카드 코스트를 `CARDS`에서 조회 → `Energy >= cost`면 `Energy -= cost`, 효과 표시(토스트/로그, 예: "타격 — 피해 6"), `Hand`에서 제거 후 `DiscardPile`에 삽입, `RenderHand(false)`+`RenderPiles()`. 부족하면 사용 불가(토스트/로그). - - `BindButtons`에서 각 카드의 `ButtonClickEvent`를 `function() self:PlayCard(i) end` 클로저로 연결(루프 변수 i는 Lua에서 반복마다 새 지역변수라 안전). 재연결 전 이전 핸들러 해제. -- **④ [Minor] 카드 데이터 단일화**: `CARDS = { Strike={name,cost,desc,kind}, Defend={...}, Bash={...} }` 테이블을 codeblock 상단에 두고, 시작덱 구성·`ApplyCardVisual`·`PlayCard`가 공유(if/elseif 중복 제거). -- **⑤ [Minor] 매직넘버 상수화**: 손패/드로우 수(5), 시작 에너지(3) 등 의미 있는 상수로. -- **⑥ [Nit] pcall 제거**: `ApplyCardVisual`의 `pcall(function() return Color(...) end)` → 직접 `Color(...)` 호출(틴트는 `CARDS[id].kind`별 색). - -## 효과 표시(③) - -적/데미지 시스템이 없으므로 카드 사용 효과는 **토스트 또는 로그**로만 표현(예: `log("타격 — 피해 6")` 또는 UIToast). 실제 데미지 적용은 범위 밖. - -## 재생성·검증 - -1. `node --check tools/gen-slaydeck.mjs` → `node tools/gen-slaydeck.mjs` -2. 검증(데이터): codeblock에 `PlayCard` 존재, `BindButtons`/EndTurn이 클로저, `CARDS` 단일 테이블, `ApplyCardVisual`에 pcall 없음. DefaultGroup.ui의 Card1~5에 `ButtonComponent` + RaycastTarget true, Card5가 균일 텍스트 카드(ImageRUID 빈값·Cost/Name/Desc 존재). -3. Maker Play: 카드 클릭 → 에너지 감소·카드가 버림더미로·재렌더, EndTurn 버튼 동작, 5장 균일. - -## stash 복구 -이전 Maker 세션에서 stash해 둔 로컬 맵 변경(map02/05/06/07/10/11)을 이 브랜치에 복구해 포함. 단 복구분이 몬스터/타일셋 작업을 유지하는지(되돌리지 않는지) 무결성 검증 후 커밋. 손상/무의미하면 사용자에게 알리고 제외. - -## 범위 밖 (YAGNI) -적 턴, 카드 효과의 실제 전투 적용, 신규 카드 종류. diff --git a/docs/superpowers/specs/2026-06-09-balance-simulator-design.md b/docs/superpowers/specs/2026-06-09-balance-simulator-design.md deleted file mode 100644 index 39fbff0..0000000 --- a/docs/superpowers/specs/2026-06-09-balance-simulator-design.md +++ /dev/null @@ -1,68 +0,0 @@ -# AI 전투 시뮬레이터 (TODO 항목 F) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 F + gen-slaydeck.mjs 전투 규칙 + D 데이터. -> 선행: D(데이터 외부화) 완료. - -## 문제 - -박재오 강점(백엔드·AI 자동화) 활용처로 기획됐으나 코드 없음. 카드/적 밸런싱을 손으로 -검증해야 한다. 데이터 기반 자동 밸런스 검증 도구가 필요하다. - -## 목표 - -`data/cards.json`·`data/enemies.json`를 입력으로, 전투를 몬테카를로로 N회 자동 시뮬레이션해 -승률·평균 턴·OP 카드 탐지 리포트를 출력하는 오프라인 Node CLI(`tools/sim-balance.mjs`). - -## 설계 - -### 구조 -`tools/sim-balance.mjs` 단일 파일, 섹션 분리: -1. **데이터 로드**: `data/cards.json`·`data/enemies.json`(D와 동일 소스). `activeEnemy` 사용. -2. **시드 PRNG**: mulberry32(시드 고정 → 재현 가능, 데이터 바꾸면 결과 변동). -3. **전투 엔진**(Lua 규칙 미러): 아래 규칙을 JS로 재현. -4. **플레이어 정책**(휴리스틱 A). -5. **집계·리포트**. -6. **CLI 파싱·출력**. - -### 전투 규칙 (gen-slaydeck.mjs Lua와 동일) -- 시작: 플레이어 `hp=PLAYER_HP(상수 80)`, `block=0`; 적 `hp=maxHp`, `block=0`, `intentIdx=0`(0-base). - 덱 = `starterDeck` 셔플(PRNG). -- 플레이어 턴 시작: `energy=3`, `block=0`, 5장 드로우(덱 소진 시 버림 더미 셔플해 재활용). -- 플레이어 행동: 정책이 카드 선택 → 사용 시 `energy -= cost`, `Attack`→적에 `damage`(적 block 우선 차감), - `Skill`→플레이어 `block += block`. 사용 카드는 버림. 더 둘 수 없으면 턴 종료. -- 적 턴: 적 `block=0` → 현재 의도 실행(`Attack`→플레이어에 피해(플레이어 block 우선 차감), - `Defend`→적 `block += value`) → `intentIdx=(intentIdx+1)%len`. -- 승패: 적 hp≤0 승리, 플레이어 hp≤0 패배. 턴 상한 `MAX_TURNS=100`(초과 시 무승부로 집계, 경고). - -### 플레이어 정책 (휴리스틱 A) -매 플레이어 행동 루프: -1. **치사 판단**: 손패의 Attack 카드들로 이번 턴 낼 수 있는 최대 데미지(에너지 한도 내) ≥ - `적 hp + 적 block` 이면 → 그 Attack들을 사용(킬). -2. 아니면 **적 의도가 Attack**이면 → 손패 Defend(Skill+block) 카드를 사용(에너지 닿는 한), - 이후 잔여 에너지로 Attack 사용. -3. 아니면(적 Defend 의도) → Attack 우선 사용. -4. 사용 가능한 카드(에너지≥cost)가 없으면 턴 종료. -- 동률 선택은 에너지 효율(뎀/E 또는 블록/E) 높은 카드 우선. - -### 리포트 지표 -- 전체: 승률(%), 평균·중앙값 턴 수, 승리 시 평균 잔여 HP, 패배율, (무승부 시 경고). -- 카드별: 사용 횟수, 누적 데미지/방어, **에너지당 효율**(Attack=총뎀/총E, Skill=총블록/총E). -- **OP 탐지**: 같은 kind 내 효율이 그 kind 중앙값의 ≥1.5배인 카드를 ⚠️로 플래그. 최다/최소 사용 카드 표기. - -### CLI -`node tools/sim-balance.mjs [N] [--seed S]` — 기본 `N=2000`, `seed=1`. 표 형식 출력. - -### 동기화 위험 -JS 전투 규칙은 Lua(`gen-slaydeck.mjs`)와 **중복**이다(공유 불가). 데이터(JSON)는 공유. -파일 상단에 "전투 규칙 변경 시 gen-slaydeck.mjs Lua와 동기화" 주석 명시. - -## 검증 (TDD + CLI) -- 전투 엔진/정책 핵심을 순수 함수로 분리해 단위 테스트(Node 내장 `node:test`): - 데미지 방어차감, 치사 판단, 적 의도 사이클, 승/패 종료. -- `node tools/sim-balance.mjs` → 승률·턴·카드 통계 출력. -- `data/cards.json`에서 강타 damage↑ → 승률·강타 효율 상승(데이터 반영). -- 동일 시드 2회 → 동일 출력(결정성). - -## 범위 밖 (금지) -- 상태이상·드로우·복합효과, 다중 적, 로그라이크 메타. 메이커 런타임 연동. -- 새 카드/적 추가(현 데이터로 검증). 정책 고도화(MCTS 등). diff --git a/docs/superpowers/specs/2026-06-09-data-externalization-design.md b/docs/superpowers/specs/2026-06-09-data-externalization-design.md deleted file mode 100644 index 5f248a5..0000000 --- a/docs/superpowers/specs/2026-06-09-data-externalization-design.md +++ /dev/null @@ -1,89 +0,0 @@ -# 카드/적 데이터 외부화 (TODO 항목 D) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 D + gen-slaydeck.mjs 분석. -> 선행: B(전투 통합)·A(문서 정합) 완료. F(밸런스 시뮬레이터)의 선행 조건. - -## 문제 - -카드 정의(`self.Cards`)·시작 덱·적 정의(이름/HP/의도)가 `gen-slaydeck.mjs`의 `StartCombat` -Lua 문자열에 하드코딩돼 있다. 카드/적 추가·밸런싱이 생성기 코드 수정을 요구한다. - -## 목표 - -카드·적 데이터를 외부 JSON으로 분리하고, 생성기가 읽어 codeblock·UI에 주입한다. -데이터만 바꿔 재생성하면 게임에 반영(코드 수정 없이). - -## 향후 방향 (참고) - -추후 카드·적 공격은 **메이플스토리 IP**에 맞춰 디벨롭 예정. 본 스키마는 명시적 `desc`와 -키 기반 확장으로 이를 수용한다(새 카드/적은 JSON 항목 추가로 확장). 본 작업은 현 3종+적1 -기준 **최소 스키마**까지만 — 새 효과 필드(상태이상/드로우 등)는 추가하지 않는다(YAGNI). - -## 단일 소스 원칙 - -생성물(`SlayDeckController.codeblock` · `ui/DefaultGroup.ui` · `common.gamelogic`)은 -`gen-slaydeck.mjs`가 생성한다. D 이후 **데이터의 단일 소스는 `data/*.json`**, 생성 로직의 -단일 소스는 `gen-slaydeck.mjs`. 결정적 출력 유지. - -## 설계 - -### 신규 파일 - -**`data/cards.json`** -```json -{ - "cards": { - "Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" }, - "Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" }, - "Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" } - }, - "starterDeck": ["Strike","Strike","Strike","Strike","Strike","Defend","Defend","Defend","Defend","Bash"] -} -``` - -**`data/enemies.json`** -```json -{ - "enemies": { - "slime": { - "name": "슬라임", "maxHp": 45, - "intents": [ - { "kind": "Attack", "value": 10 }, - { "kind": "Attack", "value": 6 }, - { "kind": "Defend", "value": 8 } - ] - } - }, - "activeEnemy": "slime" -} -``` -- `desc`는 명시적(작성자 작성). `kind`은 `"Attack"` 또는 `"Skill"`. 효과는 `damage`/`block`. -- `activeEnemy`로 현재 단일 전투의 적을 데이터에서 지정. 향후 E(맵 노드)에서 노드별 선택으로 확장. - -### 생성기(`gen-slaydeck.mjs`) 변경 -1. 상단에서 `readFileSync`+`JSON.parse`로 `data/cards.json`·`data/enemies.json` 로드. -2. **검증(fail-fast)**: `starterDeck`의 모든 id가 `cards`에 존재해야 함; `activeEnemy`가 - `enemies`에 존재해야 함. 아니면 명확한 에러로 `process.exit(1)`(또는 throw). -3. `writeCodeblocks()`의 `StartCombat`에서: - - `self.Cards = {...}`를 `cards`에서 생성(Lua 테이블 직렬화 헬퍼). - - `self.DrawPile = {...}`를 `starterDeck`에서 생성. - - `self.EnemyName`/`EnemyMaxHp`/`EnemyIntents`/`EnemyIntentIndex`를 `enemies[activeEnemy]`에서 생성. - - codeblock 속성 `EnemyMaxHp` DefaultValue도 데이터 값으로. -4. `upsertUi()`의 DeckHud 카드 미리보기 배열·CombatHud 초기 텍스트(적 이름·`HP n/n`·첫 의도)를 - 동일 데이터에서 파생. -5. Lua 문자열 직렬화 시 한글/따옴표 이스케이프 주의(데이터 값은 따옴표·역슬래시 없는 단순 문자열 가정, - 필요 시 escape 헬퍼). - -### 데이터 흐름 -`data/*.json` → `gen-slaydeck.mjs`(로드·검증·직렬화) → `SlayDeckController.codeblock`(Lua 테이블) -+ `ui/DefaultGroup.ui`(초기 텍스트) → 메이커 런타임. - -## 검증 -- `node tools/gen-slaydeck.mjs` 정상; JSON 유효; 2회 실행 결정적. -- `data/cards.json`에서 카드 1장 수치만 변경 → 재생성 → codeblock의 해당 카드 수치 변경 - (생성기/codeblock 직접 수정 없이). -- 잘못된 데이터(starterDeck에 없는 id, 잘못된 activeEnemy) → 생성기가 명확히 실패. -- 메이커 Play: 기존 B 동작과 동일(데이터 동치이므로 회귀 없음). - -## 범위 밖 (금지) -- 새 효과 필드(상태이상·드로우·복합효과) 추가. 새 카드 종류 대량 추가. F(시뮬레이터)·E(메타). diff --git a/docs/superpowers/specs/2026-06-09-floors-design.md b/docs/superpowers/specs/2026-06-09-floors-design.md deleted file mode 100644 index 141e325..0000000 --- a/docs/superpowers/specs/2026-06-09-floors-design.md +++ /dev/null @@ -1,64 +0,0 @@ -# 다음 층 / 멀티 act (TODO E6a) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E6a) + 기존 맵/전투 구조. -> 선행: E1~E5 완료. 제외: E6b 저장/불러오기(사용자가 안 함으로 결정 — MSW 저장 API 필요). - -## 문제 - -현재 보스 클리어 = 즉시 런 종료. 로그라이크의 "여러 층(act)을 점점 깊이 진행" 느낌이 없다. -보스 클리어 후 다음 막으로 이어지고, 최종 막 보스에서 진짜 런 클리어가 필요하다. - -## 설계 - -### 파라미터 (생성기 상수) -- `ACT_COUNT = 3` (막 수). -- 적 스케일: `mult = 1 + (Act-1)*0.6` → 1막 ×1, 2막 ×1.6, 3막 ×2.2. - -### 상태 재정의 -- 기존 `Floor`를 **현재 막 카운터**(1..ACT_COUNT)로 사용. `RunLength = ACT_COUNT`. -- 맵 내 행 진행은 맵 UI가 표현 → 별도 숫자 표시 없음. - -### 메서드 변경 -- `StartRun`: `Floor = 1`, `RunLength = ${ACT_COUNT}`. (맵 1회 빌드는 그대로.) -- `StartCombat`: `self.Floor = node.row` 줄 **제거**. 적 로드 시 막 스케일 적용: - ```lua - local mult = 1 + (self.Floor - 1) * 0.6 - self.EnemyMaxHp = math.floor(enemy.maxHp * mult) - self.EnemyHp = self.EnemyMaxHp - self.EnemyIntents = {} - for i = 1, #enemy.intents do - self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) } - end - ``` - (공유 enemy.intents 변형 금지 — 새 테이블 생성.) -- `CheckCombatEnd` 보스 승리 분기: - ```lua - if node ~= nil and node.type == "boss" then - if self.Floor < self.RunLength then - self.Floor = self.Floor + 1 - self.CurrentNodeId = "" - self.CurrentEnemyId = "" - self:RenderRun() - self:ShowMap() - else - self:ShowResult("런 클리어!") - self.RunActive = false - end - else - self:OfferReward() - end - ``` - (다음 막은 같은 맵 구조 재사용 — CurrentNodeId 리셋만. 적은 막 스케일로 강해짐.) -- HP·골드·덱·유물은 막 간 유지(기존 영속). combatStart 유물은 전투마다 재적용. - -### UI -- `RenderRun`: 층 텍스트를 `"막 " .. Floor .. "/" .. RunLength`로 (라벨 "층"→"막"). 골드 표시 유지. - -## 검증 (메이커 Play) -- 1막 보스(슬라임 킹 120) 처치 → 2막 맵·Floor 2 → 적 HP 스케일(슬라임 45→72, 보스 120→192). -- 3막 보스 처치 → "런 클리어!". HP/골드/덱/유물 막 간 유지. -- 패배 시 종료. 생성기 결정적·JSON 유효. -- (버튼 런타임 — MCP는 PickNode/PlayCard/CheckCombatEnd 직접 호출 + 상태 로그.) - -## 범위 밖 (금지) -- 저장/불러오기(E6b). 막별 다른 맵 디자인·신규 적/배경·막별 보상 차등. diff --git a/docs/superpowers/specs/2026-06-09-map-camera-design.md b/docs/superpowers/specs/2026-06-09-map-camera-design.md deleted file mode 100644 index 47724a1..0000000 --- a/docs/superpowers/specs/2026-06-09-map-camera-design.md +++ /dev/null @@ -1,50 +0,0 @@ -# 맵별 고정 카메라 (Map Camera Anchor) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: MSW CameraService/CameraComponent API + map01 현재 카메라 런타임 추출. - -## 문제 - -맵별로 카메라 시점을 고정해 타일·배경·몬스터를 결정적 framing 안에 배치하고 싶다. 현재 카메라는 -플레이어(DefaultPlayer) 소유로 플레이어를 추적한다(플레이어 freeze로 사실상 고정이지만 플레이어/맵 의존). -맵별로 **명시적·데이터 제어 가능한 고정 시점**이 필요하다. - -## 추출한 현재(map01) 카메라 값 (런타임) -- ZoomRatio **100** (min 30 / max 500), CameraOffset (0,0), ScreenOffset **(0.5, 0.655)**, - ConfineCameraArea **true**, UseCustomBound false. -- 플레이어 스폰 ≈ **(-5.0, -0.04)**, 카메라 가둠 영역 LB(-8.73,-1.76)~RT(7.83,4.35). - -## 설계 - -### 구조 -- **맵별 `CameraAnchor` 엔티티**(정적): 각 맵(`/maps/mapNN/CameraAnchor`)에 추가. - - `TransformComponent`: 위치 = framing 중심(스폰 `(-5, -0.04)`). - - `CameraComponent`: ZoomRatio 100, ScreenOffset (0.5, 0.655), ConfineCameraArea true (= 현재 값). - - `script.MapCamera`: 맵 로드 시 이 카메라로 전환. - - 앵커가 움직이지 않으므로 시점 고정. 플레이어와 분리. -- **`RootDesk/MyDesk/MapCamera.codeblock`**(신규, 1개): - - `OnBeginPlay`(client): `_CameraService:SwitchCameraTo(self.Entity.CameraComponent)`. -- **`data/camera.json`**(신규): 단일 카메라 설정(zoom·screenOffset·confine·anchor pos). 맵 공통값(맵들이 map01 클론)이며, 추후 맵별 오버라이드 가능 구조. - -### 생성기 -- `tools/gen-maps.mjs`에 카메라 앵커 주입 추가: `data/camera.json` 읽어 11맵 각각에 CameraAnchor 엔티티 - (Transform+Camera+script.MapCamera) 추가. CameraComponent JSON 구조는 기존 `Global/DefaultPlayer.model`의 - CameraComponent를 복제해 값만 교체(정확한 필드 보존). -- `MapCamera.codeblock`은 `gen-maps.mjs`(또는 별도 소함수)에서 생성. ExecSpace는 클라이언트(OnBeginPlay client). - -### 데이터 흐름 -`data/camera.json` → `gen-maps.mjs`(앵커 주입) + `MapCamera.codeblock` 생성 → 맵 로드 시 -`OnBeginPlay`→`SwitchCameraTo` → 고정 시점. framing 안에 타일/배경/몬스터 배치(기존대로). - -### 조정 -- 앵커는 메이커 Explorer `/maps/mapNN/CameraAnchor`에 나타나며 Scene에서 카메라 기즈모로 표시·이동 가능. - 값은 Property Editor 또는 `data/camera.json` 수정→재생성으로. - -## 검증 (메이커 Play) -- map01 진입 시 카메라가 CameraAnchor로 전환돼 현재와 동일 framing 고정(플레이어 이동/위치 무관). -- (가능하면 map02 진입해 동일 framing 확인.) -- `node tools/gen-maps.mjs` 결정적. 맵 JSON 유효. 빌드 오류 없음. -- 앵커가 Explorer/Scene에 보이고 기즈모로 framing 확인 가능. - -## 범위 밖 (금지) -- 맵별 다른 줌/오프셋 튜닝(공통값부터), 카메라 연출(흔들림/줌인/블렌드), 노드별 맵 전환 로직, - 카드 UI/전투 로직 변경. diff --git a/docs/superpowers/specs/2026-06-09-map-nodes-design.md b/docs/superpowers/specs/2026-06-09-map-nodes-design.md deleted file mode 100644 index f97afa9..0000000 --- a/docs/superpowers/specs/2026-06-09-map-nodes-design.md +++ /dev/null @@ -1,82 +0,0 @@ -# 분기 맵 노드 진행 (TODO E3) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E3) + SlayDeckController/run-loop-core 분석. -> 선행: E1+E2(런 루프 코어) 완료. 후속: E4(상점/휴식)·E5(유물)·E6(보스 연출/저장). - -## 문제 - -E1+E2는 보상 후 자동으로 다음 전투로 넘어간다(고정 N). 로그라이크는 **플레이어가 맵에서 경로를 -선택**해야 한다. 분기 노드 맵과 노드별 적 차등이 필요하다. - -## 범위 - -플레이어가 **분기 맵(작성된 DAG)** 에서 다음 노드를 선택 → 노드 타입(전투/엘리트/보스)대로 전투 -(적은 데이터로 차등) → 보상 → 맵으로 복귀 → 보스 클리어 시 "런 클리어". **상점/휴식·유물·저장· -절차적 생성·연결선 그리기는 범위 밖**. 맵 스키마는 상점/휴식 타입을 미래 수용. - -## 설계 - -### 데이터 -**`data/map.json`** (분기 DAG): -```json -{ - "start": ["A", "B"], - "nodes": { - "A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] }, - "B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["D", "E"] }, - "C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] }, - "D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] }, - "E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] }, - "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] } - } -} -``` -- `type` ∈ {combat, elite, boss} (이 슬라이스). `enemy`는 enemies.json id. `row`(1=시작), `col`(레이아웃 x 단위), `next`(도달 노드 ids). - -**`data/enemies.json`** 확장: -```json -"slime_elite": { "name": "정예 슬라임", "maxHp": 70, - "intents": [ {Attack 14}, {Attack 8}, {Defend 10} ] }, -"slime_boss": { "name": "슬라임 킹", "maxHp": 120, - "intents": [ {Attack 18}, {Defend 12}, {Attack 10}, {Attack 22} ] } -``` -(`activeEnemy`는 유지하되 런은 맵 노드의 enemy로 전투. F 시뮬레이터는 여전히 activeEnemy 기준 — 맵 적 시뮬은 후속.) - -### 상태 (SlayDeckController 속성 추가) -- `Enemies`(any) — 전체 적 테이블(id→정의). 생성기가 enemies.json 전체 주입. -- `MapNodes`(any) — 그래프(id→{type, enemy, row, col, next}). -- `MapStart`(any) — 1행 노드 id 배열. -- `CurrentNodeId`(string) — 현재 위치("" = 시작 전). -- `CurrentEnemyId`(string) — 현재 전투 적 id. - -### 메서드 -- `StartRun`(수정): 런 상태 초기화 + `Enemies`/`MapNodes`/`MapStart` 세팅 + `CurrentNodeId=""` + - BindButtons(맵 노드 버튼 포함, 1회) → `self:ShowMap()` (기존 StartCombat 대신). -- `ShowMap`(신규): 선택 가능 노드 결정(CurrentNodeId=="" 면 MapStart, 아니면 MapNodes[CurrentNodeId].next). - 각 노드 버튼 활성/비활성·라벨 갱신, 전투 UI 가리고 MapHud 표시(Enable). -- `IsReachable(id)`(헬퍼) — 현재 선택 가능 목록에 id 포함 여부. -- `PickNode(id)`(신규): `IsReachable(id)` 아니면 무시. `CurrentNodeId=id`, - `CurrentEnemyId=MapNodes[id].enemy`, MapHud 숨김 → `StartCombat()`. -- `StartCombat`(수정): 적을 `self.Enemies[self.CurrentEnemyId]`에서 로드(이름/HP/의도). Floor 증가 로직 제거. -- `CheckCombatEnd`(수정): 승리 시 골드+15 → 현재 노드 `type=="boss"`면 `ShowResult("런 클리어!")`+RunActive=false; - 아니면 `OfferReward`. 패배 → "패배..."+RunActive=false. -- `PickReward`(수정): 카드 처리 후 `StartCombat` 대신 `self:ShowMap()`. - -### UI (MapHud, 신규) -- 평소 숨김. 풀스크린 모달 배경 + 제목 "다음 노드 선택". -- 노드 버튼 6개: 위치 = (col×스페이싱, 화면중앙+row×행간), 라벨(전투/엘리트/보스 + 적 이름). -- 선택 가능 노드만 밝게·클릭, 나머지 어둡게(반투명). 클릭 → `PickNode(id)`. -- 연결선은 생략(도달성=활성/비활성으로 표현; 연결선 그리기는 후속 폴리시). - -### 단일 소스 -모든 변경은 `tools/gen-slaydeck.mjs`에서 생성. map.json/enemies.json은 데이터 단일 소스. - -## 검증 (메이커 Play) -- StartRun → MapHud, 1행 A·B만 선택 가능(나머지 비활성). -- A 선택 → 슬라임 전투 → 승리 → 보상 → 맵 복귀, 이제 C·D 선택 가능(B쪽 E는 불가). -- 엘리트 노드 → 정예 슬라임(HP 70) 전투. 보스 노드 → 슬라임 킹(HP 120). -- 보스 승리 → "런 클리어!". 패배 → "패배...". 도달 불가 노드 클릭 → 무시. -- 생성기 결정적, JSON 유효. (버튼 클릭은 런타임 — MCP는 PickNode/PlayCard/PickReward 직접 호출로 검증.) - -## 범위 밖 (금지) -- 상점/휴식 노드 동작(E4)·유물(E5)·저장(E6). 절차적 맵·무작위 분기·연결선 그리기. 새 카드. diff --git a/docs/superpowers/specs/2026-06-09-relics-design.md b/docs/superpowers/specs/2026-06-09-relics-design.md deleted file mode 100644 index d0b2d9e..0000000 --- a/docs/superpowers/specs/2026-06-09-relics-design.md +++ /dev/null @@ -1,69 +0,0 @@ -# 유물 (TODO E5) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E5) + 기존 전투/맵/상점 구조. -> 선행: E1~E4 완료. 후속: E6(저장/다음 층). 사용자 요청: 획득 3경로(시작·엘리트·상점) 모두. - -## 문제 - -런 영속·맵·보상·상점은 됐으나 유물(패시브 빌드 요소)이 없다. 훅 기반 패시브 + 다양한 획득 경로가 필요하다. - -## 설계 - -### 데이터 `data/relics.json` -```json -{ - "relics": { - "ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6 }, - "energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1 }, - "vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1 }, - "goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 골드 +10", "hook": "combatReward", "effect": "gold", "value": 10 } - }, - "startingRelic": "ironHeart", - "relicPool": ["energyCore", "vampire", "goldIdol"] -} -``` -- `relicPool` = 엘리트/상점에서 무작위로 줄 후보(시작 유물 제외). 중복 허용(스택). - -### 파라미터 (생성기 상수) -- `RELIC_PRICE = 60`. - -### 상태 추가 -- `Relics`(any) — 전체 유물 정의(주입). -- `RunRelics`(any) — 보유 유물 id 목록. -- `ShopRelic`(string) — 상점 제시 유물 id. -- `ShopRelicBought`(boolean). - -### 훅 시스템 -- `ApplyRelics(hook)`: RunRelics 순회, `hook` 일치 유물의 effect 적용: - - `block`→PlayerBlock+=value, `energy`→Energy+=value, `healOnAttack`→PlayerHp+=value(상한 클램프), `gold`→Gold+=value. -- 연결 지점: - - `combatStart` → StartCombat 끝(StartPlayerTurn 호출 뒤 — 방어도 리셋 이후 적용 → RenderCombat). - - `turnStart` → StartPlayerTurn(에너지 회복 직후). - - `cardPlayed` → PlayCard의 Attack 분기(데미지 적용 후). - - `combatReward` → CheckCombatEnd 승리(기본 골드 += 후). - -### 획득 (공통 `AddRelic(id)` → RunRelics 추가·RenderRelics) -- **C 시작**: `StartRun`에서 `RunRelics={}` → `AddRelic(startingRelic)`. -- **A 엘리트**: `CheckCombatEnd` 승리 시 노드 `type=="elite"`면 `relicPool`에서 무작위 `AddRelic`(보스는 런 종료라 제외). -- **B 상점**: `ShowShop`에서 `ShopRelic = relicPool 무작위`, ShopRelicBought=false; `BuyRelic`(ShopRelicBought거나 Gold 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 E(분해) + SlayDeckController 분석. -> E(로그라이크 메타)의 첫 하위 프로젝트. 선행: B·D 완료. 후속: E3(맵)·E4(상점)·E5(유물)·E6(보스/저장). - -## 문제 - -단일 전투(B)는 닫혔으나 승리 후 보상·다음 전투·덱 성장이 없다(보상 훅 자리만 비어 있음). -전투를 한 "런"으로 확장해야 덱빌딩 로그라이크가 된다. - -## 범위 (이 슬라이스) - -전투를 **연속 N전투 런**으로 확장: 런 상태 영속(HP/골드/덱) + 승리 후 카드 1택 보상 + -다음 전투 연결 + 고정 N전투 후 "런 클리어". **맵 노드·상점·유물·보스·저장은 범위 밖**(후속 E3~E6). -아키텍처: 기존 `SlayDeckController` 확장(별도 RunState 분리는 후속). - -## 설계 - -### 런 파라미터 (생성기 상수 — 향후 외부화) -- `RUN_LENGTH = 3` (런당 전투 수), `GOLD_PER_WIN = 15`. - -### 새 상태 (SlayDeckController 속성) -- `RunDeck`(any) — 보유 카드 id 누적 배열(영속). -- `Gold`(number) — 누적 골드. -- `Floor`(number) — 현재 전투 번호(1-base). -- `RunLength`(number) — 런당 전투 수. -- `RewardChoices`(any) — 현재 제시 중인 보상 카드 id 3개. -- `RunActive`(boolean) — 런 진행 중. -- 플레이어 HP는 전투 간 **유지**(StartCombat에서 리셋 안 함). - -### 메서드 -- `OnBeginPlay` → `self:StartRun()`. -- **`StartRun`**(신규): `PlayerMaxHp=80`, `PlayerHp=PlayerMaxHp`, `Gold=0`, `Floor=0`, - `RunLength=RUN_LENGTH`, `RunDeck = starterDeck 복사`, `RunActive=true` → `BindButtons()`(1회) → `StartCombat()`. -- **`StartCombat`**(수정): `Floor += 1`; 적 데이터(activeEnemy) 세팅; 전투별 리셋(Energy/Turn/Block/ - EnemyHp/EnemyBlock/EnemyIntentIndex/DiscardPile/Hand/CombatOver); `DrawPile = RunDeck 복사` → Shuffle; - `Cards` 테이블 세팅. **HP·Gold·RunDeck 보존, BindButtons 호출 제거.** → RenderCombat → StartPlayerTurn. -- **`BindButtons`**(수정): EndTurn·카드5·**보상카드3·건너뛰기** 버튼을 1회 바인딩(StartRun에서 호출). -- **`CheckCombatEnd`**(수정): - - 적 HP≤0(승리): `Gold += GOLD_PER_WIN`; `CombatOver=true`; - `Floor >= RunLength`이면 `ShowResult("런 클리어!")` + `RunActive=false`; - 아니면 `self:OfferReward()`. - - 플레이어 HP≤0(패배): `CombatOver=true`; `ShowResult("패배...")`; `RunActive=false`. -- **`OfferReward`**(신규): `RewardChoices = 카드풀에서 3개 무작위`(math.random); 각 보상 카드 UI 갱신 - (이름/코스트/설명/색); RewardHud 표시(Enable). -- **`PickReward(slot)`**(신규): `slot`(1~3)이면 `RewardChoices[slot]`을 `RunDeck`에 추가; `slot=0`(건너뛰기)이면 추가 안 함; - RewardHud 숨김 → `StartCombat()`(다음 층). -- **`RenderRun`**(신규): `층 Floor/RunLength`·`골드 Gold` 텍스트 갱신. RenderCombat에서 호출. - -### UI (생성기 신규) -- `RewardHud`(평소 숨김): 제목 "보상 카드 선택" + 보상 카드 3장(UISprite+버튼, 이름/코스트/설명 자식) + "건너뛰기" 버튼. -- HUD 표시 추가: `/ui/DefaultGroup/CombatHud/Floor`("층 1/3"), `/Gold`("골드 0"). -- 보상 카드 클릭 → `PickReward(slot)`, 건너뛰기 → `PickReward(0)`. - -### 버그 예방 -- `BindButtons`가 매 전투(StartCombat)마다 카드 버튼에 `ConnectEvent` → 런에서 핸들러 중첩. - **StartRun에서 1회만 바인딩**으로 이동(StartCombat의 BindButtons 호출 제거). - -## 검증 (메이커 Play) -- 전투 승리 → RewardHud에 카드 3장 표시; 골드 +15·층 표시. -- 보상 1택 → RunDeck +1(다음 전투 손패/덱에 등장 가능), RewardHud 숨김, 다음 전투 시작(HP 유지). -- 건너뛰기 → 덱 변화 없이 다음 전투. -- 3전투째 승리 → "런 클리어!"·런 종료. 도중 패배 → "패배..."·런 종료. -- 카드/보상 버튼 클릭은 런타임(MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출로 검증). -- 생성기 결정적, JSON 유효. - -## 범위 밖 (금지) -- 맵 노드(E3)·상점/휴식(E4)·유물(E5)·보스/층전환/저장(E6). 골드 소비(E4). 보상 풀 확장(메이플 IP 추후). diff --git a/docs/superpowers/specs/2026-06-09-shop-rest-design.md b/docs/superpowers/specs/2026-06-09-shop-rest-design.md deleted file mode 100644 index 7815057..0000000 --- a/docs/superpowers/specs/2026-06-09-shop-rest-design.md +++ /dev/null @@ -1,70 +0,0 @@ -# 상점/휴식 노드 (TODO E4) — 설계 - -> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E4) + E3 맵 노드 구조. -> 선행: E3(분기 맵) 완료. 후속: E5(유물)·E6(저장). 카드 제거는 덱 보기 UI 필요 → 후속 분리. - -## 문제 - -E3로 분기 맵은 됐으나 모든 노드가 전투다. 골드는 적립만 되고 소비처가 없다. 상점(골드→카드)· -휴식(HP 회복) 노드가 필요하다. - -## 범위 - -맵에 상점/휴식 노드 추가, 진입 시 전투 대신 상점/휴식 UI. 상점 = 카드 구매(골드). 휴식 = HP 회복. -**카드 제거(덱 보기 UI 필요)·유물·저장·휴식 업그레이드는 범위 밖.** - -## 설계 - -### 데이터 (`data/map.json` 교체 — 4행) -```json -{ - "start": ["A", "B"], - "nodes": { - "A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] }, - "B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] }, - "C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] }, - "D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] }, - "E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] }, - "F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] }, - "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] } - } -} -``` -- rest/shop 노드는 `enemy` 없음. 생성기 검증을 "`enemy` 있을 때만 ENEMIES 확인"으로 완화. - Lua MapNodes 직렬화도 enemy 있을 때만 `enemy = "..."` 포함. - -### 파라미터 (생성기 상수) -- `CARD_PRICE = 30`, `REST_HEAL = 30`. - -### 상태 추가 -- `ShopChoices`(any) — 상점 제시 카드 id 3개. -- `ShopBought`(any) — 슬롯별 구매 여부 {bool×3}. - -### 메서드 -- `PickNode`(수정): CurrentNodeId 세팅·맵 숨김 후 타입 분기 — - `shop`→`ShowShop`, `rest`→`ShowRest`, 그 외→`CurrentEnemyId=node.enemy`·`StartCombat`. -- `ShowShop`(신규): 카드 풀에서 3개 무작위→ShopChoices, ShopBought 초기화(false), - 각 슬롯 비주얼·가격·골드 갱신, ShopHud 표시. -- `BuyCard(slot)`(신규): ShopBought[slot]==true 또는 Gold 구현 메모: `PositionMonsterSlot`이 그룹 좌표를 알 수 있도록, BuildMonsters에서 `self.ActiveSlotPos = self.SlotPos[g]`를 설정하고 PositionMonsterSlot은 `self.ActiveSlotPos[slot]`을 참조한다. - -## 6. 저작 워크플로 (메이커) - -1. map01에 일반/엘리트/보스 몬스터를 서로 다른 위치에 배치(엘리트/보스 그룹은 졸개 포함 가능). -2. 각 몬스터의 `CombatMonster`에서 `Group`(combat/elite/boss)·`EnemyId`(enemies.json id) 지정. -3. `data/monster-slots.json`에 그룹별 슬롯 좌표 입력. -4. `node tools/monster/gen-combat-monster.mjs` → `node tools/deck/gen-slaydeck.mjs` → 메이커 reload. - -## 7. 변경 파일 요약 - -| 파일 | 변경 | -|------|------| -| `tools/monster/gen-combat-monster.mjs` | CombatMonster에 `Group` 프로퍼티 추가, 부착을 **없을 때만**(값 보존) 으로 변경, OnBeginPlay 등록에 Group 전달 | -| `RootDesk/MyDesk/CombatMonster.codeblock` | 생성물(Group 프로퍼티·등록 인자) | -| `data/monster-slots.json` | 그룹별 좌표 구조로 변경 | -| `tools/deck/gen-slaydeck.mjs` | `RegisterMonster`(group 인자)·`BuildMonsters`(노드 타입 필터·전체 숨김·그룹 슬롯)·`SlotPos` 주입·`PositionMonsterSlot`(활성 그룹 좌표) | -| 생성물 | `SlayDeckController.codeblock` 재생성. `ui/DefaultGroup.ui`는 **변경 없음**(기존 4개 슬롯을 그룹 간 재사용, 좌표만 런타임 변경) | -| `map/map01.map` | 그룹별 몬스터 배치·CombatMonster 태그(메이커 저작) | - -## 8. 알려진 한계 - -- 노드 타입 단위 구성(노드별 개별 인카운터 아님). -- 모든 그룹 합산이 많아도 UI 슬롯은 `MAX_MONSTERS`(4) 동시 표시 한도. 그룹당 ≤4. -- 전투 외(맵 UI 오버레이) 구간엔 필드에 세 그룹이 모두 보일 수 있음(허용). 원하면 StartRun에서 전체 숨김(후속). - -## 9. 리스크 - -- `gen-combat-monster`의 "값 보존" 로직: 기존 인스턴스 값을 정확히 유지하면서 componentNames 정합을 깨지 않도록 주의. -- 그룹 슬롯 좌표 미설정 시 기본 폴백 필요(좌표 없으면 슬롯 위치 미변경). -- 보스 노드 클리어 시 막 진행 로직(`CheckCombatEnd`)은 기존 그대로 동작해야 함(그룹 변경과 독립). - -## 10. 검증 - -- 생성기 2회 실행 결과 동일(결정적), JSON 유효·중복 id 없음. -- 메이커 플레이테스트: combat 노드 → 일반 그룹만, elite 노드 → 엘리트(+졸개)만, boss 노드 → 보스(+졸개)만 등장. 비활성 그룹은 숨김. 각 그룹 슬롯이 해당 몬스터 위에 표시. -- sim 테스트는 기존대로 통과(규칙 불변). diff --git a/docs/superpowers/specs/2026-06-11-act-maps-design.md b/docs/superpowers/specs/2026-06-11-act-maps-design.md deleted file mode 100644 index 688efba..0000000 --- a/docs/superpowers/specs/2026-06-11-act-maps-design.md +++ /dev/null @@ -1,46 +0,0 @@ -# 막별 맵 전환 + 맵별 인카운터 (P4) — 설계 - -- 날짜: 2026-06-11 -- 상태: 승인됨(사용자 사전 위임). 로드맵 P4/5. - -## 1. 배경/목표 - -map02~11은 SectorConfig에 등록만 되고 게임에서 미사용(모든 전투가 map01). 막(act)이 바뀌어도 같은 맵·같은 몬스터. -**목표**: ① 막별로 다른 물리 맵 사용(맵 차별화가 실제 게임에 보이도록) ② 각 맵에 노드 타입별 몬스터 그룹(combat/elite/boss)을 맵별 테마로 자동 구성. - -## 2. 설계 - -### 2.1 막→맵 매핑 + 텔레포트 -- `ACT_MAPS = ['map01','map02','map03']`(ACT_COUNT=3과 일치, 생성기 상수→Lua 주입). -- `CheckCombatEnd` 보스 클리어(다음 막 진행) 분기에서 `Floor` 증가 후 `self:TeleportToActMap()`: - - `_TeleportService:TeleportToMapPosition(_UserService.LocalPlayer, Vector3(-6, 0.03, 0), ACT_MAPS[self.Floor])` (UILogic 공식 예제의 API; 위치는 map01 플레이어 시작권 좌측 지면 — 메이커 검증으로 조정). -- 새 맵 로드 시 그 맵 몬스터들의 `CombatMonster.OnBeginPlay`가 자기등록(기존 0.1s×50 재시도 — 텔레포트 직후는 맵 화면이라 전투 진입 전 등록 여유 충분). - -### 2.2 등록 풀의 맵 필터 (크로스맵 오염 방지) -- 텔레포트 후 구 맵 몬스터가 언로드되지 않고 등록 풀에 남을 가능성 대비: - - `RegisterMonster(entity, enemyId, group, mapName)` — CombatMonster가 자기 소속 맵 이름을 전달(`self.Entity.CurrentMapName` 우선, nil이면 부모 체인에서 `/maps/` 직계 자식 이름; 구현 검증). - - `BuildMonsters`: `local pmap = _UserService.LocalPlayer.CurrentMapName` — `r.map == pmap`인 등록만 사용(+기존 isvalid·group 필터). - -### 2.3 맵별 인카운터 자동 구성 (`tools/map/gen-map-encounters.mjs` 신규) -- 대상: map02~map11 (map01은 사용자 저작 유지). -- 각 맵: 기존 `script.Monster` 엔티티 전부 제거 → 그 맵의 첫 몬스터 엔티티를 템플릿으로 6마리 생성: - | Group | 수 | x 위치 | EnemyId(맵 번호 순환) | - |---|---|---|---| - | combat | 3 | 2.3 / 3.8 / 5.2 | orange_mushroom·green_mushroom·pig·blue_mushroom 풀에서 3종 | - | elite | 2 | 3.0 / 5.0 | mushmom·modified_snail 중 | - | boss | 1 | 4.0 | king_slime·slime_boss 중 | -- 외형: gen-maps의 `MONSTER_VARIANTS`(공식 수확 9종 sprite/stand/hit/die) 풀에서 맵 시드(`nn*7919`) 결정론 선택(맵마다 다른 조합) — SpriteRenderer/StateAnimation 덮어쓰기. -- `script.CombatMonster` Group/EnemyId 태그 포함, GUID 결정론(`mapGuid` 패턴), idempotent(전체 교체 방식이라 재실행 동일). -- enemies.json 변경 없음(기존 8타입 재사용 — 스탯 일관). - -### 2.4 비범위 -- 4막+ / 맵별 배경·노드 그래프 차별화(이미 배경·타일은 맵별 상이), 이벤트 노드(P5). - -## 3. 검증 -- 생성기 결정론(2회 동일), 각 맵 그룹 구성 JSON 검사(3/2/1·EnemyId·변형 다양성). -- 메이커: 1막 보스 처치→Floor 2→**map02 텔레포트**(카메라/PlayerLock은 전 맵 부착됨)→맵 화면→전투 진입 시 map02 몬스터(combat 3, 새 외형)만 등장·구 맵 미오염→엘리트/보스 노드도 그룹 정상. - -## 4. 리스크 -- `Entity.CurrentMapName`/플레이어 CurrentMapName 형식("map02"?) — 구현 시 메이커 확인, 불일치 시 경로 기반 폴백. -- 텔레포트 직후 카메라/입력 재설정(MapCamera·PlayerLock OnBeginPlay가 맵 로드마다 도는지) — 검증. -- 구 맵 몬스터 isvalid 동작 — 맵 필터가 1차 방어라 비차단. diff --git a/docs/superpowers/specs/2026-06-11-card-visuals-design.md b/docs/superpowers/specs/2026-06-11-card-visuals-design.md deleted file mode 100644 index 80681ba..0000000 --- a/docs/superpowers/specs/2026-06-11-card-visuals-design.md +++ /dev/null @@ -1,77 +0,0 @@ -# 메이플 스킬 카드 비주얼 (P2) — 설계 - -- 날짜: 2026-06-11 -- 대상: `data/cards.json`, `tools/deck/gen-slaydeck.mjs`(카드 엔티티 구조·ApplyCardVisual류), 생성물 -- 상태: 승인됨 (배포 퀄리티 로드맵 P2/5 — P1 UI 정비 머지됨 #34) - -## 1. 배경 / 타당성 (검증 완료) - -카드가 단색 사각형+텍스트(코스트/이름/설명)뿐이라 게임 정체성이 약하다. 메이플 스킬 이미지를 카드에 넣는다. - -**타당성 검증 완료(메이커 실측)**: `asset_search_resources`(source=maplestory, cat=sprite)로 "파워 스트라이크" 검색 → 공식 RUID 10+개. 그중 `37ed94ffd1a64a22ad91a6ae14774718`를 Play 중 `SpriteGUIRendererComponent.ImageRUID`(Type=0)에 주입 → **로컬 워크스페이스에서 정상 렌더 확인**(흰 박스 아님 — 흰 박스 문제는 클라우드 '계정' 리소스에만 해당, 공식 리소스는 OK). -주의: 검색 결과는 아이콘이 아니라 **스킬 이펙트 컷**일 수 있음 → 후보 중 선별 단계 필요. - -## 2. 카드 → 메이플 스킬 매핑 (데이터) - -`data/cards.json`의 각 카드에 선택 필드 추가: -- `image`: 공식 스프라이트 RUID(string). 없으면 현행 단색 폴백. -- 카드 `name`을 메이플 스킬명으로 변경(효과·코스트·밸런스 불변): - -| 카드 id | 기존 이름 | 새 이름(전사 스킬) | 효과 | -|---|---|---|---| -| Strike | 타격 | 파워 스트라이크 | 피해 6 | -| Bash | 강타 | 슬래시 블러스트 | 피해 10 | -| Defend | 방어 | 아이언 바디 | 방어도 5 | - -(id는 기존 유지 — RunDeck/starterDeck 호환. 표시명만 변경.) - -## 3. RUID 수확 워크플로 (구현 선행 태스크) - -1. `asset_search_resources`(source=maplestory)로 스킬별 후보 수집: "파워 스트라이크", "슬래시 블러스트", "아이언 바디" (+필요시 "워리어", "스킬" 등 보조 질의). -2. 메이커 Play에서 후보 RUID를 카드 Art에 순회 주입 + 스크린샷으로 선별(§1에서 검증한 방법). 카드 일러스트로 보기 좋은 컷 1개/스킬 확정. -3. 확정 RUID를 `data/cards.json`에 기록. (RUID 문자열만 저장 — 공식 콘텐츠 정책 기존 관행과 동일) -4. 적합 컷이 없으면 폴백: 그 카드들은 이펙트 컷 중 베스트, 그래도 없으면 image 필드 생략(단색 유지). - -## 4. 카드 프레임 구조 (upsertUi — 손패 Card1~5 기준) - -기존: 루트(단색 패널) + Cost/Name/Desc 텍스트 3개. -변경(카드 180×250 기준): -``` -Card{i} (루트: 종류색 패널 — 테두리 역할, 기존 ATTACK/DEFEND/SKILL 색 유지) -├─ Art 96×96 중앙상단(pos 0, 52): ImageRUID, Type=0, 흰색 -├─ NamePlate 168×34 (pos 0, -8): 어두운 띠 {0.07,0.08,0.1,0.92} -│ └─ (Name 텍스트를 NamePlate 위치로 이동, fontSize 20) -├─ Cost 44×44 좌상(pos -68, 103): 어두운 원판 패널 + 숫자 26 (기존 위치 강조형) -└─ Desc (pos 0, -62, fontSize 18) 하단 효과 텍스트 -``` -- 구현은 기존 자식 텍스트(Cost/Name/Desc) 위치·크기 조정 + 신규 Art/NamePlate/CostPlate 스프라이트 추가. -- 동일 구조를 **RewardHud/Reward{1~3}**, **ShopHud/Card{1~3}**, **DeckInspectHud/Grid/Card{n}**, **DeckAllHud/Grid/Card{n}** 카드에도 적용(폭이 다른 그리드 카드(158×214)는 비례 축소 좌표). - -## 5. 런타임 렌더 일원화 - -- 신규 헬퍼 `ApplyCardFace(basePath, cardId)`(Lua): Cards[cardId]에서 name/cost/desc/kind/image를 읽어 — 루트 색(kind), Art ImageRUID(있으면 표시·없으면 Art 숨김), Name/Cost/Desc 텍스트 설정. -- 기존 `ApplyCardVisual`(손패)·`ApplyRewardVisual`(보상)·상점 렌더(`RenderShop` 내 카드부)·`ApplyInspectCardVisual`(인스펙터)·모든덱 렌더가 **ApplyCardFace를 호출**하도록 통일(경로만 다름). -- Lua 카드 테이블(`luaCardsTable`)에 `image` 필드 직렬화 추가(없으면 생략). - -## 6. 변경 파일 - -| 파일 | 변경 | -|---|---| -| `data/cards.json` | name 3종 변경 + image RUID 3종 추가 | -| `tools/deck/gen-slaydeck.mjs` | luaCardsTable image, 카드 엔티티 프레임(5표면), ApplyCardFace + 호출부 통일 | -| 생성물 | `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` 재생성 | -| `tools/balance/sim-balance.mjs` | 불변(이름 표시는 data에서 읽음 — 리포트에 새 이름 자동 반영) | - -## 7. 범위 제외 (후속) - -의도 아이콘(P3), 카드 호버 확대·사용 연출(P3), 희귀도 프레임 색(P5), 도적/마법사 카드(후속 콘텐츠). - -## 8. 검증 - -- 생성 결정성·dup id 0·sim 14/14(이름 변경이 테스트에 영향 없는지 확인 — 테스트는 자체 fixture 사용이라 무관). -- 메이커: 손패/보상/상점/덱보기 4표면 스크린샷 — 스킬 이미지·프레임·이름 일관 표시, image 누락 카드 폴백 정상. - -## 9. 리스크 - -- 후보 RUID가 멀티프레임 애니 시트일 수 있음 → SpriteGUIRenderer가 첫 프레임 표시(기존 옵션 FrameColumn/Row 1) — 선별 단계에서 확인. -- 그리드 카드(인스펙터/모든덱)는 ScrollLayoutGroup 셀 — 자식 추가가 셀 레이아웃에 영향 없는지 메이커 확인. diff --git a/docs/superpowers/specs/2026-06-11-combat-feel-design.md b/docs/superpowers/specs/2026-06-11-combat-feel-design.md deleted file mode 100644 index 520c553..0000000 --- a/docs/superpowers/specs/2026-06-11-combat-feel-design.md +++ /dev/null @@ -1,63 +0,0 @@ -# 전투 연출 (P3) — 설계 - -- 날짜: 2026-06-11 -- 대상: `tools/deck/gen-slaydeck.mjs`(카드 드래그·연출·적 턴 시퀀스), 생성물 -- 상태: 승인됨(사용자 사전 위임 — P2~P5 일괄 진행 지시). 로드맵 P3/5. - -## 1. 목표 (사용자 요구) - -1. **카드 드래그→몬스터 지정**: 카드를 끌어 특정 몬스터에 놓아 사용(클릭 대체). -2. **공격 모션 후 데미지**: 공격 카드 사용 시 연출(스킬 이펙트) 후 몬스터가 피해. -3. **몬스터 개별 차례**: 적 턴에 몬스터가 한 마리씩 순서대로 행동(행동자 표시). -4. 데미지 숫자 표시·사망 연출 등 게임필 보강. - -## 2. 타당성 (probe 완료) - -- `maker_mouse_input` down → `ScreenTouchEvent` 발화 + `ScreenToUIPosition` 변환 실측 일치(카드2 위치). -- **`MOD.Core.UITouchReceiveComponent`**: UI 엔티티에 부착 시 `UITouchBeginDragEvent`/`UITouchDragEvent`/`UITouchEndDragEvent`/`UITouchDownEvent`/`UITouchUpEvent` 제공(공식, Client). 드래그는 이걸 사용. -- 몬스터 world→screen(`_UILogic:WorldToScreenPosition`)은 P1에서 검증됨 — 드롭 판정에 재사용. - -## 3. 설계 - -### 3.1 카드 드래그 타겟팅 -- 손패 Card1~5 엔티티에 `MOD.Core.UITouchReceiveComponent` 추가(생성기 componentNames+컴포넌트). -- 컨트롤러 상태: `DragSlot`(0=없음), `DragOrigin`(원위치 Vector2), `DragMoved`(boolean). -- `BindButtons`에서 카드별로 connect: - - `UITouchBeginDragEvent` → CombatOver 아니고 손패에 카드 있으면 `DragSlot=i`, 원위치 저장(`CARD_XS[i]` 상수로 복원 가능하므로 저장은 단순화 가능 — 원위치 = (CARD_XS[i], 0)). - - `UITouchDragEvent` → 카드 `anchoredPosition = ScreenToUIPosition(TouchPoint) - CardHandOffset` (CardHand 부모 중심의 UI 좌표 보정값은 런타임 계산: 카드 부모 CardHand의 화면상 중심 = UI(0, -360) → 보정 상수로 굽기). - - `UITouchEndDragEvent` → `ResolveCardDrop(i, TouchPoint)` 후 카드 위치 복원. -- `ResolveCardDrop(slot, screenPoint)`: - - 카드 kind 조회. **Attack**: 생존 몬스터 중 화면 거리(몬스터 world→screen vs screenPoint) 최소이고 임계(예: 200px) 이내인 몬스터 → `SetTarget(그 몬스터)` 후 `PlayCard(slot)`. 임계 밖이면 취소(복귀만). - - **Skill**: 드롭 위치가 손패 위(화면 y 기준 카드 영역 위쪽, 예: screen y > 화면 40%)면 `PlayCard(slot)`, 아니면 취소. -- 기존 카드 ButtonComponent 클릭 `PlayCard` 바인딩 **제거**(드래그와 충돌 방지, 사용은 드래그로 일원화 — STS 방식). 몬스터 슬롯 클릭 SetTarget은 유지(타겟만 바꾸는 보조 수단). - -### 3.2 공격 연출 → 데미지 (PlayCard Attack 시퀀스) -- `CombatHud/SkillFx` 엔티티 1개(96×96 이미지 스프라이트, 평소 숨김). -- PlayCard(Attack) 흐름 변경: 에너지 차감·손패 제거·렌더는 즉시, **데미지는 지연**: - 1. `ShowSkillFx(targetIndex, c.image)`: 타겟 몬스터 world→screen 위치에 SkillFx 표시(ImageRUID=카드 이미지). - 2. 0.35s 타이머 → SkillFx 숨김 + `DealDamageToTarget(damage)` + 데미지 팝업 + RenderCombat + CheckCombatEnd. -- 연출 중 입력 보호: `FxBusy=true` 동안 PlayCard/EndPlayerTurn 무시(0.35s). - -### 3.3 데미지 숫자 팝업 -- `MonsterSlot{i}/DmgPop`(텍스트, 숨김 기본): `ShowDmgPop(slot, amount)` — "-N" 표시 → 0.6s 후 숨김(타이머; 위치 고정 단순화). -- `PlayerPanel/DmgPop` 동일(적 공격 시 "-N", 방어 흡수로 0이면 "막음"). - -### 3.4 적 개별 차례 (EnemyTurn 시퀀스화) -- `EnemyTurn` → 비동기 체인으로 재작성: - - `EnemyActIndex=0`; `EnemyActStep()`: 다음 생존 몬스터 찾기 → 없으면 `FinishEnemyTurn()`. - - 행동 몬스터 슬롯에 `ActFrame`(적색 하이라이트 — TargetFrame과 별도 자식, 156×108 적색 a0.3) 표시 → 0.45s 타이머 → 의도 적용(Attack: DealDamageToPlayer+플레이어 DmgPop / Defend: block+슬롯 의도 갱신) → ActFrame 숨김 → 다음 `EnemyActStep()` (0.15s 간격). - - 플레이어 사망 시 즉시 `FinishEnemyTurn()`. -- `FinishEnemyTurn()`: `CheckCombatEnd` 후 미종료면 0.45s 뒤 `StartPlayerTurn`(기존 EndPlayerTurn 후반부 이동). -- `EndPlayerTurn`: 손패 버림+렌더 후 `EnemyTurn()` 호출로 종료(후속 로직은 FinishEnemyTurn으로 이동). `TurnBusy=true`로 적 턴 중 입력 차단(FxBusy와 함께 가드). - -### 3.5 사망 연출 -- `KillMonster`: 즉시 SetVisible(false) → **0.4s 지연**으로 변경(DmgPop과 겹쳐 보이게), 슬롯 비활성은 즉시 유지. - -## 4. 검증 -- 생성 결정성·dup 0·sim 14/14(규칙 불변 — 연출 지연만 추가, 데미지 계산 동일). -- 메이커: ①카드를 몬스터2에 드래그→타겟 변경+이펙트→데미지 팝업→HP 감소 ②Skill 카드 위로 드래그→방어 ③드롭 취소(빈 곳) ④턴 종료→적들이 한 마리씩 순차 행동(ActFrame 이동)+플레이어 팝업 ⑤전체 처치 승리 정상. - -## 5. 리스크 -- UITouchReceiveComponent와 ButtonComponent 공존(슬롯 클릭/드래그 간섭) — 카드에서 Button 제거하므로 카드는 안전; 몬스터 슬롯은 Button 유지(드래그 없음). -- UITouchDragEvent 빈도/좌표계 — 구현 후 메이커 검증(§4①). 드래그 좌표 보정 상수는 실측 튜닝. -- 비동기 체인 중 상태 변화(연출 중 사망 등) — FxBusy/TurnBusy 가드 + 각 스텝에서 alive/CombatOver 재확인. diff --git a/docs/superpowers/specs/2026-06-11-combat-ui-overhaul-design.md b/docs/superpowers/specs/2026-06-11-combat-ui-overhaul-design.md deleted file mode 100644 index 95c0f93..0000000 --- a/docs/superpowers/specs/2026-06-11-combat-ui-overhaul-design.md +++ /dev/null @@ -1,84 +0,0 @@ -# 전투 화면 UI/HUD 전면 정비 (P1) — 설계 - -- 날짜: 2026-06-11 -- 대상: `tools/deck/gen-slaydeck.mjs`(UI 좌표·신규 엔티티·SlayDeckController 표시 로직), 생성물(`ui/DefaultGroup.ui`·`SlayDeckController.codeblock`) -- 상태: 승인됨 (배포 퀄리티 로드맵 P1/5) -- 로드맵: P1 UI 정비(본 문서) → P2 카드 비주얼(메이플 스킬) → P3 전투 연출(드래그 타겟·개별 턴·공격 모션) → P4 맵 차별화 → P5 시스템 갭 - -## 1. 배경 / 문제 - -전투 화면 HUD가 기능별로 따로 추가되며 겹침·산만함 발생. 정량 확인된 문제: -- `DeckHud/EndTurnButton`(y106~164) ↔ `DeckHud/Energy`(y69~111) **5px 겹침** (둘 다 중앙 상단) -- `DeckHud/AllDeckButton`(모든덱보기, x376~564·y106~164)이 버린덱(x524~656·y-85~101)과 5px 간격으로 답답 -- 막/골드가 화면 모서리(±820, 480) — MSW 시스템 크롬(좌상 채팅, 우상 메뉴)과 시각 충돌 -- 플레이어 HP/방어가 좌하단 텍스트 2줄(패널감 없음) -- 타겟 표시가 의도 텍스트의 `[타겟]` 프리픽스뿐, 몬스터 슬롯 이름/HP 가독성 낮음 -- 메뉴/캐릭터선택 화면 뒤로 전투 HUD가 비치는 흐름 버그(가시성 호출 산발) - -## 2. 목표 - -STS2 스타일 배치로 전투 화면을 재구성해 겹침 0·시각 위계 확립. 기능 변경 없음(레이아웃·시각·표시 로직만). - -## 3. 설계 - -### 3.1 하단 HUD 재배치 (DeckHud, parent 1280×330) -| 요소 | 현재 | 변경 | -|---|---|---| -| 에너지 | 중앙 (0,90) 텍스트 | **좌측 오브 패널** (-560,40) 96×96 어두운 원형 패널 + "3/3" 대형(36) + "에너지" 소라벨 | -| 턴 종료 | 중앙 (0,135) 170×58 | **우측 대형 버튼** (560,40) 200×64, fontSize 28 | -| 뽑을덱 | (-590,8) | 유지, 라벨 위치 정리 | -| 버린덱 | (590,8) | 유지 | -| 모든덱보기 | (470,135) | **상단 바로 이동** (§3.2) | -- 에너지 오브(-560±48=-608~-512)와 뽑을덱(-590±66=-656~-524)은 y로 분리: 뽑을덱 y8±93=-85~101, 오브 y40±48=-8~88 → x 겹침 구간에서 y도 겹침 → **오브를 (-560, 130)으로** 배치(뽑을덱 위). 같은 식으로 턴종료 (560, 130)(버린덱 위). 카드(CardHand y180 중심, 카드 h250 → y55~305)와 x 비겹침(카드 x -500~500, 오브/버튼 x>±512). - -### 3.2 상단 HUD 바 (CombatHud 신규 `TopBar`) -- 반투명 패널 1200×52, (0, 486). 자식: `Floor`(좌, x -540), `Gold`(좌, x -380), `Relics`(중앙, 폭 560), `AllDeckButton`(우, x 520, 150×40). -- 기존 CombatHud의 Floor(±820,480)·Gold·Relics(0,430) 엔티티를 TopBar 자식으로 대체(기존 경로 제거, 컨트롤러 SetText 경로 갱신). -- AllDeckButton은 DeckHud에서 TopBar로 이동(바인딩 경로 갱신). - -### 3.3 플레이어 패널 (CombatHud `PlayerPanel`, 좌하) -- 패널 300×96, (-760, -480) [화면 좌하단, DeckHud 영역 밖]. 자식: 이름라벨("플레이어"), HP바(HpBarBg/HpBarFill, 폭 220 — `SetHpBar` 재사용, 폭 파라미터화), HP 텍스트("71/80"), 방어 뱃지(56×40 청색 패널+숫자, 방어 0이면 숨김). -- 기존 PlayerHp/PlayerBlock 텍스트 엔티티 제거, RenderCombat 갱신. -- ⚠️ SetHpBar가 현재 HP_BAR_W=120 고정 → `SetHpBar(path, hp, maxHp, width)`로 폭 인자 추가(몬스터 120/140, 플레이어 220). - -### 3.4 몬스터 슬롯 가독성 + 타겟 프레임 -- `MonsterSlot{i}`에 `TargetFrame` 자식 추가: 슬롯보다 약간 큰 **단일 반투명 골드 패널**(156×108, displayOrder 최하 — 슬롯 내용 뒤 배경 하이라이트). RenderCombat에서 `i==TargetIndex`인 슬롯만 enable. -- 의도 텍스트에서 `[타겟] ` 프리픽스 제거. -- 이름 fontSize 20→22, Hp 18→20, HP바 폭 120→140(HpBarBg/Fill·SetHpBar 호출 일치), Intent 색상: kind=Attack→(1,0.45,0.35), Defend→(0.5,0.75,1). - -### 3.5 HUD 가시성 상태 통일 (`ShowState`) -- 컨트롤러에 `ShowState(state)` 단일 메서드: state별 HUD on/off 표. - -| state | 켜짐 | -|---|---| -| `menu` | MainMenu | -| `charselect` | CharacterSelectHud | -| `map` | MapHud만 (층/골드는 MapHud 진입 전 RenderRun으로 갱신된 TopBar가 꺼져도 무방 — 맵 화면 자체 표시는 현행 유지) | -| `combat` | CombatHud, DeckHud, CardHand | -| `reward` | RewardHud (+CombatHud 유지) | -| `shop` / `rest` | ShopHud / RestHud | -- `OnBeginPlay` 시작 시 전 HUD off → `ShowState("menu")`. 기존 산발적 `SetEntityEnabled` 호출을 ShowState 호출로 치환(ShowMainMenu/StartNewGame/ShowMap/PickNode/StartCombat/OfferReward/PickReward/ShowShop/ShowRest/LeaveNode/CheckCombatEnd). -- 흐름 버그(메뉴 뒤 카드 비침)는 이걸로 해소. - -### 3.6 시스템 UI -- 조이스틱/공격·점프 버튼: 기존 숨김 유지(upsertUi). -- 채팅/우상단 메뉴 숨김은 구현 단계에서 MSW API(`_UIService`/WorldConfig) 확인, 불가하면 본 레이아웃(모서리 회피)으로 충분 — 실패해도 P1 완료 조건에 미포함. - -## 4. 변경 파일 -| 파일 | 변경 | -|---|---| -| `tools/deck/gen-slaydeck.mjs` | upsertUi 좌표/엔티티 재구성(TopBar·PlayerPanel·TargetFrame·오브·버튼 이동), SlayDeckController(ShowState·RenderCombat·SetHpBar(width)·바인딩 경로) | -| 생성물 | `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` 재생성. `common.gamelogic` 불변 예상 | - -## 5. 범위 제외 (후속 페이즈) -카드 아트/스킬 아이콘·의도 아이콘(P2), 드래그 타겟·공격 모션·개별 턴·부채꼴 손패(P3), 맵(P4), 시스템(P5). - -## 6. 검증 -- 생성기 2회 실행 동일(결정적), JSON·중복 id 없음, sim 14/14(불변). -- **좌표 겹침 정적 검사**: 하단 HUD 요소 AABB 페어와이즈 겹침 0 (검증 스크립트로 확인). -- 메이커 플레이테스트: 메뉴→캐릭터선택→맵→전투(타겟 프레임 이동·에너지/턴종료 위치)→보상→상점→휴식 전 화면 스크린샷, 겹침·비침 0 확인. - -## 7. 리스크 -- SetHpBar 시그니처 변경 → 호출 3곳(몬스터·플레이어) 일치 필요. -- 기존 경로 제거(Floor/Gold/Relics/PlayerHp/PlayerBlock/AllDeckButton) 시 컨트롤러 SetText/바인딩 경로 누락 주의(grep로 구경로 0 확인). -- 사용자 PR(deck inspector·character select)이 추가한 UI와의 상호작용 — DeckAll/DeckInspect sortingOrder 오버라이드(2000/1900) 유지. diff --git a/docs/superpowers/specs/2026-06-11-system-gaps-design.md b/docs/superpowers/specs/2026-06-11-system-gaps-design.md deleted file mode 100644 index 069c9e9..0000000 --- a/docs/superpowers/specs/2026-06-11-system-gaps-design.md +++ /dev/null @@ -1,53 +0,0 @@ -# 시스템 갭 보완 (P5) — 설계 - -- 날짜: 2026-06-11 -- 상태: 승인됨(사용자 사전 위임). 로드맵 P5/5 (선별 범위). - -## 1. 선별 항목 (임팩트/리스크 기준) - -| # | 항목 | 문제 | 해결 | -|---|---|---|---| -| A | 경제 밸런스 | 골드/승리 15 < 카드 30 → 첫 상점 구매 불가 | `GOLD_PER_WIN` 15→25, 엘리트 승리 보너스 골드 +15(유물에 더해) | -| B | 카드 풀 | 3종뿐 — 보상/상점 단조 | 신규 2종 + **복합 효과(damage+block 동시) 지원** | -| C | 적 패턴 | 신규 몬스터 의도 2~3스텝 단조 | enemies.json 패턴 보강(3~4스텝, 강공 텔레그래프) | -| D | 런 루프 미완결 | 클리어/패배 후 Result 텍스트만(메뉴 복귀 없음) | 결과 표시 4s 후 `ShowMainMenu` 자동 복귀 | - -범위 제외: 저장(E6b 사용자 보류), 카드 제거/업그레이드/포션/이벤트 노드(후속). - -## 2. 설계 - -### 2.A 경제 -- `GOLD_PER_WIN = 25` (gen-slaydeck 상수). -- `CheckCombatEnd` elite 분기에 `self.Gold = self.Gold + 15` 추가(유물 지급 유지). - -### 2.B 복합 카드 -- 규칙 확장: **Attack 카드에 block 필드가 있으면 방어도 함께 적용** (Skill은 기존대로 block만). - - Lua `PlayCard`: Attack 분기에서 `if c.block ~= nil then self.PlayerBlock = self.PlayerBlock + c.block end` 추가(데미지는 기존 PlayAttackFx 경로). - - sim `simulateCombat`: Attack 분기에 동일 추가 + 테스트 1종. -- 신규 카드(`data/cards.json`): - - `WarLeap`(워 리프): cost 1, Attack, damage 4, block 3, desc "피해 4, 방어도 3" — 복합. - - `Brandish`(브랜디시): cost 2, Attack, damage 13, desc "피해 13" — 고코스트 딜. - - 이미지: 공식 RUID 수확(검증된 워크플로 — "워 리프"/"브랜디시" 질의, 부적합 시 기존 보조 질의). -- 시작 덱 불변(신규 카드는 보상/상점 풀에서 등장 — 풀은 `self.Cards` 전체이므로 자동 포함). - -### 2.C 적 패턴 (enemies.json) -- orange_mushroom: [공5, 방4, 공7] → [공5, 공5, 방4, 공8] (빌드업) -- green_mushroom: [공7, 공4] → [공7, 방3, 공9] -- pig: [공6, 방3] → [공6, 공6, 방5] -- blue_mushroom: [공8, 공4] → [공4, 공4, 공10] (텔레그래프형) -- mushmom: [공14, 방10, 공9] → [방10, 공16, 공9, 방6] -- modified_snail: [공12, 공7, 방8] → [공12, 방8, 공7, 공14] -- king_slime: 유지(이미 4스텝). slime/slime_elite/slime_boss: 유지(레거시 호환). - -### 2.D 런 종료 복귀 -- `ShowResult(text)` 호출 후 `RunActive=false`가 되는 두 곳(런 클리어·패배)에서: 4초 타이머 → `self:ShowMainMenu()`. -- 구현: `ShowResult`에 두 번째 동작 추가 대신 **새 메서드 `EndRun(text)`** = ShowResult(text) + RunActive=false + 4s 타이머 ShowMainMenu. CheckCombatEnd의 두 지점("런 클리어!"/"패배...")을 EndRun 호출로 교체. -- ShowMainMenu는 ShowState("menu")로 전 HUD 정리(기존) — Result는 CombatHud 자식이라 같이 숨겨짐. 단 Result.Enable 자체는 StartCombat에서 리셋(기존). - -## 3. 검증 -- sim: 복합 카드 테스트 추가, 전체 통과. `node tools/balance/sim-balance.mjs 2000`으로 새 패턴 승률 출력(참고 기록 — 100%면 여전히 약함이나 P5 범위는 구조, 수치 정밀 튜닝은 후속). -- 메이커: 보상/상점에서 신규 카드 등장(이미지 포함), 복합 카드 사용 시 피해+방어 동시, 패배 또는 클리어 → 4s 후 메뉴 복귀, 엘리트 승리 골드 +15+25. - -## 4. 리스크 -- 복합 카드의 sim AI(chooseAction)는 Attack 우선 로직 그대로(블록 가치 미평가) — 밸런스 추정 약간 보수적, 허용. -- EndRun 타이머 중 사용자가 이미 메뉴로 못 가는 상태(입력 잠금) — CombatOver=true가 가드. diff --git a/docs/superpowers/specs/2026-06-12-ascension-design.md b/docs/superpowers/specs/2026-06-12-ascension-design.md deleted file mode 100644 index 95ab769..0000000 --- a/docs/superpowers/specs/2026-06-12-ascension-design.md +++ /dev/null @@ -1,47 +0,0 @@ -# P11 — 승천 시스템 + UserDataStorage 설계 - -날짜: 2026-06-12 (사용자 승인 — P9/P10/P11 중 3단계) -브랜치: `feature/p11-ascension` - -## 범위 - -1. **개인별 승천 저장** — `_DataStorageService:GetUserDataStorage(userId)` (유저별 영구, 메이커↔배포 분리). 이 프로젝트 첫 서버-클라 RPC. -2. **승천 선택 UI** — 메인 메뉴에서 0~해금치 선택([-]/[+]), 런 클리어 시 해금 +1 (최대 10), 클리어 문구 "승천 N 해금!" -3. **승천 모디파이어 A1~A10** (누적): - -| 단계 | 효과 | 단계 | 효과 | -|---|---|---|---| -| A1 | 적 HP +10% | A6 | 적 HP 추가 +10% | -| A2 | 적 피해 +10% | A7 | 적 피해 추가 +10% | -| A3 | 시작 HP -10 | A8 | 시작 HP 추가 -10 | -| A4 | 정예·보스 배율 +0.2 | A9 | 정예·보스 배율 추가 +0.2 | -| A5 | 승리 메소 -25% | A10 | 승리 메소 추가 -25% | - -4. TopBar에 `· 승천N` 표시 (0이면 생략) - -## 서버-클라 구조 (ExecSpace) - -codeblock JSON ExecSpace 실측(프로브): **Server=5**(클라→서버), **Client=6**(서버→클라·마지막 인자 userId로 특정 유저 라우팅), 1=ServerOnly(클라 호출 무시), 2=ClientOnly(서버 호출 무시). 기존 메서드의 6은 Client라 클라 호출 시 제자리 실행으로 동작 동일: - -- `ReqLoadAscension(userId)` **[Server=5]** — 클라 OnBeginPlay에서 호출 → 서버에서 `GetAndWait("ascensionUnlocked")` → `RecvAscension(n, userId)` 호출 -- `RecvAscension(n, userId)` **[Client=6]** — 마지막 파라미터 userId로 **요청한 클라이언트에만** 응답 (MSW 공식 패턴) → `AscensionUnlocked` 갱신·메뉴 렌더 -- `SaveAscension(n, userId)` **[Server=5]** — `SetAndWait` -- 생성기의 `m.ExecSpace = 6` 일괄 적용을 "명시값(≠0)은 보존"으로 수정 - -## 적용 지점 - -- `StartRun`: 시작 HP에서 A3/A8 차감, `AscensionLevel`은 메뉴 선택값 유지 -- `BuildMonsters`: maxHp ×(1+A1/A6), Attack 인텐트 ×(1+A2/A7), elite/boss 그룹이면 막 배율 +A4/A9 -- `CheckCombatEnd`: 승리 메소 ×(1-A5/A10) floor -- `EndRun("런 클리어!")`: `AscensionLevel >= AscensionUnlocked and Unlocked < 10`이면 해금+1·저장·결과 문구 교체 -- 헬퍼: `AscHpMult`/`AscAtkMult`/`AscEliteBonus`/`AscGoldMult`/`AscStartHpPenalty` - -## UI (MainMenu) - -- `AscRow`: `AscMinus`[-] · `AscLabel`("승천 L / 해금 U") · `AscPlus`[+] — 새 게임 버튼 아래 -- `AdjustAscension(delta)` clamp 0..Unlocked, `RenderAscension` - -## 검증 - -1. 시뮬: 모디파이어는 Lua 전용(런 메타) — 시뮬 비대상 명시. 기존 44건 유지 -2. 메이커: 로드(첫 0)→승천 2 선택→적 HP/피해 배율·시작 HP 확인→클리어→해금+1·저장→재시작 후 로드 확인(메이커 스토리지), 빌드·런타임 0에러 diff --git a/docs/superpowers/specs/2026-06-12-buffs-power-design.md b/docs/superpowers/specs/2026-06-12-buffs-power-design.md deleted file mode 100644 index e44db7e..0000000 --- a/docs/superpowers/specs/2026-06-12-buffs-power-design.md +++ /dev/null @@ -1,107 +0,0 @@ -# P6 — 버프/디버프·Power 카드·적 방어도 UI 설계 - -날짜: 2026-06-12 -브랜치: `feature/p6-buffs-power` -근거: 덱빌딩 코어 확장 — Slay the Spire 2의 약화·취약·힘 시스템과 Power 카드 종류를 메이플 IP로 이식. 적 방어도 미표시 문제 해결. - -## 범위 - -1. **버프/디버프 3종** — 약화(Weak)·취약(Vulnerable)·힘(Strength), StS 표준 수치 -2. **Power 카드 kind** — 전투 동안 지속되는 효과, 사용 시 소멸(전투 한정) -3. **적 방어도 UI** — 적 슬롯에 플레이어와 동일한 방어도 배지 표시 -4. **예시 카드 4종** — 메이플 스킬명, StS 카드 효과 매핑 -5. **적 디버프 인텐트** — 일부 적이 플레이어에게 약화/취약 부여 (양방향 시스템) - -비범위: 물약·유물 강화(P7), 카드 강화(업그레이드), 민첩(Dexterity). - -## 규칙 (StS 표준) - -| 상태 | 효과 | 지속 | -|------|------|------| -| 힘(Strength) | 공격 피해 +N | 전투 동안 영구 | -| 약화(Weak) | 주는 공격 피해 25% 감소 (floor) | N턴, 자기 턴 종료 시 1 감소 | -| 취약(Vulnerable) | 받는 공격 피해 50% 증가 (floor) | N턴, 자기 턴 종료 시 1 감소 | - -피해 공식 (플레이어 공격): `floor( floor((base + 힘) × (약화 ? 0.75 : 1)) × (대상 취약 ? 1.5 : 1) )` -피해 공식 (적 공격): 동일 공식을 적 힘·적 약화·플레이어 취약으로 적용. - -감소 타이밍: -- 플레이어 약화/취약 → `EndPlayerTurn`에서 1 감소 -- 적 약화/취약 → 적 개별 행동(`EnemyActStep`) 종료 시 1 감소 - -## 상태 모델 - -- 플레이어: `PlayerStr` / `PlayerWeak` / `PlayerVuln` (number props), `PlayerPowers` (any) -- 몬스터: `m.str` / `m.weak` / `m.vuln` (BuildMonsters에서 0 초기화) -- 전투 시작(`StartCombat`) 시 전부 리셋 - -## 카드 데이터 스키마 확장 (`data/cards.json`) - -| 필드 | 의미 | -|------|------| -| `kind: "Power"` | 파워 카드 — 사용 시 소멸, 전투 동안 지속 효과 | -| `weak: N` | 대상 적에게 약화 N 부여 | -| `vuln: N` | 대상 적에게 취약 N 부여 | -| `strength: N` | 자신에게 힘 +N | -| `powerEffect: "strengthPerTurn"`, `value: N` | 파워: 매 턴 시작 시 힘 +N | - -`luaCardsTable`이 신규 필드를 직렬화하도록 확장. - -## 예시 카드 4종 (메이플 스킬명 × StS 효과) - -| id | 이름 | kind | 코스트 | 효과 | StS 원본 | -|----|------|------|--------|------|----------| -| ChargedBlow | 차지 블로우 | Attack | 2 | 피해 8, 취약 2 | Bash | -| Threaten | 위협 | Skill | 0 | 타겟 적에게 약화 2 | Intimidate 계열 | -| Enrage | 인레이지 | Skill | 1 | 힘 +2 | Inflame | -| Rage | 분노 | Power | 1 | 매 턴 시작 시 힘 +1 | Demon Form(경량) | - -- 보상 풀은 `self.Cards` 전체 자동 편입이므로 추가 작업 없음. 시작 덱 변경 없음. -- 카드 이미지는 MSW 공식 리소스 RUID를 검색·선별해 사용 (계정 업로드 리소스는 로컬 워크스페이스에서 흰 박스 — 금지). -- 타겟팅: Attack은 기존 드래그 타겟, 디버프 Skill(위협)은 현재 `TargetIndex` 적에게 적용 (적 클릭으로 타겟 변경 가능). - -## Power 카드 동작 - -- `PlayCard`에서 `kind == "Power"` 분기: `powerEffect` 등록(`PlayerPowers`에 push) 후 **버린 덱에 넣지 않고 소멸** (RunDeck에는 유지 — 다음 전투에서 다시 사용 가능) -- `StartPlayerTurn`: `PlayerPowers` 순회, `strengthPerTurn`이면 `PlayerStr += value` -- 카드 면 색: 기존 `ApplyCardFace`의 else 분기(초록)가 Power에 적용됨 — 명시적으로 `elseif c.kind == "Power"` 초록 지정 - -## 적 디버프 인텐트 (`data/enemies.json`) - -인텐트 스키마 확장: `{ "kind": "Debuff", "effect": "weak"|"vuln", "value": N }` - -| 적 | 추가 인텐트 | -|----|------------| -| mushmom (머쉬맘) | 포자 — 약화 2 | -| slime_elite (정예 슬라임) | 약화 1 | -| slime_boss (슬라임 킹) | 취약 2 | -| king_slime (킹 슬라임) | 취약 2 | -| modified_snail (변형된 달팽이) | 약화 1 | - -`EnemyActStep`에서 `kind == "Debuff"` 처리: `PlayerWeak/PlayerVuln += value`. - -## UI 변경 (`gen-slaydeck.mjs` UI 생성부) - -1. **적 방어도 배지**: 각 `MonsterSlot{i}`에 `BlockBadge`(파란 사각 44×40, HP바 좌측 x=-HP_BAR_W/2-32, y=-14) + `BlockBadge/Value` 텍스트. `RenderCombat`에서 `m.block > 0`일 때만 표시 — 플레이어 배지와 동일 패턴. -2. **적 버프 라인**: `MonsterSlot{i}/Buffs` 텍스트 (Intent 아래 y=-58, fontSize 15, 보라). 예: `힘+2 약화1 취약2`. 없으면 빈 문자열. -3. **플레이어 버프 라인**: `PlayerPanel/Buffs` 텍스트 (y=-44, fontSize 14). 파워 활성 시 `분노` 포함. 예: `힘+3 취약1 · 분노`. -4. **인텐트 표시 확장**: `Debuff` 인텐트 → `약화 2 부여` / `취약 2 부여`, 보라색. Attack 인텐트 숫자는 힘·약화·플레이어 취약 반영한 **최종 예상치** 표시 (StS 동일). - -## 밸런스 시뮬 동기화 (`tools/balance/sim-balance.mjs`) - -- 카드: `strength`/`weak`/`vuln`/Power 처리 재현 (chooseAction은 Attack 우선 휴리스틱 유지, Power/버프 스킬은 에너지 남을 때 사용하는 단순 규칙 추가) -- 적: `Debuff` 인텐트 재현 (플레이어 weak/vuln) -- 피해 공식 양방향 동일 적용, 감소 타이밍 동일 -- 기존 테스트(`sim-balance.test.mjs`) 통과 + 신규 케이스(약화/취약/힘 계산 단위 테스트) 추가 - -## 검증 - -1. `node tools/deck/gen-slaydeck.mjs` 성공 (산출물 재생성: SlayDeckController.codeblock·DefaultGroup.ui·common.gamelogic) -2. `node --test tools/balance/sim-balance.test.mjs` 통과 -3. `node tools/balance/sim-balance.mjs` 실행 — 승률이 0%/100% 극단으로 붕괴하지 않는지 확인 - -## 결정 사항 - -- 민첩(Dexterity)은 도입하지 않음 (방어 카드 수가 적어 효용 낮음 — YAGNI) -- 기존 카드(슬래시 블러스트 등) 수치 변경 없음 — 신규 카드로만 확장 -- Power는 1종으로 시작, 물약/유물(P7)과의 연계는 P7에서 diff --git a/docs/superpowers/specs/2026-06-12-card-frames-design.md b/docs/superpowers/specs/2026-06-12-card-frames-design.md deleted file mode 100644 index d0aeb36..0000000 --- a/docs/superpowers/specs/2026-06-12-card-frames-design.md +++ /dev/null @@ -1,81 +0,0 @@ -# P13 — 커스텀 카드 프레임 설계 - -날짜: 2026-06-12 (사용자 승인 완료) -브랜치: `feature/p13-card-frames` - -## 범위 - -사용자 제작 카드 프레임 이미지(직업 3종 × 등급 3종)를 인게임 카드 UI 전체(손패·보상·상점·덱 조회)에 적용한다. 카드에 등급(rarity)을 도입하고 전투 보상 추첨 확률에 반영한다. - -## 리소스 (임포트 완료 — RUID 수확됨) - -원본: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\card\*.png` (263×366, 카드 비율 180×250과 동일한 0.72) -메이커 로컬 임포트 → `RootDesk/MyDesk/.sprite` 디스크립터 9종 (커밋 대상). - -| 프레임 | normal | unique | legend | -|---|---|---|---| -| warior | `4bb57ef88ef449fdaf958f6cf37fe44b` | `4f71c124c8bc4e13b5e9fad392995f68` | `6d741a60c60743cb98ee740a1e2dbfed` | -| mage | `d788d09f6f50467ebc67f01dec45f9e2` | `f5def2e8022b4e59a17d3c16414034fe` | `cff71f2e472041ce80c6fbd296f42e2d` | -| bandit | `9487b06867bc46269ed1d855420f457f` | `b3081fb2fb1445fa90b12b01481a78ef` | `c357d2daf31a489d95b8fa47e50dd879` | - -bandit은 RUID 등록만 하고 보류 (도적 클래스 추가 시 사용). - -프레임 슬롯 구조: 좌상단 육각 코스트 · 상단 이름 배너 · 중앙 아트 영역 · 하단 설명 박스. - -## 데이터 - -### `data/cardframes.json` (신설) - -```json -{ - "frames": { - "warrior": { "normal": "4bb57ef88ef449fdaf958f6cf37fe44b", "unique": "4f71c124c8bc4e13b5e9fad392995f68", "legend": "6d741a60c60743cb98ee740a1e2dbfed" }, - "magician": { "normal": "d788d09f6f50467ebc67f01dec45f9e2", "unique": "f5def2e8022b4e59a17d3c16414034fe", "legend": "cff71f2e472041ce80c6fbd296f42e2d" }, - "bandit": { "normal": "9487b06867bc46269ed1d855420f457f", "unique": "b3081fb2fb1445fa90b12b01481a78ef", "legend": "c357d2daf31a489d95b8fa47e50dd879" } - }, - "classToFrame": { - "warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior", - "magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician" - }, - "rewardWeights": { "normal": 70, "unique": 25, "legend": 5 } -} -``` - -### `data/cards.json` — 전 카드에 `rarity` 추가 - -| 등급 | 카드 (32종) | -|---|---| -| normal (10) | Strike, Defend, Bash, WarLeap, Threaten, EnergyBolt, MagicGuard, MagicClaw, Teleport, Slow | -| unique (17) | Brandish, ChargedBlow, Enrage, ComboAttack, RisingAttack, ThunderCharge, BlizzardCharge, PowerGuard, Pierce, IronWall, FireArrow, PoisonBreath, ColdBeam, ChillingStep, Heal, Bless, HolyArrow | -| legend (5) | Rage, Berserk, HyperBody, ElementAmp, ThunderBolt | - -기준: 시작 덱·기본기 = normal / 강화·2차 전직 주력기 = unique / 파워 카드·전체 공격 = legend. - -생성기 검증: `rarity` 누락 또는 normal|unique|legend 외 값이면 throw. 카드 class가 `classToFrame`에 없으면 throw. - -## 렌더링 (생성기 — A안: 카드 배경 교체) - -- 카드 루트 스프라이트: 단색 틴트(kind별) → 프레임 `ImageRUID`(Type 0, 흰색). NamePlate/CostPlate 단색판 제거 — RewardHud 등 생성 섹션은 생성 중단으로 충분, **CardHand는 .ui에 잔존하므로 upsert 시 경로 매칭으로 명시 제거**. -- `ApplyCardFace`(Lua): kind 틴트 분기 제거 → `self.CardFrames[self.ClassToFrame[c.class]][c.rarity]` 적용. `CardFrames`/`ClassToFrame`는 OnBeginPlay에서 Lua 테이블 주입 + `prop('any', …)` 선언(LIA 1114 예방). -- 자식 레이아웃 공용 헬퍼 `cardFaceLayout(W)` 신설 — 중복 5곳(손패 523·조회 787·전체덱 928·보상 1443·상점 1660 부근) 일괄 적용. 180×250 기준값(스케일 s=W/180): - - Cost: pos(-68, 103)·size 44·font 26 (현 위치와 거의 일치) - - Name: pos(4, 97)·size 150×26·font 18 — 상단 배너로 이동 - - Art: pos(0, 16)·size 110 — 중앙 아트 영역 확대 - - Desc: pos(0, -85)·size 152×64·font 16 — 하단 박스 - - 초깃값이며 메이커 스크린샷으로 미세 튜닝. -- 정적 프리뷰(Card1~5)도 동일 프레임 적용. - -## 보상 가중 추첨 - -`OfferReward`(Lua): 풀을 rarity 버킷으로 분류 후 1~100 롤 — ≤70 normal / ≤95 unique / >95 legend. 해당 버킷이 비면 전체 풀 폴백. 상점·전투 계산은 변경 없음 (sim-balance 전투 미러 무관). - -JS 미러: `tools/balance/sim-balance.mjs`에 `rarityForRoll(roll)` export + 경계 테스트(70/71/95/96). - -## 검증 - -재생성 → `grep -c` 카운트(CardFrames·rarity) → 기존 테스트 40건 + 신규 통과 → 메이커 refresh·빌드 0에러 → 플레이 스크린샷(손패 프레임·등급 색 구분·보상·덱 조회) → 텍스트 위치 튜닝. - -## 주의 (이번 세션 실측) - -- maker_save 시 메이커가 산출물을 재직렬화(0→0.0 등)하고 `Mislocated/`로 엔티티를 옮길 수 있음 → 임포트 후 `.sprite`만 남기고 산출물은 `git restore`로 복원했음. 재발 시 동일 절차. -- sprite RUID는 map01.map에 등록되지 않고 `.sprite` 디스크립터 자체가 등록 메커니즘. diff --git a/docs/superpowers/specs/2026-06-12-combat-motion-design.md b/docs/superpowers/specs/2026-06-12-combat-motion-design.md deleted file mode 100644 index 2915203..0000000 --- a/docs/superpowers/specs/2026-06-12-combat-motion-design.md +++ /dev/null @@ -1,36 +0,0 @@ -# P12 — 전투 모션 설계 - -날짜: 2026-06-12 (사용자 승인 완료) -브랜치: `feature/p12-combat-motion` - -## 범위 - -플레이어·몬스터의 공격/피격 모션 (독 틱 피해 포함). 순수 클라이언트 연출 — 전투 수치·시뮬 비대상. - -## 모션 매핑 - -| 상황 | 대상 | 모션 | -|---|---|---| -| 카드 공격(단일·AoE) 사용 | 플레이어 | 아바타 공격 스윙 (`AvatarBodyActionSelectorComponent.ActionState`, pcall 가드 — 실패 시 전방 런지 폴백) → 0.4s 후 복귀 | -| 적 공격 행동 | 몬스터 | 플레이어 방향 런지 (x −0.35 → 0.18s 복귀) — 몹 다수가 공격 클립 미보유 → StS식 채택 | -| 몬스터 피격 (카드·AoE·물약·**독 틱**·체인메일 반사) | 몬스터 | `hit` 클립 재생(`SpriteRendererComponent.SpriteRUID` ← `StateAnimationComponent.ActionSheet["hit"]`, BuildMonsters에서 pcall 캐시) → 0.5s 후 stand 복귀. 클립 없으면 좌우 흔들림 폴백 | -| 플레이어 피격 (적 공격) | 플레이어 | 넉백 틱 (x −0.15 → 0.15s 복귀) | - -## 훅 지점 - -- `PlayCard` Attack 분기 → `PlayerAttackMotion()` -- `EnemyActStep` Attack 인텐트 → `MonsterLunge(idx)` + 피해 후 `PlayerHitMotion()`; 독 틱 → `MonsterHitMotion(idx)` -- `DealDamageToTarget` 피해 적용 후 → `MonsterHitMotion(slot)` (물약 화염병 포함 자동) -- `PlayAoeFx` 대상 루프 → `MonsterHitMotion(i)` -- `DealDamageToPlayer` 브론즈 체인메일 반사 → `MonsterHitMotion(attackerSlot)` -- 사망 연출은 기존(KillMonster SetVisible) 유지. 모션 중 사망 시 isvalid·alive 가드로 복귀 타이머 무해화 - -## 구현 메모 - -- `BuildMonsters`에서 `m.hitClip`/`m.standClip` pcall 캐시 (SyncDictionary 인덱싱 실패 대비) -- 모든 위치 복귀는 캡처한 원위치 기준 (이중 발동 시 어긋남 방지를 위해 모션 중 재발동은 위치 캡처 생략 — `m.motionBusy` 플래그) -- 아바타 enum `MapleAvatarBodyActionState` 멤버는 메이커 프로브로 확정 후 베이크 (후보: swingO1·stabO1) - -## 검증 - -메이커 플레이테스트: 카드 공격 시 아바타 스윙(또는 폴백) 로그·몬스터 hit 클립 전환 로그, 적 턴 런지·플레이어 넉백, 독 틱 모션. 빌드·런타임 0에러, 기존 테스트 40건 유지. diff --git a/docs/superpowers/specs/2026-06-12-job-advancement-design.md b/docs/superpowers/specs/2026-06-12-job-advancement-design.md deleted file mode 100644 index 6b508fe..0000000 --- a/docs/superpowers/specs/2026-06-12-job-advancement-design.md +++ /dev/null @@ -1,64 +0,0 @@ -# P9 — 전직 시스템 코어 + 전사 2차 설계 - -날짜: 2026-06-12 (사용자 승인 완료 — P9/P10/P11 3단계 중 1단계) -브랜치: `feature/p9-job-advancement` - -## 범위 - -1. **클래스 모델** — 카드 `class` 필드, 클래스별 카드 풀 필터 (보상·상점) -2. **전직 선택 흐름** — 보스 클리어 시 1차 상태면 [유물] vs [2차 전직] 선택, 전직 시 파이터/페이지/스피어맨 3택 -3. **전사 2차 전용 카드 9종** + 신규 메커니즘: 다단히트(`hits`)·방어 무시(`pierce`)·자가 디버프(`selfVuln`)·파워 2종(`energyPerTurn`/`blockPerTurn`) -4. 플레이어 패널·캐릭터 선택의 직업명 표기 - -비범위: 법사(P10), 승천(P11), 3차 전직. - -## 데이터 (data/cards.json) - -- 모든 카드에 `class` 필드. 기존 9종 → `"warrior"`. -- 신규 필드: `hits`(타격 횟수), `pierce`(true=방어 무시), `selfVuln`(사용 시 자신에게 취약 N), powerEffect 추가값 `energyPerTurn`/`blockPerTurn`. - -신규 카드 9종 (메이플 2차 스킬명 × StS 효과): - -| id | 직업 | 이름 | 코스트 | 효과 | StS 참조 | -|----|------|------|--------|------|----------| -| ComboAttack | fighter | 콤보 어택 | 1 | 피해 5 × 2회 | Twin Strike | -| Berserk | fighter | 버서크 | 2 | Power: 매턴 에너지 +1, 사용 시 취약 1 자가 | Berserk | -| RisingAttack | fighter | 라이징 어택 | 2 | 피해 12 | Carnage(경량) | -| ThunderCharge | page | 썬더 차지 | 1 | 피해 7, 약화 1 | Clothesline(경량) | -| BlizzardCharge | page | 블리자드 차지 | 1 | 피해 7, 취약 1 | Bash(경량) | -| PowerGuard | page | 파워 가드 | 1 | 방어도 10 | Shrug It Off(경량) | -| Pierce | spearman | 피어스 | 1 | 피해 9, **방어 무시** | — | -| IronWall | spearman | 아이언 월 | 2 | 방어도 12 | Impervious(경량) | -| HyperBody | spearman | 하이퍼 바디 | 1 | Power: 매턴 방어도 +3 | Metallicize | - -전직 시 대표 카드 1장 즉시 지급: fighter→콤보 어택, page→썬더 차지, spearman→피어스. - -## 전투 규칙 확장 (Lua + sim 동기화) - -- **다단히트**: `total = Σ CalcPlayerAttack(c.damage)` (hits회 반복 — 힘이 타격마다 적용, 펜닙 카운터도 타격마다 증가), 이펙트·팝업은 합산 1회. 취약 배수는 합산값에 적용(단순화 명시). -- **방어 무시**: `DealDamageToTarget(amount, pierce)` — pierce면 block 차감 생략. `PlayAttackFx`에 pierce 전달. -- **selfVuln**: 카드 사용 시 `PlayerVuln += selfVuln`. -- **파워 확장**: StartPlayerTurn 파워 루프에 `energyPerTurn`(Energy +v) · `blockPerTurn`(PlayerBlock +v — 블록 리셋·점토 처리 후). - -## 전직 흐름 - -- 컨트롤러 prop: `PlayerJob`(string, ""=1차). StartRun에서 리셋. -- **카드 풀 필터** (`CardPool` 헬퍼 신설): `c.class == self.SelectedClass or (PlayerJob ~= "" and c.class == PlayerJob)`. OfferReward·ShowShop이 사용. -- **보스 클리어 분기** (CheckCombatEnd): 보스 진행 로직을 `ContinueAfterBoss()`로 추출. - - `PlayerJob == "" and Floor < RunLength` → `ShowJobChoice()` (선택 후 ContinueAfterBoss) - - 그 외 → 기존 유물 지급 + ContinueAfterBoss (최종 막 클리어 시 전직 무의미 — 생략) -- **JobChoiceHud**: "보스 보상 선택" — [유물 획득](PickNewRelic+AddRelic) / [2차 전직] 버튼 2개. -- **JobSelectHud**: 파이터/페이지/스피어맨 3패널 (직업명·설명·대표 카드명). 선택 → `SetJob(jobId)`: PlayerJob 설정, 대표 카드 RunDeck 추가, 토스트, 패널 닫고 ContinueAfterBoss. -- guid 네임스페이스 `'job'` = 0xe4 (JobChoiceHud·JobSelectHud). -- **직업명 표기**: PlayerPanel/Name = "전사" → 전직 후 "파이터/페이지/스피어맨" (`JobLabel` 헬퍼, StartCombat·SetJob에서 갱신). - -## 검증 - -1. sim-balance: hits/pierce/selfVuln/energyPerTurn/blockPerTurn 재현 + 신규 테스트 5건. rogue-map 9건·기존 21건 유지. -2. 메이커: 빌드 0에러 + 플레이테스트 — 보스 클리어→선택 화면→전직→전용 카드 보상 풀 편입·패널 직업명, 유물 선택 경로, 다단히트/방어무시 동작. - -## 결정 사항 - -- 전직은 런당 1회 (PlayerJob 비가역), 최종 막 보스에선 선택 생략 -- 카드 이미지 9종: 공식 maplestory 리소스 메이커 선별 (기존 절차) -- 클래스 필터로 "해당 클래스만 획득" 충족 — 사용 제한은 별도 불요 (얻을 수 없으면 못 씀) diff --git a/docs/superpowers/specs/2026-06-12-magician-design.md b/docs/superpowers/specs/2026-06-12-magician-design.md deleted file mode 100644 index 5e92218..0000000 --- a/docs/superpowers/specs/2026-06-12-magician-design.md +++ /dev/null @@ -1,63 +0,0 @@ -# P10 — 법사 클래스 설계 - -날짜: 2026-06-12 (사용자 승인 — P9/P10/P11 중 2단계) -브랜치: `feature/p10-magician` -선행: P9 (클래스 모델·전직 흐름·CardPool 필터) - -## 범위 - -1. **캐릭터 선택 오픈** — 시작 화면 전사/법사 2택 (법사 시작 HP 70, 전용 시작 덱) -2. **법사 1차 카드 5종** + **2차 3계열 9종** (위자드(불·독)/위자드(썬·콜)/클레릭 — 실제 메이플 직업) -3. **신규 메커니즘 4종**: 독(DoT)·전체 공격(AoE)·회복 카드·드로 카드 (Lua + 시뮬 동기화) -4. 전직 선택 화면을 **클래스별 동적 구성**으로 리팩터 (P9의 고정 3패널 → 슬롯 3개 + 런타임 채움) - -## 데이터 - -- `cards.json`: `starterDeck` → **`starterDecks`** `{ warrior: [...], magician: [에너지 볼트×5, 매직 가드×4, 매직 클로×1] }` -- 신규 카드 필드: `draw`(드로 N)·`heal`(HP 회복)·`poison`(적에게 독 N)·`aoe`(true=전체 공격) -- 클래스 상수(생성기): warrior HP 80 / magician HP 70 - -법사 카드 14종 (메이플 스킬명): - -| id | 직업 | 이름 | 코 | 효과 | -|----|------|------|----|------| -| EnergyBolt | magician | 에너지 볼트 | 1 | 피해 6 | -| MagicGuard | magician | 매직 가드 | 1 | 방어 5 | -| MagicClaw | magician | 매직 클로 | 1 | 피해 3 × 2회 | -| Teleport | magician | 텔레포트 | 1 | 방어 3, 드로 1 | -| Slow | magician | 슬로우 | 1 | 약화 2 부여 | -| FireArrow | firepoison | 파이어 애로우 | 1 | 피해 8 | -| PoisonBreath | firepoison | 포이즌 브레스 | 1 | **독 4** 부여 | -| ElementAmp | firepoison | 엘레멘트 앰플 | 1 | Power: 매턴 힘 +1 | -| ThunderBolt | icelightning | 썬더 볼트 | 2 | **전체 적** 피해 6 | -| ColdBeam | icelightning | 콜드 빔 | 2 | 피해 7, 약화 2 | -| ChillingStep | icelightning | 칠링 스텝 | 1 | 방어 8 | -| Heal | cleric | 힐 | 1 | **HP 10 회복** | -| Bless | cleric | 블레스 | 1 | 힘 +1, 방어 5 | -| HolyArrow | cleric | 홀리 애로우 | 1 | 피해 8 | - -(설계 초안 대비 수치 미세 조정: 힐 12→10·블레스 방어 6→5·홀리 애로우 9→8 — 1코 효율 정렬) - -## 신규 메커니즘 규칙 - -- **독**: 적 디버프. 해당 적 행동 시작 시 `hp -= poison` 후 `poison -= 1` (StS 동일). 방어 무시. 독 사망 시 행동 생략·체인 계속. 버프 라인에 `독N` 표시. -- **AoE**(`aoe: true`): 생존 적 전원에게 각자 취약/방어 적용해 피해. 중앙 이펙트 1회(`PlayAoeFx`), 슬롯별 팝업. -- **회복**(`heal`): `PlayerHp = min(+N, Max)`. -- **드로**(`draw`): 사용 시 N장 드로 (손패 상한 5 초과분은 기존 DrawCards 동작 따름). - -## 전직 화면 동적화 - -- `JobSelectHud`의 패널을 `Job_slot1..3`(범용)으로 변경, `ShowJobSelect`가 `SelectedClass`별 옵션 테이블(JOBS 상수 주입)로 이름/설명/대표 카드 텍스트를 채움. 클릭 → `SetJob(JobOpts[i].id)`. -- JOBS: warrior=[fighter/page/spearman], magician=[firepoison(위자드 불·독)/icelightning(위자드 썬·콜)/cleric(클레릭)] -- 대표 카드: firepoison→파이어 애로우, icelightning→썬더 볼트, cleric→힐 -- `JobLabel` 확장: 마법사/위자드(불·독)/위자드(썬·콜)/클레릭 - -## 캐릭터 선택 - -- 기존 `MageButton`(잠금) → 활성: key Mage, `SelectClass("magician")`, 하이라이트·상태 텍스트 클래스 공용화, `StartNewGame` 가드 warrior|magician 허용 -- `StartRun`: 클래스별 MaxHp·RunDeck 분기 - -## 검증 - -1. 시뮬: poison/aoe/heal/draw 재현 + 테스트 4건 이상 (전체 40건+) -2. 메이커: 법사 선택→시작 덱 확인→전직(클레릭 등)→전용 카드 풀·독/AoE/힐 실동작, 빌드·런타임 0에러 diff --git a/docs/superpowers/specs/2026-06-12-potions-relics-design.md b/docs/superpowers/specs/2026-06-12-potions-relics-design.md deleted file mode 100644 index 10222be..0000000 --- a/docs/superpowers/specs/2026-06-12-potions-relics-design.md +++ /dev/null @@ -1,103 +0,0 @@ -# P7 — 물약 시스템·유물 강화 설계 - -날짜: 2026-06-12 -브랜치: `feature/p7-potions-relics` -선행: P6 (버프/디버프·Power — 물약·유물 효과가 힘/약화/취약을 참조) - -## 범위 - -1. **물약 시스템 (StS 풀세트)** — 전투 보상 확률 드랍 + 상점 구매 + 전투 중 사용 + 버리기, 슬롯 기본 3칸 -2. **유물 19종** — 기존 4종 유지 + 신규 15종 (StS 효과 그대로, 메이플 장비 외형·이름) -3. **유물 아이콘 행 + 마우스오버 툴팁** — 텍스트 나열 → 장비 아이콘, hover 시 효과 설명 창 -4. **물약 슬롯 5칸 유물(장인의 벨트)** ★ 대표 필수 - -비범위: 밸런스 시뮬의 물약/유물 재현(시뮬은 카드·적 규칙만 동기화 — 기존과 동일), 맵/휴식 화면 유물 표시. - -## 물약 (data/potions.json 신설) - -| id | 이름 | 효과 | StS 원본 | -|----|------|------|----------| -| redPotion | 빨간 포션 | HP 20 회복 | Health 계열 | -| firebomb | 화염병 | 타겟 적에게 피해 20 | Fire Potion | -| warriorElixir | 전사의 물약 | 힘 +2 (전투 동안) | Strength Potion | -| guardPotion | 수호의 물약 | 방어도 +12 | Block Potion | -| manaElixir | 마나 엘릭서 | 에너지 +2 | Energy Potion | -| cursedVial | 저주의 병 | 타겟 적에게 약화 3 | Weakness Potion | - -- 슬롯: 기본 3칸, `장인의 벨트` 보유 시 5칸. UI는 항상 5칸 그리고 벨트 없으면 4·5번째 칸 잠금 표시. -- 획득: 전투 승리 시 40% 확률(`dropChance`)로 랜덤 1개. 슬롯 가득이면 토스트 안내 후 미지급. 상점에서 랜덤 1종 20골드 판매(`ShopPotion`, 유물 패턴 동일). -- 사용: 물약 슬롯 클릭 → 미니 메뉴(사용/버리기/닫기). **사용은 전투 중에만** (전투 외 클릭 시 사용 버튼 무시 + 토스트), 버리기는 언제나 가능. -- 타겟형 물약(화염병·저주의 병)은 현재 `TargetIndex` 적에게 적용. -- 스키마: `{ potions: { id: { name, desc, effect, value, icon } }, dropChance: 0.4, baseSlots: 3, beltSlots: 5, shopPrice: 20 }` -- effect 종류: `heal` `damage` `strength` `block` `energy` `weak` -- 상태: `RunPotions` (id 배열), `PotionSlots` (3|5). StartRun에서 초기화. - -## 유물 19종 (data/relics.json 확장) - -기존 4종(강철 심장·에너지 코어·흡혈 송곳니·황금 우상) 유지. 신규 15종 — StS 효과 그대로, 메이플 장비 이름: - -| id | 장비명 | 효과 | StS 원본 | 구현 지점 | -|----|--------|------|----------|----------| -| potionBelt | 장인의 벨트 | 물약 슬롯 3→5 ★ | Potion Belt | AddRelic | -| burningBlood | 자쿰의 투구 | 전투 승리 시 HP 6 회복 | Burning Blood | combatEnd | -| vajra | 미스릴 액스 | 전투 시작 시 힘 +1 | Vajra | combatStart | -| anchor | 메이플 실드 | 첫 턴 방어도 +10 | Anchor | combatStart(block) | -| bagOfPrep | 모험가의 배낭 | 첫 턴 드로우 +2 | Bag of Preparation | combatStart | -| bloodVial | 피의 목걸이 | 전투 시작 시 HP 2 회복 | Blood Vial | combatStart | -| bronzeScales | 브론즈 체인메일 | 적 공격에 피격 시 공격자에게 3 반사 | Bronze Scales | onPlayerDamaged | -| strawberry | 건강의 반지 | 획득 시 최대 HP +7 | Strawberry | AddRelic | -| penNib | 황금 깃펜 | 10번째 공격 카드 피해 2배 | Pen Nib | CalcPlayerAttack | -| boot | 브론즈 부츠 | 5 미만 공격 피해를 5로 | The Boot | CalcPlayerAttack | -| akabeko | 황소 투구 | 전투 첫 공격 카드 피해 +8 | Akabeko | CalcPlayerAttack | -| centennialPuzzle | 백년의 부적 | 전투 중 처음 HP를 잃으면 드로우 3 | Centennial Puzzle | onPlayerDamaged | -| meatOnBone | 고기 망치 | 전투 종료 시 HP 50% 이하면 12 회복 | Meat on the Bone | combatEnd | -| selfFormingClay | 점토 갑옷 | 피해를 받으면 다음 턴 방어도 +3 | Self-Forming Clay | onPlayerDamaged + StartPlayerTurn | -| championBelt | 챔피언 벨트 | 카드로 취약 부여 시 약화 1 추가 | Champion Belt | PlayCard 디버프 적용부 | - -규칙 세부: -- penNib 카운터는 **전투 내** 공격 카드 사용 횟수 기준(StS는 런 전체 누적이나 단순화). 10·20·30…번째 2배. -- boot 은 최종 계산값이 1~4일 때 5로 보정 (0은 그대로). -- akabeko 는 전투당 1회, 첫 공격 카드의 기본 피해에 +8 (힘 적용 전 base에 합산). -- bronzeScales 반사는 공격한 적이 생존 중일 때 3 피해 (그 적의 block 무시하지 않음 — DealDamage 재사용, 취약 배수는 미적용하도록 직접 hp 차감). -- 적용 순서(CalcPlayerAttack): base + akabeko → penNib 2배 → 힘 → 약화 → boot 보정. 취약은 기존대로 명중 시. -- 유물 상태 props: `FightAttackCount`(펜닙·아카베코 겸용), `FirstHpLossDone`(퍼즐), `ClayBlockNext`(점토). -- 획득 경로(기존 유지 + 개선): 정예 승리·상점 + **보스 클리어 시 1개 추가**. 풀에서 **미보유 유물만** 추첨(`PickNewRelic`), 전부 보유 시 골드 +25 대체. -- relicPool에 신규 15종 전부 + 기존 3종(에너지 코어·흡혈 송곳니·황금 우상) 포함. 시작 유물은 ironHeart 유지. -- 스키마 확장: 각 유물에 `icon`(RUID) 추가. 신규 hook 값: `combatEnd`, `onPlayerDamaged`, `passive`(AddRelic 시 1회). - -## UI - -### 유물 아이콘 행 (CombatHud TopBar) -- 기존 `TopBar/Relics` 텍스트 제거 → `TopBar/RelicSlot1..10` (UISprite 40×40, x -240부터 48px 간격). -- `RenderRelics`: 보유 유물 순서대로 아이콘 표시, 10개 초과분은 10번째 칸을 `+N` 텍스트로 대체. -- 각 슬롯에 `UITouchReceiveComponent` + `UITouchEnterEvent/ExitEvent` → 툴팁 표시/숨김. - -### 물약 슬롯 (CombatHud TopBar 우측) -- `TopBar/PotionSlot1..5` (UISprite 40×40, x 270부터 44px 간격, AllDeckButton(x 510) 앞에서 종료). -- 빈 칸은 어두운 배경, 잠금 칸(벨트 미보유 4·5번)은 자물쇠 느낌의 더 어두운 색. -- 클릭(ButtonClickEvent 대신 UITouchDownEvent) → `PotionMenu` 팝업: 물약명·설명 + [사용] [버리기] [닫기]. -- hover 툴팁 동일 적용. - -### 툴팁 (TooltipBox) -- `/ui/DefaultGroup/CombatHud/TooltipBox` — bg(260×72) + Name + Desc 텍스트, displayOrder 최상위, 기본 비활성. -- Enter 시 대상 슬롯 인덱스에 따라 x 위치 조정해 표시, Exit 시 숨김. 공용 메서드 `ShowTooltip(name, desc, x, y)` / `HideTooltip()`. - -### 상점 (ShopHud) -- 기존 `ShopHud/Relic` 아래 `ShopHud/Potion` 추가 — 라벨·가격(20골드)·구매 처리 `BuyPotion` (ShopRelic 패턴 복제, 슬롯 가득 시 구매 거부 토스트). - -## 데이터 흐름 - -`StartRun`: `RunPotions = {}`, `PotionSlots = baseSlots`, 유물 초기화(기존) → `RenderRelics`·`RenderPotions`. -`CheckCombatEnd`(승리): combatEnd 유물 → 물약 드랍 판정 → 기존 보상 흐름. -`DealDamageToPlayer`: HP 실손실 시 onPlayerDamaged 유물 발동 (공격자 slot 인자 추가). - -## 검증 - -1. `node tools/deck/gen-slaydeck.mjs` 성공, `node --test` 통과 (기존 21건 — 시뮬 변경 없음) -2. 메이커 빌드 콘솔 0 에러 + 플레이테스트: 유물 아이콘·툴팁 hover·물약 사용/버리기·벨트 5칸 확인 - -## 결정 사항 - -- 물약 아이콘·유물 아이콘 RUID는 공식 maplestory 리소스에서 메이커 미리보기로 선별 (계정 리소스 금지) -- 물약 6종으로 시작 (StS 핵심 6역할), 추가는 데이터만으로 확장 가능 -- penNib 전투 내 카운터·bronzeScales 단순 반사 등 경량화는 표에 명시한 대로 diff --git a/docs/superpowers/specs/2026-06-12-rogue-map-design.md b/docs/superpowers/specs/2026-06-12-rogue-map-design.md deleted file mode 100644 index e3abbe0..0000000 --- a/docs/superpowers/specs/2026-06-12-rogue-map-design.md +++ /dev/null @@ -1,92 +0,0 @@ -# P8 — 로그라이크 절차 생성 맵·층 시스템·유물 방 설계 - -날짜: 2026-06-12 (사용자 승인 완료) -브랜치: `feature/p8-rogue-map` -선행: P7 (유물 19종 — 유물 방이 `PickNewRelic` 재사용) - -## 범위 - -1. **절차 생성 맵** — 막 시작마다 8층×최대 4열 DAG를 Lua 런타임 생성 (런·막마다 다른 맵). `data/map.json` 정적 맵 제거 -2. **층(depth) 시스템** — 노드를 지날 때마다 층 +1 (층 = 행), 층별 노드 타입 등장 규칙 -3. **유물 방(보물 노드)** — 상자 열리는 모션 + 유물(`PickNewRelic`)·메소 획득, 노드 타입 `treasure` 추가 -4. **맵 UI 정비** — 노드 연결 점선(도트 3개 보간), 타입별 아이콘 색/라벨, 상태 4단(방문/현재/도달 가능/잠김) -5. **메소 표기** — 화폐 표시 텍스트 "골드" → "메소" (메이플 IP, 표시만 — 내부 변수 Gold 유지) - -비범위: 이벤트 노드(?), 경로 교차 방지(시각 교차 허용 — 점선이라 식별 가능), 저장. - -## 절차 생성 알고리즘 (StS 방식) - -그리드: 행 1~7 × 열 1~4 + 8행 보스 단일 노드. 노드 id = `"r{row}c{col}"`, 보스 = `"boss"`. - -1. 시작 열: {1,2,3,4} 셔플 후 앞 2개 = 경로 1·2의 시작(서로 다름 보장), 경로 3·4는 랜덤 -2. 경로 4개를 각각 1행→7행으로 걸어 올림: 다음 열 = `clamp(열 + random(-1..1), 1, 4)`. 지나는 칸마다 노드 생성(중복은 병합), `(r,c) → (r+1,c')` 간선 추가(중복 간선 병합) -3. 7행의 모든 노드 → 보스 간선 -4. `MapStart` = 1행에 생성된 노드들 - -### 타입 배정 (행 오름차순) - -| 행 | 규칙 | -|---|---| -| 1~2행 | combat 고정 | -| 3행 | combat 45 / shop 12 / rest 12 (가중 추첨, 합계로 정규화) | -| 4~6행 | combat 45 / elite 16 / shop 12 / rest 12 / treasure 15 | -| 7행 (보스 직전) | rest 50 / combat 25 / shop 10 / elite 8 / treasure 7 | -| 8행 | boss 고정 | - -추가 제약: **부모(간선으로 들어오는 이전 행 노드) 중 elite가 있으면 해당 노드는 elite 금지** (연속 엘리트 방지). - -### 층 카운터 - -`Depth` prop: `PickNode` 시 `Depth = node.row`. 막 전환·런 시작 시 0. TopBar Floor 텍스트를 `막 F/3 · D층`으로 확장. - -### 노드 enemy 필드 제거 - -몬스터는 P4 이후 `node.type` 그룹 필터(`BuildMonsters`)로 결정되므로 `enemy` 필드·`CurrentEnemyId` 대입은 제거(`CurrentEnemyId = ""` 유지). `data/map.json`·`luaMapNodesTable`·`luaStartArray`·`MAX_ROW` 제거. - -## 유물 방 (TreasureHud) - -- `PickNode` 분기에 `treasure` → `ShowTreasure` 추가 (`ShowState("treasure")`·`HideGameHud` 등록) -- UI: 어두운 패널 + 타이틀("보물 상자") + 중앙 상자 버튼(닫힌 상자 RUID) + 보상 텍스트(초기 숨김) + 나가기 버튼 -- `OpenChest`: 1회 가드(`ChestOpened`) → **흔들림 모션**(anchoredPosition ±8px, 0.08s × 6스텝 타이머 체인) → **열린 상자 RUID로 교체** → 보상 지급·표시 - - 메소: `40 + random(0..20)` - - 유물: `PickNewRelic()` — 미보유 추첨, 전부 보유 시 메소 +30 대체 - - 보상 텍스트 예: `유물 획득: 황소 투구 · 메소 +52` -- 상자 닫힘/열림 스프라이트는 공식 maplestory 리소스 검색("상자"/"보물상자") 후 메이커 선별 -- 나가기 → `LeaveNode`(기존 → ShowMap) - -## 맵 UI - -### 정적 그리드 (생성기, MapHud 섹션 재작성) - -- 노드 버튼 28개(`Node_r{1..7}c{1..4}`, 56×56) + `Node_boss`(72×72, 상단 중앙) + 각 노드 `Label`(타입 한글) -- 배치: 행 y = `-330 + (row-1)*105` (보스 y=405), 열 x = `-270 + (col-1)*180`, 보스 x=0 -- 점선 도트: 모든 가능 간선(행 1~6: `c→c±1/c`, 행 7→보스)에 대해 도트 3개(8×8, t=0.25/0.5/0.75 보간 위치). 엔티티 `Dot_r{r}c{c}_{c'}_{k}` (보스행은 `Dot_r7c{c}_b_{k}`) -- 모든 노드·도트는 기본 비활성, `RenderMap`이 토글 - -### RenderMap 재작성 (상태 4단 + 점선) - -- 노드: 맵에 없으면 숨김. 있으면 Label = 타입명, 색: - - 방문(`VisitedNodes`에 포함) → 어둡게 `(0.18,0.19,0.22)` - - 현재 위치 → 골드 `(0.95,0.8,0.3)` - - 도달 가능(IsReachable) → 타입색 밝게 + 버튼 활성 - - 잠김 → 타입색 45% 어둡게 + 버튼 비활성 -- 타입색: 전투 `(0.78,0.36,0.32)` / 엘리트 `(0.62,0.4,0.85)` / 상점 `(0.9,0.75,0.35)` / 휴식 `(0.4,0.75,0.45)` / 보물 `(0.35,0.7,0.75)` / 보스 `(0.85,0.25,0.25)` -- 도트: 간선 존재 시 표시. 현재 노드(또는 시작 전 1행 진입선)에서 나가는 간선 = 골드, 그 외 = 회색 `(0.5,0.5,0.55)` -- `VisitedNodes`(any prop): PickNode 시 추가 - -### 메소 표기 - -표시 문자열 "골드" → "메소": TopBar Gold·ShopHud Gold·상점 가격(`N 메소`)·유물 소진 토스트. 내부 prop `Gold` 유지. - -## 검증 - -1. **JS 미러 + 단위 테스트**: `tools/map/rogue-map.mjs`에 `generateMap(rng)` 동일 로직(시드 PRNG 주입) + `rogue-map.test.mjs` — 모든 노드가 시작점에서 도달 가능·보스 수렴·1~2행 combat만·elite/treasure 4행부터·간선 인접 열만·elite 부모 연속 금지·결정성(동일 시드 동일 맵). ⚠️ Lua `GenerateMap`과 로직 동기화 유지(sim-balance 패턴) -2. 기존 `sim-balance` 21건 유지 (맵과 무관) -3. 메이커: 빌드 0에러, 플레이테스트 — 맵 생성/점선/상태색, 노드 진행(층 증가), 유물 방 상자 연출·보상, 보스 클리어 → 다음 막 새 맵 - -## 결정 사항 - -- 경로 4개·8층×4열 (사용자 승인 규모) -- 점선 도트 방식 채택 (UI 회전 리스크 회피, StS 원작 미감) -- 시각적 간선 교차는 허용 (점선이라 추적 가능 — YAGNI) -- `RUN_LENGTH`/`ACT_MAPS` 막 시스템은 변경 없음 (막마다 새 맵 생성만 추가) diff --git a/docs/superpowers/specs/2026-06-13-p14-loop-lobby-soul-design.md b/docs/superpowers/specs/2026-06-13-p14-loop-lobby-soul-design.md deleted file mode 100644 index 6bbd447..0000000 --- a/docs/superpowers/specs/2026-06-13-p14-loop-lobby-soul-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# P14 — 반복 런 · 로비 · 영혼 · 도적 · 몬스터 랜덤성 설계 - -> 작성 2026-06-13. 사용자 자율 실행 지시(Phase별 커밋 → 최종 push/PR)에 따라 인터랙티브 승인 게이트 없이 -> 설계 결정을 본 문서에 기록·커밋하고 순차 구현한다. 산출물(`ui/DefaultGroup.ui`·`*.codeblock`·`*.map`· -> `*.gamelogic`)은 생성기(`tools/`)·데이터(`data/`)에서 100% 생성되므로 본 작업은 전부 소스만 수정한다(RULES.md). - -## 목표 - -기존 P1~P13 단발 런 구조를, **로비 허브를 중심으로 반복 수행하는 로그라이트 루프**로 재편하고 -도적 직업·몬스터 랜덤성·영혼 메타 성장·카드 UX를 추가한다. - -## 핵심 설계 결정 (요약) - -1. **맵 5개 + 반복 루프**: 런은 map01~map05 5막. 최종 보스 클리어 시 무한 진행이 아니라 **로비로 복귀**해 - 영혼을 정산하고 다음 런을 준비. "반복 수행이 메인"을 *로비 기점 반복 런*으로 해석. -2. **로비 = 스크린 HUD**: 게임 전체가 이미 스크린 HUD(MapHud/ShopHud/RewardHud) 구동이고 물리 맵은 배경일 뿐이다. - 로비도 동일하게 `LobbyHud`(스크린)로 구현하고, NPC는 클릭 가능한 스프라이트 버튼으로 배치한다. - NPC 4종: **도감(Codex)·상점(영혼 메타)·런 시작·게시판(채팅 대용)**. 첫 실행/패배/클리어 시 진입점. -3. **영혼(Soul)**: 승천(패널티 누적)과 역할 분리된 **영구 강화 메타 화폐**. - *2차 전직을 한 상태로* 맵 보스를 클리어할 때마다 누적. 로비 상점 NPC에서 해금 구매 → 다음 런에 이점. - 저장은 승천 RPC 패턴 복제(`UserDataStorage`, key `soulPoints`/`soulUnlocks`). -4. **도적**: `bandit` 프레임이 이미 데이터에 준비됨. 도적 1차 + 2차(어쌔신/시프) 카드·스타터덱 신규. -5. **몬스터 랜덤성**: 구성(일반 1~3 / 엘리트 1+일반 0~2 / 보스 1)과 행동(정의된 intent 중 랜덤)을 런타임 추첨. - StS2식 "덱 오염" intent(`AddCard`)와 저주 카드 신규. -6. **메소**: 표면 문자열은 이미 메소. 잔존 `goldIdol.desc` 정정 + 메소 코인 아이콘 추가(내부 식별자 `Gold`는 유지). - -## Phase 구성 (각 Phase = 1+ 커밋, 소스 수정 → 재생성 → 카운트/테스트 검증) - -### Phase 1 — 맵 5막 · depth 7 · 노드 인접 규칙 -- `gen-slaydeck.mjs`: `MAP_ROWS 7→6`(걷는행6+보스=총7), `ACT_COUNT 3→5`, `ACT_MAPS [map01..map05]`, `RUN_LENGTH 3→5`. -- 노드 타입 인접 금지 확장: 현재 elite만 부모-자식 연속 금지 → **rest·shop·elite** 3종 모두 금지. - `GenerateMap`(Lua 4333-4358) + `rogue-map.mjs`(67-72) 미러 + `rogue-map.test.mjs` 단언 추가. -- 막 배율 `1+(Floor-1)*0.6`(`:2776`)을 5막 기준 `1+(Floor-1)*0.45`로 완화. -- 맵 파일 11→5: 생성기 카운트(`gen-maps`/`gen-map-encounters`/`gen-combat-monster`/`freeze-turn-monsters`/ - `gen-camera`/`gen-player-lock`) `[2..11]→[2..5]`, `length:11→5`. `git rm map/map06..11.map`. - `Global/SectorConfig.config`에서 map06~11 엔트리 제거(생성기가 재구성하도록 보정 또는 수동 정리). - -### Phase 2 — 노드 가로 레이아웃(왼→오른쪽) -- `gen-slaydeck.mjs:1536-1538` 좌표 함수 row↔x·col↔y 스왑 + 호출부(1573/1600/1605) 인자 스왑. Lua 무수정. - 보스는 최우측 중앙. 도트 보간·간선·라벨 자동 추종. - -### Phase 3 — 몬스터 랜덤 구성 · 랜덤 행동 · AddCard · map01 배치 -- `data/enemies.json`: 종별 `tier`(normal/elite/boss) 추가, 일부 종에 `AddCard` intent 추가. - map01용 일반 5종 + 엘리트 1종 보장(slime/orange/blue/green mushroom/pig + mushmom 등 기존 활용). -- `data/cards.json`: 저주/상태 카드 신규(`kind:"Status"`, `unplayable:true`, `curse:true`, `endTurnDamage?`). -- `gen-slaydeck.mjs`: `BuildMonsters`(2780) 노드타입별 랜덤 구성, `EnemyActStep`(3603) 랜덤 intent 선택 - (예고=확정: 턴 종료 시 다음 행동 추첨 저장), `AddCard` intent 처리, `PlayCard` unplayable 가드, - 카드 직렬화(`luaCardsTable`)에 신규 필드, intent 직렬화(`luaIntentsArray`)에 `card`/`count`. -- `sim-balance.mjs`+test: 랜덤 행동(rng)·AddCard 미러, 결정성 테스트 유지, 저주 unplayable 필터. -- `gen-map-encounters.mjs`: map01 편입 + 일반5/엘리트1 레이아웃(오른쪽 배치). 엘리트 맵에 일반 혼합용 변형 배치. - -### Phase 4 — 도적 클래스 + 2차(어쌔신/시프) -- `data/cards.json`: 도적 1차(class `thief`) + 어쌔신(class `assassin`) + 시프(class `bandit`) 카드 + 스타터덱. -- `data/cardframes.json`: `classToFrame`에 thief/assassin/bandit → `bandit` 프레임 매핑. -- `gen-slaydeck.mjs`: `CLASSES.thief`(maxHp 75), `JOBS.thief`(어쌔신/시프), CharacterSelectHud Thief 해금, - `BindMenuButtons` ThiefButton, `RenderCharacterSelect`/`StartNewGame`/`StartRun`/`JobLabel` 도적 분기. -- 전사/법사 2차는 완비 확인됨(수정 불요). - -### Phase 5 — 카드 스킬 아이콘 · 피격 이펙트 분리 -- `data/cards.json`: 공격 카드에 `fx`(이펙트 RUID) 필드 추가, `image`는 스킬 아이콘 유지. -- `gen-slaydeck.mjs`: `luaCardsTable` fx 직렬화, `PlayCard`(3296-3298) FX 인자를 `c.fx or c.image`로. -- RUID는 MSW 공식 리소스 asset 검색으로 수급(계정 업로드 금지·RULES §5). - -### Phase 6 — 카드 UX: 핸드 최대 10 · 마우스오버 확대/툴팁 -- `gen-slaydeck.mjs`: CardHand 슬롯 5→10 확장, `RenderHand` 동적 간격(장수 비례), `DrawCards` 10장 상한 - (초과 분 자동 버림), 카드 hover 이벤트 바인딩(enter→`ShowTooltip`+스케일업, exit→복귀), 드래그 중 가드. -- `sim-balance.mjs`+test: 드로우 상한 미러. - -### Phase 7 — 메소 전환 + 메소 아이콘 -- `data/relics.json`: `goldIdol.desc` "골드"→"메소". -- `gen-slaydeck.mjs`: TopBar·ShopHud 메소 텍스트 옆 코인 아이콘 sprite 추가(공식 RUID). - -### Phase 8 — 로비 + NPC + 반복 루프 -- `gen-slaydeck.mjs`: `LobbyHud` 섹션(배경 + NPC 4종 스프라이트 버튼), `ShowLobby`/`ShowState("lobby")`, - NPC 핸들러(Codex→CodexHud, Shop→영혼상점, RunStart→CharacterSelect, Board→게시판 패널). - `CodexHud`: 전 카드 도감(클래스별 그리드). `OnBeginPlay`→`ShowLobby`(메뉴 대체), `EndRun`→`ShowLobby`. - 첫 실행/패배/클리어 모두 로비 기점. - -### Phase 9 — 영혼(Soul) 시스템 -- `gen-slaydeck.mjs`: `SoulPoints`/`SoulUnlocks` 프로퍼티, RPC `ReqLoadSouls`/`SaveSouls`/`RecvSouls`(ExecSpace 5/6), - 보스 클리어 & `PlayerJob~=""`일 때 영혼 가산(`ContinueAfterBoss`/`CheckCombatEnd`), 로비 영혼 상점 UI·구매, - 해금 효과를 `StartRun`에 적용(시작 메소/시작 유물/시작 HP/덱 강화 등 덱빌딩 이점). - -### Final — 전체 재생성 · 테스트 · push · PR -- 전 생성기 재생성, `node --test`(rogue-map·sim-balance), 카운트 검증, 가능 시 메이커 플레이테스트. -- `tools/git/gitea-pr.mjs`로 UTF-8 spec JSON 작성 후 PR 생성(RULES §4). - -## 검증 원칙 (RULES §2·§6) -- 산출물 본문 출력 금지 — `grep -c`/JSON parse/카운트만. -- 전투·맵 규칙 수정 시 Lua↔JS 미러 동시 수정 + `node --test` 통과. -- 커밋은 기능 단위, 산출물 재생성은 메시지에 명시. - -## 알려진 제약 / 결정 근거 -- **로비 "돌아다니기"**: 물리 맵 walkable 로비는 player 이동이 전역 freeze(턴제)라 위험·고비용 → 스크린 HUD - NPC 클릭으로 동일 기능 제공(아키텍처 정합). 추후 walkable 로비는 확장 슬롯. -- **카드 아이콘/이펙트**: 현재 `c.image`가 카드 아트 겸 피격 FX로 이중 사용 중 → `fx` 분리로 의도 달성. -- **영혼 vs 승천**: 승천=적 강화 패널티 토글, 영혼=플레이어 영구 강화 → 같은 저장소 다른 key로 공존. diff --git a/docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md b/docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md deleted file mode 100644 index 3cfa71a..0000000 --- a/docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md +++ /dev/null @@ -1,104 +0,0 @@ -# 로비 맵 + 월드 NPC 설계 (P15) - -작성일: 2026-06-14 -브랜치: `feature/p15-lobby-map-npc` - -## 목표 - -기존 **UI 패널 로비**(P14-8 `LobbyHud` — 색칠된 `UIButton` 4개 행)를 폐기하고, -**전용 로비 맵**에 **월드 NPC 엔티티 4종**을 배치한다. NPC를 누르면 각 기능이 실행되며, -플레이어 **이동·공격 모션은 로비 맵에서만** 풀린다(전투/런 맵에서는 기존대로 잠김 유지). - -요청 원문: "로비를 UI로 만들지 말고, map을 추가해서 로비에 각각 기능을 할 수 있는 npc를 추가하고, -그 npc를 누르면 각 기능을 할 수 있도록 추가. 플레이어는 반드시 로비맵에서만 이동과 공격 모션을 풀어줘." - -## 확정된 결정 (브레인스토밍) - -| 항목 | 결정 | -|---|---| -| NPC 상호작용 | **근접 프롬프트+키(Up/Space) AND 직접 클릭** 둘 다 지원 | -| NPC 기능 | **기존 4종 유지** + 외형을 **정식 maplestory NPC 스프라이트(공식 RUID)** 로 교체 | -| 로비 맵 | **새 전용 로비 맵 추가**(`map/lobby.map`), 런 맵(map01~) 인덱스 불변 | - -## 현재 상태 (조사 결과) - -- **로비 = UI 패널** `LobbyHud`. NPC 4종은 색칠 `UIButton`, 전부 `tools/deck/gen-slaydeck.mjs`에 하드코딩. - - `NpcRun`(모험가→런 시작), `NpcCodex`(사서→카드 도감), `NpcShop`(상인→영혼 상점), `NpcBoard`(안내원→게시판). - - 정의 ~`gen-slaydeck.mjs:2469-2487`, 클릭 바인딩 `BindLobbyButtons()` ~`2997-3014`. - - 기능 진입 메서드: `ShowCharacterSelect()`(~3147), `ShowCodex()`, `ShowSoulShop()`, `ShowBoard()` — **재사용 대상**. -- **이동/공격 이중 잠금**: - - `tools/player/freeze-turn-player.mjs` → `Global/DefaultPlayer.model` speed/jump/accel = 0 (**전역**). - - `tools/player/gen-player-lock.mjs` → `PlayerLock` codeblock(`pc.Enable=false`, `FixedLookAt=true`)를 map01~05에 주입. - - 공격은 순수 모션 `PlayerAttackMotion()`(StateComponent ATTACK→IDLE), 필드 몬스터와 무관(전투는 데이터 배열 UI 전투). - - 플레이어 스폰: `TeleportToActMap()` → `Vector3(-6, 0.03, 0)`, Floor별 map 선택. -- **맵 파이프라인**: map01 = 저작 템플릿, map02~05 = 복제 생성. 포털 없이 `TeleportToMapPosition` 전환. - - `Global/SectorConfig.config`의 valid 목록에 맵 등록(현재 gen-maps.mjs가 갱신). - - 컴포넌트 부착 패턴: `gen-combat-monster.mjs`가 맵 몬스터에 `script.CombatMonster`를 붙이고, 해당 codeblock이 OnBeginPlay에서 `/common` 컨트롤러에 자가등록. -- **흐름**: `OnBeginPlay`→`ShowLobby()`(UI). `EndRun(text)`→4초 후 `ShowLobby()`. - -## 접근법 - -**A. 새 로비 맵 + 월드 NPC 엔티티 (채택)** — 맵 템플릿 복제 재사용, NPC를 월드 엔티티로 배치하고 -각 NPC의 codeblock이 근접+클릭을 감지해 **기존 기능 패널**을 띄움. 이동/공격 해제는 로비 맵 전용 codeblock. -전투맵은 손대지 않아 잠금 유지. (B: 몬스터 배치기 재활용 → 로직 혼재로 비채택. C: 화면고정 UI 버튼 → 거부된 "UI 로비"라 제외.) - -## 상세 설계 - -### 1) 로비 맵 — `tools/map/gen-lobby-map.mjs` → `map/lobby.map` -- map01 템플릿 deep clone → 경로 `/maps/lobby`로 치환, GUID 재발급(결정론 시드). -- 마을/타운 배경 RUID + 타일셋 적용. **`script.Monster`/`script.CombatMonster` 엔티티 전부 제거**(전투 없음). -- NPC 4종을 x축으로 벌려 월드 엔티티로 배치. 각 NPC: - - 스프라이트 = 공식 maplestory NPC RUID(계정 업로드 금지 — 흰 박스). 구현 단계에서 asset 검색으로 4개 확정. - - `script.LobbyNpc` 컴포넌트 + `NpcId`(`run`/`codex`/`shop`/`board`) + 머리 위 이름/`!` 프롬프트용 텍스트 노드. -- 플레이어 스폰 지점(맵 중앙-좌측). -- `Global/SectorConfig.config` valid 목록에 `map://lobby` 추가 — **SectorConfig 단일 소유자는 gen-maps.mjs**로 유지하고 lobby 항목을 그 상수에 포함(두 생성기 충돌 방지). - -### 2) NPC 상호작용 — `tools/player/gen-lobby-npc.mjs` → `LobbyNpc` codeblock -- **근접+키**: 매 틱(타이머) 로컬 플레이어와의 x거리 측정 → 임계 거리 내면 `!` 프롬프트 노드 활성 + `Up`/`Space` 입력 시 트리거. -- **직접 클릭**: NPC 엔티티 클릭/터치 → 동일 트리거. (MSW 월드 엔티티 클릭 API는 구현 시 `mlua_api_retriever`로 확정: 엔티티 TouchEvent vs 스크린 오버레이 버튼 중 검증된 방식.) -- 트리거 시 `_EntityService:GetEntityByPath("/common").SlayDeckController:OnLobbyNpcInteract(NpcId)` 호출(경로 기반 크로스 codeblock — CombatMonster 자가등록과 동일 패턴). -- 한 번에 하나만 상호작용(다른 패널 열려 있으면 무시). - -### 3) 이동·공격 잠금 해제 (로비 맵 한정) — `LobbyMobility` codeblock -- **`map/lobby.map`에만** 주입(전투맵 PlayerLock/전역 freeze는 불변). -- OnBeginPlay 런타임 복원: 로컬 플레이어 `MovementComponent`(InputSpeed/JumpForce) 정상값, `PlayerController.Enable=true`, `FixedLookAt=false`. -- 공격 입력(키/클릭) → 기존 `PlayerAttackMotion()`(코스메틱) 바인딩. **필드 타격 없음**. -- 전투맵 텔레포트 시 모델 기본값(speed=0)+PlayerLock 재적용 → **"로비맵에서만"을 구조적으로 보장**. -- 런타임 이동/공격 복원 정확한 API는 구현 단계에서 `mlua_api_retriever`로 확정. -- 생성기 배치는 `gen-lobby-npc.mjs`에 함께 둘지 별도 `gen-lobby-unlock.mjs`로 분리할지는 계획에서 결정(둘 다 lobby 맵 전용 codeblock). - -### 4) 흐름 통합 — `tools/deck/gen-slaydeck.mjs` -- **OnBeginPlay**: `ShowLobby()`(UI) → **로비 맵 텔레포트** + 경량 "lobby" 상태(전투/상점/맵 HUD 숨김). -- **EndRun**: 4초 후 `ShowLobby()` → **로비 맵 텔레포트 복귀**. -- **OnLobbyNpcInteract(id)** 신규: `run`→`ShowCharacterSelect()`, `codex`→`ShowCodex()`, `shop`→`ShowSoulShop()`, `board`→`ShowBoard()`(전부 기존 메서드 재사용, 패널은 로비 맵 위 팝업). -- **제거**: `LobbyHud` 버튼-행 허브 패널 + `BindLobbyButtons`. -- **유지**: 영혼 포인트·승천 표시는 화면 모서리 **미니 HUD**(정보 표시 필요). 기능 패널 4종은 NPC 트리거. -- 런 시작(`StartRun`/`TeleportToActMap`)·전투 흐름은 불변. - -### 5) 미러/테스트 영향 -- 이동/공격 해제·NPC 배치는 **전투 규칙도 맵 그래프 생성 알고리즘도 아님** → `sim-balance.mjs`/`rogue-map.mjs` JS 미러 동기화 **불필요**(RULES.md §6은 그 둘만 요구). -- 검증(카운트만): `lobby.map` 내 NPC 엔티티 수, 산출물의 `LobbyNpc`/`LobbyMobility`/`OnLobbyNpcInteract` 개수, SectorConfig `map://lobby` 존재. 내용 출력 금지. -- 동작 검증: 메이커 플레이테스트. - -## 검증 시나리오 (메이커) -1. 월드 시작 → **로비 맵에 스폰**, 이동 가능, 공격 모션 가능. -2. NPC 근접 → `!` 프롬프트 → `Up/Space`로 기능 패널 오픈. 직접 클릭으로도 오픈. -3. 4종 각각: 모험가→직업선택→런 시작, 사서→도감, 상인→영혼상점, 안내원→게시판. -4. 런 시작 → map01 텔레포트, **이동/공격 잠김**. -5. 런 종료(클리어/패배) → **로비 맵 복귀**, 이동/공격 재해제. -6. 미니 HUD에 영혼/승천 표시 정상. - -## 리스크 -- MSW 런타임 이동 재활성 API 가용성 → 계획 단계 `mlua_api_retriever` 검증. -- MSW 월드 엔티티 클릭 감지 방식 → 동일 검증(불가 시 근접+키만으로 폴백, 직접 클릭은 스크린 오버레이 버튼으로 구현). -- 텔레포트 복귀 좌표/스폰 위치 정합. -- 메이커 stale 상태 — git pull 후 로컬 워크스페이스 reload 필수(RULES.md §5). - -## 생성기/파일 변경 요약 -| 파일 | 변경 | -|---|---| -| `tools/map/gen-lobby-map.mjs` | **신규** — lobby.map(배경/타일/NPC 엔티티/스폰), SectorConfig 조율 | -| `tools/player/gen-lobby-npc.mjs` | **신규** — LobbyNpc 상호작용 codeblock(+LobbyMobility 또는 분리) | -| `tools/deck/gen-slaydeck.mjs` | OnBeginPlay/EndRun 로비맵 전환, OnLobbyNpcInteract, 버튼-행 허브 제거, 미니 HUD | -| `Global/SectorConfig.config` | map://lobby 등록(생성 산출물) | -| `map/lobby.map`, `ui/DefaultGroup.ui`, `*.codeblock` | 재생성 산출물 | diff --git a/docs/superpowers/specs/2026-06-15-node-map-ui-design.md b/docs/superpowers/specs/2026-06-15-node-map-ui-design.md deleted file mode 100644 index 082536a..0000000 --- a/docs/superpowers/specs/2026-06-15-node-map-ui-design.md +++ /dev/null @@ -1,96 +0,0 @@ -# 노드 맵 UI 강화 설계 - -작성일: 2026-06-15 -브랜치: `feature/node-map-ui` - -## 목표 - -맵 노드 선택 화면(`MapHud`)을 **단색 박스+텍스트** → **공식 메이플 아이콘 노드 + 배경 이미지**로 강화한다. -절차 랜덤 배치·간선·진행 로직은 그대로. 아이콘/배경은 **`data/nodeicons.json` 한 파일로 외부화**해 나중에 RUID만 바꿔 재생성하면 교체되도록 한다. - -요청 원문: "노드 창이 단순 네모 박스안에 텍스트 … 백그라운드 이미지 삽입하고 특정 아이콘을 지정해서 노드로 … 랜덤 배치 … 노드 맵 UI 강화. 내가 나중에 변경할 수도 있으니 변경이 쉽게 가능하도록." - -## 확정된 결정 (브레인스토밍) - -| 항목 | 결정 | -|---|---| -| 노드 표현 | **아이콘만**(박스 제거). 상태는 아이콘 틴트로 | -| 배경 | **공식 메이플 배경 이미지** + 반투명 어두운 오버레이 | -| 아이콘 세트 | 사용자 확정(아래 표). 공식 maplestory RUID, 썸네일 검수 완료 | -| 변경 용이성 | 모든 RUID를 `data/nodeicons.json`로 외부화 → 편집+재생성으로 교체 | - -### 확정 아이콘/배경 (공식 maplestory, 흰박스 위험 없음) - -| 노드 타입 | 아이콘 | RUID | -|---|---|---| -| combat(전투) | 주황버섯 | `f98db6823e894a4f90308d61f75894ac` | -| elite(엘리트) | 돌골렘(Stumpy) | `793ed8a757534b89a82f460747d2df24` | -| boss(보스) | 주니어 발록 | `423056cdbbc04f4da131b9721c404d96` | -| shop(상점) | 보라 돈주머니 | `da37e1fac55d455b9ade08569f09f798` | -| rest(휴식) | 모닥불 | `b86c1b0568bd45f3ae4a4b97e1b4a594` | -| treasure(보물) | 금별 보물상자 | `f8a6d58e20f54e2ca899485055df1ce4` | -| **background** | 리스항구 | `d84241f17de344a097f5b96ac914f1d2` | - -## 현재 구조 (조사 결과) - -- `MapHud` 루트 = 1920×1080 **단색** 패널(`gen-slaydeck.mjs:1664`, 배경 이미지 없음) + 타이틀. -- 노드 = `pushMapNode(id,pos,size,label)`(`:1696`) — `Node_{id}` 단색 박스(56×56, 보스 72×72) + `Label` 텍스트 자식. 그리드 `r1c1~r6c4`(24) + `boss`(`:1727`). -- 타입 6종: combat/elite/shop/rest/treasure/boss. 타입→색/라벨은 **Lua `RenderMapNode`**(`:5626~5677`)가 런타임에 박스 `Color` + Label 텍스트로 채움. 상태 4단(현재 금색/방문 회색/도달 타입색/잠김 어둡게). -- 절차 생성 `GenerateMap`(`:5505`) → `self.MapNodes[id]={type,row,col,next}`, id `r{r}c{c}`가 UI 엔티티와 1:1. 버튼 바인딩(`:3597`)은 경로 기반. -- 이미지 주입 패턴: emit `sprite({dataId: RUID, type:0})`(`sprite()` 헬퍼 `:297`) / 런타임 `e.SpriteGUIRendererComponent.ImageRUID = ""`(`ApplyCardFace :4089`, chest `:5874`). 카드 프레임은 `data/cardframes.json`→`luaFramesTable()`(`:72`)→`self.CardFrames` Lua 테이블. - -## 상세 설계 - -### 1) `data/nodeicons.json` (신설 — 단일 소스) -```json -{ - "icons": { - "combat": "f98db6823e894a4f90308d61f75894ac", - "elite": "793ed8a757534b89a82f460747d2df24", - "boss": "423056cdbbc04f4da131b9721c404d96", - "shop": "da37e1fac55d455b9ade08569f09f798", - "rest": "b86c1b0568bd45f3ae4a4b97e1b4a594", - "treasure": "f8a6d58e20f54e2ca899485055df1ce4" - }, - "background": "d84241f17de344a097f5b96ac914f1d2" -} -``` -- 사용자가 나중에 RUID만 바꾸고 `node tools/deck/gen-slaydeck.mjs` 재실행하면 교체됨. (README/주석에 명시.) - -### 2) `gen-slaydeck.mjs` — 로드·검증·직렬화 -- 상단에서 `NODEICONS = JSON.parse(readFileSync('data/nodeicons.json'))` 로드. -- **fail-fast 검증**: `icons`에 6타입(combat/elite/boss/shop/rest/treasure) 전부 존재 + 32hex RUID, `background` 존재. 누락 시 throw(카드프레임 검증과 동일 패턴). -- `luaNodeIconsTable()` 헬퍼: `self.NodeIcons = { combat="...", ... }` Lua 테이블 문자열. OnBeginPlay init에 주입(CardFrames 패턴, `:2906/3361` 인접). `prop('any','NodeIcons')` 선언. - -### 3) MapHud emit 변경 -- **배경 자식 `MapHud/Bg`**: 루트 직후 push. `uisprite`, 1920×1080, `dataId = NODEICONS.background`, `type:0`, 흰색, `raycast:false`, displayOrder 최하(0). 항상 enable. -- **루트 오버레이**: 기존 루트 단색을 **반투명 어두운 오버레이**로(예: `{r:0.04,g:0.05,b:0.08,a:0.55}`)— 배경이 비치되 노드 가독성 확보. raycast 유지(뒤 월드 클릭 차단). -- **`pushMapNode` → 아이콘 노드**: `Node_{id}` 본체를 박스 대신 **아이콘 스프라이트**로 — `sprite({ color:{1,1,1,1}, type:0, raycast:true })`(emit 시 dataId 미지정, 런타임에 타입별 ImageRUID 주입) + `button()`. **`Label` 자식 제거**(아이콘만). 노드 크기 키움: 그리드 64×64, 보스 88×88. (좌표 헬퍼 `nodeX/nodeY`·그리드 생성 루프·버튼 바인딩은 불변.) - -### 4) RenderMapNode Lua 변경 -- 타입→박스색/라벨 매핑(`:5630~5656`) 제거. 대신: - - `e.SpriteGUIRendererComponent.ImageRUID = self.NodeIcons[type]` (없으면 combat 폴백). - - 상태별 `Color` 틴트(박스가 아니라 **아이콘**에): - - 현재(`CurrentNodeId`): `Color(1, 0.82, 0.3, 1)` 금색 - - 도달가능: `Color(1, 1, 1, 1)` 원색 + `ButtonComponent.Enable=true` - - 방문: `Color(0.5, 0.5, 0.55, 0.9)` 회색 - - 잠김: `Color(0.4, 0.4, 0.45, 0.45)` 어둡고 반투명 + 버튼 비활성 -- `SetText(.../Label ...)` 호출 제거(라벨 없음). 간선 도트(`RenderMapDots`)·`RenderMap` 루프는 불변. - -### 5) 미러/테스트 영향 -- 전투 규칙·맵 그래프 알고리즘 **미변경** → `sim-balance`/`rogue-map` 미러 동기화 불필요. -- 검증(카운트): `MapHud/Bg` 1개, `NodeIcons` 주입, 노드 ImageRUID 주입 코드 존재, 6 RUID 등장. 내용 출력 금지(`tools/verify/count.mjs`). -- 동작: 메이커 플레이테스트(아이콘 렌더·상태 틴트·랜덤 배치·노드 클릭 진행). - -## 리스크 -- **아이콘 비정사각/큰 스프라이트** → 64px UI에서 잘림/왜곡 가능(보스 발록은 확인됨, 골렘·버섯은 정사각 양호). type 0 렌더의 aspect 처리 확인, 필요 시 노드별 size 패딩 조정. -- **아이콘만 상태 가독성**: 잠김/방문 틴트 대비가 약하면 플레이테스트로 알파/명도 튜닝. -- **배경 오버레이 알파**: 너무 밝으면 노드가 묻힘 — 0.5~0.65 사이 튜닝. -- 흰박스: 전부 공식 maplestory(검증) — 위험 없음. 단 로컬 워크스페이스 reload 필요. - -## 변경 파일 요약 -| 파일 | 변경 | -|---|---| -| `data/nodeicons.json` | **신설** — 아이콘 6 + 배경 RUID (단일 소스) | -| `tools/deck/gen-slaydeck.mjs` | 로드·검증·luaNodeIconsTable, MapHud Bg/오버레이, pushMapNode 아이콘화, RenderMapNode ImageRUID+틴트 | -| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | 재생성 산출물 | diff --git a/docs/superpowers/specs/2026-06-16-charselect-images-back-design.md b/docs/superpowers/specs/2026-06-16-charselect-images-back-design.md deleted file mode 100644 index 41cf60b..0000000 --- a/docs/superpowers/specs/2026-06-16-charselect-images-back-design.md +++ /dev/null @@ -1,105 +0,0 @@ -# 직업 선택 — 캐릭터 이미지 + 뒤로가기 설계 - -작성일: 2026-06-16 -브랜치: `feature/charselect-images` - -## 목표 - -런 시작 시 띄우는 **캐릭터(직업) 선택 화면**(`CharacterSelectHud`)을 두 가지로 개선한다: -1. 직업 3종(전사/도적/마법사)을 지금의 **단색 네모 박스** → **각 직업 캐릭터 이미지 카드**로. 이미지를 선택하면 그 직업으로 런 진행(기존 연결 유지). -2. 직업 선택 화면에 **뒤로가기** 버튼 추가 → 로비로 복귀. - -요청 원문: "런 시작 시, 직업 선택 창을 뒤로가기도 가능하게 추가. 각 직업별로 지금은 네모 박스인데 각각 이미지(warrior/mage/bandit.png) 추가해서 적용하고, 선택했을 때 그 캐릭터로 진행하도록 연결." - -## 확정된 결정 (브레인스토밍) - -| 항목 | 결정 | -|---|---| -| 이미지 RUID 확보 | **사용자가 메이커에서 3 PNG 로컬 임포트** → `.sprite`+RUID(P13 카드프레임과 동일). MCP/계정 업로드는 흰박스라 불가 | -| 이미지 배치 | **카드 전체를 이미지로**, 이름은 하단 배너, 선택 시 **금색 테두리** | -| 뒤로가기 대상 | **로비로** (`ShowLobby()`) — 로비 NPC에서 진입하므로 | - -### 소스 이미지 (사용자 임포트 대상) -- 전사: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\character\warrior.png` (~1.05MB) -- 법사: `…\mage.png` (~1.28MB) -- 도적: `…\bandit.png` (~1.0MB) - -세 PNG는 현재 워크스페이스 미임포트(코드 미참조). 기존 `RootDesk/MyDesk/*_normal|unique|legend.sprite`는 P13 **카드 프레임**이지 캐릭터 초상화가 아니다. - -## 현재 구조 (조사 결과) - -- **CLASSES** 상수 `gen-slaydeck.mjs:7-11` — `warrior{label,maxHp}`, `bandit`, `magician`. -- **CharacterSelectHud emit** `:2432-2598`. `classCards` 배열 `:2482-2486` (key Warrior/Thief/Mage, classId warrior/bandit/magician, x −360/0/360, tint). 각 카드(270×330)의 자식: `Name`(상단, 108), `Portrait`(142×142 색상 tint, `:2524-2525` 부근), `Desc`(하단 −105), `LockBody`/`LockShackle`(비활성 직업용). 별도 `…DeckButton`(덱 보기)·`StartButton`. -- **선택 로직**: 클릭 바인딩 `BindMenuButtons` 내 `:3100/3108/3116` → `SelectClass(classId)` `:3358-3361`(=`self.SelectedClass=…`+`RenderCharacterSelect()`). 시작 `:3151-3157` → `StartNewGame` `:3395-3399`(미선택 가드 후 `StartRun()`). -- **RenderCharacterSelect** `:3362-3394` — 선택 카드 밝게/미선택 어둡게 + Status 텍스트. -- **진입/전환**: `ShowState` `:3062-3078`가 HUD 토글. 진입 = 로비 NPC `OnLobbyNpcInteract` `:3199-3203`(런 비활성 시 `ShowCharacterSelect()` `:3355-3357`) 및 (사실상 미사용) MainMenu `:3092`. `ShowLobby` `:3175`. 게임은 OnBeginPlay→`ShowLobby`로 부팅(로비 허브). -- **emit 헬퍼**: `entity():466`, `transform():286`, `sprite():311`(`dataId`로 ImageRUID 주입 가능), `button():347`, `text():372`, `guid()`. -- **이미지 외부화 패턴**: 카드프레임은 `data/cardframes.json` → `luaFramesTable()`(`:72` 부근) → `self.CardFrames` Lua 테이블 + 런타임 `ApplyCardFace` `:4167-4202`가 `e.SpriteGUIRendererComponent.ImageRUID=ruid` 주입. 생성 시 주입은 `sprite({dataId})`. - -## 상세 설계 - -### 1) `data/characters.json` (신설 — 단일 소스) -```json -{ - "portraits": { - "warrior": "<32hex RUID>", - "magician": "<32hex RUID>", - "bandit": "<32hex RUID>" - } -} -``` -- 사용자 임포트 후 `RootDesk/MyDesk/*.sprite`에서 RUID를 읽어 채운다(파일명은 임포트 시 결정 — `warrior.sprite` 등으로 매칭, 모호하면 사용자 확인). -- 나중에 이미지 교체 = 이 파일 RUID만 바꿔 재생성. - -### 2) `gen-slaydeck.mjs` — 로드·검증·주입 -- 상단에서 `const CHARS = JSON.parse(readFileSync('data/characters.json','utf8'))` 로드(cardframes 로드 패턴 인접). -- **fail-fast 검증**: `portraits`에 `warrior`/`magician`/`bandit` 3키 존재 + 각 값이 32hex. 누락 시 throw. -- 카드 Art 이미지는 **생성 시 `dataId` 주입**(런타임 테이블 불필요). 즉 `classCards`의 classId로 `CHARS.portraits[classId]`를 조회해 Art 스프라이트 `dataId`에 박는다. - -### 3) CharacterSelectHud — 카드 전체 이미지화 (`:2432-2598`, `classCards` emit 루프) -각 직업 카드 구조를 다음으로 변경(엔티티 경로 `…/{key}Button`·클릭 바인딩은 **불변**): -- `{key}Button`(270×330): 클릭 가능한 **테두리 프레임**. sprite Color = 미선택 어둡게(`0.16,0.2,0.26,1`)/선택 금색(`1,0.82,0.3,1`). raycast on, `button()` 유지. -- 신규 자식 `Art`(약 258×318, 6px 인셋, center): `sprite({ dataId: CHARS.portraits[classId], type:1, raycast:false })` — 캐릭터 이미지 풀블리드. (테두리가 이미지 뒤로 6px 보임 → 금색 테두리 효과.) -- `Name`(하단 배너): 반투명 어두운 띠 sprite(예: `0,0,0,0.55`, 270×54, 하단) + 금색 텍스트. 기존 `Name` 재배치. -- **제거**: 기존 색상 `Portrait` 박스, `Desc` 텍스트(선택 레이아웃에 없음). -- `LockBody`/`LockShackle`: 비활성 직업용으로 유지(현재 3직업 모두 enabled라 표시 안 됨). - -### 4) `RenderCharacterSelect` Lua 변경 (`:3362-3394`) -- 기존 "박스 밝게/어둡게"를 **테두리(=`{key}Button` sprite Color) 금색/어둡게**로 교체. 선택된 classId의 카드만 `Color(1,0.82,0.3,1)`, 나머지 `Color(0.16,0.2,0.26,1)`. -- Art 이미지는 생성 시 고정 주입이라 런타임 변경 없음. Status 텍스트 로직은 유지. - -### 5) 뒤로가기 버튼 -- 신규 `CharacterSelectHud/BackButton`(ShopHud `Leave` 패턴 재사용 `:2020-2031`): 좌상단(예: `pos {x:-820,y:430}`, 180×56), text "← 뒤로", DARK sprite + `button()`. -- `BindMenuButtons`에 바인딩 추가(ShopHud Leave 바인딩 패턴 `:3715-3717`): `back:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end)`. 핸들러 prop 저장(재바인딩 시 해제). -- `ShowCharacterSelect`/`SelectClass`/`StartNewGame`/`StartRun` 로직 불변. - -### 6) GUID 네임스페이스 -- 신규 엔티티(Art·NameBanner·BackButton)는 CharacterSelect용 기존 prefix에 번호 추가. 미등록 prefix면 ns 바이트 등록(생성기 끝 id 유일성 검증이 충돌 잡음). - -## 흐름 - -``` -로비(맵) ──NPC 상호작용──> ShowCharacterSelect (HUD 오버레이) - 카드3=캐릭터 이미지, 클릭 → SelectClass → 금색 테두리 - [시작] → StartNewGame(가드) → StartRun (그 직업으로) - [← 뒤로] → ShowLobby() → 로비 HUD 복귀 -``` - -## 미러/테스트 영향 -- 전투규칙·맵생성 **미변경** → `sim-balance`/`rogue-map` 미러 동기화 불필요. -- 카운트 검증: `CharacterSelectHud/.../Art` ImageRUID 3개, `BackButton` 1개, characters.json 3 RUID 등장(`tools/verify/count.mjs` 또는 `grep -c`). -- 메이커 플레이테스트: 로비 NPC→3 이미지 표시→클릭 금색 테두리→시작 그 직업으로 진행→뒤로 로비 복귀. - -## 리스크 -- **이미지 임포트 선행 의존**: RUID가 있어야 생성기 실행 가능. 사용자 임포트 완료 후 진행(임포트 무관한 코드 골격은 먼저 작성 가능). -- **이미지 비율**: PNG가 세로 초상화면 258×318(≈0.81 비율)에서 잘리거나 여백 — 임포트 후 스크린샷으로 인셋/사이즈 조정. -- **`ShowLobby()` 재텔레포트**: 이미 로비 맵 위라 `GoLobbyMap` 재호출 시 위치/카메라 jolt 가능 → 보이면 뒤로가기를 `ShowState("lobby")`로 축소(플레이테스트 확인). -- 흰박스: 공식 절차(로컬 임포트)면 렌더됨. reload 필수. - -## 변경 파일 요약 -| 파일 | 변경 | -|---|---| -| `data/characters.json` | **신설** — 직업 3종 초상화 RUID(단일 소스) | -| `tools/deck/gen-slaydeck.mjs` | characters.json 로드·검증, CharacterSelectHud 카드 이미지화(Art/NameBanner), RenderCharacterSelect 테두리 선택표시, BackButton emit+바인딩 | -| `RootDesk/MyDesk/*.sprite` (×3) | 사용자 임포트 산출물(커밋) | -| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | 재생성 산출물 | diff --git a/docs/superpowers/specs/2026-06-16-charselect-maker-pilot-design.md b/docs/superpowers/specs/2026-06-16-charselect-maker-pilot-design.md deleted file mode 100644 index 68cba8c..0000000 --- a/docs/superpowers/specs/2026-06-16-charselect-maker-pilot-design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Phase 2 — 캐릭터 선택 메이커 저작 파일럿 설계 - -작성일: 2026-06-16 -브랜치: `feature/charselect-maker-pilot` (Phase 1b `feature/cb-modularization`/PR #71 위에 스택) - -## 목표 - -하이브리드 UI 로드맵의 **패턴 (b)**(메이커 시각 편집) 검증 파일럿. **캐릭터 선택 화면**을 "생성기 소유 → 메이커 소유"로 이관한다: -- **레이아웃**(패널·카드 위치·버튼)은 메이커에서 시각 편집(생성기가 안 덮음). -- **동적 내용**(캐릭터 이미지·선택 테두리·상태 텍스트)은 `SlayDeckController`가 런타임에 **경로로 주입** = 컨트롤러 내용주입. - -성공 시 Phase 3에서 상점·전체덱 등으로 확장. - -## 현재 구조 (조사 결과) - -- charselect는 **생성 섹션**: `lib/ui-helpers.mjs`의 `GENERATED_UI_SECTIONS`(:17)·`UI_APPEND_ORDER`(:35)에 `'CharacterSelectHud'` 포함. `hud/charselect.mjs`의 `buildCharSelect()`가 엔티티 emit, `upsertUi`가 `emit('CharacterSelectHud', buildCharSelect())`. -- **이미지 = 생성 시 주입**: `hud/charselect.mjs:86` `sprite({ dataId: CHARS.portraits[cls.classId], … })`. 런타임 주입 아님. -- **컨트롤러는 경로 구동**: `cb/charselect.mjs`의 `RenderCharacterSelect`(각 `{Warrior,Mage,Thief}Button`의 `SpriteGUIRendererComponent.Color`로 선택 테두리 + Status 텍스트), `SelectClass`, `StartNewGame`. 바인딩은 `cb/state.mjs`의 `BindMenuButtons`(경로로 WarriorButton·BackButton·StartButton 등). 표시 토글은 `ShowState`(경로). **이미지 주입은 없음.** -- **런타임 시드 모델**: `self.CardFrames`를 `${luaFramesTable()}`로 OnBeginPlay(cb/boot)·StartRun(cb/run)에서 주입 → `ClassPortraits`의 모델. -- `upsertUi` 동작: 기존 `.ui` 로드 → 생성 섹션 엔티티 필터아웃 → emit 섹션 재추가. **생성 섹션에서 빠지면 `isGeneratedUiEntity=false`라 필터 안 됨 → 기존 엔티티 보존(stock)**. - -## 상세 설계 - -### ① 생성 중단 → stock화 (generate-once-then-stop) -- `lib/ui-helpers.mjs` `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER`에서 `'CharacterSelectHud'` 제거. -- `gen-slaydeck.mjs`(upsertUi)에서 `emit('CharacterSelectHud', buildCharSelect())` + 관련 import 제거. `hud/charselect.mjs`는 **삭제**(부트스트랩 완료 — git 이력에 레퍼런스 남음). -- 효과: 현재 `DefaultGroup.ui`의 charselect 엔티티가 그대로 **stock**으로 보존 → 메이커 시각 편집 가능, 재생성에 안 덮임. - -### ② 이미지 런타임 주입 (컨트롤러 내용주입 = 패턴 b 핵심) -- `lib/data.mjs`에 `luaCharsTable()` 신설(`data/characters.json`의 `portraits` 시드, `luaFramesTable`/`luaNodeIconsTable` 패턴; `self.ClassPortraits = { warrior="…", magician="…", bandit="…" }`). -- 주입 지점: `cb/boot.mjs` OnBeginPlay·`cb/run.mjs` StartRun에 `${luaCharsTable()}`(CardFrames 시드 옆) + prop `ClassPortraits`(any) 선언. -- `cb/charselect.mjs` `RenderCharacterSelect`에 이미지 주입 추가: 각 `{key}Button/Art` 엔티티의 `SpriteGUIRendererComponent.ImageRUID`를 `self.ClassPortraits[classId]`로 설정(경로별 isvalid 가드). → 메이커 레이아웃(빈/임의 Art)이어도 컨트롤러가 올바른 이미지 채움. **characters.json 데이터 구동 유지.** - -### ③ 경로 구동 유지 (무변경) -- 선택 테두리·Status·버튼 바인딩(`RenderCharacterSelect` 색/텍스트·`SelectClass`·`BindMenuButtons`·`StartNewGame`·`ShowState`)은 이미 경로 기반 → 변경 없음. - -### ④ 엔티티 경로 계약 (docs 명시) -메이커 편집 시 아래 경로 유지 필수(컨트롤러가 이 경로로 구동; 누락 시 isvalid 가드로 무시되되 그 부분 동작 안 함): -``` -/ui/DefaultGroup/CharacterSelectHud (루트, ShowState 토글) - /OpaqueBackdrop /Title /Status - /WarriorButton (+ /Art ← 이미지 주입, /NameBanner, /Name) - /ThiefButton (+ /Art, /NameBanner, /Name) - /MageButton (+ /Art, /NameBanner, /Name) - /StartButton /BackButton -``` -(#67로 DeckButton 제거됨.) classId 매핑: Warrior→warrior, Thief→bandit, Mage→magician. - -## 검증 (동작 — 바이트동일 아님) -- 생성기: charselect 제거 후 `node tools/deck/gen-slaydeck.mjs` → **charselect 외 산출물 무영향**(`diffcheck`로 codeblock·common 확인; ui는 charselect 섹션만 stock으로 잔류·다른 섹션 동일). charselect 엔티티가 ui에 존재(`count.mjs`). -- 메이커 플레이테스트: 로비→직업선택→**3 이미지가 컨트롤러 주입으로 표시**→클릭 시 금색테두리·Status→시작 시 그 직업으로 런→**메이커에서 카드 위치 이동 후 재생성해도 유지** 확인. - -## 범위 밖 -- 상점·전체덱 등 다른 화면(Phase 3). -- 새 UIGroup(.ui) 분리(경로·ShowState 재작업 큼) — DefaultGroup 내 stock으로 충분. -- 게임 규칙·다른 화면 변경. - -## 리스크 -- stock 전환 시 charselect 엔티티의 `.ui` 내 직렬화 위치 이동 가능 → 렌더는 경로/displayOrder 기반이라 무관하나 플레이테스트로 확인. -- 메이커가 경로를 바꾸면 계약 깨짐 → 경로 표로 가드. isvalid 가드로 크래시는 방지. -- 의존: Phase 1b(cb/charselect·boot·run) 위 스택. #70·#71 머지 후 main 리타겟. - -## 변경 파일 요약 -| 파일 | 변경 | -|---|---| -| `tools/deck/lib/ui-helpers.mjs` | `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER`에서 CharacterSelectHud 제거 | -| `tools/deck/gen-slaydeck.mjs` | upsertUi에서 charselect emit·import 제거 | -| `tools/deck/hud/charselect.mjs` | **삭제** | -| `tools/deck/lib/data.mjs` | `luaCharsTable()` 신설 | -| `tools/deck/cb/boot.mjs`·`cb/run.mjs` | `${luaCharsTable()}` 시드 + ClassPortraits prop | -| `tools/deck/cb/charselect.mjs` | `RenderCharacterSelect`에 Art ImageRUID 주입 | -| `docs/...charselect 경로 계약` | 경로 표(이 스펙 §④) | -| `ui/DefaultGroup.ui`·codeblock | 재생성(charselect는 stock 잔류) | diff --git a/docs/superpowers/specs/2026-06-16-codeblock-modularization-design.md b/docs/superpowers/specs/2026-06-16-codeblock-modularization-design.md deleted file mode 100644 index fac0a1d..0000000 --- a/docs/superpowers/specs/2026-06-16-codeblock-modularization-design.md +++ /dev/null @@ -1,69 +0,0 @@ -# Phase 1b — codeblock 메서드 모듈화 설계 - -작성일: 2026-06-16 -브랜치: `feature/cb-modularization` (Phase 1 `feature/gen-modularization`/PR #70 위에 스택) - -## 목표 - -Phase 1에서 UI emit을 모듈화한 데 이어, `gen-slaydeck.mjs`의 **codeblock 메서드 161개(~3,200줄)**를 기능별 모듈로 분리한다. 출력 `RootDesk/MyDesk/SlayDeckController.codeblock`은 **바이트 동일**(순수 소스 리팩터·무위험). 하이브리드 UI 로드맵 (a) 유지보수 정리의 완결. - -## 현재 구조 (조사 결과) - -- `writeCodeblocks()`(현 `gen-slaydeck.mjs:291`)가 단일 호출로 codeblock을 만든다: - `const combat = codeblock('SlayDeckController', 'SlayDeckController', [], [])` (`:301`~`:3617`, ~3,300줄). -- 헬퍼: `prop()`·`method()`·`codeblock()` (현 `:240`~`:290` 부근, 오케스트레이터 잔류분). -- `writeCodeblocks` 지역 상수: `RUN_LENGTH`·`GOLD_PER_WIN`·`CARD_PRICE`·`REST_HEAL`·`RELIC_PRICE`·`ACT_COUNT`·`ACT_MAPS`·`LOBBY_MAP`·`LOBBY_SPAWN` 등 — 메서드 Lua 문자열 보간에 쓰임. -- 메서드 본문은 **Lua 문자열**. JS 보간(`${RUN_LENGTH}`·`${luaCardsTable(CARDS.cards)}`·`${CAM.zoomRatio}` 등)은 모듈 로드 시점에 평가됨 → 모듈은 보간에 쓰는 상수/데이터/헬퍼를 import해야 한다. -- 161 메서드 이름(순서): OnBeginPlay → [ascension 10종] → HideGameHud·ShowState·ShowMainMenu·BindMenuButtons·ShowLobby… → [soul 12종] → [character/job] → StartRun·StartCombat·[combat 다수] → [deck/hand] → [deckview] → [motion] → [relics/potions] → [tooltip] → [reward] → [map] → [shop/rest/treasure]. 자연스러운 **연속 런(run)**으로 묶임. - -## 상세 설계 - -### 핵심 제약: 바이트 동일 → 메서드 순서 보존 -`codeblock`의 methods 배열은 **순서가 직렬화에 반영**된다. 따라서 모듈은 "기능 버킷"이 아니라 **원본 161-메서드 시퀀스의 연속 구간**으로 나눈다(구간을 그 테마로 명명). `writeCodeblocks`가 모듈 배열을 **원본 순서대로 concat** → 바이트 동일. (Phase 1 HUD 분리와 동일 원리: HUD도 upsertUi 내 연속이었음.) - -### 목표 파일 구조 -``` -tools/deck/ - gen-slaydeck.mjs # 오케스트레이터: writeCodeblocks()가 codeblock(…, [props], [...m1, ...m2, …]) concat - lib/ - codeblock.mjs # 신설 — prop()·method()·codeblock() 헬퍼 + writeCodeblocks 지역 상수 - # (RUN_LENGTH·GOLD_PER_WIN·CARD_PRICE·REST_HEAL·RELIC_PRICE·ACT_COUNT·ACT_MAPS·LOBBY_MAP·LOBBY_SPAWN …) - cb/ # 신설 — 메서드 연속구간 모듈 (각 `export const xMethods = [ method(...), … ]`) - state.mjs ascension.mjs soul.mjs jobs.mjs run.mjs combat.mjs - deck.mjs deckview.mjs motion.mjs items.mjs tooltip.mjs reward.mjs shop.mjs … (~12-14, 실제 런 경계로 확정) -``` - -### 모듈 계약 -- 각 `cb/.mjs`: `export const Methods = [ method('A', \`…\`, …), method('B', …), … ];` — **메서드 호출 verbatim 이동**. -- import: `lib/codeblock.mjs`(method·prop·codeblock·상수), `lib/data.mjs`(CARDS·luaCardsTable·luaStr·CAM 등 보간용). UI 헬퍼는 메서드 보간에 거의 안 쓰임(필요 구간만 `lib/ui-helpers.mjs`). -- `writeCodeblocks()`(오케스트레이터): `codeblock('SlayDeckController','SlayDeckController', [ ...props ], [ ...stateMethods, ...ascensionMethods, … ])` — concat 순서 = 원본 순서. - -### 범위/결정 -- **메서드 161개만 모듈화.** **prop 103개는 오케스트레이터에 단일 리스트로 유지** — 한 줄짜리라 분리 가치 낮고 prop↔feature 매핑 모호(추후 필요시 별도). 게임 로직·Lua **무변경**(순수 소스 리팩터). -- 공유 헬퍼(method/prop/codeblock) + writeCodeblocks 지역 상수 → `lib/codeblock.mjs`. (이 상수들이 메서드 모듈 보간에 필요하므로 lib로.) - -### 검증 (안전망) -- 구간 추출마다 `node tools/deck/gen-slaydeck.mjs` → `node tools/verify/diffcheck.mjs` → `SlayDeckController.codeblock` **IDENTICAL**(`ui`·`common` 무영향이나 함께 확인). 증분(구간 1~2개씩) + 커밋. -- 미러 테스트 `sim-balance`·`rogue-map` 무영향(회귀 확인차 실행). -- 전투규칙·맵생성 Lua 미변경 → 미러 동기화 불필요. - -### 미러/하네스 -- RULES §1의 gen-slaydeck 단일소스에 `cb/`·`lib/codeblock.mjs` 추가 반영. - -## 범위 밖 -- prop 모듈화(추후). -- Phase 2(메이커 UIGroup 파일럿). -- 게임 동작·데이터 변경. - -## 리스크 -- 메서드가 writeCodeblocks **지역변수/다른 메서드 정의를 JS레벨로 참조**하면(드묾 — 대부분 Lua 문자열 내 `self:Method()` 런타임 호출이라 JS-무관) 추출 시 undefined → diffcheck/throw로 즉시 노출 → 그 구간만 인자/상수 조정. -- 모듈 import는 ui-helpers처럼 export 이름 자동 파생로 누락 방지. 단방향 의존 orchestrator→cb→lib(순환 없음). - -## 변경 파일 요약 -| 파일 | 변경 | -|---|---| -| `tools/deck/lib/codeblock.mjs` | **신설** — prop/method/codeblock 헬퍼 + 공유 상수 | -| `tools/deck/cb/*.mjs` (~12-14) | **신설** — 메서드 연속구간 모듈 | -| `tools/deck/gen-slaydeck.mjs` | writeCodeblocks를 import+concat로 축소(메서드 본문 → 모듈) | -| `RULES.md` | §1에 cb/·lib/codeblock 반영 | -| `SlayDeckController.codeblock`·`ui`·`common` | **무변경**(바이트 동일이 합격 기준) | diff --git a/docs/superpowers/specs/2026-06-16-generator-modularization-design.md b/docs/superpowers/specs/2026-06-16-generator-modularization-design.md deleted file mode 100644 index fa1bf3f..0000000 --- a/docs/superpowers/specs/2026-06-16-generator-modularization-design.md +++ /dev/null @@ -1,80 +0,0 @@ -# 생성기 모듈화 (Phase 1) + 하이브리드 UI 로드맵 설계 - -작성일: 2026-06-16 -브랜치: `feature/gen-modularization` - -## 배경 / 동기 - -`DefaultGroup.ui`에 모든 UI(캐릭터 선택·상점·전체 덱·전투…)가 들어 있어, 사용자가 (a) **생성기 코드 유지보수**와 (b) **메이커에서 기능별 시각 편집**을 원함. - -핵심 제약(브레인스토밍에서 확정): MSW에서 "Layer"는 렌더 z순서일 뿐 논리 분리 도구가 아니고, 실제 UI 그룹 단위는 **UIGroup**(`.ui` 파일 = UIGroup, 현재 Default/Popup/Toast 3개). 그리고 **같은 UI를 '생성'과 '메이커 수동 편집' 둘 다로 둘 수 없음**(재생성이 수동 편집을 덮어씀). - -→ **소유 모델 = 하이브리드·단계적**(사용자 승인): 정적 레이아웃은 메이커 저작(시각 편집), 동적 내용은 컨트롤러가 런타임 주입. 단, 한 번에 안 하고 단계적으로. - -## 로드맵 (단계적 — 각 Phase가 자체 spec→plan) - -- **Phase 1 (이 문서)**: `gen-slaydeck.mjs`(~6,200줄)의 **UI emit을 기능별 모듈로 분리**. 출력 `.ui`/`codeblock` **바이트 동일**(순수 리팩터·무위험). (a) 충족 + (b) 토대(화면별 파일). -- **Phase 2 (후속 spec)**: 화면 1개(**캐릭터 선택**) 파일럿 — 정적 레이아웃을 메이커 저작 UIGroup으로 이관, 생성기는 그 화면 emit 중단, `SlayDeckController`가 경로로 내용(이미지·텍스트) 주입. (b) 패턴 검증. -- **Phase 3 (후속 spec)**: 검증되면 상점·전체덱 등으로 확장. - -## 현재 구조 (조사 결과) - -- **공유 인프라**(~48–530): `luaSoulShopTable`/`luaFramesTable`/`luaNodeIconsTable`/`luaRelicsTable`/`luaPotionsTable`/`luaIntentsArray`/`luaEnemiesTable`/`luaStr`/`luaJobsTable`/`luaCardsTable`/`luaDeckTable`/`frameRuid`/`cardFaceLayout`/`guid`/`transform`/`sprite`/`button`/`text`/`scrollLayoutGroup`/`entity`/`uiPath`/`sectionRoot`/`isGeneratedUiEntity`/`appendUiSection`. 데이터 로드 상수(CARDS/CHARS/ENEMIES/RELICS/POTIONS/CARDFRAMES/NODEICONS/CAM) 및 색·치수 상수(GOLD/WHITE/TRANSPARENT/ALIGN_*/CARD_W/CARD_H 등). -- **`guid(prefix, n)`은 순수 함수**(`:284`, 내부 카운터 없음; ns는 prefix→바이트 매핑). **모듈 호출 순서와 무관하게 동일 guid** → 분리해도 바이트 동일. -- **`upsertUi()`**(`:529`)가 UI 오케스트레이터: 기존 `DefaultGroup.ui` 로드 → 생성 섹션 필터(stock 보존) → 로컬 `emit(section, entities)` 클로저로 누적 → CardHand 스톡카드 in-place upsert(`:565–691`, 특수) → HUD별 `const x=[]; const add=…; add(entity(...)); …; emit('X', x)` → (말미) 병합·기록. -- **HUD emit 16종(순서·라인)**: DeckHud(`:808`) · DeckInspectHud(`:942`) · DeckAllHud(`:1097`) · CombatHud(`:1587`) · RewardHud(`:1681`) · MapHud(`:1839`) · ShopHud(`:2038`) · RestHud(`:2095`) · TreasureHud(`:2181`) · JobChoiceHud(`:2229`) · JobSelectHud(`:2314`) · MainMenu(`:2616`) · CharacterSelectHud(`:2617`) · LobbyHud(`:2672`) · BoardHud(`:2727`) · SoulShopHud(`:2814`). **각 섹션은 서로의 지역변수 비참조**(헬퍼·데이터 상수만 사용). -- **codeblock 메서드**(`prop`/`method`/`codeblock`/`writeCodeblocks` `:2836–6124`, ~3,200줄) + **patchCommon**(`:6125`). **Phase 1 범위 제외.** - -## Phase 1 상세 설계 - -### 목표 파일 구조 -``` -tools/deck/ - gen-slaydeck.mjs # 오케스트레이터(축소): import lib+hud → 데이터 로드 → upsertUi(HUD 모듈 순차) → writeCodeblocks → patchCommon - lib/ - ui-helpers.mjs # guid, transform, sprite, button, text, entity, scrollLayoutGroup, - # 상수(GOLD/WHITE/TRANSPARENT/ALIGN_*/CARD_W/CARD_H/UI_ROOT 등), cardFaceLayout, - # uiPath/sectionRoot/isGeneratedUiEntity/appendUiSection - data.mjs # CARDS/CHARS/ENEMIES/RELICS/POTIONS/CARDFRAMES/NODEICONS/CAM 로드·검증 - # + luaXxxTable·frameRuid - hud/ - deckhud.mjs deckinspect.mjs deckall.mjs combat.mjs reward.mjs map.mjs - shop.mjs rest.mjs treasure.mjs jobchoice.mjs jobselect.mjs mainmenu.mjs - charselect.mjs lobby.mjs board.mjs soulshop.mjs -``` - -### 모듈 계약 -- 각 `hud/.mjs`: `export function build()` → 자기 HUD 엔티티 배열 반환. 필요한 헬퍼·상수·데이터는 `lib/`에서 **import**(거대 deps 객체 전달 금지). -- `upsertUi()`(오케스트레이터에 잔류)는 기존 **순서 그대로** `emit('DeckHud', buildDeckHud())` … `emit('SoulShopHud', buildSoulShop())` 호출. `emit`·섹션 병합 로직 불변. -- **CardHand 스톡카드 in-place upsert**(`:565–691`)는 기존 `.ui` 엔티티를 변형하는 특수 로직 → 오케스트레이터(또는 `hud/cardhand.mjs`)에 그대로 유지. import 경계만 정리. - -### 바이트 동일 불변식 (가장 중요) -- 리팩터는 **출력 변경 0**이 목표. 보장 근거: guid 순수·emit 순서·entity 구성 모두 보존, 로직 이동만. -- **합격 기준**: 리팩터 후 `node tools/deck/gen-slaydeck.mjs` → `git diff` 결과가 **`ui/DefaultGroup.ui`·`SlayDeckController.codeblock`에 0 변경**(`Global/common.gamelogic`은 LF churn만 허용 → `git checkout`). - -### 증분 실행 전략 -- 한 번에 16개 다 옮기지 말고 **HUD 1~2개씩 추출 → 재생성 → `git diff` 빈 결과 확인 → 커밋** 반복. 첫 추출(예: SoulShopHud 같은 말단 + lib 골격) 성공 후 패턴 반복. -- lib 추출(헬퍼·상수·데이터) 먼저 → 그 다음 HUD 모듈을 하나씩 lib import로 전환. - -### 미러/테스트·하네스 -- 전투규칙·맵생성 Lua **무변경** → `sim-balance`/`rogue-map` 미러 동기화 불필요(회귀 확인차 `node --test` 실행). -- **RULES 동기화**: 생성기가 다중 파일이 되므로 RULES §1 "단일 소스"/보조 생성기 표를 `tools/deck/`(gen-slaydeck + lib/ + hud/)로 갱신. - -## 범위 밖 (명시) -- codeblock 메서드(`method()` ~3,200줄) 분리 — 더 크고 (b)와 무관·리스크↑. 원하면 별도 **Phase 1b** spec. -- 게임 동작·데이터·런타임 로직 변경. (순수 소스 리팩터) -- UIGroup 분할·메이커 저작 이관 — Phase 2 이후. - -## 리스크 -- 클로저 참조(헬퍼/상수)를 import로 전환하는 광범위·기계적 수정 — 누락 시 런타임 throw 또는 출력 diff로 **즉시** 노출(바이트 검증이 안전망). -- 상수 정의 위치 산재(CARD_W·GOLD·UI_ROOT 등 top-level) — lib로 이동 시 누락 주의. 추출 전 `grep`으로 전체 상수 인벤토리 작성. -- ESM 순환 import 주의(lib는 hud를 import하지 않음 — 단방향: orchestrator→hud→lib). - -## 변경 파일 요약 -| 파일 | 변경 | -|---|---| -| `tools/deck/lib/ui-helpers.mjs`, `lib/data.mjs` | **신설** — 공유 헬퍼·상수·데이터 | -| `tools/deck/hud/*.mjs` (16) | **신설** — HUD별 build 함수 | -| `tools/deck/gen-slaydeck.mjs` | 오케스트레이터로 축소(데이터/UI emit 본문 → 모듈로 이동, import·호출만) | -| `RULES.md` | §1 보조 생성기/단일소스 표에 lib/·hud/ 반영 | -| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | **무변경**(바이트 동일이 합격 기준) |