Compare commits
50 Commits
a682baa5dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5297922f99 | |||
| e3a75c33a3 | |||
| 0a040837d9 | |||
| da0d74f841 | |||
| 7f30803862 | |||
| 2fdd535939 | |||
| 1100cbeb08 | |||
| e14f19e4ed | |||
| 1a10444136 | |||
| 4d8fa0f40f | |||
| 9fd4b2d2e3 | |||
| 0def604f62 | |||
| a2e4f16402 | |||
| e8ea5e249d | |||
| 66985c2af6 | |||
| 1ecccb4ae7 | |||
| 985225dbd2 | |||
| f0b7704fc1 | |||
| 8628727bcc | |||
| 7db67e3ccd | |||
| 1847e2d9b2 | |||
| 5e2fd5db22 | |||
| 17200d47ec | |||
| 95d6155086 | |||
| de917f812d | |||
| 8a43ca91da | |||
| fc03d58ee7 | |||
| ead73b427e | |||
| d78049182b | |||
| 5f615e30e2 | |||
| 222ed92807 | |||
| 72750f3647 | |||
| 1291c52346 | |||
| 926733dbef | |||
| d7813f9912 | |||
| e6f351420b | |||
| b4a4560678 | |||
| 1e0b91294a | |||
| 8f8f17bd8f | |||
| 478fd1e5f0 | |||
| 0c1dfd3162 | |||
| 8d2e320d60 | |||
| 7b5e79bcf2 | |||
| 39356e5038 | |||
| 4878e5d8cc | |||
| 5f047ae41b | |||
| d83a377865 | |||
| 8292e26726 | |||
| 07ae56909a | |||
| f33018194f |
38
README.md
38
README.md
@@ -44,11 +44,13 @@ git pull
|
||||
```
|
||||
slaymaple/
|
||||
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
|
||||
│ ├── cards.json # 카드 121장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── cards.json # 카드 166장(1~3차 전직 계열별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── enemies.json # 적 18종(일반/정예/보스, 디버프 인텐트 포함)
|
||||
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
|
||||
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
|
||||
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
|
||||
│ ├── characters.json # 클래스별 초상화 RUID
|
||||
│ ├── cards.xlsx # cards.json 왕복 편집용 엑셀(excel_to_cards.bat / cards_to_excel.bat)
|
||||
│ └── camera.json # 맵별 카메라 설정값(줌·오프셋·고정 영역)
|
||||
├── Global/ # 월드 전역 설정 · 공용 모델 · 게임로직
|
||||
│ ├── common.gamelogic # SlayDeckController 부착 지점 (산출물)
|
||||
@@ -71,13 +73,13 @@ slaymaple/
|
||||
│ ├── lobby.map # 로비 허브 맵 (마을 배경, NPC 4종, 전투 없음)
|
||||
│ └── map01.map ~ map05.map # 5막 전투/맵 노드 (공식 배경 + STS풍 우측 배치)
|
||||
├── tools/ # 결정적 생성기·도구 (주체별 폴더, 단일 소스)
|
||||
│ ├── deck/ # gen-slaydeck.mjs(★게임 전체 생성: 카드/덱·전투·맵노드·상점·유물·로비·메뉴 UI + SlayDeckController + common) · gen-cardhand.mjs
|
||||
│ ├── deck/ # gen-slaydeck.mjs(★컨트롤러+common 생성 오케스트레이터) · cb/(codeblock Lua 메서드 20모듈: boot·screens·combat·hand·npc·navigation·layout·shop·reward·soul 등) · lib/(공유 상수·데이터·헬퍼) · legacy/(옛 UI emit 휴면)
|
||||
│ ├── map/ # gen-maps.mjs(맵 배경/타일) · gen-lobby-map.mjs(로비 맵+NPC) · gen-map-encounters.mjs(노드별 몬스터 그룹) · rogue-map.mjs(절차 생성 JS 미러)+test
|
||||
│ ├── camera/ # gen-camera.mjs(맵별 고정 카메라 codeblock)
|
||||
│ ├── player/ # gen-player-lock.mjs(전투맵 입력 잠금) · freeze-turn-player.mjs(모델 이동 정지) · gen-lobby-npc.mjs(LobbyNpc·LobbyMobility codeblock)
|
||||
│ ├── monster/ # gen-combat-monster.mjs(EnemyId 마커) · freeze-turn-monsters.mjs(필드 AI 정지)
|
||||
│ ├── balance/ # sim-balance.mjs(전투 밸런스 몬테카를로 시뮬) · sim-balance.test.mjs
|
||||
│ ├── verify/ # count.mjs·uimap.mjs·cbgap.mjs(산출물 카운트/UIGroup 매핑/재연결 GAP 검증 — 경로 내장)
|
||||
│ ├── verify/ # count·uimap·cbgap(카운트/UIGroup 매핑/재연결 GAP) · cardkinds(카드 kind↔효과) · cbprops(미선언 self 대입) · cbset(메서드 집합 무손실) · diffcheck(바이트동일)
|
||||
│ └── git/ # gitea-pr.mjs(UTF-8 안전 PR 생성/수정/머지 — RULES.md 참조)
|
||||
├── ui/ # UIGroup 7종 — 메이커 저작(Default/Select/Lobby/Run/Deck/Popup/Toast)
|
||||
├── docs/
|
||||
@@ -98,9 +100,9 @@ slaymaple/
|
||||
|
||||
3직업 모두 Slay the Spire 2 차용 + 메이플 IP 재해석. 카드 덱 상세 설계는 [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||
|
||||
- **⚔️ 전사 (탱커, Ironclad 차용)** — **파이터**: 공격을 *연속*으로 내면 콤보가 쌓이고(방어·파워 등 비공격 카드를 쓰면 콤보 리셋) 콤보로 데미지 증가 버프 = 브루저. **페이지**: 위협 디버프로 버티며 방어도 축적 → **바디 슬램(방어 비례 피해)** 카운터. **스피어맨**: 하이퍼바디·아이언월 유지/리치형.
|
||||
- **🗡️ 도적 (단검·독, Silent 차용)** — 표창 난사 / 독 / 교활·버림. **어쌔신**(표창·크리·흡혈) / **시프**(단검 난타·독). *형 구현 완료(Silent 86장)*.
|
||||
- **🔮 법사 (약체·게이지, Defect 차용)** — **위자드(불/독)**: 독을 묻히고 *독 걸린 적에 불 카드 → 추가 데미지*(독뎀 시너지). **위자드(썬/콜)**: 오브로 썬더(다중 공격)·콜드(빙결=취약+피해), 오브 획득·다중 소모 운용. **클레릭**: 오브 없이 회복·버프 + 언데드엔 힐로 공격하는 보조 힐러.
|
||||
- **⚔️ 전사 (탱커, Ironclad 차용, HP80)** — 2차 3종. **파이터**: 공격을 *연속*으로 내면 콤보가 쌓이고(비공격 카드 시 리셋) 콤보로 데미지 증가 = 브루저(콤보 어택·버서크·라이징 어택). **페이지**: 썬더/블리자드 **속성 차지** + 파워 가드. **스피어맨**: 피어스·아이언 월·하이퍼 바디 유지/관통형.
|
||||
- **🗡️ 도적 (단검·독, Silent 차용, HP70)** — 표창 난사 / 독 / 교활·버림. **2차 어쌔신**(표창·독 압박·빠른 마무리)·**시프**(단검·드로우·연계) → **3차 헤르밋**(어쌔신 심화)·**시프 마스터**(시프 심화). 도적 계열만 132장(Silent 완역 포트 + 공식 스킬 아이콘).
|
||||
- **🔮 법사 (약체·게이지, Defect 차용, HP70)** — 2차 3종. **위자드(불·독)**: 독을 묻히고 *독 걸린 적에 불 카드 → 추가 데미지*(독뎀 시너지). **위자드(썬·콜)**: 오브로 썬더(다중 공격)·콜드(빙결=취약+피해), 오브 획득·다중 소모 운용. **클레릭**: 오브 없이 회복·버프 + 언데드엔 힐로 공격하는 보조 힐러.
|
||||
|
||||
## 게임 프레임워크 현황
|
||||
|
||||
@@ -114,13 +116,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~#79)
|
||||
### 구현된 기능 (배포 퀄리티 P1~P15+, PR #34~#104)
|
||||
|
||||
| 영역 | 내용 |
|
||||
|---|---|
|
||||
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`키 **또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
|
||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **121장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**전직**] — 전사→파이터/페이지/스피어맨, 법사→위자드(불·독)/위자드(썬·콜)/클레릭 (2차 3종씩), **도적→어쌔신·시프(2차) → 헤르밋·시프 마스터(3차)**. 전직 시 대표 카드 지급, 전용 카드는 해당 계열 풀만 획득 |
|
||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬/파워=위로 스윕). 카드 **166장** — kind **Attack(59)/Skill(74)/Power(31)/Status(2)**. kind↔효과 정합성 정적 검증(`cardkinds.mjs`). 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||
| **도적 카드 공용 효과** | 카드 효과를 **카드명 하드코딩 대신 `data/cards.json` 공용 필드**로 표현(재사용). **불가침**·**x-cost**(에너지 비례 피해/약화)·드로우 수 비례 데미지·**다음 스킬 반복**·**처치 보상/반복**·카드 설명 **키워드 하이라이트**·드로우 연동(`drawSkillBlock`·`drawPoison`)·독 버스트·랜덤 타깃 등. **Lua + JS 미러 양쪽 구현**. 필드 사전 [`docs/card-effect-fields.md`](docs/card-effect-fields.md) |
|
||||
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 −25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
|
||||
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
|
||||
@@ -131,10 +133,10 @@ slaymaple/
|
||||
| **승천(Ascension)** | A1~A10 누적 모디파이어(적 강화·시작 HP 감소·보상 감소). UserDataStorage 유저별 영구 저장, 런 클리어 시 다음 단계 해금 |
|
||||
| **멀티 act** | **5막** 진행(보스 클리어→다음 막 텔레포트, 맵·인카운터 변경, 적 스케일 `1+(막-1)*0.45`), 5막 클리어 시 런 종료 |
|
||||
| **경제** | 화폐 표기 **메소**(코인 아이콘), 카드/유물/물약 메소 가격. 내부 식별자는 Gold 유지 |
|
||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트(현 84종) |
|
||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트(현 97종) |
|
||||
|
||||
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
|
||||
> ℹ️ 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||
> ℹ️ 도적 계열 카드 132장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**, rogue 1차 + 어쌔신/시프(2차) + 헤르밋/시프 마스터(3차)로 재편. 남은 작업은 카드명 메이플 재서사·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md)·[`docs/bandit-card-audit.md`](docs/bandit-card-audit.md) 참조.
|
||||
|
||||
### 유용한 스크립트 호출
|
||||
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
|
||||
@@ -144,14 +146,14 @@ local c = _EntityService:GetEntityByPath("/common").SlayDeckController
|
||||
c:OnLobbyNpcInteract("run") -- 모험가(런 시작) / "codex"(도감) / "shop"(영혼상점) / "board"(게시판)
|
||||
c:ShowLobby() -- 로비 맵 복귀 + 상태 초기화
|
||||
-- 런
|
||||
c:SelectClass("warrior") -- "warrior" / "bandit" / "magician"
|
||||
c:SelectClass("warrior") -- "warrior" / "rogue" / "magician"
|
||||
c:StartNewGame() -- 캐릭터 선택 → 런 시작(map01 텔레포트)
|
||||
c:PickNode("r1c2") -- 맵 노드 선택(절차 생성 그리드 id) / "boss"
|
||||
c:PlayCard(1) -- 손패 slot 카드 사용
|
||||
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
|
||||
c:PickReward(1) -- 보상 카드 1택(0=건너뛰기)
|
||||
c:BuyCard(1) / c:BuyRelic() / c:BuyPotion() -- 상점 구매(메소)
|
||||
c:SetJob("fighter") -- 전직 (보스 보상 선택 화면)
|
||||
c:SetJob("fighter") -- 전직 (보스 보상 화면) — 2차: fighter/page/spearman·firepoison/icelightning/cleric·assassin/thief, 3차: hermit/thiefmaster
|
||||
c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1
|
||||
```
|
||||
|
||||
@@ -179,7 +181,7 @@ node tools/camera/gen-camera.mjs # 맵별 카메라
|
||||
node tools/player/gen-player-lock.mjs # 전투맵 입력 잠금
|
||||
node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
|
||||
```
|
||||
> 산출물 검증은 내용 출력 없이 카운트만: `node tools/verify/count.mjs <ui|cb|common> <regex>...` (자세한 가드는 [`RULES.md`](RULES.md)).
|
||||
> 산출물 검증은 내용 출력 없이 카운트만: `node tools/verify/count.mjs <ui|cb|common> <regex>...`. 정적 가드 — 카드 kind↔효과 `cardkinds.mjs` · 미선언 self 대입 `cbprops.mjs` · UI 경로 재연결 GAP `cbgap.mjs` · 리팩터 바이트동일 `diffcheck.mjs` (자세한 가드는 [`RULES.md`](RULES.md)).
|
||||
|
||||
---
|
||||
|
||||
@@ -188,6 +190,7 @@ node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
|
||||
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다. **2026-06-17**: UI를 단일 `DefaultGroup`에서 7개 UIGroup(Select/Lobby/Run/Deck 등)으로 분리해 **메이커 저작으로 전환** — 생성기는 더 이상 `.ui`를 만들지 않고, 컨트롤러가 새 UIGroup 경로로 재연결됨(옛 UI emit `hud/*`·`gen-cardhand`는 `tools/deck/legacy/` 휴면). 재연결 무결성은 `tools/verify/cbgap.mjs`(GAP 0)로 검증.
|
||||
|
||||
> ⚠️ **전투 규칙과 맵 생성은 Lua(gen-slaydeck 내장)와 JS 미러(sim-balance/rogue-map)로 이중 구현**입니다. 한쪽을 고치면 반드시 다른 쪽도 동기화하고 테스트하세요(`RULES.md` §6).
|
||||
> ⚠️ **카드 `kind`는 효과와 반드시 일치**해야 합니다 — 데미지=`Attack`, 방어/유틸=`Skill`, 지속효과=`Power`. 안 맞으면 런타임 에러 없이 *사용 불가/무효과 死카드*가 됩니다(2026-06-30 Defend·Rage 사고). 새 효과 필드는 `docs/card-effect-fields.md` 등록 + Lua/JS 양쪽 핸들러 구현. 정적 검증 `node tools/verify/cardkinds.mjs`(`RULES.md` §9). cb Lua 지역변수는 의미명 사용(`RULES.md` §8).
|
||||
|
||||
---
|
||||
|
||||
@@ -195,11 +198,14 @@ 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) (2026-06-17)
|
||||
- [x] **시작 로비 직행 · 캐릭터 선택 UI · 디버그 치트 · map01 로스터 (2026-06-18)** — 게임 시작 시 MainMenu 없이 곧장 로비 진입(MainMenu는 추후 싱글/멀티/종료 메뉴로 재지정); 캐릭터 선택 화면 초상화·직업 설명·선택 테두리·Art 클리핑(MaskComponent) 배선; 디버그 단축키 Ctrl+Shift+C(카드 picker)·Ctrl+Shift+E(체력+에너지 전체 회복); map01 몬스터 18종 로스터(랜덤 행동)
|
||||
- [ ] **도적 카드명 재서사·설명 한글화** — Silent 직역 카드명을 어쌔신/시프 메이플 스킬명으로 재서사(아이콘은 적용 완료), 2차 전직 설명 한글화
|
||||
- [x] **컨트롤러 관심사별 모듈 분리 · 코드 규칙 (2026-06-26, #94)** — SlayDeckController를 `cb/*.mjs` 20모듈로 분리(런타임은 단일 codeblock 유지), 변수명 의미화, 검증 `cbset.mjs`(집합 무손실)·`cbprops.mjs`(미선언 self)
|
||||
- [x] **도적 계열 대개편 + 3차 전직 · 카드 공용 효과 (2026-06-23~30, #82~#99)** — Silent 포트를 rogue 1차 + 어쌔신/시프(2차) + 헤르밋/시프 마스터(3차)로 재편, 카드 효과를 카드명 하드코딩 대신 `cards.json` 공용 필드로(`docs/card-effect-fields.md`), 카드 **166장**
|
||||
- [x] **코드리뷰 버그수정 + kind↔효과 규칙 (2026-06-29~30, #96·#102)** — 게임버그 6·시뮬 충실도 3·설명 2 수정(Defend kind Attack→Skill·Rage Power→Attack 포함), kind↔효과 정적 검증 `cardkinds.mjs`, 카드 왕복 편집 엑셀(#93)
|
||||
- [ ] **도적 카드명 재서사·설명 한글화** — Silent 직역 카드명을 어쌔신/시프 메이플 스킬명으로 재서사(아이콘은 적용 완료), 2·3차 전직 설명 한글화
|
||||
- [ ] **런 이어하기** — 진행 중 런 직렬화 저장(UserDataStorage 확장, 메뉴 "이어하기" 활성화)
|
||||
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
|
||||
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)
|
||||
- [ ] **3차 전직** — 후반 막 보상으로 확장
|
||||
- [ ] **3차 전직 — 전사·법사 확장** (도적은 완료: 헤르밋·시프 마스터), 후반 막 보상으로
|
||||
- [ ] **궁수 등 추가 클래스** — 캐릭터 선택 슬롯 확장
|
||||
- [ ] **정밀 밸런싱** — 첫 인카운터 승률 완화·직업별 카드 효율 튜닝(`sim-balance.mjs` 리포트 기반)
|
||||
- [ ] **상점 보장 규칙** — 막당 상점 최소 1회 등장
|
||||
|
||||
19
RULES.md
19
RULES.md
@@ -23,7 +23,7 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
|
||||
|
||||
- `.claude/settings.json`의 permissions.deny가 위 파일의 Read/Edit/Write 도구 사용을 차단한다 (이 저장소를 열면 자동 적용). deny는 **glob** — `ui/*.ui`·`map/*.map`·`RootDesk/MyDesk/*.codeblock`·`Global/common.gamelogic`·`Global/SectorConfig.config`. 따라서 **메이커 저작 codeblock/UI**(`Monster`·`MonsterAttack`·`PlayerAttack`·`PlayerHit`·`UIPopup`·`UIToast`.codeblock, **그리고 모든 `ui/*.ui`** — UI는 6개 UIGroup으로 메이커 저작)**도** Read/Edit 금지 — 이들은 생성기가 없으니 **메이커에서** 편집한다(텍스트 도구로 X). codeblock은 한 줄짜리 JSON이라 Read 시 토큰 폭발.
|
||||
- **게임 로직 수정** = `tools/deck/gen-slaydeck.mjs`(오케스트레이터) + `tools/deck/cb/*.mjs`(codeblock Lua) 또는 `data/*.json`(데이터) 수정 → 재생성(`SlayDeckController.codeblock`+`common.gamelogic`만, **`.ui` 미접근**) → 통째로 커밋. **UI 수정 = 메이커에서**(생성기는 UI를 안 만든다).
|
||||
- **codeblock 메서드(Lua)는 기능별 모듈** `tools/deck/cb/*.mjs`(boot·state·combat·hand·deckview·items·map·shop 등 17종). **공유분**: 상수·데이터·lua 테이블 = `tools/deck/lib/{ui-helpers,data,codeblock}.mjs`(cb가 import — `MAX_MONSTERS`=4 등). prop 103개는 오케스트레이터 `writeCodeblocks`에 유지. 특정 메서드 수정은 `cb/<name>.mjs`만(의존: orchestrator→cb→lib 단방향). **cb 모듈은 원본 메서드 순서 보존이 바이트동일 조건**. **UI emit(옛 `hud/*.mjs` 15종·`gen-cardhand.mjs`)은 `tools/deck/legacy/`로 이관 — 휴면(생성기 미사용)**: UI가 메이커 저작이라 생성기가 안 만든다. (롤백용 `legacy/upsert-ui.mjs`는 직접 실행 시에만 옛 `DefaultGroup.ui`를 재생성.)
|
||||
- **codeblock 메서드(Lua)는 관심사별 모듈** `tools/deck/cb/*.mjs`(boot·screens·npc·navigation·layout·combat·hand·deckview·items·map·shop 등 20종 — 화면전환=`screens`·NPC=`npc`·포지션=`navigation`(월드 텔레포트)/`layout`(UI 슬롯 배치). 새 메서드는 관심사에 맞는 모듈에 작성하고, 한 모듈이 비대해지면 분할한다. 횡단 관심사를 한 모듈에 몰아넣지 않는다). **공유분**: 상수·데이터·lua 테이블 = `tools/deck/lib/{ui-helpers,data,codeblock}.mjs`(cb가 import — `MAX_MONSTERS`=4 등). prop 103개는 오케스트레이터 `writeCodeblocks`에 유지. 특정 메서드 수정은 `cb/<name>.mjs`만(의존: orchestrator→cb→lib 단방향). **cb 모듈은 원본 메서드 순서 보존이 바이트동일 조건**. **UI emit(옛 `hud/*.mjs` 15종·`gen-cardhand.mjs`)은 `tools/deck/legacy/`로 이관 — 휴면(생성기 미사용)**: UI가 메이커 저작이라 생성기가 안 만든다. (롤백용 `legacy/upsert-ui.mjs`는 직접 실행 시에만 옛 `DefaultGroup.ui`를 재생성.)
|
||||
- 리팩터 시 **출력 바이트-동일 검증**: `node tools/deck/gen-slaydeck.mjs` 후 `node tools/verify/diffcheck.mjs [ref]`(워킹트리 vs ref(기본 HEAD) 줄바꿈 정규화 비교 — 산출물 경로를 명령줄에 노출 안 해 deny 회피). 산출물 ` M`은 보통 autocrlf churn이니 `git checkout --`로 복원.
|
||||
- **UI 전면 메이커 저작 (2026-06-17~)**: 단일 `DefaultGroup`을 7개 UIGroup으로 분리 — `DefaultGroup`(MainMenu+월드조작), `SelectUIGroup`(charselect/job), `LobbyUIGroup`(lobby/board/soulshop), `RunUIGroup`(combat/map/shop/rest/treasure/reward/cardhand/deck), `DeckUIGroup`(덱 도감), `PopupGroup`·`ToastGroup`. 컨트롤러(`cb/*.mjs`)는 엔티티 **경로**(`/ui/<UIGroup>/<Hud>/...`)로 텍스트·이미지·표시숨김·상태기반 위치/크기/색을 **런타임 주입**(레이아웃=메이커, 내용=컨트롤러 — 메이커가 이 경로 유지 필수). 몬스터 슬롯 = `RunUIGroup/CombatHud/MonsterStatus{1..4}`(자식 Name·Hp·Intent·HpBarFill·Buffs·BlockBadge·TargetMarker; TargetFrame 없음). **부트 흐름**: `OnBeginPlay`→MainMenu→(`MainMenu/NewGameButton`)→로비→run NPC(`OnLobbyNpcInteract` id=="run")→charselect→런. **재연결 검증**: `node tools/verify/cbgap.mjs`(cb 참조 경로↔.ui GAP 0이어야) + 재생성 후 `git status -- ui/` 변경 0(생성기 .ui 미접근 증명). 섹션→UIGroup 일괄 remap 마이그레이션은 `tools/deck/reconnect-ui-paths.mjs`(멱등). UIGroup별 .ui 분포 확인은 `tools/verify/uimap.mjs`.
|
||||
- **머지 충돌(gen-slaydeck.mjs)**: 다른 브랜치가 단일체를 수정해 충돌나면, 그쪽 버전(`git checkout --theirs tools/deck/gen-slaydeck.mjs`)을 취해 **콘텐츠 마커 기반으로 재모듈화**(라인인덱스 X — 줄 추가에 안전·export 이름 자동 파생·`const x=[]` 직전 전문 상수 walk-back 포함) 후 `node tools/verify/diffcheck.mjs origin/main`으로 ui·codeblock 바이트-동일 확인(손실 0 증명). codeblock 메서드·patchCommon은 오케스트레이터 잔류라 그쪽 변경은 자동 보존됨.
|
||||
@@ -65,6 +65,8 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
- 제목/본문은 UTF-8 spec JSON 파일로 작성 후 `create <spec.json>` / `merge <번호>`.
|
||||
- PR 제목과 본문은 한국어로 작성한다.
|
||||
- 산출물 재생성 커밋은 소스 변경 커밋과 분리하거나, 메시지에 "산출물 재생성"을 명시.
|
||||
- **PR 머지 후 브랜치 삭제**: 머지된 `feature/*`·`docs/*` 브랜치는 로컬·원격 모두 삭제한다. 삭제 전 `git merge-base --is-ancestor origin/<브랜치> origin/main`로 완전 머지 확인(종료코드 0=완전 머지 → 삭제 가능). main에 없는 커밋이 남은 브랜치와 `codex/*` 등 작업 중 브랜치는 보존한다.
|
||||
- **⚠️ main 머지 충돌 시 "머지 전체 revert" 금지 (타인 작업 유실 방지)**: 작업 브랜치에 `git merge main`(또는 origin/main) 했다가 충돌·문제가 나도 **그 머지 커밋을 통째로 `git revert` 하지 말 것.** main에 먼저 들어간 타인의 작업이 collateral로 전부 사라진다. 대신 **소스 충돌만 해소**하고 산출물(codeblock 등)은 **재생성**한다. 충돌이 산출물뿐이면 `git checkout --theirs`/재생성으로 끝. (2026-06-30 사고: codex `#98/#99`가 main 머지 후 그 머지를 revert해 `#96`의 버그수정 11개를 전부 날림 → 다시 재통합해야 했다. 복구는 `git diff <pre-merge> <내브랜치> -- <소스> | git apply --3way` 로 소스만 재적용 후 재생성하면 codex 변경과 충돌 없이 양립.)
|
||||
|
||||
## 5. 메이커(MSW) 연동 주의
|
||||
|
||||
@@ -86,3 +88,18 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
- UI 텍스트에서는 정수값인 숫자에 `.0`을 붙이지 않는다. `1.0/1.0`이 아니라 `1/1`처럼 표시한다.
|
||||
- 생성기 내 Lua UI 코드에서 number 또는 숫자 문자열을 텍스트에 붙일 때는 `FormatNumber` 같은 포맷 헬퍼를 우선 사용한다.
|
||||
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.
|
||||
|
||||
## 8. codeblock 변수명
|
||||
|
||||
- cb(`tools/deck/cb/*.mjs`)의 Lua 지역변수는 **의미가 드러나는 이름**으로 작성한다(`e`→`entity`, `n`→`count`, `m`→`monster`, `lp`→`localPlayer`, `s`→`soulPoints`, `tr`→`transform`). `a`/`b`/`c` 같은 무의미 단일문자 변수는 금지.
|
||||
- 단, 순수 반복 인덱스 `i`/`j`/`r`/`c`는 관용상 허용한다.
|
||||
- 새 cb 메서드를 작성하거나 기존 메서드를 손댈 때 이 규칙을 적용한다(대규모 일괄 개명은 별도 작업으로).
|
||||
|
||||
## 9. 카드 데이터 규칙 (kind ↔ 효과 일치)
|
||||
|
||||
새 카드를 추가/수정할 때 `data/cards.json`의 `kind`는 카드의 효과·사용 메커니즘과 **반드시 일치**해야 한다. 안 맞으면 카드가 **사용 불가**거나 **재생 시 아무 효과 없는 死카드**가 된다(런타임 에러도 안 나고 sim 테스트도 못 잡음 — 정적 검증 필수).
|
||||
|
||||
- **`ResolveCardDrop` 사용 라우팅이 kind별로 다름**: `Attack`=몬스터 위에 드롭(`FindMonsterAtTouch>0` 필요)·`Skill`/`Power`=위로 스윕(`ui.y>-180`)·`Status`=unplayable. → **block·디버프·드로우 등 유틸만 있고 데미지가 없는 카드를 `Attack`으로 두면 위로 스윕으로 사용할 수 없다**(2026-06-30 아이언 바디 사고: block만 있는 방어카드가 Attack이라 전사 시작덱 4장이 먹통 → Skill로 수정).
|
||||
- **`PlayCard`의 `Power` 분기는 PlayerPowers 등록만 하고 `damage`/`aoe`를 무시**한다. → 데미지 카드=`Attack`, 방어/유틸=`Skill`, 지속효과=`Power`(단 `powerEffect` 또는 지속/온플레이 power 필드 — `turnStart*`·`dex`·`thorns`·`intangible`·`attackPoison`·`drawDamage`·`shivX`·`cardPlayed*` 등 — 이 있어야 함). Power인데 power 효과 필드가 없으면 死카드(2026-06-30 분노 사고: `damage:4/aoe`만 있어 Power 분기서 무시됨 → kind Power→Attack으로 기능화).
|
||||
- 새 효과 필드는 `docs/card-effect-fields.md` 사전에 등록하고 Lua(`tools/deck/cb/*.mjs`) + JS 미러(`tools/balance/sim-balance.mjs`) **양쪽에 핸들러 구현**(§6). 한쪽만 있으면 게임↔시뮬 드리프트.
|
||||
- **검증: `node tools/verify/cardkinds.mjs`** — kind↔효과 위반(Attack-무데미지 / Power-무효과 / 미지원 kind)을 정적 검출(이상 0 = exit 0). 카드 추가/수정 후 반드시 실행. (관련 가드: 미선언 `self.X` = `cbprops.mjs`, UI 경로 = `cbgap.mjs`, 이중구현 = `sim-balance.test.mjs`.)
|
||||
|
||||
File diff suppressed because one or more lines are too long
7
cards_to_excel.bat
Normal file
7
cards_to_excel.bat
Normal file
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
setlocal
|
||||
chcp 65001 >nul
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" export
|
||||
echo.
|
||||
echo Press any key to close this window.
|
||||
pause >nul
|
||||
@@ -10,7 +10,7 @@
|
||||
"unique": "f5def2e8022b4e59a17d3c16414034fe",
|
||||
"legend": "cff71f2e472041ce80c6fbd296f42e2d"
|
||||
},
|
||||
"bandit": {
|
||||
"rogue": {
|
||||
"normal": "9487b06867bc46269ed1d855420f457f",
|
||||
"unique": "b3081fb2fb1445fa90b12b01481a78ef",
|
||||
"legend": "c357d2daf31a489d95b8fa47e50dd879"
|
||||
@@ -25,11 +25,13 @@
|
||||
"firepoison": "magician",
|
||||
"icelightning": "magician",
|
||||
"cleric": "magician",
|
||||
"bandit": "bandit",
|
||||
"curse": "bandit",
|
||||
"shiv": "bandit",
|
||||
"poisoner": "bandit",
|
||||
"trickster": "bandit"
|
||||
"curse": "rogue",
|
||||
"shiv": "rogue",
|
||||
"rogue": "rogue",
|
||||
"assassin": "rogue",
|
||||
"hermit": "rogue",
|
||||
"thief": "rogue",
|
||||
"thiefmaster": "rogue"
|
||||
},
|
||||
"rewardWeights": {
|
||||
"normal": 70,
|
||||
|
||||
1016
data/cards.json
1016
data/cards.json
File diff suppressed because it is too large
Load Diff
BIN
data/cards.xlsx
Normal file
BIN
data/cards.xlsx
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"portraits": {
|
||||
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"magician": "3b9ea1f066a744bb859df47fef817277",
|
||||
"bandit": "efa920e58d31426486ef974106e7dc8b"
|
||||
"rogue": "efa920e58d31426486ef974106e7dc8b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# 공격 적중 독
|
||||
# 공격 중독
|
||||
|
||||
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
|
||||
|
||||
동작:
|
||||
|
||||
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
|
||||
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
|
||||
- `Envenom` 같은 카드가 이 필드를 사용합니다.
|
||||
`attackPoison`은 전투 동안 유지되는 공용 카드 효과 필드입니다.
|
||||
|
||||
- 공격 카드가 실제 체력 피해를 주면 대상에게 지정된 수치만큼 중독을 부여합니다.
|
||||
- 광역 공격은 피해를 받은 각 적에게 중독을 부여합니다.
|
||||
- 현재 Thief Master와 Hermit의 `베놈`이 이 효과를 사용합니다.
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
# Bandit Card Audit
|
||||
# 도적 카드 구성 및 밸런스 기록
|
||||
|
||||
Current status of bandit cards and shared effect hooks.
|
||||
도적 계보의 카드 역할, 직업 이동 금지 대상, 공용 효과 필드를 정리합니다.
|
||||
|
||||
## Implemented
|
||||
## 직업별 컨셉
|
||||
|
||||
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `BouncingFlask`, `Accuracy`, `PhantomBlades`, `Speedster`, `CorrosiveWave`, `Tracking`, `FanOfKnives`, `Strangle`, `Mirage`, `Accelerant`, `MasterPlanner`, `Outbreak`, `EscapePlan`, `HandTrick`, `NoxiousFumes`, `Pinpoint`, `TheHunt`, `Murder`, `Malaise`, `BladeOfInk`, `KnifeTrap`, `BulletTime`, `Envenom`, `SerpentForm`, `WraithForm`, `Skewer`, `Ricochet`, `Anticipate`, `PiercingWail`, `Expose`, `UpMySleeve`, `EchoingSlash`, `BubbleBubble`
|
||||
- `rogue`: 시작 카드, 1차 스킬, 기초 공격·회피·방어
|
||||
- `thief`: 단검 난타, 교활, 버리기, 중독의 시작
|
||||
- `thiefmaster`: 교활·버리기 연계 완성, 광역 난타, 중독 증폭
|
||||
- `assassin`: 표창 생성, 표창 연속 공격, 표창 비용·피해 보조
|
||||
- `hermit`: 표창 보존·광역화·지속 생성 등 표창 빌드 완성
|
||||
|
||||
Shared hooks already in use:
|
||||
Rogue 단계에서도 분기 방향을 미리 경험할 수 있도록 약한 입문 카드를 유지합니다.
|
||||
|
||||
- 중독: `PoisonedStab`
|
||||
- 표창: `LeadingStrike`
|
||||
- 교활: `Untouchable`
|
||||
|
||||
## 스킬 카드 고정
|
||||
|
||||
실제 직업 스킬을 바탕으로 추가한 아래 카드는 다른 차수나 계열로 이동하지 않습니다.
|
||||
|
||||
- Rogue: `DoubleStab`, `LuckySeven`, `Haste`, `DarkSight`, `FlashJump`, `NimbleBody`
|
||||
- Thief: `SavageBlow` 포함 9장
|
||||
- Thief Master: `EdgeCarnival` 포함 11장
|
||||
- Assassin: `ShurikenBurst` 포함 10장
|
||||
- Hermit: `TripleThrow` 포함 9장
|
||||
|
||||
나머지 비스킬 카드는 컨셉에 맞춰 상위 직업으로 이동할 수 있습니다. 상위 직업은 하위 직업 카드를 함께 사용하므로, 이동은 해당 분기의 보상 풀을 제한하는 역할을 합니다.
|
||||
|
||||
## 공용 효과 필드
|
||||
|
||||
- `poison`, `innate`, `playableWhenDrawPileEmpty`
|
||||
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne`
|
||||
@@ -17,6 +39,34 @@ Shared hooks already in use:
|
||||
- `firstCardDamageBonus`
|
||||
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`, `skillSlyOnPlay`, `extraPoisonTicks`, `poisonApplicationBurstEvery`, `poisonApplicationBurstDamage`
|
||||
|
||||
## Open questions
|
||||
## 중복 제거 및 보정
|
||||
|
||||
None at the moment.
|
||||
- 삭제: `Mirage`, `Accuracy`, `PhantomBlades`, `Adrenaline`, `Afterimage`, `Accelerant`, `Envenom`, `Tracking`
|
||||
- 이유: 상위 직업 스킬 카드와 효과가 같거나, 비용 대비 열세라 별도 선택지가 되지 못함
|
||||
- `Anticipate`: 턴 종료 시 얻은 민첩을 잃도록 실제 효과와 설명을 일치시킴
|
||||
- `Backstab`, `Assassinate`, `TheHunt`, `PiercingWail`: 설명에 있던 소멸을 실제 필드에 반영
|
||||
- 2차 지급: Thief `DaggerAcceleration`, Assassin `JavelinAcceleration`
|
||||
- 3차 지급: Thief Master `Venom`, Hermit `SpiritJavelin`
|
||||
|
||||
## 카드 효율 검증
|
||||
|
||||
`node tools/balance/card-efficiency.mjs --runs 1000`으로 도적 계열 카드 전체를 검증합니다.
|
||||
|
||||
- 각 직업의 기준 덱에서 같은 종류의 카드 한 장을 교체하고 동일 시드로 반복 전투합니다.
|
||||
- 승률, 승리 시 체력, 전투 턴을 합친 점수를 같은 직업·희귀도 중앙값과 비교합니다.
|
||||
- 0코스트 에너지 생성, 재사용 가능한 영구 능력치, 저비용 2배 증폭처럼 자동 플레이가 놓치기 쉬운 구조도 별도로 검사합니다.
|
||||
- 교활, 조건부 중독, 카드 보존처럼 플레이 순서 의존성이 큰 효과는 자동 시뮬레이션 하위권만으로 상향하지 않습니다.
|
||||
|
||||
2026-07-01 검증 결과 구조적 위험은 0장입니다. 주요 조정은 `독맥 터뜨리기`, `메아리 칼자국`, `소리 없는 제압`, `그리드`, `그림자 속도전`, `스틸`, 두 계열의 `피지컬 트레이닝`, `마크 오브 어쌔신`, `자벨린 액셀레이션`, `비장의 패`에 반영했습니다.
|
||||
|
||||
비스킬 카드 78장의 메이플풍 표시 이름은 `docs/rogue-card-names.md`에서 관리합니다. 메이플 원본 스킬 카드 45장의 이름은 변경하지 않습니다.
|
||||
|
||||
## 5섹션 캠페인 검증
|
||||
|
||||
`node tools/balance/rogue-campaign.mjs --runs 5000 --reward-min 5`로 전체 런을 검증합니다.
|
||||
|
||||
- 섹션마다 일반전 4회, 엘리트 1회, 보스 1회를 진행합니다.
|
||||
- 1섹션은 Rogue, 2섹션은 2차 직업, 3~5섹션은 3차 직업 카드 풀을 사용합니다.
|
||||
- 실제 카드 보상 확률, 전직 지급 카드, 시작·획득 유물, 체력 유지와 휴식 회복을 반영합니다.
|
||||
- 몬스터 배율은 `1.00 → 1.075 → 1.15 → 1.30 → 1.45`이며 런타임과 시뮬레이터가 같은 공용 상수를 사용합니다.
|
||||
- 5,000회 결과: Thief Master 완주 2.9%, Hermit 완주 3.6%. 자동 플레이와 일부 공격형 유물 미구현을 감안한 보수적 결과입니다.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
- 공용으로 표현 가능한 효과는 카드 전용 분기로 만들지 않는다.
|
||||
- 같은 의미의 효과는 같은 필드 이름을 쓴다.
|
||||
- 문서는 카드별 상태표와 공용 필드 사전을 분리해서 유지한다.
|
||||
- 카드 `kind`는 효과와 맞춘다 — 데미지 카드=`Attack`, block·유틸만 있으면=`Skill`, 지속효과=`Power`(`powerEffect` 또는 power 필드 필수). 안 맞으면 사용 불가/死카드가 된다(Power 분기는 damage/aoe 무시, Attack은 몬스터 드롭 라우팅).
|
||||
- 새 효과 필드는 Lua(`cb/*.mjs`)와 JS 미러(`tools/balance/sim-balance.mjs`) 양쪽에 구현한다(한쪽만 = 게임↔시뮬 드리프트).
|
||||
|
||||
## 응답 원칙
|
||||
|
||||
@@ -29,3 +31,9 @@
|
||||
- 바뀐 점과 남은 점만 말한다.
|
||||
- 불필요한 재설명은 줄인다.
|
||||
|
||||
## 검증·통합 원칙
|
||||
|
||||
- 카드/cb 변경 후 검증 스위트를 돌린다: `node tools/verify/cardkinds.mjs`(kind↔효과)·`cbprops.mjs`(미선언 `self.X` 필드)·`cbgap.mjs`(UI 경로) + `node --test tools/balance/sim-balance.test.mjs`(이중구현 미러). 이상 0을 확인한 뒤 산출물을 갱신한다.
|
||||
- 작업 브랜치에 `main`을 머지했다가 충돌·문제가 나도 그 머지 커밋을 통째로 `git revert`하지 않는다 — main에 먼저 들어간 타인 작업이 collateral로 사라진다(2026-06-30 `#98/#99`가 `#96` 11개 수정을 이렇게 날린 사고). 소스 충돌만 해소하고 산출물(codeblock 등)은 재생성한다.
|
||||
- 하네스 규칙의 최종 권위는 `RULES.md`(§1 산출물 읽기/수정 금지·§4 git/PR·§6 이중구현 동기화·§9 카드 kind)이고, codex 전용 하드룰은 `docs/codex-working-rules.md`다. 작업 전 둘 다 따른다.
|
||||
|
||||
|
||||
11
docs/codex-working-rules.md
Normal file
11
docs/codex-working-rules.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Codex Working Rules
|
||||
|
||||
1. 사용자가 특정 클래스만 수정하라고 했으면 그 클래스 외의 데이터, 설명문, 밸런스 문구는 건드리지 않는다.
|
||||
2. 기존 한글 텍스트는 요청이 없으면 임의로 바꾸지 않는다.
|
||||
3. 전직 구조를 바꿀 때는 실제 직업명만 사용한다. 임의의 내부 분류명이나 새 직업명을 사용자-facing 구조에 추가하지 않는다.
|
||||
4. 대량 치환 전에 수정 대상 파일과 범위를 먼저 확인하고, 원본 문자열이 깨진 상태면 치환 작업을 진행하지 않는다.
|
||||
5. 생성기 파일을 크게 수정할 때는 `node --check`와 생성기 실행으로 문법을 먼저 검증한 뒤 산출물을 갱신한다.
|
||||
6. 작업 브랜치에 `main`을 머지했다가 충돌·문제가 나도 **그 머지 커밋을 통째로 `git revert`하지 않는다** — main에 먼저 들어간 타인 작업이 collateral로 사라진다(2026-06-30 `#98/#99`가 `#96`의 버그수정 11개를 이렇게 전부 날림). 소스 충돌만 해소하고 산출물(codeblock 등)은 재생성한다. (RULES §4)
|
||||
7. 카드 `kind`는 효과와 일치시킨다 — 데미지 카드=`Attack`, block·유틸만 있으면=`Skill`, 지속효과=`Power`(`powerEffect` 또는 power 필드 필수). 안 맞으면 사용 불가/死카드가 된다(2026-06-30 아이언 바디=Attack인데 block만, 분노=Power인데 damage만 → 둘 다 먹통). 카드 추가/수정 후 `node tools/verify/cardkinds.mjs`로 검증(이상 0 = exit 0). (RULES §9)
|
||||
8. 카드/cb 변경 후 검증 스위트를 돌린다: `node tools/verify/cardkinds.mjs`(kind↔효과)·`cbprops.mjs`(미선언 `self.X` 필드)·`cbgap.mjs`(UI 경로) + `node --test tools/balance/sim-balance.test.mjs`(이중구현 미러). 새 효과 필드는 Lua(`cb/*.mjs`)와 JS 미러(`tools/balance/sim-balance.mjs`) **양쪽**에 구현(한쪽만 = 게임↔시뮬 드리프트). (RULES §6)
|
||||
9. 하네스 규칙의 권위는 `RULES.md`다 — 작업 전 RULES.md(§1 산출물 읽기/수정 금지·§4 git/PR·§6 이중구현 동기화·§9 카드 kind)를 읽고 따른다.
|
||||
97
docs/rogue-card-names.md
Normal file
97
docs/rogue-card-names.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 도적 비스킬 카드 이름
|
||||
|
||||
메이플스토리 원본 스킬을 바탕으로 만든 카드 45장은 이름을 고정합니다.
|
||||
아래 78장은 다른 직업의 스킬명을 점유하지 않도록 도적 계열의 독자적인 이름으로 변경했습니다.
|
||||
|
||||
## Rogue
|
||||
|
||||
- `Neutralize`: 무력화 -> 커닝식 견제
|
||||
- `SilentStrike`: 타격 -> 초보 도적의 칼끝
|
||||
- `Survivor`: 생존자 -> 골목길 생존술
|
||||
- `SilentDefend`: 수비 -> 낡은 가죽 방패
|
||||
- `Slice`: 칼질 -> 짧은 단검질
|
||||
- `PoisonedStab`: 독 찌르기 -> 초록 독단검
|
||||
- `SuckerPunch`: 불의의 일격 -> 골목 기습
|
||||
- `LeadingStrike`: 선제 타격 -> 초보 표창 던지기
|
||||
- `Anticipate`: 예측 -> 럭키 예감
|
||||
- `Deflect`: 튕겨내기 -> 단검 쳐내기
|
||||
- `Backflip`: 공중제비 -> 커닝 곡예
|
||||
- `DodgeAndRoll`: 구르기 -> 골목 구르기
|
||||
- `Untouchable`: 범접 불가 -> 연막 속 숨기
|
||||
- `Backstab`: 배신 -> 그림자 등찌르기
|
||||
- `EscapePlan`: 탈출구 -> 비상용 연막탄
|
||||
|
||||
## Thief
|
||||
|
||||
- `DaggerSpray`: 단검 분사 -> 단검비
|
||||
- `DaggerThrow`: 단검 투척 -> 비도 투척
|
||||
- `FollowThrough`: 완수 -> 연달아 찌르기
|
||||
- `FlickFlack`: 재주넘기 -> 커닝 난무
|
||||
- `Prepared`: 예비 -> 비장의 패
|
||||
- `PiercingWail`: 귀를 찢는 비명 -> 골목의 살기
|
||||
- `DeadlyPoison`: 맹독 -> 맹독 조제
|
||||
- `Snakebite`: 뱀 물기 -> 독니 단검
|
||||
- `PreciseCut`: 정밀한 베기 -> 급소 절개
|
||||
- `Finisher`: 마무리 -> 마지막 칼끝
|
||||
- `MementoMori`: 메멘토 모리 -> 사신의 장부
|
||||
- `Strangle`: 목 조르기 -> 그림자 올가미
|
||||
- `Dash`: 돌진 -> 뒷골목 돌파
|
||||
- `CalculatedGamble`: 계산된 도박 -> 메소 건 승부
|
||||
- `Expose`: 들춰내기 -> 약점 들추기
|
||||
- `Acrobatics`: 곡예 -> 지붕 위 곡예
|
||||
- `HandTrick`: 손기술 -> 재빠른 손놀림
|
||||
- `Expertise`: 전문성 -> 노련한 단검술
|
||||
- `BubbleBubble`: 차오르는 독 -> 독액 농축
|
||||
- `Blur`: 흐릿함 -> 흐린 잔영
|
||||
- `LegSweep`: 다리 걸기 -> 발목 베기
|
||||
- `Reflex`: 반사신경 -> 찰나의 반응
|
||||
- `Tactician`: 전략가 -> 골목길 책략
|
||||
- `WellLaidPlans`: 괜찮은 전략 -> 빈틈없는 작전
|
||||
- `Footwork`: 발놀림 -> 사뿐한 발놀림
|
||||
- `NoxiousFumes`: 유독 가스 -> 숨막히는 독연기
|
||||
|
||||
## Thief Master
|
||||
|
||||
- `BouncingFlask`: 탄성 플라스크 -> 통통 독병
|
||||
- `Haze`: 아지랑이 -> 보랏빛 독연기
|
||||
- `Outbreak`: 발병 -> 독맥 터뜨리기
|
||||
- `Speedster`: 스피드스터 -> 그림자 속도전
|
||||
- `GrandFinale`: 대단원의 막 -> 커닝의 대단원
|
||||
- `Assassinate`: 암살 -> 어둠 속 급소
|
||||
- `EchoingSlash`: 메아리 참격 -> 메아리 칼자국
|
||||
- `Murder`: 살해 -> 쌓여가는 살의
|
||||
- `Malaise`: 불쾌 -> 기운 빼는 독
|
||||
- `ShadowStep`: 그림자 걸음 -> 그림자 발자국
|
||||
- `Shadowmeld`: 그림자 은신 -> 연막 속 은신
|
||||
- `CorrosiveWave`: 부식성 파도 -> 부식 독물결
|
||||
- `Burst`: 폭주 -> 연속 술수
|
||||
- `KnifeTrap`: 칼날 함정 -> 숨은 칼날덫
|
||||
- `BulletTime`: 불릿 타임 -> 멈춘 듯한 순간
|
||||
- `Nightmare`: 악몽 -> 검은 꿈
|
||||
- `ToolsOfTheTrade`: 작업 도구 -> 도적의 연장통
|
||||
- `MasterPlanner`: 설계의 대가 -> 작전의 달인
|
||||
- `SerpentForm`: 구렁이의 형상 -> 독사의 몸놀림
|
||||
- `Abrasive`: 연마 -> 거친 숫돌질
|
||||
- `Suppress`: 진압 -> 소리 없는 제압
|
||||
- `WraithForm`: 유령의 형상 -> 유령 같은 몸놀림
|
||||
|
||||
## Assassin
|
||||
|
||||
- `Ricochet`: 도탄 -> 통통 튀는 표창
|
||||
- `BladeDance`: 검무 -> 표창 별무리
|
||||
- `CloakAndDagger`: 망토와 단검 -> 망토 속 별
|
||||
- `Skewer`: 꼬챙이 -> 꿰뚫는 표창
|
||||
- `Flechettes`: 프레췌 -> 표창 셈법
|
||||
- `Pounce`: 덮치기 -> 어둠을 가르는 도약
|
||||
- `Predator`: 천적 -> 표창 끝의 추격
|
||||
- `Pinpoint`: 정밀 사격 -> 한 점 겨냥
|
||||
- `HiddenDaggers`: 숨겨진 표창 -> 숨겨둔 표창
|
||||
- `UpMySleeve`: 비책 -> 소매 속 표창
|
||||
- `InfiniteBlades`: 무한의 검날 -> 끝없는 표창통
|
||||
- `TheHunt`: 사냥 -> 커닝 현상금
|
||||
- `StormOfSteel`: 강철의 폭풍 -> 쇠별 폭풍
|
||||
|
||||
## Hermit
|
||||
|
||||
- `BladeOfInk`: 잉크 칼날 -> 먹빛 표창
|
||||
- `FanOfKnives`: 칼날 부채 -> 사방 표창비
|
||||
7
excel_to_cards.bat
Normal file
7
excel_to_cards.bat
Normal file
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
setlocal
|
||||
chcp 65001 >nul
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\cards\cards_excel.ps1" import
|
||||
echo.
|
||||
echo Press any key to close this window.
|
||||
pause >nul
|
||||
246
tools/balance/card-efficiency.mjs
Normal file
246
tools/balance/card-efficiency.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
PLAYER_HP,
|
||||
loadData,
|
||||
mulberry32,
|
||||
simulateCombat,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
const ROGUE_CLASSES = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
|
||||
|
||||
const CONTEXT_DECKS = {
|
||||
rogue: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'DoubleStab', 'Backflip',
|
||||
],
|
||||
thief: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'SavageBlow', 'DaggerAcceleration',
|
||||
'DeadlyPoison', 'Acrobatics',
|
||||
],
|
||||
thiefmaster: [
|
||||
'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend',
|
||||
'Survivor', 'SavageBlow', 'DaggerAcceleration', 'DeadlyPoison',
|
||||
'Acrobatics', 'EdgeCarnival', 'PickPocket', 'Venom',
|
||||
],
|
||||
assassin: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'LeadingStrike', 'BladeDance',
|
||||
'JavelinAcceleration', 'JavelinMastery',
|
||||
],
|
||||
hermit: [
|
||||
'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend',
|
||||
'Survivor', 'LeadingStrike', 'BladeDance', 'JavelinAcceleration',
|
||||
'JavelinMastery', 'TripleThrow', 'SpiritJavelin', 'SkilledJavelin',
|
||||
],
|
||||
};
|
||||
|
||||
const ENCOUNTER_SCALE = {
|
||||
rogue: { hp: 1.9, attack: 1.5 },
|
||||
thief: { hp: 2.2, attack: 1.6 },
|
||||
assassin: { hp: 2.25, attack: 1.65 },
|
||||
thiefmaster: { hp: 2.4, attack: 1.5 },
|
||||
hermit: { hp: 2.6, attack: 1.65 },
|
||||
};
|
||||
|
||||
const median = (values) => {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
};
|
||||
|
||||
function validateContextDecks(cards) {
|
||||
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
||||
for (const cardId of deck) {
|
||||
if (!cards[cardId]) throw new Error(`${classId} 효율 기준 덱에 없는 카드: ${cardId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeScore(result) {
|
||||
if (result.draw) return -60;
|
||||
if (!result.win) return -100 - result.turns;
|
||||
return 100 + (result.playerHpRemaining / PLAYER_HP) * 30 - result.turns * 2;
|
||||
}
|
||||
|
||||
function scaledEncounter(data, classId) {
|
||||
const scale = ENCOUNTER_SCALE[classId];
|
||||
return {
|
||||
...data,
|
||||
monsters: data.monsters.map((monster) => ({
|
||||
...monster,
|
||||
maxHp: Math.round(monster.maxHp * scale.hp),
|
||||
intents: monster.intents.map((intent) => intent.kind === 'Attack'
|
||||
? { ...intent, value: Math.round(intent.value * scale.attack) }
|
||||
: { ...intent }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function simulateDeck(baseData, deck, runs, seed, trackedCardId = null) {
|
||||
let wins = 0;
|
||||
let totalTurns = 0;
|
||||
let totalHp = 0;
|
||||
let totalScore = 0;
|
||||
let totalPlays = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const stats = {};
|
||||
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
||||
const result = simulateCombat({ ...baseData, starterDeck: deck }, rng, stats);
|
||||
if (result.win) {
|
||||
wins++;
|
||||
totalHp += result.playerHpRemaining;
|
||||
}
|
||||
totalTurns += result.turns;
|
||||
totalScore += outcomeScore(result);
|
||||
if (trackedCardId && stats[trackedCardId]) totalPlays += stats[trackedCardId].plays;
|
||||
}
|
||||
return {
|
||||
winRate: wins / runs,
|
||||
avgTurns: totalTurns / runs,
|
||||
avgHpOnWin: wins > 0 ? totalHp / wins : 0,
|
||||
score: totalScore / runs,
|
||||
avgPlays: totalPlays / runs,
|
||||
};
|
||||
}
|
||||
|
||||
function replacementIndex(deck, cards, candidate) {
|
||||
const preferredKind = candidate.kind === 'Attack' ? 'Attack' : 'Skill';
|
||||
const preferred = deck.findIndex((id) => cards[id]?.kind === preferredKind);
|
||||
if (preferred >= 0) return preferred;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function structuralRisks(card) {
|
||||
const risks = [];
|
||||
const cost = card.cost || 0;
|
||||
const exhaust = card.exhaust === true;
|
||||
const permanentDex = Math.max(0, (card.dex || 0) - (card.endTurnDexLoss || 0));
|
||||
const permanentStats = (card.strength || 0) + permanentDex + (card.thorns || 0);
|
||||
const generatedCards = (card.addShiv || 0) + (card.addShivPerDiscard ? 1 : 0);
|
||||
|
||||
if (cost === 0 && !exhaust && (card.gainEnergy || 0) > 0) {
|
||||
risks.push('0코스트 비소멸 카드가 에너지를 생성');
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.draw || 0) >= 2 && generatedCards > 0) {
|
||||
risks.push('0코스트 비소멸 카드가 2장 이상 드로우하면서 카드를 생성');
|
||||
}
|
||||
if (card.kind !== 'Power' && !exhaust && permanentStats > 0) {
|
||||
risks.push('재사용 가능한 카드가 영구 능력치를 누적');
|
||||
}
|
||||
if (card.kind === 'Power' && (card.attackDamageVsWeakMultiplier || 0) >= 2 && cost <= 1) {
|
||||
risks.push('저비용 지속 효과가 공격 피해를 2배 이상 증폭');
|
||||
}
|
||||
if ((card.poisonApplicationBurstEvery || 0) > 0) {
|
||||
const burstPerApplication = (card.poisonApplicationBurstDamage || 0) / card.poisonApplicationBurstEvery;
|
||||
if (burstPerApplication > 3 && cost <= 1) {
|
||||
risks.push('저비용 독 누적 폭발 피해가 부여 1회당 3을 초과');
|
||||
}
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.block || 0) + (card.nextTurnBlock || 0) >= 8) {
|
||||
risks.push('0코스트 비소멸 카드의 현재·다음 턴 방어 합계가 8 이상');
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.blockPerDamageDealtThisTurn || 0) >= 1) {
|
||||
risks.push('0코스트 비소멸 카드가 이번 턴 누적 피해 전부를 방어로 전환');
|
||||
}
|
||||
if (!exhaust && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0 && generatedCards > 0) {
|
||||
risks.push('에너지 손실 없이 드로우와 카드 생성을 동시에 수행');
|
||||
}
|
||||
if (!exhaust && (card.skillCostReductionThisTurn || 0) > 0 && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0) {
|
||||
risks.push('에너지 손실 없이 드로우하고 이번 턴 스킬 비용까지 감소');
|
||||
}
|
||||
return risks;
|
||||
}
|
||||
|
||||
export function auditCardEfficiency({ runs = 300, seed = 20260701 } = {}) {
|
||||
const data = loadData();
|
||||
const cards = data.cards;
|
||||
validateContextDecks(cards);
|
||||
|
||||
const baselines = {};
|
||||
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
||||
baselines[classId] = simulateDeck(scaledEncounter(data, classId), deck, runs, seed);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const [id, card] of Object.entries(cards)) {
|
||||
if (!ROGUE_CLASSES.has(card.class)) continue;
|
||||
const deck = CONTEXT_DECKS[card.class].slice();
|
||||
deck[replacementIndex(deck, cards, card)] = id;
|
||||
const result = simulateDeck(scaledEncounter(data, card.class), deck, runs, seed, id);
|
||||
rows.push({
|
||||
id,
|
||||
name: card.name,
|
||||
classId: card.class,
|
||||
rarity: card.rarity,
|
||||
kind: card.kind,
|
||||
cost: card.cost || 0,
|
||||
delta: result.score - baselines[card.class].score,
|
||||
...result,
|
||||
risks: structuralRisks(card),
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const peers = rows.filter((other) => other.classId === row.classId && other.rarity === row.rarity);
|
||||
row.peerMedianDelta = median(peers.map((peer) => peer.delta));
|
||||
row.peerGap = row.delta - row.peerMedianDelta;
|
||||
}
|
||||
|
||||
return { runs, seed, baselines, rows };
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatEfficiencyReport(report) {
|
||||
const lines = [];
|
||||
lines.push(`도적 카드 효율 검증: 카드 ${report.rows.length}장, 카드당 ${report.runs}회`);
|
||||
lines.push('기준 덱:');
|
||||
for (const [classId, baseline] of Object.entries(report.baselines)) {
|
||||
lines.push(` ${classId}: 승률 ${formatPercent(baseline.winRate)}, 평균 ${baseline.avgTurns.toFixed(2)}턴, 승리 HP ${baseline.avgHpOnWin.toFixed(1)}`);
|
||||
}
|
||||
|
||||
const risky = report.rows.filter((row) => row.risks.length > 0);
|
||||
lines.push('');
|
||||
lines.push(`구조적 위험 ${risky.length}장:`);
|
||||
for (const row of risky) {
|
||||
lines.push(` ${row.name}(${row.id}, ${row.classId}): ${row.risks.join(' / ')}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('동급 대비 효율 상위:');
|
||||
for (const row of report.rows.slice().sort((a, b) => b.peerGap - a.peerGap).slice(0, 10)) {
|
||||
lines.push(` ${row.name}(${row.id}): 중앙값 대비 +${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('동급 대비 효율 하위:');
|
||||
for (const row of report.rows.slice().sort((a, b) => a.peerGap - b.peerGap).slice(0, 10)) {
|
||||
lines.push(` ${row.name}(${row.id}): 중앙값 대비 ${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let runs = 300;
|
||||
let seed = 20260701;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
||||
}
|
||||
const report = auditCardEfficiency({ runs, seed });
|
||||
console.log(formatEfficiencyReport(report));
|
||||
if (report.rows.some((row) => row.risks.length > 0)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('card-efficiency.mjs')) main();
|
||||
30
tools/balance/card-efficiency.test.mjs
Normal file
30
tools/balance/card-efficiency.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { structuralRisks } from './card-efficiency.mjs';
|
||||
|
||||
test('0코스트 에너지 생성 카드를 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 0, kind: 'Skill', gainEnergy: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('에너지를 생성')));
|
||||
});
|
||||
|
||||
test('재사용 가능한 영구 능력치 스킬을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 1, kind: 'Skill', strength: 1, dex: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('영구 능력치')));
|
||||
});
|
||||
|
||||
test('소멸하거나 파워인 능력치 카드는 허용', () => {
|
||||
assert.deepEqual(structuralRisks({ cost: 1, kind: 'Skill', strength: 1, exhaust: true }), []);
|
||||
assert.deepEqual(structuralRisks({ cost: 1, kind: 'Power', dex: 1 }), []);
|
||||
assert.deepEqual(structuralRisks({ cost: 0, kind: 'Skill', dex: 2, endTurnDexLoss: 2 }), []);
|
||||
});
|
||||
|
||||
test('저비용 2배 피해 증폭을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 1, kind: 'Power', attackDamageVsWeakMultiplier: 2 });
|
||||
assert.ok(risks.some((risk) => risk.includes('2배')));
|
||||
});
|
||||
|
||||
test('0코스트 누적 피해 전체 방어 전환을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 0, kind: 'Skill', blockPerDamageDealtThisTurn: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('누적 피해')));
|
||||
assert.deepEqual(structuralRisks({ cost: 0, kind: 'Skill', blockPerDamageDealtThisTurn: 0.5 }), []);
|
||||
});
|
||||
314
tools/balance/rogue-campaign.mjs
Normal file
314
tools/balance/rogue-campaign.mjs
Normal file
@@ -0,0 +1,314 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mulberry32, rarityForRoll, simulateCombat } from './sim-balance.mjs';
|
||||
import { ACT_DIFFICULTY_MULTIPLIERS } from '../deck/lib/codeblock.mjs';
|
||||
|
||||
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||||
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
const relicsData = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
|
||||
const PLAYER_MAX_HP = 70;
|
||||
const REST_HEAL = 30;
|
||||
const SECTION_COUNT = 5;
|
||||
const NORMAL_FIGHTS = 4;
|
||||
export const DEFAULT_SECTION_MULTIPLIERS = ACT_DIFFICULTY_MULTIPLIERS;
|
||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump'];
|
||||
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
||||
const BOSS_POOL = ['king_slime', 'slime_boss'];
|
||||
|
||||
const JOBS = {
|
||||
thief: { tier2: 'thief', tier3: 'thiefmaster', tier2Starter: 'DaggerAcceleration', tier3Starter: 'Venom' },
|
||||
assassin: { tier2: 'assassin', tier3: 'hermit', tier2Starter: 'JavelinAcceleration', tier3Starter: 'SpiritJavelin' },
|
||||
};
|
||||
|
||||
const LINEAGES = {
|
||||
rogue: ['rogue'],
|
||||
thief: ['rogue', 'thief'],
|
||||
thiefmaster: ['rogue', 'thief', 'thiefmaster'],
|
||||
assassin: ['rogue', 'assassin'],
|
||||
hermit: ['rogue', 'assassin', 'hermit'],
|
||||
};
|
||||
|
||||
const pick = (rng, values) => values[Math.floor(rng() * values.length)];
|
||||
|
||||
export function campaignJobAtSection(branch, section) {
|
||||
if (section <= 1) return 'rogue';
|
||||
if (section === 2) return JOBS[branch].tier2;
|
||||
return JOBS[branch].tier3;
|
||||
}
|
||||
|
||||
export function playableClassesForJob(job) {
|
||||
return LINEAGES[job] || [job];
|
||||
}
|
||||
|
||||
export function scaleEnemy(enemy, section, rng = () => 0, scaleStep = null) {
|
||||
const multiplier = scaleStep == null
|
||||
? (DEFAULT_SECTION_MULTIPLIERS[section - 1] || DEFAULT_SECTION_MULTIPLIERS.at(-1))
|
||||
: 1 + (section - 1) * scaleStep;
|
||||
const offset = enemy.intents.length > 0 ? Math.floor(rng() * enemy.intents.length) : 0;
|
||||
const rotatedIntents = enemy.intents.map((_, index) => enemy.intents[(index + offset) % enemy.intents.length]);
|
||||
return {
|
||||
...enemy,
|
||||
maxHp: Math.floor(enemy.maxHp * multiplier),
|
||||
intents: rotatedIntents.map((intent) => ({
|
||||
...intent,
|
||||
value: intent.kind === 'Debuff' || intent.value == null
|
||||
? intent.value
|
||||
: Math.floor(intent.value * multiplier),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEncounter(kind, section, rng, scaleStep) {
|
||||
const ids = [];
|
||||
if (kind === 'normal') {
|
||||
const count = 1 + Math.floor(rng() * 3);
|
||||
for (let i = 0; i < count; i++) ids.push(pick(rng, COMBAT_POOL));
|
||||
} else if (kind === 'elite') {
|
||||
ids.push(pick(rng, ELITE_POOL));
|
||||
const extra = Math.floor(rng() * 3);
|
||||
for (let i = 0; i < extra; i++) ids.push(pick(rng, COMBAT_POOL));
|
||||
} else {
|
||||
ids.push(pick(rng, BOSS_POOL));
|
||||
}
|
||||
return ids.map((id) => scaleEnemy(enemiesData.enemies[id], section, rng, scaleStep));
|
||||
}
|
||||
|
||||
function baseCardValue(card) {
|
||||
const hits = card.hits || 1;
|
||||
const targets = card.aoe ? 1.7 : 1;
|
||||
let value = (card.damage || 0) * hits * targets;
|
||||
value += (card.block || 0) + (card.nextTurnBlock || 0) * 0.7;
|
||||
value += (card.poison || 0) * (card.poisonHits || 1) * (card.affectsAllEnemies ? 2 : 1) * 1.5;
|
||||
value += (card.draw || 0) * 4 + (card.gainEnergy || 0) * 5;
|
||||
value += (card.addShiv || 0) * 4;
|
||||
value += (card.strength || 0) * 6 + (card.dex || 0) * 5;
|
||||
value += (card.weak || 0) * 3 + (card.vuln || 0) * 4;
|
||||
value += (card.intangible || 0) * 12;
|
||||
value += (card.turnStartShiv || 0) * 8 + (card.shivDamageBonus || 0) * 4;
|
||||
value += (card.cardPlayedBlock || 0) * 8 + (card.attackPoison || 0) * 8;
|
||||
value += (card.powerEffect ? 7 : 0) + (card.retain ? 2 : 0) + (card.sly ? 3 : 0);
|
||||
value += (card.damagePerDiscardedThisTurn || 0) * 2;
|
||||
value += (card.damagePerAttackPlayedThisTurn || 0) * 2;
|
||||
value += (card.firstShivDamageBonus || 0) * 2;
|
||||
value -= (card.cost || 0) * 5;
|
||||
if (card.exhaust) value -= 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
function branchCardValue(card, branch, deck, id) {
|
||||
let value = baseCardValue(card);
|
||||
if (branch === 'thief') {
|
||||
value += (card.poison || 0) * 1.5 + (card.attackPoison || 0) * 8;
|
||||
value += card.sly ? 5 : 0;
|
||||
value += (card.discard || 0) * 2 + (card.drawPerDiscarded || 0) * 4;
|
||||
value += (card.poisonApplicationBurstDamage || 0) * 1.5;
|
||||
} else {
|
||||
value += (card.addShiv || 0) * 3 + (card.turnStartShiv || 0) * 8;
|
||||
value += (card.shivDamageBonus || 0) * 6 + (card.firstShivDamageBonus || 0) * 3;
|
||||
value += card.shivAoe ? 12 : 0;
|
||||
value += card.shivRetain ? 5 : 0;
|
||||
}
|
||||
const copies = deck.filter((cardId) => cardId === id).length;
|
||||
value -= copies * (card.kind === 'Power' ? 10 : 3);
|
||||
return value;
|
||||
}
|
||||
|
||||
function rewardPool(job) {
|
||||
const classes = new Set(playableClassesForJob(job));
|
||||
return Object.entries(cardsData.cards)
|
||||
.filter(([, card]) => classes.has(card.class) && card.token !== true && card.unplayable !== true);
|
||||
}
|
||||
|
||||
function offerReward(job, branch, deck, rng, minimumValue) {
|
||||
const pool = rewardPool(job);
|
||||
const choices = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const rarity = rarityForRoll(1 + Math.floor(rng() * 100));
|
||||
const bucket = pool.filter(([, card]) => card.rarity === rarity);
|
||||
choices.push(pick(rng, bucket.length > 0 ? bucket : pool));
|
||||
}
|
||||
choices.sort((a, b) => branchCardValue(b[1], branch, deck, b[0]) - branchCardValue(a[1], branch, deck, a[0]));
|
||||
const [id, card] = choices[0];
|
||||
if (branchCardValue(card, branch, deck, id) >= minimumValue) deck.push(id);
|
||||
}
|
||||
|
||||
function relicModifiers(state) {
|
||||
const result = {
|
||||
playerStartBlock: 0,
|
||||
playerStrength: 0,
|
||||
playerThorns: 0,
|
||||
energyBonus: 0,
|
||||
openingDrawBonus: 0,
|
||||
healOnAttack: 0,
|
||||
};
|
||||
for (const id of state.relics) {
|
||||
const relic = relicsData.relics[id];
|
||||
if (!relic) continue;
|
||||
if (relic.hook === 'combatStart' && relic.effect === 'block') result.playerStartBlock += relic.value;
|
||||
else if (relic.hook === 'combatStart' && relic.effect === 'strength') result.playerStrength += relic.value;
|
||||
else if (relic.hook === 'turnStart' && relic.effect === 'energy') result.energyBonus += relic.value;
|
||||
else if (relic.hook === 'combatStart' && relic.effect === 'draw') result.openingDrawBonus += relic.value;
|
||||
else if (relic.effect === 'thorns') result.playerThorns += relic.value;
|
||||
else if (relic.effect === 'healOnAttack') result.healOnAttack += relic.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function healFromRelics(state, hook) {
|
||||
for (const id of state.relics) {
|
||||
const relic = relicsData.relics[id];
|
||||
if (!relic || relic.hook !== hook) continue;
|
||||
if (relic.effect === 'heal') state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
else if (relic.effect === 'healOnWin') state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
else if (relic.effect === 'healIfLow' && state.hp <= state.maxHp * 0.5) state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
}
|
||||
}
|
||||
|
||||
function acquireRelic(state, rng) {
|
||||
const available = relicsData.relicPool.filter((id) => !state.relics.includes(id));
|
||||
if (available.length === 0) return;
|
||||
const id = pick(rng, available);
|
||||
state.relics.push(id);
|
||||
const relic = relicsData.relics[id];
|
||||
if (relic?.effect === 'maxHp') {
|
||||
state.maxHp += relic.value;
|
||||
state.hp += relic.value;
|
||||
}
|
||||
}
|
||||
|
||||
function fight(state, branch, kind, section, rng, options) {
|
||||
const monsters = buildEncounter(kind, section, rng, options.scaleStep);
|
||||
healFromRelics(state, 'combatStart');
|
||||
const result = simulateCombat({
|
||||
cards: cardsData.cards,
|
||||
starterDeck: state.deck,
|
||||
monsters,
|
||||
playerHp: state.hp,
|
||||
playerMaxHp: state.maxHp,
|
||||
smartPlayer: true,
|
||||
...relicModifiers(state),
|
||||
}, rng);
|
||||
state.hp = result.playerHpRemaining;
|
||||
state.turns += result.turns;
|
||||
if (!result.win) return false;
|
||||
healFromRelics(state, 'combatEnd');
|
||||
if (kind !== 'boss') offerReward(state.job, branch, state.deck, rng, options.minimumRewardValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function simulateCampaign(branch, rng, {
|
||||
restHeal = REST_HEAL,
|
||||
sectionHeal = 0,
|
||||
scaleStep = null,
|
||||
minimumRewardValue = 10,
|
||||
} = {}) {
|
||||
if (!JOBS[branch]) throw new Error(`지원하지 않는 도적 분기: ${branch}`);
|
||||
const state = {
|
||||
hp: PLAYER_MAX_HP,
|
||||
maxHp: PLAYER_MAX_HP,
|
||||
deck: cardsData.starterDecks.rogue.slice(),
|
||||
job: 'rogue',
|
||||
turns: 0,
|
||||
sectionCleared: 0,
|
||||
diedAt: '',
|
||||
hpAfterSections: [],
|
||||
relics: [relicsData.startingRelic],
|
||||
};
|
||||
const options = { scaleStep, minimumRewardValue };
|
||||
|
||||
for (let section = 1; section <= SECTION_COUNT; section++) {
|
||||
state.job = campaignJobAtSection(branch, section);
|
||||
for (let fightIndex = 1; fightIndex <= NORMAL_FIGHTS; fightIndex++) {
|
||||
if (!fight(state, branch, 'normal', section, rng, options)) {
|
||||
state.diedAt = `${section}-normal`;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
state.hp = Math.min(state.maxHp, state.hp + restHeal);
|
||||
if (!fight(state, branch, 'elite', section, rng, options)) {
|
||||
state.diedAt = `${section}-elite`;
|
||||
return state;
|
||||
}
|
||||
acquireRelic(state, rng);
|
||||
if (!fight(state, branch, 'boss', section, rng, options)) {
|
||||
state.diedAt = `${section}-boss`;
|
||||
return state;
|
||||
}
|
||||
state.sectionCleared = section;
|
||||
state.hpAfterSections.push(state.hp);
|
||||
if (section === 1) state.deck.push(JOBS[branch].tier2Starter);
|
||||
if (section === 2) state.deck.push(JOBS[branch].tier3Starter);
|
||||
if (section >= 3) acquireRelic(state, rng);
|
||||
if (section < SECTION_COUNT) state.hp = Math.min(state.maxHp, state.hp + sectionHeal);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function runCampaignBatch(branch, runs = 1000, seed = 20260701, options = {}) {
|
||||
const sectionReached = Array(SECTION_COUNT).fill(0);
|
||||
const sectionClears = Array(SECTION_COUNT).fill(0);
|
||||
const deaths = {};
|
||||
let fullClears = 0;
|
||||
let totalDeckSize = 0;
|
||||
let totalFinalHp = 0;
|
||||
let totalTurns = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
||||
const result = simulateCampaign(branch, rng, options);
|
||||
for (let section = 0; section < SECTION_COUNT; section++) {
|
||||
if (result.sectionCleared >= section) sectionReached[section]++;
|
||||
if (result.sectionCleared >= section + 1) sectionClears[section]++;
|
||||
}
|
||||
if (result.sectionCleared === SECTION_COUNT) {
|
||||
fullClears++;
|
||||
totalFinalHp += result.hp;
|
||||
}
|
||||
if (result.diedAt) deaths[result.diedAt] = (deaths[result.diedAt] || 0) + 1;
|
||||
totalDeckSize += result.deck.length;
|
||||
totalTurns += result.turns;
|
||||
}
|
||||
return {
|
||||
branch,
|
||||
runs,
|
||||
fullClearRate: fullClears / runs,
|
||||
avgFinalHp: fullClears > 0 ? totalFinalHp / fullClears : 0,
|
||||
avgDeckSize: totalDeckSize / runs,
|
||||
avgTurns: totalTurns / runs,
|
||||
sectionConditionalClearRates: sectionClears.map((clears, index) => sectionReached[index] > 0 ? clears / sectionReached[index] : 0),
|
||||
sectionReachRates: sectionReached.map((reached) => reached / runs),
|
||||
deaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCampaignReport(result) {
|
||||
const lines = [];
|
||||
lines.push(`${result.branch} 캠페인 ${result.runs}회`);
|
||||
lines.push(` 전체 클리어 ${(result.fullClearRate * 100).toFixed(1)}%, 클리어 HP ${result.avgFinalHp.toFixed(1)}, 평균 덱 ${result.avgDeckSize.toFixed(1)}장`);
|
||||
result.sectionConditionalClearRates.forEach((rate, index) => {
|
||||
lines.push(` 섹션 ${index + 1}: 도달 ${(result.sectionReachRates[index] * 100).toFixed(1)}%, 도달자 클리어 ${(rate * 100).toFixed(1)}%`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let runs = 1000;
|
||||
let seed = 20260701;
|
||||
let restHeal = REST_HEAL;
|
||||
let sectionHeal = 0;
|
||||
let scaleStep = null;
|
||||
let minimumRewardValue = 10;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--rest-heal') restHeal = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--section-heal') sectionHeal = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--scale-step') scaleStep = Number.parseFloat(args[++i]);
|
||||
else if (args[i] === '--reward-min') minimumRewardValue = Number.parseFloat(args[++i]);
|
||||
}
|
||||
for (const branch of ['thief', 'assassin']) {
|
||||
console.log(formatCampaignReport(runCampaignBatch(branch, runs, seed, { restHeal, sectionHeal, scaleStep, minimumRewardValue })));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('rogue-campaign.mjs')) main();
|
||||
28
tools/balance/rogue-campaign.test.mjs
Normal file
28
tools/balance/rogue-campaign.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
campaignJobAtSection,
|
||||
playableClassesForJob,
|
||||
scaleEnemy,
|
||||
} from './rogue-campaign.mjs';
|
||||
|
||||
test('도적 전직 시점: 1섹션 Rogue, 2섹션 2차, 3섹션부터 3차', () => {
|
||||
assert.equal(campaignJobAtSection('thief', 1), 'rogue');
|
||||
assert.equal(campaignJobAtSection('thief', 2), 'thief');
|
||||
assert.equal(campaignJobAtSection('thief', 3), 'thiefmaster');
|
||||
assert.equal(campaignJobAtSection('assassin', 2), 'assassin');
|
||||
assert.equal(campaignJobAtSection('assassin', 5), 'hermit');
|
||||
});
|
||||
|
||||
test('3차 직업은 자기 계보 카드만 사용', () => {
|
||||
assert.deepEqual(playableClassesForJob('thiefmaster'), ['rogue', 'thief', 'thiefmaster']);
|
||||
assert.deepEqual(playableClassesForJob('hermit'), ['rogue', 'assassin', 'hermit']);
|
||||
});
|
||||
|
||||
test('섹션 난이도는 3차 이후 더 빠르게 증가', () => {
|
||||
const enemy = { maxHp: 100, intents: [{ kind: 'Attack', value: 10 }, { kind: 'Debuff', value: 2 }] };
|
||||
const scaled = scaleEnemy(enemy, 3, () => 0);
|
||||
assert.equal(scaled.maxHp, 114);
|
||||
assert.equal(scaled.intents[0].value, 11);
|
||||
assert.equal(scaled.intents[1].value, 2);
|
||||
});
|
||||
@@ -55,7 +55,8 @@ export function calcAttack(base, str, weak, vulnOnTarget) {
|
||||
}
|
||||
|
||||
export function calcEnemyAttack(base, str, weak, vulnOnTarget, strengthLoss = 0) {
|
||||
return calcAttack(base, Math.max(0, str - strengthLoss), weak, vulnOnTarget);
|
||||
// Lua EnemyActStep 동기화: 힘 손실은 (value+str) 전체에서 차감(음수 힘 허용), 최종 calcAttack이 0 클램프.
|
||||
return calcAttack(base, str - strengthLoss, weak, vulnOnTarget);
|
||||
}
|
||||
|
||||
// 방어 우선 차감 후 hp 적용 → { hp, block }
|
||||
@@ -129,6 +130,19 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(x), 1);
|
||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||
if ((ctx.incomingDamage || 0) > (ctx.currentBlock || 0)) {
|
||||
const defensive = entries.filter((x) => {
|
||||
const card = cards[x.id];
|
||||
return (card.block || 0) > 0 || (card.intangible || 0) > 0 || (card.enemyStrengthLossThisTurn || 0) > 0;
|
||||
});
|
||||
if (defensive.length) {
|
||||
return bestBy(defensive, (x) => {
|
||||
const card = cards[x.id];
|
||||
const protection = (card.block || 0) + (card.intangible || 0) * 15 + (card.enemyStrengthLossThisTurn || 0) * 2;
|
||||
return protection / Math.max(effectiveCost(x), 1);
|
||||
}).i;
|
||||
}
|
||||
}
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
if (skills.length) return bestBy(skills, blkEff).i;
|
||||
@@ -153,13 +167,15 @@ function bump(s, cost, dmg, blk) {
|
||||
// 반환: { win, turns, playerHpRemaining, draw? }
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
const playerMaxHp = data.playerMaxHp || PLAYER_HP;
|
||||
const startingPlayerHp = Math.min(data.playerHp ?? playerMaxHp, playerMaxHp);
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: startingPlayerHp };
|
||||
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, pIntangible = 0;
|
||||
let pHp = startingPlayerHp, pBlock = data.playerStartBlock || 0;
|
||||
let pStr = data.playerStrength || 0, pDex = 0, pThorns = data.playerThorns || 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||
let blockGainMultiplier = 1;
|
||||
let handCostZeroThisTurn = false;
|
||||
let drawDisabledThisTurn = false;
|
||||
@@ -199,6 +215,16 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (!alive.length) return null;
|
||||
return alive[Math.floor(rng() * alive.length)];
|
||||
};
|
||||
const expectedIncomingDamage = () => mob.filter((m) => m.alive).reduce((total, m) => {
|
||||
if (!m.intents || m.intents.length === 0) return total;
|
||||
const expected = m.intents.reduce((sum, intent) => {
|
||||
if (intent.kind !== 'Attack') return sum;
|
||||
let amount = calcEnemyAttack(intent.value, m.str, m.weak, pVuln, enemyStrengthLossThisTurn);
|
||||
if (pIntangible > 0 && amount > 1) amount = 1;
|
||||
return sum + amount;
|
||||
}, 0) / m.intents.length;
|
||||
return total + expected;
|
||||
}, 0);
|
||||
const removeEnemyBlock = (target) => {
|
||||
if (target) target.block = 0;
|
||||
};
|
||||
@@ -307,10 +333,30 @@ export function simulateCombat(data, rng, stats) {
|
||||
pBlock += amount;
|
||||
return amount;
|
||||
}
|
||||
function smartDiscardIndex() {
|
||||
if (hand.length === 0) return -1;
|
||||
if (data.smartPlayer !== true) return hand.length - 1;
|
||||
const ranked = hand.map((id, index) => {
|
||||
const card = cards[id] || {};
|
||||
const isSly = card.sly === true || skillSlyOnPlayCards.has(id) || turnSkillSlyCards.has(id);
|
||||
const utility = (card.damage || 0) * (card.hits || 1)
|
||||
+ (card.block || 0)
|
||||
+ (card.draw || 0) * 4
|
||||
+ (card.addShiv || 0) * 4
|
||||
+ (card.poison || 0) * 2;
|
||||
return { index, isSly, unplayable: card.unplayable === true, tooExpensive: (card.cost || 0) > energy, utility };
|
||||
});
|
||||
ranked.sort((a, b) => Number(b.isSly) - Number(a.isSly)
|
||||
|| Number(b.unplayable) - Number(a.unplayable)
|
||||
|| Number(b.tooExpensive) - Number(a.tooExpensive)
|
||||
|| a.utility - b.utility
|
||||
|| a.index - b.index);
|
||||
return ranked[0].index;
|
||||
}
|
||||
function discardForTurnStart(n) {
|
||||
const cnt = Math.min(n, hand.length);
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const idx = hand
|
||||
const idx = data.smartPlayer === true ? smartDiscardIndex() : hand
|
||||
.map((id, k) => ({ id, k, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ac = a.card?.cost || 0;
|
||||
@@ -342,7 +388,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
|
||||
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
|
||||
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
|
||||
if (c.class === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
|
||||
if (c.kind === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
|
||||
if (c.class === 'shiv') {
|
||||
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
|
||||
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');
|
||||
@@ -422,6 +468,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
const hitN = (c.hits || 1) + bonusHits;
|
||||
let useAoe = c.aoe === true;
|
||||
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
|
||||
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
|
||||
shivFirstDamageBonusUsed = true;
|
||||
}
|
||||
const perHit = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
const dealToTarget = (target, amount) => {
|
||||
if (!target || !target.alive) return { killed: false, dealt: 0 };
|
||||
@@ -515,16 +564,13 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
|
||||
shivFirstDamageBonusUsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.strength) pStr += c.strength;
|
||||
if (c.dex) pDex += c.dex;
|
||||
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.heal) pHp = Math.min(pHp + c.heal, playerMaxHp);
|
||||
if (c.gainEnergy) energy += c.gainEnergy;
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
if (c.intangible) pIntangible += c.intangible;
|
||||
@@ -564,7 +610,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
}
|
||||
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
|
||||
blockGained += Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn);
|
||||
blockGained += addBlock(Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn));
|
||||
}
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
@@ -587,7 +633,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
} else if (c.discard) {
|
||||
const n = Math.min(c.discard, hand.length);
|
||||
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
for (let i = 0; i < n; i++) { discardHandCard(smartDiscardIndex(), true); discarded++; }
|
||||
}
|
||||
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
@@ -641,15 +687,23 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount);
|
||||
nextTurnAddCards = [];
|
||||
}
|
||||
energy = ENERGY + energyBonus;
|
||||
energy = ENERGY + (data.energyBonus || 0) + energyBonus;
|
||||
const drawBonus = nextTurnDraw + powerTurnDraw;
|
||||
nextTurnDraw = 0;
|
||||
draw(HAND_SIZE + drawBonus);
|
||||
draw(HAND_SIZE + drawBonus + (turns === 1 ? (data.openingDrawBonus || 0) : 0));
|
||||
if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
|
||||
const idx = chooseAction(hand, cards, energy, {
|
||||
drawPileCount: drawPile.length,
|
||||
nextSkillCostZero,
|
||||
skillCostReductionThisTurn,
|
||||
handCostZeroThisTurn,
|
||||
combatCardCostReduction,
|
||||
incomingDamage: data.smartPlayer === true ? expectedIncomingDamage() : 0,
|
||||
currentBlock: pBlock,
|
||||
});
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
let dmg = 0;
|
||||
@@ -658,9 +712,12 @@ export function simulateCombat(data, rng, stats) {
|
||||
const baseCost = c.cost || 0;
|
||||
const combatReduction = combatCardCostReduction[id] || 0;
|
||||
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||
const finalCost = Math.max(0, cost - combatReduction);
|
||||
const finalCost = c.useAllEnergy === true ? cost : Math.max(0, cost - combatReduction);
|
||||
energy -= finalCost;
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
if (c.kind === 'Attack' && (data.healOnAttack || 0) > 0) {
|
||||
pHp = Math.min(playerMaxHp, pHp + data.healOnAttack);
|
||||
}
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (skillRepeat > 0) {
|
||||
@@ -721,7 +778,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
||||
if (it) {
|
||||
if (it.kind === 'Attack') {
|
||||
const atk = calcAttack(it.value, Math.max(0, m.str - enemyStrengthLossThisTurn), m.weak, pVuln);
|
||||
const atk = calcEnemyAttack(it.value, m.str, m.weak, pVuln, enemyStrengthLossThisTurn);
|
||||
const beforeHp = pHp;
|
||||
let incoming = atk;
|
||||
if (pIntangible > 0 && incoming > 1) incoming = 1;
|
||||
|
||||
@@ -121,6 +121,14 @@ test('chooseAction: 공격 없으면 스킬 선택', () => {
|
||||
assert.equal(idx, 0);
|
||||
});
|
||||
|
||||
test('chooseAction: 예상 피해가 남으면 방어 카드를 우선 선택', () => {
|
||||
const cards = {
|
||||
Hit: { kind: 'Attack', cost: 1, damage: 12 },
|
||||
Guard: { kind: 'Skill', cost: 1, block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(['Hit', 'Guard'], cards, 1, { incomingDamage: 8, currentBlock: 0 }), 1);
|
||||
});
|
||||
|
||||
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
|
||||
const idx = chooseAction(['Bash'], CARDS, 1);
|
||||
assert.equal(idx, -1);
|
||||
@@ -220,6 +228,21 @@ test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test('simulateCombat: 캠페인 시작 체력과 유물 전투 보너스를 반영', () => {
|
||||
const data = {
|
||||
cards: { Guard: { name: 'Guard', cost: 1, kind: 'Skill', block: 1 } },
|
||||
starterDeck: ['Guard'],
|
||||
monsters: [{ name: 'Dummy', maxHp: 1, intents: [{ kind: 'Attack', value: 1 }] }],
|
||||
playerHp: 37,
|
||||
playerMaxHp: 70,
|
||||
playerStartBlock: 6,
|
||||
energyBonus: 1,
|
||||
openingDrawBonus: 2,
|
||||
};
|
||||
const result = simulateCombat(data, mulberry32(3));
|
||||
assert.ok(result.playerHpRemaining <= 37);
|
||||
});
|
||||
|
||||
test('calcAttack: 힘·약화·취약 공식 (Lua CalcPlayerAttack·DealDamageToTarget 동기화)', () => {
|
||||
assert.equal(calcAttack(6, 0, 0, 0), 6); // 기본
|
||||
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
|
||||
@@ -262,6 +285,19 @@ test('simulateCombat: 카드 취약 부여가 같은 카드 피해에 선적용
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test('simulateCombat: firstCardDamageBonus가 턴 첫 카드에 적용 (kind===Attack, Lua 동기화)', () => {
|
||||
// ChargedBlow처럼 class=warrior·kind=Attack인 카드의 첫-카드 보너스.
|
||||
// 게이트가 class==="Attack"이면 영구 false라 미발동(버그) → 5뎀/2턴.
|
||||
// kind==="Attack"이면 5+2=7 → 1턴 처치.
|
||||
const data = {
|
||||
cards: { CB: { name: '차지블로우', cost: 3, kind: 'Attack', class: 'warrior', damage: 5, firstCardDamageBonus: 2 } },
|
||||
starterDeck: ['CB', 'CB', 'CB', 'CB', 'CB'],
|
||||
monsters: [{ name: '적', maxHp: 7, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test('simulateCombat: Power(매턴 힘) 누적', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
@@ -882,6 +918,44 @@ test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", (
|
||||
assert.equal(calcEnemyAttack(10, 6, 0, 0, 0), 16);
|
||||
});
|
||||
|
||||
test("calcEnemyAttack: 힘 손실이 base 아래로 공격을 낮춘다 (음수 힘, Lua 동기화)", () => {
|
||||
// 적 str=0, loss=6 → 힘 -6 → 10-6=4. JS가 str을 0에서 클램프하면 10(버그). Lua는 전체에서 차감.
|
||||
assert.equal(calcEnemyAttack(10, 0, 0, 0, 6), 4);
|
||||
assert.equal(calcEnemyAttack(10, 3, 0, 0, 6), 7);
|
||||
assert.equal(calcEnemyAttack(5, 0, 0, 0, 6), 0); // 5-6=-1 → 0 클램프
|
||||
});
|
||||
|
||||
test('simulateCombat: firstShivDamageBonus는 턴당 첫 Shiv에만 적용 (Lua 동기화)', () => {
|
||||
// PhantomBlades(firstShivDamageBonus 9) 활성. 턴당 3 Shiv 사용(에너지3·cost1).
|
||||
// 정답(첫 Shiv만 +9): 턴1 = 10+1+1=12 → 13HP에 1 남김 → 2턴.
|
||||
// 버그(모든 Shiv +9): 턴1 = 10*3=30 → 1턴.
|
||||
const data = {
|
||||
cards: {
|
||||
PhantomBlades: { name: '환영검', cost: 0, kind: 'Power', firstShivDamageBonus: 9 },
|
||||
Shiv: { name: '시브', cost: 1, kind: 'Attack', class: 'shiv', damage: 1 },
|
||||
},
|
||||
starterDeck: ['PhantomBlades', 'Shiv', 'Shiv', 'Shiv', 'Shiv'],
|
||||
monsters: [{ name: '적', maxHp: 13, intents: [{ kind: 'Attack', value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test('simulateCombat: blockPerDamageDealtThisTurn이 실제 방어를 부여 (Lua 동기화)', () => {
|
||||
// 매턴 Hit(5뎀) → Guard(준 피해만큼 방어 5) → 적 공격 5 상쇄.
|
||||
// 수정(실제 방어): 무한 생존 → 무승부. 버그(방어 미부여): 매턴 5피해 → 사망.
|
||||
const data = {
|
||||
cards: {
|
||||
Hit: { name: '타격', cost: 2, kind: 'Attack', damage: 5 },
|
||||
Guard: { name: '대비', cost: 1, kind: 'Skill', blockPerDamageDealtThisTurn: 1 },
|
||||
},
|
||||
starterDeck: ['Hit', 'Guard'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 5 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.draw, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => {
|
||||
const shared = {
|
||||
cards: {
|
||||
|
||||
629
tools/cards/cards_excel.ps1
Normal file
629
tools/cards/cards_excel.ps1
Normal file
@@ -0,0 +1,629 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[ValidateSet('export', 'import')]
|
||||
[string]$Action,
|
||||
[string]$JsonPath,
|
||||
[string]$XlsxPath,
|
||||
[string]$OutJsonPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
if ([string]::IsNullOrWhiteSpace($JsonPath)) { $JsonPath = Join-Path $repoRoot 'data\cards.json' }
|
||||
if ([string]::IsNullOrWhiteSpace($XlsxPath)) { $XlsxPath = Join-Path $repoRoot 'data\cards.xlsx' }
|
||||
if ([string]::IsNullOrWhiteSpace($OutJsonPath)) { $OutJsonPath = $JsonPath }
|
||||
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
function Escape-Xml([string]$Text) {
|
||||
if ($null -eq $Text) { return '' }
|
||||
return [System.Security.SecurityElement]::Escape($Text)
|
||||
}
|
||||
|
||||
function Get-ColumnName([int]$Index) {
|
||||
$n = $Index
|
||||
$name = ''
|
||||
while ($n -gt 0) {
|
||||
$n--
|
||||
$name = [char][int](65 + ($n % 26)) + $name
|
||||
$n = [math]::Floor($n / 26)
|
||||
}
|
||||
return $name
|
||||
}
|
||||
|
||||
function Get-ColumnIndex([string]$Name) {
|
||||
$n = 0
|
||||
foreach ($ch in $Name.ToCharArray()) {
|
||||
if ($ch -match '[A-Z]') {
|
||||
$n = $n * 26 + ([int][char]$ch - 64)
|
||||
}
|
||||
}
|
||||
return $n
|
||||
}
|
||||
|
||||
function Get-CellRef([int]$Col, [int]$Row) {
|
||||
return (Get-ColumnName $Col) + $Row
|
||||
}
|
||||
|
||||
function Has-MapKey($Map, $Key) {
|
||||
if ($null -eq $Map) { return $false }
|
||||
if ($null -eq $Key) { return $false }
|
||||
if ($Key -is [string] -and [string]::IsNullOrWhiteSpace($Key)) { return $false }
|
||||
foreach ($existingKey in $Map.Keys) {
|
||||
if ($existingKey -eq $Key) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-ScalarType($Value) {
|
||||
if ($null -eq $Value) { return 'null' }
|
||||
if ($Value -is [bool]) { return 'boolean' }
|
||||
if ($Value -is [byte] -or $Value -is [sbyte] -or
|
||||
$Value -is [int16] -or $Value -is [uint16] -or
|
||||
$Value -is [int32] -or $Value -is [uint32] -or
|
||||
$Value -is [int64] -or $Value -is [uint64] -or
|
||||
$Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return 'number' }
|
||||
if ($Value -is [string]) { return 'string' }
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function Get-CardSchema($Cards) {
|
||||
$schema = [ordered]@{}
|
||||
foreach ($cardEntry in $Cards.PSObject.Properties) {
|
||||
$card = $cardEntry.Value
|
||||
foreach ($prop in $card.PSObject.Properties) {
|
||||
$kind = Get-ScalarType $prop.Value
|
||||
if (-not (Has-MapKey $schema $prop.Name)) {
|
||||
$schema[$prop.Name] = $kind
|
||||
} elseif ($schema[$prop.Name] -ne $kind -and $kind -ne 'null') {
|
||||
$schema[$prop.Name] = 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
return $schema
|
||||
}
|
||||
|
||||
function Get-ColumnWidth([string]$Header, [string]$Type) {
|
||||
switch ($Header) {
|
||||
'id' { return 18 }
|
||||
'name' { return 24 }
|
||||
'desc' { return 48 }
|
||||
'image' { return 36 }
|
||||
'fx' { return 36 }
|
||||
'kind' { return 12 }
|
||||
'class' { return 12 }
|
||||
'rarity' { return 12 }
|
||||
default {
|
||||
if ($Type -eq 'boolean') { return 10 }
|
||||
if ($Type -eq 'number') { return 12 }
|
||||
return 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function To-InvariantNumber($Value) {
|
||||
return [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0}', $Value)
|
||||
}
|
||||
|
||||
function New-HeaderCellXml([string]$Ref, [string]$Text) {
|
||||
$escaped = Escape-Xml $Text
|
||||
return "<c r=""$Ref"" s=""1"" t=""inlineStr""><is><t xml:space=""preserve"">$escaped</t></is></c>"
|
||||
}
|
||||
|
||||
function New-TextCellXml([string]$Ref, [string]$Text) {
|
||||
$escaped = Escape-Xml $Text
|
||||
return "<c r=""$Ref"" t=""inlineStr""><is><t xml:space=""preserve"">$escaped</t></is></c>"
|
||||
}
|
||||
|
||||
function New-NumberCellXml([string]$Ref, $Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
if ($Value -is [string] -and $Value -eq '') { return $null }
|
||||
return "<c r=""$Ref""><v>$(To-InvariantNumber $Value)</v></c>"
|
||||
}
|
||||
|
||||
function New-BoolCellXml([string]$Ref, $Value) {
|
||||
if ($null -eq $Value) { return $null }
|
||||
if ($Value -is [string] -and $Value -eq '') { return $null }
|
||||
$bool = $false
|
||||
if ($Value -is [bool]) {
|
||||
$bool = $Value
|
||||
} else {
|
||||
$text = [string]$Value
|
||||
if ($text -match '^(?i:true|1|yes|y)$') { $bool = $true }
|
||||
elseif ($text -match '^(?i:false|0|no|n)$') { $bool = $false }
|
||||
else { return $null }
|
||||
}
|
||||
$n = if ($bool) { 1 } else { 0 }
|
||||
return "<c r=""$Ref"" t=""b""><v>$n</v></c>"
|
||||
}
|
||||
|
||||
function New-CellXml([string]$Ref, $Value, [string]$Type) {
|
||||
switch ($Type) {
|
||||
'number' { return New-NumberCellXml $Ref $Value }
|
||||
'boolean' { return New-BoolCellXml $Ref $Value }
|
||||
default {
|
||||
if ($null -eq $Value) {
|
||||
return New-TextCellXml $Ref ''
|
||||
}
|
||||
return New-TextCellXml $Ref ([string]$Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WorksheetXml([string]$SheetName, [string[]]$Headers, [object[]]$Rows, [hashtable]$TypeMap) {
|
||||
$maxCol = $Headers.Count
|
||||
$lastCol = Get-ColumnName $maxCol
|
||||
$rowCount = $Rows.Count + 1
|
||||
$colsXml = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 0; $i -lt $Headers.Count; $i++) {
|
||||
$header = $Headers[$i]
|
||||
$type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' }
|
||||
$width = Get-ColumnWidth $header $type
|
||||
$colsXml.Add("<col min=""$($i + 1)"" max=""$($i + 1)"" width=""$width"" customWidth=""1"" />")
|
||||
}
|
||||
|
||||
$rowsXml = New-Object System.Collections.Generic.List[string]
|
||||
$headerCells = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 0; $i -lt $Headers.Count; $i++) {
|
||||
$headerCells.Add((New-HeaderCellXml (Get-CellRef ($i + 1) 1) $Headers[$i]))
|
||||
}
|
||||
$rowsXml.Add("<row r=""1"" spans=""1:$maxCol"" ht=""20"" customHeight=""1"">$($headerCells -join '')</row>")
|
||||
|
||||
for ($r = 0; $r -lt $Rows.Count; $r++) {
|
||||
$row = $Rows[$r]
|
||||
$cells = New-Object System.Collections.Generic.List[string]
|
||||
for ($c = 0; $c -lt $Headers.Count; $c++) {
|
||||
$header = $Headers[$c]
|
||||
$type = if (Has-MapKey $TypeMap $header) { [string]$TypeMap[$header] } else { 'string' }
|
||||
$value = $null
|
||||
if (Has-MapKey $row $header) { $value = $row[$header] }
|
||||
$cellXml = New-CellXml (Get-CellRef ($c + 1) ($r + 2)) $value $type
|
||||
if ($null -ne $cellXml) { $cells.Add($cellXml) }
|
||||
}
|
||||
$rowsXml.Add("<row r=""$($r + 2)"" spans=""1:$maxCol"">$($cells -join '')</row>")
|
||||
}
|
||||
|
||||
$sheetView = '<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft" activeCell="A2" sqref="A2"/></sheetView></sheetViews>'
|
||||
$cols = '<cols>' + ($colsXml -join '') + '</cols>'
|
||||
$sheetData = '<sheetData>' + ($rowsXml -join '') + '</sheetData>'
|
||||
$autoFilter = "<autoFilter ref=""A1:$lastCol$rowCount""/>"
|
||||
return @"
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
$sheetView
|
||||
<sheetFormatPr defaultRowHeight="18"/>
|
||||
$cols
|
||||
$sheetData
|
||||
$autoFilter
|
||||
<pageMargins left="0.25" right="0.25" top="0.5" bottom="0.5" header="0.3" footer="0.3"/>
|
||||
</worksheet>
|
||||
"@
|
||||
}
|
||||
|
||||
function Get-StylesXml {
|
||||
return @"
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<fonts count="2">
|
||||
<font>
|
||||
<sz val="11"/>
|
||||
<color rgb="FF000000"/>
|
||||
<name val="Calibri"/>
|
||||
<family val="2"/>
|
||||
<scheme val="minor"/>
|
||||
</font>
|
||||
<font>
|
||||
<b/>
|
||||
<sz val="11"/>
|
||||
<color rgb="FFFFFFFF"/>
|
||||
<name val="Calibri"/>
|
||||
<family val="2"/>
|
||||
<scheme val="minor"/>
|
||||
</font>
|
||||
</fonts>
|
||||
<fills count="2">
|
||||
<fill><patternFill patternType="none"/></fill>
|
||||
<fill><patternFill patternType="solid"><fgColor rgb="FF2D3748"/><bgColor indexed="64"/></patternFill></fill>
|
||||
</fills>
|
||||
<borders count="1">
|
||||
<border>
|
||||
<left/><right/><top/><bottom/><diagonal/>
|
||||
</border>
|
||||
</borders>
|
||||
<cellStyleXfs count="1">
|
||||
<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>
|
||||
</cellStyleXfs>
|
||||
<cellXfs count="2">
|
||||
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
||||
<xf numFmtId="0" fontId="1" fillId="1" borderId="0" xfId="0" applyFont="1" applyFill="1"/>
|
||||
</cellXfs>
|
||||
<cellStyles count="1">
|
||||
<cellStyle name="Normal" xfId="0" builtinId="0"/>
|
||||
</cellStyles>
|
||||
</styleSheet>
|
||||
"@
|
||||
}
|
||||
|
||||
function Get-WorkbookXml([string[]]$SheetNames) {
|
||||
$sheetsXml = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 0; $i -lt $SheetNames.Count; $i++) {
|
||||
$sheetsXml.Add("<sheet name=""$(Escape-Xml $SheetNames[$i])"" sheetId=""$($i + 1)"" r:id=""rId$($i + 1)""/>")
|
||||
}
|
||||
return @"
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets>
|
||||
$($sheetsXml -join '')
|
||||
</sheets>
|
||||
</workbook>
|
||||
"@
|
||||
}
|
||||
|
||||
function Get-WorkbookRelsXml([int]$SheetCount) {
|
||||
$rels = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 1; $i -le $SheetCount; $i++) {
|
||||
$rels.Add("<Relationship Id=""rId$i"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"" Target=""worksheets/sheet$i.xml""/>")
|
||||
}
|
||||
$rels.Add("<Relationship Id=""rId$($SheetCount + 1)"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"" Target=""styles.xml""/>")
|
||||
return @"
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
$($rels -join '')
|
||||
</Relationships>
|
||||
"@
|
||||
}
|
||||
|
||||
function Get-RootRelsXml {
|
||||
return @"
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>
|
||||
"@
|
||||
}
|
||||
|
||||
function Get-ContentTypesXml([int]$SheetCount) {
|
||||
$overrides = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 1; $i -le $SheetCount; $i++) {
|
||||
$overrides.Add("<Override PartName=""/xl/worksheets/sheet$i.xml"" ContentType=""application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml""/>")
|
||||
}
|
||||
$overrides.Add('<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>')
|
||||
$overrides.Add('<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>')
|
||||
return @"
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
$($overrides -join '')
|
||||
</Types>
|
||||
"@
|
||||
}
|
||||
|
||||
function Write-Xlsx([string]$Path, [hashtable]$Parts) {
|
||||
$dir = Split-Path -Parent $Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
if (Test-Path $Path) {
|
||||
Remove-Item -LiteralPath $Path -Force
|
||||
}
|
||||
$file = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::ReadWrite)
|
||||
try {
|
||||
$zip = New-Object System.IO.Compression.ZipArchive($file, [System.IO.Compression.ZipArchiveMode]::Create, $false)
|
||||
try {
|
||||
foreach ($entryName in $Parts.Keys) {
|
||||
$entry = $zip.CreateEntry($entryName)
|
||||
$stream = $entry.Open()
|
||||
$writer = New-Object System.IO.StreamWriter($stream, $utf8NoBom)
|
||||
try {
|
||||
$writer.Write([string]$Parts[$entryName])
|
||||
} finally {
|
||||
$writer.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$file.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Read-XlsxXml([string]$Path, [string]$EntryName) {
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($Path)
|
||||
try {
|
||||
$entry = $zip.GetEntry($EntryName)
|
||||
if ($null -eq $entry) { throw "Missing XLSX entry: $EntryName" }
|
||||
$stream = $entry.Open()
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($stream, $utf8NoBom)
|
||||
try { return $reader.ReadToEnd() } finally { $reader.Dispose() }
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Read-SharedStrings([string]$Path) {
|
||||
try {
|
||||
$xmlText = Read-XlsxXml $Path 'xl/sharedStrings.xml'
|
||||
} catch {
|
||||
return @()
|
||||
}
|
||||
[xml]$xml = $xmlText
|
||||
$ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
|
||||
$ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main')
|
||||
$items = $xml.SelectNodes('/x:sst/x:si', $ns)
|
||||
$values = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($item in $items) {
|
||||
$values.Add([string]$item.InnerText)
|
||||
}
|
||||
return $values.ToArray()
|
||||
}
|
||||
|
||||
function Read-WorksheetRows([string]$XmlText, [string[]]$SharedStrings) {
|
||||
[xml]$xml = $XmlText
|
||||
$ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
|
||||
$ns.AddNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main')
|
||||
$rows = $xml.SelectNodes('/x:worksheet/x:sheetData/x:row', $ns)
|
||||
$parsed = @()
|
||||
foreach ($row in $rows) {
|
||||
$cells = @{}
|
||||
foreach ($cell in @($row.ChildNodes)) {
|
||||
if ($cell.Name -ne 'c') { continue }
|
||||
$ref = [string]$cell.Attributes['r'].Value
|
||||
$col = Get-ColumnIndex (($ref -replace '\d+$', ''))
|
||||
$type = [string]$cell.Attributes['t'].Value
|
||||
$text = [string]$cell.InnerText
|
||||
if ($type -eq 's' -and $text -match '^\d+$') {
|
||||
$index = [int]$text
|
||||
if ($index -ge 0 -and $index -lt $SharedStrings.Count) {
|
||||
$text = [string]$SharedStrings[$index]
|
||||
}
|
||||
}
|
||||
$cells[$col] = $text
|
||||
}
|
||||
$parsed += ,$cells
|
||||
}
|
||||
return $parsed
|
||||
}
|
||||
|
||||
function Convert-CellValue([string]$Text, [string]$Type) {
|
||||
if ($null -eq $Text -or $Text -eq '') { return $null }
|
||||
switch ($Type) {
|
||||
'number' {
|
||||
$num = 0
|
||||
if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$num)) {
|
||||
if ([math]::Abs($num - [math]::Round($num)) -lt 0.0000001) { return [int64][math]::Round($num) }
|
||||
return $num
|
||||
}
|
||||
return $null
|
||||
}
|
||||
'boolean' {
|
||||
if ($Text -match '^(?i:true|1|yes|y)$') { return $true }
|
||||
if ($Text -match '^(?i:false|0|no|n)$') { return $false }
|
||||
return $null
|
||||
}
|
||||
default {
|
||||
return $Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Export-Cards {
|
||||
$source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$schema = Get-CardSchema $source.cards
|
||||
$cardCore = @('id', 'name', 'cost', 'kind', 'rarity', 'class', 'desc', 'image', 'fx')
|
||||
$cardExtras = @($schema.Keys | Where-Object { $_ -notin $cardCore } | Sort-Object)
|
||||
$cardHeaders = @($cardCore + $cardExtras)
|
||||
|
||||
$maxDeckSize = 0
|
||||
foreach ($deckEntry in $source.starterDecks.PSObject.Properties) {
|
||||
$deckSize = @($deckEntry.Value).Count
|
||||
if ($deckSize -gt $maxDeckSize) {
|
||||
$maxDeckSize = $deckSize
|
||||
}
|
||||
}
|
||||
if ($maxDeckSize -lt 1) { $maxDeckSize = 1 }
|
||||
|
||||
$starterDeckHeaders = New-Object System.Collections.Generic.List[string]
|
||||
$starterDeckHeaders.Add('class')
|
||||
for ($i = 1; $i -le $maxDeckSize; $i++) {
|
||||
$starterDeckHeaders.Add("slot$i")
|
||||
}
|
||||
|
||||
$cardRows = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($cardEntry in $source.cards.PSObject.Properties) {
|
||||
$cardId = $cardEntry.Name
|
||||
$card = $cardEntry.Value
|
||||
$row = [ordered]@{ id = $cardId }
|
||||
foreach ($header in $cardHeaders) {
|
||||
if ($header -eq 'id') { continue }
|
||||
if ($card.PSObject.Properties.Name -contains $header) {
|
||||
$row[$header] = $card.$header
|
||||
} else {
|
||||
$row[$header] = $null
|
||||
}
|
||||
}
|
||||
$cardRows.Add($row)
|
||||
}
|
||||
|
||||
$deckRows = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($deckEntry in $source.starterDecks.PSObject.Properties) {
|
||||
$cls = $deckEntry.Name
|
||||
$deck = @($deckEntry.Value)
|
||||
$row = [ordered]@{ class = $cls }
|
||||
for ($i = 1; $i -le $maxDeckSize; $i++) {
|
||||
$key = "slot$i"
|
||||
$row[$key] = if ($i -le $deck.Count) { $deck[$i - 1] } else { $null }
|
||||
}
|
||||
$deckRows.Add($row)
|
||||
}
|
||||
|
||||
$cardSheet = Get-WorksheetXml 'Cards' $cardHeaders $cardRows $schema
|
||||
$deckTypeMap = [ordered]@{ class = 'string' }
|
||||
for ($i = 1; $i -le $maxDeckSize; $i++) { $deckTypeMap["slot$i"] = 'string' }
|
||||
$deckSheet = Get-WorksheetXml 'StarterDecks' $starterDeckHeaders $deckRows $deckTypeMap
|
||||
|
||||
$parts = [ordered]@{
|
||||
'[Content_Types].xml' = (Get-ContentTypesXml 2)
|
||||
'_rels/.rels' = (Get-RootRelsXml)
|
||||
'xl/workbook.xml' = (Get-WorkbookXml @('Cards', 'StarterDecks'))
|
||||
'xl/_rels/workbook.xml.rels' = (Get-WorkbookRelsXml 2)
|
||||
'xl/styles.xml' = (Get-StylesXml)
|
||||
'xl/worksheets/sheet1.xml' = $cardSheet
|
||||
'xl/worksheets/sheet2.xml' = $deckSheet
|
||||
}
|
||||
|
||||
Write-Host "Source JSON: $JsonPath"
|
||||
Write-Host "Target XLSX: $XlsxPath"
|
||||
Write-Xlsx $XlsxPath $parts
|
||||
Write-Host "Excel export complete: $XlsxPath"
|
||||
}
|
||||
|
||||
function Import-Cards {
|
||||
$source = Get-Content -LiteralPath $JsonPath -Raw -Encoding utf8 | ConvertFrom-Json
|
||||
$schema = Get-CardSchema $source.cards
|
||||
$origCardOrders = @{}
|
||||
foreach ($cardEntry in $source.cards.PSObject.Properties) {
|
||||
$origCardOrders[$cardEntry.Name] = @($cardEntry.Value.PSObject.Properties.Name)
|
||||
}
|
||||
$origDeckOrder = @($source.starterDecks.PSObject.Properties.Name)
|
||||
|
||||
$sharedStrings = Read-SharedStrings $XlsxPath
|
||||
$cardsXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet1.xml'
|
||||
$deckXml = Read-XlsxXml $XlsxPath 'xl/worksheets/sheet2.xml'
|
||||
$cardRowsRaw = Read-WorksheetRows $cardsXml $sharedStrings
|
||||
$deckRowsRaw = Read-WorksheetRows $deckXml $sharedStrings
|
||||
|
||||
if ($cardRowsRaw.Count -lt 2) { throw 'Cards sheet has no data rows.' }
|
||||
if ($deckRowsRaw.Count -lt 2) { throw 'StarterDecks sheet has no data rows.' }
|
||||
|
||||
$cardHeaderMap = $cardRowsRaw[0]
|
||||
$cardHeaders = @($cardHeaderMap.Keys | Sort-Object)
|
||||
$orderedCardHeaders = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($col in $cardHeaders) {
|
||||
$header = $cardHeaderMap[$col]
|
||||
if ([string]::IsNullOrWhiteSpace($header)) { continue }
|
||||
$orderedCardHeaders.Add($header)
|
||||
}
|
||||
|
||||
$newCards = [ordered]@{}
|
||||
for ($r = 1; $r -lt $cardRowsRaw.Count; $r++) {
|
||||
$row = $cardRowsRaw[$r]
|
||||
$cardId = $null
|
||||
$rowValues = @{}
|
||||
for ($c = 0; $c -lt $orderedCardHeaders.Count; $c++) {
|
||||
$header = $orderedCardHeaders[$c]
|
||||
$text = $null
|
||||
if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] }
|
||||
if ($header -eq 'id') {
|
||||
$cardId = [string]$text
|
||||
continue
|
||||
}
|
||||
$rowValues[$header] = $text
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($cardId)) {
|
||||
$cardObj = [ordered]@{}
|
||||
$fieldOrder = New-Object System.Collections.Generic.List[string]
|
||||
if ($origCardOrders.ContainsKey($cardId)) {
|
||||
foreach ($name in @($origCardOrders[$cardId])) {
|
||||
if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) {
|
||||
$fieldOrder.Add($name)
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($name in $orderedCardHeaders) {
|
||||
if ($name -ne 'id' -and -not $fieldOrder.Contains($name)) {
|
||||
$fieldOrder.Add($name)
|
||||
}
|
||||
}
|
||||
foreach ($header in $fieldOrder) {
|
||||
$text = $null
|
||||
if (Has-MapKey $rowValues $header) { $text = $rowValues[$header] }
|
||||
$type = if (Has-MapKey $schema $header) { [string]$schema[$header] } else { 'string' }
|
||||
$value = Convert-CellValue $text $type
|
||||
if ($null -eq $value) { continue }
|
||||
$cardObj[$header] = $value
|
||||
}
|
||||
$newCards[$cardId] = $cardObj
|
||||
}
|
||||
}
|
||||
|
||||
$deckHeaderMap = $deckRowsRaw[0]
|
||||
$deckHeaderCols = @($deckHeaderMap.Keys | Sort-Object)
|
||||
$orderedDeckHeaders = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($col in $deckHeaderCols) {
|
||||
$header = $deckHeaderMap[$col]
|
||||
if ([string]::IsNullOrWhiteSpace($header)) { continue }
|
||||
$orderedDeckHeaders.Add($header)
|
||||
}
|
||||
|
||||
$newDecks = [ordered]@{}
|
||||
for ($r = 1; $r -lt $deckRowsRaw.Count; $r++) {
|
||||
$row = $deckRowsRaw[$r]
|
||||
$cls = $null
|
||||
$deckValues = @{}
|
||||
for ($c = 0; $c -lt $orderedDeckHeaders.Count; $c++) {
|
||||
$header = $orderedDeckHeaders[$c]
|
||||
$text = $null
|
||||
if (Has-MapKey $row ($c + 1)) { $text = $row[$c + 1] }
|
||||
if ($header -eq 'class') {
|
||||
$cls = [string]$text
|
||||
continue
|
||||
}
|
||||
$deckValues[$header] = $text
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($cls)) {
|
||||
$deck = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($header in $orderedDeckHeaders) {
|
||||
if ($header -eq 'class') { continue }
|
||||
$text = $null
|
||||
if (Has-MapKey $deckValues $header) { $text = $deckValues[$header] }
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$text)) {
|
||||
$deck.Add([string]$text)
|
||||
}
|
||||
}
|
||||
$newDecks[$cls] = $deck.ToArray()
|
||||
}
|
||||
}
|
||||
|
||||
if ($origDeckOrder.Count -gt 0) {
|
||||
$orderedDecks = [ordered]@{}
|
||||
foreach ($cls in $origDeckOrder) {
|
||||
if (Has-MapKey $newDecks $cls) {
|
||||
$orderedDecks[$cls] = $newDecks[$cls]
|
||||
}
|
||||
}
|
||||
foreach ($entry in $newDecks.GetEnumerator()) {
|
||||
if (-not (Has-MapKey $orderedDecks $entry.Key)) {
|
||||
$orderedDecks[$entry.Key] = $entry.Value
|
||||
}
|
||||
}
|
||||
$newDecks = $orderedDecks
|
||||
}
|
||||
|
||||
$out = [ordered]@{
|
||||
cards = $newCards
|
||||
starterDecks = $newDecks
|
||||
}
|
||||
|
||||
$json = $out | ConvertTo-Json -Depth 64
|
||||
Write-Host "Source XLSX: $XlsxPath"
|
||||
Write-Host "Target JSON: $OutJsonPath"
|
||||
[System.IO.File]::WriteAllText($OutJsonPath, $json, $utf8NoBom)
|
||||
Write-Host "JSON import complete: $OutJsonPath"
|
||||
}
|
||||
|
||||
switch ($Action) {
|
||||
'export' { Export-Cards }
|
||||
'import' { Import-Cards }
|
||||
}
|
||||
@@ -11,14 +11,14 @@ self:RenderCharacterSelect()`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' },
|
||||
]),
|
||||
method('RenderCharacterSelect', `local base = "/ui/SelectUIGroup/CharacterSelectHud"
|
||||
local arts = { { p = "/WarriorButton/Art", c = "warrior" }, { p = "/MageButton/Art", c = "magician" }, { p = "/BanditButton/Art", c = "bandit" } }
|
||||
local arts = { { p = "/WarriorButton/Art", c = "warrior" }, { p = "/MageButton/Art", c = "magician" }, { p = "/BanditButton/Art", c = "rogue" } }
|
||||
for i = 1, #arts do
|
||||
local e = _EntityService:GetEntityByPath(base .. arts[i].p)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits[arts[i].c] ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits[arts[i].c]
|
||||
end
|
||||
end
|
||||
local btns = { { p = "/WarriorButton", c = "warrior" }, { p = "/MageButton", c = "magician" }, { p = "/BanditButton", c = "bandit" } }
|
||||
local btns = { { p = "/WarriorButton", c = "warrior" }, { p = "/MageButton", c = "magician" }, { p = "/BanditButton", c = "rogue" } }
|
||||
for i = 1, #btns do
|
||||
local e = _EntityService:GetEntityByPath(base .. btns[i].p)
|
||||
if e ~= nil then
|
||||
@@ -44,9 +44,9 @@ if self.SelectedClass == "warrior" then
|
||||
eng = "Warrior"
|
||||
btnName = "/WarriorButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "방어를 쌓고 버티다 강하게 역공하는 단단한 탱커."
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
elseif self.SelectedClass == "rogue" then
|
||||
name = "도적"
|
||||
eng = "Thief"
|
||||
eng = "Rogue"
|
||||
btnName = "/BanditButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "표창 난사와 독으로 빠르게 몰아치는 민첩한 직업."
|
||||
elseif self.SelectedClass == "magician" then
|
||||
@@ -65,7 +65,7 @@ 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
|
||||
method('StartNewGame', `if self.SelectedClass ~= "warrior" and self.SelectedClass ~= "rogue" and self.SelectedClass ~= "magician" then
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/SelectedClassStatus", "직업을 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -52,14 +52,14 @@ if self.HandCostZeroThisTurn == true then
|
||||
elseif c.useAllEnergy == true then
|
||||
cost = self.Energy
|
||||
end
|
||||
if c.kind == "Skill" and self.NextSkillCostZero == true then
|
||||
if c.kind == "Skill" and c.useAllEnergy ~= true and self.NextSkillCostZero == true then
|
||||
cost = 0
|
||||
skillFree = true
|
||||
end
|
||||
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
||||
if c.kind == "Skill" and c.useAllEnergy ~= true and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
||||
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
|
||||
end
|
||||
if self.CombatCardCostReduction ~= nil and self.CombatCardCostReduction[cardId] ~= nil then
|
||||
if c.useAllEnergy ~= true and self.CombatCardCostReduction ~= nil and self.CombatCardCostReduction[cardId] ~= nil then
|
||||
cost = math.max(0, cost - self.CombatCardCostReduction[cardId])
|
||||
end
|
||||
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
|
||||
@@ -381,7 +381,7 @@ for i = 1, #self.Monsters do
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
local dmg = amount
|
||||
if m.vuln > 0 then
|
||||
if isAttack == true and m.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
if m.block > 0 then
|
||||
@@ -392,6 +392,12 @@ for i = 1, #self.Monsters do
|
||||
m.hp = m.hp - dmg
|
||||
if dmg > 0 then
|
||||
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
|
||||
if isAttack == true then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
self:ApplyPoisonToMonster(m, poison)
|
||||
end
|
||||
end
|
||||
end
|
||||
self:ShowDmgPop(i, dmg)
|
||||
self:MonsterHitMotion(i)
|
||||
@@ -409,6 +415,7 @@ self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
return killCount > 0`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'isAttack' },
|
||||
], 0, 'boolean'),
|
||||
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
@@ -682,7 +689,10 @@ self.NextTurnAddCards = {}
|
||||
self:UpdateDiscardPrompt()
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()`),
|
||||
method('CheckCombatEnd', `local anyAlive = false
|
||||
method('CheckCombatEnd', `if self.CombatOver == true then
|
||||
return
|
||||
end
|
||||
local anyAlive = false
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then anyAlive = true; break end
|
||||
end
|
||||
@@ -707,7 +717,7 @@ if anyAlive == false then
|
||||
end
|
||||
end
|
||||
if node ~= nil and node.type == "boss" then
|
||||
if self.PlayerJob == "" and self.Floor < self.RunLength then
|
||||
if self:CanAdvanceJob() == true and self.Floor < self.RunLength then
|
||||
self:ShowJobChoice()
|
||||
else
|
||||
if self.PlayerJob ~= "" then self:AwardSouls(1) end
|
||||
|
||||
@@ -10,7 +10,11 @@ for i = #list, 2, -1 do
|
||||
\tlocal j = math.random(1, i)
|
||||
\tlist[i], list[j] = list[j], list[i]
|
||||
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]),
|
||||
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/EndTurnButton")
|
||||
method('BindButtons', `if self.ButtonsBound == true then
|
||||
return
|
||||
end
|
||||
self.ButtonsBound = true
|
||||
local endTurn = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/EndTurnButton")
|
||||
if endTurn ~= nil and (endTurn.ButtonComponent ~= nil or endTurn:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.EndTurnHandler ~= nil then
|
||||
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
|
||||
@@ -471,6 +475,7 @@ for i = 1, amount do
|
||||
\tlocal cardId = table.remove(self.DrawPile)
|
||||
\ttable.insert(drawnCards, cardId)
|
||||
\tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1
|
||||
\tself:ApplyDrawTrigger()
|
||||
\tif #self.Hand >= 10 then
|
||||
\t\ttable.insert(self.DiscardPile, cardId)
|
||||
\t\tself:TriggerSly(cardId)
|
||||
|
||||
@@ -77,7 +77,7 @@ if thiefTab ~= nil and (thiefTab.ButtonComponent ~= nil or thiefTab:AddComponent
|
||||
thiefTab:DisconnectEvent(ButtonClickEvent, self.ThiefDeckTabHandler)
|
||||
self.ThiefDeckTabHandler = nil
|
||||
end
|
||||
self.ThiefDeckTabHandler = thiefTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("bandit") end)
|
||||
self.ThiefDeckTabHandler = thiefTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("rogue") end)
|
||||
end
|
||||
local mageTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/MageTab")
|
||||
if mageTab ~= nil and (mageTab.ButtonComponent ~= nil or mageTab:AddComponent("ButtonComponent") ~= nil) then
|
||||
@@ -101,8 +101,8 @@ end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], N
|
||||
return
|
||||
end
|
||||
local className = self.SelectedClass
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
|
||||
className = "bandit"
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "rogue" then
|
||||
className = "rogue"
|
||||
end
|
||||
self.CodexMode = false
|
||||
self.ClassDeckMode = true
|
||||
@@ -119,32 +119,30 @@ self:Toast("테스트 카드 추가 모드")`),
|
||||
end
|
||||
self.ClassDeckCards = {}
|
||||
self.ClassDeckTitle = "직업 덱"
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
|
||||
className = "bandit"
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "rogue" then
|
||||
className = "rogue"
|
||||
end
|
||||
self.ClassDeckClass = className
|
||||
local allowed = {}
|
||||
local group = nil
|
||||
if self.ClassGroups ~= nil then
|
||||
group = self.ClassGroups[className]
|
||||
end
|
||||
if group == nil then
|
||||
group = { className }
|
||||
end
|
||||
for i = 1, #group do
|
||||
allowed[group[i]] = true
|
||||
end
|
||||
if className == "warrior" then
|
||||
allowed["warrior"] = true
|
||||
allowed["fighter"] = true
|
||||
allowed["page"] = true
|
||||
allowed["spearman"] = true
|
||||
self.ClassDeckTitle = "전사 전체 덱"
|
||||
elseif className == "magician" then
|
||||
allowed["magician"] = true
|
||||
allowed["firepoison"] = true
|
||||
allowed["icelightning"] = true
|
||||
allowed["cleric"] = true
|
||||
self.ClassDeckTitle = "마법사 전체 덱"
|
||||
else
|
||||
allowed["bandit"] = true
|
||||
allowed["shiv"] = true
|
||||
allowed["poisoner"] = true
|
||||
allowed["trickster"] = true
|
||||
self.ClassDeckTitle = "도적 전체 덱"
|
||||
end
|
||||
for id, c in pairs(self.Cards) do
|
||||
if c ~= nil and c.curse ~= true and allowed[c.class] == true then
|
||||
if c ~= nil and c.curse ~= true and c.token ~= true and allowed[c.class] == true then
|
||||
table.insert(self.ClassDeckCards, id)
|
||||
end
|
||||
end
|
||||
@@ -162,7 +160,7 @@ self:RenderAllDeck()
|
||||
self:RenderClassDeckTabs()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
|
||||
method('RenderClassDeckTabs', `local tabs = {
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/WarriorTab", cls = "warrior" },
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/ThiefTab", cls = "bandit" },
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/ThiefTab", cls = "rogue" },
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/MageTab", cls = "magician" },
|
||||
}
|
||||
for i = 1, #tabs do
|
||||
|
||||
@@ -3,6 +3,42 @@ 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 handMethods = [
|
||||
method('ApplyDrawTrigger', `if self.Monsters == nil then
|
||||
return
|
||||
end
|
||||
local drawDamage = self:AddPowerFieldTotal("drawDamage") + (self.DrawDamageThisTurn or 0)
|
||||
local drawPoison = self:AddPowerFieldTotal("drawPoison") + (self.DrawPoisonThisTurn or 0)
|
||||
if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison > 0) then
|
||||
for mi = 1, #self.Monsters do
|
||||
local m2 = self.Monsters[mi]
|
||||
if m2 ~= nil and m2.alive == true then
|
||||
local dmg = drawDamage or 0
|
||||
if m2.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
if m2.block > 0 then
|
||||
local absorbed = math.min(m2.block, dmg)
|
||||
m2.block = m2.block - absorbed
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
if drawPoison ~= nil and drawPoison > 0 then
|
||||
self:ApplyPoisonToMonster(m2, drawPoison)
|
||||
end
|
||||
if dmg > 0 then
|
||||
m2.hp = m2.hp - dmg
|
||||
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
|
||||
end
|
||||
self:ShowDmgPop(mi, dmg)
|
||||
self:MonsterHitMotion(mi)
|
||||
if m2.hp <= 0 then
|
||||
m2.hp = 0
|
||||
self:KillMonster(m2.slot)
|
||||
end
|
||||
end
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end`),
|
||||
method('GetHandSlotX', `local n = 0
|
||||
if self.Hand ~= nil then
|
||||
n = #self.Hand
|
||||
@@ -311,7 +347,7 @@ end
|
||||
if c.damagePerCardDrawnThisCombat ~= nil then
|
||||
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
|
||||
end
|
||||
if c.class == "Attack" and (self.TurnCardsPlayedThisTurn or 0) == 0 and c.firstCardDamageBonus ~= nil then
|
||||
if c.kind == "Attack" and (self.TurnCardsPlayedThisTurn or 0) == 0 and c.firstCardDamageBonus ~= nil then
|
||||
base2 = base2 + c.firstCardDamageBonus
|
||||
end
|
||||
if c.class == "shiv" then
|
||||
@@ -506,7 +542,7 @@ if c.kind == "Attack" then
|
||||
local function resolveAttackRound()
|
||||
local roundKilled = false
|
||||
if useAoe == true then
|
||||
local killed = self:DealDamageToAllMonsters(total)
|
||||
local killed = self:DealDamageToAllMonsters(total, true)
|
||||
if killed == true then roundKilled = true end
|
||||
elseif c.randomTargetEachHit == true then
|
||||
for h = 1, hitN do
|
||||
@@ -681,39 +717,6 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
|
||||
end
|
||||
end
|
||||
end
|
||||
local drawDamage = self:AddPowerFieldTotal("drawDamage") + (self.DrawDamageThisTurn or 0)
|
||||
local drawPoison = self:AddPowerFieldTotal("drawPoison") + (self.DrawPoisonThisTurn or 0)
|
||||
if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison > 0) then
|
||||
for mi = 1, #self.Monsters do
|
||||
local m2 = self.Monsters[mi]
|
||||
if m2 ~= nil and m2.alive == true then
|
||||
local dmg = drawDamage or 0
|
||||
if m2.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
if m2.block > 0 then
|
||||
local absorbed = math.min(m2.block, dmg)
|
||||
m2.block = m2.block - absorbed
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
if drawPoison ~= nil and drawPoison > 0 then
|
||||
self:ApplyPoisonToMonster(m2, drawPoison)
|
||||
end
|
||||
if dmg > 0 then
|
||||
m2.hp = m2.hp - dmg
|
||||
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
|
||||
end
|
||||
self:ShowDmgPop(mi, dmg)
|
||||
self:MonsterHitMotion(mi)
|
||||
if m2.hp <= 0 then
|
||||
m2.hp = 0
|
||||
self:KillMonster(m2.slot)
|
||||
end
|
||||
end
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end
|
||||
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
||||
self:AddCardsToHand("Shiv", c.addShiv)
|
||||
end`, [
|
||||
|
||||
@@ -1,9 +1,50 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
import { method } from '../lib/codeblock.mjs';
|
||||
|
||||
export const jobMethods = [
|
||||
method('ShowJobChoice', `self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
|
||||
method('BaseClassLabel', `if classId == "warrior" then
|
||||
return "전사"
|
||||
elseif classId == "rogue" then
|
||||
return "Rogue"
|
||||
elseif classId == "magician" then
|
||||
return "마법사"
|
||||
end
|
||||
return "플레이어"`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'classId' }], 0, 'string'),
|
||||
method('CurrentClassId', `if self.PlayerJob ~= nil and self.PlayerJob ~= "" then
|
||||
return self.PlayerJob
|
||||
end
|
||||
return self.SelectedClass or ""`, [], 0, 'string'),
|
||||
method('GetPlayableClasses', `local current = self:CurrentClassId()
|
||||
if current == nil or current == "" then
|
||||
return {}
|
||||
end
|
||||
if self.ClassLineages ~= nil and self.ClassLineages[current] ~= nil then
|
||||
return self.ClassLineages[current]
|
||||
end
|
||||
return { current }`, [], 0, 'any'),
|
||||
method('CanUseClassCard', `if cardClass == nil or cardClass == "" then
|
||||
return false
|
||||
end
|
||||
if cardClass == "curse" then
|
||||
return true
|
||||
end
|
||||
local playable = self:GetPlayableClasses()
|
||||
for i = 1, #playable do
|
||||
if playable[i] == cardClass then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardClass' }], 0, 'boolean'),
|
||||
method('CanAdvanceJob', `local current = self:CurrentClassId()
|
||||
if current == nil or current == "" or self.Jobs == nil then
|
||||
return false
|
||||
end
|
||||
local opts = self.Jobs[current]
|
||||
return opts ~= nil and #opts > 0`, [], 0, 'boolean'),
|
||||
method('ShowJobChoice', `if self:CanAdvanceJob() ~= true then
|
||||
self:ContinueAfterBoss()
|
||||
return
|
||||
end
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", true)`),
|
||||
method('PickJobReward', `self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", false)
|
||||
@@ -20,9 +61,13 @@ if kind == "relic" then
|
||||
else
|
||||
self:ShowJobSelect()
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
|
||||
method('ShowJobSelect', `local opts = self.Jobs[self.SelectedClass]
|
||||
method('ShowJobSelect', `local current = self:CurrentClassId()
|
||||
local opts = nil
|
||||
if self.Jobs ~= nil then
|
||||
opts = self.Jobs[current]
|
||||
end
|
||||
if opts == nil then
|
||||
opts = self.Jobs["warrior"]
|
||||
opts = {}
|
||||
end
|
||||
self.JobOpts = opts
|
||||
for i = 1, 3 do
|
||||
@@ -41,36 +86,30 @@ for i = 1, 3 do
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", true)`),
|
||||
method('JobLabel', `if self.PlayerJob ~= "" and self.Jobs ~= nil then
|
||||
for cls, list in pairs(self.Jobs) do
|
||||
for i = 1, #list do
|
||||
if list[i].id == self.PlayerJob then
|
||||
return list[i].name
|
||||
end
|
||||
end
|
||||
end
|
||||
method('JobLabel', `if self.PlayerJob ~= "" and self.JobMeta ~= nil and self.JobMeta[self.PlayerJob] ~= nil then
|
||||
return self.JobMeta[self.PlayerJob].name
|
||||
end
|
||||
if self.SelectedClass == "warrior" then
|
||||
return "전사"
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
return "도적"
|
||||
elseif self.SelectedClass == "magician" then
|
||||
return "마법사"
|
||||
end
|
||||
return "플레이어"`, [], 0, 'string'),
|
||||
method('SetJob', `self.PlayerJob = jobId
|
||||
return self:BaseClassLabel(self.SelectedClass)`, [], 0, 'string'),
|
||||
method('SetJob', `local current = self:CurrentClassId()
|
||||
local starter = ""
|
||||
local opts = self.Jobs[self.SelectedClass] or {}
|
||||
local tier = 2
|
||||
local opts = {}
|
||||
if self.Jobs ~= nil and self.Jobs[current] ~= nil then
|
||||
opts = self.Jobs[current]
|
||||
end
|
||||
for i = 1, #opts do
|
||||
if opts[i].id == jobId then
|
||||
starter = opts[i].starter
|
||||
starter = opts[i].starter or ""
|
||||
tier = opts[i].tier or 2
|
||||
break
|
||||
end
|
||||
end
|
||||
self.PlayerJob = jobId
|
||||
if starter ~= "" then
|
||||
table.insert(self.RunDeck, starter)
|
||||
local sc = self.Cards[starter]
|
||||
if sc ~= nil then
|
||||
self:Toast("2차 전직: " .. self:JobLabel() .. "! 신규 카드 — " .. sc.name)
|
||||
self:Toast(tostring(tier) .. "차 전직: " .. self:JobLabel() .. "! 신규 카드 - " .. sc.name)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
|
||||
21
tools/deck/cb/layout.mjs
Normal file
21
tools/deck/cb/layout.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const layoutMethods = [
|
||||
method('PositionMonsterSlot', `local monster = self.Monsters[slot]
|
||||
if monster == nil or monster.entity == nil or not isvalid(monster.entity) then
|
||||
return
|
||||
end
|
||||
local transform = monster.entity.TransformComponent
|
||||
if transform == nil then
|
||||
return
|
||||
end
|
||||
local worldPos = transform.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(worldPos.x, worldPos.y + ${HEAD_OFFSET_Y}))
|
||||
local uipos = _UILogic:ScreenToUIPosition(screen)
|
||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot))
|
||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||
slotEntity.UITransformComponent.anchoredPosition = uipos
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
];
|
||||
34
tools/deck/cb/navigation.mjs
Normal file
34
tools/deck/cb/navigation.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const navigationMethods = [
|
||||
method('GoLobbyMap', `self.LobbyTpTries = 0
|
||||
local eventId = 0
|
||||
local function tryTeleport()
|
||||
self.LobbyTpTries = self.LobbyTpTries + 1
|
||||
local localPlayer = _UserService.LocalPlayer
|
||||
if localPlayer ~= nil then
|
||||
if localPlayer.CurrentMapName ~= "${LOBBY_MAP}" then
|
||||
_TeleportService:TeleportToMapPosition(localPlayer, ${LOBBY_SPAWN}, "${LOBBY_MAP}")
|
||||
end
|
||||
_TimerService:ClearTimer(eventId)
|
||||
elseif self.LobbyTpTries > 50 then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(tryTeleport, 0.1)`),
|
||||
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((mapName) => `"${mapName}"`).join(', ')} }
|
||||
local target = maps[self.Floor]
|
||||
if target == nil then
|
||||
return
|
||||
end
|
||||
local localPlayer = _UserService.LocalPlayer
|
||||
if localPlayer == nil then
|
||||
return
|
||||
end
|
||||
if localPlayer.CurrentMapName == target then
|
||||
return
|
||||
end
|
||||
_TeleportService:TeleportToMapPosition(localPlayer, Vector3(-6, 0.03, 0), target)`),
|
||||
];
|
||||
18
tools/deck/cb/npc.mjs
Normal file
18
tools/deck/cb/npc.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const npcMethods = [
|
||||
method('OnLobbyNpcInteract', `if self.RunActive == true then
|
||||
return
|
||||
end
|
||||
if id == "run" then
|
||||
self:ShowCharacterSelect()
|
||||
elseif id == "codex" then
|
||||
self:ShowCodex()
|
||||
elseif id == "shop" then
|
||||
self:ShowSoulShop()
|
||||
elseif id == "board" then
|
||||
self:ShowBoard()
|
||||
end`, [{ Type: 'string', DefaultValue: '""', SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
];
|
||||
@@ -1,311 +1,296 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const renderMethods = [
|
||||
method('BuffsLabel', `local parts = {}
|
||||
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
|
||||
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
|
||||
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
|
||||
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||
return table.concat(parts, " ")`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||
], 0, 'string'),
|
||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", m.name)
|
||||
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
||||
local intent = m.intents[m.intentIdx]
|
||||
local t = ""
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
local atk = intent.value + m.str
|
||||
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
||||
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
||||
t = "공격 " .. tostring(atk)
|
||||
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
||||
elseif intent.kind == "Debuff" then
|
||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||
elseif intent.kind == "AddCard" then
|
||||
t = "저주 카드 추가"
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Intent", t)
|
||||
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||
local shownTarget = self.TargetIndex
|
||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
||||
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
||||
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
||||
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||
elseif intent.kind == "Debuff" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||
elseif intent.kind == "AddCard" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||
else
|
||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||
end
|
||||
end
|
||||
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
||||
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
|
||||
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
||||
if self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "불가침" .. tostring(self.PlayerIntangible)
|
||||
end
|
||||
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
||||
end
|
||||
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
||||
end
|
||||
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
||||
local names = {}
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil then table.insert(names, pc.name) end
|
||||
end
|
||||
if pb ~= "" then pb = pb .. " · " end
|
||||
pb = pb .. table.concat(names, " ")
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||
self:RenderRun()`),
|
||||
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
||||
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
|
||||
local pop = _EntityService:GetEntityByPath(base)
|
||||
if pop == nil then
|
||||
return
|
||||
end
|
||||
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
|
||||
local popSeq = self.DmgPopSeq
|
||||
self:SetText(base, "")
|
||||
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
||||
local shown = tostring(math.max(0, math.floor(amount)))
|
||||
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
||||
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
||||
end
|
||||
local digits = {}
|
||||
for i = 1, string.len(shown) do
|
||||
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
||||
end
|
||||
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
||||
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
||||
end
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
local digitPath = base .. "/Digit" .. tostring(i)
|
||||
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
||||
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||
if digits[i] ~= nil then
|
||||
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
if digitEntity.UITransformComponent ~= nil then
|
||||
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
||||
end
|
||||
self:SetEntityEnabled(digitPath, true)
|
||||
else
|
||||
self:SetEntityEnabled(digitPath, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
local popPos = nil
|
||||
local m = self.Monsters[slot]
|
||||
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
||||
popPos = _UILogic:ScreenToUIPosition(screen)
|
||||
else
|
||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||
local sp = slotEntity.UITransformComponent.anchoredPosition
|
||||
popPos = Vector2(sp.x, sp.y + 76)
|
||||
end
|
||||
end
|
||||
if pop ~= nil and pop.UITransformComponent ~= nil then
|
||||
if popPos ~= nil then
|
||||
pop.UITransformComponent.anchoredPosition = popPos
|
||||
else
|
||||
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
for i = 1, 6 do
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
local p = _EntityService:GetEntityByPath(base)
|
||||
if p ~= nil and p.UITransformComponent ~= nil then
|
||||
local cur = p.UITransformComponent.anchoredPosition
|
||||
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
||||
end
|
||||
end, 0.045 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
self:SetEntityEnabled(base, false)
|
||||
end, 0.48)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
||||
if amount > 0 then
|
||||
self:SetText(base, "-" .. string.format("%d", amount))
|
||||
else
|
||||
self:SetText(base, "막음")
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent == nil then
|
||||
return
|
||||
end
|
||||
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
||||
end
|
||||
end, 0.5)`),
|
||||
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
||||
end
|
||||
local tr = lp.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
||||
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
end, 0.15)`),
|
||||
method('MonsterLunge', `local m = self.Monsters[idx]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local e = m.entity
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
m.motionBusy = false
|
||||
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
||||
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local e = m.entity
|
||||
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
||||
end
|
||||
end, 0.5)
|
||||
else
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
local seq = { 0.12, -0.12, 0 }
|
||||
for i = 1, #seq do
|
||||
local dx = seq[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
||||
end
|
||||
if i == #seq then
|
||||
m.motionBusy = false
|
||||
end
|
||||
end, 0.06 * i)
|
||||
end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
||||
if e == nil or e.UITransformComponent == nil then
|
||||
return
|
||||
end
|
||||
local ratio = 0
|
||||
if maxHp > 0 then ratio = hp / maxHp end
|
||||
if ratio < 0 then ratio = 0 end
|
||||
local w = width * ratio
|
||||
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
||||
]),
|
||||
method('PositionMonsterSlot', `local m = self.Monsters[slot]
|
||||
if m == nil or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local tr = m.entity.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local wp = tr.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y}))
|
||||
local uipos = _UILogic:ScreenToUIPosition(screen)
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.anchoredPosition = uipos
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
|
||||
self.TargetIndex = slot
|
||||
self:RenderCombat()
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
|
||||
if self.AscensionLevel > 0 then
|
||||
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
];
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const renderMethods = [
|
||||
method('BuffsLabel', `local parts = {}
|
||||
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
|
||||
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
|
||||
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
|
||||
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||
return table.concat(parts, " ")`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||
], 0, 'string'),
|
||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", m.name)
|
||||
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
||||
local intent = m.intents[m.intentIdx]
|
||||
local t = ""
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
local atk = intent.value + m.str
|
||||
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
||||
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
||||
t = "공격 " .. tostring(atk)
|
||||
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
||||
elseif intent.kind == "Debuff" then
|
||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||
elseif intent.kind == "AddCard" then
|
||||
t = "저주 카드 추가"
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Intent", t)
|
||||
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||
local shownTarget = self.TargetIndex
|
||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
||||
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
||||
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
||||
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||
elseif intent.kind == "Debuff" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||
elseif intent.kind == "AddCard" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||
else
|
||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||
end
|
||||
end
|
||||
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
||||
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
|
||||
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
||||
if self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "불가침" .. tostring(self.PlayerIntangible)
|
||||
end
|
||||
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
||||
end
|
||||
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
||||
end
|
||||
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
||||
local names = {}
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil then table.insert(names, pc.name) end
|
||||
end
|
||||
if pb ~= "" then pb = pb .. " · " end
|
||||
pb = pb .. table.concat(names, " ")
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||
self:RenderRun()`),
|
||||
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
||||
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
|
||||
local pop = _EntityService:GetEntityByPath(base)
|
||||
if pop == nil then
|
||||
return
|
||||
end
|
||||
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
|
||||
local popSeq = self.DmgPopSeq
|
||||
self:SetText(base, "")
|
||||
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
||||
local shown = tostring(math.max(0, math.floor(amount)))
|
||||
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
||||
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
||||
end
|
||||
local digits = {}
|
||||
for i = 1, string.len(shown) do
|
||||
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
||||
end
|
||||
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
||||
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
||||
end
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
local digitPath = base .. "/Digit" .. tostring(i)
|
||||
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
||||
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||
if digits[i] ~= nil then
|
||||
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
if digitEntity.UITransformComponent ~= nil then
|
||||
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
||||
end
|
||||
self:SetEntityEnabled(digitPath, true)
|
||||
else
|
||||
self:SetEntityEnabled(digitPath, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
local popPos = nil
|
||||
local m = self.Monsters[slot]
|
||||
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
||||
popPos = _UILogic:ScreenToUIPosition(screen)
|
||||
else
|
||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||
local sp = slotEntity.UITransformComponent.anchoredPosition
|
||||
popPos = Vector2(sp.x, sp.y + 76)
|
||||
end
|
||||
end
|
||||
if pop ~= nil and pop.UITransformComponent ~= nil then
|
||||
if popPos ~= nil then
|
||||
pop.UITransformComponent.anchoredPosition = popPos
|
||||
else
|
||||
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
for i = 1, 6 do
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
local p = _EntityService:GetEntityByPath(base)
|
||||
if p ~= nil and p.UITransformComponent ~= nil then
|
||||
local cur = p.UITransformComponent.anchoredPosition
|
||||
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
||||
end
|
||||
end, 0.045 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
self:SetEntityEnabled(base, false)
|
||||
end, 0.48)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
||||
if amount > 0 then
|
||||
self:SetText(base, "-" .. string.format("%d", amount))
|
||||
else
|
||||
self:SetText(base, "막음")
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent == nil then
|
||||
return
|
||||
end
|
||||
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
||||
end
|
||||
end, 0.5)`),
|
||||
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
||||
end
|
||||
local tr = lp.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
||||
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
end, 0.15)`),
|
||||
method('MonsterLunge', `local m = self.Monsters[idx]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local e = m.entity
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
m.motionBusy = false
|
||||
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
||||
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local e = m.entity
|
||||
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
||||
end
|
||||
end, 0.5)
|
||||
else
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
local seq = { 0.12, -0.12, 0 }
|
||||
for i = 1, #seq do
|
||||
local dx = seq[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
||||
end
|
||||
if i == #seq then
|
||||
m.motionBusy = false
|
||||
end
|
||||
end, 0.06 * i)
|
||||
end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
||||
if e == nil or e.UITransformComponent == nil then
|
||||
return
|
||||
end
|
||||
local ratio = 0
|
||||
if maxHp > 0 then ratio = hp / maxHp end
|
||||
if ratio < 0 then ratio = 0 end
|
||||
local w = width * ratio
|
||||
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
||||
]),
|
||||
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
|
||||
self.TargetIndex = slot
|
||||
self:RenderCombat()
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
|
||||
if self.AscensionLevel > 0 then
|
||||
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER,
|
||||
export const rewardMethods = [
|
||||
method('CardPool', `local pool = {}
|
||||
for id, c in pairs(self.Cards) do
|
||||
if c.token ~= true and (c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob)) then
|
||||
if c.token ~= true and self:CanUseClassCard(c.class) == true then
|
||||
table.insert(pool, id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, ACT_DIFFICULTY_MULTIPLIERS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, JOB_META, CLASS_GROUPS, CLASS_LINEAGES, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaClassGroupsTable, luaClassLineagesTable, luaJobMetaTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const runMethods = [
|
||||
method('StartRun', `if self.SelectedClass == "magician" then
|
||||
self.PlayerMaxHp = ${CLASSES.magician.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.magician.map(luaStr).join(', ')} }
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
self.PlayerMaxHp = ${CLASSES.bandit.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.bandit.map(luaStr).join(', ')} }
|
||||
elseif self.SelectedClass == "rogue" then
|
||||
self.PlayerMaxHp = ${CLASSES.rogue.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.rogue.map(luaStr).join(', ')} }
|
||||
else
|
||||
self.PlayerMaxHp = ${CLASSES.warrior.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.warrior.map(luaStr).join(', ')} }
|
||||
@@ -30,6 +30,9 @@ self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self.PlayerJob = ""
|
||||
${luaJobsTable(JOBS)}
|
||||
${luaJobMetaTable(JOB_META)}
|
||||
${luaClassGroupsTable(CLASS_GROUPS)}
|
||||
${luaClassLineagesTable(CLASS_LINEAGES)}
|
||||
${luaFramesTable()}
|
||||
${luaNodeIconsTable()}
|
||||
${luaCharsTable()}
|
||||
@@ -208,7 +211,8 @@ end
|
||||
if #chosen == 0 then takeFrom(g, 1) end
|
||||
if #chosen == 0 then takeFrom("combat", 1) end
|
||||
table.sort(chosen, function(a, b) return a.x < b.x end)
|
||||
local mult = 1 + (self.Floor - 1) * 0.45
|
||||
local actMultipliers = { ${ACT_DIFFICULTY_MULTIPLIERS.join(', ')} }
|
||||
local mult = actMultipliers[self.Floor] or actMultipliers[#actMultipliers]
|
||||
if g == "elite" or g == "boss" then
|
||||
mult = mult + self:AscEliteBonus()
|
||||
end
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const runEndMethods = [
|
||||
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} }
|
||||
local target = maps[self.Floor]
|
||||
if target == nil then
|
||||
return
|
||||
end
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.CurrentMapName == target then
|
||||
return
|
||||
end
|
||||
_TeleportService:TeleportToMapPosition(lp, Vector3(-6, 0.03, 0), target)`),
|
||||
method('ShowResult', `self:SetText("/ui/RunUIGroup/CombatHud/Result", text)
|
||||
local entity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/Result")
|
||||
if entity ~= nil then
|
||||
entity.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
method('EndRun', `local msg = text
|
||||
if text == "런 클리어!" and self.AscensionLevel >= self.AscensionUnlocked and self.AscensionUnlocked < 10 then
|
||||
self.AscensionUnlocked = self.AscensionUnlocked + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:SaveAscension(self.AscensionUnlocked, lp.PlayerComponent.UserId)
|
||||
end
|
||||
self:RenderAscension()
|
||||
msg = "런 클리어! 승천 " .. string.format("%d", self.AscensionUnlocked) .. " 해금!"
|
||||
end
|
||||
self:ShowResult(msg)
|
||||
self.RunActive = false
|
||||
_TimerService:SetTimerOnce(function() self:ShowLobby() end, 4)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
];
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const runEndMethods = [
|
||||
method('ShowResult', `self:SetText("/ui/RunUIGroup/CombatHud/Result", text)
|
||||
local entity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/Result")
|
||||
if entity ~= nil then
|
||||
entity.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
method('EndRun', `local msg = text
|
||||
if text == "런 클리어!" and self.AscensionLevel >= self.AscensionUnlocked and self.AscensionUnlocked < 10 then
|
||||
self.AscensionUnlocked = self.AscensionUnlocked + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:SaveAscension(self.AscensionUnlocked, lp.PlayerComponent.UserId)
|
||||
end
|
||||
self:RenderAscension()
|
||||
msg = "런 클리어! 승천 " .. string.format("%d", self.AscensionUnlocked) .. " 해금!"
|
||||
end
|
||||
self:ShowResult(msg)
|
||||
self.RunActive = false
|
||||
_TimerService:SetTimerOnce(function() self:ShowLobby() end, 4)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, A
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const stateMethods = [
|
||||
export const screensMethods = [
|
||||
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/Button_Jump", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/UIJoystick", false)
|
||||
@@ -21,14 +21,14 @@ self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
|
||||
method('ActivateUIGroups', `local function grp(n)
|
||||
local g = _EntityService:GetEntityByPath("/ui/" .. n)
|
||||
if g ~= nil then g:SetEnable(true) end
|
||||
method('ActivateUIGroups', `local function enableGroup(name)
|
||||
local group = _EntityService:GetEntityByPath("/ui/" .. name)
|
||||
if group ~= nil then group:SetEnable(true) end
|
||||
end
|
||||
grp("SelectUIGroup")
|
||||
grp("LobbyUIGroup")
|
||||
grp("RunUIGroup")
|
||||
grp("DeckUIGroup")`, [], 2),
|
||||
enableGroup("SelectUIGroup")
|
||||
enableGroup("LobbyUIGroup")
|
||||
enableGroup("RunUIGroup")
|
||||
enableGroup("DeckUIGroup")`, [], 2),
|
||||
method('ShowState', `self:HideGameHud()
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu")
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/CharacterSelectHud", state == "charselect")
|
||||
@@ -75,7 +75,7 @@ if thief ~= nil and (thief.ButtonComponent ~= nil or thief:AddComponent("ButtonC
|
||||
thief:DisconnectEvent(ButtonClickEvent, self.ThiefSelectHandler)
|
||||
self.ThiefSelectHandler = nil
|
||||
end
|
||||
self.ThiefSelectHandler = thief:ConnectEvent(ButtonClickEvent, function() self:SelectClass("bandit") end)
|
||||
self.ThiefSelectHandler = thief:ConnectEvent(ButtonClickEvent, function() self:SelectClass("rogue") end)
|
||||
end
|
||||
local mage = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/MageButton")
|
||||
if mage ~= nil and (mage.ButtonComponent ~= nil or mage:AddComponent("ButtonComponent") ~= nil) then
|
||||
@@ -135,44 +135,17 @@ self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)
|
||||
self:BindLobbyButtons()
|
||||
self:BindMenuButtons()
|
||||
self:GoLobbyMap()`),
|
||||
method('GoLobbyMap', `self.LobbyTpTries = 0
|
||||
local eventId = 0
|
||||
local function go()
|
||||
self.LobbyTpTries = self.LobbyTpTries + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
if lp.CurrentMapName ~= "${LOBBY_MAP}" then
|
||||
_TeleportService:TeleportToMapPosition(lp, ${LOBBY_SPAWN}, "${LOBBY_MAP}")
|
||||
end
|
||||
_TimerService:ClearTimer(eventId)
|
||||
elseif self.LobbyTpTries > 50 then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(go, 0.1)`),
|
||||
method('OnLobbyNpcInteract', `if self.RunActive == true then
|
||||
return
|
||||
end
|
||||
if id == "run" then
|
||||
self:ShowCharacterSelect()
|
||||
elseif id == "codex" then
|
||||
self:ShowCodex()
|
||||
elseif id == "shop" then
|
||||
self:ShowSoulShop()
|
||||
elseif id == "board" then
|
||||
self:ShowBoard()
|
||||
end`, [{ Type: 'string', DefaultValue: '""', SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('RenderSoulLabel', `local s = self.SoulPoints or 0
|
||||
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "영혼 " .. string.format("%d", s))
|
||||
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "영혼 " .. string.format("%d", s))`),
|
||||
method('RenderSoulLabel', `local soulPoints = self.SoulPoints or 0
|
||||
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "영혼 " .. string.format("%d", soulPoints))
|
||||
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "영혼 " .. string.format("%d", soulPoints))`),
|
||||
method('BindLobbyButtons', `if self.LobbyBound == true then
|
||||
return
|
||||
end
|
||||
self.LobbyBound = true
|
||||
local function bindClick(path, fn)
|
||||
local e = _EntityService:GetEntityByPath(path)
|
||||
if e ~= nil and (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
|
||||
e:ConnectEvent(ButtonClickEvent, fn)
|
||||
local function bindClick(path, handler)
|
||||
local entity = _EntityService:GetEntityByPath(path)
|
||||
if entity ~= nil and (entity.ButtonComponent ~= nil or entity:AddComponent("ButtonComponent") ~= nil) then
|
||||
entity:ConnectEvent(ButtonClickEvent, handler)
|
||||
end
|
||||
end
|
||||
bindClick("/ui/LobbyUIGroup/LobbyHud/AscMinus", function() self:AdjustAscension(-1) end)
|
||||
@@ -3,7 +3,10 @@
|
||||
import { POTIONS } from './lib/data.mjs';
|
||||
import { prop, codeblock, RUN_LENGTH } from './lib/codeblock.mjs';
|
||||
import { bootMethods } from './cb/boot.mjs';
|
||||
import { stateMethods } from './cb/state.mjs';
|
||||
import { screensMethods } from './cb/screens.mjs';
|
||||
import { npcMethods } from './cb/npc.mjs';
|
||||
import { navigationMethods } from './cb/navigation.mjs';
|
||||
import { layoutMethods } from './cb/layout.mjs';
|
||||
import { soulMethods } from './cb/soul.mjs';
|
||||
import { charSelectMethods } from './cb/charselect.mjs';
|
||||
import { runMethods } from './cb/run.mjs';
|
||||
@@ -45,6 +48,9 @@ function writeCodeblocks() {
|
||||
prop('any', 'AscPlusHandler'),
|
||||
prop('any', 'JobOpts'),
|
||||
prop('any', 'Jobs'),
|
||||
prop('any', 'JobMeta'),
|
||||
prop('any', 'ClassGroups'),
|
||||
prop('any', 'ClassLineages'),
|
||||
prop('number', 'AscensionLevel', '0'),
|
||||
prop('number', 'AscensionUnlocked', '0'),
|
||||
prop('any', 'StartGameHandler'),
|
||||
@@ -58,6 +64,7 @@ function writeCodeblocks() {
|
||||
prop('any', 'AllDeckCloseHandler'),
|
||||
prop('number', 'SoulPoints', '0'),
|
||||
prop('boolean', 'LobbyBound', 'false'),
|
||||
prop('boolean', 'ButtonsBound', 'false'),
|
||||
prop('number', 'LobbyTpTries', '0'),
|
||||
prop('boolean', 'CodexMode', 'false'),
|
||||
prop('any', 'CodexCards'),
|
||||
@@ -124,6 +131,9 @@ function writeCodeblocks() {
|
||||
prop('boolean', 'HandCostZeroThisTurn', 'false'),
|
||||
prop('boolean', 'DrawDisabledThisTurn', 'false'),
|
||||
prop('number', 'SkillCostReductionThisTurn', '0'),
|
||||
prop('any', 'SkillSlyOnPlayCards'),
|
||||
prop('any', 'TurnSkillSlyCards'),
|
||||
prop('boolean', 'ShivFirstDamageBonusUsed', 'false'),
|
||||
prop('any', 'CombatCardCostReduction'),
|
||||
prop('number', 'ActiveAttackDamageVsWeakMultiplier', '1'),
|
||||
prop('number', 'DrawDamageThisTurn', '0'),
|
||||
@@ -132,6 +142,7 @@ function writeCodeblocks() {
|
||||
prop('number', 'PoisonApplicationsThisCombat', '0'),
|
||||
prop('number', 'EnemyStrengthLossThisTurn', '0'),
|
||||
prop('number', 'ActiveKillReward', '0'),
|
||||
prop('number', 'BonusRewardScreens', '0'),
|
||||
prop('number', 'FightAttackCount', '0'),
|
||||
prop('number', 'TurnAttackCardsPlayed', '0'),
|
||||
prop('number', 'TurnCardsPlayedThisTurn', '0'),
|
||||
@@ -148,6 +159,8 @@ function writeCodeblocks() {
|
||||
prop('number', 'DiscardSelectTotal', '0'),
|
||||
prop('number', 'DiscardPostShiv', '0'),
|
||||
prop('number', 'DiscardShivPerPick', '0'),
|
||||
prop('number', 'DiscardPostDraw', '0'),
|
||||
prop('number', 'DiscardDrawPerPick', '0'),
|
||||
prop('boolean', 'RetainSelectActive', 'false'),
|
||||
prop('boolean', 'ReserveSelectActive', 'false'),
|
||||
prop('number', 'NextTurnBlock', '0'),
|
||||
@@ -162,7 +175,9 @@ function writeCodeblocks() {
|
||||
prop('any', 'NextTurnAddCards'),
|
||||
], [
|
||||
...bootMethods,
|
||||
...stateMethods,
|
||||
...screensMethods,
|
||||
...npcMethods,
|
||||
...navigationMethods,
|
||||
...soulMethods,
|
||||
...charSelectMethods,
|
||||
...runMethods,
|
||||
@@ -173,6 +188,7 @@ function writeCodeblocks() {
|
||||
...jobMethods,
|
||||
...runEndMethods,
|
||||
...renderMethods,
|
||||
...layoutMethods,
|
||||
...rewardMethods,
|
||||
...itemMethods,
|
||||
...tooltipMethods,
|
||||
|
||||
@@ -54,7 +54,8 @@ const REST_HEAL = 30;
|
||||
const RELIC_PRICE = 60;
|
||||
const ACT_COUNT = 5;
|
||||
const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05'];
|
||||
const ACT_DIFFICULTY_MULTIPLIERS = [1, 1.075, 1.15, 1.3, 1.45];
|
||||
const LOBBY_MAP = 'lobby';
|
||||
const LOBBY_SPAWN = 'Vector3(-5, 0.03, 0)'; // 정찰: map01 지면 좌측
|
||||
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, ACT_DIFFICULTY_MULTIPLIERS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
|
||||
@@ -6,7 +6,7 @@ const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
|
||||
const CLASSES = {
|
||||
warrior: { label: '전사', maxHp: 80 },
|
||||
bandit: { label: '도적', maxHp: 70 },
|
||||
rogue: { label: '도적', maxHp: 70 },
|
||||
magician: { label: '마법사', maxHp: 70 },
|
||||
};
|
||||
for (const cls of Object.keys(CLASSES)) {
|
||||
@@ -15,22 +15,28 @@ for (const cls of Object.keys(CLASSES)) {
|
||||
if (!CARDS.cards[id]) throw new Error(`[gen-slaydeck] starterDecks.${cls}에 없는 카드 id 참조: ${id}`);
|
||||
}
|
||||
}
|
||||
// 전직 옵션 (클래스별 2차 — JobSelectHud 동적 구성·SetJob 대표 카드)
|
||||
|
||||
// 전직 옵션
|
||||
const JOBS = {
|
||||
warrior: [
|
||||
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack' },
|
||||
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce' },
|
||||
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack', tier: 2, parent: 'warrior' },
|
||||
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge', tier: 2, parent: 'warrior' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce', tier: 2, parent: 'warrior' },
|
||||
],
|
||||
magician: [
|
||||
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow' },
|
||||
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\n썬더 볼트(전체)\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt' },
|
||||
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal' },
|
||||
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow', tier: 2, parent: 'magician' },
|
||||
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\n썬더 볼트(전체)\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt', tier: 2, parent: 'magician' },
|
||||
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal', tier: 2, parent: 'magician' },
|
||||
],
|
||||
bandit: [
|
||||
{ id: 'shiv', name: 'Shiv', desc: 'Many small attacks\nBlade Dance\nAccuracy · After Image', starter: 'BladeDance' },
|
||||
{ id: 'poisoner', name: 'Poison', desc: 'Poison scaling\nDeadly Poison\nCatalyst · Noxious Fumes', starter: 'DeadlyPoison' },
|
||||
{ id: 'trickster', name: 'Trickster', desc: 'Draw and tempo\nAcrobatics\nAdrenaline · Tools', starter: 'Acrobatics' },
|
||||
rogue: [
|
||||
{ id: 'assassin', name: 'Assassin', desc: '표창 중심 전직\n표창 생성과 연속 공격\n빠른 마무리', starter: 'JavelinAcceleration', tier: 2, parent: 'rogue' },
|
||||
{ id: 'thief', name: 'Thief', desc: '단검 중심 전직\n드로우와 운영 강화\n빠른 연계', starter: 'DaggerAcceleration', tier: 2, parent: 'rogue' },
|
||||
],
|
||||
assassin: [
|
||||
{ id: 'hermit', name: 'Hermit', desc: 'Assassin의 3차 전직\n표창 생성과 강화 심화\n연속 공격 완성', starter: 'SpiritJavelin', tier: 3, parent: 'assassin' },
|
||||
],
|
||||
thief: [
|
||||
{ id: 'thiefmaster', name: 'Thief Master', desc: 'Thief의 3차 전직\n단검·교활·중독 심화\n연계 운영 완성', starter: 'Venom', tier: 3, parent: 'thief' },
|
||||
],
|
||||
};
|
||||
for (const [cls, jobs] of Object.entries(JOBS)) {
|
||||
@@ -38,6 +44,42 @@ for (const [cls, jobs] of Object.entries(JOBS)) {
|
||||
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
|
||||
}
|
||||
}
|
||||
|
||||
const CLASS_GROUPS = {
|
||||
warrior: ['warrior', 'fighter', 'page', 'spearman'],
|
||||
magician: ['magician', 'firepoison', 'icelightning', 'cleric'],
|
||||
rogue: ['rogue', 'assassin', 'hermit', 'thief', 'thiefmaster'],
|
||||
};
|
||||
|
||||
const CLASS_LINEAGES = {
|
||||
warrior: ['warrior'],
|
||||
fighter: ['warrior', 'fighter'],
|
||||
page: ['warrior', 'page'],
|
||||
spearman: ['warrior', 'spearman'],
|
||||
magician: ['magician'],
|
||||
firepoison: ['magician', 'firepoison'],
|
||||
icelightning: ['magician', 'icelightning'],
|
||||
cleric: ['magician', 'cleric'],
|
||||
rogue: ['rogue'],
|
||||
assassin: ['rogue', 'assassin'],
|
||||
hermit: ['rogue', 'assassin', 'hermit'],
|
||||
thief: ['rogue', 'thief'],
|
||||
thiefmaster: ['rogue', 'thief', 'thiefmaster'],
|
||||
};
|
||||
|
||||
const JOB_META = {};
|
||||
for (const [sourceClass, jobs] of Object.entries(JOBS)) {
|
||||
for (const job of jobs) {
|
||||
JOB_META[job.id] = {
|
||||
name: job.name,
|
||||
starter: job.starter,
|
||||
tier: job.tier ?? 2,
|
||||
parent: job.parent ?? sourceClass,
|
||||
sourceClass,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 영혼(soul) 메타 해금 — 2차 전직 후 보스 클리어로 영혼 적립, 로비 영혼상점에서 구매 → 다음 런 이점
|
||||
const SOUL_UNLOCKS = [
|
||||
{ key: 'meso', name: '두둑한 지갑', desc: '런 시작 시 메소 +60', cost: 3 },
|
||||
@@ -85,27 +127,23 @@ function luaCharsTable() {
|
||||
}
|
||||
|
||||
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
|
||||
const MAP_ROWS = 6; // 걷는 행 1..6, 보스 row 7 (depth 최대 7)
|
||||
const MAP_ROWS = 6;
|
||||
const MAP_COLS = 4;
|
||||
|
||||
// 보물 상자 스프라이트 (공식 maplestory 리소스, 메이커 선별)
|
||||
const CHEST_CLOSED_RUID = '43df67920c0d43298e0d93c02c6afa71';
|
||||
const CHEST_OPEN_RUID = '09c5cee56fd640bf8ae3a18ce50f4759';
|
||||
|
||||
// 노드 맵 아이콘/배경 (공식 maplestory RUID, data/nodeicons.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||||
const NODEICONS = JSON.parse(readFileSync('data/nodeicons.json', 'utf8'));
|
||||
for (const t of ['combat', 'elite', 'boss', 'shop', 'rest', 'treasure']) {
|
||||
if (!/^[0-9a-f]{32}$/.test((NODEICONS.icons || {})[t] || '')) throw new Error(`[gen-slaydeck] nodeicons.json icons.${t} RUID 누락/형식오류`);
|
||||
}
|
||||
if (!/^[0-9a-f]{32}$/.test(NODEICONS.background || '')) throw new Error('[gen-slaydeck] nodeicons.json background RUID 누락/형식오류');
|
||||
|
||||
// 캐릭터 선택 초상화 (메이커 임포트 RUID, data/characters.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||||
const CHARS = JSON.parse(readFileSync('data/characters.json', 'utf8'));
|
||||
for (const c of ['warrior', 'magician', 'bandit']) {
|
||||
for (const c of ['warrior', 'magician', 'rogue']) {
|
||||
if (!/^[0-9a-f]{32}$/.test((CHARS.portraits || {})[c] || '')) throw new Error(`[gen-slaydeck] characters.json portraits.${c} RUID 누락/형식오류`);
|
||||
}
|
||||
|
||||
// 전투 카메라 고정값(StS2: 플레이어 좌·몬스터 우). KickCombatCamera가 StartCombat에서 재confine에 사용.
|
||||
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
|
||||
|
||||
const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
@@ -143,17 +181,33 @@ function luaEnemiesTable(enemies) {
|
||||
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
|
||||
return `self.Enemies = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
// Lua 직렬화 헬퍼
|
||||
|
||||
function luaStr(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
||||
}
|
||||
function luaJobsTable(jobs) {
|
||||
const cls = Object.entries(jobs).map(([clsId, list]) => {
|
||||
const items = list.map((j) => `\t\t{ id = ${luaStr(j.id)}, name = ${luaStr(j.name)}, desc = ${luaStr(j.desc)}, starter = ${luaStr(j.starter)} },`).join('\n');
|
||||
const items = list.map((j) =>
|
||||
`\t\t{ id = ${luaStr(j.id)}, name = ${luaStr(j.name)}, desc = ${luaStr(j.desc)}, starter = ${luaStr(j.starter)}, tier = ${j.tier ?? 2}, parent = ${luaStr(j.parent ?? clsId)} },`).join('\n');
|
||||
return `\t${clsId} = {\n${items}\n\t},`;
|
||||
}).join('\n');
|
||||
return `self.Jobs = {\n${cls}\n}`;
|
||||
}
|
||||
function luaClassGroupsTable(groups) {
|
||||
const rows = Object.entries(groups).map(([clsId, list]) =>
|
||||
`\t${clsId} = { ${list.map(luaStr).join(', ')} },`).join('\n');
|
||||
return `self.ClassGroups = {\n${rows}\n}`;
|
||||
}
|
||||
function luaClassLineagesTable(lineages) {
|
||||
const rows = Object.entries(lineages).map(([clsId, list]) =>
|
||||
`\t${clsId} = { ${list.map(luaStr).join(', ')} },`).join('\n');
|
||||
return `self.ClassLineages = {\n${rows}\n}`;
|
||||
}
|
||||
function luaJobMetaTable(meta) {
|
||||
const rows = Object.entries(meta).map(([jobId, entry]) =>
|
||||
`\t${jobId} = { name = ${luaStr(entry.name)}, starter = ${luaStr(entry.starter)}, tier = ${entry.tier}, parent = ${luaStr(entry.parent)}, sourceClass = ${luaStr(entry.sourceClass)} },`);
|
||||
return `self.JobMeta = {\n${rows.join('\n')}\n}`;
|
||||
}
|
||||
function luaCardsTable(cards) {
|
||||
const lines = Object.entries(cards).map(([id, c]) => {
|
||||
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
|
||||
@@ -262,4 +316,11 @@ function luaDeckTable(deck) {
|
||||
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
|
||||
}
|
||||
|
||||
export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };
|
||||
export {
|
||||
CARDS, ENEMIES, CLASSES, JOBS, JOB_META, CLASS_GROUPS, CLASS_LINEAGES, SOUL_UNLOCKS,
|
||||
luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable,
|
||||
luaCharsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS,
|
||||
CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable,
|
||||
luaStr, luaJobsTable, luaClassGroupsTable, luaClassLineagesTable, luaJobMetaTable,
|
||||
luaCardsTable, luaDeckTable,
|
||||
};
|
||||
|
||||
42
tools/verify/cardkinds.mjs
Normal file
42
tools/verify/cardkinds.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
// 카드 kind ↔ 효과 정합성 정적 검사 (협업자/codex가 카드 추가 후 실행).
|
||||
// 배경(2026-06-30): kind가 효과와 안 맞으면 카드가 사용불가/死카드가 된다.
|
||||
// - ResolveCardDrop 라우팅: Attack=몬스터 위 드롭(FindMonsterAtTouch>0 필요) / Skill·Power=위로 스윕 / Status=unplayable.
|
||||
// → block·유틸만 있고 데미지 없는 카드를 Attack으로 두면 위로 스윕으로 못 쓴다(아이언 바디 사고).
|
||||
// - PlayCard의 Power 분기는 PlayerPowers 등록만 하고 damage/aoe를 무시한다.
|
||||
// → Power인데 powerEffect도 power필드도 없으면 재생 시 아무 효과 없는 死카드(분노 사고).
|
||||
// 사용: node tools/verify/cardkinds.mjs (이상 0 → exit 0, 있으면 목록 + exit 1)
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const cards = JSON.parse(readFileSync('data/cards.json', 'utf8')).cards;
|
||||
|
||||
// Power 카드를 실제로 기능하게 하는 필드(powerEffect 지속효과 + 온플레이/지속 power 필드).
|
||||
// damage/aoe/block 같은 Attack/Skill 전용 필드는 Power 분기서 무시되므로 제외.
|
||||
const POWER_FIELDS = [
|
||||
'powerEffect', 'strength', 'dex', 'thorns', 'intangible',
|
||||
'turnStartShiv', 'turnStartDraw', 'turnStartDiscard',
|
||||
'shivDamageBonus', 'firstShivDamageBonus', 'shivRetain', 'shivAoe',
|
||||
'attackPoison', 'drawDamage', 'drawPoison', 'attackDamageVsWeakMultiplier',
|
||||
'cardPlayedBlock', 'cardPlayedDamage', 'cardPlayedRandomDamage',
|
||||
'extraPoisonTicks', 'poisonApplicationBurstEvery', 'poisonApplicationBurstDamage',
|
||||
'skillSlyOnPlay', 'endTurnDexLoss',
|
||||
];
|
||||
const VALID_KINDS = ['Attack', 'Skill', 'Power', 'Status'];
|
||||
|
||||
const issues = [];
|
||||
for (const [id, c] of Object.entries(cards)) {
|
||||
if (!VALID_KINDS.includes(c.kind)) {
|
||||
issues.push(`${id}(${c.name}): 미지원 kind="${c.kind}"`);
|
||||
continue;
|
||||
}
|
||||
if (c.kind === 'Attack' && c.damage == null && c.xDamagePerEnergy == null) {
|
||||
issues.push(`${id}(${c.name}): kind=Attack인데 damage 없음 → 몬스터 드롭 라우팅 불가(방어/유틸이면 kind=Skill)`);
|
||||
}
|
||||
if (c.kind === 'Power' && !POWER_FIELDS.some((f) => c[f] != null)) {
|
||||
issues.push(`${id}(${c.name}): kind=Power인데 power효과 없음(死카드) → damage/aoe는 Power 분기서 무시, kind 재검토`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`카드 ${Object.keys(cards).length}장 kind↔효과 정합성: 이상 ${issues.length}`);
|
||||
for (const i of issues) console.log(' ⚠️ ' + i);
|
||||
console.log(issues.length ? 'RESULT: 정합성 위반 (위 카드 kind 수정 필요)' : 'RESULT: 모든 카드 kind↔효과 일치 ✓');
|
||||
process.exit(issues.length ? 1 : 0);
|
||||
34
tools/verify/cbprops.mjs
Normal file
34
tools/verify/cbprops.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
// cb/*.mjs의 `self.<Field> = ` 대입 ↔ gen-slaydeck.mjs 선언 prop 대조.
|
||||
// 미선언 prop에 대입하면 MSW 런타임 "cannot set X, no such field" → 그 후보를 정적 검출.
|
||||
// 사용: node tools/verify/cbprops.mjs
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
|
||||
const orch = readFileSync('tools/deck/gen-slaydeck.mjs', 'utf8');
|
||||
const declared = new Set();
|
||||
for (const m of orch.matchAll(/prop\(\s*'[^']*'\s*,\s*'([^']+)'/g)) declared.add(m[1]);
|
||||
|
||||
// MSW 빌트인/설정으로 대입 가능한 self 필드(프롭 아님) — 오탐 제외 화이트리스트.
|
||||
const BUILTIN = new Set(['Entity']);
|
||||
|
||||
const dir = 'tools/deck/cb';
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.mjs'));
|
||||
const assigns = new Map(); // name -> Set(files)
|
||||
for (const f of files) {
|
||||
const src = readFileSync(`${dir}/${f}`, 'utf8');
|
||||
// self.Name = (단, == / ~= / .Y= / [..]= 는 제외)
|
||||
for (const m of src.matchAll(/self\.([A-Za-z_]\w*)\s*=(?!=)/g)) {
|
||||
const name = m[1];
|
||||
if (!assigns.has(name)) assigns.set(name, new Set());
|
||||
assigns.get(name).add(f);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = [...assigns.keys()]
|
||||
.filter((n) => !declared.has(n) && !BUILTIN.has(n))
|
||||
.sort();
|
||||
|
||||
console.log(`선언 prop: ${declared.size} | 대입된 self.X distinct: ${assigns.size}`);
|
||||
console.log(`미선언 대입 (no such field 후보): ${missing.length}`);
|
||||
for (const n of missing) console.log(` - ${n} [${[...assigns.get(n)].join(', ')}]`);
|
||||
console.log(missing.length ? 'RESULT: MISSING PROPS ABOVE' : 'RESULT: 모든 self 대입이 선언됨 ✓');
|
||||
process.exit(missing.length ? 1 : 0);
|
||||
40
tools/verify/cbset.mjs
Normal file
40
tools/verify/cbset.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
// 순서 무관 codeblock 메서드 집합 비교. 본문 미출력 — 이름·차이 카운트만.
|
||||
// 메서드 이동 리팩터의 무손실 검증용: 워킹트리 codeblock vs ref(기본 HEAD).
|
||||
// 사용: node tools/verify/cbset.mjs [ref]
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const PATH = 'RootDesk/MyDesk/SlayDeckController.codeblock';
|
||||
const ref = process.argv[2] || 'HEAD';
|
||||
|
||||
function methodsOf(jsonText) {
|
||||
const obj = JSON.parse(jsonText);
|
||||
const arr = obj.ContentProto.Json.Methods;
|
||||
const map = new Map();
|
||||
for (const m of arr) {
|
||||
map.set(m.Name, { code: m.Code, exec: m.ExecSpace, params: JSON.stringify(m.Parameters || []) });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const work = methodsOf(readFileSync(PATH, 'utf8'));
|
||||
const base = methodsOf(execSync(`git show ${ref}:${PATH}`, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }));
|
||||
|
||||
const onlyWork = [...work.keys()].filter((k) => !base.has(k));
|
||||
const onlyBase = [...base.keys()].filter((k) => !work.has(k));
|
||||
const changed = [];
|
||||
for (const k of work.keys()) {
|
||||
if (!base.has(k)) continue;
|
||||
const a = work.get(k);
|
||||
const b = base.get(k);
|
||||
if (a.code !== b.code || a.exec !== b.exec || a.params !== b.params) changed.push(k);
|
||||
}
|
||||
|
||||
console.log(`ref=${ref} work=${work.size} base=${base.size}`);
|
||||
console.log(`only-in-work (${onlyWork.length}): ${onlyWork.join(', ') || '-'}`);
|
||||
console.log(`only-in-base (${onlyBase.length}): ${onlyBase.join(', ') || '-'}`);
|
||||
console.log(`body/exec/params changed (${changed.length}): ${changed.join(', ') || '-'}`);
|
||||
|
||||
const ok = onlyWork.length === 0 && onlyBase.length === 0 && changed.length === 0;
|
||||
console.log(ok ? 'RESULT: IDENTICAL SET (무손실)' : 'RESULT: DIFFERENCES ABOVE');
|
||||
process.exit(ok ? 0 : 1);
|
||||
82
tools/verify/rogue-card-names.mjs
Normal file
82
tools/verify/rogue-card-names.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const cards = JSON.parse(readFileSync('data/cards.json', 'utf8')).cards;
|
||||
const rogueClasses = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
|
||||
|
||||
const mapleSkillCards = {
|
||||
DoubleStab: '더블 스탭',
|
||||
LuckySeven: '럭키 세븐',
|
||||
Haste: '헤이스트',
|
||||
DarkSight: '다크 사이트',
|
||||
FlashJump: '플래시 점프',
|
||||
NimbleBody: '님블 바디',
|
||||
SavageBlow: '새비지 블로우',
|
||||
CriticalEdge: '크리티컬 엣지',
|
||||
Steal: '스틸',
|
||||
DaggerAcceleration: '대거 액셀레이션',
|
||||
Karma: '카르마',
|
||||
DaggerMastery: '대거 마스터리',
|
||||
PhysicalTraining: '피지컬 트레이닝',
|
||||
ShieldMastery: '실드 마스터리',
|
||||
ThiefAgility: '시프 어질리티',
|
||||
EdgeCarnival: '엣지 카니발',
|
||||
MuspelHeim: '무스펠 하임',
|
||||
MesoExplosion: '메소 익스플로젼',
|
||||
DarkFlare: '다크 플레어',
|
||||
PickPocket: '픽 파킷',
|
||||
ShadowPartner: '쉐도우 파트너',
|
||||
AdvancedDarkSight: '어드밴스드 다크 사이트',
|
||||
IntoDarkness: '인투 다크니스',
|
||||
Venom: '베놈',
|
||||
Grid: '그리드',
|
||||
RadicalDarkness: '래디컬 다크니스',
|
||||
ShurikenBurst: '슈리켄 버스트',
|
||||
WindTalisman: '윈드 탈리스만',
|
||||
MarkOfAssassin: '마크 오브 어쌔신',
|
||||
ShadowRush: '쉐도우 러쉬',
|
||||
ShadowLeap: '쉐도우 리프',
|
||||
ShadowBlink: '쉐도우 블링크',
|
||||
JavelinMastery: '자벨린 마스터리',
|
||||
JavelinAcceleration: '자벨린 액셀레이션',
|
||||
CriticalThrow: '크리티컬 스로우',
|
||||
AssassinPhysicalTraining: '피지컬 트레이닝',
|
||||
TripleThrow: '트리플 스로우',
|
||||
ShurikenChallenge: '슈리켄 챌린지',
|
||||
HermitDarkFlare: '다크 플레어',
|
||||
HermitShadowPartner: '쉐도우 파트너',
|
||||
SpiritJavelin: '스피릿 자벨린',
|
||||
HermitRadicalDarkness: '래디컬 다크니스',
|
||||
HermitVenom: '베놈',
|
||||
SkilledJavelin: '숙련된 표창술',
|
||||
HermitAdrenaline: '아드레날린',
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
for (const [id, expectedName] of Object.entries(mapleSkillCards)) {
|
||||
if (!cards[id]) errors.push(`원본 스킬 카드 없음: ${id}`);
|
||||
else if (cards[id].name !== expectedName) errors.push(`원본 스킬명 변경: ${id} (${cards[id].name} != ${expectedName})`);
|
||||
}
|
||||
|
||||
const customCards = Object.entries(cards).filter(([id, card]) => rogueClasses.has(card.class) && !mapleSkillCards[id]);
|
||||
if (customCards.length !== 78) errors.push(`도적 비스킬 카드 수 불일치: ${customCards.length} != 78`);
|
||||
|
||||
const names = new Map();
|
||||
for (const [id, card] of Object.entries(cards)) {
|
||||
if (!names.has(card.name)) names.set(card.name, []);
|
||||
names.get(card.name).push(id);
|
||||
}
|
||||
|
||||
const nonRogueNames = new Set(Object.values(cards).filter((card) => !rogueClasses.has(card.class) && card.class !== 'shiv').map((card) => card.name));
|
||||
for (const [id, card] of customCards) {
|
||||
const sameNameIds = names.get(card.name) || [];
|
||||
if (sameNameIds.length > 1) errors.push(`비스킬 카드명 중복: ${id} ${card.name} (${sameNameIds.join(', ')})`);
|
||||
if (nonRogueNames.has(card.name)) errors.push(`다른 직업 카드명 충돌: ${id} ${card.name}`);
|
||||
}
|
||||
|
||||
console.log(`메이플 원본 스킬명 고정 ${Object.keys(mapleSkillCards).length}장 | 도적 비스킬 고유 이름 ${customCards.length}장`);
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) console.error(`ERROR: ${error}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('RESULT: 도적 카드 이름 규칙 이상 0');
|
||||
}
|
||||
Reference in New Issue
Block a user