328 Commits

Author SHA1 Message Date
34531b184f 도적 카드 공용 효과 추가 2026-06-19 21:59:49 +09:00
f6650a6c70 Merge pull request '도적 카드 공용 효과 추가' (#84) from codex/bandit-effect-pack into main 2026-06-19 03:06:03 +09:00
acf295d56c 도적 카드 공용 효과 추가 2026-06-19 02:57:11 +09:00
9278c47901 Merge pull request '카드 설명 키워드 하이라이트와 드로우 연동 공용 효과 추가' (#83) from codex/bandit-effect-pack into main 2026-06-19 01:53:02 +09:00
b2bf1bf4dd 카드 설명 키워드 하이라이트 추가 2026-06-19 01:51:36 +09:00
5da6e8f3aa Merge pull request '밴딧 카드 공용 효과 확장 및 문서 정리' (#82) from codex/bandit-effect-pack into main 2026-06-19 01:36:57 +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
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
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
197 changed files with 279876 additions and 53351 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)"
]
}
}

9
.gitignore vendored
View File

@@ -4,8 +4,11 @@
# Codex CLI 로컬 설정 — Authorization Bearer 토큰 포함
.codex/
.agents/
# Claude Code 로컬 설정
.claude/
# Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조)
.claude/*
!.claude/settings.json
# 개인 스킬(superpowers) 브레인스토밍/계획 산출물 — 로컬 전용, 협업 공유 X (프로젝트 설계 문서 docs/*.md 는 추적 유지)
docs/superpowers/
# === OS / 에디터 잡파일 ===
Thumbs.db
@@ -22,3 +25,5 @@ AGENTS.md
Environment/
McpScreenshots/
*.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.RigidbodyComponent",
"MOD.Core.MovementComponent",
"MOD.Core.AIChaseComponent",
"MOD.Core.StateComponent",
"MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent",
@@ -150,4 +149,4 @@
"Children": []
}
}
}
}

View File

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

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent",
"MOD.Core.AIWanderComponent",
"MOD.Core.StateComponent",
"MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent",
@@ -141,4 +140,4 @@
"Children": []
}
}
}
}

View File

@@ -24,12 +24,7 @@
"map://map03",
"map://map04",
"map://map05",
"map://map06",
"map://map07",
"map://map08",
"map://map09",
"map://map10",
"map://map11"
"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": []
}
}
}

193
README.md
View File

@@ -1,7 +1,7 @@
# SlayMaple
[MapleStory Worlds(MSW)](https://maplestoryworlds.nexon.com/) 기반으로 제작하는 **Slay the Spire 풍 덱빌더 로그라이크** 월드.
턴제 카드 전투, 덱 구성, 보상 선택, 맵 노드 진행을 메이플 월드 위에서 구현하는 것을 목표로 합니다.
로비 마을에서 NPC와 상호작용해 런을 시작하고, 턴제 카드 전투·덱 구성·보상 선택·절차 생성 맵 진행·전직·영혼 메타 성장을 메이플 월드 위에서 구현합니다.
> 이 저장소는 MSW **로컬 워크스페이스(Local Workspace)** 데이터를 git으로 형상관리하기 위한 것입니다.
> 공동작업자는 이 저장소를 통해 월드 데이터를 주고받습니다. (클라우드 공동제작 모드 미사용)
@@ -32,9 +32,10 @@ git push
```bash
git pull
```
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델 파일이 에디터 상태로 반영됩니다.
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델/맵 파일이 에디터 상태로 반영됩니다.
> 💡 같은 파일을 동시에 수정하면 git 충돌이 날 수 있으니, **서로 다른 맵/codeblock/UI를 나눠서** 작업하는 것을 권장합니다.
> ⚠️ git pull 후 reload를 빠뜨리면 메이커의 stale 상태가 디스크를 덮어쓸 수 있습니다. 재생성 후에도 reload → 빌드 콘솔 0 에러 확인.
---
@@ -42,95 +43,166 @@ git pull
```
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/ # 월드 전역 설정 · 공용 모델 · 게임로직
│ ├── common.gamelogic # SlayDeckController 부착 지점 (카드 UI 전투)
│ ├── Player.model # 플레이어 모델
│ ├── *.model # 몬스터 공용 모델
│ ├── common.gamelogic # SlayDeckController 부착 지점 (산출물)
│ ├── DefaultPlayer.model # 플레이어 모델 (턴전투용 이동 정지 freeze 적용)
│ ├── ChaseMonster.model · MoveMonster.model # 몬스터 공용 모델
│ ├── SectorConfig.config # 섹터/맵 등록 (lobby + map01~05 = 6 entries)
│ ├── WorldConfig.config # 월드 설정
│ └── ...
├── RootDesk/
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·모델·타일셋
│ ├── SlayDeckController.codeblock # 카드 UI 컨트롤러 (생성물)
│ ├── Monster.codeblock # 필드 액션 몬스터 (HP·피격·리스폰, 카드 전투와 별개)
│ ├── MonsterAttack.codeblock
│ ├── PlayerAttack.codeblock
│ ├── PlayerHit.codeblock
│ ├── UIPopup.codeblock
│ ├── UIToast.codeblock
│ └── RectTileData_Henesys.tileset
├── map/
│ ├── map01.map ~ map11.map # 맵 11종 (공식 배경 + STS풍 우측 배치)
├── tools/ # 결정적 생성기 (단일 소스)
│ ├── gen-slaydeck.mjs # 카드/덱 UI · SlayDeckController · common 생성
│ ├── gen-cardhand.mjs # 손패 카드 엔티티 생성
── gen-maps.mjs # 맵 생성
├── ui/ # UI 그룹 (Default / Popup / Toast)
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·타일셋
│ ├── SlayDeckController.codeblock # 게임 컨트롤러 (★산출물, 직접 편집 금지)
│ ├── Monster.codeblock · MonsterAttack.codeblock # 필드 액션 몬스터 (카드 전투와 별개)
│ ├── PlayerAttack.codeblock · PlayerHit.codeblock · UIPopup.codeblock · UIToast.codeblock
│ ├── CombatMonster.codeblock # 맵 몬스터 EnemyId 마커 + /common 자기등록
│ ├── MapCamera.codeblock # 맵별 카메라 적용
│ ├── PlayerLock.codeblock # 전투맵 플레이어 입력·이동 잠금
│ ├── LobbyNpc.codeblock # 로비 NPC 상호작용(근접·클릭)
│ └── LobbyMobility.codeblock # 로비 이동·공격 해제 + 카메라 추종
├── map/ # 맵 6종 (산출물)
│ ├── 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/
── 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
```
> ⚠️ **`map/*.map` · `SlayDeckController.codeblock` · `Global/common.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 재생성 때 사라집니다. 게임 로직 변경은 `data/*.json`·`tools/`의 생성기를 고쳐 재생성하세요. **`ui/*.ui`는 메이커 저작**(생성기 미생성)이라 메이커에서만 편집합니다(자세한 규칙은 [`RULES.md`](RULES.md)).
> `.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` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. 모든 카드/덱/전투 관련 산출물(`ui/DefaultGroup.ui` · `RootDesk/MyDesk/SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(직접 편집 금지, 결정적 출력).
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다 (게임 시작 시 MainMenu 없이 바로 로비로 진입):
| 컴포넌트 | 상태 | 역할 |
|---|---|---|
| `SlayDeckController` | ✅ 구현됨 | 카드 손패 UI 전투 — 드로우/버림/재셔플, 에너지, 카드 효과(데미지/방어), 적 HP·방어·의도, 턴 진행, 승패 |
| `Monster.codeblock` | ✅ 구현됨 | 필드 액션 몬스터(HP·피격·리스폰) — 카드 전투와는 **별개** 시스템 |
```
로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
→ 전투/엘리트/상점/휴식/유물 방 → 보상·전직·덱 성장 → 보스 → 다음 막
→ 런 클리어(승천 해금) → 로비 복귀(영혼 정산) → 다음 런 …
```
### 구현된 카드 전투 (단일 전투 루프)
- **카드 손패 UI**: 에너지 3, 매 턴 5장 드로우, 버림 더미·재셔플, 카드 클릭 사용, 종류별 색상.
- **카드 3종**: 타격(피해 6) · 방어(방어도 5) · 강타(피해 10). 각 카드에 `damage`/`block` 수치 필드. 시작 덱 10장.
- **전투 상태**: 플레이어 `HP`/`Block`, 적 `HP`/`Block`/`Intent(의도)`. 적 의도는 **결정적 사이클**(공격10 → 공격6 → 방어8)로 다음 행동을 미리 표시.
- **규칙**: 데미지는 방어도 먼저 차감 후 잔여만 HP에 적용. 플레이어 Block은 턴 시작 시 리셋.
- **턴 흐름**: 카드 사용(`Attack`→적 HP↓, `Skill`→플레이어 Block↑) → 턴 종료 → 적 턴(의도 실행) → 다음 플레이어 턴.
- **승패**: 적 HP 0 → 승리, 플레이어 HP 0 → 패배. 승패 시 입력 잠금 + 결과 표시(전투 보상 훅 자리 = E 예정).
- **UI(CombatHud)**: 적 패널(이름·HP·방어·의도)·플레이어 패널(HP·방어)·승패 결과 텍스트.
게임 전체는 `/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 미러).
> ⚠️ 플레이어 HP(80)·적 HP(45)·의도 수치(10·6·8)는 **루프 검증용 임시 placeholder**입니다.
> 향후 캐릭터 특성별/몬스터별 데이터로 분리할 예정입니다(아래 D 참조).
### 구현된 기능 (배포 퀄리티 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:PlayCard(1) -- 손패 slot 카드 사용
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
c:StartCombat() -- 전투 재시작(상태 초기화)
-- 로비
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
```
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 참조.
밸런스 검증: `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용 키보드 단축키. **전투 중**(런 활성 + 전투 진행 중)에만 동작합니다.
아래는 로그라이크 메타까지 확장했을 때의 **목표 컴포넌트 구조**로, 현재는 미구현입니다. (현재는 `SlayDeckController` 하나가 카드 전투만 담당)
| 컴포넌트 (계획) | 역할 |
| 단축키 | 기능 |
|---|---|
| `SlayCardCatalog` | 카드 데이터, 시작 덱 구성, 보상 풀, 카드 복제 정의 |
| `SlayRunState` | HP·골드·층수·덱·유물·카드 보상 등 런(run) 영속 데이터 관리 |
| `SlayCombatManager` | 턴 진행, 드로우/버림/소멸 더미, 에너지, 적 의도, 방어도, 데미지, 승패 처리 |
| **Ctrl + Shift + C** | **카드 picker** — 직업 전체 카드 패널을 띄우고, 카드를 클릭하면 **즉시 손패에 추가**. 상단 탭(전사/도적/마법사)으로 직업별 카드 풀 전환. 카드 효과·메커니즘 즉석 테스트용 |
| **Ctrl + Shift + E** | **전체 회복 치트** — 체력·에너지를 최대치로 회복 |
> 위 구조로 가더라도 카드/적 데이터는 `tools/`의 결정적 생성기를 단일 소스로 유지하는 방향을 권장합니다.
> 카드 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)).
---
## 다음 구현 단계
- [x] HP·방어도·에너지·적 의도·손패 카드를 렌더링하는 전투 UI **(완료 — `SlayDeckController` + CombatHud)**
- [x] 카드 사용이 실제 데미지/방어/적 의도/승패에 반영되는 단일 전투 루프 **(완료)**
- [ ] 카드/적 데이터를 `data/cards.json` · `data/enemies.json`로 외부화 (D)
- [ ] 전투를 N회 자동 시뮬레이션하는 밸런스 검증 도구 `tools/sim-balance.mjs` (F, D 선행)
- [ ] 전투/엘리트/상점/휴식/이벤트/보스 노드를 가진 맵 노드 UI (E)
- [ ] `OnCombatStart` / `OnCardPlayed` / `OnTurnStart` / `OnCombatReward` 훅을 가진 유물 시스템 (E)
- [ ] 적 행동 패턴을 데이터로 정의 (현재 단순 결정적 의도 사이클 → 무브셋)
- [ ] 런 영속(HP/골드/층/덱/유물) + 저장/불러오기 (E, 루프 end-to-end 후)
## 아키텍처 메모
현재 게임 전체 로직이 `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)로 검증.
> ⚠️ **전투 규칙과 맵 생성은 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회 등장
- [ ] **연출 보강** — 사운드(타격·획득), 맵 화면에 유물/물약 표시
---
@@ -141,4 +213,5 @@ c:StartCombat() -- 전투 재시작(상태 초기화)
```
2. MSW Maker에서 이 폴더를 **로컬 워크스페이스 경로**로 지정해 월드 열기
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

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

View File

@@ -85,7 +85,7 @@
"Name": null
},
"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,
"ExecSpace": 1,
"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
}
}

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"
}
}

View File

@@ -15,7 +15,8 @@
"intents": [
{ "kind": "Attack", "value": 14 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Defend", "value": 10 }
{ "kind": "Defend", "value": 10 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"slime_boss": {
@@ -24,10 +25,161 @@
"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"
"activeEnemy": "slime",
"simEncounter": ["orange_mushroom", "orange_mushroom", "blue_mushroom"]
}

View File

@@ -1,11 +0,0 @@
{
"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": [] }
}
}

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"
]
}

10
docs/attack-poison.md Normal file
View File

@@ -0,0 +1,10 @@
# 공격 적중 독
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
동작:
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
- `Envenom` 같은 카드가 이 필드를 사용합니다.

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`: 턴 종료 시 손패에 있으면 피해
## 사용 원칙
- 카드 전용 분기보다 공용 필드를 먼저 쓴다.
- 같은 효과는 같은 필드로 재사용한다.
- 새 카드가 같은 패턴이면 먼저 공용 필드를 추가한다.

5
docs/card-play-damage.md Normal file
View File

@@ -0,0 +1,5 @@
# 카드 사용 시 피해
`cardPlayedDamage`는 카드를 사용할 때마다 현재 대상에게 체력을 직접 깎는 공용 효과입니다. 방어도는 무시하고, 같은 필드를 다른 카드에도 그대로 붙여 재사용할 수 있습니다.
`cardPlayedRandomDamage`는 같은 시점에 살아 있는 적 하나를 랜덤으로 골라 체력을 직접 깎습니다. `Strangle``SerpentForm` 같은 카드가 이 계열을 씁니다.

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-차용-덱컨셉` 참조.

8
docs/draw-count.md Normal file
View File

@@ -0,0 +1,8 @@
# 전투 드로우 누적
`damagePerCardDrawnThisCombat`은 이번 전투 동안 실제로 뽑힌 카드 수를 기준으로 공격력을 올리는 공용 필드입니다.
적용 예시:
- `Murder`: 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가

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

@@ -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,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,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 위치 이동(렌더 무관).

View File

@@ -0,0 +1,127 @@
# Phase 1b — codeblock 메서드 모듈화 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스.
**Goal:** `gen-slaydeck.mjs` `writeCodeblocks()`의 메서드 161개를 연속-런 모듈 `cb/*.mjs`로 분리하되 출력 `SlayDeckController.codeblock`은 바이트 동일.
**Architecture:** 단방향 의존 orchestrator→cb→lib. method/prop/codeblock 헬퍼+공유상수를 `lib/codeblock.mjs`로. 메서드는 **원본 순서 보존**을 위해 기능 버킷이 아닌 **연속 구간**으로 나눠 `writeCodeblocks`가 순서대로 spread-concat. prop 103개는 오케스트레이터 유지.
**Tech Stack:** Node ESM. 검증 = `diffcheck`(codeblock 바이트 동일) + 미러 `node --test`.
**의존:** Phase 1(#70)의 모듈 gen-slaydeck 위에 스택(`feature/cb-modularization`). #70 머지 후 main에 리베이스.
---
## 🔑 검증 게이트 (모든 Task 공통)
각 추출 후:
```
node tools/deck/gen-slaydeck.mjs
node tools/verify/diffcheck.mjs
```
**합격**: `RootDesk/MyDesk/SlayDeckController.codeblock`(+ui·common) **IDENTICAL**. 워킹트리 ` M`은 autocrlf churn → `git checkout --`로 복원, **산출물은 커밋 안 함**(소스만).
---
### Task 1: `lib/codeblock.mjs` — 헬퍼 + 공유 상수 추출
**Files:** Create `tools/deck/lib/codeblock.mjs`; Modify `tools/deck/gen-slaydeck.mjs`
- [ ] **Step 1:** `lib/codeblock.mjs` 생성. gen-slaydeck.mjs에서 이동:
- 함수: `prop`·`method`·`codeblock` (정의 본문 그대로).
- `writeCodeblocks` 지역 상수 9개(현 `:292-300`): `RUN_LENGTH`(5) `GOLD_PER_WIN`(25) `CARD_PRICE`(30) `REST_HEAL`(30) `RELIC_PRICE`(60) `ACT_COUNT`(5) `ACT_MAPS`(['map01'..'map05']) `LOBBY_MAP`('lobby') `LOBBY_SPAWN`('Vector3(-5, 0.03, 0)'). → writeCodeblocks 본문에서 제거(import로 대체).
- 끝에 `export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };`
- ⚠️ `prop`/`method`/`codeblock`이 다른 헬퍼(없음 — 순수)·데이터를 참조하지 않는지 확인. 참조 시 함께 import.
- [ ] **Step 2:** gen-slaydeck.mjs에 `import { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from './lib/codeblock.mjs';` 추가(기존 lib import 옆).
- [ ] **Step 3:** 검증 게이트 → codeblock IDENTICAL → churn 복원.
- [ ] **Step 4:** 커밋
```
git add tools/deck/lib/codeblock.mjs tools/deck/gen-slaydeck.mjs
git commit -m "refactor(cb): lib/codeblock.mjs로 헬퍼·상수 추출 (출력 바이트 동일)"
```
---
### 메서드 추출 공통 레시피 (Task 2~의 각 런)
`writeCodeblocks``const combat = codeblock('SlayDeckController','SlayDeckController', [<props>], [\n method('OnBeginPlay', …),\n method('ReqLoadAscension', …),\n … 161개 …\n])` 에서 메서드 배열을 런별로 분리:
1. `tools/deck/cb/<name>.mjs` 생성:
```js
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { /* lib/data.mjs 전체 — 자동 파생 */ } from '../lib/data.mjs';
import { /* lib/ui-helpers.mjs 전체 — 자동 파생 */ } from '../lib/ui-helpers.mjs';
export const <name>Methods = [
method('<First>', ``, ),
, // ← 원본 method() 호출 verbatim 이동
method('<Last>', ``, ),
];
```
2. writeCodeblocks의 해당 런 method() 호출 스팬을 제거하고, methods 배열을 spread로 교체:
`codeblock(…, [<props>], [ ...bootMethods, ...stateMethods, …, ...shopMethods ])`.
3. **검증 게이트**(codeblock IDENTICAL) → churn 복원 → 커밋.
4. ⚠️ 메서드가 writeCodeblocks 지역변수/다른 메서드를 **JS레벨로 참조**하면(드묾) undefined throw/diffcheck로 노출 → 그 변수도 lib로 옮기거나 인자화.
- import 이름은 lib export에서 **자동 파생**(누락 방지). 메서드 본문은 Lua 문자열이라 보간(`${RUN_LENGTH}`·`${luaCardsTable(...)}`)만 JS평가.
### 런 → 모듈 경계 (원본 순서, 161개)
| 모듈 | export | 첫 메서드 → 끝 메서드 | 수 |
|---|---|---|---|
| `cb/boot.mjs` | `bootMethods` | `OnBeginPlay``AscStartHpPenalty` | 11 |
| `cb/state.mjs` | `stateMethods` | `HideGameHud``CloseBoard` | 12 |
| `cb/soul.mjs` | `soulMethods` | `ShowSoulShop``ApplySoulUnlocks` | 11 |
| `cb/charselect.mjs` | `charSelectMethods` | `ShowCharacterSelect``SetEntityEnabled` | 5 |
| `cb/run.mjs` | `runMethods` | `StartRun``ReviveMonsterEntity` | 6 |
| `cb/deckturn.mjs` | `deckTurnMethods` | `Shuffle``RenderPiles` | 8 |
| `cb/deckview.mjs` | `deckViewMethods` | `OpenDeckInspect``ApplyAllDeckCardVisual` | 12 |
| `cb/hand.mjs` | `handMethods` | `GetHandSlotX``SelectDiscardSlot` | 18 |
| `cb/combat.mjs` | `combatMethods` | `PlayCard``ContinueAfterBoss` | 20 |
| `cb/jobs.mjs` | `jobMethods` | `ShowJobChoice``SetJob` | 5 |
| `cb/runend.mjs` | `runEndMethods` | `TeleportToActMap``EndRun` | 3 |
| `cb/render.mjs` | `renderMethods` | `BuffsLabel``RenderRun` | 12 |
| `cb/reward.mjs` | `rewardMethods` | `CardPool``PickReward` | 4 |
| `cb/items.mjs` | `itemMethods` | `HasRelic``RenderRelics` | 12 |
| `cb/tooltip.mjs` | `tooltipMethods` | `BuildCardKeywordTooltip``HideTooltip` | 6 |
| `cb/map.mjs` | `mapMethods` | `ShowMap``PickNode` | 7 |
| `cb/shop.mjs` | `shopMethods` | `ShowShop``OpenChest` | 9 |
최종 concat 순서(= 원본): `[ ...bootMethods, ...stateMethods, ...soulMethods, ...charSelectMethods, ...runMethods, ...deckTurnMethods, ...deckViewMethods, ...handMethods, ...combatMethods, ...jobMethods, ...runEndMethods, ...renderMethods, ...rewardMethods, ...itemMethods, ...tooltipMethods, ...mapMethods, ...shopMethods ]`.
---
### Task 2: 런 추출 배치 A (말단부터 — 위험 낮은 순)
**Files:** Create `cb/shop.mjs map.mjs tooltip.mjs items.mjs reward.mjs`; Modify gen-slaydeck.mjs
- [ ] 레시피로 **shop → map → tooltip → items → reward** 추출·검증·커밋(런 1개당 또는 묶음당 게이트 통과 필수).
### Task 3: 런 추출 배치 B
**Files:** Create `cb/render.mjs runend.mjs jobs.mjs combat.mjs`; Modify gen-slaydeck.mjs
- [ ] 레시피로 **render → runend → jobs → combat** 추출·검증·커밋.
### Task 4: 런 추출 배치 C
**Files:** Create `cb/hand.mjs deckview.mjs deckturn.mjs run.mjs`; Modify gen-slaydeck.mjs
- [ ] 레시피로 **hand → deckview → deckturn → run** 추출·검증·커밋.
### Task 5: 런 추출 배치 D (앞부분 — 마지막)
**Files:** Create `cb/charselect.mjs soul.mjs state.mjs boot.mjs`; Modify gen-slaydeck.mjs
- [ ] 레시피로 **charselect → soul → state → boot** 추출·검증·커밋. 완료 후 writeCodeblocks는 props 배열 + `[ ...17 spreads ]` + write만 남아야 함.
---
### Task 6: 마무리 — RULES + 회귀 + PR
**Files:** Modify `RULES.md`
- [ ] **Step 1:** RULES §1의 gen-slaydeck 모듈 설명에 `tools/deck/cb/*.mjs`(메서드)·`tools/deck/lib/codeblock.mjs`(헬퍼·상수) 추가. 단일소스 표/보조 생성기 일관성 유지.
- [ ] **Step 2:** 회귀: `node --test tools/balance/sim-balance.test.mjs` · `node --test tools/map/rogue-map.test.mjs` (exit 0).
- [ ] **Step 3:** 최종 재생성 + 검증 게이트(누적 codeblock IDENTICAL). `git status --short` 산출물 변경 없음.
- [ ] **Step 4:** RULES 커밋 → push → PR(`node tools/git/gitea-pr.mjs create <spec.json>`, UTF-8). PR 제목·본문 한국어(RULES §4).
---
## Self-Review
- **스펙 커버리지**: lib/codeblock(T1) · 메서드 17런 모듈화(T2~5) · prop 유지(범위 명시) · 바이트동일 게이트(공통) · RULES(T6) · 미러회귀(T6). 누락 없음.
- **플레이스홀더**: 런 경계는 첫/끝 메서드로 구체 지정(161개 합), 상수 9개 명시, import 자동 파생. "verbatim 이동"은 리팩터 특성(바이트 검증이 정확성 보장).
- **타입 일관성**: export명(`xMethods`)↔concat spread 일치. lib/codeblock export↔orchestrator/cb import 일치.
- **리스크**: 메서드 JS레벨 외부참조 → diffcheck/throw 즉시 노출, 증분으로 범위 최소. 단방향 의존 순환 없음.

View File

@@ -0,0 +1,169 @@
# 생성기 모듈화 (Phase 1) 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스.
**Goal:** `tools/deck/gen-slaydeck.mjs`(~6,200줄)의 공유 인프라와 UI emit 16종을 `lib/`·`hud/` 모듈로 분리하되 출력 산출물은 바이트 동일로 유지한다.
**Architecture:** 단방향 의존 — `gen-slaydeck.mjs`(오케스트레이터) → `hud/*.mjs`(HUD별 build 함수) → `lib/*.mjs`(헬퍼·상수·데이터). `guid(prefix,n)`가 순수 함수라 모듈화해도 emit 순서만 보존하면 출력 불변. codeblock 메서드는 이번 범위 제외.
**Tech Stack:** Node ESM. 검증 = **바이트 동일 재생성**(git diff 빈 결과) + 미러 `node --test`.
---
## 🔑 검증 게이트 (모든 Task 공통)
각 추출 후 반드시:
```
node tools/deck/gen-slaydeck.mjs # 성공 메시지 1줄, throw 없음
git status --short
```
**합격 기준**: `ui/DefaultGroup.ui`·`RootDesk/MyDesk/SlayDeckController.codeblock`**변경 안 됨**(git status에 안 뜸). `Global/common.gamelogic`` M`이면 LF churn → `git checkout -- Global/common.gamelogic`.
- 만약 ui/codeblock이 ` M`로 뜨면 **추출 중 실수**(참조 누락/순서 변경) → `git diff --stat`로 어느 산출물인지 보고 되돌려 원인 수정. (RULES상 산출물 content는 안 봄 — 소스 diff로 원인 파악.)
---
## 파일 구조 (목표)
```
tools/deck/
gen-slaydeck.mjs # 오케스트레이터: import → 데이터 로드(lib) → upsertUi(hud 호출) → writeCodeblocks → patchCommon
lib/
data.mjs # 데이터 로드·검증·luaXxxTable·frameRuid·게임상수
ui-helpers.mjs # guid/transform/sprite/button/text/entity/scrollLayoutGroup/cardFaceLayout/applySortingOverride
# + UI 상수 + uiPath/sectionRoot/isGeneratedUiEntity/appendUiSection
hud/
deckhud.mjs deckinspect.mjs deckall.mjs combat.mjs reward.mjs map.mjs
shop.mjs rest.mjs treasure.mjs jobchoice.mjs jobselect.mjs mainmenu.mjs
charselect.mjs lobby.mjs board.mjs soulshop.mjs
```
---
### Task 1: `lib/data.mjs` — 데이터·게임상수·lua 테이블 추출
**Files:** Create `tools/deck/lib/data.mjs`; Modify `tools/deck/gen-slaydeck.mjs`(상단 데이터/lua 블록 → import)
- [ ] **Step 1:** `lib/data.mjs` 생성. gen-slaydeck.mjs에서 아래를 **잘라 이동**(정의 본문 그대로):
- 데이터 로드+검증: `CARDS`(:3) `ENEMIES`(:4) `CLASSES`(:7~17) `JOBS`(:19~40) `SOUL_UNLOCKS`(:42~47) `CARDFRAMES`+검증(:57~68) `RARITIES`(:58) `NODEICONS`+검증(:92~96) `CHARS`+검증(:99~103) `CAM`(:105) `RELICS`+검증(:107~) `POTIONS`+검증(:118~)
- 게임 상수: `MAP_ROWS`(:84) `MAP_COLS`(:85) `CHEST_CLOSED_RUID`(:88) `CHEST_OPEN_RUID`(:89)
- 함수: `luaSoulShopTable`(:48) `frameRuid`(:69) `luaFramesTable`(:72) `luaNodeIconsTable`(:78) `luaRelicsTable` `luaPotionsTable` `luaIntentsArray` `luaEnemiesTable` `luaStr` `luaJobsTable` `luaCardsTable` `luaDeckTable`
- 맨 위 `import { readFileSync } from 'node:fs';`, 맨 끝 `export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, NODEICONS, CHARS, CAM, RELICS, POTIONS, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };`
- ⚠️ `luaNodeIconsTable``luaStr`를 쓰므로 `luaStr`도 같이 이동. `luaFramesTable``luaStr` 사용. 상호 참조는 같은 모듈 내라 OK.
- [ ] **Step 2:** gen-slaydeck.mjs 상단에 `import { CARDS, ENEMIES, ... , luaDeckTable } from './lib/data.mjs';` 추가(이동한 정의 위치에).
- [ ] **Step 3:** 검증 게이트 실행 → ui/codeblock 0 변경 확인 → common.gamelogic churn 복원.
- [ ] **Step 4:** 커밋
```
git add tools/deck/lib/data.mjs tools/deck/gen-slaydeck.mjs
git commit -m "refactor(gen): lib/data.mjs로 데이터·lua 테이블 추출 (출력 불변)"
```
---
### Task 2: `lib/ui-helpers.mjs` — UI 헬퍼·상수 추출
**Files:** Create `tools/deck/lib/ui-helpers.mjs`; Modify `tools/deck/gen-slaydeck.mjs`
- [ ] **Step 1:** `lib/ui-helpers.mjs` 생성. gen-slaydeck.mjs에서 이동:
- UI 상수: `UI_FILE`(:190) `COMMON_FILE`(:191) `UI_ROOT`(:192) `GENERATED_UI_SECTIONS`(:193) `UI_APPEND_ORDER`(:211) `DISABLED_STOCK_CONTROLS`(:229) `TRANSPARENT DARK GOLD ATTACK DEFEND SKILL`(:231~236) `DAMAGE_DIGIT_RUIDS`(:237) `DAMAGE_POP_*`(:249~252) `MAX_MONSTERS`(:254) `HEAD_OFFSET_Y`(:255) `HP_BAR_W`(:257) `WHITE`(:258) `CARD_NAME_TEXT CARD_DESC_TEXT`(:259~260) `CARD_W CARD_H CARD_SPACING CARD_XS`(:276~279) `ALIGN_CENTER ALIGN_BOTTOM_CENTER`(:281~282)
- 헬퍼: `cardFaceLayout`(:264) `guid`(:284) `transform`(:292) `sprite`(:317) `button`(:353) `text`(:378) `scrollLayoutGroup`(:405) `popupLayerFor`(:437) `uiOrderFor`(:443) `displayOrderFor`(:452) `applySortingOverride`(:456) `entity`(:472) `uiPath`(:504) `sectionRoot`(:508) `isGeneratedUiEntity`(:512) `appendUiSection`(:516)
- `export { ... }` 전부.
- ⚠️ COMMON_FILE은 patchCommon(:6125)도 사용 → export 필요. UI_APPEND_ORDER·GENERATED_UI_SECTIONS는 upsertUi가 사용.
- [ ] **Step 2:** gen-slaydeck.mjs에 `import { ... } from './lib/ui-helpers.mjs';` 추가.
- [ ] **Step 3:** 검증 게이트 → 0 변경 → churn 복원.
- [ ] **Step 4:** 커밋 `git commit -m "refactor(gen): lib/ui-helpers.mjs로 UI 헬퍼·상수 추출 (출력 불변)"`
---
### HUD 추출 공통 레시피 (Task 3~6에 반복 적용)
각 HUD는 현재 `upsertUi()` 안에서 `const <v> = []; const add = (e) => <v>.push(e); add(entity(...)); …; emit('<Name>', <v>);` 형태다. 추출 절차:
1. `tools/deck/hud/<name>.mjs` 생성: `import { guid, entity, transform, sprite, button, text, GOLD, ... } from '../lib/ui-helpers.mjs';` + 필요한 데이터는 `'../lib/data.mjs'`. `export function build<Name>() { const e = []; const add = (x)=>e.push(x); add(entity(...)); …; return e; }`**본문은 기존 라인 그대로 이동**(emit 호출 줄만 제외).
2. upsertUi에서 해당 블록을 `emit('<Name>', build<Name>());` 한 줄로 치환.
3. **검증 게이트**(ui/codeblock 0 변경) → churn 복원 → 커밋.
4. ⚠️ 옮긴 블록이 upsertUi 지역변수(`byPath`/`ui`/`cards`/`previewIds`)를 참조하면 안 됨(HUD 섹션은 헬퍼·데이터만 씀이 확인됨). 참조 시 바이트 diff로 즉시 드러남 → 그 변수를 인자로 받도록 조정.
추출 대상(순서·소스 라인·emit명·모듈):
| 모듈 | emit | 소스(블록 시작~emit줄) |
|---|---|---|
| `hud/deckhud.mjs``buildDeckHud` | DeckHud | `:693`(`const hud=[]`)~`:808` |
| `hud/deckinspect.mjs``buildDeckInspect` | DeckInspectHud | `:810`~`:942` |
| `hud/deckall.mjs``buildDeckAll` | DeckAllHud | `:944`~`:1097` |
| `hud/combat.mjs``buildCombat` | CombatHud | `:1100`~`:1587` |
| `hud/reward.mjs``buildReward` | RewardHud | `:1589`~`:1681` |
| `hud/map.mjs``buildMap` | MapHud | `:1684`~`:1839` |
| `hud/shop.mjs``buildShop` | ShopHud | `:1841`~`:2038` |
| `hud/rest.mjs``buildRest` | RestHud | `:2040`~`:2095` |
| `hud/treasure.mjs``buildTreasure` | TreasureHud | `:2098`~`:2181` |
| `hud/jobchoice.mjs``buildJobChoice` | JobChoiceHud | `:2184`~`:2229` |
| `hud/jobselect.mjs``buildJobSelect` | JobSelectHud | `:2231`~`:2314` |
| `hud/mainmenu.mjs``buildMainMenu` | MainMenu | `:2316`~`:2616` |
| `hud/charselect.mjs``buildCharSelect` | CharacterSelectHud | `:2437`~`:2617`(`select[0]…enable=false` 포함) |
| `hud/lobby.mjs``buildLobby` | LobbyHud | `:2620`~`:2672` |
| `hud/board.mjs``buildBoard` | BoardHud | `:2675`~`:2727` |
| `hud/soulshop.mjs``buildSoulShop` | SoulShopHud | `:2729`~`:2814` |
⚠️ MainMenu/CharacterSelectHud는 `const menu=[]`(:2316)·`const select=[]`(:2437)로 인접 정의 후 `emit('MainMenu', menu); emit('CharacterSelectHud', select);`가 :2616~2617에 연속. 각각 별 모듈로 분리, emit 두 줄로. `select[0].jsonString.enable=false`(:2596)는 buildCharSelect 내부에서 `e[0].jsonString.enable=false`로.
⚠️ **CardHand 스톡카드 in-place upsert(`:557~691`)는 추출 안 함** — 기존 .ui 엔티티를 변형하는 특수 로직이라 upsertUi에 잔류(import만 정리).
---
### Task 3: HUD 추출 배치 A (말단부터 — 위험 낮은 순)
**Files:** Create `hud/soulshop.mjs board.mjs lobby.mjs charselect.mjs`; Modify gen-slaydeck.mjs
- [ ] **Step 1~4:** 레시피로 **soulshop → board → lobby → charselect** 순서로 하나씩 추출·검증·커밋(HUD 1개당 커밋 1개 권장, 배치로 묶어도 무방). 각 추출 후 검증 게이트 통과 필수.
- charselect는 P15/이번 작업으로 검증된 화면이라 패턴 안정. `e[0].jsonString.enable=false` 처리 확인.
---
### Task 4: HUD 추출 배치 B
**Files:** Create `hud/mainmenu.mjs jobselect.mjs jobchoice.mjs treasure.mjs rest.mjs`; Modify gen-slaydeck.mjs
- [ ] **Step 1~4:** 레시피로 **mainmenu → jobselect → jobchoice → treasure → rest** 추출·검증·커밋.
---
### Task 5: HUD 추출 배치 C
**Files:** Create `hud/shop.mjs map.mjs reward.mjs`; Modify gen-slaydeck.mjs
- [ ] **Step 1~4:** 레시피로 **shop → map → reward** 추출·검증·커밋.
---
### Task 6: HUD 추출 배치 D (대형 — 마지막)
**Files:** Create `hud/combat.mjs deckall.mjs deckinspect.mjs deckhud.mjs`; Modify gen-slaydeck.mjs
- [ ] **Step 1~4:** 레시피로 **deckhud → deckinspect → deckall → combat** 추출·검증·커밋. combat(~487줄)이 가장 크니 마지막. 추출 후 upsertUi는 데이터 준비 + CardHand upsert + `emit('X', buildX())` 16줄 + 병합만 남아야 함.
---
### Task 7: 마무리 — RULES 동기화 + 회귀 + PR
**Files:** Modify `RULES.md`
- [ ] **Step 1:** RULES.md §1 보조 생성기/단일소스 표에 반영: `tools/deck/gen-slaydeck.mjs`(오케스트레이터)·`tools/deck/lib/*.mjs`(공유)·`tools/deck/hud/*.mjs`(HUD별)가 함께 `ui/DefaultGroup.ui`·`SlayDeckController.codeblock`의 단일 소스임을 명시.
- [ ] **Step 2:** 회귀 테스트(무영향 확인)
```
node --test tools/balance/sim-balance.test.mjs
node --test tools/map/rogue-map.test.mjs
```
Expected: 전부 pass(37/0, 9/0).
- [ ] **Step 3:** 최종 재생성 + 전체 검증 게이트 → ui/codeblock 0 변경(누적) 최종 확인. `git status --short`에 산출물 변경 없음.
- [ ] **Step 4:** RULES 커밋 → push → PR(`node tools/git/gitea-pr.mjs create <spec.json>`, UTF-8).
---
## Self-Review
- **스펙 커버리지**: lib/data·ui-helpers(T1,T2) · HUD 16종 모듈화(T3~6) · 바이트 동일 게이트(공통 게이트, 매 Task) · codeblock 제외(범위 명시) · RULES 동기화(T7) · 미러 회귀(T7). 누락 없음.
- **플레이스홀더**: 이동 대상은 라인 범위로 구체 지정, 검증 명령·합격기준 명시, export/import 목록 구체. "본문 그대로 이동"은 리팩터 특성상 코드 재타이핑 대신 정확한 소스 위치 지정(바이트 검증이 정확성 보장).
- **타입 일관성**: build 함수명·emit명·모듈 경로 표로 고정. data.mjs/ui-helpers.mjs export ↔ gen-slaydeck import 일치.
- **리스크**: 상수/헬퍼 참조 누락 → 바이트 diff 또는 throw로 즉시 노출. 증분 추출로 실패 범위 최소화. 단방향 의존(orchestrator→hud→lib)로 순환 없음.

View File

@@ -0,0 +1,64 @@
# 다음 층 / 멀티 act (TODO E6a) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E6a) + 기존 맵/전투 구조.
> 선행: E1~E5 완료. 제외: E6b 저장/불러오기(사용자가 안 함으로 결정 — MSW 저장 API 필요).
## 문제
현재 보스 클리어 = 즉시 런 종료. 로그라이크의 "여러 층(act)을 점점 깊이 진행" 느낌이 없다.
보스 클리어 후 다음 막으로 이어지고, 최종 막 보스에서 진짜 런 클리어가 필요하다.
## 설계
### 파라미터 (생성기 상수)
- `ACT_COUNT = 3` (막 수).
- 적 스케일: `mult = 1 + (Act-1)*0.6` → 1막 ×1, 2막 ×1.6, 3막 ×2.2.
### 상태 재정의
- 기존 `Floor`**현재 막 카운터**(1..ACT_COUNT)로 사용. `RunLength = ACT_COUNT`.
- 맵 내 행 진행은 맵 UI가 표현 → 별도 숫자 표시 없음.
### 메서드 변경
- `StartRun`: `Floor = 1`, `RunLength = ${ACT_COUNT}`. (맵 1회 빌드는 그대로.)
- `StartCombat`: `self.Floor = node.row`**제거**. 적 로드 시 막 스케일 적용:
```lua
local mult = 1 + (self.Floor - 1) * 0.6
self.EnemyMaxHp = math.floor(enemy.maxHp * mult)
self.EnemyHp = self.EnemyMaxHp
self.EnemyIntents = {}
for i = 1, #enemy.intents do
self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) }
end
```
(공유 enemy.intents 변형 금지 — 새 테이블 생성.)
- `CheckCombatEnd` 보스 승리 분기:
```lua
if node ~= nil and node.type == "boss" then
if self.Floor < self.RunLength then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:RenderRun()
self:ShowMap()
else
self:ShowResult("런 클리어!")
self.RunActive = false
end
else
self:OfferReward()
end
```
(다음 막은 같은 맵 구조 재사용 — CurrentNodeId 리셋만. 적은 막 스케일로 강해짐.)
- HP·골드·덱·유물은 막 간 유지(기존 영속). combatStart 유물은 전투마다 재적용.
### UI
- `RenderRun`: 층 텍스트를 `"막 " .. Floor .. "/" .. RunLength`로 (라벨 "층"→"막"). 골드 표시 유지.
## 검증 (메이커 Play)
- 1막 보스(슬라임 킹 120) 처치 → 2막 맵·Floor 2 → 적 HP 스케일(슬라임 45→72, 보스 120→192).
- 3막 보스 처치 → "런 클리어!". HP/골드/덱/유물 막 간 유지.
- 패배 시 종료. 생성기 결정적·JSON 유효.
- (버튼 런타임 — MCP는 PickNode/PlayCard/CheckCombatEnd 직접 호출 + 상태 로그.)
## 범위 밖 (금지)
- 저장/불러오기(E6b). 막별 다른 맵 디자인·신규 적/배경·막별 보상 차등.

View File

@@ -0,0 +1,50 @@
# 맵별 고정 카메라 (Map Camera Anchor) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: MSW CameraService/CameraComponent API + map01 현재 카메라 런타임 추출.
## 문제
맵별로 카메라 시점을 고정해 타일·배경·몬스터를 결정적 framing 안에 배치하고 싶다. 현재 카메라는
플레이어(DefaultPlayer) 소유로 플레이어를 추적한다(플레이어 freeze로 사실상 고정이지만 플레이어/맵 의존).
맵별로 **명시적·데이터 제어 가능한 고정 시점**이 필요하다.
## 추출한 현재(map01) 카메라 값 (런타임)
- ZoomRatio **100** (min 30 / max 500), CameraOffset (0,0), ScreenOffset **(0.5, 0.655)**,
ConfineCameraArea **true**, UseCustomBound false.
- 플레이어 스폰 ≈ **(-5.0, -0.04)**, 카메라 가둠 영역 LB(-8.73,-1.76)~RT(7.83,4.35).
## 설계
### 구조
- **맵별 `CameraAnchor` 엔티티**(정적): 각 맵(`/maps/mapNN/CameraAnchor`)에 추가.
- `TransformComponent`: 위치 = framing 중심(스폰 `(-5, -0.04)`).
- `CameraComponent`: ZoomRatio 100, ScreenOffset (0.5, 0.655), ConfineCameraArea true (= 현재 값).
- `script.MapCamera`: 맵 로드 시 이 카메라로 전환.
- 앵커가 움직이지 않으므로 시점 고정. 플레이어와 분리.
- **`RootDesk/MyDesk/MapCamera.codeblock`**(신규, 1개):
- `OnBeginPlay`(client): `_CameraService:SwitchCameraTo(self.Entity.CameraComponent)`.
- **`data/camera.json`**(신규): 단일 카메라 설정(zoom·screenOffset·confine·anchor pos). 맵 공통값(맵들이 map01 클론)이며, 추후 맵별 오버라이드 가능 구조.
### 생성기
- `tools/gen-maps.mjs`에 카메라 앵커 주입 추가: `data/camera.json` 읽어 11맵 각각에 CameraAnchor 엔티티
(Transform+Camera+script.MapCamera) 추가. CameraComponent JSON 구조는 기존 `Global/DefaultPlayer.model`
CameraComponent를 복제해 값만 교체(정확한 필드 보존).
- `MapCamera.codeblock``gen-maps.mjs`(또는 별도 소함수)에서 생성. ExecSpace는 클라이언트(OnBeginPlay client).
### 데이터 흐름
`data/camera.json``gen-maps.mjs`(앵커 주입) + `MapCamera.codeblock` 생성 → 맵 로드 시
`OnBeginPlay``SwitchCameraTo` → 고정 시점. framing 안에 타일/배경/몬스터 배치(기존대로).
### 조정
- 앵커는 메이커 Explorer `/maps/mapNN/CameraAnchor`에 나타나며 Scene에서 카메라 기즈모로 표시·이동 가능.
값은 Property Editor 또는 `data/camera.json` 수정→재생성으로.
## 검증 (메이커 Play)
- map01 진입 시 카메라가 CameraAnchor로 전환돼 현재와 동일 framing 고정(플레이어 이동/위치 무관).
- (가능하면 map02 진입해 동일 framing 확인.)
- `node tools/gen-maps.mjs` 결정적. 맵 JSON 유효. 빌드 오류 없음.
- 앵커가 Explorer/Scene에 보이고 기즈모로 framing 확인 가능.
## 범위 밖 (금지)
- 맵별 다른 줌/오프셋 튜닝(공통값부터), 카메라 연출(흔들림/줌인/블렌드), 노드별 맵 전환 로직,
카드 UI/전투 로직 변경.

View File

@@ -0,0 +1,69 @@
# 유물 (TODO E5) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E5) + 기존 전투/맵/상점 구조.
> 선행: E1~E4 완료. 후속: E6(저장/다음 층). 사용자 요청: 획득 3경로(시작·엘리트·상점) 모두.
## 문제
런 영속·맵·보상·상점은 됐으나 유물(패시브 빌드 요소)이 없다. 훅 기반 패시브 + 다양한 획득 경로가 필요하다.
## 설계
### 데이터 `data/relics.json`
```json
{
"relics": {
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6 },
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1 },
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1 },
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 골드 +10", "hook": "combatReward", "effect": "gold", "value": 10 }
},
"startingRelic": "ironHeart",
"relicPool": ["energyCore", "vampire", "goldIdol"]
}
```
- `relicPool` = 엘리트/상점에서 무작위로 줄 후보(시작 유물 제외). 중복 허용(스택).
### 파라미터 (생성기 상수)
- `RELIC_PRICE = 60`.
### 상태 추가
- `Relics`(any) — 전체 유물 정의(주입).
- `RunRelics`(any) — 보유 유물 id 목록.
- `ShopRelic`(string) — 상점 제시 유물 id.
- `ShopRelicBought`(boolean).
### 훅 시스템
- `ApplyRelics(hook)`: RunRelics 순회, `hook` 일치 유물의 effect 적용:
- `block`→PlayerBlock+=value, `energy`→Energy+=value, `healOnAttack`→PlayerHp+=value(상한 클램프), `gold`→Gold+=value.
- 연결 지점:
- `combatStart` → StartCombat 끝(StartPlayerTurn 호출 뒤 — 방어도 리셋 이후 적용 → RenderCombat).
- `turnStart` → StartPlayerTurn(에너지 회복 직후).
- `cardPlayed` → PlayCard의 Attack 분기(데미지 적용 후).
- `combatReward` → CheckCombatEnd 승리(기본 골드 += 후).
### 획득 (공통 `AddRelic(id)` → RunRelics 추가·RenderRelics)
- **C 시작**: `StartRun`에서 `RunRelics={}``AddRelic(startingRelic)`.
- **A 엘리트**: `CheckCombatEnd` 승리 시 노드 `type=="elite"``relicPool`에서 무작위 `AddRelic`(보스는 런 종료라 제외).
- **B 상점**: `ShowShop`에서 `ShopRelic = relicPool 무작위`, ShopRelicBought=false; `BuyRelic`(ShopRelicBought거나 Gold<RELIC_PRICE면 무시; 아니면 Gold-=60·AddRelic·비활성).
### UI
- 상단 유물 바: `/ui/DefaultGroup/CombatHud/Relics` 텍스트, `RenderRelics`가 보유 유물 이름을 ", "로 join해 "유물: …" 표시(없으면 "유물: 없음").
- ShopHud에 유물 슬롯: `/ui/DefaultGroup/ShopHud/Relic`(sprite+button) + Name/Desc/Price 자식. `RenderShop`이 ShopRelic 비주얼·가격·구매상태 갱신.
- 엘리트 유물 획득은 유물 바 갱신으로 표시.
### 단일 소스
모든 변경은 `tools/gen-slaydeck.mjs`에서 생성. relics.json은 데이터 단일 소스.
## 검증 (메이커 Play)
- 시작 유물(강철심장) → 전투 시작 시 PlayerBlock 6.
- energyCore 보유 → 턴 시작 에너지 4(3+1).
- vampire 보유 → 공격 카드 사용 시 HP +1(상한).
- goldIdol 보유 → 승리 시 골드 +25(15+10).
- 엘리트 승리 → relicPool 유물 1개 RunRelics 추가(바 갱신).
- 상점 유물 구매 → 골드 -60·RunRelics 추가·슬롯 비활성. 골드 부족/재구매 무시.
- 생성기 결정적·JSON 유효.
- (버튼은 런타임 — MCP는 AddRelic/BuyRelic/PlayCard 등 직접 호출 + 상태 로그로 검증.)
## 범위 밖 (금지)
- 부정적 유물·복합/조건부 효과·유물 제거·보스 유물·유물 등급/툴팁. 카드 제거(별도).

View File

@@ -0,0 +1,70 @@
# 상점/휴식 노드 (TODO E4) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E4) + E3 맵 노드 구조.
> 선행: E3(분기 맵) 완료. 후속: E5(유물)·E6(저장). 카드 제거는 덱 보기 UI 필요 → 후속 분리.
## 문제
E3로 분기 맵은 됐으나 모든 노드가 전투다. 골드는 적립만 되고 소비처가 없다. 상점(골드→카드)·
휴식(HP 회복) 노드가 필요하다.
## 범위
맵에 상점/휴식 노드 추가, 진입 시 전투 대신 상점/휴식 UI. 상점 = 카드 구매(골드). 휴식 = HP 회복.
**카드 제거(덱 보기 UI 필요)·유물·저장·휴식 업그레이드는 범위 밖.**
## 설계
### 데이터 (`data/map.json` 교체 — 4행)
```json
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
}
}
```
- rest/shop 노드는 `enemy` 없음. 생성기 검증을 "`enemy` 있을 때만 ENEMIES 확인"으로 완화.
Lua MapNodes 직렬화도 enemy 있을 때만 `enemy = "..."` 포함.
### 파라미터 (생성기 상수)
- `CARD_PRICE = 30`, `REST_HEAL = 30`.
### 상태 추가
- `ShopChoices`(any) — 상점 제시 카드 id 3개.
- `ShopBought`(any) — 슬롯별 구매 여부 {bool×3}.
### 메서드
- `PickNode`(수정): CurrentNodeId 세팅·맵 숨김 후 타입 분기 —
`shop``ShowShop`, `rest``ShowRest`, 그 외→`CurrentEnemyId=node.enemy`·`StartCombat`.
- `ShowShop`(신규): 카드 풀에서 3개 무작위→ShopChoices, ShopBought 초기화(false),
각 슬롯 비주얼·가격·골드 갱신, ShopHud 표시.
- `BuyCard(slot)`(신규): ShopBought[slot]==true 또는 Gold<CARD_PRICE면 무시. 아니면
Gold-=CARD_PRICE, RunDeck에 ShopChoices[slot] 추가, ShopBought[slot]=true, 해당 카드 어둡게, 골드 갱신.
- `ShowRest`(신규): `PlayerHp = min(PlayerMaxHp, PlayerHp + REST_HEAL)`, RestHud에 "HP 옛→새 (+회복)" 표시, RestHud 표시.
- `LeaveNode`(신규): ShopHud·RestHud 숨김 → `ShowMap`. (상점·휴식 나가기 공용)
- `RenderShop`(신규): 3 카드 비주얼/가격/구매상태 + 골드 텍스트 갱신.
### UI (신규)
- `ShopHud`(모달, 숨김): 제목 "상점", 골드 텍스트, 카드 3장(sprite+button + Name/Cost/Desc/Price 자식), "나가기" 버튼.
- `RestHud`(모달, 숨김): 제목 "휴식", 정보 텍스트(런타임), "나가기" 버튼.
- BindButtons: 상점 카드 버튼 3(→BuyCard i)·상점 나가기(→LeaveNode)·휴식 나가기(→LeaveNode) 바인딩.
### MapHud (4행 대응)
- 노드 y = `(row - (MAX_ROW+1)/2) * 140` (행 수에 맞춰 세로 중앙 정렬). col×180 유지.
## 검증 (메이커 Play)
- 맵→상점(D) 진입 → 카드 3장·가격·골드 표시 → 구매 시 골드 -30·RunDeck +1·해당 카드 비활성 →
골드 부족 시 구매 무시 → 나가기 → 맵(다음 노드).
- 맵→휴식(C) 진입 → HP +30(상한 클램프) → 나가기 → 맵.
- 전투/엘리트/보스/런 클리어/패배 회귀 없음. 생성기 결정적·JSON 유효.
- (버튼 클릭은 런타임 — MCP는 PickNode/BuyCard/ShowRest/LeaveNode 직접 호출로 검증.)
## 범위 밖 (금지)
- 카드 제거·덱 보기 UI(후속)·유물(E5)·저장(E6)·휴식 업그레이드·상점 유물/물약.

View File

@@ -0,0 +1,118 @@
# 맵 몬스터 카드 전투 — 설계
- 날짜: 2026-06-10
- 대상: `tools/deck/gen-slaydeck.mjs`(SlayDeckController + UI), `data/enemies.json`, 신규 `tools/monster/gen-combat-monster.mjs`(+`CombatMonster.codeblock`), 맵 몬스터 엔티티, `tools/balance/sim-balance.mjs`
- 상태: 승인됨 (브레인스토밍 → 본 스펙)
## 1. 배경
현재 카드 전투는 `SlayDeckController` 내부의 **추상 단일 적**으로 동작한다.
- 상태: `EnemyHp`/`EnemyMaxHp`/`EnemyBlock`/`EnemyName`/`EnemyIntents`/`EnemyIntentIndex` (단일 적 1체).
- 공격 카드 → `DealDamageToEnemy(damage)``EnemyHp` 차감.
- `CheckCombatEnd`: `EnemyHp<=0` → 승리(골드·보상·노드/막 진행), `PlayerHp<=0` → 패배.
- 적 데이터는 `data/enemies.json`(slime/elite/boss), 노드의 `CurrentEnemyId`가 어떤 적인지 결정, `Floor` 배율 적용.
- CombatHud는 단일 적 패널(EnemyName/EnemyHp/EnemyBlock/EnemyIntent)을 텍스트로 표시.
맵에는 실제 몬스터 엔티티(`script.Monster` + `script.MonsterAttack`, 예: map01의 주황버섯)가 있으나, 이는 물리 액션 전투용이라 **카드 전투와 완전히 분리**되어 있다.
## 2. 목표
카드 공격이 추상 슬라임이 아니라 **맵의 실제 몬스터**에 적용되고, **맵의 모든 몬스터가 쓰러지면 전투 승리**가 되도록 한다. 승리 이후 흐름(보상·노드·상점·휴식·막/보스)은 기존 런 시스템을 그대로 재사용한다.
## 3. 확정 요구사항 (브레인스토밍 결과)
1. **타겟팅**: 몬스터를 클릭하면 그 몬스터가 "현재 타겟"이 되고, 이후 공격 카드가 그 타겟에 적용된다.
2. **전투원 모델**: 각 몬스터가 개별 HP와 의도(공격/방어)를 가지며, 적 턴에 생존 몬스터가 각자 플레이어를 공격한다(멀티 적).
3. **런 연동**: 전투 노드 = 현재 물리 맵의 몬스터들. 전부 처치 시 노드 클리어 → 기존 보상/맵/상점/막 흐름 유지.
4. **스탯 소스**: 각 맵 몬스터가 적 타입 id를 보유하고 HP/의도를 `data/enemies.json`에서 읽는다. 배율은 **막 배율(필수, 기존 `1+(Floor-1)*0.6` 재사용)** + **노드 타입 배율(선택, 기본 1; 엘리트/보스만 가산)**.
5. **상태 표시**: 각 몬스터 머리 위에 월드(화면) HP바 + 의도 표시.
6. **아키텍처**: 컨트롤러 중심. 전투 상태는 `SlayDeckController`가 단일 소유, 몬스터 엔티티는 타겟·시각(애니메이션) 역할만. 사망 연출은 기존 `script.Monster` 자산 재사용.
7. **몬스터↔적ID**: 전용 경량 스크립트 `script.CombatMonster``EnemyId`(string) 속성으로 명시.
## 4. 데이터 모델
### 4.1 `data/enemies.json`
맵 몬스터용 적 타입을 추가한다(기존 slime/elite/boss는 유지). 예:
```json
"orange_mushroom": {
"name": "주황버섯",
"maxHp": 16,
"intents": [ { "kind": "Attack", "value": 5 }, { "kind": "Defend", "value": 4 } ]
}
```
스탯 수치는 placeholder이며 `sim-balance.mjs`로 추후 튜닝한다.
### 4.2 `script.CombatMonster` (신규 코드블록)
맵 몬스터 엔티티에 부착하는 경량 마커. 속성 `EnemyId`(string) 1개. 런타임 로직 없음(컨트롤러가 읽기만). `script.Monster`를 보유한 엔티티에 함께 부착한다.
### 4.3 런타임 전투 상태 (SlayDeckController)
단일 `Enemy*` 속성군을 제거하고 리스트로 교체한다.
- `Monsters`(any 리스트). 원소: `{ path, enemyId, name, hp, maxHp, block, intents, intentIndex, alive }`.
- `TargetIndex`(number). 현재 선택된 타겟의 `Monsters` 인덱스.
- 상수 `MAX_MONSTERS`(예: 4). UI 슬롯 수 = 이 값. 맵 몬스터가 더 많으면 앞에서 `MAX_MONSTERS`마리만 전투에 참여(초과분은 미지원, 로그 경고).
## 5. 전투 흐름 (SlayDeckController 메서드 변경)
- **StartCombat**:
1. 현재 맵에서 `script.CombatMonster`를 가진 몬스터 엔티티를 스캔(맵 루트 하위, 최대 `MAX_MONSTERS`).
2. 각 몬스터의 `EnemyId``enemies.json` 스탯을 읽어 배율(막 배율 필수 + 노드 타입 배율 선택, §3-4)을 적용해 `Monsters` 리스트 구성.
3. 몬스터 부활: 가시성 on, `StateComponent` IDLE, HP 리셋.
4. 각 몬스터 화면 위치에 UI 슬롯(HP바·의도·타겟버튼) 배치·활성화.
5. `TargetIndex` = 첫 생존자. 손패/턴 시작.
- **SetTarget(i)**: `TargetIndex` 갱신 + 타겟 하이라이트 갱신.
- **PlayCard(Attack)**: `Monsters[TargetIndex]`에 방어도→HP 차감. HP≤0 → 해당 몬스터 사망 처리(§6), UI 슬롯 숨김, 자동으로 다음 생존 타겟 선택. 이후 `CheckCombatEnd`.
- **PlayCard(Skill)**: 기존대로 플레이어 방어도 증가(변경 없음).
- **EnemyTurn**: 생존 몬스터 각자 `intentIndex` 진행 — Attack→`DealDamageToPlayer`, Defend→자기 `block` 증가. (각 몬스터는 독립 의도 사이클)
- **CheckCombatEnd**: 생존 몬스터 0 → 승리(기존 보상/노드/막 분기 재사용). `PlayerHp<=0` → 패배.
- **RenderCombat**: 각 생존 몬스터 UI 슬롯의 HP바·의도 갱신, 플레이어 패널 갱신. 기존 단일 적 패널(EnemyName/EnemyHp/EnemyIntent)은 제거 또는 숨김.
## 6. 사망 / 부활 연출
컨트롤러가 직접 관리하여 **노드 간 몬스터 영속**(엔티티 Destroy 안 함):
- 사망: 타겟 몬스터의 `StateComponent`를 DEAD로 전환(die 애니메이션) 후 짧은 지연 뒤 가시성 off. `alive=false`.
- 부활: `StartCombat`에서 가시성 on, IDLE 상태, HP 리셋.
기존 `Monster.codeblock`의 hit/die 애니메이션 자산을 활용하되, Destroy/Respawn 타이머에 의존하지 않고 컨트롤러가 생사 시점을 통제한다.
## 7. UI — 몬스터 슬롯 (DefaultGroup.ui)
카메라가 고정(MapCamera)이라 몬스터의 화면상 위치가 불변 → UI 슬롯을 전투 시작 시 한 번 배치하면 된다.
- `gen-slaydeck.mjs``CombatHud` 아래 **MonsterSlot ×MAX_MONSTERS**를 사전 생성(평소 비활성):
- HP바 스프라이트(배경+채움), 의도 텍스트, 투명 타겟 버튼(클릭→`SetTarget`).
- **위치 결정**: 런타임 world→screen 변환을 우선 시도(카메라 고정이므로 전투 시작 시 1회 계산). 변환 API가 여의치 않으면 **`data`에 슬롯 화면좌표를 명시**(현재 map01 몬스터 배치 기준)하는 폴백을 사용한다. → 구현 단계에서 변환 가용성 검증.
## 8. 변경 파일 요약
| 파일 | 변경 |
|------|------|
| `data/enemies.json` | 맵 몬스터 적 타입 추가(orange_mushroom 등) |
| 신규 `tools/monster/gen-combat-monster.mjs` | `CombatMonster.codeblock` 생성 + 11개 맵 몬스터 엔티티에 `script.CombatMonster`(EnemyId) 부착(idempotent) |
| `RootDesk/MyDesk/CombatMonster.codeblock` | 신규 생성물 |
| `tools/deck/gen-slaydeck.mjs` | 전투 멀티 몬스터화(상태·PlayCard·EnemyTurn·CheckCombatEnd·RenderCombat·StartCombat·타겟 바인딩) + UI 몬스터 슬롯 생성 |
| `tools/balance/sim-balance.mjs` | 멀티 몬스터 규칙으로 동기화 |
| `map/map01.map`~`map11.map` | 몬스터 엔티티에 `script.CombatMonster` 부착(생성기 재실행 산출) |
| `ui/DefaultGroup.ui` | 몬스터 슬롯 추가(생성기 산출) |
## 9. 알려진 한계 (MVP)
- 모든 전투 노드가 같은 물리 맵 몬스터를 재사용한다(막 배율로 난이도 차등). 노드별 다른 적 구성/맵 이동은 후속 과제.
- `MAX_MONSTERS` 초과 몬스터는 전투에 미참여.
- 보스 노드도 동일 맵 몬스터를 사용(테마 불일치)는 후속 콘텐츠 확장에서 해결.
## 10. 리스크
- **world→screen 변환 가용성**: 미지원 시 슬롯 좌표 데이터 폴백으로 대응(§7).
- **외부 엔티티 스크립트 메서드/상태 접근**: 컨트롤러가 몬스터 엔티티의 `StateComponent`·가시성을 제어할 수 있어야 함(구현 단계 검증).
- **생성물 단일 소스 유지**: 전투/HUD 산출물은 `gen-slaydeck.mjs`에서만 생성(직접 편집 금지) 규칙 유지.
## 11. 검증
- `node tools/balance/sim-balance.test.mjs` 통과 + 멀티 몬스터 규칙 반영.
- 생성기 2회 실행 결과 동일(결정적).
- 메이커 플레이: 카드로 특정 몬스터 타겟 공격 → HP 감소·사망 애니 → 전체 처치 시 승리 → 기존 보상/노드 흐름 진입. 적 턴에 생존 몬스터가 플레이어 공격.

View File

@@ -0,0 +1,103 @@
# 노드 타입별 몬스터 그룹 — 설계
- 날짜: 2026-06-10
- 대상: `RootDesk/MyDesk/CombatMonster.codeblock`(+`tools/monster/gen-combat-monster.mjs`), `tools/deck/gen-slaydeck.mjs`(SlayDeckController), `data/monster-slots.json`, map 몬스터 엔티티(메이커 저작)
- 상태: 승인됨 (브레인스토밍 → 본 스펙)
## 1. 배경
맵 몬스터 카드 전투가 구현돼 있다. 현재 `BuildMonsters`는 맵에 등록된 `script.CombatMonster` 몬스터를 **노드 타입과 무관하게 전부** 전투에 투입한다. 그래서 일반/엘리트/보스 노드를 어디로 가든 같은 몬스터와 싸운다.
흐름: `PickNode(id)``self.CurrentNodeId = id`, `self.CurrentEnemyId = node.enemy`, `StartCombat()``BuildMonsters()`. 노드 타입은 `self.MapNodes[self.CurrentNodeId].type`(`combat`/`elite`/`boss`/`shop`/`rest`)로 접근 가능.
## 2. 목표
한 맵 안에서 **노드 타입에 따라 다른 몬스터 그룹**이 등장하도록 한다.
- 일반(`combat`) 노드 → 일반 몬스터 그룹
- 엘리트(`elite`) 노드 → 엘리트(+졸개) 그룹
- 보스(`boss`) 노드 → 보스(+졸개) 그룹
## 3. 확정 요구사항 (브레인스토밍 결과)
1. **구성**: 노드 타입별 그룹. 엘리트/보스 그룹은 졸개(일반 몬스터)를 포함할 수 있다. 그룹당 몬스터 수는 `MAX_MONSTERS`(4) 이하.
2. **선택 단위**: 노드 **타입**(모든 `combat` 노드는 동일한 일반 그룹, `elite`는 엘리트 그룹…). 노드별 개별 구성은 후속.
3. **배치**: 세 그룹을 맵 내 **서로 다른 위치**에 배치. HP바 슬롯 좌표는 **그룹별**로 둔다.
4. **메커니즘**: 각 몬스터에 `Group` 태그 + `BuildMonsters`에서 현재 노드 타입으로 필터. (MSW Layer는 렌더 z-순서용이라 부적합 — 사용 안 함)
5. **저작**: 각 몬스터의 `Group`/`EnemyId`는 **메이커 인스펙터**에서 직접 설정. 생성기는 컴포넌트 존재만 보장하고 사용자 설정 값을 덮어쓰지 않는다.
## 4. 데이터·컴포넌트 변경
### 4.1 `script.CombatMonster` (codeblock)
- 속성 추가: `Group`(string, 기본 `"combat"`). 기존 `EnemyId`(string)·`RegTries`(number) 유지.
- `OnBeginPlay`: 등록 호출에 Group 추가 → `c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group)`.
### 4.2 `tools/monster/gen-combat-monster.mjs` (클로버 금지로 변경)
- `script.Monster` 엔티티에 `script.CombatMonster`**없을 때만** 부착(기본값 `Group="combat"`, `EnemyId=DEFAULT_ENEMY`).
- 이미 `script.CombatMonster`가 있으면 **그 인스턴스의 Group/EnemyId 값을 보존**(필터-후-재삽입으로 값을 날리지 않음). `Enable`·componentNames만 정합 유지.
- 결과: 신규 배치 몬스터엔 컴포넌트가 자동 생기고, 메이커에서 설정한 값은 유지된다.
### 4.3 `data/monster-slots.json` (그룹별 좌표)
평면 배열 → 그룹 키 객체:
```json
{
"combat": [ { "x": 430, "y": 140 }, ... ],
"elite": [ ... ],
"boss": [ ... ]
}
```
각 배열 길이는 해당 그룹의 몬스터 수(≤ MAX_MONSTERS)를 커버. 좌표는 플레이테스트로 튜닝.
### 4.4 런타임 상태 (SlayDeckController)
- `Registered` 원소: `{ entity, enemyId, group }` (group 추가).
- `SlotPos`: 그룹별 좌표 테이블(`SlotPos.combat`/`.elite`/`.boss`)로 주입(StartRun).
## 5. 런타임 흐름
- **RegisterMonster(monster, enemyId, group)**: `Registered``{entity, enemyId, group}` append.
- **BuildMonsters**:
1. `local g = self.MapNodes[self.CurrentNodeId].type` (combat/elite/boss)
2. 등록된 모든 몬스터 엔티티 **숨김**(`SetVisible(false)`).
3. `r.group == g`인 항목만 추려 월드 x 정렬 → 최대 `MAX_MONSTERS`.
4. 각 몬스터: `enemies.json[enemyId]` 스탯 + 막 배율(`1+(Floor-1)*0.6`)로 `Monsters[i]` 구성, `ReviveMonsterEntity`(표시), `PositionMonsterSlot(i)`.
5. 슬롯 좌표는 `SlotPos[g]` 사용.
6. `TargetIndex = 1`.
- **PositionMonsterSlot(slot)**: 현재 그룹 좌표(`self.ActiveSlotPos` 또는 `SlotPos[g]`)에서 위치 설정. (BuildMonsters가 현재 그룹 좌표를 임시 보관)
- 나머지(SetTarget·PlayCard·DealDamageToTarget·KillMonster·EnemyTurn·CheckCombatEnd·RenderCombat)는 **변경 없음**.
> 구현 메모: `PositionMonsterSlot`이 그룹 좌표를 알 수 있도록, BuildMonsters에서 `self.ActiveSlotPos = self.SlotPos[g]`를 설정하고 PositionMonsterSlot은 `self.ActiveSlotPos[slot]`을 참조한다.
## 6. 저작 워크플로 (메이커)
1. map01에 일반/엘리트/보스 몬스터를 서로 다른 위치에 배치(엘리트/보스 그룹은 졸개 포함 가능).
2. 각 몬스터의 `CombatMonster`에서 `Group`(combat/elite/boss)·`EnemyId`(enemies.json id) 지정.
3. `data/monster-slots.json`에 그룹별 슬롯 좌표 입력.
4. `node tools/monster/gen-combat-monster.mjs``node tools/deck/gen-slaydeck.mjs` → 메이커 reload.
## 7. 변경 파일 요약
| 파일 | 변경 |
|------|------|
| `tools/monster/gen-combat-monster.mjs` | CombatMonster에 `Group` 프로퍼티 추가, 부착을 **없을 때만**(값 보존) 으로 변경, OnBeginPlay 등록에 Group 전달 |
| `RootDesk/MyDesk/CombatMonster.codeblock` | 생성물(Group 프로퍼티·등록 인자) |
| `data/monster-slots.json` | 그룹별 좌표 구조로 변경 |
| `tools/deck/gen-slaydeck.mjs` | `RegisterMonster`(group 인자)·`BuildMonsters`(노드 타입 필터·전체 숨김·그룹 슬롯)·`SlotPos` 주입·`PositionMonsterSlot`(활성 그룹 좌표) |
| 생성물 | `SlayDeckController.codeblock` 재생성. `ui/DefaultGroup.ui`**변경 없음**(기존 4개 슬롯을 그룹 간 재사용, 좌표만 런타임 변경) |
| `map/map01.map` | 그룹별 몬스터 배치·CombatMonster 태그(메이커 저작) |
## 8. 알려진 한계
- 노드 타입 단위 구성(노드별 개별 인카운터 아님).
- 모든 그룹 합산이 많아도 UI 슬롯은 `MAX_MONSTERS`(4) 동시 표시 한도. 그룹당 ≤4.
- 전투 외(맵 UI 오버레이) 구간엔 필드에 세 그룹이 모두 보일 수 있음(허용). 원하면 StartRun에서 전체 숨김(후속).
## 9. 리스크
- `gen-combat-monster`의 "값 보존" 로직: 기존 인스턴스 값을 정확히 유지하면서 componentNames 정합을 깨지 않도록 주의.
- 그룹 슬롯 좌표 미설정 시 기본 폴백 필요(좌표 없으면 슬롯 위치 미변경).
- 보스 노드 클리어 시 막 진행 로직(`CheckCombatEnd`)은 기존 그대로 동작해야 함(그룹 변경과 독립).
## 10. 검증
- 생성기 2회 실행 결과 동일(결정적), JSON 유효·중복 id 없음.
- 메이커 플레이테스트: combat 노드 → 일반 그룹만, elite 노드 → 엘리트(+졸개)만, boss 노드 → 보스(+졸개)만 등장. 비활성 그룹은 숨김. 각 그룹 슬롯이 해당 몬스터 위에 표시.
- sim 테스트는 기존대로 통과(규칙 불변).

View File

@@ -0,0 +1,46 @@
# 막별 맵 전환 + 맵별 인카운터 (P4) — 설계
- 날짜: 2026-06-11
- 상태: 승인됨(사용자 사전 위임). 로드맵 P4/5.
## 1. 배경/목표
map02~11은 SectorConfig에 등록만 되고 게임에서 미사용(모든 전투가 map01). 막(act)이 바뀌어도 같은 맵·같은 몬스터.
**목표**: ① 막별로 다른 물리 맵 사용(맵 차별화가 실제 게임에 보이도록) ② 각 맵에 노드 타입별 몬스터 그룹(combat/elite/boss)을 맵별 테마로 자동 구성.
## 2. 설계
### 2.1 막→맵 매핑 + 텔레포트
- `ACT_MAPS = ['map01','map02','map03']`(ACT_COUNT=3과 일치, 생성기 상수→Lua 주입).
- `CheckCombatEnd` 보스 클리어(다음 막 진행) 분기에서 `Floor` 증가 후 `self:TeleportToActMap()`:
- `_TeleportService:TeleportToMapPosition(_UserService.LocalPlayer, Vector3(-6, 0.03, 0), ACT_MAPS[self.Floor])` (UILogic 공식 예제의 API; 위치는 map01 플레이어 시작권 좌측 지면 — 메이커 검증으로 조정).
- 새 맵 로드 시 그 맵 몬스터들의 `CombatMonster.OnBeginPlay`가 자기등록(기존 0.1s×50 재시도 — 텔레포트 직후는 맵 화면이라 전투 진입 전 등록 여유 충분).
### 2.2 등록 풀의 맵 필터 (크로스맵 오염 방지)
- 텔레포트 후 구 맵 몬스터가 언로드되지 않고 등록 풀에 남을 가능성 대비:
- `RegisterMonster(entity, enemyId, group, mapName)` — CombatMonster가 자기 소속 맵 이름을 전달(`self.Entity.CurrentMapName` 우선, nil이면 부모 체인에서 `/maps/` 직계 자식 이름; 구현 검증).
- `BuildMonsters`: `local pmap = _UserService.LocalPlayer.CurrentMapName``r.map == pmap`인 등록만 사용(+기존 isvalid·group 필터).
### 2.3 맵별 인카운터 자동 구성 (`tools/map/gen-map-encounters.mjs` 신규)
- 대상: map02~map11 (map01은 사용자 저작 유지).
- 각 맵: 기존 `script.Monster` 엔티티 전부 제거 → 그 맵의 첫 몬스터 엔티티를 템플릿으로 6마리 생성:
| Group | 수 | x 위치 | EnemyId(맵 번호 순환) |
|---|---|---|---|
| combat | 3 | 2.3 / 3.8 / 5.2 | orange_mushroom·green_mushroom·pig·blue_mushroom 풀에서 3종 |
| elite | 2 | 3.0 / 5.0 | mushmom·modified_snail 중 |
| boss | 1 | 4.0 | king_slime·slime_boss 중 |
- 외형: gen-maps의 `MONSTER_VARIANTS`(공식 수확 9종 sprite/stand/hit/die) 풀에서 맵 시드(`nn*7919`) 결정론 선택(맵마다 다른 조합) — SpriteRenderer/StateAnimation 덮어쓰기.
- `script.CombatMonster` Group/EnemyId 태그 포함, GUID 결정론(`mapGuid` 패턴), idempotent(전체 교체 방식이라 재실행 동일).
- enemies.json 변경 없음(기존 8타입 재사용 — 스탯 일관).
### 2.4 비범위
- 4막+ / 맵별 배경·노드 그래프 차별화(이미 배경·타일은 맵별 상이), 이벤트 노드(P5).
## 3. 검증
- 생성기 결정론(2회 동일), 각 맵 그룹 구성 JSON 검사(3/2/1·EnemyId·변형 다양성).
- 메이커: 1막 보스 처치→Floor 2→**map02 텔레포트**(카메라/PlayerLock은 전 맵 부착됨)→맵 화면→전투 진입 시 map02 몬스터(combat 3, 새 외형)만 등장·구 맵 미오염→엘리트/보스 노드도 그룹 정상.
## 4. 리스크
- `Entity.CurrentMapName`/플레이어 CurrentMapName 형식("map02"?) — 구현 시 메이커 확인, 불일치 시 경로 기반 폴백.
- 텔레포트 직후 카메라/입력 재설정(MapCamera·PlayerLock OnBeginPlay가 맵 로드마다 도는지) — 검증.
- 구 맵 몬스터 isvalid 동작 — 맵 필터가 1차 방어라 비차단.

View File

@@ -0,0 +1,77 @@
# 메이플 스킬 카드 비주얼 (P2) — 설계
- 날짜: 2026-06-11
- 대상: `data/cards.json`, `tools/deck/gen-slaydeck.mjs`(카드 엔티티 구조·ApplyCardVisual류), 생성물
- 상태: 승인됨 (배포 퀄리티 로드맵 P2/5 — P1 UI 정비 머지됨 #34)
## 1. 배경 / 타당성 (검증 완료)
카드가 단색 사각형+텍스트(코스트/이름/설명)뿐이라 게임 정체성이 약하다. 메이플 스킬 이미지를 카드에 넣는다.
**타당성 검증 완료(메이커 실측)**: `asset_search_resources`(source=maplestory, cat=sprite)로 "파워 스트라이크" 검색 → 공식 RUID 10+개. 그중 `37ed94ffd1a64a22ad91a6ae14774718`를 Play 중 `SpriteGUIRendererComponent.ImageRUID`(Type=0)에 주입 → **로컬 워크스페이스에서 정상 렌더 확인**(흰 박스 아님 — 흰 박스 문제는 클라우드 '계정' 리소스에만 해당, 공식 리소스는 OK).
주의: 검색 결과는 아이콘이 아니라 **스킬 이펙트 컷**일 수 있음 → 후보 중 선별 단계 필요.
## 2. 카드 → 메이플 스킬 매핑 (데이터)
`data/cards.json`의 각 카드에 선택 필드 추가:
- `image`: 공식 스프라이트 RUID(string). 없으면 현행 단색 폴백.
- 카드 `name`을 메이플 스킬명으로 변경(효과·코스트·밸런스 불변):
| 카드 id | 기존 이름 | 새 이름(전사 스킬) | 효과 |
|---|---|---|---|
| Strike | 타격 | 파워 스트라이크 | 피해 6 |
| Bash | 강타 | 슬래시 블러스트 | 피해 10 |
| Defend | 방어 | 아이언 바디 | 방어도 5 |
(id는 기존 유지 — RunDeck/starterDeck 호환. 표시명만 변경.)
## 3. RUID 수확 워크플로 (구현 선행 태스크)
1. `asset_search_resources`(source=maplestory)로 스킬별 후보 수집: "파워 스트라이크", "슬래시 블러스트", "아이언 바디" (+필요시 "워리어", "스킬" 등 보조 질의).
2. 메이커 Play에서 후보 RUID를 카드 Art에 순회 주입 + 스크린샷으로 선별(§1에서 검증한 방법). 카드 일러스트로 보기 좋은 컷 1개/스킬 확정.
3. 확정 RUID를 `data/cards.json`에 기록. (RUID 문자열만 저장 — 공식 콘텐츠 정책 기존 관행과 동일)
4. 적합 컷이 없으면 폴백: 그 카드들은 이펙트 컷 중 베스트, 그래도 없으면 image 필드 생략(단색 유지).
## 4. 카드 프레임 구조 (upsertUi — 손패 Card1~5 기준)
기존: 루트(단색 패널) + Cost/Name/Desc 텍스트 3개.
변경(카드 180×250 기준):
```
Card{i} (루트: 종류색 패널 — 테두리 역할, 기존 ATTACK/DEFEND/SKILL 색 유지)
├─ Art 96×96 중앙상단(pos 0, 52): ImageRUID, Type=0, 흰색
├─ NamePlate 168×34 (pos 0, -8): 어두운 띠 {0.07,0.08,0.1,0.92}
│ └─ (Name 텍스트를 NamePlate 위치로 이동, fontSize 20)
├─ Cost 44×44 좌상(pos -68, 103): 어두운 원판 패널 + 숫자 26 (기존 위치 강조형)
└─ Desc (pos 0, -62, fontSize 18) 하단 효과 텍스트
```
- 구현은 기존 자식 텍스트(Cost/Name/Desc) 위치·크기 조정 + 신규 Art/NamePlate/CostPlate 스프라이트 추가.
- 동일 구조를 **RewardHud/Reward{1~3}**, **ShopHud/Card{1~3}**, **DeckInspectHud/Grid/Card{n}**, **DeckAllHud/Grid/Card{n}** 카드에도 적용(폭이 다른 그리드 카드(158×214)는 비례 축소 좌표).
## 5. 런타임 렌더 일원화
- 신규 헬퍼 `ApplyCardFace(basePath, cardId)`(Lua): Cards[cardId]에서 name/cost/desc/kind/image를 읽어 — 루트 색(kind), Art ImageRUID(있으면 표시·없으면 Art 숨김), Name/Cost/Desc 텍스트 설정.
- 기존 `ApplyCardVisual`(손패)·`ApplyRewardVisual`(보상)·상점 렌더(`RenderShop` 내 카드부)·`ApplyInspectCardVisual`(인스펙터)·모든덱 렌더가 **ApplyCardFace를 호출**하도록 통일(경로만 다름).
- Lua 카드 테이블(`luaCardsTable`)에 `image` 필드 직렬화 추가(없으면 생략).
## 6. 변경 파일
| 파일 | 변경 |
|---|---|
| `data/cards.json` | name 3종 변경 + image RUID 3종 추가 |
| `tools/deck/gen-slaydeck.mjs` | luaCardsTable image, 카드 엔티티 프레임(5표면), ApplyCardFace + 호출부 통일 |
| 생성물 | `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` 재생성 |
| `tools/balance/sim-balance.mjs` | 불변(이름 표시는 data에서 읽음 — 리포트에 새 이름 자동 반영) |
## 7. 범위 제외 (후속)
의도 아이콘(P3), 카드 호버 확대·사용 연출(P3), 희귀도 프레임 색(P5), 도적/마법사 카드(후속 콘텐츠).
## 8. 검증
- 생성 결정성·dup id 0·sim 14/14(이름 변경이 테스트에 영향 없는지 확인 — 테스트는 자체 fixture 사용이라 무관).
- 메이커: 손패/보상/상점/덱보기 4표면 스크린샷 — 스킬 이미지·프레임·이름 일관 표시, image 누락 카드 폴백 정상.
## 9. 리스크
- 후보 RUID가 멀티프레임 애니 시트일 수 있음 → SpriteGUIRenderer가 첫 프레임 표시(기존 옵션 FrameColumn/Row 1) — 선별 단계에서 확인.
- 그리드 카드(인스펙터/모든덱)는 ScrollLayoutGroup 셀 — 자식 추가가 셀 레이아웃에 영향 없는지 메이커 확인.

View File

@@ -0,0 +1,63 @@
# 전투 연출 (P3) — 설계
- 날짜: 2026-06-11
- 대상: `tools/deck/gen-slaydeck.mjs`(카드 드래그·연출·적 턴 시퀀스), 생성물
- 상태: 승인됨(사용자 사전 위임 — P2~P5 일괄 진행 지시). 로드맵 P3/5.
## 1. 목표 (사용자 요구)
1. **카드 드래그→몬스터 지정**: 카드를 끌어 특정 몬스터에 놓아 사용(클릭 대체).
2. **공격 모션 후 데미지**: 공격 카드 사용 시 연출(스킬 이펙트) 후 몬스터가 피해.
3. **몬스터 개별 차례**: 적 턴에 몬스터가 한 마리씩 순서대로 행동(행동자 표시).
4. 데미지 숫자 표시·사망 연출 등 게임필 보강.
## 2. 타당성 (probe 완료)
- `maker_mouse_input` down → `ScreenTouchEvent` 발화 + `ScreenToUIPosition` 변환 실측 일치(카드2 위치).
- **`MOD.Core.UITouchReceiveComponent`**: UI 엔티티에 부착 시 `UITouchBeginDragEvent`/`UITouchDragEvent`/`UITouchEndDragEvent`/`UITouchDownEvent`/`UITouchUpEvent` 제공(공식, Client). 드래그는 이걸 사용.
- 몬스터 world→screen(`_UILogic:WorldToScreenPosition`)은 P1에서 검증됨 — 드롭 판정에 재사용.
## 3. 설계
### 3.1 카드 드래그 타겟팅
- 손패 Card1~5 엔티티에 `MOD.Core.UITouchReceiveComponent` 추가(생성기 componentNames+컴포넌트).
- 컨트롤러 상태: `DragSlot`(0=없음), `DragOrigin`(원위치 Vector2), `DragMoved`(boolean).
- `BindButtons`에서 카드별로 connect:
- `UITouchBeginDragEvent` → CombatOver 아니고 손패에 카드 있으면 `DragSlot=i`, 원위치 저장(`CARD_XS[i]` 상수로 복원 가능하므로 저장은 단순화 가능 — 원위치 = (CARD_XS[i], 0)).
- `UITouchDragEvent` → 카드 `anchoredPosition = ScreenToUIPosition(TouchPoint) - CardHandOffset` (CardHand 부모 중심의 UI 좌표 보정값은 런타임 계산: 카드 부모 CardHand의 화면상 중심 = UI(0, -360) → 보정 상수로 굽기).
- `UITouchEndDragEvent``ResolveCardDrop(i, TouchPoint)` 후 카드 위치 복원.
- `ResolveCardDrop(slot, screenPoint)`:
- 카드 kind 조회. **Attack**: 생존 몬스터 중 화면 거리(몬스터 world→screen vs screenPoint) 최소이고 임계(예: 200px) 이내인 몬스터 → `SetTarget(그 몬스터)``PlayCard(slot)`. 임계 밖이면 취소(복귀만).
- **Skill**: 드롭 위치가 손패 위(화면 y 기준 카드 영역 위쪽, 예: screen y > 화면 40%)면 `PlayCard(slot)`, 아니면 취소.
- 기존 카드 ButtonComponent 클릭 `PlayCard` 바인딩 **제거**(드래그와 충돌 방지, 사용은 드래그로 일원화 — STS 방식). 몬스터 슬롯 클릭 SetTarget은 유지(타겟만 바꾸는 보조 수단).
### 3.2 공격 연출 → 데미지 (PlayCard Attack 시퀀스)
- `CombatHud/SkillFx` 엔티티 1개(96×96 이미지 스프라이트, 평소 숨김).
- PlayCard(Attack) 흐름 변경: 에너지 차감·손패 제거·렌더는 즉시, **데미지는 지연**:
1. `ShowSkillFx(targetIndex, c.image)`: 타겟 몬스터 world→screen 위치에 SkillFx 표시(ImageRUID=카드 이미지).
2. 0.35s 타이머 → SkillFx 숨김 + `DealDamageToTarget(damage)` + 데미지 팝업 + RenderCombat + CheckCombatEnd.
- 연출 중 입력 보호: `FxBusy=true` 동안 PlayCard/EndPlayerTurn 무시(0.35s).
### 3.3 데미지 숫자 팝업
- `MonsterSlot{i}/DmgPop`(텍스트, 숨김 기본): `ShowDmgPop(slot, amount)` — "-N" 표시 → 0.6s 후 숨김(타이머; 위치 고정 단순화).
- `PlayerPanel/DmgPop` 동일(적 공격 시 "-N", 방어 흡수로 0이면 "막음").
### 3.4 적 개별 차례 (EnemyTurn 시퀀스화)
- `EnemyTurn` → 비동기 체인으로 재작성:
- `EnemyActIndex=0`; `EnemyActStep()`: 다음 생존 몬스터 찾기 → 없으면 `FinishEnemyTurn()`.
- 행동 몬스터 슬롯에 `ActFrame`(적색 하이라이트 — TargetFrame과 별도 자식, 156×108 적색 a0.3) 표시 → 0.45s 타이머 → 의도 적용(Attack: DealDamageToPlayer+플레이어 DmgPop / Defend: block+슬롯 의도 갱신) → ActFrame 숨김 → 다음 `EnemyActStep()` (0.15s 간격).
- 플레이어 사망 시 즉시 `FinishEnemyTurn()`.
- `FinishEnemyTurn()`: `CheckCombatEnd` 후 미종료면 0.45s 뒤 `StartPlayerTurn`(기존 EndPlayerTurn 후반부 이동).
- `EndPlayerTurn`: 손패 버림+렌더 후 `EnemyTurn()` 호출로 종료(후속 로직은 FinishEnemyTurn으로 이동). `TurnBusy=true`로 적 턴 중 입력 차단(FxBusy와 함께 가드).
### 3.5 사망 연출
- `KillMonster`: 즉시 SetVisible(false) → **0.4s 지연**으로 변경(DmgPop과 겹쳐 보이게), 슬롯 비활성은 즉시 유지.
## 4. 검증
- 생성 결정성·dup 0·sim 14/14(규칙 불변 — 연출 지연만 추가, 데미지 계산 동일).
- 메이커: ①카드를 몬스터2에 드래그→타겟 변경+이펙트→데미지 팝업→HP 감소 ②Skill 카드 위로 드래그→방어 ③드롭 취소(빈 곳) ④턴 종료→적들이 한 마리씩 순차 행동(ActFrame 이동)+플레이어 팝업 ⑤전체 처치 승리 정상.
## 5. 리스크
- UITouchReceiveComponent와 ButtonComponent 공존(슬롯 클릭/드래그 간섭) — 카드에서 Button 제거하므로 카드는 안전; 몬스터 슬롯은 Button 유지(드래그 없음).
- UITouchDragEvent 빈도/좌표계 — 구현 후 메이커 검증(§4①). 드래그 좌표 보정 상수는 실측 튜닝.
- 비동기 체인 중 상태 변화(연출 중 사망 등) — FxBusy/TurnBusy 가드 + 각 스텝에서 alive/CombatOver 재확인.

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