75 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
82 changed files with 260027 additions and 255194 deletions

4
.gitignore vendored
View File

@@ -7,6 +7,8 @@
# Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조)
.claude/*
!.claude/settings.json
# 개인 스킬(superpowers) 브레인스토밍/계획 산출물 — 로컬 전용, 협업 공유 X (프로젝트 설계 문서 docs/*.md 는 추적 유지)
docs/superpowers/
# === OS / 에디터 잡파일 ===
Thumbs.db
@@ -23,3 +25,5 @@ AGENTS.md
Environment/
McpScreenshots/
*.log
# 메이커가 재편(reorg) 중 부모를 잃은 엔티티를 모아두는 임시 폴더 (잡파일)
Mislocated/

142
Global/UIButton.model Normal file
View File

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

View File

@@ -44,8 +44,8 @@ git pull
```
slaymaple/
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
│ ├── cards.json # 카드 122장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
│ ├── enemies.json # 적 12종(일반/정예/보스, 디버프 인텐트 포함)
│ ├── cards.json # 카드 121장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
│ ├── enemies.json # 적 18종(일반/정예/보스, 디버프 인텐트 포함)
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
@@ -77,9 +77,9 @@ slaymaple/
│ ├── 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(산출물 카운트 검증 헬퍼 — 경로 내장)
│ ├── verify/ # count.mjs·uimap.mjs·cbgap.mjs(산출물 카운트/UIGroup 매핑/재연결 GAP 검증 — 경로 내장)
│ └── git/ # gitea-pr.mjs(UTF-8 안전 PR 생성/수정/머지 — RULES.md 참조)
├── ui/ # UI 그룹 (DefaultGroup 8.3MB 산출물 / PopupGroup / ToastGroup)
├── ui/ # UIGroup 7종 — 메이커 저작(Default/Select/Lobby/Run/Deck/Popup/Toast)
├── docs/
│ ├── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
│ ├── ui-generation-structure.md # UI 생성 구조 문서
@@ -89,14 +89,22 @@ slaymaple/
└── README.md
```
> ⚠️ **`map/*.map` · `ui/DefaultGroup.ui` · `*.codeblock` · `Global/*.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 다음 재생성 때 사라집니다. 게임 변경은 `data/*.json` 또는 `tools/`의 생성기를 고친 뒤 재생성하세요(자세한 규칙은 [`RULES.md`](RULES.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 차용)** — **위자드(불/독)**: 독을 묻히고 *독 걸린 적에 불 카드 → 추가 데미지*(독뎀 시너지). **위자드(썬/콜)**: 오브로 썬더(다중 공격)·콜드(빙결=취약+피해), 오브 획득·다중 소모 운용. **클레릭**: 오브 없이 회복·버프 + 언데드엔 힐로 공격하는 보조 힐러.
## 게임 프레임워크 현황
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다:
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다 (게임 시작 시 MainMenu 없이 바로 로비로 진입):
```
로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
@@ -104,15 +112,15 @@ slaymaple/
→ 런 클리어(승천 해금) → 로비 복귀(영혼 정산) → 다음 런 …
```
게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작하며, 모든 산출물(`ui/DefaultGroup.ui` · `SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(결정적 출력, 직접 편집 금지`RULES.md` 참조). 게임 데이터는 **`data/*.json`** 가 단일 소스, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. **UI는 메이커 저작**(7개 UIGroup: Default/Select/Lobby/Run/Deck/Popup/Toast)이고, 컨트롤러가 엔티티 경로(`/ui/<UIGroup>/<Hud>/...`)로 내용을 런타임 주입합니다. 생성기 `tools/deck/gen-slaydeck.mjs`**`SlayDeckController.codeblock` + `common.gamelogic`만 생성**(`.ui` 미접근, 결정적 출력`RULES.md` 참조). 게임 데이터는 **`data/*.json`**, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
### 구현된 기능 (배포 퀄리티 P1~P15, PR #34~#57)
### 구현된 기능 (배포 퀄리티 P1~P15+, PR #34~#79)
| 영역 | 내용 |
|---|---|
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`**또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택, 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **122** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
| **캐릭터·전직** | 시작 시 **전사(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 랜덤 행동 |
@@ -125,7 +133,7 @@ slaymaple/
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
> 도적(Silent) 카드 88장은 효과·프레임은 적용됐으나 **카드 아이콘(image/fx) 미할당** 상태입니다(전사·마법사 카드는 실 스킬 아이콘 적용 완료).
> 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
### 유용한 스크립트 호출
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
@@ -149,9 +157,20 @@ c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1
밸런스 검증: `node tools/balance/sim-balance.mjs [N] [--seed S]` · 테스트: `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs`.
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 및 `docs/superpowers/specs/` 참조.
### 디버그 단축키
개발·QA용 키보드 단축키. **전투 중**(런 활성 + 전투 진행 중)에만 동작합니다.
| 단축키 | 기능 |
|---|---|
| **Ctrl + Shift + C** | **카드 picker** — 직업 전체 카드 패널을 띄우고, 카드를 클릭하면 **즉시 손패에 추가**. 상단 탭(전사/도적/마법사)으로 직업별 카드 풀 전환. 카드 효과·메커니즘 즉석 테스트용 |
| **Ctrl + Shift + E** | **전체 회복 치트** — 체력·에너지를 최대치로 회복 |
> 카드 picker는 메이커 저작 UI `DeckUIGroup/DeckAllHud`(120 슬롯 그리드 + 직업 탭 3종)를 사용하고, 컨트롤러가 런타임에 카드 비주얼·버튼을 바인딩합니다. 구현: 키 바인딩 `tools/deck/cb/boot.mjs`, picker 로직 `tools/deck/cb/deckview.mjs`(`OpenDebugCardPicker`/`OnAllDeckCardButton`), 버튼 바인딩 `tools/deck/cb/deckturn.mjs`(`BindButtons`). 옛 picker UI 생성기 `tools/deck/legacy/hud/deckall.mjs`는 UI 메이커-저작 전환 후 **휴면**(Maker UI가 대체).
### 산출물 재생성
```bash
node tools/deck/gen-slaydeck.mjs # 게임 전체(UI·컨트롤러·common·맵 인카운터)
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)
@@ -165,7 +184,7 @@ node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
## 아키텍처 메모
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다.
현재 게임 전체 로직이 `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).
@@ -173,7 +192,9 @@ node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
## 향후 개선 계획 (후속 후보)
- [x] 전투 루프 · 런 루프 · 절차 생성 맵 · 상점/휴식/유물 방 · 유물 19종 · 물약 · 버프/디버프 · Power · 전직(전사/법사/도적 2차) · 승천+개인 저장 · 전투 모션 · 커스텀 프레임 · **반복 런·로비 맵·NPC·영혼·메소·카메라 추종 (P1~P15 완료)**
- [ ] **도적 카드 아이콘** — Silent 88장에 실 스킬 아이콘(image/fx) 할당, 2차 전직 설명 한글화
- [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 확장, 메뉴 "이어하기" 활성화)
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)

View File

@@ -11,8 +11,8 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
| 산출물 (절대 Read/Edit 금지) | 크기 | 단일 소스 (여기만 편집) | 재생성 명령 |
|---|---|---|---|
| `ui/DefaultGroup.ui` | **~7.1MB** | `data/*.json` + `tools/deck/gen-slaydeck.mjs` | `node tools/deck/gen-slaydeck.mjs` |
| `RootDesk/MyDesk/SlayDeckController.codeblock` | ~270KB | 〃 | 〃 |
| `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` |
@@ -21,8 +21,12 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
| `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/PopupGroup.ui`·`ui/ToastGroup.ui`)**도** Read/Edit 금지 — 이들은 생성기가 없으니 **메이커에서** 편집한다(텍스트 도구로 X). codeblock은 한 줄짜리 JSON이라 Read 시 토큰 폭발.
- 게임 로직·UI 수정 = **`tools/deck/gen-slaydeck.mjs`(생성기 JS) 또는 `data/*.json`(데이터) 수정** → 재생성 → 산출물은 통째로 커밋.
- `.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 템플릿 클론)
@@ -33,7 +37,7 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
- `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``DefaultGroup.ui` 카드핸드 보조 패처
- (옛 `tools/deck/gen-cardhand.mjs`·`hud/*.mjs``tools/deck/legacy/`로 이관 — 휴면, UI 메이커 저작 전환)
## 2. 산출물 검증은 카운트로, 내용 출력 금지
@@ -59,6 +63,7 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
- 브랜치 → 커밋(기능 단위) → 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) 연동 주의

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,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": []
}
}
}

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://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://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://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

@@ -14,7 +14,7 @@
"Defend": {
"name": "아이언 바디",
"cost": 1,
"kind": "Skill",
"kind": "Attack",
"block": 5,
"desc": "방어도 5",
"image": "7648c3b8e1ca44fc8ec353561207a670",
@@ -89,8 +89,8 @@
"name": "분노",
"cost": 1,
"kind": "Power",
"powerEffect": "strengthPerTurn",
"value": 1,
"aoe": true,
"damage": 4,
"desc": "매 턴 시작 시 힘 +1",
"image": "379d86e3de064959aa4612f71e84ccfb",
"class": "warrior",
@@ -237,7 +237,8 @@
"kind": "Skill",
"class": "magician",
"block": 3,
"draw": 1,
"discardAll": true,
"drawPerDiscarded": 1,
"desc": "방어도 3, 드로 1",
"image": "7f70a9dc7e304433bb8121dd9c4df98b",
"rarity": "normal"
@@ -378,7 +379,8 @@
"rarity": "normal",
"desc": "피해를 3 줍니다. 약화를 1 부여합니다.",
"weak": 1,
"damage": 3
"damage": 3,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"SilentStrike": {
"name": "타격",
@@ -387,7 +389,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "피해를 6 줍니다.",
"damage": 6
"damage": 6,
"image": "92a5020c978c46bdabab910598118b86"
},
"Survivor": {
"name": "생존자",
@@ -397,7 +400,8 @@
"rarity": "normal",
"desc": "방어도를 8 얻습니다. 카드를 1장 버립니다.",
"block": 8,
"discard": 1
"discard": 1,
"image": "49c8f279bfa64bf3954037f17da0052d"
},
"SilentDefend": {
"name": "수비",
@@ -406,7 +410,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "방어도를 5 얻습니다.",
"block": 5
"block": 5,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Slice": {
"name": "칼질",
@@ -415,7 +420,20 @@
"class": "bandit",
"rarity": "normal",
"desc": "피해를 6 줍니다.",
"damage": 6
"damage": 6,
"image": "92a5020c978c46bdabab910598118b86"
},
"Shiv": {
"name": "표창",
"cost": 0,
"kind": "Attack",
"class": "shiv",
"rarity": "normal",
"desc": "피해를 4 줍니다. 소멸.",
"damage": 4,
"exhaust": true,
"token": true,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"DaggerSpray": {
"name": "단검 분사",
@@ -426,7 +444,8 @@
"desc": "모든 적에게 피해를 4만큼 2번 줍니다.",
"aoe": true,
"damage": 4,
"hits": 2
"hits": 2,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"DaggerThrow": {
"name": "단검 투척",
@@ -435,9 +454,10 @@
"class": "bandit",
"rarity": "normal",
"desc": "피해를 9 줍니다. 카드를 1장 뽑습니다. 카드를 1장 버립니다.",
"draw": 1,
"drawUntilHandSize": 6,
"damage": 9,
"discard": 1
"discard": 1,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"PoisonedStab": {
"name": "독 찌르기",
@@ -447,7 +467,8 @@
"rarity": "normal",
"desc": "피해를 6 줍니다. 중독을 3 부여합니다.",
"poison": 3,
"damage": 6
"damage": 6,
"image": "19361e72087946b1888684185b40d935"
},
"SuckerPunch": {
"name": "불의의 일격",
@@ -457,7 +478,9 @@
"rarity": "normal",
"desc": "피해를 8 줍니다. 약화를 1 부여합니다.",
"weak": 1,
"damage": 8
"damage": 8,
"cardPlayedDamage": 2,
"image": "92a5020c978c46bdabab910598118b86"
},
"LeadingStrike": {
"name": "선제 타격",
@@ -465,8 +488,10 @@
"kind": "Attack",
"class": "bandit",
"rarity": "normal",
"desc": "피해를 3 줍니다. 단도를 2장 손으로 가져옵니다.",
"damage": 3
"desc": "피해를 3 줍니다. 표창을 2장 손으로 가져옵니다.",
"damage": 3,
"addShiv": 2,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"FollowThrough": {
"name": "완수",
@@ -475,7 +500,10 @@
"class": "bandit",
"rarity": "normal",
"desc": "피해를 7 줍니다. 손에 다른 카드가 5장 이상 있다면, 1번 추가로 적중합니다.",
"damage": 7
"damage": 7,
"otherHandAtLeast": 5,
"bonusHitsWhenOtherHandAtLeast": 1,
"image": "92a5020c978c46bdabab910598118b86"
},
"FlickFlack": {
"name": "재주넘기",
@@ -486,7 +514,8 @@
"desc": "교활. 모든 적에게 피해를 6 줍니다.",
"aoe": true,
"damage": 6,
"sly": true
"sly": true,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"Ricochet": {
"name": "도탄",
@@ -497,7 +526,8 @@
"desc": "교활. 무작위 적에게 피해를 3만큼 4번 줍니다.",
"damage": 3,
"hits": 4,
"sly": true
"sly": true,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"Prepared": {
"name": "예비",
@@ -507,7 +537,8 @@
"rarity": "normal",
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
"draw": 1,
"discard": 1
"discard": 1,
"image": "c1e19219745e44c39ae6ac2f77e347d9"
},
"Anticipate": {
"name": "예측",
@@ -516,7 +547,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "이번 턴 동안 민첩을 2 얻습니다.",
"dex": 2
"dex": 2,
"image": "49c8f279bfa64bf3954037f17da0052d"
},
"Deflect": {
"name": "튕겨내기",
@@ -525,17 +557,19 @@
"class": "bandit",
"rarity": "normal",
"desc": "방어도를 4 얻습니다.",
"block": 4
"block": 4,
"image": "0946f69d84464df29b24b94c744c868d"
},
"BladeDance": {
"name": "검무",
"cost": 1,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "normal",
"desc": "단도를 3장 손으로 가져옵니다. 소멸.",
"damage": 4,
"hits": 3
"desc": "표창을 3장 손으로 가져옵니다. 소멸.",
"addShiv": 3,
"exhaust": true,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"Backflip": {
"name": "공중제비",
@@ -545,7 +579,8 @@
"rarity": "normal",
"desc": "방어도를 5 얻습니다. 카드를 2장 뽑습니다.",
"block": 5,
"draw": 2
"draw": 2,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"DodgeAndRoll": {
"name": "구르기",
@@ -554,7 +589,9 @@
"class": "bandit",
"rarity": "normal",
"desc": "방어도를 4 얻습니다. 다음 턴에, 방어도를 4 얻습니다",
"block": 4
"block": 4,
"nextTurnBlock": 4,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"PiercingWail": {
"name": "귀를 찢는 비명",
@@ -563,18 +600,19 @@
"class": "bandit",
"rarity": "normal",
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
"draw": 1
"draw": 1,
"image": "0946f69d84464df29b24b94c744c868d"
},
"CloakAndDagger": {
"name": "망토와 단검",
"cost": 1,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "normal",
"desc": "방어도를 6 얻습니다. 단도를 1장 손으로 가져옵니다.",
"desc": "방어도를 6 얻습니다. 표창을 1장 손으로 가져옵니다.",
"block": 6,
"damage": 4,
"hits": 1
"addShiv": 1,
"image": "0946f69d84464df29b24b94c744c868d"
},
"DeadlyPoison": {
"name": "맹독",
@@ -583,7 +621,8 @@
"class": "bandit",
"rarity": "normal",
"desc": "중독을 5 부여합니다.",
"poison": 5
"poison": 5,
"image": "19361e72087946b1888684185b40d935"
},
"Snakebite": {
"name": "뱀 물기",
@@ -593,7 +632,8 @@
"rarity": "normal",
"desc": "보존. 중독을 7 부여합니다.",
"poison": 7,
"retain": true
"retain": true,
"image": "19361e72087946b1888684185b40d935"
},
"Untouchable": {
"name": "범접 불가",
@@ -603,7 +643,8 @@
"rarity": "normal",
"desc": "교활. 방어도를 6 얻습니다.",
"block": 6,
"sly": true
"sly": true,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Skewer": {
"name": "꼬챙이",
@@ -612,7 +653,10 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 8만큼 X번 줍니다.",
"draw": 1
"useAllEnergy": true,
"xDamagePerEnergy": 8,
"draw": 1,
"image": "92a5020c978c46bdabab910598118b86"
},
"Backstab": {
"name": "배신",
@@ -621,7 +665,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "선천성. 피해를 11 줍니다. 소멸.",
"damage": 11
"innate": true,
"damage": 11,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"PreciseCut": {
"name": "정밀한 베기",
@@ -630,7 +676,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 13 줍니다. 손에 있는 다른 카드 1장당 피해량이 2 감소합니다.",
"damage": 13
"damage": 13,
"damagePerOtherHandCard": -2,
"image": "92a5020c978c46bdabab910598118b86"
},
"Finisher": {
"name": "마무리",
@@ -639,7 +687,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "이번 턴에 사용한 공격 카드 1장당 피해를 6 줍니다.",
"damage": 6
"damage": 0,
"damagePerAttackPlayedThisTurn": 6,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"MementoMori": {
"name": "메멘토 모리",
@@ -648,7 +698,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 9 줍니다. 이번 턴에 버린 카드 1장당 피해량이 4 증가합니다.",
"damage": 9
"damage": 9,
"damagePerDiscardedThisTurn": 4,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Strangle": {
"name": "목 조르기",
@@ -657,7 +709,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 8 줍니다. 이번 턴에 카드를 사용할 때마다, 대상 적이 체력을 2 잃습니다.",
"damage": 8
"damage": 8,
"image": "92a5020c978c46bdabab910598118b86"
},
"Flechettes": {
"name": "프레췌",
@@ -666,7 +719,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "손에 있는 스킬 카드 1장당 피해를 5 줍니다.",
"damage": 5
"damage": 0,
"damagePerSkillInHand": 5,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"Pounce": {
"name": "덮치기",
@@ -675,7 +730,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 12 줍니다. 다음에 사용하는 스킬 카드의 비용이 0 이 됩니다.",
"damage": 12
"damage": 12,
"nextSkillCostZero": true,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"Dash": {
"name": "돌진",
@@ -685,7 +742,8 @@
"rarity": "unique",
"desc": "방어도를 10 얻습니다. 피해를 10 줍니다.",
"block": 10,
"damage": 10
"damage": 10,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"Predator": {
"name": "천적",
@@ -694,8 +752,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 15 줍니다. 다음 턴에, 카드를 2장 뽑습니다.",
"draw": 2,
"damage": 15
"nextTurnDraw": 2,
"damage": 15,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"Pinpoint": {
"name": "정밀 사격",
@@ -704,7 +763,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "피해를 15 줍니다. 이번 턴에 스킬을 사용할 때마다 비용이 1 감소합니다.",
"damage": 15
"damage": 15,
"skillCostReductionThisTurn": 1,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"CalculatedGamble": {
"name": "계산된 도박",
@@ -713,7 +774,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "손에 있는 모든 카드를 버린 뒤, 버린 카드의 수만큼 카드를 뽑습니다. 소멸.",
"draw": 1
"image": "c1e19219745e44c39ae6ac2f77e347d9",
"discardAll": true,
"drawPerDiscarded": 1
},
"Expose": {
"name": "들춰내기",
@@ -722,18 +785,19 @@
"class": "bandit",
"rarity": "unique",
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
"vuln": 2
"vuln": 2,
"image": "0946f69d84464df29b24b94c744c868d"
},
"HiddenDaggers": {
"name": "숨겨진 단검",
"cost": 0,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "unique",
"desc": "카드를 2장 버립니다. 단도를 2장 손으로 가져옵니다.",
"damage": 4,
"hits": 2,
"discard": 2
"desc": "카드를 2장 버립니다. 표창을 2장 손으로 가져옵니다.",
"discard": 2,
"addShiv": 2,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"EscapePlan": {
"name": "탈출구",
@@ -742,8 +806,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "카드를 1장 뽑습니다. 뽑은 카드가 스킬 카드라면, 방어도를 3 얻습니다.",
"block": 3,
"draw": 1
"draw": 1,
"drawSkillBlock": 3,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"Acrobatics": {
"name": "곡예",
@@ -753,7 +818,8 @@
"rarity": "unique",
"desc": "카드를 3장 뽑습니다. 카드를 1장 버립니다.",
"draw": 3,
"discard": 1
"discard": 1,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"HandTrick": {
"name": "손기술",
@@ -762,7 +828,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
"block": 7
"block": 7,
"image": "c1e19219745e44c39ae6ac2f77e347d9"
},
"Mirage": {
"name": "신기루",
@@ -771,7 +838,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "모든 적에게 부여된 중독과 동일한 만큼의 방어도를 얻습니다. 소멸.",
"draw": 1
"draw": 1,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Expertise": {
"name": "전문성",
@@ -780,7 +848,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "손에 있는 카드가 6장이 될 때까지 카드를 뽑습니다.",
"draw": 1
"image": "c1e19219745e44c39ae6ac2f77e347d9",
"drawUntilHandSize": 6
},
"BubbleBubble": {
"name": "차오르는 독",
@@ -789,7 +858,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
"poison": 9
"poison": 9,
"image": "19361e72087946b1888684185b40d935"
},
"Blur": {
"name": "흐릿함",
@@ -798,7 +868,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "방어도를 5 얻습니다. 다음 턴 시작 시 방어도가 사라지지 않습니다.",
"block": 5
"block": 5,
"nextTurnKeepBlock": true,
"image": "0946f69d84464df29b24b94c744c868d"
},
"LegSweep": {
"name": "다리 걸기",
@@ -808,17 +880,18 @@
"rarity": "unique",
"desc": "약화를 2 부여합니다. 방어도를 11 얻습니다.",
"block": 11,
"weak": 2
"weak": 2,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"UpMySleeve": {
"name": "비책",
"cost": 2,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "unique",
"desc": "단도를 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
"damage": 4,
"hits": 3
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
"addShiv": 3,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"BouncingFlask": {
"name": "탄성 플라스크",
@@ -827,7 +900,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
"poison": 9
"poison": 9,
"image": "19361e72087946b1888684185b40d935"
},
"Reflex": {
"name": "반사신경",
@@ -837,7 +911,8 @@
"rarity": "unique",
"desc": "교활. 카드를 2장 뽑습니다.",
"draw": 2,
"sly": true
"sly": true,
"image": "49c8f279bfa64bf3954037f17da0052d"
},
"Haze": {
"name": "아지랑이",
@@ -847,7 +922,8 @@
"rarity": "unique",
"desc": "교활. 모든 적에게 중독을 4 부여합니다.",
"poison": 4,
"sly": true
"sly": true,
"image": "19361e72087946b1888684185b40d935"
},
"Tactician": {
"name": "전략가",
@@ -856,9 +932,9 @@
"class": "bandit",
"rarity": "unique",
"desc": "교활. 을 얻습니다.",
"powerEffect": "energyPerTurn",
"value": 1,
"sly": true
"gainEnergy": 1,
"sly": true,
"image": "c1e19219745e44c39ae6ac2f77e347d9"
},
"WellLaidPlans": {
"name": "괜찮은 전략",
@@ -867,18 +943,19 @@
"class": "bandit",
"rarity": "unique",
"desc": "내 턴 종료 시, 카드를 최대 1장까지 보존합니다.",
"powerEffect": "blockPerTurn",
"value": 2
"powerEffect": "retainOne",
"value": 1,
"image": "c1e19219745e44c39ae6ac2f77e347d9"
},
"InfiniteBlades": {
"name": "무한의 검날",
"cost": 1,
"kind": "Attack",
"kind": "Power",
"class": "bandit",
"rarity": "unique",
"desc": "내 턴 시작 시, 단도를 1장 손으로 가져옵니다.",
"damage": 4,
"hits": 1
"desc": "내 턴 시작 시, 표창을 1장 손으로 가져옵니다.",
"turnStartShiv": 1,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"Footwork": {
"name": "발놀림",
@@ -887,7 +964,8 @@
"class": "bandit",
"rarity": "unique",
"desc": "민첩을 2 얻습니다.",
"dex": 2
"dex": 2,
"image": "49c8f279bfa64bf3954037f17da0052d"
},
"Outbreak": {
"name": "발병",
@@ -899,7 +977,8 @@
"aoe": true,
"powerEffect": "strengthPerTurn",
"value": 1,
"damage": 11
"damage": 11,
"image": "19361e72087946b1888684185b40d935"
},
"NoxiousFumes": {
"name": "유독 가스",
@@ -909,8 +988,9 @@
"rarity": "unique",
"desc": "내 턴 시작 시, 모든 적에게 중독을 2 부여합니다.",
"poison": 2,
"powerEffect": "strengthPerTurn",
"value": 1
"powerEffect": "poisonPerTurn",
"value": 2,
"image": "19361e72087946b1888684185b40d935"
},
"Accuracy": {
"name": "정밀",
@@ -918,9 +998,10 @@
"kind": "Power",
"class": "bandit",
"rarity": "unique",
"desc": "단도의 피해량이 4 증가합니다.",
"desc": "표창의 피해량이 4 증가합니다.",
"powerEffect": "strengthPerTurn",
"value": 1
"value": 1,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"PhantomBlades": {
"name": "환영검",
@@ -928,9 +1009,10 @@
"kind": "Power",
"class": "bandit",
"rarity": "unique",
"desc": "단도가 보존을 얻습니다. 매 턴마다 처음으로 사용하는 단도의 피해량이 9 증가합니다.",
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
"powerEffect": "strengthPerTurn",
"value": 1
"value": 1,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Speedster": {
"name": "스피드스터",
@@ -940,9 +1022,9 @@
"rarity": "unique",
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
"aoe": true,
"powerEffect": "strengthPerTurn",
"value": 1,
"damage": 2
"powerEffect": "damagePerTurn",
"value": 2,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"GrandFinale": {
"name": "대단원의 막",
@@ -951,8 +1033,10 @@
"class": "bandit",
"rarity": "legend",
"desc": "뽑을 카드 더미에 카드가 없을 때만 사용할 수 있습니다. 모든 적에게 피해를 60 줍니다.",
"playableWhenDrawPileEmpty": true,
"aoe": true,
"damage": 60
"damage": 60,
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
},
"Assassinate": {
"name": "암살",
@@ -961,8 +1045,10 @@
"class": "bandit",
"rarity": "legend",
"desc": "선천성. 피해를 10 줍니다. 취약을 1 부여합니다. 소멸.",
"innate": true,
"vuln": 1,
"damage": 10
"damage": 10,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"EchoingSlash": {
"name": "메아리 참격",
@@ -972,7 +1058,8 @@
"rarity": "legend",
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
"aoe": true,
"damage": 10
"damage": 10,
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
},
"TheHunt": {
"name": "사냥",
@@ -981,7 +1068,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
"damage": 10
"damage": 10,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"Murder": {
"name": "살해",
@@ -990,7 +1078,9 @@
"class": "bandit",
"rarity": "legend",
"desc": "피해를 1 줍니다. 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가합니다.",
"damage": 1
"damage": 1,
"damagePerCardDrawnThisCombat": 1,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"Malaise": {
"name": "불쾌",
@@ -999,7 +1089,9 @@
"class": "bandit",
"rarity": "legend",
"desc": "적이 힘을 X 잃습니다. 약화를 X 부여합니다. 소멸.",
"weak": 3
"useAllEnergy": true,
"xWeakPerEnergy": 1,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Adrenaline": {
"name": "아드레날린",
@@ -1009,8 +1101,8 @@
"rarity": "legend",
"desc": "를 얻습니다. 카드를 2장 뽑습니다. 소멸.",
"draw": 2,
"powerEffect": "energyPerTurn",
"value": 1
"gainEnergy": 1,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"StormOfSteel": {
"name": "강철의 폭풍",
@@ -1018,9 +1110,10 @@
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 단도를 손으로 가져옵니다.",
"draw": 1,
"discardAll": true
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 표창을 손으로 가져옵니다.",
"discardAll": true,
"addShivPerDiscard": true,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"ShadowStep": {
"name": "그림자 걸음",
@@ -1029,8 +1122,9 @@
"class": "bandit",
"rarity": "legend",
"desc": "손에 있는 모든 카드를 버립니다. 다음 턴에, 공격 카드의 피해량이 2배가 됩니다.",
"draw": 1,
"discardAll": true
"nextTurnAttackMultiplier": 2,
"discardAll": true,
"image": "0946f69d84464df29b24b94c744c868d"
},
"Shadowmeld": {
"name": "그림자 은신",
@@ -1039,7 +1133,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "이번 턴 동안 얻는 방어도가 2배가 됩니다.",
"draw": 1
"blockGainMultiplier": 2,
"image": "0946f69d84464df29b24b94c744c868d"
},
"CorrosiveWave": {
"name": "부식성 파도",
@@ -1048,17 +1143,18 @@
"class": "bandit",
"rarity": "legend",
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
"poison": 2
"poison": 2,
"image": "19361e72087946b1888684185b40d935"
},
"BladeOfInk": {
"name": "잉크 칼날",
"cost": 1,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "잉크투성이 단도를 2장 손으로 가져옵니다.",
"damage": 4,
"hits": 2
"desc": "잉크투성이 표창을 2장 손으로 가져옵니다.",
"addShiv": 2,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"Burst": {
"name": "폭주",
@@ -1067,7 +1163,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
"draw": 1
"draw": 1,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"KnifeTrap": {
"name": "칼날 함정",
@@ -1075,8 +1172,9 @@
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 단도를 사용합니다.",
"draw": 1
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 표창을 사용합니다.",
"draw": 1,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"BulletTime": {
"name": "불릿 타임",
@@ -1085,8 +1183,9 @@
"class": "bandit",
"rarity": "legend",
"desc": "이번 턴 동안 더 이상 카드를 뽑을 수 없습니다. 이번 턴 동안 손에 있는 모든 카드를 비용 없이 사용할 수 있습니다.",
"powerEffect": "energyPerTurn",
"value": 1
"handCostZeroThisTurn": true,
"drawDisabledThisTurn": true,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
},
"Nightmare": {
"name": "악몽",
@@ -1095,7 +1194,10 @@
"class": "bandit",
"rarity": "legend",
"desc": "카드를 1장 선택합니다. 다음 턴에, 그 카드의 복사본을 3장 손으로 가져옵니다. 소멸.",
"draw": 1
"nextTurnCopies": 3,
"nextTurnSelectHandCard": true,
"nextTurnSelectPrompt": "복사할 카드를 선택하세요",
"image": "0946f69d84464df29b24b94c744c868d"
},
"ToolsOfTheTrade": {
"name": "작업 도구",
@@ -1104,10 +1206,9 @@
"class": "bandit",
"rarity": "legend",
"desc": "내 턴 시작 시, 카드를 1장 뽑고 카드를 1장 버립니다.",
"draw": 1,
"powerEffect": "energyPerTurn",
"value": 1,
"discard": 1
"turnStartDraw": 1,
"turnStartDiscard": 1,
"image": "c1e19219745e44c39ae6ac2f77e347d9"
},
"Afterimage": {
"name": "잔상",
@@ -1116,9 +1217,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "카드를 사용할 때마다, 방어도를 1 얻습니다.",
"block": 1,
"powerEffect": "blockPerTurn",
"value": 2
"image": "0946f69d84464df29b24b94c744c868d",
"cardPlayedBlock": 1
},
"Accelerant": {
"name": "촉진제",
@@ -1128,7 +1228,8 @@
"rarity": "legend",
"desc": "중독이 1번 추가로 발동합니다.",
"powerEffect": "strengthPerTurn",
"value": 1
"value": 1,
"image": "19361e72087946b1888684185b40d935"
},
"Envenom": {
"name": "독 바르기",
@@ -1137,9 +1238,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "공격 카드가 막히지 않은 피해를 줄 때마다, 중독을 1 부여합니다.",
"poison": 1,
"powerEffect": "strengthPerTurn",
"value": 1
"attackPoison": 1,
"image": "19361e72087946b1888684185b40d935"
},
"MasterPlanner": {
"name": "설계의 대가",
@@ -1149,7 +1249,8 @@
"rarity": "legend",
"desc": "스킬 카드를 사용 시, 그 카드가 교활을 얻습니다.",
"powerEffect": "strengthPerTurn",
"value": 1
"value": 1,
"image": "c1e19219745e44c39ae6ac2f77e347d9"
},
"Tracking": {
"name": "추적",
@@ -1159,19 +1260,20 @@
"rarity": "legend",
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
"powerEffect": "strengthPerTurn",
"value": 1
"value": 1,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"FanOfKnives": {
"name": "칼날 부채",
"cost": 2,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "단도가 이제 모든 적을 대상으로 합니다. 단도를 4장 손으로 가져옵니다.",
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"damage": 4,
"hits": 4
"addShiv": 4,
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
},
"SerpentForm": {
"name": "구렁이의 형상",
@@ -1180,9 +1282,8 @@
"class": "bandit",
"rarity": "legend",
"desc": "카드를 사용할 때마다, 무작위 적에게 피해를 4 줍니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"damage": 4
"cardPlayedRandomDamage": 4,
"image": "19361e72087946b1888684185b40d935"
},
"Abrasive": {
"name": "연마",
@@ -1193,7 +1294,8 @@
"desc": "교활. 민첩을 1 얻습니다. 가시를 4 얻습니다.",
"dex": 1,
"thorns": 4,
"sly": true
"sly": true,
"image": "49c8f279bfa64bf3954037f17da0052d"
},
"Suppress": {
"name": "진압",
@@ -1202,8 +1304,10 @@
"class": "bandit",
"rarity": "legend",
"desc": "선천성. 피해를 11 줍니다. 약화를 3 부여합니다.",
"innate": true,
"weak": 3,
"damage": 11
"damage": 11,
"image": "b1360ed0c4b942309d240634b8f36872"
},
"WraithForm": {
"name": "유령의 형상",
@@ -1213,26 +1317,8 @@
"rarity": "legend",
"desc": "불가침을 2 얻습니다. 내 턴 종료 시 민첩을 1 잃습니다.",
"powerEffect": "blockPerTurn",
"value": 8
},
"Flanking": {
"name": "측면 공격",
"cost": 2,
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "이번 턴에 대상 적이 다른 플레이어에게 받는 피해량이 2배가 됩니다.",
"draw": 1
},
"Sneaky": {
"name": "비열함",
"cost": 2,
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "교활. 다른 플레이어가 적을 공격할 때마다, 방어도를 1 얻습니다.",
"block": 1,
"sly": true
"value": 8,
"image": "0946f69d84464df29b24b94c744c868d"
}
},
"starterDecks": {

View File

@@ -119,6 +119,65 @@
{ "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",

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,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,73 @@
# Phase 2 — 캐릭터 선택 메이커 저작 파일럿 설계
작성일: 2026-06-16
브랜치: `feature/charselect-maker-pilot` (Phase 1b `feature/cb-modularization`/PR #71 위에 스택)
## 목표
하이브리드 UI 로드맵의 **패턴 (b)**(메이커 시각 편집) 검증 파일럿. **캐릭터 선택 화면**을 "생성기 소유 → 메이커 소유"로 이관한다:
- **레이아웃**(패널·카드 위치·버튼)은 메이커에서 시각 편집(생성기가 안 덮음).
- **동적 내용**(캐릭터 이미지·선택 테두리·상태 텍스트)은 `SlayDeckController`가 런타임에 **경로로 주입** = 컨트롤러 내용주입.
성공 시 Phase 3에서 상점·전체덱 등으로 확장.
## 현재 구조 (조사 결과)
- charselect는 **생성 섹션**: `lib/ui-helpers.mjs``GENERATED_UI_SECTIONS`(:17)·`UI_APPEND_ORDER`(:35)에 `'CharacterSelectHud'` 포함. `hud/charselect.mjs``buildCharSelect()`가 엔티티 emit, `upsertUi``emit('CharacterSelectHud', buildCharSelect())`.
- **이미지 = 생성 시 주입**: `hud/charselect.mjs:86` `sprite({ dataId: CHARS.portraits[cls.classId], … })`. 런타임 주입 아님.
- **컨트롤러는 경로 구동**: `cb/charselect.mjs``RenderCharacterSelect`(각 `{Warrior,Mage,Thief}Button``SpriteGUIRendererComponent.Color`로 선택 테두리 + Status 텍스트), `SelectClass`, `StartNewGame`. 바인딩은 `cb/state.mjs``BindMenuButtons`(경로로 WarriorButton·BackButton·StartButton 등). 표시 토글은 `ShowState`(경로). **이미지 주입은 없음.**
- **런타임 시드 모델**: `self.CardFrames``${luaFramesTable()}`로 OnBeginPlay(cb/boot)·StartRun(cb/run)에서 주입 → `ClassPortraits`의 모델.
- `upsertUi` 동작: 기존 `.ui` 로드 → 생성 섹션 엔티티 필터아웃 → emit 섹션 재추가. **생성 섹션에서 빠지면 `isGeneratedUiEntity=false`라 필터 안 됨 → 기존 엔티티 보존(stock)**.
## 상세 설계
### ① 생성 중단 → stock화 (generate-once-then-stop)
- `lib/ui-helpers.mjs` `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER`에서 `'CharacterSelectHud'` 제거.
- `gen-slaydeck.mjs`(upsertUi)에서 `emit('CharacterSelectHud', buildCharSelect())` + 관련 import 제거. `hud/charselect.mjs`**삭제**(부트스트랩 완료 — git 이력에 레퍼런스 남음).
- 효과: 현재 `DefaultGroup.ui`의 charselect 엔티티가 그대로 **stock**으로 보존 → 메이커 시각 편집 가능, 재생성에 안 덮임.
### ② 이미지 런타임 주입 (컨트롤러 내용주입 = 패턴 b 핵심)
- `lib/data.mjs``luaCharsTable()` 신설(`data/characters.json``portraits` 시드, `luaFramesTable`/`luaNodeIconsTable` 패턴; `self.ClassPortraits = { warrior="…", magician="…", bandit="…" }`).
- 주입 지점: `cb/boot.mjs` OnBeginPlay·`cb/run.mjs` StartRun에 `${luaCharsTable()}`(CardFrames 시드 옆) + prop `ClassPortraits`(any) 선언.
- `cb/charselect.mjs` `RenderCharacterSelect`에 이미지 주입 추가: 각 `{key}Button/Art` 엔티티의 `SpriteGUIRendererComponent.ImageRUID``self.ClassPortraits[classId]`로 설정(경로별 isvalid 가드). → 메이커 레이아웃(빈/임의 Art)이어도 컨트롤러가 올바른 이미지 채움. **characters.json 데이터 구동 유지.**
### ③ 경로 구동 유지 (무변경)
- 선택 테두리·Status·버튼 바인딩(`RenderCharacterSelect` 색/텍스트·`SelectClass`·`BindMenuButtons`·`StartNewGame`·`ShowState`)은 이미 경로 기반 → 변경 없음.
### ④ 엔티티 경로 계약 (docs 명시)
메이커 편집 시 아래 경로 유지 필수(컨트롤러가 이 경로로 구동; 누락 시 isvalid 가드로 무시되되 그 부분 동작 안 함):
```
/ui/DefaultGroup/CharacterSelectHud (루트, ShowState 토글)
/OpaqueBackdrop /Title /Status
/WarriorButton (+ /Art ← 이미지 주입, /NameBanner, /Name)
/ThiefButton (+ /Art, /NameBanner, /Name)
/MageButton (+ /Art, /NameBanner, /Name)
/StartButton /BackButton
```
(#67로 DeckButton 제거됨.) classId 매핑: Warrior→warrior, Thief→bandit, Mage→magician.
## 검증 (동작 — 바이트동일 아님)
- 생성기: charselect 제거 후 `node tools/deck/gen-slaydeck.mjs`**charselect 외 산출물 무영향**(`diffcheck`로 codeblock·common 확인; ui는 charselect 섹션만 stock으로 잔류·다른 섹션 동일). charselect 엔티티가 ui에 존재(`count.mjs`).
- 메이커 플레이테스트: 로비→직업선택→**3 이미지가 컨트롤러 주입으로 표시**→클릭 시 금색테두리·Status→시작 시 그 직업으로 런→**메이커에서 카드 위치 이동 후 재생성해도 유지** 확인.
## 범위 밖
- 상점·전체덱 등 다른 화면(Phase 3).
- 새 UIGroup(.ui) 분리(경로·ShowState 재작업 큼) — DefaultGroup 내 stock으로 충분.
- 게임 규칙·다른 화면 변경.
## 리스크
- stock 전환 시 charselect 엔티티의 `.ui` 내 직렬화 위치 이동 가능 → 렌더는 경로/displayOrder 기반이라 무관하나 플레이테스트로 확인.
- 메이커가 경로를 바꾸면 계약 깨짐 → 경로 표로 가드. isvalid 가드로 크래시는 방지.
- 의존: Phase 1b(cb/charselect·boot·run) 위 스택. #70·#71 머지 후 main 리타겟.
## 변경 파일 요약
| 파일 | 변경 |
|---|---|
| `tools/deck/lib/ui-helpers.mjs` | `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER`에서 CharacterSelectHud 제거 |
| `tools/deck/gen-slaydeck.mjs` | upsertUi에서 charselect emit·import 제거 |
| `tools/deck/hud/charselect.mjs` | **삭제** |
| `tools/deck/lib/data.mjs` | `luaCharsTable()` 신설 |
| `tools/deck/cb/boot.mjs`·`cb/run.mjs` | `${luaCharsTable()}` 시드 + ClassPortraits prop |
| `tools/deck/cb/charselect.mjs` | `RenderCharacterSelect`에 Art ImageRUID 주입 |
| `docs/...charselect 경로 계약` | 경로 표(이 스펙 §④) |
| `ui/DefaultGroup.ui`·codeblock | 재생성(charselect는 stock 잔류) |

View File

@@ -0,0 +1,69 @@
# Phase 1b — codeblock 메서드 모듈화 설계
작성일: 2026-06-16
브랜치: `feature/cb-modularization` (Phase 1 `feature/gen-modularization`/PR #70 위에 스택)
## 목표
Phase 1에서 UI emit을 모듈화한 데 이어, `gen-slaydeck.mjs`**codeblock 메서드 161개(~3,200줄)**를 기능별 모듈로 분리한다. 출력 `RootDesk/MyDesk/SlayDeckController.codeblock`은 **바이트 동일**(순수 소스 리팩터·무위험). 하이브리드 UI 로드맵 (a) 유지보수 정리의 완결.
## 현재 구조 (조사 결과)
- `writeCodeblocks()`(현 `gen-slaydeck.mjs:291`)가 단일 호출로 codeblock을 만든다:
`const combat = codeblock('SlayDeckController', 'SlayDeckController', [<prop 103개>], [<method 161개>])` (`:301`~`:3617`, ~3,300줄).
- 헬퍼: `prop()`·`method()`·`codeblock()` (현 `:240`~`:290` 부근, 오케스트레이터 잔류분).
- `writeCodeblocks` 지역 상수: `RUN_LENGTH`·`GOLD_PER_WIN`·`CARD_PRICE`·`REST_HEAL`·`RELIC_PRICE`·`ACT_COUNT`·`ACT_MAPS`·`LOBBY_MAP`·`LOBBY_SPAWN` 등 — 메서드 Lua 문자열 보간에 쓰임.
- 메서드 본문은 **Lua 문자열**. JS 보간(`${RUN_LENGTH}`·`${luaCardsTable(CARDS.cards)}`·`${CAM.zoomRatio}` 등)은 모듈 로드 시점에 평가됨 → 모듈은 보간에 쓰는 상수/데이터/헬퍼를 import해야 한다.
- 161 메서드 이름(순서): OnBeginPlay → [ascension 10종] → HideGameHud·ShowState·ShowMainMenu·BindMenuButtons·ShowLobby… → [soul 12종] → [character/job] → StartRun·StartCombat·[combat 다수] → [deck/hand] → [deckview] → [motion] → [relics/potions] → [tooltip] → [reward] → [map] → [shop/rest/treasure]. 자연스러운 **연속 런(run)**으로 묶임.
## 상세 설계
### 핵심 제약: 바이트 동일 → 메서드 순서 보존
`codeblock`의 methods 배열은 **순서가 직렬화에 반영**된다. 따라서 모듈은 "기능 버킷"이 아니라 **원본 161-메서드 시퀀스의 연속 구간**으로 나눈다(구간을 그 테마로 명명). `writeCodeblocks`가 모듈 배열을 **원본 순서대로 concat** → 바이트 동일. (Phase 1 HUD 분리와 동일 원리: HUD도 upsertUi 내 연속이었음.)
### 목표 파일 구조
```
tools/deck/
gen-slaydeck.mjs # 오케스트레이터: writeCodeblocks()가 codeblock(…, [props], [...m1, ...m2, …]) concat
lib/
codeblock.mjs # 신설 — prop()·method()·codeblock() 헬퍼 + writeCodeblocks 지역 상수
# (RUN_LENGTH·GOLD_PER_WIN·CARD_PRICE·REST_HEAL·RELIC_PRICE·ACT_COUNT·ACT_MAPS·LOBBY_MAP·LOBBY_SPAWN …)
cb/ # 신설 — 메서드 연속구간 모듈 (각 `export const xMethods = [ method(...), … ]`)
state.mjs ascension.mjs soul.mjs jobs.mjs run.mjs combat.mjs
deck.mjs deckview.mjs motion.mjs items.mjs tooltip.mjs reward.mjs shop.mjs … (~12-14, 실제 런 경계로 확정)
```
### 모듈 계약
-`cb/<name>.mjs`: `export const <name>Methods = [ method('A', \`…\`, …), method('B', …), … ];` — **메서드 호출 verbatim 이동**.
- import: `lib/codeblock.mjs`(method·prop·codeblock·상수), `lib/data.mjs`(CARDS·luaCardsTable·luaStr·CAM 등 보간용). UI 헬퍼는 메서드 보간에 거의 안 쓰임(필요 구간만 `lib/ui-helpers.mjs`).
- `writeCodeblocks()`(오케스트레이터): `codeblock('SlayDeckController','SlayDeckController', [ ...props ], [ ...stateMethods, ...ascensionMethods, … ])` — concat 순서 = 원본 순서.
### 범위/결정
- **메서드 161개만 모듈화.** **prop 103개는 오케스트레이터에 단일 리스트로 유지** — 한 줄짜리라 분리 가치 낮고 prop↔feature 매핑 모호(추후 필요시 별도). 게임 로직·Lua **무변경**(순수 소스 리팩터).
- 공유 헬퍼(method/prop/codeblock) + writeCodeblocks 지역 상수 → `lib/codeblock.mjs`. (이 상수들이 메서드 모듈 보간에 필요하므로 lib로.)
### 검증 (안전망)
- 구간 추출마다 `node tools/deck/gen-slaydeck.mjs``node tools/verify/diffcheck.mjs``SlayDeckController.codeblock` **IDENTICAL**(`ui`·`common` 무영향이나 함께 확인). 증분(구간 1~2개씩) + 커밋.
- 미러 테스트 `sim-balance`·`rogue-map` 무영향(회귀 확인차 실행).
- 전투규칙·맵생성 Lua 미변경 → 미러 동기화 불필요.
### 미러/하네스
- RULES §1의 gen-slaydeck 단일소스에 `cb/`·`lib/codeblock.mjs` 추가 반영.
## 범위 밖
- prop 모듈화(추후).
- Phase 2(메이커 UIGroup 파일럿).
- 게임 동작·데이터 변경.
## 리스크
- 메서드가 writeCodeblocks **지역변수/다른 메서드 정의를 JS레벨로 참조**하면(드묾 — 대부분 Lua 문자열 내 `self:Method()` 런타임 호출이라 JS-무관) 추출 시 undefined → diffcheck/throw로 즉시 노출 → 그 구간만 인자/상수 조정.
- 모듈 import는 ui-helpers처럼 export 이름 자동 파생로 누락 방지. 단방향 의존 orchestrator→cb→lib(순환 없음).
## 변경 파일 요약
| 파일 | 변경 |
|---|---|
| `tools/deck/lib/codeblock.mjs` | **신설** — prop/method/codeblock 헬퍼 + 공유 상수 |
| `tools/deck/cb/*.mjs` (~12-14) | **신설** — 메서드 연속구간 모듈 |
| `tools/deck/gen-slaydeck.mjs` | writeCodeblocks를 import+concat로 축소(메서드 본문 → 모듈) |
| `RULES.md` | §1에 cb/·lib/codeblock 반영 |
| `SlayDeckController.codeblock`·`ui`·`common` | **무변경**(바이트 동일이 합격 기준) |

View File

@@ -0,0 +1,80 @@
# 생성기 모듈화 (Phase 1) + 하이브리드 UI 로드맵 설계
작성일: 2026-06-16
브랜치: `feature/gen-modularization`
## 배경 / 동기
`DefaultGroup.ui`에 모든 UI(캐릭터 선택·상점·전체 덱·전투…)가 들어 있어, 사용자가 (a) **생성기 코드 유지보수**와 (b) **메이커에서 기능별 시각 편집**을 원함.
핵심 제약(브레인스토밍에서 확정): MSW에서 "Layer"는 렌더 z순서일 뿐 논리 분리 도구가 아니고, 실제 UI 그룹 단위는 **UIGroup**(`.ui` 파일 = UIGroup, 현재 Default/Popup/Toast 3개). 그리고 **같은 UI를 '생성'과 '메이커 수동 편집' 둘 다로 둘 수 없음**(재생성이 수동 편집을 덮어씀).
**소유 모델 = 하이브리드·단계적**(사용자 승인): 정적 레이아웃은 메이커 저작(시각 편집), 동적 내용은 컨트롤러가 런타임 주입. 단, 한 번에 안 하고 단계적으로.
## 로드맵 (단계적 — 각 Phase가 자체 spec→plan)
- **Phase 1 (이 문서)**: `gen-slaydeck.mjs`(~6,200줄)의 **UI emit을 기능별 모듈로 분리**. 출력 `.ui`/`codeblock` **바이트 동일**(순수 리팩터·무위험). (a) 충족 + (b) 토대(화면별 파일).
- **Phase 2 (후속 spec)**: 화면 1개(**캐릭터 선택**) 파일럿 — 정적 레이아웃을 메이커 저작 UIGroup으로 이관, 생성기는 그 화면 emit 중단, `SlayDeckController`가 경로로 내용(이미지·텍스트) 주입. (b) 패턴 검증.
- **Phase 3 (후속 spec)**: 검증되면 상점·전체덱 등으로 확장.
## 현재 구조 (조사 결과)
- **공유 인프라**(~48530): `luaSoulShopTable`/`luaFramesTable`/`luaNodeIconsTable`/`luaRelicsTable`/`luaPotionsTable`/`luaIntentsArray`/`luaEnemiesTable`/`luaStr`/`luaJobsTable`/`luaCardsTable`/`luaDeckTable`/`frameRuid`/`cardFaceLayout`/`guid`/`transform`/`sprite`/`button`/`text`/`scrollLayoutGroup`/`entity`/`uiPath`/`sectionRoot`/`isGeneratedUiEntity`/`appendUiSection`. 데이터 로드 상수(CARDS/CHARS/ENEMIES/RELICS/POTIONS/CARDFRAMES/NODEICONS/CAM) 및 색·치수 상수(GOLD/WHITE/TRANSPARENT/ALIGN_*/CARD_W/CARD_H 등).
- **`guid(prefix, n)`은 순수 함수**(`:284`, 내부 카운터 없음; ns는 prefix→바이트 매핑). **모듈 호출 순서와 무관하게 동일 guid** → 분리해도 바이트 동일.
- **`upsertUi()`**(`:529`)가 UI 오케스트레이터: 기존 `DefaultGroup.ui` 로드 → 생성 섹션 필터(stock 보존) → 로컬 `emit(section, entities)` 클로저로 누적 → CardHand 스톡카드 in-place upsert(`:565691`, 특수) → HUD별 `const x=[]; const add=…; add(entity(...)); …; emit('X', x)` → (말미) 병합·기록.
- **HUD emit 16종(순서·라인)**: DeckHud(`:808`) · DeckInspectHud(`:942`) · DeckAllHud(`:1097`) · CombatHud(`:1587`) · RewardHud(`:1681`) · MapHud(`:1839`) · ShopHud(`:2038`) · RestHud(`:2095`) · TreasureHud(`:2181`) · JobChoiceHud(`:2229`) · JobSelectHud(`:2314`) · MainMenu(`:2616`) · CharacterSelectHud(`:2617`) · LobbyHud(`:2672`) · BoardHud(`:2727`) · SoulShopHud(`:2814`). **각 섹션은 서로의 지역변수 비참조**(헬퍼·데이터 상수만 사용).
- **codeblock 메서드**(`prop`/`method`/`codeblock`/`writeCodeblocks` `:28366124`, ~3,200줄) + **patchCommon**(`:6125`). **Phase 1 범위 제외.**
## Phase 1 상세 설계
### 목표 파일 구조
```
tools/deck/
gen-slaydeck.mjs # 오케스트레이터(축소): import lib+hud → 데이터 로드 → upsertUi(HUD 모듈 순차) → writeCodeblocks → patchCommon
lib/
ui-helpers.mjs # guid, transform, sprite, button, text, entity, scrollLayoutGroup,
# 상수(GOLD/WHITE/TRANSPARENT/ALIGN_*/CARD_W/CARD_H/UI_ROOT 등), cardFaceLayout,
# uiPath/sectionRoot/isGeneratedUiEntity/appendUiSection
data.mjs # CARDS/CHARS/ENEMIES/RELICS/POTIONS/CARDFRAMES/NODEICONS/CAM 로드·검증
# + luaXxxTable·frameRuid
hud/
deckhud.mjs deckinspect.mjs deckall.mjs combat.mjs reward.mjs map.mjs
shop.mjs rest.mjs treasure.mjs jobchoice.mjs jobselect.mjs mainmenu.mjs
charselect.mjs lobby.mjs board.mjs soulshop.mjs
```
### 모듈 계약
-`hud/<name>.mjs`: `export function build<Name>()` → 자기 HUD 엔티티 배열 반환. 필요한 헬퍼·상수·데이터는 `lib/`에서 **import**(거대 deps 객체 전달 금지).
- `upsertUi()`(오케스트레이터에 잔류)는 기존 **순서 그대로** `emit('DeckHud', buildDeckHud())``emit('SoulShopHud', buildSoulShop())` 호출. `emit`·섹션 병합 로직 불변.
- **CardHand 스톡카드 in-place upsert**(`:565691`)는 기존 `.ui` 엔티티를 변형하는 특수 로직 → 오케스트레이터(또는 `hud/cardhand.mjs`)에 그대로 유지. import 경계만 정리.
### 바이트 동일 불변식 (가장 중요)
- 리팩터는 **출력 변경 0**이 목표. 보장 근거: guid 순수·emit 순서·entity 구성 모두 보존, 로직 이동만.
- **합격 기준**: 리팩터 후 `node tools/deck/gen-slaydeck.mjs``git diff` 결과가 **`ui/DefaultGroup.ui`·`SlayDeckController.codeblock`에 0 변경**(`Global/common.gamelogic`은 LF churn만 허용 → `git checkout`).
### 증분 실행 전략
- 한 번에 16개 다 옮기지 말고 **HUD 1~2개씩 추출 → 재생성 → `git diff` 빈 결과 확인 → 커밋** 반복. 첫 추출(예: SoulShopHud 같은 말단 + lib 골격) 성공 후 패턴 반복.
- lib 추출(헬퍼·상수·데이터) 먼저 → 그 다음 HUD 모듈을 하나씩 lib import로 전환.
### 미러/테스트·하네스
- 전투규칙·맵생성 Lua **무변경**`sim-balance`/`rogue-map` 미러 동기화 불필요(회귀 확인차 `node --test` 실행).
- **RULES 동기화**: 생성기가 다중 파일이 되므로 RULES §1 "단일 소스"/보조 생성기 표를 `tools/deck/`(gen-slaydeck + lib/ + hud/)로 갱신.
## 범위 밖 (명시)
- codeblock 메서드(`method()` ~3,200줄) 분리 — 더 크고 (b)와 무관·리스크↑. 원하면 별도 **Phase 1b** spec.
- 게임 동작·데이터·런타임 로직 변경. (순수 소스 리팩터)
- UIGroup 분할·메이커 저작 이관 — Phase 2 이후.
## 리스크
- 클로저 참조(헬퍼/상수)를 import로 전환하는 광범위·기계적 수정 — 누락 시 런타임 throw 또는 출력 diff로 **즉시** 노출(바이트 검증이 안전망).
- 상수 정의 위치 산재(CARD_W·GOLD·UI_ROOT 등 top-level) — lib로 이동 시 누락 주의. 추출 전 `grep`으로 전체 상수 인벤토리 작성.
- ESM 순환 import 주의(lib는 hud를 import하지 않음 — 단방향: orchestrator→hud→lib).
## 변경 파일 요약
| 파일 | 변경 |
|---|---|
| `tools/deck/lib/ui-helpers.mjs`, `lib/data.mjs` | **신설** — 공유 헬퍼·상수·데이터 |
| `tools/deck/hud/*.mjs` (16) | **신설** — HUD별 build 함수 |
| `tools/deck/gen-slaydeck.mjs` | 오케스트레이터로 축소(데이터/UI emit 본문 → 모듈로 이동, import·호출만) |
| `RULES.md` | §1 보조 생성기/단일소스 표에 lib/·hud/ 반영 |
| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | **무변경**(바이트 동일이 합격 기준) |

