375 Commits

Author SHA1 Message Date
acf295d56c 도적 카드 공용 효과 추가 2026-06-19 02:57:11 +09:00
b2bf1bf4dd 카드 설명 키워드 하이라이트 추가 2026-06-19 01:51:36 +09:00
71435a2c91 밴딧 카드 공용 효과 확장 2026-06-19 01:26:15 +09:00
f64e35668d Merge origin/main into main 2026-06-19 00:58:31 +09:00
ba1651e52c 밴딧 공용 효과와 문서 정리 2026-06-19 00:56:08 +09:00
f8414a9c33 Merge pull request 'docs(readme): 현황 동기화 — 적 18종·카드 121장·로비 직행·캐릭터 선택 UI 반영' (#80) from docs/sync-readme-memory into main 2026-06-18 10:04:39 +09:00
6344685052 docs(readme): 현황 동기화 — 적 18종·카드 121장·로비 직행·캐릭터 선택 UI 반영
지금까지 main 머지분(#76~#79)을 README에 반영하고 stale 수치/문구 정리:
- 적 12종 → 18종, 카드 122장 → 121장(도적 86), Silent 88 → 86
- 구현 PR 범위 #34~#57 → #34~#79
- 게임 시작 시 MainMenu 없이 로비 직행(MainMenu 추후 재지정) 명시
- 캐릭터 선택: 초상화·직업 설명·선택 테두리 강조 UI 반영
- 향후 계획: 로비 직행·캐릭터 선택 UI·디버그 치트·map01 로스터 [x],
  도적 카드 아이콘 완료 반영(TODO는 카드명 재서사·한글화로 정정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:04:05 +09:00
b0f1a0840c Merge pull request 'feat(charselect): 캐릭터 선택 UI 배선 — 초상화·흰 테두리·직업 정보·Art 클리핑' (#79) from feature/charselect-wire into main 2026-06-18 09:57:03 +09:00
c703bd9b4d feat(charselect): 캐릭터 선택 UI 동작 배선 (초상화·흰 테두리·직업 정보·Art 클리핑)
사용자가 메이커에서 재구성한 CharacterSelectHud(SelectedClass/Eng/Status·
SelectedCharacterArt 신설, ThiefButton→BanditButton 개명)에 컨트롤러 연결.

RenderCharacterSelect:
- 버튼 클릭 시 해당 버튼 하위 Art의 ImageRUID를 SelectedCharacterArt로 복사(큰 그림)
- 선택 버튼 테두리 흰색(1,1,1,1)·비선택 디밍
- SelectedClass(한글)·SelectedClassEng(Warrior/Thief/Magician)·SelectedClassStatus
  ("직업군 · 모험가" + 설명) 갱신. 개행은 string.char(10) 연결(Lua 문자열 raw 개행 문법오류 회피)
- 각 Button에 MaskComponent(기본 Shape Rect) 런타임 부착 → Art를 Button 영역으로
  클리핑(Art 크기 불변, 넘치는 부분 숨김)
경로 교정: BindMenuButtons ThiefButton→BanditButton, StartNewGame Status→SelectedClassStatus.

UI(메이커 저작): CharacterSelectHud 재구성 + MageButton/BanditButton Art 위치 미세조정,
char-select 배경 에셋 01_blue_background_clean(sprite ac448e…) 추가.

산출물 재생성: SlayDeckController.codeblock + common.gamelogic.
검증: cbgap GAP 0, JS 미러 41/41, 인게임(초상화·테두리·텍스트·Art 클리핑) 확인.
(map01·타 UIGroup의 메이커 재직렬화 churn은 revert.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:56:19 +09:00
96102dc41f Merge pull request 'feat(boot): 시작 시 MainMenu 대신 로비로 바로 진입 (MainMenu 일시 비활성)' (#78) from feature/boot-to-lobby into main 2026-06-18 02:14:55 +09:00
8702d5209e feat(boot): 시작 시 MainMenu 대신 로비로 바로 진입 (MainMenu 일시 비활성)
OnBeginPlay에서 UI 로드 후 self:ShowMainMenu() 대신 self:ShowLobby() 호출.
게임 시작하면 MainMenu를 거치지 않고 곧장 로비 맵(LobbyUIGroup)으로 진입한다.
ShowState("lobby")가 MainMenu를 숨기므로 메뉴는 표시되지 않음.

MainMenu는 한동안 비활성이지만 ShowMainMenu 메서드·BindMenuButtons·
DefaultGroup/MainMenu UI는 그대로 유지 — 추후 싱글/멀티/게임 종료 선택
메뉴가 필요할 때 OnBeginPlay를 self:ShowMainMenu()로 되돌리면 복구된다.

산출물 재생성: SlayDeckController.codeblock. 검증: cbgap GAP 0, JS 41/41.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 02:08:08 +09:00
74d4824a1c Merge pull request 'fix(debug): Ctrl+Shift+E 치트 체력 회복 추가 (체력+에너지 전체 회복)' (#77) from fix/debug-cheat-hp-restore into main 2026-06-18 01:58:51 +09:00
bdea6a8c28 fix(debug): Ctrl+Shift+E 치트에 체력 회복 추가 (체력+에너지 전체 회복)
원인: Ctrl+Shift+E의 CheatFillEnergy는 #75에서 energy-only로 만들어져
self.Energy만 채우고 self.PlayerHp는 전혀 건드리지 않았다(설계상 체력 미회복).
바인딩·발동은 정상(Ctrl+Shift+C 카드 picker와 동일 입력 메커니즘)이라
에너지는 차지만 체력은 회복된 적이 없었음 → "체력 회복 안됨".

수정: CheatFillEnergy에 self.PlayerHp = self.PlayerMaxHp 추가 + RenderCombat()
호출(HP 표시 갱신). 누르던 Ctrl+Shift+E 그대로 체력+에너지 전체 회복으로 확장.
토스트 "치트: 체력·에너지 회복", README 디버그 단축키 표기도 갱신.

산출물 재생성: SlayDeckController.codeblock. 검증: cbgap GAP 0, JS 41/41.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 01:56:30 +09:00
4fa0bc85c0 Merge pull request 'fix: map01 신규 몬스터 6종 등록 — 랜덤 행동 복구' (#76) from fix/map01-monster-roster into main 2026-06-18 01:44:10 +09:00
db76870d4e fix(deck): 카드 picker 표시·클릭 복구 + UIGroup 렌더 순서/덱 레이아웃 조정
덱창(카드 picker)의 카드가 표시·클릭되지 않던 두 버그를 근본 수정.

표시 버그: 덱뷰 render(RenderAllDeck/RenderClassDeckTabs/RenderDeckInspect)가
비-DefaultGroup(DeckUIGroup) 카드를 직접 e.Enable로 켰는데, 깊게 중첩된
ExecSpace 6 호출이라 .Enable 토글이 스코프 상실로 무효(문서화된 afac34d 버그).
Maker 저작 DeckAllHud 카드는 기본 enable=false라 안 보였음. → SetEntityEnabled
(ExecSpace 2 ClientOnly, 인라인 실행) 경유로 변경(전투 HUD와 동일 패턴).

클릭 버그: Maker 저작 DeckAllHud 카드 120장의 SpriteGUIRendererComponent.
RaycastTarget=false라 클릭 레이를 못 받아 런타임 부착 ButtonComponent에 클릭이
도달 못함(같은 패널 탭/Close는 raycast=true라 정상이던 게 결정적 단서). →
BindButtons 카드 루프에서 RaycastTarget=true 런타임 주입.

README: 디버그 단축키 섹션 추가(Ctrl+Shift+C 카드 picker / Ctrl+Shift+E 에너지).

Maker UI 저작(메이커 편집분 동반): 6개 UIGroup GroupOrder 재배치
(DeckUIGroup 최상단 4→6 등) + DeckUIGroup 카드 그리드 위치 조정(180장).

산출물 재생성: SlayDeckController.codeblock.
검증: cbgap GAP 0, JS 미러 41/41, 인게임 클릭 동작 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 01:33:26 +09:00
491833b025 Merge remote-tracking branch 'origin/main' into feature/maker-ui-edit 2026-06-17 23:26:20 +09:00
83de73c2c1 fix(combat): map01 신규 몬스터 6종 enemies.json 등록 — 랜덤 행동 복구
메이커 저작(964cf7c)에서 map01에 수작업 배치한 몬스터
octopus·kapa_drake·junior_neki·junior_bugi(combat) / dile·mano(elite)의
EnemyId가 enemies.json에 미등록 상태였다. 전투 시 BuildMonsters가
self.Enemies[id]=nil → fallback {maxHp=10, intents={{Attack,5}}}(단일 intent)
으로 떨어져, EnemyActStep의 math.random(1,#intents)가 random(1,1)이 되어
항상 "공격 5"만 반복 → 행동 랜덤화 불가·이름 raw id·HP 10 고정.

수정: 6종을 기존 티어 밸런스(combat HP15~24/elite HP65~80, 다중 intent)에
맞춰 enemies.json에 등록하고 SlayDeckController.codeblock 재생성(산출물).
맵은 이미 해당 id를 참조하므로 맵 재생성 불필요(수작업 배치 유지).

검증: 전 맵 EnemyId 매핑 OK 33/MISSING 0, JS 미러 테스트 41/41.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:14:14 +09:00
b06ad8e8ee Merge PR #75: 도적 버림/보존 카드 흐름 구현 2026-06-17 23:07:49 +09:00
578f4416b2 Merge PR #75: 도적 버림/보존 카드 흐름 구현 2026-06-17 23:01:20 +09:00
bc80b96875 Add energy refill debug shortcut 2026-06-17 22:50:53 +09:00
f2828deb19 Implement thief discard and retain flows 2026-06-17 22:48:55 +09:00
b549abc3b3 Merge pull request 'feat: UI 메이커-저작 전환 + 컨트롤러 재연결 + MainMenu 부트' (#74) from feature/maker-ui-edit into main
Reviewed-on: #74
2026-06-17 22:39:37 +09:00
5b21e7f436 Merge origin/main (#73 도적 카드 아이콘 + in-combat card picker) into feature/maker-ui-edit
#73이 우리 분기(#72) 이후 main에 머지돼 충돌. 해결:
- 소스(boot·deckturn·deckview·gen-slaydeck·data/cards·legacy/hud/deckall): 자동머지로 통합
  (우리 부트폴링/버튼수정/슬롯추종 + #73 thief 아이콘/card-picker 공존).
- 산출물 ui/DefaultGroup.ui: 우리것(메이커 저작 6 UIGroup) 유지(#73의 옛 단일그룹 생성본 폐기).
- 산출물 SlayDeckController.codeblock: 머지된 소스로 재생성(양쪽 기능 모두 반영).
- card-picker reconcile: #73 새 코드의 옛 경로(/ui/DefaultGroup/DeckAllHud)를
  reconnect-ui-paths로 DeckUIGroup으로 remap + 120카드 ButtonComponent 런타임 부착 wrap.
- 검증: cbgap GAP 0, OpenDebugCardPicker/OnAllDeckCardButton 보존, .ui churn 0, JS 41/41.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:33:37 +09:00
b42d5fcf51 docs: 직업 컨셉 섹션 + deck-concept.md (덱 설계 문서)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:28:44 +09:00
43530bee60 chore(deck): 산출물 재생성 — 슬롯 지속 추종 반영 2026-06-17 22:14:42 +09:00
9b8884da81 fix(deck): 몬스터 상태 슬롯이 몬스터를 지속 추종 (카메라 정착 전 배치 버그)
MonsterStatus 슬롯이 너무 오른쪽으로 어긋나던 문제. 원인: StartCombat이
KickCombatCamera(0.2s 지연 재confine)+플레이어 텔레포트 정착 전에
BuildMonsters→PositionMonsterSlot을 실행 → 전이중 카메라로 world→screen 변환이
잘못돼 슬롯이 화면 밖 우측에 배치, 카메라 정착 후 재배치 안 됨(고정).
메이커 MCP 플레이테스트로 확정(StartCombat 시점 ui x=978 / 정착 후 정답 442).

수정: StartCombat 끝에 전투 중 슬롯 지속 추종 타이머(0.15s) 추가 —
살아있는 몬스터마다 PositionMonsterSlot 재호출, CombatOver/몬스터0이면 자동 종료.
카메라 정착 타이밍과 무관하게 슬롯이 항상 몬스터 머리 위 추종.
검증: 정착 후 slotUiX == monScreenX-960 (몬스터 위 정확), 스크린샷 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:14:41 +09:00
2b3d77c588 chore(deck): 산출물 재생성 — 버튼 ButtonComponent 부착 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:37:25 +09:00
3efb6993c7 fix(deck): 메이커 저작 버튼에 ButtonComponent 런타임 부착 (클릭 복구)
사용자 메이커 저작 버튼들이 ButtonComponent 없는 단순 스프라이트라 BindXxx가
ButtonComponent ~= nil 조건에서 스킵 → 어떤 버튼도 클릭 안 됨(시작 화면 포함).
바인드 조건 41곳의 `X.ButtonComponent ~= nil`을
`(X.ButtonComponent ~= nil or X:AddComponent("ButtonComponent") ~= nil)`로 바꿔
없으면 런타임 부착 후 통과(있으면 short-circuit). Entity:AddComponent(ControlOnly) 실측 확인.
.ui 무수정(연결만). 메뉴·로비·charselect·전투·상점·덱·맵 버튼 전부 일괄 적용.

검증(플레이테스트): 부트 후 NewGame/Start/Warrior 핸들러 바인딩 완료·버튼 ButtonComponent
부착 확인. 메뉴 상태서 타 UIGroup 활성 자식 0(레이캐스트 블로커 없음). 실제 클릭은 사용자 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:37:25 +09:00
5f8475d018 chore(deck): 산출물 재생성 — UIGroup 표시 복구 반영 컨트롤러
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:15:44 +09:00
afac34d7b5 fix(deck): UIGroup 분리 후 화면 표시 복구 (그룹 활성화 + .Enable 토글 ClientOnly + 부트 폴링)
메이커가 UI를 6개 UIGroup으로 분리하면서 발생한 2개 버그(시작이 MainMenu가
아니라 로비·NPC 상호작용 무반응) 근본 수정. 메이커 MCP 플레이테스트로 확정:
- 원인1: 새 UIGroup(Select/Lobby/Run/Deck)이 DefaultShow=false라 시작 시 비활성.
  → ActivateUIGroups(ClientOnly)로 그룹 :SetEnable(true) 활성화.
- 원인2: 컨트롤러의 중첩 self:SetEntityEnabled(.Enable 토글)가 비-DefaultGroup
  스코프를 잃음(ExecSpace 6 RPC 재디스패치). → SetEntityEnabled를 ClientOnly(2)로
  바꿔 인라인 실행 → 모든 UIGroup 해석. (.Text/RectSize/ImageRUID 등 다른 속성은
  중첩에서도 정상이라 SetText/SetHpBar는 무변경.)
- 원인3: OnBeginPlay가 UI 로드 전 실행 → DeckUIGroup 로드까지 폴링 후
  ActivateUIGroups + ShowMainMenu.

검증(플레이테스트): 부트→MainMenu·시작→로비+LobbyUIGroup·run NPC→charselect 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:15:44 +09:00
a917a6d82b docs: UI 메이커-저작 전환·UIGroup 분리·부트 흐름 반영 (RULES/README)
- RULES §1: ui/*.ui = 메이커 저작(생성기 미생성), 생성기는 컨트롤러+common만,
  hud/*·gen-cardhand → legacy 휴면, 섹션→UIGroup 매핑·재연결 검증(cbgap)·부트 흐름
- README: UI 7 UIGroup 구조·생성기 범위·아키텍처 메모(2026-06-17)·향후 완료 항목

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:00:09 +09:00
9fba9b5aaa chore(deck): 산출물 재생성 — 재연결·부트 반영 컨트롤러/common
산출물 재생성: SlayDeckController.codeblock + common.gamelogic.
- 컨트롤러 UI 경로가 새 UIGroup(Select/Lobby/Run/Deck)으로 재연결됨
- 부트 흐름(MainMenu→로비) 반영
- 검증: .ui 무변경, DefaultGroup 이동섹션 0, JS 미러 테스트 50/50 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:54:55 +09:00
54d9632534 feat(deck): 부트 흐름 변경 — 시작→MainMenu→로비→(run NPC)→charselect
- OnBeginPlay: ShowLobby → ShowMainMenu (최초 화면을 메인메뉴로)
- MainMenu NewGameButton: ShowCharacterSelect → ShowLobby (시작→로비맵+LobbyUIGroup)
- 로비 run NPC(OnLobbyNpcInteract id=="run")→ShowCharacterSelect는 기존 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:53:19 +09:00
c274322887 feat(deck): 제거된 TargetFrame 엔티티 참조 삭제 (TargetMarker 유지)
메이커 새 구조에서 MonsterStatus 슬롯의 TargetFrame이 제거됨 →
combat.mjs·render.mjs의 SetEntityEnabled(.../TargetFrame) 2줄 삭제.
TargetMarker·TargetMarker/Label·RenderTargetFrames(메서드)는 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:51:43 +09:00
5900af087e feat(deck): 컨트롤러 UI 경로를 새 UIGroup으로 재연결
cb/*.mjs의 /ui/DefaultGroup/<Section> 리터럴을 메이커 재편 UIGroup으로 일괄 remap:
- SelectUIGroup(charselect/job), LobbyUIGroup(lobby/board/soulshop),
  RunUIGroup(combat/map/shop/rest/treasure/reward/cardhand/deck),
  DeckUIGroup(덱 도감). MainMenu·월드조작은 DefaultGroup 잔류.
- 몬스터 슬롯 CombatHud/MonsterSlot → RunUIGroup/CombatHud/MonsterStatus
- 검증: cbgap GAP 0 (참조 경로 전부 새 .ui에 실재), 이동섹션 DefaultGroup 잔여 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:50:02 +09:00
b0d3da2f39 refactor(deck): 오케스트레이터를 컨트롤러+common 전용으로 슬림화
- upsertUi(UI 저작) 함수·hud import 15종 제거 → legacy로 이전(Task 2·3)
- data/codeblock/ui-helpers import를 writeCodeblocks·patchCommon에 필요한
  최소(POTIONS / prop·codeblock·RUN_LENGTH / COMMON_FILE)로 슬림화
- 결과: 생성기가 .ui에 일절 접근 안 함(메이커 저작 UI 보존)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:46:59 +09:00
6ef0ba48f6 refactor(deck): upsertUi(UI 저작)를 legacy/upsert-ui.mjs로 분리(휴면)
- gen-slaydeck의 upsertUi 함수를 legacy/upsert-ui.mjs로 추출(롤백/참조용,
  직접 실행 시에만 동작 — import 무해)
- legacy/hud/* 이동으로 깨진 상대경로 ../lib/ → ../../lib/ 교정(15종)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:43:33 +09:00
1b1fce3d6e refactor(deck): UI 저작 모듈(hud/*, gen-cardhand) legacy로 이동
UI를 메이커 저작으로 전환 — 생성기는 더 이상 .ui를 만들지 않는다.
hud/* 15종 + gen-cardhand.mjs를 tools/deck/legacy/로 이동(휴면).
(이 커밋 시점 gen-slaydeck import는 깨짐 — Task 3·4에서 정리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:39:28 +09:00
c34a1126fb chore(verify): UIGroup 매핑·재연결 GAP 검증 헬퍼 추가
- uimap.mjs: .ui별 섹션/엔티티 카운트 매핑 (deny 우회, 카운트만)
- cbgap.mjs: cb 참조 경로↔새 UIGroup 대조, GAP 분류

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:36:27 +09:00
964cf7cc3d feat(ui): UI를 6개 UIGroup으로 분리 + 신규 에셋 (메이커 저작)
메이커에서 단일 DefaultGroup UI를 6개 UIGroup으로 재편:
- DefaultGroup(MainMenu+월드조작), SelectUIGroup(charselect/job),
  LobbyUIGroup(lobby/board/soulshop), RunUIGroup(combat/map/shop 등),
  DeckUIGroup(덱 도감) + PopupGroup/ToastGroup(기존)
- 신규 에셋: UIButton.model, 배경 스프라이트 4종, MapleTree.codeblock 등
- 몬스터 전투 슬롯 MonsterSlot{1..5} → MonsterStatus{1..4}, TargetFrame 제거

컨트롤러 재연결은 후속 커밋. (.gitignore: docs/superpowers·Mislocated 무시)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 02:36:15 +09:00
098571e9aa Merge pull request '도적 카드 전체에 공식 스킬 아이콘 적용' (#73) from codex/thief-card-icons into main 2026-06-16 23:20:40 +09:00
ea832ad846 feat(debug): add in-combat card picker 2026-06-16 23:18:16 +09:00
4288c4101b feat(cards): add thief card icons 2026-06-16 23:05:40 +09:00
2bb7360a47 Merge pull request 'feat(charselect): 메이커 저작 stock 이관 + 컨트롤러 이미지 주입 (Phase 2 파일럿)' (#72) from feature/charselect-maker-pilot into main
Reviewed-on: #72
2026-06-16 08:35:06 +09:00
a5388da2cc docs(harness): RULES §1에 charselect 메이커 stock(Phase 2) + hud 15종 반영 2026-06-16 08:22:03 +09:00
8ca48eca60 feat(charselect): charselect 생성 중단 → 메이커 저작 stock화
GENERATED_UI_SECTIONS·UI_APPEND_ORDER에서 CharacterSelectHud 제거 + upsertUi emit·
hud/charselect.mjs 제거. 기존 charselect 엔티티는 stock으로 보존(메이커 편집 가능,
재생성에 안 덮임). ui 엔티티 경로집합 1442개 동일(재배치만, 손실 0). 컨트롤러는
경로+ClassPortraits 주입으로 구동 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:21:06 +09:00
eeca77df35 feat(charselect): 캐릭터 이미지 컨트롤러 런타임 주입 (ClassPortraits)
luaCharsTable() 신설(characters.json→self.ClassPortraits), boot/run 시드 +
prop, RenderCharacterSelect가 각 {key}Button/Art ImageRUID를 경로로 주입.
(메이커 저작 레이아웃이어도 컨트롤러가 이미지 채움 = 패턴 b 내용주입.)
산출물: SlayDeckController.codeblock 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:19:13 +09:00
40e351333e docs(plan): Phase 2 charselect 메이커 저작 파일럿 구현 계획 2026-06-16 08:15:24 +09:00
0a83dea2d8 docs(spec): Phase 2 캐릭터 선택 메이커 저작 파일럿 설계
charselect를 생성중단→stock화(메이커 편집), 이미지는 컨트롤러 런타임 주입
(ClassPortraits/luaCharsTable), 경로 구동 유지. 패턴 b 검증 파일럿.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:12:58 +09:00
fbf4d8d02d docs(harness): RULES §1에 cb/*.mjs(메서드)·lib/codeblock.mjs 반영 2026-06-16 08:03:14 +09:00
a141939675 refactor(cb): codeblock 메서드 161개를 cb/*.mjs 17 모듈로 분리 (codeblock 바이트 동일)
writeCodeblocks의 메서드를 연속-런 17 모듈(boot/state/soul/charselect/run/
deckturn/deckview/hand/combat/jobs/runend/render/reward/items/tooltip/map/shop)로
분리, methods 배열은 spread-concat(원본 순서 보존). prop 103개는 오케스트레이터 유지.
산출물 무변경(diffcheck: SlayDeckController.codeblock IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:02:22 +09:00
42eb33b579 refactor(cb): lib/codeblock.mjs로 헬퍼·상수 추출 (codeblock 바이트 동일) 2026-06-16 07:58:55 +09:00
9f7713267c docs(plan): Phase 1b codeblock 메서드 모듈화 구현 계획 (17 런 모듈, 바이트 동일) 2026-06-16 07:57:01 +09:00
bfa86f0f28 docs(spec): Phase 1b codeblock 메서드 모듈화 설계
writeCodeblocks의 메서드 161개를 연속구간별 cb/*.mjs 모듈로 분리(바이트 동일).
prop 103개는 오케스트레이터 유지. 헬퍼+공유상수는 lib/codeblock.mjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:53:22 +09:00
c5bb8c18a9 docs(harness): RULES §1에 gen-slaydeck 머지충돌 재모듈화 해결법 + diffcheck ref 추가 2026-06-16 07:44:39 +09:00
2f9c325c96 Merge pull request 'refactor(gen): 생성기 모듈화 Phase 1 (lib/+hud/, 출력 바이트 동일)' (#70) from feature/gen-modularization into main
Reviewed-on: #70
2026-06-16 07:40:22 +09:00
420cce561c Merge origin/main into feature/gen-modularization (생성기 모듈화)
main의 5개 PR(#62 exhaust+tooltip, #66 dex/thorns, #67 캐릭터 덱버튼 제거,
#68 스크롤바, #69 표창카드)을 모듈화 브랜치에 병합.

충돌은 tools/deck/gen-slaydeck.mjs 한 파일 — main이 그 단일체를 콘텐츠 변경
(구조/emit/top-level 상수는 불변)한 반면 본 브랜치는 모듈로 재구조화.
해결: main 버전(theirs)을 취해 **콘텐츠 마커 기반으로 재모듈화**(라인인덱스 X,
이름 자동 파생) → lib/data·lib/ui-helpers + hud/*.mjs 16종 재생성.

검증(손실 0): 재모듈화 생성기 출력이 origin/main 산출물과 **바이트 동일**
(diffcheck: ui/DefaultGroup.ui·SlayDeckController.codeblock IDENTICAL).
common.gamelogic은 origin/main 그대로 채택(유일 차이는 main의 stale `.0` 정수표기
— origin/main 원본 생성기도 정수를 만듦을 확인). 미러 테스트 sim-balance·rogue-map 통과.
RULES.md는 §1(모듈구조)+§4/§7(main) 자동 병합.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:38:54 +09:00
d265c8f918 chore(verify): diffcheck에 비교 ref 인자 추가(기본 HEAD) 2026-06-16 07:32:20 +09:00
255781d969 Merge pull request '표창 카드 손패 생성 구현' (#69) from codex/implement-shuriken-cards into main
Reviewed-on: #69
2026-06-16 07:26:11 +09:00
b904b29503 Merge pull request '덱보기 스크롤바 방향 수정' (#68) from codex/fix-lobby-deck-scrollbar into main
Reviewed-on: #68
2026-06-16 07:25:42 +09:00
0435a76fc1 Merge pull request '캐릭터 선택 덱보기 버튼 제거' (#67) from codex/remove-character-deck-buttons into main
Reviewed-on: #67
2026-06-16 07:25:03 +09:00
d82e98f832 docs(harness): RULES §1에 gen-slaydeck 모듈 구조(lib/·hud/)·diffcheck 게이트 반영 2026-06-16 02:42:20 +09:00
eafd6747a7 refactor(gen): MainMenu·CharacterSelectHud를 hud/*.mjs로 추출 (출력 바이트 동일)
emit이 묶여있던 menu/select 쌍을 buildMainMenu/buildCharSelect로 분리
(select[0].enable=false는 charselect에 포함). HUD 16종 모듈화 완료.
산출물 무변경(diffcheck IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:40:47 +09:00
bc266b1885 refactor(gen): HUD 14종을 hud/*.mjs로 추출 (출력 바이트 동일)
DeckHud/DeckInspect/DeckAll/Combat/Reward/Map/Shop/Rest/Treasure/JobChoice/
JobSelect/Lobby/Board/SoulShop를 각 build 함수로 분리, upsertUi는 emit 한 줄로.
전문 상수(PANEL_BG·TYPE_KO)는 해당 블록에 포함. 산출물 무변경(diffcheck IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:39:20 +09:00
e6a397cc55 refactor(gen): lib/ui-helpers.mjs로 UI 헬퍼·상수 추출 (출력 바이트 동일)
UI_FILE~appendUiSection(상수 30 + 헬퍼 15, 총 45)을 tools/deck/lib/ui-helpers.mjs로
이동, import로 연결. 산출물 무변경(diffcheck IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:31:59 +09:00
fcc103227c refactor(gen): lib/data.mjs로 데이터·lua 테이블 추출 (출력 바이트 동일)
gen-slaydeck.mjs의 데이터 로드·검증·luaXxxTable·게임상수(라인 3~188)를
tools/deck/lib/data.mjs로 이동, import로 연결. 산출물 무변경(diffcheck로 검증).
+ tools/verify/diffcheck.mjs: 워킹트리 vs HEAD 줄바꿈 정규화 비교(deny 회피) 게이트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:30:28 +09:00
44878bab9e docs(plan): 생성기 모듈화 Phase 1 구현 계획 (lib/+hud/, 바이트 동일 게이트) 2026-06-16 02:24:52 +09:00
064d81d424 docs(spec): 생성기 모듈화(Phase 1) + 하이브리드 UI 로드맵 설계
gen-slaydeck.mjs UI emit 16종을 lib/+hud/ 모듈로 분리(출력 바이트 동일·무위험).
codeblock 메서드 제외. 하이브리드 단계적: Phase2 캐릭터선택 메이커 저작 파일럿.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:20:53 +09:00
62187db5dd 표창 카드 손패 생성 구현 2026-06-16 02:19:22 +09:00
1a5953050f 덱보기 스크롤바 방향 수정 2026-06-16 02:10:34 +09:00
a902cb8bce 캐릭터 선택 덱보기 버튼 제거 2026-06-16 02:06:13 +09:00
b23dc3868e Merge pull request 'Implement dex and thorns effects' (#66) from codex/dex-thorns-combat-effects into main 2026-06-16 02:01:53 +09:00
98ca1668c8 Implement dex and thorns effects 2026-06-16 01:59:40 +09:00
654a49f3a2 Merge pull request 'Add exhaust pile and restore keyword tooltips' (#62) from codex/integer-ui-number-format into main 2026-06-16 01:33:09 +09:00
3e4619ed2f Merge origin/main into exhaust tooltip branch 2026-06-16 01:31:36 +09:00
aa872afa7b Merge pull request 'feat(charselect): 직업 선택 캐릭터 이미지 + 뒤로가기' (#65) from feature/charselect-images into main
Reviewed-on: #65
2026-06-16 01:19:09 +09:00
1eb6622cf5 chore(assets): 캐릭터 초상화 스프라이트 임포트
메이커 로컬 임포트 .sprite 디스크립터. 이번 직업 선택 화면은 기본 3종
(warrior/mage/bandit)을 사용. 나머지(hero·palladin·darkknight·archmage×2·
cleric·nightlord·shadower·bowmaster·hunter·pirate·singung)는 향후
2차 전직 선택 이미지용으로 임포트해 둠.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:16:18 +09:00
8309b25ec5 chore: 산출물 재생성 (charselect 캐릭터 이미지 + 뒤로가기)
node tools/deck/gen-slaydeck.mjs 산출물. 소스 변경(이전 커밋)의 결정적 재생성.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:16:07 +09:00
00903f2659 feat(charselect): 직업 카드 캐릭터 이미지 + 뒤로가기 (소스)
- data/characters.json 신설(전사/법사/도적 초상화 RUID 단일 소스), 생성기 로드·검증
- CharacterSelectHud: 단색 박스 → 카드 전체 캐릭터 이미지(Art 풀블리드 258×318)
  + 하단 이름 배너(NameBanner), Portrait/Desc 제거
- RenderCharacterSelect: 선택 시 카드 테두리 금색(Art 6px 인셋 뒤로)
- BackButton 추가 + BindMenuButtons 바인딩 → ShowLobby(로비 복귀), prop CharBackHandler

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:16:00 +09:00
f2c470f972 docs(plan): 직업 선택 캐릭터 이미지 + 뒤로가기 구현 계획 2026-06-16 01:08:00 +09:00
2e8a1ab869 docs(spec): 직업 선택 캐릭터 이미지 + 뒤로가기 설계
CharacterSelectHud 단색 박스 → 캐릭터 이미지 카드(이름 하단 배너·선택 금색
테두리), 뒤로가기→로비. data/characters.json 단일 소스(메이커 임포트 RUID).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:59:43 +09:00
4228f58b09 Merge pull request 'fix(monster): MonsterAttack.OnBeginPlay AnimationClip 타입가드 (LEA-3023/2007)' (#64) from fix/monsterattack-animationclip-guard into main
Reviewed-on: #64
2026-06-16 00:47:40 +09:00
5e0eca6cdf fix(monster): MonsterAttack.OnBeginPlay AnimationClip 타입가드 (LEA-3023/2007)
증상: 전투맵 진입 시 몬스터마다 [LEA-3023] TypeMismatch(AnimationClip) +
[LEA-2007] AttemptToIndex(clip nil) 서버 로그 스팸(몬스터 수만큼 반복).

원인: MonsterAttack.OnBeginPlay(chasemonster 모델 상속·메이커 저작·생성기 없음)가
정적 Sprite인 SpriteRUID를 _ResourceService:LoadAnimationClipAndWait에 넘김 →
AnimationClip이 아니라 nil 반환(LEA-3023) → clip.Frames[1] 인덱싱(LEA-2007).
이 멜리 공격 로직은 카드 기반 턴제 전투에서 호출하는 코드가 전혀 없는 죽은 코드라
크래시 외 게임 영향은 없으나 로그를 더럽힘.

수정: LoadAnimationClipAndWait 호출 전 GetTypeAndWait가 ResourceType.AnimationClip이
아니면 early-return + clip nil 가드. 정적 스프라이트 몬스터는 공격범위 설정을 건너뜀
(원래 미사용), 애니메이션 클립 몬스터는 기존대로 동작.

주의: MonsterAttack은 생성기 없는 메이커 저작 codeblock이라 디스크 직접 패치.
적용하려면 메이커에서 로컬 워크스페이스 reload 필요.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:37:36 +09:00
4da934585c Merge pull request 'docs(harness): RULES/CLAUDE/settings를 현재 저장소 상태와 동기화' (#63) from feature/harness-sync into main
Reviewed-on: #63
2026-06-16 00:13:23 +09:00
49069a16cf docs(harness): RULES/CLAUDE/settings를 현재 저장소 상태와 동기화
P14/P15/노드맵 작업으로 생긴 산출물·생성기가 하네스 문서에 미반영이던
드리프트를 정정 (개인 메모리에만 있던 내용을 공용 하네스로 승격).

- RULES §1 표: `map01~map11` → `map01~map05` + `lobby.map`(P14 5막화),
  크기 정정(ui ~7.1MB·controller ~270KB), 누락 산출물 추가
  (CombatMonster/PlayerLock/MapCamera/LobbyNpc/LobbyMobility codeblock,
  Global/SectorConfig.config)
- RULES §1: deny glob 범위 + 메이커 저작 codeblock/UI 금지 + 보조 생성기
  10종 인벤토리(생성기→산출물 매핑) 명시
- .claude/settings.json: deny를 glob화(`ui/*.ui`·`RootDesk/MyDesk/*.codeblock`)
  해 전 산출물(PopupGroup/ToastGroup.ui, codeblock 12종) 커버 + SectorConfig.config
- CLAUDE.md: 크기 정정(8.3MB→~7.1MB)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:59:28 +09:00
bda35eefc7 Add exhaust pile and restore keyword tooltips 2026-06-15 23:34:26 +09:00
44010e0fce fix(camera): 전투 진입 시 StS2 고정 카메라 재적용(KickCombatCamera)
회귀: 로비 follow(ConfineCameraArea=false)로 푼 공유 카메라가 전투맵에서 플레이어 중심으로 보임 — MapCamera의 1회성 true-set으론 재confine 안 됨. StartCombat에서 플레이어가 전투 위치(-6) 정착 후 false→true '킥'(0.2s)으로 재confine해야 StS2(플레이어 좌·몬스터 우) 복원(맵 로드 시점엔 텔레포트/낙하 중이라 바운드 오계산). data/camera.json 값 사용, 로비 follow 불변.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:26:55 +09:00
efa32d0a8f Merge pull request 'Add card keyword hover tooltips' (#60) from codex/card-keyword-tooltips into main
Reviewed-on: #60
2026-06-15 23:22:04 +09:00
7c776864e2 Add card keyword hover tooltips 2026-06-15 23:15:04 +09:00
72370aab23 Merge pull request 'Fix combat target cleanup and damage popups' (#59) from codex/fix-card-target-ui-dmgpop into main
Reviewed-on: #59
2026-06-15 23:05:46 +09:00
5377112826 Merge pull request 'feat: 노드 맵 UI 강화 — 아이콘 노드 + 다크 배경 (nodeicons.json 외부화)' (#58) from feature/node-map-ui into main
Reviewed-on: #58
2026-06-15 23:05:25 +09:00
8a5b0d4f8d Fix combat target cleanup and damage popups 2026-06-15 22:46:10 +09:00
6c35d959ac feat(node-map): 임시 scenic 배경(Critias 도시 스프라이트) + BgImage 어둡게 틴트
배경을 작동하는 map/back SPRITE RUID로 교체(메이플 BackgroundComponent 대신). BgImage 색을 0.5로 낮춰 노드 가독성 확보. 교체는 data/nodeicons.json background만 수정.
2026-06-15 15:59:43 +09:00
67d21a9619 feat(node-map): 우측 하단 노드 종류 범례 + 잠김 노드 밝기 상향(0.45→0.85)
범례: 6타입 아이콘+이름(전투/엘리트/보스/상점/휴식/보물) 패널(우하단). 잠김 노드 틴트를 밝게 해 타입 식별 가능.
2026-06-15 15:56:22 +09:00
b1d0af311a fix(node-map): 루트 불투명 다크 배경 + BgImage 자식(스프라이트 RUID만 렌더)
메이플 BackgroundComponent 리소스는 UI 스프라이트로 안 떠서 배경 이미지 미표시 → 루트를 불투명 다크로 깔아 월드 누출 방지, BgImage 자식에 background RUID(유효 스프라이트면 표시). 노드 아이콘·상태 틴트는 정상 검증.
2026-06-15 14:35:38 +09:00
5b41eb78a4 feat(node-map): MapHud 배경 이미지+오버레이, 노드 아이콘화(라벨 제거·확대), RenderMapNode ImageRUID+상태 틴트
타입별 공식 아이콘(주황버섯/골렘/발록/돈주머니/모닥불/상자) + 리스항구 배경. 절차 배치·간선·바인딩 불변.
2026-06-15 14:26:28 +09:00
3902c9b1ee feat(node-map): nodeicons.json 외부화 + 생성기 로드·검증·NodeIcons 직렬화 2026-06-15 14:22:32 +09:00
d1e51878c3 docs(node-map): 노드 맵 UI 강화 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:21:04 +09:00
cc945fce8b docs(node-map): 노드 맵 UI 강화 설계 spec (아이콘 노드+배경, nodeicons.json 외부화)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:01 +09:00
9966065409 docs(readme): P13~P15 + codex 반영 최신화 (로비 맵·NPC·영혼·도적·메소·카메라 추종)
P12 기준이던 README를 현재 구현 상태로 갱신:
- 디렉토리 구조: map01~05+lobby(6), data 6종(cardframes/camera 추가), tools 신규(gen-lobby-map/npc·verify), 신규 codeblock 5종
- 기능표: 로비 마을(NPC 4종·근접/클릭·로비 한정 이동/공격·카메라 추종), 도적 클래스, 카드 122장, 영혼 메타, 커스텀 프레임, 메소, 5막화, retain/sly discard/데미지 팝업(codex)
- 유용한 스크립트 호출·산출물 재생성 명령 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:17:36 +09:00
bc9bc78cef Merge pull request 'feat(lobby): 로비 카메라를 플레이어 추종(follow)으로' (#57) from feature/p15-lobby-camera-follow into main
Reviewed-on: #57
2026-06-15 08:32:02 +09:00
9cb5e1abff feat(lobby): 로비 카메라를 플레이어 추종(follow)으로 — 전투맵은 고정 유지
로비 루트에서 script.MapCamera 제거(고정 framing 억제 해제) + LobbyMobility가 진입 시
ConfineCameraArea=false·ScreenOffset(0.5,0.5)·Zoom 90으로 플레이어 추종 카메라 설정.
MSW 카메라는 기본 follow이고 ConfineCameraArea=true가 그걸 억제하므로 false가 핵심.
검증: 로비 우측 이동 시 플레이어 중앙 유지+배경 스크롤, 런 시작→map01 Confine=true 고정, 복귀→follow 복원(누설 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:05:22 +09:00
1fce0b284a Merge pull request 'feat(bandit): STS2 사일런트 카드풀 및 직업 탭 정리' (#56) from codex/class-tabbed-codex into main
Reviewed-on: #56
2026-06-15 07:27:24 +09:00
e269154d17 feat(combat): render monster damage popups with digit skins 2026-06-15 01:10:20 +09:00
b65d4af1eb fix(ui): show target marker only while dragging 2026-06-15 00:51:50 +09:00
d5318ac86b feat(ui): style monster damage numbers 2026-06-15 00:50:17 +09:00
bd91c67483 feat(ui): add prominent target marker 2026-06-15 00:47:24 +09:00
b43ee02014 feat(cards): implement retain keyword 2026-06-15 00:45:11 +09:00
6427d23f50 feat(cards): highlight drag target monster 2026-06-15 00:42:40 +09:00
b40c8d11d8 fix(combat): clear temporary curse cards after combat 2026-06-15 00:37:57 +09:00
f9e7bc3603 fix(cards): support large hand drag positions 2026-06-15 00:29:48 +09:00
256433d3f3 feat(bandit): add discard card selection 2026-06-15 00:14:08 +09:00
05a06644cf feat(bandit): implement sly discard trigger 2026-06-15 00:06:53 +09:00
709e6f8f99 fix(ui): 카드 텍스트 그림자 제거 2026-06-14 21:26:51 +09:00
a88c1d344c fix(ui): 카드 텍스트 가독성 개선 2026-06-14 21:23:52 +09:00
a24f3592c4 feat(bandit): STS2 사일런트 카드풀 반영 2026-06-14 21:14:13 +09:00
3db11f5d82 fix(ui): 전체덱 보기를 직업 탭으로 제한 2026-06-14 20:46:56 +09:00
6e1f1cf990 Merge pull request 'fix(bandit): 도적 덱을 사일런트 전용으로 정리' (#55) from codex/bandit-silent-only into main 2026-06-14 19:59:29 +09:00
304b2f3c2a fix(ui): 덱 미리보기에 직업 탭 추가 2026-06-14 19:38:43 +09:00
15bc17b351 feat(ui): 직업별 덱 미리보기 추가 2026-06-14 19:27:40 +09:00
6f436ef3eb fix(bandit): 도적 덱을 사일런트 전용으로 정리 2026-06-14 18:48:15 +09:00
cf193bf51a Merge pull request 'feat(bandit): 사일런트 도적 덱 추가' (#53) from codex/bandit-silent-deck into main 2026-06-14 17:53:02 +09:00
1e87be2cd6 merge main into bandit silent deck 2026-06-14 17:48:59 +09:00
6cc008e894 Merge pull request 'feat: P15 — 로비를 전용 맵 + 월드 NPC로 (근접·클릭 상호작용, 로비 한정 이동·공격)' (#54) from feature/p15-lobby-map-npc into main
Reviewed-on: #54
2026-06-14 13:04:17 +09:00
760b856576 fix(lobby): 로비 이동·점프를 기본값으로 — InputSpeed 5→1.4, JumpForce 5→1.23 (P15)
기존 5/5는 보행 ~9u/s·점프 상승 14u로 과함. freeze가 안 건드린 intact RigidbodyComponent.WalkSpeed(1.4)/WalkJump(1.23) 기본값에 맞춤. 실측: 보행 2.5u/s, 점프 상승 1.79u로 정상화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:02:30 +09:00
91bbe7d200 docs(p15): 계획에 메이커 정찰 실측 결과 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:51:34 +09:00
989e3fe000 fix(lobby): 플레이테스트 — 이동 복원에 InputSpeed 필수 + FixedLookAt int 타입 (P15)
메이커 실측으로 확인:
- 이동에는 RigidbodyComponent.WalkAcceleration과 MovementComponent.InputSpeed가 둘 다 양수여야 함(WalkAccel만으론 안 걸림). LobbyMobility에 InputSpeed=5·JumpForce=5 추가.
- pc.FixedLookAt은 boolean이 아니라 int32 → false→0 (빌드 에러 해소).
- PlayerLock에 InputSpeed/JumpForce=0 대칭 재잠금 추가(전투맵 누설 방어).
- NPC 베이스 모델 inheritance 경고는 비치명적이라 proven-good(모델 유지) 결정 주석화.

검증: 로비 이동·점프, NpcCodex 근접(d=1.10<1.2)·↑키→카드 도감, 런 시작→map01 텔레포트+이동 잠금(InputSpeed=0), 로비 복귀→이동 재해제 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:50:51 +09:00
0e064cc1e9 feat(lobby): 로비 맵 흐름 통합 — 텔레포트·NPC 디스패치·StartRun map01·LobbyHud 슬림화 (P15)
- OnBeginPlay: 공격 키(Ctrl, 로비 한정) 바인딩
- ShowLobby→GoLobbyMap: 월드 시작·런 종료 시 로비 맵 텔레포트
- OnLobbyNpcInteract(id): 월드 NPC→기존 기능 패널 디스패치
- StartRun: 1막 진입 시 map01 물리 텔레포트(BuildMonsters CurrentMapName 필터 대응)
- LobbyHud: 버튼-행 제거, 투명·비레이캐스트화(맵 노출·클릭 통과), 영혼/승천 미니 정보바만 유지
- BindLobbyButtons: NPC 바인딩 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:36:13 +09:00
de1e69de7d fix(lobby): 전투맵 PlayerLock에 WalkAcceleration=0 런타임 재잠금 (P15)
로비에서 푼 이동(WalkAcceleration)이 텔레포트 후 전투맵에 누설돼도 PlayerLock이 맵 로드 시 0으로 재설정해 확실히 잠금. 정찰로 WalkAcceleration이 실제 이동 레버임을 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:31:50 +09:00
a683f186d4 feat(lobby): LobbyNpc(근접·클릭 상호작용)·LobbyMobility(이동 해제) codeblock (P15)
LobbyNpc: 거리 폴링→머리위 마크 토글, TouchEvent/UpArrow→Interact→컨트롤러 OnLobbyNpcInteract. LobbyMobility: 진입 시 pc.Enable·WalkAcceleration=0.7 복원(정찰 확정). 공격 키는 컨트롤러에서 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:29:44 +09:00
a309da2a99 feat(lobby): 로비 전용 맵 + NPC 4종 월드 엔티티 생성기 (P15)
map01 클론→lobby.map(헤네시스 배경), 몬스터 제거, NPC 4종(공식 메이플 NPC 스프라이트)+머리위 마크 배치. 각 NPC에 TouchReceiveComponent+script.LobbyNpc, 루트에 script.LobbyMobility(PlayerLock 제거). SectorConfig map://lobby 등록.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:27:44 +09:00
82bf22d4cc docs(p15): 로비 맵 + 월드 NPC 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:15:44 +09:00
f36bc0d14e docs(p15): 로비 맵 + 월드 NPC 설계 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:55:54 +09:00
1f0a8099ee fix(ui): 카드 효과 드로우 애니메이션 적용
- DrawCards에 animate 인자를 추가해 새로 뽑힌 손패 슬롯만 덱 위치에서 손패 위치로 이동하도록 처리

- 카드 효과의 draw 호출은 animate=true로 실행해 즉시 생성되는 느낌을 제거

- 턴 시작 드로우와 유물 드로우는 기존 렌더 흐름을 유지

- SlayDeckController.codeblock 산출물을 생성기로 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
2026-06-14 02:59:34 +09:00
a5f6a4509d fix(ui): 카드 드래그 중 hover 보간 중단
- hover 확대 보간 타이머 ID를 추적해 새 hover 또는 드래그 시작 시 기존 타이머를 종료

- 손패 카드 드래그 시작 시 모든 손패 카드의 scale과 위치를 기본값으로 즉시 정리

- 드래그 중 손패 hover enter/exit 처리를 무시해 드래그 위치와 hover 보간이 충돌하지 않도록 수정

- 드래그 종료 시 드래그 카드 scale을 1.0으로 복귀

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
2026-06-14 02:55:38 +09:00
d3ae6c1c62 feat(ui): 카드 hover 보간 확대 적용
- hover된 손패/보상/상점 카드를 1.5배까지 SineEaseOut 보간으로 확대

- 같은 줄의 다른 카드는 hover 카드 기준 좌우로 110px 밀어 겹침을 줄임

- hover 해제 시 같은 보간으로 scale 1.0 및 기본 위치로 복귀

- SlayDeckController.codeblock 산출물을 생성기로 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
2026-06-14 02:50:47 +09:00
4d3f6fc0af feat(ui): 카드 hover 확대 추가
- 손패 카드에 마우스 진입/이탈 이벤트를 연결해 hover 시 1.12배로 확대

- 보상 카드와 상점 카드에도 UITouchReceiveComponent를 추가하고 같은 hover 확대 동작 적용

- ApplyCardFace에서 카드 렌더 시 UIScale을 기본값으로 리셋해 재사용 카드가 확대 상태로 남지 않도록 처리

- 생성기 변경 후 ui/DefaultGroup.ui와 SlayDeckController.codeblock 산출물 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs

- SetCardHover/UITouchEnterEvent/UITouchReceiveComponent 산출물 카운트 확인
2026-06-14 02:42:11 +09:00
a2b8d6bfb9 feat(bandit): 사일런트 도적 덱 추가
- 도적 시작 직업을 선택 화면에서 활성화하고 bandit 스타터 덱으로 런을 시작하도록 생성기를 연결

- Slay the Spire 사일런트 카드 75장을 bandit 카드 풀에 추가하고 카드명/설명을 한글화

- 현재 전투 엔진이 지원하는 피해, 방어도, 드로우, 독, 약화, 취약, 광역, 다단히트, 회복, 파워 효과로 카드 효과를 매핑

- 도적 스타터 덱을 타격 5장, 수비 5장, 무력화, 생존자로 구성

- bandit 및 도적 전직 계열(shiv, poisoner, trickster)을 카드 프레임 매핑에 연결

- ui/DefaultGroup.ui와 SlayDeckController.codeblock을 생성기로 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs

- 도적 카드 75장 및 한글화 잔여 영어/깨짐 없음 확인
2026-06-14 02:37:14 +09:00
8f233296af Merge pull request 'feat: P14 — 반복 런·로비·영혼·도적·몬스터 랜덤성 (요청 17항목)' (#52) from feature/p14-loop-lobby-soul into main
Reviewed-on: #52
2026-06-14 01:50:19 +09:00
5f89d61a8b fix(lobby): 로비 카드 도감용 Cards/Frames 테이블을 OnBeginPlay에 시드
- ShowCodex가 self.Cards를 순회하는데 런 시작 전(로비)엔 미설정이라 pairs(nil) 오류
  → OnBeginPlay에서 luaCardsTable/luaFramesTable을 미리 주입(StartRun과 중복 무해)
- 메이커 플레이테스트로 로비·도감·가로맵·전투(도적/메소아이콘/스킬아이콘) 전 루프 검증

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:28:56 +09:00
8879647b26 feat(soul): 영혼 메타 성장 시스템 (P14-9)
- 2차 전직 상태로 맵 보스 클리어 시 영혼 +1 적립(CheckCombatEnd 보스 분기 AwardSouls)
- UserDataStorage 영속 RPC: ReqLoadSouls/RecvSouls/SaveSouls(ExecSpace 5/6, 승천 패턴 복제)
  · key soulPoints(수치)·soulUnlocks(해금 set, 콤마 직렬화). OnBeginPlay에서 로드
- 로비 영혼 상점(SoulShopHud) 4종 해금: 두둑한 지갑(시작 메소+60)·단련된 육체(시작HP+15)
  ·덱 정제(기본카드 1장 제거)·유물 수집가(시작 유물+1). BuySoulUnlock 구매·영속 저장
- ApplySoulUnlocks가 StartRun에서 해금 효과 적용 → 덱빌딩 이점. 승천(패널티)과 분리 공존
- 산출물 재생성(id 유일성·JSON 검증 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:23:00 +09:00
7ee323ea8b feat(lobby): 로비 허브 + NPC 4종 + 반복 런 루프 (P14-8)
- LobbyHud: 메이플 로비 화면. NPC 4종 클릭 — 모험가(런 시작)·사서(카드 도감)·
  상인(영혼 상점)·안내원(게시판) + 승천 +/- + 영혼 수치 표시
- ShowState "lobby" 추가, OnBeginPlay·EndRun → ShowLobby (첫 실행/패배/클리어 모두
  로비 기점, 반복 런 루프). ShowLobby가 BindMenuButtons도 호출(전직 버튼 바인딩 보존)
- 카드 도감: DeckAllHud 재사용(CodexMode) — 저주 제외 전 카드 표시, 닫으면 로비 복귀
- 게시판(BoardHud): 게임 규칙/팁 패널. 영혼 상점(SoulShopHud): 셸(P9에서 해금 채움)
- guid 네임스페이스 lob/brd/soul 추가(id 충돌 방지). 산출물 재생성(id 유일성 검증 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:16:56 +09:00
7cc311ee91 feat(economy): 메소 표기 완성 + 메소 코인 아이콘 추가 (P14-7)
- relics.json goldIdol 설명 "골드"→"메소" (유일하게 남았던 표면 잔존 정정)
- CombatHud TopBar·ShopHud 메소 금액 옆에 금색 코인 아이콘 스프라이트 추가
  (공식 RUID 흰박스 리스크 회피 위해 색상 채움 스프라이트 사용)
- 내부 식별자 self.Gold 등은 산출물 id 안정성 위해 유지. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:06:59 +09:00
5cde11647f feat(card-ux): 손패 최대 10장·초과 자동버림 + 마우스오버 확대/툴팁 (P14-6)
- 손패 슬롯 5→10 확장(Card6~10 신규 생성), RenderHand 장수 비례 동적 간격 배치
- DrawCards 손패 10장 상한 — 초과 드로는 버린 더미로 자동 이동(StS식, sim 미러)
- 카드 마우스오버: UITouchEnter→UIScale 1.3배 확대 + ShowTooltip(이름/설명),
  Exit→복원+숨김. 드래그 중(DragSlot) 확대 억제. RenderHand가 UIScale 리셋
- HoverCard/UnhoverCard 메서드 신설. sim 35/35 통과. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:04:24 +09:00
8296775e21 feat(cards): 실제 메이플 스킬 아이콘 적용 + 피격 이펙트(fx) 분리 (P14-5)
- 카드 아트(image)를 경로 검증된 실제 스킬 아이콘 RUID로 교체(28종)
  · mapleImgFullPath의 /icon 경로 확인으로 스킬 아이콘 보장(기존엔 이펙트 프레임·맵
    오브젝트가 섞여 있었음)
- 피격 이펙트(fx) 필드 신설(18종) — 스킬 effect/hit 프레임 RUID
  · PlayCard가 PlayAttackFx/PlayAoeFx에 c.fx or c.image 전달(이펙트 분리, 없으면 폴백)
  · luaCardsTable fx 직렬화. MSW asset 메타데이터 경로 검증으로 수급(워크플로 6에이전트)
- 아이콘 미발견 카드는 기존 RUID 유지. sim 35/35 통과. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:57:24 +09:00
d546d62755 feat(thief): 도적 클래스 + 2차 전직(어쌔신/시프) 추가 (P14-4)
- CLASSES.thief(maxHp 75), JOBS.thief = [어쌔신(CriticalThrow), 시프(SavageBlow)]
- 카드 11종: 도적1차(럭키세븐/더블스탭/다크사이트/헤이스트/드레인)
  · 어쌔신(크리티컬스로우/쉐도우스타/클로마스터리)
  · 시프(새비지블로우/스틸/메소가드), starterDecks.thief 추가
- cardframes classToFrame: thief/assassin/bandit → bandit 프레임(기존 RUID 재사용)
- CharacterSelectHud Thief 해금, BindMenuButtons ThiefButton, RenderCharacterSelect·
  StartNewGame·StartRun·JobLabel 도적 분기. 전사/법사 2차는 기존 완비 확인
- 산출물 재생성(생성기 검증 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:38:52 +09:00
8f58a90746 feat(combat): 몬스터 랜덤 구성·랜덤 행동·덱 오염(AddCard)·map01 로스터 (P14-3)
- 구성 랜덤화: BuildMonsters를 그룹별 수집 후 노드 타입별 추첨
  (일반 1~3 / 엘리트 1+일반0~2 / 보스 1, MAX_MONSTERS=4 내)
- 행동 랜덤화: EnemyActStep 순차(round-robin) → 정의된 intent 중 math.random 선택
  (스폰 시 시작 intent도 랜덤, sim-balance.mjs 미러 동기화)
- StS2식 덱 오염: AddCard intent 신규 — 저주 카드(Wound/Burn)를 버린 더미에 추가
  · Wound=사용불가 사석, Burn=사용불가+턴종료 피해2(EndPlayerTurn 처리)
  · PlayCard unplayable 가드, CardPool class 필터로 보상/상점 자동 제외
  · luaIntentsArray(card/count)·luaCardsTable(unplayable/curse/endTurnDamage) 직렬화
- map01 인카운터: 일반 5종(주황/초록/빨강달팽이/파랑/돼지) + 엘리트1 + 보스1, 우측 포메이션
  · enemies.json red_snail/stump 신규, blue_mushroom/mushmom에 AddCard intent
  · gen-map-encounters 레이아웃 맵별 분기 + 풀 인덱싱 일반화
- 막 배율 0.6→0.45(5막 기준 완화). sim 테스트 35/35 통과(신규 3 포함). 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:33:49 +09:00
8fa1079548 feat(map-ui): 노드 맵 가로 진행 레이아웃(왼→오른쪽) (P14-2)
- nodeX=행(좌→우)·nodeY=열(분기)로 좌표축 스왑, 보스는 최우측 중앙
- 호출부(노드/도트/보스 간선) 인자 스왑. Lua 무수정(좌표는 JS 빌더 전담)
- 도트 보간·간선·라벨 좌표 함수 파생이라 자동 추종. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:14:24 +09:00
9e16465218 feat(map): 맵 5막화·노드 depth 7·rest/shop/elite 연속 금지 (P14-1)
- ACT_COUNT/RUN_LENGTH 3→5, ACT_MAPS map01~map05 (반복 런 기반 확장)
- MAP_ROWS 7→6 (걷는 행 6 + 보스 = depth 최대 7), 막 배율 0.6→0.45 완화
- 노드 타입 인접 금지를 elite 단독 → rest/shop/elite 3종으로 일반화
  (Lua GenerateMap + rogue-map.mjs JS 미러 동시 수정, 테스트 9/9 통과)
- 맵 파일 생성기 카운트 11→5, map06~map11 삭제, SectorConfig 정리(stale 제거)
- 산출물 재생성(ui/codeblock/map01~05). 검증 헬퍼 tools/verify/count.mjs 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:13:16 +09:00
f67471435e docs(p14): 반복 런·로비·영혼·도적·몬스터 랜덤성 설계 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:03:13 +09:00
fd57e0d56d Merge pull request 'fix(card-frames): 카드 프레임 슬롯 레이아웃 정밀 보정 (픽셀 실측)' (#51) from fix/p13-card-layout into main 2026-06-13 04:01:57 +09:00
afe995a895 fix(card-frames): 프레임 슬롯 픽셀 실측 기반 레이아웃 정밀 보정 (이름→배너 중심·코스트→육각 중심·이름 폭 축소로 육각 겹침 제거)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 04:01:08 +09:00
6a6b64cbc5 Merge pull request 'feat(card-frames): 커스텀 카드 프레임 — 직업×등급 프레임·보상 가중 추첨 (P13)' (#50) from feature/p13-card-frames into main 2026-06-13 00:11:01 +09:00
b2693be111 fix(card-frames): 카드 단위 엔티티 id v2 네임스페이스 발급 — 에디터에서 소실된 자식 엔티티 신규 생성 유도
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:01:25 +09:00
675616bf51 fix(card-frames): 엔티티 id↔path 매핑 보존 (구 stride 유지·중복 검증) — 메이커 refresh in-place 병합 꼬임 수정
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:56:40 +09:00
1e48fa35b3 feat(card-frames): 산출물 재생성 (프레임 렌더링·등급·보상 가중)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:41:40 +09:00
aaa68ebe07 feat(card-frames): 보상 등급 가중 추첨 70/25/5 (Lua + JS 미러 rarityForRoll·경계 테스트)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:40:58 +09:00
35dfcbaffe feat(card-frames): 생성기 — 프레임 렌더링·cardFaceLayout 통합 (5개 카드 사이트·단색판 제거)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:39:27 +09:00
9aa4721790 feat(card-frames): 카드 등급 배정(normal10·unique17·legend5)·프레임 RUID 매핑 데이터
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:31:33 +09:00
e553ebe666 feat(card-frames): 카드 프레임 스프라이트 9종 로컬 임포트 (warior·mage·bandit × normal·unique·legend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:30:53 +09:00
a814bf2c4b docs(card-frames): P13 설계·계획 — 커스텀 카드 프레임(직업×등급)·보상 가중
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:30:05 +09:00
9e162d6e2d Merge pull request 'docs(readme): P1~P12 구현 현황·향후 개선 계획 갱신' (#48) from docs/readme-p12 into main 2026-06-12 19:31:22 +09:00
8baa97bde8 docs(readme): P1~P12 구현 현황·향후 개선 계획 갱신
- 구현 기능 표 전면 갱신 (클래스/전직·버프/디버프·절차 맵·유물/물약·승천·모션)
- 디렉토리 구조 갱신 (potions.json·rogue-map·gitea-pr·RULES.md, map.json 제거 반영)
- 스크립트 호출 예시·향후 개선 계획 10항목·RULES.md 셋업 안내

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:30:46 +09:00
66c1ac8ee1 Merge pull request 'feat(motion): 전투 모션 — 공격·피격·독뎀 (배포 퀄리티 P12)' (#47) from feature/p12-combat-motion into main 2026-06-12 18:42:41 +09:00
abd6d00052 feat(motion): 전투 모션 — 공격/피격/독뎀 (생성기+산출물)
- PlayerAttackMotion(StateComponent ATTACK→IDLE)·PlayerHitMotion(HIT+넉백 틱)
- MonsterLunge(공격 시 런지)·MonsterHitMotion(hit 클립 스왑→stand 복귀, 폴백 흔들림)
- BuildMonsters에 hit/stand 클립 pcall 캐시·motionBusy
- 훅: PlayCard·DealDamageToTarget·PlayAoeFx·독 틱·체인메일 반사·EnemyActStep

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:37:42 +09:00
2cd672b474 docs(motion): P12 설계·계획 — 전투 모션 (공격/피격/독뎀)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:32:39 +09:00
56d958fe19 Merge pull request 'feat(ascension): 승천 시스템 A1~A10 + UserDataStorage 개인 저장 (배포 퀄리티 P11)' (#46) from feature/p11-ascension into main 2026-06-12 14:25:19 +09:00
7aed1943b7 fix(ascension): 메뉴 Asc 엔티티 guid 충돌 해소 (190→195~197)
CharacterSelectHud/OpaqueBackdrop이 menu:190 선점 — AscMinus 미표시 원인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:24:36 +09:00
9989a61675 fix(ascension): RPC ExecSpace 실측 보정 — Server=5·Client=6 (프로브 검증)
- 1은 ServerOnly(클라 호출 무시)라 저장/로드 RPC 미동작 → 5로 수정
- RecvAscension 6(Client): 서버→특정 클라 userId 라우팅 실측 확인
- 설계 문서 ExecSpace 표 갱신, 프로브 메서드 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:20:05 +09:00
2c28935d95 fix(ascension): UserId 접근을 PlayerComponent.UserId로 (빌드 경고 2건 해소)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:11:48 +09:00
b635cb3a63 feat(ascension): 승천 A1~A10·UserDataStorage 개인 저장·서버 RPC (생성기+산출물)
- ReqLoadAscension[Server]/RecvAscension[Client·특정 유저 응답]/SaveAscension[Server]
- ExecSpace 일괄 6 → 명시값 보존 (첫 서버-클라 RPC)
- 모디파이어: 적 HP/피해 ×1.1~1.2·정예 배율 +0.2/0.4·시작 HP -10/-20·메소 ×0.75/0.5
- 메인 메뉴 승천 [-]/라벨/[+], 클리어 시 해금+1·저장·'승천 N 해금!' 표시
- TopBar '· 승천N', 테스트 40건 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:30 +09:00
bcdf9457c8 docs(ascension): P11 설계·계획 — 승천 A1~A10·UserDataStorage 개인 저장·서버 RPC
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:04:06 +09:00
89056e903d Merge pull request 'feat(magician): 법사 클래스 — 1차 5종·2차 3계열·신규 메커니즘 4종 (배포 퀄리티 P10)' (#45) from feature/p10-magician into main 2026-06-12 14:00:42 +09:00
fc0a96fcb7 fix(magician): Jobs prop 선언 누락 (빌드 경고 6건 해소)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:57:41 +09:00
7b6e181cb0 feat(magician): 시뮬 메커니즘 동기화 + 산출물 재생성
- poison 틱(행동 시작·사망 시 행동 생략·전멸 승리 체크)·aoe(개별 취약/방어)·heal 클램프·draw
- 테스트 4건 추가 — 전체 40건 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:56:52 +09:00
e1d298f972 feat(magician): 캐릭터 선택 오픈·전직 화면 동적화 (생성기)
- Mage 버튼 활성·SelectClass(magician)·2클래스 하이라이트/가드
- JobSelectHud Job_slot1..3 범용화 + ShowJobSelect(클래스별 JOBS 채움)
- SetJob/JobLabel을 Jobs 테이블 기반으로 (luaJobsTable 주입, luaStr 개행 이스케이프)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:54:14 +09:00
811c8ec2ac feat(magician): 독 DoT·AoE·회복·드로 메커니즘 (생성기+시뮬 loadData)
- PlayCard: aoe→PlayAoeFx(전 생존 적 각자 취약/방어·슬롯별 팝업), heal/draw/poison
- EnemyActStep 행동 시작 독 틱(피해 팝업·사망 시 행동 생략·체인 계속)
- BuffsLabel 독N 표시, 클래스별 StartRun(HP 80/70·시작 덱), JOBS 상수·검증
- sim loadData: starterDecks.warrior 사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:51:15 +09:00
6b6037739f feat(magician): 법사 카드 14종·클래스별 시작 덱 데이터
- 1차 5종(에너지 볼트·매직 가드·매직 클로·텔레포트·슬로우)
- 2차: 위자드 불독(파이어 애로우·포이즌 브레스·엘레멘트 앰플)
  위자드 썬콜(썬더 볼트 AoE·콜드 빔·칠링 스텝) 클레릭(힐·블레스·홀리 애로우)
- starterDeck → starterDecks{warrior, magician}, 신규 필드 draw/heal/poison/aoe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:48:01 +09:00
80c5daabbf docs(magician): P10 설계·계획 — 법사 14종·신규 메커니즘 4종·전직 동적화
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:45:54 +09:00
6e8d1a88f5 Merge pull request 'feat(job): 전직 시스템 코어 + 전사 2차 (배포 퀄리티 P9)' (#44) from feature/p9-job-advancement into main 2026-06-12 13:42:36 +09:00
13d1ccb771 feat(job): 산출물 재생성 (전직·전사 2차)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:10 +09:00
d0b8fbe091 feat(job): 시뮬 신규 메커니즘 동기화 (hits·pierce·selfVuln·energy/blockPerTurn)
- 다단히트: 타격마다 힘 적용 합산·취약 1회 (Lua 동기화)
- pierce 방어 무시, selfVuln, 파워 루프 확장 (블록 리셋 후)
- 신규 테스트 6건 — 전체 36건 통과 (sim 27 + rogue-map 9)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:08 +09:00
2c9a1b351e feat(job): 클래스 풀 필터·전직 선택 흐름·전직 HUD (생성기)
- CardPool(클래스 필터) — 보상·상점 공용
- 보스 분기: 1차+비최종막 → JobChoiceHud(유물/전직), ContinueAfterBoss 추출
- JobSelectHud 3직업 패널, SetJob(대표 카드 지급·직업명 갱신)
- guid 'job'=0xe4, HideGameHud·BindButtons 등록

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:36:46 +09:00
74e3a70a19 feat(job): 다단히트·방어무시·자가취약·파워 2종 (생성기)
- PlayCard Attack: hits 합산(힘·펜닙 타격마다 적용), pierce 전달, selfVuln
- DealDamageToTarget/PlayAttackFx pierce 시그니처 (물약 화염병 false)
- StartPlayerTurn 파워 루프: energyPerTurn·blockPerTurn (블록 리셋 후)
- 카드 class 직렬화(누락 시 fail-fast)·PlayerJob prop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:34:11 +09:00
15b342972d feat(job): 전사 2차 카드 9종·클래스 필드 데이터
- 기존 9종 class=warrior, 신규: 파이터(콤보 어택·버서크·라이징 어택)
  페이지(썬더/블리자드 차지·파워 가드) 스피어맨(피어스·아이언 월·하이퍼 바디)
- 신규 필드: hits(다단)·pierce(방어무시)·selfVuln·energyPerTurn/blockPerTurn
- 이미지 RUID 메이커 선별

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:31:48 +09:00
1925144f85 docs(job): P9 설계·계획 — 전직 코어·전사 2차 9종·신규 메커니즘 4종
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:29:06 +09:00
2a0ec0ef21 Merge pull request 'chore(harness): 토큰 가드 하네스 — 산출물 접근 차단·RULES.md 공유 규칙' (#43) from chore/harness-rules into main 2026-06-12 11:01:43 +09:00
65ad2fe854 chore(harness): 토큰 가드 하네스 — 산출물 접근 차단·RULES.md·CLAUDE.md
- .claude/settings.json: ui/DefaultGroup.ui(8.3MB)·map/*.map·SlayDeckController.codeblock
  Read/Edit/Write 도구 차단 (생성 산출물 — 단일 소스는 data/*.json + tools/)
- RULES.md: 협업 공용 하네스 규칙 (카운트 검증·탐색 경로 제한·gitea-pr 절차·이중 구현 동기화)
- CLAUDE.md: RULES.md 임포트로 Claude Code 자동 적용
- .gitignore: .claude/settings.json만 커밋 예외 (local 설정은 계속 제외)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:01:13 +09:00
d7e4e88182 Merge pull request 'chore(git): Gitea PR 헬퍼 — UTF-8 안전 생성/수정/머지 도구' (#42) from chore/gitea-pr-tool into main 2026-06-12 10:48:28 +09:00
52c03b208e chore(git): Gitea PR 헬퍼 — UTF-8 안전 생성/수정/머지
Windows 셸 인라인 curl -d 본문이 CP949로 전송되어 PR #34~41 한글이
깨진 사고 재발 방지. 제목/본문은 UTF-8 spec JSON 파일로만 받고
Node fetch가 전송. git credential(GCM)에서 토큰 자동 취득·401 재시도.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:47:52 +09:00
76aaafcf1b Merge pull request 'feat(rogue-map): �α׶���ũ ���� ���� �ʡ��� �ý��ۡ����� �� (���� ����Ƽ P8)' (#41) from feature/p8-rogue-map into main 2026-06-12 10:25:39 +09:00
c69a17abe0 feat(rogue-map): 유물 방 상자 연출·TreasureHud·메소 표기 (생성기+산출물)
- TreasureHud: 보물 상자(클릭→흔들림 0.08s×6→열림 RUID 교체→유물+메소 보상)
- 상자 닫힘/열림 RUID 메이커 선별, PickNewRelic 재사용 (소진 시 메소 대체)
- ShowState/HideGameHud/LeaveNode treasure 등록, 표시 화폐 '골드'→'메소' 전환
- 테스트 30건(rogue-map 9 + sim 21) 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:22:29 +09:00
1abd9f7987 feat(rogue-map): 맵 그리드·점선 도트 UI + RenderMap 상태 4단 (생성기)
- Node_r{1..7}c{1..4}+boss 정적 그리드, Dot 간선 192개 (기본 비활성)
- RenderMapNode: 타입색 6종·현재(골드)/방문(어둡게)/도달가능/잠김
- RenderMapDots: 간선 존재 토글·현재 노드 발신 간선 골드 강조

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:18:36 +09:00
1624ef6f3b feat(rogue-map): GenerateMap 런타임 절차 생성 + 층 시스템 (생성기)
- 정적 map.json·luaMapNodesTable·luaStartArray 제거
- GenerateMap: 경로 4개 걷기·행별 가중 타입·elite 연속 금지 (JS 미러 동기화)
- Depth/VisitedNodes prop, PickNode treasure 분기·층 갱신, 보스 클리어 시 새 맵
- TopBar '막 F/3 · D층', 메소 표기 시작

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:16:55 +09:00
443aaf83d2 feat(rogue-map): 절차 생성 알고리즘 JS 미러 + 테스트 9건
- StS식 경로 걷기 4개 (시작열 셔플 앞2 상이 보장)
- 행별 가중 타입 배정 + elite 부모 연속 금지
- 연결성·타입 규칙·간선 제약·결정성 테스트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:04:42 +09:00
67e8b4c848 docs(rogue-map): P8 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:03:34 +09:00
52d808eacd docs(rogue-map): P8 설계 — 절차 생성 맵·층 시스템·유물 방·점선 맵 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:01:29 +09:00
b5a648fc23 Merge pull request 'feat(potions-relics): ���� �ý��ۡ����� 19����������/���� UI (���� ����Ƽ P7)' (#40) from feature/p7-potions-relics into main 2026-06-12 07:37:20 +09:00
62b2193f2e feat(potions-relics): 유물 아이콘 행·물약 슬롯·툴팁·물약 메뉴 UI (생성기+산출물)
- TopBar: RelicSlot×10(+오버플로)·PotionSlot×5 (UITouchReceive hover 툴팁)
- TooltipBox(이름+설명)·PotionMenu(사용/버리기/닫기) 팝업
- ShopHud Potion 판매 항목, AllDeckButton 우측 이동

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:33:15 +09:00
2a42c4a372 feat(potions-relics): 물약 사용·드랍·상점 로직 (생성기)
- AddPotion(슬롯 검사)·MaybeDropPotion(승리 40% 드랍)·RenderPotions(아이콘/빈칸/잠금)
- OpenPotionMenu/ClosePotionMenu/UsePotion(전투 중 한정)/TossPotion
- 상점 ShopPotion 판매 20골드 (BuyPotion, 슬롯 검사 선행)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:30:00 +09:00
42f9143f73 feat(potions-relics): 유물 15종 효과 훅 (생성기)
- HasRelic·PickNewRelic(미보유 추첨, 소진 시 골드+25)
- ApplyRelics 확장: strength/draw/heal/healOnWin/healIfLow + combatEnd 훅
- AddRelic passive: 물약 슬롯 5칸(장인의 벨트)·최대 HP+7(건강의 반지)
- CalcPlayerAttack: 아카베코(첫 공격+8)·펜닙(10번째 2배)·부츠(5 미만→5)
- DealDamageToPlayer: 브론즈 체인메일 반사·점토 갑옷·백년의 부적
- 챔피언 벨트(취약 부여 시 약화+1), 정예·보스 유물 보상 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:29:01 +09:00
239dd6caf3 feat(potions-relics): 물약 6종·유물 15종 신규 데이터 (아이콘 RUID 메이커 선별)
- 물약: 빨간 포션·화염병·전사의 물약·수호의 물약·마나 엘릭서·저주의 병
- 유물 19종 전체 icon RUID 부여, relicPool 18종으로 확장
- 장인의 벨트(물약 슬롯 5칸) 포함 — StS 효과 × 메이플 장비 외형

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:26:55 +09:00
e4f7ff10d7 docs(potions-relics): P7 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:20:53 +09:00
6b8db0b871 docs(potions-relics): P7 설계 — 물약 시스템·유물 19종·아이콘/툴팁 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:41:02 +09:00
4c3be95c86 Merge pull request 'feat(buffs-power): ����/��������Power ī�塤�� ��� UI (���� ����Ƽ P6)' (#39) from feature/p6-buffs-power into main 2026-06-12 01:37:17 +09:00
88a9136398 feat(buffs-power): 산출물 재생성 (버프/디버프·Power·적 방어도 UI)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:33:20 +09:00
0a96a6955a feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화
- calcAttack(힘·약화·취약) 공식 export + 단위 테스트
- 적 Debuff 인텐트·플레이어/적 디버프 감소 타이밍 Lua 동기화
- Power 등록·매턴 발동·소멸 재현, chooseAction 파워 우선
- 신규 테스트 6건 (총 21건 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:32:31 +09:00
00844857ee feat(buffs-power): 적 방어도 배지·버프 라인·디버프 인텐트 UI (생성기)
- MonsterSlot에 BlockBadge(방어도)·Buffs(힘/약화/취약) 추가
- PlayerPanel Buffs 라인 (버프 + 활성 파워 표시)
- 인텐트: Debuff 보라색·공격 최종 예상치(힘·약화·취약 반영) 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:29:04 +09:00
6df3fcf01c feat(buffs-power): 버프/디버프·Power 전투 규칙 (생성기)
- 약화(-25%)·취약(+50%)·힘(+N) 피해 공식 양방향 적용 (CalcPlayerAttack·EnemyActStep)
- Power 카드: 사용 시 소멸, PlayerPowers 등록, 매턴 strengthPerTurn 발동
- 적 Debuff 인텐트 처리·디버프 턴 감소(플레이어 턴 종료·적 행동 후)
- 인텐트 effect 직렬화·막 배율은 Debuff 값 제외

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:28:03 +09:00
2bf5716fce feat(buffs-power): 신규 카드 4종·적 디버프 인텐트 데이터
- 차지 블로우(피해8·취약2)·위협(약화2)·인레이지(힘+2)·분노(Power, 매턴 힘+1)
- 카드 이미지 RUID 메이커 선별 (공식 리소스)
- 정예 슬라임·머쉬맘·변형된 달팽이(약화), 슬라임 킹·킹 슬라임(취약) 디버프 인텐트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:25:02 +09:00
12f3928ab4 docs(buffs-power): P6 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:16:05 +09:00
eb06663758 docs(buffs-power): P6 설계 — 버프/디버프·Power 카드·적 방어도 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:12:57 +09:00
02aa73dda3 Merge pull request 'feat(system-gaps): 경제·복합 카드·적 패턴·런 종료 복귀 (배포 퀄리티 P5)' (#38) from feature/p5-system-gaps into main 2026-06-11 09:08:55 +09:00
134353d374 feat(system-gaps): 산출물 재생성 (경제·복합카드·EndRun)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:05:17 +09:00
013f946c6b feat(system-gaps): 신규 카드 이미지 RUID (워 리프·브랜디시 — 메이커 선별)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:05:00 +09:00
8ff50b428d feat(system-gaps): 복합 카드(피해+방어)·런 종료 후 메뉴 복귀(EndRun)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:01:42 +09:00
1de8fac893 feat(system-gaps): 경제 상향(승리25·엘리트+15)·적 패턴 보강·신규 카드 2종 데이터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:57:33 +09:00
248677759c docs(system-gaps): P5 설계·계획 (경제·복합카드·적패턴·런종료 복귀)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:55:44 +09:00
2af67b3b2b Merge pull request 'feat(act-maps): 막별 맵 전환 + 맵별 인카운터 (배포 퀄리티 P4)' (#37) from feature/p4-act-maps into main 2026-06-11 08:53:20 +09:00
7890f4c029 feat(act-maps): 산출물 재생성 (맵 필터·막 텔레포트)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:50:17 +09:00
32a0b2437b feat(act-maps): 막별 맵 텔레포트 + 등록 맵 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:48:03 +09:00
dd2a814eeb feat(act-maps): map02~11 인카운터 자동 구성 (combat3/elite2/boss1·맵별 테마)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:45:32 +09:00
4a1c66c5fe docs(act-maps): P4 막별 맵 전환+맵별 인카운터 설계·계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:42:59 +09:00
869e4d88df Merge pull request 'feat(combat-feel): 전투 연출 — 드래그 타겟·공격 이펙트·적 개별 차례 (배포 퀄리티 P3)' (#36) from feature/p3-combat-feel into main 2026-06-11 08:39:29 +09:00
e876a8ce3a feat(combat-feel): 산출물 재생성 (드래그·이펙트·개별 차례·팝업)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:34:41 +09:00
f88e6ffeeb feat(combat-feel): 적 개별 차례 시퀀스 (ActFrame·EnemyActStep)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:31:37 +09:00
5921dfbeb8 feat(combat-feel): 데미지 팝업·사망 지연
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:28:02 +09:00
4b7ad753a4 feat(combat-feel): 공격 이펙트 후 지연 데미지 (SkillFx·FxBusy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:25:05 +09:00
c80020a464 feat(combat-feel): 카드 드래그 타겟팅 (UITouchReceive·ResolveCardDrop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 03:20:33 +09:00
858f9727dd docs(combat-feel): P3 전투 연출 설계+계획 (드래그 타겟·공격 이펙트·개별 차례·팝업)
probe 완료: ScreenTouchEvent/ScreenToUIPosition 실측, UITouchReceiveComponent 드래그 이벤트 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 03:18:10 +09:00
1c2062b8cc Merge pull request 'feat(card-visuals): 메이플 스킬 카드 비주얼 (배포 퀄리티 P2)' (#35) from feature/p2-card-visuals into main 2026-06-11 03:12:30 +09:00
d4ec8757ce feat(card-visuals): 산출물 재생성 2026-06-11 03:07:48 +09:00
76cb2cd65e feat(card-visuals): 보상·상점·그리드 카드 프레임 적용
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:59:38 +09:00
d2aba643fa feat(card-visuals): 손패 카드 프레임(Art·NamePlate·CostPlate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:55:54 +09:00
d783cccea1 feat(card-visuals): ApplyCardFace 렌더 일원화 + Cards image 직렬화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:53:07 +09:00
c4ad8fc068 feat(card-visuals): 카드를 전사 스킬로 리네임 + 공식 스킬 이미지 RUID
파워 스트라이크(a71b…)/아이언 바디(1ae9…)/슬래시 블러스트(d5bc…) — 메이커 순회 주입으로 선별(26후보 중).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:50:26 +09:00
4ea9bfe14b docs(card-visuals): P2 구현 계획 (6개 태스크)
RUID 수확(컨트롤러)→ApplyCardFace 일원화→손패 프레임→보상/상점/그리드 프레임→재생성→플레이테스트·PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:41:19 +09:00
18729a2047 docs(card-visuals): 메이플 스킬 카드 비주얼 설계 (P2)
공식 RUID 렌더 타당성 실측 완료. 카드→전사 스킬 매핑(파워 스트라이크/슬래시 블러스트/아이언 바디),
RUID 수확 워크플로, 카드 프레임(Art/NamePlate/Cost), ApplyCardFace 렌더 일원화(5표면).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:35:51 +09:00
50462f3d8a Merge pull request 'feat(combat-ui): 전투 화면 UI/HUD 전면 정비 — STS2 배치 (배포 퀄리티 P1)' (#34) from feature/p1-combat-ui into main
Reviewed-on: #34
2026-06-11 02:29:51 +09:00
2f73910e47 fix(combat-ui): 보상 화면에서 손패/덱HUD 숨김 (플레이테스트 발견)
보상 오버레이 아래로 손패가 비쳐 시각 충돌 → OfferReward 진입 시 CardHand/DeckHud off
(PickReward→ShowMap 경로가 상태 복원).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:27:13 +09:00
659ae2a60f feat(combat-ui): UI 정비 산출물 재생성 2026-06-11 02:18:38 +09:00
b5facb7763 fix(combat-ui): AABB 겹침 해소(오브·턴종료 y160, 패널 y-494)·HP바 높이 일치·덱 팝업 숨김·cmbN 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:16:58 +09:00
1762112296 feat(combat-ui): ShowState 가시성 통일 + 전투 시작 시 Result 초기화 2026-06-11 02:05:53 +09:00
389e7f36cf feat(combat-ui): 타겟 프레임·몬스터 슬롯 가독성·의도 색상
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:02:20 +09:00
24e6aca305 feat(combat-ui): 플레이어 패널(HP바·방어 뱃지) + SetHpBar 폭 인자
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:58:01 +09:00
d1f894a802 feat(combat-ui): 상단 TopBar (막·골드·유물·모든덱보기 통합)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:53:36 +09:00
c5e28062eb feat(combat-ui): 에너지 오브(좌)·턴 종료 버튼(우) 재배치
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:50:00 +09:00
c7d795f839 docs(combat-ui): P1 구현 계획 (7개 태스크)
에너지 오브/턴종료 재배치 → TopBar → 플레이어 패널+SetHpBar(width) → 타겟 프레임·가독성 → ShowState → 재생성·겹침 정적검사 → 플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:48:11 +09:00
2265bd7fa1 docs(combat-ui): 전투 화면 UI/HUD 전면 정비 설계 (배포 퀄리티 로드맵 P1)
STS2 배치 재구성 — 에너지 오브(좌)/턴종료(우) 분리·상단 HUD 바·플레이어 패널·
타겟 프레임·몬스터 슬롯 가독성·ShowState 가시성 상태 통일. 로드맵 P1~P5 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:48:11 +09:00
fb5f0d6d5b Merge pull request 'fix(ui): keep deck popups above monster hp' (#33) from fix/deck-popup-layering into main
Reviewed-on: #33
2026-06-11 01:44:48 +09:00
569c1d5eb4 Merge pull request 'feat(map01): 노드 타입별 몬스터 그룹 콘텐츠 + HP바 몬스터 추종' (#32) from feature/map01-monster-content into main
Reviewed-on: #32
2026-06-11 01:44:24 +09:00
066ad6ddca fix(ui): keep deck popups above monster hp 2026-06-11 01:41:40 +09:00
1a15225ff6 Merge pull request '덱 팝업에서 몬스터 HP UI가 앞에 뜨지 않도록 수정' (#30) from fix/deck-popup-monster-layer into main
Reviewed-on: #30
2026-06-10 23:39:12 +09:00
33bc98c015 feat(map01): HP바를 각 몬스터 위에 표시(world→screen) + 엘리트/보스 우측 배치
- PositionMonsterSlot: 고정 좌표 대신 _UILogic:WorldToScreenPosition→ScreenToUIPosition 으로
  각 몬스터 월드 위치 위(HEAD_OFFSET_Y)에 슬롯을 띄움 → 몬스터를 옮겨도 자동 추종
- 미사용 고정좌표 시스템 제거: SlotPos/ActiveSlotPos prop·StartRun 주입·SLOTS 로드·
  luaSlotGroup·upsertUi 단언·data/monster-slots.json
- map01 엘리트(머쉬맘 x3·변형된 달팽이 x5)·보스(킹 슬라임 x4) 월드 위치를 우측으로 이동

메이커 검증: combat/elite/boss 각 노드에서 해당 그룹만 등장, HP바가 각 몬스터 머리 위에 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:26:56 +09:00
895e521724 feat(map01): 노드 타입별 몬스터 그룹 콘텐츠 — 일반/엘리트/보스 배치
- map01에 6마리 배치·태그: combat(주황버섯/돼지/초록버섯), elite(머쉬맘/변형된 달팽이), boss(킹 슬라임)
- enemies.json에 적 타입 추가: pig·green_mushroom(일반), mushmom·modified_snail(엘리트), king_slime(보스)
- 컨트롤러 재생성(self.Enemies 신규 타입 포함)

메이커 검증: combat 노드→일반3, elite 노드→엘리트2, boss 노드→보스1, HP가 enemies.json 값으로 해소됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:54:57 +09:00
cd7ae102ca Merge pull request 'feat(combat): 노드 타입별 몬스터 그룹 (일반/엘리트/보스)' (#31) from feature/node-type-monster-groups into main
Reviewed-on: #31
2026-06-10 22:35:23 +09:00
d0353fb81f feat(node-groups): 컨트롤러 재생성 (그룹 필터·그룹 슬롯)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:53:38 +09:00
6eea4f9e17 fix(node-groups): 슬롯 좌표 단언을 MAX_MONSTERS 기준으로 (fail-fast) 2026-06-10 21:52:10 +09:00
903a06d233 feat(node-groups): RegisterMonster(group) + BuildMonsters 노드 타입 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:47:25 +09:00
c614b10566 feat(node-groups): 그룹별 슬롯 좌표 플러밍 (SlotPos/ActiveSlotPos)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:44:59 +09:00
6feb252674 fix(node-groups): no-clobber 가드를 == null 로 (null 값도 보존 처리) 2026-06-10 21:43:14 +09:00
428bdc8a2e feat(node-groups): CombatMonster 에 Group + 생성기 값 보존(no-clobber) 2026-06-10 21:38:39 +09:00
271a7991d1 feat(node-groups): monster-slots.json 을 그룹별 좌표 구조로 2026-06-10 21:36:42 +09:00
59c699c04b docs(node-monster-groups): 구현 계획 (5개 태스크)
slots 그룹화 → CombatMonster Group+no-clobber → 슬롯 플러밍 → RegisterMonster(group)+BuildMonsters 필터 → 재생성·플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:32:46 +09:00
0d517617a3 docs(node-monster-groups): 노드 타입별 몬스터 그룹 설계
한 맵에 일반/엘리트/보스 그룹 배치 → 노드 타입으로 필터해 해당 그룹만 등장.
CombatMonster에 Group 태그(메이커 인스펙터 저작) + BuildMonsters 필터 + 그룹별 슬롯 좌표.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:29:04 +09:00
d247115ad7 Keep monster HP behind deck popups 2026-06-10 21:17:24 +09:00
26c084d951 Merge pull request '캐릭터 선택 시작 메뉴 추가' (#28) from feature/character-select-menu into main
Reviewed-on: #28
2026-06-10 21:03:44 +09:00
6349be63aa Merge pull request 'chore(model): Model_monster-43 을 Models/Monsters/ 로 재배치' (#29) from chore/relocate-monster-model into main
Reviewed-on: #29
2026-06-10 21:01:47 +09:00
7167ece6a7 Merge pull request 'fix(ui): 덱 팝업을 몬스터 HP UI 위에 렌더' (#27) from fix/deck-popup-sorting into main
Reviewed-on: #27
2026-06-10 21:00:38 +09:00
76e60d3350 Add character select start menu 2026-06-10 20:57:43 +09:00
e241382d09 chore(model): Model_monster-43 을 Models/Monsters/ 로 재배치
메이커에서 수행한 모델 재배치 반영.
- RootDesk/MyDesk/Model_monster-43.model → RootDesk/MyDesk/Models/Monsters/Model_monster-43.model (이동, 내용 동일)
- freeze-turn-monsters.mjs 의 모델 경로 참조를 새 위치로 갱신 (이동 후에도 생성기가 모델을 찾도록)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:55:19 +09:00
f4b349532d fix(ui): render deck popups above monster hp 2026-06-10 20:20:42 +09:00
683ea88271 Merge pull request 'Map monster combat' from feature/map-monster-combat
# Conflicts:
#	RootDesk/MyDesk/SlayDeckController.codeblock
2026-06-10 19:58:56 +09:00
516348c0ec Merge pull request 'Add all deck popup' from feature/deck-pile-inspector 2026-06-10 19:57:37 +09:00
f211a79c82 Merge remote-tracking branch 'origin/main' into feature/map-monster-combat
# Conflicts:
#	RootDesk/MyDesk/SlayDeckController.codeblock
2026-06-10 08:34:23 +09:00
f33a5507db fix(combat): 플레이테스트 반영 — 상태전이 실행공간 에러 제거 + 슬롯 좌표 정렬
- ReviveMonsterEntity/KillMonster의 StateComponent:ChangeState 제거
  (client 실행공간에서 LEA-3022 InvalidExecSpace 발생) → SetVisible 기반 표시/숨김으로 대체
- monster-slots.json 좌표를 맵 우측 몬스터 무리 위로 조정
- 메이커 플레이테스트로 전체 전투 루프(등록·타겟·공격·적턴·처치·승리·보상) 무에러 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 02:15:40 +09:00
647516d0cd feat(combat): 맵 몬스터 카드 전투 산출물 재생성 (UI 슬롯·컨트롤러)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:31:03 +09:00
f704d0f14e refactor(combat): 죽은 단일적 코드 제거 + HP_BAR_W 상수 추출
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:29:27 +09:00
f0569d9a53 feat(combat-ui): 몬스터 슬롯 UI(HP바·의도·타겟버튼) + monster-slots.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:21:45 +09:00
a5c7d96770 feat(combat): 승리조건(전체 처치)·몬스터 슬롯 렌더·HP바·타겟 클릭
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:17:32 +09:00
423407325d feat(combat): EnemyTurn 생존 몬스터 각자 행동 2026-06-10 01:15:12 +09:00
ec45438b3c feat(combat): PlayCard 타겟 몬스터 공격 + 사망 처리/연출
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:13:33 +09:00
020be477e6 feat(combat): 컨트롤러 멀티 몬스터 상태 + 등록/BuildMonsters/부활
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:09:27 +09:00
9eef5eb66e fix(monster): gen-combat-monster 방어적 가드(componentNames/@components) + 코드블록 trailing newline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:06:11 +09:00
185e0f3a94 feat(monster): CombatMonster 마커(EnemyId·자기등록) + 11맵 몬스터 패치 2026-06-10 01:00:23 +09:00
de23829439 fix(sim): 빈 인카운터 즉시 승리·타겟 타이브레이크 결정성·주석·draw/empty 테스트 2026-06-10 00:57:53 +09:00
4ef3d1811d feat(sim): 전투 규칙을 멀티 몬스터로 (타겟 선택·각자 의도·전체 처치 승리) 2026-06-10 00:52:17 +09:00
b14b614d94 feat(combat-data): 맵 몬스터 적 타입(주황/파란버섯) + simEncounter 추가 2026-06-10 00:48:46 +09:00
0cbcf4c70d docs(map-monster-combat): 구현 계획 (9개 태스크)
데이터→sim(TDD)→CombatMonster 마커→컨트롤러 멀티 전투(상태/PlayCard/EnemyTurn/승리/렌더)→슬롯 UI→재생성·플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:45:40 +09:00
1583f7ec26 Add all deck popup 2026-06-10 00:38:34 +09:00
da5dd03183 docs(map-monster-combat): 맵 몬스터 카드 전투 설계
추상 단일 적 → 맵 실제 몬스터 멀티 전투(클릭 타겟·각자 HP/의도·전체 처치 시 승리).
컨트롤러 단일 소유 + script.CombatMonster(EnemyId) 매핑 + 월드 HP바 슬롯.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:35:49 +09:00
c0cbea42a2 Merge pull request 'Add deck pile inspector UI' (#24) from feature/deck-pile-inspector into main
Reviewed-on: #24
2026-06-10 00:24:38 +09:00
de6e12c765 Add deck pile inspector UI 2026-06-10 00:17:00 +09:00
f38a3c98b1 Merge pull request 'feat(map01): 주황버섯 추격 몬스터 배치 + 카메라 시점 미세조정' (#23) from feature/map01-monster into main
Reviewed-on: #23
2026-06-10 00:16:21 +09:00
62e76f7db2 feat(map01): 주황버섯 추격 몬스터 배치 + 카메라 시점 미세조정
- map01: StaticMonsterTemplate → 주황버섯(ChaseMonster 모델) 교체
  (애니메이션 move/stand/jump/hit/die·히트박스·위치 x 5.2 갱신, 이동은 턴전투용 정지)
- 타일맵 TemplateRUID 변경
- 카메라 cameraOffsetY -1 → -0.83 (data/camera.json + MapCamera.codeblock)
- 메이커 저장 재직렬화 포함: common.gamelogic 수치 표기(0→0.0), map02~11 컴포넌트 순서, ui/DefaultGroup.ui

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:14:43 +09:00
ff91680f18 Merge pull request '.mjs 주체별 폴더 분류 + 카메라/플레이어 제어 분리' (#22) from feature/map-camera into main
Reviewed-on: #22
2026-06-10 00:01:12 +09:00
b18c44f0a5 Merge remote-tracking branch 'origin/main' into feature/map-camera
# Conflicts:
#	README.md
2026-06-09 23:59:44 +09:00
124e49b938 refactor(tools): .mjs를 주체별 폴더로 분류 + 카메라/플레이어 제어 분리
- tools/{player,monster,camera,map,deck,balance}/ 로 8개 스크립트 분류 (git mv 이력 보존)
- gen-camera의 플레이어 입력 차단·시선 고정을 tools/player/gen-player-lock.mjs(PlayerLock 코드블록)로 분리
- MapCamera 코드블록은 카메라 속성 전용으로 정리, 11개 맵 루트에 script.PlayerLock 부착
- README 및 스크립트 주석의 도구 경로 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:52:02 +09:00
c9f82708c8 Merge pull request '맵별 고정 카메라 + 시점 조정 + 플레이어 셋업(입력차단·오른쪽) + map01 배치' (#21) from feature/map-camera into main
Reviewed-on: #21
2026-06-09 23:39:18 +09:00
f1d101f6a4 feat(map-camera): 게임 시작 시 플레이어 입력 차단·오른쪽 바라보기 + map01 몬스터 3마리 배치
MapCamera 스크립트(맵 진입 OnBeginPlay)가 카메라에 더해 플레이어도 셋업:
- PlayerControllerComponent.LookDirectionX=1 (오른쪽 — 기본은 -1 왼쪽)
- FixedLookAt=true (방향 고정)
- Enable=false (키보드 입력 차단: 이동/점프/공격)
- map01: 몬스터 3마리 배치(사용자 의도 변경 포함)
- 메이커 Play 검증: LookDirectionX=1·Enable=false 확인, 오른쪽키 입력→플레이어 미이동(입력 차단), 아바타 정상, 카메라 zoom90·offset(1.5,-1) 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:35:58 +09:00
5dfbef4f0f Merge pull request '맵별 고정 카메라 (MapCamera) — 11맵 카메라 framing 고정' (#20) from feature/map-camera into main
Reviewed-on: #20
2026-06-09 23:28:57 +09:00
fbf5cfe19f fix(map-camera): ScreenOffset→CameraOffset로 시점 조정 + zoom 90
ConfineCameraArea=true에서는 ScreenOffset이 무시됨(MSW 문서·실측 확인) → 시점 이동은 CameraOffset(월드 좌표)으로.

- data/camera.json: zoomRatio 90, cameraOffsetX 1.5, cameraOffsetY -1 추가 (x+ 오른쪽 / y- 아래)
- gen-camera: codeblock에 cam.CameraOffset = Vector2(...) 굽기 추가
- 메이커 Play 검증: 파이프라인(camera.json→gen-camera→reload)으로 zoom90·offset(1.5,-1) 적용, 시점이 우하단으로 이동 확인
- 참고: 시점 조정은 CameraOffset 사용(ScreenOffset은 confine=true에서 무효, 범위 0~1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:22:52 +09:00
9eeb12adf9 docs(map-camera): 고정 카메라 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:24:09 +09:00
68c9333b59 feat(map-camera): 맵별 고정 카메라 — MapCamera 스크립트로 11맵 카메라 framing 고정
맵 로드 시 플레이어 CameraComponent를 data/camera.json 값(현재 map01: zoom100·screenOffset0.5/0.655·confine)으로 설정.

- data/camera.json: 카메라 framing 단일 설정
- tools/gen-camera.mjs: MapCamera.codeblock 생성 + 11맵 루트에 script.MapCamera 부착(idempotent)
- 새 CameraComponent 미생성(엔진 소유) — 기존 플레이어 카메라 속성만 런타임 설정
- OnBeginPlay(client, ExecSpace 6) + LocalPlayer 카메라 재시도 타이머
- 메이커 Play 검증: zoom 60 테스트로 적용 입증, 100으로 복원. idempotent·결정적
- 참고: 맵 루트 client 스크립트는 ExecSpace 6 필요(1은 미발동)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:24:09 +09:00
8405395d84 Merge pull request 'docs: README를 전체 구현(B~E6a+메인메뉴+freeze)에 맞게 갱신' (#19) from docs/readme-full-status into main
Reviewed-on: #19
2026-06-09 13:52:38 +09:00
f310d2978e docs: README를 전체 구현(B~E6a+메인메뉴+freeze)에 맞게 갱신
디렉토리 구조(data/·tools 도구), 게임 프레임워크 현황(메인메뉴·카드전투·런영속·보상·분기맵·상점/휴식·유물·멀티act·밸런스시뮬·freeze), 스크립트 호출, 다음 단계(저장·카드제거·밸런싱·IP확장) 반영.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:51:20 +09:00
971539a72f Merge pull request 'feature/slay-deck-controller' (#14) from feature/slay-deck-controller into main
Reviewed-on: #14
2026-06-09 13:39:16 +09:00
861442e2c1 Merge main into feature/slay-deck-controller — B~E6a 카드시스템 통합 + 메인 메뉴 이식
main의 35커밋(B 전투통합~E6a 멀티act)을 브랜치에 통합. 충돌 해결:
- tools/gen-slaydeck.mjs: main(B~E6a) 생성기 채택 + 브랜치 메인 메뉴(MainMenu UI·ShowMainMenu/BindMenuButtons/StartNewGame/SetEntityEnabled·OnBeginPlay→메뉴) 이식
- ui/DefaultGroup.ui·SlayDeckController.codeblock: 통합 생성기로 재생성
- map10·모델: main 채택 후 freeze 도구(freeze-turn-monsters/player) 재적용
정적 검증: 문법·JSON 유효·생성기 결정적·메뉴/B~E6a 양쪽 유지·freeze 적용.
⚠️ Maker 연결 해제로 메뉴→게임 런타임 검증은 미수행(사용자 메이커 확인 필요).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:29:28 +09:00
cb4d72ead2 Merge pull request '다음 막 진행·적 스케일 (TODO E6a) — 멀티 act 런' (#18) from feature/floors into main
Reviewed-on: #18
2026-06-09 13:18:00 +09:00
376511dfa9 docs(E6a): 다음 막/멀티 act 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:13:56 +09:00
f42628c2e9 feat(E6a): 다음 막 진행·적 스케일 — 멀티 act 런
보스 클리어 시 즉시 종료 대신 다음 막으로, 최종 막 보스에서 런 클리어.

- Floor를 막 카운터(1..ACT_COUNT=3)로 재정의, RunLength=ACT_COUNT
- StartCombat: 적을 막 배율(mult=1+(Floor-1)*0.6)로 스케일(maxHp·의도값, 새 테이블)
- CheckCombatEnd 보스 승리: Floor<RunLength면 다음 막(같은 맵 재사용·CurrentNodeId 리셋·ShowMap), 최종 막이면 '런 클리어!'
- HP/골드/덱/유물 막 간 유지(기존 영속), combatStart 유물 전투마다 재적용
- RenderRun HUD 라벨 '층'→'막'
- 메이커 Play 검증: 보스 120→192→264 스케일, 막 1→2→3, 3막 클리어, 영속 유지
- 제외: E6b 저장/불러오기(미진행)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:13:55 +09:00
ace489ed0f Merge pull request '유물 시스템 (TODO E5) — 훅 패시브 + 3획득경로' (#17) from feature/relics into main
Reviewed-on: #17
2026-06-09 04:04:31 +09:00
4c866f3cd9 docs(E5): 유물 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:53:38 +09:00
5ebc781f81 feat(E5): 유물 시스템 — 훅 패시브 + 3획득경로
훅 기반 유물(패시브) + 시작/엘리트/상점 획득.

- data/relics.json: 유물 4종(강철심장 combatStart 방어+6, 에너지코어 turnStart 에너지+1, 흡혈 cardPlayed HP+1, 황금우상 combatReward 골드+10) + startingRelic + relicPool
- ApplyRelics(hook): RunRelics 순회·effect 적용. 4지점 연결(StartCombat/StartPlayerTurn/PlayCard Attack/CheckCombatEnd)
- 획득: AddRelic 공용 — StartRun 시작 유물(C), 엘리트 승리 무작위(A), 상점 BuyRelic 골드-60(B)
- UI: CombatHud 유물 바(RenderRelics)·ShopHud 유물 슬롯
- 생성기: relics.json 로드/검증/luaRelicsTable, RELIC_PRICE=60
- 메이커 Play 검증: 방어+6·에너지4·공격HP+1·승리골드+25·엘리트/상점 유물 획득
- 범위 밖: 부정 유물·조건부 효과·유물 제거·보스 유물

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:53:37 +09:00
edbc717426 Merge pull request '상점/휴식 노드 (TODO E4) — 골드 소비·HP 회복' (#16) from feature/shop-rest into main
Reviewed-on: #16
2026-06-09 03:37:49 +09:00
03b59eeafc docs(E4): 상점/휴식 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:35:05 +09:00
0400291939 feat(E4): 상점/휴식 노드 — 골드 소비·HP 회복
맵에 상점/휴식 노드 추가, 진입 시 전투 대신 상호작용 UI로 분기.

- data/map.json: 4행 맵에 rest(휴식)·shop(상점) 노드 추가(enemy 없음)
- 생성기: enemy 선택적 검증/직렬화, MapHud 노드 y 행수 비례 중앙정렬, TYPE_KO에 상점/휴식
- PickNode 타입 분기: shop→ShowShop, rest→ShowRest, 그 외→StartCombat
- 상점: ShowShop(카드3 무작위)·RenderShop·BuyCard(골드-30·RunDeck+1·재구매/부족 가드)
- 휴식: ShowRest(HP+30 상한 클램프)
- LeaveNode(상점/휴식 공용 나가기→ShowMap)
- UI: ShopHud(카드3·가격·골드·나가기)·RestHud(회복 정보·나가기), 상수 CARD_PRICE=30·REST_HEAL=30
- 메이커 Play 검증: 상점 구매(부족/재구매 무시 포함)·휴식 회복·맵 분기 정상
- 알려진 튜닝: 골드/승리 15 < 카드값 30 → 경제 밸런싱 필요(sim-balance 활용)
- 범위 밖: 카드 제거(덱 보기 UI)·유물(E5)·저장(E6)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:35:05 +09:00
42ce7286f5 Merge pull request '분기 맵 노드 진행 (TODO E3) — 경로 선택·적 차등·보스 클리어' (#15) from feature/map-nodes into main
Reviewed-on: #15
2026-06-09 03:20:13 +09:00
444d02367e docs(E3): 분기 맵 노드 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:18:49 +09:00
15975d7f51 feat(E3): 분기 맵 노드 진행 — 경로 선택·적 차등·보스 클리어
단일 경로 자동 진행을 분기 맵 네비게이션으로 확장.

- data/map.json: 분기 DAG(start + nodes: type/enemy/row/col/next). A,B→C,D,E→BOSS
- data/enemies.json: slime_elite(HP70)·slime_boss(HP120) 추가
- SlayDeckController: Enemies(전체 적)·MapNodes·MapStart·CurrentNodeId·CurrentEnemyId 속성
- StartRun→맵 빌드·ShowMap, PickNode(도달성 검증)→StartCombat(적은 self.Enemies에서)
- ShowMap/IsReachable(boolean)/RenderMap(도달 가능 노드만 활성·강조)/PickNode
- 승리→보상→ShowMap 복귀, 보스 노드 승리 시 '런 클리어!'
- MapHud UI: 노드 버튼(행=y/col=x), 타입+적 라벨, 모달 배경
- 생성기: method() returnType 파라미터, 다중 적/맵 Lua 직렬화 헬퍼
- 메이커 Play 검증: 맵→A→보상→C(엘리트)→보스→런 클리어, 도달불가 노드 무시
- 범위 밖(후속): 상점/휴식(E4)·유물(E5)·저장(E6)·절차적 맵·연결선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:18:49 +09:00
e5960e150b Merge pull request '런 루프 코어 (TODO E1+E2) — 연속 전투·카드 보상·덱 성장' (#13) from feature/run-loop-core into main
Reviewed-on: #13
2026-06-09 02:43:13 +09:00
266b7ddb0c docs(E1+E2): 런 루프 코어 설계·계획 + E 분해
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:27:38 +09:00
e9b6d9c6c0 feat(E1+E2): 런 루프 코어 — 연속 전투·카드 보상·덱 성장
단일 전투를 N전투 런으로 확장(로그라이크 메타 첫 조각).

- 런 상태: RunDeck(보유 카드)·Gold·Floor·RunLength·RewardChoices·RunActive (SlayDeckController 확장)
- StartRun(영속 초기화·버튼 1회 바인딩) vs StartCombat(전투별 초기화·RunDeck에서 드로·Floor++) 분리
- 플레이어 HP 전투 간 유지, BindButtons를 StartRun 1회 호출로 이동(핸들러 중첩 버그 예방)
- 승리: 골드 +15 → Floor<RunLength면 OfferReward(카드 3택1), 아니면 '런 클리어!'
- PickReward: 선택 카드 RunDeck 추가(건너뛰기=추가 안 함)→다음 전투. 입력잠금 가드
- UI: CombatHud 층/골드 표시, RewardHud(보상 카드 3+건너뛰기) 생성
- 런 파라미터(RUN_LENGTH=3·GOLD_PER_WIN=15)는 생성기 상수(향후 외부화)
- 메이커 Play 검증: 전투→보상→덱+1→다음전투→3전투 런 클리어, 패배·입력잠금 정상

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:27:38 +09:00
3be5e85c94 Merge pull request 'AI 전투 밸런스 시뮬레이터 (TODO F)' (#12) from feature/balance-simulator into main
Reviewed-on: #12
2026-06-09 01:52:07 +09:00
911f45407c docs(F): 밸런스 시뮬레이터 설계·구현 계획 문서
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:39:21 +09:00
f42e03a006 feat(F): AI 전투 밸런스 시뮬레이터 tools/sim-balance.mjs
data/*.json을 입력으로 전투를 몬테카를로 N회 시뮬 → 승률·턴·OP 카드 리포트.

- 전투 엔진 JS 재현(gen-slaydeck Lua 미러): 에너지3·드로우5·방어우선차감·결정적 의도·승패·턴상한
- 플레이어 휴리스틱 정책: 치사 우선 → 적 공격의도 시 방어 → 공격 우선, 에너지 효율순
- 시드 PRNG(mulberry32)로 재현성, OP 탐지(kind별 효율 중앙값 1.5배↑ 플래그)
- CLI: node tools/sim-balance.mjs [N] [--seed S]
- node:test 단위 테스트 10종(applyDamage·정책·엔진·집계)
- 검증: 현 데이터 승률 100%(슬라임 약함 신호), 적 HP 45→300 시 평균턴 5.6→39.6(데이터 반영)
- 전투 규칙은 Lua와 중복이라 동기화 주석 명시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:39:21 +09:00
e6994f92f7 Merge pull request 'feat(D): 카드/적 데이터 외부화 (data/*.json)' (#11) from feature/data-externalization into main
Reviewed-on: #11
2026-06-09 01:29:03 +09:00
929347c599 docs(D): 데이터 외부화 설계·구현 계획 문서
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:25:25 +09:00
8789330a4e feat(D): 카드/적 데이터 외부화 (data/*.json)
Cards·시작덱·적 정의를 data/cards.json·data/enemies.json으로 분리, gen-slaydeck가 로드·검증·주입.

- data/cards.json: 카드 정의(name/cost/kind/damage|block/desc) + starterDeck
- data/enemies.json: 적 정의(name/maxHp/intents) + activeEnemy
- 생성기: JSON 로드 + fail-fast 검증(미존재 카드/적 id) + Lua 직렬화 헬퍼
- StartCombat·EnemyMaxHp·카드 미리보기·CombatHud 초기텍스트를 데이터에서 생성
- codeblock 출력은 기존과 동일(순수 리팩터), ui 미리보기는 카드 종류 순환 표시
- 검증: 데이터 1장 수치 변경→재생성 반영 확인, 결정성, fail-fast(exit1), 메이커 Play 정상
- 수치는 임시 placeholder, 추후 메이플 IP대로 카드/적 확장 예정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:25:25 +09:00
9550302137 Merge pull request 'docs(A): README·framework 문서를 실제 구현에 맞게 정정' (#10) from feature/docs-alignment into main
Reviewed-on: #10
2026-06-09 01:12:17 +09:00
e8ed467cda docs(A): README·framework 문서를 실제 구현에 맞게 정정
미존재 3컴포넌트(SlayCardCatalog/SlayRunState/SlayCombatManager)를 구현된 것처럼 서술하던 부분을 정정.

- '게임 프레임워크 현황'을 실제 구현(SlayDeckController 기반 카드 전투 + B 결과)으로 재작성
- 3대 컴포넌트는 '향후 설계(미구현 — 목표 아키텍처)' 섹션으로 분리 보존
- 디렉토리 트리·codeblock 목록·생성기(tools/) 반영, SlayDeckController 추가
- 스크립트 호출 예시를 실제 메서드(PlayCard/EndPlayerTurn/StartCombat)로 교체
- '다음 구현 단계'에서 전투 UI·전투 루프 완료 표시, D/F/E 반영
- 수치는 임시 placeholder임을 명시
- framework 문서도 동일 기조로 재작성 + Planned 섹션에 3컴포넌트 보존

검증: 문서에 적힌 컴포넌트명을 코드와 grep 교차확인 (SlayDeckController 실재, 3컴포넌트 코드 0건).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:10:35 +09:00
9206018fbe Merge pull request 'carddeck(B): CombatHud 적 패널 정렬·정수 표기 수정' (#9) from feature/combat-hud-polish into main
Reviewed-on: #9
2026-06-09 01:03:44 +09:00
788167b1ae carddeck(B): CombatHud 적 패널 정렬·정수 표기 수정
런타임 검증에서 발견된 시각 결함 2건 수정.

- 적 텍스트(이름/HP/방어/의도)를 EnemyBg 패널(y=300) 위로 정렬 (기존 center 배치로 패널과 분리됨)
- HP/방어/에너지 등 codeblock number Property를 string.format("%d")로 정수 표기 (Lua tostring의 .0 제거)

생성기 단일 소스에서 재생성. 메이커 Play로 정렬·정수 갱신 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:02:56 +09:00
e5a75a83b5 Merge pull request '카드 전투 통합 (TODO B) + 미커밋 노이즈 정리 (C)' (#8) from feature/deck-controller-fixes into main
Reviewed-on: #8
2026-06-09 00:51:04 +09:00
724cd5a04d docs(B): 카드 전투 통합 설계·구현 계획 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:04:33 +09:00
bd02865f4f carddeck(B): 카드 전투 통합 — 적/플레이어 전투 상태·의도·승패
PlayCard가 토스트 대신 실제 효과를 적용하도록 통합.

- 카드 데이터에 damage/block 수치 필드 추가 (desc 파싱 폐기)
- 전투 상태: 플레이어 HP/Block, 적 HP/Block/의도(결정적 사이클)
- PlayCard: Attack→적 HP 감소(방어 우선 차감), Skill→플레이어 Block 증가
- EndPlayerTurn→적 턴(의도 실행)→다음 플레이어 턴, 승패 판정
- CombatHud UI: 적 패널(이름/HP/방어/의도)·플레이어 패널(HP/방어)·결과 텍스트
- 수치(플레이어80/적45/의도10·6·방8)는 임시 placeholder (D에서 캐릭터/몬스터별 외부화)

생성기 단일 소스(tools/gen-slaydeck.mjs)에서 생성. 결정적 출력 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:04:26 +09:00
8f08e67e4c 미사용 고아 리소스 invincible belief.sprite 제거
카드 5장 통일로 이미지 카드가 손패에서 빠져 이 스프라이트는 미참조 고아가 됨.
descriptor Id가 무효라 플레이마다 LEA-3021 에러를 유발하므로 제거.
(맵 Background.WebUrl의 휴면 참조는 Type=Template이라 미사용 — 무해, 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:24:25 +09:00
4bf7e29315 Maker 세션 재저장분(맵 02/05/06/07/10/11) 복구 포함
stash해 둔 로컬 맵 재저장분 복구. 몬스터 2종·old 스프라이트 미사용·타일셋 교체·유효 GUID 무결성 검증 완료.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:13:46 +09:00
b1921ee843 gen-slaydeck: 유효한 GUID 생성으로 수정 (DeckHud·카드 자식 entity id)
기존 guid() prefix+4hex는 8-4-4-4-12 형식이 아니어서 Maker가 적용 거부(LEA-3054).
네임스페이스 바이트 기반 hex GUID로 변경하고, 기존 자식 id도 재생성 시 정규화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:11:44 +09:00
f508952960 재생성: 카드 클릭 사용·균일 카드·핸들러 클로저 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:08:40 +09:00
5bc5b3dc5c 덱 컨트롤러 생성기: 핸들러 클로저화·카드데이터 단일화·카드클릭 사용·pcall 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:07:46 +09:00
6c392764d5 덱 컨트롤러 코드리뷰 수정 설계 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:01:42 +09:00
14449373e0 Merge pull request 'Add main menu flow' (#7) from feature/main-menu into feature/slay-deck-controller
Reviewed-on: #7
2026-06-08 00:38:29 +09:00
b0f0188106 Merge pull request 'Add slay deck controller UI' (#6) from feature/slay-deck-controller into main
Reviewed-on: #6
2026-06-08 00:37:54 +09:00
maple
27818e92c7 Fix turn combat facing and player movement 2026-06-08 00:01:46 +09:00
maple
1299d718e2 Disable monster patrol movement 2026-06-07 23:56:20 +09:00
maple
913b4f1721 Avoid client state transition for monsters 2026-06-07 23:42:05 +09:00
maple
a9926feea3 Freeze monsters for turn combat 2026-06-07 23:39:58 +09:00
maple
8eab9a75ac Fix generated UI GUIDs 2026-06-07 23:33:44 +09:00
maple
8c397cbc09 Add main menu flow 2026-06-07 23:27:44 +09:00
maple
cfc41ac3d9 Add slay deck controller UI 2026-06-07 22:35:00 +09:00
3ab7f10182 Merge pull request '맵 10개 추가: 맵별 배경·타일·몬스터 다양화 + StS2 전투 배치' (#5) from feature/maps-batch into main
Reviewed-on: #5
2026-06-06 14:31:25 +09:00
dd5acafab4 맵10개 개선: 수확한 다양한 몬스터 2종(StS2 우측 배치) + 맵별 타일셋
공식 필드맵 import로 몬스터 변형 9종·타일셋 12종 수확. map01 기존 4종 미사용.
각 맵: 서로 다른 몬스터 2마리(x=3.5/5.5 우측), 맵별 다른 타일셋, 기존 배경 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:09:45 +09:00
bce13fc788 맵 생성기: 수확한 다양한 몬스터 2종(StS2 우측 배치) + 맵별 타일셋 교체
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:07:10 +09:00
b5d6f913e3 맵 개선(다양한 몬스터+타일셋+StS2 배치) 설계 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:39:39 +09:00
989031239b 맵 10개 생성 구현 계획 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:42:19 +09:00
9b3276e5a4 맵 10개(map02~map11) 생성: 공식 스셌러리 배경 10종 + 몬스터 2마리, sector 등록
map01 템플릿 복제, 엔티티 GUID 재발급. 배경은 공식 MapleStory 맵에서
수확한 Background 타입 RUID 10종(맵마다 다르게). 몬스터는 기존 4종에서
랜덤 2마리 + 랜덤 위치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:42:19 +09:00
de2fcdbe7c 맵 생성기 추가 (map01 템플릿 복제·배경/몬스터 주입)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:19:11 +09:00
874d6792dc 맵 10개 생성 (랜덤 배경+몬스터) 설계 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:10:56 +09:00
9bc1163103 Merge pull request '전투 화면 하단 카드 손패 UI + 5번 카드 이미지 적용' (#4) from feature/sts2-combat-layout into main
Reviewed-on: #4
2026-06-06 12:00:08 +09:00
6a049def85 카드 슬롯 이미지 적용 구현 계획 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:54:44 +09:00
d1e8723954 5번 카드에 리부트 프로토콜 이미지 카드 적용 (로컬 워크스페이스 스프라이트)
invincible belief.png를 Maker 로컬 워크스페이스 스프라이트로 임포트해
Card5 SpriteGUIRenderer의 ImageRUID로 지정. 클라우드 계정 리소스는
로컬 워크스페이스 플레이에서 로드되지 않아 로컬 임포트 RUID 사용.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:58:25 +09:00
700e3ee373 전투 화면 하단에 카드 손패 5장 목업 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 05:49:34 +09:00
2c390660fb 카드 손패 생성기: UUID 형식 교정 및 중앙정렬 일반화 2026-06-06 01:50:25 +09:00
3b2e6afbcf 카드 손패 생성기: 기존 파일 줄바꿈(CRLF) 보존 2026-06-06 01:42:20 +09:00
af3480d8b6 하단 카드 손패 엔티티 생성 스크립트 추가 2026-06-06 01:38:38 +09:00
bff67f48bd 하단 카드 손패 UI 목업 설계 문서 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:27:05 +09:00
c9ba86e264 Merge pull request '맵 전투 구도를 Slay the Spire 2 스타일로 재배치' (#2) from feature/sts2-combat-layout into main
Reviewed-on: #2
2026-06-06 01:22:21 +09:00
1046a22753 Merge pull request 'Add Holodragon King model' (#3) from codex/msw-project-updates into main
Reviewed-on: #3
2026-06-06 01:21:56 +09:00
maple
84b82415a9 Add Holodragon King model 2026-06-06 01:17:53 +09:00
4904224d10 맵 전투 구도를 Slay the Spire 2 스타일로 재배치
플레이어 스폰을 좌측으로, 몬스터 4종을 우측 그룹으로 정렬해 좌우 대치 구도 구성.

- SpawnLocation: x 0.0 → -5.0 (좌측 1/3)
- monster-43(비행): x -1.85 → 2.4 (높이 유지)
- StaticMonsterTemplate: x 1.61 → 3.8
- ChaseMonsterTemplate: x 2.87 → 5.2
- MoveMonsterTemplate: x 4.46 → 6.6

지형 x범위(-8.93~8.03) 내 우측 일렬 그룹. y(지면 높이)는 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:10:27 +09:00
209 changed files with 313895 additions and 739 deletions

20
.claude/settings.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Read(./ui/*.ui)",
"Read(./map/*.map)",
"Read(./RootDesk/MyDesk/*.codeblock)",
"Edit(./ui/*.ui)",
"Edit(./map/*.map)",
"Edit(./RootDesk/MyDesk/*.codeblock)",
"Edit(./Global/common.gamelogic)",
"Edit(./Global/SectorConfig.config)",
"Write(./ui/*.ui)",
"Write(./map/*.map)",
"Write(./RootDesk/MyDesk/*.codeblock)",
"Write(./Global/common.gamelogic)",
"Write(./Global/SectorConfig.config)"
]
}
}

13
.gitignore vendored
View File

@@ -3,8 +3,12 @@
.mcp.json .mcp.json
# Codex CLI 로컬 설정 — Authorization Bearer 토큰 포함 # Codex CLI 로컬 설정 — Authorization Bearer 토큰 포함
.codex/ .codex/
# Claude Code 로컬 설정 .agents/
.claude/ # Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조)
.claude/*
!.claude/settings.json
# 개인 스킬(superpowers) 브레인스토밍/계획 산출물 — 로컬 전용, 협업 공유 X (프로젝트 설계 문서 docs/*.md 는 추적 유지)
docs/superpowers/
# === OS / 에디터 잡파일 === # === OS / 에디터 잡파일 ===
Thumbs.db Thumbs.db
@@ -14,7 +18,12 @@ desktop.ini
*.tmp *.tmp
*.bak *.bak
AGENTS.md
# === MSW Maker 캐시 / 임시 === # === MSW Maker 캐시 / 임시 ===
# (로컬 워크스페이스 데이터 본체 — Global/ RootDesk/ map/ ui/ — 는 형상관리 대상이므로 제외하지 않음) # (로컬 워크스페이스 데이터 본체 — Global/ RootDesk/ map/ ui/ — 는 형상관리 대상이므로 제외하지 않음)
Environment/
McpScreenshots/ McpScreenshots/
*.log *.log
# 메이커가 재편(reorg) 중 부모를 잃은 엔티티를 모아두는 임시 폴더 (잡파일)
Mislocated/

7
CLAUDE.md Normal file
View File

@@ -0,0 +1,7 @@
# SlayMaple — CLAUDE.md
MapleStory Worlds 기반 Slay the Spire 풍 덱빌더. 게임 전체가 데이터(`data/*.json`) + 생성기(`tools/`) 단일 소스이고, `ui/DefaultGroup.ui`(~7.1MB)·codeblock·map 파일은 **생성 산출물**이다.
@RULES.md
위 RULES.md의 하네스 규칙(산출물 Read/Edit 금지·카운트 검증·gitea-pr.mjs PR 절차)을 항상 따른다. `.claude/settings.json`이 산출물에 대한 Read/Edit/Write를 도구 수준에서 차단한다.

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent", "MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent", "MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent", "MOD.Core.MovementComponent",
"MOD.Core.AIChaseComponent",
"MOD.Core.StateComponent", "MOD.Core.StateComponent",
"MOD.Core.HitComponent", "MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent", "MOD.Core.DamageSkinSpawnerComponent",

View File

@@ -30,7 +30,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 0
}, },
{ {
"TargetType": null, "TargetType": null,
@@ -39,7 +39,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 0
}, },
{ {
"TargetType": null, "TargetType": null,
@@ -48,7 +48,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 0
}, },
{ {
"TargetType": null, "TargetType": null,
@@ -57,7 +57,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 1
}, },
{ {
"TargetType": null, "TargetType": null,
@@ -118,7 +118,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 0.0 "Value": 0
}, },
{ {
"TargetType": null, "TargetType": null,
@@ -129,8 +129,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {
@@ -185,7 +185,7 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.35 "y": 0.35
} }
}, },
@@ -198,7 +198,7 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.35 "y": 0.35
} }
}, },
@@ -218,7 +218,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 500.0 "Value": 500
}, },
{ {
"TargetType": "script.PlayerHit", "TargetType": "script.PlayerHit",
@@ -254,7 +254,7 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.35 "y": 0.35
} }
}, },
@@ -265,7 +265,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 0
}, },
{ {
"TargetType": "MOD.Core.MovementComponent", "TargetType": "MOD.Core.MovementComponent",
@@ -274,7 +274,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 0
}, },
{ {
"TargetType": "MOD.Core.PlayerComponent", "TargetType": "MOD.Core.PlayerComponent",

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent", "MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent", "MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent", "MOD.Core.MovementComponent",
"MOD.Core.AIWanderComponent",
"MOD.Core.StateComponent", "MOD.Core.StateComponent",
"MOD.Core.HitComponent", "MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent", "MOD.Core.DamageSkinSpawnerComponent",

View File

@@ -7,7 +7,7 @@
"Usage": 0, "Usage": 0,
"UsePublish": 1, "UsePublish": 1,
"UseService": 0, "UseService": 0,
"CoreVersion": "1.21.0.0", "CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0", "StudioVersion": "0.1.0.0",
"DynamicLoading": 0, "DynamicLoading": 0,
"ContentProto": { "ContentProto": {
@@ -19,7 +19,12 @@
"name": "sector01", "name": "sector01",
"maxUserNo": 16, "maxUserNo": 16,
"entries": [ "entries": [
"map://map01" "map://map01",
"map://map02",
"map://map03",
"map://map04",
"map://map05",
"map://lobby"
] ]
} }
], ],

142
Global/UIButton.model Normal file
View File

@@ -0,0 +1,142 @@
{
"Id": "",
"GameId": "",
"EntryKey": "model://uibutton",
"ContentType": "x-mod/model",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"Version": 1,
"Name": "UIButton",
"BaseModelId": null,
"Id": "uibutton",
"Components": [
"MOD.Core.UITransformComponent",
"MOD.Core.SpriteGUIRendererComponent"
],
"Properties": [
{
"Type": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Name": "RectSize",
"DisplayName": "RectSize",
"ShowInInspector": true,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.UITransformComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "RectSize"
}
},
{
"Type": {
"$type": "MODNativeType",
"type": "MOD.Core.MODDataRef, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Name": "ImageRUID",
"DisplayName": "ImageRUID",
"ShowInInspector": true,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.SpriteGUIRendererComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "ImageRUID"
}
},
{
"Type": {
"$type": "MODNativeType",
"type": "MOD.Core.MODColor, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Name": "Color",
"DisplayName": "Color",
"ShowInInspector": true,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.SpriteGUIRendererComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "Color"
}
}
],
"Values": [
{
"TargetType": "MOD.Core.UITransformComponent",
"Name": "anchoredPosition",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
}
},
{
"TargetType": "MOD.Core.UITransformComponent",
"Name": "RectSize",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 200.0,
"y": 75.0
}
},
{
"TargetType": "MOD.Core.UITransformComponent",
"Name": "AlignmentOption",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.AlignmentType, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": 0
},
{
"TargetType": "MOD.Core.SpriteGUIRendererComponent",
"Name": "ImageRUID",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.MODDataRef, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": {
"$type": "MOD.Core.MODDataRef, MOD.Core",
"DataId": "cc3457b8e97b3e14f9d5c39ccdd640bf"
}
},
{
"TargetType": "MOD.Core.SpriteGUIRendererComponent",
"Name": "Color",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.MODColor, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": {
"$type": "MOD.Core.MODColor, MOD.Core",
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
}
}
],
"EventLinks": [],
"Children": []
}
}
}

View File

@@ -16,7 +16,7 @@
{ {
"id": "b447311c-6f03-4ba1-aad0-8f0fe58df5c1", "id": "b447311c-6f03-4ba1-aad0-8f0fe58df5c1",
"path": "/common", "path": "/common",
"componentNames": "", "componentNames": "script.SlayDeckController",
"jsonString": { "jsonString": {
"name": "common", "name": "common",
"path": "/common", "path": "/common",
@@ -28,7 +28,16 @@
"pathConstraints": "/", "pathConstraints": "/",
"revision": 1, "revision": 1,
"modelId": null, "modelId": null,
"@components": [], "@components": [
{
"@type": "script.SlayDeckController",
"Enable": true,
"Energy": 0,
"MaxEnergy": 3,
"Turn": 0,
"TweenEventId": 0
}
],
"@version": 1 "@version": 1
} }
} }

193
README.md
View File

@@ -1,7 +1,7 @@
# SlayMaple # SlayMaple
[MapleStory Worlds(MSW)](https://maplestoryworlds.nexon.com/) 기반으로 제작하는 **Slay the Spire 풍 덱빌더 로그라이크** 월드. [MapleStory Worlds(MSW)](https://maplestoryworlds.nexon.com/) 기반으로 제작하는 **Slay the Spire 풍 덱빌더 로그라이크** 월드.
턴제 카드 전투, 덱 구성, 보상 선택, 맵 노드 진행을 메이플 월드 위에서 구현하는 것을 목표로 합니다. 로비 마을에서 NPC와 상호작용해 런을 시작하고, 턴제 카드 전투·덱 구성·보상 선택·절차 생성 맵 진행·전직·영혼 메타 성장을 메이플 월드 위에서 구현합니다.
> 이 저장소는 MSW **로컬 워크스페이스(Local Workspace)** 데이터를 git으로 형상관리하기 위한 것입니다. > 이 저장소는 MSW **로컬 워크스페이스(Local Workspace)** 데이터를 git으로 형상관리하기 위한 것입니다.
> 공동작업자는 이 저장소를 통해 월드 데이터를 주고받습니다. (클라우드 공동제작 모드 미사용) > 공동작업자는 이 저장소를 통해 월드 데이터를 주고받습니다. (클라우드 공동제작 모드 미사용)
@@ -32,9 +32,10 @@ git push
```bash ```bash
git pull git pull
``` ```
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델 파일이 에디터 상태로 반영됩니다. 받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델/맵 파일이 에디터 상태로 반영됩니다.
> 💡 같은 파일을 동시에 수정하면 git 충돌이 날 수 있으니, **서로 다른 맵/codeblock/UI를 나눠서** 작업하는 것을 권장합니다. > 💡 같은 파일을 동시에 수정하면 git 충돌이 날 수 있으니, **서로 다른 맵/codeblock/UI를 나눠서** 작업하는 것을 권장합니다.
> ⚠️ git pull 후 reload를 빠뜨리면 메이커의 stale 상태가 디스크를 덮어쓸 수 있습니다. 재생성 후에도 reload → 빌드 콘솔 0 에러 확인.
--- ---
@@ -42,71 +43,166 @@ git pull
``` ```
slaymaple/ slaymaple/
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
│ ├── cards.json # 카드 121장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
│ ├── enemies.json # 적 18종(일반/정예/보스, 디버프 인텐트 포함)
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
│ └── camera.json # 맵별 카메라 설정값(줌·오프셋·고정 영역)
├── Global/ # 월드 전역 설정 · 공용 모델 · 게임로직 ├── Global/ # 월드 전역 설정 · 공용 모델 · 게임로직
│ ├── common.gamelogic # SlayCardCatalog / SlayRunState / SlayCombatManager 부착 지점 │ ├── common.gamelogic # SlayDeckController 부착 지점 (산출물)
│ ├── Player.model # 플레이어 모델 │ ├── DefaultPlayer.model # 플레이어 모델 (턴전투용 이동 정지 freeze 적용)
│ ├── *.model # 몬스터 공용 모델 │ ├── ChaseMonster.model · MoveMonster.model # 몬스터 공용 모델
│ ├── SectorConfig.config # 섹터/맵 등록 (lobby + map01~05 = 6 entries)
│ ├── WorldConfig.config # 월드 설정 │ ├── WorldConfig.config # 월드 설정
│ └── ... │ └── ...
├── RootDesk/ ├── RootDesk/
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·모델·타일셋 │ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·타일셋
│ ├── Monster.codeblock │ ├── SlayDeckController.codeblock # 게임 전체 컨트롤러 (★산출물, 직접 편집 금지)
│ ├── MonsterAttack.codeblock │ ├── Monster.codeblock · MonsterAttack.codeblock # 필드 액션 몬스터 (카드 전투와 별개)
│ ├── PlayerAttack.codeblock │ ├── PlayerAttack.codeblock · PlayerHit.codeblock · UIPopup.codeblock · UIToast.codeblock
│ ├── PlayerHit.codeblock │ ├── CombatMonster.codeblock # 맵 몬스터 EnemyId 마커 + /common 자기등록
│ ├── UIPopup.codeblock │ ├── MapCamera.codeblock # 맵별 카메라 적용
│ ├── UIToast.codeblock │ ├── PlayerLock.codeblock # 전투맵 플레이어 입력·이동 잠금
── RectTileData_Henesys.tileset ── LobbyNpc.codeblock # 로비 NPC 상호작용(근접·클릭)
├── map/ │ └── LobbyMobility.codeblock # 로비 이동·공격 해제 + 카메라 추종
│ └── map01.map # 메인 ── map/ # 맵 6종 (산출물)
├── ui/ # UI 그룹 (Default / Popup / Toast) │ ├── lobby.map # 로비 허브 맵 (마을 배경, NPC 4종, 전투 없음)
│ └── map01.map ~ map05.map # 5막 전투/맵 노드 (공식 배경 + STS풍 우측 배치)
├── tools/ # 결정적 생성기·도구 (주체별 폴더, 단일 소스)
│ ├── deck/ # gen-slaydeck.mjs(★게임 전체 생성: 카드/덱·전투·맵노드·상점·유물·로비·메뉴 UI + SlayDeckController + common) · gen-cardhand.mjs
│ ├── map/ # gen-maps.mjs(맵 배경/타일) · gen-lobby-map.mjs(로비 맵+NPC) · gen-map-encounters.mjs(노드별 몬스터 그룹) · rogue-map.mjs(절차 생성 JS 미러)+test
│ ├── camera/ # gen-camera.mjs(맵별 고정 카메라 codeblock)
│ ├── player/ # gen-player-lock.mjs(전투맵 입력 잠금) · freeze-turn-player.mjs(모델 이동 정지) · gen-lobby-npc.mjs(LobbyNpc·LobbyMobility codeblock)
│ ├── monster/ # gen-combat-monster.mjs(EnemyId 마커) · freeze-turn-monsters.mjs(필드 AI 정지)
│ ├── balance/ # sim-balance.mjs(전투 밸런스 몬테카를로 시뮬) · sim-balance.test.mjs
│ ├── verify/ # count.mjs·uimap.mjs·cbgap.mjs(산출물 카운트/UIGroup 매핑/재연결 GAP 검증 — 경로 내장)
│ └── git/ # gitea-pr.mjs(UTF-8 안전 PR 생성/수정/머지 — RULES.md 참조)
├── ui/ # UIGroup 7종 — 메이커 저작(Default/Select/Lobby/Run/Deck/Popup/Toast)
├── docs/ ├── docs/
── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서 ── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
│ ├── ui-generation-structure.md # UI 생성 구조 문서
│ └── superpowers/specs|plans/ # 각 기능 설계·구현 계획 문서(P1~P15)
├── RULES.md # 협업·AI 에이전트 하네스 규칙 (토큰 가드·검증·PR 절차)
├── CLAUDE.md # Claude Code 자동 로드 (RULES.md 임포트)
└── README.md └── README.md
``` ```
> ⚠️ **`map/*.map` · `SlayDeckController.codeblock` · `Global/common.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 재생성 때 사라집니다. 게임 로직 변경은 `data/*.json`·`tools/`의 생성기를 고쳐 재생성하세요. **`ui/*.ui`는 메이커 저작**(생성기 미생성)이라 메이커에서만 편집합니다(자세한 규칙은 [`RULES.md`](RULES.md)).
> `.mcp.json`, `.codex/` 는 **Authorization 토큰이 포함**되어 있어 git에서 제외됩니다(`.gitignore`). 각자 로컬에서 직접 구성하세요. > `.mcp.json`, `.codex/` 는 **Authorization 토큰이 포함**되어 있어 git에서 제외됩니다(`.gitignore`). 각자 로컬에서 직접 구성하세요.
--- ---
## 직업 컨셉
3직업 모두 Slay the Spire 2 차용 + 메이플 IP 재해석. 카드 덱 상세 설계는 [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
- **⚔️ 전사 (탱커, Ironclad 차용)** — **파이터**: 공격을 *연속*으로 내면 콤보가 쌓이고(방어·파워 등 비공격 카드를 쓰면 콤보 리셋) 콤보로 데미지 증가 버프 = 브루저. **페이지**: 위협 디버프로 버티며 방어도 축적 → **바디 슬램(방어 비례 피해)** 카운터. **스피어맨**: 하이퍼바디·아이언월 유지/리치형.
- **🗡️ 도적 (단검·독, Silent 차용)** — 표창 난사 / 독 / 교활·버림. **어쌔신**(표창·크리·흡혈) / **시프**(단검 난타·독). *형 구현 완료(Silent 86장)*.
- **🔮 법사 (약체·게이지, Defect 차용)** — **위자드(불/독)**: 독을 묻히고 *독 걸린 적에 불 카드 → 추가 데미지*(독뎀 시너지). **위자드(썬/콜)**: 오브로 썬더(다중 공격)·콜드(빙결=취약+피해), 오브 획득·다중 소모 운용. **클레릭**: 오브 없이 회복·버프 + 언데드엔 힐로 공격하는 보조 힐러.
## 게임 프레임워크 현황 ## 게임 프레임워크 현황
`Global/common.gamelogic``/common` 엔티티에 부착된 세 컴포넌트가 전투의 핵심입니다. **StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다 (게임 시작 시 MainMenu 없이 바로 로비로 진입):
| 컴포넌트 | 역할 | ```
|---|---| 로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
| `SlayCardCatalog` | 카드 데이터, 시작 덱 구성, 보상 풀, 카드 복제 정의 | → 전투/엘리트/상점/휴식/유물 방 → 보상·전직·덱 성장 → 보스 → 다음 막
| `SlayRunState` | HP·골드·층수·덱·유물·카드 보상 등 런(run) 영속 데이터 관리 | → 런 클리어(승천 해금) → 로비 복귀(영혼 정산) → 다음 런 …
| `SlayCombatManager` | 턴 진행, 드로우/버림/소멸 더미, 에너지, 적 의도, 방어도, 데미지, 승패 처리 |
### 프로토타입 흐름
1. `SlayRunState`가 HP 80 · 10장 시작 덱으로 새 런 시작
2. `SlayCombatManager`가 데모 전투 자동 시작
3. 매 플레이어 턴: 에너지 3 회복, 방어도 초기화, 적 의도 갱신, 5장 드로우
4. 카드 사용 시 에너지 소모 → 데미지/방어/드로우/에너지/상태이상 적용 → 버림 또는 소멸
5. 턴 종료 시 손패 버림, 적 의도 실행, 상태이상 처리, 다음 턴 시작
6. 전투 승리 시 잔여 HP 저장, 골드 15 지급, 카드 보상 3종 생성
### 유용한 스크립트 호출
`/common` 엔티티에 붙은 스크립트에서:
```lua
self.Entity.SlayCombatManager:PlayCard(1, 1)
self.Entity.SlayCombatManager:EndPlayerTurn()
self.Entity.SlayCombatManager:DebugPlayFirstPlayable()
self.Entity.SlayRunState:PickReward(1)
self.Entity.SlayCombatManager:StartCombat("elite")
``` ```
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 참조. 게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. **UI는 메이커 저작**(7개 UIGroup: Default/Select/Lobby/Run/Deck/Popup/Toast)이고, 컨트롤러가 엔티티 경로(`/ui/<UIGroup>/<Hud>/...`)로 내용을 런타임 주입합니다. 생성기 `tools/deck/gen-slaydeck.mjs`**`SlayDeckController.codeblock` + `common.gamelogic`만 생성**(`.ui` 미접근, 결정적 출력 — `RULES.md` 참조). 게임 데이터는 **`data/*.json`**, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
### 구현된 기능 (배포 퀄리티 P1~P15+, PR #34~#79)
| 영역 | 내용 |
|---|---|
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`**또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **121장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. 점선 경로·상태 4단·층 카운터. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
| **유물 19종 / 물약 6종** | 유물: StS 효과 × 메이플 장비 외형, TopBar 아이콘 + 마우스오버 툴팁, 8종 훅. 물약: 승리 40% 드랍·상점·슬롯 메뉴. 보물 방=상자 연출 → 유물+메소 |
| **카드 프레임·등급** | 커스텀 프레임 3종(전사/마법사/도적 × normal/unique/legend), 카드 5개 사이트 통합 레이아웃. 보상 등급 가중 추첨 70/25/5 |
| **영혼(Soul) 메타 성장** | 승천과 별개의 영구 강화 화폐. 2차 전직 상태로 보스 클리어 시 적립 → 로비 영혼 상점 4종 해금(시작 메소 +60·HP +15·덱 정제·시작 유물 +1). **UserDataStorage 영구 저장** |
| **승천(Ascension)** | A1~A10 누적 모디파이어(적 강화·시작 HP 감소·보상 감소). UserDataStorage 유저별 영구 저장, 런 클리어 시 다음 단계 해금 |
| **멀티 act** | **5막** 진행(보스 클리어→다음 막 텔레포트, 맵·인카운터 변경, 적 스케일 `1+(막-1)*0.45`), 5막 클리어 시 런 종료 |
| **경제** | 화폐 표기 **메소**(코인 아이콘), 카드/유물/물약 메소 가격. 내부 식별자는 Gold 유지 |
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
> 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
### 유용한 스크립트 호출
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
```lua
local c = _EntityService:GetEntityByPath("/common").SlayDeckController
-- 로비
c:OnLobbyNpcInteract("run") -- 모험가(런 시작) / "codex"(도감) / "shop"(영혼상점) / "board"(게시판)
c:ShowLobby() -- 로비 맵 복귀 + 상태 초기화
-- 런
c:SelectClass("warrior") -- "warrior" / "bandit" / "magician"
c:StartNewGame() -- 캐릭터 선택 → 런 시작(map01 텔레포트)
c:PickNode("r1c2") -- 맵 노드 선택(절차 생성 그리드 id) / "boss"
c:PlayCard(1) -- 손패 slot 카드 사용
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
c:PickReward(1) -- 보상 카드 1택(0=건너뛰기)
c:BuyCard(1) / c:BuyRelic() / c:BuyPotion() -- 상점 구매(메소)
c:SetJob("fighter") -- 전직 (보스 보상 선택 화면)
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/` 참조.
### 디버그 단축키
개발·QA용 키보드 단축키. **전투 중**(런 활성 + 전투 진행 중)에만 동작합니다.
| 단축키 | 기능 |
|---|---|
| **Ctrl + Shift + C** | **카드 picker** — 직업 전체 카드 패널을 띄우고, 카드를 클릭하면 **즉시 손패에 추가**. 상단 탭(전사/도적/마법사)으로 직업별 카드 풀 전환. 카드 효과·메커니즘 즉석 테스트용 |
| **Ctrl + Shift + E** | **전체 회복 치트** — 체력·에너지를 최대치로 회복 |
> 카드 picker는 메이커 저작 UI `DeckUIGroup/DeckAllHud`(120 슬롯 그리드 + 직업 탭 3종)를 사용하고, 컨트롤러가 런타임에 카드 비주얼·버튼을 바인딩합니다. 구현: 키 바인딩 `tools/deck/cb/boot.mjs`, picker 로직 `tools/deck/cb/deckview.mjs`(`OpenDebugCardPicker`/`OnAllDeckCardButton`), 버튼 바인딩 `tools/deck/cb/deckturn.mjs`(`BindButtons`). 옛 picker UI 생성기 `tools/deck/legacy/hud/deckall.mjs`는 UI 메이커-저작 전환 후 **휴면**(Maker UI가 대체).
### 산출물 재생성
```bash
node tools/deck/gen-slaydeck.mjs # 컨트롤러+common (UI는 메이커 저작 — 미생성)
node tools/map/gen-maps.mjs # map01~05 배경/타일
node tools/map/gen-lobby-map.mjs # 로비 맵 + NPC 배치
node tools/player/gen-lobby-npc.mjs # 로비 codeblock(LobbyNpc·LobbyMobility)
node tools/camera/gen-camera.mjs # 맵별 카메라
node tools/player/gen-player-lock.mjs # 전투맵 입력 잠금
node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
```
> 산출물 검증은 내용 출력 없이 카운트만: `node tools/verify/count.mjs <ui|cb|common> <regex>...` (자세한 가드는 [`RULES.md`](RULES.md)).
--- ---
## 다음 구현 단계 ## 아키텍처 메모
- [ ] HP·방어도·에너지·적 의도·손패 5버튼을 렌더링하는 전투 UI
- [ ] 전투/엘리트/상점/휴식/이벤트/보스 노드를 가진 맵 노드 UI 현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다. **2026-06-17**: UI를 단일 `DefaultGroup`에서 7개 UIGroup(Select/Lobby/Run/Deck 등)으로 분리해 **메이커 저작으로 전환** — 생성기는 더 이상 `.ui`를 만들지 않고, 컨트롤러가 새 UIGroup 경로로 재연결됨(옛 UI emit `hud/*`·`gen-cardhand``tools/deck/legacy/` 휴면). 재연결 무결성은 `tools/verify/cbgap.mjs`(GAP 0)로 검증.
- [ ] `OnCombatStart` / `OnCardPlayed` / `OnTurnStart` / `OnCombatReward` 훅을 가진 유물 시스템
- [ ] 적 행동 패턴을 데이터로 정의 (현재 단순 의도 패턴 → 무브셋) > ⚠️ **전투 규칙과 맵 생성은 Lua(gen-slaydeck 내장)와 JS 미러(sim-balance/rogue-map)로 이중 구현**입니다. 한쪽을 고치면 반드시 다른 쪽도 동기화하고 테스트하세요(`RULES.md` §6).
- [ ] 런 루프 완성 후 저장/불러오기
---
## 향후 개선 계획 (후속 후보)
- [x] 전투 루프 · 런 루프 · 절차 생성 맵 · 상점/휴식/유물 방 · 유물 19종 · 물약 · 버프/디버프 · Power · 전직(전사/법사/도적 2차) · 승천+개인 저장 · 전투 모션 · 커스텀 프레임 · **반복 런·로비 맵·NPC·영혼·메소·카메라 추종 (P1~P15 완료)**
- [x] **UI 메이커-저작 전환** — 단일 DefaultGroup → 7개 UIGroup 분리, 생성기 UI 저작 폐기(`tools/deck/legacy/`), 컨트롤러 경로 재연결(cbgap GAP 0) (2026-06-17)
- [x] **시작 로비 직행 · 캐릭터 선택 UI · 디버그 치트 · map01 로스터 (2026-06-18)** — 게임 시작 시 MainMenu 없이 곧장 로비 진입(MainMenu는 추후 싱글/멀티/종료 메뉴로 재지정); 캐릭터 선택 화면 초상화·직업 설명·선택 테두리·Art 클리핑(MaskComponent) 배선; 디버그 단축키 Ctrl+Shift+C(카드 picker)·Ctrl+Shift+E(체력+에너지 전체 회복); map01 몬스터 18종 로스터(랜덤 행동)
- [ ] **도적 카드명 재서사·설명 한글화** — Silent 직역 카드명을 어쌔신/시프 메이플 스킬명으로 재서사(아이콘은 적용 완료), 2차 전직 설명 한글화
- [ ] **런 이어하기** — 진행 중 런 직렬화 저장(UserDataStorage 확장, 메뉴 "이어하기" 활성화)
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)
- [ ] **3차 전직** — 후반 막 보상으로 확장
- [ ] **궁수 등 추가 클래스** — 캐릭터 선택 슬롯 확장
- [ ] **정밀 밸런싱** — 첫 인카운터 승률 완화·직업별 카드 효율 튜닝(`sim-balance.mjs` 리포트 기반)
- [ ] **상점 보장 규칙** — 막당 상점 최소 1회 등장
- [ ] **연출 보강** — 사운드(타격·획득), 맵 화면에 유물/물약 표시
--- ---
@@ -117,4 +213,5 @@ self.Entity.SlayCombatManager:StartCombat("elite")
``` ```
2. MSW Maker에서 이 폴더를 **로컬 워크스페이스 경로**로 지정해 월드 열기 2. MSW Maker에서 이 폴더를 **로컬 워크스페이스 경로**로 지정해 월드 열기
3. `.mcp.json` / `.codex/` 는 git에 없으므로, 본인 토큰으로 직접 생성 (MCP·Codex 사용 시) 3. `.mcp.json` / `.codex/` 는 git에 없으므로, 본인 토큰으로 직접 생성 (MCP·Codex 사용 시)
4. 작업 전 항상 `git pull`, 작업 후 `git add/commit/push` 4. 작업 전 항상 `git pull` + 메이커 reload, 작업 후 `git add/commit/push`
5. **AI 에이전트(Claude Code 등)로 작업한다면 [`RULES.md`](RULES.md) 필독** — 생성 산출물 접근 금지(토큰 가드)·검증 절차·PR 도구(`tools/git/gitea-pr.mjs`) 규칙. Claude Code는 `CLAUDE.md`가 자동 적용

88
RULES.md Normal file
View File

@@ -0,0 +1,88 @@
# RULES.md — SlayMaple 하네스 엔지니어링 규칙
AI 에이전트(Claude Code 등)와 협업자가 이 저장소에서 **토큰을 낭비하지 않고 안전하게** 작업하기 위한 공용 규칙.
Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된다. 다른 도구(Codex 등)를 쓰면 세션 시작 시 이 파일을 읽혀라.
---
## 1. 생성 산출물은 읽지도, 고치지도 않는다 (가장 중요)
이 저장소의 큰 파일들은 전부 **생성기 산출물**이다. 직접 읽으면 토큰이 증발하고, 직접 고치면 다음 재생성 때 사라진다.
| 산출물 (절대 Read/Edit 금지) | 크기 | 단일 소스 (여기만 편집) | 재생성 명령 |
|---|---|---|---|
| `ui/*.ui` (Default·Select·Lobby·Run·Deck·Popup·Toast UIGroup 7종) | 9KB~4.5MB | **메이커 저작 (생성기 미생성, 2026-06-17~)** — 메이커에서 시각 편집 | (없음) |
| `RootDesk/MyDesk/SlayDeckController.codeblock` | ~270KB | `data/*.json` + `tools/deck/`(`gen-slaydeck.mjs`+`lib/`+`cb/`) | `node tools/deck/gen-slaydeck.mjs` |
| `Global/common.gamelogic` | ~1KB | 〃 | 〃 |
| `map/map01.map`~`map05.map`, `map/lobby.map` | 각 ~210KB | `tools/map/`·`tools/monster/`·`tools/camera/`·`tools/player/` (↓ 보조 생성기) | 해당 생성기 |
| `RootDesk/MyDesk/CombatMonster.codeblock` | ~2KB | `tools/monster/gen-combat-monster.mjs` | `node tools/monster/gen-combat-monster.mjs` |
| `RootDesk/MyDesk/PlayerLock.codeblock` | ~2KB | `tools/player/gen-player-lock.mjs` | `node tools/player/gen-player-lock.mjs` |
| `RootDesk/MyDesk/MapCamera.codeblock` | ~2KB | `tools/camera/gen-camera.mjs` (값: `data/camera.json`) | `node tools/camera/gen-camera.mjs` |
| `RootDesk/MyDesk/LobbyNpc.codeblock`·`LobbyMobility.codeblock` | 각 ~2-3KB | `tools/player/gen-lobby-npc.mjs` | `node tools/player/gen-lobby-npc.mjs` |
| `Global/SectorConfig.config` | ~1KB | `tools/map/gen-maps.mjs`·`gen-lobby-map.mjs` (패치) | 해당 생성기 |
- `.claude/settings.json`의 permissions.deny가 위 파일의 Read/Edit/Write 도구 사용을 차단한다 (이 저장소를 열면 자동 적용). deny는 **glob**`ui/*.ui`·`map/*.map`·`RootDesk/MyDesk/*.codeblock`·`Global/common.gamelogic`·`Global/SectorConfig.config`. 따라서 **메이커 저작 codeblock/UI**(`Monster`·`MonsterAttack`·`PlayerAttack`·`PlayerHit`·`UIPopup`·`UIToast`.codeblock, **그리고 모든 `ui/*.ui`** — UI는 6개 UIGroup으로 메이커 저작)**도** Read/Edit 금지 — 이들은 생성기가 없으니 **메이커에서** 편집한다(텍스트 도구로 X). codeblock은 한 줄짜리 JSON이라 Read 시 토큰 폭발.
- **게임 로직 수정** = `tools/deck/gen-slaydeck.mjs`(오케스트레이터) + `tools/deck/cb/*.mjs`(codeblock Lua) 또는 `data/*.json`(데이터) 수정 → 재생성(`SlayDeckController.codeblock`+`common.gamelogic`만, **`.ui` 미접근**) → 통째로 커밋. **UI 수정 = 메이커에서**(생성기는 UI를 안 만든다).
- **codeblock 메서드(Lua)는 기능별 모듈** `tools/deck/cb/*.mjs`(boot·state·combat·hand·deckview·items·map·shop 등 17종). **공유분**: 상수·데이터·lua 테이블 = `tools/deck/lib/{ui-helpers,data,codeblock}.mjs`(cb가 import — `MAX_MONSTERS`=4 등). prop 103개는 오케스트레이터 `writeCodeblocks`에 유지. 특정 메서드 수정은 `cb/<name>.mjs`만(의존: orchestrator→cb→lib 단방향). **cb 모듈은 원본 메서드 순서 보존이 바이트동일 조건**. **UI emit(옛 `hud/*.mjs` 15종·`gen-cardhand.mjs`)은 `tools/deck/legacy/`로 이관 — 휴면(생성기 미사용)**: UI가 메이커 저작이라 생성기가 안 만든다. (롤백용 `legacy/upsert-ui.mjs`는 직접 실행 시에만 옛 `DefaultGroup.ui`를 재생성.)
- 리팩터 시 **출력 바이트-동일 검증**: `node tools/deck/gen-slaydeck.mjs``node tools/verify/diffcheck.mjs [ref]`(워킹트리 vs ref(기본 HEAD) 줄바꿈 정규화 비교 — 산출물 경로를 명령줄에 노출 안 해 deny 회피). 산출물 ` M`은 보통 autocrlf churn이니 `git checkout --`로 복원.
- **UI 전면 메이커 저작 (2026-06-17~)**: 단일 `DefaultGroup`을 7개 UIGroup으로 분리 — `DefaultGroup`(MainMenu+월드조작), `SelectUIGroup`(charselect/job), `LobbyUIGroup`(lobby/board/soulshop), `RunUIGroup`(combat/map/shop/rest/treasure/reward/cardhand/deck), `DeckUIGroup`(덱 도감), `PopupGroup`·`ToastGroup`. 컨트롤러(`cb/*.mjs`)는 엔티티 **경로**(`/ui/<UIGroup>/<Hud>/...`)로 텍스트·이미지·표시숨김·상태기반 위치/크기/색을 **런타임 주입**(레이아웃=메이커, 내용=컨트롤러 — 메이커가 이 경로 유지 필수). 몬스터 슬롯 = `RunUIGroup/CombatHud/MonsterStatus{1..4}`(자식 Name·Hp·Intent·HpBarFill·Buffs·BlockBadge·TargetMarker; TargetFrame 없음). **부트 흐름**: `OnBeginPlay`→MainMenu→(`MainMenu/NewGameButton`)→로비→run NPC(`OnLobbyNpcInteract` id=="run")→charselect→런. **재연결 검증**: `node tools/verify/cbgap.mjs`(cb 참조 경로↔.ui GAP 0이어야) + 재생성 후 `git status -- ui/` 변경 0(생성기 .ui 미접근 증명). 섹션→UIGroup 일괄 remap 마이그레이션은 `tools/deck/reconnect-ui-paths.mjs`(멱등). UIGroup별 .ui 분포 확인은 `tools/verify/uimap.mjs`.
- **머지 충돌(gen-slaydeck.mjs)**: 다른 브랜치가 단일체를 수정해 충돌나면, 그쪽 버전(`git checkout --theirs tools/deck/gen-slaydeck.mjs`)을 취해 **콘텐츠 마커 기반으로 재모듈화**(라인인덱스 X — 줄 추가에 안전·export 이름 자동 파생·`const x=[]` 직전 전문 상수 walk-back 포함) 후 `node tools/verify/diffcheck.mjs origin/main`으로 ui·codeblock 바이트-동일 확인(손실 0 증명). codeblock 메서드·patchCommon은 오케스트레이터 잔류라 그쪽 변경은 자동 보존됨.
- **보조 생성기**(각자 자기 산출물의 단일 소스 — 위 표의 메인 `gen-slaydeck.mjs` 외):
- `tools/camera/gen-camera.mjs``MapCamera.codeblock` + map01~05 카메라 부착 (값 `data/camera.json`)
- `tools/map/gen-maps.mjs``map02~05` + `Global/SectorConfig.config` (map01 템플릿 클론)
- `tools/map/gen-lobby-map.mjs``map/lobby.map` + `SectorConfig.config`
- `tools/map/gen-map-encounters.mjs` → map01~05 노드 타입별 몬스터 그룹 재구성
- `tools/monster/gen-combat-monster.mjs``CombatMonster.codeblock` + map01~05 부착
- `tools/monster/freeze-turn-monsters.mjs` → 몬스터 `.model`·맵 AI 컴포넌트 제거
- `tools/player/gen-player-lock.mjs``PlayerLock.codeblock` + map01~05 부착
- `tools/player/gen-lobby-npc.mjs``LobbyNpc.codeblock`·`LobbyMobility.codeblock`
- `tools/player/freeze-turn-player.mjs``Global/DefaultPlayer.model` 이동 0 고정
- (옛 `tools/deck/gen-cardhand.mjs`·`hud/*.mjs``tools/deck/legacy/`로 이관 — 휴면, UI 메이커 저작 전환)
## 2. 산출물 검증은 카운트로, 내용 출력 금지
재생성 결과 확인이 필요하면 **본문을 출력하지 말고** 존재/개수만 확인한다:
```bash
node tools/deck/gen-slaydeck.mjs # 성공 메시지 1줄
grep -c "TreasureHud" ui/DefaultGroup.ui # 개수만
grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
```
- ⚠️ codeblock은 **한 줄이 수만 자**(JSON 직렬화)다. `grep`(내용 출력)·`sed -n` 광역 범위 출력은 한 줄만 걸려도 토큰 폭발 → `grep -c`/`grep -l`/`grep -o '짧은패턴'`만 사용.
- Claude Code의 Grep 도구를 산출물에 쓸 때는 `output_mode: count` 또는 `files_with_matches`만. content 모드 금지.
- 진짜 내용 확인이 필요하면 좁은 `grep -o` 또는 `python`으로 슬라이스해서 **수 줄 이내**로.
## 3. 탐색 규칙
- 코드 탐색은 `tools/`·`data/`·`docs/`만 대상으로. 저장소 전체 grep은 산출물이 걸리므로 경로를 지정한다.
- `git add -A``git status --short`로 산출물 외 의도치 않은 변경이 없는지 확인 (산출물 diff는 보지 않는다 — 생성기가 결정적이므로 소스 리뷰로 충분).
- 게임 동작 확인은 메이커 플레이테스트(스크린샷·로그)로 한다. 산출물 정독으로 동작을 추론하지 않는다.
## 4. Git/PR 절차
- 브랜치 → 커밋(기능 단위) → push → **PR은 반드시 `node tools/git/gitea-pr.mjs`로** (인라인 `curl -d` 한글 본문은 Windows에서 CP949로 깨짐 — PR #34~41 사고).
- 제목/본문은 UTF-8 spec JSON 파일로 작성 후 `create <spec.json>` / `merge <번호>`.
- PR 제목과 본문은 한국어로 작성한다.
- 산출물 재생성 커밋은 소스 변경 커밋과 분리하거나, 메시지에 "산출물 재생성"을 명시.
## 5. 메이커(MSW) 연동 주의
- git pull 후 메이커에서 **로컬 워크스페이스 reload** 필수 (안 하면 메이커의 stale 상태가 디스크를 덮어씀).
- 재생성 후 메이커가 켜져 있으면 refresh → 빌드 콘솔 0 에러 확인.
- 카드/유물/물약 이미지는 **공식 maplestory 리소스 RUID**만 (계정 업로드 리소스는 로컬 워크스페이스에서 흰 박스).
## 6. 밸런스·맵 규칙 동기화
전투 규칙과 맵 생성은 Lua(생성기 내장)와 JS가 **이중 구현**이다. 한쪽을 고치면 반드시 다른 쪽도:
| 영역 | Lua (gen-slaydeck.mjs 내) | JS 미러 | 테스트 |
|---|---|---|---|
| 전투 규칙 | PlayCard·CalcPlayerAttack 등 | `tools/balance/sim-balance.mjs` | `node --test tools/balance/sim-balance.test.mjs` |
| 맵 생성 | GenerateMap | `tools/map/rogue-map.mjs` | `node --test tools/map/rogue-map.test.mjs` |
## 7. UI 숫자 표기
- UI 텍스트에서는 정수값인 숫자에 `.0`을 붙이지 않는다. `1.0/1.0`이 아니라 `1/1`처럼 표시한다.
- 생성기 내 Lua UI 코드에서 number 또는 숫자 문자열을 텍스트에 붙일 때는 `FormatNumber` 같은 포맷 헬퍼를 우선 사용한다.
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://ac448e909f89464898708ce232ab8b51",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/ac448e909f89464898708ce232ab8b51/639173152021849887",
"upload_hash": "CCC4771B9353971748EF9BEE32D57F15090CE62C4BA6446B11E7842FC7AFDF1F",
"name": "01_blue_background_clean_1920x1080",
"resource_guid": "ac448e909f89464898708ce232ab8b51",
"resource_version": "6a32dd82c325482f6e2bb455"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://7629b520ced54d508b040681d06741fb",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/7629b520ced54d508b040681d06741fb/639172208899179951",
"upload_hash": "C84DCE36101CF3F05E74F93F9B416E7D08D8B78B699E22CF8A6784994115DDAE",
"name": "Character select bg",
"resource_guid": "7629b520ced54d508b040681d06741fb",
"resource_version": "6a316d1a3d5de2eb0c7d345b"
}
}
}

View File

@@ -0,0 +1,74 @@
{
"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": [
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyId"
},
{
"Type": "string",
"DefaultValue": "\"combat\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "Group"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "RegTries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.RegTries = 0\nlocal eventId = 0\nlocal function reg()\n\tself.RegTries = self.RegTries + 1\n\tlocal c = _EntityService:GetEntityByPath(\"/common\")\n\tif c ~= nil and c.SlayDeckController ~= nil then\n\t\tlocal mapName = \"\"\n\t\tif self.Entity.CurrentMapName ~= nil then\n\t\t\tmapName = self.Entity.CurrentMapName\n\t\tend\n\t\tc.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName)\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.RegTries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(reg, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,60 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://lobbymobility",
"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": "LobbyMobility",
"Language": 1,
"Name": "LobbyMobility",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "Tries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.Tries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.Tries = self.Tries + 1\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil and lp.PlayerControllerComponent ~= nil then\n\t\tlocal pc = lp.PlayerControllerComponent\n\t\tpc.Enable = true\n\t\tpc.FixedLookAt = 0\n\t\tlocal rb = lp.RigidbodyComponent\n\t\tif rb ~= nil then rb.WalkAcceleration = 0.7 end\n\t\tlocal mv = lp.MovementComponent\n\t\tif mv ~= nil then\n\t\t\tmv.InputSpeed = 1.4\n\t\t\tmv.JumpForce = 1.23\n\t\tend\n\t\tlocal cam = lp.CameraComponent\n\t\tif cam == nil then cam = _CameraService:GetCurrentCameraComponent() end\n\t\tif cam ~= nil then\n\t\t\tcam.ZoomRatio = 90\n\t\t\tcam.ConfineCameraArea = false\n\t\t\tcam.ScreenOffset = Vector2(0.5, 0.5)\n\t\t\tcam.CameraOffset = Vector2(0, 0)\n\t\tend\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.Tries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,89 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://lobbynpc",
"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": "LobbyNpc",
"Language": 1,
"Name": "LobbyNpc",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "NpcId"
},
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "MarkName"
},
{
"Type": "boolean",
"DefaultValue": "false",
"SyncDirection": 0,
"Attributes": [],
"Name": "InRange"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.InRange = false\nlocal mark = _EntityService:GetEntityByPath(\"/maps/lobby/\" .. self.MarkName)\nif mark ~= nil then mark:SetVisible(false) end\nself.Entity:ConnectEvent(TouchEvent, function(e)\n\tself:Interact()\nend)\n_InputService:ConnectEvent(KeyDownEvent, function(e)\n\tif self.InRange and e.key == KeyboardKey.UpArrow then\n\t\tself:Interact()\n\tend\nend)\nlocal eventId = 0\nlocal function tick()\n\tlocal lp = _UserService.LocalPlayer\n\tif lp == nil then return end\n\tif mark == nil then mark = _EntityService:GetEntityByPath(\"/maps/lobby/\" .. self.MarkName) end\n\tlocal a = lp.TransformComponent.WorldPosition\n\tlocal b = self.Entity.TransformComponent.WorldPosition\n\tlocal d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y))\n\tlocal near = d < 1.2\n\tif near ~= self.InRange then\n\t\tself.InRange = near\n\t\tif mark ~= nil then mark:SetVisible(near) end\n\tend\nend\neventId = _TimerService:SetTimerRepeat(tick, 0.15)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local c = _EntityService:GetEntityByPath(\"/common\")\nif c ~= nil and c.SlayDeckController ~= nil then\n\tc.SlayDeckController:OnLobbyNpcInteract(self.NpcId)\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "Interact"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,60 @@
{
"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": [
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "CamTries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.CamTries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.CamTries = self.CamTries + 1\n\tlocal cam = nil\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil then\n\t\tcam = lp.CameraComponent\n\tend\n\tif cam == nil then\n\t\tcam = _CameraService:GetCurrentCameraComponent()\n\tend\n\tif cam ~= nil then\n\t\tcam.ZoomRatio = 90\n\t\tcam.ScreenOffset = Vector2(0.5, 0.655)\n\t\tcam.ConfineCameraArea = true\n\t\tcam.CameraOffset = Vector2(1.5, -0.83)\n\tend\n\tif cam ~= nil then\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.CamTries > 30 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,36 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://4a220aa8-e014-4c7b-8234-fff8c5c66686",
"ContentType": "x-mod/codeblock",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"CoreVersion": {
"Major": 0,
"Minor": 2
},
"ScriptVersion": {
"Major": 1,
"Minor": 1
},
"Description": "",
"Id": "4a220aa8-e014-4c7b-8234-fff8c5c66686",
"Language": 1,
"Name": "MapleTree",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [],
"Methods": [],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "directory://e437237972284a70804115d84e29c4ff",
"ContentType": "x-mod/directory",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"entry_id": "directory://e437237972284a70804115d84e29c4ff",
"name": "Models",
"lock": false,
"folding": false,
"nameEditable": true
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "directory://e0797d03936c462dbc92d04c51f91494",
"ContentType": "x-mod/directory",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"entry_id": "directory://e0797d03936c462dbc92d04c51f91494",
"name": "Monsters",
"lock": false,
"folding": false,
"nameEditable": true
}
}
}

View File

@@ -0,0 +1,151 @@
{
"Id": "",
"GameId": "",
"EntryKey": "model://holodragonking",
"ContentType": "x-mod/model",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"Version": 1,
"Name": "HolodragonKing",
"BaseModelId": null,
"Id": "holodragonking",
"Components": [
"MOD.Core.TransformComponent",
"MOD.Core.SpriteRendererComponent"
],
"Properties": [
{
"Type": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector3, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Name": "Position",
"DisplayName": "Position",
"ShowInInspector": true,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.TransformComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "Position"
}
},
{
"Type": {
"$type": "MODNativeType",
"type": "MOD.Core.MODQuaternion, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Name": "Rotation",
"DisplayName": "Rotation",
"ShowInInspector": true,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.TransformComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "Rotation"
}
},
{
"Type": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector3, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Name": "Scale",
"DisplayName": "Scale",
"ShowInInspector": true,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.TransformComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "Scale"
}
},
{
"Type": {
"$type": "MODNativeType",
"type": "System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Name": "renderguid",
"DisplayName": "renderguid",
"ShowInInspector": false,
"Link": {
"Target": {
"$type": "MODNativeType",
"type": "MOD.Core.SpriteRendererComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Property": "SpriteRUID"
}
}
],
"Values": [
{
"TargetType": "MOD.Core.TransformComponent",
"Name": "Position",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector3, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": {
"$type": "MOD.Core.MODVector3, MOD.Core",
"x": 0,
"y": 0,
"z": 0
}
},
{
"TargetType": "MOD.Core.TransformComponent",
"Name": "Scale",
"ValueType": {
"$type": "MODNativeType",
"type": "MOD.Core.MODVector3, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
},
"Value": {
"$type": "MOD.Core.MODVector3, MOD.Core",
"x": 1.35,
"y": 1.35,
"z": 1
}
},
{
"TargetType": "MOD.Core.SpriteRendererComponent",
"Name": "SpriteRUID",
"ValueType": {
"$type": "MODNativeType",
"type": "System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": "3caa4d96acd74517829aa166f10e8a28"
},
{
"TargetType": "MOD.Core.SpriteRendererComponent",
"Name": "SortingLayer",
"ValueType": {
"$type": "MODNativeType",
"type": "System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": "MapLayer0"
},
{
"TargetType": "MOD.Core.SpriteRendererComponent",
"Name": "OrderInLayer",
"ValueType": {
"$type": "MODNativeType",
"type": "System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 20
}
],
"EventLinks": [],
"Children": []
}
}
}

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent", "MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent", "MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent", "MOD.Core.MovementComponent",
"MOD.Core.AIWanderComponent",
"MOD.Core.StateComponent", "MOD.Core.StateComponent",
"MOD.Core.HitComponent", "MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent", "MOD.Core.DamageSkinSpawnerComponent",
@@ -57,10 +56,10 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODQuaternion, MOD.Core", "$type": "MOD.Core.MODQuaternion, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0, "y": 0,
"z": 0.0, "z": 0,
"w": 1.0 "w": 1
} }
}, },
{ {
@@ -186,8 +185,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {
@@ -199,8 +198,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {
@@ -219,7 +218,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 1.0 "Value": 0
}, },
{ {
"TargetType": "MOD.Core.MovementComponent", "TargetType": "MOD.Core.MovementComponent",
@@ -228,7 +227,7 @@
"$type": "MODNativeType", "$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" "type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
}, },
"Value": 0.0 "Value": 0
}, },
{ {
"TargetType": "MOD.Core.MovementComponent", "TargetType": "MOD.Core.MovementComponent",
@@ -239,24 +238,6 @@
}, },
"Value": true "Value": true
}, },
{
"TargetType": "MOD.Core.AIWanderComponent",
"Name": "IsLegacy",
"ValueType": {
"$type": "MODNativeType",
"type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": false
},
{
"TargetType": "MOD.Core.AIWanderComponent",
"Name": "Enable",
"ValueType": {
"$type": "MODNativeType",
"type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": true
},
{ {
"TargetType": "MOD.Core.StateComponent", "TargetType": "MOD.Core.StateComponent",
"Name": "IsLegacy", "Name": "IsLegacy",
@@ -376,8 +357,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {
@@ -389,8 +370,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {
@@ -402,8 +383,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {
@@ -424,8 +405,8 @@
}, },
"Value": { "Value": {
"$type": "MOD.Core.MODVector2, MOD.Core", "$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0, "x": 0,
"y": 0.0 "y": 0
} }
}, },
{ {

View File

@@ -85,7 +85,7 @@
"Name": null "Name": null
}, },
"Arguments": [], "Arguments": [],
"Code": "local monster = self.Entity.Monster\nif not monster then\n\treturn\nend\n\nself.Shape = BoxShape(Vector2.zero, Vector2.one, 0)\n\n-- sprite 사이즈를 가져와 공격 영역으로 사용한다\n_ResourceService:PreloadAsync({self.Entity.SpriteRendererComponent.SpriteRUID}, function()\n\tlocal clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)\n\tlocal firstFrameSprite = clip.Frames[1].FrameSprite\n\tlocal firstSpriteSizeInPixel = Vector2(firstFrameSprite.Width, firstFrameSprite.Height)\n\tlocal ppu = firstFrameSprite.PixelPerUnit\n\n\tself.SpriteSize = firstSpriteSizeInPixel / ppu\n\tself.PositionOffset = (firstSpriteSizeInPixel / 2 - firstFrameSprite.PivotPixel:ToVector2()) / ppu\n\t\n\t_TimerService:SetTimerRepeat(function() \n\t\tif monster.IsDead == false then\n\t\t\tself:AttackNear()\n\t\tend\n\tend, self.AttackInterval)\nend)", "Code": "local monster = self.Entity.Monster\nif not monster then\n\treturn\nend\n\nself.Shape = BoxShape(Vector2.zero, Vector2.one, 0)\n\n-- sprite 사이즈를 가져와 공격 영역으로 사용한다\n_ResourceService:PreloadAsync({self.Entity.SpriteRendererComponent.SpriteRUID}, function()\n\tif _ResourceService:GetTypeAndWait(self.Entity.SpriteRendererComponent.SpriteRUID) ~= ResourceType.AnimationClip then\n\t\treturn\n\tend\n\tlocal clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)\n\tif clip == nil then\n\t\treturn\n\tend\n\tlocal firstFrameSprite = clip.Frames[1].FrameSprite\n\tlocal firstSpriteSizeInPixel = Vector2(firstFrameSprite.Width, firstFrameSprite.Height)\n\tlocal ppu = firstFrameSprite.PixelPerUnit\n\n\tself.SpriteSize = firstSpriteSizeInPixel / ppu\n\tself.PositionOffset = (firstSpriteSizeInPixel / 2 - firstFrameSprite.PivotPixel:ToVector2()) / ppu\n\t\n\t_TimerService:SetTimerRepeat(function() \n\t\tif monster.IsDead == false then\n\t\t\tself:AttackNear()\n\t\tend\n\tend, self.AttackInterval)\nend)",
"Scope": 2, "Scope": 2,
"ExecSpace": 1, "ExecSpace": 1,
"Attributes": [], "Attributes": [],

View File

@@ -0,0 +1,60 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://playerlock",
"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": "PlayerLock",
"Language": 1,
"Name": "PlayerLock",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "LockTries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.LockTries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.LockTries = self.LockTries + 1\n\tlocal pc = nil\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil then\n\t\tpc = lp.PlayerControllerComponent\n\tend\n\tif pc ~= nil then\n\t\tpc.LookDirectionX = 1\n\t\tpc.FixedLookAt = true\n\t\tpc.Enable = false\n\tend\n\tif lp ~= nil then\n\t\tif lp.RigidbodyComponent ~= nil then lp.RigidbodyComponent.WalkAcceleration = 0 end\n\t\tif lp.MovementComponent ~= nil then lp.MovementComponent.InputSpeed = 0; lp.MovementComponent.JumpForce = 0 end\n\tend\n\tif pc ~= nil then\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.LockTries > 30 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://41ad73da083d41b0ae30bf7b86794376",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/41ad73da083d41b0ae30bf7b86794376/639172145413258274",
"upload_hash": "CFC620F96E1621FEE5594456FC8A4157BC6EF0D3E7661C5543293200FD364A85",
"name": "Thumnail",
"resource_guid": "41ad73da083d41b0ae30bf7b86794376",
"resource_version": "6a31544d335c959bb11f45eb"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://2d659478140c4b1c8f37febbb61bdaa0",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/2d659478140c4b1c8f37febbb61bdaa0/639171361295360405",
"upload_hash": "13E6D3B629261148095059F7C1D8EDC012C7A60422FC769ECB574A7C5A75759E",
"name": "archmage(fire_poison)",
"resource_guid": "2d659478140c4b1c8f37febbb61bdaa0",
"resource_version": "6a3022013e53f03801a4ac58"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://ddd0a729328f452b9ff0802ab1f6f579",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/ddd0a729328f452b9ff0802ab1f6f579/639171361295458274",
"upload_hash": "7B874B7774FA37E570B16E369112EC467EEA5EC1395A32E4DF01FB6165062E67",
"name": "archmage(thun_cold)",
"resource_guid": "ddd0a729328f452b9ff0802ab1f6f579",
"resource_version": "6a3022012c6a274be88a0819"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://efa920e58d31426486ef974106e7dc8b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/efa920e58d31426486ef974106e7dc8b/639171361295547945",
"upload_hash": "C4B0469A46B70C2356DC8B0F99D36FD9480BFDA5832E251960DD1FF30C201B7F",
"name": "bandit",
"resource_guid": "efa920e58d31426486ef974106e7dc8b",
"resource_version": "6a302201a81bed5f59770e23"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://c357d2daf31a489d95b8fa47e50dd879",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/c357d2daf31a489d95b8fa47e50dd879/639168711224334184",
"upload_hash": "A1639991C7A8CB1025C97E6BE93F618088971DC609F03106C50D8B6EA145F6A2",
"name": "bandit_legend",
"resource_guid": "c357d2daf31a489d95b8fa47e50dd879",
"resource_version": "6a2c16d2cc7e89479f128ffb"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://9487b06867bc46269ed1d855420f457f",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/9487b06867bc46269ed1d855420f457f/639168711224307886",
"upload_hash": "FD9F2140D16EFC7A77F0B09CA230702CA6CA25E3178AAF6ACD63EF07D5C2C83E",
"name": "bandit_normal",
"resource_guid": "9487b06867bc46269ed1d855420f457f",
"resource_version": "6a2c16d23d5de2eb0c7d16a2"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://b3081fb2fb1445fa90b12b01481a78ef",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/b3081fb2fb1445fa90b12b01481a78ef/639168711224396185",
"upload_hash": "1F5218270148F873D060223A617DFC184AEEE61344D26C85705E40EECC086D5E",
"name": "bandit_unique",
"resource_guid": "b3081fb2fb1445fa90b12b01481a78ef",
"resource_version": "6a2c16d21a7908d59b5dc059"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://93e615d645e948f5a76656bfdd9dce15",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/93e615d645e948f5a76656bfdd9dce15/639171361295501823",
"upload_hash": "A5A1263A9E1F5D58B0F985AC58DE7DDE1EA13E0CC5CEE56FC34C3FD792A8D39E",
"name": "bowmaster",
"resource_guid": "93e615d645e948f5a76656bfdd9dce15",
"resource_version": "6a302201a0766b148f66ec2c"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://3c752ffcd4984dcb9f04baab06544f02",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/3c752ffcd4984dcb9f04baab06544f02/639171361295496567",
"upload_hash": "ED1ABC3DBCB04FCBD365BAD0408C8CA1D2458FB337DBDEB4A7EC5DF1BAC32496",
"name": "cleric",
"resource_guid": "3c752ffcd4984dcb9f04baab06544f02",
"resource_version": "6a302201d03493c632770e41"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://e207e6839a4a4bd0aab681bd296a609a",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/e207e6839a4a4bd0aab681bd296a609a/639171361295401732",
"upload_hash": "D5931943781C46611D43595594234BFB133F8BCCAA920C13BCA7E2F8AC9C09D0",
"name": "darkknight",
"resource_guid": "e207e6839a4a4bd0aab681bd296a609a",
"resource_version": "6a302201644d4c175c75d435"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://0efbf37bb7414aea82b257781068372b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/0efbf37bb7414aea82b257781068372b/639171361295431877",
"upload_hash": "DE22318162E1F93E7B90A0B6A59BE31FC76DEEC122914979915D6D575A4D99B5",
"name": "hero",
"resource_guid": "0efbf37bb7414aea82b257781068372b",
"resource_version": "6a302201c377d9630d82c463"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://fd460e6ee38a40e3b6b05580d00773b6",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/fd460e6ee38a40e3b6b05580d00773b6/639171361295408991",
"upload_hash": "B832DE85217EA6544BA4477F0DBA5D2148E4E392A170944336E0DA74E8D41961",
"name": "hunter",
"resource_guid": "fd460e6ee38a40e3b6b05580d00773b6",
"resource_version": "6a3022013d5de2eb0c7d2a51"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://3b9ea1f066a744bb859df47fef817277",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/3b9ea1f066a744bb859df47fef817277/639171361295599801",
"upload_hash": "770C9D83241F8CE2C0EA8B742887D8351A580E2DE15A6380380653855A974F14",
"name": "mage",
"resource_guid": "3b9ea1f066a744bb859df47fef817277",
"resource_version": "6a302201cc7e89479f12a1ac"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://cff71f2e472041ce80c6fbd296f42e2d",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/cff71f2e472041ce80c6fbd296f42e2d/639168711224422293",
"upload_hash": "040CA63C5D3D52E1661F3A008D229A182B1A7C09D919BB74E23D1305B1AA56A7",
"name": "mage_legend",
"resource_guid": "cff71f2e472041ce80c6fbd296f42e2d",
"resource_version": "6a2c16d2a0766b148f66d799"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://d788d09f6f50467ebc67f01dec45f9e2",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/d788d09f6f50467ebc67f01dec45f9e2/639168711224258598",
"upload_hash": "5D91E6E333564F15A76554AE07402DC0ED39ABC02439F65C2CCB818C71FB6994",
"name": "mage_normal",
"resource_guid": "d788d09f6f50467ebc67f01dec45f9e2",
"resource_version": "6a2c16d2e75b0d4ccdfcee8b"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://f5def2e8022b4e59a17d3c16414034fe",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/f5def2e8022b4e59a17d3c16414034fe/639168711224271729",
"upload_hash": "B07075BE5F13B1D7BFB4AE36F4C47D97A10A3CC7EF763F02DD2A11C2AFC61E67",
"name": "mage_unique",
"resource_guid": "f5def2e8022b4e59a17d3c16414034fe",
"resource_version": "6a2c16d2e75b0d4ccdfcee8a"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://994c64290e6d4248bd60aba03a595f72",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/994c64290e6d4248bd60aba03a595f72/639171361295422460",
"upload_hash": "A0B7146F6D8E9D72CEACDB7E21A2CD6EEBD4135554D3965B4E1C06BC98FC1211",
"name": "nightlord",
"resource_guid": "994c64290e6d4248bd60aba03a595f72",
"resource_version": "6a30220139613d284615a1e1"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://415d423954764b659574fe829f9aff52",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/415d423954764b659574fe829f9aff52/639171361329450040",
"upload_hash": "EB94BA457C18E04D87964201DD50042695A3C7F93651CBC8ED9509BDDE07F331",
"name": "palladin",
"resource_guid": "415d423954764b659574fe829f9aff52",
"resource_version": "6a302205a0766b148f66ec2d"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://94d6417c55da48e9861964c405991219",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/94d6417c55da48e9861964c405991219/639171361329410075",
"upload_hash": "EBEC4B43CDBB1759C0BC92C051252A7DF84E6B89C9C56ECBFEFF543A0E260889",
"name": "pirate",
"resource_guid": "94d6417c55da48e9861964c405991219",
"resource_version": "6a3022052c6a274be88a081a"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://477679b832b44e099a30e4905078dbcb",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/477679b832b44e099a30e4905078dbcb/639172226341792721",
"upload_hash": "3E30B07C24C4BC4E373CDEA653035146D2F50ACC6484F6E9DA34E6179BB38F15",
"name": "restBgImage",
"resource_guid": "477679b832b44e099a30e4905078dbcb",
"resource_version": "6a3173ea002bbe95706406b6"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://06c1060586e3457f897f2c596eb5cd71",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/06c1060586e3457f897f2c596eb5cd71/639171361329387468",
"upload_hash": "42EE2E63508762E86391486107A23322E11038070F48851D03FE91255CF41596",
"name": "shadower",
"resource_guid": "06c1060586e3457f897f2c596eb5cd71",
"resource_version": "6a302205a81bed5f59770e24"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://28f3b10ac0334fbfbf29677bf963c57a",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/28f3b10ac0334fbfbf29677bf963c57a/639172222414073214",
"upload_hash": "01BE0B58F480BA86DA1D18BFE25C01E1B27219A14FE2DCD73456A7A48553CF15",
"name": "shopBgImage",
"resource_guid": "28f3b10ac0334fbfbf29677bf963c57a",
"resource_version": "6a3172612c6a274be88a130e"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://b2e099f2e5334705af122e3f88840ba7",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/b2e099f2e5334705af122e3f88840ba7/639171361329408374",
"upload_hash": "2AB66279D07E64A17CD2AD05BB03F732632805B0076278EC94F51C0227341CC5",
"name": "singung",
"resource_guid": "b2e099f2e5334705af122e3f88840ba7",
"resource_version": "6a302204c377d9630d82c464"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://dd6193fd37da4b12bcdbcdcf2fbe8e40",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/dd6193fd37da4b12bcdbcdcf2fbe8e40/639172228832890845",
"upload_hash": "3EDD046B291806637ADD12A77BF94CF00BDD9F4F9912C132B14323D9DE5F297C",
"name": "treasureBgImage",
"resource_guid": "dd6193fd37da4b12bcdbcdcf2fbe8e40",
"resource_version": "6a3174e32a2802c06419f288"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://6d741a60c60743cb98ee740a1e2dbfed",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/6d741a60c60743cb98ee740a1e2dbfed/639168711258121433",
"upload_hash": "80E2D8FFC8E56ECE4694BED5A387413F49633841DD37B7AA9FA9AC713962602C",
"name": "warior_legend",
"resource_guid": "6d741a60c60743cb98ee740a1e2dbfed",
"resource_version": "6a2c16d59c3c6c308bfd4bf8"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://4bb57ef88ef449fdaf958f6cf37fe44b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/4bb57ef88ef449fdaf958f6cf37fe44b/639168711258172386",
"upload_hash": "2832F3B6C4C9530DE69DF061E590FD314D8646BE6BDB65931AFBE68D38DBB0ED",
"name": "warior_normal",
"resource_guid": "4bb57ef88ef449fdaf958f6cf37fe44b",
"resource_version": "6a2c16d53d5de2eb0c7d16a3"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://4f71c124c8bc4e13b5e9fad392995f68",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/4f71c124c8bc4e13b5e9fad392995f68/639168711257524633",
"upload_hash": "F27B2D713455FB18C10E924D381C4582E5E039D5DCCA9CECD2FB0D8E9A4C8135",
"name": "warior_unique",
"resource_guid": "4f71c124c8bc4e13b5e9fad392995f68",
"resource_version": "6a2c16d5a43134e1b8a34b65"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://28c88fdc5ab44f34a8b3fc1e19d4ce78",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/28c88fdc5ab44f34a8b3fc1e19d4ce78/639171361330139251",
"upload_hash": "2929F452FBB26215631886FFB430EE6035D55EB42B1770E880C1B0A34D97BDA0",
"name": "warrior",
"resource_guid": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
"resource_version": "6a30220539613d284615a1e2"
}
}
}

8
data/camera.json Normal file
View File

@@ -0,0 +1,8 @@
{
"zoomRatio": 90,
"screenOffsetX": 0.5,
"screenOffsetY": 0.655,
"confineCameraArea": true,
"cameraOffsetX": 1.5,
"cameraOffsetY": -0.83
}

39
data/cardframes.json Normal file
View File

@@ -0,0 +1,39 @@
{
"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",
"bandit": "bandit",
"curse": "bandit",
"shiv": "bandit",
"poisoner": "bandit",
"trickster": "bandit"
},
"rewardWeights": {
"normal": 70,
"unique": 25,
"legend": 5
}
}

1363
data/cards.json Normal file

File diff suppressed because it is too large Load Diff

7
data/characters.json Normal file
View File

@@ -0,0 +1,7 @@
{
"portraits": {
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
"magician": "3b9ea1f066a744bb859df47fef817277",
"bandit": "efa920e58d31426486ef974106e7dc8b"
}
}

185
data/enemies.json Normal file
View File

@@ -0,0 +1,185 @@
{
"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 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"slime_boss": {
"name": "슬라임 킹",
"maxHp": 120,
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 12 },
{ "kind": "Debuff", "effect": "vuln", "value": 2 },
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 22 }
]
},
"orange_mushroom": {
"name": "주황버섯",
"maxHp": 16,
"intents": [
{ "kind": "Attack", "value": 5 },
{ "kind": "Attack", "value": 5 },
{ "kind": "Defend", "value": 4 },
{ "kind": "Attack", "value": 8 }
]
},
"blue_mushroom": {
"name": "파란버섯",
"maxHp": 22,
"intents": [
{ "kind": "Attack", "value": 4 },
{ "kind": "Attack", "value": 4 },
{ "kind": "Attack", "value": 10 },
{ "kind": "AddCard", "card": "Wound", "count": 1 }
]
},
"pig": {
"name": "돼지",
"maxHp": 18,
"intents": [
{ "kind": "Attack", "value": 6 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 5 }
]
},
"green_mushroom": {
"name": "초록버섯",
"maxHp": 20,
"intents": [
{ "kind": "Attack", "value": 7 },
{ "kind": "Defend", "value": 3 },
{ "kind": "Attack", "value": 9 }
]
},
"red_snail": {
"name": "빨간 달팽이",
"maxHp": 14,
"intents": [
{ "kind": "Attack", "value": 5 },
{ "kind": "Defend", "value": 6 },
{ "kind": "Attack", "value": 7 }
]
},
"stump": {
"name": "나무토막",
"maxHp": 19,
"intents": [
{ "kind": "Defend", "value": 5 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Attack", "value": 6 }
]
},
"mushmom": {
"name": "머쉬맘",
"maxHp": 75,
"intents": [
{ "kind": "Defend", "value": 10 },
{ "kind": "Debuff", "effect": "weak", "value": 2 },
{ "kind": "Attack", "value": 16 },
{ "kind": "Attack", "value": 9 },
{ "kind": "Defend", "value": 6 },
{ "kind": "AddCard", "card": "Burn", "count": 1 }
]
},
"modified_snail": {
"name": "변형된 달팽이",
"maxHp": 60,
"intents": [
{ "kind": "Attack", "value": 12 },
{ "kind": "Defend", "value": 8 },
{ "kind": "Attack", "value": 7 },
{ "kind": "Attack", "value": 14 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"king_slime": {
"name": "킹 슬라임",
"maxHp": 130,
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 14 },
{ "kind": "Debuff", "effect": "vuln", "value": 2 },
{ "kind": "Attack", "value": 12 },
{ "kind": "Attack", "value": 24 }
]
},
"octopus": {
"name": "문어",
"maxHp": 15,
"intents": [
{ "kind": "Attack", "value": 5 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 4 }
]
},
"kapa_drake": {
"name": "카파 드레이크",
"maxHp": 24,
"intents": [
{ "kind": "Attack", "value": 9 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 6 },
{ "kind": "Attack", "value": 11 }
]
},
"junior_neki": {
"name": "주니어 네키",
"maxHp": 18,
"intents": [
{ "kind": "Attack", "value": 6 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"junior_bugi": {
"name": "주니어 부기",
"maxHp": 20,
"intents": [
{ "kind": "Attack", "value": 7 },
{ "kind": "Defend", "value": 5 },
{ "kind": "Attack", "value": 9 }
]
},
"dile": {
"name": "다일",
"maxHp": 65,
"intents": [
{ "kind": "Attack", "value": 13 },
{ "kind": "Defend", "value": 9 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Attack", "value": 16 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"mano": {
"name": "마노",
"maxHp": 80,
"intents": [
{ "kind": "Defend", "value": 12 },
{ "kind": "Attack", "value": 14 },
{ "kind": "Debuff", "effect": "vuln", "value": 1 },
{ "kind": "Attack", "value": 10 },
{ "kind": "AddCard", "card": "Wound", "count": 1 }
]
}
},
"activeEnemy": "slime",
"simEncounter": ["orange_mushroom", "orange_mushroom", "blue_mushroom"]
}

11
data/nodeicons.json Normal file
View File

@@ -0,0 +1,11 @@
{
"icons": {
"combat": "f98db6823e894a4f90308d61f75894ac",
"elite": "793ed8a757534b89a82f460747d2df24",
"boss": "423056cdbbc04f4da131b9721c404d96",
"shop": "da37e1fac55d455b9ade08569f09f798",
"rest": "b86c1b0568bd45f3ae4a4b97e1b4a594",
"treasure": "f8a6d58e20f54e2ca899485055df1ce4"
},
"background": "ef89906dd9844fcbaafc0b2313812eca"
}

14
data/potions.json Normal file
View File

@@ -0,0 +1,14 @@
{
"potions": {
"redPotion": { "name": "빨간 포션", "desc": "HP 20 회복", "effect": "heal", "value": 20, "icon": "393e2a0d8da544899eaa8b22c97f832b" },
"firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "7ddb464c2574456289a4eb72ce86f193" },
"warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "7cfbd410581e4073815daaf5f3e6c72f" },
"guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "8f8402dfa0f746e18bf606ed74302c0a" },
"manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "ec2778c366f6477ab0f8e7f06bcd73f4" },
"cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "a9a2763fdb6849dcba3028c737487680" }
},
"dropChance": 0.4,
"baseSlots": 3,
"beltSlots": 5,
"shopPrice": 20
}

30
data/relics.json Normal file
View File

@@ -0,0 +1,30 @@
{
"relics": {
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6, "icon": "e555b3a62f3c49dbb2c53784e6bd481f" },
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1, "icon": "a41014f28b47434ab9f49ef104523862" },
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1, "icon": "ed64cde7e6c44b9e99502847e54f04e9" },
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 메소 +10", "hook": "combatReward", "effect": "gold", "value": 10, "icon": "03bb05c92b8f45edb0f3dad2e118fd5a" },
"potionBelt": { "name": "장인의 벨트", "desc": "물약 슬롯이 5칸으로 늘어난다", "hook": "passive", "effect": "potionSlots", "value": 5, "icon": "36725b4566ac40d4902e2ab2113c2096" },
"burningBlood": { "name": "자쿰의 투구", "desc": "전투 승리 시 HP 6 회복", "hook": "combatEnd", "effect": "healOnWin", "value": 6, "icon": "07f994825ce34131b419d43e890c878d" },
"vajra": { "name": "미스릴 해머", "desc": "전투 시작 시 힘 +1", "hook": "combatStart", "effect": "strength", "value": 1, "icon": "59d2579d46dc41d590a9e6b141ad458b" },
"anchor": { "name": "메이플 실드", "desc": "첫 턴 방어도 +10", "hook": "combatStart", "effect": "block", "value": 10, "icon": "6349413e08cc49848862591863d056a0" },
"bagOfPrep": { "name": "모험가의 배낭", "desc": "첫 턴 드로우 +2", "hook": "combatStart", "effect": "draw", "value": 2, "icon": "77b240cb8af245b4801a714380267ae9" },
"bloodVial": { "name": "피의 목걸이", "desc": "전투 시작 시 HP 2 회복", "hook": "combatStart", "effect": "heal", "value": 2, "icon": "c782e949506a42c49eb139c7e65527d7" },
"bronzeScales": { "name": "브론즈 체인메일", "desc": "피격 시 공격자에게 3 반사", "hook": "onPlayerDamaged", "effect": "thorns", "value": 3, "icon": "87272346b145412391622cf803f888d1" },
"strawberry": { "name": "건강의 반지", "desc": "획득 시 최대 HP +7", "hook": "passive", "effect": "maxHp", "value": 7, "icon": "58f643e29c354c2783a5ce9a72ec155c" },
"penNib": { "name": "황금 깃펜", "desc": "10번째 공격마다 피해 2배", "hook": "attackCalc", "effect": "penNib", "value": 10, "icon": "4d38d721cc064d14b31b9e9a92754139" },
"boot": { "name": "브론즈 부츠", "desc": "5 미만 공격 피해가 5로", "hook": "attackCalc", "effect": "boot", "value": 5, "icon": "d572b3aa4dac4162aa0d9e551b055dce" },
"akabeko": { "name": "황소 투구", "desc": "전투 첫 공격 피해 +8", "hook": "attackCalc", "effect": "akabeko", "value": 8, "icon": "eb3330a6e2274eff958639f8792119d3" },
"centennialPuzzle": { "name": "백년의 부적", "desc": "전투 첫 피격 시 드로우 3", "hook": "onPlayerDamaged", "effect": "firstLossDraw", "value": 3, "icon": "cfe5ed6556b944fc83ab58b774bb2b73" },
"meatOnBone": { "name": "고기 망치", "desc": "승리 시 HP 50% 이하면 12 회복", "hook": "combatEnd", "effect": "healIfLow", "value": 12, "icon": "a93e8e87f184411c98c96b877d9f8b10" },
"selfFormingClay": { "name": "점토 갑옷", "desc": "피해를 받으면 다음 턴 방어 +3", "hook": "onPlayerDamaged", "effect": "clayBlock", "value": 3, "icon": "bb446793c5204d5db7d33563fe79f648" },
"championBelt": { "name": "챔피언 벨트", "desc": "취약 부여 시 약화 1 추가", "hook": "cardDebuff", "effect": "vulnAddsWeak", "value": 1, "icon": "7ca8c63026034113a561d6adf679fed2" }
},
"startingRelic": "ironHeart",
"relicPool": [
"energyCore", "vampire", "goldIdol",
"potionBelt", "burningBlood", "vajra", "anchor", "bagOfPrep", "bloodVial",
"bronzeScales", "strawberry", "penNib", "boot", "akabeko",
"centennialPuzzle", "meatOnBone", "selfFormingClay", "championBelt"
]
}

102
docs/bandit-card-audit.md Normal file
View File

@@ -0,0 +1,102 @@
# Bandit Card Audit
`bandit` 카드의 구현 상태를 카드별로 정리한 문서입니다.
상태 기준:
- `구현됨`: 공용 필드와 공용 로직으로 처리됨
- `부분구현`: 카드 설명의 일부만 맞음
- `미구현`: 아직 전용 메커니즘이 없음
## 구현됨
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `Pinpoint`
공용 메모:
- `poison`, `innate`, `playableWhenDrawPileEmpty` 구현됨
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne` 구현됨
- `turnStartDraw`, `turnStartDiscard` 구현됨
- `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard` 구현됨
- `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast` 구현됨
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `nextSkillCostZero`, `skillCostReductionThisTurn` 구현됨
## 부분구현
`Ricochet`: 무작위 적 4회 타격이 아니라 일반 분산 공격으로만 처리됨
`Anticipate`: 이번 턴 동안 민첩 2가 아니라 전투 전체 민첩 증가
`PiercingWail`: 이번 턴 적 공격 감소가 아니라 공용 약화/취약 계열만 적용
`Expose`: 방어도/인공물 제거는 없고 취약만 적용됨
`BubbleBubble`: 적이 독을 보유한 경우라는 조건이 아직 없음
`BouncingFlask`: 무작위 적 3번 분산 대신 단일 독 9 처리
## 미구현
`Skewer`: X코스트 연타 공격
`Outbreak`: 독 3번 부여 시 전체 피해 트리거
`Strangle`: 이번 턴 카드 사용마다 추가 피해
`EscapePlan`: 드로우한 카드가 스킬이면 방어도 3
`HandTrick`: 손패의 스킬 카드 하나에 교활 부여
`Mirage`: 모든 적의 독 총합만큼 방어 획득
`UpMySleeve`: 표창 생성 + 비용 감소
`NoxiousFumes`: 턴 시작 전체 적 독 부여 파워
`Accuracy`: 표창 피해 증가 파워
`PhantomBlades`: 표창 보존 + 첫 표창 강화
`Speedster`: 드로우할 때마다 전체 피해
`EchoingSlash`: 처치 시 반복
`TheHunt`: 처치 조건 보상
`Murder`: 이번 전투 동안 뽑은 카드 수 비례 피해
`Malaise`: X코스트 약화/피해 감소
`Pinpoint`: 이번 턴 스킬 비용 감소
`CorrosiveWave`: 드로우할 때마다 독
`BladeOfInk`: 전용 표창 생성
`Burst`: 다음 스킬 1회 추가 사용
`KnifeTrap`: 소멸된 표창 전부 사용
`BulletTime`: 드로우 금지 + 손패 무료 사용
`Accelerant`: 추가 독 발동
`Envenom`: 공격 적중 시 독 부여
`MasterPlanner`: 스킬 사용 시 교활 부여
`Tracking`: 약화된 적이 공격 피해를 2배로 받음
`FanOfKnives`: 표창이 모든 적 대상
`SerpentForm`: 카드 사용할 때마다 무작위 적에게 피해
`WraithForm`: 불가침 2 + 턴 종료 시 민첩 감소
## 다음 축
- 조건부 피해
- 카드 사용 트리거
- 비용/X코스트
- 드로우 연동 파워

View File

@@ -0,0 +1,87 @@
# Card Effect Fields
`data/cards.json`의 카드 효과를 공용 데이터 필드로 표현하는 기준 문서입니다.
## 피해 수치
- `damage`: 기본 피해
- `damagePerOtherHandCard`: 손패의 다른 카드 수만큼 피해 증감
- `damagePerAttackPlayedThisTurn`: 이번 턴에 사용한 공격 카드 수만큼 피해 증감
- `damagePerDiscardedThisTurn`: 이번 턴에 버린 카드 수만큼 피해 증감
- `damagePerSkillInHand`: 손패의 스킬 카드 수만큼 피해 증감
- `otherHandAtLeast`: 손패의 다른 카드가 이 수 이상일 때 조건 충족
- `bonusHitsWhenOtherHandAtLeast`: 조건 충족 시 추가 적중 수
## 방어/상태
- `block`: 방어도 획득
- `cardPlayedBlock`: 카드를 사용할 때마다 방어도 획득
- `blockGainMultiplier`: 이번 턴 동안 얻는 방어도 배수
- `hits`: 다단히트 횟수
- `aoe`: 모든 적 대상
- `pierce`: 방어도 무시
- `draw`: 즉시 드로우
- `drawUntilHandSize`: 손패가 지정 장수에 도달할 때까지 드로우
- `heal`: 즉시 회복
- `gainEnergy`: 즉시 에너지 획득
- `strength`: 힘 획득
- `dex`: 민첩 획득
- `thorns`: 가시 획득
- `selfVuln`: 자신에게 취약 부여
## 상태이상
- `weak`: 약화 부여
- `vuln`: 취약 부여
- `poison`: 중독 부여
`poison`은 적 턴 시작 시 피해를 주고 1 감소합니다.
## 드로우/버리기
- `discard`: 손패에서 지정 장수 버리기
- `discardAll`: 손패 전부 버리기
- `drawPerDiscarded`: 버린 카드 1장당 추가 드로우
- `addShiv`: 표창 생성
- `addShivPerDiscard`: 버린 장수만큼 표창 생성
- `sly`: 버려질 때 교활 발동
- `retain`: 턴 종료 시 해당 카드 보존
## 파워/턴 효과
- `powerEffect: "strengthPerTurn"`
- `powerEffect: "energyPerTurn"`
- `powerEffect: "blockPerTurn"`
- `powerEffect: "retainOne"`
- `turnStartShiv`: 턴 시작 시 표창 생성
- `turnStartDraw`: 턴 시작 시 추가 드로우
- `turnStartDiscard`: 턴 시작 시 카드 버리기
## 다음 턴 예약
- `nextTurnBlock`: 다음 턴 시작 시 방어도 획득
- `nextTurnDraw`: 다음 턴 시작 시 추가 드로우
- `nextTurnKeepBlock`: 다음 턴 시작 시 기존 방어도 유지
- `nextTurnAttackMultiplier`: 다음 턴 공격 피해 배수
- `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수
- `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택
- `nextTurnSelectPrompt`: 선택 UI 문구
- `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦
- `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소
## 기타
- `innate`: 전투 시작 시 첫 손패에 우선 진입
- `playableWhenDrawPileEmpty`: 뽑을 카드 더미가 비었을 때만 사용 가능
- `exhaust`: 사용 후 소멸
- `unplayable`: 사용 불가
- `curse`: 저주 카드
- `token`: 토큰 카드
- `endTurnDamage`: 턴 종료 시 손패에 있으면 피해
## 사용 원칙
- 카드 전용 분기보다 공용 필드를 먼저 쓴다.
- 같은 효과는 같은 필드로 재사용한다.
- 새 카드가 같은 패턴이면 먼저 공용 필드를 추가한다.

31
docs/codex-workflow.md Normal file
View File

@@ -0,0 +1,31 @@
# Codex Workflow
이 저장소에서 작업할 때는 토큰과 변경량을 아끼는 쪽을 기본으로 둔다.
## 작업 원칙
- 이미 확인한 사실은 다시 읽지 않는다.
- 같은 내용을 통째로 지우고 새로 쓰지 않는다.
- 수정은 가능한 한 `apply_patch`로 섹션 단위만 한다.
- 문서는 전체 재작성보다 부분 수정으로 유지한다.
- 카드 구현은 한 번에 하나씩, 공용 필드 우선으로 넣는다.
- 새 기능은 `데이터 1곳 + 런타임 1곳 + 테스트 1곳` 순서로 맞춘다.
## 읽기 원칙
- 파일은 필요한 것만 읽는다.
- 비슷한 파일은 병렬로 한 번에 확인한다.
- 같은 정보를 여러 번 요약하지 않는다.
## 쓰기 원칙
- 공용으로 표현 가능한 효과는 카드 전용 분기로 만들지 않는다.
- 같은 의미의 효과는 같은 필드 이름을 쓴다.
- 문서는 카드별 상태표와 공용 필드 사전을 분리해서 유지한다.
## 응답 원칙
- 중간 보고는 짧게 한다.
- 바뀐 점과 남은 점만 말한다.
- 불필요한 재설명은 줄인다.

86
docs/deck-concept.md Normal file
View File

@@ -0,0 +1,86 @@
# SlayMaple 덱 컨셉 & 직업 스킬셋
> SlayMaple 카드 덱의 **직업별 컨셉 · 메이플 스킬셋 · Slay the Spire 2 차용 매칭** 설계 문서.
> 원칙: 카드 한 장 = **STS2 메커니즘(뼈대) + 메이플 스킬(외형)**. STS 고유 *표현*(카드명·아트·UI)은 모방 금지, *메커니즘*만 차용(IP 해석 심사 대비).
> 수치(데미지·코스트·등급)는 `tools/balance/sim-balance.mjs`로 검증. 본 문서는 *어떤 스킬을 어떤 카드로* 만들지의 설계도.
기준: 메이플 = **클래식(빅뱅 이전)** 스킬 외형, STS = **Slay the Spire 2**.
---
## 직업 ↔ STS2 매칭 요약
| 직업 | 컨셉 | STS2 차용 |
|---|---|---|
| ⚔️ 전사 | 단단한 탱커/브루저 | The Ironclad (힘·방어·소멸) |
| 🗡️ 도적 | 단검 난사 / 독 | The Silent (표창·독·교활) |
| 🔮 법사 | 약체 + 게이지 운용 | The Defect (오브·집중) |
---
## ⚔️ 전사 (Warrior) — HP80 · 탱커
방어를 쌓고 버티다 역공하는 브루저. Ironclad의 두 축을 2차 전직으로 분화.
### 파이터 — 콤보 브루저형 탱커
- **콤보 규칙**: 공격 카드를 **연속으로** 사용하면 콤보가 쌓인다. **방어·파워 등 비공격(Skill/Power) 카드를 사용하면 콤보가 리셋(0)** 된다.
- 콤보가 쌓일수록 **데미지 증가 버프(힘 계열)** 를 받는다 → 방어를 포기하고 공격을 몰아칠수록 강해지는 리스크/리워드.
- 차용: Ironclad 힘 스택/Demon Form + 콤보. 메이플 외형: 콤보 어택·분노·브랜디시.
### 페이지 — 방어 축적 → 바디 슬램 카운터
- **위협**(전체 적 약화+취약 디버프)로 버티며 **방어도를 축적**(아이언 월 등 + 방어 유지 retain).
- **바디 슬램**: 현재 방어도에 비례한 피해로 카운터. 파워 가드(반사) 보조.
- 차용: Ironclad 방어 빌드(Barricade+Entrench→Body Slam). 메이플 외형: 위협·아이언 월·파워 가드.
### 스피어맨 — 유지/리치형
- 하이퍼 바디(최대 HP↑)·아이언 월(방어 유지)·창 리치 광역. 공격 스케일(파이터)·방어 카운터(페이지)와 구분되는 지속 탱.
---
## 🗡️ 도적 (Thief) — HP70 · 단검/독
Slay the Spire *Silent* 차용. **형(codex)이 Silent 88장 완역 포트 + 스킬 아이콘 적용 완료.** 3대 아키타입:
- **표창(Shiv) 난사**: 0코스트 표창 토큰 대량 생성 → 공격마다 연계. (정밀=표창 피해↑, 칼날 부채=표창 전체화)
- **독(Poison)**: 중독 중첩 → 매턴 틱뎀. (유독 가스·발병·촉진제·독 바르기)
- **교활(Sly)·버림(discard)**: 버려질 때 무료 발동, 얇은 덱 빠른 순환.
### 2차 갈래
- **어쌔신** — 표창 난사 + 크리 / 흡혈(드레인) 중심.
- **시프** — 단검 난타(새비지 블로우 = 다단히트) + 독 / 버림 중심.
> 남은 작업: 카드명이 STS 직역(무력화·배신·아드레날린 등) → **어쌔신/시프 메이플 스킬명으로 재서사** + 멀티플레이어 전제 카드(측면 공격·비열함·추적) 싱글 출품용 정리.
---
## 🔮 법사 (Magician) — HP70 · 약체/게이지
몸은 약하나 **게이지(오브) 운용**으로 다중 공격·화력 집중. **오브 메커니즘은 위자드(불/독·썬/콜)에만 적용**, 클레릭은 별도 보조 컨셉.
### 위자드(불/독) — 독 + 불 시너지
- **독을 묻히는 스킬**(포이즌 브레스 등)으로 대상에 독을 부여(독뎀 = DoT).
- **독이 묻은 적에게 불 카드(파이어 애로우 등)를 쓰면 추가 데미지** — *독뎀 상수* 보너스(독 스택/상수 비례).
- 즉 "독 깔기 → 불로 폭발"의 2단 콤보. 불·독 오브로 운용.
### 위자드(썬/콜) — 오브 운용(썬더 다중 / 콜드 빙결)
- **오브로 썬더·콜드를 보유**. **썬더 = 다중 공격 특화**(AoE·다단). **콜드 = 빙결 부여**(빙결 = *취약과 동일 효과* 를 데미지와 함께).
- **오브를 사용하는 만큼 오브를 획득하거나 다중 소모**하는 방식 — 오브 수급/소비 운용이 핵심.
### 클레릭 — 보조(회복·버프) · 오브 없음
- **회복 스킬**(힐)과 **각종 버프**(블레스 등) 중심의 서포트.
- **언데드 계열 몬스터에게는 힐로 공격** 가능 — 보조 힐러 정체성.
> ⚠️ 위자드 오브/게이지·전사 콤보 스택·바디 슬램·독뎀 시너지는 **신규 메커니즘** — `tools/deck/gen-slaydeck.mjs`(전투 규칙) + `tools/balance/sim-balance.mjs`(JS 미러) 양쪽 구현·동기화 필요.
---
## 차용 경계 (IP 심사 대비)
- 차용 OK = **메커니즘**(콤보 스택·방어→피해 전환·독+불 시너지·오브 게이지·빙결=취약 등 시스템).
- 모방 금지 = STS 고유 **표현**(카드명·아트·UI 직접 사용).
- 만점 루트 = STS2 메커니즘을 **메이플 스킬·외형으로 완전 재서사화**.
## 참고
- 카드 데이터 단일 소스: `data/cards.json` (현 122장: 전사 18·마법사 14·도적 88 + Shiv·저주)
- 메이플 스킬 외형 매핑·STS2 캐릭터 상세는 박재오 개인 위키 `프로젝트-메이플-덱빌딩-스킬구성` / `프로젝트-메이플-STS2-차용-덱컨셉` 참조.

22
docs/draw-skill-block.md Normal file
View File

@@ -0,0 +1,22 @@
# 드로우 연동 효과
드로우 결과를 받아 후속 효과를 처리하는 공용 패턴을 정리합니다.
## 현재 구현
- `draw`: 카드를 뽑음
- `drawUntilHandSize`: 손패가 지정 수치가 될 때까지 뽑음
- `drawSkillBlock`: 이번 카드로 뽑힌 카드 중 스킬 카드마다 방어도를 얻음
## 동작 방식
- 드로우 함수는 이번에 뽑힌 카드 ID 목록을 반환합니다.
- 카드 효과는 그 목록을 보고 조건을 판정합니다.
- 그래서 `EscapePlan` 같은 카드뿐 아니라, 나중에 같은 규칙이 필요한 카드에도 같은 필드를 붙이면 됩니다.
## 예시
- `EscapePlan`
- `draw = 1`
- `drawSkillBlock = 3`

View File

@@ -1,42 +1,84 @@
# SlayMaple Basic Framework # SlayMaple Basic Framework
This project now has a small deckbuilder roguelike foundation inspired by turn-based card combat games. This project has a working single-combat deckbuilder loop inspired by turn-based
card combat games. Card play is wired to real combat state (enemy/player HP,
block, enemy intent, win/lose).
## Components ## Current Components (implemented)
- `SlayCardCatalog`: Defines card data, starter deck composition, reward pool, and card cloning. - `SlayDeckController`: The single combat component, attached to the `/common`
- `SlayRunState`: Owns persistent run data such as HP, gold, floor, deck, relics, and card rewards. entity in `Global/common.gamelogic`. Handles the card-hand UI (draw/discard/
- `SlayCombatManager`: Runs combat turns, draw/discard/exhaust piles, energy, enemy intents, block, damage, victory, and defeat. reshuffle, energy, card-click play), card effects (damage/block), enemy
HP/block/intent, turn flow, and victory/defeat.
- `Monster.codeblock`: A separate field-action monster system (HP, hit event,
respawn). **Not** part of the card combat.
All three components are attached to the `/common` entity in `Global/common.gamelogic`. All card/deck/combat artifacts (`ui/DefaultGroup.ui`,
`RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`) are
**generated from a single source, `tools/gen-slaydeck.mjs`** (deterministic
output — do not hand-edit; change the generator and re-run `node
tools/gen-slaydeck.mjs`).
If the Maker session was already open before these files were added, reopen or reload the local workspace so the new codeblock files are imported into the editor state. If the Maker session was already open before these files changed, reload the
local workspace so the regenerated codeblock/UI files are imported into the
editor state.
## Prototype Flow ## Implemented Combat Loop
1. `SlayRunState` starts a new run with 80 HP and a 10-card starter deck. 1. `StartCombat` initializes player (HP 80, Block 0) and a single enemy
2. `SlayCombatManager` starts a demo combat automatically. (HP 45, Block 0) with a deterministic 3-step intent cycle
3. Each player turn refreshes energy to 3, clears block, rolls enemy intent, and draws 5 cards. (Attack 10 → Attack 6 → Defend 8), then starts the first player turn.
4. Playing a card spends energy, applies damage/block/draw/energy/status effects, then sends the card to discard or exhaust. 2. Each player turn refreshes energy to 3, resets player block, and draws 5
5. Ending the turn discards the hand, resolves enemy intent, ticks statuses, and starts the next turn. cards. The enemy's upcoming intent is shown in advance.
6. Winning combat stores the remaining HP back into the run, grants 15 gold, and generates 3 card reward options. 3. Cards: Strike (damage 6), Defend (block 5), Bash (damage 10). Each card has
numeric `damage`/`block` fields; starter deck is 10 cards.
4. Playing a card spends energy: `Attack` reduces enemy HP (block absorbs
first); `Skill` adds player block. The card moves to the discard pile.
5. Ending the turn discards the hand, runs the enemy turn (executes the current
intent — attack the player or gain block), advances the intent index, then
starts the next player turn.
6. Enemy HP 0 → victory; player HP 0 → defeat. On combat end, input is locked
and a result text is shown (combat-reward hook reserved for the roguelike
meta — see Planned below).
> Player HP (80), enemy HP (45), and intent values (10/6/8) are temporary
> placeholders for loop verification. They will move to per-character /
> per-enemy data (see "Data externalization" under Planned).
## Useful Script Calls ## Useful Script Calls
From a script attached to the same `/common` entity: From the `/common` entity (or a Play Test context):
```lua ```lua
self.Entity.SlayCombatManager:PlayCard(1, 1) local c = _EntityService:GetEntityByPath("/common").SlayDeckController
self.Entity.SlayCombatManager:EndPlayerTurn() c:PlayCard(1) -- play the hand card in the given slot
self.Entity.SlayCombatManager:DebugPlayFirstPlayable() c:EndPlayerTurn() -- end turn → enemy turn → next turn
self.Entity.SlayRunState:PickReward(1) c:StartCombat() -- restart combat (reset state)
self.Entity.SlayCombatManager:StartCombat("elite")
``` ```
## Planned (not yet implemented) — Target Architecture
The originally envisioned component split for the full roguelike. Currently
**none of these exist**; `SlayDeckController` covers only the card combat above.
- `SlayCardCatalog`: Card data, starter deck composition, reward pool, card cloning.
- `SlayRunState`: Persistent run data — HP, gold, floor, deck, relics, card rewards.
- `SlayCombatManager`: Turn flow, draw/discard/exhaust piles, energy, enemy
intents, block, damage, victory, and defeat (the role currently played by
`SlayDeckController`).
## Next Implementation Steps ## Next Implementation Steps
- Add a combat UI that renders HP, block, energy, enemy intent, and 5 hand-card buttons. - [x] Combat UI rendering HP, block, energy, enemy intent, and hand cards
- Add a map node UI with combat, elite, shop, rest, event, and boss node types. (done — `SlayDeckController` + CombatHud).
- Add relic definitions and hooks such as `OnCombatStart`, `OnCardPlayed`, `OnTurnStart`, and `OnCombatReward`. - [x] Card play wired to real damage/block/intent/win-lose (done).
- Add enemy move sets as data instead of the current simple intent pattern. - [ ] Externalize card/enemy data to `data/cards.json` / `data/enemies.json`,
- Add save/load once the run loop is playable end to end. injected by the generator.
- [ ] Monte-Carlo balance simulator `tools/sim-balance.mjs` (requires the data
externalization above).
- [ ] Map node UI with combat, elite, shop, rest, event, and boss node types.
- [ ] Relic definitions and hooks (`OnCombatStart`, `OnCardPlayed`,
`OnTurnStart`, `OnCombatReward`).
- [ ] Enemy move sets as data instead of the current deterministic intent cycle.
- [ ] Run persistence (HP/gold/floor/deck/relics) + save/load once the loop is
playable end to end.

View File

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

View File

@@ -0,0 +1,235 @@
# 카드 슬롯 이미지 적용 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 하단 손패 5번 슬롯(강타)의 외형을 완성형 카드 이미지 `invincible belief.png`("리부트 프로토콜")로 교체한다.
**Architecture:** PNG를 MSW 계정 sprite 리소스로 업로드해 RUID를 발급받고, 그 RUID를 생성기 `gen-cardhand.mjs`의 5번 카드 데이터에 `image` 필드로 넣는다. 생성기는 `image`가 있는 카드를 단색 배경 대신 해당 RUID 스프라이트(흰색 틴트, 180×270)로 만들고 텍스트 자식을 생성하지 않는다. 재생성 후 Maker reload→play 스크린샷으로 검증한다.
**Tech Stack:** MSW 에셋 MCP(`asset_create_account_resource_storage_item`, 2단계 업로드), curl PUT, Node.js 생성기, msw-maker-mcp(reload/play/screenshot).
---
## File Structure
- Modify: `tools/gen-cardhand.mjs` — 카드 빌드 루프에 `image` 분기 추가, 5번 카드에 RUID 부여.
- Modify: `ui/DefaultGroup.ui` — 생성기가 5번 카드를 이미지 스프라이트로 재생성.
- 외부: MSW 계정 리소스 스토리지에 PNG 업로드(저장소엔 RUID만 들어감).
원본 이미지: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\invincible belief.png` (세로 약 2:3, 완성형 카드).
---
### Task 1: 이미지 업로드 및 RUID 확보 (컨트롤러/MCP 실행)
**Files:** 없음 (외부 리소스 생성)
- [ ] **Step 1: 파일 크기 확인**
```bash
node -e "console.log(require('fs').statSync('C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png').size)"
```
출력된 바이트 수를 `<BYTES>`로 사용한다.
- [ ] **Step 2: 업로드 1단계 — presigned URL 발급**
MCP `asset_create_account_resource_storage_item` 호출:
- `category`: `sprite`
- `subcategory`: `etc`
- `name`: `slaymaple_card_reboot_protocol`
- `description`: `SlayMaple 손패 카드 이미지 (리부트 프로토콜)`
- `contentLength`: `<BYTES>`
- `fileUrl`: 생략
응답에서 `presignedUrl``<PRESIGNED_URL>`로 확보.
- [ ] **Step 3: 파일 PUT 업로드**
```bash
curl.exe -X PUT --data-binary "@C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png" "<PRESIGNED_URL>"
```
Expected: HTTP 200 (출력 없음 또는 빈 본문). 오류 시 응답 본문 확인.
- [ ] **Step 4: 업로드 2단계 — 리소스 생성 완료**
MCP `asset_create_account_resource_storage_item` 다시 호출, 이번엔 동일 파라미터 + `fileUrl`: `<PRESIGNED_URL>`.
응답에서 발급된 리소스 **RUID(GUID/DataId)**`<RUID>`로 확보.
- [ ] **Step 5: RUID 검증**
MCP `asset_list_account_resources` (`category`: `sprite`, `subcategory`: `etc`, `searchWord`: `reboot`) 호출 → 방금 만든 리소스가 목록에 있고 RUID가 일치하는지 확인. `<RUID>`를 기록해 Task 2에서 사용.
---
### Task 2: 생성기에 image 분기 추가
**Files:**
- Modify: `tools/gen-cardhand.mjs`
- [ ] **Step 1: 5번 카드 데이터에 image 필드 추가**
`cards` 배열의 마지막 원소(강타)를 다음으로 교체 (`<RUID>`는 Task 1에서 확보한 실제 값):
```js
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK, image: '<RUID>' },
```
- [ ] **Step 2: 카드 빌드 루프를 image 분기로 교체**
`cards.forEach((c, i) => { ... });` 블록 전체(현재 카드 배경 + cost/name/desc 생성)를 다음으로 교체:
```js
cards.forEach((c, i) => {
const cardPath = `/ui/DefaultGroup/CardHand/Card${i + 1}`;
const cardH = c.image ? 270 : CARD_H;
const cardSprite = c.image
? sprite({ dataId: c.image, color: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, type: 0, raycast: true })
: sprite({ color: c.tint, type: 1, raycast: true });
// card background (or full image)
ents.push(entity({
id: guid(g++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: i,
components: [
transform({ parentW: 1020, parentH: 280, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: cardH }, pos: { x: (i - (cards.length - 1) / 2) * CARD_SPACING, y: 0 }, align: ALIGN_CENTER }),
cardSprite,
],
}));
// 이미지 카드는 텍스트 오버레이를 만들지 않는다 (이미지에 이미 포함)
if (c.image) return;
// cost (top-left)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Cost`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 50, y: 50 }, pos: { x: -60, y: 95 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.cost, fontSize: 34, bold: true }),
],
}));
// name (upper-center)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Name`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 50 }, pos: { x: 0, y: 50 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.name, fontSize: 28, bold: true }),
],
}));
// desc (lower-center)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Desc`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 80 }, pos: { x: 0, y: -80 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.desc, fontSize: 22, bold: false }),
],
}));
});
```
- [ ] **Step 3: 스크립트 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add tools/gen-cardhand.mjs
git commit -m "카드 손패 생성기: image 필드 지원 (5번 카드 이미지 적용)"
```
---
### Task 3: 재생성 및 데이터 검증
**Files:**
- Modify: `ui/DefaultGroup.ui`
- [ ] **Step 1: 카드 없는 베이스로 되돌린 뒤 재생성**
직전 카드 커밋(`c9c761d`) 이전 베이스에서 ui를 받아 재생성한다. (생성기는 CardHand가 이미 있으면 no-op이므로 베이스가 필요)
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git checkout 2c39066 -- ui/DefaultGroup.ui
node tools/gen-cardhand.mjs
```
Expected: `Inserted 18 CardHand entities.` (컨테이너 1 + 카드 5 + 텍스트 12 = 18)
- [ ] **Step 2: 5번 카드 = 이미지, 텍스트 없음 / 나머지 4장 텍스트 유지 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=j.ContentProto.Entities;const card5=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card5');const sp=card5.jsonString['@components'][1];const tr=card5.jsonString['@components'][0];console.log('card5 image:', sp.ImageRUID.DataId);console.log('card5 height:', tr.RectSize.y);console.log('card5 has text children:', E.some(e=>e.path.startsWith('/ui/DefaultGroup/CardHand/Card5/')));console.log('card1 has text children:', E.some(e=>e.path.startsWith('/ui/DefaultGroup/CardHand/Card1/')));console.log('total CardHand entities:', E.filter(e=>e.path.includes('CardHand')).length)"
```
Expected:
- `card5 image:``<RUID>` 와 일치
- `card5 height: 270`
- `card5 has text children: false`
- `card1 has text children: true`
- `total CardHand entities: 18`
- [ ] **Step 3: JSON 유효성 + 기존(우리 외) 엔티티 불변**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log('JSON ok')"
```
Expected: `JSON ok`. (Button_Attack/Jump/UIJoystick/UIChat 4개 기본 엔티티는 splice가 끝에만 추가하므로 불변)
---
### Task 4: Maker 시각 검증 (컨트롤러 실행)
**Files:** 없음
- [ ] **Step 1: reload** — msw-maker-mcp `maker_refresh_workspace` (edit 모드). Expected `status: ok`.
- [ ] **Step 2: play**`maker_play`.
- [ ] **Step 3: 로드 확인**`maker_execute_script` (context client):
```lua
local c5 = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card5")
log("CARD5="..tostring(c5 ~= nil))
if c5 ~= nil then local r = c5.SpriteGUIRendererComponent; log("CARD5_IMG="..tostring(r.ImageRUID.DataId)) end
```
→ `maker_logs(kind=normal)` 에서 `CARD5=true`, `CARD5_IMG=<RUID>` 확인. (이미지 미로드 시 `maker_logs(kind=build)` 도 확인)
- [ ] **Step 4: screenshot** — `maker_screenshot` 후 Read로 열어 5번 자리에 "리부트 프로토콜" 카드 이미지가 왜곡 없이 표시되는지 확인. 나머지 4장은 단색 목업 유지.
- [ ] **Step 5: stop** — `maker_stop`.
문제 시(이미지 안 보임/깨짐): subcategory를 `item`으로 바꿔 재업로드하거나, 스프라이트 Type/PreserveSprite를 조정. ui 되돌리기: `git checkout ui/DefaultGroup.ui` 후 Task 3부터 재실행.
---
### Task 5: 최종 커밋
**Files:**
- `ui/DefaultGroup.ui`
- [ ] **Step 1: 디스크 무결성 후 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add ui/DefaultGroup.ui
git commit -m "5번 카드 슬롯에 리부트 프로토콜 이미지 카드 적용"
```
---
## 검증 요약
- RUID 발급/검증 (asset_list)
- 생성기: `Inserted 18`, 5번=이미지·텍스트없음·270, 나머지 텍스트 유지, JSON 유효
- Maker: Lua로 Card5 이미지 RUID 확인 + 스크린샷 시각 확인

View File

@@ -0,0 +1,217 @@
# 맵 개선(다양한 몬스터 + 타일셋 + 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 표본 시각 확인

View File

@@ -0,0 +1,273 @@
# 맵 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 표본 맵 시각 확인

View File

@@ -0,0 +1,481 @@
# 카드 전투 통합 (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)에서 일치.

View File

@@ -0,0 +1,256 @@
# 덱 컨트롤러 코드리뷰 수정 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', `<lua>`, [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미사용·타일셋교체) 검증 후 포함

View File

@@ -0,0 +1,475 @@
# 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`가 데이터·정책·엔진에서 일치.

View File

@@ -0,0 +1,341 @@
# 카드/적 데이터 외부화 (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)에서 일치.

View File

@@ -0,0 +1,206 @@
# 다음 층 / 멀티 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 사용.

View File

@@ -0,0 +1,213 @@
# 맵별 고정 카메라 (런타임 설정) 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 타이밍은 재시도 타이머로 흡수.

View File

@@ -0,0 +1,493 @@
# 분기 맵 노드 진행 (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에서 일치.

View File

@@ -0,0 +1,400 @@
# 유물 (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 사용.

View File

@@ -0,0 +1,438 @@
# 런 루프 코어 (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) 일치.

View File

@@ -0,0 +1,488 @@
# 상점/휴식 노드 (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 사용 일치.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
# 노드 타입별 몬스터 그룹 구현 계획
> **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는 기존과 동일(검증됨).

View File

@@ -0,0 +1,160 @@
# 막별 맵 전환 + 맵별 인카운터 (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 검증).

View File

@@ -0,0 +1,212 @@
# 메이플 스킬 카드 비주얼 (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 본문은 구현 시 현재 코드를 읽고 지정 부분만 치환(앵커 명시됨).

View File

@@ -0,0 +1,289 @@
# 전투 연출 (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 스윕 기준 — 실측 튜닝 가능).

View File

@@ -0,0 +1,462 @@
# 전투 화면 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<items.length;i++)for(let j=i+1;j<items.length;j++)if(hit(items[i],items[j])){bad++;console.log('OVERLAP',items[i].p,items[j].p);}console.log(bad===0?'no overlap ✓':'OVERLAPS:'+bad)"`
Expected: `no overlap ✓`
- [ ] **Step 4: sim 회귀**`node --test tools/balance/sim-balance.test.mjs` → 14/14
- [ ] **Step 5: Commit**`git add ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock && git commit -m "feat(combat-ui): UI 정비 산출물 재생성"`
---
## Task 7: 메이커 플레이테스트 (수동/MCP — 컨트롤러 주도)
maker MCP: refresh → build log 0 → play. `execute_script`로 상태 전환하며 스크린샷:
1. 초기(menu): 메인메뉴만, 전투 HUD 비침 없음
2. `s:ShowCharacterSelect()` → 캐릭터선택만
3. `s.SelectedClass="warrior"; s:StartNewGame()` → 맵(MapHud만)
4. `s:PickNode("A")` → 전투: TopBar(막/골드/유물/모든덱보기)·에너지 오브(좌)·턴종료(우)·플레이어 패널(HP바)·타겟 프레임(1번 슬롯 골드 하이라이트)
5. `s:SetTarget(2)` → 프레임 이동 확인, `s:PlayCard(<방어 슬롯>)` → 방어 뱃지 표시
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 블록 삭제 누락하면 중복(무해하나 정리).

View File

@@ -0,0 +1,79 @@
# 시스템 갭 보완 (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로 결정적으로 판별.

View File

@@ -0,0 +1,23 @@
# 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(특정 클라 응답) ✓ / 시뮬 비대상 명시 ✓

View File

@@ -0,0 +1,377 @@
# 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 = { "<ruid1>", "<ruid2>", "..." }
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 산출물에 의존 (의도된 순서)

View File

@@ -0,0 +1,79 @@
# 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 <spec.json>` → merge → main pull → 메모리 갱신 (slaymaple-build-status에 P13 추가)
## Self-Review
- 설계 전 항목에 대응 Task 존재 ✓ / 코드 블록 placeholder 없음 ✓ / CardFrames·ClassToFrame·rarityForRoll 명칭 일관 ✓ / maker_save 덮어쓰기 주의(설계서 '주의' 절) Task 6에서 refresh만 사용 ✓

View File

@@ -0,0 +1,18 @@
# 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 가드 ✓ / 시뮬 비대상 명시 ✓ / 산출물 검증 카운트만 ✓

View File

@@ -0,0 +1,89 @@
# 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": "<RUID>" },
"Berserk": { "name": "버서크", "cost": 2, "kind": "Power", "class": "fighter", "powerEffect": "energyPerTurn", "value": 1, "selfVuln": 1, "desc": "매턴 에너지 +1, 취약 1 자가", "image": "<RUID>" },
"RisingAttack": { "name": "라이징 어택", "cost": 2, "kind": "Attack", "class": "fighter", "damage": 12, "desc": "피해 12", "image": "<RUID>" },
"ThunderCharge": { "name": "썬더 차지", "cost": 1, "kind": "Attack", "class": "page", "damage": 7, "weak": 1, "desc": "피해 7, 약화 1", "image": "<RUID>" },
"BlizzardCharge": { "name": "블리자드 차지", "cost": 1, "kind": "Attack", "class": "page", "damage": 7, "vuln": 1, "desc": "피해 7, 취약 1", "image": "<RUID>" },
"PowerGuard": { "name": "파워 가드", "cost": 1, "kind": "Skill", "class": "page", "block": 10, "desc": "방어도 10", "image": "<RUID>" },
"Pierce": { "name": "피어스", "cost": 1, "kind": "Attack", "class": "spearman", "damage": 9, "pierce": true, "desc": "피해 9, 방어 무시", "image": "<RUID>" },
"IronWall": { "name": "아이언 월", "cost": 2, "kind": "Skill", "class": "spearman", "block": 12, "desc": "방어도 12", "image": "<RUID>" },
"HyperBody": { "name": "하이퍼 바디", "cost": 1, "kind": "Power", "class": "spearman", "powerEffect": "blockPerTurn", "value": 3, "desc": "매턴 방어도 +3", "image": "<RUID>" }
```
- [ ] **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 전 호출부 갱신 명시 ✓
- 하네스: 산출물 검증 카운트만 ✓

View File

@@ -0,0 +1,38 @@
# 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 시그니처 변경 시 호출부(몬스터·플레이어) 동시 갱신 명시 ✓

View File

@@ -0,0 +1,285 @@
# 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": "<RUID>" },
"firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "<RUID>" },
"warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "<RUID>" },
"guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "<RUID>" },
"manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "<RUID>" },
"cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "<RUID>" }
},
"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) ✓

View File

@@ -0,0 +1,71 @@
# 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_<fid>_<c'>_<k>` 통일 ✓
- 리스크: MapHud 섹션 전체 교체로 guid('map') 재배정 — 섹션 단위 emit이라 안전. RenderMap pairs 순회 제거(그리드 고정 루프) ✓

View File

@@ -0,0 +1,524 @@
# 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=<V>; mv.JumpForce=<J>`(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 = '<gen-maps.mjs BACKGROUNDS 풀에서 타운(헤네시스 등) RUID 1개 복사>'; // 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 = '<Task1 Step2: asset_search로 "!" 말풍선/느낌표 공식 스프라이트 RUID, 못찾으면 NPC와 구분되는 작은 공식 스프라이트>';
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 <spec.json>` (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 검색.

View File

@@ -0,0 +1,227 @@
# 노드 맵 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 <spec.json>`. 제목 "feat: 노드 맵 UI 강화 — 아이콘 노드 + 배경 이미지(nodeicons.json 외부화)".
- [ ] **Step 3:** 사용자에게 PR 번호 보고. (변경 용이성: `data/nodeicons.json` RUID만 바꾸고 `node tools/deck/gen-slaydeck.mjs` 재실행하면 교체됨을 명시.)

View File

@@ -0,0 +1,205 @@
# 직업 선택 캐릭터 이미지 + 뒤로가기 — 구현 계획
> **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 <spec.json>`, 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 필수(산출물 디스크 반영).

View File

@@ -0,0 +1,106 @@
# 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 <spec.json>`, 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 위치 이동(렌더 무관).

Some files were not shown because too many files have changed in this diff Show More