Compare commits
48 Commits
codex/band
...
1847e2d9b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1847e2d9b2 | |||
| 5e2fd5db22 | |||
| 17200d47ec | |||
| 95d6155086 | |||
| de917f812d | |||
| 8a43ca91da | |||
| fc03d58ee7 | |||
| ead73b427e | |||
| d78049182b | |||
| 5f615e30e2 | |||
| 222ed92807 | |||
| 72750f3647 | |||
| 1291c52346 | |||
| 926733dbef | |||
| d7813f9912 | |||
| e6f351420b | |||
| b4a4560678 | |||
| 1e0b91294a | |||
| 8f8f17bd8f | |||
| 478fd1e5f0 | |||
| 0c1dfd3162 | |||
| 8d2e320d60 | |||
| 7b5e79bcf2 | |||
| 39356e5038 | |||
| 4878e5d8cc | |||
| 5f047ae41b | |||
| d83a377865 | |||
| 8292e26726 | |||
| 07ae56909a | |||
| f33018194f | |||
| a682baa5dc | |||
| 6d0ebde863 | |||
| 4ce87bec5d | |||
| 0cf714dca6 | |||
| fd00ed12d9 | |||
| 74a2106021 | |||
| a2044e20af | |||
| a3d5174b34 | |||
| 4f9be00ff2 | |||
| 24a79a309f | |||
| ba450f16b0 | |||
| 278007f908 | |||
| 16ebf304a5 | |||
| 5b7f7bb69f | |||
| 34531b184f | |||
| f6650a6c70 | |||
| 9278c47901 | |||
| 5da6e8f3aa |
@@ -121,6 +121,7 @@ slaymaple/
|
|||||||
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`키 **또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
|
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`키 **또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
|
||||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
||||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **121장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **121장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||||
|
| **도적 카드 공용 효과** | 카드 효과를 **카드명 하드코딩 대신 `data/cards.json` 공용 필드**로 표현(재사용). **불가침**·**x-cost**(에너지 비례 피해/약화)·드로우 수 비례 데미지·**다음 스킬 반복**·**처치 보상/반복**·카드 설명 **키워드 하이라이트**·드로우 연동(`drawSkillBlock`·`drawPoison`)·독 버스트·랜덤 타깃 등. **Lua + JS 미러 양쪽 구현**. 필드 사전 [`docs/card-effect-fields.md`](docs/card-effect-fields.md) |
|
||||||
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 −25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
|
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 −25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
|
||||||
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
|
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
|
||||||
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. 점선 경로·상태 4단·층 카운터. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
|
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. 점선 경로·상태 4단·층 카운터. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
|
||||||
@@ -130,7 +131,7 @@ slaymaple/
|
|||||||
| **승천(Ascension)** | A1~A10 누적 모디파이어(적 강화·시작 HP 감소·보상 감소). UserDataStorage 유저별 영구 저장, 런 클리어 시 다음 단계 해금 |
|
| **승천(Ascension)** | A1~A10 누적 모디파이어(적 강화·시작 HP 감소·보상 감소). UserDataStorage 유저별 영구 저장, 런 클리어 시 다음 단계 해금 |
|
||||||
| **멀티 act** | **5막** 진행(보스 클리어→다음 막 텔레포트, 맵·인카운터 변경, 적 스케일 `1+(막-1)*0.45`), 5막 클리어 시 런 종료 |
|
| **멀티 act** | **5막** 진행(보스 클리어→다음 막 텔레포트, 맵·인카운터 변경, 적 스케일 `1+(막-1)*0.45`), 5막 클리어 시 런 종료 |
|
||||||
| **경제** | 화폐 표기 **메소**(코인 아이콘), 카드/유물/물약 메소 가격. 내부 식별자는 Gold 유지 |
|
| **경제** | 화폐 표기 **메소**(코인 아이콘), 카드/유물/물약 메소 가격. 내부 식별자는 Gold 유지 |
|
||||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
|
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트(현 84종) |
|
||||||
|
|
||||||
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
|
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
|
||||||
> ℹ️ 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
> ℹ️ 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||||
|
|||||||
9
RULES.md
9
RULES.md
@@ -23,7 +23,7 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
|
|||||||
|
|
||||||
- `.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 시 토큰 폭발.
|
- `.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를 안 만든다).
|
- **게임 로직 수정** = `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`를 재생성.)
|
- **codeblock 메서드(Lua)는 관심사별 모듈** `tools/deck/cb/*.mjs`(boot·screens·npc·navigation·layout·combat·hand·deckview·items·map·shop 등 20종 — 화면전환=`screens`·NPC=`npc`·포지션=`navigation`(월드 텔레포트)/`layout`(UI 슬롯 배치). 새 메서드는 관심사에 맞는 모듈에 작성하고, 한 모듈이 비대해지면 분할한다. 횡단 관심사를 한 모듈에 몰아넣지 않는다). **공유분**: 상수·데이터·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 --`로 복원.
|
- 리팩터 시 **출력 바이트-동일 검증**: `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`.
|
- **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)**: 다른 브랜치가 단일체를 수정해 충돌나면, 그쪽 버전(`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은 오케스트레이터 잔류라 그쪽 변경은 자동 보존됨.
|
||||||
@@ -65,6 +65,7 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
|||||||
- 제목/본문은 UTF-8 spec JSON 파일로 작성 후 `create <spec.json>` / `merge <번호>`.
|
- 제목/본문은 UTF-8 spec JSON 파일로 작성 후 `create <spec.json>` / `merge <번호>`.
|
||||||
- PR 제목과 본문은 한국어로 작성한다.
|
- PR 제목과 본문은 한국어로 작성한다.
|
||||||
- 산출물 재생성 커밋은 소스 변경 커밋과 분리하거나, 메시지에 "산출물 재생성"을 명시.
|
- 산출물 재생성 커밋은 소스 변경 커밋과 분리하거나, 메시지에 "산출물 재생성"을 명시.
|
||||||
|
- **PR 머지 후 브랜치 삭제**: 머지된 `feature/*`·`docs/*` 브랜치는 로컬·원격 모두 삭제한다. 삭제 전 `git merge-base --is-ancestor origin/<브랜치> origin/main`로 완전 머지 확인(종료코드 0=완전 머지 → 삭제 가능). main에 없는 커밋이 남은 브랜치와 `codex/*` 등 작업 중 브랜치는 보존한다.
|
||||||
|
|
||||||
## 5. 메이커(MSW) 연동 주의
|
## 5. 메이커(MSW) 연동 주의
|
||||||
|
|
||||||
@@ -86,3 +87,9 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
|||||||
- UI 텍스트에서는 정수값인 숫자에 `.0`을 붙이지 않는다. `1.0/1.0`이 아니라 `1/1`처럼 표시한다.
|
- UI 텍스트에서는 정수값인 숫자에 `.0`을 붙이지 않는다. `1.0/1.0`이 아니라 `1/1`처럼 표시한다.
|
||||||
- 생성기 내 Lua UI 코드에서 number 또는 숫자 문자열을 텍스트에 붙일 때는 `FormatNumber` 같은 포맷 헬퍼를 우선 사용한다.
|
- 생성기 내 Lua UI 코드에서 number 또는 숫자 문자열을 텍스트에 붙일 때는 `FormatNumber` 같은 포맷 헬퍼를 우선 사용한다.
|
||||||
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.
|
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.
|
||||||
|
|
||||||
|
## 8. codeblock 변수명
|
||||||
|
|
||||||
|
- cb(`tools/deck/cb/*.mjs`)의 Lua 지역변수는 **의미가 드러나는 이름**으로 작성한다(`e`→`entity`, `n`→`count`, `m`→`monster`, `lp`→`localPlayer`, `s`→`soulPoints`, `tr`→`transform`). `a`/`b`/`c` 같은 무의미 단일문자 변수는 금지.
|
||||||
|
- 단, 순수 반복 인덱스 `i`/`j`/`r`/`c`는 관용상 허용한다.
|
||||||
|
- 새 cb 메서드를 작성하거나 기존 메서드를 손댈 때 이 규칙을 적용한다(대규모 일괄 개명은 별도 작업으로).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
7
cards_to_excel.bat
Normal file
7
cards_to_excel.bat
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
chcp 65001 >nul
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" export
|
||||||
|
echo.
|
||||||
|
echo Press any key to close this window.
|
||||||
|
pause >nul
|
||||||
100
data/cards.json
100
data/cards.json
@@ -14,7 +14,7 @@
|
|||||||
"Defend": {
|
"Defend": {
|
||||||
"name": "아이언 바디",
|
"name": "아이언 바디",
|
||||||
"cost": 1,
|
"cost": 1,
|
||||||
"kind": "Skill",
|
"kind": "Attack",
|
||||||
"block": 5,
|
"block": 5,
|
||||||
"desc": "방어도 5",
|
"desc": "방어도 5",
|
||||||
"image": "7648c3b8e1ca44fc8ec353561207a670",
|
"image": "7648c3b8e1ca44fc8ec353561207a670",
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"cost": 2,
|
"cost": 2,
|
||||||
"kind": "Attack",
|
"kind": "Attack",
|
||||||
"damage": 8,
|
"damage": 8,
|
||||||
|
"firstCardDamageBonus": 2,
|
||||||
"vuln": 2,
|
"vuln": 2,
|
||||||
"desc": "피해 8, 취약 2",
|
"desc": "피해 8, 취약 2",
|
||||||
"image": "fe83c7635b0e49ed83d75a2833adb53e",
|
"image": "fe83c7635b0e49ed83d75a2833adb53e",
|
||||||
@@ -89,8 +90,8 @@
|
|||||||
"name": "분노",
|
"name": "분노",
|
||||||
"cost": 1,
|
"cost": 1,
|
||||||
"kind": "Power",
|
"kind": "Power",
|
||||||
"powerEffect": "strengthPerTurn",
|
"aoe": true,
|
||||||
"value": 1,
|
"damage": 4,
|
||||||
"desc": "매 턴 시작 시 힘 +1",
|
"desc": "매 턴 시작 시 힘 +1",
|
||||||
"image": "379d86e3de064959aa4612f71e84ccfb",
|
"image": "379d86e3de064959aa4612f71e84ccfb",
|
||||||
"class": "warrior",
|
"class": "warrior",
|
||||||
@@ -479,6 +480,7 @@
|
|||||||
"desc": "피해를 8 줍니다. 약화를 1 부여합니다.",
|
"desc": "피해를 8 줍니다. 약화를 1 부여합니다.",
|
||||||
"weak": 1,
|
"weak": 1,
|
||||||
"damage": 8,
|
"damage": 8,
|
||||||
|
"cardPlayedDamage": 2,
|
||||||
"image": "92a5020c978c46bdabab910598118b86"
|
"image": "92a5020c978c46bdabab910598118b86"
|
||||||
},
|
},
|
||||||
"LeadingStrike": {
|
"LeadingStrike": {
|
||||||
@@ -526,7 +528,8 @@
|
|||||||
"damage": 3,
|
"damage": 3,
|
||||||
"hits": 4,
|
"hits": 4,
|
||||||
"sly": true,
|
"sly": true,
|
||||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
"image": "1b0f2dc8abd0434990eee1befefcbe0d",
|
||||||
|
"randomTargetEachHit": true
|
||||||
},
|
},
|
||||||
"Prepared": {
|
"Prepared": {
|
||||||
"name": "예비",
|
"name": "예비",
|
||||||
@@ -535,7 +538,7 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "normal",
|
"rarity": "normal",
|
||||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||||
"draw": 1,
|
"blockPerDamageDealtThisTurn": 1,
|
||||||
"discard": 1,
|
"discard": 1,
|
||||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||||
},
|
},
|
||||||
@@ -600,7 +603,9 @@
|
|||||||
"rarity": "normal",
|
"rarity": "normal",
|
||||||
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
|
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
|
||||||
"draw": 1,
|
"draw": 1,
|
||||||
"image": "0946f69d84464df29b24b94c744c868d"
|
"image": "0946f69d84464df29b24b94c744c868d",
|
||||||
|
"affectsAllEnemies": true,
|
||||||
|
"enemyStrengthLossThisTurn": 6
|
||||||
},
|
},
|
||||||
"CloakAndDagger": {
|
"CloakAndDagger": {
|
||||||
"name": "망토와 단검",
|
"name": "망토와 단검",
|
||||||
@@ -652,6 +657,8 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "피해를 8만큼 X번 줍니다.",
|
"desc": "피해를 8만큼 X번 줍니다.",
|
||||||
|
"useAllEnergy": true,
|
||||||
|
"xDamagePerEnergy": 8,
|
||||||
"draw": 1,
|
"draw": 1,
|
||||||
"image": "92a5020c978c46bdabab910598118b86"
|
"image": "92a5020c978c46bdabab910598118b86"
|
||||||
},
|
},
|
||||||
@@ -783,7 +790,10 @@
|
|||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
|
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
|
||||||
"vuln": 2,
|
"vuln": 2,
|
||||||
"image": "0946f69d84464df29b24b94c744c868d"
|
"image": "0946f69d84464df29b24b94c744c868d",
|
||||||
|
"affectsAllEnemies": true,
|
||||||
|
"removeEnemyBlock": true,
|
||||||
|
"removeEnemyArtifact": true
|
||||||
},
|
},
|
||||||
"HiddenDaggers": {
|
"HiddenDaggers": {
|
||||||
"name": "숨겨진 단검",
|
"name": "숨겨진 단검",
|
||||||
@@ -826,7 +836,8 @@
|
|||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
|
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
|
||||||
"block": 7,
|
"block": 7,
|
||||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||||
|
"turnHandSlyCount": 1
|
||||||
},
|
},
|
||||||
"Mirage": {
|
"Mirage": {
|
||||||
"name": "신기루",
|
"name": "신기루",
|
||||||
@@ -856,7 +867,8 @@
|
|||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
|
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
|
||||||
"poison": 9,
|
"poison": 9,
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
"image": "19361e72087946b1888684185b40d935",
|
||||||
|
"poisonIfTargetPoisoned": true
|
||||||
},
|
},
|
||||||
"Blur": {
|
"Blur": {
|
||||||
"name": "흐릿함",
|
"name": "흐릿함",
|
||||||
@@ -888,7 +900,8 @@
|
|||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
||||||
"addShiv": 3,
|
"addShiv": 3,
|
||||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
"image": "1b0f2dc8abd0434990eee1befefcbe0d",
|
||||||
|
"combatCostReductionOnPlay": 1
|
||||||
},
|
},
|
||||||
"BouncingFlask": {
|
"BouncingFlask": {
|
||||||
"name": "탄성 플라스크",
|
"name": "탄성 플라스크",
|
||||||
@@ -897,7 +910,9 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
|
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
|
||||||
"poison": 9,
|
"poison": 3,
|
||||||
|
"poisonHits": 3,
|
||||||
|
"poisonRandomTargets": true,
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
"image": "19361e72087946b1888684185b40d935"
|
||||||
},
|
},
|
||||||
"Reflex": {
|
"Reflex": {
|
||||||
@@ -970,12 +985,10 @@
|
|||||||
"kind": "Power",
|
"kind": "Power",
|
||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "중독을 3번 부여할 때마다, 모든 적에게 피해를 11 줍니다.",
|
"desc": "독이 3번 부여될 때마다 모든 적에게 11 피해를 줍니다.",
|
||||||
"aoe": true,
|
"image": "19361e72087946b1888684185b40d935",
|
||||||
"powerEffect": "strengthPerTurn",
|
"poisonApplicationBurstEvery": 3,
|
||||||
"value": 1,
|
"poisonApplicationBurstDamage": 11
|
||||||
"damage": 11,
|
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
|
||||||
},
|
},
|
||||||
"NoxiousFumes": {
|
"NoxiousFumes": {
|
||||||
"name": "유독 가스",
|
"name": "유독 가스",
|
||||||
@@ -996,8 +1009,7 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "표창의 피해량이 4 증가합니다.",
|
"desc": "표창의 피해량이 4 증가합니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
"shivDamageBonus": 4,
|
||||||
"value": 1,
|
|
||||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||||
},
|
},
|
||||||
"PhantomBlades": {
|
"PhantomBlades": {
|
||||||
@@ -1007,8 +1019,8 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
|
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
"shivRetain": true,
|
||||||
"value": 1,
|
"firstShivDamageBonus": 9,
|
||||||
"image": "0946f69d84464df29b24b94c744c868d"
|
"image": "0946f69d84464df29b24b94c744c868d"
|
||||||
},
|
},
|
||||||
"Speedster": {
|
"Speedster": {
|
||||||
@@ -1019,8 +1031,7 @@
|
|||||||
"rarity": "unique",
|
"rarity": "unique",
|
||||||
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
|
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
|
||||||
"aoe": true,
|
"aoe": true,
|
||||||
"powerEffect": "damagePerTurn",
|
"drawDamage": 2,
|
||||||
"value": 2,
|
|
||||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||||
},
|
},
|
||||||
"GrandFinale": {
|
"GrandFinale": {
|
||||||
@@ -1056,7 +1067,8 @@
|
|||||||
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
||||||
"aoe": true,
|
"aoe": true,
|
||||||
"damage": 10,
|
"damage": 10,
|
||||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
"image": "dbdbb1b56ae54672ae68ac6882fff6a2",
|
||||||
|
"repeatOnKill": true
|
||||||
},
|
},
|
||||||
"TheHunt": {
|
"TheHunt": {
|
||||||
"name": "사냥",
|
"name": "사냥",
|
||||||
@@ -1066,6 +1078,7 @@
|
|||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
|
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
|
||||||
"damage": 10,
|
"damage": 10,
|
||||||
|
"rewardOnKill": 1,
|
||||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||||
},
|
},
|
||||||
"Murder": {
|
"Murder": {
|
||||||
@@ -1076,6 +1089,7 @@
|
|||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "피해를 1 줍니다. 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가합니다.",
|
"desc": "피해를 1 줍니다. 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가합니다.",
|
||||||
"damage": 1,
|
"damage": 1,
|
||||||
|
"damagePerCardDrawnThisCombat": 1,
|
||||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||||
},
|
},
|
||||||
"Malaise": {
|
"Malaise": {
|
||||||
@@ -1085,7 +1099,8 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "적이 힘을 X 잃습니다. 약화를 X 부여합니다. 소멸.",
|
"desc": "적이 힘을 X 잃습니다. 약화를 X 부여합니다. 소멸.",
|
||||||
"weak": 3,
|
"useAllEnergy": true,
|
||||||
|
"xWeakPerEnergy": 1,
|
||||||
"image": "0946f69d84464df29b24b94c744c868d"
|
"image": "0946f69d84464df29b24b94c744c868d"
|
||||||
},
|
},
|
||||||
"Adrenaline": {
|
"Adrenaline": {
|
||||||
@@ -1138,7 +1153,7 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
|
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
|
||||||
"poison": 2,
|
"drawPoison": 2,
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
"image": "19361e72087946b1888684185b40d935"
|
||||||
},
|
},
|
||||||
"BladeOfInk": {
|
"BladeOfInk": {
|
||||||
@@ -1159,6 +1174,7 @@
|
|||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
|
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
|
||||||
"draw": 1,
|
"draw": 1,
|
||||||
|
"nextSkillRepeatCount": 1,
|
||||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||||
},
|
},
|
||||||
"KnifeTrap": {
|
"KnifeTrap": {
|
||||||
@@ -1221,10 +1237,9 @@
|
|||||||
"kind": "Power",
|
"kind": "Power",
|
||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "중독이 1번 추가로 발동합니다.",
|
"desc": "적 턴 시작 시 독이 한 번 더 틱합니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
"image": "19361e72087946b1888684185b40d935",
|
||||||
"value": 1,
|
"extraPoisonTicks": 1
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
|
||||||
},
|
},
|
||||||
"Envenom": {
|
"Envenom": {
|
||||||
"name": "독 바르기",
|
"name": "독 바르기",
|
||||||
@@ -1233,9 +1248,7 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "공격 카드가 막히지 않은 피해를 줄 때마다, 중독을 1 부여합니다.",
|
"desc": "공격 카드가 막히지 않은 피해를 줄 때마다, 중독을 1 부여합니다.",
|
||||||
"poison": 1,
|
"attackPoison": 1,
|
||||||
"powerEffect": "strengthPerTurn",
|
|
||||||
"value": 1,
|
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
"image": "19361e72087946b1888684185b40d935"
|
||||||
},
|
},
|
||||||
"MasterPlanner": {
|
"MasterPlanner": {
|
||||||
@@ -1244,10 +1257,9 @@
|
|||||||
"kind": "Power",
|
"kind": "Power",
|
||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "스킬 카드를 사용 시, 그 카드가 교활을 얻습니다.",
|
"desc": "사용한 스킬 카드는 교활해집니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||||
"value": 1,
|
"skillSlyOnPlay": true
|
||||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
|
||||||
},
|
},
|
||||||
"Tracking": {
|
"Tracking": {
|
||||||
"name": "추적",
|
"name": "추적",
|
||||||
@@ -1256,8 +1268,7 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
|
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
"attackDamageVsWeakMultiplier": 2,
|
||||||
"value": 1,
|
|
||||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||||
},
|
},
|
||||||
"FanOfKnives": {
|
"FanOfKnives": {
|
||||||
@@ -1267,9 +1278,8 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
|
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
|
||||||
"value": 1,
|
|
||||||
"addShiv": 4,
|
"addShiv": 4,
|
||||||
|
"shivAoe": true,
|
||||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||||
},
|
},
|
||||||
"SerpentForm": {
|
"SerpentForm": {
|
||||||
@@ -1279,9 +1289,7 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "카드를 사용할 때마다, 무작위 적에게 피해를 4 줍니다.",
|
"desc": "카드를 사용할 때마다, 무작위 적에게 피해를 4 줍니다.",
|
||||||
"powerEffect": "strengthPerTurn",
|
"cardPlayedRandomDamage": 4,
|
||||||
"value": 1,
|
|
||||||
"damage": 4,
|
|
||||||
"image": "19361e72087946b1888684185b40d935"
|
"image": "19361e72087946b1888684185b40d935"
|
||||||
},
|
},
|
||||||
"Abrasive": {
|
"Abrasive": {
|
||||||
@@ -1315,8 +1323,8 @@
|
|||||||
"class": "bandit",
|
"class": "bandit",
|
||||||
"rarity": "legend",
|
"rarity": "legend",
|
||||||
"desc": "불가침을 2 얻습니다. 내 턴 종료 시 민첩을 1 잃습니다.",
|
"desc": "불가침을 2 얻습니다. 내 턴 종료 시 민첩을 1 잃습니다.",
|
||||||
"powerEffect": "blockPerTurn",
|
"intangible": 2,
|
||||||
"value": 8,
|
"endTurnDexLoss": 1,
|
||||||
"image": "0946f69d84464df29b24b94c744c868d"
|
"image": "0946f69d84464df29b24b94c744c868d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
data/cards.xlsx
Normal file
BIN
data/cards.xlsx
Normal file
Binary file not shown.
10
docs/attack-poison.md
Normal file
10
docs/attack-poison.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 공격 적중 독
|
||||||
|
|
||||||
|
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
|
||||||
|
|
||||||
|
동작:
|
||||||
|
|
||||||
|
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
|
||||||
|
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
|
||||||
|
- `Envenom` 같은 카드가 이 필드를 사용합니다.
|
||||||
|
|
||||||
@@ -1,102 +1,22 @@
|
|||||||
# Bandit Card Audit
|
# Bandit Card Audit
|
||||||
|
|
||||||
`bandit` 카드의 구현 상태를 카드별로 정리한 문서입니다.
|
Current status of bandit cards and shared effect hooks.
|
||||||
|
|
||||||
상태 기준:
|
## Implemented
|
||||||
|
|
||||||
- `구현됨`: 공용 필드와 공용 로직으로 처리됨
|
`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`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `BouncingFlask`, `Accuracy`, `PhantomBlades`, `Speedster`, `CorrosiveWave`, `Tracking`, `FanOfKnives`, `Strangle`, `Mirage`, `Accelerant`, `MasterPlanner`, `Outbreak`, `EscapePlan`, `HandTrick`, `NoxiousFumes`, `Pinpoint`, `TheHunt`, `Murder`, `Malaise`, `BladeOfInk`, `KnifeTrap`, `BulletTime`, `Envenom`, `SerpentForm`, `WraithForm`, `Skewer`, `Ricochet`, `Anticipate`, `PiercingWail`, `Expose`, `UpMySleeve`, `EchoingSlash`, `BubbleBubble`
|
||||||
- `부분구현`: 카드 설명의 일부만 맞음
|
|
||||||
- `미구현`: 아직 전용 메커니즘이 없음
|
|
||||||
|
|
||||||
## 구현됨
|
Shared hooks already in use:
|
||||||
|
|
||||||
`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`, `blockPerDamageDealtThisTurn`, `nextSkillCostZero`, `skillCostReductionThisTurn`
|
||||||
|
- `firstCardDamageBonus`
|
||||||
|
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`, `skillSlyOnPlay`, `extraPoisonTicks`, `poisonApplicationBurstEvery`, `poisonApplicationBurstDamage`
|
||||||
|
|
||||||
공용 메모:
|
## Open questions
|
||||||
|
|
||||||
- `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코스트
|
|
||||||
- 드로우 연동 파워
|
|
||||||
|
|
||||||
|
None at the moment.
|
||||||
|
|||||||
@@ -1,87 +1,126 @@
|
|||||||
# Card Effect Fields
|
# Card Effect Fields
|
||||||
|
|
||||||
`data/cards.json`의 카드 효과를 공용 데이터 필드로 표현하는 기준 문서입니다.
|
This file tracks the shared data fields used by `data/cards.json`.
|
||||||
|
The goal is to keep card behavior reusable instead of hardcoding one-off card names.
|
||||||
|
|
||||||
## 피해 수치
|
## Damage
|
||||||
|
|
||||||
- `damage`: 기본 피해
|
- `damage`: base attack damage
|
||||||
- `damagePerOtherHandCard`: 손패의 다른 카드 수만큼 피해 증감
|
- `damagePerOtherHandCard`: bonus damage per other card in hand
|
||||||
- `damagePerAttackPlayedThisTurn`: 이번 턴에 사용한 공격 카드 수만큼 피해 증감
|
- `damagePerAttackPlayedThisTurn`: bonus damage per attack played this turn
|
||||||
- `damagePerDiscardedThisTurn`: 이번 턴에 버린 카드 수만큼 피해 증감
|
- `damagePerDiscardedThisTurn`: bonus damage per card discarded this turn
|
||||||
- `damagePerSkillInHand`: 손패의 스킬 카드 수만큼 피해 증감
|
- `damagePerSkillInHand`: bonus damage per skill card in hand
|
||||||
- `otherHandAtLeast`: 손패의 다른 카드가 이 수 이상일 때 조건 충족
|
- `damagePerCardDrawnThisCombat`: bonus damage per card drawn this combat
|
||||||
- `bonusHitsWhenOtherHandAtLeast`: 조건 충족 시 추가 적중 수
|
- `damagePerTurn`: damage applied at turn start
|
||||||
|
- `cardPlayedDamage`: damage when the card is played
|
||||||
|
- `cardPlayedRandomDamage`: random damage when the card is played
|
||||||
|
- `rewardOnKill`: gain bonus reward screens when the card kills
|
||||||
|
- `randomTargetEachHit`: choose a random alive enemy for each hit
|
||||||
|
- `repeatOnKill`: repeat the attack when it kills at least one enemy
|
||||||
|
- `firstCardDamageBonus`: bonus damage for the first card played this turn
|
||||||
|
- `drawDamage`: damage dealt when a card is drawn
|
||||||
|
- `blockPerDamageDealtThisTurn`: gain block equal to damage dealt this turn
|
||||||
|
- `shivDamageBonus`: bonus damage for all Shivs
|
||||||
|
- `firstShivDamageBonus`: bonus damage for the first Shiv each turn
|
||||||
|
- `attackDamageVsWeakMultiplier`: multiplier when the attack hits Weak targets
|
||||||
|
- `useAllEnergy`: treat the card as spending all available energy
|
||||||
|
- `xDamagePerEnergy`: scale attack damage by energy spent
|
||||||
|
- `xWeakPerEnergy`: scale Weak applied by energy spent
|
||||||
|
|
||||||
## 방어/상태
|
## Block and utility
|
||||||
|
|
||||||
- `block`: 방어도 획득
|
- `block`: gain block
|
||||||
- `cardPlayedBlock`: 카드를 사용할 때마다 방어도 획득
|
- `cardPlayedBlock`: gain block whenever a card is played
|
||||||
- `blockGainMultiplier`: 이번 턴 동안 얻는 방어도 배수
|
- `blockGainMultiplier`: multiplier for block gained this turn
|
||||||
- `hits`: 다단히트 횟수
|
- `hits`: multi-hit count
|
||||||
- `aoe`: 모든 적 대상
|
- `aoe`: hit all enemies
|
||||||
- `pierce`: 방어도 무시
|
- `pierce`: ignore block
|
||||||
- `draw`: 즉시 드로우
|
- `draw`: draw cards immediately
|
||||||
- `drawUntilHandSize`: 손패가 지정 장수에 도달할 때까지 드로우
|
- `drawUntilHandSize`: draw until hand reaches a target size
|
||||||
- `heal`: 즉시 회복
|
- `drawSkillBlock`: gain block for each Skill drawn
|
||||||
- `gainEnergy`: 즉시 에너지 획득
|
- `drawPoison`: apply poison when a card is drawn
|
||||||
- `strength`: 힘 획득
|
- `handCostZeroThisTurn`: make hand cards cost 0 this turn
|
||||||
- `dex`: 민첩 획득
|
- `drawDisabledThisTurn`: disable draw for the rest of the turn
|
||||||
- `thorns`: 가시 획득
|
- `heal`: heal immediately
|
||||||
- `selfVuln`: 자신에게 취약 부여
|
- `gainEnergy`: gain energy immediately
|
||||||
|
- `strength`: gain Strength
|
||||||
|
- `dex`: gain Dexterity
|
||||||
|
- `thorns`: gain Thorns
|
||||||
|
- `selfVuln`: apply Vulnerable to self
|
||||||
|
- `extraPoisonTicks`: add extra poison ticks at enemy turn start
|
||||||
|
|
||||||
## 상태이상
|
## Status
|
||||||
|
|
||||||
- `weak`: 약화 부여
|
- `weak`: apply Weak
|
||||||
- `vuln`: 취약 부여
|
- `vuln`: apply Vulnerable
|
||||||
- `poison`: 중독 부여
|
- `poison`: apply Poison
|
||||||
|
- `poisonHits`: apply poison multiple times
|
||||||
|
- `poisonRandomTargets`: spread poison applications across random alive enemies
|
||||||
|
- `poisonIfTargetPoisoned`: apply poison only if the target is already poisoned
|
||||||
|
- `poisonApplicationBurstEvery`: trigger a burst every N poison applications
|
||||||
|
- `poisonApplicationBurstDamage`: burst damage when the poison application threshold is reached
|
||||||
|
- `skillSlyOnPlay`: make a played Skill card count as sly when it is later discarded
|
||||||
|
- `turnHandSlyCount`: mark up to N other Skill cards in hand as sly for this turn
|
||||||
|
- `attackPoison`: apply poison when attack damage is dealt
|
||||||
|
- `intangible`: reduce incoming damage to 1 for the duration
|
||||||
|
- `endTurnDexLoss`: lose Dexterity at end of turn
|
||||||
|
- `combatCostReductionOnPlay`: reduce this card's cost each time it is played this combat
|
||||||
|
- `enemyStrengthLossThisTurn`: reduce enemy Strength for the rest of the turn
|
||||||
|
- `affectsAllEnemies`: apply the card's debuffs to every alive enemy
|
||||||
|
- `removeEnemyBlock`: clear enemy block when the card resolves
|
||||||
|
- `removeEnemyArtifact`: consume enemy Artifact when the card resolves
|
||||||
|
|
||||||
`poison`은 적 턴 시작 시 피해를 주고 1 감소합니다.
|
`poison` deals damage at enemy turn start and then decreases by 1.
|
||||||
|
|
||||||
## 드로우/버리기
|
## Shivs and discard
|
||||||
|
|
||||||
- `discard`: 손패에서 지정 장수 버리기
|
- `discard`: discard a chosen number of cards from hand
|
||||||
- `discardAll`: 손패 전부 버리기
|
- `discardAll`: discard the whole hand
|
||||||
- `drawPerDiscarded`: 버린 카드 1장당 추가 드로우
|
- `drawPerDiscarded`: draw one extra card per discarded card
|
||||||
- `addShiv`: 표창 생성
|
- `addShiv`: create Shiv cards
|
||||||
- `addShivPerDiscard`: 버린 장수만큼 표창 생성
|
- `addShivPerDiscard`: create one Shiv per discarded card
|
||||||
- `sly`: 버려질 때 교활 발동
|
- `shivRetain`: Shiv cards are retained at end of turn
|
||||||
- `retain`: 턴 종료 시 해당 카드 보존
|
- `shivAoe`: Shiv cards hit all enemies for the turn
|
||||||
|
- `sly`: trigger on discard
|
||||||
|
- `retain`: keep the card at end of turn
|
||||||
|
|
||||||
## 파워/턴 효과
|
## Powers and turn effects
|
||||||
|
|
||||||
- `powerEffect: "strengthPerTurn"`
|
- `powerEffect: "strengthPerTurn"`
|
||||||
- `powerEffect: "energyPerTurn"`
|
- `powerEffect: "energyPerTurn"`
|
||||||
- `powerEffect: "blockPerTurn"`
|
- `powerEffect: "blockPerTurn"`
|
||||||
|
- `powerEffect: "poisonPerTurn"`
|
||||||
|
- `powerEffect: "damagePerTurn"`
|
||||||
- `powerEffect: "retainOne"`
|
- `powerEffect: "retainOne"`
|
||||||
- `turnStartShiv`: 턴 시작 시 표창 생성
|
- `turnStartShiv`: create Shivs at turn start
|
||||||
- `turnStartDraw`: 턴 시작 시 추가 드로우
|
- `turnStartDraw`: draw cards at turn start
|
||||||
- `turnStartDiscard`: 턴 시작 시 카드 버리기
|
- `turnStartDiscard`: discard cards at turn start
|
||||||
|
|
||||||
## 다음 턴 예약
|
## Next turn planning
|
||||||
|
|
||||||
- `nextTurnBlock`: 다음 턴 시작 시 방어도 획득
|
- `nextTurnBlock`: gain block next turn
|
||||||
- `nextTurnDraw`: 다음 턴 시작 시 추가 드로우
|
- `nextTurnDraw`: draw extra cards next turn
|
||||||
- `nextTurnKeepBlock`: 다음 턴 시작 시 기존 방어도 유지
|
- `nextTurnKeepBlock`: keep block next turn
|
||||||
- `nextTurnAttackMultiplier`: 다음 턴 공격 피해 배수
|
- `nextTurnAttackMultiplier`: attack multiplier next turn
|
||||||
- `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수
|
- `nextTurnCopies`: copy a chosen card next turn
|
||||||
- `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택
|
- `nextTurnSelectHandCard`: choose a card from the current hand for next turn copies
|
||||||
- `nextTurnSelectPrompt`: 선택 UI 문구
|
- `nextTurnSelectPrompt`: prompt text for selection UI
|
||||||
- `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦
|
- `nextSkillRepeatCount`: repeat the next Skill's effect
|
||||||
- `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소
|
- `nextSkillCostZero`: make the next Skill cost 0
|
||||||
|
- `skillCostReductionThisTurn`: reduce Skill costs this turn
|
||||||
|
|
||||||
## 기타
|
## Misc
|
||||||
|
|
||||||
- `innate`: 전투 시작 시 첫 손패에 우선 진입
|
- `innate`: place the card in the opening hand
|
||||||
- `playableWhenDrawPileEmpty`: 뽑을 카드 더미가 비었을 때만 사용 가능
|
- `playableWhenDrawPileEmpty`: only playable when the draw pile is empty
|
||||||
- `exhaust`: 사용 후 소멸
|
- `exhaust`: exhaust after use
|
||||||
- `unplayable`: 사용 불가
|
- `unplayable`: cannot be played
|
||||||
- `curse`: 저주 카드
|
- `curse`: curse card
|
||||||
- `token`: 토큰 카드
|
- `token`: token card
|
||||||
- `endTurnDamage`: 턴 종료 시 손패에 있으면 피해
|
- `endTurnDamage`: damage if the card remains in hand at end of turn
|
||||||
|
|
||||||
## 사용 원칙
|
## Rules
|
||||||
|
|
||||||
- 카드 전용 분기보다 공용 필드를 먼저 쓴다.
|
|
||||||
- 같은 효과는 같은 필드로 재사용한다.
|
|
||||||
- 새 카드가 같은 패턴이면 먼저 공용 필드를 추가한다.
|
|
||||||
|
|
||||||
|
- Prefer shared fields over card-specific branches.
|
||||||
|
- Reuse the same field name for the same behavior.
|
||||||
|
- Add a new shared field before adding more special-case card logic.
|
||||||
|
|||||||
5
docs/card-play-damage.md
Normal file
5
docs/card-play-damage.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 카드 사용 시 피해
|
||||||
|
|
||||||
|
`cardPlayedDamage`는 카드를 사용할 때마다 현재 대상에게 체력을 직접 깎는 공용 효과입니다. 방어도는 무시하고, 같은 필드를 다른 카드에도 그대로 붙여 재사용할 수 있습니다.
|
||||||
|
|
||||||
|
`cardPlayedRandomDamage`는 같은 시점에 살아 있는 적 하나를 랜덤으로 골라 체력을 직접 깎습니다. `Strangle`과 `SerpentForm` 같은 카드가 이 계열을 씁니다.
|
||||||
8
docs/draw-count.md
Normal file
8
docs/draw-count.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 전투 드로우 누적
|
||||||
|
|
||||||
|
`damagePerCardDrawnThisCombat`은 이번 전투 동안 실제로 뽑힌 카드 수를 기준으로 공격력을 올리는 공용 필드입니다.
|
||||||
|
|
||||||
|
적용 예시:
|
||||||
|
|
||||||
|
- `Murder`: 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가
|
||||||
|
|
||||||
5
docs/intangible.md
Normal file
5
docs/intangible.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 불가침
|
||||||
|
|
||||||
|
`intangible`는 카드를 사용할 때 플레이어에게 불가침 수치를 부여하는 공용 필드입니다. 불가침이 남아 있는 동안 받는 피해는 1로 줄어들고, 턴이 끝날 때 1씩 감소합니다.
|
||||||
|
|
||||||
|
`endTurnDexLoss`는 그 카드가 활성화된 동안 매 턴 종료 시 민첩을 잃게 만드는 공용 필드입니다. `WraithForm` 같은 카드가 이 조합을 사용합니다.
|
||||||
12
docs/next-skill-repeat.md
Normal file
12
docs/next-skill-repeat.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Next Skill Repeat
|
||||||
|
|
||||||
|
`nextSkillRepeatCount`는 다음에 사용하는 스킬 카드의 효과를 추가 횟수만큼 다시 적용하는 공용 필드입니다.
|
||||||
|
|
||||||
|
현재 구현은 카드가 발동할 때 이 수치를 전역 상태에 누적해 두고, 다음 스킬 카드가 실제로 사용되면 그 효과를 같은 카드에 대해 다시 한 번 이상 적용합니다. 카드 종류는 고정하지 않았기 때문에, 같은 필드를 다른 카드에도 그대로 붙일 수 있습니다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- `Burst`
|
||||||
|
- `nextSkillRepeatCount = 1`
|
||||||
|
- 다음 스킬을 한 번 더 적용
|
||||||
|
|
||||||
5
docs/reward-on-kill.md
Normal file
5
docs/reward-on-kill.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 처치 보상
|
||||||
|
|
||||||
|
`rewardOnKill`은 해당 카드가 적을 처치했을 때 전투 보상 화면을 한 번 더 이어서 보여주는 공용 필드입니다. 현재 보상 UI는 3장 선택을 유지하고, 보상 화면만 추가로 한 번 더 열립니다.
|
||||||
|
|
||||||
|
`TheHunt`는 이 규칙을 사용합니다. 같은 패턴이 필요한 다른 카드에도 그대로 붙일 수 있습니다.
|
||||||
14
docs/x-cost.md
Normal file
14
docs/x-cost.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# X 코스트 카드
|
||||||
|
|
||||||
|
`useAllEnergy`는 카드가 사용될 때 남은 에너지를 전부 쓰는 공용 필드입니다.
|
||||||
|
|
||||||
|
연동 필드:
|
||||||
|
|
||||||
|
- `xDamagePerEnergy`: 에너지 1당 피해량
|
||||||
|
- `xWeakPerEnergy`: 에너지 1당 약화량
|
||||||
|
|
||||||
|
적용 예시:
|
||||||
|
|
||||||
|
- `Skewer`: 남은 에너지 전부를 써서 `8 * energy` 피해
|
||||||
|
- `Malaise`: 남은 에너지 전부를 써서 약화 부여
|
||||||
|
|
||||||
7
excel_to_cards.bat
Normal file
7
excel_to_cards.bat
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
chcp 65001 >nul
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" import
|
||||||
|
echo.
|
||||||
|
echo Press any key to close this window.
|
||||||
|
pause >nul
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
|
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
|
||||||
// ⚠️ 전투 규칙은 tools/deck/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
|
// ⚠️ 전투 규칙은 tools/deck/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
|
||||||
// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현)
|
// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현)
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -54,6 +54,10 @@ export function calcAttack(base, str, weak, vulnOnTarget) {
|
|||||||
return dmg;
|
return dmg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function calcEnemyAttack(base, str, weak, vulnOnTarget, strengthLoss = 0) {
|
||||||
|
return calcAttack(base, Math.max(0, str - strengthLoss), weak, vulnOnTarget);
|
||||||
|
}
|
||||||
|
|
||||||
// 방어 우선 차감 후 hp 적용 → { hp, block }
|
// 방어 우선 차감 후 hp 적용 → { hp, block }
|
||||||
export function applyDamage(hp, block, amount) {
|
export function applyDamage(hp, block, amount) {
|
||||||
let dmg = amount;
|
let dmg = amount;
|
||||||
@@ -95,26 +99,35 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
|||||||
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
|
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
|
||||||
let effectiveCost = card.cost || 0;
|
let effectiveCost = card.cost || 0;
|
||||||
if (ctx.handCostZeroThisTurn === true) effectiveCost = 0;
|
if (ctx.handCostZeroThisTurn === true) effectiveCost = 0;
|
||||||
|
else if (card.useAllEnergy === true) effectiveCost = 1;
|
||||||
else if (card.kind === 'Skill') {
|
else if (card.kind === 'Skill') {
|
||||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||||
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
|
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
|
||||||
}
|
}
|
||||||
return effectiveCost <= energy;
|
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
|
||||||
|
effectiveCost = Math.max(0, effectiveCost - ctx.combatCardCostReduction[x.id]);
|
||||||
|
}
|
||||||
|
return card.useAllEnergy === true ? true : effectiveCost <= energy;
|
||||||
});
|
});
|
||||||
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
||||||
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
||||||
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
||||||
const effectiveCost = (card) => {
|
const effectiveCost = (x) => {
|
||||||
|
const card = cards[x.id];
|
||||||
let cost = card.cost || 0;
|
let cost = card.cost || 0;
|
||||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||||
|
else if (card.useAllEnergy === true) cost = 1;
|
||||||
else if (card.kind === 'Skill') {
|
else if (card.kind === 'Skill') {
|
||||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||||
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
|
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
|
||||||
}
|
}
|
||||||
|
if (ctx.combatCardCostReduction && ctx.combatCardCostReduction[x.id] != null) {
|
||||||
|
cost = Math.max(0, cost - ctx.combatCardCostReduction[x.id]);
|
||||||
|
}
|
||||||
return cost;
|
return cost;
|
||||||
};
|
};
|
||||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(x), 1);
|
||||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||||
if (powers.length) return powers[0].i;
|
if (powers.length) return powers[0].i;
|
||||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||||
@@ -146,24 +159,103 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
const exhaust = [];
|
const exhaust = [];
|
||||||
let hand = [];
|
let hand = [];
|
||||||
let pHp = PLAYER_HP, pBlock = 0;
|
let pHp = PLAYER_HP, pBlock = 0;
|
||||||
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
|
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||||
let blockGainMultiplier = 1;
|
let blockGainMultiplier = 1;
|
||||||
let handCostZeroThisTurn = false;
|
let handCostZeroThisTurn = false;
|
||||||
let drawDisabledThisTurn = false;
|
let drawDisabledThisTurn = false;
|
||||||
let nextSkillCostZero = false;
|
let nextSkillCostZero = false;
|
||||||
|
let nextSkillRepeatCount = 0;
|
||||||
let skillCostReductionThisTurn = 0;
|
let skillCostReductionThisTurn = 0;
|
||||||
|
const combatCardCostReduction = {};
|
||||||
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
||||||
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
||||||
let nextTurnAddCards = [];
|
let nextTurnAddCards = [];
|
||||||
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
|
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
|
||||||
|
let turnCardsPlayedThisTurn = 0;
|
||||||
|
let damageDealtThisTurn = 0;
|
||||||
|
let shivFirstDamageBonusUsed = false;
|
||||||
|
let drawDamageThisTurn = 0;
|
||||||
|
let drawPoisonThisTurn = 0;
|
||||||
|
let shivAoeThisCombat = false;
|
||||||
|
const skillSlyOnPlayCards = new Set();
|
||||||
|
const turnSkillSlyCards = new Set();
|
||||||
|
let poisonApplicationsThisCombat = 0;
|
||||||
|
let enemyStrengthLossThisTurn = 0;
|
||||||
|
let cardsDrawnThisCombat = 0;
|
||||||
|
let bonusRewardScreens = 0;
|
||||||
|
let activeKillReward = 0;
|
||||||
let energy = 0;
|
let energy = 0;
|
||||||
const powers = [];
|
const powers = [];
|
||||||
const mob = monsters.map((m) => ({
|
const mob = monsters.map((m) => ({
|
||||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
|
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: m.str || 0, weak: 0, vuln: 0, poison: 0, artifact: m.artifact || 0,
|
||||||
intents: m.intents, intentIdx: 0, alive: true,
|
intents: m.intents, intentIdx: 0, alive: true,
|
||||||
}));
|
}));
|
||||||
let turns = 0;
|
let turns = 0;
|
||||||
|
|
||||||
|
const aliveMonsters = () => mob.filter((m) => m.alive);
|
||||||
|
const countAliveMonsters = () => aliveMonsters().length;
|
||||||
|
const randomAliveMonster = () => {
|
||||||
|
const alive = aliveMonsters();
|
||||||
|
if (!alive.length) return null;
|
||||||
|
return alive[Math.floor(rng() * alive.length)];
|
||||||
|
};
|
||||||
|
const removeEnemyBlock = (target) => {
|
||||||
|
if (target) target.block = 0;
|
||||||
|
};
|
||||||
|
const removeEnemyArtifact = (target) => {
|
||||||
|
if (target) target.artifact = 0;
|
||||||
|
};
|
||||||
|
const applyMonsterWeak = (target, amount) => {
|
||||||
|
if (!target || !amount || amount <= 0) return;
|
||||||
|
if (target.artifact > 0) { target.artifact--; return; }
|
||||||
|
target.weak += amount;
|
||||||
|
};
|
||||||
|
const applyMonsterVuln = (target, amount) => {
|
||||||
|
if (!target || !amount || amount <= 0) return;
|
||||||
|
if (target.artifact > 0) { target.artifact--; return; }
|
||||||
|
target.vuln += amount;
|
||||||
|
};
|
||||||
|
const applyPoisonToMonster = (target, amount) => {
|
||||||
|
if (!target || !target.alive || !amount || amount <= 0) return;
|
||||||
|
if (target.artifact > 0) { target.artifact--; return; }
|
||||||
|
target.poison += amount;
|
||||||
|
poisonApplicationsThisCombat += 1;
|
||||||
|
const burstEvery = powerFieldTotal('poisonApplicationBurstEvery');
|
||||||
|
const burstDamage = powerFieldTotal('poisonApplicationBurstDamage');
|
||||||
|
if (burstEvery > 0 && burstDamage > 0 && poisonApplicationsThisCombat % burstEvery === 0) {
|
||||||
|
for (const m of mob) {
|
||||||
|
if (!m.alive) continue;
|
||||||
|
const r = applyDamage(m.hp, m.block, burstDamage);
|
||||||
|
m.hp = r.hp; m.block = r.block;
|
||||||
|
if (burstDamage > 0) damageDealtThisTurn += burstDamage;
|
||||||
|
if (m.hp <= 0) m.alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dealDamageToMonster = (target, amount, pierce = false) => {
|
||||||
|
if (!target || !target.alive) return false;
|
||||||
|
let dmg = amount;
|
||||||
|
const effectiveStr = Math.max(0, target.str - enemyStrengthLossThisTurn);
|
||||||
|
dmg = calcAttack(dmg, effectiveStr, target.weak, 0);
|
||||||
|
if (target.vuln > 0) dmg = Math.floor(dmg * 1.5);
|
||||||
|
if (target.block > 0 && !pierce) {
|
||||||
|
const absorbed = Math.min(target.block, dmg);
|
||||||
|
target.block -= absorbed;
|
||||||
|
dmg -= absorbed;
|
||||||
|
}
|
||||||
|
target.hp -= dmg;
|
||||||
|
if (dmg > 0) {
|
||||||
|
const attackPoison = powerFieldTotal('attackPoison');
|
||||||
|
if (attackPoison > 0) applyPoisonToMonster(target, attackPoison);
|
||||||
|
}
|
||||||
|
if (target.hp <= 0) {
|
||||||
|
target.hp = 0;
|
||||||
|
target.alive = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
function draw(n) {
|
function draw(n) {
|
||||||
const drawn = [];
|
const drawn = [];
|
||||||
if (drawDisabledThisTurn === true) return drawn;
|
if (drawDisabledThisTurn === true) return drawn;
|
||||||
@@ -172,6 +264,27 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
if (drawPile.length === 0) break;
|
if (drawPile.length === 0) break;
|
||||||
const card = drawPile.pop();
|
const card = drawPile.pop();
|
||||||
drawn.push(card);
|
drawn.push(card);
|
||||||
|
cardsDrawnThisCombat++;
|
||||||
|
const drawDamage = powerFieldTotal('drawDamage') + drawDamageThisTurn;
|
||||||
|
const drawPoison = powerFieldTotal('drawPoison') + drawPoisonThisTurn;
|
||||||
|
if ((drawDamage > 0 || drawPoison > 0) && mob.some((m) => m.alive)) {
|
||||||
|
for (const m of mob) {
|
||||||
|
if (!m.alive) continue;
|
||||||
|
let dmg = drawDamage;
|
||||||
|
if (m.vuln > 0) dmg = Math.floor(dmg * 1.5);
|
||||||
|
if (m.block > 0) {
|
||||||
|
const absorbed = Math.min(m.block, dmg);
|
||||||
|
m.block -= absorbed;
|
||||||
|
dmg -= absorbed;
|
||||||
|
}
|
||||||
|
if (drawPoison > 0) applyPoisonToMonster(m, drawPoison);
|
||||||
|
if (dmg > 0) {
|
||||||
|
m.hp -= dmg;
|
||||||
|
damageDealtThisTurn += dmg;
|
||||||
|
}
|
||||||
|
if (m.hp <= 0) { m.hp = 0; m.alive = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
||||||
if (hand.length >= 10) {
|
if (hand.length >= 10) {
|
||||||
discard.push(card);
|
discard.push(card);
|
||||||
@@ -228,6 +341,12 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
|
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
|
||||||
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
|
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
|
||||||
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
|
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
|
||||||
|
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
|
||||||
|
if (c.class === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
|
||||||
|
if (c.class === 'shiv') {
|
||||||
|
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
|
||||||
|
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');
|
||||||
|
}
|
||||||
if (base < 0) base = 0;
|
if (base < 0) base = 0;
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -274,51 +393,131 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
let blockGained = 0;
|
let blockGained = 0;
|
||||||
if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier;
|
if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier;
|
||||||
if (c.nextSkillCostZero === true) nextSkillCostZero = true;
|
if (c.nextSkillCostZero === true) nextSkillCostZero = true;
|
||||||
|
if (c.nextSkillRepeatCount && c.nextSkillRepeatCount > 0) nextSkillRepeatCount += c.nextSkillRepeatCount;
|
||||||
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
|
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
|
||||||
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
|
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
|
||||||
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
|
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
|
||||||
|
if (c.drawDamage && c.kind !== 'Power') drawDamageThisTurn += c.drawDamage;
|
||||||
|
if (c.drawPoison && c.kind !== 'Power') drawPoisonThisTurn += c.drawPoison;
|
||||||
|
if (c.shivAoe === true && c.kind !== 'Power') shivAoeThisCombat = true;
|
||||||
|
if (c.skillSlyOnPlay === true && c.kind === 'Skill') skillSlyOnPlayCards.add(id);
|
||||||
|
if (c.turnHandSlyCount && c.turnHandSlyCount > 0) {
|
||||||
|
let picked = 0;
|
||||||
|
for (const hid of hand) {
|
||||||
|
if (hid === id) continue;
|
||||||
|
const hc = cards[hid];
|
||||||
|
if (hc?.kind === 'Skill' && !turnSkillSlyCards.has(hid) && !skillSlyOnPlayCards.has(hid) && hc.sly !== true) {
|
||||||
|
turnSkillSlyCards.add(hid);
|
||||||
|
picked++;
|
||||||
|
if (picked >= c.turnHandSlyCount) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const xEnergy = costSpent || 0;
|
||||||
if (c.kind === 'Attack') {
|
if (c.kind === 'Attack') {
|
||||||
if (alive.length && c.damage) {
|
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||||
const baseDamage = attackBaseForCard(id, c);
|
const baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c);
|
||||||
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
|
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
|
||||||
? c.bonusHitsWhenOtherHandAtLeast : 0;
|
? c.bonusHitsWhenOtherHandAtLeast : 0;
|
||||||
const hitN = (c.hits || 1) + bonusHits;
|
const hitN = (c.hits || 1) + bonusHits;
|
||||||
const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
let useAoe = c.aoe === true;
|
||||||
const target = chooseTarget(alive, preview);
|
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
|
||||||
if (c.weak) target.weak += c.weak;
|
const perHit = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||||
if (c.vuln) target.vuln += c.vuln;
|
const dealToTarget = (target, amount) => {
|
||||||
let totalNv = 0;
|
if (!target || !target.alive) return { killed: false, dealt: 0 };
|
||||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
let dealt = amount;
|
||||||
dmg = totalNv;
|
if (target.vuln > 0) dealt = Math.floor(dealt * 1.5);
|
||||||
if (c.aoe === true) {
|
if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
|
||||||
for (const m2 of aliveList()) {
|
dealt = Math.floor(dealt * c.attackDamageVsWeakMultiplier);
|
||||||
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;
|
|
||||||
if (m2.hp <= 0) m2.alive = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
|
||||||
if (c.pierce === true) {
|
if (c.pierce === true) {
|
||||||
target.hp -= dmg;
|
target.hp -= dealt;
|
||||||
if (target.hp < 0) target.hp = 0;
|
if (target.hp < 0) target.hp = 0;
|
||||||
} else {
|
} else {
|
||||||
const r = applyDamage(target.hp, target.block, dmg);
|
const r = applyDamage(target.hp, target.block, dealt);
|
||||||
target.hp = r.hp; target.block = r.block;
|
target.hp = r.hp; target.block = r.block;
|
||||||
}
|
}
|
||||||
if (target.hp <= 0) target.alive = false;
|
const attackPoison = powerFieldTotal('attackPoison');
|
||||||
}
|
if (dealt > 0 && attackPoison > 0) applyPoisonToMonster(target, attackPoison);
|
||||||
|
let killed = false;
|
||||||
|
if (target.hp <= 0) {
|
||||||
|
target.alive = false;
|
||||||
|
killed = true;
|
||||||
|
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
|
||||||
|
}
|
||||||
|
return { killed, dealt };
|
||||||
|
};
|
||||||
|
const resolveAttackRound = () => {
|
||||||
|
let roundKilled = false;
|
||||||
|
let roundDamage = 0;
|
||||||
|
if (useAoe === true) {
|
||||||
|
for (const m2 of aliveList()) {
|
||||||
|
const r2 = dealToTarget(m2, perHit);
|
||||||
|
roundDamage += r2.dealt;
|
||||||
|
if (r2.killed) roundKilled = true;
|
||||||
|
}
|
||||||
|
} else if (c.randomTargetEachHit === true) {
|
||||||
|
for (let h = 0; h < hitN; h++) {
|
||||||
|
const target = randomAliveMonster();
|
||||||
|
if (!target) break;
|
||||||
|
const r = dealToTarget(target, perHit);
|
||||||
|
roundDamage += r.dealt;
|
||||||
|
if (r.killed) roundKilled = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const preview = perHit;
|
||||||
|
const target = chooseTarget(aliveList(), preview);
|
||||||
|
if (target) {
|
||||||
|
if (c.weak) applyMonsterWeak(target, c.weak);
|
||||||
|
if (c.vuln) applyMonsterVuln(target, c.vuln);
|
||||||
|
const totalNv = perHit * hitN;
|
||||||
|
const r = dealToTarget(target, totalNv);
|
||||||
|
roundDamage += r.dealt;
|
||||||
|
if (r.killed) roundKilled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dmg += roundDamage;
|
||||||
|
damageDealtThisTurn += roundDamage;
|
||||||
|
return roundKilled;
|
||||||
|
};
|
||||||
|
let roundKilled = false;
|
||||||
|
do {
|
||||||
|
roundKilled = resolveAttackRound();
|
||||||
|
} while (c.repeatOnKill === true && roundKilled === true && countAliveMonsters() > 0);
|
||||||
}
|
}
|
||||||
if (c.block) blockGained = addBlock(c.block);
|
if (c.block) blockGained = addBlock(c.block);
|
||||||
} else if (c.kind === 'Power') {
|
} else if (c.kind === 'Power') {
|
||||||
if (recordStats) powers.push(id);
|
if (recordStats) powers.push(id);
|
||||||
} else {
|
} else {
|
||||||
if (c.block) blockGained = addBlock(c.block);
|
if (c.block) blockGained = addBlock(c.block);
|
||||||
if ((c.weak || c.vuln || c.poison) && alive.length) {
|
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
|
||||||
const target = chooseTarget(alive, 0);
|
const vulnAmount = c.vuln || 0;
|
||||||
if (c.weak) target.weak += c.weak;
|
if ((weakAmount || vulnAmount || c.poison || c.removeEnemyBlock || c.removeEnemyArtifact || c.enemyStrengthLossThisTurn) && alive.length) {
|
||||||
if (c.vuln) target.vuln += c.vuln;
|
const targets = c.affectsAllEnemies === true ? aliveList() : [chooseTarget(alive, 0)];
|
||||||
if (c.poison) target.poison += c.poison;
|
if (c.enemyStrengthLossThisTurn && c.enemyStrengthLossThisTurn > 0) {
|
||||||
|
enemyStrengthLossThisTurn += c.enemyStrengthLossThisTurn;
|
||||||
|
}
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!target || !target.alive) continue;
|
||||||
|
if (c.removeEnemyBlock === true) removeEnemyBlock(target);
|
||||||
|
if (c.removeEnemyArtifact === true) removeEnemyArtifact(target);
|
||||||
|
if (weakAmount) applyMonsterWeak(target, weakAmount);
|
||||||
|
if (vulnAmount) applyMonsterVuln(target, vulnAmount);
|
||||||
|
if (c.poison) {
|
||||||
|
if (c.poisonIfTargetPoisoned !== true || target.poison > 0) {
|
||||||
|
const poisonHits = c.poisonHits || 1;
|
||||||
|
for (let i = 0; i < poisonHits; i++) {
|
||||||
|
const target2 = c.poisonRandomTargets === true
|
||||||
|
? alive[Math.floor(rng() * alive.length)]
|
||||||
|
: target;
|
||||||
|
if (target2) applyPoisonToMonster(target2, c.poison);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
|
||||||
|
shivFirstDamageBonusUsed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (c.strength) pStr += c.strength;
|
if (c.strength) pStr += c.strength;
|
||||||
@@ -327,7 +526,10 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
if (c.selfVuln) pVuln += c.selfVuln;
|
if (c.selfVuln) pVuln += c.selfVuln;
|
||||||
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
||||||
if (c.gainEnergy) energy += c.gainEnergy;
|
if (c.gainEnergy) energy += c.gainEnergy;
|
||||||
|
activeKillReward = c.rewardOnKill || 0;
|
||||||
|
if (c.intangible) pIntangible += c.intangible;
|
||||||
queueNextTurnEffects(c);
|
queueNextTurnEffects(c);
|
||||||
|
turnCardsPlayedThisTurn++;
|
||||||
let drawnCards = [];
|
let drawnCards = [];
|
||||||
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
|
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
|
||||||
if (c.drawUntilHandSize) {
|
if (c.drawUntilHandSize) {
|
||||||
@@ -340,11 +542,36 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
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;
|
||||||
|
damageDealtThisTurn += 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;
|
||||||
|
damageDealtThisTurn += c.cardPlayedRandomDamage;
|
||||||
|
if (target.hp <= 0) target.alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
|
||||||
|
blockGained += Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn);
|
||||||
|
}
|
||||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||||
}
|
}
|
||||||
function triggerSly(id) {
|
function triggerSly(id) {
|
||||||
const c = cards[id];
|
const c = cards[id];
|
||||||
if (!c?.sly) return;
|
if (!c) return;
|
||||||
|
if (!c.sly && !skillSlyOnPlayCards.has(id) && !turnSkillSlyCards.has(id)) return;
|
||||||
resolveCardEffects(id, c, 0, false);
|
resolveCardEffects(id, c, 0, false);
|
||||||
}
|
}
|
||||||
function discardHandCard(idx, trigger = true) {
|
function discardHandCard(idx, trigger = true) {
|
||||||
@@ -371,6 +598,12 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
turns++;
|
turns++;
|
||||||
turnAttackCardsPlayed = 0;
|
turnAttackCardsPlayed = 0;
|
||||||
turnDiscardedCards = 0;
|
turnDiscardedCards = 0;
|
||||||
|
shivFirstDamageBonusUsed = false;
|
||||||
|
drawDamageThisTurn = 0;
|
||||||
|
drawPoisonThisTurn = 0;
|
||||||
|
shivAoeThisCombat = false;
|
||||||
|
turnSkillSlyCards.clear();
|
||||||
|
enemyStrengthLossThisTurn = 0;
|
||||||
blockGainMultiplier = 1;
|
blockGainMultiplier = 1;
|
||||||
handCostZeroThisTurn = false;
|
handCostZeroThisTurn = false;
|
||||||
drawDisabledThisTurn = false;
|
drawDisabledThisTurn = false;
|
||||||
@@ -390,7 +623,7 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
||||||
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
||||||
else if (pc.powerEffect === 'poisonPerTurn') {
|
else if (pc.powerEffect === 'poisonPerTurn') {
|
||||||
for (const m of mob) if (m.alive) m.poison += pc.value;
|
for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
|
||||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||||
for (const m of mob) {
|
for (const m of mob) {
|
||||||
if (!m.alive) continue;
|
if (!m.alive) continue;
|
||||||
@@ -416,24 +649,38 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const alive = aliveList();
|
const alive = aliveList();
|
||||||
if (alive.length === 0) break;
|
if (alive.length === 0) break;
|
||||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn });
|
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
|
||||||
if (idx < 0) break;
|
if (idx < 0) break;
|
||||||
const id = hand[idx], c = cards[id];
|
const id = hand[idx], c = cards[id];
|
||||||
|
let dmg = 0;
|
||||||
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
|
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
|
||||||
|
const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0;
|
||||||
const baseCost = c.cost || 0;
|
const baseCost = c.cost || 0;
|
||||||
const cost = handCostZeroThisTurn === true ? 0 : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost));
|
const combatReduction = combatCardCostReduction[id] || 0;
|
||||||
energy -= cost;
|
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||||
resolveCardEffects(id, c, cost);
|
const finalCost = Math.max(0, cost - combatReduction);
|
||||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
energy -= finalCost;
|
||||||
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
resolveCardEffects(id, c, finalCost);
|
||||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||||
if (playedBlock > 0) addBlock(playedBlock);
|
if (playedBlock > 0) addBlock(playedBlock);
|
||||||
|
if (skillRepeat > 0) {
|
||||||
|
nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat);
|
||||||
|
for (let r = 0; r < skillRepeat; r++) {
|
||||||
|
resolveCardEffects(id, c, finalCost);
|
||||||
|
if (playedBlock > 0) addBlock(playedBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||||
|
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
||||||
hand.splice(idx, 1);
|
hand.splice(idx, 1);
|
||||||
queueSelectedReserve(c);
|
queueSelectedReserve(c);
|
||||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
||||||
else if (c.kind !== 'Power') discard.push(id);
|
else if (c.kind !== 'Power') discard.push(id);
|
||||||
|
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
|
||||||
|
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;
|
||||||
|
}
|
||||||
applyDiscardEffects(c);
|
applyDiscardEffects(c);
|
||||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
|
||||||
}
|
}
|
||||||
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
|
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
|
||||||
let burn = 0;
|
let burn = 0;
|
||||||
@@ -442,10 +689,18 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
const kept = [];
|
const kept = [];
|
||||||
for (const hid of hand) {
|
for (const hid of hand) {
|
||||||
const hc = cards[hid];
|
const hc = cards[hid];
|
||||||
if (hc?.retain === true) kept.push(hid);
|
if (hc?.retain === true || (hc?.class === 'shiv' && powerFieldTotal('shivRetain') > 0)) kept.push(hid);
|
||||||
else discard.push(hid);
|
else discard.push(hid);
|
||||||
}
|
}
|
||||||
hand = kept;
|
hand = kept;
|
||||||
|
for (const pid of powers) {
|
||||||
|
const pc = cards[pid];
|
||||||
|
if (pc?.endTurnDexLoss) {
|
||||||
|
pDex -= pc.endTurnDexLoss;
|
||||||
|
if (pDex < 0) pDex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pIntangible > 0) pIntangible--;
|
||||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||||
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
||||||
if (pWeak > 0) pWeak--;
|
if (pWeak > 0) pWeak--;
|
||||||
@@ -453,19 +708,24 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
for (const m of mob) {
|
for (const m of mob) {
|
||||||
if (!m.alive) continue;
|
if (!m.alive) continue;
|
||||||
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
||||||
if (m.poison > 0) {
|
const poisonTicks = 1 + Math.max(0, powerFieldTotal('extraPoisonTicks'));
|
||||||
|
for (let tick = 0; tick < poisonTicks; tick++) {
|
||||||
|
if (m.poison <= 0) break;
|
||||||
m.hp -= m.poison;
|
m.hp -= m.poison;
|
||||||
m.poison--;
|
m.poison--;
|
||||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
if (m.hp <= 0) { m.hp = 0; m.alive = false; break; }
|
||||||
}
|
}
|
||||||
|
if (!m.alive) continue;
|
||||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
||||||
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
|
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
|
||||||
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
||||||
if (it) {
|
if (it) {
|
||||||
if (it.kind === 'Attack') {
|
if (it.kind === 'Attack') {
|
||||||
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
const atk = calcAttack(it.value, Math.max(0, m.str - enemyStrengthLossThisTurn), m.weak, pVuln);
|
||||||
const beforeHp = pHp;
|
const beforeHp = pHp;
|
||||||
const r = applyDamage(pHp, pBlock, atk); pHp = r.hp; pBlock = r.block;
|
let incoming = atk;
|
||||||
|
if (pIntangible > 0 && incoming > 1) incoming = 1;
|
||||||
|
const r = applyDamage(pHp, pBlock, incoming); pHp = r.hp; pBlock = r.block;
|
||||||
if (beforeHp > pHp && pThorns > 0) {
|
if (beforeHp > pHp && pThorns > 0) {
|
||||||
m.hp -= pThorns;
|
m.hp -= pThorns;
|
||||||
if (m.hp <= 0) m.alive = false;
|
if (m.hp <= 0) m.alive = false;
|
||||||
@@ -486,9 +746,9 @@ export function simulateCombat(data, rng, stats) {
|
|||||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||||
}
|
}
|
||||||
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
|
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
|
||||||
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp };
|
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
|
||||||
}
|
}
|
||||||
return { win: false, turns, playerHpRemaining: pHp, draw: true };
|
return { win: false, turns, playerHpRemaining: pHp, draw: true, bonusRewardScreens };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }
|
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll,
|
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, calcEnemyAttack, rarityForRoll,
|
||||||
} from './sim-balance.mjs';
|
} from './sim-balance.mjs';
|
||||||
|
|
||||||
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
||||||
@@ -713,6 +713,28 @@ test("simulateCombat: nextSkillCostZero makes the next skill free", () => {
|
|||||||
assert.equal(r.playerHpRemaining, 80);
|
assert.equal(r.playerHpRemaining, 80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: nextSkillRepeatCount repeats the next skill effect", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
Burst: { name: "Burst", cost: 1, kind: "Skill", draw: 1, block: 5, nextSkillRepeatCount: 1 },
|
||||||
|
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
||||||
|
},
|
||||||
|
starterDeck: ["Burst", "Guard"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 15 }] }],
|
||||||
|
};
|
||||||
|
const withBurst = simulateCombat(shared, () => 0.999999);
|
||||||
|
const withoutBurst = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
Burst: { name: "Burst", cost: 1, kind: "Skill", draw: 1, block: 5 },
|
||||||
|
Guard: shared.cards.Guard,
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
assert.equal(withBurst.draw, true);
|
||||||
|
assert.equal(withBurst.playerHpRemaining, 80);
|
||||||
|
assert.ok(withBurst.playerHpRemaining > withoutBurst.playerHpRemaining);
|
||||||
|
});
|
||||||
|
|
||||||
test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => {
|
test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => {
|
||||||
const cards = {
|
const cards = {
|
||||||
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
||||||
@@ -729,6 +751,21 @@ test("chooseAction: handCostZeroThisTurn lets expensive cards be played", () =>
|
|||||||
assert.equal(chooseAction(["Burst"], cards, 0, {}), -1);
|
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("chooseAction: combatCardCostReduction discounts the same card across combat", () => {
|
||||||
|
const cards = {
|
||||||
|
Sleeve: { name: "UpMySleeve", cost: 2, kind: "Skill" },
|
||||||
|
};
|
||||||
|
assert.equal(chooseAction(["Sleeve"], cards, 1, { combatCardCostReduction: { Sleeve: 1 } }), 0);
|
||||||
|
assert.equal(chooseAction(["Sleeve"], cards, 1, {}), -1);
|
||||||
|
});
|
||||||
|
|
||||||
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
|
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
|
||||||
const data = {
|
const data = {
|
||||||
cards: {
|
cards: {
|
||||||
@@ -777,3 +814,330 @@ test("simulateCombat: damagePerTurn powers damage all enemies at turn start", ()
|
|||||||
const r = simulateCombat(data, () => 0.999999);
|
const r = simulateCombat(data, () => 0.999999);
|
||||||
assert.equal(r.win, true);
|
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: skillSlyOnPlay makes later discards of the same skill trigger sly effects", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true },
|
||||||
|
},
|
||||||
|
starterDeck: ["MasterPlanner", "MasterPlanner"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
|
||||||
|
};
|
||||||
|
const withSly = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true, skillSlyOnPlay: true },
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
const withoutSly = simulateCombat(shared, () => 0.999999);
|
||||||
|
assert.equal(withSly.win, true);
|
||||||
|
assert.equal(withSly.turns, 1);
|
||||||
|
assert.ok(withoutSly.turns > withSly.turns);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: randomTargetEachHit can spread hits across alive enemies", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4, randomTargetEachHit: true },
|
||||||
|
},
|
||||||
|
starterDeck: ["Ricochet"],
|
||||||
|
monsters: [
|
||||||
|
{ name: "DummyA", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
{ name: "DummyB", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const makeRng = () => {
|
||||||
|
const seq = [0, 0.999999, 0, 0.999999];
|
||||||
|
let i = 0;
|
||||||
|
return () => seq[i++ % seq.length];
|
||||||
|
};
|
||||||
|
const withRicochet = simulateCombat(shared, makeRng());
|
||||||
|
const withoutRicochet = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4 },
|
||||||
|
},
|
||||||
|
}, makeRng());
|
||||||
|
assert.equal(withRicochet.win, true);
|
||||||
|
assert.equal(withRicochet.turns, 1);
|
||||||
|
assert.equal(withoutRicochet.turns, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", () => {
|
||||||
|
assert.equal(calcEnemyAttack(10, 6, 0, 0, 6), 10);
|
||||||
|
assert.equal(calcEnemyAttack(10, 6, 0, 0, 0), 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10, repeatOnKill: true },
|
||||||
|
},
|
||||||
|
starterDeck: ["EchoingSlash"],
|
||||||
|
monsters: [
|
||||||
|
{ name: "DummyA", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
{ name: "DummyB", maxHp: 20, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const withRepeat = simulateCombat(shared, () => 0.999999);
|
||||||
|
const withoutRepeat = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10 },
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
assert.equal(withRepeat.win, true);
|
||||||
|
assert.equal(withRepeat.turns, 1);
|
||||||
|
assert.equal(withoutRepeat.turns, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: poisonIfTargetPoisoned only applies poison to already poisoned enemies", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9, poisonIfTargetPoisoned: true },
|
||||||
|
},
|
||||||
|
starterDeck: ["Bubble"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }],
|
||||||
|
};
|
||||||
|
const withBubble = simulateCombat(shared, () => 0.999999);
|
||||||
|
const withoutBubble = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9 },
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
assert.equal(withBubble.draw, true);
|
||||||
|
assert.equal(withBubble.turns, 100);
|
||||||
|
assert.equal(withoutBubble.win, true);
|
||||||
|
assert.equal(withoutBubble.turns, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: turnHandSlyCount marks a skill in hand as sly for the turn", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7, turnHandSlyCount: 1 },
|
||||||
|
Shield: { name: "Shield", cost: 0, kind: "Skill", unplayable: true, block: 7 },
|
||||||
|
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true },
|
||||||
|
},
|
||||||
|
starterDeck: ["Gamble", "Shield", "HandTrick"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }],
|
||||||
|
};
|
||||||
|
const withHandTrick = simulateCombat(shared, () => 0.999999);
|
||||||
|
const withoutHandTrick = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7 },
|
||||||
|
Shield: shared.cards.Shield,
|
||||||
|
Gamble: shared.cards.Gamble,
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
assert.equal(withHandTrick.playerHpRemaining, 80);
|
||||||
|
assert.equal(withoutHandTrick.playerHpRemaining, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: extraPoisonTicks adds an extra poison tick at enemy turn start", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
Accelerant: { name: "Accelerant", cost: 1, kind: "Power", extraPoisonTicks: 1 },
|
||||||
|
Poison: { name: "Poison", cost: 1, kind: "Skill", poison: 2 },
|
||||||
|
},
|
||||||
|
starterDeck: ["Accelerant", "Poison"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
|
||||||
|
};
|
||||||
|
const withTick = simulateCombat(shared, () => 0.999999);
|
||||||
|
const withoutTick = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
Accelerant: { name: "Accelerant", cost: 1, kind: "Power" },
|
||||||
|
Poison: shared.cards.Poison,
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
assert.equal(withTick.win, true);
|
||||||
|
assert.equal(withTick.turns, 1);
|
||||||
|
assert.equal(withoutTick.turns, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: poisonApplicationBurstEvery bursts after every third poison application", () => {
|
||||||
|
const shared = {
|
||||||
|
cards: {
|
||||||
|
Outbreak: { name: "Outbreak", cost: 1, kind: "Power", poisonApplicationBurstEvery: 3, poisonApplicationBurstDamage: 11 },
|
||||||
|
Poison1: { name: "Poison1", cost: 0, kind: "Skill", poison: 1 },
|
||||||
|
Poison2: { name: "Poison2", cost: 0, kind: "Skill", poison: 1 },
|
||||||
|
Poison3: { name: "Poison3", cost: 0, kind: "Skill", poison: 1 },
|
||||||
|
},
|
||||||
|
starterDeck: ["Outbreak", "Poison1", "Poison2", "Poison3"],
|
||||||
|
monsters: [
|
||||||
|
{ name: "DummyA", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
{ name: "DummyB", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const withBurst = simulateCombat(shared, () => 0.999999);
|
||||||
|
const withoutBurst = simulateCombat({
|
||||||
|
...shared,
|
||||||
|
cards: {
|
||||||
|
Outbreak: { name: "Outbreak", cost: 1, kind: "Power" },
|
||||||
|
Poison1: shared.cards.Poison1,
|
||||||
|
Poison2: shared.cards.Poison2,
|
||||||
|
Poison3: shared.cards.Poison3,
|
||||||
|
},
|
||||||
|
}, () => 0.999999);
|
||||||
|
assert.equal(withBurst.win, true);
|
||||||
|
assert.equal(withBurst.turns, 1);
|
||||||
|
assert.ok(withoutBurst.turns > withBurst.turns);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: firstCardDamageBonus applies on the first card played this turn", () => {
|
||||||
|
const data = {
|
||||||
|
cards: {
|
||||||
|
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, firstCardDamageBonus: 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: blockPerDamageDealtThisTurn grants block from damage dealt this turn", () => {
|
||||||
|
const data = {
|
||||||
|
cards: {
|
||||||
|
Mirage: { name: "Mirage", cost: 1, kind: "Skill", blockPerDamageDealtThisTurn: 1, block: 0 },
|
||||||
|
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 4 },
|
||||||
|
},
|
||||||
|
starterDeck: ["Strike", "Mirage"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 4, 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: rewardOnKill grants an extra reward screen when an attack kills", () => {
|
||||||
|
const data = {
|
||||||
|
cards: {
|
||||||
|
TheHunt: { name: "TheHunt", cost: 1, kind: "Attack", damage: 10, rewardOnKill: 1 },
|
||||||
|
},
|
||||||
|
starterDeck: ["TheHunt"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||||
|
};
|
||||||
|
const r = simulateCombat(data, () => 0.999999);
|
||||||
|
assert.equal(r.win, true);
|
||||||
|
assert.equal(r.bonusRewardScreens, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => {
|
||||||
|
const data = {
|
||||||
|
cards: {
|
||||||
|
Wraith: { name: "WraithForm", cost: 3, kind: "Power", intangible: 2, endTurnDexLoss: 1, innate: true },
|
||||||
|
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
|
||||||
|
},
|
||||||
|
starterDeck: ["Wraith", "Strike"],
|
||||||
|
monsters: [{ name: "Dummy", maxHp: 1, intents: [{ kind: "Attack", value: 10 }] }],
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: shiv damage bonuses stack and first Shiv bonus applies once per turn", () => {
|
||||||
|
const data = {
|
||||||
|
cards: {
|
||||||
|
Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 },
|
||||||
|
PhantomBlades: { name: "PhantomBlades", cost: 1, kind: "Power", firstShivDamageBonus: 3 },
|
||||||
|
Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 },
|
||||||
|
},
|
||||||
|
starterDeck: ["Accuracy", "PhantomBlades", "Shiv"],
|
||||||
|
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, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simulateCombat: shivAoe makes Shivs hit all enemies", () => {
|
||||||
|
const data = {
|
||||||
|
cards: {
|
||||||
|
FanOfKnives: { name: "FanOfKnives", cost: 2, kind: "Skill", addShiv: 2, shivAoe: true },
|
||||||
|
Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 },
|
||||||
|
Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 },
|
||||||
|
Pass: { name: "Pass", cost: 99, kind: "Skill" },
|
||||||
|
},
|
||||||
|
starterDeck: ["Accuracy", "FanOfKnives", "Pass"],
|
||||||
|
monsters: [
|
||||||
|
{ name: "A", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
{ name: "B", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
{ name: "C", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const r = simulateCombat(data, () => 0.999999);
|
||||||
|
assert.equal(r.win, true);
|
||||||
|
assert.equal(r.turns, 1);
|
||||||
|
});
|
||||||
|
|||||||
629
tools/cards/cards_excel.ps1
Normal file
629
tools/cards/cards_excel.ps1
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true, Position = 0)]
|
||||||
|
[ValidateSet('export', 'import')]
|
||||||
|
[string]$Action,
|
||||||
|
[string]$JsonPath,
|
||||||
|
[string]$XlsxPath,
|
||||||
|
[string]$OutJsonPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
Add-Type -AssemblyName System.IO.Compression
|
||||||
|
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
if ([string]::IsNullOrWhiteSpace($JsonPath)) { $JsonPath = Join-Path $repoRoot 'data\cards.json' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($XlsxPath)) { $XlsxPath = Join-Path $repoRoot 'data\cards.xlsx' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutJsonPath)) { $OutJsonPath = $JsonPath }
|
||||||
|
|
||||||
|
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
|
||||||
|
function Escape-Xml([string]$Text) {
|
||||||
|
if ($null -eq $Text) { return '' }
|
||||||
|
return [System.Security.SecurityElement]::Escape($Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ColumnName([int]$Index) {
|
||||||
|
$n = $Index
|
||||||
|
$name = ''
|
||||||
|
while ($n -gt 0) {
|
||||||
|
$n--
|
||||||
|
$name = [char][int](65 + ($n % 26)) + $name
|
||||||
|
$n = [math]::Floor($n / 26)
|
||||||
|
}
|
||||||
|
return $name
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ColumnIndex([string]$Name) {
|
||||||
|
$n = 0
|
||||||
|
foreach ($ch in $Name.ToCharArray()) {
|
||||||
|
if ($ch -match '[A-Z]') {
|
||||||
|
$n = $n * 26 + ([int][char]$ch - 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $n
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CellRef([int]$Col, [int]$Row) {
|
||||||
|
return (Get-ColumnName $Col) + $Row
|
||||||
|
}
|
||||||
|
|
||||||
|
function Has-MapKey($Map, $Key) {
|
||||||
|
if ($null -eq $Map) { return $false }
|
||||||
|
if ($null -eq $Key) { return $false }
|
||||||
|
if ($Key -is [string] -and [string]::IsNullOrWhiteSpace($Key)) { return $false }
|
||||||
|
foreach ($existingKey in $Map.Keys) {
|
||||||
|
if ($existingKey -eq $Key) { return $true }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ScalarType($Value) {
|
||||||
|
if ($null -eq $Value) { return 'null' }
|
||||||
|
if ($Value -is [bool]) { return 'boolean' }
|
||||||
|
if ($Value -is [byte] -or $Value -is [sbyte] -or
|
||||||
|
$Value -is [int16] -or $Value -is [uint16] -or
|
||||||
|
$Value -is [int32] -or $Value -is [uint32] -or
|
||||||
|
$Value -is [int64] -or $Value -is [uint64] -or
|
||||||
|
$Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return 'number' }
|
||||||
|
if ($Value -is [string]) { return 'string' }
|
||||||
|
return 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CardSchema($Cards) {
|
||||||
|
$schema = [ordered]@{}
|
||||||
|
foreach ($cardEntry in $Cards.PSObject.Properties) {
|
||||||
|
$card = $cardEntry.Value
|
||||||
|
foreach ($prop in $card.PSObject.Properties) {
|
||||||
|
$kind = Get-ScalarType $prop.Value
|
||||||
|
if (-not (Has-MapKey $schema $prop.Name)) {
|
||||||
|
$schema[$prop.Name] = $kind
|
||||||
|
} elseif ($schema[$prop.Name] -ne $kind -and $kind -ne 'null') {
|
||||||
|
$schema[$prop.Name] = 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $schema
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ColumnWidth([string]$Header, [string]$Type) {
|
||||||
|
switch ($Header) {
|
||||||
|
'id' { return 18 }
|
||||||
|
'name' { return 24 }
|
||||||
|
'desc' { return 48 }
|
||||||
|
'image' { return 36 }
|
||||||
|
'fx' { return 36 }
|
||||||
|
'kind' { return 12 }
|
||||||
|
'class' { return 12 }
|
||||||
|
'rarity' { return 12 }
|
||||||
|
default {
|
||||||
|
if ($Type -eq 'boolean') { return 10 }
|
||||||
|
if ($Type -eq 'number') { return 12 }
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function To-InvariantNumber($Value) {
|
||||||
|
return [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0}', $Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-HeaderCellXml([string]$Ref, [string]$Text) {
|
||||||
|
$escaped = Escape-Xml $Text
|
||||||
|
return "<c r=""$Ref"" s=""1"" t=""inlineStr""><is><t xml:space=""preserve"">$escaped</t></is></c>"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-TextCellXml([string]$Ref, [string]$Text) {
|
||||||
|
$escaped = Escape-Xml $Text
|
||||||
|
return "<c r=""$Ref"" t=""inlineStr""><is><t xml:space=""preserve"">$escaped</t></is></c>"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-NumberCellXml([string]$Ref, $Value) {
|
||||||
|
if ($null -eq $Value) { return $null }
|
||||||
|
if ($Value -is [string] -and $Value -eq '') { return $null }
|
||||||
|
return "<c r=""$Ref""><v>$(To-InvariantNumber $Value)</v></c>"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-BoolCellXml([string]$Ref, $Value) {
|
||||||
|
if ($null -eq $Value) { return $null }
|
||||||
|
if ($Value -is [string] -and $Value -eq '') { return $null }
|
||||||
|
$bool = $false
|
||||||
|
if ($Value -is [bool]) {
|
||||||
|
$bool = $Value
|
||||||
|
} else {
|
||||||
|
$text = [string]$Value
|
||||||
|
if ($text -match '^(?i:true|1|yes|y)$') { $bool = $true }
|
||||||
|
elseif ($text -match '^(?i:false|0|no|n)$') { $bool = $false }
|
||||||
|
else { return $null }
|
||||||
|
}
|
||||||
|
$n = if ($bool) { 1 } else { 0 }
|
||||||
|
return "<c r=""$Ref"" t=""b""><v>$n</v></c>"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-CellXml([string]$Ref, $Value, [string]$Type) {
|
||||||
|
switch ($Type) {
|
||||||
|
'number' { return New-NumberCellXml $Ref $Value }
|
||||||
|
'boolean' { return New-BoolCellXml $Ref $Value }
|
||||||
|
default {
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return New-TextCellXml $Ref ''
|
||||||
|
}
|
||||||
|
return New-TextCellXml $Ref ([string]$Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WorksheetXml([string]$SheetName, [string[]]$Headers, [object[]]$Rows, [hashtable]$TypeMap) {
|
||||||
|
$maxCol = $Headers.Count
|
||||||
|
$lastCol = Get-ColumnName $maxCol
|
||||||
|
$rowCount = $Rows.Count + 1
|
||||||
|
$colsXml = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($i = 0; $i -lt $Headers.Count; $i++) {
|
||||||
|
$header = $Headers[$i]
|
||||||
|
$type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' }
|
||||||
|
$width = Get-ColumnWidth $header $type
|
||||||
|
$colsXml.Add("<col min=""$($i + 1)"" max=""$($i + 1)"" width=""$width"" customWidth=""1"" />")
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowsXml = New-Object System.Collections.Generic.List[string]
|
||||||
|
$headerCells = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($i = 0; $i -lt $Headers.Count; $i++) {
|
||||||
|
$headerCells.Add((New-HeaderCellXml (Get-CellRef ($i + 1) 1) $Headers[$i]))
|
||||||
|
}
|
||||||
|
$rowsXml.Add("<row r=""1"" spans=""1:$maxCol"" ht=""20"" customHeight=""1"">$($headerCells -join '')</row>")
|
||||||
|
|
||||||
|
for ($r = 0; $r -lt $Rows.Count; $r++) {
|
||||||
|
$row = $Rows[$r]
|
||||||
|
$cells = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($c = 0; $c -lt $Headers.Count; $c++) {
|
||||||
|
$header = $Headers[$c]
|
||||||
|
$type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' }
|
||||||
|
$value = $null
|
||||||
|
if (Has-MapKey $row $header) { $value = $row[$header] }
|
||||||
|
$cellXml = New-CellXml (Get-CellRef ($c + 1) ($r + 2)) $value $type
|
||||||
|
if ($null -ne $cellXml) { $cells.Add($cellXml) }
|
||||||
|
}
|
||||||
|
$rowsXml.Add("<row r=""$($r + 2)"" spans=""1:$maxCol"">$($cells -join '')</row>")
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheetView = '<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft" activeCell="A2" sqref="A2"/></sheetView></sheetViews>'
|
||||||
|
$cols = '<cols>' + ($colsXml -join '') + '</cols>'
|
||||||
|
$sheetData = '<sheetData>' + ($rowsXml -join '') + '</sheetData>'
|
||||||
|
$autoFilter = "<autoFilter ref=""A1:$lastCol$rowCount""/>"
|
||||||
|
return @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
$sheetView
|
||||||
|
<sheetFormatPr defaultRowHeight="18"/>
|
||||||
|
$cols
|
||||||
|
$sheetData
|
||||||
|
$autoFilter
|
||||||
|
<pageMargins left="0.25" right="0.25" top="0.5" bottom="0.5" header="0.3" footer="0.3"/>
|
||||||
|
</worksheet>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-StylesXml {
|
||||||
|
return @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<fonts count="2">
|
||||||
|
<font>
|
||||||
|
<sz val="11"/>
|
||||||
|
<color rgb="FF000000"/>
|
||||||
|
<name val="Calibri"/>
|
||||||
|
<family val="2"/>
|
||||||
|
<scheme val="minor"/>
|
||||||
|
</font>
|
||||||
|
<font>
|
||||||
|
<b/>
|
||||||
|
<sz val="11"/>
|
||||||
|
<color rgb="FFFFFFFF"/>
|
||||||
|
<name val="Calibri"/>
|
||||||
|
<family val="2"/>
|
||||||
|
<scheme val="minor"/>
|
||||||
|
</font>
|
||||||
|
</fonts>
|
||||||
|
<fills count="2">
|
||||||
|
<fill><patternFill patternType="none"/></fill>
|
||||||
|
<fill><patternFill patternType="solid"><fgColor rgb="FF2D3748"/><bgColor indexed="64"/></patternFill></fill>
|
||||||
|
</fills>
|
||||||
|
<borders count="1">
|
||||||
|
<border>
|
||||||
|
<left/><right/><top/><bottom/><diagonal/>
|
||||||
|
</border>
|
||||||
|
</borders>
|
||||||
|
<cellStyleXfs count="1">
|
||||||
|
<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>
|
||||||
|
</cellStyleXfs>
|
||||||
|
<cellXfs count="2">
|
||||||
|
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
||||||
|
<xf numFmtId="0" fontId="1" fillId="1" borderId="0" xfId="0" applyFont="1" applyFill="1"/>
|
||||||
|
</cellXfs>
|
||||||
|
<cellStyles count="1">
|
||||||
|
<cellStyle name="Normal" xfId="0" builtinId="0"/>
|
||||||
|
</cellStyles>
|
||||||
|
</styleSheet>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WorkbookXml([string[]]$SheetNames) {
|
||||||
|
$sheetsXml = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($i = 0; $i -lt $SheetNames.Count; $i++) {
|
||||||
|
$sheetsXml.Add("<sheet name=""$(Escape-Xml $SheetNames[$i])"" sheetId=""$($i + 1)"" r:id=""rId$($i + 1)""/>")
|
||||||
|
}
|
||||||
|
return @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets>
|
||||||
|
$($sheetsXml -join '')
|
||||||
|
</sheets>
|
||||||
|
</workbook>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WorkbookRelsXml([int]$SheetCount) {
|
||||||
|
$rels = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($i = 1; $i -le $SheetCount; $i++) {
|
||||||
|
$rels.Add("<Relationship Id=""rId$i"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"" Target=""worksheets/sheet$i.xml""/>")
|
||||||
|
}
|
||||||
|
$rels.Add("<Relationship Id=""rId$($SheetCount + 1)"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"" Target=""styles.xml""/>")
|
||||||
|
return @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
$($rels -join '')
|
||||||
|
</Relationships>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RootRelsXml {
|
||||||
|
return @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
</Relationships>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ContentTypesXml([int]$SheetCount) {
|
||||||
|
$overrides = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($i = 1; $i -le $SheetCount; $i++) {
|
||||||
|
$overrides.Add("<Override PartName=""/xl/worksheets/sheet$i.xml"" ContentType=""application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml""/>")
|
||||||
|
}
|
||||||
|
$overrides.Add('<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>')
|
||||||
|
$overrides.Add('<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>')
|
||||||
|
return @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
$($overrides -join '')
|
||||||
|
</Types>
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Xlsx([string]$Path, [hashtable]$Parts) {
|
||||||
|
$dir = Split-Path -Parent $Path
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path $dir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||||
|
}
|
||||||
|
if (Test-Path $Path) {
|
||||||
|
Remove-Item -LiteralPath $Path -Force
|
||||||
|
}
|
||||||
|
$file = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::ReadWrite)
|
||||||
|
try {
|
||||||
|
$zip = New-Object System.IO.Compression.ZipArchive($file, [System.IO.Compression.ZipArchiveMode]::Create, $false)
|
||||||
|
try {
|
||||||
|
foreach ($entryName in $Parts.Keys) {
|
||||||
|
$entry = $zip.CreateEntry($entryName)
|
||||||
|
$stream = $entry.Open()
|
||||||
|
$writer = New-Object System.IO.StreamWriter($stream, $utf8NoBom)
|
||||||
|
try {
|
||||||
|
$writer.Write([string]$Parts[$entryName])
|
||||||
|
} finally {
|
||||||
|
$writer.Dispose()
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$file.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-XlsxXml([string]$Path, [string]$EntryName) {
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead($Path)
|
||||||
|
try {
|
||||||
|
$entry = $zip.GetEntry($EntryName)
|
||||||
|
if ($null -eq $entry) { throw "Missing XLSX entry: $EntryName" }
|
||||||
|
$stream = $entry.Open()
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream, $utf8NoBom)
|
||||||
|
try { return $reader.ReadToEnd() } finally { $reader.Dispose() }
|
||||||
|
} finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$zip.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SharedStrings([string]$Path) {
|
||||||
|
try {
|
||||||
|
$xmlText = Read-XlsxXml $Path 'xl/sharedStrings.xml'
|
||||||
|
} catch {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
[xml]$xml = $xmlText
|
||||||
|
$ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
|
||||||
|
$ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main')
|
||||||
|
$items = $xml.SelectNodes('/x:sst/x:si', $ns)
|
||||||
|
$values = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($item in $items) {
|
||||||
|
$values.Add([string]$item.InnerText)
|
||||||
|
}
|
||||||
|
return $values.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-WorksheetRows([string]$XmlText, [string[]]$SharedStrings) {
|
||||||
|
[xml]$xml = $XmlText
|
||||||
|
$ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
|
||||||
|
$ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main')
|
||||||
|
$rows = $xml.SelectNodes('/x:worksheet/x:sheetData/x:row', $ns)
|
||||||
|
$parsed = @()
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$cells = @{}
|
||||||
|
foreach ($cell in @($row.ChildNodes)) {
|
||||||
|
if ($cell.Name -ne 'c') { continue }
|
||||||
|
$ref = [string]$cell.Attributes['r'].Value
|
||||||
|
$col = Get-ColumnIndex (($ref -replace '\d+$', ''))
|
||||||
|
$type = [string]$cell.Attributes['t'].Value
|
||||||
|
$text = [string]$cell.InnerText
|
||||||
|
if ($type -eq 's' -and $text -match '^\d+$') {
|
||||||
|
$index = [int]$text
|
||||||
|
if ($index -ge 0 -and $index -lt $SharedStrings.Count) {
|
||||||
|
$text = [string]$SharedStrings[$index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cells[$col] = $text
|
||||||
|
}
|
||||||
|
$parsed += ,$cells
|
||||||
|
}
|
||||||
|
return $parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-CellValue([string]$Text, [string]$Type) {
|
||||||
|
if ($null -eq $Text -or $Text -eq '') { return $null }
|
||||||
|
switch ($Type) {
|
||||||
|
'number' {
|
||||||
|
$num = 0
|
||||||
|
if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$num)) {
|
||||||
|
if ([math]::Abs($num - [math]::Round($num)) -lt 0.0000001) { return [int64][math]::Round($num) }
|
||||||
|
return $num
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
'boolean' {
|
||||||
|
if ($Text -match '^(?i:true|1|yes|y)$') { return $true }
|
||||||
|
if ($Text -match '^(?i:false|0|no|n)$') { return $false }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Export-Cards {
|
||||||
|
$source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json
|
||||||
|
$schema = Get-CardSchema $source.cards
|
||||||
|
$cardCore = @('id', 'name', 'cost', 'kind', 'rarity', 'class', 'desc', 'image', 'fx')
|
||||||
|
$cardExtras = @($schema.Keys | Where-Object { $_ -notin $cardCore } | Sort-Object)
|
||||||
|
$cardHeaders = @($cardCore + $cardExtras)
|
||||||
|
|
||||||
|
$maxDeckSize = 0
|
||||||
|
foreach ($deckEntry in $source.starterDecks.PSObject.Properties) {
|
||||||
|
$deckSize = @($deckEntry.Value).Count
|
||||||
|
if ($deckSize -gt $maxDeckSize) {
|
||||||
|
$maxDeckSize = $deckSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($maxDeckSize -lt 1) { $maxDeckSize = 1 }
|
||||||
|
|
||||||
|
$starterDeckHeaders = New-Object System.Collections.Generic.List[string]
|
||||||
|
$starterDeckHeaders.Add('class')
|
||||||
|
for ($i = 1; $i -le $maxDeckSize; $i++) {
|
||||||
|
$starterDeckHeaders.Add("slot$i")
|
||||||
|
}
|
||||||
|
|
||||||
|
$cardRows = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($cardEntry in $source.cards.PSObject.Properties) {
|
||||||
|
$cardId = $cardEntry.Name
|
||||||
|
$card = $cardEntry.Value
|
||||||
|
$row = [ordered]@{ id = $cardId }
|
||||||
|
foreach ($header in $cardHeaders) {
|
||||||
|
if ($header -eq 'id') { continue }
|
||||||
|
if ($card.PSObject.Properties.Name -contains $header) {
|
||||||
|
$row[$header] = $card.$header
|
||||||
|
} else {
|
||||||
|
$row[$header] = $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cardRows.Add($row)
|
||||||
|
}
|
||||||
|
|
||||||
|
$deckRows = New-Object System.Collections.Generic.List[object]
|
||||||
|
foreach ($deckEntry in $source.starterDecks.PSObject.Properties) {
|
||||||
|
$cls = $deckEntry.Name
|
||||||
|
$deck = @($deckEntry.Value)
|
||||||
|
$row = [ordered]@{ class = $cls }
|
||||||
|
for ($i = 1; $i -le $maxDeckSize; $i++) {
|
||||||
|
$key = "slot$i"
|
||||||
|
$row[$key] = if ($i -le $deck.Count) { $deck[$i - 1] } else { $null }
|
||||||
|
}
|
||||||
|
$deckRows.Add($row)
|
||||||
|
}
|
||||||
|
|
||||||
|
$cardSheet = Get-WorksheetXml 'Cards' $cardHeaders $cardRows $schema
|
||||||
|
$deckTypeMap = [ordered]@{ class = 'string' }
|
||||||
|
for ($i = 1; $i -le $maxDeckSize; $i++) { $deckTypeMap["slot$i"] = 'string' }
|
||||||
|
$deckSheet = Get-WorksheetXml 'StarterDecks' $starterDeckHeaders $deckRows $deckTypeMap
|
||||||
|
|
||||||
|
$parts = [ordered]@{
|
||||||
|
'[Content_Types].xml' = (Get-ContentTypesXml 2)
|
||||||
|
'_rels/.rels' = (Get-RootRelsXml)
|
||||||
|
'xl/workbook.xml' = (Get-WorkbookXml @('Cards', 'StarterDecks'))
|
||||||
|
'xl/_rels/workbook.xml.rels' = (Get-WorkbookRelsXml 2)
|
||||||
|
'xl/styles.xml' = (Get-StylesXml)
|
||||||
|
'xl/worksheets/sheet1.xml' = $cardSheet
|
||||||
|
'xl/worksheets/sheet2.xml' = $deckSheet
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Source JSON: $JsonPath"
|
||||||
|
Write-Host "Target XLSX: $XlsxPath"
|
||||||
|
Write-Xlsx $XlsxPath $parts
|
||||||
|
Write-Host "Excel export complete: $XlsxPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Import-Cards {
|
||||||
|
$source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json
|
||||||
|
$schema = Get-CardSchema $source.cards
|
||||||
|
$origCardOrders = @{}
|
||||||
|
foreach ($cardEntry in $source.cards.PSObject.Properties) {
|
||||||
|
$origCardOrders[$cardEntry.Name] = @($cardEntry.Value.PSObject.Properties.Name)
|
||||||
|
}
|
||||||
|
$origDeckOrder = @($source.starterDecks.PSObject.Properties.Name)
|
||||||
|
|
||||||
|
$sharedStrings = Read-SharedStrings $XlsxPath
|
||||||
|
$cardsXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet1.xml'
|
||||||
|
$deckXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet2.xml'
|
||||||
|
$cardRowsRaw = Read-WorksheetRows $cardsXml $sharedStrings
|
||||||
|
$deckRowsRaw = Read-WorksheetRows $deckXml $sharedStrings
|
||||||
|
|
||||||
|
if ($cardRowsRaw.Count -lt 2) { throw 'Cards sheet has no data rows.' }
|
||||||
|
if ($deckRowsRaw.Count -lt 2) { throw 'StarterDecks sheet has no data rows.' }
|
||||||
|
|
||||||
|
$cardHeaderMap = $cardRowsRaw[0]
|
||||||
|
$cardHeaders = @($cardHeaderMap.Keys | Sort-Object)
|
||||||
|
$orderedCardHeaders = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($col in $cardHeaders) {
|
||||||
|
$header = $cardHeaderMap[$col]
|
||||||
|
if ([string]::IsNullOrWhiteSpace($header)) { continue }
|
||||||
|
$orderedCardHeaders.Add($header)
|
||||||
|
}
|
||||||
|
|
||||||
|
$newCards = [ordered]@{}
|
||||||
|
for ($r = 1; $r -lt $cardRowsRaw.Count; $r++) {
|
||||||
|
$row = $cardRowsRaw[$r]
|
||||||
|
$cardId = $null
|
||||||
|
$rowValues = @{}
|
||||||
|
for ($c = 0; $c -lt $orderedCardHeaders.Count; $c++) {
|
||||||
|
$header = $orderedCardHeaders[$c]
|
||||||
|
$text = $null
|
||||||
|
if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] }
|
||||||
|
if ($header -eq 'id') {
|
||||||
|
$cardId = [string]$text
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$rowValues[$header] = $text
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($cardId)) {
|
||||||
|
$cardObj = [ordered]@{}
|
||||||
|
$fieldOrder = New-Object System.Collections.Generic.List[string]
|
||||||
|
if ($origCardOrders.ContainsKey($cardId)) {
|
||||||
|
foreach ($name in @($origCardOrders[$cardId])) {
|
||||||
|
if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) {
|
||||||
|
$fieldOrder.Add($name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($name in $orderedCardHeaders) {
|
||||||
|
if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) {
|
||||||
|
$fieldOrder.Add($name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($header in $fieldOrder) {
|
||||||
|
$text = $null
|
||||||
|
if (Has-MapKey $rowValues $header) { $text = $rowValues[$header] }
|
||||||
|
$type = if (Has-MapKey $schema $header) { [string]$schema[$header] } else { 'string' }
|
||||||
|
$value = Convert-CellValue $text $type
|
||||||
|
if ($null -eq $value) { continue }
|
||||||
|
$cardObj[$header] = $value
|
||||||
|
}
|
||||||
|
$newCards[$cardId] = $cardObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$deckHeaderMap = $deckRowsRaw[0]
|
||||||
|
$deckHeaderCols = @($deckHeaderMap.Keys | Sort-Object)
|
||||||
|
$orderedDeckHeaders = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($col in $deckHeaderCols) {
|
||||||
|
$header = $deckHeaderMap[$col]
|
||||||
|
if ([string]::IsNullOrWhiteSpace($header)) { continue }
|
||||||
|
$orderedDeckHeaders.Add($header)
|
||||||
|
}
|
||||||
|
|
||||||
|
$newDecks = [ordered]@{}
|
||||||
|
for ($r = 1; $r -lt $deckRowsRaw.Count; $r++) {
|
||||||
|
$row = $deckRowsRaw[$r]
|
||||||
|
$cls = $null
|
||||||
|
$deckValues = @{}
|
||||||
|
for ($c = 0; $c -lt $orderedDeckHeaders.Count; $c++) {
|
||||||
|
$header = $orderedDeckHeaders[$c]
|
||||||
|
$text = $null
|
||||||
|
if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] }
|
||||||
|
if ($header -eq 'class') {
|
||||||
|
$cls = [string]$text
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$deckValues[$header] = $text
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($cls)) {
|
||||||
|
$deck = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($header in $orderedDeckHeaders) {
|
||||||
|
if ($header -eq 'class') { continue }
|
||||||
|
$text = $null
|
||||||
|
if (Has-MapKey $deckValues $header) { $text = $deckValues[$header] }
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$text)) {
|
||||||
|
$deck.Add([string]$text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$newDecks[$cls] = $deck.ToArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($origDeckOrder.Count -gt 0) {
|
||||||
|
$orderedDecks = [ordered]@{}
|
||||||
|
foreach ($cls in $origDeckOrder) {
|
||||||
|
if (Has-MapKey $newDecks $cls) {
|
||||||
|
$orderedDecks[$cls] = $newDecks[$cls]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($entry in $newDecks.GetEnumerator()) {
|
||||||
|
if (-not (Has-MapKey $orderedDecks $entry.Key)) {
|
||||||
|
$orderedDecks[$entry.Key] = $entry.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$newDecks = $orderedDecks
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [ordered]@{
|
||||||
|
cards = $newCards
|
||||||
|
starterDecks = $newDecks
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = $out | ConvertTo-Json -Depth 64
|
||||||
|
Write-Host "Source XLSX: $XlsxPath"
|
||||||
|
Write-Host "Target JSON: $OutJsonPath"
|
||||||
|
[System.IO.File]::WriteAllText($OutJsonPath, $json, $utf8NoBom)
|
||||||
|
Write-Host "JSON import complete: $OutJsonPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Action) {
|
||||||
|
'export' { Export-Cards }
|
||||||
|
'import' { Import-Cards }
|
||||||
|
}
|
||||||
@@ -46,8 +46,11 @@ if self:CanPlayCardNow(c) ~= true then
|
|||||||
end
|
end
|
||||||
local cost = c.cost or 0
|
local cost = c.cost or 0
|
||||||
local skillFree = false
|
local skillFree = false
|
||||||
|
local skillRepeat = 0
|
||||||
if self.HandCostZeroThisTurn == true then
|
if self.HandCostZeroThisTurn == true then
|
||||||
\tcost = 0
|
cost = 0
|
||||||
|
elseif c.useAllEnergy == true then
|
||||||
|
cost = self.Energy
|
||||||
end
|
end
|
||||||
if c.kind == "Skill" and self.NextSkillCostZero == true then
|
if c.kind == "Skill" and self.NextSkillCostZero == true then
|
||||||
cost = 0
|
cost = 0
|
||||||
@@ -56,12 +59,42 @@ end
|
|||||||
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
||||||
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
|
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
|
||||||
end
|
end
|
||||||
|
if self.CombatCardCostReduction ~= nil and self.CombatCardCostReduction[cardId] ~= nil then
|
||||||
|
cost = math.max(0, cost - self.CombatCardCostReduction[cardId])
|
||||||
|
end
|
||||||
|
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
|
||||||
|
skillRepeat = self.NextSkillRepeatCount
|
||||||
|
end
|
||||||
if self.Energy < cost then
|
if self.Energy < cost then
|
||||||
self:Toast("에너지가 부족합니다")
|
self:Toast("에너지가 부족합니다")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.Energy = self.Energy - cost
|
self.Energy = self.Energy - cost
|
||||||
self:ResolveCardEffects(cardId, slot, c, false)
|
self.ActiveKillReward = c.rewardOnKill or 0
|
||||||
|
self:ResolveCardEffects(cardId, slot, c, false, cost)
|
||||||
|
local function applyCardPlayHooks()
|
||||||
|
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
|
||||||
|
end
|
||||||
|
applyCardPlayHooks()
|
||||||
|
if skillRepeat > 0 then
|
||||||
|
local remaining = (self.NextSkillRepeatCount or 0) - skillRepeat
|
||||||
|
if remaining < 0 then
|
||||||
|
remaining = 0
|
||||||
|
end
|
||||||
|
self.NextSkillRepeatCount = remaining
|
||||||
|
for i = 1, skillRepeat do
|
||||||
|
self:ResolveCardEffects(cardId, slot, c, false, cost)
|
||||||
|
applyCardPlayHooks()
|
||||||
|
end
|
||||||
|
end
|
||||||
if c.kind == "Attack" then
|
if c.kind == "Attack" then
|
||||||
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
|
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
|
||||||
end
|
end
|
||||||
@@ -70,8 +103,14 @@ if skillFree == true then
|
|||||||
self.NextSkillCostZero = false
|
self.NextSkillCostZero = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self:HasPowerField("cardPlayedBlock") == true then
|
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
|
||||||
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
|
self.ActiveKillReward = 0
|
||||||
|
end
|
||||||
|
if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then
|
||||||
|
if self.CombatCardCostReduction == nil then
|
||||||
|
self.CombatCardCostReduction = {}
|
||||||
|
end
|
||||||
|
self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay
|
||||||
end
|
end
|
||||||
table.remove(self.Hand, slot)
|
table.remove(self.Hand, slot)
|
||||||
if c.exhaust == true then
|
if c.exhaust == true then
|
||||||
@@ -227,7 +266,7 @@ end`, [
|
|||||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
|
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
|
||||||
]),
|
]),
|
||||||
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
|
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
|
||||||
method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex]
|
method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex]
|
||||||
if m == nil or m.alive ~= true then
|
if m == nil or m.alive ~= true then
|
||||||
m = nil
|
m = nil
|
||||||
for i = 1, #self.Monsters do
|
for i = 1, #self.Monsters do
|
||||||
@@ -235,29 +274,146 @@ if m == nil or m.alive ~= true then
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if m == nil then
|
if m == nil then
|
||||||
return
|
return false
|
||||||
end
|
end
|
||||||
local dmg = amount
|
local dmg = amount
|
||||||
if m.vuln > 0 then
|
if m.vuln > 0 then
|
||||||
dmg = math.floor(dmg * 1.5)
|
dmg = math.floor(dmg * 1.5)
|
||||||
end
|
end
|
||||||
|
if m.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
|
||||||
|
dmg = math.floor(dmg * self.ActiveAttackDamageVsWeakMultiplier)
|
||||||
|
end
|
||||||
if m.block > 0 and pierce ~= true then
|
if m.block > 0 and pierce ~= true then
|
||||||
local absorbed = math.min(m.block, dmg)
|
local absorbed = math.min(m.block, dmg)
|
||||||
m.block = m.block - absorbed
|
m.block = m.block - absorbed
|
||||||
dmg = dmg - absorbed
|
dmg = dmg - absorbed
|
||||||
end
|
end
|
||||||
m.hp = m.hp - dmg
|
m.hp = m.hp - dmg
|
||||||
|
if dmg > 0 then
|
||||||
|
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||||
|
if poison ~= nil and poison > 0 then
|
||||||
|
self:ApplyPoisonToMonster(m, poison)
|
||||||
|
end
|
||||||
|
end
|
||||||
self:MonsterHitMotion(m.slot)
|
self:MonsterHitMotion(m.slot)
|
||||||
|
local killed = false
|
||||||
if m.hp <= 0 then
|
if m.hp <= 0 then
|
||||||
m.hp = 0
|
m.hp = 0
|
||||||
self:KillMonster(m.slot)
|
self:KillMonster(m.slot)
|
||||||
end`, [
|
killed = true
|
||||||
|
end
|
||||||
|
return killed`, [
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
|
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
|
||||||
|
], 0, 'boolean'),
|
||||||
|
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 false
|
||||||
|
end
|
||||||
|
m.hp = m.hp - amount
|
||||||
|
self:ShowDmgPop(m.slot, amount)
|
||||||
|
self:MonsterHitMotion(m.slot)
|
||||||
|
local killed = false
|
||||||
|
if m.hp <= 0 then
|
||||||
|
m.hp = 0
|
||||||
|
self:KillMonster(m.slot)
|
||||||
|
killed = true
|
||||||
|
end
|
||||||
|
return killed`, [
|
||||||
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||||
|
], 0, 'boolean'),
|
||||||
|
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 false
|
||||||
|
end
|
||||||
|
local m = alive[math.random(1, #alive)]
|
||||||
|
if m == nil then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
m.hp = m.hp - amount
|
||||||
|
self:ShowDmgPop(m.slot, amount)
|
||||||
|
self:MonsterHitMotion(m.slot)
|
||||||
|
local killed = false
|
||||||
|
if m.hp <= 0 then
|
||||||
|
m.hp = 0
|
||||||
|
self:KillMonster(m.slot)
|
||||||
|
killed = true
|
||||||
|
end
|
||||||
|
return killed`, [
|
||||||
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||||
|
], 0, 'boolean'),
|
||||||
|
method('ApplyPoisonToMonster', `if target == nil or target.alive ~= true or amount == nil or amount <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if target.artifact ~= nil and target.artifact > 0 then
|
||||||
|
target.artifact = target.artifact - 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
target.poison = (target.poison or 0) + amount
|
||||||
|
self.PoisonApplicationsThisCombat = (self.PoisonApplicationsThisCombat or 0) + 1
|
||||||
|
local burstEvery = self:AddPowerFieldTotal("poisonApplicationBurstEvery")
|
||||||
|
local burstDamage = self:AddPowerFieldTotal("poisonApplicationBurstDamage")
|
||||||
|
if burstEvery ~= nil and burstEvery > 0 and burstDamage ~= nil and burstDamage > 0 then
|
||||||
|
if (self.PoisonApplicationsThisCombat % burstEvery) == 0 then
|
||||||
|
self:DealDamageToAllMonsters(burstDamage)
|
||||||
|
end
|
||||||
|
end`, [
|
||||||
|
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'target' },
|
||||||
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||||
]),
|
]),
|
||||||
|
method('DealDamageToAllMonsters', `if self.Monsters == nil then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local killCount = 0
|
||||||
|
for i = 1, #self.Monsters do
|
||||||
|
local m = self.Monsters[i]
|
||||||
|
if m ~= nil and m.alive == true then
|
||||||
|
local dmg = amount
|
||||||
|
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
|
||||||
|
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
|
||||||
|
end
|
||||||
|
self:ShowDmgPop(i, dmg)
|
||||||
|
self:MonsterHitMotion(i)
|
||||||
|
if m.hp <= 0 then
|
||||||
|
m.hp = 0
|
||||||
|
self:KillMonster(m.slot)
|
||||||
|
killCount = killCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||||
|
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
|
||||||
|
end
|
||||||
|
self:RenderCombat()
|
||||||
|
self:CheckCombatEnd()
|
||||||
|
return killCount > 0`, [
|
||||||
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||||
|
], 0, 'boolean'),
|
||||||
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
|
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
|
||||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||||
self:DealDamageToTarget(damage, pierce)
|
self:DealDamageToTarget(damage, pierce)
|
||||||
|
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||||
self:RenderCombat()
|
self:RenderCombat()
|
||||||
self:CheckCombatEnd()
|
self:CheckCombatEnd()
|
||||||
return
|
return
|
||||||
@@ -283,7 +439,15 @@ _TimerService:SetTimerOnce(function()
|
|||||||
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
|
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
|
||||||
shown = math.floor(damage * 1.5)
|
shown = math.floor(damage * 1.5)
|
||||||
end
|
end
|
||||||
self:DealDamageToTarget(damage, pierce)
|
if mt ~= nil and mt.alive == true and mt.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
|
||||||
|
shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier)
|
||||||
|
end
|
||||||
|
local killed = self:DealDamageToTarget(damage, pierce)
|
||||||
|
if killed == true and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||||
|
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + self.ActiveKillReward
|
||||||
|
end
|
||||||
|
self.ActiveKillReward = 0
|
||||||
|
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||||
self:ShowDmgPop(targetIndex, shown)
|
self:ShowDmgPop(targetIndex, shown)
|
||||||
self:RenderCombat()
|
self:RenderCombat()
|
||||||
self:CheckCombatEnd()
|
self:CheckCombatEnd()
|
||||||
@@ -307,6 +471,7 @@ end
|
|||||||
_TimerService:SetTimerOnce(function()
|
_TimerService:SetTimerOnce(function()
|
||||||
if fx ~= nil then fx.Enable = false end
|
if fx ~= nil then fx.Enable = false end
|
||||||
self.FxBusy = false
|
self.FxBusy = false
|
||||||
|
local killCount = 0
|
||||||
for i = 1, #self.Monsters do
|
for i = 1, #self.Monsters do
|
||||||
local m = self.Monsters[i]
|
local m = self.Monsters[i]
|
||||||
if m ~= nil and m.alive == true then
|
if m ~= nil and m.alive == true then
|
||||||
@@ -314,20 +479,35 @@ _TimerService:SetTimerOnce(function()
|
|||||||
if m.vuln > 0 then
|
if m.vuln > 0 then
|
||||||
dmg = math.floor(dmg * 1.5)
|
dmg = math.floor(dmg * 1.5)
|
||||||
end
|
end
|
||||||
|
if m.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
|
||||||
|
dmg = math.floor(dmg * self.ActiveAttackDamageVsWeakMultiplier)
|
||||||
|
end
|
||||||
if m.block > 0 then
|
if m.block > 0 then
|
||||||
local absorbed = math.min(m.block, dmg)
|
local absorbed = math.min(m.block, dmg)
|
||||||
m.block = m.block - absorbed
|
m.block = m.block - absorbed
|
||||||
dmg = dmg - absorbed
|
dmg = dmg - absorbed
|
||||||
end
|
end
|
||||||
m.hp = m.hp - dmg
|
m.hp = m.hp - dmg
|
||||||
|
if dmg > 0 then
|
||||||
|
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||||
|
if poison ~= nil and poison > 0 then
|
||||||
|
self:ApplyPoisonToMonster(m, poison)
|
||||||
|
end
|
||||||
|
end
|
||||||
self:ShowDmgPop(i, dmg)
|
self:ShowDmgPop(i, dmg)
|
||||||
self:MonsterHitMotion(i)
|
self:MonsterHitMotion(i)
|
||||||
if m.hp <= 0 then
|
if m.hp <= 0 then
|
||||||
m.hp = 0
|
m.hp = 0
|
||||||
self:KillMonster(m.slot)
|
self:KillMonster(m.slot)
|
||||||
|
killCount = killCount + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||||
|
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
|
||||||
|
end
|
||||||
|
self.ActiveKillReward = 0
|
||||||
|
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||||
self:RenderCombat()
|
self:RenderCombat()
|
||||||
self:CheckCombatEnd()
|
self:CheckCombatEnd()
|
||||||
end, 0.35)`, [
|
end, 0.35)`, [
|
||||||
@@ -353,6 +533,9 @@ if self.PlayerBlock > 0 then
|
|||||||
self.PlayerBlock = self.PlayerBlock - absorbed
|
self.PlayerBlock = self.PlayerBlock - absorbed
|
||||||
dmg = dmg - absorbed
|
dmg = dmg - absorbed
|
||||||
end
|
end
|
||||||
|
if dmg > 0 and self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 and dmg > 1 then
|
||||||
|
dmg = 1
|
||||||
|
end
|
||||||
if dmg > 0 then
|
if dmg > 0 then
|
||||||
self.PlayerHp = self.PlayerHp - dmg
|
self.PlayerHp = self.PlayerHp - dmg
|
||||||
local reflect = self.PlayerThorns or 0
|
local reflect = self.PlayerThorns or 0
|
||||||
@@ -400,18 +583,25 @@ local m = self.Monsters[idx]
|
|||||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx)
|
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx)
|
||||||
self:SetEntityEnabled(base .. "/ActFrame", true)
|
self:SetEntityEnabled(base .. "/ActFrame", true)
|
||||||
_TimerService:SetTimerOnce(function()
|
_TimerService:SetTimerOnce(function()
|
||||||
if m.poison ~= nil and m.poison > 0 then
|
local poisonTicks = 1
|
||||||
m.hp = m.hp - m.poison
|
local bonusTicks = self:AddPowerFieldTotal("extraPoisonTicks")
|
||||||
self:ShowDmgPop(idx, m.poison)
|
if bonusTicks ~= nil and bonusTicks > 0 then
|
||||||
self:MonsterHitMotion(idx)
|
poisonTicks = poisonTicks + bonusTicks
|
||||||
m.poison = m.poison - 1
|
end
|
||||||
if m.hp <= 0 then
|
for pt = 1, poisonTicks do
|
||||||
m.hp = 0
|
if m.poison ~= nil and m.poison > 0 then
|
||||||
self:KillMonster(m.slot)
|
m.hp = m.hp - m.poison
|
||||||
self:RenderCombat()
|
self:ShowDmgPop(idx, m.poison)
|
||||||
self:SetEntityEnabled(base .. "/ActFrame", false)
|
self:MonsterHitMotion(idx)
|
||||||
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
|
m.poison = m.poison - 1
|
||||||
return
|
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
|
end
|
||||||
end
|
end
|
||||||
m.block = 0
|
m.block = 0
|
||||||
@@ -420,6 +610,10 @@ _TimerService:SetTimerOnce(function()
|
|||||||
if intent.kind == "Attack" then
|
if intent.kind == "Attack" then
|
||||||
self:MonsterLunge(idx)
|
self:MonsterLunge(idx)
|
||||||
local atk = intent.value + m.str
|
local atk = intent.value + m.str
|
||||||
|
if self.EnemyStrengthLossThisTurn ~= nil and self.EnemyStrengthLossThisTurn > 0 then
|
||||||
|
atk = atk - self.EnemyStrengthLossThisTurn
|
||||||
|
if atk < 0 then atk = 0 end
|
||||||
|
end
|
||||||
if m.weak > 0 then
|
if m.weak > 0 then
|
||||||
atk = math.floor(atk * 0.75)
|
atk = math.floor(atk * 0.75)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ self.RetainSelectActive = false
|
|||||||
self.ReserveSelectActive = false
|
self.ReserveSelectActive = false
|
||||||
self.TurnAttackCardsPlayed = 0
|
self.TurnAttackCardsPlayed = 0
|
||||||
self.TurnDiscardedCards = 0
|
self.TurnDiscardedCards = 0
|
||||||
|
self.TurnCardsPlayedThisTurn = 0
|
||||||
|
self.DamageDealtThisTurn = 0
|
||||||
self.NextTurnSelectCopies = 0
|
self.NextTurnSelectCopies = 0
|
||||||
self.NextTurnSelectPrompt = ""
|
self.NextTurnSelectPrompt = ""
|
||||||
self.SkillCostReductionThisTurn = 0
|
self.SkillCostReductionThisTurn = 0
|
||||||
@@ -246,6 +248,15 @@ if self.ClayBlockNext > 0 then
|
|||||||
end
|
end
|
||||||
self.TurnAttackMultiplier = self.NextTurnAttackMultiplier or 1
|
self.TurnAttackMultiplier = self.NextTurnAttackMultiplier or 1
|
||||||
self.NextTurnAttackMultiplier = 1
|
self.NextTurnAttackMultiplier = 1
|
||||||
|
self.CardsDrawnThisCombat = self.CardsDrawnThisCombat or 0
|
||||||
|
self.ShivFirstDamageBonusUsed = false
|
||||||
|
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||||
|
self.DrawDamageThisTurn = 0
|
||||||
|
self.DrawPoisonThisTurn = 0
|
||||||
|
self.ShivAoeThisCombat = false
|
||||||
|
self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {}
|
||||||
|
self.TurnSkillSlyCards = {}
|
||||||
|
self.EnemyStrengthLossThisTurn = 0
|
||||||
self.HandCostZeroThisTurn = false
|
self.HandCostZeroThisTurn = false
|
||||||
self.DrawDisabledThisTurn = false
|
self.DrawDisabledThisTurn = false
|
||||||
local powerTurnDraw = 0
|
local powerTurnDraw = 0
|
||||||
@@ -265,7 +276,7 @@ if self.PlayerPowers ~= nil then
|
|||||||
for j = 1, #self.Monsters do
|
for j = 1, #self.Monsters do
|
||||||
local tm = self.Monsters[j]
|
local tm = self.Monsters[j]
|
||||||
if tm ~= nil and tm.alive == true then
|
if tm ~= nil and tm.alive == true then
|
||||||
tm.poison = (tm.poison or 0) + pc.value
|
self:ApplyPoisonToMonster(tm, pc.value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -418,17 +429,31 @@ local kept = {}
|
|||||||
for i = 1, #self.Hand do
|
for i = 1, #self.Hand do
|
||||||
\tlocal cardId = self.Hand[i]
|
\tlocal cardId = self.Hand[i]
|
||||||
\tlocal c = self.Cards[cardId]
|
\tlocal c = self.Cards[cardId]
|
||||||
\tif c ~= nil and (c.retain == true or i == retainSlot) then
|
\tif c ~= nil and (c.retain == true or (c.class == "shiv" and self:HasPowerField("shivRetain") == true) or i == retainSlot) then
|
||||||
\t\ttable.insert(kept, cardId)
|
\t\ttable.insert(kept, cardId)
|
||||||
\telse
|
\telse
|
||||||
\t\ttable.insert(self.DiscardPile, cardId)
|
\t\ttable.insert(self.DiscardPile, cardId)
|
||||||
\tend
|
\tend
|
||||||
end
|
end
|
||||||
self.Hand = kept
|
self.Hand = kept
|
||||||
|
if self.PlayerPowers ~= nil then
|
||||||
|
for i = 1, #self.PlayerPowers do
|
||||||
|
local pc = self.Cards[self.PlayerPowers[i]]
|
||||||
|
if pc ~= nil and pc.endTurnDexLoss ~= nil and pc.endTurnDexLoss > 0 then
|
||||||
|
self.PlayerDex = self.PlayerDex - pc.endTurnDexLoss
|
||||||
|
if self.PlayerDex < 0 then self.PlayerDex = 0 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 then
|
||||||
|
self.PlayerIntangible = self.PlayerIntangible - 1
|
||||||
|
if self.PlayerIntangible < 0 then self.PlayerIntangible = 0 end
|
||||||
|
end
|
||||||
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
|
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
|
||||||
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
|
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
|
||||||
self:RenderHand(false)
|
self:RenderHand(false)
|
||||||
self:RenderPiles()
|
self:RenderPiles()
|
||||||
|
self.TurnSkillSlyCards = {}
|
||||||
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
|
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
|
||||||
method('DrawCards', `local drawnSlots = {}
|
method('DrawCards', `local drawnSlots = {}
|
||||||
local drawnCards = {}
|
local drawnCards = {}
|
||||||
@@ -445,6 +470,7 @@ for i = 1, amount do
|
|||||||
\tend
|
\tend
|
||||||
\tlocal cardId = table.remove(self.DrawPile)
|
\tlocal cardId = table.remove(self.DrawPile)
|
||||||
\ttable.insert(drawnCards, cardId)
|
\ttable.insert(drawnCards, cardId)
|
||||||
|
\tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1
|
||||||
\tif #self.Hand >= 10 then
|
\tif #self.Hand >= 10 then
|
||||||
\t\ttable.insert(self.DiscardPile, cardId)
|
\t\ttable.insert(self.DiscardPile, cardId)
|
||||||
\t\tself:TriggerSly(cardId)
|
\t\tself:TriggerSly(cardId)
|
||||||
|
|||||||
@@ -308,6 +308,20 @@ end
|
|||||||
if c.damagePerSkillInHand ~= nil then
|
if c.damagePerSkillInHand ~= nil then
|
||||||
base2 = base2 + self:CountOtherHandSkills(slot) * c.damagePerSkillInHand
|
base2 = base2 + self:CountOtherHandSkills(slot) * c.damagePerSkillInHand
|
||||||
end
|
end
|
||||||
|
if c.damagePerCardDrawnThisCombat ~= nil then
|
||||||
|
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
|
||||||
|
end
|
||||||
|
if c.class == "Attack" and (self.TurnCardsPlayedThisTurn or 0) == 0 and c.firstCardDamageBonus ~= nil then
|
||||||
|
base2 = base2 + c.firstCardDamageBonus
|
||||||
|
end
|
||||||
|
if c.class == "shiv" then
|
||||||
|
if self:HasPowerField("shivDamageBonus") == true then
|
||||||
|
base2 = base2 + self:AddPowerFieldTotal("shivDamageBonus")
|
||||||
|
end
|
||||||
|
if self.ShivFirstDamageBonusUsed ~= true and self:HasPowerField("firstShivDamageBonus") == true then
|
||||||
|
base2 = base2 + self:AddPowerFieldTotal("firstShivDamageBonus")
|
||||||
|
end
|
||||||
|
end
|
||||||
if base2 < 0 then
|
if base2 < 0 then
|
||||||
base2 = 0
|
base2 = 0
|
||||||
end
|
end
|
||||||
@@ -379,6 +393,9 @@ end
|
|||||||
if c.nextSkillCostZero == true then
|
if c.nextSkillCostZero == true then
|
||||||
self.NextSkillCostZero = true
|
self.NextSkillCostZero = true
|
||||||
end
|
end
|
||||||
|
if c.nextSkillRepeatCount ~= nil and c.nextSkillRepeatCount > 0 then
|
||||||
|
self.NextSkillRepeatCount = (self.NextSkillRepeatCount or 0) + c.nextSkillRepeatCount
|
||||||
|
end
|
||||||
if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then
|
if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then
|
||||||
self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn
|
self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn
|
||||||
end
|
end
|
||||||
@@ -388,10 +405,57 @@ end
|
|||||||
if c.drawDisabledThisTurn == true then
|
if c.drawDisabledThisTurn == true then
|
||||||
self.DrawDisabledThisTurn = true
|
self.DrawDisabledThisTurn = true
|
||||||
end
|
end
|
||||||
|
if c.drawDamage ~= nil and c.drawDamage > 0 and c.kind ~= "Power" then
|
||||||
|
self.DrawDamageThisTurn = (self.DrawDamageThisTurn or 0) + c.drawDamage
|
||||||
|
end
|
||||||
|
if c.drawPoison ~= nil and c.drawPoison > 0 and c.kind ~= "Power" then
|
||||||
|
self.DrawPoisonThisTurn = (self.DrawPoisonThisTurn or 0) + c.drawPoison
|
||||||
|
end
|
||||||
|
if c.shivAoe == true and c.kind ~= "Power" then
|
||||||
|
self.ShivAoeThisCombat = true
|
||||||
|
end
|
||||||
|
if c.skillSlyOnPlay == true and c.kind == "Skill" then
|
||||||
|
if self.SkillSlyOnPlayCards == nil then
|
||||||
|
self.SkillSlyOnPlayCards = {}
|
||||||
|
end
|
||||||
|
self.SkillSlyOnPlayCards[cardId] = true
|
||||||
|
end
|
||||||
|
if c.turnHandSlyCount ~= nil and c.turnHandSlyCount > 0 then
|
||||||
|
if self.TurnSkillSlyCards == nil then
|
||||||
|
self.TurnSkillSlyCards = {}
|
||||||
|
end
|
||||||
|
local picked = 0
|
||||||
|
if self.Hand ~= nil then
|
||||||
|
for i = 1, #self.Hand do
|
||||||
|
local hid = self.Hand[i]
|
||||||
|
if hid ~= nil and hid ~= cardId then
|
||||||
|
local hc = self.Cards[hid]
|
||||||
|
if hc ~= nil and hc.kind == "Skill" and self.TurnSkillSlyCards[hid] ~= true and self.SkillSlyOnPlayCards[hid] ~= true and hc.sly ~= true then
|
||||||
|
self.TurnSkillSlyCards[hid] = true
|
||||||
|
picked = picked + 1
|
||||||
|
if picked >= c.turnHandSlyCount then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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.kind == "Attack" then
|
||||||
if c.damage ~= nil then
|
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
|
||||||
self:PlayerAttackMotion()
|
self:PlayerAttackMotion()
|
||||||
local baseDmg = self:AttackBaseForCard(slot, c)
|
local baseDmg = self:AttackBaseForCard(slot, c)
|
||||||
|
self.ActiveAttackDamageVsWeakMultiplier = c.attackDamageVsWeakMultiplier or 1
|
||||||
|
if c.xDamagePerEnergy ~= nil and c.xDamagePerEnergy > 0 then
|
||||||
|
baseDmg = xEnergy * c.xDamagePerEnergy
|
||||||
|
end
|
||||||
local total = 0
|
local total = 0
|
||||||
local hitN = c.hits or 1
|
local hitN = c.hits or 1
|
||||||
if c.otherHandAtLeast ~= nil and c.bonusHitsWhenOtherHandAtLeast ~= nil then
|
if c.otherHandAtLeast ~= nil and c.bonusHitsWhenOtherHandAtLeast ~= nil then
|
||||||
@@ -407,11 +471,67 @@ if c.kind == "Attack" then
|
|||||||
for h = 1, hitN do
|
for h = 1, hitN do
|
||||||
total = total + self:CalcPlayerAttack(baseDmg)
|
total = total + self:CalcPlayerAttack(baseDmg)
|
||||||
end
|
end
|
||||||
if c.aoe == true then
|
local useAoe = c.aoe == true
|
||||||
self:PlayAoeFx(c.fx or c.image, total)
|
if c.class == "shiv" and (self.ShivAoeThisCombat == true or self:HasPowerField("shivAoe") == true) then
|
||||||
else
|
useAoe = true
|
||||||
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
|
|
||||||
end
|
end
|
||||||
|
if c.class == "shiv" and self.ShivFirstDamageBonusUsed ~= true and self:HasPowerField("firstShivDamageBonus") == true then
|
||||||
|
self.ShivFirstDamageBonusUsed = true
|
||||||
|
end
|
||||||
|
local function countAliveMonsters()
|
||||||
|
local n = 0
|
||||||
|
if self.Monsters ~= nil then
|
||||||
|
for mi = 1, #self.Monsters do
|
||||||
|
local om = self.Monsters[mi]
|
||||||
|
if om ~= nil and om.alive == true then n = n + 1 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
local function randomAliveMonsterIndex()
|
||||||
|
local alive = {}
|
||||||
|
if self.Monsters ~= nil then
|
||||||
|
for mi = 1, #self.Monsters do
|
||||||
|
local om = self.Monsters[mi]
|
||||||
|
if om ~= nil and om.alive == true then
|
||||||
|
table.insert(alive, mi)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #alive <= 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return alive[math.random(1, #alive)]
|
||||||
|
end
|
||||||
|
local function resolveAttackRound()
|
||||||
|
local roundKilled = false
|
||||||
|
if useAoe == true then
|
||||||
|
local killed = self:DealDamageToAllMonsters(total)
|
||||||
|
if killed == true then roundKilled = true end
|
||||||
|
elseif c.randomTargetEachHit == true then
|
||||||
|
for h = 1, hitN do
|
||||||
|
local targetIdx = randomAliveMonsterIndex()
|
||||||
|
if targetIdx ~= nil and targetIdx > 0 then
|
||||||
|
local prev = self.TargetIndex
|
||||||
|
self.TargetIndex = targetIdx
|
||||||
|
local killed = self:DealDamageToTarget(total / hitN, c.pierce == true)
|
||||||
|
self.TargetIndex = prev
|
||||||
|
if killed == true then roundKilled = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local killed = self:DealDamageToTarget(total, c.pierce == true)
|
||||||
|
if killed == true then roundKilled = true end
|
||||||
|
end
|
||||||
|
return roundKilled
|
||||||
|
end
|
||||||
|
local totalDamage = 0
|
||||||
|
local roundKilled = false
|
||||||
|
repeat
|
||||||
|
roundKilled = resolveAttackRound()
|
||||||
|
totalDamage = totalDamage + total
|
||||||
|
until c.repeatOnKill ~= true or roundKilled ~= true or countAliveMonsters() <= 0
|
||||||
|
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + totalDamage
|
||||||
end
|
end
|
||||||
if c.block ~= nil then
|
if c.block ~= nil then
|
||||||
self:AddCardBlock(c.block)
|
self:AddCardBlock(c.block)
|
||||||
@@ -446,21 +566,89 @@ end
|
|||||||
if c.gainEnergy ~= nil and c.gainEnergy ~= 0 then
|
if c.gainEnergy ~= nil and c.gainEnergy ~= 0 then
|
||||||
self.Energy = self.Energy + c.gainEnergy
|
self.Energy = self.Energy + c.gainEnergy
|
||||||
end
|
end
|
||||||
|
if c.intangible ~= nil and c.intangible > 0 then
|
||||||
|
self.PlayerIntangible = (self.PlayerIntangible or 0) + c.intangible
|
||||||
|
end
|
||||||
|
self.TurnCardsPlayedThisTurn = (self.TurnCardsPlayedThisTurn or 0) + 1
|
||||||
|
if c.blockPerDamageDealtThisTurn ~= nil and c.blockPerDamageDealtThisTurn > 0 then
|
||||||
|
self:AddCardBlock((self.DamageDealtThisTurn or 0) * c.blockPerDamageDealtThisTurn)
|
||||||
|
end
|
||||||
self:QueueNextTurnEffects(c)
|
self:QueueNextTurnEffects(c)
|
||||||
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
|
if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then
|
||||||
|
if self.CombatCardCostReduction == nil then
|
||||||
|
self.CombatCardCostReduction = {}
|
||||||
|
end
|
||||||
|
self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay
|
||||||
|
end
|
||||||
|
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil or c.affectsAllEnemies == true or c.removeEnemyBlock == true or c.removeEnemyArtifact == true or (c.enemyStrengthLossThisTurn ~= nil and c.enemyStrengthLossThisTurn > 0) then
|
||||||
local tm = self.Monsters[self.TargetIndex]
|
local tm = self.Monsters[self.TargetIndex]
|
||||||
if tm == nil or tm.alive ~= true then
|
if tm == nil or tm.alive ~= true then
|
||||||
for i = 1, #self.Monsters do
|
for i = 1, #self.Monsters do
|
||||||
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
|
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if tm ~= nil and tm.alive == true then
|
local targets = {}
|
||||||
if c.weak ~= nil then tm.weak = tm.weak + c.weak end
|
if c.affectsAllEnemies == true and self.Monsters ~= nil then
|
||||||
if c.poison ~= nil then tm.poison = (tm.poison or 0) + c.poison end
|
for mi = 1, #self.Monsters do
|
||||||
if c.vuln ~= nil then
|
local om = self.Monsters[mi]
|
||||||
tm.vuln = tm.vuln + c.vuln
|
if om ~= nil and om.alive == true then
|
||||||
if self:HasRelic("championBelt") then
|
table.insert(targets, om)
|
||||||
tm.weak = tm.weak + 1
|
end
|
||||||
|
end
|
||||||
|
elseif tm ~= nil and tm.alive == true then
|
||||||
|
table.insert(targets, tm)
|
||||||
|
end
|
||||||
|
if c.enemyStrengthLossThisTurn ~= nil and c.enemyStrengthLossThisTurn > 0 then
|
||||||
|
self.EnemyStrengthLossThisTurn = (self.EnemyStrengthLossThisTurn or 0) + c.enemyStrengthLossThisTurn
|
||||||
|
end
|
||||||
|
for ti = 1, #targets do
|
||||||
|
local target = targets[ti]
|
||||||
|
if target ~= nil and target.alive == true then
|
||||||
|
if c.removeEnemyBlock == true then
|
||||||
|
target.block = 0
|
||||||
|
end
|
||||||
|
if c.removeEnemyArtifact == true then
|
||||||
|
target.artifact = 0
|
||||||
|
end
|
||||||
|
if weakAmount ~= nil and weakAmount > 0 then
|
||||||
|
if target.artifact ~= nil and target.artifact > 0 then
|
||||||
|
target.artifact = target.artifact - 1
|
||||||
|
else
|
||||||
|
target.weak = target.weak + weakAmount
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if poisonAmount ~= nil and poisonAmount > 0 then
|
||||||
|
if c.poisonIfTargetPoisoned ~= true or (target.poison ~= nil and target.poison > 0) then
|
||||||
|
local poisonHits = c.poisonHits or 1
|
||||||
|
for pi = 1, poisonHits do
|
||||||
|
local target2 = target
|
||||||
|
if c.poisonRandomTargets == true and self.Monsters ~= nil then
|
||||||
|
local alive = {}
|
||||||
|
for mi = 1, #self.Monsters do
|
||||||
|
local om = self.Monsters[mi]
|
||||||
|
if om ~= nil and om.alive == true then
|
||||||
|
table.insert(alive, om)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #alive > 0 then
|
||||||
|
target2 = alive[math.random(#alive)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if target2 ~= nil and target2.alive == true then
|
||||||
|
self:ApplyPoisonToMonster(target2, poisonAmount)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if vulnAmount ~= nil and vulnAmount > 0 then
|
||||||
|
if target.artifact ~= nil and target.artifact > 0 then
|
||||||
|
target.artifact = target.artifact - 1
|
||||||
|
else
|
||||||
|
target.vuln = target.vuln + vulnAmount
|
||||||
|
if self:HasRelic("championBelt") then
|
||||||
|
target.weak = target.weak + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -493,6 +681,39 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local drawDamage = self:AddPowerFieldTotal("drawDamage") + (self.DrawDamageThisTurn or 0)
|
||||||
|
local drawPoison = self:AddPowerFieldTotal("drawPoison") + (self.DrawPoisonThisTurn or 0)
|
||||||
|
if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison > 0) then
|
||||||
|
for mi = 1, #self.Monsters do
|
||||||
|
local m2 = self.Monsters[mi]
|
||||||
|
if m2 ~= nil and m2.alive == true then
|
||||||
|
local dmg = drawDamage or 0
|
||||||
|
if m2.vuln > 0 then
|
||||||
|
dmg = math.floor(dmg * 1.5)
|
||||||
|
end
|
||||||
|
if m2.block > 0 then
|
||||||
|
local absorbed = math.min(m2.block, dmg)
|
||||||
|
m2.block = m2.block - absorbed
|
||||||
|
dmg = dmg - absorbed
|
||||||
|
end
|
||||||
|
if drawPoison ~= nil and drawPoison > 0 then
|
||||||
|
self:ApplyPoisonToMonster(m2, drawPoison)
|
||||||
|
end
|
||||||
|
if dmg > 0 then
|
||||||
|
m2.hp = m2.hp - dmg
|
||||||
|
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
|
||||||
|
end
|
||||||
|
self:ShowDmgPop(mi, dmg)
|
||||||
|
self:MonsterHitMotion(mi)
|
||||||
|
if m2.hp <= 0 then
|
||||||
|
m2.hp = 0
|
||||||
|
self:KillMonster(m2.slot)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self:RenderCombat()
|
||||||
|
self:CheckCombatEnd()
|
||||||
|
end
|
||||||
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
||||||
self:AddCardsToHand("Shiv", c.addShiv)
|
self:AddCardsToHand("Shiv", c.addShiv)
|
||||||
end`, [
|
end`, [
|
||||||
@@ -500,13 +721,21 @@ end`, [
|
|||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'free' },
|
{ 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]
|
method('TriggerSly', `local c = self.Cards[cardId]
|
||||||
if c == nil or c.sly ~= true then
|
if c == nil then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if c.sly ~= true then
|
||||||
|
local onPlay = self.SkillSlyOnPlayCards ~= nil and self.SkillSlyOnPlayCards[cardId] == true
|
||||||
|
local tempSly = self.TurnSkillSlyCards ~= nil and self.TurnSkillSlyCards[cardId] == true
|
||||||
|
if onPlay ~= true and tempSly ~= true then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
self:Toast("교활 발동: " .. c.name)
|
self:Toast("교활 발동: " .. c.name)
|
||||||
self:ResolveCardEffects(cardId, 0, c, true)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
|
self:ResolveCardEffects(cardId, 0, c, true, 0)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
|
||||||
method('DiscardHandCard', `if self.Hand == nil then
|
method('DiscardHandCard', `if self.Hand == nil then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
21
tools/deck/cb/layout.mjs
Normal file
21
tools/deck/cb/layout.mjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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 layoutMethods = [
|
||||||
|
method('PositionMonsterSlot', `local monster = self.Monsters[slot]
|
||||||
|
if monster == nil or monster.entity == nil or not isvalid(monster.entity) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local transform = monster.entity.TransformComponent
|
||||||
|
if transform == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local worldPos = transform.WorldPosition
|
||||||
|
local screen = _UILogic:WorldToScreenPosition(Vector2(worldPos.x, worldPos.y + ${HEAD_OFFSET_Y}))
|
||||||
|
local uipos = _UILogic:ScreenToUIPosition(screen)
|
||||||
|
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot))
|
||||||
|
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||||
|
slotEntity.UITransformComponent.anchoredPosition = uipos
|
||||||
|
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||||
|
];
|
||||||
34
tools/deck/cb/navigation.mjs
Normal file
34
tools/deck/cb/navigation.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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 navigationMethods = [
|
||||||
|
method('GoLobbyMap', `self.LobbyTpTries = 0
|
||||||
|
local eventId = 0
|
||||||
|
local function tryTeleport()
|
||||||
|
self.LobbyTpTries = self.LobbyTpTries + 1
|
||||||
|
local localPlayer = _UserService.LocalPlayer
|
||||||
|
if localPlayer ~= nil then
|
||||||
|
if localPlayer.CurrentMapName ~= "${LOBBY_MAP}" then
|
||||||
|
_TeleportService:TeleportToMapPosition(localPlayer, ${LOBBY_SPAWN}, "${LOBBY_MAP}")
|
||||||
|
end
|
||||||
|
_TimerService:ClearTimer(eventId)
|
||||||
|
elseif self.LobbyTpTries > 50 then
|
||||||
|
_TimerService:ClearTimer(eventId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
eventId = _TimerService:SetTimerRepeat(tryTeleport, 0.1)`),
|
||||||
|
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((mapName) => `"${mapName}"`).join(', ')} }
|
||||||
|
local target = maps[self.Floor]
|
||||||
|
if target == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local localPlayer = _UserService.LocalPlayer
|
||||||
|
if localPlayer == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if localPlayer.CurrentMapName == target then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
_TeleportService:TeleportToMapPosition(localPlayer, Vector3(-6, 0.03, 0), target)`),
|
||||||
|
];
|
||||||
18
tools/deck/cb/npc.mjs
Normal file
18
tools/deck/cb/npc.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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 npcMethods = [
|
||||||
|
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' }]),
|
||||||
|
];
|
||||||
@@ -1,307 +1,296 @@
|
|||||||
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 { 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 { 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';
|
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 = [
|
export const renderMethods = [
|
||||||
method('BuffsLabel', `local parts = {}
|
method('BuffsLabel', `local parts = {}
|
||||||
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
|
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 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 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
|
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||||
return table.concat(parts, " ")`, [
|
return table.concat(parts, " ")`, [
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
|
{ 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: 'weak' },
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||||
], 0, 'string'),
|
], 0, 'string'),
|
||||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
||||||
local m = self.Monsters[i]
|
local m = self.Monsters[i]
|
||||||
if m ~= nil and m.alive == true then
|
if m ~= nil and m.alive == true then
|
||||||
self:SetEntityEnabled(base, true)
|
self:SetEntityEnabled(base, true)
|
||||||
self:SetText(base .. "/Name", m.name)
|
self:SetText(base .. "/Name", m.name)
|
||||||
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
||||||
local intent = m.intents[m.intentIdx]
|
local intent = m.intents[m.intentIdx]
|
||||||
local t = ""
|
local t = ""
|
||||||
if intent ~= nil then
|
if intent ~= nil then
|
||||||
if intent.kind == "Attack" then
|
if intent.kind == "Attack" then
|
||||||
local atk = intent.value + m.str
|
local atk = intent.value + m.str
|
||||||
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
||||||
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
||||||
t = "공격 " .. tostring(atk)
|
t = "공격 " .. tostring(atk)
|
||||||
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
||||||
elseif intent.kind == "Debuff" then
|
elseif intent.kind == "Debuff" then
|
||||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||||
elseif intent.kind == "AddCard" then
|
elseif intent.kind == "AddCard" then
|
||||||
t = "저주 카드 추가"
|
t = "저주 카드 추가"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self:SetText(base .. "/Intent", t)
|
self:SetText(base .. "/Intent", t)
|
||||||
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||||
local shownTarget = self.TargetIndex
|
local shownTarget = self.TargetIndex
|
||||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||||
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
||||||
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
||||||
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
||||||
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
||||||
if intent.kind == "Attack" then
|
if intent.kind == "Attack" then
|
||||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||||
elseif intent.kind == "Debuff" then
|
elseif intent.kind == "Debuff" then
|
||||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||||
elseif intent.kind == "AddCard" then
|
elseif intent.kind == "AddCard" then
|
||||||
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||||
else
|
else
|
||||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
||||||
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||||
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
|
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))
|
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
|
||||||
else
|
else
|
||||||
self:SetEntityEnabled(base, false)
|
self:SetEntityEnabled(base, false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
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:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
||||||
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
if self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 then
|
||||||
if pb ~= "" then pb = pb .. " " end
|
if pb ~= "" then pb = pb .. " " end
|
||||||
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
pb = pb .. "불가침" .. tostring(self.PlayerIntangible)
|
||||||
end
|
end
|
||||||
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
||||||
if pb ~= "" then pb = pb .. " " end
|
if pb ~= "" then pb = pb .. " " end
|
||||||
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
||||||
end
|
end
|
||||||
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
||||||
local names = {}
|
if pb ~= "" then pb = pb .. " " end
|
||||||
for i = 1, #self.PlayerPowers do
|
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
||||||
local pc = self.Cards[self.PlayerPowers[i]]
|
end
|
||||||
if pc ~= nil then table.insert(names, pc.name) end
|
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
||||||
end
|
local names = {}
|
||||||
if pb ~= "" then pb = pb .. " · " end
|
for i = 1, #self.PlayerPowers do
|
||||||
pb = pb .. table.concat(names, " ")
|
local pc = self.Cards[self.PlayerPowers[i]]
|
||||||
end
|
if pc ~= nil then table.insert(names, pc.name) end
|
||||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
end
|
||||||
self:RenderRun()`),
|
if pb ~= "" then pb = pb .. " · " end
|
||||||
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
pb = pb .. table.concat(names, " ")
|
||||||
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
|
end
|
||||||
local pop = _EntityService:GetEntityByPath(base)
|
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||||
if pop == nil then
|
self:RenderRun()`),
|
||||||
return
|
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
||||||
end
|
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
|
||||||
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
|
local pop = _EntityService:GetEntityByPath(base)
|
||||||
local popSeq = self.DmgPopSeq
|
if pop == nil then
|
||||||
self:SetText(base, "")
|
return
|
||||||
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
end
|
||||||
local shown = tostring(math.max(0, math.floor(amount)))
|
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
|
||||||
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
local popSeq = self.DmgPopSeq
|
||||||
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
self:SetText(base, "")
|
||||||
end
|
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
||||||
local digits = {}
|
local shown = tostring(math.max(0, math.floor(amount)))
|
||||||
for i = 1, string.len(shown) do
|
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
||||||
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
||||||
end
|
end
|
||||||
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
local digits = {}
|
||||||
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
for i = 1, string.len(shown) do
|
||||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
||||||
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
end
|
||||||
end
|
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
||||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
||||||
local digitPath = base .. "/Digit" .. tostring(i)
|
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||||
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
||||||
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
end
|
||||||
if digits[i] ~= nil then
|
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||||
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
local digitPath = base .. "/Digit" .. tostring(i)
|
||||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
||||||
if digitEntity.UITransformComponent ~= nil then
|
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||||
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
if digits[i] ~= nil then
|
||||||
end
|
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
||||||
self:SetEntityEnabled(digitPath, true)
|
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||||
else
|
if digitEntity.UITransformComponent ~= nil then
|
||||||
self:SetEntityEnabled(digitPath, false)
|
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
||||||
end
|
end
|
||||||
end
|
self:SetEntityEnabled(digitPath, true)
|
||||||
end
|
else
|
||||||
local popPos = nil
|
self:SetEntityEnabled(digitPath, false)
|
||||||
local m = self.Monsters[slot]
|
end
|
||||||
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
end
|
||||||
local wp = m.entity.TransformComponent.WorldPosition
|
end
|
||||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
local popPos = nil
|
||||||
popPos = _UILogic:ScreenToUIPosition(screen)
|
local m = self.Monsters[slot]
|
||||||
else
|
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
local wp = m.entity.TransformComponent.WorldPosition
|
||||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
||||||
local sp = slotEntity.UITransformComponent.anchoredPosition
|
popPos = _UILogic:ScreenToUIPosition(screen)
|
||||||
popPos = Vector2(sp.x, sp.y + 76)
|
else
|
||||||
end
|
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
||||||
end
|
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||||
if pop ~= nil and pop.UITransformComponent ~= nil then
|
local sp = slotEntity.UITransformComponent.anchoredPosition
|
||||||
if popPos ~= nil then
|
popPos = Vector2(sp.x, sp.y + 76)
|
||||||
pop.UITransformComponent.anchoredPosition = popPos
|
end
|
||||||
else
|
end
|
||||||
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
if pop ~= nil and pop.UITransformComponent ~= nil then
|
||||||
end
|
if popPos ~= nil then
|
||||||
end
|
pop.UITransformComponent.anchoredPosition = popPos
|
||||||
self:SetEntityEnabled(base, true)
|
else
|
||||||
for i = 1, 6 do
|
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
||||||
_TimerService:SetTimerOnce(function()
|
end
|
||||||
if self.DmgPopSeq ~= popSeq then
|
end
|
||||||
return
|
self:SetEntityEnabled(base, true)
|
||||||
end
|
for i = 1, 6 do
|
||||||
local p = _EntityService:GetEntityByPath(base)
|
_TimerService:SetTimerOnce(function()
|
||||||
if p ~= nil and p.UITransformComponent ~= nil then
|
if self.DmgPopSeq ~= popSeq then
|
||||||
local cur = p.UITransformComponent.anchoredPosition
|
return
|
||||||
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
end
|
||||||
end
|
local p = _EntityService:GetEntityByPath(base)
|
||||||
end, 0.045 * i)
|
if p ~= nil and p.UITransformComponent ~= nil then
|
||||||
end
|
local cur = p.UITransformComponent.anchoredPosition
|
||||||
_TimerService:SetTimerOnce(function()
|
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
||||||
if self.DmgPopSeq ~= popSeq then
|
end
|
||||||
return
|
end, 0.045 * i)
|
||||||
end
|
end
|
||||||
self:SetEntityEnabled(base, false)
|
_TimerService:SetTimerOnce(function()
|
||||||
end, 0.48)`, [
|
if self.DmgPopSeq ~= popSeq then
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
return
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
end
|
||||||
]),
|
self:SetEntityEnabled(base, false)
|
||||||
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
end, 0.48)`, [
|
||||||
if amount > 0 then
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||||
self:SetText(base, "-" .. string.format("%d", amount))
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||||
else
|
]),
|
||||||
self:SetText(base, "막음")
|
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
||||||
end
|
if amount > 0 then
|
||||||
self:SetEntityEnabled(base, true)
|
self:SetText(base, "-" .. string.format("%d", amount))
|
||||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
else
|
||||||
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
self:SetText(base, "막음")
|
||||||
if lp == nil then
|
end
|
||||||
return
|
self:SetEntityEnabled(base, true)
|
||||||
end
|
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||||
if lp.StateComponent == nil then
|
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
||||||
return
|
if lp == nil then
|
||||||
end
|
return
|
||||||
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
end
|
||||||
_TimerService:SetTimerOnce(function()
|
if lp.StateComponent == nil then
|
||||||
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
return
|
||||||
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
end
|
||||||
end
|
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
||||||
end, 0.5)`),
|
_TimerService:SetTimerOnce(function()
|
||||||
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
||||||
if lp == nil then
|
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
||||||
return
|
end
|
||||||
end
|
end, 0.5)`),
|
||||||
if lp.StateComponent ~= nil then
|
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
||||||
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
if lp == nil then
|
||||||
end
|
return
|
||||||
local tr = lp.TransformComponent
|
end
|
||||||
if tr == nil then
|
if lp.StateComponent ~= nil then
|
||||||
return
|
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
||||||
end
|
end
|
||||||
local p = tr.Position
|
local tr = lp.TransformComponent
|
||||||
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
if tr == nil then
|
||||||
_TimerService:SetTimerOnce(function()
|
return
|
||||||
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
end
|
||||||
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
local p = tr.Position
|
||||||
end
|
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
||||||
end, 0.15)`),
|
_TimerService:SetTimerOnce(function()
|
||||||
method('MonsterLunge', `local m = self.Monsters[idx]
|
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
||||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||||
return
|
end
|
||||||
end
|
end, 0.15)`),
|
||||||
if m.motionBusy == true then
|
method('MonsterLunge', `local m = self.Monsters[idx]
|
||||||
return
|
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||||
end
|
return
|
||||||
m.motionBusy = true
|
end
|
||||||
local e = m.entity
|
if m.motionBusy == true then
|
||||||
local tr = e.TransformComponent
|
return
|
||||||
if tr == nil then
|
end
|
||||||
m.motionBusy = false
|
m.motionBusy = true
|
||||||
return
|
local e = m.entity
|
||||||
end
|
local tr = e.TransformComponent
|
||||||
local p = tr.Position
|
if tr == nil then
|
||||||
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
m.motionBusy = false
|
||||||
_TimerService:SetTimerOnce(function()
|
return
|
||||||
if isvalid(e) and e.TransformComponent ~= nil then
|
end
|
||||||
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
local p = tr.Position
|
||||||
end
|
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
||||||
m.motionBusy = false
|
_TimerService:SetTimerOnce(function()
|
||||||
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
if isvalid(e) and e.TransformComponent ~= nil then
|
||||||
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
end
|
||||||
return
|
m.motionBusy = false
|
||||||
end
|
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
||||||
local e = m.entity
|
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
||||||
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||||
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
return
|
||||||
_TimerService:SetTimerOnce(function()
|
end
|
||||||
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
local e = m.entity
|
||||||
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
||||||
end
|
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
||||||
end, 0.5)
|
_TimerService:SetTimerOnce(function()
|
||||||
else
|
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
||||||
if m.motionBusy == true then
|
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
||||||
return
|
end
|
||||||
end
|
end, 0.5)
|
||||||
m.motionBusy = true
|
else
|
||||||
local tr = e.TransformComponent
|
if m.motionBusy == true then
|
||||||
if tr == nil then
|
return
|
||||||
m.motionBusy = false
|
end
|
||||||
return
|
m.motionBusy = true
|
||||||
end
|
local tr = e.TransformComponent
|
||||||
local p = tr.Position
|
if tr == nil then
|
||||||
local seq = { 0.12, -0.12, 0 }
|
m.motionBusy = false
|
||||||
for i = 1, #seq do
|
return
|
||||||
local dx = seq[i]
|
end
|
||||||
_TimerService:SetTimerOnce(function()
|
local p = tr.Position
|
||||||
if isvalid(e) and e.TransformComponent ~= nil then
|
local seq = { 0.12, -0.12, 0 }
|
||||||
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
for i = 1, #seq do
|
||||||
end
|
local dx = seq[i]
|
||||||
if i == #seq then
|
_TimerService:SetTimerOnce(function()
|
||||||
m.motionBusy = false
|
if isvalid(e) and e.TransformComponent ~= nil then
|
||||||
end
|
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
||||||
end, 0.06 * i)
|
end
|
||||||
end
|
if i == #seq then
|
||||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
m.motionBusy = false
|
||||||
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
end
|
||||||
if e == nil or e.UITransformComponent == nil then
|
end, 0.06 * i)
|
||||||
return
|
end
|
||||||
end
|
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||||
local ratio = 0
|
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
||||||
if maxHp > 0 then ratio = hp / maxHp end
|
if e == nil or e.UITransformComponent == nil then
|
||||||
if ratio < 0 then ratio = 0 end
|
return
|
||||||
local w = width * ratio
|
end
|
||||||
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
local ratio = 0
|
||||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
if maxHp > 0 then ratio = hp / maxHp end
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
if ratio < 0 then ratio = 0 end
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
local w = width * ratio
|
||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
||||||
]),
|
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||||
method('PositionMonsterSlot', `local m = self.Monsters[slot]
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
||||||
if m == nil or m.entity == nil or not isvalid(m.entity) then
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
||||||
return
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
||||||
end
|
]),
|
||||||
local tr = m.entity.TransformComponent
|
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
|
||||||
if tr == nil then
|
self.TargetIndex = slot
|
||||||
return
|
self:RenderCombat()
|
||||||
end
|
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||||
local wp = tr.WorldPosition
|
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
|
||||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y}))
|
if self.AscensionLevel > 0 then
|
||||||
local uipos = _UILogic:ScreenToUIPosition(screen)
|
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
|
||||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot))
|
end
|
||||||
if e ~= nil and e.UITransformComponent ~= nil then
|
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
|
||||||
e.UITransformComponent.anchoredPosition = uipos
|
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||||
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))`),
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ end`),
|
|||||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||||
]),
|
]),
|
||||||
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
|
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if slot ~= 0 and self.RewardChoices ~= nil then
|
if slot ~= 0 and self.RewardChoices ~= nil then
|
||||||
@@ -47,6 +47,11 @@ if slot ~= 0 and self.RewardChoices ~= nil then
|
|||||||
table.insert(self.RunDeck, id)
|
table.insert(self.RunDeck, id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if self.BonusRewardScreens ~= nil and self.BonusRewardScreens > 0 and slot ~= 0 then
|
||||||
|
self.BonusRewardScreens = self.BonusRewardScreens - 1
|
||||||
|
self:OfferReward()
|
||||||
|
return
|
||||||
|
end
|
||||||
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
|
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
|
||||||
if hud ~= nil then
|
if hud ~= nil then
|
||||||
hud.Enable = false
|
hud.Enable = false
|
||||||
|
|||||||
@@ -68,19 +68,36 @@ self.MaxEnergy = 3
|
|||||||
self.Turn = 0
|
self.Turn = 0
|
||||||
self.PlayerBlock = 0
|
self.PlayerBlock = 0
|
||||||
self.BlockGainMultiplier = 1
|
self.BlockGainMultiplier = 1
|
||||||
|
self.CardsDrawnThisCombat = 0
|
||||||
self.HandCostZeroThisTurn = false
|
self.HandCostZeroThisTurn = false
|
||||||
self.DrawDisabledThisTurn = false
|
self.DrawDisabledThisTurn = false
|
||||||
self.NextSkillCostZero = false
|
self.NextSkillCostZero = false
|
||||||
|
self.NextSkillRepeatCount = 0
|
||||||
self.SkillCostReductionThisTurn = 0
|
self.SkillCostReductionThisTurn = 0
|
||||||
|
self.CombatCardCostReduction = {}
|
||||||
|
self.SkillSlyOnPlayCards = {}
|
||||||
|
self.TurnSkillSlyCards = {}
|
||||||
|
self.ShivFirstDamageBonusUsed = false
|
||||||
|
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||||
|
self.DrawDamageThisTurn = 0
|
||||||
|
self.DrawPoisonThisTurn = 0
|
||||||
|
self.ShivAoeThisCombat = false
|
||||||
|
self.PoisonApplicationsThisCombat = 0
|
||||||
|
self.EnemyStrengthLossThisTurn = 0
|
||||||
self.PlayerStr = 0
|
self.PlayerStr = 0
|
||||||
self.PlayerDex = 0
|
self.PlayerDex = 0
|
||||||
self.PlayerThorns = 0
|
self.PlayerThorns = 0
|
||||||
self.PlayerWeak = 0
|
self.PlayerWeak = 0
|
||||||
self.PlayerVuln = 0
|
self.PlayerVuln = 0
|
||||||
|
self.PlayerIntangible = 0
|
||||||
|
self.BonusRewardScreens = 0
|
||||||
|
self.ActiveKillReward = 0
|
||||||
self.PlayerPowers = {}
|
self.PlayerPowers = {}
|
||||||
self.FightAttackCount = 0
|
self.FightAttackCount = 0
|
||||||
self.TurnAttackCardsPlayed = 0
|
self.TurnAttackCardsPlayed = 0
|
||||||
self.TurnDiscardedCards = 0
|
self.TurnDiscardedCards = 0
|
||||||
|
self.TurnCardsPlayedThisTurn = 0
|
||||||
|
self.DamageDealtThisTurn = 0
|
||||||
self.DmgPopSeq = 0
|
self.DmgPopSeq = 0
|
||||||
self.FirstHpLossDone = false
|
self.FirstHpLossDone = false
|
||||||
self.ClayBlockNext = 0
|
self.ClayBlockNext = 0
|
||||||
@@ -223,7 +240,7 @@ for i = 1, n do
|
|||||||
local startIdx = 1
|
local startIdx = 1
|
||||||
if #intents > 0 then startIdx = math.random(1, #intents) end
|
if #intents > 0 then startIdx = math.random(1, #intents) end
|
||||||
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
|
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,
|
hp = maxHp, maxHp = maxHp, block = 0, str = e.str or 0, weak = 0, vuln = 0, poison = 0, artifact = e.artifact or 0,
|
||||||
hitClip = hitClip, standClip = standClip, motionBusy = false,
|
hitClip = hitClip, standClip = standClip, motionBusy = false,
|
||||||
intents = intents, intentIdx = startIdx, alive = true, slot = i }
|
intents = intents, intentIdx = startIdx, alive = true, slot = i }
|
||||||
self:ReviveMonsterEntity(item.entity)
|
self:ReviveMonsterEntity(item.entity)
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
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 { 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 { 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';
|
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 = [
|
export const runEndMethods = [
|
||||||
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} }
|
method('ShowResult', `self:SetText("/ui/RunUIGroup/CombatHud/Result", text)
|
||||||
local target = maps[self.Floor]
|
local entity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/Result")
|
||||||
if target == nil then
|
if entity ~= nil then
|
||||||
return
|
entity.Enable = true
|
||||||
end
|
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||||
local lp = _UserService.LocalPlayer
|
method('EndRun', `local msg = text
|
||||||
if lp == nil then
|
if text == "런 클리어!" and self.AscensionLevel >= self.AscensionUnlocked and self.AscensionUnlocked < 10 then
|
||||||
return
|
self.AscensionUnlocked = self.AscensionUnlocked + 1
|
||||||
end
|
local lp = _UserService.LocalPlayer
|
||||||
if lp.CurrentMapName == target then
|
if lp ~= nil then
|
||||||
return
|
self:SaveAscension(self.AscensionUnlocked, lp.PlayerComponent.UserId)
|
||||||
end
|
end
|
||||||
_TeleportService:TeleportToMapPosition(lp, Vector3(-6, 0.03, 0), target)`),
|
self:RenderAscension()
|
||||||
method('ShowResult', `self:SetText("/ui/RunUIGroup/CombatHud/Result", text)
|
msg = "런 클리어! 승천 " .. string.format("%d", self.AscensionUnlocked) .. " 해금!"
|
||||||
local entity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/Result")
|
end
|
||||||
if entity ~= nil then
|
self:ShowResult(msg)
|
||||||
entity.Enable = true
|
self.RunActive = false
|
||||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
_TimerService:SetTimerOnce(function() self:ShowLobby() end, 4)`, [{ 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' }]),
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, A
|
|||||||
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 { 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';
|
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 = [
|
export const screensMethods = [
|
||||||
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
|
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
|
||||||
self:SetEntityEnabled("/ui/DefaultGroup/Button_Jump", false)
|
self:SetEntityEnabled("/ui/DefaultGroup/Button_Jump", false)
|
||||||
self:SetEntityEnabled("/ui/DefaultGroup/UIJoystick", false)
|
self:SetEntityEnabled("/ui/DefaultGroup/UIJoystick", false)
|
||||||
@@ -21,14 +21,14 @@ self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud", false)
|
|||||||
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
|
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
|
||||||
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
|
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
|
||||||
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
|
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
|
||||||
method('ActivateUIGroups', `local function grp(n)
|
method('ActivateUIGroups', `local function enableGroup(name)
|
||||||
local g = _EntityService:GetEntityByPath("/ui/" .. n)
|
local group = _EntityService:GetEntityByPath("/ui/" .. name)
|
||||||
if g ~= nil then g:SetEnable(true) end
|
if group ~= nil then group:SetEnable(true) end
|
||||||
end
|
end
|
||||||
grp("SelectUIGroup")
|
enableGroup("SelectUIGroup")
|
||||||
grp("LobbyUIGroup")
|
enableGroup("LobbyUIGroup")
|
||||||
grp("RunUIGroup")
|
enableGroup("RunUIGroup")
|
||||||
grp("DeckUIGroup")`, [], 2),
|
enableGroup("DeckUIGroup")`, [], 2),
|
||||||
method('ShowState', `self:HideGameHud()
|
method('ShowState', `self:HideGameHud()
|
||||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu")
|
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu")
|
||||||
self:SetEntityEnabled("/ui/SelectUIGroup/CharacterSelectHud", state == "charselect")
|
self:SetEntityEnabled("/ui/SelectUIGroup/CharacterSelectHud", state == "charselect")
|
||||||
@@ -135,44 +135,17 @@ self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)
|
|||||||
self:BindLobbyButtons()
|
self:BindLobbyButtons()
|
||||||
self:BindMenuButtons()
|
self:BindMenuButtons()
|
||||||
self:GoLobbyMap()`),
|
self:GoLobbyMap()`),
|
||||||
method('GoLobbyMap', `self.LobbyTpTries = 0
|
method('RenderSoulLabel', `local soulPoints = self.SoulPoints or 0
|
||||||
local eventId = 0
|
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "영혼 " .. string.format("%d", soulPoints))
|
||||||
local function go()
|
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "영혼 " .. string.format("%d", soulPoints))`),
|
||||||
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
|
method('BindLobbyButtons', `if self.LobbyBound == true then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.LobbyBound = true
|
self.LobbyBound = true
|
||||||
local function bindClick(path, fn)
|
local function bindClick(path, handler)
|
||||||
local e = _EntityService:GetEntityByPath(path)
|
local entity = _EntityService:GetEntityByPath(path)
|
||||||
if e ~= nil and (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
|
if entity ~= nil and (entity.ButtonComponent ~= nil or entity:AddComponent("ButtonComponent") ~= nil) then
|
||||||
e:ConnectEvent(ButtonClickEvent, fn)
|
entity:ConnectEvent(ButtonClickEvent, handler)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
bindClick("/ui/LobbyUIGroup/LobbyHud/AscMinus", function() self:AdjustAscension(-1) end)
|
bindClick("/ui/LobbyUIGroup/LobbyHud/AscMinus", function() self:AdjustAscension(-1) end)
|
||||||
@@ -105,6 +105,9 @@ return out`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [
|
|||||||
method('HoverCard', `if self.DragSlot ~= nil and self.DragSlot > 0 then
|
method('HoverCard', `if self.DragSlot ~= nil and self.DragSlot > 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self.Hand == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
local cardId = self.Hand[slot]
|
local cardId = self.Hand[slot]
|
||||||
if cardId == nil then
|
if cardId == nil then
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import { POTIONS } from './lib/data.mjs';
|
import { POTIONS } from './lib/data.mjs';
|
||||||
import { prop, codeblock, RUN_LENGTH } from './lib/codeblock.mjs';
|
import { prop, codeblock, RUN_LENGTH } from './lib/codeblock.mjs';
|
||||||
import { bootMethods } from './cb/boot.mjs';
|
import { bootMethods } from './cb/boot.mjs';
|
||||||
import { stateMethods } from './cb/state.mjs';
|
import { screensMethods } from './cb/screens.mjs';
|
||||||
|
import { npcMethods } from './cb/npc.mjs';
|
||||||
|
import { navigationMethods } from './cb/navigation.mjs';
|
||||||
|
import { layoutMethods } from './cb/layout.mjs';
|
||||||
import { soulMethods } from './cb/soul.mjs';
|
import { soulMethods } from './cb/soul.mjs';
|
||||||
import { charSelectMethods } from './cb/charselect.mjs';
|
import { charSelectMethods } from './cb/charselect.mjs';
|
||||||
import { runMethods } from './cb/run.mjs';
|
import { runMethods } from './cb/run.mjs';
|
||||||
@@ -113,14 +116,33 @@ function writeCodeblocks() {
|
|||||||
prop('number', 'PlayerStr', '0'),
|
prop('number', 'PlayerStr', '0'),
|
||||||
prop('number', 'PlayerWeak', '0'),
|
prop('number', 'PlayerWeak', '0'),
|
||||||
prop('number', 'PlayerVuln', '0'),
|
prop('number', 'PlayerVuln', '0'),
|
||||||
|
prop('number', 'PlayerIntangible', '0'),
|
||||||
prop('any', 'PlayerPowers'),
|
prop('any', 'PlayerPowers'),
|
||||||
prop('any', 'Potions'),
|
prop('any', 'Potions'),
|
||||||
prop('any', 'RunPotions'),
|
prop('any', 'RunPotions'),
|
||||||
prop('number', 'PotionSlots', String(POTIONS.baseSlots)),
|
prop('number', 'PotionSlots', String(POTIONS.baseSlots)),
|
||||||
prop('string', 'ShopPotion', '""'),
|
prop('string', 'ShopPotion', '""'),
|
||||||
prop('boolean', 'ShopPotionBought', 'false'),
|
prop('boolean', 'ShopPotionBought', 'false'),
|
||||||
|
prop('number', 'CardsDrawnThisCombat', '0'),
|
||||||
|
prop('boolean', 'HandCostZeroThisTurn', 'false'),
|
||||||
|
prop('boolean', 'DrawDisabledThisTurn', 'false'),
|
||||||
|
prop('number', 'SkillCostReductionThisTurn', '0'),
|
||||||
|
prop('any', 'SkillSlyOnPlayCards'),
|
||||||
|
prop('any', 'TurnSkillSlyCards'),
|
||||||
|
prop('boolean', 'ShivFirstDamageBonusUsed', 'false'),
|
||||||
|
prop('any', 'CombatCardCostReduction'),
|
||||||
|
prop('number', 'ActiveAttackDamageVsWeakMultiplier', '1'),
|
||||||
|
prop('number', 'DrawDamageThisTurn', '0'),
|
||||||
|
prop('number', 'DrawPoisonThisTurn', '0'),
|
||||||
|
prop('boolean', 'ShivAoeThisCombat', 'false'),
|
||||||
|
prop('number', 'PoisonApplicationsThisCombat', '0'),
|
||||||
|
prop('number', 'EnemyStrengthLossThisTurn', '0'),
|
||||||
|
prop('number', 'ActiveKillReward', '0'),
|
||||||
|
prop('number', 'BonusRewardScreens', '0'),
|
||||||
prop('number', 'FightAttackCount', '0'),
|
prop('number', 'FightAttackCount', '0'),
|
||||||
prop('number', 'TurnAttackCardsPlayed', '0'),
|
prop('number', 'TurnAttackCardsPlayed', '0'),
|
||||||
|
prop('number', 'TurnCardsPlayedThisTurn', '0'),
|
||||||
|
prop('number', 'DamageDealtThisTurn', '0'),
|
||||||
prop('number', 'TurnDiscardedCards', '0'),
|
prop('number', 'TurnDiscardedCards', '0'),
|
||||||
prop('boolean', 'FirstHpLossDone', 'false'),
|
prop('boolean', 'FirstHpLossDone', 'false'),
|
||||||
prop('number', 'ClayBlockNext', '0'),
|
prop('number', 'ClayBlockNext', '0'),
|
||||||
@@ -133,6 +155,8 @@ function writeCodeblocks() {
|
|||||||
prop('number', 'DiscardSelectTotal', '0'),
|
prop('number', 'DiscardSelectTotal', '0'),
|
||||||
prop('number', 'DiscardPostShiv', '0'),
|
prop('number', 'DiscardPostShiv', '0'),
|
||||||
prop('number', 'DiscardShivPerPick', '0'),
|
prop('number', 'DiscardShivPerPick', '0'),
|
||||||
|
prop('number', 'DiscardPostDraw', '0'),
|
||||||
|
prop('number', 'DiscardDrawPerPick', '0'),
|
||||||
prop('boolean', 'RetainSelectActive', 'false'),
|
prop('boolean', 'RetainSelectActive', 'false'),
|
||||||
prop('boolean', 'ReserveSelectActive', 'false'),
|
prop('boolean', 'ReserveSelectActive', 'false'),
|
||||||
prop('number', 'NextTurnBlock', '0'),
|
prop('number', 'NextTurnBlock', '0'),
|
||||||
@@ -143,11 +167,13 @@ function writeCodeblocks() {
|
|||||||
prop('string', 'NextTurnSelectPrompt', '""'),
|
prop('string', 'NextTurnSelectPrompt', '""'),
|
||||||
prop('number', 'NextTurnSelectCopies', '0'),
|
prop('number', 'NextTurnSelectCopies', '0'),
|
||||||
prop('boolean', 'NextSkillCostZero', 'false'),
|
prop('boolean', 'NextSkillCostZero', 'false'),
|
||||||
prop('number', 'SkillCostReductionThisTurn', '0'),
|
prop('number', 'NextSkillRepeatCount', '0'),
|
||||||
prop('any', 'NextTurnAddCards'),
|
prop('any', 'NextTurnAddCards'),
|
||||||
], [
|
], [
|
||||||
...bootMethods,
|
...bootMethods,
|
||||||
...stateMethods,
|
...screensMethods,
|
||||||
|
...npcMethods,
|
||||||
|
...navigationMethods,
|
||||||
...soulMethods,
|
...soulMethods,
|
||||||
...charSelectMethods,
|
...charSelectMethods,
|
||||||
...runMethods,
|
...runMethods,
|
||||||
@@ -158,6 +184,7 @@ function writeCodeblocks() {
|
|||||||
...jobMethods,
|
...jobMethods,
|
||||||
...runEndMethods,
|
...runEndMethods,
|
||||||
...renderMethods,
|
...renderMethods,
|
||||||
|
...layoutMethods,
|
||||||
...rewardMethods,
|
...rewardMethods,
|
||||||
...itemMethods,
|
...itemMethods,
|
||||||
...tooltipMethods,
|
...tooltipMethods,
|
||||||
|
|||||||
@@ -162,12 +162,21 @@ function luaCardsTable(cards) {
|
|||||||
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
||||||
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
||||||
if (c.damagePerSkillInHand != null) fields.push(`damagePerSkillInHand = ${c.damagePerSkillInHand}`);
|
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.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.firstCardDamageBonus != null) fields.push(`firstCardDamageBonus = ${c.firstCardDamageBonus}`);
|
||||||
|
if (c.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`);
|
||||||
|
if (c.intangible != null) fields.push(`intangible = ${c.intangible}`);
|
||||||
|
if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`);
|
||||||
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
|
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.otherHandAtLeast != null) fields.push(`otherHandAtLeast = ${c.otherHandAtLeast}`);
|
||||||
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
|
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
|
||||||
if (c.block != null) fields.push(`block = ${c.block}`);
|
if (c.block != null) fields.push(`block = ${c.block}`);
|
||||||
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
|
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
|
||||||
|
if (c.blockPerDamageDealtThisTurn != null) fields.push(`blockPerDamageDealtThisTurn = ${c.blockPerDamageDealtThisTurn}`);
|
||||||
if (c.strength != null) fields.push(`strength = ${c.strength}`);
|
if (c.strength != null) fields.push(`strength = ${c.strength}`);
|
||||||
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
||||||
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
||||||
@@ -185,6 +194,8 @@ function luaCardsTable(cards) {
|
|||||||
if (c.draw != null) fields.push(`draw = ${c.draw}`);
|
if (c.draw != null) fields.push(`draw = ${c.draw}`);
|
||||||
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
|
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
|
||||||
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
||||||
|
if (c.drawDamage != null) fields.push(`drawDamage = ${c.drawDamage}`);
|
||||||
|
if (c.drawPoison != null) fields.push(`drawPoison = ${c.drawPoison}`);
|
||||||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||||||
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
||||||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||||||
@@ -198,6 +209,17 @@ function luaCardsTable(cards) {
|
|||||||
if (c.handCostZeroThisTurn === true) fields.push('handCostZeroThisTurn = true');
|
if (c.handCostZeroThisTurn === true) fields.push('handCostZeroThisTurn = true');
|
||||||
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
|
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
|
||||||
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
|
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
|
||||||
|
if (c.useAllEnergy === true) fields.push('useAllEnergy = true');
|
||||||
|
if (c.shivDamageBonus != null) fields.push(`shivDamageBonus = ${c.shivDamageBonus}`);
|
||||||
|
if (c.firstShivDamageBonus != null) fields.push(`firstShivDamageBonus = ${c.firstShivDamageBonus}`);
|
||||||
|
if (c.shivRetain === true) fields.push('shivRetain = true');
|
||||||
|
if (c.shivAoe === true) fields.push('shivAoe = true');
|
||||||
|
if (c.attackDamageVsWeakMultiplier != null) fields.push(`attackDamageVsWeakMultiplier = ${c.attackDamageVsWeakMultiplier}`);
|
||||||
|
if (c.poisonHits != null) fields.push(`poisonHits = ${c.poisonHits}`);
|
||||||
|
if (c.poisonRandomTargets === true) fields.push('poisonRandomTargets = true');
|
||||||
|
if (c.poisonIfTargetPoisoned === true) fields.push('poisonIfTargetPoisoned = 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.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`);
|
||||||
if (c.nextTurnDraw != null) fields.push(`nextTurnDraw = ${c.nextTurnDraw}`);
|
if (c.nextTurnDraw != null) fields.push(`nextTurnDraw = ${c.nextTurnDraw}`);
|
||||||
if (c.nextTurnKeepBlock === true) fields.push('nextTurnKeepBlock = true');
|
if (c.nextTurnKeepBlock === true) fields.push('nextTurnKeepBlock = true');
|
||||||
@@ -205,8 +227,21 @@ function luaCardsTable(cards) {
|
|||||||
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
|
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
|
||||||
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
|
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
|
||||||
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
|
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
|
||||||
|
if (c.nextSkillRepeatCount != null) fields.push(`nextSkillRepeatCount = ${c.nextSkillRepeatCount}`);
|
||||||
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
|
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
|
||||||
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
|
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
|
||||||
|
if (c.skillSlyOnPlay === true) fields.push('skillSlyOnPlay = true');
|
||||||
|
if (c.turnHandSlyCount != null) fields.push(`turnHandSlyCount = ${c.turnHandSlyCount}`);
|
||||||
|
if (c.combatCostReductionOnPlay != null) fields.push(`combatCostReductionOnPlay = ${c.combatCostReductionOnPlay}`);
|
||||||
|
if (c.randomTargetEachHit === true) fields.push('randomTargetEachHit = true');
|
||||||
|
if (c.repeatOnKill === true) fields.push('repeatOnKill = true');
|
||||||
|
if (c.affectsAllEnemies === true) fields.push('affectsAllEnemies = true');
|
||||||
|
if (c.removeEnemyBlock === true) fields.push('removeEnemyBlock = true');
|
||||||
|
if (c.removeEnemyArtifact === true) fields.push('removeEnemyArtifact = true');
|
||||||
|
if (c.enemyStrengthLossThisTurn != null) fields.push(`enemyStrengthLossThisTurn = ${c.enemyStrengthLossThisTurn}`);
|
||||||
|
if (c.extraPoisonTicks != null) fields.push(`extraPoisonTicks = ${c.extraPoisonTicks}`);
|
||||||
|
if (c.poisonApplicationBurstEvery != null) fields.push(`poisonApplicationBurstEvery = ${c.poisonApplicationBurstEvery}`);
|
||||||
|
if (c.poisonApplicationBurstDamage != null) fields.push(`poisonApplicationBurstDamage = ${c.poisonApplicationBurstDamage}`);
|
||||||
if (c.innate === true) fields.push('innate = true');
|
if (c.innate === true) fields.push('innate = true');
|
||||||
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
|
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
|
||||||
if (c.sly === true) fields.push('sly = true');
|
if (c.sly === true) fields.push('sly = true');
|
||||||
|
|||||||
34
tools/verify/cbprops.mjs
Normal file
34
tools/verify/cbprops.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// cb/*.mjs의 `self.<Field> = ` 대입 ↔ gen-slaydeck.mjs 선언 prop 대조.
|
||||||
|
// 미선언 prop에 대입하면 MSW 런타임 "cannot set X, no such field" → 그 후보를 정적 검출.
|
||||||
|
// 사용: node tools/verify/cbprops.mjs
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const orch = readFileSync('tools/deck/gen-slaydeck.mjs', 'utf8');
|
||||||
|
const declared = new Set();
|
||||||
|
for (const m of orch.matchAll(/prop\(\s*'[^']*'\s*,\s*'([^']+)'/g)) declared.add(m[1]);
|
||||||
|
|
||||||
|
// MSW 빌트인/설정으로 대입 가능한 self 필드(프롭 아님) — 오탐 제외 화이트리스트.
|
||||||
|
const BUILTIN = new Set(['Entity']);
|
||||||
|
|
||||||
|
const dir = 'tools/deck/cb';
|
||||||
|
const files = readdirSync(dir).filter((f) => f.endsWith('.mjs'));
|
||||||
|
const assigns = new Map(); // name -> Set(files)
|
||||||
|
for (const f of files) {
|
||||||
|
const src = readFileSync(`${dir}/${f}`, 'utf8');
|
||||||
|
// self.Name = (단, == / ~= / .Y= / [..]= 는 제외)
|
||||||
|
for (const m of src.matchAll(/self\.([A-Za-z_]\w*)\s*=(?!=)/g)) {
|
||||||
|
const name = m[1];
|
||||||
|
if (!assigns.has(name)) assigns.set(name, new Set());
|
||||||
|
assigns.get(name).add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = [...assigns.keys()]
|
||||||
|
.filter((n) => !declared.has(n) && !BUILTIN.has(n))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`선언 prop: ${declared.size} | 대입된 self.X distinct: ${assigns.size}`);
|
||||||
|
console.log(`미선언 대입 (no such field 후보): ${missing.length}`);
|
||||||
|
for (const n of missing) console.log(` - ${n} [${[...assigns.get(n)].join(', ')}]`);
|
||||||
|
console.log(missing.length ? 'RESULT: MISSING PROPS ABOVE' : 'RESULT: 모든 self 대입이 선언됨 ✓');
|
||||||
|
process.exit(missing.length ? 1 : 0);
|
||||||
40
tools/verify/cbset.mjs
Normal file
40
tools/verify/cbset.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 순서 무관 codeblock 메서드 집합 비교. 본문 미출력 — 이름·차이 카운트만.
|
||||||
|
// 메서드 이동 리팩터의 무손실 검증용: 워킹트리 codeblock vs ref(기본 HEAD).
|
||||||
|
// 사용: node tools/verify/cbset.mjs [ref]
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const PATH = 'RootDesk/MyDesk/SlayDeckController.codeblock';
|
||||||
|
const ref = process.argv[2] || 'HEAD';
|
||||||
|
|
||||||
|
function methodsOf(jsonText) {
|
||||||
|
const obj = JSON.parse(jsonText);
|
||||||
|
const arr = obj.ContentProto.Json.Methods;
|
||||||
|
const map = new Map();
|
||||||
|
for (const m of arr) {
|
||||||
|
map.set(m.Name, { code: m.Code, exec: m.ExecSpace, params: JSON.stringify(m.Parameters || []) });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = methodsOf(readFileSync(PATH, 'utf8'));
|
||||||
|
const base = methodsOf(execSync(`git show ${ref}:${PATH}`, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }));
|
||||||
|
|
||||||
|
const onlyWork = [...work.keys()].filter((k) => !base.has(k));
|
||||||
|
const onlyBase = [...base.keys()].filter((k) => !work.has(k));
|
||||||
|
const changed = [];
|
||||||
|
for (const k of work.keys()) {
|
||||||
|
if (!base.has(k)) continue;
|
||||||
|
const a = work.get(k);
|
||||||
|
const b = base.get(k);
|
||||||
|
if (a.code !== b.code || a.exec !== b.exec || a.params !== b.params) changed.push(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`ref=${ref} work=${work.size} base=${base.size}`);
|
||||||
|
console.log(`only-in-work (${onlyWork.length}): ${onlyWork.join(', ') || '-'}`);
|
||||||
|
console.log(`only-in-base (${onlyBase.length}): ${onlyBase.join(', ') || '-'}`);
|
||||||
|
console.log(`body/exec/params changed (${changed.length}): ${changed.join(', ') || '-'}`);
|
||||||
|
|
||||||
|
const ok = onlyWork.length === 0 && onlyBase.length === 0 && changed.length === 0;
|
||||||
|
console.log(ok ? 'RESULT: IDENTICAL SET (무손실)' : 'RESULT: DIFFERENCES ABOVE');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
Reference in New Issue
Block a user