14
docs/x-cost.md Normal file
View File

@@ -0,0 +1,14 @@
# X 코스트 카드
`useAllEnergy`는 카드가 사용될 때 남은 에너지를 전부 쓰는 공용 필드입니다.
연동 필드:
- `xDamagePerEnergy`: 에너지 1당 피해량
- `xWeakPerEnergy`: 에너지 1당 약화량
적용 예시:
- `Skewer`: 남은 에너지 전부를 써서 `8 * energy` 피해
- `Malaise`: 남은 에너지 전부를 써서 약화 부여

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,16 @@ export function shuffle(arr, rng) {
return a;
}
function prepareCombatDrawPile(deck, cards) {
const rest = [];
const innate = [];
for (const id of deck) {
if (cards[id]?.innate === true) innate.push(id);
else rest.push(id);
}
return rest.concat(innate);
}
// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화.
// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1))
// 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5
@@ -70,16 +80,43 @@ export function loadData() {
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
}
function canPlayCardNow(card, ctx = {}) {
if (!card) return false;
if (card.playableWhenDrawPileEmpty === true && (ctx.drawPileCount || 0) > 0) return false;
return true;
}
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
export function chooseAction(hand, cards, energy) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
export function chooseAction(hand, cards, energy, ctx = {}) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => {
const card = cards[x.id];
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
let effectiveCost = card.cost || 0;
if (ctx.handCostZeroThisTurn === true) effectiveCost = 0;
else if (card.useAllEnergy === true) effectiveCost = 1;
else if (card.kind === 'Skill') {
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
}
return card.useAllEnergy === true ? true : effectiveCost <= energy;
});
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(cards[x.id].cost, 1);
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(cards[x.id].cost, 1);
const effectiveCost = (card) => {
let cost = card.cost || 0;
if (ctx.handCostZeroThisTurn === true) cost = 0;
else if (card.useAllEnergy === true) cost = 1;
else if (card.kind === 'Skill') {
if (ctx.nextSkillCostZero === true) cost = 0;
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
}
return cost;
};
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1);
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 1);
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
if (powers.length) return powers[0].i;
if (attacks.length) return bestBy(attacks, dmgEff).i;
@@ -106,12 +143,23 @@ function bump(s, cost, dmg, blk) {
export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, monsters } = data;
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
let drawPile = shuffle(starterDeck, rng);
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
let discard = [];
const exhaust = [];
let hand = [];
let pHp = PLAYER_HP, pBlock = 0;
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
let blockGainMultiplier = 1;
let handCostZeroThisTurn = false;
let drawDisabledThisTurn = false;
let nextSkillCostZero = false;
let skillCostReductionThisTurn = 0;
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
let nextTurnAddCards = [];
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
let cardsDrawnThisCombat = 0;
let energy = 0;
const powers = [];
const mob = monsters.map((m) => ({
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
@@ -120,36 +168,141 @@ export function simulateCombat(data, rng, stats) {
let turns = 0;
function draw(n) {
const drawn = [];
if (drawDisabledThisTurn === true) return drawn;
for (let k = 0; k < n; k++) {
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
if (drawPile.length === 0) break;
const card = drawPile.pop();
drawn.push(card);
cardsDrawnThisCombat++;
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
if (hand.length >= 10) {
discard.push(card);
triggerSly(card);
} else hand.push(card);
}
return drawn;
}
function addCardsToHand(id, n) {
for (let k = 0; k < n; k++) {
if (hand.length >= 10) discard.push(id);
else hand.push(id);
}
}
function addBlock(base) {
let amount = base || 0;
if (amount > 0) amount += pDex;
if (blockGainMultiplier > 1) amount *= blockGainMultiplier;
if (amount < 0) amount = 0;
pBlock += amount;
return amount;
}
function discardForTurnStart(n) {
const cnt = Math.min(n, hand.length);
for (let i = 0; i < cnt; i++) {
const idx = hand
.map((id, k) => ({ id, k, card: cards[id] }))
.sort((a, b) => {
const ac = a.card?.cost || 0;
const bc = b.card?.cost || 0;
if (ac !== bc) return ac - bc;
const ad = a.card?.damage || 0;
const bd = b.card?.damage || 0;
if (ad !== bd) return ad - bd;
return a.k - b.k;
})[0]?.k;
if (idx == null) break;
discardHandCard(idx, true);
}
}
function countOtherHandSkills(currentId) {
let n = 0;
let skippedSelf = false;
for (const id of hand) {
if (!skippedSelf && id === currentId) { skippedSelf = true; continue; }
if (cards[id]?.kind === 'Skill') n++;
}
return n;
}
function attackBaseForCard(id, c) {
let base = c.damage || 0;
const otherHand = Math.max(0, hand.length - 1);
if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard;
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
if (base < 0) base = 0;
return base;
}
function queueNextTurnAddCard(id, n) {
if (!id || !n || n <= 0) return;
const entry = nextTurnAddCards.find((x) => x.cardId === id);
if (entry) entry.amount += n;
else nextTurnAddCards.push({ cardId: id, amount: n });
}
function queueNextTurnEffects(c) {
if (!c) return;
if (c.nextTurnBlock) nextTurnBlock += c.nextTurnBlock;
if (c.nextTurnDraw) nextTurnDraw += c.nextTurnDraw;
if (c.nextTurnKeepBlock === true) nextTurnKeepBlock = true;
if (c.nextTurnAttackMultiplier && c.nextTurnAttackMultiplier > 0) nextTurnAttackMultiplier *= c.nextTurnAttackMultiplier;
}
function queueSelectedReserve(c) {
if (!c?.nextTurnSelectHandCard || !c.nextTurnCopies || hand.length === 0) return;
const choice = hand
.map((id, i) => ({ id, i, card: cards[id] }))
.sort((a, b) => {
const ak = a.card?.kind === 'Attack' ? 3 : a.card?.kind === 'Skill' ? 2 : 1;
const bk = b.card?.kind === 'Attack' ? 3 : b.card?.kind === 'Skill' ? 2 : 1;
if (bk !== ak) return bk - ak;
const ad = a.card?.damage || 0;
const bd = b.card?.damage || 0;
if (bd !== ad) return bd - ad;
return a.i - b.i;
})[0];
if (choice?.id) queueNextTurnAddCard(choice.id, c.nextTurnCopies);
}
const aliveList = () => mob.filter((m) => m.alive);
function powerFieldTotal(field) {
let total = 0;
for (const pid of powers) {
const pc = cards[pid];
if (pc?.[field] != null) total += pc[field];
}
return total;
}
function resolveCardEffects(id, c, costSpent, recordStats = true) {
const alive = aliveList();
let dmg = 0;
let blockGained = 0;
if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier;
if (c.nextSkillCostZero === true) nextSkillCostZero = true;
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
const xEnergy = costSpent || 0;
if (c.kind === 'Attack') {
if (alive.length && c.damage) {
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
const baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c);
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
? c.bonusHitsWhenOtherHandAtLeast : 0;
const hitN = (c.hits || 1) + bonusHits;
const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
const target = chooseTarget(alive, preview);
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
const hitN = c.hits || 1;
let totalNv = 0;
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
dmg = totalNv;
if (c.aoe === true) {
for (const m2 of aliveList()) {
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
const r2 = applyDamage(m2.hp, m2.block, d2);
m2.hp = r2.hp; m2.block = r2.block;
const attackPoison = powerFieldTotal('attackPoison');
if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison;
if (m2.hp <= 0) m2.alive = false;
}
} else {
@@ -161,17 +314,20 @@ export function simulateCombat(data, rng, stats) {
const r = applyDamage(target.hp, target.block, dmg);
target.hp = r.hp; target.block = r.block;
}
const attackPoison = powerFieldTotal('attackPoison');
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison;
if (target.hp <= 0) target.alive = false;
}
}
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
if (c.block) blockGained = addBlock(c.block);
} else if (c.kind === 'Power') {
if (c.powerEffect && recordStats) powers.push(id);
if (recordStats) powers.push(id);
} else {
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
if ((c.weak || c.vuln || c.poison) && alive.length) {
if (c.block) blockGained = addBlock(c.block);
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
if ((weakAmount || c.vuln || c.poison) && alive.length) {
const target = chooseTarget(alive, 0);
if (c.weak) target.weak += c.weak;
if (weakAmount) target.weak += weakAmount;
if (c.vuln) target.vuln += c.vuln;
if (c.poison) target.poison += c.poison;
}
@@ -181,7 +337,39 @@ export function simulateCombat(data, rng, stats) {
if (c.thorns) pThorns += c.thorns;
if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (c.draw) draw(c.draw);
if (c.gainEnergy) energy += c.gainEnergy;
queueNextTurnEffects(c);
let drawnCards = [];
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
if (c.drawUntilHandSize) {
const need = c.drawUntilHandSize - Math.max(0, hand.length - 1);
if (need > 0) drawnCards = drawnCards.concat(draw(need));
}
if (c.drawSkillBlock && c.drawSkillBlock > 0) {
for (const drawnId of drawnCards) {
if (cards[drawnId]?.kind === 'Skill') blockGained += addBlock(c.drawSkillBlock);
}
}
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
if (c.cardPlayedDamage && alive.length) {
const target = chooseTarget(aliveList(), 0);
if (target && target.alive) {
target.hp -= c.cardPlayedDamage;
dmg += c.cardPlayedDamage;
if (target.hp <= 0) target.alive = false;
}
}
if (c.cardPlayedRandomDamage && alive.length) {
const pool = aliveList();
if (pool.length) {
const target = pool[Math.floor(rng() * pool.length)];
if (target) {
target.hp -= c.cardPlayedRandomDamage;
dmg += c.cardPlayedRandomDamage;
if (target.hp <= 0) target.alive = false;
}
}
}
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
}
function triggerSly(id) {
@@ -193,39 +381,85 @@ export function simulateCombat(data, rng, stats) {
const [id] = hand.splice(idx, 1);
if (!id) return;
discard.push(id);
turnDiscardedCards++;
if (trigger) triggerSly(id);
}
function applyDiscardEffects(c) {
let discarded = 0;
if (c.discardAll) {
while (hand.length) discardHandCard(hand.length - 1, true);
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
} else if (c.discard) {
const n = Math.min(c.discard, hand.length);
for (let i = 0; i < n; i++) discardHandCard(hand.length - 1, true);
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
}
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded);
}
while (turns < MAX_TURNS) {
turns++;
turnAttackCardsPlayed = 0;
turnDiscardedCards = 0;
blockGainMultiplier = 1;
handCostZeroThisTurn = false;
drawDisabledThisTurn = false;
skillCostReductionThisTurn = 0;
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
pBlock = 0;
if (nextTurnKeepBlock === true) nextTurnKeepBlock = false;
else pBlock = 0;
turnAttackMultiplier = nextTurnAttackMultiplier;
nextTurnAttackMultiplier = 1;
let energyBonus = 0;
let powerTurnDraw = 0;
let powerTurnDiscard = 0;
for (const pid of powers) {
const pc = cards[pid];
if (!pc) continue;
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
else if (pc.powerEffect === 'poisonPerTurn') {
for (const m of mob) if (m.alive) m.poison += pc.value;
} else if (pc.powerEffect === 'damagePerTurn') {
for (const m of mob) {
if (!m.alive) continue;
const r = applyDamage(m.hp, m.block, pc.value || 0);
m.hp = r.hp; m.block = r.block;
if (m.hp <= 0) m.alive = false;
}
}
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
if (pc.turnStartDraw) powerTurnDraw += pc.turnStartDraw;
if (pc.turnStartDiscard) powerTurnDiscard += pc.turnStartDiscard;
}
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
if (nextTurnBlock > 0) { addBlock(nextTurnBlock); nextTurnBlock = 0; }
if (nextTurnAddCards.length) {
for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount);
nextTurnAddCards = [];
}
energy = ENERGY + energyBonus;
const drawBonus = nextTurnDraw + powerTurnDraw;
nextTurnDraw = 0;
draw(HAND_SIZE + drawBonus);
if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard);
while (true) {
const alive = aliveList();
if (alive.length === 0) break;
const idx = chooseAction(hand, cards, energy);
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn });
if (idx < 0) break;
const id = hand[idx], c = cards[id];
energy -= c.cost;
resolveCardEffects(id, c, c.cost);
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
const baseCost = c.cost || 0;
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
energy -= cost;
resolveCardEffects(id, c, cost);
if (c.kind === 'Attack') turnAttackCardsPlayed++;
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
const playedBlock = powerFieldTotal('cardPlayedBlock');
if (playedBlock > 0) addBlock(playedBlock);
hand.splice(idx, 1);
queueSelectedReserve(c);
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
else if (c.kind !== 'Power') discard.push(id);
applyDiscardEffects(c);

View File

@@ -13,6 +13,85 @@ test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
assert.equal(rarityForRoll(100), 'legend');
});
test("simulateCombat: nextTurnBlock grants block on the following turn", () => {
const data = {
cards: {
GuardLater: { name: "예약 방어", cost: 0, kind: "Skill", nextTurnBlock: 4 },
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["GuardLater", "Pass"],
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, false);
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 77);
});
test("simulateCombat: nextTurnDraw draws extra cards next turn", () => {
const data = {
cards: {
Setup: { name: "설치", cost: 0, kind: "Skill", nextTurnDraw: 2 },
Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 },
Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 },
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Hit1", "Hit2", "Pass1", "Pass2", "Pass3", "Pass4", "Setup"],
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test("simulateCombat: nextTurnKeepBlock preserves current block", () => {
const data = {
cards: {
BlurLater: { name: "흐릿함", cost: 0, kind: "Skill", block: 5, nextTurnKeepBlock: true },
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["BlurLater", "Pass"],
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, false);
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 80);
});
test("simulateCombat: nextTurnAttackMultiplier boosts attacks next turn", () => {
const data = {
cards: {
Prep: { name: "그림자 걸음", cost: 0, kind: "Skill", nextTurnAttackMultiplier: 2 },
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 3 },
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Prep", "Pass", "Hit"],
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test("simulateCombat: nextTurnSelectHandCard queues selected copies for next turn", () => {
const data = {
cards: {
Nightmare: { name: "악몽", cost: 0, kind: "Skill", nextTurnCopies: 3, nextTurnSelectHandCard: true },
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 2 },
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Pass", "Nightmare", "Hit"],
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 4);
});
test('applyDamage: 방어 우선 차감 후 hp', () => {
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
@@ -447,3 +526,343 @@ test("simulateCombat: thorns reflects unblocked attack damage", () => {
assert.equal(r.turns, 1);
assert.equal(r.playerHpRemaining, 79);
});
test("simulateCombat: addShiv creates shuriken cards in hand", () => {
const data = {
cards: {
MakeShiv: { name: "MakeShiv", cost: 0, kind: "Skill", addShiv: 2 },
Shiv: { name: "표창", cost: 0, kind: "Attack", damage: 4, exhaust: true },
},
starterDeck: ["MakeShiv"],
monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("simulateCombat: innate cards are drawn into the opening hand first", () => {
const data = {
cards: {
Backstab: { name: "배신", cost: 0, kind: "Attack", damage: 11, innate: true, exhaust: true },
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Backstab"],
monsters: [{ name: "Dummy", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("simulateCombat: GrandFinale waits until draw pile is empty", () => {
const data = {
cards: {
Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, aoe: true, playableWhenDrawPileEmpty: true },
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Finale"],
monsters: [{ name: "Dummy", maxHp: 60, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0);
assert.equal(r.win, false);
assert.equal(r.draw, true);
});
test("simulateCombat: turnStartDraw and turnStartDiscard powers resolve at turn start", () => {
const data = {
cards: {
Tool: { name: "작업 도구", cost: 0, kind: "Power", turnStartDraw: 1, turnStartDiscard: 1 },
Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 },
Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 },
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Tool", "Pass1", "Pass2", "Pass3", "Hit1", "Hit2"],
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("chooseAction: GrandFinale is blocked until draw pile is empty", () => {
const cards = {
Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, playableWhenDrawPileEmpty: true },
Defend: { name: "방어", cost: 1, kind: "Skill", block: 5 },
};
assert.equal(chooseAction(["Finale", "Defend"], cards, 3, { drawPileCount: 1 }), 1);
assert.equal(chooseAction(["Finale"], cards, 3, { drawPileCount: 0 }), 0);
});
test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => {
const data = {
cards: {
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 6 },
Finisher: { name: "마무리", cost: 0, kind: "Attack", damage: 0, damagePerAttackPlayedThisTurn: 6 },
},
starterDeck: ["Hit", "Finisher"],
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0);
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => {
const data = {
cards: {
Precise: { name: "정밀", cost: 0, kind: "Attack", damage: 13, damagePerOtherHandCard: -2 },
Flechettes: { name: "프레췌", cost: 0, kind: "Attack", damage: 0, damagePerSkillInHand: 5 },
Skill1: { name: "스킬1", cost: 99, kind: "Skill", block: 0 },
Skill2: { name: "스킬2", cost: 99, kind: "Skill", block: 0 },
Blank: { name: "공백", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"],
monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0);
assert.equal(r.win, true);
assert.equal(r.turns, 5);
});
test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => {
const data = {
cards: {
Toss: { name: "버리기", cost: 0, kind: "Skill", discard: 1 },
Memento: { name: "메멘토", cost: 0, kind: "Attack", damage: 9, damagePerDiscardedThisTurn: 4 },
Follow: { name: "완수", cost: 0, kind: "Attack", damage: 7, otherHandAtLeast: 2, bonusHitsWhenOtherHandAtLeast: 1 },
Blank1: { name: "공백1", cost: 99, kind: "Skill", block: 0 },
Blank2: { name: "공백2", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Toss", "Memento", "Follow", "Blank1", "Blank2"],
monsters: [{ name: "Dummy", maxHp: 27, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test("simulateCombat: gainEnergy, drawUntilHandSize, and drawPerDiscarded are applied", () => {
const data = {
cards: {
Adrenaline: { name: "Adrenaline", cost: 0, kind: "Skill", gainEnergy: 1, draw: 2, exhaust: true },
Expertise: { name: "Expertise", cost: 1, kind: "Skill", drawUntilHandSize: 6 },
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true, drawPerDiscarded: 1, exhaust: true },
Tactician: { name: "Tactician", cost: 99, kind: "Skill", gainEnergy: 1, sly: true },
Hit1: { name: "Hit1", cost: 1, kind: "Attack", damage: 6 },
Hit2: { name: "Hit2", cost: 1, kind: "Attack", damage: 6 },
Hit3: { name: "Hit3", cost: 1, kind: "Attack", damage: 6 },
},
starterDeck: ["Adrenaline", "Expertise", "Gamble", "Tactician", "Hit1", "Hit2", "Hit3"],
monsters: [{ name: "Dummy", maxHp: 18, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("simulateCombat: cardPlayedBlock grants block whenever a card is played", () => {
const data = {
cards: {
After: { name: "Afterimage", cost: 1, kind: "Power", cardPlayedBlock: 1 },
Hit: { name: "Hit", cost: 1, kind: "Attack", damage: 1 },
},
starterDeck: ["After", "Hit", "Hit", "Hit", "Hit"],
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 1 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 80);
});
test("simulateCombat: blockGainMultiplier doubles block gain for the turn", () => {
const data = {
cards: {
Shadow: { name: "Shadowmeld", cost: 1, kind: "Skill", block: 5, blockGainMultiplier: 2 },
Shield: { name: "Shield", cost: 1, kind: "Skill", block: 2 },
},
starterDeck: ["Shadow", "Shield"],
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 80);
});
test("simulateCombat: nextSkillCostZero makes the next skill free", () => {
const data = {
cards: {
Pounce: { name: "Pounce", cost: 2, kind: "Attack", damage: 12, nextSkillCostZero: true },
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
},
starterDeck: ["Pounce", "Guard"],
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 80);
});
test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => {
const cards = {
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
};
assert.equal(chooseAction(["Guard"], cards, 1, { skillCostReductionThisTurn: 1 }), 0);
assert.equal(chooseAction(["Guard"], cards, 1, {}), -1);
});
test("chooseAction: handCostZeroThisTurn lets expensive cards be played", () => {
const cards = {
Burst: { name: "Burst", cost: 3, kind: "Skill", block: 8 },
};
assert.equal(chooseAction(["Burst"], cards, 0, { handCostZeroThisTurn: true }), 0);
assert.equal(chooseAction(["Burst"], cards, 0, {}), -1);
});
test("chooseAction: useAllEnergy cards remain playable at zero energy", () => {
const cards = {
Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 },
};
assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0);
});
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
const data = {
cards: {
Escape: { name: "EscapePlan", cost: 0, kind: "Skill", draw: 1, drawSkillBlock: 3, innate: true, exhaust: true },
Filler1: { name: "Filler1", cost: 99, kind: "Skill", block: 0 },
Filler2: { name: "Filler2", cost: 99, kind: "Skill", block: 0 },
Filler3: { name: "Filler3", cost: 99, kind: "Skill", block: 0 },
Filler4: { name: "Filler4", cost: 99, kind: "Skill", block: 0 },
Filler5: { name: "Filler5", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Escape", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"],
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 0 }] }],
};
const stats = {};
const r = simulateCombat(data, () => 0.999999, stats);
assert.equal(r.draw, true);
assert.equal(stats.Escape.block, 3);
});
test("simulateCombat: poisonPerTurn powers poison all enemies at turn start", () => {
const data = {
cards: {
Fumes: { name: "NoxiousFumes", cost: 1, kind: "Power", powerEffect: "poisonPerTurn", value: 2 },
},
starterDeck: ["Fumes"],
monsters: [
{ name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
{ name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: damagePerTurn powers damage all enemies at turn start", () => {
const data = {
cards: {
Speed: { name: "Speedster", cost: 2, kind: "Power", powerEffect: "damagePerTurn", value: 2 },
},
starterDeck: ["Speed"],
monsters: [
{ name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
{ name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: attackPoison power applies poison on attack damage", () => {
const data = {
cards: {
Venom: { name: "Envenom", cost: 2, kind: "Power", attackPoison: 2 },
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
},
starterDeck: ["Venom", "Strike"],
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("simulateCombat: cardPlayedDamage hits the target whenever a card is played", () => {
const data = {
cards: {
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, cardPlayedDamage: 2 },
},
starterDeck: ["Strangle"],
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
const data = {
cards: {
SerpentForm: { name: "SerpentForm", cost: 3, kind: "Power", cardPlayedRandomDamage: 4 },
},
starterDeck: ["SerpentForm"],
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: useAllEnergy skewer consumes all energy for damage", () => {
const data = {
cards: {
Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 },
},
starterDeck: ["Skewer"],
monsters: [{ name: "Dummy", maxHp: 24, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: useAllEnergy malaise scales weak with energy spent", () => {
const data = {
cards: {
Malaise: { name: "Malaise", cost: 2, kind: "Skill", useAllEnergy: true, xWeakPerEnergy: 1 },
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
},
starterDeck: ["Malaise", "Strike"],
monsters: [{ name: "Dummy", maxHp: 1, intents: [{ kind: "Attack", value: 10 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
});
test("simulateCombat: damagePerCardDrawnThisCombat scales murder", () => {
const data = {
cards: {
Murder: { name: "Murder", cost: 3, kind: "Attack", damage: 1, damagePerCardDrawnThisCombat: 1 },
Filler1: { name: "Filler1", cost: 99, kind: "Skill" },
Filler2: { name: "Filler2", cost: 99, kind: "Skill" },
Filler3: { name: "Filler3", cost: 99, kind: "Skill" },
Filler4: { name: "Filler4", cost: 99, kind: "Skill" },
Filler5: { name: "Filler5", cost: 99, kind: "Skill" },
},
starterDeck: ["Murder", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"],
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
};
const stats = {};
const r = simulateCombat(data, () => 0.999999, stats);
assert.equal(r.win, true);
assert.ok(stats.Murder.damage > 1);
});

113
tools/deck/cb/boot.mjs Normal file
View File

@@ -0,0 +1,113 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const bootMethods = [
method('OnBeginPlay', `${luaCardsTable(CARDS.cards)}
${luaFramesTable()}
${luaNodeIconsTable()}
${luaCharsTable()}
${luaSoulShopTable(SOUL_UNLOCKS)}
self.SoulUnlocks = {}
self.SoulPoints = self.SoulPoints or 0
local uiTries = 0
local uiInit = 0
uiInit = _TimerService:SetTimerRepeat(function()
uiTries = uiTries + 1
if _EntityService:GetEntityByPath("/ui/DeckUIGroup") ~= nil then
self:ActivateUIGroups()
-- MainMenu는 한동안 비활성화: 시작 시 바로 로비로 진입.
-- 추후 싱글/멀티/종료 선택 메뉴가 필요하면 self:ShowMainMenu()로 되돌린다(메서드·UI 유지됨).
self:ShowLobby()
_TimerService:ClearTimer(uiInit)
elseif uiTries > 80 then
_TimerService:ClearTimer(uiInit)
end
end, 0.1)
local lp = _UserService.LocalPlayer
if lp ~= nil then
self:ReqLoadAscension(lp.PlayerComponent.UserId)
self:ReqLoadSouls(lp.PlayerComponent.UserId)
end
_InputService:ConnectEvent(KeyDownEvent, function(e)
if e.key == KeyboardKey.LeftControl then
self.DebugCtrlDown = true
local lp2 = _UserService.LocalPlayer
if lp2 ~= nil and lp2.CurrentMapName == "${LOBBY_MAP}" and self.RunActive ~= true then
self:PlayerAttackMotion()
end
elseif e.key == KeyboardKey.LeftShift or e.key == KeyboardKey.RightShift then
self.DebugShiftDown = true
elseif e.key == KeyboardKey.C then
if self.DebugCtrlDown == true and self.DebugShiftDown == true then
self:OpenDebugCardPicker()
end
elseif e.key == KeyboardKey.E then
if self.DebugCtrlDown == true and self.DebugShiftDown == true then
self:CheatFillEnergy()
end
end
end)
_InputService:ConnectEvent(KeyUpEvent, function(e)
if e.key == KeyboardKey.LeftControl then
self.DebugCtrlDown = false
elseif e.key == KeyboardKey.LeftShift or e.key == KeyboardKey.RightShift then
self.DebugShiftDown = false
end
end)`),
method('CheatFillEnergy', `if self.RunActive ~= true or self.CombatOver == true then
return
end
self.PlayerHp = self.PlayerMaxHp
self.Energy = self.MaxEnergy
self:RenderCombat()
self:RenderPiles()
self:Toast("치트: 체력·에너지 회복")`),
method('ReqLoadAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
local errCode, value = ds:GetAndWait("ascensionUnlocked")
local n = 0
if errCode == 0 and value ~= nil and value ~= "" then
n = tonumber(value) or 0
end
self:RecvAscension(n, userId)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' }], 5),
method('RecvAscension', `self.AscensionUnlocked = n
if self.AscensionLevel > self.AscensionUnlocked then
self.AscensionLevel = self.AscensionUnlocked
end
self:RenderAscension()`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'n' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' },
], 6),
method('SaveAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
ds:SetAndWait("ascensionUnlocked", tostring(n))`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'n' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' },
], 5),
method('AdjustAscension', `local v = self.AscensionLevel + delta
if v < 0 then v = 0 end
if v > self.AscensionUnlocked then v = self.AscensionUnlocked end
self.AscensionLevel = v
self:RenderAscension()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'delta' }]),
method('RenderAscension', `self:SetText("/ui/DefaultGroup/MainMenu/AscLabel", "승천 " .. string.format("%d", self.AscensionLevel) .. " / 해금 " .. string.format("%d", self.AscensionUnlocked))
self:SetText("/ui/LobbyUIGroup/LobbyHud/AscLabel", "승천 " .. string.format("%d", self.AscensionLevel) .. " / 해금 " .. string.format("%d", self.AscensionUnlocked))`),
method('AscHpMult', `local m = 1
if self.AscensionLevel >= 1 then m = m + 0.1 end
if self.AscensionLevel >= 6 then m = m + 0.1 end
return m`, [], 0, 'number'),
method('AscAtkMult', `local m = 1
if self.AscensionLevel >= 2 then m = m + 0.1 end
if self.AscensionLevel >= 7 then m = m + 0.1 end
return m`, [], 0, 'number'),
method('AscEliteBonus', `local b = 0
if self.AscensionLevel >= 4 then b = b + 0.2 end
if self.AscensionLevel >= 9 then b = b + 0.2 end
return b`, [], 0, 'number'),
method('AscGoldMult', `local m = 1
if self.AscensionLevel >= 5 then m = m - 0.25 end
if self.AscensionLevel >= 10 then m = m - 0.25 end
return m`, [], 0, 'number'),
method('AscStartHpPenalty', `local p = 0
if self.AscensionLevel >= 3 then p = p + 10 end
if self.AscensionLevel >= 8 then p = p + 10 end
return p`, [], 0, 'number'),
];

View File

@@ -0,0 +1,80 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const charSelectMethods = [
method('ShowCharacterSelect', `self.SelectedClass = ""
self:ShowState("charselect")
self:RenderCharacterSelect()`),
method('SelectClass', `self.SelectedClass = className
self:RenderCharacterSelect()`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' },
]),
method('RenderCharacterSelect', `local base = "/ui/SelectUIGroup/CharacterSelectHud"
local arts = { { p = "/WarriorButton/Art", c = "warrior" }, { p = "/MageButton/Art", c = "magician" }, { p = "/BanditButton/Art", c = "bandit" } }
for i = 1, #arts do
local e = _EntityService:GetEntityByPath(base .. arts[i].p)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits[arts[i].c] ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits[arts[i].c]
end
end
local btns = { { p = "/WarriorButton", c = "warrior" }, { p = "/MageButton", c = "magician" }, { p = "/BanditButton", c = "bandit" } }
for i = 1, #btns do
local e = _EntityService:GetEntityByPath(base .. btns[i].p)
if e ~= nil then
if e.MaskComponent == nil then
e:AddComponent("MaskComponent")
end
if e.SpriteGUIRendererComponent ~= nil then
if self.SelectedClass == btns[i].c then
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.45, 0.5, 0.58, 1)
end
end
end
end
local nl = string.char(10)
local name = ""
local eng = ""
local desc = "직업을 선택하고 시작하세요"
local btnName = ""
if self.SelectedClass == "warrior" then
name = "전사"
eng = "Warrior"
btnName = "/WarriorButton"
desc = "직업군 · 모험가" .. nl .. "방어를 쌓고 버티다 강하게 역공하는 단단한 탱커."
elseif self.SelectedClass == "bandit" then
name = "도적"
eng = "Thief"
btnName = "/BanditButton"
desc = "직업군 · 모험가" .. nl .. "표창 난사와 독으로 빠르게 몰아치는 민첩한 직업."
elseif self.SelectedClass == "magician" then
name = "법사"
eng = "Magician"
btnName = "/MageButton"
desc = "직업군 · 모험가" .. nl .. "약하지만 게이지 운용으로 화력을 집중하는 원소 마법사."
end
if btnName ~= "" then
local art = _EntityService:GetEntityByPath(base .. btnName .. "/Art")
local target = _EntityService:GetEntityByPath(base .. "/SelectedCharacterArt")
if art ~= nil and art.SpriteGUIRendererComponent ~= nil and target ~= nil and target.SpriteGUIRendererComponent ~= nil then
target.SpriteGUIRendererComponent.ImageRUID = art.SpriteGUIRendererComponent.ImageRUID
end
end
self:SetText(base .. "/SelectedClass", name)
self:SetText(base .. "/SelectedClass/SelectedClassEng", eng)
self:SetText(base .. "/SelectedClassStatus", desc)`),
method('StartNewGame', `if self.SelectedClass ~= "warrior" and self.SelectedClass ~= "bandit" and self.SelectedClass ~= "magician" then
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/SelectedClassStatus", "직업을 먼저 선택하세요")
return
end
self:StartRun()`),
method('SetEntityEnabled', `local e = _EntityService:GetEntityByPath(path)
if e ~= nil then
e.Enable = enabled
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enabled' },
], 2),
];

610
tools/deck/cb/combat.mjs Normal file
View File

@@ -0,0 +1,610 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const combatMethods = [
method('CanPlayCardNow', `if c == nil then
return false
end
if c.playableWhenDrawPileEmpty == true and self.DrawPile ~= nil and #self.DrawPile > 0 then
self:Toast("뽑을 카드 더미가 비어 있을 때만 사용할 수 있습니다.")
return false
end
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
method('PlayCard', `if self:IsDiscardSelecting() == true then
self:SelectDiscardSlot(slot)
return
end
if self:IsRetainSelecting() == true then
self:SelectRetainSlot(slot)
return
end
if self:IsReserveSelecting() == true then
self:SelectReserveSlot(slot)
return
end
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if c.unplayable == true then
self:Toast("사용할 수 없는 카드입니다")
return
end
if self:CanPlayCardNow(c) ~= true then
return
end
local cost = c.cost or 0
local skillFree = false
if self.HandCostZeroThisTurn == true then
cost = 0
elseif c.useAllEnergy == true then
cost = self.Energy
end
if c.kind == "Skill" and self.NextSkillCostZero == true then
cost = 0
skillFree = true
end
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
end
if self.Energy < cost then
self:Toast("에너지가 부족합니다")
return
end
self.Energy = self.Energy - cost
self:ResolveCardEffects(cardId, slot, c, false, cost)
if c.kind == "Attack" then
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
end
if skillFree == true then
if c.nextSkillCostZero ~= true then
self.NextSkillCostZero = false
end
end
if self:HasPowerField("cardPlayedBlock") == true then
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
end
if c.cardPlayedDamage ~= nil and c.cardPlayedDamage > 0 then
self:DealDirectDamageToTarget(c.cardPlayedDamage)
end
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
end
table.remove(self.Hand, slot)
if c.exhaust == true then
if self.ExhaustPile == nil then self.ExhaustPile = {} end
table.insert(self.ExhaustPile, cardId)
elseif c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId)
end
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
if self:BeginDiscardSelection(c) == true then
return
end
if self:BeginReserveSelection(c) == true then
return
end
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('OnCardButton', `if self:IsDiscardSelecting() == true then
self:SelectDiscardSlot(slot)
elseif self:IsRetainSelecting() == true then
self:SelectRetainSlot(slot)
elseif self:IsReserveSelecting() == true then
self:SelectReserveSlot(slot)
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('FindMonsterAtTouch', `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
return best`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }], 0, 'number'),
method('RenderTargetFrames', `local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
local shownTarget = self.TargetIndex
if dragActive == true then shownTarget = self.DragTargetIndex end
for i = 1, #self.Monsters do
local m = self.Monsters[i]
local active = false
if m ~= nil and m.alive == true and i == shownTarget then active = true end
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i) .. "/TargetMarker", active and dragActive)
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i) .. "/TargetMarker/Label", active and dragActive)
end`),
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
if self.CardHoverTweenId ~= nil and self.CardHoverTweenId ~= 0 then
_TimerService:ClearTimer(self.CardHoverTweenId)
self.CardHoverTweenId = 0
end
for i = 1, 10 do
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
e.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(i), 0)
end
end
self.DragSlot = slot
self.DragTargetIndex = 0
self:RenderTargetFrames()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('OnCardDrag', `if self.DragSlot ~= slot then
return
end
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/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
local cardId = self.Hand[slot]
local c = nil
if cardId ~= nil then c = self.Cards[cardId] end
if c ~= nil and c.kind == "Attack" then
local best = self:FindMonsterAtTouch(touchPoint)
if best ~= self.DragTargetIndex then
self.DragTargetIndex = best
self:RenderTargetFrames()
end
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 e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(slot), 0)
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
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:IsDiscardSelecting() == true then
self:SelectDiscardSlot(slot)
return
end
if self:IsRetainSelecting() == true then
self:SelectRetainSlot(slot)
return
end
if self:IsReserveSelecting() == true then
self:SelectReserveSlot(slot)
return
end
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 = self.DragTargetIndex or 0
if best <= 0 then best = self:FindMonsterAtTouch(touchPoint) end
self.DragTargetIndex = 0
if best > 0 then
self.TargetIndex = best
self:PlayCard(slot)
self:RenderTargetFrames()
else
self:RenderTargetFrames()
end
else
self.DragTargetIndex = 0
self:RenderTargetFrames()
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' },
]),
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex]
if m == nil or m.alive ~= true then
m = nil
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end
end
end
if m == nil then
return
end
local dmg = amount
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.block > 0 and pierce ~= true then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
dmg = dmg - absorbed
end
m.hp = m.hp - dmg
if dmg > 0 then
local poison = self:AddPowerFieldTotal("attackPoison")
if poison ~= nil and poison > 0 then
m.poison = (m.poison or 0) + poison
end
end
self:MonsterHitMotion(m.slot)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
]),
method('DealDirectDamageToTarget', `local m = self.Monsters[self.TargetIndex]
if m == nil or m.alive ~= true then
m = nil
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end
end
end
if m == nil then
return
end
m.hp = m.hp - amount
self:ShowDmgPop(m.slot, amount)
self:MonsterHitMotion(m.slot)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('DealDirectDamageToRandomMonster', `local alive = {}
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
table.insert(alive, m)
end
end
if #alive <= 0 then
return
end
local m = alive[math.random(1, #alive)]
if m == nil then
return
end
m.hp = m.hp - amount
self:ShowDmgPop(m.slot, amount)
self:MonsterHitMotion(m.slot)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
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, pierce)
self:RenderCombat()
self:CheckCombatEnd()
return
end
self.FxBusy = true
local fx = _EntityService:GetEntityByPath("/ui/RunUIGroup/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
local shown = damage
local mt = self.Monsters[targetIndex]
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
shown = math.floor(damage * 1.5)
end
self:DealDamageToTarget(damage, pierce)
self:ShowDmgPop(targetIndex, shown)
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' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
]),
method('PlayAoeFx', `self.FxBusy = true
local fx = _EntityService:GetEntityByPath("/ui/RunUIGroup/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 then
fx.UITransformComponent.anchoredPosition = Vector2(300, 60)
end
fx.Enable = true
end
_TimerService:SetTimerOnce(function()
if fx ~= nil then fx.Enable = false end
self.FxBusy = false
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
local dmg = damage
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.block > 0 then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
dmg = dmg - absorbed
end
m.hp = m.hp - dmg
if dmg > 0 then
local poison = self:AddPowerFieldTotal("attackPoison")
if poison ~= nil and poison > 0 then
m.poison = (m.poison or 0) + poison
end
end
self:ShowDmgPop(i, dmg)
self:MonsterHitMotion(i)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end
end
end
self:RenderCombat()
self:CheckCombatEnd()
end, 0.35)`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
]),
method('KillMonster', `local m = self.Monsters[slot]
if m == nil then
return
end
m.alive = false
if m.entity ~= nil and isvalid(m.entity) then
local ent = m.entity
_TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.4)
end
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot), false)
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then self.TargetIndex = i; break end
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('DealDamageToPlayer', `local dmg = amount
if self.PlayerBlock > 0 then
local absorbed = math.min(self.PlayerBlock, dmg)
self.PlayerBlock = self.PlayerBlock - absorbed
dmg = dmg - absorbed
end
if dmg > 0 then
self.PlayerHp = self.PlayerHp - dmg
local reflect = self.PlayerThorns or 0
if self:HasRelic("bronzeScales") then
reflect = reflect + 3
end
if reflect > 0 and attackerSlot ~= nil and attackerSlot > 0 then
local am = self.Monsters[attackerSlot]
if am ~= nil and am.alive == true then
am.hp = am.hp - reflect
self:ShowDmgPop(am.slot, reflect)
self:MonsterHitMotion(am.slot)
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`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'attackerSlot' },
]),
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/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx)
self:SetEntityEnabled(base .. "/ActFrame", true)
_TimerService:SetTimerOnce(function()
if m.poison ~= nil and m.poison > 0 then
m.hp = m.hp - m.poison
self:ShowDmgPop(idx, m.poison)
self:MonsterHitMotion(idx)
m.poison = m.poison - 1
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
self:RenderCombat()
self:SetEntityEnabled(base .. "/ActFrame", false)
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
return
end
end
m.block = 0
local intent = m.intents[m.intentIdx]
if intent ~= nil then
if intent.kind == "Attack" then
self:MonsterLunge(idx)
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, idx)
self:ShowPlayerDmgPop(before - self.PlayerHp)
self:PlayerHitMotion()
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
elseif intent.kind == "AddCard" then
local cnt = intent.count or 1
for ci = 1, cnt do
table.insert(self.DiscardPile, intent.card)
end
self:RenderPiles()
local cn = intent.card
local cc = self.Cards[intent.card]
if cc ~= nil then cn = cc.name end
self:Toast(m.name .. ": " .. cn .. " 추가!")
end
end
if #m.intents > 0 then
m.intentIdx = math.random(1, #m.intents)
end
if m.weak > 0 then m.weak = m.weak - 1 end
if m.vuln > 0 then m.vuln = m.vuln - 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)`),
method('ClearCombatCards', `self.DrawPile = {}
self.DiscardPile = {}
self.ExhaustPile = {}
self.Hand = {}
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self.RetainSelectActive = false
self.TurnAttackCardsPlayed = 0
self.TurnDiscardedCards = 0
self.ReserveSelectActive = false
self.NextTurnBlock = 0
self.NextTurnDraw = 0
self.NextTurnKeepBlock = false
self.NextTurnAttackMultiplier = 1
self.TurnAttackMultiplier = 1
self.NextTurnSelectPrompt = ""
self.NextTurnSelectCopies = 0
self.NextTurnAddCards = {}
self:UpdateDiscardPrompt()
self:RenderHand(false)
self:RenderPiles()`),
method('CheckCombatEnd', `local anyAlive = false
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then anyAlive = true; break end
end
if anyAlive == false then
self.CombatOver = true
self:ClearCombatCards()
self.Gold = self.Gold + math.floor(${GOLD_PER_WIN} * self:AscGoldMult())
self:ApplyRelics("combatEnd")
self:ApplyRelics("combatReward")
self:MaybeDropPotion()
self:RenderRun()
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil and node.type == "elite" then
self.Gold = self.Gold + 15
local nid = self:PickNewRelic()
if nid ~= "" then
self:AddRelic(nid)
local nr = self.Relics[nid]
if nr ~= nil then
self:Toast("유물 획득: " .. nr.name)
end
end
end
if node ~= nil and node.type == "boss" then
if self.PlayerJob == "" and self.Floor < self.RunLength then
self:ShowJobChoice()
else
if self.PlayerJob ~= "" then self:AwardSouls(1) end
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
self:ContinueAfterBoss()
end
else
self:OfferReward()
end
elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:EndRun("패배...")
end`),
method('ContinueAfterBoss', `if self.Floor < self.RunLength then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:GenerateMap()
self:RenderRun()
self:TeleportToActMap()
self:ShowMap()
else
self:EndRun("런 클리어!")
end`),
];

509
tools/deck/cb/deckturn.mjs Normal file
View File

@@ -0,0 +1,509 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const deckTurnMethods = [
method('Shuffle', `if list == nil then
\treturn
end
for i = #list, 2, -1 do
\tlocal j = math.random(1, i)
\tlist[i], list[j] = list[j], list[i]
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]),
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and (endTurn.ButtonComponent ~= nil or endTurn:AddComponent("ButtonComponent") ~= nil) then
if self.EndTurnHandler ~= nil then
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
self.EndTurnHandler = nil
end
self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
end
local drawPile = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/DrawPile")
if drawPile ~= nil and (drawPile.ButtonComponent ~= nil or drawPile:AddComponent("ButtonComponent") ~= nil) then
if self.DrawPileHandler ~= nil then
drawPile:DisconnectEvent(ButtonClickEvent, self.DrawPileHandler)
self.DrawPileHandler = nil
end
self.DrawPileHandler = drawPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("draw") end)
end
local discardPile = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/DiscardPile")
if discardPile ~= nil and (discardPile.ButtonComponent ~= nil or discardPile:AddComponent("ButtonComponent") ~= nil) then
if self.DiscardPileHandler ~= nil then
discardPile:DisconnectEvent(ButtonClickEvent, self.DiscardPileHandler)
self.DiscardPileHandler = nil
end
self.DiscardPileHandler = discardPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("discard") end)
end
local exhaustPile = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/ExhaustPile")
if exhaustPile ~= nil and (exhaustPile.ButtonComponent ~= nil or exhaustPile:AddComponent("ButtonComponent") ~= nil) then
if self.ExhaustPileHandler ~= nil then
exhaustPile:DisconnectEvent(ButtonClickEvent, self.ExhaustPileHandler)
self.ExhaustPileHandler = nil
end
self.ExhaustPileHandler = exhaustPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("exhaust") end)
end
local inspectClose = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud/Close")
if inspectClose ~= nil and (inspectClose.ButtonComponent ~= nil or inspectClose:AddComponent("ButtonComponent") ~= nil) then
if self.DeckInspectCloseHandler ~= nil then
inspectClose:DisconnectEvent(ButtonClickEvent, self.DeckInspectCloseHandler)
self.DeckInspectCloseHandler = nil
end
self.DeckInspectCloseHandler = inspectClose:ConnectEvent(ButtonClickEvent, function() self:CloseDeckInspect() end)
end
local allDeckButton = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TopBar/AllDeckButton")
if allDeckButton ~= nil and (allDeckButton.ButtonComponent ~= nil or allDeckButton:AddComponent("ButtonComponent") ~= nil) then
if self.AllDeckHandler ~= nil then
allDeckButton:DisconnectEvent(ButtonClickEvent, self.AllDeckHandler)
self.AllDeckHandler = nil
end
self.AllDeckHandler = allDeckButton:ConnectEvent(ButtonClickEvent, function() self:OpenAllDeck() end)
end
local allDeckClose = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
if allDeckClose ~= nil and (allDeckClose.ButtonComponent ~= nil or allDeckClose:AddComponent("ButtonComponent") ~= nil) then
if self.AllDeckCloseHandler ~= nil then
allDeckClose:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
self.AllDeckCloseHandler = nil
end
self.AllDeckCloseHandler = allDeckClose:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
end
self:BindClassDeckTabs()
for i = 1, 120 do
local allCard = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i))
if allCard ~= nil and (allCard.ButtonComponent ~= nil or allCard:AddComponent("ButtonComponent") ~= nil) then
if allCard.SpriteGUIRendererComponent ~= nil then
allCard.SpriteGUIRendererComponent.RaycastTarget = true
end
local slot = i
allCard:ConnectEvent(ButtonClickEvent, function() self:OnAllDeckCardButton(slot) end)
end
end
for i = 1, 10 do
local cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then
local cardPath = "/ui/RunUIGroup/CardHand/Card" .. tostring(i)
cardEntity:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
cardEntity:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
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)
cardEntity:ConnectEvent(UITouchEnterEvent, function() self:HoverCard(i) end)
cardEntity:ConnectEvent(UITouchExitEvent, function() self:UnhoverCard(i) end)
if (cardEntity.ButtonComponent ~= nil or cardEntity:AddComponent("ButtonComponent") ~= nil) then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:OnCardButton(i) end)
end
end
end
for i = 1, 3 do
local rc = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud/Reward" .. tostring(i))
if rc ~= nil and (rc.ButtonComponent ~= nil or rc:AddComponent("ButtonComponent") ~= nil) then
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
if rc.UITouchReceiveComponent ~= nil then
local cardPath = "/ui/RunUIGroup/RewardHud/Reward" .. tostring(i)
rc:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
rc:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
end
end
end
local skip = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud/Skip")
if skip ~= nil and (skip.ButtonComponent ~= nil or skip:AddComponent("ButtonComponent") ~= nil) then
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
end
local mapNodeIds = {}
for r = 1, ${MAP_ROWS} do
for c = 1, ${MAP_COLS} do
table.insert(mapNodeIds, "r" .. tostring(r) .. "c" .. tostring(c))
end
end
table.insert(mapNodeIds, "boss")
for i = 1, #mapNodeIds do
local nid = mapNodeIds[i]
local mn = _EntityService:GetEntityByPath("/ui/RunUIGroup/MapHud/Node_" .. nid)
if mn ~= nil and (mn.ButtonComponent ~= nil or mn:AddComponent("ButtonComponent") ~= nil) then
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
end
end
for i = 1, 3 do
local sc = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Card" .. tostring(i))
if sc ~= nil and (sc.ButtonComponent ~= nil or sc:AddComponent("ButtonComponent") ~= nil) then
sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)
if sc.UITouchReceiveComponent ~= nil then
local cardPath = "/ui/RunUIGroup/ShopHud/Card" .. tostring(i)
sc:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
sc:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
end
end
end
local shopLeave = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Leave")
if shopLeave ~= nil and (shopLeave.ButtonComponent ~= nil or shopLeave:AddComponent("ButtonComponent") ~= nil) then
shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
local shopRelic = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Relic")
if shopRelic ~= nil and (shopRelic.ButtonComponent ~= nil or shopRelic:AddComponent("ButtonComponent") ~= nil) then
shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)
end
local restLeave = _EntityService:GetEntityByPath("/ui/RunUIGroup/RestHud/Leave")
if restLeave ~= nil and (restLeave.ButtonComponent ~= nil or restLeave:AddComponent("ButtonComponent") ~= nil) then
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
for i = 1, ${MAX_MONSTERS} do
local ms = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i))
if ms ~= nil and (ms.ButtonComponent ~= nil or ms:AddComponent("ButtonComponent") ~= nil) then
ms:ConnectEvent(ButtonClickEvent, function() self:SetTarget(i) end)
end
end
for i = 1, 10 do
local rs = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TopBar/RelicSlot" .. tostring(i))
if rs ~= nil and rs.UITouchReceiveComponent ~= nil then
local idx = i
rs:ConnectEvent(UITouchEnterEvent, function()
local rid = nil
if self.RunRelics ~= nil then rid = self.RunRelics[idx] end
if rid ~= nil and self.Relics[rid] ~= nil then
self:ShowTooltip(self.Relics[rid].name, self.Relics[rid].desc, -240 + (idx - 1) * 48)
end
end)
rs:ConnectEvent(UITouchExitEvent, function() self:HideTooltip() end)
end
end
for i = 1, 5 do
local ps = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TopBar/PotionSlot" .. tostring(i))
if ps ~= nil and ps.UITouchReceiveComponent ~= nil then
local idx = i
ps:ConnectEvent(UITouchEnterEvent, function()
local pid = nil
if self.RunPotions ~= nil then pid = self.RunPotions[idx] end
if pid ~= nil and self.Potions[pid] ~= nil then
self:ShowTooltip(self.Potions[pid].name, self.Potions[pid].desc, 240 + (idx - 1) * 44)
end
end)
ps:ConnectEvent(UITouchExitEvent, function() self:HideTooltip() end)
ps:ConnectEvent(UITouchDownEvent, function() self:OpenPotionMenu(idx) end)
end
end
local pmUse = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/PotionMenu/Use")
if pmUse ~= nil and (pmUse.ButtonComponent ~= nil or pmUse:AddComponent("ButtonComponent") ~= nil) then
pmUse:ConnectEvent(ButtonClickEvent, function() self:UsePotion() end)
end
local pmToss = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/PotionMenu/Toss")
if pmToss ~= nil and (pmToss.ButtonComponent ~= nil or pmToss:AddComponent("ButtonComponent") ~= nil) then
pmToss:ConnectEvent(ButtonClickEvent, function() self:TossPotion() end)
end
local pmClose = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/PotionMenu/Close")
if pmClose ~= nil and (pmClose.ButtonComponent ~= nil or pmClose:AddComponent("ButtonComponent") ~= nil) then
pmClose:ConnectEvent(ButtonClickEvent, function() self:ClosePotionMenu() end)
end
local shopPotion = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Potion")
if shopPotion ~= nil and (shopPotion.ButtonComponent ~= nil or shopPotion:AddComponent("ButtonComponent") ~= nil) then
shopPotion:ConnectEvent(ButtonClickEvent, function() self:BuyPotion() end)
end
local chest = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Chest")
if chest ~= nil and (chest.ButtonComponent ~= nil or chest:AddComponent("ButtonComponent") ~= nil) then
chest:ConnectEvent(ButtonClickEvent, function() self:OpenChest() end)
end
local treasureLeave = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Leave")
if treasureLeave ~= nil and (treasureLeave.ButtonComponent ~= nil or treasureLeave:AddComponent("ButtonComponent") ~= nil) then
treasureLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
local jcRelic = _EntityService:GetEntityByPath("/ui/SelectUIGroup/JobChoiceHud/RelicButton")
if jcRelic ~= nil and (jcRelic.ButtonComponent ~= nil or jcRelic:AddComponent("ButtonComponent") ~= nil) then
jcRelic:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("relic") end)
end
local jcJob = _EntityService:GetEntityByPath("/ui/SelectUIGroup/JobChoiceHud/JobButton")
if jcJob ~= nil and (jcJob.ButtonComponent ~= nil or jcJob:AddComponent("ButtonComponent") ~= nil) then
jcJob:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("job") end)
end
for i = 1, 3 do
local slotIdx = i
local jb = _EntityService:GetEntityByPath("/ui/SelectUIGroup/JobSelectHud/Job_slot" .. tostring(i))
if jb ~= nil and (jb.ButtonComponent ~= nil or jb:AddComponent("ButtonComponent") ~= nil) then
jb:ConnectEvent(ButtonClickEvent, function()
if self.JobOpts ~= nil and self.JobOpts[slotIdx] ~= nil then
self:SetJob(self.JobOpts[slotIdx].id)
end
end)
end
end`),
method('StartPlayerTurn', `self.Turn = self.Turn + 1
self.RetainSelectActive = false
self.ReserveSelectActive = false
self.TurnAttackCardsPlayed = 0
self.TurnDiscardedCards = 0
self.NextTurnSelectCopies = 0
self.NextTurnSelectPrompt = ""
self.SkillCostReductionThisTurn = 0
self:UpdateDiscardPrompt()
self.Energy = self.MaxEnergy
self.BlockGainMultiplier = 1
self:ApplyRelics("turnStart")
if self.NextTurnKeepBlock == true then
self.NextTurnKeepBlock = false
else
self.PlayerBlock = 0
end
if self.ClayBlockNext > 0 then
self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext
self.ClayBlockNext = 0
end
self.TurnAttackMultiplier = self.NextTurnAttackMultiplier or 1
self.NextTurnAttackMultiplier = 1
self.CardsDrawnThisCombat = self.CardsDrawnThisCombat or 0
self.HandCostZeroThisTurn = false
self.DrawDisabledThisTurn = false
local powerTurnDraw = 0
local powerTurnDiscard = 0
if self.PlayerPowers ~= nil then
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil then
if pc.powerEffect == "strengthPerTurn" then
self.PlayerStr = self.PlayerStr + pc.value
elseif pc.powerEffect == "energyPerTurn" then
self.Energy = self.Energy + pc.value
elseif pc.powerEffect == "blockPerTurn" then
self.PlayerBlock = self.PlayerBlock + pc.value
elseif pc.powerEffect == "poisonPerTurn" then
if self.Monsters ~= nil then
for j = 1, #self.Monsters do
local tm = self.Monsters[j]
if tm ~= nil and tm.alive == true then
tm.poison = (tm.poison or 0) + pc.value
end
end
end
elseif pc.powerEffect == "damagePerTurn" then
if self.Monsters ~= nil then
self:PlayAoeFx(pc.fx or pc.image, pc.value or 0)
end
end
if pc.turnStartShiv ~= nil then
self:AddCardsToHand("Shiv", pc.turnStartShiv)
end
if pc.turnStartDraw ~= nil then
powerTurnDraw = powerTurnDraw + pc.turnStartDraw
end
if pc.turnStartDiscard ~= nil then
powerTurnDiscard = powerTurnDiscard + pc.turnStartDiscard
end
end
end
end
if self.NextTurnBlock ~= nil and self.NextTurnBlock > 0 then
self:AddCardBlock(self.NextTurnBlock)
self.NextTurnBlock = 0
end
if self.NextTurnAddCards ~= nil then
for i = 1, #self.NextTurnAddCards do
local entry = self.NextTurnAddCards[i]
if entry ~= nil and entry.cardId ~= nil and entry.amount ~= nil and entry.amount > 0 then
self:AddCardsToHand(entry.cardId, entry.amount)
end
end
self.NextTurnAddCards = {}
end
local drawN = 5 + (self.NextTurnDraw or 0) + powerTurnDraw
self.NextTurnDraw = 0
self:DrawCards(drawN)
self:RenderHand(true)
self:RenderCombat()
if powerTurnDiscard > 0 then
self:BeginDiscardSelection({ discard = math.min(powerTurnDiscard, #self.Hand) })
return
end
self:RenderCombat()`),
method('PrepareCombatDrawPile', `if self.DrawPile == nil or self.Cards == nil then
return
end
local rest = {}
local innate = {}
for i = 1, #self.DrawPile do
local cardId = self.DrawPile[i]
local c = self.Cards[cardId]
if c ~= nil and c.innate == true then
table.insert(innate, cardId)
else
table.insert(rest, cardId)
end
end
self.DrawPile = {}
for i = 1, #rest do
table.insert(self.DrawPile, rest[i])
end
for i = 1, #innate do
table.insert(self.DrawPile, innate[i])
end`, []),
method('HasPowerEffect', `if self.PlayerPowers == nil then
return false
end
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil and pc.powerEffect == effect then
return true
end
end
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'effect' }], 0, 'boolean'),
method('HasPowerField', `if self.PlayerPowers == nil then
return false
end
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil and pc[field] ~= nil and pc[field] ~= 0 then
return true
end
end
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'boolean'),
method('AddPowerFieldTotal', `local total = 0
if self.PlayerPowers == nil then
return total
end
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil and pc[field] ~= nil then
total = total + pc[field]
end
end
return total`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'number'),
method('ShouldOfferRetain', `if self:HasPowerEffect("retainOne") ~= true then
return false
end
if self.Hand == nil or #self.Hand <= 0 then
return false
end
for i = 1, #self.Hand do
local c = self.Cards[self.Hand[i]]
if c ~= nil and c.retain ~= true then
return true
end
end
return false`, [], 0, 'boolean'),
method('BeginRetainSelection', `self.RetainSelectActive = true
self:UpdateDiscardPrompt()
self:Toast("보존할 카드를 선택하세요")
self:RenderHand(false)`, []),
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
if self:IsDiscardSelecting() == true then
self:Toast("버릴 카드를 먼저 선택하세요")
return
end
if self:IsRetainSelecting() == true then
self:FinishPlayerTurn(0)
return
end
if self:IsReserveSelecting() == true then
self:Toast("예약할 카드를 먼저 선택하세요")
return
end
if self:ShouldOfferRetain() == true then
self:BeginRetainSelection()
return
end
self:FinishPlayerTurn(0)`),
method('FinishPlayerTurn', `self.RetainSelectActive = false
self.ReserveSelectActive = false
self.NextTurnSelectCopies = 0
self.NextTurnSelectPrompt = ""
self:UpdateDiscardPrompt()
local burn = 0
for bi = 1, #self.Hand do
\tlocal hc = self.Cards[self.Hand[bi]]
\tif hc ~= nil and hc.endTurnDamage ~= nil then burn = burn + hc.endTurnDamage end
end
if burn > 0 then
\tself.PlayerHp = self.PlayerHp - burn
\tif self.PlayerHp < 0 then self.PlayerHp = 0 end
\tself:ShowPlayerDmgPop(burn)
\tself:RenderCombat()
end
local kept = {}
for i = 1, #self.Hand do
\tlocal cardId = self.Hand[i]
\tlocal c = self.Cards[cardId]
\tif c ~= nil and (c.retain == true or i == retainSlot) then
\t\ttable.insert(kept, cardId)
\telse
\t\ttable.insert(self.DiscardPile, cardId)
\tend
end
self.Hand = kept
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
self:RenderHand(false)
self:RenderPiles()
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
method('DrawCards', `local drawnSlots = {}
local drawnCards = {}
local drewAny = false
if self.DrawDisabledThisTurn == true then
\treturn drawnCards
end
for i = 1, amount do
\tif #self.DrawPile <= 0 then
\t\tself:RecycleDiscardIntoDraw()
\tend
\tif #self.DrawPile <= 0 then
\t\tbreak
\tend
\tlocal cardId = table.remove(self.DrawPile)
\ttable.insert(drawnCards, cardId)
\tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1
\tif #self.Hand >= 10 then
\t\ttable.insert(self.DiscardPile, cardId)
\t\tself:TriggerSly(cardId)
\telse
\t\ttable.insert(self.Hand, cardId)
\t\tdrewAny = true
\t\ttable.insert(drawnSlots, #self.Hand)
\tend
end
self:RenderPiles()
if drewAny == true then
\tself:RenderHand(false)
end
if animate == true and #drawnSlots > 0 then
\tlocal drawStart = Vector2(-590, 8)
\tfor i = 1, #drawnSlots do
\t\tlocal slot = drawnSlots[i]
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
\tend
return drawnCards
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
], 0, 'any'),
method('AddCardsToHand', `if self.Hand == nil then
self.Hand = {}
end
if self.DiscardPile == nil then
self.DiscardPile = {}
end
for i = 1, amount do
if #self.Hand >= 10 then
table.insert(self.DiscardPile, cardId)
else
table.insert(self.Hand, cardId)
end
end
self:RenderHand(false)
self:RenderPiles()`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('RecycleDiscardIntoDraw', `if self.DiscardPile == nil or #self.DiscardPile <= 0 then
\treturn
end
self.DrawPile = {}
for i = 1, #self.DiscardPile do
\tself.DrawPile[i] = self.DiscardPile[i]
end
self.DiscardPile = {}
self:Shuffle(self.DrawPile)`),
method('RenderPiles', `self:SetText("/ui/RunUIGroup/DeckHud/DrawPile/Count", self:FormatNumber(#self.DrawPile))
self:SetText("/ui/RunUIGroup/DeckHud/DiscardPile/Count", self:FormatNumber(#self.DiscardPile))
self:SetText("/ui/RunUIGroup/DeckHud/ExhaustPile/Count", self:FormatNumber(#(self.ExhaustPile or {})))
self:SetText("/ui/RunUIGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
local inspect = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
if inspect ~= nil and inspect.Enable == true and self.DeckInspectKind ~= "" then
self:OpenDeckInspect(self.DeckInspectKind)
end`),
];

258
tools/deck/cb/deckview.mjs Normal file
View File

@@ -0,0 +1,258 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const deckViewMethods = [
method('OpenDeckInspect', `self.DeckInspectKind = kind
if self.DeckAllOpen == true then
self.DeckAllOpen = false
local allHud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
if allHud ~= nil then
allHud.Enable = false
end
end
local pile = {}
local title = ""
if kind == "discard" then
pile = self.DiscardPile or {}
title = "버린 덱"
elseif kind == "exhaust" then
pile = self.ExhaustPile or {}
title = "소멸 덱"
else
pile = self.DrawPile or {}
title = "뽑을 덱"
end
self:RenderDeckInspect(pile, title)
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
if hud ~= nil then
hud.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
method('CloseDeckInspect', `self.DeckInspectKind = ""
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
if hud ~= nil then
hud.Enable = false
end`),
method('RenderDeckInspect', `local count = 0
if pile ~= nil then
count = #pile
end
local suffix = " (" .. tostring(count) .. ")"
if count > 60 then
suffix = suffix .. " - 60장까지 표시"
end
self:SetText("/ui/DeckUIGroup/DeckInspectHud/Title", title .. suffix)
self:SetEntityEnabled("/ui/DeckUIGroup/DeckInspectHud/Empty", count <= 0)
for i = 1, 60 do
local path = "/ui/DeckUIGroup/DeckInspectHud/Grid/Card" .. tostring(i)
local cardId = nil
if pile ~= nil then
cardId = pile[i]
end
if cardId == nil then
self:SetEntityEnabled(path, false)
else
self:SetEntityEnabled(path, true)
self:ApplyInspectCardVisual(i, cardId)
end
end`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pile' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'title' },
]),
method('ApplyInspectCardVisual', `self:ApplyCardFace("/ui/DeckUIGroup/DeckInspectHud/Grid/Card" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('BindClassDeckTabs', `local warriorTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/WarriorTab")
if warriorTab ~= nil and (warriorTab.ButtonComponent ~= nil or warriorTab:AddComponent("ButtonComponent") ~= nil) then
if self.WarriorDeckTabHandler ~= nil then
warriorTab:DisconnectEvent(ButtonClickEvent, self.WarriorDeckTabHandler)
self.WarriorDeckTabHandler = nil
end
self.WarriorDeckTabHandler = warriorTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("warrior") end)
end
local thiefTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/ThiefTab")
if thiefTab ~= nil and (thiefTab.ButtonComponent ~= nil or thiefTab:AddComponent("ButtonComponent") ~= nil) then
if self.ThiefDeckTabHandler ~= nil then
thiefTab:DisconnectEvent(ButtonClickEvent, self.ThiefDeckTabHandler)
self.ThiefDeckTabHandler = nil
end
self.ThiefDeckTabHandler = thiefTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("bandit") end)
end
local mageTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/MageTab")
if mageTab ~= nil and (mageTab.ButtonComponent ~= nil or mageTab:AddComponent("ButtonComponent") ~= nil) then
if self.MageDeckTabHandler ~= nil then
mageTab:DisconnectEvent(ButtonClickEvent, self.MageDeckTabHandler)
self.MageDeckTabHandler = nil
end
self.MageDeckTabHandler = mageTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("magician") end)
end`),
method('OpenClassDeck', `self.CodexMode = false
self.ClassDeckMode = true
self.DebugCardPickerMode = false
self.DeckAllOpen = true
self:SetClassDeckTab(className)
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
method('OpenDebugCardPicker', `if self.RunActive ~= true or self.CombatOver == true or self.Hand == nil then
self:Toast("전투 중에만 테스트 카드를 추가할 수 있습니다")
return
end
local className = self.SelectedClass
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
className = "bandit"
end
self.CodexMode = false
self.ClassDeckMode = true
self.DebugCardPickerMode = true
self.DeckAllOpen = true
self:SetClassDeckTab(className)
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = true
end
self:Toast("테스트 카드 추가 모드")`),
method('SetClassDeckTab', `if self.ClassDeckMode ~= true then
return
end
self.ClassDeckCards = {}
self.ClassDeckTitle = "직업 덱"
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
className = "bandit"
end
self.ClassDeckClass = className
local allowed = {}
if className == "warrior" then
allowed["warrior"] = true
allowed["fighter"] = true
allowed["page"] = true
allowed["spearman"] = true
self.ClassDeckTitle = "전사 전체 덱"
elseif className == "magician" then
allowed["magician"] = true
allowed["firepoison"] = true
allowed["icelightning"] = true
allowed["cleric"] = true
self.ClassDeckTitle = "마법사 전체 덱"
else
allowed["bandit"] = true
allowed["shiv"] = true
allowed["poisoner"] = true
allowed["trickster"] = true
self.ClassDeckTitle = "도적 전체 덱"
end
for id, c in pairs(self.Cards) do
if c ~= nil and c.curse ~= true and allowed[c.class] == true then
table.insert(self.ClassDeckCards, id)
end
end
table.sort(self.ClassDeckCards, function(a, b)
local ca = self.Cards[a]
local cb = self.Cards[b]
local na = a
local nb = b
if ca ~= nil and ca.name ~= nil then na = ca.name end
if cb ~= nil and cb.name ~= nil then nb = cb.name end
if na == nb then return a < b end
return na < nb
end)
self:RenderAllDeck()
self:RenderClassDeckTabs()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
method('RenderClassDeckTabs', `local tabs = {
{ path = "/ui/DeckUIGroup/DeckAllHud/WarriorTab", cls = "warrior" },
{ path = "/ui/DeckUIGroup/DeckAllHud/ThiefTab", cls = "bandit" },
{ path = "/ui/DeckUIGroup/DeckAllHud/MageTab", cls = "magician" },
}
for i = 1, #tabs do
self:SetEntityEnabled(tabs[i].path, self.ClassDeckMode == true)
local e = _EntityService:GetEntityByPath(tabs[i].path)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if self.ClassDeckClass == tabs[i].cls then
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.28, 0.34, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.11, 0.13, 0.16, 1)
end
end
end`),
method('OpenAllDeck', `local inspectHud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
if inspectHud ~= nil then
inspectHud.Enable = false
end
self.DeckInspectKind = ""
self.ClassDeckMode = false
self.ClassDeckClass = ""
self.DebugCardPickerMode = false
self:RenderClassDeckTabs()
self.DeckAllOpen = true
self:RenderAllDeck()
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = true
end`),
method('CloseAllDeck', `self.DeckAllOpen = false
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = false
end
if self.ClassDeckMode == true then
self.ClassDeckMode = false
self.ClassDeckCards = {}
self.ClassDeckTitle = ""
self.ClassDeckClass = ""
end
self.DebugCardPickerMode = false
self:RenderClassDeckTabs()
if self.CodexMode == true then
self.CodexMode = false
self:ShowLobby()
end`),
method('RenderAllDeck', `local pile = self.RunDeck or {}
local title = "모든 덱"
if self.ClassDeckMode == true then
pile = self.ClassDeckCards or {}
title = self.ClassDeckTitle
if self.DebugCardPickerMode == true then
title = title .. " - 테스트 카드 추가"
end
elseif self.CodexMode == true then
pile = self.CodexCards or {}
title = "카드 도감"
end
local count = #pile
self:SetText("/ui/DeckUIGroup/DeckAllHud/Title", title .. " (" .. tostring(count) .. ")")
self:RenderClassDeckTabs()
self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud/Empty", count <= 0)
for i = 1, 120 do
local path = "/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i)
local cardId = pile[i]
if cardId == nil then
self:SetEntityEnabled(path, false)
else
self:SetEntityEnabled(path, true)
self:ApplyAllDeckCardVisual(i, cardId)
end
end`),
method('ApplyAllDeckCardVisual', `self:ApplyCardFace("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('OnAllDeckCardButton', `if self.DebugCardPickerMode ~= true then
return
end
if self.ClassDeckCards == nil then
return
end
local cardId = self.ClassDeckCards[slot]
if cardId == nil or self.Cards == nil or self.Cards[cardId] == nil then
return
end
self:AddCardsToHand(cardId, 1)
local c = self.Cards[cardId]
local name = cardId
if c.name ~= nil then name = c.name end
self:Toast("테스트 카드 추가: " .. name)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
]),
];

741
tools/deck/cb/hand.mjs Normal file
View File

@@ -0,0 +1,741 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const handMethods = [
method('GetHandSlotX', `local n = 0
if self.Hand ~= nil then
n = #self.Hand
end
if n <= 0 then
return 0
end
local spacing = 175
if n > 8 then spacing = math.floor(1400 / n) end
local startX = -((n - 1) * spacing) / 2
return startX + (slot - 1) * spacing`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
method('RenderHand', `local n = #self.Hand
local spacing = 175
if n > 8 then spacing = math.floor(1400 / n) end
local startX = -((n - 1) * spacing) / 2
local drawStart = Vector2(-590, 8)
for i = 1, 10 do
\tlocal cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
\tif cardEntity ~= nil then
\t\tlocal cardId = self.Hand[i]
\t\tif cardId == nil then
\t\t\tcardEntity.Enable = false
\t\telse
\t\t\tcardEntity.Enable = true
\t\t\tif cardEntity.UITransformComponent ~= nil then cardEntity.UITransformComponent.UIScale = Vector3(1, 1, 1) end
\t\t\tself:ApplyCardVisual(i, cardId)
\t\t\tlocal tx = self:GetHandSlotX(i)
\t\t\tif animate == true then
\t\t\t\tself:AnimateCardFrom(i, drawStart, Vector2(tx, 0), 0.16 + i * 0.03)
\t\t\telse
\t\t\t\tif cardEntity.UITransformComponent ~= nil then cardEntity.UITransformComponent.anchoredPosition = Vector2(tx, 0) end
\t\t\tend
\t\tend
\tend
end
self:RenderPiles()`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }]),
method('ApplyCardFace', `local c = self.Cards[cardId]
if c == nil then
c = { name = cardId, cost = 0, desc = "", kind = "Skill", class = "warrior", rarity = "normal" }
end
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if e.UITransformComponent ~= nil then
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
end
local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"]
local ruid = nil
if frames ~= nil then
ruid = frames[c.rarity or "normal"]
end
if ruid ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = ruid
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
end
end
self:SetText(base .. "/Cost", string.format("%d", c.cost))
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Desc", self:FormatCardDescription(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' },
]),
method('SetCardHover', `local prefix = ""
local count = 0
local xs = {}
local baseY = 0
local hoverIndex = 0
local push = 110
if string.find(path, "/ui/RunUIGroup/CardHand/Card") == 1 then
if self.DragSlot ~= nil and self.DragSlot > 0 then
return
end
prefix = "/ui/RunUIGroup/CardHand/Card"
count = 0
if self.Hand ~= nil then count = #self.Hand end
for i = 1, count do
xs[i] = self:GetHandSlotX(i)
end
baseY = 0
hoverIndex = tonumber(string.match(path, "Card(%d+)")) or 0
elseif string.find(path, "/ui/RunUIGroup/RewardHud/Reward") == 1 then
prefix = "/ui/RunUIGroup/RewardHud/Reward"
count = 3
xs = { -300, 0, 300 }
baseY = 0
hoverIndex = tonumber(string.match(path, "Reward(%d+)")) or 0
elseif string.find(path, "/ui/RunUIGroup/ShopHud/Card") == 1 then
prefix = "/ui/RunUIGroup/ShopHud/Card"
count = 3
xs = { -300, 0, 300 }
baseY = 20
hoverIndex = tonumber(string.match(path, "Card(%d+)")) or 0
end
if count <= 0 then
return
end
if self.CardHoverTweenId ~= nil and self.CardHoverTweenId ~= 0 then
_TimerService:ClearTimer(self.CardHoverTweenId)
self.CardHoverTweenId = 0
end
local items = {}
for i = 1, count do
local e = _EntityService:GetEntityByPath(prefix .. tostring(i))
if e ~= nil and e.UITransformComponent ~= nil then
local tr = e.UITransformComponent
local tx = xs[i]
local ty = baseY
local sc = 1
if hover == true and hoverIndex > 0 then
if i == hoverIndex and e.Enable == true then
sc = 1.5
elseif i < hoverIndex then
tx = tx - push
elseif i > hoverIndex then
tx = tx + push
end
end
table.insert(items, { tr = tr, sx = tr.anchoredPosition.x, sy = tr.anchoredPosition.y, ss = tr.UIScale.x, tx = tx, ty = ty, ts = sc })
end
end
local elapsed = 0
local duration = 0.12
local eventId = 0
eventId = _TimerService:SetTimerRepeat(function()
elapsed = elapsed + 1 / 60
local t = math.min(elapsed / duration, 1)
local eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t)
for i = 1, #items do
local it = items[i]
local x = it.sx + (it.tx - it.sx) * eased
local y = it.sy + (it.ty - it.sy) * eased
local s = it.ss + (it.ts - it.ss) * eased
it.tr.anchoredPosition = Vector2(x, y)
it.tr.UIScale = Vector3(s, s, 1)
end
if t >= 1 then
_TimerService:ClearTimer(eventId)
if self.CardHoverTweenId == eventId then
self.CardHoverTweenId = 0
end
end
end, 1 / 60)
self.CardHoverTweenId = eventId`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hover' },
]),
method('ApplyCardVisual', `self:ApplyCardFace("/ui/RunUIGroup/CardHand/Card" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('SetText', `local entity = _EntityService:GetEntityByPath(path)
if entity ~= nil and entity.TextComponent ~= nil then
\tentity.TextComponent.Text = value
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' },
]),
method('FormatNumber', `if value == nil then
return ""
end
local n = tonumber(value)
if n == nil then
return tostring(value)
end
if math.abs(n - math.floor(n)) < 0.00001 then
return string.format("%d", math.floor(n))
end
return tostring(n)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' }], 0, 'string'),
method('AnimateCardFrom', `local cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
if cardEntity == nil or cardEntity.UITransformComponent == nil then
\treturn
end
local tr = cardEntity.UITransformComponent
tr.anchoredPosition = fromPos
local elapsed = 0
local eventId = 0
eventId = _TimerService:SetTimerRepeat(function()
\telapsed = elapsed + 1 / 60
\tlocal t = math.min(elapsed / duration, 1)
\tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t)
\ttr.anchoredPosition = Vector2(fromPos.x + (toPos.x - fromPos.x) * eased, fromPos.y + (toPos.y - fromPos.y) * eased)
\tif t >= 1 then
\t\t_TimerService:ClearTimer(eventId)
\tend
end, 1 / 60)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromPos' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toPos' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'duration' },
]),
method('AnimateDiscardCards', `if cardIds == nil or slots == nil then
\treturn
end
local target = Vector2(590, 8)
local duration = 0.18
for i = 1, #cardIds do
\tlocal slot = slots[i] or i
\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
\tif e ~= nil then
\t\te.Enable = true
\t\tself:ApplyCardFace("/ui/RunUIGroup/CardHand/Card" .. tostring(slot), cardIds[i])
\t\tif e.UITransformComponent ~= nil then
\t\t\tlocal sx = 0
\t\t\tif startXs ~= nil and startXs[i] ~= nil then sx = startXs[i] else sx = self:GetHandSlotX(slot) end
\t\t\te.UITransformComponent.anchoredPosition = Vector2(sx, 0)
\t\t\te.UITransformComponent.UIScale = Vector3(1, 1, 1)
\t\tend
\tend
end
local elapsed = 0
local eventId = 0
eventId = _TimerService:SetTimerRepeat(function()
\telapsed = elapsed + 1 / 60
\tlocal t = math.min(elapsed / duration, 1)
\tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseIn, t)
\tfor i = 1, #cardIds do
\t\tlocal slot = slots[i] or i
\t\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
\t\tif e ~= nil and e.UITransformComponent ~= nil then
\t\t\tlocal sx = 0
\t\t\tif startXs ~= nil and startXs[i] ~= nil then sx = startXs[i] else sx = self:GetHandSlotX(slot) end
\t\t\tlocal x = sx + (target.x - sx) * eased
\t\t\tlocal y = 0 + (target.y - 0) * eased
\t\t\tlocal s = 1 - 0.25 * eased
\t\t\te.UITransformComponent.anchoredPosition = Vector2(x, y)
\t\t\te.UITransformComponent.UIScale = Vector3(s, s, 1)
\t\tend
\tend
\tif t >= 1 then
\t\t_TimerService:ClearTimer(eventId)
\t\tfor i = 1, #cardIds do
\t\t\tlocal slot = slots[i] or i
\t\t\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
\t\t\tif e ~= nil then
\t\t\t\tif self.Hand ~= nil and self.Hand[slot] ~= nil then
\t\t\t\t\te.Enable = true
\t\t\t\t\tself:ApplyCardVisual(slot, self.Hand[slot])
\t\t\t\t\tif e.UITransformComponent ~= nil then
\t\t\t\t\t\te.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(slot), 0)
\t\t\t\t\t\te.UITransformComponent.UIScale = Vector3(1, 1, 1)
\t\t\t\t\tend
\t\t\t\telse
\t\t\t\t\te.Enable = false
\t\t\t\tend
\t\t\tend
\t\tend
\tend
end, 1 / 60)`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardIds' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'startXs' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slots' },
]),
method('AddCardBlock', `local amount = base or 0
if amount > 0 and self.PlayerDex ~= nil then
amount = amount + self.PlayerDex
end
if self.BlockGainMultiplier ~= nil and self.BlockGainMultiplier > 1 then
amount = amount * self.BlockGainMultiplier
end
if amount < 0 then
amount = 0
end
self.PlayerBlock = self.PlayerBlock + amount
return amount`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
method('CountOtherHandSkills', `if self.Hand == nil then
return 0
end
local n = 0
for i = 1, #self.Hand do
if i ~= slot then
local hc = self.Cards[self.Hand[i]]
if hc ~= nil and hc.kind == "Skill" then
n = n + 1
end
end
end
return n`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
method('AttackBaseForCard', `local base2 = c.damage or 0
local otherHand = 0
if self.Hand ~= nil then
otherHand = #self.Hand - 1
if otherHand < 0 then otherHand = 0 end
end
if c.damagePerOtherHandCard ~= nil then
base2 = base2 + otherHand * c.damagePerOtherHandCard
end
if c.damagePerAttackPlayedThisTurn ~= nil then
base2 = base2 + (self.TurnAttackCardsPlayed or 0) * c.damagePerAttackPlayedThisTurn
end
if c.damagePerDiscardedThisTurn ~= nil then
base2 = base2 + (self.TurnDiscardedCards or 0) * c.damagePerDiscardedThisTurn
end
if c.damagePerSkillInHand ~= nil then
base2 = base2 + self:CountOtherHandSkills(slot) * c.damagePerSkillInHand
end
if c.damagePerCardDrawnThisCombat ~= nil then
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
end
if base2 < 0 then
base2 = 0
end
return base2`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
], 0, 'number'),
method('CalcPlayerAttack', `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 self.TurnAttackMultiplier ~= nil and self.TurnAttackMultiplier > 1 then
dmg = dmg * self.TurnAttackMultiplier
end
if dmg > 0 and dmg < 5 and self:HasRelic("boot") then
dmg = 5
end
if dmg < 0 then
dmg = 0
end
return dmg`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
method('QueueNextTurnAddCard', `if cardId == nil or cardId == "" or amount == nil or amount <= 0 then
return
end
if self.NextTurnAddCards == nil then
self.NextTurnAddCards = {}
end
for i = 1, #self.NextTurnAddCards do
local entry = self.NextTurnAddCards[i]
if entry ~= nil and entry.cardId == cardId then
entry.amount = (entry.amount or 0) + amount
return
end
end
table.insert(self.NextTurnAddCards, { cardId = cardId, amount = amount })`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('QueueNextTurnEffects', `if c == nil then
return
end
if c.nextTurnBlock ~= nil then
self.NextTurnBlock = (self.NextTurnBlock or 0) + c.nextTurnBlock
end
if c.nextTurnDraw ~= nil then
self.NextTurnDraw = (self.NextTurnDraw or 0) + c.nextTurnDraw
end
if c.nextTurnKeepBlock == true then
self.NextTurnKeepBlock = true
end
if c.nextTurnAttackMultiplier ~= nil and c.nextTurnAttackMultiplier > 0 then
local cur = self.NextTurnAttackMultiplier or 1
self.NextTurnAttackMultiplier = cur * c.nextTurnAttackMultiplier
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }]),
method('ResolveCardEffects', `if c == nil then
return
end
if c.blockGainMultiplier ~= nil and c.blockGainMultiplier > 0 then
self.BlockGainMultiplier = (self.BlockGainMultiplier or 1) * c.blockGainMultiplier
end
if c.nextSkillCostZero == true then
self.NextSkillCostZero = true
end
if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then
self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn
end
if c.handCostZeroThisTurn == true then
self.HandCostZeroThisTurn = true
end
if c.drawDisabledThisTurn == true then
self.DrawDisabledThisTurn = true
end
local xEnergy = energySpent or 0
local weakAmount = c.weak or 0
local vulnAmount = c.vuln or 0
local poisonAmount = c.poison or 0
if c.xWeakPerEnergy ~= nil and c.xWeakPerEnergy > 0 then
weakAmount = weakAmount + xEnergy * c.xWeakPerEnergy
end
if c.kind == "Attack" then
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
self:PlayerAttackMotion()
local baseDmg = self:AttackBaseForCard(slot, c)
if c.xDamagePerEnergy ~= nil and c.xDamagePerEnergy > 0 then
baseDmg = xEnergy * c.xDamagePerEnergy
end
local total = 0
local hitN = c.hits or 1
if c.otherHandAtLeast ~= nil and c.bonusHitsWhenOtherHandAtLeast ~= nil then
local otherHand = 0
if self.Hand ~= nil then
otherHand = #self.Hand - 1
if otherHand < 0 then otherHand = 0 end
end
if otherHand >= c.otherHandAtLeast then
hitN = hitN + c.bonusHitsWhenOtherHandAtLeast
end
end
for h = 1, hitN do
total = total + self:CalcPlayerAttack(baseDmg)
end
if c.aoe == true then
self:PlayAoeFx(c.fx or c.image, total)
else
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
end
end
if c.block ~= nil then
self:AddCardBlock(c.block)
end
if free ~= true then
self:ApplyRelics("cardPlayed")
end
elseif c.kind == "Skill" then
if c.block ~= nil then
self:AddCardBlock(c.block)
end
elseif c.kind == "Power" then
if free ~= true then
table.insert(self.PlayerPowers, cardId)
end
end
if c.strength ~= nil then
self.PlayerStr = self.PlayerStr + c.strength
end
if c.dex ~= nil then
self.PlayerDex = self.PlayerDex + c.dex
end
if c.thorns ~= nil then
self.PlayerThorns = self.PlayerThorns + c.thorns
end
if c.selfVuln ~= nil then
self.PlayerVuln = self.PlayerVuln + c.selfVuln
end
if c.heal ~= nil then
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
end
if c.gainEnergy ~= nil and c.gainEnergy ~= 0 then
self.Energy = self.Energy + c.gainEnergy
end
self:QueueNextTurnEffects(c)
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil then
local tm = self.Monsters[self.TargetIndex]
if tm == nil or tm.alive ~= true then
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
end
end
if tm ~= nil and tm.alive == true then
if weakAmount ~= nil and weakAmount > 0 then tm.weak = tm.weak + weakAmount end
if poisonAmount ~= nil and poisonAmount > 0 then tm.poison = (tm.poison or 0) + poisonAmount end
if vulnAmount ~= nil and vulnAmount > 0 then
tm.vuln = tm.vuln + vulnAmount
if self:HasRelic("championBelt") then
tm.weak = tm.weak + 1
end
end
end
end
local drawnCards = {}
if c.draw ~= nil then
drawnCards = self:DrawCards(c.draw, true) or {}
end
if c.drawUntilHandSize ~= nil and c.drawUntilHandSize > 0 then
local currentHand = 0
if self.Hand ~= nil then
currentHand = #self.Hand
if slot ~= nil and slot > 0 and self.Hand[slot] == cardId then
currentHand = currentHand - 1
end
end
local need = c.drawUntilHandSize - currentHand
if need > 0 then
local moreDrawnCards = self:DrawCards(need, true) or {}
for i = 1, #moreDrawnCards do
table.insert(drawnCards, moreDrawnCards[i])
end
end
end
if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
for i = 1, #drawnCards do
local drawnCard = self.Cards[drawnCards[i]]
if drawnCard ~= nil and drawnCard.kind == "Skill" then
self:AddCardBlock(c.drawSkillBlock)
end
end
end
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
self:AddCardsToHand("Shiv", c.addShiv)
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'free' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
]),
method('TriggerSly', `local c = self.Cards[cardId]
if c == nil or c.sly ~= true then
return
end
self:Toast("교활 발동: " .. c.name)
self:ResolveCardEffects(cardId, 0, c, true, 0)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
method('DiscardHandCard', `if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local startX = self:GetHandSlotX(slot)
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
self.TurnDiscardedCards = (self.TurnDiscardedCards or 0) + 1
if triggerSly == true then
self:TriggerSly(cardId)
end
if animate == true then
self:AnimateDiscardCards({ cardId }, { startX }, { slot })
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'triggerSly' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
]),
method('IsDiscardSelecting', `return self.DiscardSelectRemaining ~= nil and self.DiscardSelectRemaining > 0`, [], 0, 'boolean'),
method('IsRetainSelecting', `return self.RetainSelectActive == true`, [], 0, 'boolean'),
method('IsReserveSelecting', `return self.ReserveSelectActive == true`, [], 0, 'boolean'),
method('UpdateDiscardPrompt', `local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/DiscardPrompt")
if e == nil then
return
end
if self:IsDiscardSelecting() == true then
local picked = self.DiscardSelectTotal - self.DiscardSelectRemaining
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", "버릴 카드 선택 " .. self:FormatNumber(picked + 1) .. "/" .. self:FormatNumber(self.DiscardSelectTotal))
e.Enable = true
elseif self:IsRetainSelecting() == true then
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", "보존할 카드 선택 (턴 종료: 건너뛰기)")
e.Enable = true
elseif self:IsReserveSelecting() == true then
local msg = self.NextTurnSelectPrompt or ""
if msg == "" then
msg = "다음 턴에 예약할 카드를 선택하세요"
end
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", msg)
e.Enable = true
else
e.Enable = false
end`),
method('BeginDiscardSelection', `if c == nil or self.Hand == nil then
return false
end
if c.discardAll == true then
return self:AutoDiscardHand(c)
end
local n = 0
if c.discard ~= nil then
n = math.min(c.discard, #self.Hand)
end
if n <= 0 then
return false
end
self.DiscardSelectRemaining = n
self.DiscardSelectTotal = n
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self.DiscardPostDraw = 0
self.DiscardDrawPerPick = 0
if c.addShiv ~= nil then
self.DiscardPostShiv = c.addShiv
end
if c.addShivPerDiscard == true then
self.DiscardShivPerPick = 1
end
if c.drawPerDiscarded ~= nil and c.drawPerDiscarded > 0 then
self.DiscardDrawPerPick = c.drawPerDiscarded
end
self:UpdateDiscardPrompt()
self:Toast("버릴 카드를 선택하세요")
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
method('BeginReserveSelection', `if c == nil or c.nextTurnSelectHandCard ~= true or c.nextTurnCopies == nil or c.nextTurnCopies <= 0 then
return false
end
if self.Hand == nil or #self.Hand <= 0 then
return false
end
self.ReserveSelectActive = true
self.NextTurnSelectCopies = c.nextTurnCopies
self.NextTurnSelectPrompt = c.nextTurnSelectPrompt or ""
self:UpdateDiscardPrompt()
self:Toast("예약할 카드를 선택하세요")
self:RenderHand(false)
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
method('SelectReserveSlot', `if self:IsReserveSelecting() ~= true then
return false
end
if self.Hand == nil or self.Hand[slot] == nil then
return true
end
local cardId = self.Hand[slot]
local amount = self.NextTurnSelectCopies or 0
self.ReserveSelectActive = false
self.NextTurnSelectCopies = 0
self.NextTurnSelectPrompt = ""
self:UpdateDiscardPrompt()
if amount > 0 and cardId ~= nil then
self:QueueNextTurnAddCard(cardId, amount)
local label = cardId
if self.Cards[cardId] ~= nil and self.Cards[cardId].name ~= nil then
label = self.Cards[cardId].name
end
self:Toast("다음 턴 예약: " .. label .. " " .. self:FormatNumber(amount) .. "장")
end
self:RenderPiles()
self:RenderCombat()
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
method('SelectRetainSlot', `if self:IsRetainSelecting() ~= true then
return false
end
if self.Hand == nil or self.Hand[slot] == nil then
return true
end
self:FinishPlayerTurn(slot)
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
method('AutoDiscardHand', `if c == nil or self.Hand == nil or #self.Hand <= 0 then
return false
end
local cardIds = {}
local startXs = {}
local slots = {}
local n = #self.Hand
for i = 1, n do
local cardId = self.Hand[i]
table.insert(cardIds, cardId)
table.insert(startXs, self:GetHandSlotX(i))
table.insert(slots, i)
table.insert(self.DiscardPile, cardId)
self.TurnDiscardedCards = (self.TurnDiscardedCards or 0) + 1
end
self.Hand = {}
local shivCount = 0
if c.addShiv ~= nil then shivCount = shivCount + c.addShiv end
if c.addShivPerDiscard == true then shivCount = shivCount + n end
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self.DiscardPostDraw = 0
self.DiscardDrawPerPick = 0
self:UpdateDiscardPrompt()
self:AnimateDiscardCards(cardIds, startXs, slots)
for i = 1, #cardIds do
self:TriggerSly(cardIds[i])
end
self:RenderPiles()
self:RenderCombat()
_TimerService:SetTimerOnce(function()
if shivCount > 0 then
self:AddCardsToHand("Shiv", shivCount)
else
self:RenderHand(false)
self:RenderPiles()
end
if c.drawPerDiscarded ~= nil and c.drawPerDiscarded > 0 then
self:DrawCards(n * c.drawPerDiscarded, true)
end
self:RenderCombat()
self:CheckCombatEnd()
end, 0.22)
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
method('FinishDiscardSelection', `self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
local shivCount = self.DiscardPostShiv or 0
local drawCount = self.DiscardPostDraw or 0
self.DiscardPostShiv = 0
self.DiscardPostDraw = 0
self.DiscardShivPerPick = 0
self.DiscardDrawPerPick = 0
self:UpdateDiscardPrompt()
local finish = function()
if shivCount > 0 then
self:AddCardsToHand("Shiv", shivCount)
else
self:RenderHand(false)
self:RenderPiles()
end
if drawCount > 0 then
self:DrawCards(drawCount, true)
end
self:RenderCombat()
self:CheckCombatEnd()
end
if delayRender == true then
_TimerService:SetTimerOnce(finish, 0.22)
else
finish()
end`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'delayRender' }]),
method('SelectDiscardSlot', `if self:IsDiscardSelecting() ~= true then
return false
end
if self.Hand == nil or self.Hand[slot] == nil then
return true
end
local discarded = self.Hand[slot]
self:DiscardHandCard(slot, true, true)
if discarded ~= nil and self.DiscardShivPerPick ~= nil and self.DiscardShivPerPick > 0 then
self.DiscardPostShiv = (self.DiscardPostShiv or 0) + self.DiscardShivPerPick
end
if discarded ~= nil and self.DiscardDrawPerPick ~= nil and self.DiscardDrawPerPick > 0 then
self.DiscardPostDraw = (self.DiscardPostDraw or 0) + self.DiscardDrawPerPick
end
self.DiscardSelectRemaining = self.DiscardSelectRemaining - 1
if self.DiscardSelectRemaining <= 0 or #self.Hand <= 0 then
self:FinishDiscardSelection(true)
else
self:UpdateDiscardPrompt()
self:RenderPiles()
self:RenderCombat()
_TimerService:SetTimerOnce(function() self:RenderHand(false) end, 0.22)
end
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
];

213
tools/deck/cb/items.mjs Normal file
View File

@@ -0,0 +1,213 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const itemMethods = [
method('HasRelic', `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`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
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 == "strength" then
self.PlayerStr = self.PlayerStr + r.value
elseif r.effect == "draw" then
self:DrawCards(r.value)
self:RenderHand(false)
elseif r.effect == "heal" or r.effect == "healOnAttack" or r.effect == "healOnWin" then
self.PlayerHp = self.PlayerHp + r.value
if self.PlayerHp > self.PlayerMaxHp then
self.PlayerHp = self.PlayerMaxHp
end
elseif r.effect == "healIfLow" then
if self.PlayerHp * 2 <= self.PlayerMaxHp then
self.PlayerHp = self.PlayerHp + r.value
if self.PlayerHp > self.PlayerMaxHp then
self.PlayerHp = self.PlayerMaxHp
end
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)
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
self:RenderRelics()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
method('PickNewRelic', `local pool = {}
for i = 1, #self.RelicPool do
if self:HasRelic(self.RelicPool[i]) == false then
table.insert(pool, self.RelicPool[i])
end
end
if #pool == 0 then
self.Gold = self.Gold + 25
self:Toast("유물을 모두 모았습니다! 메소 +25")
return ""
end
return pool[math.random(1, #pool)]`, [], 0, 'string'),
method('AddPotion', `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`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pid' }], 0, 'boolean'),
method('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`),
method('RenderPotions', `for i = 1, 5 do
local base = "/ui/RunUIGroup/CombatHud/TopBar/PotionSlot" .. tostring(i)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
local pid = nil
if self.RunPotions ~= nil then
pid = self.RunPotions[i]
end
if pid ~= nil and self.Potions[pid] ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = self.Potions[pid].icon
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
elseif i > self.PotionSlots then
e.SpriteGUIRendererComponent.ImageRUID = ""
e.SpriteGUIRendererComponent.Color = Color(0.1, 0.1, 0.12, 0.85)
else
e.SpriteGUIRendererComponent.ImageRUID = ""
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.25, 0.3, 0.9)
end
end
end`),
method('OpenPotionMenu', `if self.RunPotions == nil or self.RunPotions[slot] == nil then
return
end
self.PotionMenuSlot = slot
local pid = self.RunPotions[slot]
local p = self.Potions[pid]
if p ~= nil then
self:SetText("/ui/RunUIGroup/CombatHud/PotionMenu/Title", p.name .. " — " .. p.desc)
end
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PotionMenu", true)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('ClosePotionMenu', `self.PotionMenuSlot = 0
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PotionMenu", false)`),
method('UsePotion', `if self.PotionMenuSlot <= 0 then
return
end
if self.CombatOver == true or self.TurnBusy == true or self.FxBusy == true then
self:Toast("지금은 사용할 수 없습니다")
return
end
local combat = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud")
local hand = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand")
if combat == nil or combat.Enable ~= true or hand == nil or hand.Enable ~= 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, false)
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:Toast("물약 사용: " .. p.name)
self:ClosePotionMenu()
self:RenderPotions()
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()`),
method('TossPotion', `if self.PotionMenuSlot <= 0 then
return
end
local pid = self.RunPotions[self.PotionMenuSlot]
if pid ~= nil then
local p = self.Potions[pid]
table.remove(self.RunPotions, self.PotionMenuSlot)
if p ~= nil then
self:Toast("물약 버림: " .. p.name)
end
end
self:ClosePotionMenu()
self:RenderPotions()`),
method('RenderRelics', `local count = 0
if self.RunRelics ~= nil then
count = #self.RunRelics
end
for i = 1, 10 do
local base = "/ui/RunUIGroup/CombatHud/TopBar/RelicSlot" .. tostring(i)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
local rid = nil
if self.RunRelics ~= nil then
rid = self.RunRelics[i]
end
if rid ~= nil and self.Relics[rid] ~= nil and (i < 10 or count <= 10) then
e.SpriteGUIRendererComponent.ImageRUID = self.Relics[rid].icon
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
else
e.SpriteGUIRendererComponent.ImageRUID = ""
e.SpriteGUIRendererComponent.Color = Color(0.15, 0.16, 0.2, 0.6)
end
end
end
local of = ""
if count > 10 then
of = "+" .. tostring(count - 9)
end
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/RelicOverflow", of)`),
];

79
tools/deck/cb/jobs.mjs Normal file
View File

@@ -0,0 +1,79 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const jobMethods = [
method('ShowJobChoice', `self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", true)`),
method('PickJobReward', `self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", false)
if kind == "relic" then
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
self:ContinueAfterBoss()
else
self:ShowJobSelect()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
method('ShowJobSelect', `local opts = self.Jobs[self.SelectedClass]
if opts == nil then
opts = self.Jobs["warrior"]
end
self.JobOpts = opts
for i = 1, 3 do
local base = "/ui/SelectUIGroup/JobSelectHud/Job_slot" .. tostring(i)
local o = opts[i]
if o ~= nil then
self:SetEntityEnabled(base, true)
self:SetText(base .. "/Name", o.name)
self:SetText(base .. "/Desc", o.desc)
local sc = self.Cards[o.starter]
if sc ~= nil then
self:SetText(base .. "/Starter", "대표 카드: " .. sc.name)
end
else
self:SetEntityEnabled(base, false)
end
end
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", true)`),
method('JobLabel', `if self.PlayerJob ~= "" and self.Jobs ~= nil then
for cls, list in pairs(self.Jobs) do
for i = 1, #list do
if list[i].id == self.PlayerJob then
return list[i].name
end
end
end
end
if self.SelectedClass == "warrior" then
return "전사"
elseif self.SelectedClass == "bandit" then
return "도적"
elseif self.SelectedClass == "magician" then
return "마법사"
end
return "플레이어"`, [], 0, 'string'),
method('SetJob', `self.PlayerJob = jobId
local starter = ""
local opts = self.Jobs[self.SelectedClass] or {}
for i = 1, #opts do
if opts[i].id == jobId then
starter = opts[i].starter
end
end
if starter ~= "" then
table.insert(self.RunDeck, starter)
local sc = self.Cards[starter]
if sc ~= nil then
self:Toast("2차 전직: " .. self:JobLabel() .. "! 신규 카드 — " .. sc.name)
end
end
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", false)
self:ContinueAfterBoss()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'jobId' }]),
];

230
tools/deck/cb/map.mjs Normal file
View File

@@ -0,0 +1,230 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const mapMethods = [
method('ShowMap', `self:ShowState("map")
self:RenderMap()`),
method('GenerateMap', `-- 절차 생성 — tools/map/rogue-map.mjs(JS 미러)와 로직 동기화 유지
self.MapNodes = {}
self.MapStart = {}
self.VisitedNodes = {}
self.Depth = 0
self.MapNodes["boss"] = { type = "boss", row = ${MAP_ROWS} + 1, col = 0, next = {} }
local cols = { 1, 2, 3, 4 }
for i = #cols, 2, -1 do
local j = math.random(1, i)
cols[i], cols[j] = cols[j], cols[i]
end
local starts = { cols[1], cols[2], math.random(1, ${MAP_COLS}), math.random(1, ${MAP_COLS}) }
for p = 1, 4 do
local c = starts[p]
local sid = "r1c" .. tostring(c)
if self.MapNodes[sid] == nil then
self.MapNodes[sid] = { type = "combat", row = 1, col = c, next = {} }
end
local found = false
for i = 1, #self.MapStart do
if self.MapStart[i] == sid then found = true end
end
if found == false then
table.insert(self.MapStart, sid)
end
for r = 1, ${MAP_ROWS} - 1 do
local nc = c + math.random(-1, 1)
if nc < 1 then nc = 1 end
if nc > ${MAP_COLS} then nc = ${MAP_COLS} end
local nid = "r" .. tostring(r + 1) .. "c" .. tostring(nc)
if self.MapNodes[nid] == nil then
self.MapNodes[nid] = { type = "combat", row = r + 1, col = nc, next = {} }
end
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
local dup = false
for i = 1, #self.MapNodes[fid].next do
if self.MapNodes[fid].next[i] == nid then dup = true end
end
if dup == false then
table.insert(self.MapNodes[fid].next, nid)
end
c = nc
end
local lid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
local bdup = false
for i = 1, #self.MapNodes[lid].next do
if self.MapNodes[lid].next[i] == "boss" then bdup = true end
end
if bdup == false then
table.insert(self.MapNodes[lid].next, "boss")
end
end
for r = 3, ${MAP_ROWS} do
for c = 1, ${MAP_COLS} do
local id = "r" .. tostring(r) .. "c" .. tostring(c)
local node = self.MapNodes[id]
if node ~= nil then
-- 부모 노드 타입 수집 (rest/shop/elite 는 부모와 같은 타입 연속 금지)
local parentTypes = {}
for pid, pn in pairs(self.MapNodes) do
if pn.row == r - 1 then
for i = 1, #pn.next do
if pn.next[i] == id then parentTypes[pn.type] = true end
end
end
end
local w
if r == ${MAP_ROWS} then
w = { { "rest", 50 }, { "combat", 25 }, { "shop", 10 }, { "elite", 8 }, { "treasure", 7 } }
elseif r >= 4 then
w = { { "combat", 45 }, { "elite", 16 }, { "shop", 12 }, { "rest", 12 }, { "treasure", 15 } }
else
w = { { "combat", 45 }, { "shop", 12 }, { "rest", 12 } }
end
local total = 0
for i = 1, #w do
local t = w[i][1]
if (t == "elite" or t == "rest" or t == "shop") and parentTypes[t] == true then
w[i][2] = 0
end
total = total + w[i][2]
end
local roll = math.random() * total
local acc = 0
for i = 1, #w do
acc = acc + w[i][2]
if roll <= acc then
node.type = w[i][1]
break
end
end
end
end
end`),
method('IsReachable', `local list
if self.CurrentNodeId == "" then
list = self.MapStart
else
local node = self.MapNodes[self.CurrentNodeId]
if node == nil then
return false
end
list = node.next
end
for i = 1, #list do
if list[i] == id then
return true
end
end
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
method('RenderMapNode', `local base = "/ui/RunUIGroup/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.68, 0.68, 0.72, 0.85)
end
end
if (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
e.ButtonComponent.Enable = reachable
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
method('RenderMapDots', `local node = self.MapNodes[fromId]
local has = false
if node ~= nil then
for i = 1, #node.next do
if node.next[i] == toId then has = true end
end
end
for k = 1, 3 do
local d = _EntityService:GetEntityByPath("/ui/RunUIGroup/MapHud/Dot_" .. dotId .. "_" .. tostring(k))
if d ~= nil then
d.Enable = has
if has == true and d.SpriteGUIRendererComponent ~= nil then
if fromId == self.CurrentNodeId then
d.SpriteGUIRendererComponent.Color = Color(0.95, 0.8, 0.3, 1)
else
d.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.8)
end
end
end
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'dotId' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromId' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toId' },
]),
method('RenderMap', `for r = 1, ${MAP_ROWS} do
for c = 1, ${MAP_COLS} do
self:RenderMapNode("r" .. tostring(r) .. "c" .. tostring(c))
end
end
self:RenderMapNode("boss")
for r = 1, ${MAP_ROWS} - 1 do
for c = 1, ${MAP_COLS} do
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
for c2 = c - 1, c + 1 do
if c2 >= 1 and c2 <= ${MAP_COLS} then
self:RenderMapDots(fid .. "_" .. tostring(c2), fid, "r" .. tostring(r + 1) .. "c" .. tostring(c2))
end
end
end
end
for c = 1, ${MAP_COLS} do
local fid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
self:RenderMapDots(fid .. "_b", fid, "boss")
end
`),
method('PickNode', `if self.RunActive ~= true then
return
end
if self:IsReachable(id) ~= true then
return
end
self.CurrentNodeId = id
if self.VisitedNodes == nil then
self.VisitedNodes = {}
end
table.insert(self.VisitedNodes, id)
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/MapHud")
if hud ~= nil then
hud.Enable = false
end
local node = self.MapNodes[id]
self.Depth = node.row
self:RenderRun()
if node.type == "shop" then
self:ShowShop()
elseif node.type == "rest" then
self:ShowRest()
elseif node.type == "treasure" then
self:ShowTreasure()
else
self.CurrentEnemyId = ""
self:StartCombat()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
];

307
tools/deck/cb/render.mjs Normal file
View File

@@ -0,0 +1,307 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const renderMethods = [
method('BuffsLabel', `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
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
return table.concat(parts, " ")`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
], 0, 'string'),
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
self:SetEntityEnabled(base, true)
self:SetText(base .. "/Name", m.name)
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
local intent = m.intents[m.intentIdx]
local t = ""
if intent ~= nil then
if intent.kind == "Attack" then
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
elseif intent.kind == "AddCard" then
t = "저주 카드 추가"
end
end
self:SetText(base .. "/Intent", t)
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
local shownTarget = self.TargetIndex
if dragActive == true then shownTarget = self.DragTargetIndex end
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
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)
elseif intent.kind == "Debuff" then
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
elseif intent.kind == "AddCard" then
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
else
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
end
end
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
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, m.poison or 0))
else
self:SetEntityEnabled(base, false)
end
end
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
self:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
if pb ~= "" then pb = pb .. " " end
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
end
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
if pb ~= "" then pb = pb .. " " end
pb = pb .. "가시" .. tostring(self.PlayerThorns)
end
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/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
self:RenderRun()`),
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
local pop = _EntityService:GetEntityByPath(base)
if pop == nil then
return
end
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
local popSeq = self.DmgPopSeq
self:SetText(base, "")
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
local shown = tostring(math.max(0, math.floor(amount)))
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
end
local digits = {}
for i = 1, string.len(shown) do
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
end
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
end
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
local digitPath = base .. "/Digit" .. tostring(i)
local digitEntity = _EntityService:GetEntityByPath(digitPath)
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
if digits[i] ~= nil then
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
if digitEntity.UITransformComponent ~= nil then
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
end
self:SetEntityEnabled(digitPath, true)
else
self:SetEntityEnabled(digitPath, false)
end
end
end
local popPos = nil
local m = self.Monsters[slot]
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
local wp = m.entity.TransformComponent.WorldPosition
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
popPos = _UILogic:ScreenToUIPosition(screen)
else
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
local sp = slotEntity.UITransformComponent.anchoredPosition
popPos = Vector2(sp.x, sp.y + 76)
end
end
if pop ~= nil and pop.UITransformComponent ~= nil then
if popPos ~= nil then
pop.UITransformComponent.anchoredPosition = popPos
else
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
end
end
self:SetEntityEnabled(base, true)
for i = 1, 6 do
_TimerService:SetTimerOnce(function()
if self.DmgPopSeq ~= popSeq then
return
end
local p = _EntityService:GetEntityByPath(base)
if p ~= nil and p.UITransformComponent ~= nil then
local cur = p.UITransformComponent.anchoredPosition
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
end
end, 0.045 * i)
end
_TimerService:SetTimerOnce(function()
if self.DmgPopSeq ~= popSeq then
return
end
self:SetEntityEnabled(base, false)
end, 0.48)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/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' }]),
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
if lp == nil then
return
end
if lp.StateComponent == nil then
return
end
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
_TimerService:SetTimerOnce(function()
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
end
end, 0.5)`),
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
if lp == nil then
return
end
if lp.StateComponent ~= nil then
pcall(function() lp.StateComponent:ChangeState("HIT") end)
end
local tr = lp.TransformComponent
if tr == nil then
return
end
local p = tr.Position
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
_TimerService:SetTimerOnce(function()
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
end
end, 0.15)`),
method('MonsterLunge', `local m = self.Monsters[idx]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
return
end
if m.motionBusy == true then
return
end
m.motionBusy = true
local e = m.entity
local tr = e.TransformComponent
if tr == nil then
m.motionBusy = false
return
end
local p = tr.Position
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
_TimerService:SetTimerOnce(function()
if isvalid(e) and e.TransformComponent ~= nil then
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
end
m.motionBusy = false
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
method('MonsterHitMotion', `local m = self.Monsters[slot]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
return
end
local e = m.entity
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
e.SpriteRendererComponent.SpriteRUID = m.hitClip
_TimerService:SetTimerOnce(function()
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
e.SpriteRendererComponent.SpriteRUID = m.standClip
end
end, 0.5)
else
if m.motionBusy == true then
return
end
m.motionBusy = true
local tr = e.TransformComponent
if tr == nil then
m.motionBusy = false
return
end
local p = tr.Position
local seq = { 0.12, -0.12, 0 }
for i = 1, #seq do
local dx = seq[i]
_TimerService:SetTimerOnce(function()
if isvalid(e) and e.TransformComponent ~= nil then
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
end
if i == #seq then
m.motionBusy = false
end
end, 0.06 * i)
end
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
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' },
]),
method('PositionMonsterSlot', `local m = self.Monsters[slot]
if m == nil or m.entity == nil or not isvalid(m.entity) then
return
end
local tr = m.entity.TransformComponent
if tr == nil then
return
end
local wp = tr.WorldPosition
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y}))
local uipos = _UILogic:ScreenToUIPosition(screen)
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.anchoredPosition = uipos
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
self.TargetIndex = slot
self:RenderCombat()
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
if self.AscensionLevel > 0 then
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
end
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
];

55
tools/deck/cb/reward.mjs Normal file
View File

@@ -0,0 +1,55 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const rewardMethods = [
method('CardPool', `local pool = {}
for id, c in pairs(self.Cards) do
if c.token ~= true and (c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob)) then
table.insert(pool, id)
end
end
table.sort(pool)
return pool`, [], 0, 'any'),
method('OfferReward', `self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
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
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
if hud ~= nil then
hud.Enable = true
end`),
method('ApplyRewardVisual', `self:ApplyCardFace("/ui/RunUIGroup/RewardHud/Reward" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return
end
if slot ~= 0 and self.RewardChoices ~= nil then
local id = self.RewardChoices[slot]
if id ~= nil then
table.insert(self.RunDeck, id)
end
end
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
if hud ~= nil then
hud.Enable = false
end
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
];

239
tools/deck/cb/run.mjs Normal file
View File

@@ -0,0 +1,239 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const runMethods = [
method('StartRun', `if self.SelectedClass == "magician" then
self.PlayerMaxHp = ${CLASSES.magician.maxHp}
self.RunDeck = { ${CARDS.starterDecks.magician.map(luaStr).join(', ')} }
elseif self.SelectedClass == "bandit" then
self.PlayerMaxHp = ${CLASSES.bandit.maxHp}
self.RunDeck = { ${CARDS.starterDecks.bandit.map(luaStr).join(', ')} }
else
self.PlayerMaxHp = ${CLASSES.warrior.maxHp}
self.RunDeck = { ${CARDS.starterDecks.warrior.map(luaStr).join(', ')} }
end
self.PlayerMaxHp = self.PlayerMaxHp - self:AscStartHpPenalty()
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 1
self.RunLength = ${ACT_COUNT}
self.RunActive = true
self.RunRelics = {}
self.RunPotions = {}
self.PotionSlots = ${POTIONS.baseSlots}
${luaPotionsTable(POTIONS.potions)}
${luaRelicsTable(RELICS.relics)}
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
${luaEnemiesTable(ENEMIES.enemies)}
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self.PlayerJob = ""
${luaJobsTable(JOBS)}
${luaFramesTable()}
${luaNodeIconsTable()}
${luaCharsTable()}
self:GenerateMap()
self:BindButtons()
self:AddRelic("${RELICS.startingRelic}")
self:ApplySoulUnlocks()
self:RenderPotions()
self:TeleportToActMap()
self:ShowMap()`),
method('KickCombatCamera', `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.ConfineCameraArea = false end
_TimerService:SetTimerOnce(function()
local cc = nil
local lp2 = _UserService.LocalPlayer
if lp2 ~= nil then cc = lp2.CameraComponent end
if cc == nil then cc = _CameraService:GetCurrentCameraComponent() end
if cc ~= nil then
cc.ZoomRatio = ${CAM.zoomRatio}
cc.CameraOffset = Vector2(${CAM.cameraOffsetX}, ${CAM.cameraOffsetY})
cc.ScreenOffset = Vector2(${CAM.screenOffsetX}, ${CAM.screenOffsetY})
cc.ConfineCameraArea = true
end
end, 0.2)`),
method('StartCombat', `self:ShowState("combat")
self:KickCombatCamera()
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/Result", false)
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PotionMenu", false)
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/TooltipBox", false)
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/DiscardPrompt", false)
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self.MaxEnergy = 3
self.Turn = 0
self.PlayerBlock = 0
self.BlockGainMultiplier = 1
self.CardsDrawnThisCombat = 0
self.HandCostZeroThisTurn = false
self.DrawDisabledThisTurn = false
self.NextSkillCostZero = false
self.SkillCostReductionThisTurn = 0
self.PlayerStr = 0
self.PlayerDex = 0
self.PlayerThorns = 0
self.PlayerWeak = 0
self.PlayerVuln = 0
self.PlayerPowers = {}
self.FightAttackCount = 0
self.TurnAttackCardsPlayed = 0
self.TurnDiscardedCards = 0
self.DmgPopSeq = 0
self.FirstHpLossDone = false
self.ClayBlockNext = 0
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self.RetainSelectActive = false
self.ReserveSelectActive = false
self.NextTurnBlock = 0
self.NextTurnDraw = 0
self.NextTurnKeepBlock = false
self.NextTurnAttackMultiplier = 1
self.TurnAttackMultiplier = 1
self.NextTurnSelectPrompt = ""
self.NextTurnSelectCopies = 0
self.NextTurnAddCards = {}
self.CombatOver = false
self.DiscardPile = {}
self.ExhaustPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:PrepareCombatDrawPile()
self:BuildMonsters()
self:RenderCombat()
self:StartPlayerTurn()
self:ApplyRelics("combatStart")
self:RenderCombat()
local slotTid = 0
slotTid = _TimerService:SetTimerRepeat(function()
if self.CombatOver == true or self.Monsters == nil or #self.Monsters == 0 then
_TimerService:ClearTimer(slotTid)
return
end
for i = 1, #self.Monsters do
if self.Monsters[i] ~= nil and self.Monsters[i].alive == true then
self:PositionMonsterSlot(i)
end
end
end, 0.15)`),
method('RegisterMonster', `if self.Registered == nil then
self.Registered = {}
end
local g = group
if g == nil or g == "" then g = "combat" end
local mp = mapName
if mp == nil then mp = "" end
table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g, map = mp })`, [
{ 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' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'mapName' },
]),
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
local pmap = ""
local lp = _UserService.LocalPlayer
if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end
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
local byGroup = {}
for i = 1, #reg do
local r = reg[i]
if r.entity ~= nil and isvalid(r.entity) and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
local gg = r.group
if gg == nil or gg == "" then gg = "combat" end
if byGroup[gg] == nil then byGroup[gg] = {} end
local x = 0
if r.entity.TransformComponent ~= nil then
x = r.entity.TransformComponent.WorldPosition.x
end
table.insert(byGroup[gg], { entity = r.entity, enemyId = r.enemyId, x = x })
end
end
-- 노드 타입별 랜덤 구성: 일반 1~3 / 엘리트 1+일반0~2 / 보스 1
local chosen = {}
local function takeFrom(key, k)
local src = byGroup[key] or {}
local pool = {}
for i = 1, #src do pool[i] = src[i] end
self:Shuffle(pool)
local taken = 0
for i = 1, #pool do
if taken >= k then break end
table.insert(chosen, pool[i])
taken = taken + 1
end
end
if g == "boss" then
takeFrom("boss", 1)
elseif g == "elite" then
takeFrom("elite", 1)
takeFrom("combat", math.random(0, 2))
else
takeFrom("combat", math.random(1, 3))
end
if #chosen == 0 then takeFrom(g, 1) end
if #chosen == 0 then takeFrom("combat", 1) end
table.sort(chosen, function(a, b) return a.x < b.x end)
local mult = 1 + (self.Floor - 1) * 0.45
if g == "elite" or g == "boss" then
mult = mult + self:AscEliteBonus()
end
local n = #chosen
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
for i = 1, n do
local item = chosen[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
local v = e.intents[k].value or 0
if e.intents[k].kind == "Attack" then
v = math.floor(v * mult * self:AscAtkMult())
elseif e.intents[k].kind ~= "Debuff" then
v = math.floor(v * mult)
end
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect, card = e.intents[k].card, count = e.intents[k].count }
end
local maxHp = math.floor(e.maxHp * mult * self:AscHpMult())
local hitClip = nil
local standClip = nil
if item.entity.StateAnimationComponent ~= nil then
pcall(function()
hitClip = item.entity.StateAnimationComponent.ActionSheet["hit"]
standClip = item.entity.StateAnimationComponent.ActionSheet["stand"]
end)
end
local startIdx = 1
if #intents > 0 then startIdx = math.random(1, #intents) end
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0,
hitClip = hitClip, standClip = standClip, motionBusy = false,
intents = intents, intentIdx = startIdx, alive = true, slot = i }
self:ReviveMonsterEntity(item.entity)
self:PositionMonsterSlot(i)
end
self.TargetIndex = 1`),
method('ReviveMonsterEntity', `if monster == nil or not isvalid(monster) then
return
end
monster:SetEnable(true)
monster:SetVisible(true)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }]),
];

37
tools/deck/cb/runend.mjs Normal file
View File

@@ -0,0 +1,37 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const runEndMethods = [
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)`),
method('ShowResult', `self:SetText("/ui/RunUIGroup/CombatHud/Result", text)
local entity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/Result")
if entity ~= nil then
entity.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
method('EndRun', `local msg = text
if text == "런 클리어!" and self.AscensionLevel >= self.AscensionUnlocked and self.AscensionUnlocked < 10 then
self.AscensionUnlocked = self.AscensionUnlocked + 1
local lp = _UserService.LocalPlayer
if lp ~= nil then
self:SaveAscension(self.AscensionUnlocked, lp.PlayerComponent.UserId)
end
self:RenderAscension()
msg = "런 클리어! 승천 " .. string.format("%d", self.AscensionUnlocked) .. " 해금!"
end
self:ShowResult(msg)
self.RunActive = false
_TimerService:SetTimerOnce(function() self:ShowLobby() end, 4)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
];

173
tools/deck/cb/shop.mjs Normal file
View File

@@ -0,0 +1,173 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const shopMethods = [
method('ShowShop', `local pool = self:CardPool()
self.ShopChoices = {}
self.ShopBought = { false, false, false }
for i = 1, 3 do
self.ShopChoices[i] = pool[math.random(1, #pool)]
end
self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]
self.ShopRelicBought = false
local pkeys = {}
for pid, _ in pairs(self.Potions) do
table.insert(pkeys, pid)
end
table.sort(pkeys)
self.ShopPotion = pkeys[math.random(1, #pkeys)]
self.ShopPotionBought = false
self:RenderShop()
self:ShowState("shop")`),
method('RenderShop', `self:SetText("/ui/RunUIGroup/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/RunUIGroup/ShopHud/Card" .. tostring(i)
if c ~= nil then
self:ApplyCardFace(base, cid)
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)
end
end
end
end
local rr = self.Relics[self.ShopRelic]
if rr ~= nil then
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc)
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 메소")
local re = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Relic")
if re ~= nil and re.SpriteGUIRendererComponent ~= nil then
if self.ShopRelicBought == true then
re.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
else
re.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)
end
end
end
local pp = self.Potions[self.ShopPotion]
if pp ~= nil then
self:SetText("/ui/RunUIGroup/ShopHud/Potion/Label", pp.name .. " — " .. pp.desc)
self:SetText("/ui/RunUIGroup/ShopHud/Potion/Price", string.format("%d", ${POTIONS.shopPrice}) .. " 메소")
local pe = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Potion")
if pe ~= nil and pe.SpriteGUIRendererComponent ~= nil then
if self.ShopPotionBought == true then
pe.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
else
pe.SpriteGUIRendererComponent.Color = Color(0.45, 0.7, 0.55, 1)
end
end
end`),
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()`),
method('BuyPotion', `if self.ShopPotionBought == true then
return
end
if self.Gold < ${POTIONS.shopPrice} then
return
end
if self.RunPotions ~= nil and #self.RunPotions >= self.PotionSlots then
self:Toast("물약 슬롯이 가득 찼습니다")
return
end
if self:AddPotion(self.ShopPotion) == true then
self.Gold = self.Gold - ${POTIONS.shopPrice}
self.ShopPotionBought = true
end
self:RenderShop()
self:RenderRun()`),
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/RunUIGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")")
self:RenderCombat()
self:ShowState("rest")`),
method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud")
if s ~= nil then
s.Enable = false
end
local r = _EntityService:GetEntityByPath("/ui/RunUIGroup/RestHud")
if r ~= nil then
r.Enable = false
end
local t = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud")
if t ~= nil then
t.Enable = false
end
self:ShowMap()`),
method('ShowTreasure', `self.ChestOpened = false
local chest = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Chest")
if chest ~= nil then
if chest.SpriteGUIRendererComponent ~= nil then
chest.SpriteGUIRendererComponent.ImageRUID = "${CHEST_CLOSED_RUID}"
end
if chest.UITransformComponent ~= nil then
chest.UITransformComponent.anchoredPosition = Vector2(0, 40)
end
end
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Reward", false)
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Hint", true)
self:ShowState("treasure")`),
method('OpenChest', `if self.ChestOpened == true then
return
end
self.ChestOpened = true
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Hint", false)
local chest = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Chest")
local steps = { 10, -10, 8, -8, 5, 0 }
for i = 1, #steps do
local dx = steps[i]
_TimerService:SetTimerOnce(function()
if chest ~= nil and isvalid(chest) and chest.UITransformComponent ~= nil then
chest.UITransformComponent.anchoredPosition = Vector2(dx, 40)
end
end, 0.08 * i)
end
_TimerService:SetTimerOnce(function()
if chest ~= nil and isvalid(chest) and chest.SpriteGUIRendererComponent ~= nil then
chest.SpriteGUIRendererComponent.ImageRUID = "${CHEST_OPEN_RUID}"
end
local g = 40 + math.random(0, 20)
local nid = self:PickNewRelic()
local msg = ""
if nid ~= "" then
self:AddRelic(nid)
local nr = self.Relics[nid]
msg = "유물 획득: " .. nr.name .. " · 메소 +" .. tostring(g)
else
g = g + 30
msg = "메소 +" .. tostring(g)
end
self.Gold = self.Gold + g
self:RenderRun()
self:SetText("/ui/RunUIGroup/TreasureHud/Reward", msg)
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Reward", true)
end, 0.55)`),
];

114
tools/deck/cb/soul.mjs Normal file
View File

@@ -0,0 +1,114 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const soulMethods = [
method('ShowSoulShop', `self:RenderSoulLabel()
self:RenderSoulShop()
self:BindSoulShopButtons()
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", true)`),
method('CloseSoulShop', `self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
method('ReqLoadSouls', `local ds = _DataStorageService:GetUserDataStorage(userId)
local e1, pts = ds:GetAndWait("soulPoints")
local e2, unl = ds:GetAndWait("soulUnlocks")
local p = 0
if e1 == 0 and pts ~= nil and pts ~= "" then p = tonumber(pts) or 0 end
local u = ""
if e2 == 0 and unl ~= nil then u = unl end
self:RecvSouls(p, u, userId)`, [{ Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "userId" }], 5),
method('RecvSouls', `self.SoulPoints = p
self.SoulUnlocks = {}
if u ~= nil and u ~= "" then
for key in string.gmatch(u, "([^,]+)") do
self.SoulUnlocks[key] = true
end
end
self:RenderSoulLabel()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "p" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "u" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "userId" }], 6),
method('SaveSouls', `local ds = _DataStorageService:GetUserDataStorage(userId)
ds:SetAndWait("soulPoints", tostring(p))
ds:SetAndWait("soulUnlocks", u)`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "p" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "u" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "userId" }], 5),
method('SerializeUnlocks', `local parts = {}
if self.SoulUnlocks ~= nil then
for k, v in pairs(self.SoulUnlocks) do
if v == true then table.insert(parts, k) end
end
end
return table.concat(parts, ",")`, [], 0, 'string'),
method('AwardSouls', `self.SoulPoints = (self.SoulPoints or 0) + n
local lp = _UserService.LocalPlayer
if lp ~= nil then
self:SaveSouls(self.SoulPoints, self:SerializeUnlocks(), lp.PlayerComponent.UserId)
end
self:RenderSoulLabel()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "n" }]),
method('BuySoulUnlock', `local d = nil
if self.SoulShopDef ~= nil then d = self.SoulShopDef[slot] end
if d == nil then return end
if self.SoulUnlocks ~= nil and self.SoulUnlocks[d.key] == true then
self:Toast("이미 보유 중입니다")
return
end
if (self.SoulPoints or 0) < d.cost then
self:Toast("영혼이 부족합니다")
return
end
self.SoulPoints = self.SoulPoints - d.cost
if self.SoulUnlocks == nil then self.SoulUnlocks = {} end
self.SoulUnlocks[d.key] = true
local lp = _UserService.LocalPlayer
if lp ~= nil then
self:SaveSouls(self.SoulPoints, self:SerializeUnlocks(), lp.PlayerComponent.UserId)
end
self:Toast(d.name .. " 해금!")
self:RenderSoulLabel()
self:RenderSoulShop()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "slot" }]),
method('RenderSoulShop', `local defs = self.SoulShopDef or {}
for i = 1, 4 do
local base = "/ui/LobbyUIGroup/SoulShopHud/Item" .. tostring(i)
local d = defs[i]
if d == nil then
self:SetEntityEnabled(base, false)
else
self:SetEntityEnabled(base, true)
self:SetText(base .. "/Name", d.name)
self:SetText(base .. "/Desc", d.desc)
local owned = self.SoulUnlocks ~= nil and self.SoulUnlocks[d.key] == true
if owned then
self:SetText(base .. "/Status", "보유 중")
elseif (self.SoulPoints or 0) >= d.cost then
self:SetText(base .. "/Status", tostring(d.cost) .. " 영혼 · 구매")
else
self:SetText(base .. "/Status", tostring(d.cost) .. " 영혼 · 부족")
end
end
end`),
method('BindSoulShopButtons', `if self.SoulShopBound == true then
return
end
self.SoulShopBound = true
for i = 1, 4 do
local idx = i
local e = _EntityService:GetEntityByPath("/ui/LobbyUIGroup/SoulShopHud/Item" .. tostring(i))
if e ~= nil and (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
e:ConnectEvent(ButtonClickEvent, function() self:BuySoulUnlock(idx) end)
end
end`),
method('ApplySoulUnlocks', `if self.SoulUnlocks == nil then return end
if self.SoulUnlocks["meso"] == true then self.Gold = self.Gold + 60 end
if self.SoulUnlocks["hp"] == true then
self.PlayerMaxHp = self.PlayerMaxHp + 15
self.PlayerHp = self.PlayerMaxHp
end
if self.SoulUnlocks["trim"] == true then
for i = 1, #self.RunDeck do
local cid = self.RunDeck[i]
if cid == "Defend" or cid == "MagicGuard" or cid == "DarkSight" then
table.remove(self.RunDeck, i)
break
end
end
end
if self.SoulUnlocks["relic"] == true then
local nid = self:PickNewRelic()
if nid ~= "" then self:AddRelic(nid) end
end`),
];

201
tools/deck/cb/state.mjs Normal file
View File

@@ -0,0 +1,201 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const stateMethods = [
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
self:SetEntityEnabled("/ui/DefaultGroup/Button_Jump", false)
self:SetEntityEnabled("/ui/DefaultGroup/UIJoystick", false)
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud", false)
self:SetEntityEnabled("/ui/RunUIGroup/RewardHud", false)
self:SetEntityEnabled("/ui/RunUIGroup/MapHud", false)
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud", false)
self:SetEntityEnabled("/ui/RunUIGroup/RestHud", false)
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud", false)
self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", false)
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", false)
self:SetEntityEnabled("/ui/DeckUIGroup/DeckInspectHud", false)
self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud", false)
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
method('ActivateUIGroups', `local function grp(n)
local g = _EntityService:GetEntityByPath("/ui/" .. n)
if g ~= nil then g:SetEnable(true) end
end
grp("SelectUIGroup")
grp("LobbyUIGroup")
grp("RunUIGroup")
grp("DeckUIGroup")`, [], 2),
method('ShowState', `self:HideGameHud()
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu")
self:SetEntityEnabled("/ui/SelectUIGroup/CharacterSelectHud", state == "charselect")
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", state == "lobby")
if state == "map" then
self:SetEntityEnabled("/ui/RunUIGroup/MapHud", true)
elseif state == "combat" then
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud", true)
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", true)
self:SetEntityEnabled("/ui/RunUIGroup/CardHand", true)
elseif state == "shop" then
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud", true)
elseif state == "rest" then
self:SetEntityEnabled("/ui/RunUIGroup/RestHud", true)
elseif state == "treasure" then
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud", true)
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'state' }]),
method('ShowMainMenu', `self.SelectedClass = ""
self:RenderAscension()
self:ShowState("menu")
self:SetText("/ui/DefaultGroup/MainMenu/Title", "메이플 덱 어드벤처")
self:SetText("/ui/DefaultGroup/MainMenu/Subtitle", "캐릭터를 고르고 덱을 만들어 모험을 시작하세요")
self:SetText("/ui/DefaultGroup/MainMenu/NewGameButton", "새 게임")
self:BindMenuButtons()`),
method('BindMenuButtons', `local buttonEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/NewGameButton")
if buttonEntity ~= nil and (buttonEntity.ButtonComponent ~= nil or buttonEntity:AddComponent("ButtonComponent") ~= nil) then
if self.NewGameHandler ~= nil then
buttonEntity:DisconnectEvent(ButtonClickEvent, self.NewGameHandler)
self.NewGameHandler = nil
end
self.NewGameHandler = buttonEntity:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end)
end
local warrior = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/WarriorButton")
if warrior ~= nil and (warrior.ButtonComponent ~= nil or warrior:AddComponent("ButtonComponent") ~= nil) then
if self.WarriorSelectHandler ~= nil then
warrior:DisconnectEvent(ButtonClickEvent, self.WarriorSelectHandler)
self.WarriorSelectHandler = nil
end
self.WarriorSelectHandler = warrior:ConnectEvent(ButtonClickEvent, function() self:SelectClass("warrior") end)
end
local thief = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/BanditButton")
if thief ~= nil and (thief.ButtonComponent ~= nil or thief:AddComponent("ButtonComponent") ~= nil) then
if self.ThiefSelectHandler ~= nil then
thief:DisconnectEvent(ButtonClickEvent, self.ThiefSelectHandler)
self.ThiefSelectHandler = nil
end
self.ThiefSelectHandler = thief:ConnectEvent(ButtonClickEvent, function() self:SelectClass("bandit") end)
end
local mage = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/MageButton")
if mage ~= nil and (mage.ButtonComponent ~= nil or mage:AddComponent("ButtonComponent") ~= nil) then
if self.MageSelectHandler ~= nil then
mage:DisconnectEvent(ButtonClickEvent, self.MageSelectHandler)
self.MageSelectHandler = nil
end
self.MageSelectHandler = mage:ConnectEvent(ButtonClickEvent, function() self:SelectClass("magician") end)
end
local allDeckClose = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
if allDeckClose ~= nil and (allDeckClose.ButtonComponent ~= nil or allDeckClose:AddComponent("ButtonComponent") ~= nil) then
if self.AllDeckCloseHandler ~= nil then
allDeckClose:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
self.AllDeckCloseHandler = nil
end
self.AllDeckCloseHandler = allDeckClose:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
end
self:BindClassDeckTabs()
local start = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/StartButton")
if start ~= nil and (start.ButtonComponent ~= nil or start:AddComponent("ButtonComponent") ~= nil) then
if self.StartGameHandler ~= nil then
start:DisconnectEvent(ButtonClickEvent, self.StartGameHandler)
self.StartGameHandler = nil
end
self.StartGameHandler = start:ConnectEvent(ButtonClickEvent, function() self:StartNewGame() end)
end
local charBack = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/BackButton")
if charBack ~= nil and (charBack.ButtonComponent ~= nil or charBack:AddComponent("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
local ascMinus = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/AscMinus")
if ascMinus ~= nil and (ascMinus.ButtonComponent ~= nil or ascMinus:AddComponent("ButtonComponent") ~= nil) then
if self.AscMinusHandler ~= nil then
ascMinus:DisconnectEvent(ButtonClickEvent, self.AscMinusHandler)
self.AscMinusHandler = nil
end
self.AscMinusHandler = ascMinus:ConnectEvent(ButtonClickEvent, function() self:AdjustAscension(-1) end)
end
local ascPlus = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/AscPlus")
if ascPlus ~= nil and (ascPlus.ButtonComponent ~= nil or ascPlus:AddComponent("ButtonComponent") ~= nil) then
if self.AscPlusHandler ~= nil then
ascPlus:DisconnectEvent(ButtonClickEvent, self.AscPlusHandler)
self.AscPlusHandler = nil
end
self.AscPlusHandler = ascPlus:ConnectEvent(ButtonClickEvent, function() self:AdjustAscension(1) end)
end`),
method('ShowLobby', `self.SelectedClass = ""
self:RenderAscension()
self:RenderSoulLabel()
self:ShowState("lobby")
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)
self:BindLobbyButtons()
self:BindMenuButtons()
self:GoLobbyMap()`),
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)`),
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' }]),
method('RenderSoulLabel', `local s = self.SoulPoints or 0
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "영혼 " .. string.format("%d", s))
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "영혼 " .. string.format("%d", s))`),
method('BindLobbyButtons', `if self.LobbyBound == true then
return
end
self.LobbyBound = true
local function bindClick(path, fn)
local e = _EntityService:GetEntityByPath(path)
if e ~= nil and (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
e:ConnectEvent(ButtonClickEvent, fn)
end
end
bindClick("/ui/LobbyUIGroup/LobbyHud/AscMinus", function() self:AdjustAscension(-1) end)
bindClick("/ui/LobbyUIGroup/LobbyHud/AscPlus", function() self:AdjustAscension(1) end)
bindClick("/ui/LobbyUIGroup/BoardHud/Close", function() self:CloseBoard() end)
bindClick("/ui/LobbyUIGroup/SoulShopHud/Close", function() self:CloseSoulShop() end)`),
method('ShowCodex', `self.CodexMode = true
self.ClassDeckMode = true
local close = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
if close ~= nil and (close.ButtonComponent ~= nil or close:AddComponent("ButtonComponent") ~= nil) then
if self.AllDeckCloseHandler ~= nil then
close:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
end
self.AllDeckCloseHandler = close:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
end
self:BindClassDeckTabs()
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
self:SetClassDeckTab("warrior")
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = true
end
self:RenderAllDeck()`),
method('ShowBoard', `self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", true)`),
method('CloseBoard', `self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)`),
];

156
tools/deck/cb/tooltip.mjs Normal file
View File

@@ -0,0 +1,156 @@
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 { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const tooltipMethods = [
method('FormatCardDescription', `if desc == nil or desc == "" then
return ""
end
local function replacePlain(text, needle, replacement)
local out = ""
local pos = 1
while true do
local s, e = string.find(text, needle, pos, true)
if s == nil then
out = out .. string.sub(text, pos)
break
end
out = out .. string.sub(text, pos, s - 1) .. replacement
pos = e + 1
end
return out
end
local terms = {
"교활",
"보존",
"민첩",
"가시",
"소멸",
"선천성",
"취약",
"약화",
"독",
"광역",
"관통",
"방어도",
"힘",
"스킬",
"공격",
"파워",
}
local out = desc
for i = 1, #terms do
local term = terms[i]
out = replacePlain(out, term, "<color=#70D6FF>" .. term .. "</color>")
end
return out`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' }], 0, 'string'),
method('BuildCardKeywordTooltip', `if c == nil then
return ""
end
local lines = {}
local function add(name, desc)
for i = 1, #lines do
if string.find(lines[i], name .. ":", 1, true) == 1 then
return
end
end
table.insert(lines, name .. ": " .. desc)
end
local cardDesc = c.desc or ""
if c.sly == true or string.find(cardDesc, "교활", 1, true) ~= nil then
add("교활", "버려지면 비용 없이 사용됩니다.")
end
if c.retain == true or string.find(cardDesc, "보존", 1, true) ~= nil then
add("보존", "턴 종료 시 버려지지 않고 손에 남습니다.")
end
if c.dex ~= nil and c.dex > 0 or string.find(cardDesc, "민첩", 1, true) ~= nil then
add("민첩", "카드로 얻는 방어도가 증가합니다.")
end
if c.thorns ~= nil and c.thorns > 0 or string.find(cardDesc, "가시", 1, true) ~= nil then
add("가시", "피해를 받으면 공격자에게 반사 피해를 줍니다.")
end
if c.exhaust == true or string.find(cardDesc, "소멸.", 1, true) ~= nil then
add("소멸", "사용 후 소멸 덱으로 이동해 이번 전투 동안 다시 나오지 않습니다.")
end
if string.find(cardDesc, "선천성", 1, true) ~= nil then
add("선천성", "전투 시작 시 손패에 들어옵니다.")
end
if c.vuln ~= nil and c.vuln > 0 then
add("취약", "받는 공격 피해가 50% 증가합니다.")
end
if c.weak ~= nil and c.weak > 0 then
add("약화", "주는 공격 피해가 25% 감소합니다.")
end
if c.poison ~= nil and c.poison > 0 then
add("중독", "턴 시작 시 체력을 잃고 수치가 1 감소합니다.")
end
if c.pierce == true then
add("관통", "방어도를 무시하고 피해를 줍니다.")
end
if c.aoe == true then
add("전체", "모든 적에게 적용됩니다.")
end
if c.kind == "Power" then
add("파워", "사용하면 전투 동안 지속 효과로 남습니다.")
end
if c.unplayable == true then
add("저주", "사용할 수 없고 손패를 방해합니다.")
end
local out = ""
for i = 1, #lines do
if i > 1 then out = out .. "\\n" end
out = out .. lines[i]
end
return out`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'string'),
method('HoverCard', `if self.DragSlot ~= nil and self.DragSlot > 0 then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
local tx = 0
if e ~= nil and e.UITransformComponent ~= nil then
tx = e.UITransformComponent.anchoredPosition.x
e.UITransformComponent.UIScale = Vector3(1.3, 1.3, 1)
end
local c = self.Cards[cardId]
if c ~= nil then
local tip = self:BuildCardKeywordTooltip(c)
if tip ~= "" then
local tipX = tx + 270
if tx > 180 then tipX = tx - 270 end
if tipX > 760 then tipX = tx - 270 end
if tipX < -760 then tipX = tx + 270 end
self:ShowTooltipAt("키워드", tip, tipX, 90)
else
self:HideTooltip()
end
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('UnhoverCard', `local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
end
self:HideTooltip()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('ShowTooltip', `self:ShowTooltipAt(name, desc, x, 400)`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'name' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'x' },
]),
method('ShowTooltipAt', `self:SetText("/ui/RunUIGroup/CombatHud/TooltipBox/Name", name)
self:SetText("/ui/RunUIGroup/CombatHud/TooltipBox/Desc", desc)
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TooltipBox")
if e ~= nil then
if e.UITransformComponent ~= nil then
e.UITransformComponent.anchoredPosition = Vector2(x, y)
end
e.Enable = true
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'name' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'x' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'y' },
]),
method('HideTooltip', `self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/TooltipBox", false)`),
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildBoard() {
const board = [];
let brdId = 0;
const boardRoot = entity({
id: guid('brd', brdId++),
path: '/ui/DefaultGroup/BoardHud',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 14,
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.95 }, type: 1, raycast: true }),
],
});
boardRoot.jsonString.enable = false;
board.push(boardRoot);
board.push(entity({
id: guid('brd', brdId++),
path: '/ui/DefaultGroup/BoardHud/Title',
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: 700, y: 60 }, pos: { x: 0, y: 400 } }),
sprite({ color: TRANSPARENT }),
text({ value: '게시판', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
board.push(entity({
id: guid('brd', brdId++),
path: '/ui/DefaultGroup/BoardHud/Body',
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: 1100, y: 520 }, pos: { x: 0, y: 20 } }),
sprite({ color: { r: 0.1, g: 0.12, b: 0.16, a: 0.9 }, type: 1 }),
text({ value: '· 카드는 직업/등급에 따라 보상 확률이 다릅니다.\n· 몬스터는 매 턴 정해진 행동 중 하나를 무작위로 합니다.\n· 일부 몬스터는 덱에 저주 카드(상처/화상)를 넣습니다.\n· 손패는 최대 10장, 초과분은 자동으로 버려집니다.\n· 2차 전직 후 보스를 잡으면 영혼이 쌓입니다.\n· 영혼은 상인 NPC에서 덱빌딩 해금에 사용합니다.', fontSize: 24, bold: false, color: { r: 0.86, g: 0.9, b: 0.94, a: 1 }, alignment: 0 }),
],
}));
board.push(entity({
id: guid('brd', brdId++),
path: '/ui/DefaultGroup/BoardHud/Close',
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: -380 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.2, g: 0.24, b: 0.3, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: '닫기', fontSize: 28, bold: true, color: WHITE, alignment: 4 }),
],
}));
return board;
}

View File

@@ -0,0 +1,494 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildCombat() {
const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 };
const combat = [];
combat.push(entity({
id: guid('cmb', 0),
path: '/ui/DefaultGroup/CombatHud',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: TRANSPARENT }),
],
}));
const SLOT_W = 140, SLOT_H = 96;
for (let i = 1; i <= MAX_MONSTERS; i++) {
const base = `/ui/DefaultGroup/CombatHud/MonsterSlot${i}`;
const slot = entity({
id: guid('cmb', 40 + i),
path: base,
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 20 + i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: SLOT_H }, pos: { x: (i - 2.5) * 320, y: 300 } }),
sprite({ color: { r: 0, g: 0, b: 0, a: 0.0001 }, type: 1, raycast: true }),
button(),
],
});
slot.jsonString.enable = false;
combat.push(slot);
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);
const targetMarker = entity({
id: guid('cmb', 360 + i), path: `${base}/TargetMarker`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 9,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 116, y: 116 }, pos: { x: 0, y: 2 } }),
sprite({ color: { r: 0.95, g: 0.08, b: 0.05, a: 0.92 }, type: 1 }),
text({ value: '+', fontSize: 72, bold: true, color: { r: 1, g: 0.94, b: 0.28, a: 1 }, alignment: 4, outlineWidth: 4 }),
],
});
targetMarker.jsonString.enable = false;
combat.push(targetMarker);
const targetLabel = entity({
id: guid('cmb', 370 + i), path: `${base}/TargetMarker/Label`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 10,
components: [
transform({ parentW: 116, parentH: 116, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 112, y: 28 }, pos: { x: 0, y: -52 } }),
sprite({ color: { r: 0.08, g: 0.02, b: 0.02, a: 0.86 }, type: 1 }),
text({ value: 'TARGET', fontSize: 18, bold: true, color: { r: 1, g: 0.94, b: 0.28, a: 1 }, alignment: 4, outlineWidth: 3 }),
],
});
targetLabel.jsonString.enable = false;
combat.push(targetLabel);
const actFrame = entity({
id: guid('cmb', 240 + i), path: `${base}/ActFrame`, 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.3, b: 0.25, a: 0.3 }, type: 1 }),
],
});
actFrame.jsonString.enable = false;
combat.push(actFrame);
combat.push(entity({
id: guid('cmb', 60 + i), path: `${base}/Name`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 30 }, pos: { x: 0, y: 34 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
],
}));
combat.push(entity({
id: guid('cmb', 80 + i), path: `${base}/Hp`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 26 }, pos: { x: 0, y: 6 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 20, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
combat.push(entity({
id: guid('cmb', 100 + i), path: `${base}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 3,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: HP_BAR_W, y: 14 }, pos: { x: 0, y: -14 } }),
sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }),
],
}));
combat.push(entity({
id: guid('cmb', 120 + i), path: `${base}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: HP_BAR_W, y: 14 }, pos: { x: -HP_BAR_W / 2, y: -14 } }),
sprite({ color: { r: 0.86, g: 0.35, b: 0.32, a: 1 }, type: 1 }),
],
}));
combat.push(entity({
id: guid('cmb', 140 + i), path: `${base}/Intent`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 5,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 40, y: 24 }, pos: { x: 0, y: -36 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 17, bold: true, color: { r: 1, g: 0.72, b: 0.5, a: 1 }, alignment: 4 }),
],
}));
const dmgPopBase = `/ui/DefaultGroup/CombatHud/DmgPop${i}`;
const dmgPop = entity({
id: guid('cmb', 250 + i), path: dmgPopBase, modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 80 + i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 192, y: 56 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT, type: 1 }),
],
});
dmgPop.jsonString.enable = false;
combat.push(dmgPop);
for (let d = 0; d < DAMAGE_POP_MAX_DIGITS; d++) {
combat.push(entity({
id: guid('cmb', 380 + i * 10 + d), path: `${dmgPopBase}/Digit${d + 1}`, modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 90 + i,
components: [
transform({ parentW: 192, parentH: 56, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: DAMAGE_POP_DIGIT_W, y: DAMAGE_POP_DIGIT_H }, pos: { x: 0, y: 0 } }),
sprite({ dataId: DAMAGE_DIGIT_RUIDS[0], color: { r: 1, g: 1, b: 1, a: 1 }, type: 0 }),
],
}));
}
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 }),
],
}));
}
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: -494 }, 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: 14 }, 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 }),
],
}));
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 }),
],
}));
const playerDmgPop = entity({
id: guid('cmb', 260), path: `${PP}/DmgPop`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 5,
components: [
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 30 }, pos: { x: 16, y: 40 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 22, bold: true, color: { r: 1, g: 0.4, b: 0.35, a: 1 }, alignment: 4 }),
],
});
playerDmgPop.jsonString.enable = false;
combat.push(playerDmgPop);
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 }],
];
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: 22, bold: true, color, alignment: 4 }),
],
}));
});
combat.push(entity({
id: guid('cmb', 209),
path: '/ui/DefaultGroup/CombatHud/TopBar/MesoIcon',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 2,
components: [
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 26, y: 26 }, pos: { x: -432, y: 0 } }),
sprite({ color: { r: 1, g: 0.82, b: 0.2, a: 1 }, type: 1 }),
],
}));
for (let i = 1; i <= 10; i++) {
combat.push(entity({
id: guid('cmb', 300 + i),
path: `/ui/DefaultGroup/CombatHud/TopBar/RelicSlot${i}`,
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.UITouchReceiveComponent',
displayOrder: 3 + i,
components: [
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 40 }, pos: { x: -240 + (i - 1) * 48, y: 0 } }),
sprite({ color: { r: 0.15, g: 0.16, b: 0.2, a: 0.6 }, type: 0, raycast: true }),
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
],
}));
}
combat.push(entity({
id: guid('cmb', 311),
path: '/ui/DefaultGroup/CombatHud/TopBar/RelicOverflow',
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 14,
components: [
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 60, y: 30 }, pos: { x: 192, y: 0 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 18, bold: true, color: { r: 0.8, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }),
],
}));
for (let i = 1; i <= 5; i++) {
combat.push(entity({
id: guid('cmb', 320 + i),
path: `/ui/DefaultGroup/CombatHud/TopBar/PotionSlot${i}`,
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.UITouchReceiveComponent',
displayOrder: 14 + i,
components: [
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 40 }, pos: { x: 240 + (i - 1) * 44, y: 0 } }),
sprite({ color: { r: 0.22, g: 0.25, b: 0.3, a: 0.9 }, type: 0, raycast: true }),
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
],
}));
}
const tooltipBox = entity({
id: guid('cmb', 330),
path: '/ui/DefaultGroup/CombatHud/TooltipBox',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 20,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 150 }, pos: { x: 0, y: 400 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.08, a: 0.96 }, type: 1 }),
],
});
tooltipBox.jsonString.enable = false;
combat.push(tooltipBox);
combat.push(entity({
id: guid('cmb', 331),
path: '/ui/DefaultGroup/CombatHud/TooltipBox/Name',
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 360, parentH: 150, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 332, y: 28 }, pos: { x: 0, y: 52 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 19, bold: true, color: GOLD, alignment: 4 }),
],
}));
combat.push(entity({
id: guid('cmb', 332),
path: '/ui/DefaultGroup/CombatHud/TooltipBox/Desc',
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 360, parentH: 150, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 332, y: 102 }, pos: { x: 0, y: -18 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 15, bold: false, color: { r: 0.92, g: 0.92, b: 0.95, a: 1 }, alignment: 0 }),
],
}));
const discardPrompt = entity({
id: guid('cmb', 333),
path: '/ui/DefaultGroup/CombatHud/DiscardPrompt',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,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: 520, y: 48 }, pos: { x: 0, y: -260 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.86 }, type: 1 }),
text({ value: '', fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
],
});
discardPrompt.jsonString.enable = false;
combat.push(discardPrompt);
const potionMenu = entity({
id: guid('cmb', 340),
path: '/ui/DefaultGroup/CombatHud/PotionMenu',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 21,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 180 }, pos: { x: 0, y: 120 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.07, g: 0.08, b: 0.12, a: 0.97 }, type: 1 }),
],
});
potionMenu.jsonString.enable = false;
combat.push(potionMenu);
combat.push(entity({
id: guid('cmb', 341),
path: '/ui/DefaultGroup/CombatHud/PotionMenu/Title',
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 380, parentH: 180, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 36 }, pos: { x: 0, y: 52 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 19, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
const pmButtons = [
['Use', '사용', -120, { r: 0.32, g: 0.55, b: 0.36, a: 1 }],
['Toss', '버리기', 0, { r: 0.6, g: 0.32, b: 0.3, a: 1 }],
['Close', '닫기', 120, { r: 0.25, g: 0.28, b: 0.35, a: 1 }],
];
pmButtons.forEach(([suffix, label, x, color], bi) => {
combat.push(entity({
id: guid('cmb', 342 + bi),
path: `/ui/DefaultGroup/CombatHud/PotionMenu/${suffix}`,
modelId: 'uibutton', entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 1 + bi,
components: [
transform({ parentW: 380, parentH: 180, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 104, y: 46 }, pos: { x, y: -40 } }),
sprite({ color, type: 1, raycast: true }),
button(),
text({ value: label, fontSize: 20, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, 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: 140, y: 40 }, pos: { x: 528, y: 0 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '모든덱보기', fontSize: 18, bold: true, color: GOLD, alignment: 0 }),
],
}));
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);
const result = entity({
id: guid('cmb', 2),
path: '/ui/DefaultGroup/CombatHud/Result',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 8,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }),
],
});
result.jsonString.enable = false;
combat.push(result);
return combat;
}

