Compare commits
31 Commits
43530bee60
...
codex/card
| Author | SHA1 | Date | |
|---|---|---|---|
| 34531b184f | |||
| f6650a6c70 | |||
| acf295d56c | |||
| 9278c47901 | |||
| b2bf1bf4dd | |||
| 5da6e8f3aa | |||
| 71435a2c91 | |||
| f64e35668d | |||
| ba1651e52c | |||
| f8414a9c33 | |||
| 6344685052 | |||
| b0f1a0840c | |||
| c703bd9b4d | |||
| 96102dc41f | |||
| 8702d5209e | |||
| 74d4824a1c | |||
| bdea6a8c28 | |||
| 4fa0bc85c0 | |||
| db76870d4e | |||
| 491833b025 | |||
| 83de73c2c1 | |||
| b06ad8e8ee | |||
| 578f4416b2 | |||
| bc80b96875 | |||
| f2828deb19 | |||
| b549abc3b3 | |||
| 5b21e7f436 | |||
| b42d5fcf51 | |||
| 098571e9aa | |||
| ea832ad846 | |||
| 4288c4101b |
38
README.md
38
README.md
@@ -44,8 +44,8 @@ git pull
|
||||
```
|
||||
slaymaple/
|
||||
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
|
||||
│ ├── cards.json # 카드 122장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── enemies.json # 적 12종(일반/정예/보스, 디버프 인텐트 포함)
|
||||
│ ├── cards.json # 카드 121장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── enemies.json # 적 18종(일반/정예/보스, 디버프 인텐트 포함)
|
||||
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
|
||||
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
|
||||
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
|
||||
@@ -94,9 +94,17 @@ slaymaple/
|
||||
|
||||
---
|
||||
|
||||
## 직업 컨셉
|
||||
|
||||
3직업 모두 Slay the Spire 2 차용 + 메이플 IP 재해석. 카드 덱 상세 설계는 [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||
|
||||
- **⚔️ 전사 (탱커, Ironclad 차용)** — **파이터**: 공격을 *연속*으로 내면 콤보가 쌓이고(방어·파워 등 비공격 카드를 쓰면 콤보 리셋) 콤보로 데미지 증가 버프 = 브루저. **페이지**: 위협 디버프로 버티며 방어도 축적 → **바디 슬램(방어 비례 피해)** 카운터. **스피어맨**: 하이퍼바디·아이언월 유지/리치형.
|
||||
- **🗡️ 도적 (단검·독, Silent 차용)** — 표창 난사 / 독 / 교활·버림. **어쌔신**(표창·크리·흡혈) / **시프**(단검 난타·독). *형 구현 완료(Silent 86장)*.
|
||||
- **🔮 법사 (약체·게이지, Defect 차용)** — **위자드(불/독)**: 독을 묻히고 *독 걸린 적에 불 카드 → 추가 데미지*(독뎀 시너지). **위자드(썬/콜)**: 오브로 썬더(다중 공격)·콜드(빙결=취약+피해), 오브 획득·다중 소모 운용. **클레릭**: 오브 없이 회복·버프 + 언데드엔 힐로 공격하는 보조 힐러.
|
||||
|
||||
## 게임 프레임워크 현황
|
||||
|
||||
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다:
|
||||
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다 (게임 시작 시 MainMenu 없이 바로 로비로 진입):
|
||||
|
||||
```
|
||||
로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
|
||||
@@ -106,13 +114,13 @@ slaymaple/
|
||||
|
||||
게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. **UI는 메이커 저작**(7개 UIGroup: Default/Select/Lobby/Run/Deck/Popup/Toast)이고, 컨트롤러가 엔티티 경로(`/ui/<UIGroup>/<Hud>/...`)로 내용을 런타임 주입합니다. 생성기 `tools/deck/gen-slaydeck.mjs`는 **`SlayDeckController.codeblock` + `common.gamelogic`만 생성**(`.ui` 미접근, 결정적 출력 — `RULES.md` 참조). 게임 데이터는 **`data/*.json`**, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
|
||||
|
||||
### 구현된 기능 (배포 퀄리티 P1~P15, PR #34~#57)
|
||||
### 구현된 기능 (배포 퀄리티 P1~P15+, PR #34~#79)
|
||||
|
||||
| 영역 | 내용 |
|
||||
|---|---|
|
||||
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`키 **또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
|
||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택, 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **122장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **121장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 −25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
|
||||
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
|
||||
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. 점선 경로·상태 4단·층 카운터. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
|
||||
@@ -125,7 +133,7 @@ slaymaple/
|
||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
|
||||
|
||||
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
|
||||
> ℹ️ 도적(Silent) 카드 88장은 효과·프레임은 적용됐으나 **카드 아이콘(image/fx) 미할당** 상태입니다(전사·마법사 카드는 실 스킬 아이콘 적용 완료).
|
||||
> ℹ️ 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||
|
||||
### 유용한 스크립트 호출
|
||||
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
|
||||
@@ -149,6 +157,17 @@ c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1
|
||||
밸런스 검증: `node tools/balance/sim-balance.mjs [N] [--seed S]` · 테스트: `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs`.
|
||||
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 및 `docs/superpowers/specs/` 참조.
|
||||
|
||||
### 디버그 단축키
|
||||
|
||||
개발·QA용 키보드 단축키. **전투 중**(런 활성 + 전투 진행 중)에만 동작합니다.
|
||||
|
||||
| 단축키 | 기능 |
|
||||
|---|---|
|
||||
| **Ctrl + Shift + C** | **카드 picker** — 직업 전체 카드 패널을 띄우고, 카드를 클릭하면 **즉시 손패에 추가**. 상단 탭(전사/도적/마법사)으로 직업별 카드 풀 전환. 카드 효과·메커니즘 즉석 테스트용 |
|
||||
| **Ctrl + Shift + E** | **전체 회복 치트** — 체력·에너지를 최대치로 회복 |
|
||||
|
||||
> 카드 picker는 메이커 저작 UI `DeckUIGroup/DeckAllHud`(120 슬롯 그리드 + 직업 탭 3종)를 사용하고, 컨트롤러가 런타임에 카드 비주얼·버튼을 바인딩합니다. 구현: 키 바인딩 `tools/deck/cb/boot.mjs`, picker 로직 `tools/deck/cb/deckview.mjs`(`OpenDebugCardPicker`/`OnAllDeckCardButton`), 버튼 바인딩 `tools/deck/cb/deckturn.mjs`(`BindButtons`). 옛 picker UI 생성기 `tools/deck/legacy/hud/deckall.mjs`는 UI 메이커-저작 전환 후 **휴면**(Maker UI가 대체).
|
||||
|
||||
### 산출물 재생성
|
||||
```bash
|
||||
node tools/deck/gen-slaydeck.mjs # 컨트롤러+common (UI는 메이커 저작 — 미생성)
|
||||
@@ -173,8 +192,9 @@ node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
|
||||
|
||||
## 향후 개선 계획 (후속 후보)
|
||||
- [x] 전투 루프 · 런 루프 · 절차 생성 맵 · 상점/휴식/유물 방 · 유물 19종 · 물약 · 버프/디버프 · Power · 전직(전사/법사/도적 2차) · 승천+개인 저장 · 전투 모션 · 커스텀 프레임 · **반복 런·로비 맵·NPC·영혼·메소·카메라 추종 (P1~P15 완료)**
|
||||
- [x] **UI 메이커-저작 전환** — 단일 DefaultGroup → 7개 UIGroup 분리, 생성기 UI 저작 폐기(`tools/deck/legacy/`), 컨트롤러 경로 재연결(cbgap GAP 0), MainMenu→로비→run NPC→charselect 부트 흐름 (2026-06-17)
|
||||
- [ ] **도적 카드 아이콘** — Silent 88장에 실 스킬 아이콘(image/fx) 할당, 2차 전직 설명 한글화
|
||||
- [x] **UI 메이커-저작 전환** — 단일 DefaultGroup → 7개 UIGroup 분리, 생성기 UI 저작 폐기(`tools/deck/legacy/`), 컨트롤러 경로 재연결(cbgap GAP 0) (2026-06-17)
|
||||
- [x] **시작 로비 직행 · 캐릭터 선택 UI · 디버그 치트 · map01 로스터 (2026-06-18)** — 게임 시작 시 MainMenu 없이 곧장 로비 진입(MainMenu는 추후 싱글/멀티/종료 메뉴로 재지정); 캐릭터 선택 화면 초상화·직업 설명·선택 테두리·Art 클리핑(MaskComponent) 배선; 디버그 단축키 Ctrl+Shift+C(카드 picker)·Ctrl+Shift+E(체력+에너지 전체 회복); map01 몬스터 18종 로스터(랜덤 행동)
|
||||
- [ ] **도적 카드명 재서사·설명 한글화** — Silent 직역 카드명을 어쌔신/시프 메이플 스킬명으로 재서사(아이콘은 적용 완료), 2차 전직 설명 한글화
|
||||
- [ ] **런 이어하기** — 진행 중 런 직렬화 저장(UserDataStorage 확장, 메뉴 "이어하기" 활성화)
|
||||
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
|
||||
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)
|
||||
|
||||
23
RootDesk/MyDesk/01_blue_background_clean_1920x1080.sprite
Normal file
23
RootDesk/MyDesk/01_blue_background_clean_1920x1080.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://ac448e909f89464898708ce232ab8b51",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/ac448e909f89464898708ce232ab8b51/639173152021849887",
|
||||
"upload_hash": "CCC4771B9353971748EF9BEE32D57F15090CE62C4BA6446B11E7842FC7AFDF1F",
|
||||
"name": "01_blue_background_clean_1920x1080",
|
||||
"resource_guid": "ac448e909f89464898708ce232ab8b51",
|
||||
"resource_version": "6a32dd82c325482f6e2bb455"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
342
data/cards.json
342
data/cards.json
@@ -14,7 +14,7 @@
|
||||
"Defend": {
|
||||
"name": "아이언 바디",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"kind": "Attack",
|
||||
"block": 5,
|
||||
"desc": "방어도 5",
|
||||
"image": "7648c3b8e1ca44fc8ec353561207a670",
|
||||
@@ -89,8 +89,8 @@
|
||||
"name": "분노",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"aoe": true,
|
||||
"damage": 4,
|
||||
"desc": "매 턴 시작 시 힘 +1",
|
||||
"image": "379d86e3de064959aa4612f71e84ccfb",
|
||||
"class": "warrior",
|
||||
@@ -237,7 +237,8 @@
|
||||
"kind": "Skill",
|
||||
"class": "magician",
|
||||
"block": 3,
|
||||
"draw": 1,
|
||||
"discardAll": true,
|
||||
"drawPerDiscarded": 1,
|
||||
"desc": "방어도 3, 드로 1",
|
||||
"image": "7f70a9dc7e304433bb8121dd9c4df98b",
|
||||
"rarity": "normal"
|
||||
@@ -378,7 +379,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 3 줍니다. 약화를 1 부여합니다.",
|
||||
"weak": 1,
|
||||
"damage": 3
|
||||
"damage": 3,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"SilentStrike": {
|
||||
"name": "타격",
|
||||
@@ -387,7 +389,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 6 줍니다.",
|
||||
"damage": 6
|
||||
"damage": 6,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Survivor": {
|
||||
"name": "생존자",
|
||||
@@ -397,7 +400,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 8 얻습니다. 카드를 1장 버립니다.",
|
||||
"block": 8,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"SilentDefend": {
|
||||
"name": "수비",
|
||||
@@ -406,7 +410,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 5 얻습니다.",
|
||||
"block": 5
|
||||
"block": 5,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Slice": {
|
||||
"name": "칼질",
|
||||
@@ -415,7 +420,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 6 줍니다.",
|
||||
"damage": 6
|
||||
"damage": 6,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Shiv": {
|
||||
"name": "표창",
|
||||
@@ -426,7 +432,8 @@
|
||||
"desc": "피해를 4 줍니다. 소멸.",
|
||||
"damage": 4,
|
||||
"exhaust": true,
|
||||
"token": true
|
||||
"token": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"DaggerSpray": {
|
||||
"name": "단검 분사",
|
||||
@@ -437,7 +444,8 @@
|
||||
"desc": "모든 적에게 피해를 4만큼 2번 줍니다.",
|
||||
"aoe": true,
|
||||
"damage": 4,
|
||||
"hits": 2
|
||||
"hits": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"DaggerThrow": {
|
||||
"name": "단검 투척",
|
||||
@@ -446,9 +454,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 9 줍니다. 카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"drawUntilHandSize": 6,
|
||||
"damage": 9,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"PoisonedStab": {
|
||||
"name": "독 찌르기",
|
||||
@@ -458,7 +467,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 6 줍니다. 중독을 3 부여합니다.",
|
||||
"poison": 3,
|
||||
"damage": 6
|
||||
"damage": 6,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"SuckerPunch": {
|
||||
"name": "불의의 일격",
|
||||
@@ -468,7 +478,9 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 8 줍니다. 약화를 1 부여합니다.",
|
||||
"weak": 1,
|
||||
"damage": 8
|
||||
"damage": 8,
|
||||
"cardPlayedDamage": 2,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"LeadingStrike": {
|
||||
"name": "선제 타격",
|
||||
@@ -478,7 +490,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 3 줍니다. 표창을 2장 손으로 가져옵니다.",
|
||||
"damage": 3,
|
||||
"addShiv": 2
|
||||
"addShiv": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"FollowThrough": {
|
||||
"name": "완수",
|
||||
@@ -487,7 +500,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 7 줍니다. 손에 다른 카드가 5장 이상 있다면, 1번 추가로 적중합니다.",
|
||||
"damage": 7
|
||||
"damage": 7,
|
||||
"otherHandAtLeast": 5,
|
||||
"bonusHitsWhenOtherHandAtLeast": 1,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"FlickFlack": {
|
||||
"name": "재주넘기",
|
||||
@@ -498,7 +514,8 @@
|
||||
"desc": "교활. 모든 적에게 피해를 6 줍니다.",
|
||||
"aoe": true,
|
||||
"damage": 6,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Ricochet": {
|
||||
"name": "도탄",
|
||||
@@ -509,7 +526,8 @@
|
||||
"desc": "교활. 무작위 적에게 피해를 3만큼 4번 줍니다.",
|
||||
"damage": 3,
|
||||
"hits": 4,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Prepared": {
|
||||
"name": "예비",
|
||||
@@ -519,7 +537,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Anticipate": {
|
||||
"name": "예측",
|
||||
@@ -528,7 +547,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 민첩을 2 얻습니다.",
|
||||
"dex": 2
|
||||
"dex": 2,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Deflect": {
|
||||
"name": "튕겨내기",
|
||||
@@ -537,7 +557,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 4 얻습니다.",
|
||||
"block": 4
|
||||
"block": 4,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"BladeDance": {
|
||||
"name": "검무",
|
||||
@@ -547,7 +568,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 소멸.",
|
||||
"addShiv": 3,
|
||||
"exhaust": true
|
||||
"exhaust": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Backflip": {
|
||||
"name": "공중제비",
|
||||
@@ -557,7 +579,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 5 얻습니다. 카드를 2장 뽑습니다.",
|
||||
"block": 5,
|
||||
"draw": 2
|
||||
"draw": 2,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"DodgeAndRoll": {
|
||||
"name": "구르기",
|
||||
@@ -566,7 +589,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 4 얻습니다. 다음 턴에, 방어도를 4 얻습니다",
|
||||
"block": 4
|
||||
"block": 4,
|
||||
"nextTurnBlock": 4,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"PiercingWail": {
|
||||
"name": "귀를 찢는 비명",
|
||||
@@ -575,7 +600,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"CloakAndDagger": {
|
||||
"name": "망토와 단검",
|
||||
@@ -585,7 +611,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 6 얻습니다. 표창을 1장 손으로 가져옵니다.",
|
||||
"block": 6,
|
||||
"addShiv": 1
|
||||
"addShiv": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"DeadlyPoison": {
|
||||
"name": "맹독",
|
||||
@@ -594,7 +621,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "중독을 5 부여합니다.",
|
||||
"poison": 5
|
||||
"poison": 5,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Snakebite": {
|
||||
"name": "뱀 물기",
|
||||
@@ -604,7 +632,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "보존. 중독을 7 부여합니다.",
|
||||
"poison": 7,
|
||||
"retain": true
|
||||
"retain": true,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Untouchable": {
|
||||
"name": "범접 불가",
|
||||
@@ -614,7 +643,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "교활. 방어도를 6 얻습니다.",
|
||||
"block": 6,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Skewer": {
|
||||
"name": "꼬챙이",
|
||||
@@ -623,7 +653,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 8만큼 X번 줍니다.",
|
||||
"draw": 1
|
||||
"useAllEnergy": true,
|
||||
"xDamagePerEnergy": 8,
|
||||
"draw": 1,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Backstab": {
|
||||
"name": "배신",
|
||||
@@ -632,7 +665,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "선천성. 피해를 11 줍니다. 소멸.",
|
||||
"damage": 11
|
||||
"innate": true,
|
||||
"damage": 11,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"PreciseCut": {
|
||||
"name": "정밀한 베기",
|
||||
@@ -641,7 +676,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 13 줍니다. 손에 있는 다른 카드 1장당 피해량이 2 감소합니다.",
|
||||
"damage": 13
|
||||
"damage": 13,
|
||||
"damagePerOtherHandCard": -2,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Finisher": {
|
||||
"name": "마무리",
|
||||
@@ -650,7 +687,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "이번 턴에 사용한 공격 카드 1장당 피해를 6 줍니다.",
|
||||
"damage": 6
|
||||
"damage": 0,
|
||||
"damagePerAttackPlayedThisTurn": 6,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"MementoMori": {
|
||||
"name": "메멘토 모리",
|
||||
@@ -659,7 +698,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 9 줍니다. 이번 턴에 버린 카드 1장당 피해량이 4 증가합니다.",
|
||||
"damage": 9
|
||||
"damage": 9,
|
||||
"damagePerDiscardedThisTurn": 4,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Strangle": {
|
||||
"name": "목 조르기",
|
||||
@@ -668,7 +709,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 8 줍니다. 이번 턴에 카드를 사용할 때마다, 대상 적이 체력을 2 잃습니다.",
|
||||
"damage": 8
|
||||
"damage": 8,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Flechettes": {
|
||||
"name": "프레췌",
|
||||
@@ -677,7 +719,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 스킬 카드 1장당 피해를 5 줍니다.",
|
||||
"damage": 5
|
||||
"damage": 0,
|
||||
"damagePerSkillInHand": 5,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Pounce": {
|
||||
"name": "덮치기",
|
||||
@@ -686,7 +730,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 12 줍니다. 다음에 사용하는 스킬 카드의 비용이 0 이 됩니다.",
|
||||
"damage": 12
|
||||
"damage": 12,
|
||||
"nextSkillCostZero": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Dash": {
|
||||
"name": "돌진",
|
||||
@@ -696,7 +742,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 10 얻습니다. 피해를 10 줍니다.",
|
||||
"block": 10,
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Predator": {
|
||||
"name": "천적",
|
||||
@@ -705,8 +752,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 15 줍니다. 다음 턴에, 카드를 2장 뽑습니다.",
|
||||
"draw": 2,
|
||||
"damage": 15
|
||||
"nextTurnDraw": 2,
|
||||
"damage": 15,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Pinpoint": {
|
||||
"name": "정밀 사격",
|
||||
@@ -715,7 +763,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 15 줍니다. 이번 턴에 스킬을 사용할 때마다 비용이 1 감소합니다.",
|
||||
"damage": 15
|
||||
"damage": 15,
|
||||
"skillCostReductionThisTurn": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"CalculatedGamble": {
|
||||
"name": "계산된 도박",
|
||||
@@ -724,7 +774,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 모든 카드를 버린 뒤, 버린 카드의 수만큼 카드를 뽑습니다. 소멸.",
|
||||
"draw": 1
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"discardAll": true,
|
||||
"drawPerDiscarded": 1
|
||||
},
|
||||
"Expose": {
|
||||
"name": "들춰내기",
|
||||
@@ -733,7 +785,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
|
||||
"vuln": 2
|
||||
"vuln": 2,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"HiddenDaggers": {
|
||||
"name": "숨겨진 단검",
|
||||
@@ -743,7 +796,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 2장 버립니다. 표창을 2장 손으로 가져옵니다.",
|
||||
"discard": 2,
|
||||
"addShiv": 2
|
||||
"addShiv": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"EscapePlan": {
|
||||
"name": "탈출구",
|
||||
@@ -752,8 +806,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 1장 뽑습니다. 뽑은 카드가 스킬 카드라면, 방어도를 3 얻습니다.",
|
||||
"block": 3,
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"drawSkillBlock": 3,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Acrobatics": {
|
||||
"name": "곡예",
|
||||
@@ -763,7 +818,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 3장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 3,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"HandTrick": {
|
||||
"name": "손기술",
|
||||
@@ -772,7 +828,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
|
||||
"block": 7
|
||||
"block": 7,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Mirage": {
|
||||
"name": "신기루",
|
||||
@@ -781,7 +838,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "모든 적에게 부여된 중독과 동일한 만큼의 방어도를 얻습니다. 소멸.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Expertise": {
|
||||
"name": "전문성",
|
||||
@@ -790,7 +848,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 카드가 6장이 될 때까지 카드를 뽑습니다.",
|
||||
"draw": 1
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"drawUntilHandSize": 6
|
||||
},
|
||||
"BubbleBubble": {
|
||||
"name": "차오르는 독",
|
||||
@@ -799,7 +858,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
|
||||
"poison": 9
|
||||
"poison": 9,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Blur": {
|
||||
"name": "흐릿함",
|
||||
@@ -808,7 +868,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 5 얻습니다. 다음 턴 시작 시 방어도가 사라지지 않습니다.",
|
||||
"block": 5
|
||||
"block": 5,
|
||||
"nextTurnKeepBlock": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"LegSweep": {
|
||||
"name": "다리 걸기",
|
||||
@@ -818,7 +880,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "약화를 2 부여합니다. 방어도를 11 얻습니다.",
|
||||
"block": 11,
|
||||
"weak": 2
|
||||
"weak": 2,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"UpMySleeve": {
|
||||
"name": "비책",
|
||||
@@ -827,7 +890,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
||||
"addShiv": 3
|
||||
"addShiv": 3,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"BouncingFlask": {
|
||||
"name": "탄성 플라스크",
|
||||
@@ -836,7 +900,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
|
||||
"poison": 9
|
||||
"poison": 9,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Reflex": {
|
||||
"name": "반사신경",
|
||||
@@ -846,7 +911,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 카드를 2장 뽑습니다.",
|
||||
"draw": 2,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Haze": {
|
||||
"name": "아지랑이",
|
||||
@@ -856,7 +922,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 모든 적에게 중독을 4 부여합니다.",
|
||||
"poison": 4,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Tactician": {
|
||||
"name": "전략가",
|
||||
@@ -865,9 +932,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 을 얻습니다.",
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1,
|
||||
"sly": true
|
||||
"gainEnergy": 1,
|
||||
"sly": true,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"WellLaidPlans": {
|
||||
"name": "괜찮은 전략",
|
||||
@@ -876,8 +943,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 종료 시, 카드를 최대 1장까지 보존합니다.",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 2
|
||||
"powerEffect": "retainOne",
|
||||
"value": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"InfiniteBlades": {
|
||||
"name": "무한의 검날",
|
||||
@@ -886,7 +954,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 시작 시, 표창을 1장 손으로 가져옵니다.",
|
||||
"turnStartShiv": 1
|
||||
"turnStartShiv": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Footwork": {
|
||||
"name": "발놀림",
|
||||
@@ -895,7 +964,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "민첩을 2 얻습니다.",
|
||||
"dex": 2
|
||||
"dex": 2,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Outbreak": {
|
||||
"name": "발병",
|
||||
@@ -907,7 +977,8 @@
|
||||
"aoe": true,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 11
|
||||
"damage": 11,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"NoxiousFumes": {
|
||||
"name": "유독 가스",
|
||||
@@ -917,8 +988,9 @@
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 시작 시, 모든 적에게 중독을 2 부여합니다.",
|
||||
"poison": 2,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"powerEffect": "poisonPerTurn",
|
||||
"value": 2,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Accuracy": {
|
||||
"name": "정밀",
|
||||
@@ -928,7 +1000,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "표창의 피해량이 4 증가합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"PhantomBlades": {
|
||||
"name": "환영검",
|
||||
@@ -938,7 +1011,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Speedster": {
|
||||
"name": "스피드스터",
|
||||
@@ -948,9 +1022,9 @@
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
|
||||
"aoe": true,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 2
|
||||
"powerEffect": "damagePerTurn",
|
||||
"value": 2,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"GrandFinale": {
|
||||
"name": "대단원의 막",
|
||||
@@ -959,8 +1033,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "뽑을 카드 더미에 카드가 없을 때만 사용할 수 있습니다. 모든 적에게 피해를 60 줍니다.",
|
||||
"playableWhenDrawPileEmpty": true,
|
||||
"aoe": true,
|
||||
"damage": 60
|
||||
"damage": 60,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
||||
},
|
||||
"Assassinate": {
|
||||
"name": "암살",
|
||||
@@ -969,8 +1045,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "선천성. 피해를 10 줍니다. 취약을 1 부여합니다. 소멸.",
|
||||
"innate": true,
|
||||
"vuln": 1,
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"EchoingSlash": {
|
||||
"name": "메아리 참격",
|
||||
@@ -980,7 +1058,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
||||
"aoe": true,
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
||||
},
|
||||
"TheHunt": {
|
||||
"name": "사냥",
|
||||
@@ -989,7 +1068,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Murder": {
|
||||
"name": "살해",
|
||||
@@ -998,7 +1078,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "피해를 1 줍니다. 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가합니다.",
|
||||
"damage": 1
|
||||
"damage": 1,
|
||||
"damagePerCardDrawnThisCombat": 1,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Malaise": {
|
||||
"name": "불쾌",
|
||||
@@ -1007,7 +1089,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "적이 힘을 X 잃습니다. 약화를 X 부여합니다. 소멸.",
|
||||
"weak": 3
|
||||
"useAllEnergy": true,
|
||||
"xWeakPerEnergy": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Adrenaline": {
|
||||
"name": "아드레날린",
|
||||
@@ -1017,8 +1101,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "를 얻습니다. 카드를 2장 뽑습니다. 소멸.",
|
||||
"draw": 2,
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1
|
||||
"gainEnergy": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"StormOfSteel": {
|
||||
"name": "강철의 폭풍",
|
||||
@@ -1028,7 +1112,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 표창을 손으로 가져옵니다.",
|
||||
"discardAll": true,
|
||||
"addShivPerDiscard": true
|
||||
"addShivPerDiscard": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"ShadowStep": {
|
||||
"name": "그림자 걸음",
|
||||
@@ -1037,8 +1122,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 다음 턴에, 공격 카드의 피해량이 2배가 됩니다.",
|
||||
"draw": 1,
|
||||
"discardAll": true
|
||||
"nextTurnAttackMultiplier": 2,
|
||||
"discardAll": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Shadowmeld": {
|
||||
"name": "그림자 은신",
|
||||
@@ -1047,7 +1133,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴 동안 얻는 방어도가 2배가 됩니다.",
|
||||
"draw": 1
|
||||
"blockGainMultiplier": 2,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"CorrosiveWave": {
|
||||
"name": "부식성 파도",
|
||||
@@ -1056,7 +1143,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
|
||||
"poison": 2
|
||||
"poison": 2,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"BladeOfInk": {
|
||||
"name": "잉크 칼날",
|
||||
@@ -1065,7 +1153,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "잉크투성이 표창을 2장 손으로 가져옵니다.",
|
||||
"addShiv": 2
|
||||
"addShiv": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Burst": {
|
||||
"name": "폭주",
|
||||
@@ -1074,7 +1163,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"KnifeTrap": {
|
||||
"name": "칼날 함정",
|
||||
@@ -1083,7 +1173,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 표창을 사용합니다.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"BulletTime": {
|
||||
"name": "불릿 타임",
|
||||
@@ -1092,8 +1183,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴 동안 더 이상 카드를 뽑을 수 없습니다. 이번 턴 동안 손에 있는 모든 카드를 비용 없이 사용할 수 있습니다.",
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1
|
||||
"handCostZeroThisTurn": true,
|
||||
"drawDisabledThisTurn": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Nightmare": {
|
||||
"name": "악몽",
|
||||
@@ -1102,7 +1194,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 1장 선택합니다. 다음 턴에, 그 카드의 복사본을 3장 손으로 가져옵니다. 소멸.",
|
||||
"draw": 1
|
||||
"nextTurnCopies": 3,
|
||||
"nextTurnSelectHandCard": true,
|
||||
"nextTurnSelectPrompt": "복사할 카드를 선택하세요",
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"ToolsOfTheTrade": {
|
||||
"name": "작업 도구",
|
||||
@@ -1111,10 +1206,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "내 턴 시작 시, 카드를 1장 뽑고 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1,
|
||||
"discard": 1
|
||||
"turnStartDraw": 1,
|
||||
"turnStartDiscard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Afterimage": {
|
||||
"name": "잔상",
|
||||
@@ -1123,9 +1217,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 사용할 때마다, 방어도를 1 얻습니다.",
|
||||
"block": 1,
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 2
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"cardPlayedBlock": 1
|
||||
},
|
||||
"Accelerant": {
|
||||
"name": "촉진제",
|
||||
@@ -1135,7 +1228,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "중독이 1번 추가로 발동합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Envenom": {
|
||||
"name": "독 바르기",
|
||||
@@ -1144,9 +1238,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "공격 카드가 막히지 않은 피해를 줄 때마다, 중독을 1 부여합니다.",
|
||||
"poison": 1,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"attackPoison": 1,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"MasterPlanner": {
|
||||
"name": "설계의 대가",
|
||||
@@ -1156,7 +1249,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "스킬 카드를 사용 시, 그 카드가 교활을 얻습니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Tracking": {
|
||||
"name": "추적",
|
||||
@@ -1166,7 +1260,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"FanOfKnives": {
|
||||
"name": "칼날 부채",
|
||||
@@ -1177,7 +1272,8 @@
|
||||
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"addShiv": 4
|
||||
"addShiv": 4,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"SerpentForm": {
|
||||
"name": "구렁이의 형상",
|
||||
@@ -1186,9 +1282,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 사용할 때마다, 무작위 적에게 피해를 4 줍니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 4
|
||||
"cardPlayedRandomDamage": 4,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Abrasive": {
|
||||
"name": "연마",
|
||||
@@ -1199,7 +1294,8 @@
|
||||
"desc": "교활. 민첩을 1 얻습니다. 가시를 4 얻습니다.",
|
||||
"dex": 1,
|
||||
"thorns": 4,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Suppress": {
|
||||
"name": "진압",
|
||||
@@ -1208,8 +1304,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "선천성. 피해를 11 줍니다. 약화를 3 부여합니다.",
|
||||
"innate": true,
|
||||
"weak": 3,
|
||||
"damage": 11
|
||||
"damage": 11,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"WraithForm": {
|
||||
"name": "유령의 형상",
|
||||
@@ -1219,26 +1317,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "불가침을 2 얻습니다. 내 턴 종료 시 민첩을 1 잃습니다.",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 8
|
||||
},
|
||||
"Flanking": {
|
||||
"name": "측면 공격",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 대상 적이 다른 플레이어에게 받는 피해량이 2배가 됩니다.",
|
||||
"draw": 1
|
||||
},
|
||||
"Sneaky": {
|
||||
"name": "비열함",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "교활. 다른 플레이어가 적을 공격할 때마다, 방어도를 1 얻습니다.",
|
||||
"block": 1,
|
||||
"sly": true
|
||||
"value": 8,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
}
|
||||
},
|
||||
"starterDecks": {
|
||||
|
||||
@@ -119,6 +119,65 @@
|
||||
{ "kind": "Attack", "value": 12 },
|
||||
{ "kind": "Attack", "value": 24 }
|
||||
]
|
||||
},
|
||||
"octopus": {
|
||||
"name": "문어",
|
||||
"maxHp": 15,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 5 },
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Defend", "value": 4 }
|
||||
]
|
||||
},
|
||||
"kapa_drake": {
|
||||
"name": "카파 드레이크",
|
||||
"maxHp": 24,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 9 },
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Defend", "value": 6 },
|
||||
{ "kind": "Attack", "value": 11 }
|
||||
]
|
||||
},
|
||||
"junior_neki": {
|
||||
"name": "주니어 네키",
|
||||
"maxHp": 18,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 1 }
|
||||
]
|
||||
},
|
||||
"junior_bugi": {
|
||||
"name": "주니어 부기",
|
||||
"maxHp": 20,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 7 },
|
||||
{ "kind": "Defend", "value": 5 },
|
||||
{ "kind": "Attack", "value": 9 }
|
||||
]
|
||||
},
|
||||
"dile": {
|
||||
"name": "다일",
|
||||
"maxHp": 65,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 13 },
|
||||
{ "kind": "Defend", "value": 9 },
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Attack", "value": 16 },
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 1 }
|
||||
]
|
||||
},
|
||||
"mano": {
|
||||
"name": "마노",
|
||||
"maxHp": 80,
|
||||
"intents": [
|
||||
{ "kind": "Defend", "value": 12 },
|
||||
{ "kind": "Attack", "value": 14 },
|
||||
{ "kind": "Debuff", "effect": "vuln", "value": 1 },
|
||||
{ "kind": "Attack", "value": 10 },
|
||||
{ "kind": "AddCard", "card": "Wound", "count": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"activeEnemy": "slime",
|
||||
|
||||
10
docs/attack-poison.md
Normal file
10
docs/attack-poison.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 공격 적중 독
|
||||
|
||||
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
|
||||
|
||||
동작:
|
||||
|
||||
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
|
||||
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
|
||||
- `Envenom` 같은 카드가 이 필드를 사용합니다.
|
||||
|
||||
102
docs/bandit-card-audit.md
Normal file
102
docs/bandit-card-audit.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Bandit Card Audit
|
||||
|
||||
`bandit` 카드의 구현 상태를 카드별로 정리한 문서입니다.
|
||||
|
||||
상태 기준:
|
||||
|
||||
- `구현됨`: 공용 필드와 공용 로직으로 처리됨
|
||||
- `부분구현`: 카드 설명의 일부만 맞음
|
||||
- `미구현`: 아직 전용 메커니즘이 없음
|
||||
|
||||
## 구현됨
|
||||
|
||||
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `Pinpoint`
|
||||
|
||||
공용 메모:
|
||||
|
||||
- `poison`, `innate`, `playableWhenDrawPileEmpty` 구현됨
|
||||
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne` 구현됨
|
||||
- `turnStartDraw`, `turnStartDiscard` 구현됨
|
||||
- `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard` 구현됨
|
||||
- `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast` 구현됨
|
||||
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `nextSkillCostZero`, `skillCostReductionThisTurn` 구현됨
|
||||
|
||||
## 부분구현
|
||||
|
||||
`Ricochet`: 무작위 적 4회 타격이 아니라 일반 분산 공격으로만 처리됨
|
||||
|
||||
`Anticipate`: 이번 턴 동안 민첩 2가 아니라 전투 전체 민첩 증가
|
||||
|
||||
`PiercingWail`: 이번 턴 적 공격 감소가 아니라 공용 약화/취약 계열만 적용
|
||||
|
||||
`Expose`: 방어도/인공물 제거는 없고 취약만 적용됨
|
||||
|
||||
`BubbleBubble`: 적이 독을 보유한 경우라는 조건이 아직 없음
|
||||
|
||||
`BouncingFlask`: 무작위 적 3번 분산 대신 단일 독 9 처리
|
||||
|
||||
## 미구현
|
||||
|
||||
`Skewer`: X코스트 연타 공격
|
||||
|
||||
`Outbreak`: 독 3번 부여 시 전체 피해 트리거
|
||||
|
||||
`Strangle`: 이번 턴 카드 사용마다 추가 피해
|
||||
|
||||
`EscapePlan`: 드로우한 카드가 스킬이면 방어도 3
|
||||
|
||||
`HandTrick`: 손패의 스킬 카드 하나에 교활 부여
|
||||
|
||||
`Mirage`: 모든 적의 독 총합만큼 방어 획득
|
||||
|
||||
`UpMySleeve`: 표창 생성 + 비용 감소
|
||||
|
||||
`NoxiousFumes`: 턴 시작 전체 적 독 부여 파워
|
||||
|
||||
`Accuracy`: 표창 피해 증가 파워
|
||||
|
||||
`PhantomBlades`: 표창 보존 + 첫 표창 강화
|
||||
|
||||
`Speedster`: 드로우할 때마다 전체 피해
|
||||
|
||||
`EchoingSlash`: 처치 시 반복
|
||||
|
||||
`TheHunt`: 처치 조건 보상
|
||||
|
||||
`Murder`: 이번 전투 동안 뽑은 카드 수 비례 피해
|
||||
|
||||
`Malaise`: X코스트 약화/피해 감소
|
||||
|
||||
`Pinpoint`: 이번 턴 스킬 비용 감소
|
||||
|
||||
`CorrosiveWave`: 드로우할 때마다 독
|
||||
|
||||
`BladeOfInk`: 전용 표창 생성
|
||||
|
||||
`Burst`: 다음 스킬 1회 추가 사용
|
||||
|
||||
`KnifeTrap`: 소멸된 표창 전부 사용
|
||||
|
||||
`BulletTime`: 드로우 금지 + 손패 무료 사용
|
||||
|
||||
`Accelerant`: 추가 독 발동
|
||||
|
||||
`Envenom`: 공격 적중 시 독 부여
|
||||
|
||||
`MasterPlanner`: 스킬 사용 시 교활 부여
|
||||
|
||||
`Tracking`: 약화된 적이 공격 피해를 2배로 받음
|
||||
|
||||
`FanOfKnives`: 표창이 모든 적 대상
|
||||
|
||||
`SerpentForm`: 카드 사용할 때마다 무작위 적에게 피해
|
||||
|
||||
`WraithForm`: 불가침 2 + 턴 종료 시 민첩 감소
|
||||
|
||||
## 다음 축
|
||||
|
||||
- 조건부 피해
|
||||
- 카드 사용 트리거
|
||||
- 비용/X코스트
|
||||
- 드로우 연동 파워
|
||||
|
||||
87
docs/card-effect-fields.md
Normal file
87
docs/card-effect-fields.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Card Effect Fields
|
||||
|
||||
`data/cards.json`의 카드 효과를 공용 데이터 필드로 표현하는 기준 문서입니다.
|
||||
|
||||
## 피해 수치
|
||||
|
||||
- `damage`: 기본 피해
|
||||
- `damagePerOtherHandCard`: 손패의 다른 카드 수만큼 피해 증감
|
||||
- `damagePerAttackPlayedThisTurn`: 이번 턴에 사용한 공격 카드 수만큼 피해 증감
|
||||
- `damagePerDiscardedThisTurn`: 이번 턴에 버린 카드 수만큼 피해 증감
|
||||
- `damagePerSkillInHand`: 손패의 스킬 카드 수만큼 피해 증감
|
||||
- `otherHandAtLeast`: 손패의 다른 카드가 이 수 이상일 때 조건 충족
|
||||
- `bonusHitsWhenOtherHandAtLeast`: 조건 충족 시 추가 적중 수
|
||||
|
||||
## 방어/상태
|
||||
|
||||
- `block`: 방어도 획득
|
||||
- `cardPlayedBlock`: 카드를 사용할 때마다 방어도 획득
|
||||
- `blockGainMultiplier`: 이번 턴 동안 얻는 방어도 배수
|
||||
- `hits`: 다단히트 횟수
|
||||
- `aoe`: 모든 적 대상
|
||||
- `pierce`: 방어도 무시
|
||||
- `draw`: 즉시 드로우
|
||||
- `drawUntilHandSize`: 손패가 지정 장수에 도달할 때까지 드로우
|
||||
- `heal`: 즉시 회복
|
||||
- `gainEnergy`: 즉시 에너지 획득
|
||||
- `strength`: 힘 획득
|
||||
- `dex`: 민첩 획득
|
||||
- `thorns`: 가시 획득
|
||||
- `selfVuln`: 자신에게 취약 부여
|
||||
|
||||
## 상태이상
|
||||
|
||||
- `weak`: 약화 부여
|
||||
- `vuln`: 취약 부여
|
||||
- `poison`: 중독 부여
|
||||
|
||||
`poison`은 적 턴 시작 시 피해를 주고 1 감소합니다.
|
||||
|
||||
## 드로우/버리기
|
||||
|
||||
- `discard`: 손패에서 지정 장수 버리기
|
||||
- `discardAll`: 손패 전부 버리기
|
||||
- `drawPerDiscarded`: 버린 카드 1장당 추가 드로우
|
||||
- `addShiv`: 표창 생성
|
||||
- `addShivPerDiscard`: 버린 장수만큼 표창 생성
|
||||
- `sly`: 버려질 때 교활 발동
|
||||
- `retain`: 턴 종료 시 해당 카드 보존
|
||||
|
||||
## 파워/턴 효과
|
||||
|
||||
- `powerEffect: "strengthPerTurn"`
|
||||
- `powerEffect: "energyPerTurn"`
|
||||
- `powerEffect: "blockPerTurn"`
|
||||
- `powerEffect: "retainOne"`
|
||||
- `turnStartShiv`: 턴 시작 시 표창 생성
|
||||
- `turnStartDraw`: 턴 시작 시 추가 드로우
|
||||
- `turnStartDiscard`: 턴 시작 시 카드 버리기
|
||||
|
||||
## 다음 턴 예약
|
||||
|
||||
- `nextTurnBlock`: 다음 턴 시작 시 방어도 획득
|
||||
- `nextTurnDraw`: 다음 턴 시작 시 추가 드로우
|
||||
- `nextTurnKeepBlock`: 다음 턴 시작 시 기존 방어도 유지
|
||||
- `nextTurnAttackMultiplier`: 다음 턴 공격 피해 배수
|
||||
- `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수
|
||||
- `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택
|
||||
- `nextTurnSelectPrompt`: 선택 UI 문구
|
||||
- `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦
|
||||
- `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소
|
||||
|
||||
## 기타
|
||||
|
||||
- `innate`: 전투 시작 시 첫 손패에 우선 진입
|
||||
- `playableWhenDrawPileEmpty`: 뽑을 카드 더미가 비었을 때만 사용 가능
|
||||
- `exhaust`: 사용 후 소멸
|
||||
- `unplayable`: 사용 불가
|
||||
- `curse`: 저주 카드
|
||||
- `token`: 토큰 카드
|
||||
- `endTurnDamage`: 턴 종료 시 손패에 있으면 피해
|
||||
|
||||
## 사용 원칙
|
||||
|
||||
- 카드 전용 분기보다 공용 필드를 먼저 쓴다.
|
||||
- 같은 효과는 같은 필드로 재사용한다.
|
||||
- 새 카드가 같은 패턴이면 먼저 공용 필드를 추가한다.
|
||||
|
||||
5
docs/card-play-damage.md
Normal file
5
docs/card-play-damage.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 카드 사용 시 피해
|
||||
|
||||
`cardPlayedDamage`는 카드를 사용할 때마다 현재 대상에게 체력을 직접 깎는 공용 효과입니다. 방어도는 무시하고, 같은 필드를 다른 카드에도 그대로 붙여 재사용할 수 있습니다.
|
||||
|
||||
`cardPlayedRandomDamage`는 같은 시점에 살아 있는 적 하나를 랜덤으로 골라 체력을 직접 깎습니다. `Strangle`과 `SerpentForm` 같은 카드가 이 계열을 씁니다.
|
||||
31
docs/codex-workflow.md
Normal file
31
docs/codex-workflow.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Codex Workflow
|
||||
|
||||
이 저장소에서 작업할 때는 토큰과 변경량을 아끼는 쪽을 기본으로 둔다.
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
- 이미 확인한 사실은 다시 읽지 않는다.
|
||||
- 같은 내용을 통째로 지우고 새로 쓰지 않는다.
|
||||
- 수정은 가능한 한 `apply_patch`로 섹션 단위만 한다.
|
||||
- 문서는 전체 재작성보다 부분 수정으로 유지한다.
|
||||
- 카드 구현은 한 번에 하나씩, 공용 필드 우선으로 넣는다.
|
||||
- 새 기능은 `데이터 1곳 + 런타임 1곳 + 테스트 1곳` 순서로 맞춘다.
|
||||
|
||||
## 읽기 원칙
|
||||
|
||||
- 파일은 필요한 것만 읽는다.
|
||||
- 비슷한 파일은 병렬로 한 번에 확인한다.
|
||||
- 같은 정보를 여러 번 요약하지 않는다.
|
||||
|
||||
## 쓰기 원칙
|
||||
|
||||
- 공용으로 표현 가능한 효과는 카드 전용 분기로 만들지 않는다.
|
||||
- 같은 의미의 효과는 같은 필드 이름을 쓴다.
|
||||
- 문서는 카드별 상태표와 공용 필드 사전을 분리해서 유지한다.
|
||||
|
||||
## 응답 원칙
|
||||
|
||||
- 중간 보고는 짧게 한다.
|
||||
- 바뀐 점과 남은 점만 말한다.
|
||||
- 불필요한 재설명은 줄인다.
|
||||
|
||||
86
docs/deck-concept.md
Normal file
86
docs/deck-concept.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# SlayMaple 덱 컨셉 & 직업 스킬셋
|
||||
|
||||
> SlayMaple 카드 덱의 **직업별 컨셉 · 메이플 스킬셋 · Slay the Spire 2 차용 매칭** 설계 문서.
|
||||
> 원칙: 카드 한 장 = **STS2 메커니즘(뼈대) + 메이플 스킬(외형)**. STS 고유 *표현*(카드명·아트·UI)은 모방 금지, *메커니즘*만 차용(IP 해석 심사 대비).
|
||||
> 수치(데미지·코스트·등급)는 `tools/balance/sim-balance.mjs`로 검증. 본 문서는 *어떤 스킬을 어떤 카드로* 만들지의 설계도.
|
||||
|
||||
기준: 메이플 = **클래식(빅뱅 이전)** 스킬 외형, STS = **Slay the Spire 2**.
|
||||
|
||||
---
|
||||
|
||||
## 직업 ↔ STS2 매칭 요약
|
||||
|
||||
| 직업 | 컨셉 | STS2 차용 |
|
||||
|---|---|---|
|
||||
| ⚔️ 전사 | 단단한 탱커/브루저 | The Ironclad (힘·방어·소멸) |
|
||||
| 🗡️ 도적 | 단검 난사 / 독 | The Silent (표창·독·교활) |
|
||||
| 🔮 법사 | 약체 + 게이지 운용 | The Defect (오브·집중) |
|
||||
|
||||
---
|
||||
|
||||
## ⚔️ 전사 (Warrior) — HP80 · 탱커
|
||||
|
||||
방어를 쌓고 버티다 역공하는 브루저. Ironclad의 두 축을 2차 전직으로 분화.
|
||||
|
||||
### 파이터 — 콤보 브루저형 탱커
|
||||
- **콤보 규칙**: 공격 카드를 **연속으로** 사용하면 콤보가 쌓인다. **방어·파워 등 비공격(Skill/Power) 카드를 사용하면 콤보가 리셋(0)** 된다.
|
||||
- 콤보가 쌓일수록 **데미지 증가 버프(힘 계열)** 를 받는다 → 방어를 포기하고 공격을 몰아칠수록 강해지는 리스크/리워드.
|
||||
- 차용: Ironclad 힘 스택/Demon Form + 콤보. 메이플 외형: 콤보 어택·분노·브랜디시.
|
||||
|
||||
### 페이지 — 방어 축적 → 바디 슬램 카운터
|
||||
- **위협**(전체 적 약화+취약 디버프)로 버티며 **방어도를 축적**(아이언 월 등 + 방어 유지 retain).
|
||||
- **바디 슬램**: 현재 방어도에 비례한 피해로 카운터. 파워 가드(반사) 보조.
|
||||
- 차용: Ironclad 방어 빌드(Barricade+Entrench→Body Slam). 메이플 외형: 위협·아이언 월·파워 가드.
|
||||
|
||||
### 스피어맨 — 유지/리치형
|
||||
- 하이퍼 바디(최대 HP↑)·아이언 월(방어 유지)·창 리치 광역. 공격 스케일(파이터)·방어 카운터(페이지)와 구분되는 지속 탱.
|
||||
|
||||
---
|
||||
|
||||
## 🗡️ 도적 (Thief) — HP70 · 단검/독
|
||||
|
||||
Slay the Spire *Silent* 차용. **형(codex)이 Silent 88장 완역 포트 + 스킬 아이콘 적용 완료.** 3대 아키타입:
|
||||
|
||||
- **표창(Shiv) 난사**: 0코스트 표창 토큰 대량 생성 → 공격마다 연계. (정밀=표창 피해↑, 칼날 부채=표창 전체화)
|
||||
- **독(Poison)**: 중독 중첩 → 매턴 틱뎀. (유독 가스·발병·촉진제·독 바르기)
|
||||
- **교활(Sly)·버림(discard)**: 버려질 때 무료 발동, 얇은 덱 빠른 순환.
|
||||
|
||||
### 2차 갈래
|
||||
- **어쌔신** — 표창 난사 + 크리 / 흡혈(드레인) 중심.
|
||||
- **시프** — 단검 난타(새비지 블로우 = 다단히트) + 독 / 버림 중심.
|
||||
|
||||
> 남은 작업: 카드명이 STS 직역(무력화·배신·아드레날린 등) → **어쌔신/시프 메이플 스킬명으로 재서사** + 멀티플레이어 전제 카드(측면 공격·비열함·추적) 싱글 출품용 정리.
|
||||
|
||||
---
|
||||
|
||||
## 🔮 법사 (Magician) — HP70 · 약체/게이지
|
||||
|
||||
몸은 약하나 **게이지(오브) 운용**으로 다중 공격·화력 집중. **오브 메커니즘은 위자드(불/독·썬/콜)에만 적용**, 클레릭은 별도 보조 컨셉.
|
||||
|
||||
### 위자드(불/독) — 독 + 불 시너지
|
||||
- **독을 묻히는 스킬**(포이즌 브레스 등)으로 대상에 독을 부여(독뎀 = DoT).
|
||||
- **독이 묻은 적에게 불 카드(파이어 애로우 등)를 쓰면 추가 데미지** — *독뎀 상수* 보너스(독 스택/상수 비례).
|
||||
- 즉 "독 깔기 → 불로 폭발"의 2단 콤보. 불·독 오브로 운용.
|
||||
|
||||
### 위자드(썬/콜) — 오브 운용(썬더 다중 / 콜드 빙결)
|
||||
- **오브로 썬더·콜드를 보유**. **썬더 = 다중 공격 특화**(AoE·다단). **콜드 = 빙결 부여**(빙결 = *취약과 동일 효과* 를 데미지와 함께).
|
||||
- **오브를 사용하는 만큼 오브를 획득하거나 다중 소모**하는 방식 — 오브 수급/소비 운용이 핵심.
|
||||
|
||||
### 클레릭 — 보조(회복·버프) · 오브 없음
|
||||
- **회복 스킬**(힐)과 **각종 버프**(블레스 등) 중심의 서포트.
|
||||
- **언데드 계열 몬스터에게는 힐로 공격** 가능 — 보조 힐러 정체성.
|
||||
|
||||
> ⚠️ 위자드 오브/게이지·전사 콤보 스택·바디 슬램·독뎀 시너지는 **신규 메커니즘** — `tools/deck/gen-slaydeck.mjs`(전투 규칙) + `tools/balance/sim-balance.mjs`(JS 미러) 양쪽 구현·동기화 필요.
|
||||
|
||||
---
|
||||
|
||||
## 차용 경계 (IP 심사 대비)
|
||||
|
||||
- 차용 OK = **메커니즘**(콤보 스택·방어→피해 전환·독+불 시너지·오브 게이지·빙결=취약 등 시스템).
|
||||
- 모방 금지 = STS 고유 **표현**(카드명·아트·UI 직접 사용).
|
||||
- 만점 루트 = STS2 메커니즘을 **메이플 스킬·외형으로 완전 재서사화**.
|
||||
|
||||
## 참고
|
||||
|
||||
- 카드 데이터 단일 소스: `data/cards.json` (현 122장: 전사 18·마법사 14·도적 88 + Shiv·저주)
|
||||
- 메이플 스킬 외형 매핑·STS2 캐릭터 상세는 박재오 개인 위키 `프로젝트-메이플-덱빌딩-스킬구성` / `프로젝트-메이플-STS2-차용-덱컨셉` 참조.
|
||||
8
docs/draw-count.md
Normal file
8
docs/draw-count.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 전투 드로우 누적
|
||||
|
||||
`damagePerCardDrawnThisCombat`은 이번 전투 동안 실제로 뽑힌 카드 수를 기준으로 공격력을 올리는 공용 필드입니다.
|
||||
|
||||
적용 예시:
|
||||
|
||||
- `Murder`: 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가
|
||||
|
||||
22
docs/draw-skill-block.md
Normal file
22
docs/draw-skill-block.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 드로우 연동 효과
|
||||
|
||||
드로우 결과를 받아 후속 효과를 처리하는 공용 패턴을 정리합니다.
|
||||
|
||||
## 현재 구현
|
||||
|
||||
- `draw`: 카드를 뽑음
|
||||
- `drawUntilHandSize`: 손패가 지정 수치가 될 때까지 뽑음
|
||||
- `drawSkillBlock`: 이번 카드로 뽑힌 카드 중 스킬 카드마다 방어도를 얻음
|
||||
|
||||
## 동작 방식
|
||||
|
||||
- 드로우 함수는 이번에 뽑힌 카드 ID 목록을 반환합니다.
|
||||
- 카드 효과는 그 목록을 보고 조건을 판정합니다.
|
||||
- 그래서 `EscapePlan` 같은 카드뿐 아니라, 나중에 같은 규칙이 필요한 카드에도 같은 필드를 붙이면 됩니다.
|
||||
|
||||
## 예시
|
||||
|
||||
- `EscapePlan`
|
||||
- `draw = 1`
|
||||
- `drawSkillBlock = 3`
|
||||
|
||||
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`: 남은 에너지 전부를 써서 약화 부여
|
||||
|
||||
@@ -27,6 +27,16 @@ export function shuffle(arr, rng) {
|
||||
return a;
|
||||
}
|
||||
|
||||
function prepareCombatDrawPile(deck, cards) {
|
||||
const rest = [];
|
||||
const innate = [];
|
||||
for (const id of deck) {
|
||||
if (cards[id]?.innate === true) innate.push(id);
|
||||
else rest.push(id);
|
||||
}
|
||||
return rest.concat(innate);
|
||||
}
|
||||
|
||||
// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화.
|
||||
// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1))
|
||||
// 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5
|
||||
@@ -70,16 +80,43 @@ export function loadData() {
|
||||
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
|
||||
}
|
||||
|
||||
function canPlayCardNow(card, ctx = {}) {
|
||||
if (!card) return false;
|
||||
if (card.playableWhenDrawPileEmpty === true && (ctx.drawPileCount || 0) > 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
|
||||
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
||||
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
||||
export function chooseAction(hand, cards, energy) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
|
||||
export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => {
|
||||
const card = cards[x.id];
|
||||
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
|
||||
let effectiveCost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) effectiveCost = 0;
|
||||
else if (card.useAllEnergy === true) effectiveCost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
return card.useAllEnergy === true ? true : effectiveCost <= energy;
|
||||
});
|
||||
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
||||
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
||||
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(cards[x.id].cost, 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(cards[x.id].cost, 1);
|
||||
const effectiveCost = (card) => {
|
||||
let cost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||
else if (card.useAllEnergy === true) cost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
@@ -106,12 +143,23 @@ function bump(s, cost, dmg, blk) {
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
let drawPile = shuffle(starterDeck, rng);
|
||||
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
|
||||
let discard = [];
|
||||
const exhaust = [];
|
||||
let hand = [];
|
||||
let pHp = PLAYER_HP, pBlock = 0;
|
||||
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
|
||||
let blockGainMultiplier = 1;
|
||||
let handCostZeroThisTurn = false;
|
||||
let drawDisabledThisTurn = false;
|
||||
let nextSkillCostZero = false;
|
||||
let skillCostReductionThisTurn = 0;
|
||||
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
||||
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
||||
let nextTurnAddCards = [];
|
||||
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
|
||||
let cardsDrawnThisCombat = 0;
|
||||
let energy = 0;
|
||||
const powers = [];
|
||||
const mob = monsters.map((m) => ({
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
|
||||
@@ -120,16 +168,21 @@ export function simulateCombat(data, rng, stats) {
|
||||
let turns = 0;
|
||||
|
||||
function draw(n) {
|
||||
const drawn = [];
|
||||
if (drawDisabledThisTurn === true) return drawn;
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
|
||||
if (drawPile.length === 0) break;
|
||||
const card = drawPile.pop();
|
||||
drawn.push(card);
|
||||
cardsDrawnThisCombat++;
|
||||
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
||||
if (hand.length >= 10) {
|
||||
discard.push(card);
|
||||
triggerSly(card);
|
||||
} else hand.push(card);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
function addCardsToHand(id, n) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
@@ -137,25 +190,119 @@ export function simulateCombat(data, rng, stats) {
|
||||
else hand.push(id);
|
||||
}
|
||||
}
|
||||
function addBlock(base) {
|
||||
let amount = base || 0;
|
||||
if (amount > 0) amount += pDex;
|
||||
if (blockGainMultiplier > 1) amount *= blockGainMultiplier;
|
||||
if (amount < 0) amount = 0;
|
||||
pBlock += amount;
|
||||
return amount;
|
||||
}
|
||||
function discardForTurnStart(n) {
|
||||
const cnt = Math.min(n, hand.length);
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const idx = hand
|
||||
.map((id, k) => ({ id, k, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ac = a.card?.cost || 0;
|
||||
const bc = b.card?.cost || 0;
|
||||
if (ac !== bc) return ac - bc;
|
||||
const ad = a.card?.damage || 0;
|
||||
const bd = b.card?.damage || 0;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return a.k - b.k;
|
||||
})[0]?.k;
|
||||
if (idx == null) break;
|
||||
discardHandCard(idx, true);
|
||||
}
|
||||
}
|
||||
function countOtherHandSkills(currentId) {
|
||||
let n = 0;
|
||||
let skippedSelf = false;
|
||||
for (const id of hand) {
|
||||
if (!skippedSelf && id === currentId) { skippedSelf = true; continue; }
|
||||
if (cards[id]?.kind === 'Skill') n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function attackBaseForCard(id, c) {
|
||||
let base = c.damage || 0;
|
||||
const otherHand = Math.max(0, hand.length - 1);
|
||||
if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard;
|
||||
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
|
||||
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
|
||||
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
|
||||
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
|
||||
if (base < 0) base = 0;
|
||||
return base;
|
||||
}
|
||||
function queueNextTurnAddCard(id, n) {
|
||||
if (!id || !n || n <= 0) return;
|
||||
const entry = nextTurnAddCards.find((x) => x.cardId === id);
|
||||
if (entry) entry.amount += n;
|
||||
else nextTurnAddCards.push({ cardId: id, amount: n });
|
||||
}
|
||||
function queueNextTurnEffects(c) {
|
||||
if (!c) return;
|
||||
if (c.nextTurnBlock) nextTurnBlock += c.nextTurnBlock;
|
||||
if (c.nextTurnDraw) nextTurnDraw += c.nextTurnDraw;
|
||||
if (c.nextTurnKeepBlock === true) nextTurnKeepBlock = true;
|
||||
if (c.nextTurnAttackMultiplier && c.nextTurnAttackMultiplier > 0) nextTurnAttackMultiplier *= c.nextTurnAttackMultiplier;
|
||||
}
|
||||
function queueSelectedReserve(c) {
|
||||
if (!c?.nextTurnSelectHandCard || !c.nextTurnCopies || hand.length === 0) return;
|
||||
const choice = hand
|
||||
.map((id, i) => ({ id, i, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ak = a.card?.kind === 'Attack' ? 3 : a.card?.kind === 'Skill' ? 2 : 1;
|
||||
const bk = b.card?.kind === 'Attack' ? 3 : b.card?.kind === 'Skill' ? 2 : 1;
|
||||
if (bk !== ak) return bk - ak;
|
||||
const ad = a.card?.damage || 0;
|
||||
const bd = b.card?.damage || 0;
|
||||
if (bd !== ad) return bd - ad;
|
||||
return a.i - b.i;
|
||||
})[0];
|
||||
if (choice?.id) queueNextTurnAddCard(choice.id, c.nextTurnCopies);
|
||||
}
|
||||
const aliveList = () => mob.filter((m) => m.alive);
|
||||
function powerFieldTotal(field) {
|
||||
let total = 0;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (pc?.[field] != null) total += pc[field];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function resolveCardEffects(id, c, costSpent, recordStats = true) {
|
||||
const alive = aliveList();
|
||||
let dmg = 0;
|
||||
let blockGained = 0;
|
||||
if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier;
|
||||
if (c.nextSkillCostZero === true) nextSkillCostZero = true;
|
||||
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
|
||||
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
|
||||
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
|
||||
const xEnergy = costSpent || 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && c.damage) {
|
||||
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
|
||||
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||
const baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c);
|
||||
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
|
||||
? c.bonusHitsWhenOtherHandAtLeast : 0;
|
||||
const hitN = (c.hits || 1) + bonusHits;
|
||||
const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
const target = chooseTarget(alive, preview);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
const hitN = c.hits || 1;
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
dmg = totalNv;
|
||||
if (c.aoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
const r2 = applyDamage(m2.hp, m2.block, d2);
|
||||
m2.hp = r2.hp; m2.block = r2.block;
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison;
|
||||
if (m2.hp <= 0) m2.alive = false;
|
||||
}
|
||||
} else {
|
||||
@@ -167,17 +314,20 @@ export function simulateCombat(data, rng, stats) {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
}
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
} else if (c.kind === 'Power') {
|
||||
if (recordStats) powers.push(id);
|
||||
} else {
|
||||
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
||||
if ((c.weak || c.vuln || c.poison) && alive.length) {
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
|
||||
if ((weakAmount || c.vuln || c.poison) && alive.length) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (weakAmount) target.weak += weakAmount;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
if (c.poison) target.poison += c.poison;
|
||||
}
|
||||
@@ -187,8 +337,39 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.thorns) pThorns += c.thorns;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
||||
if (c.draw) draw(c.draw);
|
||||
if (c.gainEnergy) energy += c.gainEnergy;
|
||||
queueNextTurnEffects(c);
|
||||
let drawnCards = [];
|
||||
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
|
||||
if (c.drawUntilHandSize) {
|
||||
const need = c.drawUntilHandSize - Math.max(0, hand.length - 1);
|
||||
if (need > 0) drawnCards = drawnCards.concat(draw(need));
|
||||
}
|
||||
if (c.drawSkillBlock && c.drawSkillBlock > 0) {
|
||||
for (const drawnId of drawnCards) {
|
||||
if (cards[drawnId]?.kind === 'Skill') blockGained += addBlock(c.drawSkillBlock);
|
||||
}
|
||||
}
|
||||
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.cardPlayedDamage && alive.length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
dmg += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedRandomDamage && alive.length) {
|
||||
const pool = aliveList();
|
||||
if (pool.length) {
|
||||
const target = pool[Math.floor(rng() * pool.length)];
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
dmg += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
function triggerSly(id) {
|
||||
@@ -200,6 +381,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
const [id] = hand.splice(idx, 1);
|
||||
if (!id) return;
|
||||
discard.push(id);
|
||||
turnDiscardedCards++;
|
||||
if (trigger) triggerSly(id);
|
||||
}
|
||||
function applyDiscardEffects(c) {
|
||||
@@ -212,31 +394,72 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded);
|
||||
}
|
||||
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
turnAttackCardsPlayed = 0;
|
||||
turnDiscardedCards = 0;
|
||||
blockGainMultiplier = 1;
|
||||
handCostZeroThisTurn = false;
|
||||
drawDisabledThisTurn = false;
|
||||
skillCostReductionThisTurn = 0;
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
pBlock = 0;
|
||||
if (nextTurnKeepBlock === true) nextTurnKeepBlock = false;
|
||||
else pBlock = 0;
|
||||
turnAttackMultiplier = nextTurnAttackMultiplier;
|
||||
nextTurnAttackMultiplier = 1;
|
||||
let energyBonus = 0;
|
||||
let powerTurnDraw = 0;
|
||||
let powerTurnDiscard = 0;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (!pc) continue;
|
||||
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
|
||||
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
||||
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
||||
else if (pc.powerEffect === 'poisonPerTurn') {
|
||||
for (const m of mob) if (m.alive) m.poison += pc.value;
|
||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
const r = applyDamage(m.hp, m.block, pc.value || 0);
|
||||
m.hp = r.hp; m.block = r.block;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
}
|
||||
}
|
||||
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
|
||||
if (pc.turnStartDraw) powerTurnDraw += pc.turnStartDraw;
|
||||
if (pc.turnStartDiscard) powerTurnDiscard += pc.turnStartDiscard;
|
||||
}
|
||||
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
|
||||
if (nextTurnBlock > 0) { addBlock(nextTurnBlock); nextTurnBlock = 0; }
|
||||
if (nextTurnAddCards.length) {
|
||||
for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount);
|
||||
nextTurnAddCards = [];
|
||||
}
|
||||
energy = ENERGY + energyBonus;
|
||||
const drawBonus = nextTurnDraw + powerTurnDraw;
|
||||
nextTurnDraw = 0;
|
||||
draw(HAND_SIZE + drawBonus);
|
||||
if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy);
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn });
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
energy -= c.cost;
|
||||
resolveCardEffects(id, c, c.cost);
|
||||
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
|
||||
const baseCost = c.cost || 0;
|
||||
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||
energy -= cost;
|
||||
resolveCardEffects(id, c, cost);
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
hand.splice(idx, 1);
|
||||
queueSelectedReserve(c);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
||||
else if (c.kind !== 'Power') discard.push(id);
|
||||
applyDiscardEffects(c);
|
||||
|
||||
@@ -13,6 +13,85 @@ test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
||||
assert.equal(rarityForRoll(100), 'legend');
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnBlock grants block on the following turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
GuardLater: { name: "예약 방어", cost: 0, kind: "Skill", nextTurnBlock: 4 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["GuardLater", "Pass"],
|
||||
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 77);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnDraw draws extra cards next turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Setup: { name: "설치", cost: 0, kind: "Skill", nextTurnDraw: 2 },
|
||||
Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 },
|
||||
Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Hit1", "Hit2", "Pass1", "Pass2", "Pass3", "Pass4", "Setup"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnKeepBlock preserves current block", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
BlurLater: { name: "흐릿함", cost: 0, kind: "Skill", block: 5, nextTurnKeepBlock: true },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["BlurLater", "Pass"],
|
||||
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnAttackMultiplier boosts attacks next turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Prep: { name: "그림자 걸음", cost: 0, kind: "Skill", nextTurnAttackMultiplier: 2 },
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 3 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Prep", "Pass", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnSelectHandCard queues selected copies for next turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Nightmare: { name: "악몽", cost: 0, kind: "Skill", nextTurnCopies: 3, nextTurnSelectHandCard: true },
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 2 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Pass", "Nightmare", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 4);
|
||||
});
|
||||
|
||||
test('applyDamage: 방어 우선 차감 후 hp', () => {
|
||||
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
|
||||
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
|
||||
@@ -461,3 +540,329 @@ test("simulateCombat: addShiv creates shuriken cards in hand", () => {
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: innate cards are drawn into the opening hand first", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Backstab: { name: "배신", cost: 0, kind: "Attack", damage: 11, innate: true, exhaust: true },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Backstab"],
|
||||
monsters: [{ name: "Dummy", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: GrandFinale waits until draw pile is empty", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, aoe: true, playableWhenDrawPileEmpty: true },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Finale"],
|
||||
monsters: [{ name: "Dummy", maxHp: 60, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: turnStartDraw and turnStartDiscard powers resolve at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Tool: { name: "작업 도구", cost: 0, kind: "Power", turnStartDraw: 1, turnStartDiscard: 1 },
|
||||
Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 },
|
||||
Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Tool", "Pass1", "Pass2", "Pass3", "Hit1", "Hit2"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("chooseAction: GrandFinale is blocked until draw pile is empty", () => {
|
||||
const cards = {
|
||||
Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, playableWhenDrawPileEmpty: true },
|
||||
Defend: { name: "방어", cost: 1, kind: "Skill", block: 5 },
|
||||
};
|
||||
assert.equal(chooseAction(["Finale", "Defend"], cards, 3, { drawPileCount: 1 }), 1);
|
||||
assert.equal(chooseAction(["Finale"], cards, 3, { drawPileCount: 0 }), 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 6 },
|
||||
Finisher: { name: "마무리", cost: 0, kind: "Attack", damage: 0, damagePerAttackPlayedThisTurn: 6 },
|
||||
},
|
||||
starterDeck: ["Hit", "Finisher"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Precise: { name: "정밀", cost: 0, kind: "Attack", damage: 13, damagePerOtherHandCard: -2 },
|
||||
Flechettes: { name: "프레췌", cost: 0, kind: "Attack", damage: 0, damagePerSkillInHand: 5 },
|
||||
Skill1: { name: "스킬1", cost: 99, kind: "Skill", block: 0 },
|
||||
Skill2: { name: "스킬2", cost: 99, kind: "Skill", block: 0 },
|
||||
Blank: { name: "공백", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"],
|
||||
monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 5);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Toss: { name: "버리기", cost: 0, kind: "Skill", discard: 1 },
|
||||
Memento: { name: "메멘토", cost: 0, kind: "Attack", damage: 9, damagePerDiscardedThisTurn: 4 },
|
||||
Follow: { name: "완수", cost: 0, kind: "Attack", damage: 7, otherHandAtLeast: 2, bonusHitsWhenOtherHandAtLeast: 1 },
|
||||
Blank1: { name: "공백1", cost: 99, kind: "Skill", block: 0 },
|
||||
Blank2: { name: "공백2", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Toss", "Memento", "Follow", "Blank1", "Blank2"],
|
||||
monsters: [{ name: "Dummy", maxHp: 27, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: gainEnergy, drawUntilHandSize, and drawPerDiscarded are applied", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Adrenaline: { name: "Adrenaline", cost: 0, kind: "Skill", gainEnergy: 1, draw: 2, exhaust: true },
|
||||
Expertise: { name: "Expertise", cost: 1, kind: "Skill", drawUntilHandSize: 6 },
|
||||
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true, drawPerDiscarded: 1, exhaust: true },
|
||||
Tactician: { name: "Tactician", cost: 99, kind: "Skill", gainEnergy: 1, sly: true },
|
||||
Hit1: { name: "Hit1", cost: 1, kind: "Attack", damage: 6 },
|
||||
Hit2: { name: "Hit2", cost: 1, kind: "Attack", damage: 6 },
|
||||
Hit3: { name: "Hit3", cost: 1, kind: "Attack", damage: 6 },
|
||||
},
|
||||
starterDeck: ["Adrenaline", "Expertise", "Gamble", "Tactician", "Hit1", "Hit2", "Hit3"],
|
||||
monsters: [{ name: "Dummy", maxHp: 18, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedBlock grants block whenever a card is played", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
After: { name: "Afterimage", cost: 1, kind: "Power", cardPlayedBlock: 1 },
|
||||
Hit: { name: "Hit", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["After", "Hit", "Hit", "Hit", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 1 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: blockGainMultiplier doubles block gain for the turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Shadow: { name: "Shadowmeld", cost: 1, kind: "Skill", block: 5, blockGainMultiplier: 2 },
|
||||
Shield: { name: "Shield", cost: 1, kind: "Skill", block: 2 },
|
||||
},
|
||||
starterDeck: ["Shadow", "Shield"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextSkillCostZero makes the next skill free", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Pounce: { name: "Pounce", cost: 2, kind: "Attack", damage: 12, nextSkillCostZero: true },
|
||||
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
||||
},
|
||||
starterDeck: ["Pounce", "Guard"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => {
|
||||
const cards = {
|
||||
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(["Guard"], cards, 1, { skillCostReductionThisTurn: 1 }), 0);
|
||||
assert.equal(chooseAction(["Guard"], cards, 1, {}), -1);
|
||||
});
|
||||
|
||||
test("chooseAction: handCostZeroThisTurn lets expensive cards be played", () => {
|
||||
const cards = {
|
||||
Burst: { name: "Burst", cost: 3, kind: "Skill", block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(["Burst"], cards, 0, { handCostZeroThisTurn: true }), 0);
|
||||
assert.equal(chooseAction(["Burst"], cards, 0, {}), -1);
|
||||
});
|
||||
|
||||
test("chooseAction: useAllEnergy cards remain playable at zero energy", () => {
|
||||
const cards = {
|
||||
Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Escape: { name: "EscapePlan", cost: 0, kind: "Skill", draw: 1, drawSkillBlock: 3, innate: true, exhaust: true },
|
||||
Filler1: { name: "Filler1", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler2: { name: "Filler2", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler3: { name: "Filler3", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler4: { name: "Filler4", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler5: { name: "Filler5", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Escape", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(stats.Escape.block, 3);
|
||||
});
|
||||
|
||||
test("simulateCombat: poisonPerTurn powers poison all enemies at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Fumes: { name: "NoxiousFumes", cost: 1, kind: "Power", powerEffect: "poisonPerTurn", value: 2 },
|
||||
},
|
||||
starterDeck: ["Fumes"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerTurn powers damage all enemies at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Speed: { name: "Speedster", cost: 2, kind: "Power", powerEffect: "damagePerTurn", value: 2 },
|
||||
},
|
||||
starterDeck: ["Speed"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: attackPoison power applies poison on attack damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Venom: { name: "Envenom", cost: 2, kind: "Power", attackPoison: 2 },
|
||||
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Venom", "Strike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedDamage hits the target whenever a card is played", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, cardPlayedDamage: 2 },
|
||||
},
|
||||
starterDeck: ["Strangle"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
SerpentForm: { name: "SerpentForm", cost: 3, kind: "Power", cardPlayedRandomDamage: 4 },
|
||||
},
|
||||
starterDeck: ["SerpentForm"],
|
||||
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: useAllEnergy skewer consumes all energy for damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 },
|
||||
},
|
||||
starterDeck: ["Skewer"],
|
||||
monsters: [{ name: "Dummy", maxHp: 24, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: useAllEnergy malaise scales weak with energy spent", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Malaise: { name: "Malaise", cost: 2, kind: "Skill", useAllEnergy: true, xWeakPerEnergy: 1 },
|
||||
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Malaise", "Strike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 1, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerCardDrawnThisCombat scales murder", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Murder: { name: "Murder", cost: 3, kind: "Attack", damage: 1, damagePerCardDrawnThisCombat: 1 },
|
||||
Filler1: { name: "Filler1", cost: 99, kind: "Skill" },
|
||||
Filler2: { name: "Filler2", cost: 99, kind: "Skill" },
|
||||
Filler3: { name: "Filler3", cost: 99, kind: "Skill" },
|
||||
Filler4: { name: "Filler4", cost: 99, kind: "Skill" },
|
||||
Filler5: { name: "Filler5", cost: 99, kind: "Skill" },
|
||||
},
|
||||
starterDeck: ["Murder", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(stats.Murder.damage > 1);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ uiInit = _TimerService:SetTimerRepeat(function()
|
||||
uiTries = uiTries + 1
|
||||
if _EntityService:GetEntityByPath("/ui/DeckUIGroup") ~= nil then
|
||||
self:ActivateUIGroups()
|
||||
self:ShowMainMenu()
|
||||
-- MainMenu는 한동안 비활성화: 시작 시 바로 로비로 진입.
|
||||
-- 추후 싱글/멀티/종료 선택 메뉴가 필요하면 self:ShowMainMenu()로 되돌린다(메서드·UI 유지됨).
|
||||
self:ShowLobby()
|
||||
_TimerService:ClearTimer(uiInit)
|
||||
elseif uiTries > 80 then
|
||||
_TimerService:ClearTimer(uiInit)
|
||||
@@ -29,12 +31,38 @@ if lp ~= nil then
|
||||
end
|
||||
_InputService:ConnectEvent(KeyDownEvent, function(e)
|
||||
if e.key == KeyboardKey.LeftControl then
|
||||
self.DebugCtrlDown = true
|
||||
local lp2 = _UserService.LocalPlayer
|
||||
if lp2 ~= nil and lp2.CurrentMapName == "${LOBBY_MAP}" and self.RunActive ~= true then
|
||||
self:PlayerAttackMotion()
|
||||
end
|
||||
elseif e.key == KeyboardKey.LeftShift or e.key == KeyboardKey.RightShift then
|
||||
self.DebugShiftDown = true
|
||||
elseif e.key == KeyboardKey.C then
|
||||
if self.DebugCtrlDown == true and self.DebugShiftDown == true then
|
||||
self:OpenDebugCardPicker()
|
||||
end
|
||||
elseif e.key == KeyboardKey.E then
|
||||
if self.DebugCtrlDown == true and self.DebugShiftDown == true then
|
||||
self:CheatFillEnergy()
|
||||
end
|
||||
end
|
||||
end)
|
||||
_InputService:ConnectEvent(KeyUpEvent, function(e)
|
||||
if e.key == KeyboardKey.LeftControl then
|
||||
self.DebugCtrlDown = false
|
||||
elseif e.key == KeyboardKey.LeftShift or e.key == KeyboardKey.RightShift then
|
||||
self.DebugShiftDown = false
|
||||
end
|
||||
end)`),
|
||||
method('CheatFillEnergy', `if self.RunActive ~= true or self.CombatOver == true then
|
||||
return
|
||||
end
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
self.Energy = self.MaxEnergy
|
||||
self:RenderCombat()
|
||||
self:RenderPiles()
|
||||
self:Toast("치트: 체력·에너지 회복")`),
|
||||
method('ReqLoadAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
|
||||
local errCode, value = ds:GetAndWait("ascensionUnlocked")
|
||||
local n = 0
|
||||
|
||||
@@ -10,53 +10,63 @@ self:RenderCharacterSelect()`),
|
||||
self:RenderCharacterSelect()`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' },
|
||||
]),
|
||||
method('RenderCharacterSelect', `local warriorArt = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/WarriorButton/Art")
|
||||
if warriorArt ~= nil and warriorArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["warrior"] ~= nil then
|
||||
warriorArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["warrior"]
|
||||
end
|
||||
local mageArt = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/MageButton/Art")
|
||||
if mageArt ~= nil and mageArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["magician"] ~= nil then
|
||||
mageArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["magician"]
|
||||
end
|
||||
local thiefArt = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/ThiefButton/Art")
|
||||
if thiefArt ~= nil and thiefArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["bandit"] ~= nil then
|
||||
thiefArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["bandit"]
|
||||
end
|
||||
local warrior = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/WarriorButton")
|
||||
if warrior ~= nil and warrior.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == "warrior" then
|
||||
warrior.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
else
|
||||
warrior.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
|
||||
method('RenderCharacterSelect', `local base = "/ui/SelectUIGroup/CharacterSelectHud"
|
||||
local arts = { { p = "/WarriorButton/Art", c = "warrior" }, { p = "/MageButton/Art", c = "magician" }, { p = "/BanditButton/Art", c = "bandit" } }
|
||||
for i = 1, #arts do
|
||||
local e = _EntityService:GetEntityByPath(base .. arts[i].p)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits[arts[i].c] ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits[arts[i].c]
|
||||
end
|
||||
end
|
||||
local mage = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/MageButton")
|
||||
if mage ~= nil and mage.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == "magician" then
|
||||
mage.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
else
|
||||
mage.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
|
||||
end
|
||||
end
|
||||
local thief = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/ThiefButton")
|
||||
if thief ~= nil and thief.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == "bandit" then
|
||||
thief.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
else
|
||||
thief.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
|
||||
local btns = { { p = "/WarriorButton", c = "warrior" }, { p = "/MageButton", c = "magician" }, { p = "/BanditButton", c = "bandit" } }
|
||||
for i = 1, #btns do
|
||||
local e = _EntityService:GetEntityByPath(base .. btns[i].p)
|
||||
if e ~= nil then
|
||||
if e.MaskComponent == nil then
|
||||
e:AddComponent("MaskComponent")
|
||||
end
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == btns[i].c then
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.45, 0.5, 0.58, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local nl = string.char(10)
|
||||
local name = ""
|
||||
local eng = ""
|
||||
local desc = "직업을 선택하고 시작하세요"
|
||||
local btnName = ""
|
||||
if self.SelectedClass == "warrior" then
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/Status", "전사 선택됨")
|
||||
name = "전사"
|
||||
eng = "Warrior"
|
||||
btnName = "/WarriorButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "방어를 쌓고 버티다 강하게 역공하는 단단한 탱커."
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/Status", "도적 선택됨")
|
||||
name = "도적"
|
||||
eng = "Thief"
|
||||
btnName = "/BanditButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "표창 난사와 독으로 빠르게 몰아치는 민첩한 직업."
|
||||
elseif self.SelectedClass == "magician" then
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/Status", "마법사 선택됨")
|
||||
else
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/Status", "직업을 선택하고 시작하세요")
|
||||
end`),
|
||||
name = "법사"
|
||||
eng = "Magician"
|
||||
btnName = "/MageButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "약하지만 게이지 운용으로 화력을 집중하는 원소 마법사."
|
||||
end
|
||||
if btnName ~= "" then
|
||||
local art = _EntityService:GetEntityByPath(base .. btnName .. "/Art")
|
||||
local target = _EntityService:GetEntityByPath(base .. "/SelectedCharacterArt")
|
||||
if art ~= nil and art.SpriteGUIRendererComponent ~= nil and target ~= nil and target.SpriteGUIRendererComponent ~= nil then
|
||||
target.SpriteGUIRendererComponent.ImageRUID = art.SpriteGUIRendererComponent.ImageRUID
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/SelectedClass", name)
|
||||
self:SetText(base .. "/SelectedClass/SelectedClassEng", eng)
|
||||
self:SetText(base .. "/SelectedClassStatus", desc)`),
|
||||
method('StartNewGame', `if self.SelectedClass ~= "warrior" and self.SelectedClass ~= "bandit" and self.SelectedClass ~= "magician" then
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/Status", "직업을 먼저 선택하세요")
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/SelectedClassStatus", "직업을 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
self:StartRun()`),
|
||||
|
||||
@@ -3,10 +3,26 @@ import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const combatMethods = [
|
||||
method('CanPlayCardNow', `if c == nil then
|
||||
return false
|
||||
end
|
||||
if c.playableWhenDrawPileEmpty == true and self.DrawPile ~= nil and #self.DrawPile > 0 then
|
||||
self:Toast("뽑을 카드 더미가 비어 있을 때만 사용할 수 있습니다.")
|
||||
return false
|
||||
end
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('PlayCard', `if self:IsDiscardSelecting() == true then
|
||||
self:SelectDiscardSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsRetainSelecting() == true then
|
||||
self:SelectRetainSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsReserveSelecting() == true then
|
||||
self:SelectReserveSlot(slot)
|
||||
return
|
||||
end
|
||||
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
@@ -25,12 +41,46 @@ if c.unplayable == true then
|
||||
self:Toast("사용할 수 없는 카드입니다")
|
||||
return
|
||||
end
|
||||
if self.Energy < c.cost then
|
||||
if self:CanPlayCardNow(c) ~= true then
|
||||
return
|
||||
end
|
||||
local cost = c.cost or 0
|
||||
local skillFree = false
|
||||
if self.HandCostZeroThisTurn == true then
|
||||
cost = 0
|
||||
elseif c.useAllEnergy == true then
|
||||
cost = self.Energy
|
||||
end
|
||||
if c.kind == "Skill" and self.NextSkillCostZero == true then
|
||||
cost = 0
|
||||
skillFree = true
|
||||
end
|
||||
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
||||
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
|
||||
end
|
||||
if self.Energy < cost then
|
||||
self:Toast("에너지가 부족합니다")
|
||||
return
|
||||
end
|
||||
self.Energy = self.Energy - c.cost
|
||||
self:ResolveCardEffects(cardId, c, false)
|
||||
self.Energy = self.Energy - cost
|
||||
self:ResolveCardEffects(cardId, slot, c, false, cost)
|
||||
if c.kind == "Attack" then
|
||||
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
|
||||
end
|
||||
if skillFree == true then
|
||||
if c.nextSkillCostZero ~= true then
|
||||
self.NextSkillCostZero = false
|
||||
end
|
||||
end
|
||||
if self:HasPowerField("cardPlayedBlock") == true then
|
||||
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
|
||||
end
|
||||
if c.cardPlayedDamage ~= nil and c.cardPlayedDamage > 0 then
|
||||
self:DealDirectDamageToTarget(c.cardPlayedDamage)
|
||||
end
|
||||
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
|
||||
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
|
||||
end
|
||||
table.remove(self.Hand, slot)
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
@@ -44,12 +94,19 @@ self:RenderCombat()
|
||||
if self:BeginDiscardSelection(c) == true then
|
||||
return
|
||||
end
|
||||
if self:BeginReserveSelection(c) == true then
|
||||
return
|
||||
end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('OnCardButton', `if self:IsDiscardSelecting() == true then
|
||||
self:SelectDiscardSlot(slot)
|
||||
elseif self:IsRetainSelecting() == true then
|
||||
self:SelectRetainSlot(slot)
|
||||
elseif self:IsReserveSelecting() == true then
|
||||
self:SelectReserveSlot(slot)
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('FindMonsterAtTouch', `local best = 0
|
||||
local bestDist = 200
|
||||
@@ -136,6 +193,14 @@ self:ResolveCardDrop(slot, touchPoint)`, [
|
||||
self:SelectDiscardSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsRetainSelecting() == true then
|
||||
self:SelectRetainSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsReserveSelecting() == true then
|
||||
self:SelectReserveSlot(slot)
|
||||
return
|
||||
end
|
||||
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
@@ -190,6 +255,12 @@ if m.block > 0 and pierce ~= true then
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
m.hp = m.hp - dmg
|
||||
if dmg > 0 then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
m.poison = (m.poison or 0) + poison
|
||||
end
|
||||
end
|
||||
self:MonsterHitMotion(m.slot)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
@@ -198,6 +269,48 @@ end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
|
||||
]),
|
||||
method('DealDirectDamageToTarget', `local m = self.Monsters[self.TargetIndex]
|
||||
if m == nil or m.alive ~= true then
|
||||
m = nil
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end
|
||||
end
|
||||
end
|
||||
if m == nil then
|
||||
return
|
||||
end
|
||||
m.hp = m.hp - amount
|
||||
self:ShowDmgPop(m.slot, amount)
|
||||
self:MonsterHitMotion(m.slot)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('DealDirectDamageToRandomMonster', `local alive = {}
|
||||
for i = 1, #self.Monsters do
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
table.insert(alive, m)
|
||||
end
|
||||
end
|
||||
if #alive <= 0 then
|
||||
return
|
||||
end
|
||||
local m = alive[math.random(1, #alive)]
|
||||
if m == nil then
|
||||
return
|
||||
end
|
||||
m.hp = m.hp - amount
|
||||
self:ShowDmgPop(m.slot, amount)
|
||||
self:MonsterHitMotion(m.slot)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
@@ -263,6 +376,12 @@ _TimerService:SetTimerOnce(function()
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
m.hp = m.hp - dmg
|
||||
if dmg > 0 then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
m.poison = (m.poison or 0) + poison
|
||||
end
|
||||
end
|
||||
self:ShowDmgPop(i, dmg)
|
||||
self:MonsterHitMotion(i)
|
||||
if m.hp <= 0 then
|
||||
@@ -416,6 +535,18 @@ self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.RetainSelectActive = false
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnBlock = 0
|
||||
self.NextTurnDraw = 0
|
||||
self.NextTurnKeepBlock = false
|
||||
self.NextTurnAttackMultiplier = 1
|
||||
self.TurnAttackMultiplier = 1
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnAddCards = {}
|
||||
self:UpdateDiscardPrompt()
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()`),
|
||||
|
||||
@@ -67,6 +67,16 @@ if allDeckClose ~= nil and (allDeckClose.ButtonComponent ~= nil or allDeckClose:
|
||||
self.AllDeckCloseHandler = allDeckClose:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
|
||||
end
|
||||
self:BindClassDeckTabs()
|
||||
for i = 1, 120 do
|
||||
local allCard = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i))
|
||||
if allCard ~= nil and (allCard.ButtonComponent ~= nil or allCard:AddComponent("ButtonComponent") ~= nil) then
|
||||
if allCard.SpriteGUIRendererComponent ~= nil then
|
||||
allCard.SpriteGUIRendererComponent.RaycastTarget = true
|
||||
end
|
||||
local slot = i
|
||||
allCard:ConnectEvent(ButtonClickEvent, function() self:OnAllDeckCardButton(slot) end)
|
||||
end
|
||||
end
|
||||
for i = 1, 10 do
|
||||
local cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
|
||||
if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then
|
||||
@@ -214,13 +224,33 @@ for i = 1, 3 do
|
||||
end
|
||||
end`),
|
||||
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
||||
self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.SkillCostReductionThisTurn = 0
|
||||
self:UpdateDiscardPrompt()
|
||||
self.Energy = self.MaxEnergy
|
||||
self.BlockGainMultiplier = 1
|
||||
self:ApplyRelics("turnStart")
|
||||
self.PlayerBlock = 0
|
||||
if self.NextTurnKeepBlock == true then
|
||||
self.NextTurnKeepBlock = false
|
||||
else
|
||||
self.PlayerBlock = 0
|
||||
end
|
||||
if self.ClayBlockNext > 0 then
|
||||
self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext
|
||||
self.ClayBlockNext = 0
|
||||
end
|
||||
self.TurnAttackMultiplier = self.NextTurnAttackMultiplier or 1
|
||||
self.NextTurnAttackMultiplier = 1
|
||||
self.CardsDrawnThisCombat = self.CardsDrawnThisCombat or 0
|
||||
self.HandCostZeroThisTurn = false
|
||||
self.DrawDisabledThisTurn = false
|
||||
local powerTurnDraw = 0
|
||||
local powerTurnDiscard = 0
|
||||
if self.PlayerPowers ~= nil then
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
@@ -231,16 +261,124 @@ if self.PlayerPowers ~= nil then
|
||||
self.Energy = self.Energy + pc.value
|
||||
elseif pc.powerEffect == "blockPerTurn" then
|
||||
self.PlayerBlock = self.PlayerBlock + pc.value
|
||||
elseif pc.powerEffect == "poisonPerTurn" then
|
||||
if self.Monsters ~= nil then
|
||||
for j = 1, #self.Monsters do
|
||||
local tm = self.Monsters[j]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
tm.poison = (tm.poison or 0) + pc.value
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif pc.powerEffect == "damagePerTurn" then
|
||||
if self.Monsters ~= nil then
|
||||
self:PlayAoeFx(pc.fx or pc.image, pc.value or 0)
|
||||
end
|
||||
end
|
||||
if pc.turnStartShiv ~= nil then
|
||||
self:AddCardsToHand("Shiv", pc.turnStartShiv)
|
||||
end
|
||||
if pc.turnStartDraw ~= nil then
|
||||
powerTurnDraw = powerTurnDraw + pc.turnStartDraw
|
||||
end
|
||||
if pc.turnStartDiscard ~= nil then
|
||||
powerTurnDiscard = powerTurnDiscard + pc.turnStartDiscard
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
self:DrawCards(5)
|
||||
if self.NextTurnBlock ~= nil and self.NextTurnBlock > 0 then
|
||||
self:AddCardBlock(self.NextTurnBlock)
|
||||
self.NextTurnBlock = 0
|
||||
end
|
||||
if self.NextTurnAddCards ~= nil then
|
||||
for i = 1, #self.NextTurnAddCards do
|
||||
local entry = self.NextTurnAddCards[i]
|
||||
if entry ~= nil and entry.cardId ~= nil and entry.amount ~= nil and entry.amount > 0 then
|
||||
self:AddCardsToHand(entry.cardId, entry.amount)
|
||||
end
|
||||
end
|
||||
self.NextTurnAddCards = {}
|
||||
end
|
||||
local drawN = 5 + (self.NextTurnDraw or 0) + powerTurnDraw
|
||||
self.NextTurnDraw = 0
|
||||
self:DrawCards(drawN)
|
||||
self:RenderHand(true)
|
||||
self:RenderCombat()
|
||||
if powerTurnDiscard > 0 then
|
||||
self:BeginDiscardSelection({ discard = math.min(powerTurnDiscard, #self.Hand) })
|
||||
return
|
||||
end
|
||||
self:RenderCombat()`),
|
||||
method('PrepareCombatDrawPile', `if self.DrawPile == nil or self.Cards == nil then
|
||||
return
|
||||
end
|
||||
local rest = {}
|
||||
local innate = {}
|
||||
for i = 1, #self.DrawPile do
|
||||
local cardId = self.DrawPile[i]
|
||||
local c = self.Cards[cardId]
|
||||
if c ~= nil and c.innate == true then
|
||||
table.insert(innate, cardId)
|
||||
else
|
||||
table.insert(rest, cardId)
|
||||
end
|
||||
end
|
||||
self.DrawPile = {}
|
||||
for i = 1, #rest do
|
||||
table.insert(self.DrawPile, rest[i])
|
||||
end
|
||||
for i = 1, #innate do
|
||||
table.insert(self.DrawPile, innate[i])
|
||||
end`, []),
|
||||
method('HasPowerEffect', `if self.PlayerPowers == nil then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc.powerEffect == effect then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'effect' }], 0, 'boolean'),
|
||||
method('HasPowerField', `if self.PlayerPowers == nil then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc[field] ~= nil and pc[field] ~= 0 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'boolean'),
|
||||
method('AddPowerFieldTotal', `local total = 0
|
||||
if self.PlayerPowers == nil then
|
||||
return total
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc[field] ~= nil then
|
||||
total = total + pc[field]
|
||||
end
|
||||
end
|
||||
return total`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'number'),
|
||||
method('ShouldOfferRetain', `if self:HasPowerEffect("retainOne") ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or #self.Hand <= 0 then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.Hand do
|
||||
local c = self.Cards[self.Hand[i]]
|
||||
if c ~= nil and c.retain ~= true then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [], 0, 'boolean'),
|
||||
method('BeginRetainSelection', `self.RetainSelectActive = true
|
||||
self:UpdateDiscardPrompt()
|
||||
self:Toast("보존할 카드를 선택하세요")
|
||||
self:RenderHand(false)`, []),
|
||||
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
@@ -248,6 +386,24 @@ if self:IsDiscardSelecting() == true then
|
||||
self:Toast("버릴 카드를 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
if self:IsRetainSelecting() == true then
|
||||
self:FinishPlayerTurn(0)
|
||||
return
|
||||
end
|
||||
if self:IsReserveSelecting() == true then
|
||||
self:Toast("예약할 카드를 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
if self:ShouldOfferRetain() == true then
|
||||
self:BeginRetainSelection()
|
||||
return
|
||||
end
|
||||
self:FinishPlayerTurn(0)`),
|
||||
method('FinishPlayerTurn', `self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self:UpdateDiscardPrompt()
|
||||
local burn = 0
|
||||
for bi = 1, #self.Hand do
|
||||
\tlocal hc = self.Cards[self.Hand[bi]]
|
||||
@@ -263,7 +419,7 @@ local kept = {}
|
||||
for i = 1, #self.Hand do
|
||||
\tlocal cardId = self.Hand[i]
|
||||
\tlocal c = self.Cards[cardId]
|
||||
\tif c ~= nil and c.retain == true then
|
||||
\tif c ~= nil and (c.retain == true or i == retainSlot) then
|
||||
\t\ttable.insert(kept, cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.DiscardPile, cardId)
|
||||
@@ -274,8 +430,13 @@ if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
|
||||
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:EnemyTurn()`),
|
||||
method('DrawCards', `local drawnSlots = {}
|
||||
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
|
||||
method('DrawCards', `local drawnSlots = {}
|
||||
local drawnCards = {}
|
||||
local drewAny = false
|
||||
if self.DrawDisabledThisTurn == true then
|
||||
\treturn drawnCards
|
||||
end
|
||||
for i = 1, amount do
|
||||
\tif #self.DrawPile <= 0 then
|
||||
\t\tself:RecycleDiscardIntoDraw()
|
||||
@@ -284,28 +445,32 @@ for i = 1, amount do
|
||||
\t\tbreak
|
||||
\tend
|
||||
\tlocal cardId = table.remove(self.DrawPile)
|
||||
\ttable.insert(drawnCards, cardId)
|
||||
\tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1
|
||||
\tif #self.Hand >= 10 then
|
||||
\t\ttable.insert(self.DiscardPile, cardId)
|
||||
\t\tself:TriggerSly(cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.Hand, cardId)
|
||||
\t\tif #self.Hand <= 5 then
|
||||
\t\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\t\tend
|
||||
\t\tdrewAny = true
|
||||
\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\tend
|
||||
end
|
||||
self:RenderPiles()
|
||||
if animate == true and #drawnSlots > 0 then
|
||||
if drewAny == true then
|
||||
\tself:RenderHand(false)
|
||||
end
|
||||
if animate == true and #drawnSlots > 0 then
|
||||
\tlocal drawStart = Vector2(-590, 8)
|
||||
\tfor i = 1, #drawnSlots do
|
||||
\t\tlocal slot = drawnSlots[i]
|
||||
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
|
||||
\tend
|
||||
return drawnCards
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
]),
|
||||
], 0, 'any'),
|
||||
method('AddCardsToHand', `if self.Hand == nil then
|
||||
self.Hand = {}
|
||||
end
|
||||
|
||||
@@ -42,23 +42,18 @@ if count > 60 then
|
||||
suffix = suffix .. " - 60장까지 표시"
|
||||
end
|
||||
self:SetText("/ui/DeckUIGroup/DeckInspectHud/Title", title .. suffix)
|
||||
local empty = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud/Empty")
|
||||
if empty ~= nil then
|
||||
empty.Enable = count <= 0
|
||||
end
|
||||
self:SetEntityEnabled("/ui/DeckUIGroup/DeckInspectHud/Empty", count <= 0)
|
||||
for i = 1, 60 do
|
||||
local e = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud/Grid/Card" .. tostring(i))
|
||||
if e ~= nil then
|
||||
local cardId = nil
|
||||
if pile ~= nil then
|
||||
cardId = pile[i]
|
||||
end
|
||||
if cardId == nil then
|
||||
e.Enable = false
|
||||
else
|
||||
e.Enable = true
|
||||
self:ApplyInspectCardVisual(i, cardId)
|
||||
end
|
||||
local path = "/ui/DeckUIGroup/DeckInspectHud/Grid/Card" .. tostring(i)
|
||||
local cardId = nil
|
||||
if pile ~= nil then
|
||||
cardId = pile[i]
|
||||
end
|
||||
if cardId == nil then
|
||||
self:SetEntityEnabled(path, false)
|
||||
else
|
||||
self:SetEntityEnabled(path, true)
|
||||
self:ApplyInspectCardVisual(i, cardId)
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pile' },
|
||||
@@ -94,12 +89,31 @@ if mageTab ~= nil and (mageTab.ButtonComponent ~= nil or mageTab:AddComponent("B
|
||||
end`),
|
||||
method('OpenClassDeck', `self.CodexMode = false
|
||||
self.ClassDeckMode = true
|
||||
self.DebugCardPickerMode = false
|
||||
self.DeckAllOpen = true
|
||||
self:SetClassDeckTab(className)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
|
||||
method('OpenDebugCardPicker', `if self.RunActive ~= true or self.CombatOver == true or self.Hand == nil then
|
||||
self:Toast("전투 중에만 테스트 카드를 추가할 수 있습니다")
|
||||
return
|
||||
end
|
||||
local className = self.SelectedClass
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
|
||||
className = "bandit"
|
||||
end
|
||||
self.CodexMode = false
|
||||
self.ClassDeckMode = true
|
||||
self.DebugCardPickerMode = true
|
||||
self.DeckAllOpen = true
|
||||
self:SetClassDeckTab(className)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end
|
||||
self:Toast("테스트 카드 추가 모드")`),
|
||||
method('SetClassDeckTab', `if self.ClassDeckMode ~= true then
|
||||
return
|
||||
end
|
||||
@@ -152,15 +166,13 @@ self:RenderClassDeckTabs()`, [{ Type: 'string', DefaultValue: null, SyncDirectio
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/MageTab", cls = "magician" },
|
||||
}
|
||||
for i = 1, #tabs do
|
||||
self:SetEntityEnabled(tabs[i].path, self.ClassDeckMode == true)
|
||||
local e = _EntityService:GetEntityByPath(tabs[i].path)
|
||||
if e ~= nil then
|
||||
e.Enable = self.ClassDeckMode == true
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ClassDeckClass == tabs[i].cls then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.28, 0.34, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.11, 0.13, 0.16, 1)
|
||||
end
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ClassDeckClass == tabs[i].cls then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.28, 0.34, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.11, 0.13, 0.16, 1)
|
||||
end
|
||||
end
|
||||
end`),
|
||||
@@ -171,6 +183,7 @@ end
|
||||
self.DeckInspectKind = ""
|
||||
self.ClassDeckMode = false
|
||||
self.ClassDeckClass = ""
|
||||
self.DebugCardPickerMode = false
|
||||
self:RenderClassDeckTabs()
|
||||
self.DeckAllOpen = true
|
||||
self:RenderAllDeck()
|
||||
@@ -189,6 +202,7 @@ if self.ClassDeckMode == true then
|
||||
self.ClassDeckTitle = ""
|
||||
self.ClassDeckClass = ""
|
||||
end
|
||||
self.DebugCardPickerMode = false
|
||||
self:RenderClassDeckTabs()
|
||||
if self.CodexMode == true then
|
||||
self.CodexMode = false
|
||||
@@ -199,6 +213,9 @@ local title = "모든 덱"
|
||||
if self.ClassDeckMode == true then
|
||||
pile = self.ClassDeckCards or {}
|
||||
title = self.ClassDeckTitle
|
||||
if self.DebugCardPickerMode == true then
|
||||
title = title .. " - 테스트 카드 추가"
|
||||
end
|
||||
elseif self.CodexMode == true then
|
||||
pile = self.CodexCards or {}
|
||||
title = "카드 도감"
|
||||
@@ -206,24 +223,36 @@ end
|
||||
local count = #pile
|
||||
self:SetText("/ui/DeckUIGroup/DeckAllHud/Title", title .. " (" .. tostring(count) .. ")")
|
||||
self:RenderClassDeckTabs()
|
||||
local empty = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Empty")
|
||||
if empty ~= nil then
|
||||
empty.Enable = count <= 0
|
||||
end
|
||||
self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud/Empty", count <= 0)
|
||||
for i = 1, 120 do
|
||||
local e = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i))
|
||||
if e ~= nil then
|
||||
local cardId = pile[i]
|
||||
if cardId == nil then
|
||||
e.Enable = false
|
||||
else
|
||||
e.Enable = true
|
||||
self:ApplyAllDeckCardVisual(i, cardId)
|
||||
end
|
||||
local path = "/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i)
|
||||
local cardId = pile[i]
|
||||
if cardId == nil then
|
||||
self:SetEntityEnabled(path, false)
|
||||
else
|
||||
self:SetEntityEnabled(path, true)
|
||||
self:ApplyAllDeckCardVisual(i, cardId)
|
||||
end
|
||||
end`),
|
||||
method('ApplyAllDeckCardVisual', `self:ApplyCardFace("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
]),
|
||||
method('OnAllDeckCardButton', `if self.DebugCardPickerMode ~= true then
|
||||
return
|
||||
end
|
||||
if self.ClassDeckCards == nil then
|
||||
return
|
||||
end
|
||||
local cardId = self.ClassDeckCards[slot]
|
||||
if cardId == nil or self.Cards == nil or self.Cards[cardId] == nil then
|
||||
return
|
||||
end
|
||||
self:AddCardsToHand(cardId, 1)
|
||||
local c = self.Cards[cardId]
|
||||
local name = cardId
|
||||
if c.name ~= nil then name = c.name end
|
||||
self:Toast("테스트 카드 추가: " .. name)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -60,7 +60,7 @@ if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
end
|
||||
self:SetText(base .. "/Cost", string.format("%d", c.cost))
|
||||
self:SetText(base .. "/Name", c.name)
|
||||
self:SetText(base .. "/Desc", c.desc)
|
||||
self:SetText(base .. "/Desc", self:FormatCardDescription(c.desc))
|
||||
local art = _EntityService:GetEntityByPath(base .. "/Art")
|
||||
if art ~= nil then
|
||||
if c.image ~= nil and c.image ~= "" then
|
||||
@@ -203,15 +203,121 @@ end, 1 / 60)`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toPos' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'duration' },
|
||||
]),
|
||||
method('AnimateDiscardCards', `if cardIds == nil or slots == nil then
|
||||
\treturn
|
||||
end
|
||||
local target = Vector2(590, 8)
|
||||
local duration = 0.18
|
||||
for i = 1, #cardIds do
|
||||
\tlocal slot = slots[i] or i
|
||||
\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
\tif e ~= nil then
|
||||
\t\te.Enable = true
|
||||
\t\tself:ApplyCardFace("/ui/RunUIGroup/CardHand/Card" .. tostring(slot), cardIds[i])
|
||||
\t\tif e.UITransformComponent ~= nil then
|
||||
\t\t\tlocal sx = 0
|
||||
\t\t\tif startXs ~= nil and startXs[i] ~= nil then sx = startXs[i] else sx = self:GetHandSlotX(slot) end
|
||||
\t\t\te.UITransformComponent.anchoredPosition = Vector2(sx, 0)
|
||||
\t\t\te.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
\t\tend
|
||||
\tend
|
||||
end
|
||||
local elapsed = 0
|
||||
local eventId = 0
|
||||
eventId = _TimerService:SetTimerRepeat(function()
|
||||
\telapsed = elapsed + 1 / 60
|
||||
\tlocal t = math.min(elapsed / duration, 1)
|
||||
\tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseIn, t)
|
||||
\tfor i = 1, #cardIds do
|
||||
\t\tlocal slot = slots[i] or i
|
||||
\t\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
\t\tif e ~= nil and e.UITransformComponent ~= nil then
|
||||
\t\t\tlocal sx = 0
|
||||
\t\t\tif startXs ~= nil and startXs[i] ~= nil then sx = startXs[i] else sx = self:GetHandSlotX(slot) end
|
||||
\t\t\tlocal x = sx + (target.x - sx) * eased
|
||||
\t\t\tlocal y = 0 + (target.y - 0) * eased
|
||||
\t\t\tlocal s = 1 - 0.25 * eased
|
||||
\t\t\te.UITransformComponent.anchoredPosition = Vector2(x, y)
|
||||
\t\t\te.UITransformComponent.UIScale = Vector3(s, s, 1)
|
||||
\t\tend
|
||||
\tend
|
||||
\tif t >= 1 then
|
||||
\t\t_TimerService:ClearTimer(eventId)
|
||||
\t\tfor i = 1, #cardIds do
|
||||
\t\t\tlocal slot = slots[i] or i
|
||||
\t\t\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
\t\t\tif e ~= nil then
|
||||
\t\t\t\tif self.Hand ~= nil and self.Hand[slot] ~= nil then
|
||||
\t\t\t\t\te.Enable = true
|
||||
\t\t\t\t\tself:ApplyCardVisual(slot, self.Hand[slot])
|
||||
\t\t\t\t\tif e.UITransformComponent ~= nil then
|
||||
\t\t\t\t\t\te.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(slot), 0)
|
||||
\t\t\t\t\t\te.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
\t\t\t\t\tend
|
||||
\t\t\t\telse
|
||||
\t\t\t\t\te.Enable = false
|
||||
\t\t\t\tend
|
||||
\t\t\tend
|
||||
\t\tend
|
||||
\tend
|
||||
end, 1 / 60)`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardIds' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'startXs' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slots' },
|
||||
]),
|
||||
method('AddCardBlock', `local amount = base or 0
|
||||
if amount > 0 and self.PlayerDex ~= nil then
|
||||
amount = amount + self.PlayerDex
|
||||
end
|
||||
if self.BlockGainMultiplier ~= nil and self.BlockGainMultiplier > 1 then
|
||||
amount = amount * self.BlockGainMultiplier
|
||||
end
|
||||
if amount < 0 then
|
||||
amount = 0
|
||||
end
|
||||
self.PlayerBlock = self.PlayerBlock + amount
|
||||
return amount`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
|
||||
method('CountOtherHandSkills', `if self.Hand == nil then
|
||||
return 0
|
||||
end
|
||||
local n = 0
|
||||
for i = 1, #self.Hand do
|
||||
if i ~= slot then
|
||||
local hc = self.Cards[self.Hand[i]]
|
||||
if hc ~= nil and hc.kind == "Skill" then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return n`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
|
||||
method('AttackBaseForCard', `local base2 = c.damage or 0
|
||||
local otherHand = 0
|
||||
if self.Hand ~= nil then
|
||||
otherHand = #self.Hand - 1
|
||||
if otherHand < 0 then otherHand = 0 end
|
||||
end
|
||||
if c.damagePerOtherHandCard ~= nil then
|
||||
base2 = base2 + otherHand * c.damagePerOtherHandCard
|
||||
end
|
||||
if c.damagePerAttackPlayedThisTurn ~= nil then
|
||||
base2 = base2 + (self.TurnAttackCardsPlayed or 0) * c.damagePerAttackPlayedThisTurn
|
||||
end
|
||||
if c.damagePerDiscardedThisTurn ~= nil then
|
||||
base2 = base2 + (self.TurnDiscardedCards or 0) * c.damagePerDiscardedThisTurn
|
||||
end
|
||||
if c.damagePerSkillInHand ~= nil then
|
||||
base2 = base2 + self:CountOtherHandSkills(slot) * c.damagePerSkillInHand
|
||||
end
|
||||
if c.damagePerCardDrawnThisCombat ~= nil then
|
||||
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
|
||||
end
|
||||
if base2 < 0 then
|
||||
base2 = 0
|
||||
end
|
||||
return base2`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
], 0, 'number'),
|
||||
method('CalcPlayerAttack', `local base2 = base
|
||||
self.FightAttackCount = self.FightAttackCount + 1
|
||||
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then
|
||||
@@ -224,6 +330,9 @@ end
|
||||
if self.PlayerWeak > 0 then
|
||||
dmg = math.floor(dmg * 0.75)
|
||||
end
|
||||
if self.TurnAttackMultiplier ~= nil and self.TurnAttackMultiplier > 1 then
|
||||
dmg = dmg * self.TurnAttackMultiplier
|
||||
end
|
||||
if dmg > 0 and dmg < 5 and self:HasRelic("boot") then
|
||||
dmg = 5
|
||||
end
|
||||
@@ -231,16 +340,85 @@ if dmg < 0 then
|
||||
dmg = 0
|
||||
end
|
||||
return dmg`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
|
||||
method('ResolveCardEffects', `if c == nil then
|
||||
method('QueueNextTurnAddCard', `if cardId == nil or cardId == "" or amount == nil or amount <= 0 then
|
||||
return
|
||||
end
|
||||
if self.NextTurnAddCards == nil then
|
||||
self.NextTurnAddCards = {}
|
||||
end
|
||||
for i = 1, #self.NextTurnAddCards do
|
||||
local entry = self.NextTurnAddCards[i]
|
||||
if entry ~= nil and entry.cardId == cardId then
|
||||
entry.amount = (entry.amount or 0) + amount
|
||||
return
|
||||
end
|
||||
end
|
||||
table.insert(self.NextTurnAddCards, { cardId = cardId, amount = amount })`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('QueueNextTurnEffects', `if c == nil then
|
||||
return
|
||||
end
|
||||
if c.nextTurnBlock ~= nil then
|
||||
self.NextTurnBlock = (self.NextTurnBlock or 0) + c.nextTurnBlock
|
||||
end
|
||||
if c.nextTurnDraw ~= nil then
|
||||
self.NextTurnDraw = (self.NextTurnDraw or 0) + c.nextTurnDraw
|
||||
end
|
||||
if c.nextTurnKeepBlock == true then
|
||||
self.NextTurnKeepBlock = true
|
||||
end
|
||||
if c.nextTurnAttackMultiplier ~= nil and c.nextTurnAttackMultiplier > 0 then
|
||||
local cur = self.NextTurnAttackMultiplier or 1
|
||||
self.NextTurnAttackMultiplier = cur * c.nextTurnAttackMultiplier
|
||||
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }]),
|
||||
method('ResolveCardEffects', `if c == nil then
|
||||
return
|
||||
end
|
||||
if c.blockGainMultiplier ~= nil and c.blockGainMultiplier > 0 then
|
||||
self.BlockGainMultiplier = (self.BlockGainMultiplier or 1) * c.blockGainMultiplier
|
||||
end
|
||||
if c.nextSkillCostZero == true then
|
||||
self.NextSkillCostZero = true
|
||||
end
|
||||
if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then
|
||||
self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn
|
||||
end
|
||||
if c.handCostZeroThisTurn == true then
|
||||
self.HandCostZeroThisTurn = true
|
||||
end
|
||||
if c.drawDisabledThisTurn == true then
|
||||
self.DrawDisabledThisTurn = true
|
||||
end
|
||||
local xEnergy = energySpent or 0
|
||||
local weakAmount = c.weak or 0
|
||||
local vulnAmount = c.vuln or 0
|
||||
local poisonAmount = c.poison or 0
|
||||
if c.xWeakPerEnergy ~= nil and c.xWeakPerEnergy > 0 then
|
||||
weakAmount = weakAmount + xEnergy * c.xWeakPerEnergy
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
|
||||
self:PlayerAttackMotion()
|
||||
local baseDmg = self:AttackBaseForCard(slot, c)
|
||||
if c.xDamagePerEnergy ~= nil and c.xDamagePerEnergy > 0 then
|
||||
baseDmg = xEnergy * c.xDamagePerEnergy
|
||||
end
|
||||
local total = 0
|
||||
local hitN = c.hits or 1
|
||||
if c.otherHandAtLeast ~= nil and c.bonusHitsWhenOtherHandAtLeast ~= nil then
|
||||
local otherHand = 0
|
||||
if self.Hand ~= nil then
|
||||
otherHand = #self.Hand - 1
|
||||
if otherHand < 0 then otherHand = 0 end
|
||||
end
|
||||
if otherHand >= c.otherHandAtLeast then
|
||||
hitN = hitN + c.bonusHitsWhenOtherHandAtLeast
|
||||
end
|
||||
end
|
||||
for h = 1, hitN do
|
||||
total = total + self:CalcPlayerAttack(c.damage)
|
||||
total = total + self:CalcPlayerAttack(baseDmg)
|
||||
end
|
||||
if c.aoe == true then
|
||||
self:PlayAoeFx(c.fx or c.image, total)
|
||||
@@ -278,7 +456,11 @@ end
|
||||
if c.heal ~= nil then
|
||||
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
|
||||
end
|
||||
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
|
||||
if c.gainEnergy ~= nil and c.gainEnergy ~= 0 then
|
||||
self.Energy = self.Energy + c.gainEnergy
|
||||
end
|
||||
self:QueueNextTurnEffects(c)
|
||||
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil then
|
||||
local tm = self.Monsters[self.TargetIndex]
|
||||
if tm == nil or tm.alive ~= true then
|
||||
for i = 1, #self.Monsters do
|
||||
@@ -286,32 +468,59 @@ if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
|
||||
end
|
||||
end
|
||||
if tm ~= nil and tm.alive == true then
|
||||
if c.weak ~= nil then tm.weak = tm.weak + c.weak end
|
||||
if c.poison ~= nil then tm.poison = (tm.poison or 0) + c.poison end
|
||||
if c.vuln ~= nil then
|
||||
tm.vuln = tm.vuln + c.vuln
|
||||
if weakAmount ~= nil and weakAmount > 0 then tm.weak = tm.weak + weakAmount end
|
||||
if poisonAmount ~= nil and poisonAmount > 0 then tm.poison = (tm.poison or 0) + poisonAmount end
|
||||
if vulnAmount ~= nil and vulnAmount > 0 then
|
||||
tm.vuln = tm.vuln + vulnAmount
|
||||
if self:HasRelic("championBelt") then
|
||||
tm.weak = tm.weak + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local drawnCards = {}
|
||||
if c.draw ~= nil then
|
||||
self:DrawCards(c.draw, true)
|
||||
drawnCards = self:DrawCards(c.draw, true) or {}
|
||||
end
|
||||
if c.drawUntilHandSize ~= nil and c.drawUntilHandSize > 0 then
|
||||
local currentHand = 0
|
||||
if self.Hand ~= nil then
|
||||
currentHand = #self.Hand
|
||||
if slot ~= nil and slot > 0 and self.Hand[slot] == cardId then
|
||||
currentHand = currentHand - 1
|
||||
end
|
||||
end
|
||||
local need = c.drawUntilHandSize - currentHand
|
||||
if need > 0 then
|
||||
local moreDrawnCards = self:DrawCards(need, true) or {}
|
||||
for i = 1, #moreDrawnCards do
|
||||
table.insert(drawnCards, moreDrawnCards[i])
|
||||
end
|
||||
end
|
||||
end
|
||||
if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
|
||||
for i = 1, #drawnCards do
|
||||
local drawnCard = self.Cards[drawnCards[i]]
|
||||
if drawnCard ~= nil and drawnCard.kind == "Skill" then
|
||||
self:AddCardBlock(c.drawSkillBlock)
|
||||
end
|
||||
end
|
||||
end
|
||||
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
||||
self:AddCardsToHand("Shiv", c.addShiv)
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'free' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
]),
|
||||
method('TriggerSly', `local c = self.Cards[cardId]
|
||||
if c == nil or c.sly ~= true then
|
||||
return
|
||||
end
|
||||
self:Toast("교활 발동: " .. c.name)
|
||||
self:ResolveCardEffects(cardId, 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
|
||||
return
|
||||
end
|
||||
@@ -319,15 +528,23 @@ local cardId = self.Hand[slot]
|
||||
if cardId == nil then
|
||||
return
|
||||
end
|
||||
local startX = self:GetHandSlotX(slot)
|
||||
table.remove(self.Hand, slot)
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
self.TurnDiscardedCards = (self.TurnDiscardedCards or 0) + 1
|
||||
if triggerSly == true then
|
||||
self:TriggerSly(cardId)
|
||||
end
|
||||
if animate == true then
|
||||
self:AnimateDiscardCards({ cardId }, { startX }, { slot })
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'triggerSly' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
]),
|
||||
method('IsDiscardSelecting', `return self.DiscardSelectRemaining ~= nil and self.DiscardSelectRemaining > 0`, [], 0, 'boolean'),
|
||||
method('IsRetainSelecting', `return self.RetainSelectActive == true`, [], 0, 'boolean'),
|
||||
method('IsReserveSelecting', `return self.ReserveSelectActive == true`, [], 0, 'boolean'),
|
||||
method('UpdateDiscardPrompt', `local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/DiscardPrompt")
|
||||
if e == nil then
|
||||
return
|
||||
@@ -336,16 +553,27 @@ if self:IsDiscardSelecting() == true then
|
||||
local picked = self.DiscardSelectTotal - self.DiscardSelectRemaining
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", "버릴 카드 선택 " .. self:FormatNumber(picked + 1) .. "/" .. self:FormatNumber(self.DiscardSelectTotal))
|
||||
e.Enable = true
|
||||
elseif self:IsRetainSelecting() == true then
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", "보존할 카드 선택 (턴 종료: 건너뛰기)")
|
||||
e.Enable = true
|
||||
elseif self:IsReserveSelecting() == true then
|
||||
local msg = self.NextTurnSelectPrompt or ""
|
||||
if msg == "" then
|
||||
msg = "다음 턴에 예약할 카드를 선택하세요"
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", msg)
|
||||
e.Enable = true
|
||||
else
|
||||
e.Enable = false
|
||||
end`),
|
||||
method('BeginDiscardSelection', `if c == nil or self.Hand == nil then
|
||||
return false
|
||||
end
|
||||
local n = 0
|
||||
if c.discardAll == true then
|
||||
n = #self.Hand
|
||||
elseif c.discard ~= nil then
|
||||
return self:AutoDiscardHand(c)
|
||||
end
|
||||
local n = 0
|
||||
if c.discard ~= nil then
|
||||
n = math.min(c.discard, #self.Hand)
|
||||
end
|
||||
if n <= 0 then
|
||||
@@ -355,28 +583,137 @@ self.DiscardSelectRemaining = n
|
||||
self.DiscardSelectTotal = n
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.DiscardPostDraw = 0
|
||||
self.DiscardDrawPerPick = 0
|
||||
if c.addShiv ~= nil then
|
||||
self.DiscardPostShiv = c.addShiv
|
||||
end
|
||||
if c.addShivPerDiscard == true then
|
||||
self.DiscardShivPerPick = 1
|
||||
end
|
||||
if c.drawPerDiscarded ~= nil and c.drawPerDiscarded > 0 then
|
||||
self.DiscardDrawPerPick = c.drawPerDiscarded
|
||||
end
|
||||
self:UpdateDiscardPrompt()
|
||||
self:Toast("버릴 카드를 선택하세요")
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('BeginReserveSelection', `if c == nil or c.nextTurnSelectHandCard ~= true or c.nextTurnCopies == nil or c.nextTurnCopies <= 0 then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or #self.Hand <= 0 then
|
||||
return false
|
||||
end
|
||||
self.ReserveSelectActive = true
|
||||
self.NextTurnSelectCopies = c.nextTurnCopies
|
||||
self.NextTurnSelectPrompt = c.nextTurnSelectPrompt or ""
|
||||
self:UpdateDiscardPrompt()
|
||||
self:Toast("예약할 카드를 선택하세요")
|
||||
self:RenderHand(false)
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('SelectReserveSlot', `if self:IsReserveSelecting() ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return true
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
local amount = self.NextTurnSelectCopies or 0
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self:UpdateDiscardPrompt()
|
||||
if amount > 0 and cardId ~= nil then
|
||||
self:QueueNextTurnAddCard(cardId, amount)
|
||||
local label = cardId
|
||||
if self.Cards[cardId] ~= nil and self.Cards[cardId].name ~= nil then
|
||||
label = self.Cards[cardId].name
|
||||
end
|
||||
self:Toast("다음 턴 예약: " .. label .. " " .. self:FormatNumber(amount) .. "장")
|
||||
end
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
|
||||
method('SelectRetainSlot', `if self:IsRetainSelecting() ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return true
|
||||
end
|
||||
self:FinishPlayerTurn(slot)
|
||||
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
|
||||
method('AutoDiscardHand', `if c == nil or self.Hand == nil or #self.Hand <= 0 then
|
||||
return false
|
||||
end
|
||||
local cardIds = {}
|
||||
local startXs = {}
|
||||
local slots = {}
|
||||
local n = #self.Hand
|
||||
for i = 1, n do
|
||||
local cardId = self.Hand[i]
|
||||
table.insert(cardIds, cardId)
|
||||
table.insert(startXs, self:GetHandSlotX(i))
|
||||
table.insert(slots, i)
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
self.TurnDiscardedCards = (self.TurnDiscardedCards or 0) + 1
|
||||
end
|
||||
self.Hand = {}
|
||||
local shivCount = 0
|
||||
if c.addShiv ~= nil then shivCount = shivCount + c.addShiv end
|
||||
if c.addShivPerDiscard == true then shivCount = shivCount + n end
|
||||
self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.DiscardPostDraw = 0
|
||||
self.DiscardDrawPerPick = 0
|
||||
self:UpdateDiscardPrompt()
|
||||
self:AnimateDiscardCards(cardIds, startXs, slots)
|
||||
for i = 1, #cardIds do
|
||||
self:TriggerSly(cardIds[i])
|
||||
end
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if shivCount > 0 then
|
||||
self:AddCardsToHand("Shiv", shivCount)
|
||||
else
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
end
|
||||
if c.drawPerDiscarded ~= nil and c.drawPerDiscarded > 0 then
|
||||
self:DrawCards(n * c.drawPerDiscarded, true)
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end, 0.22)
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('FinishDiscardSelection', `self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
local shivCount = self.DiscardPostShiv or 0
|
||||
local drawCount = self.DiscardPostDraw or 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardPostDraw = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.DiscardDrawPerPick = 0
|
||||
self:UpdateDiscardPrompt()
|
||||
if shivCount > 0 then
|
||||
self:AddCardsToHand("Shiv", shivCount)
|
||||
local finish = function()
|
||||
if shivCount > 0 then
|
||||
self:AddCardsToHand("Shiv", shivCount)
|
||||
else
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
end
|
||||
if drawCount > 0 then
|
||||
self:DrawCards(drawCount, true)
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()`),
|
||||
if delayRender == true then
|
||||
_TimerService:SetTimerOnce(finish, 0.22)
|
||||
else
|
||||
finish()
|
||||
end`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'delayRender' }]),
|
||||
method('SelectDiscardSlot', `if self:IsDiscardSelecting() ~= true then
|
||||
return false
|
||||
end
|
||||
@@ -384,18 +721,21 @@ if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return true
|
||||
end
|
||||
local discarded = self.Hand[slot]
|
||||
self:DiscardHandCard(slot, true)
|
||||
self:DiscardHandCard(slot, true, true)
|
||||
if discarded ~= nil and self.DiscardShivPerPick ~= nil and self.DiscardShivPerPick > 0 then
|
||||
self.DiscardPostShiv = (self.DiscardPostShiv or 0) + self.DiscardShivPerPick
|
||||
end
|
||||
if discarded ~= nil and self.DiscardDrawPerPick ~= nil and self.DiscardDrawPerPick > 0 then
|
||||
self.DiscardPostDraw = (self.DiscardPostDraw or 0) + self.DiscardDrawPerPick
|
||||
end
|
||||
self.DiscardSelectRemaining = self.DiscardSelectRemaining - 1
|
||||
if self.DiscardSelectRemaining <= 0 or #self.Hand <= 0 then
|
||||
self:FinishDiscardSelection()
|
||||
self:FinishDiscardSelection(true)
|
||||
else
|
||||
self:UpdateDiscardPrompt()
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
_TimerService:SetTimerOnce(function() self:RenderHand(false) end, 0.22)
|
||||
end
|
||||
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
|
||||
];
|
||||
|
||||
@@ -67,6 +67,12 @@ self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
self.MaxEnergy = 3
|
||||
self.Turn = 0
|
||||
self.PlayerBlock = 0
|
||||
self.BlockGainMultiplier = 1
|
||||
self.CardsDrawnThisCombat = 0
|
||||
self.HandCostZeroThisTurn = false
|
||||
self.DrawDisabledThisTurn = false
|
||||
self.NextSkillCostZero = false
|
||||
self.SkillCostReductionThisTurn = 0
|
||||
self.PlayerStr = 0
|
||||
self.PlayerDex = 0
|
||||
self.PlayerThorns = 0
|
||||
@@ -74,6 +80,8 @@ self.PlayerWeak = 0
|
||||
self.PlayerVuln = 0
|
||||
self.PlayerPowers = {}
|
||||
self.FightAttackCount = 0
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.DmgPopSeq = 0
|
||||
self.FirstHpLossDone = false
|
||||
self.ClayBlockNext = 0
|
||||
@@ -81,6 +89,16 @@ self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnBlock = 0
|
||||
self.NextTurnDraw = 0
|
||||
self.NextTurnKeepBlock = false
|
||||
self.NextTurnAttackMultiplier = 1
|
||||
self.TurnAttackMultiplier = 1
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnAddCards = {}
|
||||
self.CombatOver = false
|
||||
self.DiscardPile = {}
|
||||
self.ExhaustPile = {}
|
||||
@@ -91,6 +109,7 @@ for i = 1, #self.RunDeck do
|
||||
self.DrawPile[i] = self.RunDeck[i]
|
||||
end
|
||||
self:Shuffle(self.DrawPile)
|
||||
self:PrepareCombatDrawPile()
|
||||
self:BuildMonsters()
|
||||
self:RenderCombat()
|
||||
self:StartPlayerTurn()
|
||||
|
||||
@@ -69,7 +69,7 @@ if warrior ~= nil and (warrior.ButtonComponent ~= nil or warrior:AddComponent("B
|
||||
end
|
||||
self.WarriorSelectHandler = warrior:ConnectEvent(ButtonClickEvent, function() self:SelectClass("warrior") end)
|
||||
end
|
||||
local thief = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/ThiefButton")
|
||||
local thief = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/BanditButton")
|
||||
if thief ~= nil and (thief.ButtonComponent ~= nil or thief:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.ThiefSelectHandler ~= nil then
|
||||
thief:DisconnectEvent(ButtonClickEvent, self.ThiefSelectHandler)
|
||||
|
||||
@@ -3,6 +3,47 @@ import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const tooltipMethods = [
|
||||
method('FormatCardDescription', `if desc == nil or desc == "" then
|
||||
return ""
|
||||
end
|
||||
local function replacePlain(text, needle, replacement)
|
||||
local out = ""
|
||||
local pos = 1
|
||||
while true do
|
||||
local s, e = string.find(text, needle, pos, true)
|
||||
if s == nil then
|
||||
out = out .. string.sub(text, pos)
|
||||
break
|
||||
end
|
||||
out = out .. string.sub(text, pos, s - 1) .. replacement
|
||||
pos = e + 1
|
||||
end
|
||||
return out
|
||||
end
|
||||
local terms = {
|
||||
"교활",
|
||||
"보존",
|
||||
"민첩",
|
||||
"가시",
|
||||
"소멸",
|
||||
"선천성",
|
||||
"취약",
|
||||
"약화",
|
||||
"독",
|
||||
"광역",
|
||||
"관통",
|
||||
"방어도",
|
||||
"힘",
|
||||
"스킬",
|
||||
"공격",
|
||||
"파워",
|
||||
}
|
||||
local out = desc
|
||||
for i = 1, #terms do
|
||||
local term = terms[i]
|
||||
out = replacePlain(out, term, "<color=#70D6FF>" .. term .. "</color>")
|
||||
end
|
||||
return out`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' }], 0, 'string'),
|
||||
method('BuildCardKeywordTooltip', `if c == nil then
|
||||
return ""
|
||||
end
|
||||
|
||||
@@ -62,6 +62,9 @@ function writeCodeblocks() {
|
||||
prop('boolean', 'CodexMode', 'false'),
|
||||
prop('any', 'CodexCards'),
|
||||
prop('boolean', 'ClassDeckMode', 'false'),
|
||||
prop('boolean', 'DebugCardPickerMode', 'false'),
|
||||
prop('boolean', 'DebugCtrlDown', 'false'),
|
||||
prop('boolean', 'DebugShiftDown', 'false'),
|
||||
prop('any', 'ClassDeckCards'),
|
||||
prop('string', 'ClassDeckTitle', '""'),
|
||||
prop('string', 'ClassDeckClass', '""'),
|
||||
@@ -78,6 +81,7 @@ function writeCodeblocks() {
|
||||
prop('number', 'PlayerHp', '0'),
|
||||
prop('number', 'PlayerMaxHp', '80'),
|
||||
prop('number', 'PlayerBlock', '0'),
|
||||
prop('number', 'BlockGainMultiplier', '1'),
|
||||
prop('number', 'PlayerDex', '0'),
|
||||
prop('number', 'PlayerThorns', '0'),
|
||||
prop('boolean', 'CombatOver', 'false'),
|
||||
@@ -116,6 +120,8 @@ function writeCodeblocks() {
|
||||
prop('string', 'ShopPotion', '""'),
|
||||
prop('boolean', 'ShopPotionBought', 'false'),
|
||||
prop('number', 'FightAttackCount', '0'),
|
||||
prop('number', 'TurnAttackCardsPlayed', '0'),
|
||||
prop('number', 'TurnDiscardedCards', '0'),
|
||||
prop('boolean', 'FirstHpLossDone', 'false'),
|
||||
prop('number', 'ClayBlockNext', '0'),
|
||||
prop('number', 'PotionMenuSlot', '0'),
|
||||
@@ -127,6 +133,18 @@ function writeCodeblocks() {
|
||||
prop('number', 'DiscardSelectTotal', '0'),
|
||||
prop('number', 'DiscardPostShiv', '0'),
|
||||
prop('number', 'DiscardShivPerPick', '0'),
|
||||
prop('boolean', 'RetainSelectActive', 'false'),
|
||||
prop('boolean', 'ReserveSelectActive', 'false'),
|
||||
prop('number', 'NextTurnBlock', '0'),
|
||||
prop('number', 'NextTurnDraw', '0'),
|
||||
prop('boolean', 'NextTurnKeepBlock', 'false'),
|
||||
prop('number', 'NextTurnAttackMultiplier', '1'),
|
||||
prop('number', 'TurnAttackMultiplier', '1'),
|
||||
prop('string', 'NextTurnSelectPrompt', '""'),
|
||||
prop('number', 'NextTurnSelectCopies', '0'),
|
||||
prop('boolean', 'NextSkillCostZero', 'false'),
|
||||
prop('number', 'SkillCostReductionThisTurn', '0'),
|
||||
prop('any', 'NextTurnAddCards'),
|
||||
], [
|
||||
...bootMethods,
|
||||
...stateMethods,
|
||||
|
||||
@@ -116,11 +116,12 @@ export function buildDeckAll() {
|
||||
path: cardPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: i,
|
||||
components: [
|
||||
transform({ parentW: 980, parentH: 620, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: ALL_DECK_CARD_W, y: ALL_DECK_CARD_H }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0 }),
|
||||
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
});
|
||||
card.jsonString.enable = false;
|
||||
|
||||
@@ -158,10 +158,24 @@ function luaCardsTable(cards) {
|
||||
const lines = Object.entries(cards).map(([id, c]) => {
|
||||
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
|
||||
if (c.damage != null) fields.push(`damage = ${c.damage}`);
|
||||
if (c.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
|
||||
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
||||
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
||||
if (c.damagePerSkillInHand != null) fields.push(`damagePerSkillInHand = ${c.damagePerSkillInHand}`);
|
||||
if (c.damagePerCardDrawnThisCombat != null) fields.push(`damagePerCardDrawnThisCombat = ${c.damagePerCardDrawnThisCombat}`);
|
||||
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
|
||||
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
|
||||
if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
|
||||
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
|
||||
if (c.attackPoison != null) fields.push(`attackPoison = ${c.attackPoison}`);
|
||||
if (c.otherHandAtLeast != null) fields.push(`otherHandAtLeast = ${c.otherHandAtLeast}`);
|
||||
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
|
||||
if (c.block != null) fields.push(`block = ${c.block}`);
|
||||
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
|
||||
if (c.strength != null) fields.push(`strength = ${c.strength}`);
|
||||
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
||||
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
||||
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`);
|
||||
if (c.weak != null) fields.push(`weak = ${c.weak}`);
|
||||
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
|
||||
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`);
|
||||
@@ -173,13 +187,35 @@ function luaCardsTable(cards) {
|
||||
if (c.pierce === true) fields.push('pierce = true');
|
||||
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
|
||||
if (c.draw != null) fields.push(`draw = ${c.draw}`);
|
||||
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
|
||||
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
||||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||||
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
||||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||||
if (c.discard != null) fields.push(`discard = ${c.discard}`);
|
||||
if (c.discardAll === true) fields.push('discardAll = true');
|
||||
if (c.drawPerDiscarded != null) fields.push(`drawPerDiscarded = ${c.drawPerDiscarded}`);
|
||||
if (c.addShiv != null) fields.push(`addShiv = ${c.addShiv}`);
|
||||
if (c.turnStartShiv != null) fields.push(`turnStartShiv = ${c.turnStartShiv}`);
|
||||
if (c.turnStartDraw != null) fields.push(`turnStartDraw = ${c.turnStartDraw}`);
|
||||
if (c.turnStartDiscard != null) fields.push(`turnStartDiscard = ${c.turnStartDiscard}`);
|
||||
if (c.handCostZeroThisTurn === true) fields.push('handCostZeroThisTurn = true');
|
||||
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
|
||||
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
|
||||
if (c.useAllEnergy === true) fields.push('useAllEnergy = true');
|
||||
if (c.xDamagePerEnergy != null) fields.push(`xDamagePerEnergy = ${c.xDamagePerEnergy}`);
|
||||
if (c.xWeakPerEnergy != null) fields.push(`xWeakPerEnergy = ${c.xWeakPerEnergy}`);
|
||||
if (c.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`);
|
||||
if (c.nextTurnDraw != null) fields.push(`nextTurnDraw = ${c.nextTurnDraw}`);
|
||||
if (c.nextTurnKeepBlock === true) fields.push('nextTurnKeepBlock = true');
|
||||
if (c.nextTurnAttackMultiplier != null) fields.push(`nextTurnAttackMultiplier = ${c.nextTurnAttackMultiplier}`);
|
||||
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
|
||||
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
|
||||
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
|
||||
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
|
||||
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
|
||||
if (c.innate === true) fields.push('innate = true');
|
||||
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
|
||||
if (c.sly === true) fields.push('sly = true');
|
||||
if (c.retain === true) fields.push('retain = true');
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) fields.push('exhaust = true');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"localize": true,
|
||||
"displayOrder": 5,
|
||||
"pathConstraints": "//",
|
||||
"revision": 1,
|
||||
@@ -86,7 +86,7 @@
|
||||
{
|
||||
"@type": "MOD.Core.UIGroupComponent",
|
||||
"DefaultShow": false,
|
||||
"GroupOrder": 5,
|
||||
"GroupOrder": 1,
|
||||
"GroupType": 2,
|
||||
"Enable": true
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"@type": "MOD.Core.UIGroupComponent",
|
||||
"DefaultShow": false,
|
||||
"GroupOrder": 1,
|
||||
"GroupOrder": 2,
|
||||
"GroupType": 1,
|
||||
"Enable": true
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"localize": true,
|
||||
"displayOrder": 6,
|
||||
"pathConstraints": "//",
|
||||
"revision": 1,
|
||||
@@ -86,7 +86,7 @@
|
||||
{
|
||||
"@type": "MOD.Core.UIGroupComponent",
|
||||
"DefaultShow": false,
|
||||
"GroupOrder": 6,
|
||||
"GroupOrder": 5,
|
||||
"GroupType": 2,
|
||||
"Enable": true
|
||||
},
|
||||
|
||||
2594
ui/SelectUIGroup.ui
2594
ui/SelectUIGroup.ui
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"@type": "MOD.Core.UIGroupComponent",
|
||||
"DefaultShow": false,
|
||||
"GroupOrder": 2,
|
||||
"GroupOrder": 3,
|
||||
"GroupType": 1,
|
||||
"Enable": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user