View File

@@ -0,0 +1,160 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildDeckAll() {
const allDeck = [];
const allHud = entity({
id: guid('all', 0),
path: '/ui/DefaultGroup/DeckAllHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 16,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.78 }, type: 1, raycast: true }),
],
});
allHud.jsonString.enable = false;
allDeck.push(allHud);
allDeck.push(entity({
id: guid('all', 1),
path: '/ui/DefaultGroup/DeckAllHud/Panel',
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: 1080, y: 800 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.08, g: 0.09, b: 0.11, a: 0.96 }, type: 1 }),
],
}));
allDeck.push(entity({
id: guid('all', 2),
path: '/ui/DefaultGroup/DeckAllHud/Title',
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: 760, y: 54 }, pos: { x: 0, y: 380 } }),
sprite({ color: TRANSPARENT }),
text({ value: '모든 덱', fontSize: 34, bold: true, color: GOLD, alignment: 4 }),
],
}));
allDeck.push(entity({
id: guid('all', 3),
path: '/ui/DefaultGroup/DeckAllHud/Close',
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: 78, y: 52 }, pos: { x: 486, y: 380 } }),
sprite({ color: { r: 0.16, g: 0.18, b: 0.22, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: 'X', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
const deckTabs = [
{ key: 'Warrior', label: '전사', x: -210 },
{ key: 'Thief', label: '도적', x: 0 },
{ key: 'Mage', label: '마법사', x: 210 },
];
for (let i = 0; i < deckTabs.length; i++) {
const tab = deckTabs[i];
allDeck.push(entity({
id: guid('all', 10 + i),
path: `/ui/DefaultGroup/DeckAllHud/${tab.key}Tab`,
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 3 + i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 170, y: 46 }, pos: { x: tab.x, y: 318 } }),
sprite({ color: { r: 0.11, g: 0.13, b: 0.16, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: tab.label, fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
],
}));
}
allDeck.push(entity({
id: guid('all', 4),
path: '/ui/DefaultGroup/DeckAllHud/Empty',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 3,
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: 40 } }),
sprite({ color: TRANSPARENT }),
text({ value: '덱이 없습니다', fontSize: 28, bold: true, color: { r: 0.82, g: 0.86, b: 0.9, a: 1 }, alignment: 4 }),
],
}));
allDeck.push(entity({
id: guid('all', 5),
path: '/ui/DefaultGroup/DeckAllHud/Grid',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ScrollLayoutGroupComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 980, y: 620 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: true }),
scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }),
],
}));
const ALL_DECK_CARD_COUNT = 120;
const ALL_DECK_CARD_W = 158;
const ALL_DECK_CARD_H = 214;
// 카드 단위 엔티티 v2 네임스페이스 — DeckInspectHud 주석 참조
for (let i = 1; i <= ALL_DECK_CARD_COUNT; i++) {
const allBase = 6 + (i - 1) * 7;
const cardPath = `/ui/DefaultGroup/DeckAllHud/Grid/Card${i}`;
const card = entity({
id: guid('all2', allBase),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: i,
components: [
transform({ parentW: 980, parentH: 620, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: ALL_DECK_CARD_W, y: ALL_DECK_CARD_H }, pos: { x: 0, y: 0 } }),
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
button(),
],
});
card.jsonString.enable = false;
allDeck.push(card);
const allDeckLayout = cardFaceLayout(ALL_DECK_CARD_W);
for (const [tIdx, [suffix, cfg]] of allDeckLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : '' }]).entries()) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
allDeck.push(entity({
id: guid('all2', allBase + 1 + tIdx),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: dOrder,
components: [
transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_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, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
],
}));
}
allDeck.push(entity({
id: guid('all2', allBase + 6),
path: `${cardPath}/Art`,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: allDeckLayout.art.size, pos: allDeckLayout.art.pos }),
sprite({ color: WHITE, type: 0, raycast: false }),
],
}));
}
return allDeck;
}

View File

@@ -0,0 +1,122 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildDeckHud() {
const hud = [];
const add = (e) => hud.push(e);
add(entity({
id: guid('hud', 0),
path: '/ui/DefaultGroup/DeckHud',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1280, y: 330 }, pos: { x: 0, y: 180 }, align: ALIGN_BOTTOM_CENTER }),
sprite({ color: TRANSPARENT }),
],
}));
for (const pile of [
{ key: 'DrawPile', x: -590, label: '뽑을 덱', count: '10', color: { r: 0.17, g: 0.20, b: 0.25, a: 1 } },
{ key: 'ExhaustPile', x: 430, label: '소멸 덱', count: '0', color: { r: 0.13, g: 0.13, b: 0.18, a: 1 } },
{ key: 'DiscardPile', x: 590, label: '버린 덱', count: '0', color: { r: 0.22, g: 0.18, b: 0.16, a: 1 } },
]) {
add(entity({
id: guid('hud', hud.length),
path: `/ui/DefaultGroup/DeckHud/${pile.key}`,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: pile.key === 'DrawPile' ? 0 : pile.key === 'ExhaustPile' ? 1 : 2,
components: [
transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 132, y: 186 }, pos: { x: pile.x, y: 8 }, align: ALIGN_CENTER }),
sprite({ color: pile.color, type: 1, raycast: true }),
button(),
],
}));
add(entity({
id: guid('hud', hud.length),
path: `/ui/DefaultGroup/DeckHud/${pile.key}/Label`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 132, parentH: 186, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 42 }, pos: { x: 0, y: 45 } }),
sprite({ color: TRANSPARENT }),
text({ value: pile.label, fontSize: 21, bold: true, color: GOLD }),
],
}));
add(entity({
id: guid('hud', hud.length),
path: `/ui/DefaultGroup/DeckHud/${pile.key}/Count`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 132, parentH: 186, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 72 }, pos: { x: 0, y: -20 } }),
sprite({ color: TRANSPARENT }),
text({ value: pile.count, fontSize: 42, bold: true }),
],
}));
}
add(entity({
id: guid('hud', hud.length),
path: '/ui/DefaultGroup/DeckHud/EndTurnButton',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
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: 160 }, align: ALIGN_CENTER }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '턴 종료', fontSize: 28, bold: true, color: GOLD, alignment: 0 }),
],
}));
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: 160 }, 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 }),
],
}));
return hud;
}

View File

@@ -0,0 +1,138 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildDeckInspect() {
const inspect = [];
const inspectHud = entity({
id: guid('ins', 0),
path: '/ui/DefaultGroup/DeckInspectHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 15,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.78 }, type: 1, raycast: true }),
],
});
inspectHud.jsonString.enable = false;
inspect.push(inspectHud);
inspect.push(entity({
id: guid('ins', 1),
path: '/ui/DefaultGroup/DeckInspectHud/Panel',
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: 1040, y: 760 }, pos: { x: 0, y: 10 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.08, g: 0.09, b: 0.11, a: 0.96 }, type: 1 }),
],
}));
inspect.push(entity({
id: guid('ins', 2),
path: '/ui/DefaultGroup/DeckInspectHud/Title',
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: 720, y: 54 }, pos: { x: 0, y: 350 } }),
sprite({ color: TRANSPARENT }),
text({ value: '\uB371 \uBCF4\uAE30', fontSize: 34, bold: true, color: GOLD, alignment: 4 }),
],
}));
inspect.push(entity({
id: guid('ins', 3),
path: '/ui/DefaultGroup/DeckInspectHud/Close',
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: 78, y: 52 }, pos: { x: 466, y: 350 } }),
sprite({ color: { r: 0.16, g: 0.18, b: 0.22, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: 'X', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
inspect.push(entity({
id: guid('ins', 4),
path: '/ui/DefaultGroup/DeckInspectHud/Empty',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 3,
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: '\uCE74\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4', fontSize: 28, bold: true, color: { r: 0.82, g: 0.86, b: 0.9, a: 1 }, alignment: 4 }),
],
}));
inspect.push(entity({
id: guid('ins', 5),
path: '/ui/DefaultGroup/DeckInspectHud/Grid',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ScrollLayoutGroupComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 950, y: 610 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: true }),
scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }),
],
}));
const INSPECT_CARD_COUNT = 60;
const INSPECT_CARD_W = 158;
const INSPECT_CARD_H = 214;
// 카드 단위 엔티티는 v2 네임스페이스(ins2/all2/rwd2/shp2) — 자식 구성이 바뀌면 id를 통째로 새로 발급해야 함.
// 구 id를 다른 path에 재사용하면 메이커 refresh의 id 기준 in-place 병합이 꼬여 자식이 소실됨 (P13 실측).
for (let i = 1; i <= INSPECT_CARD_COUNT; i++) {
const insBase = 6 + (i - 1) * 7;
const cardPath = `/ui/DefaultGroup/DeckInspectHud/Grid/Card${i}`;
const card = entity({
id: guid('ins2', insBase),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: i,
components: [
transform({ parentW: 950, parentH: 610, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: INSPECT_CARD_W, y: INSPECT_CARD_H }, pos: { x: 0, y: 0 } }),
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0 }),
],
});
card.jsonString.enable = false;
inspect.push(card);
const inspectLayout = cardFaceLayout(INSPECT_CARD_W);
for (const [tIdx, [suffix, cfg]] of inspectLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : '' }]).entries()) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
inspect.push(entity({
id: guid('ins2', insBase + 1 + tIdx),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: dOrder,
components: [
transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_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, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
],
}));
}
inspect.push(entity({
id: guid('ins2', insBase + 6),
path: `${cardPath}/Art`,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: inspectLayout.art.size, pos: inspectLayout.art.pos }),
sprite({ color: WHITE, type: 0, raycast: false }),
],
}));
}
return inspect;
}

View File

@@ -0,0 +1,51 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildJobChoice() {
const jobChoice = [];
const jobChoiceHud = entity({
id: guid('job', 0),
path: '/ui/DefaultGroup/JobChoiceHud',
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.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
],
});
jobChoiceHud.jsonString.enable = false;
jobChoice.push(jobChoiceHud);
jobChoice.push(entity({
id: guid('job', 1),
path: '/ui/DefaultGroup/JobChoiceHud/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: 800, y: 60 }, pos: { x: 0, y: 220 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보스 처치 보상을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
],
}));
const jcButtons = [
['RelicButton', '유물 획득', -240, { r: 0.7, g: 0.55, b: 0.85, a: 1 }],
['JobButton', '2차 전직', 240, { r: 0.86, g: 0.6, b: 0.3, a: 1 }],
];
jcButtons.forEach(([suffix, label, x, color], bi) => {
jobChoice.push(entity({
id: guid('job', 2 + bi),
path: `/ui/DefaultGroup/JobChoiceHud/${suffix}`,
modelId: 'uibutton', entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 1 + bi,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 140 }, pos: { x, y: 0 } }),
sprite({ color, type: 1, raycast: true }),
button(),
text({ value: label, fontSize: 32, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
});
return jobChoice;
}

View File

@@ -0,0 +1,89 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildJobSelect() {
const jobSelect = [];
const jobSelectHud = entity({
id: guid('job', 10),
path: '/ui/DefaultGroup/JobSelectHud',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 10,
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.94 }, type: 1, raycast: true }),
],
});
jobSelectHud.jsonString.enable = false;
jobSelect.push(jobSelectHud);
jobSelect.push(entity({
id: guid('job', 11),
path: '/ui/DefaultGroup/JobSelectHud/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: 800, y: 60 }, pos: { x: 0, y: 300 } }),
sprite({ color: TRANSPARENT }),
text({ value: '2차 전직 — 직업을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
],
}));
// 범용 슬롯 3개 — ShowJobSelect(Lua)가 클래스별 JOBS로 텍스트를 채움 (P10 동적화)
const jobs = [
['slot1', '', '', '', -440, { r: 0.82, g: 0.4, b: 0.34, a: 1 }],
['slot2', '', '', '', 0, { r: 0.4, g: 0.55, b: 0.85, a: 1 }],
['slot3', '', '', '', 440, { r: 0.42, g: 0.72, b: 0.46, a: 1 }],
];
jobs.forEach(([jobId, name, desc, starter, x, color], ji) => {
const base = `/ui/DefaultGroup/JobSelectHud/Job_${jobId}`;
jobSelect.push(entity({
id: guid('job', 12 + ji * 4),
path: base,
modelId: 'uibutton', entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 1 + ji,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 420 }, pos: { x, y: -20 } }),
sprite({ color, type: 1, raycast: true }),
button(),
],
}));
jobSelect.push(entity({
id: guid('job', 13 + ji * 4),
path: `${base}/Name`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 50 }, pos: { x: 0, y: 150 } }),
sprite({ color: TRANSPARENT }),
text({ value: name, fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
jobSelect.push(entity({
id: guid('job', 14 + ji * 4),
path: `${base}/Desc`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 160 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT }),
text({ value: desc, fontSize: 22, bold: false, color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, alignment: 4 }),
],
}));
jobSelect.push(entity({
id: guid('job', 15 + ji * 4),
path: `${base}/Starter`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 32 }, pos: { x: 0, y: -160 } }),
sprite({ color: TRANSPARENT }),
text({ value: starter, fontSize: 18, bold: true, color: GOLD, alignment: 4 }),
],
}));
});
return jobSelect;
}

View File

@@ -0,0 +1,58 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildLobby() {
const lobby = [];
let lobId = 0;
const lobbyRoot = entity({
id: guid('lob', lobId++),
path: '/ui/DefaultGroup/LobbyHud',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 11,
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 }),
// 로비가 물리 맵이 됨 — 투명 + 비레이캐스트로 맵을 가리지 않고 월드 NPC 클릭이 통과되게 함.
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
],
});
lobbyRoot.jsonString.enable = false;
lobby.push(lobbyRoot);
const lobTexts = [
['Title', 0, 478, 760, '메이플 로비', 40, GOLD],
['SoulLabel', 700, 478, 320, '영혼 0', 28, { r: 0.6, g: 0.85, b: 1, a: 1 }],
['AscLabel', -560, 478, 380, '승천 0 / 해금 0', 22, { r: 0.9, g: 0.7, b: 0.5, a: 1 }],
['Hint', 0, -478, 1500, 'NPC에게 다가가 ↑ 또는 클릭으로 대화 · ← → 이동 · Ctrl 공격', 20, { r: 0.72, g: 0.76, b: 0.82, a: 1 }],
];
for (const [suffix, x, y, w, value, fs, color] of lobTexts) {
lobby.push(entity({
id: guid('lob', lobId++),
path: `/ui/DefaultGroup/LobbyHud/${suffix}`,
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: w, y: 56 }, pos: { x, y } }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize: fs, bold: true, color, alignment: 4 }),
],
}));
}
for (const [suffix, x, label] of [['AscMinus', -780, '<'], ['AscPlus', -540, '>']]) {
lobby.push(entity({
id: guid('lob', lobId++),
path: `/ui/DefaultGroup/LobbyHud/${suffix}`,
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: 46, y: 42 }, pos: { x, y: 470 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.2, g: 0.24, b: 0.3, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: label, fontSize: 28, bold: true, color: WHITE, alignment: 4 }),
],
}));
}
// NPC 4종은 로비 물리 맵의 월드 엔티티(map/lobby.map + LobbyNpc codeblock)로 이동. UI 버튼 행 제거.
return lobby;
}

View File

@@ -0,0 +1,127 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildMainMenu() {
const menu = [];
menu.push(entity({
id: guid('menu', 0),
path: '/ui/DefaultGroup/MainMenu',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 20,
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.06, g: 0.09, b: 0.13, a: 1 }, type: 1, raycast: true }),
],
}));
menu.push(entity({
id: guid('menu', 50),
path: '/ui/DefaultGroup/MainMenu/OpaqueBackdrop',
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: TRANSPARENT, type: 1, raycast: false }),
],
}));
menu.push(entity({
id: guid('menu', 1),
path: '/ui/DefaultGroup/MainMenu/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: 720, y: 100 }, pos: { x: 0, y: 180 }, align: ALIGN_CENTER }),
sprite({ color: TRANSPARENT }),
text({ value: '슬레이 메이플', fontSize: 64, bold: true, color: GOLD, alignment: 0 }),
],
}));
menu.push(entity({
id: guid('menu', 2),
path: '/ui/DefaultGroup/MainMenu/Subtitle',
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: 760, y: 48 }, pos: { x: 0, y: 104 }, align: ALIGN_CENTER }),
sprite({ color: TRANSPARENT }),
text({ value: '카드를 뽑고, 덱을 만들고, 첨탑을 오른다', fontSize: 24, color: { r: 0.82, g: 0.86, b: 0.9, a: 1 }, alignment: 0 }),
],
}));
menu.push(entity({
id: guid('menu', 3),
path: '/ui/DefaultGroup/MainMenu/NewGameButton',
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: 260, y: 68 }, pos: { x: 0, y: -20 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.13, g: 0.15, b: 0.18, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: '새 게임', fontSize: 30, bold: true, color: GOLD, alignment: 0 }),
],
}));
// 승천 선택 (P11): [-] 라벨 [+]
menu.push(entity({
id: guid('menu', 195),
path: '/ui/DefaultGroup/MainMenu/AscMinus',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 5,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 52, y: 52 }, pos: { x: -170, y: -185 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.13, g: 0.15, b: 0.18, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: '-', fontSize: 30, bold: true, color: GOLD, alignment: 4 }),
],
}));
menu.push(entity({
id: guid('menu', 196),
path: '/ui/DefaultGroup/MainMenu/AscLabel',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 6,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 250, y: 40 }, pos: { x: 0, y: -185 }, align: ALIGN_CENTER }),
sprite({ color: TRANSPARENT }),
text({ value: '승천 0 / 해금 0', fontSize: 22, bold: true, color: { r: 0.85, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }),
],
}));
menu.push(entity({
id: guid('menu', 197),
path: '/ui/DefaultGroup/MainMenu/AscPlus',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 7,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 52, y: 52 }, pos: { x: 170, y: -185 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.13, g: 0.15, b: 0.18, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: '+', fontSize: 30, bold: true, color: GOLD, alignment: 4 }),
],
}));
menu.push(entity({
id: guid('menu', 4),
path: '/ui/DefaultGroup/MainMenu/ContinueButton',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 3,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 260, y: 58 }, pos: { x: 0, y: -100 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.1, g: 0.11, b: 0.13, a: 0.78 }, type: 1, raycast: false }),
button({ enabled: false }),
text({ value: '이어하기', fontSize: 24, bold: true, color: { r: 0.55, g: 0.58, b: 0.62, a: 1 }, alignment: 0 }),
],
}));
return menu;
}

View File

@@ -0,0 +1,162 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildMap() {
const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스', shop: '상점', rest: '휴식' };
const map = [];
const mapHud = entity({
id: guid('map', 0),
path: '/ui/DefaultGroup/MapHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 7,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
// 불투명 다크 배경(게임 월드 가림 보장). 그 위 BgImage 자식이 배경 스프라이트를 얹는다.
sprite({ color: { r: 0.06, g: 0.07, b: 0.11, a: 1 }, type: 1, raycast: true }),
],
});
mapHud.jsonString.enable = false;
map.push(mapHud);
// 배경 이미지(displayOrder 0 = 도트/타이틀/노드 아래). nodeicons.json background는 SPRITE RUID여야 렌더됨
// — 메이플 BackgroundComponent 리소스는 UI 스프라이트로 안 뜬다. 유효 스프라이트면 풀스크린 표시, 아니면 투명(다크 배경 노출).
map.push(entity({
id: guid('map', 990),
path: '/ui/DefaultGroup/MapHud/BgImage',
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({ dataId: NODEICONS.background, color: { r: 0.5, g: 0.52, b: 0.58, a: 1 }, type: 0, raycast: false }),
],
}));
map.push(entity({
id: guid('map', 1),
path: '/ui/DefaultGroup/MapHud/Title',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 510 } }),
sprite({ color: TRANSPARENT }),
text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
],
}));
// 절차 생성 맵용 정적 그리드 — 가로 진행(왼→오른쪽): 행(row)=x축, 열(col)=y축 분기, 보스는 최우측 중앙.
const nodeX = (row) => -540 + (row - 1) * 150;
const nodeY = (col) => 180 - (col - 1) * 120;
const BOSS_POS = { x: nodeX(MAP_ROWS) + 150, y: 0 };
let mapN = 2;
const pushMapNode = (id, pos, size, label) => {
const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`;
const nodeEnt = entity({
id: guid('map', mapN++),
path: nodePath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 5,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),
button(),
],
});
nodeEnt.jsonString.enable = false;
map.push(nodeEnt);
};
for (let r = 1; r <= MAP_ROWS; r++) {
for (let c = 1; c <= MAP_COLS; c++) {
pushMapNode(`r${r}c${c}`, { x: nodeX(r), y: nodeY(c) }, { x: 64, y: 64 }, '');
}
}
pushMapNode('boss', BOSS_POS, { x: 88, y: 88 }, '보스');
const pushDots = (dotId, from, to) => {
for (let k = 1; k <= 3; k++) {
const t = k / 4;
const dot = entity({
id: guid('map', mapN++),
path: `/ui/DefaultGroup/MapHud/Dot_${dotId}_${k}`,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 8, y: 8 }, pos: { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t } }),
sprite({ color: { r: 0.5, g: 0.5, b: 0.55, a: 0.8 }, type: 1 }),
],
});
dot.jsonString.enable = false;
map.push(dot);
}
};
for (let r = 1; r < MAP_ROWS; r++) {
for (let c = 1; c <= MAP_COLS; c++) {
for (let c2 = c - 1; c2 <= c + 1; c2++) {
if (c2 < 1 || c2 > MAP_COLS) continue;
pushDots(`r${r}c${c}_${c2}`, { x: nodeX(r), y: nodeY(c) }, { x: nodeX(r + 1), y: nodeY(c2) });
}
}
}
for (let c = 1; c <= MAP_COLS; c++) {
pushDots(`r${MAP_ROWS}c${c}_b`, { x: nodeX(MAP_ROWS), y: nodeY(c) }, BOSS_POS);
}
// 노드 종류 범례 (우측 하단) — 각 타입 아이콘 + 이름
const LEGEND = [['combat', '전투'], ['elite', '엘리트'], ['boss', '보스'], ['shop', '상점'], ['rest', '휴식'], ['treasure', '보물']];
const lgW = 300, lgH = 312;
map.push(entity({
id: guid('map', 991),
path: '/ui/DefaultGroup/MapHud/Legend',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: lgW, y: lgH }, pos: { x: 760, y: -334 } }),
sprite({ color: { r: 0.08, g: 0.09, b: 0.14, a: 0.86 }, type: 1, raycast: false }),
],
}));
map.push(entity({
id: guid('map', 992),
path: '/ui/DefaultGroup/MapHud/Legend/LegendTitle',
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: lgW, parentH: lgH, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: lgW - 20, y: 32 }, pos: { x: 0, y: lgH / 2 - 26 } }),
sprite({ color: TRANSPARENT }),
text({ value: '노드 종류', fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
],
}));
let lgId = 993;
LEGEND.forEach(([t, ko], i) => {
const rowY = lgH / 2 - 78 - i * 38;
map.push(entity({
id: guid('map', lgId++),
path: `/ui/DefaultGroup/MapHud/Legend/Icon_${t}`,
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 2,
components: [
transform({ parentW: lgW, parentH: lgH, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 36, y: 36 }, pos: { x: -lgW / 2 + 38, y: rowY } }),
sprite({ dataId: NODEICONS.icons[t], color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }),
],
}));
map.push(entity({
id: guid('map', lgId++),
path: `/ui/DefaultGroup/MapHud/Legend/Label_${t}`,
modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: lgW, parentH: lgH, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: lgW - 110, y: 30 }, pos: { x: 32, y: rowY } }),
sprite({ color: TRANSPARENT }),
text({ value: ko, fontSize: 19, bold: false, color: { r: 0.9, g: 0.92, b: 0.96, a: 1 }, alignment: 4 }),
],
}));
});
return map;
}

View File

@@ -0,0 +1,61 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildRest() {
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 }),
],
}));
return rest;
}

View File

@@ -0,0 +1,98 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildReward() {
const reward = [];
const rewardHud = entity({
id: guid('rwd', 0),
path: '/ui/DefaultGroup/RewardHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 6,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }),
],
});
rewardHud.jsonString.enable = false;
reward.push(rewardHud);
reward.push(entity({
id: guid('rwd', 1),
path: '/ui/DefaultGroup/RewardHud/Title',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 64 }, pos: { x: 0, y: 300 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
const rewardXs = [-300, 0, 300];
// 카드 단위 엔티티 v2 네임스페이스 — DeckInspectHud 주석 참조
for (let i = 1; i <= 3; i++) {
const rwdBase = 2 + (i - 1) * 7;
const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
reward.push(entity({
id: guid('rwd2', rwdBase),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent',
displayOrder: i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: rewardXs[i - 1], y: 0 } }),
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
button(),
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
],
}));
const rewardLayout = cardFaceLayout(CARD_W);
for (const [tIdx, [suffix, cfg]] of rewardLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : sfx === 'Name' ? '카드' : '' }]).entries()) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
reward.push(entity({
id: guid('rwd2', rwdBase + 1 + tIdx),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
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({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
],
}));
}
reward.push(entity({
id: guid('rwd2', rwdBase + 6),
path: `${cardPath}/Art`,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: rewardLayout.art.size, pos: rewardLayout.art.pos }),
sprite({ color: WHITE, type: 0, raycast: false }),
],
}));
}
let rwdN = 2 + 3 * 7; // 구 시퀀스의 루프 종료 시점 값(23) 보존 — Skip 등 후속 id 불변
reward.push(entity({
id: guid('rwd', rwdN++),
path: '/ui/DefaultGroup/RewardHud/Skip',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 10,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -260 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
return reward;
}

View File

@@ -0,0 +1,203 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildShop() {
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 }),
],
}));
shop.push(entity({
id: guid('shp', 3),
path: '/ui/DefaultGroup/ShopHud/MesoIcon',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 30, y: 30 }, pos: { x: -86, y: 330 } }),
sprite({ color: { r: 1, g: 0.82, b: 0.2, a: 1 }, type: 1 }),
],
}));
const shopXs = [-300, 0, 300];
// 카드 단위 엔티티 v2 네임스페이스 (stride 8: Price 포함) — DeckInspectHud 주석 참조
for (let i = 1; i <= 3; i++) {
const shpBase = 3 + (i - 1) * 8;
const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`;
shop.push(entity({
id: guid('shp2', shpBase),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent',
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({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
button(),
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
],
}));
const shopLayout = cardFaceLayout(CARD_W);
const shopTexts = shopLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : sfx === 'Name' ? '카드' : '' }]);
shopTexts.push(['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -135 }, value: '30 메소', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }]);
for (const [tIdx, [suffix, cfg]] of shopTexts.entries()) {
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : suffix === 'Desc' ? 8 : 9;
shop.push(entity({
id: guid('shp2', shpBase + 1 + tIdx),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
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({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
],
}));
}
shop.push(entity({
id: guid('shp2', shpBase + 7),
path: `${cardPath}/Art`,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: shopLayout.art.size, pos: shopLayout.art.pos }),
sprite({ color: WHITE, type: 0, raycast: false }),
],
}));
}
let shpN = 3 + 3 * 8; // 구 시퀀스의 루프 종료 시점 값(27) 보존 — Relic 등 후속 id 불변
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 }),
],
}));
shop.push(entity({
id: guid('shp', shpN++),
path: '/ui/DefaultGroup/ShopHud/Potion',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 11,
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: -278 } }),
sprite({ color: { r: 0.45, g: 0.7, b: 0.55, a: 1 }, type: 1, raycast: true }),
button(),
],
}));
shop.push(entity({
id: guid('shp', shpN++),
path: '/ui/DefaultGroup/ShopHud/Potion/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/Potion/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: '20 메소', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
],
}));
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: -380 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
return shop;
}

View File

@@ -0,0 +1,90 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildSoulShop() {
const soulShop = [];
let soulId = 0;
const soulRoot = entity({
id: guid('soul', soulId++),
path: '/ui/DefaultGroup/SoulShopHud',
modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 15,
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.95 }, type: 1, raycast: true }),
],
});
soulRoot.jsonString.enable = false;
soulShop.push(soulRoot);
soulShop.push(entity({
id: guid('soul', soulId++),
path: '/ui/DefaultGroup/SoulShopHud/Title',
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: 700, y: 60 }, pos: { x: 0, y: 410 } }),
sprite({ color: TRANSPARENT }),
text({ value: '영혼 상점', fontSize: 44, bold: true, color: { r: 0.6, g: 0.85, b: 1, a: 1 }, alignment: 4 }),
],
}));
soulShop.push(entity({
id: guid('soul', soulId++),
path: '/ui/DefaultGroup/SoulShopHud/Souls',
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: 400, y: 44 }, pos: { x: 0, y: 345 } }),
sprite({ color: TRANSPARENT }),
text({ value: '영혼 0', fontSize: 28, bold: true, color: { r: 0.6, g: 0.85, b: 1, a: 1 }, alignment: 4 }),
],
}));
soulShop.push(entity({
id: guid('soul', soulId++),
path: '/ui/DefaultGroup/SoulShopHud/Close',
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: -400 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.2, g: 0.24, b: 0.3, a: 1 }, type: 1, raycast: true }),
button(),
text({ value: '닫기', fontSize: 28, bold: true, color: WHITE, alignment: 4 }),
],
}));
for (let i = 1; i <= 4; i++) {
const ip = `/ui/DefaultGroup/SoulShopHud/Item${i}`;
const iy = 230 - (i - 1) * 125;
soulShop.push(entity({
id: guid('soul', soulId++),
path: ip, modelId: 'uibutton', entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 2,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 760, y: 104 }, pos: { x: 0, y: iy }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.14, g: 0.16, b: 0.22, a: 1 }, type: 1, raycast: true }),
button(),
],
}));
for (const [suffix, x, y, w, fs, color] of [
['Name', -180, 22, 360, 28, GOLD],
['Desc', -180, -24, 440, 20, { r: 0.86, g: 0.9, b: 0.94, a: 1 }],
['Status', 270, 0, 220, 22, { r: 0.6, g: 0.85, b: 1, a: 1 }],
]) {
soulShop.push(entity({
id: guid('soul', soulId++),
path: `${ip}/${suffix}`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 760, parentH: 104, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: w, y: 38 }, pos: { x, y } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: fs, bold: suffix === 'Name', color, alignment: 4 }),
],
}));
}
}
return soulShop;
}

View File

@@ -0,0 +1,89 @@
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
export function buildTreasure() {
const treasure = [];
const treasureHud = entity({
id: guid('trs', 0),
path: '/ui/DefaultGroup/TreasureHud',
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 }),
],
});
treasureHud.jsonString.enable = false;
treasure.push(treasureHud);
treasure.push(entity({
id: guid('trs', 1),
path: '/ui/DefaultGroup/TreasureHud/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: 320 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보물 상자', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
],
}));
treasure.push(entity({
id: guid('trs', 2),
path: '/ui/DefaultGroup/TreasureHud/Chest',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 180, y: 180 }, pos: { x: 0, y: 40 } }),
sprite({ dataId: CHEST_CLOSED_RUID, color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),
button(),
],
}));
treasure.push(entity({
id: guid('trs', 3),
path: '/ui/DefaultGroup/TreasureHud/Hint',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,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: 500, y: 34 }, pos: { x: 0, y: -90 } }),
sprite({ color: TRANSPARENT }),
text({ value: '상자를 클릭해 여세요', fontSize: 20, bold: false, color: { r: 0.85, g: 0.85, b: 0.9, a: 1 }, alignment: 4 }),
],
}));
const treasureReward = entity({
id: guid('trs', 4),
path: '/ui/DefaultGroup/TreasureHud/Reward',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 3,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 44 }, pos: { x: 0, y: -160 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
],
});
treasureReward.jsonString.enable = false;
treasure.push(treasureReward);
treasure.push(entity({
id: guid('trs', 5),
path: '/ui/DefaultGroup/TreasureHud/Leave',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 4,
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: -280 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
return treasure;
}

View File

@@ -0,0 +1,246 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { CARDS, frameRuid } from '../lib/data.mjs';
import { UI_FILE, isGeneratedUiEntity, DISABLED_STOCK_CONTROLS, uiPath, guid, entity, transform, sprite, button, text, WHITE, TRANSPARENT, CARD_W, CARD_H, cardFaceLayout, UI_APPEND_ORDER, appendUiSection } from '../lib/ui-helpers.mjs';
import { buildDeckHud } from './hud/deckhud.mjs';
import { buildDeckInspect } from './hud/deckinspect.mjs';
import { buildDeckAll } from './hud/deckall.mjs';
import { buildCombat } from './hud/combat.mjs';
import { buildReward } from './hud/reward.mjs';
import { buildMap } from './hud/map.mjs';
import { buildShop } from './hud/shop.mjs';
import { buildRest } from './hud/rest.mjs';
import { buildTreasure } from './hud/treasure.mjs';
import { buildJobChoice } from './hud/jobchoice.mjs';
import { buildJobSelect } from './hud/jobselect.mjs';
import { buildLobby } from './hud/lobby.mjs';
import { buildBoard } from './hud/board.mjs';
import { buildSoulShop } from './hud/soulshop.mjs';
import { buildMainMenu } from './hud/mainmenu.mjs';
// ⚠️ 휴면(LEGACY): 메이커 저작 전환 후 생성기는 .ui를 안 만든다. 이 파일은 옛 DefaultGroup.ui
// 단일 저작 로직의 롤백/참조용. import는 무해(함수만 정의), 직접 실행할 때만 .ui를 옛 생성본으로 덮어쓴다.
function upsertUi() {
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
const E = ui.ContentProto.Entities;
// CardHand는 스톡 섹션이라 과거 생성된 단색판(NamePlate/CostPlate)이 잔존 → 프레임 이미지 도입으로 제거
const obsoletePlate = /^\/ui\/DefaultGroup\/CardHand\/Card\d+\/(NamePlate|CostPlate)$/;
ui.ContentProto.Entities = E.filter((e) => !isGeneratedUiEntity(e) && !obsoletePlate.test(e.path));
const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e]));
const uiSections = new Map();
const emit = (section, entities) => {
if (uiSections.has(section)) {
throw new Error(`[gen-slaydeck] duplicate generated UI section: ${section}`);
}
uiSections.set(section, entities);
};
for (const path of DISABLED_STOCK_CONTROLS.map((name) => uiPath(name))) {
const e = byPath.get(path);
if (e != null) {
e.jsonString.enable = false;
e.jsonString.visible = false;
for (const component of e.jsonString['@components'] || []) {
component.Enable = false;
if (component.RaycastTarget != null) component.RaycastTarget = false;
}
}
}
// 카드 미리보기(초기 정적 표시 — 런타임 RenderHand가 덮어씀): 카드 종류를 순환해 다양성 표시
const previewIds = Object.keys(CARDS.cards);
const cards = Array.from({ length: 10 }, (_, i) => {
const c = CARDS.cards[previewIds[i % previewIds.length]];
return { name: c.name, cost: String(c.cost), desc: c.desc, frame: frameRuid(c) };
});
// 손패 슬롯 10개 (최대 손패 한도). Card1~5는 기존 엔티티, Card6~10은 신규 생성.
for (let i = 1; i <= 10; i++) {
const cardPath = `/ui/DefaultGroup/CardHand/Card${i}`;
let card = byPath.get(cardPath);
if (!card) {
card = entity({
id: guid('dck', 500 + i),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent',
displayOrder: 4,
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: 0, y: 0 } }),
sprite({ color: WHITE, type: 0, raycast: true }),
button(),
],
});
ui.ContentProto.Entities.push(card);
byPath.set(cardPath, card);
}
const tr = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent');
const sp = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.SpriteGUIRendererComponent');
const sx = -680 + (i - 1) * 150;
tr.RectSize = { x: CARD_W, y: CARD_H };
tr.anchoredPosition = { x: sx, y: 0 };
tr.OffsetMin = { x: sx - CARD_W / 2, y: -CARD_H / 2 };
tr.OffsetMax = { x: sx + CARD_W / 2, y: CARD_H / 2 };
sp.ImageRUID = { DataId: cards[i - 1].frame };
sp.Type = 0;
sp.Color = WHITE;
sp.RaycastTarget = true;
const comps = card.jsonString['@components'];
if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) {
comps.push(button());
}
if (!card.componentNames.includes('MOD.Core.ButtonComponent')) {
card.componentNames += ',MOD.Core.ButtonComponent';
}
if (!comps.some((c) => c['@type'] === 'MOD.Core.UITouchReceiveComponent')) {
comps.push({ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true });
}
if (!card.componentNames.includes('MOD.Core.UITouchReceiveComponent')) {
card.componentNames += ',MOD.Core.UITouchReceiveComponent';
}
card.jsonString.enable = true;
card.jsonString.visible = true;
const handLayout = cardFaceLayout(CARD_W);
const previewValues = { Cost: cards[i - 1].cost, Name: cards[i - 1].name, Desc: cards[i - 1].desc };
const children = handLayout.texts.map(([suffix, cfg]) => [suffix, { ...cfg, value: previewValues[suffix] }]);
for (const [suffix, cfg] of children) {
const path = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
let child = byPath.get(path);
if (!child) {
child = entity({
id: guid('dck', i * 10 + children.findIndex(([s]) => s === suffix)),
path,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
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({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
],
});
ui.ContentProto.Entities.push(child);
byPath.set(path, child);
} else {
child.id = guid('dck', i * 10 + children.findIndex(([s]) => s === suffix));
child.jsonString.enable = true;
child.jsonString.visible = true;
child.jsonString.displayOrder = dOrder;
const ctr = child.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent');
if (ctr) {
const pivot = { x: 0.5, y: 0.5 };
ctr.RectSize = cfg.size;
ctr.anchoredPosition = cfg.pos;
ctr.OffsetMin = { x: cfg.pos.x - pivot.x * cfg.size.x, y: cfg.pos.y - pivot.y * cfg.size.y };
ctr.OffsetMax = { x: cfg.pos.x + (1 - pivot.x) * cfg.size.x, y: cfg.pos.y + (1 - pivot.y) * cfg.size.y };
}
child.jsonString['@components'][2].Text = cfg.value;
child.jsonString['@components'][2].FontSize = cfg.fontSize;
child.jsonString['@components'][2].MaxSize = cfg.fontSize;
child.jsonString['@components'][2].FontColor = cfg.color;
child.jsonString['@components'][2].Bold = cfg.bold;
child.jsonString['@components'][2].DropShadow = cfg.dropShadow === true;
child.jsonString['@components'][2].DropShadowDistance = cfg.dropShadow === true ? 18 : 32;
child.jsonString['@components'][2].OutlineWidth = cfg.outlineWidth || 1;
}
}
// 프레임 이미지가 이름판·코스트판을 내장하므로 Art만 유지 (잔존 NamePlate/CostPlate는 upsertUi 초입에서 제거)
const frameKids = [
['Art', 5, handLayout.art, WHITE, 0],
];
for (const [suffix, dOrder, cfg, color, spriteType] of frameKids) {
const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
let fe = byPath.get(fPath);
if (!fe) {
fe = entity({
id: guid('dck', 200 + i * 10 + dOrder),
path: fPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
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({ color, type: spriteType, raycast: false }),
],
});
ui.ContentProto.Entities.push(fe);
byPath.set(fPath, fe);
} else {
const ftr = fe.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent');
if (ftr) {
ftr.RectSize = cfg.size;
ftr.anchoredPosition = cfg.pos;
ftr.OffsetMin = { x: cfg.pos.x - cfg.size.x / 2, y: cfg.pos.y - cfg.size.y / 2 };
ftr.OffsetMax = { x: cfg.pos.x + cfg.size.x / 2, y: cfg.pos.y + cfg.size.y / 2 };
}
}
}
}
emit('DeckHud', buildDeckHud());
emit('DeckInspectHud', buildDeckInspect());
emit('DeckAllHud', buildDeckAll());
emit('CombatHud', buildCombat());
emit('RewardHud', buildReward());
emit('MapHud', buildMap());
emit('ShopHud', buildShop());
emit('RestHud', buildRest());
// 유물 방 — 보물 상자 (P8)
emit('TreasureHud', buildTreasure());
// 전직 선택 (P9) — 보스 보상: 유물 vs 2차 전직
emit('JobChoiceHud', buildJobChoice());
emit('JobSelectHud', buildJobSelect());
emit('MainMenu', buildMainMenu());
// ── LobbyHud — 반복 런의 허브. NPC 클릭으로 런시작/도감/영혼상점/게시판 ──
emit('LobbyHud', buildLobby());
// ── BoardHud — 게시판(공지/팁) ──
emit('BoardHud', buildBoard());
// ── SoulShopHud — 영혼 메타 상점 (Phase 9에서 해금 항목·구매 로직 채움) ──
emit('SoulShopHud', buildSoulShop());
for (const section of UI_APPEND_ORDER) {
const entities = uiSections.get(section);
if (entities == null) {
throw new Error(`[gen-slaydeck] missing generated UI section: ${section}`);
}
appendUiSection(ui, section, entities);
}
// 엔티티 id 유일성 검증 — 같은 id가 다른 path에 재배정되면 메이커 refresh 병합이 꼬임
const seenIds = new Map();
for (const e of ui.ContentProto.Entities) {
const prev = seenIds.get(e.id);
if (prev != null) throw new Error(`[gen-slaydeck] 엔티티 id 중복: ${e.id} (${prev}${e.path})`);
seenIds.set(e.id, e.path);
}
JSON.parse(JSON.stringify(ui));
writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8');
}
// 롤백/참조용 직접 실행 시에만 동작 (import 시에는 실행 안 함)
if (process.argv[1] === fileURLToPath(import.meta.url)) {
upsertUi();
}

View File

@@ -0,0 +1,60 @@
function prop(Type, Name, DefaultValue = 'nil') {
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
}
function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') {
return {
Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
Arguments,
Code,
Scope: 2,
ExecSpace,
Attributes: [],
Name,
};
}
function codeblock(id, name, properties, methods) {
return {
Id: '',
GameId: '',
EntryKey: `codeblock://${id}`,
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: id,
Language: 1,
Name: name,
Type: 1,
Source: 0,
Target: null,
Properties: properties,
Methods: methods,
EntityEventHandlers: [],
},
},
};
}
const RUN_LENGTH = 5;
const GOLD_PER_WIN = 25;
const CARD_PRICE = 30;
const REST_HEAL = 30;
const RELIC_PRICE = 60;
const ACT_COUNT = 5;
const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05'];
const LOBBY_MAP = 'lobby';
const LOBBY_SPAWN = 'Vector3(-5, 0.03, 0)'; // 정찰: map01 지면 좌측
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };

237
tools/deck/lib/data.mjs Normal file
View File

@@ -0,0 +1,237 @@
import { readFileSync } from 'node:fs';
const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
const CLASSES = {
warrior: { label: '전사', maxHp: 80 },
bandit: { label: '도적', maxHp: 70 },
magician: { label: '마법사', maxHp: 70 },
};
for (const cls of Object.keys(CLASSES)) {
if (!CARDS.starterDecks?.[cls]) throw new Error(`[gen-slaydeck] starterDecks.${cls} 없음`);
for (const id of CARDS.starterDecks[cls]) {
if (!CARDS.cards[id]) throw new Error(`[gen-slaydeck] starterDecks.${cls}에 없는 카드 id 참조: ${id}`);
}
}
// 전직 옵션 (클래스별 2차 — JobSelectHud 동적 구성·SetJob 대표 카드)
const JOBS = {
warrior: [
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack' },
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge' },
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce' },
],
magician: [
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow' },
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\n썬더 볼트(전체)\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt' },
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal' },
],
bandit: [
{ id: 'shiv', name: 'Shiv', desc: 'Many small attacks\nBlade Dance\nAccuracy · After Image', starter: 'BladeDance' },
{ id: 'poisoner', name: 'Poison', desc: 'Poison scaling\nDeadly Poison\nCatalyst · Noxious Fumes', starter: 'DeadlyPoison' },
{ id: 'trickster', name: 'Trickster', desc: 'Draw and tempo\nAcrobatics\nAdrenaline · Tools', starter: 'Acrobatics' },
],
};
for (const [cls, jobs] of Object.entries(JOBS)) {
for (const j of jobs) {
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
}
}
// 영혼(soul) 메타 해금 — 2차 전직 후 보스 클리어로 영혼 적립, 로비 영혼상점에서 구매 → 다음 런 이점
const SOUL_UNLOCKS = [
{ key: 'meso', name: '두둑한 지갑', desc: '런 시작 시 메소 +60', cost: 3 },
{ key: 'hp', name: '단련된 육체', desc: '시작 최대 HP +15', cost: 4 },
{ key: 'trim', name: '덱 정제', desc: '시작 덱에서 기본 카드 1장 제거', cost: 5 },
{ key: 'relic', name: '유물 수집가', desc: '런 시작 시 유물 1개 추가', cost: 6 },
];
function luaSoulShopTable(unlocks) {
const items = unlocks.map((u) => `\t{ key = ${luaStr(u.key)}, name = ${luaStr(u.name)}, desc = ${luaStr(u.desc)}, cost = ${u.cost} },`).join('\n');
return `self.SoulShopDef = {\n${items}\n}`;
}
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
}
// 카드 프레임 (사용자 제작 이미지 — 로컬 임포트 .sprite RUID, 직업 3종 × 등급 3종)
const CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json', 'utf8'));
const RARITIES = ['normal', 'unique', 'legend'];
for (const [fid, fr] of Object.entries(CARDFRAMES.frames)) {
for (const r of RARITIES) {
if (!fr[r]) throw new Error(`[gen-slaydeck] cardframes.frames.${fid}.${r} RUID 없음`);
}
}
for (const [id, c] of Object.entries(CARDS.cards)) {
if (!RARITIES.includes(c.rarity)) throw new Error(`[gen-slaydeck] 카드 ${id} rarity 누락/오류: ${c.rarity}`);
const fc = CARDFRAMES.classToFrame[c.class];
if (!fc || !CARDFRAMES.frames[fc]) throw new Error(`[gen-slaydeck] 카드 ${id} class ${c.class} → 프레임 매핑 없음`);
}
function frameRuid(card) {
return CARDFRAMES.frames[CARDFRAMES.classToFrame[card.class]][card.rarity];
}
function luaFramesTable() {
const frames = Object.entries(CARDFRAMES.frames).map(([fid, fr]) =>
`\t${fid} = { normal = ${luaStr(fr.normal)}, unique = ${luaStr(fr.unique)}, legend = ${luaStr(fr.legend)} },`).join('\n');
const cls = Object.entries(CARDFRAMES.classToFrame).map(([c, f]) => `\t${c} = ${luaStr(f)},`).join('\n');
return `self.CardFrames = {\n${frames}\n}\nself.ClassToFrame = {\n${cls}\n}`;
}
function luaNodeIconsTable() {
const rows = Object.entries(NODEICONS.icons).map(([t, ruid]) => `\t${t} = ${luaStr(ruid)},`).join('\n');
return `self.NodeIcons = {\n${rows}\n}`;
}
function luaCharsTable() {
const rows = Object.entries(CHARS.portraits).map(([c, ruid]) => `\t${c} = ${luaStr(ruid)},`).join('\n');
return `self.ClassPortraits = {\n${rows}\n}`;
}
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
const MAP_ROWS = 6; // 걷는 행 1..6, 보스 row 7 (depth 최대 7)
const MAP_COLS = 4;
// 보물 상자 스프라이트 (공식 maplestory 리소스, 메이커 선별)
const CHEST_CLOSED_RUID = '43df67920c0d43298e0d93c02c6afa71';
const CHEST_OPEN_RUID = '09c5cee56fd640bf8ae3a18ce50f4759';
// 노드 맵 아이콘/배경 (공식 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 누락/형식오류');
// 캐릭터 선택 초상화 (메이커 임포트 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 누락/형식오류`);
}
// 전투 카메라 고정값(StS2: 플레이어 좌·몬스터 우). KickCombatCamera가 StartCombat에서 재confine에 사용.
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
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}, icon = ${luaStr(r.icon || '')} },`);
return `self.Relics = {\n${lines.join('\n')}\n}`;
}
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}`;
}
function luaIntentsArray(intents) {
return '{ ' + intents.map((it) => {
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value != null ? it.value : 0}`];
if (it.effect != null) fields.push(`effect = ${luaStr(it.effect)}`);
if (it.card != null) fields.push(`card = ${luaStr(it.card)}`);
if (it.count != null) fields.push(`count = ${it.count}`);
return `{ ${fields.join(', ')} }`;
}).join(', ') + ' }';
}
function luaEnemiesTable(enemies) {
const lines = Object.entries(enemies).map(([id, e]) =>
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
return `self.Enemies = {\n${lines.join('\n')}\n}`;
}
// Lua 직렬화 헬퍼
function luaStr(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
}
function luaJobsTable(jobs) {
const cls = Object.entries(jobs).map(([clsId, list]) => {
const items = list.map((j) => `\t\t{ id = ${luaStr(j.id)}, name = ${luaStr(j.name)}, desc = ${luaStr(j.desc)}, starter = ${luaStr(j.starter)} },`).join('\n');
return `\t${clsId} = {\n${items}\n\t},`;
}).join('\n');
return `self.Jobs = {\n${cls}\n}`;
}
function luaCardsTable(cards) {
const lines = Object.entries(cards).map(([id, c]) => {
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
if (c.damage != null) fields.push(`damage = ${c.damage}`);
if (c.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
if (c.damagePerSkillInHand != null) fields.push(`damagePerSkillInHand = ${c.damagePerSkillInHand}`);
if (c.damagePerCardDrawnThisCombat != null) fields.push(`damagePerCardDrawnThisCombat = ${c.damagePerCardDrawnThisCombat}`);
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
if (c.attackPoison != null) fields.push(`attackPoison = ${c.attackPoison}`);
if (c.otherHandAtLeast != null) fields.push(`otherHandAtLeast = ${c.otherHandAtLeast}`);
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
if (c.block != null) fields.push(`block = ${c.block}`);
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
if (c.strength != null) fields.push(`strength = ${c.strength}`);
if (c.dex != null) fields.push(`dex = ${c.dex}`);
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`);
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}`);
if (!c.class) throw new Error(`[gen-slaydeck] 카드 ${id}에 class 누락`);
fields.push(`class = ${luaStr(c.class)}`);
fields.push(`rarity = ${luaStr(c.rarity)}`);
if (c.hits != null) fields.push(`hits = ${c.hits}`);
if (c.pierce === true) fields.push('pierce = true');
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
if (c.draw != null) fields.push(`draw = ${c.draw}`);
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
if (c.heal != null) fields.push(`heal = ${c.heal}`);
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
if (c.poison != null) fields.push(`poison = ${c.poison}`);
if (c.discard != null) fields.push(`discard = ${c.discard}`);
if (c.discardAll === true) fields.push('discardAll = true');
if (c.drawPerDiscarded != null) fields.push(`drawPerDiscarded = ${c.drawPerDiscarded}`);
if (c.addShiv != null) fields.push(`addShiv = ${c.addShiv}`);
if (c.turnStartShiv != null) fields.push(`turnStartShiv = ${c.turnStartShiv}`);
if (c.turnStartDraw != null) fields.push(`turnStartDraw = ${c.turnStartDraw}`);
if (c.turnStartDiscard != null) fields.push(`turnStartDiscard = ${c.turnStartDiscard}`);
if (c.handCostZeroThisTurn === true) fields.push('handCostZeroThisTurn = true');
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
if (c.useAllEnergy === true) fields.push('useAllEnergy = true');
if (c.xDamagePerEnergy != null) fields.push(`xDamagePerEnergy = ${c.xDamagePerEnergy}`);
if (c.xWeakPerEnergy != null) fields.push(`xWeakPerEnergy = ${c.xWeakPerEnergy}`);
if (c.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`);
if (c.nextTurnDraw != null) fields.push(`nextTurnDraw = ${c.nextTurnDraw}`);
if (c.nextTurnKeepBlock === true) fields.push('nextTurnKeepBlock = true');
if (c.nextTurnAttackMultiplier != null) fields.push(`nextTurnAttackMultiplier = ${c.nextTurnAttackMultiplier}`);
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
if (c.innate === true) fields.push('innate = true');
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
if (c.sly === true) fields.push('sly = true');
if (c.retain === true) fields.push('retain = true');
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) fields.push('exhaust = true');
if (c.aoe === true) fields.push('aoe = true');
if (c.unplayable === true) fields.push('unplayable = true');
if (c.curse === true) fields.push('curse = true');
if (c.token === true) fields.push('token = true');
if (c.endTurnDamage != null) fields.push(`endTurnDamage = ${c.endTurnDamage}`);
if (c.fx != null) fields.push(`fx = ${luaStr(c.fx)}`);
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
return `\t${id} = { ${fields.join(', ')} },`;
});
return `self.Cards = {\n${lines.join('\n')}\n}`;
}
function luaDeckTable(deck) {
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
}
export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };

View File

@@ -0,0 +1,338 @@
const UI_FILE = 'ui/DefaultGroup.ui';
const COMMON_FILE = 'Global/common.gamelogic';
const UI_ROOT = '/ui/DefaultGroup';
const GENERATED_UI_SECTIONS = [
'DeckHud',
'DeckInspectHud',
'DeckAllHud',
'CombatHud',
'RewardHud',
'MapHud',
'ShopHud',
'RestHud',
'TreasureHud',
'JobChoiceHud',
'JobSelectHud',
'MainMenu',
'LobbyHud',
'BoardHud',
'SoulShopHud',
];
const UI_APPEND_ORDER = [
'DeckHud',
'CombatHud',
'RewardHud',
'MapHud',
'ShopHud',
'RestHud',
'TreasureHud',
'JobChoiceHud',
'JobSelectHud',
'DeckInspectHud',
'DeckAllHud',
'MainMenu',
'LobbyHud',
'BoardHud',
'SoulShopHud',
];
const DISABLED_STOCK_CONTROLS = ['Button_Attack', 'Button_Jump', 'UIJoystick'];
const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
const DARK = { r: 0.08, g: 0.09, b: 0.11, a: 0.92 };
const GOLD = { r: 0.94, g: 0.74, b: 0.26, a: 1 };
const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1 };
const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1 };
const SKILL = { r: 0.46, g: 0.68, b: 0.52, a: 1 };
const DAMAGE_DIGIT_RUIDS = [
'b94c19830538447f81617035d89bcc05',
'01b023122a6f4a5789e1d4c61ff8f430',
'57ff71d1b9eb471b9feb1c15348770c9',
'cab92837798a42ad9143c67e93f999e1',
'366f271f9ca94a0684083aad9298efad',
'5c7a6ad38491466aa84bf450e0fdcf25',
'7d82a6838e1b4f4a8a0f7420db34c985',
'c0765bb1e47d46ffbe1df4ac19ea9b1b',
'6ea0bfed61e149f88a9b3f22dd79774f',
'82ad2acaae4e4b3fb87bf73635250d22',
];
const DAMAGE_POP_MAX_DIGITS = 5;
const DAMAGE_POP_DIGIT_W = 22;
const DAMAGE_POP_DIGIT_H = 32;
const DAMAGE_POP_DIGIT_SPACING = -4;
const MAX_MONSTERS = 4;
const HEAD_OFFSET_Y = 1.4; // 몬스터 월드 원점 위로 띄울 높이(머리 위) — world→screen 변환 전 가산
const HP_BAR_W = 140;
const WHITE = { r: 1, g: 1, b: 1, a: 1 };
const CARD_NAME_TEXT = { r: 1, g: 0.92, b: 0.62, a: 1 };
const CARD_DESC_TEXT = { r: 0.98, g: 0.96, b: 0.9, a: 1 };
// 카드 프레임(1054×1492 원본) 슬롯 레이아웃 — 픽셀 실측을 180×250 카드 좌표로 환산한 기준값을 폭 비례 스케일.
// 실측(워리어·메이지·밴딧 공통): 육각 중심 (120,127)→(-70,104) · 배너 본체 y55..165, x215..1015→중심 (+15,+107)
// · 설명 박스 y~1030..1480→중심 (0,-86) · 아트 영역 y260..1030→중심 (0,+17)
function cardFaceLayout(W) {
const s = W / 180;
const r = (v) => Math.round(v * s);
return {
texts: [
['Cost', { size: { x: r(40), y: r(40) }, pos: { x: r(-70), y: r(104) }, fontSize: r(24), bold: true, color: WHITE, dropShadow: false, outlineWidth: 2 }],
['Name', { size: { x: r(142), y: r(28) }, pos: { x: r(15), y: r(106) }, fontSize: r(17), bold: true, color: CARD_NAME_TEXT, dropShadow: false, outlineWidth: 2 }],
['Desc', { size: { x: r(158), y: r(78) }, pos: { x: 0, y: r(-82) }, fontSize: r(14), bold: true, color: CARD_DESC_TEXT, dropShadow: false, outlineWidth: 2 }],
],
art: { size: { x: r(112), y: r(112) }, pos: { x: 0, y: r(17) } },
};
}
const CARD_W = 180;
const CARD_H = 250;
const CARD_SPACING = 200;
const CARD_XS = [-400, -200, 0, 200, 400];
const ALIGN_CENTER = 0;
const ALIGN_BOTTOM_CENTER = 6;
function guid(prefix, n) {
// 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑.
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : prefix === 'job' ? 0xe4
: prefix === 'ins2' ? 0xe5 : prefix === 'all2' ? 0xe6 : prefix === 'rwd2' ? 0xe7 : prefix === 'shp2' ? 0xe8 : prefix === 'lob' ? 0xe9 : prefix === 'brd' ? 0xea : prefix === 'soul' ? 0xeb : 0xfe;
const v = (ns * 0x100000 + n) >>> 0;
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
}
function transform({ parentW, parentH, anchor, pivot, size, pos, align = 0 }) {
const offMin = { x: pos.x - pivot.x * size.x, y: pos.y - pivot.y * size.y };
const offMax = { x: pos.x + (1 - pivot.x) * size.x, y: pos.y + (1 - pivot.y) * size.y };
return {
'@type': 'MOD.Core.UITransformComponent',
ActivePlatform: 255,
AlignmentOption: align,
AnchorsMax: anchor,
AnchorsMin: anchor,
MobileOnly: false,
OffsetMax: offMax,
OffsetMin: offMin,
Pivot: pivot,
RectSize: size,
UIMode: 1,
UIScale: { x: 1, y: 1, z: 1 },
UIVersion: 2,
anchoredPosition: pos,
Position: { x: anchor.x * parentW - parentW / 2 + pos.x, y: anchor.y * parentH - parentH / 2 + pos.y, z: 0 },
QuaternionRotation: { x: 0, y: 0, z: 0, w: 1 },
Scale: { x: 1, y: 1, z: 1 },
Enable: true,
};
}
function sprite({ dataId = '', color = TRANSPARENT, type = 1, raycast = false }) {
return {
'@type': 'MOD.Core.SpriteGUIRendererComponent',
AnimClipPlayType: 0,
EndFrameIndex: 2147483647,
ImageRUID: { DataId: dataId },
LocalPosition: { x: 0, y: 0 },
LocalScale: { x: 1, y: 1 },
OverrideSorting: false,
PlayRate: 1,
PreserveSprite: 0,
StartFrameIndex: 0,
Color: color,
DropShadow: false,
DropShadowAngle: 30,
DropShadowColor: { r: 0, g: 0, b: 0, a: 0.72 },
DropShadowDistance: 32,
FillAmount: 1,
FillCenter: true,
FillClockWise: true,
FillMethod: 0,
FillOrigin: 0,
FlipX: false,
FlipY: false,
FrameColumn: 1,
FrameRate: 0,
FrameRow: 1,
Outline: false,
OutlineColor: { r: 0, g: 0, b: 0, a: 1 },
OutlineWidth: 3,
RaycastTarget: raycast,
Type: type,
Enable: true,
};
}
function button({ enabled = true } = {}) {
return {
'@type': 'MOD.Core.ButtonComponent',
Colors: {
NormalColor: { r: 1, g: 1, b: 1, a: 1 },
HighlightedColor: { r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1 },
PressedColor: { r: 0.784313738, g: 0.784313738, b: 0.784313738, a: 1 },
SelectedColor: { r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1 },
DisabledColor: { r: 0.784313738, g: 0.784313738, b: 0.784313738, a: 0.5019608 },
ColorMultiplier: 1,
FadeDuration: 0.1,
},
ImageRUIDs: {
HighlightedSprite: null,
PressedSprite: null,
SelectedSprite: null,
DisabledSprite: null,
},
KeyCode: 0,
OverrideSorting: false,
Transition: 1,
Enable: enabled,
};
}
function text({ value, fontSize, bold = false, color = { r: 1, g: 1, b: 1, a: 1 }, alignment = 4, dropShadow = false, outlineWidth = 1 }) {
return {
'@type': 'MOD.Core.TextComponent',
Alignment: alignment,
Bold: bold,
DropShadow: dropShadow,
DropShadowAngle: 30,
DropShadowColor: { r: 0, g: 0, b: 0, a: 0.72 },
DropShadowDistance: dropShadow ? 18 : 32,
Font: 0,
FontColor: color,
FontSize: fontSize,
MaxSize: fontSize,
MinSize: 8,
OutlineColor: { r: 0.08, g: 0.08, b: 0.08, a: 1 },
OutlineDistance: { x: 1, y: -1 },
OutlineWidth: outlineWidth,
Overflow: 0,
OverrideSorting: false,
Padding: { left: 0, right: 0, top: 0, bottom: 0 },
SizeFit: false,
Text: value,
UseOutLine: true,
Enable: true,
};
}
function scrollLayoutGroup({ cellSize, spacing, columns }) {
return {
'@type': 'MOD.Core.ScrollLayoutGroupComponent',
CellSize: cellSize,
ChildAlignment: 0,
Constraint: 1,
ConstraintCount: columns,
GridChildAlignment: 0,
GridSpacing: spacing,
HorizontalScrollBarDirection: 0,
IgnoreMapLayerCheck: false,
OrderInLayer: 0,
OverrideSorting: false,
Padding: { left: 16, right: 16, top: 16, bottom: 16 },
ReverseArrangement: false,
ScrollBarBackgroundColor: { r: 1, g: 1, b: 1, a: 0.18 },
ScrollBarBgImageRUID: { DataId: '' },
ScrollBarHandleColor: { r: 0.94, g: 0.74, b: 0.26, a: 0.9 },
ScrollBarHandleImageRUID: { DataId: '' },
ScrollBarThickness: 12,
ScrollBarVisible: 2,
SortingLayer: 'UI',
Spacing: 0,
StartAxis: 0,
StartCorner: 0,
Type: 2,
UseScroll: true,
VerticalScrollBarDirection: 1,
Enable: true,
};
}
function popupLayerFor(path) {
if (path.startsWith('/ui/DefaultGroup/DeckAllHud')) return { root: '/ui/DefaultGroup/DeckAllHud', base: 4000 };
if (path.startsWith('/ui/DefaultGroup/DeckInspectHud')) return { root: '/ui/DefaultGroup/DeckInspectHud', base: 3000 };
return null;
}
function uiOrderFor(path, displayOrder) {
const popup = popupLayerFor(path);
if (popup != null) {
const relative = path.slice(popup.root.length).split('/').filter(Boolean);
return popup.base + relative.length * 100 + displayOrder;
}
return displayOrder;
}
function displayOrderFor(path, displayOrder) {
return uiOrderFor(path, displayOrder);
}
function applySortingOverride(path, components, displayOrder) {
if (popupLayerFor(path) == null) return components;
const order = uiOrderFor(path, displayOrder);
return components.map((component) => {
if (component['@type'] !== 'MOD.Core.SpriteGUIRendererComponent' && component['@type'] !== 'MOD.Core.TextComponent') {
return component;
}
return {
...component,
OverrideSorting: true,
SortingLayer: 'UI',
OrderInLayer: order,
};
});
}
function entity({ id, path, modelId, entryId, componentNames, components, displayOrder }) {
const parts = path.split('/');
const name = parts[parts.length - 1];
const sortedComponents = applySortingOverride(path, components, displayOrder);
return {
id,
path,
componentNames,
jsonString: {
name,
path,
nameEditable: true,
enable: true,
visible: true,
localize: true,
displayOrder: displayOrderFor(path, displayOrder),
pathConstraints: '/'.repeat(parts.length - 1),
revision: 1,
origin: {
type: 'Model',
entry_id: entryId,
sub_entity_id: null,
root_entity_id: null,
replaced_model_id: null,
},
modelId,
'@components': sortedComponents,
'@version': 1,
},
};
}
function uiPath(...parts) {
return [UI_ROOT, ...parts].join('/');
}
function sectionRoot(section) {
return uiPath(section);
}
function isGeneratedUiEntity(e) {
return GENERATED_UI_SECTIONS.some((section) => e.path.startsWith(sectionRoot(section)));
}
function appendUiSection(ui, section, entities) {
if (!GENERATED_UI_SECTIONS.includes(section)) {
throw new Error(`[gen-slaydeck] unknown generated UI section: ${section}`);
}
const root = sectionRoot(section);
for (const e of entities) {
if (!e.path.startsWith(root)) {
throw new Error(`[gen-slaydeck] ${section} section emitted unexpected path: ${e.path}`);
}
}
ui.ContentProto.Entities.push(...entities);
}
export { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection };

View File

@@ -0,0 +1,28 @@
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// 일회성·멱등 마이그레이션: cb/*.mjs의 UI 경로 리터럴을 메이커 재편 UIGroup으로 재연결.
// 이미 이동된 경로는 매치 안 됨(멱등). MainMenu·Button_Attack/Jump·UIJoystick(=DefaultGroup 잔류)은 미변경.
// 섹션→UIGroup 매핑은 tools/verify/uimap.mjs 탐색으로 검증된 실제 .ui 분포 기준.
const MOVE = {
CharacterSelectHud: 'SelectUIGroup', JobChoiceHud: 'SelectUIGroup', JobSelectHud: 'SelectUIGroup',
LobbyHud: 'LobbyUIGroup', BoardHud: 'LobbyUIGroup', SoulShopHud: 'LobbyUIGroup',
CombatHud: 'RunUIGroup', DeckHud: 'RunUIGroup', CardHand: 'RunUIGroup', MapHud: 'RunUIGroup',
RewardHud: 'RunUIGroup', ShopHud: 'RunUIGroup', RestHud: 'RunUIGroup', TreasureHud: 'RunUIGroup',
DeckInspectHud: 'DeckUIGroup', DeckAllHud: 'DeckUIGroup',
};
const CB_DIR = 'tools/deck/cb';
let n = 0;
for (const f of readdirSync(CB_DIR).filter((x) => x.endsWith('.mjs'))) {
const p = join(CB_DIR, f);
const before = readFileSync(p, 'utf8');
let s = before;
// 1) 몬스터 슬롯: 그룹+이름 동시 (CombatHud 일반 remap보다 먼저). 슬롯 5→4는 MAX_MONSTERS(=4)가 이미 반영.
s = s.split('/ui/DefaultGroup/CombatHud/MonsterSlot').join('/ui/RunUIGroup/CombatHud/MonsterStatus');
// 2) 섹션별 그룹 접두사 remap
for (const [section, group] of Object.entries(MOVE)) {
s = s.split(`/ui/DefaultGroup/${section}`).join(`/ui/${group}/${section}`);
}
if (s !== before) { writeFileSync(p, s, 'utf8'); n++; console.log(' remapped', f); }
}
console.log(`reconnect-ui-paths: ${n} files updated`);

69
tools/verify/cbgap.mjs Normal file
View File

@@ -0,0 +1,69 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// cb 컨트롤러가 참조하는 /ui/DefaultGroup/... 경로를, 사용자가 메이커에서 재편한
// 새 UIGroup(.ui) 구조에 대조해 "그대로 옮겨지면 해결(resolved)" vs "이름/구조가 바뀌어
// 못 찾음(GAP)"을 분류. GAP = 사용자에게 구→신 매핑을 받아야 할 항목.
// 출력은 경로 이름 + EXISTS/GAP boolean 뿐(엔티티 값 본문 미출력 → RULES §2 준수).
// 섹션(=DefaultGroup 다음 첫 세그먼트) → 이동된 UIGroup (uimap.mjs 탐색 결과 기반)
const SECTION_TO_GROUP = {
MainMenu: 'DefaultGroup', Button_Attack: 'DefaultGroup', Button_Jump: 'DefaultGroup', UIJoystick: 'DefaultGroup',
CharacterSelectHud: 'SelectUIGroup', JobChoiceHud: 'SelectUIGroup', JobSelectHud: 'SelectUIGroup',
LobbyHud: 'LobbyUIGroup', BoardHud: 'LobbyUIGroup', SoulShopHud: 'LobbyUIGroup',
CombatHud: 'RunUIGroup', DeckHud: 'RunUIGroup', CardHand: 'RunUIGroup', MapHud: 'RunUIGroup',
RewardHud: 'RunUIGroup', ShopHud: 'RunUIGroup', RestHud: 'RunUIGroup', TreasureHud: 'RunUIGroup',
DeckInspectHud: 'DeckUIGroup', DeckAllHud: 'DeckUIGroup',
};
// 새 .ui 로드
const UI_DIR = 'ui';
const ui = {};
for (const f of readdirSync(UI_DIR).filter((x) => x.endsWith('.ui'))) {
ui[f.replace('.ui', '')] = readFileSync(join(UI_DIR, f), 'utf8');
}
// cb 소스에서 /ui/DefaultGroup/... 경로 리터럴 추출 (템플릿 ${...} 포함)
const CB_DIR = 'tools/deck/cb';
const re = /\/ui\/DefaultGroup\/[^"'`\s),]+/g;
const paths = new Set();
for (const f of readdirSync(CB_DIR).filter((x) => x.endsWith('.mjs'))) {
const src = readFileSync(join(CB_DIR, f), 'utf8');
for (const m of src.match(re) || []) paths.add(m);
}
// 동적 세그먼트(${...}) 앞 정적 prefix만 취해 존재검사 (e.g. .../Card${i} → .../Card)
function staticPrefix(p) {
const i = p.indexOf('${');
if (i === -1) return { p, dyn: false };
// ${...} 직전까지 (마지막 세그먼트의 정적 앞부분 포함)
return { p: p.slice(0, i), dyn: true };
}
const bySection = {};
for (const p of [...paths].sort()) {
const rest = p.slice('/ui/DefaultGroup/'.length); // Section/child/...
const section = rest.split('/')[0].split('${')[0];
const group = SECTION_TO_GROUP[section] || '??';
const newPath = group === '??' ? p : p.replace('/ui/DefaultGroup/', `/ui/${group}/`);
const { p: probe } = staticPrefix(newPath);
const blob = ui[group] || '';
const exists = group !== '??' && blob.includes(probe);
(bySection[section] ||= []).push({ old: p, neu: newPath, exists, group });
}
// 보고
let totResolved = 0, totGap = 0;
for (const section of Object.keys(bySection).sort()) {
const rows = bySection[section];
const group = rows[0].group;
const gaps = rows.filter((r) => !r.exists);
const ok = rows.length - gaps.length;
totResolved += ok; totGap += gaps.length;
console.log(`\n[${section}] → ${group} 해결 ${ok} / GAP ${gaps.length} (총 ${rows.length})`);
for (const g of gaps) {
// GAP만 상세 출력 (사용자가 신규 이름 채워야 할 대상)
console.log(` GAP ${g.old.replace('/ui/DefaultGroup/', '')} →(없음) ${g.neu.replace(`/ui/${group}/`, `${group}: `)}`);
}
}
console.log(`\n=== 합계: 자동해결 ${totResolved} / GAP ${totGap} (distinct 경로 ${paths.size}) ===`);

View File

@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
// 산출물 바이트-동일 게이트: 워킹트리 vs 지정 ref(blob)를 줄바꿈 정규화 후 비교.
// 산출물 경로를 Bash 명령줄에 노출하지 않아 settings.json deny를 회피(count.mjs와 동일 취지).
// 사용: node tools/verify/diffcheck.mjs [ref] (ref 기본 HEAD; 예: origin/main)
const ref = process.argv[2] || 'HEAD';
const FILES = [
'ui/DefaultGroup.ui',
'RootDesk/MyDesk/SlayDeckController.codeblock',
'Global/common.gamelogic',
];
let allSame = true;
for (const f of FILES) {
const work = readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
const blob = execSync(`git show ${ref}:${f}`, { encoding: 'utf8', maxBuffer: 1 << 30 }).replace(/\r\n/g, '\n');
const same = work === blob;
if (!same) allSame = false;
console.log(`${same ? 'IDENTICAL ' : 'DIFFER '} ${f}${same ? '' : ` (work=${work.length} ${ref}=${blob.length})`}`);
}
console.log(allSame ? `\n=> 산출물이 ${ref}와 바이트-동일` : `\n=> ${ref}와 차이 있음 (확인 필요)`);

35
tools/verify/uimap.mjs Normal file
View File

@@ -0,0 +1,35 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
// UIGroup(.ui) 매핑 헬퍼 (RULES §2: 내용 출력 금지·카운트만).
// 어떤 섹션/엔티티 이름이 어느 .ui 파일에 들어있는지 카운트 매트릭스로 보고.
// 산출물 경로를 명령줄에 노출하지 않아(디렉토리 스캔) deny 회피.
//
// 사용: node tools/verify/uimap.mjs <pattern> [<pattern> ...]
// 각 pattern은 정규식. 출력은 "pattern | file=count ..." 형식(본문 미출력).
const UI_DIR = 'ui';
const files = readdirSync(UI_DIR).filter((f) => f.endsWith('.ui'));
const cache = {};
for (const f of files) {
cache[f] = readFileSync(join(UI_DIR, f), 'utf8');
}
// 파일별 크기/JSON 유효성 헤더
console.log('=== ui/*.ui ===');
for (const f of files) {
let ok = false;
try { JSON.parse(cache[f]); ok = true; } catch { ok = false; }
console.log(` ${f} bytes=${cache[f].length} jsonValid=${ok}`);
}
const pats = process.argv.slice(2);
if (pats.length === 0) process.exit(0);
console.log('=== matches (file=count, 0 생략) ===');
for (const pat of pats) {
const re = new RegExp(pat, 'g');
const hits = [];
for (const f of files) {
const m = cache[f].match(re);
const n = m ? m.length : 0;
if (n > 0) hits.push(`${f}=${n}`);
}
console.log(` /${pat}/ ${hits.length ? hits.join(' ') : '(none)'}`);
}

157957
ui/DeckUIGroup.ui Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5414
ui/LobbyUIGroup.ui Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -97,7 +97,7 @@
{
"@type": "MOD.Core.UIGroupComponent",
"DefaultShow": false,
"GroupOrder": 1,
"GroupOrder": 2,
"GroupType": 1,
"Enable": true
},
@@ -585,7 +585,7 @@
{
"id": "94a274e4-4111-40f1-924d-c95a3a1f14d5",
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnOK",
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent",
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent",
"jsonString": {
"name": "PopupBtnOK",
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnOK",
@@ -719,53 +719,6 @@
"Type": 1,
"Enable": true
},
{
"@type": "MOD.Core.ButtonComponent",
"Colors": {
"NormalColor": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
},
"HighlightedColor": {
"r": 0.9607843,
"g": 0.9607843,
"b": 0.9607843,
"a": 1.0
},
"PressedColor": {
"r": 0.784313738,
"g": 0.784313738,
"b": 0.784313738,
"a": 1.0
},
"SelectedColor": {
"r": 0.9607843,
"g": 0.9607843,
"b": 0.9607843,
"a": 1.0
},
"DisabledColor": {
"r": 0.784313738,
"g": 0.784313738,
"b": 0.784313738,
"a": 0.5019608
},
"ColorMultiplier": 1.0,
"FadeDuration": 0.1
},
"ImageRUIDs": {
"HighlightedSprite": null,
"PressedSprite": null,
"SelectedSprite": null,
"DisabledSprite": null
},
"KeyCode": 0,
"OverrideSorting": false,
"Transition": 1,
"Enable": true
},
{
"@type": "MOD.Core.TextComponent",
"Alignment": 4,
@@ -820,7 +773,7 @@
{
"id": "0f5de49b-2adc-409a-816d-15aa43df8e0d",
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnCancel",
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent",
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent",
"jsonString": {
"name": "PopupBtnCancel",
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnCancel",
@@ -954,53 +907,6 @@
"Type": 1,
"Enable": true
},
{
"@type": "MOD.Core.ButtonComponent",
"Colors": {
"NormalColor": {
"r": 1.0,
"g": 1.0,
"b": 1.0,
"a": 1.0
},
"HighlightedColor": {
"r": 0.9607843,
"g": 0.9607843,
"b": 0.9607843,
"a": 1.0
},
"PressedColor": {
"r": 0.784313738,
"g": 0.784313738,
"b": 0.784313738,
"a": 1.0
},
"SelectedColor": {
"r": 0.9607843,
"g": 0.9607843,
"b": 0.9607843,
"a": 1.0
},
"DisabledColor": {
"r": 0.784313738,
"g": 0.784313738,
"b": 0.784313738,
"a": 0.5019608
},
"ColorMultiplier": 1.0,
"FadeDuration": 0.1
},
"ImageRUIDs": {
"HighlightedSprite": null,
"PressedSprite": null,
"SelectedSprite": null,
"DisabledSprite": null
},
"KeyCode": 0,
"OverrideSorting": false,
"Transition": 1,
"Enable": true
},
{
"@type": "MOD.Core.TextComponent",
"Alignment": 4,

69868
ui/RunUIGroup.ui Normal file

File diff suppressed because it is too large Load Diff

6311
ui/SelectUIGroup.ui Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -97,7 +97,7 @@
{
"@type": "MOD.Core.UIGroupComponent",
"DefaultShow": false,
"GroupOrder": 2,
"GroupOrder": 3,
"GroupType": 1,
"Enable": true
},