Compare commits
88 Commits
4da934585c
...
codex/card
| Author | SHA1 | Date | |
|---|---|---|---|
| 34531b184f | |||
| f6650a6c70 | |||
| acf295d56c | |||
| 9278c47901 | |||
| b2bf1bf4dd | |||
| 5da6e8f3aa | |||
| 71435a2c91 | |||
| f64e35668d | |||
| ba1651e52c | |||
| f8414a9c33 | |||
| 6344685052 | |||
| b0f1a0840c | |||
| c703bd9b4d | |||
| 96102dc41f | |||
| 8702d5209e | |||
| 74d4824a1c | |||
| bdea6a8c28 | |||
| 4fa0bc85c0 | |||
| db76870d4e | |||
| 491833b025 | |||
| 83de73c2c1 | |||
| b06ad8e8ee | |||
| 578f4416b2 | |||
| bc80b96875 | |||
| f2828deb19 | |||
| b549abc3b3 | |||
| 5b21e7f436 | |||
| b42d5fcf51 | |||
| 43530bee60 | |||
| 9b8884da81 | |||
| 2b3d77c588 | |||
| 3efb6993c7 | |||
| 5f8475d018 | |||
| afac34d7b5 | |||
| a917a6d82b | |||
| 9fba9b5aaa | |||
| 54d9632534 | |||
| c274322887 | |||
| 5900af087e | |||
| b0d3da2f39 | |||
| 6ef0ba48f6 | |||
| 1b1fce3d6e | |||
| c34a1126fb | |||
| 964cf7cc3d | |||
| 098571e9aa | |||
| ea832ad846 | |||
| 4288c4101b | |||
| 2bb7360a47 | |||
| a5388da2cc | |||
| 8ca48eca60 | |||
| eeca77df35 | |||
| 40e351333e | |||
| 0a83dea2d8 | |||
| fbf4d8d02d | |||
| a141939675 | |||
| 42eb33b579 | |||
| 9f7713267c | |||
| bfa86f0f28 | |||
| c5bb8c18a9 | |||
| 2f9c325c96 | |||
| 420cce561c | |||
| d265c8f918 | |||
| 255781d969 | |||
| b904b29503 | |||
| 0435a76fc1 | |||
| d82e98f832 | |||
| eafd6747a7 | |||
| bc266b1885 | |||
| e6a397cc55 | |||
| fcc103227c | |||
| 44878bab9e | |||
| 064d81d424 | |||
| 62187db5dd | |||
| 1a5953050f | |||
| a902cb8bce | |||
| b23dc3868e | |||
| 98ca1668c8 | |||
| 654a49f3a2 | |||
| 3e4619ed2f | |||
| aa872afa7b | |||
| 1eb6622cf5 | |||
| 8309b25ec5 | |||
| 00903f2659 | |||
| f2c470f972 | |||
| 2e8a1ab869 | |||
| 4228f58b09 | |||
| 5e0eca6cdf | |||
| bda35eefc7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,8 @@
|
||||
# Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조)
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
# 개인 스킬(superpowers) 브레인스토밍/계획 산출물 — 로컬 전용, 협업 공유 X (프로젝트 설계 문서 docs/*.md 는 추적 유지)
|
||||
docs/superpowers/
|
||||
|
||||
# === OS / 에디터 잡파일 ===
|
||||
Thumbs.db
|
||||
@@ -23,3 +25,5 @@ AGENTS.md
|
||||
Environment/
|
||||
McpScreenshots/
|
||||
*.log
|
||||
# 메이커가 재편(reorg) 중 부모를 잃은 엔티티를 모아두는 임시 폴더 (잡파일)
|
||||
Mislocated/
|
||||
|
||||
142
Global/UIButton.model
Normal file
142
Global/UIButton.model
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "model://uibutton",
|
||||
"ContentType": "x-mod/model",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"Version": 1,
|
||||
"Name": "UIButton",
|
||||
"BaseModelId": null,
|
||||
"Id": "uibutton",
|
||||
"Components": [
|
||||
"MOD.Core.UITransformComponent",
|
||||
"MOD.Core.SpriteGUIRendererComponent"
|
||||
],
|
||||
"Properties": [
|
||||
{
|
||||
"Type": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Name": "RectSize",
|
||||
"DisplayName": "RectSize",
|
||||
"ShowInInspector": true,
|
||||
"Link": {
|
||||
"Target": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.UITransformComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Property": "RectSize"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Type": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODDataRef, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Name": "ImageRUID",
|
||||
"DisplayName": "ImageRUID",
|
||||
"ShowInInspector": true,
|
||||
"Link": {
|
||||
"Target": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.SpriteGUIRendererComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Property": "ImageRUID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Type": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODColor, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Name": "Color",
|
||||
"DisplayName": "Color",
|
||||
"ShowInInspector": true,
|
||||
"Link": {
|
||||
"Target": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.SpriteGUIRendererComponent, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Property": "Color"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Values": [
|
||||
{
|
||||
"TargetType": "MOD.Core.UITransformComponent",
|
||||
"Name": "anchoredPosition",
|
||||
"ValueType": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Value": {
|
||||
"$type": "MOD.Core.MODVector2, MOD.Core",
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"TargetType": "MOD.Core.UITransformComponent",
|
||||
"Name": "RectSize",
|
||||
"ValueType": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODVector2, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Value": {
|
||||
"$type": "MOD.Core.MODVector2, MOD.Core",
|
||||
"x": 200.0,
|
||||
"y": 75.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"TargetType": "MOD.Core.UITransformComponent",
|
||||
"Name": "AlignmentOption",
|
||||
"ValueType": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.AlignmentType, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Value": 0
|
||||
},
|
||||
{
|
||||
"TargetType": "MOD.Core.SpriteGUIRendererComponent",
|
||||
"Name": "ImageRUID",
|
||||
"ValueType": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODDataRef, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Value": {
|
||||
"$type": "MOD.Core.MODDataRef, MOD.Core",
|
||||
"DataId": "cc3457b8e97b3e14f9d5c39ccdd640bf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"TargetType": "MOD.Core.SpriteGUIRendererComponent",
|
||||
"Name": "Color",
|
||||
"ValueType": {
|
||||
"$type": "MODNativeType",
|
||||
"type": "MOD.Core.MODColor, MOD.Core, Version=26.5.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
},
|
||||
"Value": {
|
||||
"$type": "MOD.Core.MODColor, MOD.Core",
|
||||
"r": 1.0,
|
||||
"g": 1.0,
|
||||
"b": 1.0,
|
||||
"a": 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"EventLinks": [],
|
||||
"Children": []
|
||||
}
|
||||
}
|
||||
}
|
||||
49
README.md
49
README.md
@@ -44,8 +44,8 @@ git pull
|
||||
```
|
||||
slaymaple/
|
||||
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
|
||||
│ ├── cards.json # 카드 122장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── enemies.json # 적 12종(일반/정예/보스, 디버프 인텐트 포함)
|
||||
│ ├── cards.json # 카드 121장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── enemies.json # 적 18종(일반/정예/보스, 디버프 인텐트 포함)
|
||||
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
|
||||
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
|
||||
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
|
||||
@@ -77,9 +77,9 @@ slaymaple/
|
||||
│ ├── player/ # gen-player-lock.mjs(전투맵 입력 잠금) · freeze-turn-player.mjs(모델 이동 정지) · gen-lobby-npc.mjs(LobbyNpc·LobbyMobility codeblock)
|
||||
│ ├── monster/ # gen-combat-monster.mjs(EnemyId 마커) · freeze-turn-monsters.mjs(필드 AI 정지)
|
||||
│ ├── balance/ # sim-balance.mjs(전투 밸런스 몬테카를로 시뮬) · sim-balance.test.mjs
|
||||
│ ├── verify/ # count.mjs(산출물 카운트 검증 헬퍼 — 경로 내장)
|
||||
│ ├── verify/ # count.mjs·uimap.mjs·cbgap.mjs(산출물 카운트/UIGroup 매핑/재연결 GAP 검증 — 경로 내장)
|
||||
│ └── git/ # gitea-pr.mjs(UTF-8 안전 PR 생성/수정/머지 — RULES.md 참조)
|
||||
├── ui/ # UI 그룹 (DefaultGroup 8.3MB 산출물 / PopupGroup / ToastGroup)
|
||||
├── ui/ # UIGroup 7종 — 메이커 저작(Default/Select/Lobby/Run/Deck/Popup/Toast)
|
||||
├── docs/
|
||||
│ ├── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
|
||||
│ ├── ui-generation-structure.md # UI 생성 구조 문서
|
||||
@@ -89,14 +89,22 @@ slaymaple/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
> ⚠️ **`map/*.map` · `ui/DefaultGroup.ui` · `*.codeblock` · `Global/*.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 다음 재생성 때 사라집니다. 게임 변경은 `data/*.json` 또는 `tools/`의 생성기를 고친 뒤 재생성하세요(자세한 규칙은 [`RULES.md`](RULES.md)).
|
||||
> ⚠️ **`map/*.map` · `SlayDeckController.codeblock` · `Global/common.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 재생성 때 사라집니다. 게임 로직 변경은 `data/*.json`·`tools/`의 생성기를 고쳐 재생성하세요. **`ui/*.ui`는 메이커 저작**(생성기 미생성)이라 메이커에서만 편집합니다(자세한 규칙은 [`RULES.md`](RULES.md)).
|
||||
> `.mcp.json`, `.codex/` 는 **Authorization 토큰이 포함**되어 있어 git에서 제외됩니다(`.gitignore`). 각자 로컬에서 직접 구성하세요.
|
||||
|
||||
---
|
||||
|
||||
## 직업 컨셉
|
||||
|
||||
3직업 모두 Slay the Spire 2 차용 + 메이플 IP 재해석. 카드 덱 상세 설계는 [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||
|
||||
- **⚔️ 전사 (탱커, Ironclad 차용)** — **파이터**: 공격을 *연속*으로 내면 콤보가 쌓이고(방어·파워 등 비공격 카드를 쓰면 콤보 리셋) 콤보로 데미지 증가 버프 = 브루저. **페이지**: 위협 디버프로 버티며 방어도 축적 → **바디 슬램(방어 비례 피해)** 카운터. **스피어맨**: 하이퍼바디·아이언월 유지/리치형.
|
||||
- **🗡️ 도적 (단검·독, Silent 차용)** — 표창 난사 / 독 / 교활·버림. **어쌔신**(표창·크리·흡혈) / **시프**(단검 난타·독). *형 구현 완료(Silent 86장)*.
|
||||
- **🔮 법사 (약체·게이지, Defect 차용)** — **위자드(불/독)**: 독을 묻히고 *독 걸린 적에 불 카드 → 추가 데미지*(독뎀 시너지). **위자드(썬/콜)**: 오브로 썬더(다중 공격)·콜드(빙결=취약+피해), 오브 획득·다중 소모 운용. **클레릭**: 오브 없이 회복·버프 + 언데드엔 힐로 공격하는 보조 힐러.
|
||||
|
||||
## 게임 프레임워크 현황
|
||||
|
||||
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다:
|
||||
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다 (게임 시작 시 MainMenu 없이 바로 로비로 진입):
|
||||
|
||||
```
|
||||
로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
|
||||
@@ -104,15 +112,15 @@ slaymaple/
|
||||
→ 런 클리어(승천 해금) → 로비 복귀(영혼 정산) → 다음 런 …
|
||||
```
|
||||
|
||||
게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작하며, 모든 산출물(`ui/DefaultGroup.ui` · `SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(결정적 출력, 직접 편집 금지 — `RULES.md` 참조). 게임 데이터는 **`data/*.json`** 가 단일 소스, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
|
||||
게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. **UI는 메이커 저작**(7개 UIGroup: Default/Select/Lobby/Run/Deck/Popup/Toast)이고, 컨트롤러가 엔티티 경로(`/ui/<UIGroup>/<Hud>/...`)로 내용을 런타임 주입합니다. 생성기 `tools/deck/gen-slaydeck.mjs`는 **`SlayDeckController.codeblock` + `common.gamelogic`만 생성**(`.ui` 미접근, 결정적 출력 — `RULES.md` 참조). 게임 데이터는 **`data/*.json`**, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
|
||||
|
||||
### 구현된 기능 (배포 퀄리티 P1~P15, PR #34~#57)
|
||||
### 구현된 기능 (배포 퀄리티 P1~P15+, PR #34~#79)
|
||||
|
||||
| 영역 | 내용 |
|
||||
|---|---|
|
||||
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`키 **또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
|
||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택, 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **122장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**초상화·직업 설명·선택 테두리 강조** 캐릭터 선택 UI), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
|
||||
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **121장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거) |
|
||||
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 −25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
|
||||
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
|
||||
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. 점선 경로·상태 4단·층 카운터. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
|
||||
@@ -125,7 +133,7 @@ slaymaple/
|
||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
|
||||
|
||||
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
|
||||
> ℹ️ 도적(Silent) 카드 88장은 효과·프레임은 적용됐으나 **카드 아이콘(image/fx) 미할당** 상태입니다(전사·마법사 카드는 실 스킬 아이콘 적용 완료).
|
||||
> ℹ️ 도적(Silent) 카드 86장은 STS Silent 완역 포트 + **공식 스킬 아이콘 적용 완료**. 남은 작업은 카드명 메이플 재서사(어쌔신/시프)·멀티플레이어 전제 카드 싱글 정리 — [`docs/deck-concept.md`](docs/deck-concept.md) 참조.
|
||||
|
||||
### 유용한 스크립트 호출
|
||||
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
|
||||
@@ -149,9 +157,20 @@ c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1
|
||||
밸런스 검증: `node tools/balance/sim-balance.mjs [N] [--seed S]` · 테스트: `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs`.
|
||||
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 및 `docs/superpowers/specs/` 참조.
|
||||
|
||||
### 디버그 단축키
|
||||
|
||||
개발·QA용 키보드 단축키. **전투 중**(런 활성 + 전투 진행 중)에만 동작합니다.
|
||||
|
||||
| 단축키 | 기능 |
|
||||
|---|---|
|
||||
| **Ctrl + Shift + C** | **카드 picker** — 직업 전체 카드 패널을 띄우고, 카드를 클릭하면 **즉시 손패에 추가**. 상단 탭(전사/도적/마법사)으로 직업별 카드 풀 전환. 카드 효과·메커니즘 즉석 테스트용 |
|
||||
| **Ctrl + Shift + E** | **전체 회복 치트** — 체력·에너지를 최대치로 회복 |
|
||||
|
||||
> 카드 picker는 메이커 저작 UI `DeckUIGroup/DeckAllHud`(120 슬롯 그리드 + 직업 탭 3종)를 사용하고, 컨트롤러가 런타임에 카드 비주얼·버튼을 바인딩합니다. 구현: 키 바인딩 `tools/deck/cb/boot.mjs`, picker 로직 `tools/deck/cb/deckview.mjs`(`OpenDebugCardPicker`/`OnAllDeckCardButton`), 버튼 바인딩 `tools/deck/cb/deckturn.mjs`(`BindButtons`). 옛 picker UI 생성기 `tools/deck/legacy/hud/deckall.mjs`는 UI 메이커-저작 전환 후 **휴면**(Maker UI가 대체).
|
||||
|
||||
### 산출물 재생성
|
||||
```bash
|
||||
node tools/deck/gen-slaydeck.mjs # 게임 전체(UI·컨트롤러·common·맵 인카운터)
|
||||
node tools/deck/gen-slaydeck.mjs # 컨트롤러+common (UI는 메이커 저작 — 미생성)
|
||||
node tools/map/gen-maps.mjs # map01~05 배경/타일
|
||||
node tools/map/gen-lobby-map.mjs # 로비 맵 + NPC 배치
|
||||
node tools/player/gen-lobby-npc.mjs # 로비 codeblock(LobbyNpc·LobbyMobility)
|
||||
@@ -165,7 +184,7 @@ node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
|
||||
|
||||
## 아키텍처 메모
|
||||
|
||||
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다.
|
||||
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다. **2026-06-17**: UI를 단일 `DefaultGroup`에서 7개 UIGroup(Select/Lobby/Run/Deck 등)으로 분리해 **메이커 저작으로 전환** — 생성기는 더 이상 `.ui`를 만들지 않고, 컨트롤러가 새 UIGroup 경로로 재연결됨(옛 UI emit `hud/*`·`gen-cardhand`는 `tools/deck/legacy/` 휴면). 재연결 무결성은 `tools/verify/cbgap.mjs`(GAP 0)로 검증.
|
||||
|
||||
> ⚠️ **전투 규칙과 맵 생성은 Lua(gen-slaydeck 내장)와 JS 미러(sim-balance/rogue-map)로 이중 구현**입니다. 한쪽을 고치면 반드시 다른 쪽도 동기화하고 테스트하세요(`RULES.md` §6).
|
||||
|
||||
@@ -173,7 +192,9 @@ node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
|
||||
|
||||
## 향후 개선 계획 (후속 후보)
|
||||
- [x] 전투 루프 · 런 루프 · 절차 생성 맵 · 상점/휴식/유물 방 · 유물 19종 · 물약 · 버프/디버프 · Power · 전직(전사/법사/도적 2차) · 승천+개인 저장 · 전투 모션 · 커스텀 프레임 · **반복 런·로비 맵·NPC·영혼·메소·카메라 추종 (P1~P15 완료)**
|
||||
- [ ] **도적 카드 아이콘** — Silent 88장에 실 스킬 아이콘(image/fx) 할당, 2차 전직 설명 한글화
|
||||
- [x] **UI 메이커-저작 전환** — 단일 DefaultGroup → 7개 UIGroup 분리, 생성기 UI 저작 폐기(`tools/deck/legacy/`), 컨트롤러 경로 재연결(cbgap GAP 0) (2026-06-17)
|
||||
- [x] **시작 로비 직행 · 캐릭터 선택 UI · 디버그 치트 · map01 로스터 (2026-06-18)** — 게임 시작 시 MainMenu 없이 곧장 로비 진입(MainMenu는 추후 싱글/멀티/종료 메뉴로 재지정); 캐릭터 선택 화면 초상화·직업 설명·선택 테두리·Art 클리핑(MaskComponent) 배선; 디버그 단축키 Ctrl+Shift+C(카드 picker)·Ctrl+Shift+E(체력+에너지 전체 회복); map01 몬스터 18종 로스터(랜덤 행동)
|
||||
- [ ] **도적 카드명 재서사·설명 한글화** — Silent 직역 카드명을 어쌔신/시프 메이플 스킬명으로 재서사(아이콘은 적용 완료), 2차 전직 설명 한글화
|
||||
- [ ] **런 이어하기** — 진행 중 런 직렬화 저장(UserDataStorage 확장, 메뉴 "이어하기" 활성화)
|
||||
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
|
||||
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)
|
||||
|
||||
21
RULES.md
21
RULES.md
@@ -11,8 +11,8 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
|
||||
|
||||
| 산출물 (절대 Read/Edit 금지) | 크기 | 단일 소스 (여기만 편집) | 재생성 명령 |
|
||||
|---|---|---|---|
|
||||
| `ui/DefaultGroup.ui` | **~7.1MB** | `data/*.json` + `tools/deck/gen-slaydeck.mjs` | `node tools/deck/gen-slaydeck.mjs` |
|
||||
| `RootDesk/MyDesk/SlayDeckController.codeblock` | ~270KB | 〃 | 〃 |
|
||||
| `ui/*.ui` (Default·Select·Lobby·Run·Deck·Popup·Toast UIGroup 7종) | 9KB~4.5MB | **메이커 저작 (생성기 미생성, 2026-06-17~)** — 메이커에서 시각 편집 | (없음) |
|
||||
| `RootDesk/MyDesk/SlayDeckController.codeblock` | ~270KB | `data/*.json` + `tools/deck/`(`gen-slaydeck.mjs`+`lib/`+`cb/`) | `node tools/deck/gen-slaydeck.mjs` |
|
||||
| `Global/common.gamelogic` | ~1KB | 〃 | 〃 |
|
||||
| `map/map01.map`~`map05.map`, `map/lobby.map` | 각 ~210KB | `tools/map/`·`tools/monster/`·`tools/camera/`·`tools/player/` (↓ 보조 생성기) | 해당 생성기 |
|
||||
| `RootDesk/MyDesk/CombatMonster.codeblock` | ~2KB | `tools/monster/gen-combat-monster.mjs` | `node tools/monster/gen-combat-monster.mjs` |
|
||||
@@ -21,8 +21,12 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
|
||||
| `RootDesk/MyDesk/LobbyNpc.codeblock`·`LobbyMobility.codeblock` | 각 ~2-3KB | `tools/player/gen-lobby-npc.mjs` | `node tools/player/gen-lobby-npc.mjs` |
|
||||
| `Global/SectorConfig.config` | ~1KB | `tools/map/gen-maps.mjs`·`gen-lobby-map.mjs` (패치) | 해당 생성기 |
|
||||
|
||||
- `.claude/settings.json`의 permissions.deny가 위 파일의 Read/Edit/Write 도구 사용을 차단한다 (이 저장소를 열면 자동 적용). deny는 **glob** — `ui/*.ui`·`map/*.map`·`RootDesk/MyDesk/*.codeblock`·`Global/common.gamelogic`·`Global/SectorConfig.config`. 따라서 **메이커 저작 codeblock/UI**(`Monster`·`MonsterAttack`·`PlayerAttack`·`PlayerHit`·`UIPopup`·`UIToast`.codeblock, `ui/PopupGroup.ui`·`ui/ToastGroup.ui`)**도** Read/Edit 금지 — 이들은 생성기가 없으니 **메이커에서** 편집한다(텍스트 도구로 X). codeblock은 한 줄짜리 JSON이라 Read 시 토큰 폭발.
|
||||
- 게임 로직·UI 수정 = **`tools/deck/gen-slaydeck.mjs`(생성기 JS) 또는 `data/*.json`(데이터)을 수정** → 재생성 → 산출물은 통째로 커밋.
|
||||
- `.claude/settings.json`의 permissions.deny가 위 파일의 Read/Edit/Write 도구 사용을 차단한다 (이 저장소를 열면 자동 적용). deny는 **glob** — `ui/*.ui`·`map/*.map`·`RootDesk/MyDesk/*.codeblock`·`Global/common.gamelogic`·`Global/SectorConfig.config`. 따라서 **메이커 저작 codeblock/UI**(`Monster`·`MonsterAttack`·`PlayerAttack`·`PlayerHit`·`UIPopup`·`UIToast`.codeblock, **그리고 모든 `ui/*.ui`** — UI는 6개 UIGroup으로 메이커 저작)**도** Read/Edit 금지 — 이들은 생성기가 없으니 **메이커에서** 편집한다(텍스트 도구로 X). codeblock은 한 줄짜리 JSON이라 Read 시 토큰 폭발.
|
||||
- **게임 로직 수정** = `tools/deck/gen-slaydeck.mjs`(오케스트레이터) + `tools/deck/cb/*.mjs`(codeblock Lua) 또는 `data/*.json`(데이터) 수정 → 재생성(`SlayDeckController.codeblock`+`common.gamelogic`만, **`.ui` 미접근**) → 통째로 커밋. **UI 수정 = 메이커에서**(생성기는 UI를 안 만든다).
|
||||
- **codeblock 메서드(Lua)는 기능별 모듈** `tools/deck/cb/*.mjs`(boot·state·combat·hand·deckview·items·map·shop 등 17종). **공유분**: 상수·데이터·lua 테이블 = `tools/deck/lib/{ui-helpers,data,codeblock}.mjs`(cb가 import — `MAX_MONSTERS`=4 등). prop 103개는 오케스트레이터 `writeCodeblocks`에 유지. 특정 메서드 수정은 `cb/<name>.mjs`만(의존: orchestrator→cb→lib 단방향). **cb 모듈은 원본 메서드 순서 보존이 바이트동일 조건**. **UI emit(옛 `hud/*.mjs` 15종·`gen-cardhand.mjs`)은 `tools/deck/legacy/`로 이관 — 휴면(생성기 미사용)**: UI가 메이커 저작이라 생성기가 안 만든다. (롤백용 `legacy/upsert-ui.mjs`는 직접 실행 시에만 옛 `DefaultGroup.ui`를 재생성.)
|
||||
- 리팩터 시 **출력 바이트-동일 검증**: `node tools/deck/gen-slaydeck.mjs` 후 `node tools/verify/diffcheck.mjs [ref]`(워킹트리 vs ref(기본 HEAD) 줄바꿈 정규화 비교 — 산출물 경로를 명령줄에 노출 안 해 deny 회피). 산출물 ` M`은 보통 autocrlf churn이니 `git checkout --`로 복원.
|
||||
- **UI 전면 메이커 저작 (2026-06-17~)**: 단일 `DefaultGroup`을 7개 UIGroup으로 분리 — `DefaultGroup`(MainMenu+월드조작), `SelectUIGroup`(charselect/job), `LobbyUIGroup`(lobby/board/soulshop), `RunUIGroup`(combat/map/shop/rest/treasure/reward/cardhand/deck), `DeckUIGroup`(덱 도감), `PopupGroup`·`ToastGroup`. 컨트롤러(`cb/*.mjs`)는 엔티티 **경로**(`/ui/<UIGroup>/<Hud>/...`)로 텍스트·이미지·표시숨김·상태기반 위치/크기/색을 **런타임 주입**(레이아웃=메이커, 내용=컨트롤러 — 메이커가 이 경로 유지 필수). 몬스터 슬롯 = `RunUIGroup/CombatHud/MonsterStatus{1..4}`(자식 Name·Hp·Intent·HpBarFill·Buffs·BlockBadge·TargetMarker; TargetFrame 없음). **부트 흐름**: `OnBeginPlay`→MainMenu→(`MainMenu/NewGameButton`)→로비→run NPC(`OnLobbyNpcInteract` id=="run")→charselect→런. **재연결 검증**: `node tools/verify/cbgap.mjs`(cb 참조 경로↔.ui GAP 0이어야) + 재생성 후 `git status -- ui/` 변경 0(생성기 .ui 미접근 증명). 섹션→UIGroup 일괄 remap 마이그레이션은 `tools/deck/reconnect-ui-paths.mjs`(멱등). UIGroup별 .ui 분포 확인은 `tools/verify/uimap.mjs`.
|
||||
- **머지 충돌(gen-slaydeck.mjs)**: 다른 브랜치가 단일체를 수정해 충돌나면, 그쪽 버전(`git checkout --theirs tools/deck/gen-slaydeck.mjs`)을 취해 **콘텐츠 마커 기반으로 재모듈화**(라인인덱스 X — 줄 추가에 안전·export 이름 자동 파생·`const x=[]` 직전 전문 상수 walk-back 포함) 후 `node tools/verify/diffcheck.mjs origin/main`으로 ui·codeblock 바이트-동일 확인(손실 0 증명). codeblock 메서드·patchCommon은 오케스트레이터 잔류라 그쪽 변경은 자동 보존됨.
|
||||
- **보조 생성기**(각자 자기 산출물의 단일 소스 — 위 표의 메인 `gen-slaydeck.mjs` 외):
|
||||
- `tools/camera/gen-camera.mjs` → `MapCamera.codeblock` + map01~05 카메라 부착 (값 `data/camera.json`)
|
||||
- `tools/map/gen-maps.mjs` → `map02~05` + `Global/SectorConfig.config` (map01 템플릿 클론)
|
||||
@@ -33,7 +37,7 @@ Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된
|
||||
- `tools/player/gen-player-lock.mjs` → `PlayerLock.codeblock` + map01~05 부착
|
||||
- `tools/player/gen-lobby-npc.mjs` → `LobbyNpc.codeblock`·`LobbyMobility.codeblock`
|
||||
- `tools/player/freeze-turn-player.mjs` → `Global/DefaultPlayer.model` 이동 0 고정
|
||||
- `tools/deck/gen-cardhand.mjs` → `DefaultGroup.ui` 카드핸드 보조 패처
|
||||
- (옛 `tools/deck/gen-cardhand.mjs`·`hud/*.mjs`는 `tools/deck/legacy/`로 이관 — 휴면, UI 메이커 저작 전환)
|
||||
|
||||
## 2. 산출물 검증은 카운트로, 내용 출력 금지
|
||||
|
||||
@@ -59,6 +63,7 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
|
||||
- 브랜치 → 커밋(기능 단위) → push → **PR은 반드시 `node tools/git/gitea-pr.mjs`로** (인라인 `curl -d` 한글 본문은 Windows에서 CP949로 깨짐 — PR #34~41 사고).
|
||||
- 제목/본문은 UTF-8 spec JSON 파일로 작성 후 `create <spec.json>` / `merge <번호>`.
|
||||
- PR 제목과 본문은 한국어로 작성한다.
|
||||
- 산출물 재생성 커밋은 소스 변경 커밋과 분리하거나, 메시지에 "산출물 재생성"을 명시.
|
||||
|
||||
## 5. 메이커(MSW) 연동 주의
|
||||
@@ -75,3 +80,9 @@ grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
|---|---|---|---|
|
||||
| 전투 규칙 | PlayCard·CalcPlayerAttack 등 | `tools/balance/sim-balance.mjs` | `node --test tools/balance/sim-balance.test.mjs` |
|
||||
| 맵 생성 | GenerateMap | `tools/map/rogue-map.mjs` | `node --test tools/map/rogue-map.test.mjs` |
|
||||
|
||||
## 7. UI 숫자 표기
|
||||
|
||||
- UI 텍스트에서는 정수값인 숫자에 `.0`을 붙이지 않는다. `1.0/1.0`이 아니라 `1/1`처럼 표시한다.
|
||||
- 생성기 내 Lua UI 코드에서 number 또는 숫자 문자열을 텍스트에 붙일 때는 `FormatNumber` 같은 포맷 헬퍼를 우선 사용한다.
|
||||
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.
|
||||
|
||||
23
RootDesk/MyDesk/01_blue_background_clean_1920x1080.sprite
Normal file
23
RootDesk/MyDesk/01_blue_background_clean_1920x1080.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://ac448e909f89464898708ce232ab8b51",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/ac448e909f89464898708ce232ab8b51/639173152021849887",
|
||||
"upload_hash": "CCC4771B9353971748EF9BEE32D57F15090CE62C4BA6446B11E7842FC7AFDF1F",
|
||||
"name": "01_blue_background_clean_1920x1080",
|
||||
"resource_guid": "ac448e909f89464898708ce232ab8b51",
|
||||
"resource_version": "6a32dd82c325482f6e2bb455"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/Character select bg.sprite
Normal file
23
RootDesk/MyDesk/Character select bg.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://7629b520ced54d508b040681d06741fb",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/7629b520ced54d508b040681d06741fb/639172208899179951",
|
||||
"upload_hash": "C84DCE36101CF3F05E74F93F9B416E7D08D8B78B699E22CF8A6784994115DDAE",
|
||||
"name": "Character select bg",
|
||||
"resource_guid": "7629b520ced54d508b040681d06741fb",
|
||||
"resource_version": "6a316d1a3d5de2eb0c7d345b"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
RootDesk/MyDesk/MapleTree.codeblock
Normal file
36
RootDesk/MyDesk/MapleTree.codeblock
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "codeblock://4a220aa8-e014-4c7b-8234-fff8c5c66686",
|
||||
"ContentType": "x-mod/codeblock",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"CoreVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 2
|
||||
},
|
||||
"ScriptVersion": {
|
||||
"Major": 1,
|
||||
"Minor": 1
|
||||
},
|
||||
"Description": "",
|
||||
"Id": "4a220aa8-e014-4c7b-8234-fff8c5c66686",
|
||||
"Language": 1,
|
||||
"Name": "MapleTree",
|
||||
"Type": 1,
|
||||
"Source": 0,
|
||||
"Target": null,
|
||||
"Properties": [],
|
||||
"Methods": [],
|
||||
"EntityEventHandlers": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@
|
||||
"Name": null
|
||||
},
|
||||
"Arguments": [],
|
||||
"Code": "local monster = self.Entity.Monster\nif not monster then\n\treturn\nend\n\nself.Shape = BoxShape(Vector2.zero, Vector2.one, 0)\n\n-- sprite 사이즈를 가져와 공격 영역으로 사용한다\n_ResourceService:PreloadAsync({self.Entity.SpriteRendererComponent.SpriteRUID}, function()\n\tlocal clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)\n\tlocal firstFrameSprite = clip.Frames[1].FrameSprite\n\tlocal firstSpriteSizeInPixel = Vector2(firstFrameSprite.Width, firstFrameSprite.Height)\n\tlocal ppu = firstFrameSprite.PixelPerUnit\n\n\tself.SpriteSize = firstSpriteSizeInPixel / ppu\n\tself.PositionOffset = (firstSpriteSizeInPixel / 2 - firstFrameSprite.PivotPixel:ToVector2()) / ppu\n\t\n\t_TimerService:SetTimerRepeat(function() \n\t\tif monster.IsDead == false then\n\t\t\tself:AttackNear()\n\t\tend\n\tend, self.AttackInterval)\nend)",
|
||||
"Code": "local monster = self.Entity.Monster\nif not monster then\n\treturn\nend\n\nself.Shape = BoxShape(Vector2.zero, Vector2.one, 0)\n\n-- sprite 사이즈를 가져와 공격 영역으로 사용한다\n_ResourceService:PreloadAsync({self.Entity.SpriteRendererComponent.SpriteRUID}, function()\n\tif _ResourceService:GetTypeAndWait(self.Entity.SpriteRendererComponent.SpriteRUID) ~= ResourceType.AnimationClip then\n\t\treturn\n\tend\n\tlocal clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)\n\tif clip == nil then\n\t\treturn\n\tend\n\tlocal firstFrameSprite = clip.Frames[1].FrameSprite\n\tlocal firstSpriteSizeInPixel = Vector2(firstFrameSprite.Width, firstFrameSprite.Height)\n\tlocal ppu = firstFrameSprite.PixelPerUnit\n\n\tself.SpriteSize = firstSpriteSizeInPixel / ppu\n\tself.PositionOffset = (firstSpriteSizeInPixel / 2 - firstFrameSprite.PivotPixel:ToVector2()) / ppu\n\t\n\t_TimerService:SetTimerRepeat(function() \n\t\tif monster.IsDead == false then\n\t\t\tself:AttackNear()\n\t\tend\n\tend, self.AttackInterval)\nend)",
|
||||
"Scope": 2,
|
||||
"ExecSpace": 1,
|
||||
"Attributes": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
23
RootDesk/MyDesk/Thumnail.sprite
Normal file
23
RootDesk/MyDesk/Thumnail.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://41ad73da083d41b0ae30bf7b86794376",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/41ad73da083d41b0ae30bf7b86794376/639172145413258274",
|
||||
"upload_hash": "CFC620F96E1621FEE5594456FC8A4157BC6EF0D3E7661C5543293200FD364A85",
|
||||
"name": "Thumnail",
|
||||
"resource_guid": "41ad73da083d41b0ae30bf7b86794376",
|
||||
"resource_version": "6a31544d335c959bb11f45eb"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/archmage(fire_poison).sprite
Normal file
23
RootDesk/MyDesk/archmage(fire_poison).sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://2d659478140c4b1c8f37febbb61bdaa0",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/2d659478140c4b1c8f37febbb61bdaa0/639171361295360405",
|
||||
"upload_hash": "13E6D3B629261148095059F7C1D8EDC012C7A60422FC769ECB574A7C5A75759E",
|
||||
"name": "archmage(fire_poison)",
|
||||
"resource_guid": "2d659478140c4b1c8f37febbb61bdaa0",
|
||||
"resource_version": "6a3022013e53f03801a4ac58"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/archmage(thun_cold).sprite
Normal file
23
RootDesk/MyDesk/archmage(thun_cold).sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://ddd0a729328f452b9ff0802ab1f6f579",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/ddd0a729328f452b9ff0802ab1f6f579/639171361295458274",
|
||||
"upload_hash": "7B874B7774FA37E570B16E369112EC467EEA5EC1395A32E4DF01FB6165062E67",
|
||||
"name": "archmage(thun_cold)",
|
||||
"resource_guid": "ddd0a729328f452b9ff0802ab1f6f579",
|
||||
"resource_version": "6a3022012c6a274be88a0819"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/bandit.sprite
Normal file
23
RootDesk/MyDesk/bandit.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://efa920e58d31426486ef974106e7dc8b",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/efa920e58d31426486ef974106e7dc8b/639171361295547945",
|
||||
"upload_hash": "C4B0469A46B70C2356DC8B0F99D36FD9480BFDA5832E251960DD1FF30C201B7F",
|
||||
"name": "bandit",
|
||||
"resource_guid": "efa920e58d31426486ef974106e7dc8b",
|
||||
"resource_version": "6a302201a81bed5f59770e23"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/bowmaster.sprite
Normal file
23
RootDesk/MyDesk/bowmaster.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://93e615d645e948f5a76656bfdd9dce15",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/93e615d645e948f5a76656bfdd9dce15/639171361295501823",
|
||||
"upload_hash": "A5A1263A9E1F5D58B0F985AC58DE7DDE1EA13E0CC5CEE56FC34C3FD792A8D39E",
|
||||
"name": "bowmaster",
|
||||
"resource_guid": "93e615d645e948f5a76656bfdd9dce15",
|
||||
"resource_version": "6a302201a0766b148f66ec2c"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/cleric.sprite
Normal file
23
RootDesk/MyDesk/cleric.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://3c752ffcd4984dcb9f04baab06544f02",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/3c752ffcd4984dcb9f04baab06544f02/639171361295496567",
|
||||
"upload_hash": "ED1ABC3DBCB04FCBD365BAD0408C8CA1D2458FB337DBDEB4A7EC5DF1BAC32496",
|
||||
"name": "cleric",
|
||||
"resource_guid": "3c752ffcd4984dcb9f04baab06544f02",
|
||||
"resource_version": "6a302201d03493c632770e41"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/darkknight.sprite
Normal file
23
RootDesk/MyDesk/darkknight.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://e207e6839a4a4bd0aab681bd296a609a",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/e207e6839a4a4bd0aab681bd296a609a/639171361295401732",
|
||||
"upload_hash": "D5931943781C46611D43595594234BFB133F8BCCAA920C13BCA7E2F8AC9C09D0",
|
||||
"name": "darkknight",
|
||||
"resource_guid": "e207e6839a4a4bd0aab681bd296a609a",
|
||||
"resource_version": "6a302201644d4c175c75d435"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/hero.sprite
Normal file
23
RootDesk/MyDesk/hero.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://0efbf37bb7414aea82b257781068372b",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/0efbf37bb7414aea82b257781068372b/639171361295431877",
|
||||
"upload_hash": "DE22318162E1F93E7B90A0B6A59BE31FC76DEEC122914979915D6D575A4D99B5",
|
||||
"name": "hero",
|
||||
"resource_guid": "0efbf37bb7414aea82b257781068372b",
|
||||
"resource_version": "6a302201c377d9630d82c463"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/hunter.sprite
Normal file
23
RootDesk/MyDesk/hunter.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://fd460e6ee38a40e3b6b05580d00773b6",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/fd460e6ee38a40e3b6b05580d00773b6/639171361295408991",
|
||||
"upload_hash": "B832DE85217EA6544BA4477F0DBA5D2148E4E392A170944336E0DA74E8D41961",
|
||||
"name": "hunter",
|
||||
"resource_guid": "fd460e6ee38a40e3b6b05580d00773b6",
|
||||
"resource_version": "6a3022013d5de2eb0c7d2a51"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/mage.sprite
Normal file
23
RootDesk/MyDesk/mage.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://3b9ea1f066a744bb859df47fef817277",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/3b9ea1f066a744bb859df47fef817277/639171361295599801",
|
||||
"upload_hash": "770C9D83241F8CE2C0EA8B742887D8351A580E2DE15A6380380653855A974F14",
|
||||
"name": "mage",
|
||||
"resource_guid": "3b9ea1f066a744bb859df47fef817277",
|
||||
"resource_version": "6a302201cc7e89479f12a1ac"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/nightlord.sprite
Normal file
23
RootDesk/MyDesk/nightlord.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://994c64290e6d4248bd60aba03a595f72",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/994c64290e6d4248bd60aba03a595f72/639171361295422460",
|
||||
"upload_hash": "A0B7146F6D8E9D72CEACDB7E21A2CD6EEBD4135554D3965B4E1C06BC98FC1211",
|
||||
"name": "nightlord",
|
||||
"resource_guid": "994c64290e6d4248bd60aba03a595f72",
|
||||
"resource_version": "6a30220139613d284615a1e1"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/palladin.sprite
Normal file
23
RootDesk/MyDesk/palladin.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://415d423954764b659574fe829f9aff52",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/415d423954764b659574fe829f9aff52/639171361329450040",
|
||||
"upload_hash": "EB94BA457C18E04D87964201DD50042695A3C7F93651CBC8ED9509BDDE07F331",
|
||||
"name": "palladin",
|
||||
"resource_guid": "415d423954764b659574fe829f9aff52",
|
||||
"resource_version": "6a302205a0766b148f66ec2d"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/pirate.sprite
Normal file
23
RootDesk/MyDesk/pirate.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://94d6417c55da48e9861964c405991219",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/94d6417c55da48e9861964c405991219/639171361329410075",
|
||||
"upload_hash": "EBEC4B43CDBB1759C0BC92C051252A7DF84E6B89C9C56ECBFEFF543A0E260889",
|
||||
"name": "pirate",
|
||||
"resource_guid": "94d6417c55da48e9861964c405991219",
|
||||
"resource_version": "6a3022052c6a274be88a081a"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/restBgImage.sprite
Normal file
23
RootDesk/MyDesk/restBgImage.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://477679b832b44e099a30e4905078dbcb",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/477679b832b44e099a30e4905078dbcb/639172226341792721",
|
||||
"upload_hash": "3E30B07C24C4BC4E373CDEA653035146D2F50ACC6484F6E9DA34E6179BB38F15",
|
||||
"name": "restBgImage",
|
||||
"resource_guid": "477679b832b44e099a30e4905078dbcb",
|
||||
"resource_version": "6a3173ea002bbe95706406b6"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/shadower.sprite
Normal file
23
RootDesk/MyDesk/shadower.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://06c1060586e3457f897f2c596eb5cd71",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/06c1060586e3457f897f2c596eb5cd71/639171361329387468",
|
||||
"upload_hash": "42EE2E63508762E86391486107A23322E11038070F48851D03FE91255CF41596",
|
||||
"name": "shadower",
|
||||
"resource_guid": "06c1060586e3457f897f2c596eb5cd71",
|
||||
"resource_version": "6a302205a81bed5f59770e24"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/shopBgImage.sprite
Normal file
23
RootDesk/MyDesk/shopBgImage.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://28f3b10ac0334fbfbf29677bf963c57a",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/28f3b10ac0334fbfbf29677bf963c57a/639172222414073214",
|
||||
"upload_hash": "01BE0B58F480BA86DA1D18BFE25C01E1B27219A14FE2DCD73456A7A48553CF15",
|
||||
"name": "shopBgImage",
|
||||
"resource_guid": "28f3b10ac0334fbfbf29677bf963c57a",
|
||||
"resource_version": "6a3172612c6a274be88a130e"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/singung.sprite
Normal file
23
RootDesk/MyDesk/singung.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://b2e099f2e5334705af122e3f88840ba7",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/b2e099f2e5334705af122e3f88840ba7/639171361329408374",
|
||||
"upload_hash": "2AB66279D07E64A17CD2AD05BB03F732632805B0076278EC94F51C0227341CC5",
|
||||
"name": "singung",
|
||||
"resource_guid": "b2e099f2e5334705af122e3f88840ba7",
|
||||
"resource_version": "6a302204c377d9630d82c464"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/treasureBgImage.sprite
Normal file
23
RootDesk/MyDesk/treasureBgImage.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://dd6193fd37da4b12bcdbcdcf2fbe8e40",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/dd6193fd37da4b12bcdbcdcf2fbe8e40/639172228832890845",
|
||||
"upload_hash": "3EDD046B291806637ADD12A77BF94CF00BDD9F4F9912C132B14323D9DE5F297C",
|
||||
"name": "treasureBgImage",
|
||||
"resource_guid": "dd6193fd37da4b12bcdbcdcf2fbe8e40",
|
||||
"resource_version": "6a3174e32a2802c06419f288"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/warrior.sprite
Normal file
23
RootDesk/MyDesk/warrior.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"ContentType": "x-mod/sprite",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
"UsePublish": 1,
|
||||
"UseService": 0,
|
||||
"CoreVersion": "26.5.0.0",
|
||||
"StudioVersion": "0.1.0.0",
|
||||
"DynamicLoading": 0,
|
||||
"ContentProto": {
|
||||
"Use": "Json",
|
||||
"Json": {
|
||||
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/28c88fdc5ab44f34a8b3fc1e19d4ce78/639171361330139251",
|
||||
"upload_hash": "2929F452FBB26215631886FFB430EE6035D55EB42B1770E880C1B0A34D97BDA0",
|
||||
"name": "warrior",
|
||||
"resource_guid": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"resource_version": "6a30220539613d284615a1e2"
|
||||
}
|
||||
}
|
||||
}
|
||||
407
data/cards.json
407
data/cards.json
@@ -14,7 +14,7 @@
|
||||
"Defend": {
|
||||
"name": "아이언 바디",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"kind": "Attack",
|
||||
"block": 5,
|
||||
"desc": "방어도 5",
|
||||
"image": "7648c3b8e1ca44fc8ec353561207a670",
|
||||
@@ -89,8 +89,8 @@
|
||||
"name": "분노",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"aoe": true,
|
||||
"damage": 4,
|
||||
"desc": "매 턴 시작 시 힘 +1",
|
||||
"image": "379d86e3de064959aa4612f71e84ccfb",
|
||||
"class": "warrior",
|
||||
@@ -237,7 +237,8 @@
|
||||
"kind": "Skill",
|
||||
"class": "magician",
|
||||
"block": 3,
|
||||
"draw": 1,
|
||||
"discardAll": true,
|
||||
"drawPerDiscarded": 1,
|
||||
"desc": "방어도 3, 드로 1",
|
||||
"image": "7f70a9dc7e304433bb8121dd9c4df98b",
|
||||
"rarity": "normal"
|
||||
@@ -378,7 +379,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 3 줍니다. 약화를 1 부여합니다.",
|
||||
"weak": 1,
|
||||
"damage": 3
|
||||
"damage": 3,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"SilentStrike": {
|
||||
"name": "타격",
|
||||
@@ -387,7 +389,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 6 줍니다.",
|
||||
"damage": 6
|
||||
"damage": 6,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Survivor": {
|
||||
"name": "생존자",
|
||||
@@ -397,7 +400,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 8 얻습니다. 카드를 1장 버립니다.",
|
||||
"block": 8,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"SilentDefend": {
|
||||
"name": "수비",
|
||||
@@ -406,7 +410,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 5 얻습니다.",
|
||||
"block": 5
|
||||
"block": 5,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Slice": {
|
||||
"name": "칼질",
|
||||
@@ -415,7 +420,20 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 6 줍니다.",
|
||||
"damage": 6
|
||||
"damage": 6,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Shiv": {
|
||||
"name": "표창",
|
||||
"cost": 0,
|
||||
"kind": "Attack",
|
||||
"class": "shiv",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 4 줍니다. 소멸.",
|
||||
"damage": 4,
|
||||
"exhaust": true,
|
||||
"token": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"DaggerSpray": {
|
||||
"name": "단검 분사",
|
||||
@@ -426,7 +444,8 @@
|
||||
"desc": "모든 적에게 피해를 4만큼 2번 줍니다.",
|
||||
"aoe": true,
|
||||
"damage": 4,
|
||||
"hits": 2
|
||||
"hits": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"DaggerThrow": {
|
||||
"name": "단검 투척",
|
||||
@@ -435,9 +454,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 9 줍니다. 카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"drawUntilHandSize": 6,
|
||||
"damage": 9,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"PoisonedStab": {
|
||||
"name": "독 찌르기",
|
||||
@@ -447,7 +467,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 6 줍니다. 중독을 3 부여합니다.",
|
||||
"poison": 3,
|
||||
"damage": 6
|
||||
"damage": 6,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"SuckerPunch": {
|
||||
"name": "불의의 일격",
|
||||
@@ -457,7 +478,9 @@
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 8 줍니다. 약화를 1 부여합니다.",
|
||||
"weak": 1,
|
||||
"damage": 8
|
||||
"damage": 8,
|
||||
"cardPlayedDamage": 2,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"LeadingStrike": {
|
||||
"name": "선제 타격",
|
||||
@@ -465,8 +488,10 @@
|
||||
"kind": "Attack",
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 3 줍니다. 단도를 2장 손으로 가져옵니다.",
|
||||
"damage": 3
|
||||
"desc": "피해를 3 줍니다. 표창을 2장 손으로 가져옵니다.",
|
||||
"damage": 3,
|
||||
"addShiv": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"FollowThrough": {
|
||||
"name": "완수",
|
||||
@@ -475,7 +500,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 7 줍니다. 손에 다른 카드가 5장 이상 있다면, 1번 추가로 적중합니다.",
|
||||
"damage": 7
|
||||
"damage": 7,
|
||||
"otherHandAtLeast": 5,
|
||||
"bonusHitsWhenOtherHandAtLeast": 1,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"FlickFlack": {
|
||||
"name": "재주넘기",
|
||||
@@ -486,7 +514,8 @@
|
||||
"desc": "교활. 모든 적에게 피해를 6 줍니다.",
|
||||
"aoe": true,
|
||||
"damage": 6,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Ricochet": {
|
||||
"name": "도탄",
|
||||
@@ -497,7 +526,8 @@
|
||||
"desc": "교활. 무작위 적에게 피해를 3만큼 4번 줍니다.",
|
||||
"damage": 3,
|
||||
"hits": 4,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Prepared": {
|
||||
"name": "예비",
|
||||
@@ -507,7 +537,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Anticipate": {
|
||||
"name": "예측",
|
||||
@@ -516,7 +547,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 민첩을 2 얻습니다.",
|
||||
"draw": 1
|
||||
"dex": 2,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Deflect": {
|
||||
"name": "튕겨내기",
|
||||
@@ -525,17 +557,19 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 4 얻습니다.",
|
||||
"block": 4
|
||||
"block": 4,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"BladeDance": {
|
||||
"name": "검무",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "단도를 3장 손으로 가져옵니다. 소멸.",
|
||||
"damage": 4,
|
||||
"hits": 3
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 소멸.",
|
||||
"addShiv": 3,
|
||||
"exhaust": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Backflip": {
|
||||
"name": "공중제비",
|
||||
@@ -545,7 +579,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 5 얻습니다. 카드를 2장 뽑습니다.",
|
||||
"block": 5,
|
||||
"draw": 2
|
||||
"draw": 2,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"DodgeAndRoll": {
|
||||
"name": "구르기",
|
||||
@@ -554,7 +589,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 4 얻습니다. 다음 턴에, 방어도를 4 얻습니다",
|
||||
"block": 4
|
||||
"block": 4,
|
||||
"nextTurnBlock": 4,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"PiercingWail": {
|
||||
"name": "귀를 찢는 비명",
|
||||
@@ -563,18 +600,19 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"CloakAndDagger": {
|
||||
"name": "망토와 단검",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 6 얻습니다. 단도를 1장 손으로 가져옵니다.",
|
||||
"desc": "방어도를 6 얻습니다. 표창을 1장 손으로 가져옵니다.",
|
||||
"block": 6,
|
||||
"damage": 4,
|
||||
"hits": 1
|
||||
"addShiv": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"DeadlyPoison": {
|
||||
"name": "맹독",
|
||||
@@ -583,7 +621,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "normal",
|
||||
"desc": "중독을 5 부여합니다.",
|
||||
"poison": 5
|
||||
"poison": 5,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Snakebite": {
|
||||
"name": "뱀 물기",
|
||||
@@ -593,7 +632,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "보존. 중독을 7 부여합니다.",
|
||||
"poison": 7,
|
||||
"retain": true
|
||||
"retain": true,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Untouchable": {
|
||||
"name": "범접 불가",
|
||||
@@ -603,7 +643,8 @@
|
||||
"rarity": "normal",
|
||||
"desc": "교활. 방어도를 6 얻습니다.",
|
||||
"block": 6,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Skewer": {
|
||||
"name": "꼬챙이",
|
||||
@@ -612,7 +653,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 8만큼 X번 줍니다.",
|
||||
"draw": 1
|
||||
"useAllEnergy": true,
|
||||
"xDamagePerEnergy": 8,
|
||||
"draw": 1,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Backstab": {
|
||||
"name": "배신",
|
||||
@@ -621,7 +665,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "선천성. 피해를 11 줍니다. 소멸.",
|
||||
"damage": 11
|
||||
"innate": true,
|
||||
"damage": 11,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"PreciseCut": {
|
||||
"name": "정밀한 베기",
|
||||
@@ -630,7 +676,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 13 줍니다. 손에 있는 다른 카드 1장당 피해량이 2 감소합니다.",
|
||||
"damage": 13
|
||||
"damage": 13,
|
||||
"damagePerOtherHandCard": -2,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Finisher": {
|
||||
"name": "마무리",
|
||||
@@ -639,7 +687,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "이번 턴에 사용한 공격 카드 1장당 피해를 6 줍니다.",
|
||||
"damage": 6
|
||||
"damage": 0,
|
||||
"damagePerAttackPlayedThisTurn": 6,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"MementoMori": {
|
||||
"name": "메멘토 모리",
|
||||
@@ -648,7 +698,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 9 줍니다. 이번 턴에 버린 카드 1장당 피해량이 4 증가합니다.",
|
||||
"damage": 9
|
||||
"damage": 9,
|
||||
"damagePerDiscardedThisTurn": 4,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Strangle": {
|
||||
"name": "목 조르기",
|
||||
@@ -657,7 +709,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 8 줍니다. 이번 턴에 카드를 사용할 때마다, 대상 적이 체력을 2 잃습니다.",
|
||||
"damage": 8
|
||||
"damage": 8,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Flechettes": {
|
||||
"name": "프레췌",
|
||||
@@ -666,7 +719,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 스킬 카드 1장당 피해를 5 줍니다.",
|
||||
"damage": 5
|
||||
"damage": 0,
|
||||
"damagePerSkillInHand": 5,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Pounce": {
|
||||
"name": "덮치기",
|
||||
@@ -675,7 +730,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 12 줍니다. 다음에 사용하는 스킬 카드의 비용이 0 이 됩니다.",
|
||||
"damage": 12
|
||||
"damage": 12,
|
||||
"nextSkillCostZero": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Dash": {
|
||||
"name": "돌진",
|
||||
@@ -685,7 +742,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 10 얻습니다. 피해를 10 줍니다.",
|
||||
"block": 10,
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Predator": {
|
||||
"name": "천적",
|
||||
@@ -694,8 +752,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 15 줍니다. 다음 턴에, 카드를 2장 뽑습니다.",
|
||||
"draw": 2,
|
||||
"damage": 15
|
||||
"nextTurnDraw": 2,
|
||||
"damage": 15,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Pinpoint": {
|
||||
"name": "정밀 사격",
|
||||
@@ -704,7 +763,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 15 줍니다. 이번 턴에 스킬을 사용할 때마다 비용이 1 감소합니다.",
|
||||
"damage": 15
|
||||
"damage": 15,
|
||||
"skillCostReductionThisTurn": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"CalculatedGamble": {
|
||||
"name": "계산된 도박",
|
||||
@@ -713,7 +774,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 모든 카드를 버린 뒤, 버린 카드의 수만큼 카드를 뽑습니다. 소멸.",
|
||||
"draw": 1
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"discardAll": true,
|
||||
"drawPerDiscarded": 1
|
||||
},
|
||||
"Expose": {
|
||||
"name": "들춰내기",
|
||||
@@ -722,18 +785,19 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
|
||||
"vuln": 2
|
||||
"vuln": 2,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"HiddenDaggers": {
|
||||
"name": "숨겨진 단검",
|
||||
"cost": 0,
|
||||
"kind": "Attack",
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 2장 버립니다. 단도를 2장 손으로 가져옵니다.",
|
||||
"damage": 4,
|
||||
"hits": 2,
|
||||
"discard": 2
|
||||
"desc": "카드를 2장 버립니다. 표창을 2장 손으로 가져옵니다.",
|
||||
"discard": 2,
|
||||
"addShiv": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"EscapePlan": {
|
||||
"name": "탈출구",
|
||||
@@ -742,8 +806,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 1장 뽑습니다. 뽑은 카드가 스킬 카드라면, 방어도를 3 얻습니다.",
|
||||
"block": 3,
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"drawSkillBlock": 3,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Acrobatics": {
|
||||
"name": "곡예",
|
||||
@@ -753,7 +818,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 3장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 3,
|
||||
"discard": 1
|
||||
"discard": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"HandTrick": {
|
||||
"name": "손기술",
|
||||
@@ -762,7 +828,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
|
||||
"block": 7
|
||||
"block": 7,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Mirage": {
|
||||
"name": "신기루",
|
||||
@@ -771,7 +838,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "모든 적에게 부여된 중독과 동일한 만큼의 방어도를 얻습니다. 소멸.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Expertise": {
|
||||
"name": "전문성",
|
||||
@@ -780,7 +848,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 카드가 6장이 될 때까지 카드를 뽑습니다.",
|
||||
"draw": 1
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"drawUntilHandSize": 6
|
||||
},
|
||||
"BubbleBubble": {
|
||||
"name": "차오르는 독",
|
||||
@@ -789,7 +858,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
|
||||
"poison": 9
|
||||
"poison": 9,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Blur": {
|
||||
"name": "흐릿함",
|
||||
@@ -798,7 +868,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 5 얻습니다. 다음 턴 시작 시 방어도가 사라지지 않습니다.",
|
||||
"block": 5
|
||||
"block": 5,
|
||||
"nextTurnKeepBlock": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"LegSweep": {
|
||||
"name": "다리 걸기",
|
||||
@@ -808,17 +880,18 @@
|
||||
"rarity": "unique",
|
||||
"desc": "약화를 2 부여합니다. 방어도를 11 얻습니다.",
|
||||
"block": 11,
|
||||
"weak": 2
|
||||
"weak": 2,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"UpMySleeve": {
|
||||
"name": "비책",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "단도를 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
||||
"damage": 4,
|
||||
"hits": 3
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
||||
"addShiv": 3,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"BouncingFlask": {
|
||||
"name": "탄성 플라스크",
|
||||
@@ -827,7 +900,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
|
||||
"poison": 9
|
||||
"poison": 9,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Reflex": {
|
||||
"name": "반사신경",
|
||||
@@ -837,7 +911,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 카드를 2장 뽑습니다.",
|
||||
"draw": 2,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Haze": {
|
||||
"name": "아지랑이",
|
||||
@@ -847,7 +922,8 @@
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 모든 적에게 중독을 4 부여합니다.",
|
||||
"poison": 4,
|
||||
"sly": true
|
||||
"sly": true,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Tactician": {
|
||||
"name": "전략가",
|
||||
@@ -856,9 +932,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 을 얻습니다.",
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1,
|
||||
"sly": true
|
||||
"gainEnergy": 1,
|
||||
"sly": true,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"WellLaidPlans": {
|
||||
"name": "괜찮은 전략",
|
||||
@@ -867,18 +943,19 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 종료 시, 카드를 최대 1장까지 보존합니다.",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 2
|
||||
"powerEffect": "retainOne",
|
||||
"value": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"InfiniteBlades": {
|
||||
"name": "무한의 검날",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"kind": "Power",
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 시작 시, 단도를 1장 손으로 가져옵니다.",
|
||||
"damage": 4,
|
||||
"hits": 1
|
||||
"desc": "내 턴 시작 시, 표창을 1장 손으로 가져옵니다.",
|
||||
"turnStartShiv": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Footwork": {
|
||||
"name": "발놀림",
|
||||
@@ -887,8 +964,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "민첩을 2 얻습니다.",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 2
|
||||
"dex": 2,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Outbreak": {
|
||||
"name": "발병",
|
||||
@@ -900,7 +977,8 @@
|
||||
"aoe": true,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 11
|
||||
"damage": 11,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"NoxiousFumes": {
|
||||
"name": "유독 가스",
|
||||
@@ -910,8 +988,9 @@
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 시작 시, 모든 적에게 중독을 2 부여합니다.",
|
||||
"poison": 2,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"powerEffect": "poisonPerTurn",
|
||||
"value": 2,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Accuracy": {
|
||||
"name": "정밀",
|
||||
@@ -919,9 +998,10 @@
|
||||
"kind": "Power",
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "단도의 피해량이 4 증가합니다.",
|
||||
"desc": "표창의 피해량이 4 증가합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"PhantomBlades": {
|
||||
"name": "환영검",
|
||||
@@ -929,9 +1009,10 @@
|
||||
"kind": "Power",
|
||||
"class": "bandit",
|
||||
"rarity": "unique",
|
||||
"desc": "단도가 보존을 얻습니다. 매 턴마다 처음으로 사용하는 단도의 피해량이 9 증가합니다.",
|
||||
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Speedster": {
|
||||
"name": "스피드스터",
|
||||
@@ -941,9 +1022,9 @@
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
|
||||
"aoe": true,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 2
|
||||
"powerEffect": "damagePerTurn",
|
||||
"value": 2,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"GrandFinale": {
|
||||
"name": "대단원의 막",
|
||||
@@ -952,8 +1033,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "뽑을 카드 더미에 카드가 없을 때만 사용할 수 있습니다. 모든 적에게 피해를 60 줍니다.",
|
||||
"playableWhenDrawPileEmpty": true,
|
||||
"aoe": true,
|
||||
"damage": 60
|
||||
"damage": 60,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
||||
},
|
||||
"Assassinate": {
|
||||
"name": "암살",
|
||||
@@ -962,8 +1045,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "선천성. 피해를 10 줍니다. 취약을 1 부여합니다. 소멸.",
|
||||
"innate": true,
|
||||
"vuln": 1,
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"EchoingSlash": {
|
||||
"name": "메아리 참격",
|
||||
@@ -973,7 +1058,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
||||
"aoe": true,
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
||||
},
|
||||
"TheHunt": {
|
||||
"name": "사냥",
|
||||
@@ -982,7 +1068,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
|
||||
"damage": 10
|
||||
"damage": 10,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Murder": {
|
||||
"name": "살해",
|
||||
@@ -991,7 +1078,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "피해를 1 줍니다. 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가합니다.",
|
||||
"damage": 1
|
||||
"damage": 1,
|
||||
"damagePerCardDrawnThisCombat": 1,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Malaise": {
|
||||
"name": "불쾌",
|
||||
@@ -1000,7 +1089,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "적이 힘을 X 잃습니다. 약화를 X 부여합니다. 소멸.",
|
||||
"weak": 3
|
||||
"useAllEnergy": true,
|
||||
"xWeakPerEnergy": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Adrenaline": {
|
||||
"name": "아드레날린",
|
||||
@@ -1010,8 +1101,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "를 얻습니다. 카드를 2장 뽑습니다. 소멸.",
|
||||
"draw": 2,
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1
|
||||
"gainEnergy": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"StormOfSteel": {
|
||||
"name": "강철의 폭풍",
|
||||
@@ -1019,9 +1110,10 @@
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 단도를 손으로 가져옵니다.",
|
||||
"draw": 1,
|
||||
"discardAll": true
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 표창을 손으로 가져옵니다.",
|
||||
"discardAll": true,
|
||||
"addShivPerDiscard": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"ShadowStep": {
|
||||
"name": "그림자 걸음",
|
||||
@@ -1030,8 +1122,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 다음 턴에, 공격 카드의 피해량이 2배가 됩니다.",
|
||||
"draw": 1,
|
||||
"discardAll": true
|
||||
"nextTurnAttackMultiplier": 2,
|
||||
"discardAll": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Shadowmeld": {
|
||||
"name": "그림자 은신",
|
||||
@@ -1040,7 +1133,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴 동안 얻는 방어도가 2배가 됩니다.",
|
||||
"draw": 1
|
||||
"blockGainMultiplier": 2,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"CorrosiveWave": {
|
||||
"name": "부식성 파도",
|
||||
@@ -1049,17 +1143,18 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
|
||||
"poison": 2
|
||||
"poison": 2,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"BladeOfInk": {
|
||||
"name": "잉크 칼날",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "잉크투성이 단도를 2장 손으로 가져옵니다.",
|
||||
"damage": 4,
|
||||
"hits": 2
|
||||
"desc": "잉크투성이 표창을 2장 손으로 가져옵니다.",
|
||||
"addShiv": 2,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"Burst": {
|
||||
"name": "폭주",
|
||||
@@ -1068,7 +1163,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
|
||||
"draw": 1
|
||||
"draw": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"KnifeTrap": {
|
||||
"name": "칼날 함정",
|
||||
@@ -1076,8 +1172,9 @@
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 단도를 사용합니다.",
|
||||
"draw": 1
|
||||
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 표창을 사용합니다.",
|
||||
"draw": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"BulletTime": {
|
||||
"name": "불릿 타임",
|
||||
@@ -1086,8 +1183,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴 동안 더 이상 카드를 뽑을 수 없습니다. 이번 턴 동안 손에 있는 모든 카드를 비용 없이 사용할 수 있습니다.",
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1
|
||||
"handCostZeroThisTurn": true,
|
||||
"drawDisabledThisTurn": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Nightmare": {
|
||||
"name": "악몽",
|
||||
@@ -1096,7 +1194,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 1장 선택합니다. 다음 턴에, 그 카드의 복사본을 3장 손으로 가져옵니다. 소멸.",
|
||||
"draw": 1
|
||||
"nextTurnCopies": 3,
|
||||
"nextTurnSelectHandCard": true,
|
||||
"nextTurnSelectPrompt": "복사할 카드를 선택하세요",
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"ToolsOfTheTrade": {
|
||||
"name": "작업 도구",
|
||||
@@ -1105,10 +1206,9 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "내 턴 시작 시, 카드를 1장 뽑고 카드를 1장 버립니다.",
|
||||
"draw": 1,
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1,
|
||||
"discard": 1
|
||||
"turnStartDraw": 1,
|
||||
"turnStartDiscard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Afterimage": {
|
||||
"name": "잔상",
|
||||
@@ -1117,9 +1217,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 사용할 때마다, 방어도를 1 얻습니다.",
|
||||
"block": 1,
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 2
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"cardPlayedBlock": 1
|
||||
},
|
||||
"Accelerant": {
|
||||
"name": "촉진제",
|
||||
@@ -1129,7 +1228,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "중독이 1번 추가로 발동합니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Envenom": {
|
||||
"name": "독 바르기",
|
||||
@@ -1138,9 +1238,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "공격 카드가 막히지 않은 피해를 줄 때마다, 중독을 1 부여합니다.",
|
||||
"poison": 1,
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"attackPoison": 1,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"MasterPlanner": {
|
||||
"name": "설계의 대가",
|
||||
@@ -1150,7 +1249,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "스킬 카드를 사용 시, 그 카드가 교활을 얻습니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Tracking": {
|
||||
"name": "추적",
|
||||
@@ -1160,19 +1260,20 @@
|
||||
"rarity": "legend",
|
||||
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1
|
||||
"value": 1,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"FanOfKnives": {
|
||||
"name": "칼날 부채",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "단도가 이제 모든 적을 대상으로 합니다. 단도를 4장 손으로 가져옵니다.",
|
||||
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 4,
|
||||
"hits": 4
|
||||
"addShiv": 4,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"SerpentForm": {
|
||||
"name": "구렁이의 형상",
|
||||
@@ -1181,9 +1282,8 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 사용할 때마다, 무작위 적에게 피해를 4 줍니다.",
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"damage": 4
|
||||
"cardPlayedRandomDamage": 4,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Abrasive": {
|
||||
"name": "연마",
|
||||
@@ -1192,9 +1292,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "교활. 민첩을 1 얻습니다. 가시를 4 얻습니다.",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 2,
|
||||
"sly": true
|
||||
"dex": 1,
|
||||
"thorns": 4,
|
||||
"sly": true,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Suppress": {
|
||||
"name": "진압",
|
||||
@@ -1203,8 +1304,10 @@
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "선천성. 피해를 11 줍니다. 약화를 3 부여합니다.",
|
||||
"innate": true,
|
||||
"weak": 3,
|
||||
"damage": 11
|
||||
"damage": 11,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"WraithForm": {
|
||||
"name": "유령의 형상",
|
||||
@@ -1214,26 +1317,8 @@
|
||||
"rarity": "legend",
|
||||
"desc": "불가침을 2 얻습니다. 내 턴 종료 시 민첩을 1 잃습니다.",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 8
|
||||
},
|
||||
"Flanking": {
|
||||
"name": "측면 공격",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 대상 적이 다른 플레이어에게 받는 피해량이 2배가 됩니다.",
|
||||
"draw": 1
|
||||
},
|
||||
"Sneaky": {
|
||||
"name": "비열함",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "bandit",
|
||||
"rarity": "legend",
|
||||
"desc": "교활. 다른 플레이어가 적을 공격할 때마다, 방어도를 1 얻습니다.",
|
||||
"block": 1,
|
||||
"sly": true
|
||||
"value": 8,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
}
|
||||
},
|
||||
"starterDecks": {
|
||||
|
||||
7
data/characters.json
Normal file
7
data/characters.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"portraits": {
|
||||
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"magician": "3b9ea1f066a744bb859df47fef817277",
|
||||
"bandit": "efa920e58d31426486ef974106e7dc8b"
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,65 @@
|
||||
{ "kind": "Attack", "value": 12 },
|
||||
{ "kind": "Attack", "value": 24 }
|
||||
]
|
||||
},
|
||||
"octopus": {
|
||||
"name": "문어",
|
||||
"maxHp": 15,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 5 },
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Defend", "value": 4 }
|
||||
]
|
||||
},
|
||||
"kapa_drake": {
|
||||
"name": "카파 드레이크",
|
||||
"maxHp": 24,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 9 },
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Defend", "value": 6 },
|
||||
{ "kind": "Attack", "value": 11 }
|
||||
]
|
||||
},
|
||||
"junior_neki": {
|
||||
"name": "주니어 네키",
|
||||
"maxHp": 18,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 1 }
|
||||
]
|
||||
},
|
||||
"junior_bugi": {
|
||||
"name": "주니어 부기",
|
||||
"maxHp": 20,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 7 },
|
||||
{ "kind": "Defend", "value": 5 },
|
||||
{ "kind": "Attack", "value": 9 }
|
||||
]
|
||||
},
|
||||
"dile": {
|
||||
"name": "다일",
|
||||
"maxHp": 65,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 13 },
|
||||
{ "kind": "Defend", "value": 9 },
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Attack", "value": 16 },
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 1 }
|
||||
]
|
||||
},
|
||||
"mano": {
|
||||
"name": "마노",
|
||||
"maxHp": 80,
|
||||
"intents": [
|
||||
{ "kind": "Defend", "value": 12 },
|
||||
{ "kind": "Attack", "value": 14 },
|
||||
{ "kind": "Debuff", "effect": "vuln", "value": 1 },
|
||||
{ "kind": "Attack", "value": 10 },
|
||||
{ "kind": "AddCard", "card": "Wound", "count": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"activeEnemy": "slime",
|
||||
|
||||
10
docs/attack-poison.md
Normal file
10
docs/attack-poison.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 공격 적중 독
|
||||
|
||||
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
|
||||
|
||||
동작:
|
||||
|
||||
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
|
||||
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
|
||||
- `Envenom` 같은 카드가 이 필드를 사용합니다.
|
||||
|
||||
102
docs/bandit-card-audit.md
Normal file
102
docs/bandit-card-audit.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Bandit Card Audit
|
||||
|
||||
`bandit` 카드의 구현 상태를 카드별로 정리한 문서입니다.
|
||||
|
||||
상태 기준:
|
||||
|
||||
- `구현됨`: 공용 필드와 공용 로직으로 처리됨
|
||||
- `부분구현`: 카드 설명의 일부만 맞음
|
||||
- `미구현`: 아직 전용 메커니즘이 없음
|
||||
|
||||
## 구현됨
|
||||
|
||||
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `Pinpoint`
|
||||
|
||||
공용 메모:
|
||||
|
||||
- `poison`, `innate`, `playableWhenDrawPileEmpty` 구현됨
|
||||
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne` 구현됨
|
||||
- `turnStartDraw`, `turnStartDiscard` 구현됨
|
||||
- `nextTurnBlock`, `nextTurnDraw`, `nextTurnKeepBlock`, `nextTurnAttackMultiplier`, `nextTurnCopies`, `nextTurnSelectHandCard` 구현됨
|
||||
- `damagePerOtherHandCard`, `damagePerAttackPlayedThisTurn`, `damagePerDiscardedThisTurn`, `damagePerSkillInHand`, `otherHandAtLeast`, `bonusHitsWhenOtherHandAtLeast` 구현됨
|
||||
- `gainEnergy`, `drawUntilHandSize`, `drawPerDiscarded`, `cardPlayedBlock`, `blockGainMultiplier`, `nextSkillCostZero`, `skillCostReductionThisTurn` 구현됨
|
||||
|
||||
## 부분구현
|
||||
|
||||
`Ricochet`: 무작위 적 4회 타격이 아니라 일반 분산 공격으로만 처리됨
|
||||
|
||||
`Anticipate`: 이번 턴 동안 민첩 2가 아니라 전투 전체 민첩 증가
|
||||
|
||||
`PiercingWail`: 이번 턴 적 공격 감소가 아니라 공용 약화/취약 계열만 적용
|
||||
|
||||
`Expose`: 방어도/인공물 제거는 없고 취약만 적용됨
|
||||
|
||||
`BubbleBubble`: 적이 독을 보유한 경우라는 조건이 아직 없음
|
||||
|
||||
`BouncingFlask`: 무작위 적 3번 분산 대신 단일 독 9 처리
|
||||
|
||||
## 미구현
|
||||
|
||||
`Skewer`: X코스트 연타 공격
|
||||
|
||||
`Outbreak`: 독 3번 부여 시 전체 피해 트리거
|
||||
|
||||
`Strangle`: 이번 턴 카드 사용마다 추가 피해
|
||||
|
||||
`EscapePlan`: 드로우한 카드가 스킬이면 방어도 3
|
||||
|
||||
`HandTrick`: 손패의 스킬 카드 하나에 교활 부여
|
||||
|
||||
`Mirage`: 모든 적의 독 총합만큼 방어 획득
|
||||
|
||||
`UpMySleeve`: 표창 생성 + 비용 감소
|
||||
|
||||
`NoxiousFumes`: 턴 시작 전체 적 독 부여 파워
|
||||
|
||||
`Accuracy`: 표창 피해 증가 파워
|
||||
|
||||
`PhantomBlades`: 표창 보존 + 첫 표창 강화
|
||||
|
||||
`Speedster`: 드로우할 때마다 전체 피해
|
||||
|
||||
`EchoingSlash`: 처치 시 반복
|
||||
|
||||
`TheHunt`: 처치 조건 보상
|
||||
|
||||
`Murder`: 이번 전투 동안 뽑은 카드 수 비례 피해
|
||||
|
||||
`Malaise`: X코스트 약화/피해 감소
|
||||
|
||||
`Pinpoint`: 이번 턴 스킬 비용 감소
|
||||
|
||||
`CorrosiveWave`: 드로우할 때마다 독
|
||||
|
||||
`BladeOfInk`: 전용 표창 생성
|
||||
|
||||
`Burst`: 다음 스킬 1회 추가 사용
|
||||
|
||||
`KnifeTrap`: 소멸된 표창 전부 사용
|
||||
|
||||
`BulletTime`: 드로우 금지 + 손패 무료 사용
|
||||
|
||||
`Accelerant`: 추가 독 발동
|
||||
|
||||
`Envenom`: 공격 적중 시 독 부여
|
||||
|
||||
`MasterPlanner`: 스킬 사용 시 교활 부여
|
||||
|
||||
`Tracking`: 약화된 적이 공격 피해를 2배로 받음
|
||||
|
||||
`FanOfKnives`: 표창이 모든 적 대상
|
||||
|
||||
`SerpentForm`: 카드 사용할 때마다 무작위 적에게 피해
|
||||
|
||||
`WraithForm`: 불가침 2 + 턴 종료 시 민첩 감소
|
||||
|
||||
## 다음 축
|
||||
|
||||
- 조건부 피해
|
||||
- 카드 사용 트리거
|
||||
- 비용/X코스트
|
||||
- 드로우 연동 파워
|
||||
|
||||
87
docs/card-effect-fields.md
Normal file
87
docs/card-effect-fields.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Card Effect Fields
|
||||
|
||||
`data/cards.json`의 카드 효과를 공용 데이터 필드로 표현하는 기준 문서입니다.
|
||||
|
||||
## 피해 수치
|
||||
|
||||
- `damage`: 기본 피해
|
||||
- `damagePerOtherHandCard`: 손패의 다른 카드 수만큼 피해 증감
|
||||
- `damagePerAttackPlayedThisTurn`: 이번 턴에 사용한 공격 카드 수만큼 피해 증감
|
||||
- `damagePerDiscardedThisTurn`: 이번 턴에 버린 카드 수만큼 피해 증감
|
||||
- `damagePerSkillInHand`: 손패의 스킬 카드 수만큼 피해 증감
|
||||
- `otherHandAtLeast`: 손패의 다른 카드가 이 수 이상일 때 조건 충족
|
||||
- `bonusHitsWhenOtherHandAtLeast`: 조건 충족 시 추가 적중 수
|
||||
|
||||
## 방어/상태
|
||||
|
||||
- `block`: 방어도 획득
|
||||
- `cardPlayedBlock`: 카드를 사용할 때마다 방어도 획득
|
||||
- `blockGainMultiplier`: 이번 턴 동안 얻는 방어도 배수
|
||||
- `hits`: 다단히트 횟수
|
||||
- `aoe`: 모든 적 대상
|
||||
- `pierce`: 방어도 무시
|
||||
- `draw`: 즉시 드로우
|
||||
- `drawUntilHandSize`: 손패가 지정 장수에 도달할 때까지 드로우
|
||||
- `heal`: 즉시 회복
|
||||
- `gainEnergy`: 즉시 에너지 획득
|
||||
- `strength`: 힘 획득
|
||||
- `dex`: 민첩 획득
|
||||
- `thorns`: 가시 획득
|
||||
- `selfVuln`: 자신에게 취약 부여
|
||||
|
||||
## 상태이상
|
||||
|
||||
- `weak`: 약화 부여
|
||||
- `vuln`: 취약 부여
|
||||
- `poison`: 중독 부여
|
||||
|
||||
`poison`은 적 턴 시작 시 피해를 주고 1 감소합니다.
|
||||
|
||||
## 드로우/버리기
|
||||
|
||||
- `discard`: 손패에서 지정 장수 버리기
|
||||
- `discardAll`: 손패 전부 버리기
|
||||
- `drawPerDiscarded`: 버린 카드 1장당 추가 드로우
|
||||
- `addShiv`: 표창 생성
|
||||
- `addShivPerDiscard`: 버린 장수만큼 표창 생성
|
||||
- `sly`: 버려질 때 교활 발동
|
||||
- `retain`: 턴 종료 시 해당 카드 보존
|
||||
|
||||
## 파워/턴 효과
|
||||
|
||||
- `powerEffect: "strengthPerTurn"`
|
||||
- `powerEffect: "energyPerTurn"`
|
||||
- `powerEffect: "blockPerTurn"`
|
||||
- `powerEffect: "retainOne"`
|
||||
- `turnStartShiv`: 턴 시작 시 표창 생성
|
||||
- `turnStartDraw`: 턴 시작 시 추가 드로우
|
||||
- `turnStartDiscard`: 턴 시작 시 카드 버리기
|
||||
|
||||
## 다음 턴 예약
|
||||
|
||||
- `nextTurnBlock`: 다음 턴 시작 시 방어도 획득
|
||||
- `nextTurnDraw`: 다음 턴 시작 시 추가 드로우
|
||||
- `nextTurnKeepBlock`: 다음 턴 시작 시 기존 방어도 유지
|
||||
- `nextTurnAttackMultiplier`: 다음 턴 공격 피해 배수
|
||||
- `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수
|
||||
- `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택
|
||||
- `nextTurnSelectPrompt`: 선택 UI 문구
|
||||
- `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦
|
||||
- `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소
|
||||
|
||||
## 기타
|
||||
|
||||
- `innate`: 전투 시작 시 첫 손패에 우선 진입
|
||||
- `playableWhenDrawPileEmpty`: 뽑을 카드 더미가 비었을 때만 사용 가능
|
||||
- `exhaust`: 사용 후 소멸
|
||||
- `unplayable`: 사용 불가
|
||||
- `curse`: 저주 카드
|
||||
- `token`: 토큰 카드
|
||||
- `endTurnDamage`: 턴 종료 시 손패에 있으면 피해
|
||||
|
||||
## 사용 원칙
|
||||
|
||||
- 카드 전용 분기보다 공용 필드를 먼저 쓴다.
|
||||
- 같은 효과는 같은 필드로 재사용한다.
|
||||
- 새 카드가 같은 패턴이면 먼저 공용 필드를 추가한다.
|
||||
|
||||
5
docs/card-play-damage.md
Normal file
5
docs/card-play-damage.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 카드 사용 시 피해
|
||||
|
||||
`cardPlayedDamage`는 카드를 사용할 때마다 현재 대상에게 체력을 직접 깎는 공용 효과입니다. 방어도는 무시하고, 같은 필드를 다른 카드에도 그대로 붙여 재사용할 수 있습니다.
|
||||
|
||||
`cardPlayedRandomDamage`는 같은 시점에 살아 있는 적 하나를 랜덤으로 골라 체력을 직접 깎습니다. `Strangle`과 `SerpentForm` 같은 카드가 이 계열을 씁니다.
|
||||
31
docs/codex-workflow.md
Normal file
31
docs/codex-workflow.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Codex Workflow
|
||||
|
||||
이 저장소에서 작업할 때는 토큰과 변경량을 아끼는 쪽을 기본으로 둔다.
|
||||
|
||||
## 작업 원칙
|
||||
|
||||
- 이미 확인한 사실은 다시 읽지 않는다.
|
||||
- 같은 내용을 통째로 지우고 새로 쓰지 않는다.
|
||||
- 수정은 가능한 한 `apply_patch`로 섹션 단위만 한다.
|
||||
- 문서는 전체 재작성보다 부분 수정으로 유지한다.
|
||||
- 카드 구현은 한 번에 하나씩, 공용 필드 우선으로 넣는다.
|
||||
- 새 기능은 `데이터 1곳 + 런타임 1곳 + 테스트 1곳` 순서로 맞춘다.
|
||||
|
||||
## 읽기 원칙
|
||||
|
||||
- 파일은 필요한 것만 읽는다.
|
||||
- 비슷한 파일은 병렬로 한 번에 확인한다.
|
||||
- 같은 정보를 여러 번 요약하지 않는다.
|
||||
|
||||
## 쓰기 원칙
|
||||
|
||||
- 공용으로 표현 가능한 효과는 카드 전용 분기로 만들지 않는다.
|
||||
- 같은 의미의 효과는 같은 필드 이름을 쓴다.
|
||||
- 문서는 카드별 상태표와 공용 필드 사전을 분리해서 유지한다.
|
||||
|
||||
## 응답 원칙
|
||||
|
||||
- 중간 보고는 짧게 한다.
|
||||
- 바뀐 점과 남은 점만 말한다.
|
||||
- 불필요한 재설명은 줄인다.
|
||||
|
||||
86
docs/deck-concept.md
Normal file
86
docs/deck-concept.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# SlayMaple 덱 컨셉 & 직업 스킬셋
|
||||
|
||||
> SlayMaple 카드 덱의 **직업별 컨셉 · 메이플 스킬셋 · Slay the Spire 2 차용 매칭** 설계 문서.
|
||||
> 원칙: 카드 한 장 = **STS2 메커니즘(뼈대) + 메이플 스킬(외형)**. STS 고유 *표현*(카드명·아트·UI)은 모방 금지, *메커니즘*만 차용(IP 해석 심사 대비).
|
||||
> 수치(데미지·코스트·등급)는 `tools/balance/sim-balance.mjs`로 검증. 본 문서는 *어떤 스킬을 어떤 카드로* 만들지의 설계도.
|
||||
|
||||
기준: 메이플 = **클래식(빅뱅 이전)** 스킬 외형, STS = **Slay the Spire 2**.
|
||||
|
||||
---
|
||||
|
||||
## 직업 ↔ STS2 매칭 요약
|
||||
|
||||
| 직업 | 컨셉 | STS2 차용 |
|
||||
|---|---|---|
|
||||
| ⚔️ 전사 | 단단한 탱커/브루저 | The Ironclad (힘·방어·소멸) |
|
||||
| 🗡️ 도적 | 단검 난사 / 독 | The Silent (표창·독·교활) |
|
||||
| 🔮 법사 | 약체 + 게이지 운용 | The Defect (오브·집중) |
|
||||
|
||||
---
|
||||
|
||||
## ⚔️ 전사 (Warrior) — HP80 · 탱커
|
||||
|
||||
방어를 쌓고 버티다 역공하는 브루저. Ironclad의 두 축을 2차 전직으로 분화.
|
||||
|
||||
### 파이터 — 콤보 브루저형 탱커
|
||||
- **콤보 규칙**: 공격 카드를 **연속으로** 사용하면 콤보가 쌓인다. **방어·파워 등 비공격(Skill/Power) 카드를 사용하면 콤보가 리셋(0)** 된다.
|
||||
- 콤보가 쌓일수록 **데미지 증가 버프(힘 계열)** 를 받는다 → 방어를 포기하고 공격을 몰아칠수록 강해지는 리스크/리워드.
|
||||
- 차용: Ironclad 힘 스택/Demon Form + 콤보. 메이플 외형: 콤보 어택·분노·브랜디시.
|
||||
|
||||
### 페이지 — 방어 축적 → 바디 슬램 카운터
|
||||
- **위협**(전체 적 약화+취약 디버프)로 버티며 **방어도를 축적**(아이언 월 등 + 방어 유지 retain).
|
||||
- **바디 슬램**: 현재 방어도에 비례한 피해로 카운터. 파워 가드(반사) 보조.
|
||||
- 차용: Ironclad 방어 빌드(Barricade+Entrench→Body Slam). 메이플 외형: 위협·아이언 월·파워 가드.
|
||||
|
||||
### 스피어맨 — 유지/리치형
|
||||
- 하이퍼 바디(최대 HP↑)·아이언 월(방어 유지)·창 리치 광역. 공격 스케일(파이터)·방어 카운터(페이지)와 구분되는 지속 탱.
|
||||
|
||||
---
|
||||
|
||||
## 🗡️ 도적 (Thief) — HP70 · 단검/독
|
||||
|
||||
Slay the Spire *Silent* 차용. **형(codex)이 Silent 88장 완역 포트 + 스킬 아이콘 적용 완료.** 3대 아키타입:
|
||||
|
||||
- **표창(Shiv) 난사**: 0코스트 표창 토큰 대량 생성 → 공격마다 연계. (정밀=표창 피해↑, 칼날 부채=표창 전체화)
|
||||
- **독(Poison)**: 중독 중첩 → 매턴 틱뎀. (유독 가스·발병·촉진제·독 바르기)
|
||||
- **교활(Sly)·버림(discard)**: 버려질 때 무료 발동, 얇은 덱 빠른 순환.
|
||||
|
||||
### 2차 갈래
|
||||
- **어쌔신** — 표창 난사 + 크리 / 흡혈(드레인) 중심.
|
||||
- **시프** — 단검 난타(새비지 블로우 = 다단히트) + 독 / 버림 중심.
|
||||
|
||||
> 남은 작업: 카드명이 STS 직역(무력화·배신·아드레날린 등) → **어쌔신/시프 메이플 스킬명으로 재서사** + 멀티플레이어 전제 카드(측면 공격·비열함·추적) 싱글 출품용 정리.
|
||||
|
||||
---
|
||||
|
||||
## 🔮 법사 (Magician) — HP70 · 약체/게이지
|
||||
|
||||
몸은 약하나 **게이지(오브) 운용**으로 다중 공격·화력 집중. **오브 메커니즘은 위자드(불/독·썬/콜)에만 적용**, 클레릭은 별도 보조 컨셉.
|
||||
|
||||
### 위자드(불/독) — 독 + 불 시너지
|
||||
- **독을 묻히는 스킬**(포이즌 브레스 등)으로 대상에 독을 부여(독뎀 = DoT).
|
||||
- **독이 묻은 적에게 불 카드(파이어 애로우 등)를 쓰면 추가 데미지** — *독뎀 상수* 보너스(독 스택/상수 비례).
|
||||
- 즉 "독 깔기 → 불로 폭발"의 2단 콤보. 불·독 오브로 운용.
|
||||
|
||||
### 위자드(썬/콜) — 오브 운용(썬더 다중 / 콜드 빙결)
|
||||
- **오브로 썬더·콜드를 보유**. **썬더 = 다중 공격 특화**(AoE·다단). **콜드 = 빙결 부여**(빙결 = *취약과 동일 효과* 를 데미지와 함께).
|
||||
- **오브를 사용하는 만큼 오브를 획득하거나 다중 소모**하는 방식 — 오브 수급/소비 운용이 핵심.
|
||||
|
||||
### 클레릭 — 보조(회복·버프) · 오브 없음
|
||||
- **회복 스킬**(힐)과 **각종 버프**(블레스 등) 중심의 서포트.
|
||||
- **언데드 계열 몬스터에게는 힐로 공격** 가능 — 보조 힐러 정체성.
|
||||
|
||||
> ⚠️ 위자드 오브/게이지·전사 콤보 스택·바디 슬램·독뎀 시너지는 **신규 메커니즘** — `tools/deck/gen-slaydeck.mjs`(전투 규칙) + `tools/balance/sim-balance.mjs`(JS 미러) 양쪽 구현·동기화 필요.
|
||||
|
||||
---
|
||||
|
||||
## 차용 경계 (IP 심사 대비)
|
||||
|
||||
- 차용 OK = **메커니즘**(콤보 스택·방어→피해 전환·독+불 시너지·오브 게이지·빙결=취약 등 시스템).
|
||||
- 모방 금지 = STS 고유 **표현**(카드명·아트·UI 직접 사용).
|
||||
- 만점 루트 = STS2 메커니즘을 **메이플 스킬·외형으로 완전 재서사화**.
|
||||
|
||||
## 참고
|
||||
|
||||
- 카드 데이터 단일 소스: `data/cards.json` (현 122장: 전사 18·마법사 14·도적 88 + Shiv·저주)
|
||||
- 메이플 스킬 외형 매핑·STS2 캐릭터 상세는 박재오 개인 위키 `프로젝트-메이플-덱빌딩-스킬구성` / `프로젝트-메이플-STS2-차용-덱컨셉` 참조.
|
||||
8
docs/draw-count.md
Normal file
8
docs/draw-count.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 전투 드로우 누적
|
||||
|
||||
`damagePerCardDrawnThisCombat`은 이번 전투 동안 실제로 뽑힌 카드 수를 기준으로 공격력을 올리는 공용 필드입니다.
|
||||
|
||||
적용 예시:
|
||||
|
||||
- `Murder`: 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가
|
||||
|
||||
22
docs/draw-skill-block.md
Normal file
22
docs/draw-skill-block.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 드로우 연동 효과
|
||||
|
||||
드로우 결과를 받아 후속 효과를 처리하는 공용 패턴을 정리합니다.
|
||||
|
||||
## 현재 구현
|
||||
|
||||
- `draw`: 카드를 뽑음
|
||||
- `drawUntilHandSize`: 손패가 지정 수치가 될 때까지 뽑음
|
||||
- `drawSkillBlock`: 이번 카드로 뽑힌 카드 중 스킬 카드마다 방어도를 얻음
|
||||
|
||||
## 동작 방식
|
||||
|
||||
- 드로우 함수는 이번에 뽑힌 카드 ID 목록을 반환합니다.
|
||||
- 카드 효과는 그 목록을 보고 조건을 판정합니다.
|
||||
- 그래서 `EscapePlan` 같은 카드뿐 아니라, 나중에 같은 규칙이 필요한 카드에도 같은 필드를 붙이면 됩니다.
|
||||
|
||||
## 예시
|
||||
|
||||
- `EscapePlan`
|
||||
- `draw = 1`
|
||||
- `drawSkillBlock = 3`
|
||||
|
||||
205
docs/superpowers/plans/2026-06-16-charselect-images-back.md
Normal file
205
docs/superpowers/plans/2026-06-16-charselect-images-back.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 직업 선택 캐릭터 이미지 + 뒤로가기 — 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스.
|
||||
|
||||
**Goal:** CharacterSelectHud의 단색 박스를 캐릭터 이미지 카드로 바꾸고(선택 시 금색 테두리), 뒤로가기 버튼으로 로비 복귀를 추가한다.
|
||||
|
||||
**Architecture:** 단일 생성기 `tools/deck/gen-slaydeck.mjs` 수정 + `data/characters.json` 신설(초상화 RUID 단일 소스). 이미지는 생성 시 `sprite({dataId})`로 주입, 선택 표시는 기존 `RenderCharacterSelect`의 Button Color를 금색으로. 뒤로가기는 ShopHud 나가기 패턴 재사용 → `ShowLobby()`. 산출물(ui/codeblock) 재생성.
|
||||
|
||||
**Tech Stack:** Node ESM 생성기, MSW Lua codeblock, MSW UI JSON. 검증=카운트+메이커 플레이테스트(이 저장소는 단위테스트 대신 카운트/플레이테스트).
|
||||
|
||||
**확정 RUID (메이커 임포트 완료, `.sprite`에서 추출):**
|
||||
- warrior `28c88fdc5ab44f34a8b3fc1e19d4ce78`
|
||||
- magician `3b9ea1f066a744bb859df47fef817277`
|
||||
- bandit `efa920e58d31426486ef974106e7dc8b`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `data/characters.json` + 생성기 로드·검증
|
||||
|
||||
**Files:**
|
||||
- Create: `data/characters.json`
|
||||
- Modify: `tools/deck/gen-slaydeck.mjs:91-96` 인접(NODEICONS 로드 블록 뒤)
|
||||
|
||||
- [ ] **Step 1:** `data/characters.json` 작성
|
||||
```json
|
||||
{
|
||||
"portraits": {
|
||||
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"magician": "3b9ea1f066a744bb859df47fef817277",
|
||||
"bandit": "efa920e58d31426486ef974106e7dc8b"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** gen-slaydeck.mjs NODEICONS 검증 블록(`:96`) 바로 뒤에 로드+fail-fast 검증 추가
|
||||
```js
|
||||
// 캐릭터 선택 초상화 (메이커 임포트 RUID, data/characters.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||||
const CHARS = JSON.parse(readFileSync('data/characters.json', 'utf8'));
|
||||
for (const c of ['warrior', 'magician', 'bandit']) {
|
||||
if (!/^[0-9a-f]{32}$/.test((CHARS.portraits || {})[c] || '')) throw new Error(`[gen-slaydeck] characters.json portraits.${c} RUID 누락/형식오류`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** 생성기 실행해 에러 없는지 확인(아직 UI 미사용이라 출력 동일)
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
```
|
||||
Expected: 성공 메시지 1줄, throw 없음.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: CharacterSelectHud — 카드 이미지화 (classCards 루프)
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs:2516-2540` (Portrait/Desc 블록), `:2503-2515` (Name)
|
||||
|
||||
카드 본체 `{key}Button`(2490-2502)·DeckButton(2567-2580)·StartButton·click 바인딩 경로는 **불변**. `cls.tint`/`cls.desc`는 더는 안 쓰이나 배열 정의는 그대로 둬도 무방.
|
||||
|
||||
- [ ] **Step 1:** `Name`(2503-2515) 위치를 하단으로 — `transform`의 `pos: { x: 0, y: 108 }` → `pos: { x: 0, y: -137 }`. (displayOrder 0 유지) — 텍스트는 그대로(금색).
|
||||
|
||||
- [ ] **Step 2:** `Portrait` 엔티티(2516-2527)를 **`Art` 이미지로 교체**. 경로·guid·sprite 변경:
|
||||
```js
|
||||
select.push(entity({
|
||||
id: guid('menu', 200 + i),
|
||||
path: `${base}/Art`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 270, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 258, y: 318 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ dataId: CHARS.portraits[cls.classId], color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
(258×318, 6px 인셋 → 부모 Button 색이 테두리로 보임. type:0=이미지 풀, raycast off=클릭은 부모 Button으로.)
|
||||
|
||||
- [ ] **Step 3:** `Desc` 엔티티(2528-2540) **삭제**(emit 안 함).
|
||||
|
||||
- [ ] **Step 4:** `Name` 뒤에 반투명 하단 배너 `NameBanner` 추가(displayOrder 1, Art 위·Name 아래). Name의 displayOrder를 2로 올림.
|
||||
```js
|
||||
select.push(entity({
|
||||
id: guid('menu', 210 + i),
|
||||
path: `${base}/NameBanner`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 270, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 258, y: 60 }, pos: { x: 0, y: -137 } }),
|
||||
sprite({ color: { r: 0, g: 0, b: 0, a: 0.55 }, type: 1, raycast: false }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
그리고 Name 엔티티의 `displayOrder: 0` → `displayOrder: 2`로.
|
||||
|
||||
- [ ] **Step 5:** 생성 + 카운트 검증
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
node tools/verify/count.mjs ui "CharacterSelectHud/WarriorButton/Art" "CharacterSelectHud/MageButton/Art" "CharacterSelectHud/ThiefButton/Art"
|
||||
grep -c "28c88fdc5ab44f34a8b3fc1e19d4ce78" ui/DefaultGroup.ui # warrior RUID 1
|
||||
```
|
||||
Expected: Art 3개 존재, RUID 등장. (count.mjs 없으면 `grep -c '/Art"' ui/DefaultGroup.ui`.)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RenderCharacterSelect — 선택 = 금색 테두리
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs:3362-3394`
|
||||
|
||||
- [ ] **Step 1:** 선택 시 색을 금색으로. 세 군데 `Color(0.28, 0.36, 0.46, 1)` → `Color(1, 0.82, 0.3, 1)` (미선택 `Color(0.16, 0.2, 0.26, 1)`는 유지). Status 텍스트 로직 불변.
|
||||
- `gen-slaydeck.mjs`에서 `Color(0.28, 0.36, 0.46, 1)` 를 `Color(1, 0.82, 0.3, 1)` 로 (RenderCharacterSelect 내 3회) 치환.
|
||||
|
||||
- [ ] **Step 2:** 생성 + 확인
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
grep -c "Color(1, 0.82, 0.3, 1)" RootDesk/MyDesk/SlayDeckController.codeblock # 증가 확인(기존 사용처 + 3)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 뒤로가기 버튼 + 바인딩
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs` — CharacterSelectHud emit(StartButton 뒤 `:2595` 직후), BindMenuButtons(`:3158` 뒤), prop 선언부
|
||||
|
||||
- [ ] **Step 1:** StartButton emit(2582-2595) 직후에 BackButton emit 추가(StartButton 패턴 복제, 좌상단 배치)
|
||||
```js
|
||||
select.push(entity({
|
||||
id: guid('menu', 230),
|
||||
path: '/ui/DefaultGroup/CharacterSelectHud/BackButton',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 22,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 180, y: 56 }, pos: { x: -800, y: 430 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.15, g: 0.2, b: 0.26, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '← 뒤로', fontSize: 26, bold: true, color: GOLD, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** BindMenuButtons(StartGameHandler 블록 `:3151-3158` 뒤)에 BackButton 바인딩 추가
|
||||
```lua
|
||||
local charBack = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/BackButton")
|
||||
if charBack ~= nil and charBack.ButtonComponent ~= nil then
|
||||
if self.CharBackHandler ~= nil then
|
||||
charBack:DisconnectEvent(ButtonClickEvent, self.CharBackHandler)
|
||||
self.CharBackHandler = nil
|
||||
end
|
||||
self.CharBackHandler = charBack:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end)
|
||||
end
|
||||
```
|
||||
(이 Lua는 BindMenuButtons 메서드 본문 문자열 끝에 삽입. 실제 탭/`\t` 스타일은 해당 메서드 본문 규칙을 따른다 — BindMenuButtons는 실탭 사용.)
|
||||
|
||||
- [ ] **Step 3:** prop `CharBackHandler` 선언 추가. 기존 핸들러 prop 목록(예: `StartGameHandler`/`NewGameHandler` 등 `prop('any','...')` 선언부)을 grep으로 찾아 같은 형식으로 `CharBackHandler` 추가.
|
||||
```
|
||||
grep -n "StartGameHandler" tools/deck/gen-slaydeck.mjs # prop 선언 위치 확인
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** 생성 + 검증
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
node tools/verify/count.mjs ui "CharacterSelectHud/BackButton" # 1
|
||||
grep -c "CharBackHandler" RootDesk/MyDesk/SlayDeckController.codeblock # ≥2 (선언+바인딩+해제)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 산출물 재생성 커밋 + .sprite 커밋 + 플레이테스트
|
||||
|
||||
**Files:** `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`(재생성), `RootDesk/MyDesk/*.sprite`(임포트)
|
||||
|
||||
- [ ] **Step 1:** 최종 재생성 + git status로 의도 외 변경 없는지 확인
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
git status --short
|
||||
```
|
||||
Expected: 변경 = gen-slaydeck.mjs, data/characters.json, ui/DefaultGroup.ui, SlayDeckController.codeblock (+ common.gamelogic은 churn이면 내용 동일 시 git checkout 복원). untracked = 임포트 .sprite.
|
||||
|
||||
- [ ] **Step 2:** 소스 커밋(생성기+데이터) → 산출물 커밋(재생성 명시) → .sprite 커밋 분리
|
||||
```
|
||||
git add tools/deck/gen-slaydeck.mjs data/characters.json
|
||||
git commit -m "feat(charselect): 직업 카드 캐릭터 이미지 + 뒤로가기 (소스)"
|
||||
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
git commit -m "chore: 산출물 재생성 (charselect 이미지+뒤로가기)"
|
||||
git add "RootDesk/MyDesk/warrior.sprite" "RootDesk/MyDesk/mage.sprite" "RootDesk/MyDesk/bandit.sprite"
|
||||
git commit -m "chore(assets): 캐릭터 초상화 스프라이트 임포트(전사/법사/도적)"
|
||||
```
|
||||
(2차전직 아트 12종 .sprite는 별도 — 향후 2차 전직 선택 이미지용. 사용자 의사 확인 후 커밋/보류.)
|
||||
|
||||
- [ ] **Step 3:** 메이커 플레이테스트(사용자 워크스페이스 reload 후): 로비 NPC→직업 선택 진입→3 카드에 캐릭터 이미지 표시→클릭 시 금색 테두리·Status 갱신→시작 시 그 직업으로 런→뒤로가기 시 로비 복귀. 빌드 콘솔 0 에러.
|
||||
- 이미지 비율 왜곡/잘림 보이면 Art size(258×318) 조정.
|
||||
- 뒤로가기 시 재텔레포트 jolt 보이면 BackButton 바인딩을 `self:ShowState("lobby")`로 축소.
|
||||
|
||||
- [ ] **Step 4:** push + PR (`node tools/git/gitea-pr.mjs create <spec.json>`, UTF-8).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **스펙 커버리지**: 이미지 적용(T1,T2) · 선택→진행 연결(기존 SelectClass/StartNewGame 불변, T2가 클릭경로 보존) · 선택 금색 테두리(T3) · 뒤로가기→로비(T4) · characters.json 단일소스(T1) · 검증/플레이테스트(T5). 누락 없음.
|
||||
- **플레이스홀더**: RUID·좌표·색·Lua 전부 구체값. count.mjs 부재 시 grep 대체 명시.
|
||||
- **타입 일관성**: `CHARS.portraits[classId]`(classId=warrior/magician/bandit, classCards.classId와 일치). 핸들러 `CharBackHandler` 일관. Art/NameBanner guid(200+i/210+i/230) 미사용 번호.
|
||||
- **리스크**: 이미지 비율(T5 Step3 조정), ShowLobby 재텔레포트(T5 Step3 폴백 ShowState), 메이커 reload 필수(산출물 디스크 반영).
|
||||
106
docs/superpowers/plans/2026-06-16-charselect-maker-pilot.md
Normal file
106
docs/superpowers/plans/2026-06-16-charselect-maker-pilot.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Phase 2 — 캐릭터 선택 메이커 저작 파일럿 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현.
|
||||
|
||||
**Goal:** charselect를 생성중단→stock화(메이커 편집)하고, 캐릭터 이미지를 컨트롤러가 런타임 경로 주입(ClassPortraits)하도록 바꿔 패턴 (b)를 검증한다.
|
||||
|
||||
**Architecture:** ① 이미지 런타임 주입 추가(ClassPortraits + luaCharsTable + RenderCharacterSelect) → ② charselect 생성 중단(GENERATED_UI_SECTIONS/emit 제거 → 기존 엔티티 stock 보존). 컨트롤러는 경로 구동 유지.
|
||||
|
||||
**Tech Stack:** Node ESM 생성기, MSW Lua. 검증 = **count(동작 검증)** + 메이커 플레이테스트(바이트동일 아님 — codeblock·ui 의도적 변경).
|
||||
|
||||
**의존:** Phase 1b(#71) 위 스택(`feature/charselect-maker-pilot`). #70·#71 머지 후 main 리타겟.
|
||||
|
||||
---
|
||||
|
||||
## 검증 메모
|
||||
Phase 2는 codeblock·ui를 **의도적으로 변경**(diffcheck-IDENTICAL 아님). 게이트:
|
||||
- `node tools/deck/gen-slaydeck.mjs` 성공(throw 없음).
|
||||
- `node tools/verify/count.mjs cb ClassPortraits 'ImageRUID = self.ClassPortraits'` → 주입 코드 존재.
|
||||
- `node tools/verify/count.mjs ui CharacterSelectHud/WarriorButton/Art` → charselect 엔티티 ui 잔류(stock).
|
||||
- 미러 테스트 무영향(회귀 확인차 실행).
|
||||
- **최종**: 사용자 메이커 플레이테스트.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `luaCharsTable()` 신설 (lib/data.mjs)
|
||||
|
||||
**Files:** Modify `tools/deck/lib/data.mjs`
|
||||
|
||||
- [ ] **Step 1:** `luaNodeIconsTable`(:78-81) 바로 뒤에 추가:
|
||||
```js
|
||||
function luaCharsTable() {
|
||||
const rows = Object.entries(CHARS.portraits).map(([c, ruid]) => `\t${c} = ${luaStr(ruid)},`).join('\n');
|
||||
return `self.ClassPortraits = {\n${rows}\n}`;
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** `export { ... }`에 `luaCharsTable` 추가.
|
||||
- [ ] **Step 3:** 커밋(아직 미사용 — import 시 검증).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ClassPortraits 시드 + prop
|
||||
|
||||
**Files:** Modify `tools/deck/cb/boot.mjs`, `tools/deck/cb/run.mjs`, `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** `cb/boot.mjs`·`cb/run.mjs`의 import에 `luaCharsTable` 추가(`luaNodeIconsTable` 옆, `from '../lib/data.mjs'`).
|
||||
- [ ] **Step 2:** `cb/boot.mjs:8`(`${luaNodeIconsTable()}`) 다음 줄에 `${luaCharsTable()}` 추가. `cb/run.mjs:34` 동일.
|
||||
- [ ] **Step 3:** `gen-slaydeck.mjs:311`(`prop('any', 'NodeIcons'),`) 다음 줄에 `prop('any', 'ClassPortraits'),` 추가.
|
||||
- [ ] **Step 4:** `node tools/deck/gen-slaydeck.mjs` 성공 + `node tools/verify/count.mjs cb ClassPortraits` → ≥2(시드 2회).
|
||||
- [ ] **Step 5:** 산출물 churn 복원(`git checkout --`) — codeblock은 이 시점 변경됨(ClassPortraits 추가)이므로 **복원 안 함**, ui/common만 churn이면 복원. 커밋(소스 + 재생성 codeblock 분리 또는 함께 "산출물 재생성" 명시).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RenderCharacterSelect 이미지 런타임 주입
|
||||
|
||||
**Files:** Modify `tools/deck/cb/charselect.mjs:13`(RenderCharacterSelect)
|
||||
|
||||
- [ ] **Step 1:** RenderCharacterSelect 본문 **맨 앞**에 3 Art 주입 추가(Python 치환 — 실탭). classId: Warrior→warrior, Mage→magician, Thief→bandit:
|
||||
```lua
|
||||
local warriorArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/WarriorButton/Art")
|
||||
if warriorArt ~= nil and warriorArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["warrior"] ~= nil then
|
||||
warriorArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["warrior"]
|
||||
end
|
||||
local mageArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/MageButton/Art")
|
||||
if mageArt ~= nil and mageArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["magician"] ~= nil then
|
||||
mageArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["magician"]
|
||||
end
|
||||
local thiefArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/ThiefButton/Art")
|
||||
if thiefArt ~= nil and thiefArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["bandit"] ~= nil then
|
||||
thiefArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["bandit"]
|
||||
end
|
||||
```
|
||||
(기존 border/status 로직 앞에 prepend. RenderCharacterSelect는 ShowCharacterSelect/SelectClass에서 호출 → 열림·선택 시 멱등 주입.)
|
||||
- [ ] **Step 2:** `node tools/deck/gen-slaydeck.mjs` + `node tools/verify/count.mjs cb 'ImageRUID = self.ClassPortraits'` → 3.
|
||||
- [ ] **Step 3:** 커밋(소스 + 재생성 codeblock).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: charselect 생성 중단 → stock
|
||||
|
||||
**Files:** Modify `tools/deck/lib/ui-helpers.mjs`, `tools/deck/gen-slaydeck.mjs`; Delete `tools/deck/hud/charselect.mjs`
|
||||
|
||||
- [ ] **Step 1:** `lib/ui-helpers.mjs`의 `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER` 두 배열에서 `'CharacterSelectHud',` 줄 제거(2곳).
|
||||
- [ ] **Step 2:** `gen-slaydeck.mjs`에서 `import { buildCharSelect } from './hud/charselect.mjs';`(:38)와 `emit('CharacterSelectHud', buildCharSelect());`(:229) 제거.
|
||||
- [ ] **Step 3:** `git rm tools/deck/hud/charselect.mjs` (부트스트랩 완료, git 이력에 레퍼런스 잔존).
|
||||
- [ ] **Step 4:** `node tools/deck/gen-slaydeck.mjs` 성공 + `node tools/verify/count.mjs ui CharacterSelectHud/WarriorButton/Art` → **>0**(charselect 엔티티가 stock으로 ui에 잔류). `git status`로 ui 변경 확인(charselect가 생성→stock 전환, 위치 이동 가능 — 정상).
|
||||
- [ ] **Step 5:** 커밋(소스 + 재생성 산출물, 메시지에 "charselect 생성 중단·stock화" 명시).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 마무리 — RULES·경로계약·회귀·PR
|
||||
|
||||
**Files:** Modify `RULES.md`
|
||||
|
||||
- [ ] **Step 1:** RULES §1에 한 줄: charselect는 **메이커 저작(stock)**이라 생성 안 함 — 컨트롤러가 `ClassPortraits`로 이미지 런타임 주입, 메이커 편집 시 §스펙 경로 유지. (다른 화면은 여전히 hud/cb 생성.)
|
||||
- [ ] **Step 2:** 회귀: `node --test tools/balance/sim-balance.test.mjs` · `node --test tools/map/rogue-map.test.mjs` (exit 0).
|
||||
- [ ] **Step 3:** push → PR(`node tools/git/gitea-pr.mjs create <spec.json>`, base=`feature/cb-modularization`, 한국어).
|
||||
- [ ] **Step 4:** **사용자 메이커 플레이테스트**(워크스페이스 reload 후): 로비→직업선택→3 이미지 컨트롤러 주입 표시→클릭 금색테두리·Status→시작 그 직업→**메이커에서 카드 위치 이동·저장 후 `node gen-slaydeck` 재생성해도 charselect 유지**(stock 비파괴) 확인. 이미지 비표시 시 ClassPortraits 시드/주입 경로 점검.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
- **스펙 커버리지**: ①stock화(T4) ②런타임주입(T1-3: luaCharsTable·시드·prop·RenderCharacterSelect) ③경로구동 유지(무변경) ④경로계약(T5·스펙). 누락 없음.
|
||||
- **플레이스홀더**: luaCharsTable·주입 Lua·제거 라인 구체. 검증=count+playtest(바이트동일 아님 명시).
|
||||
- **타입 일관성**: `self.ClassPortraits`(prop)↔`luaCharsTable`(self.ClassPortraits=)↔RenderCharacterSelect 참조 일치. classId Warrior→warrior/Mage→magician/Thief→bandit 일관.
|
||||
- **순서**: 추가(주입 T1-3) 먼저 → 중단(stock T4). 중단 전엔 생성+주입 공존(무해), 중단 후 stock+주입.
|
||||
- **리스크**: 메이커 경로 변경 시 계약 깨짐(isvalid 가드로 크래시 방지·해당부 미동작). stock 전환 시 ui 위치 이동(렌더 무관).
|
||||
127
docs/superpowers/plans/2026-06-16-codeblock-modularization.md
Normal file
127
docs/superpowers/plans/2026-06-16-codeblock-modularization.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Phase 1b — codeblock 메서드 모듈화 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스.
|
||||
|
||||
**Goal:** `gen-slaydeck.mjs` `writeCodeblocks()`의 메서드 161개를 연속-런 모듈 `cb/*.mjs`로 분리하되 출력 `SlayDeckController.codeblock`은 바이트 동일.
|
||||
|
||||
**Architecture:** 단방향 의존 orchestrator→cb→lib. method/prop/codeblock 헬퍼+공유상수를 `lib/codeblock.mjs`로. 메서드는 **원본 순서 보존**을 위해 기능 버킷이 아닌 **연속 구간**으로 나눠 `writeCodeblocks`가 순서대로 spread-concat. prop 103개는 오케스트레이터 유지.
|
||||
|
||||
**Tech Stack:** Node ESM. 검증 = `diffcheck`(codeblock 바이트 동일) + 미러 `node --test`.
|
||||
|
||||
**의존:** Phase 1(#70)의 모듈 gen-slaydeck 위에 스택(`feature/cb-modularization`). #70 머지 후 main에 리베이스.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 검증 게이트 (모든 Task 공통)
|
||||
각 추출 후:
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
node tools/verify/diffcheck.mjs
|
||||
```
|
||||
**합격**: `RootDesk/MyDesk/SlayDeckController.codeblock`(+ui·common) **IDENTICAL**. 워킹트리 ` M`은 autocrlf churn → `git checkout --`로 복원, **산출물은 커밋 안 함**(소스만).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `lib/codeblock.mjs` — 헬퍼 + 공유 상수 추출
|
||||
|
||||
**Files:** Create `tools/deck/lib/codeblock.mjs`; Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** `lib/codeblock.mjs` 생성. gen-slaydeck.mjs에서 이동:
|
||||
- 함수: `prop`·`method`·`codeblock` (정의 본문 그대로).
|
||||
- `writeCodeblocks` 지역 상수 9개(현 `:292-300`): `RUN_LENGTH`(5) `GOLD_PER_WIN`(25) `CARD_PRICE`(30) `REST_HEAL`(30) `RELIC_PRICE`(60) `ACT_COUNT`(5) `ACT_MAPS`(['map01'..'map05']) `LOBBY_MAP`('lobby') `LOBBY_SPAWN`('Vector3(-5, 0.03, 0)'). → writeCodeblocks 본문에서 제거(import로 대체).
|
||||
- 끝에 `export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };`
|
||||
- ⚠️ `prop`/`method`/`codeblock`이 다른 헬퍼(없음 — 순수)·데이터를 참조하지 않는지 확인. 참조 시 함께 import.
|
||||
|
||||
- [ ] **Step 2:** gen-slaydeck.mjs에 `import { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from './lib/codeblock.mjs';` 추가(기존 lib import 옆).
|
||||
|
||||
- [ ] **Step 3:** 검증 게이트 → codeblock IDENTICAL → churn 복원.
|
||||
|
||||
- [ ] **Step 4:** 커밋
|
||||
```
|
||||
git add tools/deck/lib/codeblock.mjs tools/deck/gen-slaydeck.mjs
|
||||
git commit -m "refactor(cb): lib/codeblock.mjs로 헬퍼·상수 추출 (출력 바이트 동일)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 메서드 추출 공통 레시피 (Task 2~의 각 런)
|
||||
|
||||
`writeCodeblocks`의 `const combat = codeblock('SlayDeckController','SlayDeckController', [<props>], [\n method('OnBeginPlay', …),\n method('ReqLoadAscension', …),\n … 161개 …\n])` 에서 메서드 배열을 런별로 분리:
|
||||
1. `tools/deck/cb/<name>.mjs` 생성:
|
||||
```js
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { /* lib/data.mjs 전체 — 자동 파생 */ } from '../lib/data.mjs';
|
||||
import { /* lib/ui-helpers.mjs 전체 — 자동 파생 */ } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const <name>Methods = [
|
||||
method('<First>', `…`, …),
|
||||
…, // ← 원본 method() 호출 verbatim 이동
|
||||
method('<Last>', `…`, …),
|
||||
];
|
||||
```
|
||||
2. writeCodeblocks의 해당 런 method() 호출 스팬을 제거하고, methods 배열을 spread로 교체:
|
||||
`codeblock(…, [<props>], [ ...bootMethods, ...stateMethods, …, ...shopMethods ])`.
|
||||
3. **검증 게이트**(codeblock IDENTICAL) → churn 복원 → 커밋.
|
||||
4. ⚠️ 메서드가 writeCodeblocks 지역변수/다른 메서드를 **JS레벨로 참조**하면(드묾) undefined throw/diffcheck로 노출 → 그 변수도 lib로 옮기거나 인자화.
|
||||
- import 이름은 lib export에서 **자동 파생**(누락 방지). 메서드 본문은 Lua 문자열이라 보간(`${RUN_LENGTH}`·`${luaCardsTable(...)}`)만 JS평가.
|
||||
|
||||
### 런 → 모듈 경계 (원본 순서, 161개)
|
||||
| 모듈 | export | 첫 메서드 → 끝 메서드 | 수 |
|
||||
|---|---|---|---|
|
||||
| `cb/boot.mjs` | `bootMethods` | `OnBeginPlay` → `AscStartHpPenalty` | 11 |
|
||||
| `cb/state.mjs` | `stateMethods` | `HideGameHud` → `CloseBoard` | 12 |
|
||||
| `cb/soul.mjs` | `soulMethods` | `ShowSoulShop` → `ApplySoulUnlocks` | 11 |
|
||||
| `cb/charselect.mjs` | `charSelectMethods` | `ShowCharacterSelect` → `SetEntityEnabled` | 5 |
|
||||
| `cb/run.mjs` | `runMethods` | `StartRun` → `ReviveMonsterEntity` | 6 |
|
||||
| `cb/deckturn.mjs` | `deckTurnMethods` | `Shuffle` → `RenderPiles` | 8 |
|
||||
| `cb/deckview.mjs` | `deckViewMethods` | `OpenDeckInspect` → `ApplyAllDeckCardVisual` | 12 |
|
||||
| `cb/hand.mjs` | `handMethods` | `GetHandSlotX` → `SelectDiscardSlot` | 18 |
|
||||
| `cb/combat.mjs` | `combatMethods` | `PlayCard` → `ContinueAfterBoss` | 20 |
|
||||
| `cb/jobs.mjs` | `jobMethods` | `ShowJobChoice` → `SetJob` | 5 |
|
||||
| `cb/runend.mjs` | `runEndMethods` | `TeleportToActMap` → `EndRun` | 3 |
|
||||
| `cb/render.mjs` | `renderMethods` | `BuffsLabel` → `RenderRun` | 12 |
|
||||
| `cb/reward.mjs` | `rewardMethods` | `CardPool` → `PickReward` | 4 |
|
||||
| `cb/items.mjs` | `itemMethods` | `HasRelic` → `RenderRelics` | 12 |
|
||||
| `cb/tooltip.mjs` | `tooltipMethods` | `BuildCardKeywordTooltip` → `HideTooltip` | 6 |
|
||||
| `cb/map.mjs` | `mapMethods` | `ShowMap` → `PickNode` | 7 |
|
||||
| `cb/shop.mjs` | `shopMethods` | `ShowShop` → `OpenChest` | 9 |
|
||||
|
||||
최종 concat 순서(= 원본): `[ ...bootMethods, ...stateMethods, ...soulMethods, ...charSelectMethods, ...runMethods, ...deckTurnMethods, ...deckViewMethods, ...handMethods, ...combatMethods, ...jobMethods, ...runEndMethods, ...renderMethods, ...rewardMethods, ...itemMethods, ...tooltipMethods, ...mapMethods, ...shopMethods ]`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 런 추출 배치 A (말단부터 — 위험 낮은 순)
|
||||
**Files:** Create `cb/shop.mjs map.mjs tooltip.mjs items.mjs reward.mjs`; Modify gen-slaydeck.mjs
|
||||
|
||||
- [ ] 레시피로 **shop → map → tooltip → items → reward** 추출·검증·커밋(런 1개당 또는 묶음당 게이트 통과 필수).
|
||||
|
||||
### Task 3: 런 추출 배치 B
|
||||
**Files:** Create `cb/render.mjs runend.mjs jobs.mjs combat.mjs`; Modify gen-slaydeck.mjs
|
||||
- [ ] 레시피로 **render → runend → jobs → combat** 추출·검증·커밋.
|
||||
|
||||
### Task 4: 런 추출 배치 C
|
||||
**Files:** Create `cb/hand.mjs deckview.mjs deckturn.mjs run.mjs`; Modify gen-slaydeck.mjs
|
||||
- [ ] 레시피로 **hand → deckview → deckturn → run** 추출·검증·커밋.
|
||||
|
||||
### Task 5: 런 추출 배치 D (앞부분 — 마지막)
|
||||
**Files:** Create `cb/charselect.mjs soul.mjs state.mjs boot.mjs`; Modify gen-slaydeck.mjs
|
||||
- [ ] 레시피로 **charselect → soul → state → boot** 추출·검증·커밋. 완료 후 writeCodeblocks는 props 배열 + `[ ...17 spreads ]` + write만 남아야 함.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 마무리 — RULES + 회귀 + PR
|
||||
|
||||
**Files:** Modify `RULES.md`
|
||||
|
||||
- [ ] **Step 1:** RULES §1의 gen-slaydeck 모듈 설명에 `tools/deck/cb/*.mjs`(메서드)·`tools/deck/lib/codeblock.mjs`(헬퍼·상수) 추가. 단일소스 표/보조 생성기 일관성 유지.
|
||||
- [ ] **Step 2:** 회귀: `node --test tools/balance/sim-balance.test.mjs` · `node --test tools/map/rogue-map.test.mjs` (exit 0).
|
||||
- [ ] **Step 3:** 최종 재생성 + 검증 게이트(누적 codeblock IDENTICAL). `git status --short` 산출물 변경 없음.
|
||||
- [ ] **Step 4:** RULES 커밋 → push → PR(`node tools/git/gitea-pr.mjs create <spec.json>`, UTF-8). PR 제목·본문 한국어(RULES §4).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
- **스펙 커버리지**: lib/codeblock(T1) · 메서드 17런 모듈화(T2~5) · prop 유지(범위 명시) · 바이트동일 게이트(공통) · RULES(T6) · 미러회귀(T6). 누락 없음.
|
||||
- **플레이스홀더**: 런 경계는 첫/끝 메서드로 구체 지정(161개 합), 상수 9개 명시, import 자동 파생. "verbatim 이동"은 리팩터 특성(바이트 검증이 정확성 보장).
|
||||
- **타입 일관성**: export명(`xMethods`)↔concat spread 일치. lib/codeblock export↔orchestrator/cb import 일치.
|
||||
- **리스크**: 메서드 JS레벨 외부참조 → diffcheck/throw 즉시 노출, 증분으로 범위 최소. 단방향 의존 순환 없음.
|
||||
169
docs/superpowers/plans/2026-06-16-generator-modularization.md
Normal file
169
docs/superpowers/plans/2026-06-16-generator-modularization.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 생성기 모듈화 (Phase 1) 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans 로 태스크 단위 구현. 단계는 `- [ ]` 체크박스.
|
||||
|
||||
**Goal:** `tools/deck/gen-slaydeck.mjs`(~6,200줄)의 공유 인프라와 UI emit 16종을 `lib/`·`hud/` 모듈로 분리하되 출력 산출물은 바이트 동일로 유지한다.
|
||||
|
||||
**Architecture:** 단방향 의존 — `gen-slaydeck.mjs`(오케스트레이터) → `hud/*.mjs`(HUD별 build 함수) → `lib/*.mjs`(헬퍼·상수·데이터). `guid(prefix,n)`가 순수 함수라 모듈화해도 emit 순서만 보존하면 출력 불변. codeblock 메서드는 이번 범위 제외.
|
||||
|
||||
**Tech Stack:** Node ESM. 검증 = **바이트 동일 재생성**(git diff 빈 결과) + 미러 `node --test`.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 검증 게이트 (모든 Task 공통)
|
||||
|
||||
각 추출 후 반드시:
|
||||
```
|
||||
node tools/deck/gen-slaydeck.mjs # 성공 메시지 1줄, throw 없음
|
||||
git status --short
|
||||
```
|
||||
**합격 기준**: `ui/DefaultGroup.ui`·`RootDesk/MyDesk/SlayDeckController.codeblock`이 **변경 안 됨**(git status에 안 뜸). `Global/common.gamelogic`만 ` M`이면 LF churn → `git checkout -- Global/common.gamelogic`.
|
||||
- 만약 ui/codeblock이 ` M`로 뜨면 **추출 중 실수**(참조 누락/순서 변경) → `git diff --stat`로 어느 산출물인지 보고 되돌려 원인 수정. (RULES상 산출물 content는 안 봄 — 소스 diff로 원인 파악.)
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조 (목표)
|
||||
```
|
||||
tools/deck/
|
||||
gen-slaydeck.mjs # 오케스트레이터: import → 데이터 로드(lib) → upsertUi(hud 호출) → writeCodeblocks → patchCommon
|
||||
lib/
|
||||
data.mjs # 데이터 로드·검증·luaXxxTable·frameRuid·게임상수
|
||||
ui-helpers.mjs # guid/transform/sprite/button/text/entity/scrollLayoutGroup/cardFaceLayout/applySortingOverride
|
||||
# + UI 상수 + uiPath/sectionRoot/isGeneratedUiEntity/appendUiSection
|
||||
hud/
|
||||
deckhud.mjs deckinspect.mjs deckall.mjs combat.mjs reward.mjs map.mjs
|
||||
shop.mjs rest.mjs treasure.mjs jobchoice.mjs jobselect.mjs mainmenu.mjs
|
||||
charselect.mjs lobby.mjs board.mjs soulshop.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `lib/data.mjs` — 데이터·게임상수·lua 테이블 추출
|
||||
|
||||
**Files:** Create `tools/deck/lib/data.mjs`; Modify `tools/deck/gen-slaydeck.mjs`(상단 데이터/lua 블록 → import)
|
||||
|
||||
- [ ] **Step 1:** `lib/data.mjs` 생성. gen-slaydeck.mjs에서 아래를 **잘라 이동**(정의 본문 그대로):
|
||||
- 데이터 로드+검증: `CARDS`(:3) `ENEMIES`(:4) `CLASSES`(:7~17) `JOBS`(:19~40) `SOUL_UNLOCKS`(:42~47) `CARDFRAMES`+검증(:57~68) `RARITIES`(:58) `NODEICONS`+검증(:92~96) `CHARS`+검증(:99~103) `CAM`(:105) `RELICS`+검증(:107~) `POTIONS`+검증(:118~)
|
||||
- 게임 상수: `MAP_ROWS`(:84) `MAP_COLS`(:85) `CHEST_CLOSED_RUID`(:88) `CHEST_OPEN_RUID`(:89)
|
||||
- 함수: `luaSoulShopTable`(:48) `frameRuid`(:69) `luaFramesTable`(:72) `luaNodeIconsTable`(:78) `luaRelicsTable` `luaPotionsTable` `luaIntentsArray` `luaEnemiesTable` `luaStr` `luaJobsTable` `luaCardsTable` `luaDeckTable`
|
||||
- 맨 위 `import { readFileSync } from 'node:fs';`, 맨 끝 `export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, NODEICONS, CHARS, CAM, RELICS, POTIONS, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };`
|
||||
- ⚠️ `luaNodeIconsTable`는 `luaStr`를 쓰므로 `luaStr`도 같이 이동. `luaFramesTable`도 `luaStr` 사용. 상호 참조는 같은 모듈 내라 OK.
|
||||
|
||||
- [ ] **Step 2:** gen-slaydeck.mjs 상단에 `import { CARDS, ENEMIES, ... , luaDeckTable } from './lib/data.mjs';` 추가(이동한 정의 위치에).
|
||||
|
||||
- [ ] **Step 3:** 검증 게이트 실행 → ui/codeblock 0 변경 확인 → common.gamelogic churn 복원.
|
||||
|
||||
- [ ] **Step 4:** 커밋
|
||||
```
|
||||
git add tools/deck/lib/data.mjs tools/deck/gen-slaydeck.mjs
|
||||
git commit -m "refactor(gen): lib/data.mjs로 데이터·lua 테이블 추출 (출력 불변)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `lib/ui-helpers.mjs` — UI 헬퍼·상수 추출
|
||||
|
||||
**Files:** Create `tools/deck/lib/ui-helpers.mjs`; Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** `lib/ui-helpers.mjs` 생성. gen-slaydeck.mjs에서 이동:
|
||||
- UI 상수: `UI_FILE`(:190) `COMMON_FILE`(:191) `UI_ROOT`(:192) `GENERATED_UI_SECTIONS`(:193) `UI_APPEND_ORDER`(:211) `DISABLED_STOCK_CONTROLS`(:229) `TRANSPARENT DARK GOLD ATTACK DEFEND SKILL`(:231~236) `DAMAGE_DIGIT_RUIDS`(:237) `DAMAGE_POP_*`(:249~252) `MAX_MONSTERS`(:254) `HEAD_OFFSET_Y`(:255) `HP_BAR_W`(:257) `WHITE`(:258) `CARD_NAME_TEXT CARD_DESC_TEXT`(:259~260) `CARD_W CARD_H CARD_SPACING CARD_XS`(:276~279) `ALIGN_CENTER ALIGN_BOTTOM_CENTER`(:281~282)
|
||||
- 헬퍼: `cardFaceLayout`(:264) `guid`(:284) `transform`(:292) `sprite`(:317) `button`(:353) `text`(:378) `scrollLayoutGroup`(:405) `popupLayerFor`(:437) `uiOrderFor`(:443) `displayOrderFor`(:452) `applySortingOverride`(:456) `entity`(:472) `uiPath`(:504) `sectionRoot`(:508) `isGeneratedUiEntity`(:512) `appendUiSection`(:516)
|
||||
- `export { ... }` 전부.
|
||||
- ⚠️ COMMON_FILE은 patchCommon(:6125)도 사용 → export 필요. UI_APPEND_ORDER·GENERATED_UI_SECTIONS는 upsertUi가 사용.
|
||||
|
||||
- [ ] **Step 2:** gen-slaydeck.mjs에 `import { ... } from './lib/ui-helpers.mjs';` 추가.
|
||||
|
||||
- [ ] **Step 3:** 검증 게이트 → 0 변경 → churn 복원.
|
||||
|
||||
- [ ] **Step 4:** 커밋 `git commit -m "refactor(gen): lib/ui-helpers.mjs로 UI 헬퍼·상수 추출 (출력 불변)"`
|
||||
|
||||
---
|
||||
|
||||
### HUD 추출 공통 레시피 (Task 3~6에 반복 적용)
|
||||
|
||||
각 HUD는 현재 `upsertUi()` 안에서 `const <v> = []; const add = (e) => <v>.push(e); add(entity(...)); …; emit('<Name>', <v>);` 형태다. 추출 절차:
|
||||
1. `tools/deck/hud/<name>.mjs` 생성: `import { guid, entity, transform, sprite, button, text, GOLD, ... } from '../lib/ui-helpers.mjs';` + 필요한 데이터는 `'../lib/data.mjs'`. `export function build<Name>() { const e = []; const add = (x)=>e.push(x); add(entity(...)); …; return e; }` — **본문은 기존 라인 그대로 이동**(emit 호출 줄만 제외).
|
||||
2. upsertUi에서 해당 블록을 `emit('<Name>', build<Name>());` 한 줄로 치환.
|
||||
3. **검증 게이트**(ui/codeblock 0 변경) → churn 복원 → 커밋.
|
||||
4. ⚠️ 옮긴 블록이 upsertUi 지역변수(`byPath`/`ui`/`cards`/`previewIds`)를 참조하면 안 됨(HUD 섹션은 헬퍼·데이터만 씀이 확인됨). 참조 시 바이트 diff로 즉시 드러남 → 그 변수를 인자로 받도록 조정.
|
||||
|
||||
추출 대상(순서·소스 라인·emit명·모듈):
|
||||
|
||||
| 모듈 | emit | 소스(블록 시작~emit줄) |
|
||||
|---|---|---|
|
||||
| `hud/deckhud.mjs` → `buildDeckHud` | DeckHud | `:693`(`const hud=[]`)~`:808` |
|
||||
| `hud/deckinspect.mjs` → `buildDeckInspect` | DeckInspectHud | `:810`~`:942` |
|
||||
| `hud/deckall.mjs` → `buildDeckAll` | DeckAllHud | `:944`~`:1097` |
|
||||
| `hud/combat.mjs` → `buildCombat` | CombatHud | `:1100`~`:1587` |
|
||||
| `hud/reward.mjs` → `buildReward` | RewardHud | `:1589`~`:1681` |
|
||||
| `hud/map.mjs` → `buildMap` | MapHud | `:1684`~`:1839` |
|
||||
| `hud/shop.mjs` → `buildShop` | ShopHud | `:1841`~`:2038` |
|
||||
| `hud/rest.mjs` → `buildRest` | RestHud | `:2040`~`:2095` |
|
||||
| `hud/treasure.mjs` → `buildTreasure` | TreasureHud | `:2098`~`:2181` |
|
||||
| `hud/jobchoice.mjs` → `buildJobChoice` | JobChoiceHud | `:2184`~`:2229` |
|
||||
| `hud/jobselect.mjs` → `buildJobSelect` | JobSelectHud | `:2231`~`:2314` |
|
||||
| `hud/mainmenu.mjs` → `buildMainMenu` | MainMenu | `:2316`~`:2616` |
|
||||
| `hud/charselect.mjs` → `buildCharSelect` | CharacterSelectHud | `:2437`~`:2617`(`select[0]…enable=false` 포함) |
|
||||
| `hud/lobby.mjs` → `buildLobby` | LobbyHud | `:2620`~`:2672` |
|
||||
| `hud/board.mjs` → `buildBoard` | BoardHud | `:2675`~`:2727` |
|
||||
| `hud/soulshop.mjs` → `buildSoulShop` | SoulShopHud | `:2729`~`:2814` |
|
||||
|
||||
⚠️ MainMenu/CharacterSelectHud는 `const menu=[]`(:2316)·`const select=[]`(:2437)로 인접 정의 후 `emit('MainMenu', menu); emit('CharacterSelectHud', select);`가 :2616~2617에 연속. 각각 별 모듈로 분리, emit 두 줄로. `select[0].jsonString.enable=false`(:2596)는 buildCharSelect 내부에서 `e[0].jsonString.enable=false`로.
|
||||
⚠️ **CardHand 스톡카드 in-place upsert(`:557~691`)는 추출 안 함** — 기존 .ui 엔티티를 변형하는 특수 로직이라 upsertUi에 잔류(import만 정리).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: HUD 추출 배치 A (말단부터 — 위험 낮은 순)
|
||||
**Files:** Create `hud/soulshop.mjs board.mjs lobby.mjs charselect.mjs`; Modify gen-slaydeck.mjs
|
||||
|
||||
- [ ] **Step 1~4:** 레시피로 **soulshop → board → lobby → charselect** 순서로 하나씩 추출·검증·커밋(HUD 1개당 커밋 1개 권장, 배치로 묶어도 무방). 각 추출 후 검증 게이트 통과 필수.
|
||||
- charselect는 P15/이번 작업으로 검증된 화면이라 패턴 안정. `e[0].jsonString.enable=false` 처리 확인.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: HUD 추출 배치 B
|
||||
**Files:** Create `hud/mainmenu.mjs jobselect.mjs jobchoice.mjs treasure.mjs rest.mjs`; Modify gen-slaydeck.mjs
|
||||
|
||||
- [ ] **Step 1~4:** 레시피로 **mainmenu → jobselect → jobchoice → treasure → rest** 추출·검증·커밋.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: HUD 추출 배치 C
|
||||
**Files:** Create `hud/shop.mjs map.mjs reward.mjs`; Modify gen-slaydeck.mjs
|
||||
|
||||
- [ ] **Step 1~4:** 레시피로 **shop → map → reward** 추출·검증·커밋.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: HUD 추출 배치 D (대형 — 마지막)
|
||||
**Files:** Create `hud/combat.mjs deckall.mjs deckinspect.mjs deckhud.mjs`; Modify gen-slaydeck.mjs
|
||||
|
||||
- [ ] **Step 1~4:** 레시피로 **deckhud → deckinspect → deckall → combat** 추출·검증·커밋. combat(~487줄)이 가장 크니 마지막. 추출 후 upsertUi는 데이터 준비 + CardHand upsert + `emit('X', buildX())` 16줄 + 병합만 남아야 함.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 마무리 — RULES 동기화 + 회귀 + PR
|
||||
|
||||
**Files:** Modify `RULES.md`
|
||||
|
||||
- [ ] **Step 1:** RULES.md §1 보조 생성기/단일소스 표에 반영: `tools/deck/gen-slaydeck.mjs`(오케스트레이터)·`tools/deck/lib/*.mjs`(공유)·`tools/deck/hud/*.mjs`(HUD별)가 함께 `ui/DefaultGroup.ui`·`SlayDeckController.codeblock`의 단일 소스임을 명시.
|
||||
|
||||
- [ ] **Step 2:** 회귀 테스트(무영향 확인)
|
||||
```
|
||||
node --test tools/balance/sim-balance.test.mjs
|
||||
node --test tools/map/rogue-map.test.mjs
|
||||
```
|
||||
Expected: 전부 pass(37/0, 9/0).
|
||||
|
||||
- [ ] **Step 3:** 최종 재생성 + 전체 검증 게이트 → ui/codeblock 0 변경(누적) 최종 확인. `git status --short`에 산출물 변경 없음.
|
||||
|
||||
- [ ] **Step 4:** RULES 커밋 → push → PR(`node tools/git/gitea-pr.mjs create <spec.json>`, UTF-8).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **스펙 커버리지**: lib/data·ui-helpers(T1,T2) · HUD 16종 모듈화(T3~6) · 바이트 동일 게이트(공통 게이트, 매 Task) · codeblock 제외(범위 명시) · RULES 동기화(T7) · 미러 회귀(T7). 누락 없음.
|
||||
- **플레이스홀더**: 이동 대상은 라인 범위로 구체 지정, 검증 명령·합격기준 명시, export/import 목록 구체. "본문 그대로 이동"은 리팩터 특성상 코드 재타이핑 대신 정확한 소스 위치 지정(바이트 검증이 정확성 보장).
|
||||
- **타입 일관성**: build 함수명·emit명·모듈 경로 표로 고정. data.mjs/ui-helpers.mjs export ↔ gen-slaydeck import 일치.
|
||||
- **리스크**: 상수/헬퍼 참조 누락 → 바이트 diff 또는 throw로 즉시 노출. 증분 추출로 실패 범위 최소화. 단방향 의존(orchestrator→hud→lib)로 순환 없음.
|
||||
@@ -0,0 +1,105 @@
|
||||
# 직업 선택 — 캐릭터 이미지 + 뒤로가기 설계
|
||||
|
||||
작성일: 2026-06-16
|
||||
브랜치: `feature/charselect-images`
|
||||
|
||||
## 목표
|
||||
|
||||
런 시작 시 띄우는 **캐릭터(직업) 선택 화면**(`CharacterSelectHud`)을 두 가지로 개선한다:
|
||||
1. 직업 3종(전사/도적/마법사)을 지금의 **단색 네모 박스** → **각 직업 캐릭터 이미지 카드**로. 이미지를 선택하면 그 직업으로 런 진행(기존 연결 유지).
|
||||
2. 직업 선택 화면에 **뒤로가기** 버튼 추가 → 로비로 복귀.
|
||||
|
||||
요청 원문: "런 시작 시, 직업 선택 창을 뒤로가기도 가능하게 추가. 각 직업별로 지금은 네모 박스인데 각각 이미지(warrior/mage/bandit.png) 추가해서 적용하고, 선택했을 때 그 캐릭터로 진행하도록 연결."
|
||||
|
||||
## 확정된 결정 (브레인스토밍)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| 이미지 RUID 확보 | **사용자가 메이커에서 3 PNG 로컬 임포트** → `.sprite`+RUID(P13 카드프레임과 동일). MCP/계정 업로드는 흰박스라 불가 |
|
||||
| 이미지 배치 | **카드 전체를 이미지로**, 이름은 하단 배너, 선택 시 **금색 테두리** |
|
||||
| 뒤로가기 대상 | **로비로** (`ShowLobby()`) — 로비 NPC에서 진입하므로 |
|
||||
|
||||
### 소스 이미지 (사용자 임포트 대상)
|
||||
- 전사: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\character\warrior.png` (~1.05MB)
|
||||
- 법사: `…\mage.png` (~1.28MB)
|
||||
- 도적: `…\bandit.png` (~1.0MB)
|
||||
|
||||
세 PNG는 현재 워크스페이스 미임포트(코드 미참조). 기존 `RootDesk/MyDesk/*_normal|unique|legend.sprite`는 P13 **카드 프레임**이지 캐릭터 초상화가 아니다.
|
||||
|
||||
## 현재 구조 (조사 결과)
|
||||
|
||||
- **CLASSES** 상수 `gen-slaydeck.mjs:7-11` — `warrior{label,maxHp}`, `bandit`, `magician`.
|
||||
- **CharacterSelectHud emit** `:2432-2598`. `classCards` 배열 `:2482-2486` (key Warrior/Thief/Mage, classId warrior/bandit/magician, x −360/0/360, tint). 각 카드(270×330)의 자식: `Name`(상단, 108), `Portrait`(142×142 색상 tint, `:2524-2525` 부근), `Desc`(하단 −105), `LockBody`/`LockShackle`(비활성 직업용). 별도 `…DeckButton`(덱 보기)·`StartButton`.
|
||||
- **선택 로직**: 클릭 바인딩 `BindMenuButtons` 내 `:3100/3108/3116` → `SelectClass(classId)` `:3358-3361`(=`self.SelectedClass=…`+`RenderCharacterSelect()`). 시작 `:3151-3157` → `StartNewGame` `:3395-3399`(미선택 가드 후 `StartRun()`).
|
||||
- **RenderCharacterSelect** `:3362-3394` — 선택 카드 밝게/미선택 어둡게 + Status 텍스트.
|
||||
- **진입/전환**: `ShowState` `:3062-3078`가 HUD 토글. 진입 = 로비 NPC `OnLobbyNpcInteract` `:3199-3203`(런 비활성 시 `ShowCharacterSelect()` `:3355-3357`) 및 (사실상 미사용) MainMenu `:3092`. `ShowLobby` `:3175`. 게임은 OnBeginPlay→`ShowLobby`로 부팅(로비 허브).
|
||||
- **emit 헬퍼**: `entity():466`, `transform():286`, `sprite():311`(`dataId`로 ImageRUID 주입 가능), `button():347`, `text():372`, `guid()`.
|
||||
- **이미지 외부화 패턴**: 카드프레임은 `data/cardframes.json` → `luaFramesTable()`(`:72` 부근) → `self.CardFrames` Lua 테이블 + 런타임 `ApplyCardFace` `:4167-4202`가 `e.SpriteGUIRendererComponent.ImageRUID=ruid` 주입. 생성 시 주입은 `sprite({dataId})`.
|
||||
|
||||
## 상세 설계
|
||||
|
||||
### 1) `data/characters.json` (신설 — 단일 소스)
|
||||
```json
|
||||
{
|
||||
"portraits": {
|
||||
"warrior": "<32hex RUID>",
|
||||
"magician": "<32hex RUID>",
|
||||
"bandit": "<32hex RUID>"
|
||||
}
|
||||
}
|
||||
```
|
||||
- 사용자 임포트 후 `RootDesk/MyDesk/*.sprite`에서 RUID를 읽어 채운다(파일명은 임포트 시 결정 — `warrior.sprite` 등으로 매칭, 모호하면 사용자 확인).
|
||||
- 나중에 이미지 교체 = 이 파일 RUID만 바꿔 재생성.
|
||||
|
||||
### 2) `gen-slaydeck.mjs` — 로드·검증·주입
|
||||
- 상단에서 `const CHARS = JSON.parse(readFileSync('data/characters.json','utf8'))` 로드(cardframes 로드 패턴 인접).
|
||||
- **fail-fast 검증**: `portraits`에 `warrior`/`magician`/`bandit` 3키 존재 + 각 값이 32hex. 누락 시 throw.
|
||||
- 카드 Art 이미지는 **생성 시 `dataId` 주입**(런타임 테이블 불필요). 즉 `classCards`의 classId로 `CHARS.portraits[classId]`를 조회해 Art 스프라이트 `dataId`에 박는다.
|
||||
|
||||
### 3) CharacterSelectHud — 카드 전체 이미지화 (`:2432-2598`, `classCards` emit 루프)
|
||||
각 직업 카드 구조를 다음으로 변경(엔티티 경로 `…/{key}Button`·클릭 바인딩은 **불변**):
|
||||
- `{key}Button`(270×330): 클릭 가능한 **테두리 프레임**. sprite Color = 미선택 어둡게(`0.16,0.2,0.26,1`)/선택 금색(`1,0.82,0.3,1`). raycast on, `button()` 유지.
|
||||
- 신규 자식 `Art`(약 258×318, 6px 인셋, center): `sprite({ dataId: CHARS.portraits[classId], type:1, raycast:false })` — 캐릭터 이미지 풀블리드. (테두리가 이미지 뒤로 6px 보임 → 금색 테두리 효과.)
|
||||
- `Name`(하단 배너): 반투명 어두운 띠 sprite(예: `0,0,0,0.55`, 270×54, 하단) + 금색 텍스트. 기존 `Name` 재배치.
|
||||
- **제거**: 기존 색상 `Portrait` 박스, `Desc` 텍스트(선택 레이아웃에 없음).
|
||||
- `LockBody`/`LockShackle`: 비활성 직업용으로 유지(현재 3직업 모두 enabled라 표시 안 됨).
|
||||
|
||||
### 4) `RenderCharacterSelect` Lua 변경 (`:3362-3394`)
|
||||
- 기존 "박스 밝게/어둡게"를 **테두리(=`{key}Button` sprite Color) 금색/어둡게**로 교체. 선택된 classId의 카드만 `Color(1,0.82,0.3,1)`, 나머지 `Color(0.16,0.2,0.26,1)`.
|
||||
- Art 이미지는 생성 시 고정 주입이라 런타임 변경 없음. Status 텍스트 로직은 유지.
|
||||
|
||||
### 5) 뒤로가기 버튼
|
||||
- 신규 `CharacterSelectHud/BackButton`(ShopHud `Leave` 패턴 재사용 `:2020-2031`): 좌상단(예: `pos {x:-820,y:430}`, 180×56), text "← 뒤로", DARK sprite + `button()`.
|
||||
- `BindMenuButtons`에 바인딩 추가(ShopHud Leave 바인딩 패턴 `:3715-3717`): `back:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end)`. 핸들러 prop 저장(재바인딩 시 해제).
|
||||
- `ShowCharacterSelect`/`SelectClass`/`StartNewGame`/`StartRun` 로직 불변.
|
||||
|
||||
### 6) GUID 네임스페이스
|
||||
- 신규 엔티티(Art·NameBanner·BackButton)는 CharacterSelect용 기존 prefix에 번호 추가. 미등록 prefix면 ns 바이트 등록(생성기 끝 id 유일성 검증이 충돌 잡음).
|
||||
|
||||
## 흐름
|
||||
|
||||
```
|
||||
로비(맵) ──NPC 상호작용──> ShowCharacterSelect (HUD 오버레이)
|
||||
카드3=캐릭터 이미지, 클릭 → SelectClass → 금색 테두리
|
||||
[시작] → StartNewGame(가드) → StartRun (그 직업으로)
|
||||
[← 뒤로] → ShowLobby() → 로비 HUD 복귀
|
||||
```
|
||||
|
||||
## 미러/테스트 영향
|
||||
- 전투규칙·맵생성 **미변경** → `sim-balance`/`rogue-map` 미러 동기화 불필요.
|
||||
- 카운트 검증: `CharacterSelectHud/.../Art` ImageRUID 3개, `BackButton` 1개, characters.json 3 RUID 등장(`tools/verify/count.mjs` 또는 `grep -c`).
|
||||
- 메이커 플레이테스트: 로비 NPC→3 이미지 표시→클릭 금색 테두리→시작 그 직업으로 진행→뒤로 로비 복귀.
|
||||
|
||||
## 리스크
|
||||
- **이미지 임포트 선행 의존**: RUID가 있어야 생성기 실행 가능. 사용자 임포트 완료 후 진행(임포트 무관한 코드 골격은 먼저 작성 가능).
|
||||
- **이미지 비율**: PNG가 세로 초상화면 258×318(≈0.81 비율)에서 잘리거나 여백 — 임포트 후 스크린샷으로 인셋/사이즈 조정.
|
||||
- **`ShowLobby()` 재텔레포트**: 이미 로비 맵 위라 `GoLobbyMap` 재호출 시 위치/카메라 jolt 가능 → 보이면 뒤로가기를 `ShowState("lobby")`로 축소(플레이테스트 확인).
|
||||
- 흰박스: 공식 절차(로컬 임포트)면 렌더됨. reload 필수.
|
||||
|
||||
## 변경 파일 요약
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `data/characters.json` | **신설** — 직업 3종 초상화 RUID(단일 소스) |
|
||||
| `tools/deck/gen-slaydeck.mjs` | characters.json 로드·검증, CharacterSelectHud 카드 이미지화(Art/NameBanner), RenderCharacterSelect 테두리 선택표시, BackButton emit+바인딩 |
|
||||
| `RootDesk/MyDesk/*.sprite` (×3) | 사용자 임포트 산출물(커밋) |
|
||||
| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | 재생성 산출물 |
|
||||
@@ -0,0 +1,73 @@
|
||||
# Phase 2 — 캐릭터 선택 메이커 저작 파일럿 설계
|
||||
|
||||
작성일: 2026-06-16
|
||||
브랜치: `feature/charselect-maker-pilot` (Phase 1b `feature/cb-modularization`/PR #71 위에 스택)
|
||||
|
||||
## 목표
|
||||
|
||||
하이브리드 UI 로드맵의 **패턴 (b)**(메이커 시각 편집) 검증 파일럿. **캐릭터 선택 화면**을 "생성기 소유 → 메이커 소유"로 이관한다:
|
||||
- **레이아웃**(패널·카드 위치·버튼)은 메이커에서 시각 편집(생성기가 안 덮음).
|
||||
- **동적 내용**(캐릭터 이미지·선택 테두리·상태 텍스트)은 `SlayDeckController`가 런타임에 **경로로 주입** = 컨트롤러 내용주입.
|
||||
|
||||
성공 시 Phase 3에서 상점·전체덱 등으로 확장.
|
||||
|
||||
## 현재 구조 (조사 결과)
|
||||
|
||||
- charselect는 **생성 섹션**: `lib/ui-helpers.mjs`의 `GENERATED_UI_SECTIONS`(:17)·`UI_APPEND_ORDER`(:35)에 `'CharacterSelectHud'` 포함. `hud/charselect.mjs`의 `buildCharSelect()`가 엔티티 emit, `upsertUi`가 `emit('CharacterSelectHud', buildCharSelect())`.
|
||||
- **이미지 = 생성 시 주입**: `hud/charselect.mjs:86` `sprite({ dataId: CHARS.portraits[cls.classId], … })`. 런타임 주입 아님.
|
||||
- **컨트롤러는 경로 구동**: `cb/charselect.mjs`의 `RenderCharacterSelect`(각 `{Warrior,Mage,Thief}Button`의 `SpriteGUIRendererComponent.Color`로 선택 테두리 + Status 텍스트), `SelectClass`, `StartNewGame`. 바인딩은 `cb/state.mjs`의 `BindMenuButtons`(경로로 WarriorButton·BackButton·StartButton 등). 표시 토글은 `ShowState`(경로). **이미지 주입은 없음.**
|
||||
- **런타임 시드 모델**: `self.CardFrames`를 `${luaFramesTable()}`로 OnBeginPlay(cb/boot)·StartRun(cb/run)에서 주입 → `ClassPortraits`의 모델.
|
||||
- `upsertUi` 동작: 기존 `.ui` 로드 → 생성 섹션 엔티티 필터아웃 → emit 섹션 재추가. **생성 섹션에서 빠지면 `isGeneratedUiEntity=false`라 필터 안 됨 → 기존 엔티티 보존(stock)**.
|
||||
|
||||
## 상세 설계
|
||||
|
||||
### ① 생성 중단 → stock화 (generate-once-then-stop)
|
||||
- `lib/ui-helpers.mjs` `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER`에서 `'CharacterSelectHud'` 제거.
|
||||
- `gen-slaydeck.mjs`(upsertUi)에서 `emit('CharacterSelectHud', buildCharSelect())` + 관련 import 제거. `hud/charselect.mjs`는 **삭제**(부트스트랩 완료 — git 이력에 레퍼런스 남음).
|
||||
- 효과: 현재 `DefaultGroup.ui`의 charselect 엔티티가 그대로 **stock**으로 보존 → 메이커 시각 편집 가능, 재생성에 안 덮임.
|
||||
|
||||
### ② 이미지 런타임 주입 (컨트롤러 내용주입 = 패턴 b 핵심)
|
||||
- `lib/data.mjs`에 `luaCharsTable()` 신설(`data/characters.json`의 `portraits` 시드, `luaFramesTable`/`luaNodeIconsTable` 패턴; `self.ClassPortraits = { warrior="…", magician="…", bandit="…" }`).
|
||||
- 주입 지점: `cb/boot.mjs` OnBeginPlay·`cb/run.mjs` StartRun에 `${luaCharsTable()}`(CardFrames 시드 옆) + prop `ClassPortraits`(any) 선언.
|
||||
- `cb/charselect.mjs` `RenderCharacterSelect`에 이미지 주입 추가: 각 `{key}Button/Art` 엔티티의 `SpriteGUIRendererComponent.ImageRUID`를 `self.ClassPortraits[classId]`로 설정(경로별 isvalid 가드). → 메이커 레이아웃(빈/임의 Art)이어도 컨트롤러가 올바른 이미지 채움. **characters.json 데이터 구동 유지.**
|
||||
|
||||
### ③ 경로 구동 유지 (무변경)
|
||||
- 선택 테두리·Status·버튼 바인딩(`RenderCharacterSelect` 색/텍스트·`SelectClass`·`BindMenuButtons`·`StartNewGame`·`ShowState`)은 이미 경로 기반 → 변경 없음.
|
||||
|
||||
### ④ 엔티티 경로 계약 (docs 명시)
|
||||
메이커 편집 시 아래 경로 유지 필수(컨트롤러가 이 경로로 구동; 누락 시 isvalid 가드로 무시되되 그 부분 동작 안 함):
|
||||
```
|
||||
/ui/DefaultGroup/CharacterSelectHud (루트, ShowState 토글)
|
||||
/OpaqueBackdrop /Title /Status
|
||||
/WarriorButton (+ /Art ← 이미지 주입, /NameBanner, /Name)
|
||||
/ThiefButton (+ /Art, /NameBanner, /Name)
|
||||
/MageButton (+ /Art, /NameBanner, /Name)
|
||||
/StartButton /BackButton
|
||||
```
|
||||
(#67로 DeckButton 제거됨.) classId 매핑: Warrior→warrior, Thief→bandit, Mage→magician.
|
||||
|
||||
## 검증 (동작 — 바이트동일 아님)
|
||||
- 생성기: charselect 제거 후 `node tools/deck/gen-slaydeck.mjs` → **charselect 외 산출물 무영향**(`diffcheck`로 codeblock·common 확인; ui는 charselect 섹션만 stock으로 잔류·다른 섹션 동일). charselect 엔티티가 ui에 존재(`count.mjs`).
|
||||
- 메이커 플레이테스트: 로비→직업선택→**3 이미지가 컨트롤러 주입으로 표시**→클릭 시 금색테두리·Status→시작 시 그 직업으로 런→**메이커에서 카드 위치 이동 후 재생성해도 유지** 확인.
|
||||
|
||||
## 범위 밖
|
||||
- 상점·전체덱 등 다른 화면(Phase 3).
|
||||
- 새 UIGroup(.ui) 분리(경로·ShowState 재작업 큼) — DefaultGroup 내 stock으로 충분.
|
||||
- 게임 규칙·다른 화면 변경.
|
||||
|
||||
## 리스크
|
||||
- stock 전환 시 charselect 엔티티의 `.ui` 내 직렬화 위치 이동 가능 → 렌더는 경로/displayOrder 기반이라 무관하나 플레이테스트로 확인.
|
||||
- 메이커가 경로를 바꾸면 계약 깨짐 → 경로 표로 가드. isvalid 가드로 크래시는 방지.
|
||||
- 의존: Phase 1b(cb/charselect·boot·run) 위 스택. #70·#71 머지 후 main 리타겟.
|
||||
|
||||
## 변경 파일 요약
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `tools/deck/lib/ui-helpers.mjs` | `GENERATED_UI_SECTIONS`·`UI_APPEND_ORDER`에서 CharacterSelectHud 제거 |
|
||||
| `tools/deck/gen-slaydeck.mjs` | upsertUi에서 charselect emit·import 제거 |
|
||||
| `tools/deck/hud/charselect.mjs` | **삭제** |
|
||||
| `tools/deck/lib/data.mjs` | `luaCharsTable()` 신설 |
|
||||
| `tools/deck/cb/boot.mjs`·`cb/run.mjs` | `${luaCharsTable()}` 시드 + ClassPortraits prop |
|
||||
| `tools/deck/cb/charselect.mjs` | `RenderCharacterSelect`에 Art ImageRUID 주입 |
|
||||
| `docs/...charselect 경로 계약` | 경로 표(이 스펙 §④) |
|
||||
| `ui/DefaultGroup.ui`·codeblock | 재생성(charselect는 stock 잔류) |
|
||||
@@ -0,0 +1,69 @@
|
||||
# Phase 1b — codeblock 메서드 모듈화 설계
|
||||
|
||||
작성일: 2026-06-16
|
||||
브랜치: `feature/cb-modularization` (Phase 1 `feature/gen-modularization`/PR #70 위에 스택)
|
||||
|
||||
## 목표
|
||||
|
||||
Phase 1에서 UI emit을 모듈화한 데 이어, `gen-slaydeck.mjs`의 **codeblock 메서드 161개(~3,200줄)**를 기능별 모듈로 분리한다. 출력 `RootDesk/MyDesk/SlayDeckController.codeblock`은 **바이트 동일**(순수 소스 리팩터·무위험). 하이브리드 UI 로드맵 (a) 유지보수 정리의 완결.
|
||||
|
||||
## 현재 구조 (조사 결과)
|
||||
|
||||
- `writeCodeblocks()`(현 `gen-slaydeck.mjs:291`)가 단일 호출로 codeblock을 만든다:
|
||||
`const combat = codeblock('SlayDeckController', 'SlayDeckController', [<prop 103개>], [<method 161개>])` (`:301`~`:3617`, ~3,300줄).
|
||||
- 헬퍼: `prop()`·`method()`·`codeblock()` (현 `:240`~`:290` 부근, 오케스트레이터 잔류분).
|
||||
- `writeCodeblocks` 지역 상수: `RUN_LENGTH`·`GOLD_PER_WIN`·`CARD_PRICE`·`REST_HEAL`·`RELIC_PRICE`·`ACT_COUNT`·`ACT_MAPS`·`LOBBY_MAP`·`LOBBY_SPAWN` 등 — 메서드 Lua 문자열 보간에 쓰임.
|
||||
- 메서드 본문은 **Lua 문자열**. JS 보간(`${RUN_LENGTH}`·`${luaCardsTable(CARDS.cards)}`·`${CAM.zoomRatio}` 등)은 모듈 로드 시점에 평가됨 → 모듈은 보간에 쓰는 상수/데이터/헬퍼를 import해야 한다.
|
||||
- 161 메서드 이름(순서): OnBeginPlay → [ascension 10종] → HideGameHud·ShowState·ShowMainMenu·BindMenuButtons·ShowLobby… → [soul 12종] → [character/job] → StartRun·StartCombat·[combat 다수] → [deck/hand] → [deckview] → [motion] → [relics/potions] → [tooltip] → [reward] → [map] → [shop/rest/treasure]. 자연스러운 **연속 런(run)**으로 묶임.
|
||||
|
||||
## 상세 설계
|
||||
|
||||
### 핵심 제약: 바이트 동일 → 메서드 순서 보존
|
||||
`codeblock`의 methods 배열은 **순서가 직렬화에 반영**된다. 따라서 모듈은 "기능 버킷"이 아니라 **원본 161-메서드 시퀀스의 연속 구간**으로 나눈다(구간을 그 테마로 명명). `writeCodeblocks`가 모듈 배열을 **원본 순서대로 concat** → 바이트 동일. (Phase 1 HUD 분리와 동일 원리: HUD도 upsertUi 내 연속이었음.)
|
||||
|
||||
### 목표 파일 구조
|
||||
```
|
||||
tools/deck/
|
||||
gen-slaydeck.mjs # 오케스트레이터: writeCodeblocks()가 codeblock(…, [props], [...m1, ...m2, …]) concat
|
||||
lib/
|
||||
codeblock.mjs # 신설 — prop()·method()·codeblock() 헬퍼 + writeCodeblocks 지역 상수
|
||||
# (RUN_LENGTH·GOLD_PER_WIN·CARD_PRICE·REST_HEAL·RELIC_PRICE·ACT_COUNT·ACT_MAPS·LOBBY_MAP·LOBBY_SPAWN …)
|
||||
cb/ # 신설 — 메서드 연속구간 모듈 (각 `export const xMethods = [ method(...), … ]`)
|
||||
state.mjs ascension.mjs soul.mjs jobs.mjs run.mjs combat.mjs
|
||||
deck.mjs deckview.mjs motion.mjs items.mjs tooltip.mjs reward.mjs shop.mjs … (~12-14, 실제 런 경계로 확정)
|
||||
```
|
||||
|
||||
### 모듈 계약
|
||||
- 각 `cb/<name>.mjs`: `export const <name>Methods = [ method('A', \`…\`, …), method('B', …), … ];` — **메서드 호출 verbatim 이동**.
|
||||
- import: `lib/codeblock.mjs`(method·prop·codeblock·상수), `lib/data.mjs`(CARDS·luaCardsTable·luaStr·CAM 등 보간용). UI 헬퍼는 메서드 보간에 거의 안 쓰임(필요 구간만 `lib/ui-helpers.mjs`).
|
||||
- `writeCodeblocks()`(오케스트레이터): `codeblock('SlayDeckController','SlayDeckController', [ ...props ], [ ...stateMethods, ...ascensionMethods, … ])` — concat 순서 = 원본 순서.
|
||||
|
||||
### 범위/결정
|
||||
- **메서드 161개만 모듈화.** **prop 103개는 오케스트레이터에 단일 리스트로 유지** — 한 줄짜리라 분리 가치 낮고 prop↔feature 매핑 모호(추후 필요시 별도). 게임 로직·Lua **무변경**(순수 소스 리팩터).
|
||||
- 공유 헬퍼(method/prop/codeblock) + writeCodeblocks 지역 상수 → `lib/codeblock.mjs`. (이 상수들이 메서드 모듈 보간에 필요하므로 lib로.)
|
||||
|
||||
### 검증 (안전망)
|
||||
- 구간 추출마다 `node tools/deck/gen-slaydeck.mjs` → `node tools/verify/diffcheck.mjs` → `SlayDeckController.codeblock` **IDENTICAL**(`ui`·`common` 무영향이나 함께 확인). 증분(구간 1~2개씩) + 커밋.
|
||||
- 미러 테스트 `sim-balance`·`rogue-map` 무영향(회귀 확인차 실행).
|
||||
- 전투규칙·맵생성 Lua 미변경 → 미러 동기화 불필요.
|
||||
|
||||
### 미러/하네스
|
||||
- RULES §1의 gen-slaydeck 단일소스에 `cb/`·`lib/codeblock.mjs` 추가 반영.
|
||||
|
||||
## 범위 밖
|
||||
- prop 모듈화(추후).
|
||||
- Phase 2(메이커 UIGroup 파일럿).
|
||||
- 게임 동작·데이터 변경.
|
||||
|
||||
## 리스크
|
||||
- 메서드가 writeCodeblocks **지역변수/다른 메서드 정의를 JS레벨로 참조**하면(드묾 — 대부분 Lua 문자열 내 `self:Method()` 런타임 호출이라 JS-무관) 추출 시 undefined → diffcheck/throw로 즉시 노출 → 그 구간만 인자/상수 조정.
|
||||
- 모듈 import는 ui-helpers처럼 export 이름 자동 파생로 누락 방지. 단방향 의존 orchestrator→cb→lib(순환 없음).
|
||||
|
||||
## 변경 파일 요약
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `tools/deck/lib/codeblock.mjs` | **신설** — prop/method/codeblock 헬퍼 + 공유 상수 |
|
||||
| `tools/deck/cb/*.mjs` (~12-14) | **신설** — 메서드 연속구간 모듈 |
|
||||
| `tools/deck/gen-slaydeck.mjs` | writeCodeblocks를 import+concat로 축소(메서드 본문 → 모듈) |
|
||||
| `RULES.md` | §1에 cb/·lib/codeblock 반영 |
|
||||
| `SlayDeckController.codeblock`·`ui`·`common` | **무변경**(바이트 동일이 합격 기준) |
|
||||
@@ -0,0 +1,80 @@
|
||||
# 생성기 모듈화 (Phase 1) + 하이브리드 UI 로드맵 설계
|
||||
|
||||
작성일: 2026-06-16
|
||||
브랜치: `feature/gen-modularization`
|
||||
|
||||
## 배경 / 동기
|
||||
|
||||
`DefaultGroup.ui`에 모든 UI(캐릭터 선택·상점·전체 덱·전투…)가 들어 있어, 사용자가 (a) **생성기 코드 유지보수**와 (b) **메이커에서 기능별 시각 편집**을 원함.
|
||||
|
||||
핵심 제약(브레인스토밍에서 확정): MSW에서 "Layer"는 렌더 z순서일 뿐 논리 분리 도구가 아니고, 실제 UI 그룹 단위는 **UIGroup**(`.ui` 파일 = UIGroup, 현재 Default/Popup/Toast 3개). 그리고 **같은 UI를 '생성'과 '메이커 수동 편집' 둘 다로 둘 수 없음**(재생성이 수동 편집을 덮어씀).
|
||||
|
||||
→ **소유 모델 = 하이브리드·단계적**(사용자 승인): 정적 레이아웃은 메이커 저작(시각 편집), 동적 내용은 컨트롤러가 런타임 주입. 단, 한 번에 안 하고 단계적으로.
|
||||
|
||||
## 로드맵 (단계적 — 각 Phase가 자체 spec→plan)
|
||||
|
||||
- **Phase 1 (이 문서)**: `gen-slaydeck.mjs`(~6,200줄)의 **UI emit을 기능별 모듈로 분리**. 출력 `.ui`/`codeblock` **바이트 동일**(순수 리팩터·무위험). (a) 충족 + (b) 토대(화면별 파일).
|
||||
- **Phase 2 (후속 spec)**: 화면 1개(**캐릭터 선택**) 파일럿 — 정적 레이아웃을 메이커 저작 UIGroup으로 이관, 생성기는 그 화면 emit 중단, `SlayDeckController`가 경로로 내용(이미지·텍스트) 주입. (b) 패턴 검증.
|
||||
- **Phase 3 (후속 spec)**: 검증되면 상점·전체덱 등으로 확장.
|
||||
|
||||
## 현재 구조 (조사 결과)
|
||||
|
||||
- **공유 인프라**(~48–530): `luaSoulShopTable`/`luaFramesTable`/`luaNodeIconsTable`/`luaRelicsTable`/`luaPotionsTable`/`luaIntentsArray`/`luaEnemiesTable`/`luaStr`/`luaJobsTable`/`luaCardsTable`/`luaDeckTable`/`frameRuid`/`cardFaceLayout`/`guid`/`transform`/`sprite`/`button`/`text`/`scrollLayoutGroup`/`entity`/`uiPath`/`sectionRoot`/`isGeneratedUiEntity`/`appendUiSection`. 데이터 로드 상수(CARDS/CHARS/ENEMIES/RELICS/POTIONS/CARDFRAMES/NODEICONS/CAM) 및 색·치수 상수(GOLD/WHITE/TRANSPARENT/ALIGN_*/CARD_W/CARD_H 등).
|
||||
- **`guid(prefix, n)`은 순수 함수**(`:284`, 내부 카운터 없음; ns는 prefix→바이트 매핑). **모듈 호출 순서와 무관하게 동일 guid** → 분리해도 바이트 동일.
|
||||
- **`upsertUi()`**(`:529`)가 UI 오케스트레이터: 기존 `DefaultGroup.ui` 로드 → 생성 섹션 필터(stock 보존) → 로컬 `emit(section, entities)` 클로저로 누적 → CardHand 스톡카드 in-place upsert(`:565–691`, 특수) → HUD별 `const x=[]; const add=…; add(entity(...)); …; emit('X', x)` → (말미) 병합·기록.
|
||||
- **HUD emit 16종(순서·라인)**: DeckHud(`:808`) · DeckInspectHud(`:942`) · DeckAllHud(`:1097`) · CombatHud(`:1587`) · RewardHud(`:1681`) · MapHud(`:1839`) · ShopHud(`:2038`) · RestHud(`:2095`) · TreasureHud(`:2181`) · JobChoiceHud(`:2229`) · JobSelectHud(`:2314`) · MainMenu(`:2616`) · CharacterSelectHud(`:2617`) · LobbyHud(`:2672`) · BoardHud(`:2727`) · SoulShopHud(`:2814`). **각 섹션은 서로의 지역변수 비참조**(헬퍼·데이터 상수만 사용).
|
||||
- **codeblock 메서드**(`prop`/`method`/`codeblock`/`writeCodeblocks` `:2836–6124`, ~3,200줄) + **patchCommon**(`:6125`). **Phase 1 범위 제외.**
|
||||
|
||||
## Phase 1 상세 설계
|
||||
|
||||
### 목표 파일 구조
|
||||
```
|
||||
tools/deck/
|
||||
gen-slaydeck.mjs # 오케스트레이터(축소): import lib+hud → 데이터 로드 → upsertUi(HUD 모듈 순차) → writeCodeblocks → patchCommon
|
||||
lib/
|
||||
ui-helpers.mjs # guid, transform, sprite, button, text, entity, scrollLayoutGroup,
|
||||
# 상수(GOLD/WHITE/TRANSPARENT/ALIGN_*/CARD_W/CARD_H/UI_ROOT 등), cardFaceLayout,
|
||||
# uiPath/sectionRoot/isGeneratedUiEntity/appendUiSection
|
||||
data.mjs # CARDS/CHARS/ENEMIES/RELICS/POTIONS/CARDFRAMES/NODEICONS/CAM 로드·검증
|
||||
# + luaXxxTable·frameRuid
|
||||
hud/
|
||||
deckhud.mjs deckinspect.mjs deckall.mjs combat.mjs reward.mjs map.mjs
|
||||
shop.mjs rest.mjs treasure.mjs jobchoice.mjs jobselect.mjs mainmenu.mjs
|
||||
charselect.mjs lobby.mjs board.mjs soulshop.mjs
|
||||
```
|
||||
|
||||
### 모듈 계약
|
||||
- 각 `hud/<name>.mjs`: `export function build<Name>()` → 자기 HUD 엔티티 배열 반환. 필요한 헬퍼·상수·데이터는 `lib/`에서 **import**(거대 deps 객체 전달 금지).
|
||||
- `upsertUi()`(오케스트레이터에 잔류)는 기존 **순서 그대로** `emit('DeckHud', buildDeckHud())` … `emit('SoulShopHud', buildSoulShop())` 호출. `emit`·섹션 병합 로직 불변.
|
||||
- **CardHand 스톡카드 in-place upsert**(`:565–691`)는 기존 `.ui` 엔티티를 변형하는 특수 로직 → 오케스트레이터(또는 `hud/cardhand.mjs`)에 그대로 유지. import 경계만 정리.
|
||||
|
||||
### 바이트 동일 불변식 (가장 중요)
|
||||
- 리팩터는 **출력 변경 0**이 목표. 보장 근거: guid 순수·emit 순서·entity 구성 모두 보존, 로직 이동만.
|
||||
- **합격 기준**: 리팩터 후 `node tools/deck/gen-slaydeck.mjs` → `git diff` 결과가 **`ui/DefaultGroup.ui`·`SlayDeckController.codeblock`에 0 변경**(`Global/common.gamelogic`은 LF churn만 허용 → `git checkout`).
|
||||
|
||||
### 증분 실행 전략
|
||||
- 한 번에 16개 다 옮기지 말고 **HUD 1~2개씩 추출 → 재생성 → `git diff` 빈 결과 확인 → 커밋** 반복. 첫 추출(예: SoulShopHud 같은 말단 + lib 골격) 성공 후 패턴 반복.
|
||||
- lib 추출(헬퍼·상수·데이터) 먼저 → 그 다음 HUD 모듈을 하나씩 lib import로 전환.
|
||||
|
||||
### 미러/테스트·하네스
|
||||
- 전투규칙·맵생성 Lua **무변경** → `sim-balance`/`rogue-map` 미러 동기화 불필요(회귀 확인차 `node --test` 실행).
|
||||
- **RULES 동기화**: 생성기가 다중 파일이 되므로 RULES §1 "단일 소스"/보조 생성기 표를 `tools/deck/`(gen-slaydeck + lib/ + hud/)로 갱신.
|
||||
|
||||
## 범위 밖 (명시)
|
||||
- codeblock 메서드(`method()` ~3,200줄) 분리 — 더 크고 (b)와 무관·리스크↑. 원하면 별도 **Phase 1b** spec.
|
||||
- 게임 동작·데이터·런타임 로직 변경. (순수 소스 리팩터)
|
||||
- UIGroup 분할·메이커 저작 이관 — Phase 2 이후.
|
||||
|
||||
## 리스크
|
||||
- 클로저 참조(헬퍼/상수)를 import로 전환하는 광범위·기계적 수정 — 누락 시 런타임 throw 또는 출력 diff로 **즉시** 노출(바이트 검증이 안전망).
|
||||
- 상수 정의 위치 산재(CARD_W·GOLD·UI_ROOT 등 top-level) — lib로 이동 시 누락 주의. 추출 전 `grep`으로 전체 상수 인벤토리 작성.
|
||||
- ESM 순환 import 주의(lib는 hud를 import하지 않음 — 단방향: orchestrator→hud→lib).
|
||||
|
||||
## 변경 파일 요약
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `tools/deck/lib/ui-helpers.mjs`, `lib/data.mjs` | **신설** — 공유 헬퍼·상수·데이터 |
|
||||
| `tools/deck/hud/*.mjs` (16) | **신설** — HUD별 build 함수 |
|
||||
| `tools/deck/gen-slaydeck.mjs` | 오케스트레이터로 축소(데이터/UI emit 본문 → 모듈로 이동, import·호출만) |
|
||||
| `RULES.md` | §1 보조 생성기/단일소스 표에 lib/·hud/ 반영 |
|
||||
| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | **무변경**(바이트 동일이 합격 기준) |
|
||||
14
docs/x-cost.md
Normal file
14
docs/x-cost.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# X 코스트 카드
|
||||
|
||||
`useAllEnergy`는 카드가 사용될 때 남은 에너지를 전부 쓰는 공용 필드입니다.
|
||||
|
||||
연동 필드:
|
||||
|
||||
- `xDamagePerEnergy`: 에너지 1당 피해량
|
||||
- `xWeakPerEnergy`: 에너지 1당 약화량
|
||||
|
||||
적용 예시:
|
||||
|
||||
- `Skewer`: 남은 에너지 전부를 써서 `8 * energy` 피해
|
||||
- `Malaise`: 남은 에너지 전부를 써서 약화 부여
|
||||
|
||||
12477
map/lobby.map
12477
map/lobby.map
File diff suppressed because it is too large
Load Diff
1712
map/map01.map
1712
map/map01.map
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,16 @@ export function shuffle(arr, rng) {
|
||||
return a;
|
||||
}
|
||||
|
||||
function prepareCombatDrawPile(deck, cards) {
|
||||
const rest = [];
|
||||
const innate = [];
|
||||
for (const id of deck) {
|
||||
if (cards[id]?.innate === true) innate.push(id);
|
||||
else rest.push(id);
|
||||
}
|
||||
return rest.concat(innate);
|
||||
}
|
||||
|
||||
// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화.
|
||||
// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1))
|
||||
// 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5
|
||||
@@ -70,16 +80,43 @@ export function loadData() {
|
||||
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
|
||||
}
|
||||
|
||||
function canPlayCardNow(card, ctx = {}) {
|
||||
if (!card) return false;
|
||||
if (card.playableWhenDrawPileEmpty === true && (ctx.drawPileCount || 0) > 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
|
||||
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
||||
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
||||
export function chooseAction(hand, cards, energy) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
|
||||
export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => {
|
||||
const card = cards[x.id];
|
||||
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
|
||||
let effectiveCost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) effectiveCost = 0;
|
||||
else if (card.useAllEnergy === true) effectiveCost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||
else effectiveCost = Math.max(0, effectiveCost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
return card.useAllEnergy === true ? true : effectiveCost <= energy;
|
||||
});
|
||||
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
||||
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
|
||||
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(cards[x.id].cost, 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(cards[x.id].cost, 1);
|
||||
const effectiveCost = (card) => {
|
||||
let cost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||
else if (card.useAllEnergy === true) cost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||
else cost = Math.max(0, cost - (ctx.skillCostReductionThisTurn || 0));
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(cards[x.id]), 1);
|
||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
@@ -106,11 +143,23 @@ function bump(s, cost, dmg, blk) {
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
let drawPile = shuffle(starterDeck, rng);
|
||||
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
|
||||
let discard = [];
|
||||
const exhaust = [];
|
||||
let hand = [];
|
||||
let pHp = PLAYER_HP, pBlock = 0;
|
||||
let pStr = 0, pWeak = 0, pVuln = 0;
|
||||
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
|
||||
let blockGainMultiplier = 1;
|
||||
let handCostZeroThisTurn = false;
|
||||
let drawDisabledThisTurn = false;
|
||||
let nextSkillCostZero = false;
|
||||
let skillCostReductionThisTurn = 0;
|
||||
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
|
||||
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
|
||||
let nextTurnAddCards = [];
|
||||
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
|
||||
let cardsDrawnThisCombat = 0;
|
||||
let energy = 0;
|
||||
const powers = [];
|
||||
const mob = monsters.map((m) => ({
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
|
||||
@@ -119,35 +168,141 @@ export function simulateCombat(data, rng, stats) {
|
||||
let turns = 0;
|
||||
|
||||
function draw(n) {
|
||||
const drawn = [];
|
||||
if (drawDisabledThisTurn === true) return drawn;
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
|
||||
if (drawPile.length === 0) break;
|
||||
const card = drawPile.pop();
|
||||
drawn.push(card);
|
||||
cardsDrawnThisCombat++;
|
||||
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
||||
if (hand.length >= 10) {
|
||||
discard.push(card);
|
||||
triggerSly(card);
|
||||
} else hand.push(card);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
function addCardsToHand(id, n) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (hand.length >= 10) discard.push(id);
|
||||
else hand.push(id);
|
||||
}
|
||||
}
|
||||
function addBlock(base) {
|
||||
let amount = base || 0;
|
||||
if (amount > 0) amount += pDex;
|
||||
if (blockGainMultiplier > 1) amount *= blockGainMultiplier;
|
||||
if (amount < 0) amount = 0;
|
||||
pBlock += amount;
|
||||
return amount;
|
||||
}
|
||||
function discardForTurnStart(n) {
|
||||
const cnt = Math.min(n, hand.length);
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const idx = hand
|
||||
.map((id, k) => ({ id, k, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ac = a.card?.cost || 0;
|
||||
const bc = b.card?.cost || 0;
|
||||
if (ac !== bc) return ac - bc;
|
||||
const ad = a.card?.damage || 0;
|
||||
const bd = b.card?.damage || 0;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return a.k - b.k;
|
||||
})[0]?.k;
|
||||
if (idx == null) break;
|
||||
discardHandCard(idx, true);
|
||||
}
|
||||
}
|
||||
function countOtherHandSkills(currentId) {
|
||||
let n = 0;
|
||||
let skippedSelf = false;
|
||||
for (const id of hand) {
|
||||
if (!skippedSelf && id === currentId) { skippedSelf = true; continue; }
|
||||
if (cards[id]?.kind === 'Skill') n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function attackBaseForCard(id, c) {
|
||||
let base = c.damage || 0;
|
||||
const otherHand = Math.max(0, hand.length - 1);
|
||||
if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard;
|
||||
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
|
||||
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
|
||||
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
|
||||
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
|
||||
if (base < 0) base = 0;
|
||||
return base;
|
||||
}
|
||||
function queueNextTurnAddCard(id, n) {
|
||||
if (!id || !n || n <= 0) return;
|
||||
const entry = nextTurnAddCards.find((x) => x.cardId === id);
|
||||
if (entry) entry.amount += n;
|
||||
else nextTurnAddCards.push({ cardId: id, amount: n });
|
||||
}
|
||||
function queueNextTurnEffects(c) {
|
||||
if (!c) return;
|
||||
if (c.nextTurnBlock) nextTurnBlock += c.nextTurnBlock;
|
||||
if (c.nextTurnDraw) nextTurnDraw += c.nextTurnDraw;
|
||||
if (c.nextTurnKeepBlock === true) nextTurnKeepBlock = true;
|
||||
if (c.nextTurnAttackMultiplier && c.nextTurnAttackMultiplier > 0) nextTurnAttackMultiplier *= c.nextTurnAttackMultiplier;
|
||||
}
|
||||
function queueSelectedReserve(c) {
|
||||
if (!c?.nextTurnSelectHandCard || !c.nextTurnCopies || hand.length === 0) return;
|
||||
const choice = hand
|
||||
.map((id, i) => ({ id, i, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ak = a.card?.kind === 'Attack' ? 3 : a.card?.kind === 'Skill' ? 2 : 1;
|
||||
const bk = b.card?.kind === 'Attack' ? 3 : b.card?.kind === 'Skill' ? 2 : 1;
|
||||
if (bk !== ak) return bk - ak;
|
||||
const ad = a.card?.damage || 0;
|
||||
const bd = b.card?.damage || 0;
|
||||
if (bd !== ad) return bd - ad;
|
||||
return a.i - b.i;
|
||||
})[0];
|
||||
if (choice?.id) queueNextTurnAddCard(choice.id, c.nextTurnCopies);
|
||||
}
|
||||
const aliveList = () => mob.filter((m) => m.alive);
|
||||
function powerFieldTotal(field) {
|
||||
let total = 0;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (pc?.[field] != null) total += pc[field];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function resolveCardEffects(id, c, costSpent, recordStats = true) {
|
||||
const alive = aliveList();
|
||||
let dmg = 0;
|
||||
let blockGained = 0;
|
||||
if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier;
|
||||
if (c.nextSkillCostZero === true) nextSkillCostZero = true;
|
||||
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
|
||||
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
|
||||
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
|
||||
const xEnergy = costSpent || 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && c.damage) {
|
||||
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
|
||||
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||
const baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c);
|
||||
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
|
||||
? c.bonusHitsWhenOtherHandAtLeast : 0;
|
||||
const hitN = (c.hits || 1) + bonusHits;
|
||||
const preview = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
const target = chooseTarget(alive, preview);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
const hitN = c.hits || 1;
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
|
||||
dmg = totalNv;
|
||||
if (c.aoe === true) {
|
||||
for (const m2 of aliveList()) {
|
||||
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
const r2 = applyDamage(m2.hp, m2.block, d2);
|
||||
m2.hp = r2.hp; m2.block = r2.block;
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison;
|
||||
if (m2.hp <= 0) m2.alive = false;
|
||||
}
|
||||
} else {
|
||||
@@ -159,26 +314,63 @@ export function simulateCombat(data, rng, stats) {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
}
|
||||
const attackPoison = powerFieldTotal('attackPoison');
|
||||
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.block) pBlock += c.block;
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
} else if (c.kind === 'Power') {
|
||||
if (c.powerEffect && recordStats) powers.push(id);
|
||||
if (recordStats) powers.push(id);
|
||||
} else {
|
||||
pBlock += c.block || 0;
|
||||
if ((c.weak || c.vuln || c.poison) && alive.length) {
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
|
||||
if ((weakAmount || c.vuln || c.poison) && alive.length) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (weakAmount) target.weak += weakAmount;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
if (c.poison) target.poison += c.poison;
|
||||
}
|
||||
}
|
||||
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.draw) draw(c.draw);
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, c.block || 0);
|
||||
if (c.gainEnergy) energy += c.gainEnergy;
|
||||
queueNextTurnEffects(c);
|
||||
let drawnCards = [];
|
||||
if (c.draw) drawnCards = drawnCards.concat(draw(c.draw));
|
||||
if (c.drawUntilHandSize) {
|
||||
const need = c.drawUntilHandSize - Math.max(0, hand.length - 1);
|
||||
if (need > 0) drawnCards = drawnCards.concat(draw(need));
|
||||
}
|
||||
if (c.drawSkillBlock && c.drawSkillBlock > 0) {
|
||||
for (const drawnId of drawnCards) {
|
||||
if (cards[drawnId]?.kind === 'Skill') blockGained += addBlock(c.drawSkillBlock);
|
||||
}
|
||||
}
|
||||
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.cardPlayedDamage && alive.length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
dmg += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedRandomDamage && alive.length) {
|
||||
const pool = aliveList();
|
||||
if (pool.length) {
|
||||
const target = pool[Math.floor(rng() * pool.length)];
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
dmg += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
function triggerSly(id) {
|
||||
const c = cards[id];
|
||||
@@ -189,40 +381,87 @@ export function simulateCombat(data, rng, stats) {
|
||||
const [id] = hand.splice(idx, 1);
|
||||
if (!id) return;
|
||||
discard.push(id);
|
||||
turnDiscardedCards++;
|
||||
if (trigger) triggerSly(id);
|
||||
}
|
||||
function applyDiscardEffects(c) {
|
||||
let discarded = 0;
|
||||
if (c.discardAll) {
|
||||
while (hand.length) discardHandCard(hand.length - 1, true);
|
||||
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
} else if (c.discard) {
|
||||
const n = Math.min(c.discard, hand.length);
|
||||
for (let i = 0; i < n; i++) discardHandCard(hand.length - 1, true);
|
||||
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
}
|
||||
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded);
|
||||
}
|
||||
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
turnAttackCardsPlayed = 0;
|
||||
turnDiscardedCards = 0;
|
||||
blockGainMultiplier = 1;
|
||||
handCostZeroThisTurn = false;
|
||||
drawDisabledThisTurn = false;
|
||||
skillCostReductionThisTurn = 0;
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
pBlock = 0;
|
||||
if (nextTurnKeepBlock === true) nextTurnKeepBlock = false;
|
||||
else pBlock = 0;
|
||||
turnAttackMultiplier = nextTurnAttackMultiplier;
|
||||
nextTurnAttackMultiplier = 1;
|
||||
let energyBonus = 0;
|
||||
let powerTurnDraw = 0;
|
||||
let powerTurnDiscard = 0;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (!pc) continue;
|
||||
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
|
||||
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
||||
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
||||
else if (pc.powerEffect === 'poisonPerTurn') {
|
||||
for (const m of mob) if (m.alive) m.poison += pc.value;
|
||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
const r = applyDamage(m.hp, m.block, pc.value || 0);
|
||||
m.hp = r.hp; m.block = r.block;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
}
|
||||
}
|
||||
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
|
||||
if (pc.turnStartDraw) powerTurnDraw += pc.turnStartDraw;
|
||||
if (pc.turnStartDiscard) powerTurnDiscard += pc.turnStartDiscard;
|
||||
}
|
||||
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
|
||||
if (nextTurnBlock > 0) { addBlock(nextTurnBlock); nextTurnBlock = 0; }
|
||||
if (nextTurnAddCards.length) {
|
||||
for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount);
|
||||
nextTurnAddCards = [];
|
||||
}
|
||||
energy = ENERGY + energyBonus;
|
||||
const drawBonus = nextTurnDraw + powerTurnDraw;
|
||||
nextTurnDraw = 0;
|
||||
draw(HAND_SIZE + drawBonus);
|
||||
if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy);
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn });
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
energy -= c.cost;
|
||||
resolveCardEffects(id, c, c.cost);
|
||||
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
|
||||
const baseCost = c.cost || 0;
|
||||
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||
energy -= cost;
|
||||
resolveCardEffects(id, c, cost);
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
hand.splice(idx, 1);
|
||||
if (c.kind !== 'Power') discard.push(id);
|
||||
queueSelectedReserve(c);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
||||
else if (c.kind !== 'Power') discard.push(id);
|
||||
applyDiscardEffects(c);
|
||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
||||
}
|
||||
@@ -255,7 +494,12 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (it) {
|
||||
if (it.kind === 'Attack') {
|
||||
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
||||
const beforeHp = pHp;
|
||||
const r = applyDamage(pHp, pBlock, atk); pHp = r.hp; pBlock = r.block;
|
||||
if (beforeHp > pHp && pThorns > 0) {
|
||||
m.hp -= pThorns;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
}
|
||||
} else if (it.kind === 'Defend') { m.block += it.value; }
|
||||
else if (it.kind === 'Debuff') {
|
||||
if (it.effect === 'weak') pWeak += it.value;
|
||||
|
||||
@@ -13,6 +13,85 @@ test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
||||
assert.equal(rarityForRoll(100), 'legend');
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnBlock grants block on the following turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
GuardLater: { name: "예약 방어", cost: 0, kind: "Skill", nextTurnBlock: 4 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["GuardLater", "Pass"],
|
||||
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 77);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnDraw draws extra cards next turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Setup: { name: "설치", cost: 0, kind: "Skill", nextTurnDraw: 2 },
|
||||
Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 },
|
||||
Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Hit1", "Hit2", "Pass1", "Pass2", "Pass3", "Pass4", "Setup"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnKeepBlock preserves current block", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
BlurLater: { name: "흐릿함", cost: 0, kind: "Skill", block: 5, nextTurnKeepBlock: true },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["BlurLater", "Pass"],
|
||||
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnAttackMultiplier boosts attacks next turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Prep: { name: "그림자 걸음", cost: 0, kind: "Skill", nextTurnAttackMultiplier: 2 },
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 3 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Prep", "Pass", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextTurnSelectHandCard queues selected copies for next turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Nightmare: { name: "악몽", cost: 0, kind: "Skill", nextTurnCopies: 3, nextTurnSelectHandCard: true },
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 2 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Pass", "Nightmare", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 4);
|
||||
});
|
||||
|
||||
test('applyDamage: 방어 우선 차감 후 hp', () => {
|
||||
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
|
||||
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
|
||||
@@ -405,3 +484,385 @@ test("simulateCombat: retain keeps card in hand across turns", () => {
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: exhaust cards do not return through discard reshuffle", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
BurnOut: { name: "BurnOut", cost: 1, kind: "Attack", damage: 10, exhaust: true },
|
||||
},
|
||||
starterDeck: ["BurnOut"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Defend", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: dex increases block gained from cards", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Footwork: { name: "Footwork", cost: 1, kind: "Power", dex: 2 },
|
||||
Defend: { name: "Defend", cost: 1, kind: "Skill", block: 5 },
|
||||
},
|
||||
starterDeck: ["Footwork", "Defend"],
|
||||
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 6 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: thorns reflects unblocked attack damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Spikes: { name: "Spikes", cost: 1, kind: "Power", thorns: 4 },
|
||||
},
|
||||
starterDeck: ["Spikes"],
|
||||
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 1 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
assert.equal(r.playerHpRemaining, 79);
|
||||
});
|
||||
|
||||
test("simulateCombat: addShiv creates shuriken cards in hand", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
MakeShiv: { name: "MakeShiv", cost: 0, kind: "Skill", addShiv: 2 },
|
||||
Shiv: { name: "표창", cost: 0, kind: "Attack", damage: 4, exhaust: true },
|
||||
},
|
||||
starterDeck: ["MakeShiv"],
|
||||
monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: innate cards are drawn into the opening hand first", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Backstab: { name: "배신", cost: 0, kind: "Attack", damage: 11, innate: true, exhaust: true },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Backstab"],
|
||||
monsters: [{ name: "Dummy", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: GrandFinale waits until draw pile is empty", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, aoe: true, playableWhenDrawPileEmpty: true },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Finale"],
|
||||
monsters: [{ name: "Dummy", maxHp: 60, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: turnStartDraw and turnStartDiscard powers resolve at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Tool: { name: "작업 도구", cost: 0, kind: "Power", turnStartDraw: 1, turnStartDiscard: 1 },
|
||||
Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 },
|
||||
Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Tool", "Pass1", "Pass2", "Pass3", "Hit1", "Hit2"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("chooseAction: GrandFinale is blocked until draw pile is empty", () => {
|
||||
const cards = {
|
||||
Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, playableWhenDrawPileEmpty: true },
|
||||
Defend: { name: "방어", cost: 1, kind: "Skill", block: 5 },
|
||||
};
|
||||
assert.equal(chooseAction(["Finale", "Defend"], cards, 3, { drawPileCount: 1 }), 1);
|
||||
assert.equal(chooseAction(["Finale"], cards, 3, { drawPileCount: 0 }), 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 6 },
|
||||
Finisher: { name: "마무리", cost: 0, kind: "Attack", damage: 0, damagePerAttackPlayedThisTurn: 6 },
|
||||
},
|
||||
starterDeck: ["Hit", "Finisher"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Precise: { name: "정밀", cost: 0, kind: "Attack", damage: 13, damagePerOtherHandCard: -2 },
|
||||
Flechettes: { name: "프레췌", cost: 0, kind: "Attack", damage: 0, damagePerSkillInHand: 5 },
|
||||
Skill1: { name: "스킬1", cost: 99, kind: "Skill", block: 0 },
|
||||
Skill2: { name: "스킬2", cost: 99, kind: "Skill", block: 0 },
|
||||
Blank: { name: "공백", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"],
|
||||
monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 5);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Toss: { name: "버리기", cost: 0, kind: "Skill", discard: 1 },
|
||||
Memento: { name: "메멘토", cost: 0, kind: "Attack", damage: 9, damagePerDiscardedThisTurn: 4 },
|
||||
Follow: { name: "완수", cost: 0, kind: "Attack", damage: 7, otherHandAtLeast: 2, bonusHitsWhenOtherHandAtLeast: 1 },
|
||||
Blank1: { name: "공백1", cost: 99, kind: "Skill", block: 0 },
|
||||
Blank2: { name: "공백2", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Toss", "Memento", "Follow", "Blank1", "Blank2"],
|
||||
monsters: [{ name: "Dummy", maxHp: 27, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: gainEnergy, drawUntilHandSize, and drawPerDiscarded are applied", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Adrenaline: { name: "Adrenaline", cost: 0, kind: "Skill", gainEnergy: 1, draw: 2, exhaust: true },
|
||||
Expertise: { name: "Expertise", cost: 1, kind: "Skill", drawUntilHandSize: 6 },
|
||||
Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true, drawPerDiscarded: 1, exhaust: true },
|
||||
Tactician: { name: "Tactician", cost: 99, kind: "Skill", gainEnergy: 1, sly: true },
|
||||
Hit1: { name: "Hit1", cost: 1, kind: "Attack", damage: 6 },
|
||||
Hit2: { name: "Hit2", cost: 1, kind: "Attack", damage: 6 },
|
||||
Hit3: { name: "Hit3", cost: 1, kind: "Attack", damage: 6 },
|
||||
},
|
||||
starterDeck: ["Adrenaline", "Expertise", "Gamble", "Tactician", "Hit1", "Hit2", "Hit3"],
|
||||
monsters: [{ name: "Dummy", maxHp: 18, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedBlock grants block whenever a card is played", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
After: { name: "Afterimage", cost: 1, kind: "Power", cardPlayedBlock: 1 },
|
||||
Hit: { name: "Hit", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["After", "Hit", "Hit", "Hit", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 1 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: blockGainMultiplier doubles block gain for the turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Shadow: { name: "Shadowmeld", cost: 1, kind: "Skill", block: 5, blockGainMultiplier: 2 },
|
||||
Shield: { name: "Shield", cost: 1, kind: "Skill", block: 2 },
|
||||
},
|
||||
starterDeck: ["Shadow", "Shield"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("simulateCombat: nextSkillCostZero makes the next skill free", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Pounce: { name: "Pounce", cost: 2, kind: "Attack", damage: 12, nextSkillCostZero: true },
|
||||
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
||||
},
|
||||
starterDeck: ["Pounce", "Guard"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => {
|
||||
const cards = {
|
||||
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(["Guard"], cards, 1, { skillCostReductionThisTurn: 1 }), 0);
|
||||
assert.equal(chooseAction(["Guard"], cards, 1, {}), -1);
|
||||
});
|
||||
|
||||
test("chooseAction: handCostZeroThisTurn lets expensive cards be played", () => {
|
||||
const cards = {
|
||||
Burst: { name: "Burst", cost: 3, kind: "Skill", block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(["Burst"], cards, 0, { handCostZeroThisTurn: true }), 0);
|
||||
assert.equal(chooseAction(["Burst"], cards, 0, {}), -1);
|
||||
});
|
||||
|
||||
test("chooseAction: useAllEnergy cards remain playable at zero energy", () => {
|
||||
const cards = {
|
||||
Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Escape: { name: "EscapePlan", cost: 0, kind: "Skill", draw: 1, drawSkillBlock: 3, innate: true, exhaust: true },
|
||||
Filler1: { name: "Filler1", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler2: { name: "Filler2", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler3: { name: "Filler3", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler4: { name: "Filler4", cost: 99, kind: "Skill", block: 0 },
|
||||
Filler5: { name: "Filler5", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Escape", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(stats.Escape.block, 3);
|
||||
});
|
||||
|
||||
test("simulateCombat: poisonPerTurn powers poison all enemies at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Fumes: { name: "NoxiousFumes", cost: 1, kind: "Power", powerEffect: "poisonPerTurn", value: 2 },
|
||||
},
|
||||
starterDeck: ["Fumes"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerTurn powers damage all enemies at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Speed: { name: "Speedster", cost: 2, kind: "Power", powerEffect: "damagePerTurn", value: 2 },
|
||||
},
|
||||
starterDeck: ["Speed"],
|
||||
monsters: [
|
||||
{ name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
{ name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: attackPoison power applies poison on attack damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Venom: { name: "Envenom", cost: 2, kind: "Power", attackPoison: 2 },
|
||||
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Venom", "Strike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedDamage hits the target whenever a card is played", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, cardPlayedDamage: 2 },
|
||||
},
|
||||
starterDeck: ["Strangle"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
SerpentForm: { name: "SerpentForm", cost: 3, kind: "Power", cardPlayedRandomDamage: 4 },
|
||||
},
|
||||
starterDeck: ["SerpentForm"],
|
||||
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: useAllEnergy skewer consumes all energy for damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 },
|
||||
},
|
||||
starterDeck: ["Skewer"],
|
||||
monsters: [{ name: "Dummy", maxHp: 24, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: useAllEnergy malaise scales weak with energy spent", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Malaise: { name: "Malaise", cost: 2, kind: "Skill", useAllEnergy: true, xWeakPerEnergy: 1 },
|
||||
Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Malaise", "Strike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 1, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerCardDrawnThisCombat scales murder", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Murder: { name: "Murder", cost: 3, kind: "Attack", damage: 1, damagePerCardDrawnThisCombat: 1 },
|
||||
Filler1: { name: "Filler1", cost: 99, kind: "Skill" },
|
||||
Filler2: { name: "Filler2", cost: 99, kind: "Skill" },
|
||||
Filler3: { name: "Filler3", cost: 99, kind: "Skill" },
|
||||
Filler4: { name: "Filler4", cost: 99, kind: "Skill" },
|
||||
Filler5: { name: "Filler5", cost: 99, kind: "Skill" },
|
||||
},
|
||||
starterDeck: ["Murder", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(stats.Murder.damage > 1);
|
||||
});
|
||||
|
||||
113
tools/deck/cb/boot.mjs
Normal file
113
tools/deck/cb/boot.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const bootMethods = [
|
||||
method('OnBeginPlay', `${luaCardsTable(CARDS.cards)}
|
||||
${luaFramesTable()}
|
||||
${luaNodeIconsTable()}
|
||||
${luaCharsTable()}
|
||||
${luaSoulShopTable(SOUL_UNLOCKS)}
|
||||
self.SoulUnlocks = {}
|
||||
self.SoulPoints = self.SoulPoints or 0
|
||||
local uiTries = 0
|
||||
local uiInit = 0
|
||||
uiInit = _TimerService:SetTimerRepeat(function()
|
||||
uiTries = uiTries + 1
|
||||
if _EntityService:GetEntityByPath("/ui/DeckUIGroup") ~= nil then
|
||||
self:ActivateUIGroups()
|
||||
-- MainMenu는 한동안 비활성화: 시작 시 바로 로비로 진입.
|
||||
-- 추후 싱글/멀티/종료 선택 메뉴가 필요하면 self:ShowMainMenu()로 되돌린다(메서드·UI 유지됨).
|
||||
self:ShowLobby()
|
||||
_TimerService:ClearTimer(uiInit)
|
||||
elseif uiTries > 80 then
|
||||
_TimerService:ClearTimer(uiInit)
|
||||
end
|
||||
end, 0.1)
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:ReqLoadAscension(lp.PlayerComponent.UserId)
|
||||
self:ReqLoadSouls(lp.PlayerComponent.UserId)
|
||||
end
|
||||
_InputService:ConnectEvent(KeyDownEvent, function(e)
|
||||
if e.key == KeyboardKey.LeftControl then
|
||||
self.DebugCtrlDown = true
|
||||
local lp2 = _UserService.LocalPlayer
|
||||
if lp2 ~= nil and lp2.CurrentMapName == "${LOBBY_MAP}" and self.RunActive ~= true then
|
||||
self:PlayerAttackMotion()
|
||||
end
|
||||
elseif e.key == KeyboardKey.LeftShift or e.key == KeyboardKey.RightShift then
|
||||
self.DebugShiftDown = true
|
||||
elseif e.key == KeyboardKey.C then
|
||||
if self.DebugCtrlDown == true and self.DebugShiftDown == true then
|
||||
self:OpenDebugCardPicker()
|
||||
end
|
||||
elseif e.key == KeyboardKey.E then
|
||||
if self.DebugCtrlDown == true and self.DebugShiftDown == true then
|
||||
self:CheatFillEnergy()
|
||||
end
|
||||
end
|
||||
end)
|
||||
_InputService:ConnectEvent(KeyUpEvent, function(e)
|
||||
if e.key == KeyboardKey.LeftControl then
|
||||
self.DebugCtrlDown = false
|
||||
elseif e.key == KeyboardKey.LeftShift or e.key == KeyboardKey.RightShift then
|
||||
self.DebugShiftDown = false
|
||||
end
|
||||
end)`),
|
||||
method('CheatFillEnergy', `if self.RunActive ~= true or self.CombatOver == true then
|
||||
return
|
||||
end
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
self.Energy = self.MaxEnergy
|
||||
self:RenderCombat()
|
||||
self:RenderPiles()
|
||||
self:Toast("치트: 체력·에너지 회복")`),
|
||||
method('ReqLoadAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
|
||||
local errCode, value = ds:GetAndWait("ascensionUnlocked")
|
||||
local n = 0
|
||||
if errCode == 0 and value ~= nil and value ~= "" then
|
||||
n = tonumber(value) or 0
|
||||
end
|
||||
self:RecvAscension(n, userId)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' }], 5),
|
||||
method('RecvAscension', `self.AscensionUnlocked = n
|
||||
if self.AscensionLevel > self.AscensionUnlocked then
|
||||
self.AscensionLevel = self.AscensionUnlocked
|
||||
end
|
||||
self:RenderAscension()`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'n' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' },
|
||||
], 6),
|
||||
method('SaveAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
|
||||
ds:SetAndWait("ascensionUnlocked", tostring(n))`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'n' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' },
|
||||
], 5),
|
||||
method('AdjustAscension', `local v = self.AscensionLevel + delta
|
||||
if v < 0 then v = 0 end
|
||||
if v > self.AscensionUnlocked then v = self.AscensionUnlocked end
|
||||
self.AscensionLevel = v
|
||||
self:RenderAscension()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'delta' }]),
|
||||
method('RenderAscension', `self:SetText("/ui/DefaultGroup/MainMenu/AscLabel", "승천 " .. string.format("%d", self.AscensionLevel) .. " / 해금 " .. string.format("%d", self.AscensionUnlocked))
|
||||
self:SetText("/ui/LobbyUIGroup/LobbyHud/AscLabel", "승천 " .. string.format("%d", self.AscensionLevel) .. " / 해금 " .. string.format("%d", self.AscensionUnlocked))`),
|
||||
method('AscHpMult', `local m = 1
|
||||
if self.AscensionLevel >= 1 then m = m + 0.1 end
|
||||
if self.AscensionLevel >= 6 then m = m + 0.1 end
|
||||
return m`, [], 0, 'number'),
|
||||
method('AscAtkMult', `local m = 1
|
||||
if self.AscensionLevel >= 2 then m = m + 0.1 end
|
||||
if self.AscensionLevel >= 7 then m = m + 0.1 end
|
||||
return m`, [], 0, 'number'),
|
||||
method('AscEliteBonus', `local b = 0
|
||||
if self.AscensionLevel >= 4 then b = b + 0.2 end
|
||||
if self.AscensionLevel >= 9 then b = b + 0.2 end
|
||||
return b`, [], 0, 'number'),
|
||||
method('AscGoldMult', `local m = 1
|
||||
if self.AscensionLevel >= 5 then m = m - 0.25 end
|
||||
if self.AscensionLevel >= 10 then m = m - 0.25 end
|
||||
return m`, [], 0, 'number'),
|
||||
method('AscStartHpPenalty', `local p = 0
|
||||
if self.AscensionLevel >= 3 then p = p + 10 end
|
||||
if self.AscensionLevel >= 8 then p = p + 10 end
|
||||
return p`, [], 0, 'number'),
|
||||
];
|
||||
80
tools/deck/cb/charselect.mjs
Normal file
80
tools/deck/cb/charselect.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const charSelectMethods = [
|
||||
method('ShowCharacterSelect', `self.SelectedClass = ""
|
||||
self:ShowState("charselect")
|
||||
self:RenderCharacterSelect()`),
|
||||
method('SelectClass', `self.SelectedClass = className
|
||||
self:RenderCharacterSelect()`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' },
|
||||
]),
|
||||
method('RenderCharacterSelect', `local base = "/ui/SelectUIGroup/CharacterSelectHud"
|
||||
local arts = { { p = "/WarriorButton/Art", c = "warrior" }, { p = "/MageButton/Art", c = "magician" }, { p = "/BanditButton/Art", c = "bandit" } }
|
||||
for i = 1, #arts do
|
||||
local e = _EntityService:GetEntityByPath(base .. arts[i].p)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits[arts[i].c] ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits[arts[i].c]
|
||||
end
|
||||
end
|
||||
local btns = { { p = "/WarriorButton", c = "warrior" }, { p = "/MageButton", c = "magician" }, { p = "/BanditButton", c = "bandit" } }
|
||||
for i = 1, #btns do
|
||||
local e = _EntityService:GetEntityByPath(base .. btns[i].p)
|
||||
if e ~= nil then
|
||||
if e.MaskComponent == nil then
|
||||
e:AddComponent("MaskComponent")
|
||||
end
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == btns[i].c then
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.45, 0.5, 0.58, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local nl = string.char(10)
|
||||
local name = ""
|
||||
local eng = ""
|
||||
local desc = "직업을 선택하고 시작하세요"
|
||||
local btnName = ""
|
||||
if self.SelectedClass == "warrior" then
|
||||
name = "전사"
|
||||
eng = "Warrior"
|
||||
btnName = "/WarriorButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "방어를 쌓고 버티다 강하게 역공하는 단단한 탱커."
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
name = "도적"
|
||||
eng = "Thief"
|
||||
btnName = "/BanditButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "표창 난사와 독으로 빠르게 몰아치는 민첩한 직업."
|
||||
elseif self.SelectedClass == "magician" then
|
||||
name = "법사"
|
||||
eng = "Magician"
|
||||
btnName = "/MageButton"
|
||||
desc = "직업군 · 모험가" .. nl .. "약하지만 게이지 운용으로 화력을 집중하는 원소 마법사."
|
||||
end
|
||||
if btnName ~= "" then
|
||||
local art = _EntityService:GetEntityByPath(base .. btnName .. "/Art")
|
||||
local target = _EntityService:GetEntityByPath(base .. "/SelectedCharacterArt")
|
||||
if art ~= nil and art.SpriteGUIRendererComponent ~= nil and target ~= nil and target.SpriteGUIRendererComponent ~= nil then
|
||||
target.SpriteGUIRendererComponent.ImageRUID = art.SpriteGUIRendererComponent.ImageRUID
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/SelectedClass", name)
|
||||
self:SetText(base .. "/SelectedClass/SelectedClassEng", eng)
|
||||
self:SetText(base .. "/SelectedClassStatus", desc)`),
|
||||
method('StartNewGame', `if self.SelectedClass ~= "warrior" and self.SelectedClass ~= "bandit" and self.SelectedClass ~= "magician" then
|
||||
self:SetText("/ui/SelectUIGroup/CharacterSelectHud/SelectedClassStatus", "직업을 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
self:StartRun()`),
|
||||
method('SetEntityEnabled', `local e = _EntityService:GetEntityByPath(path)
|
||||
if e ~= nil then
|
||||
e.Enable = enabled
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enabled' },
|
||||
], 2),
|
||||
];
|
||||
610
tools/deck/cb/combat.mjs
Normal file
610
tools/deck/cb/combat.mjs
Normal file
@@ -0,0 +1,610 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const combatMethods = [
|
||||
method('CanPlayCardNow', `if c == nil then
|
||||
return false
|
||||
end
|
||||
if c.playableWhenDrawPileEmpty == true and self.DrawPile ~= nil and #self.DrawPile > 0 then
|
||||
self:Toast("뽑을 카드 더미가 비어 있을 때만 사용할 수 있습니다.")
|
||||
return false
|
||||
end
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('PlayCard', `if self:IsDiscardSelecting() == true then
|
||||
self:SelectDiscardSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsRetainSelecting() == true then
|
||||
self:SelectRetainSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsReserveSelecting() == true then
|
||||
self:SelectReserveSlot(slot)
|
||||
return
|
||||
end
|
||||
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
if self.Hand == nil then
|
||||
return
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
if cardId == nil then
|
||||
return
|
||||
end
|
||||
local c = self.Cards[cardId]
|
||||
if c == nil then
|
||||
return
|
||||
end
|
||||
if c.unplayable == true then
|
||||
self:Toast("사용할 수 없는 카드입니다")
|
||||
return
|
||||
end
|
||||
if self:CanPlayCardNow(c) ~= true then
|
||||
return
|
||||
end
|
||||
local cost = c.cost or 0
|
||||
local skillFree = false
|
||||
if self.HandCostZeroThisTurn == true then
|
||||
cost = 0
|
||||
elseif c.useAllEnergy == true then
|
||||
cost = self.Energy
|
||||
end
|
||||
if c.kind == "Skill" and self.NextSkillCostZero == true then
|
||||
cost = 0
|
||||
skillFree = true
|
||||
end
|
||||
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
|
||||
cost = math.max(0, cost - self.SkillCostReductionThisTurn)
|
||||
end
|
||||
if self.Energy < cost then
|
||||
self:Toast("에너지가 부족합니다")
|
||||
return
|
||||
end
|
||||
self.Energy = self.Energy - cost
|
||||
self:ResolveCardEffects(cardId, slot, c, false, cost)
|
||||
if c.kind == "Attack" then
|
||||
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
|
||||
end
|
||||
if skillFree == true then
|
||||
if c.nextSkillCostZero ~= true then
|
||||
self.NextSkillCostZero = false
|
||||
end
|
||||
end
|
||||
if self:HasPowerField("cardPlayedBlock") == true then
|
||||
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
|
||||
end
|
||||
if c.cardPlayedDamage ~= nil and c.cardPlayedDamage > 0 then
|
||||
self:DealDirectDamageToTarget(c.cardPlayedDamage)
|
||||
end
|
||||
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
|
||||
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
|
||||
end
|
||||
table.remove(self.Hand, slot)
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId)
|
||||
elseif c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
if self:BeginDiscardSelection(c) == true then
|
||||
return
|
||||
end
|
||||
if self:BeginReserveSelection(c) == true then
|
||||
return
|
||||
end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('OnCardButton', `if self:IsDiscardSelecting() == true then
|
||||
self:SelectDiscardSlot(slot)
|
||||
elseif self:IsRetainSelecting() == true then
|
||||
self:SelectRetainSlot(slot)
|
||||
elseif self:IsReserveSelecting() == true then
|
||||
self:SelectReserveSlot(slot)
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('FindMonsterAtTouch', `local best = 0
|
||||
local bestDist = 200
|
||||
for i = 1, #self.Monsters do
|
||||
local m = self.Monsters[i]
|
||||
if m.alive == true and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7))
|
||||
local dx = sp.x - touchPoint.x
|
||||
local dy = sp.y - touchPoint.y
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
if d < bestDist then
|
||||
bestDist = d
|
||||
best = i
|
||||
end
|
||||
end
|
||||
end
|
||||
return best`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }], 0, 'number'),
|
||||
method('RenderTargetFrames', `local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||
local shownTarget = self.TargetIndex
|
||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||
for i = 1, #self.Monsters do
|
||||
local m = self.Monsters[i]
|
||||
local active = false
|
||||
if m ~= nil and m.alive == true and i == shownTarget then active = true end
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i) .. "/TargetMarker", active and dragActive)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i) .. "/TargetMarker/Label", active and dragActive)
|
||||
end`),
|
||||
method('OnCardDragBegin', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return
|
||||
end
|
||||
if self.CardHoverTweenId ~= nil and self.CardHoverTweenId ~= 0 then
|
||||
_TimerService:ClearTimer(self.CardHoverTweenId)
|
||||
self.CardHoverTweenId = 0
|
||||
end
|
||||
for i = 1, 10 do
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
e.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(i), 0)
|
||||
end
|
||||
end
|
||||
self.DragSlot = slot
|
||||
self.DragTargetIndex = 0
|
||||
self:RenderTargetFrames()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('OnCardDrag', `if self.DragSlot ~= slot then
|
||||
return
|
||||
end
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
local ui = _UILogic:ScreenToUIPosition(touchPoint)
|
||||
e.UITransformComponent.anchoredPosition = Vector2(ui.x, ui.y + 360)
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
local c = nil
|
||||
if cardId ~= nil then c = self.Cards[cardId] end
|
||||
if c ~= nil and c.kind == "Attack" then
|
||||
local best = self:FindMonsterAtTouch(touchPoint)
|
||||
if best ~= self.DragTargetIndex then
|
||||
self.DragTargetIndex = best
|
||||
self:RenderTargetFrames()
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
|
||||
]),
|
||||
method('OnCardDragEnd', `if self.DragSlot ~= slot then
|
||||
return
|
||||
end
|
||||
self.DragSlot = 0
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(slot), 0)
|
||||
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
end
|
||||
self:ResolveCardDrop(slot, touchPoint)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
|
||||
]),
|
||||
method('ResolveCardDrop', `if self:IsDiscardSelecting() == true then
|
||||
self:SelectDiscardSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsRetainSelecting() == true then
|
||||
self:SelectRetainSlot(slot)
|
||||
return
|
||||
end
|
||||
if self:IsReserveSelecting() == true then
|
||||
self:SelectReserveSlot(slot)
|
||||
return
|
||||
end
|
||||
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
if cardId == nil then
|
||||
return
|
||||
end
|
||||
local c = self.Cards[cardId]
|
||||
if c == nil then
|
||||
return
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
local best = self.DragTargetIndex or 0
|
||||
if best <= 0 then best = self:FindMonsterAtTouch(touchPoint) end
|
||||
self.DragTargetIndex = 0
|
||||
if best > 0 then
|
||||
self.TargetIndex = best
|
||||
self:PlayCard(slot)
|
||||
self:RenderTargetFrames()
|
||||
else
|
||||
self:RenderTargetFrames()
|
||||
end
|
||||
else
|
||||
self.DragTargetIndex = 0
|
||||
self:RenderTargetFrames()
|
||||
local ui = _UILogic:ScreenToUIPosition(touchPoint)
|
||||
if ui.y > -180 then
|
||||
self:PlayCard(slot)
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
|
||||
]),
|
||||
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
|
||||
method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex]
|
||||
if m == nil or m.alive ~= true then
|
||||
m = nil
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end
|
||||
end
|
||||
end
|
||||
if m == nil then
|
||||
return
|
||||
end
|
||||
local dmg = amount
|
||||
if m.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
if m.block > 0 and pierce ~= true then
|
||||
local absorbed = math.min(m.block, dmg)
|
||||
m.block = m.block - absorbed
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
m.hp = m.hp - dmg
|
||||
if dmg > 0 then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
m.poison = (m.poison or 0) + poison
|
||||
end
|
||||
end
|
||||
self:MonsterHitMotion(m.slot)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
|
||||
]),
|
||||
method('DealDirectDamageToTarget', `local m = self.Monsters[self.TargetIndex]
|
||||
if m == nil or m.alive ~= true then
|
||||
m = nil
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end
|
||||
end
|
||||
end
|
||||
if m == nil then
|
||||
return
|
||||
end
|
||||
m.hp = m.hp - amount
|
||||
self:ShowDmgPop(m.slot, amount)
|
||||
self:MonsterHitMotion(m.slot)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('DealDirectDamageToRandomMonster', `local alive = {}
|
||||
for i = 1, #self.Monsters do
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
table.insert(alive, m)
|
||||
end
|
||||
end
|
||||
if #alive <= 0 then
|
||||
return
|
||||
end
|
||||
local m = alive[math.random(1, #alive)]
|
||||
if m == nil then
|
||||
return
|
||||
end
|
||||
m.hp = m.hp - amount
|
||||
self:ShowDmgPop(m.slot, amount)
|
||||
self:MonsterHitMotion(m.slot)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
return
|
||||
end
|
||||
self.FxBusy = true
|
||||
local fx = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/SkillFx")
|
||||
if fx ~= nil then
|
||||
if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then
|
||||
fx.SpriteGUIRendererComponent.ImageRUID = image
|
||||
end
|
||||
if fx.UITransformComponent ~= nil and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7))
|
||||
fx.UITransformComponent.anchoredPosition = _UILogic:ScreenToUIPosition(sp)
|
||||
end
|
||||
fx.Enable = true
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if fx ~= nil then fx.Enable = false end
|
||||
self.FxBusy = false
|
||||
local shown = damage
|
||||
local mt = self.Monsters[targetIndex]
|
||||
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
|
||||
shown = math.floor(damage * 1.5)
|
||||
end
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
self:ShowDmgPop(targetIndex, shown)
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end, 0.35)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'targetIndex' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
|
||||
]),
|
||||
method('PlayAoeFx', `self.FxBusy = true
|
||||
local fx = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/SkillFx")
|
||||
if fx ~= nil then
|
||||
if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then
|
||||
fx.SpriteGUIRendererComponent.ImageRUID = image
|
||||
end
|
||||
if fx.UITransformComponent ~= nil then
|
||||
fx.UITransformComponent.anchoredPosition = Vector2(300, 60)
|
||||
end
|
||||
fx.Enable = true
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if fx ~= nil then fx.Enable = false end
|
||||
self.FxBusy = false
|
||||
for i = 1, #self.Monsters do
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
local dmg = damage
|
||||
if m.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
if m.block > 0 then
|
||||
local absorbed = math.min(m.block, dmg)
|
||||
m.block = m.block - absorbed
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
m.hp = m.hp - dmg
|
||||
if dmg > 0 then
|
||||
local poison = self:AddPowerFieldTotal("attackPoison")
|
||||
if poison ~= nil and poison > 0 then
|
||||
m.poison = (m.poison or 0) + poison
|
||||
end
|
||||
end
|
||||
self:ShowDmgPop(i, dmg)
|
||||
self:MonsterHitMotion(i)
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end
|
||||
end
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end, 0.35)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
|
||||
]),
|
||||
method('KillMonster', `local m = self.Monsters[slot]
|
||||
if m == nil then
|
||||
return
|
||||
end
|
||||
m.alive = false
|
||||
if m.entity ~= nil and isvalid(m.entity) then
|
||||
local ent = m.entity
|
||||
_TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.4)
|
||||
end
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot), false)
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then self.TargetIndex = i; break end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('DealDamageToPlayer', `local dmg = amount
|
||||
if self.PlayerBlock > 0 then
|
||||
local absorbed = math.min(self.PlayerBlock, dmg)
|
||||
self.PlayerBlock = self.PlayerBlock - absorbed
|
||||
dmg = dmg - absorbed
|
||||
end
|
||||
if dmg > 0 then
|
||||
self.PlayerHp = self.PlayerHp - dmg
|
||||
local reflect = self.PlayerThorns or 0
|
||||
if self:HasRelic("bronzeScales") then
|
||||
reflect = reflect + 3
|
||||
end
|
||||
if reflect > 0 and attackerSlot ~= nil and attackerSlot > 0 then
|
||||
local am = self.Monsters[attackerSlot]
|
||||
if am ~= nil and am.alive == true then
|
||||
am.hp = am.hp - reflect
|
||||
self:ShowDmgPop(am.slot, reflect)
|
||||
self:MonsterHitMotion(am.slot)
|
||||
if am.hp <= 0 then
|
||||
am.hp = 0
|
||||
self:KillMonster(am.slot)
|
||||
end
|
||||
end
|
||||
end
|
||||
if self:HasRelic("selfFormingClay") then
|
||||
self.ClayBlockNext = self.ClayBlockNext + 3
|
||||
end
|
||||
if self:HasRelic("centennialPuzzle") and self.FirstHpLossDone == false then
|
||||
self.FirstHpLossDone = true
|
||||
self:DrawCards(3)
|
||||
self:RenderHand(false)
|
||||
end
|
||||
end
|
||||
if self.PlayerHp < 0 then
|
||||
self.PlayerHp = 0
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'attackerSlot' },
|
||||
]),
|
||||
method('EnemyTurn', `self.TurnBusy = true
|
||||
self:EnemyActStep(1)`),
|
||||
method('EnemyActStep', `local idx = 0
|
||||
for i = fromIndex, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then idx = i; break end
|
||||
end
|
||||
if idx == 0 or self.PlayerHp <= 0 then
|
||||
self:FinishEnemyTurn()
|
||||
return
|
||||
end
|
||||
local m = self.Monsters[idx]
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(idx)
|
||||
self:SetEntityEnabled(base .. "/ActFrame", true)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if m.poison ~= nil and m.poison > 0 then
|
||||
m.hp = m.hp - m.poison
|
||||
self:ShowDmgPop(idx, m.poison)
|
||||
self:MonsterHitMotion(idx)
|
||||
m.poison = m.poison - 1
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
self:RenderCombat()
|
||||
self:SetEntityEnabled(base .. "/ActFrame", false)
|
||||
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
|
||||
return
|
||||
end
|
||||
end
|
||||
m.block = 0
|
||||
local intent = m.intents[m.intentIdx]
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
self:MonsterLunge(idx)
|
||||
local atk = intent.value + m.str
|
||||
if m.weak > 0 then
|
||||
atk = math.floor(atk * 0.75)
|
||||
end
|
||||
if self.PlayerVuln > 0 then
|
||||
atk = math.floor(atk * 1.5)
|
||||
end
|
||||
local before = self.PlayerHp
|
||||
self:DealDamageToPlayer(atk, idx)
|
||||
self:ShowPlayerDmgPop(before - self.PlayerHp)
|
||||
self:PlayerHitMotion()
|
||||
elseif intent.kind == "Defend" then
|
||||
m.block = m.block + intent.value
|
||||
elseif intent.kind == "Debuff" then
|
||||
if intent.effect == "weak" then
|
||||
self.PlayerWeak = self.PlayerWeak + intent.value
|
||||
elseif intent.effect == "vuln" then
|
||||
self.PlayerVuln = self.PlayerVuln + intent.value
|
||||
end
|
||||
elseif intent.kind == "AddCard" then
|
||||
local cnt = intent.count or 1
|
||||
for ci = 1, cnt do
|
||||
table.insert(self.DiscardPile, intent.card)
|
||||
end
|
||||
self:RenderPiles()
|
||||
local cn = intent.card
|
||||
local cc = self.Cards[intent.card]
|
||||
if cc ~= nil then cn = cc.name end
|
||||
self:Toast(m.name .. ": " .. cn .. " 추가!")
|
||||
end
|
||||
end
|
||||
if #m.intents > 0 then
|
||||
m.intentIdx = math.random(1, #m.intents)
|
||||
end
|
||||
if m.weak > 0 then m.weak = m.weak - 1 end
|
||||
if m.vuln > 0 then m.vuln = m.vuln - 1 end
|
||||
self:RenderCombat()
|
||||
self:SetEntityEnabled(base .. "/ActFrame", false)
|
||||
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
|
||||
end, 0.45)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromIndex' }]),
|
||||
method('FinishEnemyTurn', `self.TurnBusy = false
|
||||
self:CheckCombatEnd()
|
||||
if self.CombatOver == true then
|
||||
return
|
||||
end
|
||||
_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)`),
|
||||
method('ClearCombatCards', `self.DrawPile = {}
|
||||
self.DiscardPile = {}
|
||||
self.ExhaustPile = {}
|
||||
self.Hand = {}
|
||||
self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.RetainSelectActive = false
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnBlock = 0
|
||||
self.NextTurnDraw = 0
|
||||
self.NextTurnKeepBlock = false
|
||||
self.NextTurnAttackMultiplier = 1
|
||||
self.TurnAttackMultiplier = 1
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnAddCards = {}
|
||||
self:UpdateDiscardPrompt()
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()`),
|
||||
method('CheckCombatEnd', `local anyAlive = false
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then anyAlive = true; break end
|
||||
end
|
||||
if anyAlive == false then
|
||||
self.CombatOver = true
|
||||
self:ClearCombatCards()
|
||||
self.Gold = self.Gold + math.floor(${GOLD_PER_WIN} * self:AscGoldMult())
|
||||
self:ApplyRelics("combatEnd")
|
||||
self:ApplyRelics("combatReward")
|
||||
self:MaybeDropPotion()
|
||||
self:RenderRun()
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node ~= nil and node.type == "elite" then
|
||||
self.Gold = self.Gold + 15
|
||||
local nid = self:PickNewRelic()
|
||||
if nid ~= "" then
|
||||
self:AddRelic(nid)
|
||||
local nr = self.Relics[nid]
|
||||
if nr ~= nil then
|
||||
self:Toast("유물 획득: " .. nr.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
if node ~= nil and node.type == "boss" then
|
||||
if self.PlayerJob == "" and self.Floor < self.RunLength then
|
||||
self:ShowJobChoice()
|
||||
else
|
||||
if self.PlayerJob ~= "" then self:AwardSouls(1) end
|
||||
local bid = self:PickNewRelic()
|
||||
if bid ~= "" then
|
||||
self:AddRelic(bid)
|
||||
local br = self.Relics[bid]
|
||||
if br ~= nil then
|
||||
self:Toast("유물 획득: " .. br.name)
|
||||
end
|
||||
end
|
||||
self:ContinueAfterBoss()
|
||||
end
|
||||
else
|
||||
self:OfferReward()
|
||||
end
|
||||
elseif self.PlayerHp <= 0 then
|
||||
self.CombatOver = true
|
||||
self:EndRun("패배...")
|
||||
end`),
|
||||
method('ContinueAfterBoss', `if self.Floor < self.RunLength then
|
||||
self.Floor = self.Floor + 1
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self:GenerateMap()
|
||||
self:RenderRun()
|
||||
self:TeleportToActMap()
|
||||
self:ShowMap()
|
||||
else
|
||||
self:EndRun("런 클리어!")
|
||||
end`),
|
||||
];
|
||||
509
tools/deck/cb/deckturn.mjs
Normal file
509
tools/deck/cb/deckturn.mjs
Normal file
@@ -0,0 +1,509 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const deckTurnMethods = [
|
||||
method('Shuffle', `if list == nil then
|
||||
\treturn
|
||||
end
|
||||
for i = #list, 2, -1 do
|
||||
\tlocal j = math.random(1, i)
|
||||
\tlist[i], list[j] = list[j], list[i]
|
||||
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]),
|
||||
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/EndTurnButton")
|
||||
if endTurn ~= nil and (endTurn.ButtonComponent ~= nil or endTurn:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.EndTurnHandler ~= nil then
|
||||
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
|
||||
self.EndTurnHandler = nil
|
||||
end
|
||||
self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
|
||||
end
|
||||
local drawPile = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/DrawPile")
|
||||
if drawPile ~= nil and (drawPile.ButtonComponent ~= nil or drawPile:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.DrawPileHandler ~= nil then
|
||||
drawPile:DisconnectEvent(ButtonClickEvent, self.DrawPileHandler)
|
||||
self.DrawPileHandler = nil
|
||||
end
|
||||
self.DrawPileHandler = drawPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("draw") end)
|
||||
end
|
||||
local discardPile = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/DiscardPile")
|
||||
if discardPile ~= nil and (discardPile.ButtonComponent ~= nil or discardPile:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.DiscardPileHandler ~= nil then
|
||||
discardPile:DisconnectEvent(ButtonClickEvent, self.DiscardPileHandler)
|
||||
self.DiscardPileHandler = nil
|
||||
end
|
||||
self.DiscardPileHandler = discardPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("discard") end)
|
||||
end
|
||||
local exhaustPile = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/ExhaustPile")
|
||||
if exhaustPile ~= nil and (exhaustPile.ButtonComponent ~= nil or exhaustPile:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.ExhaustPileHandler ~= nil then
|
||||
exhaustPile:DisconnectEvent(ButtonClickEvent, self.ExhaustPileHandler)
|
||||
self.ExhaustPileHandler = nil
|
||||
end
|
||||
self.ExhaustPileHandler = exhaustPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("exhaust") end)
|
||||
end
|
||||
local inspectClose = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud/Close")
|
||||
if inspectClose ~= nil and (inspectClose.ButtonComponent ~= nil or inspectClose:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.DeckInspectCloseHandler ~= nil then
|
||||
inspectClose:DisconnectEvent(ButtonClickEvent, self.DeckInspectCloseHandler)
|
||||
self.DeckInspectCloseHandler = nil
|
||||
end
|
||||
self.DeckInspectCloseHandler = inspectClose:ConnectEvent(ButtonClickEvent, function() self:CloseDeckInspect() end)
|
||||
end
|
||||
local allDeckButton = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TopBar/AllDeckButton")
|
||||
if allDeckButton ~= nil and (allDeckButton.ButtonComponent ~= nil or allDeckButton:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.AllDeckHandler ~= nil then
|
||||
allDeckButton:DisconnectEvent(ButtonClickEvent, self.AllDeckHandler)
|
||||
self.AllDeckHandler = nil
|
||||
end
|
||||
self.AllDeckHandler = allDeckButton:ConnectEvent(ButtonClickEvent, function() self:OpenAllDeck() end)
|
||||
end
|
||||
local allDeckClose = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
|
||||
if allDeckClose ~= nil and (allDeckClose.ButtonComponent ~= nil or allDeckClose:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.AllDeckCloseHandler ~= nil then
|
||||
allDeckClose:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
|
||||
self.AllDeckCloseHandler = nil
|
||||
end
|
||||
self.AllDeckCloseHandler = allDeckClose:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
|
||||
end
|
||||
self:BindClassDeckTabs()
|
||||
for i = 1, 120 do
|
||||
local allCard = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i))
|
||||
if allCard ~= nil and (allCard.ButtonComponent ~= nil or allCard:AddComponent("ButtonComponent") ~= nil) then
|
||||
if allCard.SpriteGUIRendererComponent ~= nil then
|
||||
allCard.SpriteGUIRendererComponent.RaycastTarget = true
|
||||
end
|
||||
local slot = i
|
||||
allCard:ConnectEvent(ButtonClickEvent, function() self:OnAllDeckCardButton(slot) end)
|
||||
end
|
||||
end
|
||||
for i = 1, 10 do
|
||||
local cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
|
||||
if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then
|
||||
local cardPath = "/ui/RunUIGroup/CardHand/Card" .. tostring(i)
|
||||
cardEntity:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
|
||||
cardEntity:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
|
||||
cardEntity:ConnectEvent(UITouchBeginDragEvent, function(ev) self:OnCardDragBegin(i) end)
|
||||
cardEntity:ConnectEvent(UITouchDragEvent, function(ev) self:OnCardDrag(i, ev.TouchPoint) end)
|
||||
cardEntity:ConnectEvent(UITouchEndDragEvent, function(ev) self:OnCardDragEnd(i, ev.TouchPoint) end)
|
||||
cardEntity:ConnectEvent(UITouchEnterEvent, function() self:HoverCard(i) end)
|
||||
cardEntity:ConnectEvent(UITouchExitEvent, function() self:UnhoverCard(i) end)
|
||||
if (cardEntity.ButtonComponent ~= nil or cardEntity:AddComponent("ButtonComponent") ~= nil) then
|
||||
cardEntity:ConnectEvent(ButtonClickEvent, function() self:OnCardButton(i) end)
|
||||
end
|
||||
end
|
||||
end
|
||||
for i = 1, 3 do
|
||||
local rc = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud/Reward" .. tostring(i))
|
||||
if rc ~= nil and (rc.ButtonComponent ~= nil or rc:AddComponent("ButtonComponent") ~= nil) then
|
||||
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
|
||||
if rc.UITouchReceiveComponent ~= nil then
|
||||
local cardPath = "/ui/RunUIGroup/RewardHud/Reward" .. tostring(i)
|
||||
rc:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
|
||||
rc:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
|
||||
end
|
||||
end
|
||||
end
|
||||
local skip = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud/Skip")
|
||||
if skip ~= nil and (skip.ButtonComponent ~= nil or skip:AddComponent("ButtonComponent") ~= nil) then
|
||||
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
|
||||
end
|
||||
local mapNodeIds = {}
|
||||
for r = 1, ${MAP_ROWS} do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
table.insert(mapNodeIds, "r" .. tostring(r) .. "c" .. tostring(c))
|
||||
end
|
||||
end
|
||||
table.insert(mapNodeIds, "boss")
|
||||
for i = 1, #mapNodeIds do
|
||||
local nid = mapNodeIds[i]
|
||||
local mn = _EntityService:GetEntityByPath("/ui/RunUIGroup/MapHud/Node_" .. nid)
|
||||
if mn ~= nil and (mn.ButtonComponent ~= nil or mn:AddComponent("ButtonComponent") ~= nil) then
|
||||
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
|
||||
end
|
||||
end
|
||||
for i = 1, 3 do
|
||||
local sc = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Card" .. tostring(i))
|
||||
if sc ~= nil and (sc.ButtonComponent ~= nil or sc:AddComponent("ButtonComponent") ~= nil) then
|
||||
sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)
|
||||
if sc.UITouchReceiveComponent ~= nil then
|
||||
local cardPath = "/ui/RunUIGroup/ShopHud/Card" .. tostring(i)
|
||||
sc:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
|
||||
sc:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
|
||||
end
|
||||
end
|
||||
end
|
||||
local shopLeave = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Leave")
|
||||
if shopLeave ~= nil and (shopLeave.ButtonComponent ~= nil or shopLeave:AddComponent("ButtonComponent") ~= nil) then
|
||||
shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
local shopRelic = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Relic")
|
||||
if shopRelic ~= nil and (shopRelic.ButtonComponent ~= nil or shopRelic:AddComponent("ButtonComponent") ~= nil) then
|
||||
shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)
|
||||
end
|
||||
local restLeave = _EntityService:GetEntityByPath("/ui/RunUIGroup/RestHud/Leave")
|
||||
if restLeave ~= nil and (restLeave.ButtonComponent ~= nil or restLeave:AddComponent("ButtonComponent") ~= nil) then
|
||||
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
for i = 1, ${MAX_MONSTERS} do
|
||||
local ms = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i))
|
||||
if ms ~= nil and (ms.ButtonComponent ~= nil or ms:AddComponent("ButtonComponent") ~= nil) then
|
||||
ms:ConnectEvent(ButtonClickEvent, function() self:SetTarget(i) end)
|
||||
end
|
||||
end
|
||||
for i = 1, 10 do
|
||||
local rs = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TopBar/RelicSlot" .. tostring(i))
|
||||
if rs ~= nil and rs.UITouchReceiveComponent ~= nil then
|
||||
local idx = i
|
||||
rs:ConnectEvent(UITouchEnterEvent, function()
|
||||
local rid = nil
|
||||
if self.RunRelics ~= nil then rid = self.RunRelics[idx] end
|
||||
if rid ~= nil and self.Relics[rid] ~= nil then
|
||||
self:ShowTooltip(self.Relics[rid].name, self.Relics[rid].desc, -240 + (idx - 1) * 48)
|
||||
end
|
||||
end)
|
||||
rs:ConnectEvent(UITouchExitEvent, function() self:HideTooltip() end)
|
||||
end
|
||||
end
|
||||
for i = 1, 5 do
|
||||
local ps = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TopBar/PotionSlot" .. tostring(i))
|
||||
if ps ~= nil and ps.UITouchReceiveComponent ~= nil then
|
||||
local idx = i
|
||||
ps:ConnectEvent(UITouchEnterEvent, function()
|
||||
local pid = nil
|
||||
if self.RunPotions ~= nil then pid = self.RunPotions[idx] end
|
||||
if pid ~= nil and self.Potions[pid] ~= nil then
|
||||
self:ShowTooltip(self.Potions[pid].name, self.Potions[pid].desc, 240 + (idx - 1) * 44)
|
||||
end
|
||||
end)
|
||||
ps:ConnectEvent(UITouchExitEvent, function() self:HideTooltip() end)
|
||||
ps:ConnectEvent(UITouchDownEvent, function() self:OpenPotionMenu(idx) end)
|
||||
end
|
||||
end
|
||||
local pmUse = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/PotionMenu/Use")
|
||||
if pmUse ~= nil and (pmUse.ButtonComponent ~= nil or pmUse:AddComponent("ButtonComponent") ~= nil) then
|
||||
pmUse:ConnectEvent(ButtonClickEvent, function() self:UsePotion() end)
|
||||
end
|
||||
local pmToss = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/PotionMenu/Toss")
|
||||
if pmToss ~= nil and (pmToss.ButtonComponent ~= nil or pmToss:AddComponent("ButtonComponent") ~= nil) then
|
||||
pmToss:ConnectEvent(ButtonClickEvent, function() self:TossPotion() end)
|
||||
end
|
||||
local pmClose = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/PotionMenu/Close")
|
||||
if pmClose ~= nil and (pmClose.ButtonComponent ~= nil or pmClose:AddComponent("ButtonComponent") ~= nil) then
|
||||
pmClose:ConnectEvent(ButtonClickEvent, function() self:ClosePotionMenu() end)
|
||||
end
|
||||
local shopPotion = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Potion")
|
||||
if shopPotion ~= nil and (shopPotion.ButtonComponent ~= nil or shopPotion:AddComponent("ButtonComponent") ~= nil) then
|
||||
shopPotion:ConnectEvent(ButtonClickEvent, function() self:BuyPotion() end)
|
||||
end
|
||||
local chest = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Chest")
|
||||
if chest ~= nil and (chest.ButtonComponent ~= nil or chest:AddComponent("ButtonComponent") ~= nil) then
|
||||
chest:ConnectEvent(ButtonClickEvent, function() self:OpenChest() end)
|
||||
end
|
||||
local treasureLeave = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Leave")
|
||||
if treasureLeave ~= nil and (treasureLeave.ButtonComponent ~= nil or treasureLeave:AddComponent("ButtonComponent") ~= nil) then
|
||||
treasureLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
local jcRelic = _EntityService:GetEntityByPath("/ui/SelectUIGroup/JobChoiceHud/RelicButton")
|
||||
if jcRelic ~= nil and (jcRelic.ButtonComponent ~= nil or jcRelic:AddComponent("ButtonComponent") ~= nil) then
|
||||
jcRelic:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("relic") end)
|
||||
end
|
||||
local jcJob = _EntityService:GetEntityByPath("/ui/SelectUIGroup/JobChoiceHud/JobButton")
|
||||
if jcJob ~= nil and (jcJob.ButtonComponent ~= nil or jcJob:AddComponent("ButtonComponent") ~= nil) then
|
||||
jcJob:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("job") end)
|
||||
end
|
||||
for i = 1, 3 do
|
||||
local slotIdx = i
|
||||
local jb = _EntityService:GetEntityByPath("/ui/SelectUIGroup/JobSelectHud/Job_slot" .. tostring(i))
|
||||
if jb ~= nil and (jb.ButtonComponent ~= nil or jb:AddComponent("ButtonComponent") ~= nil) then
|
||||
jb:ConnectEvent(ButtonClickEvent, function()
|
||||
if self.JobOpts ~= nil and self.JobOpts[slotIdx] ~= nil then
|
||||
self:SetJob(self.JobOpts[slotIdx].id)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end`),
|
||||
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
||||
self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.SkillCostReductionThisTurn = 0
|
||||
self:UpdateDiscardPrompt()
|
||||
self.Energy = self.MaxEnergy
|
||||
self.BlockGainMultiplier = 1
|
||||
self:ApplyRelics("turnStart")
|
||||
if self.NextTurnKeepBlock == true then
|
||||
self.NextTurnKeepBlock = false
|
||||
else
|
||||
self.PlayerBlock = 0
|
||||
end
|
||||
if self.ClayBlockNext > 0 then
|
||||
self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext
|
||||
self.ClayBlockNext = 0
|
||||
end
|
||||
self.TurnAttackMultiplier = self.NextTurnAttackMultiplier or 1
|
||||
self.NextTurnAttackMultiplier = 1
|
||||
self.CardsDrawnThisCombat = self.CardsDrawnThisCombat or 0
|
||||
self.HandCostZeroThisTurn = false
|
||||
self.DrawDisabledThisTurn = false
|
||||
local powerTurnDraw = 0
|
||||
local powerTurnDiscard = 0
|
||||
if self.PlayerPowers ~= nil then
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil then
|
||||
if pc.powerEffect == "strengthPerTurn" then
|
||||
self.PlayerStr = self.PlayerStr + pc.value
|
||||
elseif pc.powerEffect == "energyPerTurn" then
|
||||
self.Energy = self.Energy + pc.value
|
||||
elseif pc.powerEffect == "blockPerTurn" then
|
||||
self.PlayerBlock = self.PlayerBlock + pc.value
|
||||
elseif pc.powerEffect == "poisonPerTurn" then
|
||||
if self.Monsters ~= nil then
|
||||
for j = 1, #self.Monsters do
|
||||
local tm = self.Monsters[j]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
tm.poison = (tm.poison or 0) + pc.value
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif pc.powerEffect == "damagePerTurn" then
|
||||
if self.Monsters ~= nil then
|
||||
self:PlayAoeFx(pc.fx or pc.image, pc.value or 0)
|
||||
end
|
||||
end
|
||||
if pc.turnStartShiv ~= nil then
|
||||
self:AddCardsToHand("Shiv", pc.turnStartShiv)
|
||||
end
|
||||
if pc.turnStartDraw ~= nil then
|
||||
powerTurnDraw = powerTurnDraw + pc.turnStartDraw
|
||||
end
|
||||
if pc.turnStartDiscard ~= nil then
|
||||
powerTurnDiscard = powerTurnDiscard + pc.turnStartDiscard
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.NextTurnBlock ~= nil and self.NextTurnBlock > 0 then
|
||||
self:AddCardBlock(self.NextTurnBlock)
|
||||
self.NextTurnBlock = 0
|
||||
end
|
||||
if self.NextTurnAddCards ~= nil then
|
||||
for i = 1, #self.NextTurnAddCards do
|
||||
local entry = self.NextTurnAddCards[i]
|
||||
if entry ~= nil and entry.cardId ~= nil and entry.amount ~= nil and entry.amount > 0 then
|
||||
self:AddCardsToHand(entry.cardId, entry.amount)
|
||||
end
|
||||
end
|
||||
self.NextTurnAddCards = {}
|
||||
end
|
||||
local drawN = 5 + (self.NextTurnDraw or 0) + powerTurnDraw
|
||||
self.NextTurnDraw = 0
|
||||
self:DrawCards(drawN)
|
||||
self:RenderHand(true)
|
||||
self:RenderCombat()
|
||||
if powerTurnDiscard > 0 then
|
||||
self:BeginDiscardSelection({ discard = math.min(powerTurnDiscard, #self.Hand) })
|
||||
return
|
||||
end
|
||||
self:RenderCombat()`),
|
||||
method('PrepareCombatDrawPile', `if self.DrawPile == nil or self.Cards == nil then
|
||||
return
|
||||
end
|
||||
local rest = {}
|
||||
local innate = {}
|
||||
for i = 1, #self.DrawPile do
|
||||
local cardId = self.DrawPile[i]
|
||||
local c = self.Cards[cardId]
|
||||
if c ~= nil and c.innate == true then
|
||||
table.insert(innate, cardId)
|
||||
else
|
||||
table.insert(rest, cardId)
|
||||
end
|
||||
end
|
||||
self.DrawPile = {}
|
||||
for i = 1, #rest do
|
||||
table.insert(self.DrawPile, rest[i])
|
||||
end
|
||||
for i = 1, #innate do
|
||||
table.insert(self.DrawPile, innate[i])
|
||||
end`, []),
|
||||
method('HasPowerEffect', `if self.PlayerPowers == nil then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc.powerEffect == effect then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'effect' }], 0, 'boolean'),
|
||||
method('HasPowerField', `if self.PlayerPowers == nil then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc[field] ~= nil and pc[field] ~= 0 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'boolean'),
|
||||
method('AddPowerFieldTotal', `local total = 0
|
||||
if self.PlayerPowers == nil then
|
||||
return total
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc[field] ~= nil then
|
||||
total = total + pc[field]
|
||||
end
|
||||
end
|
||||
return total`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'number'),
|
||||
method('ShouldOfferRetain', `if self:HasPowerEffect("retainOne") ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or #self.Hand <= 0 then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.Hand do
|
||||
local c = self.Cards[self.Hand[i]]
|
||||
if c ~= nil and c.retain ~= true then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [], 0, 'boolean'),
|
||||
method('BeginRetainSelection', `self.RetainSelectActive = true
|
||||
self:UpdateDiscardPrompt()
|
||||
self:Toast("보존할 카드를 선택하세요")
|
||||
self:RenderHand(false)`, []),
|
||||
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
|
||||
return
|
||||
end
|
||||
if self:IsDiscardSelecting() == true then
|
||||
self:Toast("버릴 카드를 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
if self:IsRetainSelecting() == true then
|
||||
self:FinishPlayerTurn(0)
|
||||
return
|
||||
end
|
||||
if self:IsReserveSelecting() == true then
|
||||
self:Toast("예약할 카드를 먼저 선택하세요")
|
||||
return
|
||||
end
|
||||
if self:ShouldOfferRetain() == true then
|
||||
self:BeginRetainSelection()
|
||||
return
|
||||
end
|
||||
self:FinishPlayerTurn(0)`),
|
||||
method('FinishPlayerTurn', `self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self:UpdateDiscardPrompt()
|
||||
local burn = 0
|
||||
for bi = 1, #self.Hand do
|
||||
\tlocal hc = self.Cards[self.Hand[bi]]
|
||||
\tif hc ~= nil and hc.endTurnDamage ~= nil then burn = burn + hc.endTurnDamage end
|
||||
end
|
||||
if burn > 0 then
|
||||
\tself.PlayerHp = self.PlayerHp - burn
|
||||
\tif self.PlayerHp < 0 then self.PlayerHp = 0 end
|
||||
\tself:ShowPlayerDmgPop(burn)
|
||||
\tself:RenderCombat()
|
||||
end
|
||||
local kept = {}
|
||||
for i = 1, #self.Hand do
|
||||
\tlocal cardId = self.Hand[i]
|
||||
\tlocal c = self.Cards[cardId]
|
||||
\tif c ~= nil and (c.retain == true or i == retainSlot) then
|
||||
\t\ttable.insert(kept, cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.DiscardPile, cardId)
|
||||
\tend
|
||||
end
|
||||
self.Hand = kept
|
||||
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
|
||||
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:EnemyTurn()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'retainSlot' }]),
|
||||
method('DrawCards', `local drawnSlots = {}
|
||||
local drawnCards = {}
|
||||
local drewAny = false
|
||||
if self.DrawDisabledThisTurn == true then
|
||||
\treturn drawnCards
|
||||
end
|
||||
for i = 1, amount do
|
||||
\tif #self.DrawPile <= 0 then
|
||||
\t\tself:RecycleDiscardIntoDraw()
|
||||
\tend
|
||||
\tif #self.DrawPile <= 0 then
|
||||
\t\tbreak
|
||||
\tend
|
||||
\tlocal cardId = table.remove(self.DrawPile)
|
||||
\ttable.insert(drawnCards, cardId)
|
||||
\tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1
|
||||
\tif #self.Hand >= 10 then
|
||||
\t\ttable.insert(self.DiscardPile, cardId)
|
||||
\t\tself:TriggerSly(cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.Hand, cardId)
|
||||
\t\tdrewAny = true
|
||||
\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\tend
|
||||
end
|
||||
self:RenderPiles()
|
||||
if drewAny == true then
|
||||
\tself:RenderHand(false)
|
||||
end
|
||||
if animate == true and #drawnSlots > 0 then
|
||||
\tlocal drawStart = Vector2(-590, 8)
|
||||
\tfor i = 1, #drawnSlots do
|
||||
\t\tlocal slot = drawnSlots[i]
|
||||
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
|
||||
\tend
|
||||
return drawnCards
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
], 0, 'any'),
|
||||
method('AddCardsToHand', `if self.Hand == nil then
|
||||
self.Hand = {}
|
||||
end
|
||||
if self.DiscardPile == nil then
|
||||
self.DiscardPile = {}
|
||||
end
|
||||
for i = 1, amount do
|
||||
if #self.Hand >= 10 then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
else
|
||||
table.insert(self.Hand, cardId)
|
||||
end
|
||||
end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('RecycleDiscardIntoDraw', `if self.DiscardPile == nil or #self.DiscardPile <= 0 then
|
||||
\treturn
|
||||
end
|
||||
self.DrawPile = {}
|
||||
for i = 1, #self.DiscardPile do
|
||||
\tself.DrawPile[i] = self.DiscardPile[i]
|
||||
end
|
||||
self.DiscardPile = {}
|
||||
self:Shuffle(self.DrawPile)`),
|
||||
method('RenderPiles', `self:SetText("/ui/RunUIGroup/DeckHud/DrawPile/Count", self:FormatNumber(#self.DrawPile))
|
||||
self:SetText("/ui/RunUIGroup/DeckHud/DiscardPile/Count", self:FormatNumber(#self.DiscardPile))
|
||||
self:SetText("/ui/RunUIGroup/DeckHud/ExhaustPile/Count", self:FormatNumber(#(self.ExhaustPile or {})))
|
||||
self:SetText("/ui/RunUIGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
|
||||
local inspect = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
|
||||
if inspect ~= nil and inspect.Enable == true and self.DeckInspectKind ~= "" then
|
||||
self:OpenDeckInspect(self.DeckInspectKind)
|
||||
end`),
|
||||
];
|
||||
258
tools/deck/cb/deckview.mjs
Normal file
258
tools/deck/cb/deckview.mjs
Normal file
@@ -0,0 +1,258 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const deckViewMethods = [
|
||||
method('OpenDeckInspect', `self.DeckInspectKind = kind
|
||||
if self.DeckAllOpen == true then
|
||||
self.DeckAllOpen = false
|
||||
local allHud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if allHud ~= nil then
|
||||
allHud.Enable = false
|
||||
end
|
||||
end
|
||||
local pile = {}
|
||||
local title = ""
|
||||
if kind == "discard" then
|
||||
pile = self.DiscardPile or {}
|
||||
title = "버린 덱"
|
||||
elseif kind == "exhaust" then
|
||||
pile = self.ExhaustPile or {}
|
||||
title = "소멸 덱"
|
||||
else
|
||||
pile = self.DrawPile or {}
|
||||
title = "뽑을 덱"
|
||||
end
|
||||
self:RenderDeckInspect(pile, title)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
|
||||
method('CloseDeckInspect', `self.DeckInspectKind = ""
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end`),
|
||||
method('RenderDeckInspect', `local count = 0
|
||||
if pile ~= nil then
|
||||
count = #pile
|
||||
end
|
||||
local suffix = " (" .. tostring(count) .. ")"
|
||||
if count > 60 then
|
||||
suffix = suffix .. " - 60장까지 표시"
|
||||
end
|
||||
self:SetText("/ui/DeckUIGroup/DeckInspectHud/Title", title .. suffix)
|
||||
self:SetEntityEnabled("/ui/DeckUIGroup/DeckInspectHud/Empty", count <= 0)
|
||||
for i = 1, 60 do
|
||||
local path = "/ui/DeckUIGroup/DeckInspectHud/Grid/Card" .. tostring(i)
|
||||
local cardId = nil
|
||||
if pile ~= nil then
|
||||
cardId = pile[i]
|
||||
end
|
||||
if cardId == nil then
|
||||
self:SetEntityEnabled(path, false)
|
||||
else
|
||||
self:SetEntityEnabled(path, true)
|
||||
self:ApplyInspectCardVisual(i, cardId)
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pile' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'title' },
|
||||
]),
|
||||
method('ApplyInspectCardVisual', `self:ApplyCardFace("/ui/DeckUIGroup/DeckInspectHud/Grid/Card" .. tostring(slot), cardId)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
]),
|
||||
method('BindClassDeckTabs', `local warriorTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/WarriorTab")
|
||||
if warriorTab ~= nil and (warriorTab.ButtonComponent ~= nil or warriorTab:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.WarriorDeckTabHandler ~= nil then
|
||||
warriorTab:DisconnectEvent(ButtonClickEvent, self.WarriorDeckTabHandler)
|
||||
self.WarriorDeckTabHandler = nil
|
||||
end
|
||||
self.WarriorDeckTabHandler = warriorTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("warrior") end)
|
||||
end
|
||||
local thiefTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/ThiefTab")
|
||||
if thiefTab ~= nil and (thiefTab.ButtonComponent ~= nil or thiefTab:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.ThiefDeckTabHandler ~= nil then
|
||||
thiefTab:DisconnectEvent(ButtonClickEvent, self.ThiefDeckTabHandler)
|
||||
self.ThiefDeckTabHandler = nil
|
||||
end
|
||||
self.ThiefDeckTabHandler = thiefTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("bandit") end)
|
||||
end
|
||||
local mageTab = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/MageTab")
|
||||
if mageTab ~= nil and (mageTab.ButtonComponent ~= nil or mageTab:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.MageDeckTabHandler ~= nil then
|
||||
mageTab:DisconnectEvent(ButtonClickEvent, self.MageDeckTabHandler)
|
||||
self.MageDeckTabHandler = nil
|
||||
end
|
||||
self.MageDeckTabHandler = mageTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("magician") end)
|
||||
end`),
|
||||
method('OpenClassDeck', `self.CodexMode = false
|
||||
self.ClassDeckMode = true
|
||||
self.DebugCardPickerMode = false
|
||||
self.DeckAllOpen = true
|
||||
self:SetClassDeckTab(className)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
|
||||
method('OpenDebugCardPicker', `if self.RunActive ~= true or self.CombatOver == true or self.Hand == nil then
|
||||
self:Toast("전투 중에만 테스트 카드를 추가할 수 있습니다")
|
||||
return
|
||||
end
|
||||
local className = self.SelectedClass
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
|
||||
className = "bandit"
|
||||
end
|
||||
self.CodexMode = false
|
||||
self.ClassDeckMode = true
|
||||
self.DebugCardPickerMode = true
|
||||
self.DeckAllOpen = true
|
||||
self:SetClassDeckTab(className)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end
|
||||
self:Toast("테스트 카드 추가 모드")`),
|
||||
method('SetClassDeckTab', `if self.ClassDeckMode ~= true then
|
||||
return
|
||||
end
|
||||
self.ClassDeckCards = {}
|
||||
self.ClassDeckTitle = "직업 덱"
|
||||
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
|
||||
className = "bandit"
|
||||
end
|
||||
self.ClassDeckClass = className
|
||||
local allowed = {}
|
||||
if className == "warrior" then
|
||||
allowed["warrior"] = true
|
||||
allowed["fighter"] = true
|
||||
allowed["page"] = true
|
||||
allowed["spearman"] = true
|
||||
self.ClassDeckTitle = "전사 전체 덱"
|
||||
elseif className == "magician" then
|
||||
allowed["magician"] = true
|
||||
allowed["firepoison"] = true
|
||||
allowed["icelightning"] = true
|
||||
allowed["cleric"] = true
|
||||
self.ClassDeckTitle = "마법사 전체 덱"
|
||||
else
|
||||
allowed["bandit"] = true
|
||||
allowed["shiv"] = true
|
||||
allowed["poisoner"] = true
|
||||
allowed["trickster"] = true
|
||||
self.ClassDeckTitle = "도적 전체 덱"
|
||||
end
|
||||
for id, c in pairs(self.Cards) do
|
||||
if c ~= nil and c.curse ~= true and allowed[c.class] == true then
|
||||
table.insert(self.ClassDeckCards, id)
|
||||
end
|
||||
end
|
||||
table.sort(self.ClassDeckCards, function(a, b)
|
||||
local ca = self.Cards[a]
|
||||
local cb = self.Cards[b]
|
||||
local na = a
|
||||
local nb = b
|
||||
if ca ~= nil and ca.name ~= nil then na = ca.name end
|
||||
if cb ~= nil and cb.name ~= nil then nb = cb.name end
|
||||
if na == nb then return a < b end
|
||||
return na < nb
|
||||
end)
|
||||
self:RenderAllDeck()
|
||||
self:RenderClassDeckTabs()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
|
||||
method('RenderClassDeckTabs', `local tabs = {
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/WarriorTab", cls = "warrior" },
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/ThiefTab", cls = "bandit" },
|
||||
{ path = "/ui/DeckUIGroup/DeckAllHud/MageTab", cls = "magician" },
|
||||
}
|
||||
for i = 1, #tabs do
|
||||
self:SetEntityEnabled(tabs[i].path, self.ClassDeckMode == true)
|
||||
local e = _EntityService:GetEntityByPath(tabs[i].path)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ClassDeckClass == tabs[i].cls then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.28, 0.34, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.11, 0.13, 0.16, 1)
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('OpenAllDeck', `local inspectHud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckInspectHud")
|
||||
if inspectHud ~= nil then
|
||||
inspectHud.Enable = false
|
||||
end
|
||||
self.DeckInspectKind = ""
|
||||
self.ClassDeckMode = false
|
||||
self.ClassDeckClass = ""
|
||||
self.DebugCardPickerMode = false
|
||||
self:RenderClassDeckTabs()
|
||||
self.DeckAllOpen = true
|
||||
self:RenderAllDeck()
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end`),
|
||||
method('CloseAllDeck', `self.DeckAllOpen = false
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end
|
||||
if self.ClassDeckMode == true then
|
||||
self.ClassDeckMode = false
|
||||
self.ClassDeckCards = {}
|
||||
self.ClassDeckTitle = ""
|
||||
self.ClassDeckClass = ""
|
||||
end
|
||||
self.DebugCardPickerMode = false
|
||||
self:RenderClassDeckTabs()
|
||||
if self.CodexMode == true then
|
||||
self.CodexMode = false
|
||||
self:ShowLobby()
|
||||
end`),
|
||||
method('RenderAllDeck', `local pile = self.RunDeck or {}
|
||||
local title = "모든 덱"
|
||||
if self.ClassDeckMode == true then
|
||||
pile = self.ClassDeckCards or {}
|
||||
title = self.ClassDeckTitle
|
||||
if self.DebugCardPickerMode == true then
|
||||
title = title .. " - 테스트 카드 추가"
|
||||
end
|
||||
elseif self.CodexMode == true then
|
||||
pile = self.CodexCards or {}
|
||||
title = "카드 도감"
|
||||
end
|
||||
local count = #pile
|
||||
self:SetText("/ui/DeckUIGroup/DeckAllHud/Title", title .. " (" .. tostring(count) .. ")")
|
||||
self:RenderClassDeckTabs()
|
||||
self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud/Empty", count <= 0)
|
||||
for i = 1, 120 do
|
||||
local path = "/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(i)
|
||||
local cardId = pile[i]
|
||||
if cardId == nil then
|
||||
self:SetEntityEnabled(path, false)
|
||||
else
|
||||
self:SetEntityEnabled(path, true)
|
||||
self:ApplyAllDeckCardVisual(i, cardId)
|
||||
end
|
||||
end`),
|
||||
method('ApplyAllDeckCardVisual', `self:ApplyCardFace("/ui/DeckUIGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
]),
|
||||
method('OnAllDeckCardButton', `if self.DebugCardPickerMode ~= true then
|
||||
return
|
||||
end
|
||||
if self.ClassDeckCards == nil then
|
||||
return
|
||||
end
|
||||
local cardId = self.ClassDeckCards[slot]
|
||||
if cardId == nil or self.Cards == nil or self.Cards[cardId] == nil then
|
||||
return
|
||||
end
|
||||
self:AddCardsToHand(cardId, 1)
|
||||
local c = self.Cards[cardId]
|
||||
local name = cardId
|
||||
if c.name ~= nil then name = c.name end
|
||||
self:Toast("테스트 카드 추가: " .. name)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
]),
|
||||
];
|
||||
741
tools/deck/cb/hand.mjs
Normal file
741
tools/deck/cb/hand.mjs
Normal file
@@ -0,0 +1,741 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const handMethods = [
|
||||
method('GetHandSlotX', `local n = 0
|
||||
if self.Hand ~= nil then
|
||||
n = #self.Hand
|
||||
end
|
||||
if n <= 0 then
|
||||
return 0
|
||||
end
|
||||
local spacing = 175
|
||||
if n > 8 then spacing = math.floor(1400 / n) end
|
||||
local startX = -((n - 1) * spacing) / 2
|
||||
return startX + (slot - 1) * spacing`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
|
||||
method('RenderHand', `local n = #self.Hand
|
||||
local spacing = 175
|
||||
if n > 8 then spacing = math.floor(1400 / n) end
|
||||
local startX = -((n - 1) * spacing) / 2
|
||||
local drawStart = Vector2(-590, 8)
|
||||
for i = 1, 10 do
|
||||
\tlocal cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(i))
|
||||
\tif cardEntity ~= nil then
|
||||
\t\tlocal cardId = self.Hand[i]
|
||||
\t\tif cardId == nil then
|
||||
\t\t\tcardEntity.Enable = false
|
||||
\t\telse
|
||||
\t\t\tcardEntity.Enable = true
|
||||
\t\t\tif cardEntity.UITransformComponent ~= nil then cardEntity.UITransformComponent.UIScale = Vector3(1, 1, 1) end
|
||||
\t\t\tself:ApplyCardVisual(i, cardId)
|
||||
\t\t\tlocal tx = self:GetHandSlotX(i)
|
||||
\t\t\tif animate == true then
|
||||
\t\t\t\tself:AnimateCardFrom(i, drawStart, Vector2(tx, 0), 0.16 + i * 0.03)
|
||||
\t\t\telse
|
||||
\t\t\t\tif cardEntity.UITransformComponent ~= nil then cardEntity.UITransformComponent.anchoredPosition = Vector2(tx, 0) end
|
||||
\t\t\tend
|
||||
\t\tend
|
||||
\tend
|
||||
end
|
||||
self:RenderPiles()`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }]),
|
||||
method('ApplyCardFace', `local c = self.Cards[cardId]
|
||||
if c == nil then
|
||||
c = { name = cardId, cost = 0, desc = "", kind = "Skill", class = "warrior", rarity = "normal" }
|
||||
end
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
end
|
||||
local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"]
|
||||
local ruid = nil
|
||||
if frames ~= nil then
|
||||
ruid = frames[c.rarity or "normal"]
|
||||
end
|
||||
if ruid ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ruid
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Cost", string.format("%d", c.cost))
|
||||
self:SetText(base .. "/Name", c.name)
|
||||
self:SetText(base .. "/Desc", self:FormatCardDescription(c.desc))
|
||||
local art = _EntityService:GetEntityByPath(base .. "/Art")
|
||||
if art ~= nil then
|
||||
if c.image ~= nil and c.image ~= "" then
|
||||
art.Enable = true
|
||||
if art.SpriteGUIRendererComponent ~= nil then
|
||||
art.SpriteGUIRendererComponent.ImageRUID = c.image
|
||||
end
|
||||
else
|
||||
art.Enable = false
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
]),
|
||||
method('SetCardHover', `local prefix = ""
|
||||
local count = 0
|
||||
local xs = {}
|
||||
local baseY = 0
|
||||
local hoverIndex = 0
|
||||
local push = 110
|
||||
if string.find(path, "/ui/RunUIGroup/CardHand/Card") == 1 then
|
||||
if self.DragSlot ~= nil and self.DragSlot > 0 then
|
||||
return
|
||||
end
|
||||
prefix = "/ui/RunUIGroup/CardHand/Card"
|
||||
count = 0
|
||||
if self.Hand ~= nil then count = #self.Hand end
|
||||
for i = 1, count do
|
||||
xs[i] = self:GetHandSlotX(i)
|
||||
end
|
||||
baseY = 0
|
||||
hoverIndex = tonumber(string.match(path, "Card(%d+)")) or 0
|
||||
elseif string.find(path, "/ui/RunUIGroup/RewardHud/Reward") == 1 then
|
||||
prefix = "/ui/RunUIGroup/RewardHud/Reward"
|
||||
count = 3
|
||||
xs = { -300, 0, 300 }
|
||||
baseY = 0
|
||||
hoverIndex = tonumber(string.match(path, "Reward(%d+)")) or 0
|
||||
elseif string.find(path, "/ui/RunUIGroup/ShopHud/Card") == 1 then
|
||||
prefix = "/ui/RunUIGroup/ShopHud/Card"
|
||||
count = 3
|
||||
xs = { -300, 0, 300 }
|
||||
baseY = 20
|
||||
hoverIndex = tonumber(string.match(path, "Card(%d+)")) or 0
|
||||
end
|
||||
if count <= 0 then
|
||||
return
|
||||
end
|
||||
if self.CardHoverTweenId ~= nil and self.CardHoverTweenId ~= 0 then
|
||||
_TimerService:ClearTimer(self.CardHoverTweenId)
|
||||
self.CardHoverTweenId = 0
|
||||
end
|
||||
local items = {}
|
||||
for i = 1, count do
|
||||
local e = _EntityService:GetEntityByPath(prefix .. tostring(i))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
local tr = e.UITransformComponent
|
||||
local tx = xs[i]
|
||||
local ty = baseY
|
||||
local sc = 1
|
||||
if hover == true and hoverIndex > 0 then
|
||||
if i == hoverIndex and e.Enable == true then
|
||||
sc = 1.5
|
||||
elseif i < hoverIndex then
|
||||
tx = tx - push
|
||||
elseif i > hoverIndex then
|
||||
tx = tx + push
|
||||
end
|
||||
end
|
||||
table.insert(items, { tr = tr, sx = tr.anchoredPosition.x, sy = tr.anchoredPosition.y, ss = tr.UIScale.x, tx = tx, ty = ty, ts = sc })
|
||||
end
|
||||
end
|
||||
local elapsed = 0
|
||||
local duration = 0.12
|
||||
local eventId = 0
|
||||
eventId = _TimerService:SetTimerRepeat(function()
|
||||
elapsed = elapsed + 1 / 60
|
||||
local t = math.min(elapsed / duration, 1)
|
||||
local eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t)
|
||||
for i = 1, #items do
|
||||
local it = items[i]
|
||||
local x = it.sx + (it.tx - it.sx) * eased
|
||||
local y = it.sy + (it.ty - it.sy) * eased
|
||||
local s = it.ss + (it.ts - it.ss) * eased
|
||||
it.tr.anchoredPosition = Vector2(x, y)
|
||||
it.tr.UIScale = Vector3(s, s, 1)
|
||||
end
|
||||
if t >= 1 then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
if self.CardHoverTweenId == eventId then
|
||||
self.CardHoverTweenId = 0
|
||||
end
|
||||
end
|
||||
end, 1 / 60)
|
||||
self.CardHoverTweenId = eventId`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hover' },
|
||||
]),
|
||||
method('ApplyCardVisual', `self:ApplyCardFace("/ui/RunUIGroup/CardHand/Card" .. tostring(slot), cardId)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
]),
|
||||
method('SetText', `local entity = _EntityService:GetEntityByPath(path)
|
||||
if entity ~= nil and entity.TextComponent ~= nil then
|
||||
\tentity.TextComponent.Text = value
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' },
|
||||
]),
|
||||
method('FormatNumber', `if value == nil then
|
||||
return ""
|
||||
end
|
||||
local n = tonumber(value)
|
||||
if n == nil then
|
||||
return tostring(value)
|
||||
end
|
||||
if math.abs(n - math.floor(n)) < 0.00001 then
|
||||
return string.format("%d", math.floor(n))
|
||||
end
|
||||
return tostring(n)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' }], 0, 'string'),
|
||||
method('AnimateCardFrom', `local cardEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
if cardEntity == nil or cardEntity.UITransformComponent == nil then
|
||||
\treturn
|
||||
end
|
||||
local tr = cardEntity.UITransformComponent
|
||||
tr.anchoredPosition = fromPos
|
||||
local elapsed = 0
|
||||
local eventId = 0
|
||||
eventId = _TimerService:SetTimerRepeat(function()
|
||||
\telapsed = elapsed + 1 / 60
|
||||
\tlocal t = math.min(elapsed / duration, 1)
|
||||
\tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t)
|
||||
\ttr.anchoredPosition = Vector2(fromPos.x + (toPos.x - fromPos.x) * eased, fromPos.y + (toPos.y - fromPos.y) * eased)
|
||||
\tif t >= 1 then
|
||||
\t\t_TimerService:ClearTimer(eventId)
|
||||
\tend
|
||||
end, 1 / 60)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromPos' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toPos' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'duration' },
|
||||
]),
|
||||
method('AnimateDiscardCards', `if cardIds == nil or slots == nil then
|
||||
\treturn
|
||||
end
|
||||
local target = Vector2(590, 8)
|
||||
local duration = 0.18
|
||||
for i = 1, #cardIds do
|
||||
\tlocal slot = slots[i] or i
|
||||
\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
\tif e ~= nil then
|
||||
\t\te.Enable = true
|
||||
\t\tself:ApplyCardFace("/ui/RunUIGroup/CardHand/Card" .. tostring(slot), cardIds[i])
|
||||
\t\tif e.UITransformComponent ~= nil then
|
||||
\t\t\tlocal sx = 0
|
||||
\t\t\tif startXs ~= nil and startXs[i] ~= nil then sx = startXs[i] else sx = self:GetHandSlotX(slot) end
|
||||
\t\t\te.UITransformComponent.anchoredPosition = Vector2(sx, 0)
|
||||
\t\t\te.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
\t\tend
|
||||
\tend
|
||||
end
|
||||
local elapsed = 0
|
||||
local eventId = 0
|
||||
eventId = _TimerService:SetTimerRepeat(function()
|
||||
\telapsed = elapsed + 1 / 60
|
||||
\tlocal t = math.min(elapsed / duration, 1)
|
||||
\tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseIn, t)
|
||||
\tfor i = 1, #cardIds do
|
||||
\t\tlocal slot = slots[i] or i
|
||||
\t\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
\t\tif e ~= nil and e.UITransformComponent ~= nil then
|
||||
\t\t\tlocal sx = 0
|
||||
\t\t\tif startXs ~= nil and startXs[i] ~= nil then sx = startXs[i] else sx = self:GetHandSlotX(slot) end
|
||||
\t\t\tlocal x = sx + (target.x - sx) * eased
|
||||
\t\t\tlocal y = 0 + (target.y - 0) * eased
|
||||
\t\t\tlocal s = 1 - 0.25 * eased
|
||||
\t\t\te.UITransformComponent.anchoredPosition = Vector2(x, y)
|
||||
\t\t\te.UITransformComponent.UIScale = Vector3(s, s, 1)
|
||||
\t\tend
|
||||
\tend
|
||||
\tif t >= 1 then
|
||||
\t\t_TimerService:ClearTimer(eventId)
|
||||
\t\tfor i = 1, #cardIds do
|
||||
\t\t\tlocal slot = slots[i] or i
|
||||
\t\t\tlocal e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
\t\t\tif e ~= nil then
|
||||
\t\t\t\tif self.Hand ~= nil and self.Hand[slot] ~= nil then
|
||||
\t\t\t\t\te.Enable = true
|
||||
\t\t\t\t\tself:ApplyCardVisual(slot, self.Hand[slot])
|
||||
\t\t\t\t\tif e.UITransformComponent ~= nil then
|
||||
\t\t\t\t\t\te.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(slot), 0)
|
||||
\t\t\t\t\t\te.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
\t\t\t\t\tend
|
||||
\t\t\t\telse
|
||||
\t\t\t\t\te.Enable = false
|
||||
\t\t\t\tend
|
||||
\t\t\tend
|
||||
\t\tend
|
||||
\tend
|
||||
end, 1 / 60)`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardIds' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'startXs' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slots' },
|
||||
]),
|
||||
method('AddCardBlock', `local amount = base or 0
|
||||
if amount > 0 and self.PlayerDex ~= nil then
|
||||
amount = amount + self.PlayerDex
|
||||
end
|
||||
if self.BlockGainMultiplier ~= nil and self.BlockGainMultiplier > 1 then
|
||||
amount = amount * self.BlockGainMultiplier
|
||||
end
|
||||
if amount < 0 then
|
||||
amount = 0
|
||||
end
|
||||
self.PlayerBlock = self.PlayerBlock + amount
|
||||
return amount`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
|
||||
method('CountOtherHandSkills', `if self.Hand == nil then
|
||||
return 0
|
||||
end
|
||||
local n = 0
|
||||
for i = 1, #self.Hand do
|
||||
if i ~= slot then
|
||||
local hc = self.Cards[self.Hand[i]]
|
||||
if hc ~= nil and hc.kind == "Skill" then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return n`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
|
||||
method('AttackBaseForCard', `local base2 = c.damage or 0
|
||||
local otherHand = 0
|
||||
if self.Hand ~= nil then
|
||||
otherHand = #self.Hand - 1
|
||||
if otherHand < 0 then otherHand = 0 end
|
||||
end
|
||||
if c.damagePerOtherHandCard ~= nil then
|
||||
base2 = base2 + otherHand * c.damagePerOtherHandCard
|
||||
end
|
||||
if c.damagePerAttackPlayedThisTurn ~= nil then
|
||||
base2 = base2 + (self.TurnAttackCardsPlayed or 0) * c.damagePerAttackPlayedThisTurn
|
||||
end
|
||||
if c.damagePerDiscardedThisTurn ~= nil then
|
||||
base2 = base2 + (self.TurnDiscardedCards or 0) * c.damagePerDiscardedThisTurn
|
||||
end
|
||||
if c.damagePerSkillInHand ~= nil then
|
||||
base2 = base2 + self:CountOtherHandSkills(slot) * c.damagePerSkillInHand
|
||||
end
|
||||
if c.damagePerCardDrawnThisCombat ~= nil then
|
||||
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
|
||||
end
|
||||
if base2 < 0 then
|
||||
base2 = 0
|
||||
end
|
||||
return base2`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
], 0, 'number'),
|
||||
method('CalcPlayerAttack', `local base2 = base
|
||||
self.FightAttackCount = self.FightAttackCount + 1
|
||||
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then
|
||||
base2 = base2 + 8
|
||||
end
|
||||
local dmg = base2 + self.PlayerStr
|
||||
if self:HasRelic("penNib") and self.FightAttackCount % 10 == 0 then
|
||||
dmg = dmg * 2
|
||||
end
|
||||
if self.PlayerWeak > 0 then
|
||||
dmg = math.floor(dmg * 0.75)
|
||||
end
|
||||
if self.TurnAttackMultiplier ~= nil and self.TurnAttackMultiplier > 1 then
|
||||
dmg = dmg * self.TurnAttackMultiplier
|
||||
end
|
||||
if dmg > 0 and dmg < 5 and self:HasRelic("boot") then
|
||||
dmg = 5
|
||||
end
|
||||
if dmg < 0 then
|
||||
dmg = 0
|
||||
end
|
||||
return dmg`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
|
||||
method('QueueNextTurnAddCard', `if cardId == nil or cardId == "" or amount == nil or amount <= 0 then
|
||||
return
|
||||
end
|
||||
if self.NextTurnAddCards == nil then
|
||||
self.NextTurnAddCards = {}
|
||||
end
|
||||
for i = 1, #self.NextTurnAddCards do
|
||||
local entry = self.NextTurnAddCards[i]
|
||||
if entry ~= nil and entry.cardId == cardId then
|
||||
entry.amount = (entry.amount or 0) + amount
|
||||
return
|
||||
end
|
||||
end
|
||||
table.insert(self.NextTurnAddCards, { cardId = cardId, amount = amount })`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('QueueNextTurnEffects', `if c == nil then
|
||||
return
|
||||
end
|
||||
if c.nextTurnBlock ~= nil then
|
||||
self.NextTurnBlock = (self.NextTurnBlock or 0) + c.nextTurnBlock
|
||||
end
|
||||
if c.nextTurnDraw ~= nil then
|
||||
self.NextTurnDraw = (self.NextTurnDraw or 0) + c.nextTurnDraw
|
||||
end
|
||||
if c.nextTurnKeepBlock == true then
|
||||
self.NextTurnKeepBlock = true
|
||||
end
|
||||
if c.nextTurnAttackMultiplier ~= nil and c.nextTurnAttackMultiplier > 0 then
|
||||
local cur = self.NextTurnAttackMultiplier or 1
|
||||
self.NextTurnAttackMultiplier = cur * c.nextTurnAttackMultiplier
|
||||
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }]),
|
||||
method('ResolveCardEffects', `if c == nil then
|
||||
return
|
||||
end
|
||||
if c.blockGainMultiplier ~= nil and c.blockGainMultiplier > 0 then
|
||||
self.BlockGainMultiplier = (self.BlockGainMultiplier or 1) * c.blockGainMultiplier
|
||||
end
|
||||
if c.nextSkillCostZero == true then
|
||||
self.NextSkillCostZero = true
|
||||
end
|
||||
if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then
|
||||
self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn
|
||||
end
|
||||
if c.handCostZeroThisTurn == true then
|
||||
self.HandCostZeroThisTurn = true
|
||||
end
|
||||
if c.drawDisabledThisTurn == true then
|
||||
self.DrawDisabledThisTurn = true
|
||||
end
|
||||
local xEnergy = energySpent or 0
|
||||
local weakAmount = c.weak or 0
|
||||
local vulnAmount = c.vuln or 0
|
||||
local poisonAmount = c.poison or 0
|
||||
if c.xWeakPerEnergy ~= nil and c.xWeakPerEnergy > 0 then
|
||||
weakAmount = weakAmount + xEnergy * c.xWeakPerEnergy
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
|
||||
self:PlayerAttackMotion()
|
||||
local baseDmg = self:AttackBaseForCard(slot, c)
|
||||
if c.xDamagePerEnergy ~= nil and c.xDamagePerEnergy > 0 then
|
||||
baseDmg = xEnergy * c.xDamagePerEnergy
|
||||
end
|
||||
local total = 0
|
||||
local hitN = c.hits or 1
|
||||
if c.otherHandAtLeast ~= nil and c.bonusHitsWhenOtherHandAtLeast ~= nil then
|
||||
local otherHand = 0
|
||||
if self.Hand ~= nil then
|
||||
otherHand = #self.Hand - 1
|
||||
if otherHand < 0 then otherHand = 0 end
|
||||
end
|
||||
if otherHand >= c.otherHandAtLeast then
|
||||
hitN = hitN + c.bonusHitsWhenOtherHandAtLeast
|
||||
end
|
||||
end
|
||||
for h = 1, hitN do
|
||||
total = total + self:CalcPlayerAttack(baseDmg)
|
||||
end
|
||||
if c.aoe == true then
|
||||
self:PlayAoeFx(c.fx or c.image, total)
|
||||
else
|
||||
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
|
||||
end
|
||||
end
|
||||
if c.block ~= nil then
|
||||
self:AddCardBlock(c.block)
|
||||
end
|
||||
if free ~= true then
|
||||
self:ApplyRelics("cardPlayed")
|
||||
end
|
||||
elseif c.kind == "Skill" then
|
||||
if c.block ~= nil then
|
||||
self:AddCardBlock(c.block)
|
||||
end
|
||||
elseif c.kind == "Power" then
|
||||
if free ~= true then
|
||||
table.insert(self.PlayerPowers, cardId)
|
||||
end
|
||||
end
|
||||
if c.strength ~= nil then
|
||||
self.PlayerStr = self.PlayerStr + c.strength
|
||||
end
|
||||
if c.dex ~= nil then
|
||||
self.PlayerDex = self.PlayerDex + c.dex
|
||||
end
|
||||
if c.thorns ~= nil then
|
||||
self.PlayerThorns = self.PlayerThorns + c.thorns
|
||||
end
|
||||
if c.selfVuln ~= nil then
|
||||
self.PlayerVuln = self.PlayerVuln + c.selfVuln
|
||||
end
|
||||
if c.heal ~= nil then
|
||||
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
|
||||
end
|
||||
if c.gainEnergy ~= nil and c.gainEnergy ~= 0 then
|
||||
self.Energy = self.Energy + c.gainEnergy
|
||||
end
|
||||
self:QueueNextTurnEffects(c)
|
||||
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil or c.xWeakPerEnergy ~= nil then
|
||||
local tm = self.Monsters[self.TargetIndex]
|
||||
if tm == nil or tm.alive ~= true then
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
|
||||
end
|
||||
end
|
||||
if tm ~= nil and tm.alive == true then
|
||||
if weakAmount ~= nil and weakAmount > 0 then tm.weak = tm.weak + weakAmount end
|
||||
if poisonAmount ~= nil and poisonAmount > 0 then tm.poison = (tm.poison or 0) + poisonAmount end
|
||||
if vulnAmount ~= nil and vulnAmount > 0 then
|
||||
tm.vuln = tm.vuln + vulnAmount
|
||||
if self:HasRelic("championBelt") then
|
||||
tm.weak = tm.weak + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local drawnCards = {}
|
||||
if c.draw ~= nil then
|
||||
drawnCards = self:DrawCards(c.draw, true) or {}
|
||||
end
|
||||
if c.drawUntilHandSize ~= nil and c.drawUntilHandSize > 0 then
|
||||
local currentHand = 0
|
||||
if self.Hand ~= nil then
|
||||
currentHand = #self.Hand
|
||||
if slot ~= nil and slot > 0 and self.Hand[slot] == cardId then
|
||||
currentHand = currentHand - 1
|
||||
end
|
||||
end
|
||||
local need = c.drawUntilHandSize - currentHand
|
||||
if need > 0 then
|
||||
local moreDrawnCards = self:DrawCards(need, true) or {}
|
||||
for i = 1, #moreDrawnCards do
|
||||
table.insert(drawnCards, moreDrawnCards[i])
|
||||
end
|
||||
end
|
||||
end
|
||||
if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
|
||||
for i = 1, #drawnCards do
|
||||
local drawnCard = self.Cards[drawnCards[i]]
|
||||
if drawnCard ~= nil and drawnCard.kind == "Skill" then
|
||||
self:AddCardBlock(c.drawSkillBlock)
|
||||
end
|
||||
end
|
||||
end
|
||||
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
||||
self:AddCardsToHand("Shiv", c.addShiv)
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'free' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
]),
|
||||
method('TriggerSly', `local c = self.Cards[cardId]
|
||||
if c == nil or c.sly ~= true then
|
||||
return
|
||||
end
|
||||
self:Toast("교활 발동: " .. c.name)
|
||||
self:ResolveCardEffects(cardId, 0, c, true, 0)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
|
||||
method('DiscardHandCard', `if self.Hand == nil then
|
||||
return
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
if cardId == nil then
|
||||
return
|
||||
end
|
||||
local startX = self:GetHandSlotX(slot)
|
||||
table.remove(self.Hand, slot)
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
self.TurnDiscardedCards = (self.TurnDiscardedCards or 0) + 1
|
||||
if triggerSly == true then
|
||||
self:TriggerSly(cardId)
|
||||
end
|
||||
if animate == true then
|
||||
self:AnimateDiscardCards({ cardId }, { startX }, { slot })
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'triggerSly' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
]),
|
||||
method('IsDiscardSelecting', `return self.DiscardSelectRemaining ~= nil and self.DiscardSelectRemaining > 0`, [], 0, 'boolean'),
|
||||
method('IsRetainSelecting', `return self.RetainSelectActive == true`, [], 0, 'boolean'),
|
||||
method('IsReserveSelecting', `return self.ReserveSelectActive == true`, [], 0, 'boolean'),
|
||||
method('UpdateDiscardPrompt', `local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/DiscardPrompt")
|
||||
if e == nil then
|
||||
return
|
||||
end
|
||||
if self:IsDiscardSelecting() == true then
|
||||
local picked = self.DiscardSelectTotal - self.DiscardSelectRemaining
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", "버릴 카드 선택 " .. self:FormatNumber(picked + 1) .. "/" .. self:FormatNumber(self.DiscardSelectTotal))
|
||||
e.Enable = true
|
||||
elseif self:IsRetainSelecting() == true then
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", "보존할 카드 선택 (턴 종료: 건너뛰기)")
|
||||
e.Enable = true
|
||||
elseif self:IsReserveSelecting() == true then
|
||||
local msg = self.NextTurnSelectPrompt or ""
|
||||
if msg == "" then
|
||||
msg = "다음 턴에 예약할 카드를 선택하세요"
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/DiscardPrompt", msg)
|
||||
e.Enable = true
|
||||
else
|
||||
e.Enable = false
|
||||
end`),
|
||||
method('BeginDiscardSelection', `if c == nil or self.Hand == nil then
|
||||
return false
|
||||
end
|
||||
if c.discardAll == true then
|
||||
return self:AutoDiscardHand(c)
|
||||
end
|
||||
local n = 0
|
||||
if c.discard ~= nil then
|
||||
n = math.min(c.discard, #self.Hand)
|
||||
end
|
||||
if n <= 0 then
|
||||
return false
|
||||
end
|
||||
self.DiscardSelectRemaining = n
|
||||
self.DiscardSelectTotal = n
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.DiscardPostDraw = 0
|
||||
self.DiscardDrawPerPick = 0
|
||||
if c.addShiv ~= nil then
|
||||
self.DiscardPostShiv = c.addShiv
|
||||
end
|
||||
if c.addShivPerDiscard == true then
|
||||
self.DiscardShivPerPick = 1
|
||||
end
|
||||
if c.drawPerDiscarded ~= nil and c.drawPerDiscarded > 0 then
|
||||
self.DiscardDrawPerPick = c.drawPerDiscarded
|
||||
end
|
||||
self:UpdateDiscardPrompt()
|
||||
self:Toast("버릴 카드를 선택하세요")
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('BeginReserveSelection', `if c == nil or c.nextTurnSelectHandCard ~= true or c.nextTurnCopies == nil or c.nextTurnCopies <= 0 then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or #self.Hand <= 0 then
|
||||
return false
|
||||
end
|
||||
self.ReserveSelectActive = true
|
||||
self.NextTurnSelectCopies = c.nextTurnCopies
|
||||
self.NextTurnSelectPrompt = c.nextTurnSelectPrompt or ""
|
||||
self:UpdateDiscardPrompt()
|
||||
self:Toast("예약할 카드를 선택하세요")
|
||||
self:RenderHand(false)
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('SelectReserveSlot', `if self:IsReserveSelecting() ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return true
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
local amount = self.NextTurnSelectCopies or 0
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self:UpdateDiscardPrompt()
|
||||
if amount > 0 and cardId ~= nil then
|
||||
self:QueueNextTurnAddCard(cardId, amount)
|
||||
local label = cardId
|
||||
if self.Cards[cardId] ~= nil and self.Cards[cardId].name ~= nil then
|
||||
label = self.Cards[cardId].name
|
||||
end
|
||||
self:Toast("다음 턴 예약: " .. label .. " " .. self:FormatNumber(amount) .. "장")
|
||||
end
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
|
||||
method('SelectRetainSlot', `if self:IsRetainSelecting() ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return true
|
||||
end
|
||||
self:FinishPlayerTurn(slot)
|
||||
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
|
||||
method('AutoDiscardHand', `if c == nil or self.Hand == nil or #self.Hand <= 0 then
|
||||
return false
|
||||
end
|
||||
local cardIds = {}
|
||||
local startXs = {}
|
||||
local slots = {}
|
||||
local n = #self.Hand
|
||||
for i = 1, n do
|
||||
local cardId = self.Hand[i]
|
||||
table.insert(cardIds, cardId)
|
||||
table.insert(startXs, self:GetHandSlotX(i))
|
||||
table.insert(slots, i)
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
self.TurnDiscardedCards = (self.TurnDiscardedCards or 0) + 1
|
||||
end
|
||||
self.Hand = {}
|
||||
local shivCount = 0
|
||||
if c.addShiv ~= nil then shivCount = shivCount + c.addShiv end
|
||||
if c.addShivPerDiscard == true then shivCount = shivCount + n end
|
||||
self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.DiscardPostDraw = 0
|
||||
self.DiscardDrawPerPick = 0
|
||||
self:UpdateDiscardPrompt()
|
||||
self:AnimateDiscardCards(cardIds, startXs, slots)
|
||||
for i = 1, #cardIds do
|
||||
self:TriggerSly(cardIds[i])
|
||||
end
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if shivCount > 0 then
|
||||
self:AddCardsToHand("Shiv", shivCount)
|
||||
else
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
end
|
||||
if c.drawPerDiscarded ~= nil and c.drawPerDiscarded > 0 then
|
||||
self:DrawCards(n * c.drawPerDiscarded, true)
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end, 0.22)
|
||||
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
|
||||
method('FinishDiscardSelection', `self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
local shivCount = self.DiscardPostShiv or 0
|
||||
local drawCount = self.DiscardPostDraw or 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardPostDraw = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.DiscardDrawPerPick = 0
|
||||
self:UpdateDiscardPrompt()
|
||||
local finish = function()
|
||||
if shivCount > 0 then
|
||||
self:AddCardsToHand("Shiv", shivCount)
|
||||
else
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
end
|
||||
if drawCount > 0 then
|
||||
self:DrawCards(drawCount, true)
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end
|
||||
if delayRender == true then
|
||||
_TimerService:SetTimerOnce(finish, 0.22)
|
||||
else
|
||||
finish()
|
||||
end`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'delayRender' }]),
|
||||
method('SelectDiscardSlot', `if self:IsDiscardSelecting() ~= true then
|
||||
return false
|
||||
end
|
||||
if self.Hand == nil or self.Hand[slot] == nil then
|
||||
return true
|
||||
end
|
||||
local discarded = self.Hand[slot]
|
||||
self:DiscardHandCard(slot, true, true)
|
||||
if discarded ~= nil and self.DiscardShivPerPick ~= nil and self.DiscardShivPerPick > 0 then
|
||||
self.DiscardPostShiv = (self.DiscardPostShiv or 0) + self.DiscardShivPerPick
|
||||
end
|
||||
if discarded ~= nil and self.DiscardDrawPerPick ~= nil and self.DiscardDrawPerPick > 0 then
|
||||
self.DiscardPostDraw = (self.DiscardPostDraw or 0) + self.DiscardDrawPerPick
|
||||
end
|
||||
self.DiscardSelectRemaining = self.DiscardSelectRemaining - 1
|
||||
if self.DiscardSelectRemaining <= 0 or #self.Hand <= 0 then
|
||||
self:FinishDiscardSelection(true)
|
||||
else
|
||||
self:UpdateDiscardPrompt()
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
_TimerService:SetTimerOnce(function() self:RenderHand(false) end, 0.22)
|
||||
end
|
||||
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
|
||||
];
|
||||
213
tools/deck/cb/items.mjs
Normal file
213
tools/deck/cb/items.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const itemMethods = [
|
||||
method('HasRelic', `if self.RunRelics == nil then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.RunRelics do
|
||||
if self.RunRelics[i] == id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
|
||||
method('ApplyRelics', `if self.RunRelics == nil then
|
||||
return
|
||||
end
|
||||
for i = 1, #self.RunRelics do
|
||||
local r = self.Relics[self.RunRelics[i]]
|
||||
if r ~= nil and r.hook == hook then
|
||||
if r.effect == "block" then
|
||||
self.PlayerBlock = self.PlayerBlock + r.value
|
||||
elseif r.effect == "energy" then
|
||||
self.Energy = self.Energy + r.value
|
||||
elseif r.effect == "strength" then
|
||||
self.PlayerStr = self.PlayerStr + r.value
|
||||
elseif r.effect == "draw" then
|
||||
self:DrawCards(r.value)
|
||||
self:RenderHand(false)
|
||||
elseif r.effect == "heal" or r.effect == "healOnAttack" or r.effect == "healOnWin" then
|
||||
self.PlayerHp = self.PlayerHp + r.value
|
||||
if self.PlayerHp > self.PlayerMaxHp then
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
end
|
||||
elseif r.effect == "healIfLow" then
|
||||
if self.PlayerHp * 2 <= self.PlayerMaxHp then
|
||||
self.PlayerHp = self.PlayerHp + r.value
|
||||
if self.PlayerHp > self.PlayerMaxHp then
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
end
|
||||
end
|
||||
elseif r.effect == "gold" then
|
||||
self.Gold = self.Gold + r.value
|
||||
end
|
||||
end
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hook' }]),
|
||||
method('AddRelic', `if self.RunRelics == nil then
|
||||
self.RunRelics = {}
|
||||
end
|
||||
table.insert(self.RunRelics, id)
|
||||
local r = self.Relics[id]
|
||||
if r ~= nil and r.hook == "passive" then
|
||||
if r.effect == "potionSlots" then
|
||||
self.PotionSlots = r.value
|
||||
self:RenderPotions()
|
||||
elseif r.effect == "maxHp" then
|
||||
self.PlayerMaxHp = self.PlayerMaxHp + r.value
|
||||
self.PlayerHp = self.PlayerHp + r.value
|
||||
end
|
||||
end
|
||||
self:RenderRelics()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('PickNewRelic', `local pool = {}
|
||||
for i = 1, #self.RelicPool do
|
||||
if self:HasRelic(self.RelicPool[i]) == false then
|
||||
table.insert(pool, self.RelicPool[i])
|
||||
end
|
||||
end
|
||||
if #pool == 0 then
|
||||
self.Gold = self.Gold + 25
|
||||
self:Toast("유물을 모두 모았습니다! 메소 +25")
|
||||
return ""
|
||||
end
|
||||
return pool[math.random(1, #pool)]`, [], 0, 'string'),
|
||||
method('AddPotion', `if self.RunPotions == nil then
|
||||
self.RunPotions = {}
|
||||
end
|
||||
if #self.RunPotions >= self.PotionSlots then
|
||||
self:Toast("물약 슬롯이 가득 찼습니다")
|
||||
return false
|
||||
end
|
||||
table.insert(self.RunPotions, pid)
|
||||
self:RenderPotions()
|
||||
return true`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pid' }], 0, 'boolean'),
|
||||
method('MaybeDropPotion', `if math.random() > ${POTIONS.dropChance} then
|
||||
return
|
||||
end
|
||||
local keys = {}
|
||||
for pid, _ in pairs(self.Potions) do
|
||||
table.insert(keys, pid)
|
||||
end
|
||||
table.sort(keys)
|
||||
local pid = keys[math.random(1, #keys)]
|
||||
if self:AddPotion(pid) == true then
|
||||
local p = self.Potions[pid]
|
||||
self:Toast("물약 획득: " .. p.name)
|
||||
end`),
|
||||
method('RenderPotions', `for i = 1, 5 do
|
||||
local base = "/ui/RunUIGroup/CombatHud/TopBar/PotionSlot" .. tostring(i)
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
local pid = nil
|
||||
if self.RunPotions ~= nil then
|
||||
pid = self.RunPotions[i]
|
||||
end
|
||||
if pid ~= nil and self.Potions[pid] ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = self.Potions[pid].icon
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
elseif i > self.PotionSlots then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ""
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.1, 0.1, 0.12, 0.85)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ""
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.25, 0.3, 0.9)
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('OpenPotionMenu', `if self.RunPotions == nil or self.RunPotions[slot] == nil then
|
||||
return
|
||||
end
|
||||
self.PotionMenuSlot = slot
|
||||
local pid = self.RunPotions[slot]
|
||||
local p = self.Potions[pid]
|
||||
if p ~= nil then
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PotionMenu/Title", p.name .. " — " .. p.desc)
|
||||
end
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PotionMenu", true)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('ClosePotionMenu', `self.PotionMenuSlot = 0
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PotionMenu", false)`),
|
||||
method('UsePotion', `if self.PotionMenuSlot <= 0 then
|
||||
return
|
||||
end
|
||||
if self.CombatOver == true or self.TurnBusy == true or self.FxBusy == true then
|
||||
self:Toast("지금은 사용할 수 없습니다")
|
||||
return
|
||||
end
|
||||
local combat = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud")
|
||||
local hand = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand")
|
||||
if combat == nil or combat.Enable ~= true or hand == nil or hand.Enable ~= true then
|
||||
self:Toast("전투 중에만 사용할 수 있습니다")
|
||||
return
|
||||
end
|
||||
local pid = self.RunPotions[self.PotionMenuSlot]
|
||||
if pid == nil then
|
||||
return
|
||||
end
|
||||
local p = self.Potions[pid]
|
||||
if p == nil then
|
||||
return
|
||||
end
|
||||
if p.effect == "heal" then
|
||||
self.PlayerHp = math.min(self.PlayerHp + p.value, self.PlayerMaxHp)
|
||||
elseif p.effect == "damage" then
|
||||
self:DealDamageToTarget(p.value, false)
|
||||
self:ShowDmgPop(self.TargetIndex, p.value)
|
||||
elseif p.effect == "strength" then
|
||||
self.PlayerStr = self.PlayerStr + p.value
|
||||
elseif p.effect == "block" then
|
||||
self.PlayerBlock = self.PlayerBlock + p.value
|
||||
elseif p.effect == "energy" then
|
||||
self.Energy = self.Energy + p.value
|
||||
elseif p.effect == "weak" then
|
||||
local tm = self.Monsters[self.TargetIndex]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
tm.weak = tm.weak + p.value
|
||||
end
|
||||
end
|
||||
table.remove(self.RunPotions, self.PotionMenuSlot)
|
||||
self:Toast("물약 사용: " .. p.name)
|
||||
self:ClosePotionMenu()
|
||||
self:RenderPotions()
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()`),
|
||||
method('TossPotion', `if self.PotionMenuSlot <= 0 then
|
||||
return
|
||||
end
|
||||
local pid = self.RunPotions[self.PotionMenuSlot]
|
||||
if pid ~= nil then
|
||||
local p = self.Potions[pid]
|
||||
table.remove(self.RunPotions, self.PotionMenuSlot)
|
||||
if p ~= nil then
|
||||
self:Toast("물약 버림: " .. p.name)
|
||||
end
|
||||
end
|
||||
self:ClosePotionMenu()
|
||||
self:RenderPotions()`),
|
||||
method('RenderRelics', `local count = 0
|
||||
if self.RunRelics ~= nil then
|
||||
count = #self.RunRelics
|
||||
end
|
||||
for i = 1, 10 do
|
||||
local base = "/ui/RunUIGroup/CombatHud/TopBar/RelicSlot" .. tostring(i)
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
local rid = nil
|
||||
if self.RunRelics ~= nil then
|
||||
rid = self.RunRelics[i]
|
||||
end
|
||||
if rid ~= nil and self.Relics[rid] ~= nil and (i < 10 or count <= 10) then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = self.Relics[rid].icon
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ""
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.15, 0.16, 0.2, 0.6)
|
||||
end
|
||||
end
|
||||
end
|
||||
local of = ""
|
||||
if count > 10 then
|
||||
of = "+" .. tostring(count - 9)
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/RelicOverflow", of)`),
|
||||
];
|
||||
79
tools/deck/cb/jobs.mjs
Normal file
79
tools/deck/cb/jobs.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const jobMethods = [
|
||||
method('ShowJobChoice', `self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", true)`),
|
||||
method('PickJobReward', `self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", false)
|
||||
if kind == "relic" then
|
||||
local bid = self:PickNewRelic()
|
||||
if bid ~= "" then
|
||||
self:AddRelic(bid)
|
||||
local br = self.Relics[bid]
|
||||
if br ~= nil then
|
||||
self:Toast("유물 획득: " .. br.name)
|
||||
end
|
||||
end
|
||||
self:ContinueAfterBoss()
|
||||
else
|
||||
self:ShowJobSelect()
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
|
||||
method('ShowJobSelect', `local opts = self.Jobs[self.SelectedClass]
|
||||
if opts == nil then
|
||||
opts = self.Jobs["warrior"]
|
||||
end
|
||||
self.JobOpts = opts
|
||||
for i = 1, 3 do
|
||||
local base = "/ui/SelectUIGroup/JobSelectHud/Job_slot" .. tostring(i)
|
||||
local o = opts[i]
|
||||
if o ~= nil then
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", o.name)
|
||||
self:SetText(base .. "/Desc", o.desc)
|
||||
local sc = self.Cards[o.starter]
|
||||
if sc ~= nil then
|
||||
self:SetText(base .. "/Starter", "대표 카드: " .. sc.name)
|
||||
end
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", true)`),
|
||||
method('JobLabel', `if self.PlayerJob ~= "" and self.Jobs ~= nil then
|
||||
for cls, list in pairs(self.Jobs) do
|
||||
for i = 1, #list do
|
||||
if list[i].id == self.PlayerJob then
|
||||
return list[i].name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.SelectedClass == "warrior" then
|
||||
return "전사"
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
return "도적"
|
||||
elseif self.SelectedClass == "magician" then
|
||||
return "마법사"
|
||||
end
|
||||
return "플레이어"`, [], 0, 'string'),
|
||||
method('SetJob', `self.PlayerJob = jobId
|
||||
local starter = ""
|
||||
local opts = self.Jobs[self.SelectedClass] or {}
|
||||
for i = 1, #opts do
|
||||
if opts[i].id == jobId then
|
||||
starter = opts[i].starter
|
||||
end
|
||||
end
|
||||
if starter ~= "" then
|
||||
table.insert(self.RunDeck, starter)
|
||||
local sc = self.Cards[starter]
|
||||
if sc ~= nil then
|
||||
self:Toast("2차 전직: " .. self:JobLabel() .. "! 신규 카드 — " .. sc.name)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", false)
|
||||
self:ContinueAfterBoss()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'jobId' }]),
|
||||
];
|
||||
230
tools/deck/cb/map.mjs
Normal file
230
tools/deck/cb/map.mjs
Normal file
@@ -0,0 +1,230 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const mapMethods = [
|
||||
method('ShowMap', `self:ShowState("map")
|
||||
self:RenderMap()`),
|
||||
method('GenerateMap', `-- 절차 생성 — tools/map/rogue-map.mjs(JS 미러)와 로직 동기화 유지
|
||||
self.MapNodes = {}
|
||||
self.MapStart = {}
|
||||
self.VisitedNodes = {}
|
||||
self.Depth = 0
|
||||
self.MapNodes["boss"] = { type = "boss", row = ${MAP_ROWS} + 1, col = 0, next = {} }
|
||||
local cols = { 1, 2, 3, 4 }
|
||||
for i = #cols, 2, -1 do
|
||||
local j = math.random(1, i)
|
||||
cols[i], cols[j] = cols[j], cols[i]
|
||||
end
|
||||
local starts = { cols[1], cols[2], math.random(1, ${MAP_COLS}), math.random(1, ${MAP_COLS}) }
|
||||
for p = 1, 4 do
|
||||
local c = starts[p]
|
||||
local sid = "r1c" .. tostring(c)
|
||||
if self.MapNodes[sid] == nil then
|
||||
self.MapNodes[sid] = { type = "combat", row = 1, col = c, next = {} }
|
||||
end
|
||||
local found = false
|
||||
for i = 1, #self.MapStart do
|
||||
if self.MapStart[i] == sid then found = true end
|
||||
end
|
||||
if found == false then
|
||||
table.insert(self.MapStart, sid)
|
||||
end
|
||||
for r = 1, ${MAP_ROWS} - 1 do
|
||||
local nc = c + math.random(-1, 1)
|
||||
if nc < 1 then nc = 1 end
|
||||
if nc > ${MAP_COLS} then nc = ${MAP_COLS} end
|
||||
local nid = "r" .. tostring(r + 1) .. "c" .. tostring(nc)
|
||||
if self.MapNodes[nid] == nil then
|
||||
self.MapNodes[nid] = { type = "combat", row = r + 1, col = nc, next = {} }
|
||||
end
|
||||
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
|
||||
local dup = false
|
||||
for i = 1, #self.MapNodes[fid].next do
|
||||
if self.MapNodes[fid].next[i] == nid then dup = true end
|
||||
end
|
||||
if dup == false then
|
||||
table.insert(self.MapNodes[fid].next, nid)
|
||||
end
|
||||
c = nc
|
||||
end
|
||||
local lid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
|
||||
local bdup = false
|
||||
for i = 1, #self.MapNodes[lid].next do
|
||||
if self.MapNodes[lid].next[i] == "boss" then bdup = true end
|
||||
end
|
||||
if bdup == false then
|
||||
table.insert(self.MapNodes[lid].next, "boss")
|
||||
end
|
||||
end
|
||||
for r = 3, ${MAP_ROWS} do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
local id = "r" .. tostring(r) .. "c" .. tostring(c)
|
||||
local node = self.MapNodes[id]
|
||||
if node ~= nil then
|
||||
-- 부모 노드 타입 수집 (rest/shop/elite 는 부모와 같은 타입 연속 금지)
|
||||
local parentTypes = {}
|
||||
for pid, pn in pairs(self.MapNodes) do
|
||||
if pn.row == r - 1 then
|
||||
for i = 1, #pn.next do
|
||||
if pn.next[i] == id then parentTypes[pn.type] = true end
|
||||
end
|
||||
end
|
||||
end
|
||||
local w
|
||||
if r == ${MAP_ROWS} then
|
||||
w = { { "rest", 50 }, { "combat", 25 }, { "shop", 10 }, { "elite", 8 }, { "treasure", 7 } }
|
||||
elseif r >= 4 then
|
||||
w = { { "combat", 45 }, { "elite", 16 }, { "shop", 12 }, { "rest", 12 }, { "treasure", 15 } }
|
||||
else
|
||||
w = { { "combat", 45 }, { "shop", 12 }, { "rest", 12 } }
|
||||
end
|
||||
local total = 0
|
||||
for i = 1, #w do
|
||||
local t = w[i][1]
|
||||
if (t == "elite" or t == "rest" or t == "shop") and parentTypes[t] == true then
|
||||
w[i][2] = 0
|
||||
end
|
||||
total = total + w[i][2]
|
||||
end
|
||||
local roll = math.random() * total
|
||||
local acc = 0
|
||||
for i = 1, #w do
|
||||
acc = acc + w[i][2]
|
||||
if roll <= acc then
|
||||
node.type = w[i][1]
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('IsReachable', `local list
|
||||
if self.CurrentNodeId == "" then
|
||||
list = self.MapStart
|
||||
else
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node == nil then
|
||||
return false
|
||||
end
|
||||
list = node.next
|
||||
end
|
||||
for i = 1, #list do
|
||||
if list[i] == id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
|
||||
method('RenderMapNode', `local base = "/ui/RunUIGroup/MapHud/Node_" .. id
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e == nil then
|
||||
return
|
||||
end
|
||||
local node = self.MapNodes[id]
|
||||
if node == nil then
|
||||
e.Enable = false
|
||||
return
|
||||
end
|
||||
e.Enable = true
|
||||
local ruid = self.NodeIcons[node.type]
|
||||
if ruid == nil then
|
||||
ruid = self.NodeIcons["combat"]
|
||||
end
|
||||
if e.SpriteGUIRendererComponent ~= nil and ruid ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ruid
|
||||
end
|
||||
local reachable = self:IsReachable(id)
|
||||
local visited = false
|
||||
if self.VisitedNodes ~= nil then
|
||||
for i = 1, #self.VisitedNodes do
|
||||
if self.VisitedNodes[i] == id then visited = true end
|
||||
end
|
||||
end
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if id == self.CurrentNodeId then
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
elseif visited == true then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.9)
|
||||
elseif reachable == true then
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.68, 0.68, 0.72, 0.85)
|
||||
end
|
||||
end
|
||||
if (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
|
||||
e.ButtonComponent.Enable = reachable
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('RenderMapDots', `local node = self.MapNodes[fromId]
|
||||
local has = false
|
||||
if node ~= nil then
|
||||
for i = 1, #node.next do
|
||||
if node.next[i] == toId then has = true end
|
||||
end
|
||||
end
|
||||
for k = 1, 3 do
|
||||
local d = _EntityService:GetEntityByPath("/ui/RunUIGroup/MapHud/Dot_" .. dotId .. "_" .. tostring(k))
|
||||
if d ~= nil then
|
||||
d.Enable = has
|
||||
if has == true and d.SpriteGUIRendererComponent ~= nil then
|
||||
if fromId == self.CurrentNodeId then
|
||||
d.SpriteGUIRendererComponent.Color = Color(0.95, 0.8, 0.3, 1)
|
||||
else
|
||||
d.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.8)
|
||||
end
|
||||
end
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'dotId' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromId' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toId' },
|
||||
]),
|
||||
method('RenderMap', `for r = 1, ${MAP_ROWS} do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
self:RenderMapNode("r" .. tostring(r) .. "c" .. tostring(c))
|
||||
end
|
||||
end
|
||||
self:RenderMapNode("boss")
|
||||
for r = 1, ${MAP_ROWS} - 1 do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
|
||||
for c2 = c - 1, c + 1 do
|
||||
if c2 >= 1 and c2 <= ${MAP_COLS} then
|
||||
self:RenderMapDots(fid .. "_" .. tostring(c2), fid, "r" .. tostring(r + 1) .. "c" .. tostring(c2))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for c = 1, ${MAP_COLS} do
|
||||
local fid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
|
||||
self:RenderMapDots(fid .. "_b", fid, "boss")
|
||||
end
|
||||
`),
|
||||
method('PickNode', `if self.RunActive ~= true then
|
||||
return
|
||||
end
|
||||
if self:IsReachable(id) ~= true then
|
||||
return
|
||||
end
|
||||
self.CurrentNodeId = id
|
||||
if self.VisitedNodes == nil then
|
||||
self.VisitedNodes = {}
|
||||
end
|
||||
table.insert(self.VisitedNodes, id)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/MapHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end
|
||||
local node = self.MapNodes[id]
|
||||
self.Depth = node.row
|
||||
self:RenderRun()
|
||||
if node.type == "shop" then
|
||||
self:ShowShop()
|
||||
elseif node.type == "rest" then
|
||||
self:ShowRest()
|
||||
elseif node.type == "treasure" then
|
||||
self:ShowTreasure()
|
||||
else
|
||||
self.CurrentEnemyId = ""
|
||||
self:StartCombat()
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
];
|
||||
307
tools/deck/cb/render.mjs
Normal file
307
tools/deck/cb/render.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const renderMethods = [
|
||||
method('BuffsLabel', `local parts = {}
|
||||
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
|
||||
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
|
||||
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
|
||||
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||
return table.concat(parts, " ")`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||
], 0, 'string'),
|
||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", m.name)
|
||||
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
||||
local intent = m.intents[m.intentIdx]
|
||||
local t = ""
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
local atk = intent.value + m.str
|
||||
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
||||
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
||||
t = "공격 " .. tostring(atk)
|
||||
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
||||
elseif intent.kind == "Debuff" then
|
||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||
elseif intent.kind == "AddCard" then
|
||||
t = "저주 카드 추가"
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Intent", t)
|
||||
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||
local shownTarget = self.TargetIndex
|
||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
||||
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
||||
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
||||
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||
elseif intent.kind == "Debuff" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||
elseif intent.kind == "AddCard" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||
else
|
||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||
end
|
||||
end
|
||||
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
||||
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
|
||||
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
||||
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
||||
end
|
||||
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
||||
end
|
||||
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
||||
local names = {}
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil then table.insert(names, pc.name) end
|
||||
end
|
||||
if pb ~= "" then pb = pb .. " · " end
|
||||
pb = pb .. table.concat(names, " ")
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||
self:RenderRun()`),
|
||||
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
||||
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
|
||||
local pop = _EntityService:GetEntityByPath(base)
|
||||
if pop == nil then
|
||||
return
|
||||
end
|
||||
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
|
||||
local popSeq = self.DmgPopSeq
|
||||
self:SetText(base, "")
|
||||
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
||||
local shown = tostring(math.max(0, math.floor(amount)))
|
||||
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
||||
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
||||
end
|
||||
local digits = {}
|
||||
for i = 1, string.len(shown) do
|
||||
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
||||
end
|
||||
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
||||
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
||||
end
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
local digitPath = base .. "/Digit" .. tostring(i)
|
||||
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
||||
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||
if digits[i] ~= nil then
|
||||
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
if digitEntity.UITransformComponent ~= nil then
|
||||
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
||||
end
|
||||
self:SetEntityEnabled(digitPath, true)
|
||||
else
|
||||
self:SetEntityEnabled(digitPath, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
local popPos = nil
|
||||
local m = self.Monsters[slot]
|
||||
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
||||
popPos = _UILogic:ScreenToUIPosition(screen)
|
||||
else
|
||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||
local sp = slotEntity.UITransformComponent.anchoredPosition
|
||||
popPos = Vector2(sp.x, sp.y + 76)
|
||||
end
|
||||
end
|
||||
if pop ~= nil and pop.UITransformComponent ~= nil then
|
||||
if popPos ~= nil then
|
||||
pop.UITransformComponent.anchoredPosition = popPos
|
||||
else
|
||||
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
for i = 1, 6 do
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
local p = _EntityService:GetEntityByPath(base)
|
||||
if p ~= nil and p.UITransformComponent ~= nil then
|
||||
local cur = p.UITransformComponent.anchoredPosition
|
||||
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
||||
end
|
||||
end, 0.045 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
self:SetEntityEnabled(base, false)
|
||||
end, 0.48)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
||||
if amount > 0 then
|
||||
self:SetText(base, "-" .. string.format("%d", amount))
|
||||
else
|
||||
self:SetText(base, "막음")
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent == nil then
|
||||
return
|
||||
end
|
||||
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
||||
end
|
||||
end, 0.5)`),
|
||||
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
||||
end
|
||||
local tr = lp.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
||||
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
end, 0.15)`),
|
||||
method('MonsterLunge', `local m = self.Monsters[idx]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local e = m.entity
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
m.motionBusy = false
|
||||
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
||||
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local e = m.entity
|
||||
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
||||
end
|
||||
end, 0.5)
|
||||
else
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
local seq = { 0.12, -0.12, 0 }
|
||||
for i = 1, #seq do
|
||||
local dx = seq[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
||||
end
|
||||
if i == #seq then
|
||||
m.motionBusy = false
|
||||
end
|
||||
end, 0.06 * i)
|
||||
end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
||||
if e == nil or e.UITransformComponent == nil then
|
||||
return
|
||||
end
|
||||
local ratio = 0
|
||||
if maxHp > 0 then ratio = hp / maxHp end
|
||||
if ratio < 0 then ratio = 0 end
|
||||
local w = width * ratio
|
||||
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
||||
]),
|
||||
method('PositionMonsterSlot', `local m = self.Monsters[slot]
|
||||
if m == nil or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local tr = m.entity.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local wp = tr.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y}))
|
||||
local uipos = _UILogic:ScreenToUIPosition(screen)
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(slot))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.anchoredPosition = uipos
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
|
||||
self.TargetIndex = slot
|
||||
self:RenderCombat()
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
|
||||
if self.AscensionLevel > 0 then
|
||||
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
];
|
||||
55
tools/deck/cb/reward.mjs
Normal file
55
tools/deck/cb/reward.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const rewardMethods = [
|
||||
method('CardPool', `local pool = {}
|
||||
for id, c in pairs(self.Cards) do
|
||||
if c.token ~= true and (c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob)) then
|
||||
table.insert(pool, id)
|
||||
end
|
||||
end
|
||||
table.sort(pool)
|
||||
return pool`, [], 0, 'any'),
|
||||
method('OfferReward', `self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
|
||||
local pool = self:CardPool()
|
||||
local byRarity = {}
|
||||
for _, id in ipairs(pool) do
|
||||
local r = self.Cards[id].rarity or "normal"
|
||||
if byRarity[r] == nil then byRarity[r] = {} end
|
||||
table.insert(byRarity[r], id)
|
||||
end
|
||||
self.RewardChoices = {}
|
||||
for i = 1, 3 do
|
||||
local roll = math.random(1, 100)
|
||||
local want = "normal"
|
||||
if roll > 95 then want = "legend" elseif roll > 70 then want = "unique" end
|
||||
local bucket = byRarity[want]
|
||||
if bucket == nil or #bucket == 0 then bucket = pool end
|
||||
self.RewardChoices[i] = bucket[math.random(1, #bucket)]
|
||||
self:ApplyRewardVisual(i, self.RewardChoices[i])
|
||||
end
|
||||
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end`),
|
||||
method('ApplyRewardVisual', `self:ApplyCardFace("/ui/RunUIGroup/RewardHud/Reward" .. tostring(slot), cardId)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
]),
|
||||
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
|
||||
return
|
||||
end
|
||||
if slot ~= 0 and self.RewardChoices ~= nil then
|
||||
local id = self.RewardChoices[slot]
|
||||
if id ~= nil then
|
||||
table.insert(self.RunDeck, id)
|
||||
end
|
||||
end
|
||||
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end
|
||||
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
];
|
||||
239
tools/deck/cb/run.mjs
Normal file
239
tools/deck/cb/run.mjs
Normal file
@@ -0,0 +1,239 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const runMethods = [
|
||||
method('StartRun', `if self.SelectedClass == "magician" then
|
||||
self.PlayerMaxHp = ${CLASSES.magician.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.magician.map(luaStr).join(', ')} }
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
self.PlayerMaxHp = ${CLASSES.bandit.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.bandit.map(luaStr).join(', ')} }
|
||||
else
|
||||
self.PlayerMaxHp = ${CLASSES.warrior.maxHp}
|
||||
self.RunDeck = { ${CARDS.starterDecks.warrior.map(luaStr).join(', ')} }
|
||||
end
|
||||
self.PlayerMaxHp = self.PlayerMaxHp - self:AscStartHpPenalty()
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
self.Gold = 0
|
||||
self.Floor = 1
|
||||
self.RunLength = ${ACT_COUNT}
|
||||
self.RunActive = true
|
||||
self.RunRelics = {}
|
||||
self.RunPotions = {}
|
||||
self.PotionSlots = ${POTIONS.baseSlots}
|
||||
${luaPotionsTable(POTIONS.potions)}
|
||||
${luaRelicsTable(RELICS.relics)}
|
||||
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
|
||||
${luaEnemiesTable(ENEMIES.enemies)}
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self.PlayerJob = ""
|
||||
${luaJobsTable(JOBS)}
|
||||
${luaFramesTable()}
|
||||
${luaNodeIconsTable()}
|
||||
${luaCharsTable()}
|
||||
self:GenerateMap()
|
||||
self:BindButtons()
|
||||
self:AddRelic("${RELICS.startingRelic}")
|
||||
self:ApplySoulUnlocks()
|
||||
self:RenderPotions()
|
||||
self:TeleportToActMap()
|
||||
self:ShowMap()`),
|
||||
method('KickCombatCamera', `local cam = nil
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then cam = lp.CameraComponent end
|
||||
if cam == nil then cam = _CameraService:GetCurrentCameraComponent() end
|
||||
if cam ~= nil then cam.ConfineCameraArea = false end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
local cc = nil
|
||||
local lp2 = _UserService.LocalPlayer
|
||||
if lp2 ~= nil then cc = lp2.CameraComponent end
|
||||
if cc == nil then cc = _CameraService:GetCurrentCameraComponent() end
|
||||
if cc ~= nil then
|
||||
cc.ZoomRatio = ${CAM.zoomRatio}
|
||||
cc.CameraOffset = Vector2(${CAM.cameraOffsetX}, ${CAM.cameraOffsetY})
|
||||
cc.ScreenOffset = Vector2(${CAM.screenOffsetX}, ${CAM.screenOffsetY})
|
||||
cc.ConfineCameraArea = true
|
||||
end
|
||||
end, 0.2)`),
|
||||
method('StartCombat', `self:ShowState("combat")
|
||||
self:KickCombatCamera()
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/Result", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PotionMenu", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/TooltipBox", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/DiscardPrompt", false)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
self.MaxEnergy = 3
|
||||
self.Turn = 0
|
||||
self.PlayerBlock = 0
|
||||
self.BlockGainMultiplier = 1
|
||||
self.CardsDrawnThisCombat = 0
|
||||
self.HandCostZeroThisTurn = false
|
||||
self.DrawDisabledThisTurn = false
|
||||
self.NextSkillCostZero = false
|
||||
self.SkillCostReductionThisTurn = 0
|
||||
self.PlayerStr = 0
|
||||
self.PlayerDex = 0
|
||||
self.PlayerThorns = 0
|
||||
self.PlayerWeak = 0
|
||||
self.PlayerVuln = 0
|
||||
self.PlayerPowers = {}
|
||||
self.FightAttackCount = 0
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.DmgPopSeq = 0
|
||||
self.FirstHpLossDone = false
|
||||
self.ClayBlockNext = 0
|
||||
self.DiscardSelectRemaining = 0
|
||||
self.DiscardSelectTotal = 0
|
||||
self.DiscardPostShiv = 0
|
||||
self.DiscardShivPerPick = 0
|
||||
self.RetainSelectActive = false
|
||||
self.ReserveSelectActive = false
|
||||
self.NextTurnBlock = 0
|
||||
self.NextTurnDraw = 0
|
||||
self.NextTurnKeepBlock = false
|
||||
self.NextTurnAttackMultiplier = 1
|
||||
self.TurnAttackMultiplier = 1
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnAddCards = {}
|
||||
self.CombatOver = false
|
||||
self.DiscardPile = {}
|
||||
self.ExhaustPile = {}
|
||||
self.Hand = {}
|
||||
${luaCardsTable(CARDS.cards)}
|
||||
self.DrawPile = {}
|
||||
for i = 1, #self.RunDeck do
|
||||
self.DrawPile[i] = self.RunDeck[i]
|
||||
end
|
||||
self:Shuffle(self.DrawPile)
|
||||
self:PrepareCombatDrawPile()
|
||||
self:BuildMonsters()
|
||||
self:RenderCombat()
|
||||
self:StartPlayerTurn()
|
||||
self:ApplyRelics("combatStart")
|
||||
self:RenderCombat()
|
||||
local slotTid = 0
|
||||
slotTid = _TimerService:SetTimerRepeat(function()
|
||||
if self.CombatOver == true or self.Monsters == nil or #self.Monsters == 0 then
|
||||
_TimerService:ClearTimer(slotTid)
|
||||
return
|
||||
end
|
||||
for i = 1, #self.Monsters do
|
||||
if self.Monsters[i] ~= nil and self.Monsters[i].alive == true then
|
||||
self:PositionMonsterSlot(i)
|
||||
end
|
||||
end
|
||||
end, 0.15)`),
|
||||
method('RegisterMonster', `if self.Registered == nil then
|
||||
self.Registered = {}
|
||||
end
|
||||
local g = group
|
||||
if g == nil or g == "" then g = "combat" end
|
||||
local mp = mapName
|
||||
if mp == nil then mp = "" end
|
||||
table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g, map = mp })`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'group' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'mapName' },
|
||||
]),
|
||||
method('BuildMonsters', `self.Monsters = {}
|
||||
local g = "combat"
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node ~= nil and node.type ~= nil then g = node.type end
|
||||
local pmap = ""
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end
|
||||
local reg = self.Registered or {}
|
||||
for i = 1, #reg do
|
||||
if reg[i].entity ~= nil and isvalid(reg[i].entity) then
|
||||
reg[i].entity:SetVisible(false)
|
||||
end
|
||||
end
|
||||
local byGroup = {}
|
||||
for i = 1, #reg do
|
||||
local r = reg[i]
|
||||
if r.entity ~= nil and isvalid(r.entity) and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
|
||||
local gg = r.group
|
||||
if gg == nil or gg == "" then gg = "combat" end
|
||||
if byGroup[gg] == nil then byGroup[gg] = {} end
|
||||
local x = 0
|
||||
if r.entity.TransformComponent ~= nil then
|
||||
x = r.entity.TransformComponent.WorldPosition.x
|
||||
end
|
||||
table.insert(byGroup[gg], { entity = r.entity, enemyId = r.enemyId, x = x })
|
||||
end
|
||||
end
|
||||
-- 노드 타입별 랜덤 구성: 일반 1~3 / 엘리트 1+일반0~2 / 보스 1
|
||||
local chosen = {}
|
||||
local function takeFrom(key, k)
|
||||
local src = byGroup[key] or {}
|
||||
local pool = {}
|
||||
for i = 1, #src do pool[i] = src[i] end
|
||||
self:Shuffle(pool)
|
||||
local taken = 0
|
||||
for i = 1, #pool do
|
||||
if taken >= k then break end
|
||||
table.insert(chosen, pool[i])
|
||||
taken = taken + 1
|
||||
end
|
||||
end
|
||||
if g == "boss" then
|
||||
takeFrom("boss", 1)
|
||||
elseif g == "elite" then
|
||||
takeFrom("elite", 1)
|
||||
takeFrom("combat", math.random(0, 2))
|
||||
else
|
||||
takeFrom("combat", math.random(1, 3))
|
||||
end
|
||||
if #chosen == 0 then takeFrom(g, 1) end
|
||||
if #chosen == 0 then takeFrom("combat", 1) end
|
||||
table.sort(chosen, function(a, b) return a.x < b.x end)
|
||||
local mult = 1 + (self.Floor - 1) * 0.45
|
||||
if g == "elite" or g == "boss" then
|
||||
mult = mult + self:AscEliteBonus()
|
||||
end
|
||||
local n = #chosen
|
||||
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
|
||||
for i = 1, n do
|
||||
local item = chosen[i]
|
||||
local e = self.Enemies[item.enemyId]
|
||||
if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end
|
||||
local intents = {}
|
||||
for k = 1, #e.intents do
|
||||
local v = e.intents[k].value or 0
|
||||
if e.intents[k].kind == "Attack" then
|
||||
v = math.floor(v * mult * self:AscAtkMult())
|
||||
elseif e.intents[k].kind ~= "Debuff" then
|
||||
v = math.floor(v * mult)
|
||||
end
|
||||
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect, card = e.intents[k].card, count = e.intents[k].count }
|
||||
end
|
||||
local maxHp = math.floor(e.maxHp * mult * self:AscHpMult())
|
||||
local hitClip = nil
|
||||
local standClip = nil
|
||||
if item.entity.StateAnimationComponent ~= nil then
|
||||
pcall(function()
|
||||
hitClip = item.entity.StateAnimationComponent.ActionSheet["hit"]
|
||||
standClip = item.entity.StateAnimationComponent.ActionSheet["stand"]
|
||||
end)
|
||||
end
|
||||
local startIdx = 1
|
||||
if #intents > 0 then startIdx = math.random(1, #intents) end
|
||||
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
|
||||
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0,
|
||||
hitClip = hitClip, standClip = standClip, motionBusy = false,
|
||||
intents = intents, intentIdx = startIdx, alive = true, slot = i }
|
||||
self:ReviveMonsterEntity(item.entity)
|
||||
self:PositionMonsterSlot(i)
|
||||
end
|
||||
self.TargetIndex = 1`),
|
||||
method('ReviveMonsterEntity', `if monster == nil or not isvalid(monster) then
|
||||
return
|
||||
end
|
||||
monster:SetEnable(true)
|
||||
monster:SetVisible(true)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }]),
|
||||
];
|
||||
37
tools/deck/cb/runend.mjs
Normal file
37
tools/deck/cb/runend.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const runEndMethods = [
|
||||
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} }
|
||||
local target = maps[self.Floor]
|
||||
if target == nil then
|
||||
return
|
||||
end
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.CurrentMapName == target then
|
||||
return
|
||||
end
|
||||
_TeleportService:TeleportToMapPosition(lp, Vector3(-6, 0.03, 0), target)`),
|
||||
method('ShowResult', `self:SetText("/ui/RunUIGroup/CombatHud/Result", text)
|
||||
local entity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/Result")
|
||||
if entity ~= nil then
|
||||
entity.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
method('EndRun', `local msg = text
|
||||
if text == "런 클리어!" and self.AscensionLevel >= self.AscensionUnlocked and self.AscensionUnlocked < 10 then
|
||||
self.AscensionUnlocked = self.AscensionUnlocked + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:SaveAscension(self.AscensionUnlocked, lp.PlayerComponent.UserId)
|
||||
end
|
||||
self:RenderAscension()
|
||||
msg = "런 클리어! 승천 " .. string.format("%d", self.AscensionUnlocked) .. " 해금!"
|
||||
end
|
||||
self:ShowResult(msg)
|
||||
self.RunActive = false
|
||||
_TimerService:SetTimerOnce(function() self:ShowLobby() end, 4)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
];
|
||||
173
tools/deck/cb/shop.mjs
Normal file
173
tools/deck/cb/shop.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const shopMethods = [
|
||||
method('ShowShop', `local pool = self:CardPool()
|
||||
self.ShopChoices = {}
|
||||
self.ShopBought = { false, false, false }
|
||||
for i = 1, 3 do
|
||||
self.ShopChoices[i] = pool[math.random(1, #pool)]
|
||||
end
|
||||
self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]
|
||||
self.ShopRelicBought = false
|
||||
local pkeys = {}
|
||||
for pid, _ in pairs(self.Potions) do
|
||||
table.insert(pkeys, pid)
|
||||
end
|
||||
table.sort(pkeys)
|
||||
self.ShopPotion = pkeys[math.random(1, #pkeys)]
|
||||
self.ShopPotionBought = false
|
||||
self:RenderShop()
|
||||
self:ShowState("shop")`),
|
||||
method('RenderShop', `self:SetText("/ui/RunUIGroup/ShopHud/Gold", "메소 " .. string.format("%d", self.Gold))
|
||||
for i = 1, 3 do
|
||||
local cid = self.ShopChoices[i]
|
||||
local c = self.Cards[cid]
|
||||
local base = "/ui/RunUIGroup/ShopHud/Card" .. tostring(i)
|
||||
if c ~= nil then
|
||||
self:ApplyCardFace(base, cid)
|
||||
self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 메소")
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopBought[i] == true then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local rr = self.Relics[self.ShopRelic]
|
||||
if rr ~= nil then
|
||||
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc)
|
||||
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 메소")
|
||||
local re = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Relic")
|
||||
if re ~= nil and re.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopRelicBought == true then
|
||||
re.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
|
||||
else
|
||||
re.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
local pp = self.Potions[self.ShopPotion]
|
||||
if pp ~= nil then
|
||||
self:SetText("/ui/RunUIGroup/ShopHud/Potion/Label", pp.name .. " — " .. pp.desc)
|
||||
self:SetText("/ui/RunUIGroup/ShopHud/Potion/Price", string.format("%d", ${POTIONS.shopPrice}) .. " 메소")
|
||||
local pe = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Potion")
|
||||
if pe ~= nil and pe.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopPotionBought == true then
|
||||
pe.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
|
||||
else
|
||||
pe.SpriteGUIRendererComponent.Color = Color(0.45, 0.7, 0.55, 1)
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('BuyRelic', `if self.ShopRelicBought == true then
|
||||
return
|
||||
end
|
||||
if self.Gold < ${RELIC_PRICE} then
|
||||
return
|
||||
end
|
||||
self.Gold = self.Gold - ${RELIC_PRICE}
|
||||
self:AddRelic(self.ShopRelic)
|
||||
self.ShopRelicBought = true
|
||||
self:RenderShop()
|
||||
self:RenderRun()`),
|
||||
method('BuyPotion', `if self.ShopPotionBought == true then
|
||||
return
|
||||
end
|
||||
if self.Gold < ${POTIONS.shopPrice} then
|
||||
return
|
||||
end
|
||||
if self.RunPotions ~= nil and #self.RunPotions >= self.PotionSlots then
|
||||
self:Toast("물약 슬롯이 가득 찼습니다")
|
||||
return
|
||||
end
|
||||
if self:AddPotion(self.ShopPotion) == true then
|
||||
self.Gold = self.Gold - ${POTIONS.shopPrice}
|
||||
self.ShopPotionBought = true
|
||||
end
|
||||
self:RenderShop()
|
||||
self:RenderRun()`),
|
||||
method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then
|
||||
return
|
||||
end
|
||||
if self.Gold < ${CARD_PRICE} then
|
||||
return
|
||||
end
|
||||
self.Gold = self.Gold - ${CARD_PRICE}
|
||||
table.insert(self.RunDeck, self.ShopChoices[slot])
|
||||
self.ShopBought[slot] = true
|
||||
self:RenderShop()
|
||||
self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('ShowRest', `local old = self.PlayerHp
|
||||
self.PlayerHp = self.PlayerHp + ${REST_HEAL}
|
||||
if self.PlayerHp > self.PlayerMaxHp then
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
end
|
||||
local healed = self.PlayerHp - old
|
||||
self:SetText("/ui/RunUIGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")")
|
||||
self:RenderCombat()
|
||||
self:ShowState("rest")`),
|
||||
method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud")
|
||||
if s ~= nil then
|
||||
s.Enable = false
|
||||
end
|
||||
local r = _EntityService:GetEntityByPath("/ui/RunUIGroup/RestHud")
|
||||
if r ~= nil then
|
||||
r.Enable = false
|
||||
end
|
||||
local t = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud")
|
||||
if t ~= nil then
|
||||
t.Enable = false
|
||||
end
|
||||
self:ShowMap()`),
|
||||
method('ShowTreasure', `self.ChestOpened = false
|
||||
local chest = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Chest")
|
||||
if chest ~= nil then
|
||||
if chest.SpriteGUIRendererComponent ~= nil then
|
||||
chest.SpriteGUIRendererComponent.ImageRUID = "${CHEST_CLOSED_RUID}"
|
||||
end
|
||||
if chest.UITransformComponent ~= nil then
|
||||
chest.UITransformComponent.anchoredPosition = Vector2(0, 40)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Reward", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Hint", true)
|
||||
self:ShowState("treasure")`),
|
||||
method('OpenChest', `if self.ChestOpened == true then
|
||||
return
|
||||
end
|
||||
self.ChestOpened = true
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Hint", false)
|
||||
local chest = _EntityService:GetEntityByPath("/ui/RunUIGroup/TreasureHud/Chest")
|
||||
local steps = { 10, -10, 8, -8, 5, 0 }
|
||||
for i = 1, #steps do
|
||||
local dx = steps[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if chest ~= nil and isvalid(chest) and chest.UITransformComponent ~= nil then
|
||||
chest.UITransformComponent.anchoredPosition = Vector2(dx, 40)
|
||||
end
|
||||
end, 0.08 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if chest ~= nil and isvalid(chest) and chest.SpriteGUIRendererComponent ~= nil then
|
||||
chest.SpriteGUIRendererComponent.ImageRUID = "${CHEST_OPEN_RUID}"
|
||||
end
|
||||
local g = 40 + math.random(0, 20)
|
||||
local nid = self:PickNewRelic()
|
||||
local msg = ""
|
||||
if nid ~= "" then
|
||||
self:AddRelic(nid)
|
||||
local nr = self.Relics[nid]
|
||||
msg = "유물 획득: " .. nr.name .. " · 메소 +" .. tostring(g)
|
||||
else
|
||||
g = g + 30
|
||||
msg = "메소 +" .. tostring(g)
|
||||
end
|
||||
self.Gold = self.Gold + g
|
||||
self:RenderRun()
|
||||
self:SetText("/ui/RunUIGroup/TreasureHud/Reward", msg)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud/Reward", true)
|
||||
end, 0.55)`),
|
||||
];
|
||||
114
tools/deck/cb/soul.mjs
Normal file
114
tools/deck/cb/soul.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const soulMethods = [
|
||||
method('ShowSoulShop', `self:RenderSoulLabel()
|
||||
self:RenderSoulShop()
|
||||
self:BindSoulShopButtons()
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", true)`),
|
||||
method('CloseSoulShop', `self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
|
||||
method('ReqLoadSouls', `local ds = _DataStorageService:GetUserDataStorage(userId)
|
||||
local e1, pts = ds:GetAndWait("soulPoints")
|
||||
local e2, unl = ds:GetAndWait("soulUnlocks")
|
||||
local p = 0
|
||||
if e1 == 0 and pts ~= nil and pts ~= "" then p = tonumber(pts) or 0 end
|
||||
local u = ""
|
||||
if e2 == 0 and unl ~= nil then u = unl end
|
||||
self:RecvSouls(p, u, userId)`, [{ Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "userId" }], 5),
|
||||
method('RecvSouls', `self.SoulPoints = p
|
||||
self.SoulUnlocks = {}
|
||||
if u ~= nil and u ~= "" then
|
||||
for key in string.gmatch(u, "([^,]+)") do
|
||||
self.SoulUnlocks[key] = true
|
||||
end
|
||||
end
|
||||
self:RenderSoulLabel()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "p" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "u" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "userId" }], 6),
|
||||
method('SaveSouls', `local ds = _DataStorageService:GetUserDataStorage(userId)
|
||||
ds:SetAndWait("soulPoints", tostring(p))
|
||||
ds:SetAndWait("soulUnlocks", u)`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "p" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "u" }, { Type: "string", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "userId" }], 5),
|
||||
method('SerializeUnlocks', `local parts = {}
|
||||
if self.SoulUnlocks ~= nil then
|
||||
for k, v in pairs(self.SoulUnlocks) do
|
||||
if v == true then table.insert(parts, k) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, ",")`, [], 0, 'string'),
|
||||
method('AwardSouls', `self.SoulPoints = (self.SoulPoints or 0) + n
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:SaveSouls(self.SoulPoints, self:SerializeUnlocks(), lp.PlayerComponent.UserId)
|
||||
end
|
||||
self:RenderSoulLabel()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "n" }]),
|
||||
method('BuySoulUnlock', `local d = nil
|
||||
if self.SoulShopDef ~= nil then d = self.SoulShopDef[slot] end
|
||||
if d == nil then return end
|
||||
if self.SoulUnlocks ~= nil and self.SoulUnlocks[d.key] == true then
|
||||
self:Toast("이미 보유 중입니다")
|
||||
return
|
||||
end
|
||||
if (self.SoulPoints or 0) < d.cost then
|
||||
self:Toast("영혼이 부족합니다")
|
||||
return
|
||||
end
|
||||
self.SoulPoints = self.SoulPoints - d.cost
|
||||
if self.SoulUnlocks == nil then self.SoulUnlocks = {} end
|
||||
self.SoulUnlocks[d.key] = true
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:SaveSouls(self.SoulPoints, self:SerializeUnlocks(), lp.PlayerComponent.UserId)
|
||||
end
|
||||
self:Toast(d.name .. " 해금!")
|
||||
self:RenderSoulLabel()
|
||||
self:RenderSoulShop()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "slot" }]),
|
||||
method('RenderSoulShop', `local defs = self.SoulShopDef or {}
|
||||
for i = 1, 4 do
|
||||
local base = "/ui/LobbyUIGroup/SoulShopHud/Item" .. tostring(i)
|
||||
local d = defs[i]
|
||||
if d == nil then
|
||||
self:SetEntityEnabled(base, false)
|
||||
else
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", d.name)
|
||||
self:SetText(base .. "/Desc", d.desc)
|
||||
local owned = self.SoulUnlocks ~= nil and self.SoulUnlocks[d.key] == true
|
||||
if owned then
|
||||
self:SetText(base .. "/Status", "보유 중")
|
||||
elseif (self.SoulPoints or 0) >= d.cost then
|
||||
self:SetText(base .. "/Status", tostring(d.cost) .. " 영혼 · 구매")
|
||||
else
|
||||
self:SetText(base .. "/Status", tostring(d.cost) .. " 영혼 · 부족")
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('BindSoulShopButtons', `if self.SoulShopBound == true then
|
||||
return
|
||||
end
|
||||
self.SoulShopBound = true
|
||||
for i = 1, 4 do
|
||||
local idx = i
|
||||
local e = _EntityService:GetEntityByPath("/ui/LobbyUIGroup/SoulShopHud/Item" .. tostring(i))
|
||||
if e ~= nil and (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
|
||||
e:ConnectEvent(ButtonClickEvent, function() self:BuySoulUnlock(idx) end)
|
||||
end
|
||||
end`),
|
||||
method('ApplySoulUnlocks', `if self.SoulUnlocks == nil then return end
|
||||
if self.SoulUnlocks["meso"] == true then self.Gold = self.Gold + 60 end
|
||||
if self.SoulUnlocks["hp"] == true then
|
||||
self.PlayerMaxHp = self.PlayerMaxHp + 15
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
end
|
||||
if self.SoulUnlocks["trim"] == true then
|
||||
for i = 1, #self.RunDeck do
|
||||
local cid = self.RunDeck[i]
|
||||
if cid == "Defend" or cid == "MagicGuard" or cid == "DarkSight" then
|
||||
table.remove(self.RunDeck, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.SoulUnlocks["relic"] == true then
|
||||
local nid = self:PickNewRelic()
|
||||
if nid ~= "" then self:AddRelic(nid) end
|
||||
end`),
|
||||
];
|
||||
201
tools/deck/cb/state.mjs
Normal file
201
tools/deck/cb/state.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const stateMethods = [
|
||||
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/Button_Jump", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/UIJoystick", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/RewardHud", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/MapHud", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/RestHud", false)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud", false)
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobChoiceHud", false)
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", false)
|
||||
self:SetEntityEnabled("/ui/DeckUIGroup/DeckInspectHud", false)
|
||||
self:SetEntityEnabled("/ui/DeckUIGroup/DeckAllHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)`),
|
||||
method('ActivateUIGroups', `local function grp(n)
|
||||
local g = _EntityService:GetEntityByPath("/ui/" .. n)
|
||||
if g ~= nil then g:SetEnable(true) end
|
||||
end
|
||||
grp("SelectUIGroup")
|
||||
grp("LobbyUIGroup")
|
||||
grp("RunUIGroup")
|
||||
grp("DeckUIGroup")`, [], 2),
|
||||
method('ShowState', `self:HideGameHud()
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu")
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/CharacterSelectHud", state == "charselect")
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", state == "lobby")
|
||||
if state == "map" then
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/MapHud", true)
|
||||
elseif state == "combat" then
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud", true)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/DeckHud", true)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CardHand", true)
|
||||
elseif state == "shop" then
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud", true)
|
||||
elseif state == "rest" then
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/RestHud", true)
|
||||
elseif state == "treasure" then
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/TreasureHud", true)
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'state' }]),
|
||||
method('ShowMainMenu', `self.SelectedClass = ""
|
||||
self:RenderAscension()
|
||||
self:ShowState("menu")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/Title", "메이플 덱 어드벤처")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/Subtitle", "캐릭터를 고르고 덱을 만들어 모험을 시작하세요")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/NewGameButton", "새 게임")
|
||||
self:BindMenuButtons()`),
|
||||
method('BindMenuButtons', `local buttonEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/NewGameButton")
|
||||
if buttonEntity ~= nil and (buttonEntity.ButtonComponent ~= nil or buttonEntity:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.NewGameHandler ~= nil then
|
||||
buttonEntity:DisconnectEvent(ButtonClickEvent, self.NewGameHandler)
|
||||
self.NewGameHandler = nil
|
||||
end
|
||||
self.NewGameHandler = buttonEntity:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end)
|
||||
end
|
||||
local warrior = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/WarriorButton")
|
||||
if warrior ~= nil and (warrior.ButtonComponent ~= nil or warrior:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.WarriorSelectHandler ~= nil then
|
||||
warrior:DisconnectEvent(ButtonClickEvent, self.WarriorSelectHandler)
|
||||
self.WarriorSelectHandler = nil
|
||||
end
|
||||
self.WarriorSelectHandler = warrior:ConnectEvent(ButtonClickEvent, function() self:SelectClass("warrior") end)
|
||||
end
|
||||
local thief = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/BanditButton")
|
||||
if thief ~= nil and (thief.ButtonComponent ~= nil or thief:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.ThiefSelectHandler ~= nil then
|
||||
thief:DisconnectEvent(ButtonClickEvent, self.ThiefSelectHandler)
|
||||
self.ThiefSelectHandler = nil
|
||||
end
|
||||
self.ThiefSelectHandler = thief:ConnectEvent(ButtonClickEvent, function() self:SelectClass("bandit") end)
|
||||
end
|
||||
local mage = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/MageButton")
|
||||
if mage ~= nil and (mage.ButtonComponent ~= nil or mage:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.MageSelectHandler ~= nil then
|
||||
mage:DisconnectEvent(ButtonClickEvent, self.MageSelectHandler)
|
||||
self.MageSelectHandler = nil
|
||||
end
|
||||
self.MageSelectHandler = mage:ConnectEvent(ButtonClickEvent, function() self:SelectClass("magician") end)
|
||||
end
|
||||
local allDeckClose = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
|
||||
if allDeckClose ~= nil and (allDeckClose.ButtonComponent ~= nil or allDeckClose:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.AllDeckCloseHandler ~= nil then
|
||||
allDeckClose:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
|
||||
self.AllDeckCloseHandler = nil
|
||||
end
|
||||
self.AllDeckCloseHandler = allDeckClose:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
|
||||
end
|
||||
self:BindClassDeckTabs()
|
||||
local start = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/StartButton")
|
||||
if start ~= nil and (start.ButtonComponent ~= nil or start:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.StartGameHandler ~= nil then
|
||||
start:DisconnectEvent(ButtonClickEvent, self.StartGameHandler)
|
||||
self.StartGameHandler = nil
|
||||
end
|
||||
self.StartGameHandler = start:ConnectEvent(ButtonClickEvent, function() self:StartNewGame() end)
|
||||
end
|
||||
local charBack = _EntityService:GetEntityByPath("/ui/SelectUIGroup/CharacterSelectHud/BackButton")
|
||||
if charBack ~= nil and (charBack.ButtonComponent ~= nil or charBack:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.CharBackHandler ~= nil then
|
||||
charBack:DisconnectEvent(ButtonClickEvent, self.CharBackHandler)
|
||||
self.CharBackHandler = nil
|
||||
end
|
||||
self.CharBackHandler = charBack:ConnectEvent(ButtonClickEvent, function() self:ShowLobby() end)
|
||||
end
|
||||
local ascMinus = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/AscMinus")
|
||||
if ascMinus ~= nil and (ascMinus.ButtonComponent ~= nil or ascMinus:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.AscMinusHandler ~= nil then
|
||||
ascMinus:DisconnectEvent(ButtonClickEvent, self.AscMinusHandler)
|
||||
self.AscMinusHandler = nil
|
||||
end
|
||||
self.AscMinusHandler = ascMinus:ConnectEvent(ButtonClickEvent, function() self:AdjustAscension(-1) end)
|
||||
end
|
||||
local ascPlus = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/AscPlus")
|
||||
if ascPlus ~= nil and (ascPlus.ButtonComponent ~= nil or ascPlus:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.AscPlusHandler ~= nil then
|
||||
ascPlus:DisconnectEvent(ButtonClickEvent, self.AscPlusHandler)
|
||||
self.AscPlusHandler = nil
|
||||
end
|
||||
self.AscPlusHandler = ascPlus:ConnectEvent(ButtonClickEvent, function() self:AdjustAscension(1) end)
|
||||
end`),
|
||||
method('ShowLobby', `self.SelectedClass = ""
|
||||
self:RenderAscension()
|
||||
self:RenderSoulLabel()
|
||||
self:ShowState("lobby")
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/SoulShopHud", false)
|
||||
self:BindLobbyButtons()
|
||||
self:BindMenuButtons()
|
||||
self:GoLobbyMap()`),
|
||||
method('GoLobbyMap', `self.LobbyTpTries = 0
|
||||
local eventId = 0
|
||||
local function go()
|
||||
self.LobbyTpTries = self.LobbyTpTries + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
if lp.CurrentMapName ~= "${LOBBY_MAP}" then
|
||||
_TeleportService:TeleportToMapPosition(lp, ${LOBBY_SPAWN}, "${LOBBY_MAP}")
|
||||
end
|
||||
_TimerService:ClearTimer(eventId)
|
||||
elseif self.LobbyTpTries > 50 then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(go, 0.1)`),
|
||||
method('OnLobbyNpcInteract', `if self.RunActive == true then
|
||||
return
|
||||
end
|
||||
if id == "run" then
|
||||
self:ShowCharacterSelect()
|
||||
elseif id == "codex" then
|
||||
self:ShowCodex()
|
||||
elseif id == "shop" then
|
||||
self:ShowSoulShop()
|
||||
elseif id == "board" then
|
||||
self:ShowBoard()
|
||||
end`, [{ Type: 'string', DefaultValue: '""', SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('RenderSoulLabel', `local s = self.SoulPoints or 0
|
||||
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "영혼 " .. string.format("%d", s))
|
||||
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "영혼 " .. string.format("%d", s))`),
|
||||
method('BindLobbyButtons', `if self.LobbyBound == true then
|
||||
return
|
||||
end
|
||||
self.LobbyBound = true
|
||||
local function bindClick(path, fn)
|
||||
local e = _EntityService:GetEntityByPath(path)
|
||||
if e ~= nil and (e.ButtonComponent ~= nil or e:AddComponent("ButtonComponent") ~= nil) then
|
||||
e:ConnectEvent(ButtonClickEvent, fn)
|
||||
end
|
||||
end
|
||||
bindClick("/ui/LobbyUIGroup/LobbyHud/AscMinus", function() self:AdjustAscension(-1) end)
|
||||
bindClick("/ui/LobbyUIGroup/LobbyHud/AscPlus", function() self:AdjustAscension(1) end)
|
||||
bindClick("/ui/LobbyUIGroup/BoardHud/Close", function() self:CloseBoard() end)
|
||||
bindClick("/ui/LobbyUIGroup/SoulShopHud/Close", function() self:CloseSoulShop() end)`),
|
||||
method('ShowCodex', `self.CodexMode = true
|
||||
self.ClassDeckMode = true
|
||||
local close = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
|
||||
if close ~= nil and (close.ButtonComponent ~= nil or close:AddComponent("ButtonComponent") ~= nil) then
|
||||
if self.AllDeckCloseHandler ~= nil then
|
||||
close:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
|
||||
end
|
||||
self.AllDeckCloseHandler = close:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
|
||||
end
|
||||
self:BindClassDeckTabs()
|
||||
self:SetEntityEnabled("/ui/LobbyUIGroup/LobbyHud", false)
|
||||
self:SetClassDeckTab("warrior")
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = true
|
||||
end
|
||||
self:RenderAllDeck()`),
|
||||
method('ShowBoard', `self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", true)`),
|
||||
method('CloseBoard', `self:SetEntityEnabled("/ui/LobbyUIGroup/BoardHud", false)`),
|
||||
];
|
||||
156
tools/deck/cb/tooltip.mjs
Normal file
156
tools/deck/cb/tooltip.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const tooltipMethods = [
|
||||
method('FormatCardDescription', `if desc == nil or desc == "" then
|
||||
return ""
|
||||
end
|
||||
local function replacePlain(text, needle, replacement)
|
||||
local out = ""
|
||||
local pos = 1
|
||||
while true do
|
||||
local s, e = string.find(text, needle, pos, true)
|
||||
if s == nil then
|
||||
out = out .. string.sub(text, pos)
|
||||
break
|
||||
end
|
||||
out = out .. string.sub(text, pos, s - 1) .. replacement
|
||||
pos = e + 1
|
||||
end
|
||||
return out
|
||||
end
|
||||
local terms = {
|
||||
"교활",
|
||||
"보존",
|
||||
"민첩",
|
||||
"가시",
|
||||
"소멸",
|
||||
"선천성",
|
||||
"취약",
|
||||
"약화",
|
||||
"독",
|
||||
"광역",
|
||||
"관통",
|
||||
"방어도",
|
||||
"힘",
|
||||
"스킬",
|
||||
"공격",
|
||||
"파워",
|
||||
}
|
||||
local out = desc
|
||||
for i = 1, #terms do
|
||||
local term = terms[i]
|
||||
out = replacePlain(out, term, "<color=#70D6FF>" .. term .. "</color>")
|
||||
end
|
||||
return out`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' }], 0, 'string'),
|
||||
method('BuildCardKeywordTooltip', `if c == nil then
|
||||
return ""
|
||||
end
|
||||
local lines = {}
|
||||
local function add(name, desc)
|
||||
for i = 1, #lines do
|
||||
if string.find(lines[i], name .. ":", 1, true) == 1 then
|
||||
return
|
||||
end
|
||||
end
|
||||
table.insert(lines, name .. ": " .. desc)
|
||||
end
|
||||
local cardDesc = c.desc or ""
|
||||
if c.sly == true or string.find(cardDesc, "교활", 1, true) ~= nil then
|
||||
add("교활", "버려지면 비용 없이 사용됩니다.")
|
||||
end
|
||||
if c.retain == true or string.find(cardDesc, "보존", 1, true) ~= nil then
|
||||
add("보존", "턴 종료 시 버려지지 않고 손에 남습니다.")
|
||||
end
|
||||
if c.dex ~= nil and c.dex > 0 or string.find(cardDesc, "민첩", 1, true) ~= nil then
|
||||
add("민첩", "카드로 얻는 방어도가 증가합니다.")
|
||||
end
|
||||
if c.thorns ~= nil and c.thorns > 0 or string.find(cardDesc, "가시", 1, true) ~= nil then
|
||||
add("가시", "피해를 받으면 공격자에게 반사 피해를 줍니다.")
|
||||
end
|
||||
if c.exhaust == true or string.find(cardDesc, "소멸.", 1, true) ~= nil then
|
||||
add("소멸", "사용 후 소멸 덱으로 이동해 이번 전투 동안 다시 나오지 않습니다.")
|
||||
end
|
||||
if string.find(cardDesc, "선천성", 1, true) ~= nil then
|
||||
add("선천성", "전투 시작 시 손패에 들어옵니다.")
|
||||
end
|
||||
if c.vuln ~= nil and c.vuln > 0 then
|
||||
add("취약", "받는 공격 피해가 50% 증가합니다.")
|
||||
end
|
||||
if c.weak ~= nil and c.weak > 0 then
|
||||
add("약화", "주는 공격 피해가 25% 감소합니다.")
|
||||
end
|
||||
if c.poison ~= nil and c.poison > 0 then
|
||||
add("중독", "턴 시작 시 체력을 잃고 수치가 1 감소합니다.")
|
||||
end
|
||||
if c.pierce == true then
|
||||
add("관통", "방어도를 무시하고 피해를 줍니다.")
|
||||
end
|
||||
if c.aoe == true then
|
||||
add("전체", "모든 적에게 적용됩니다.")
|
||||
end
|
||||
if c.kind == "Power" then
|
||||
add("파워", "사용하면 전투 동안 지속 효과로 남습니다.")
|
||||
end
|
||||
if c.unplayable == true then
|
||||
add("저주", "사용할 수 없고 손패를 방해합니다.")
|
||||
end
|
||||
local out = ""
|
||||
for i = 1, #lines do
|
||||
if i > 1 then out = out .. "\\n" end
|
||||
out = out .. lines[i]
|
||||
end
|
||||
return out`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'string'),
|
||||
method('HoverCard', `if self.DragSlot ~= nil and self.DragSlot > 0 then
|
||||
return
|
||||
end
|
||||
local cardId = self.Hand[slot]
|
||||
if cardId == nil then
|
||||
return
|
||||
end
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
local tx = 0
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
tx = e.UITransformComponent.anchoredPosition.x
|
||||
e.UITransformComponent.UIScale = Vector3(1.3, 1.3, 1)
|
||||
end
|
||||
local c = self.Cards[cardId]
|
||||
if c ~= nil then
|
||||
local tip = self:BuildCardKeywordTooltip(c)
|
||||
if tip ~= "" then
|
||||
local tipX = tx + 270
|
||||
if tx > 180 then tipX = tx - 270 end
|
||||
if tipX > 760 then tipX = tx - 270 end
|
||||
if tipX < -760 then tipX = tx + 270 end
|
||||
self:ShowTooltipAt("키워드", tip, tipX, 90)
|
||||
else
|
||||
self:HideTooltip()
|
||||
end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('UnhoverCard', `local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CardHand/Card" .. tostring(slot))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
|
||||
end
|
||||
self:HideTooltip()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('ShowTooltip', `self:ShowTooltipAt(name, desc, x, 400)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'name' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'x' },
|
||||
]),
|
||||
method('ShowTooltipAt', `self:SetText("/ui/RunUIGroup/CombatHud/TooltipBox/Name", name)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TooltipBox/Desc", desc)
|
||||
local e = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/TooltipBox")
|
||||
if e ~= nil then
|
||||
if e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.anchoredPosition = Vector2(x, y)
|
||||
end
|
||||
e.Enable = true
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'name' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'x' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'y' },
|
||||
]),
|
||||
method('HideTooltip', `self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/TooltipBox", false)`),
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
58
tools/deck/legacy/hud/board.mjs
Normal file
58
tools/deck/legacy/hud/board.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildBoard() {
|
||||
const board = [];
|
||||
let brdId = 0;
|
||||
const boardRoot = entity({
|
||||
id: guid('brd', brdId++),
|
||||
path: '/ui/DefaultGroup/BoardHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 14,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.95 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
boardRoot.jsonString.enable = false;
|
||||
board.push(boardRoot);
|
||||
board.push(entity({
|
||||
id: guid('brd', brdId++),
|
||||
path: '/ui/DefaultGroup/BoardHud/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 400 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '게시판', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
board.push(entity({
|
||||
id: guid('brd', brdId++),
|
||||
path: '/ui/DefaultGroup/BoardHud/Body',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1100, y: 520 }, pos: { x: 0, y: 20 } }),
|
||||
sprite({ color: { r: 0.1, g: 0.12, b: 0.16, a: 0.9 }, type: 1 }),
|
||||
text({ value: '· 카드는 직업/등급에 따라 보상 확률이 다릅니다.\n· 몬스터는 매 턴 정해진 행동 중 하나를 무작위로 합니다.\n· 일부 몬스터는 덱에 저주 카드(상처/화상)를 넣습니다.\n· 손패는 최대 10장, 초과분은 자동으로 버려집니다.\n· 2차 전직 후 보스를 잡으면 영혼이 쌓입니다.\n· 영혼은 상인 NPC에서 덱빌딩 해금에 사용합니다.', fontSize: 24, bold: false, color: { r: 0.86, g: 0.9, b: 0.94, a: 1 }, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
board.push(entity({
|
||||
id: guid('brd', brdId++),
|
||||
path: '/ui/DefaultGroup/BoardHud/Close',
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -380 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.2, g: 0.24, b: 0.3, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '닫기', fontSize: 28, bold: true, color: WHITE, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
return board;
|
||||
}
|
||||
494
tools/deck/legacy/hud/combat.mjs
Normal file
494
tools/deck/legacy/hud/combat.mjs
Normal file
@@ -0,0 +1,494 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildCombat() {
|
||||
const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 };
|
||||
const combat = [];
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 0),
|
||||
path: '/ui/DefaultGroup/CombatHud',
|
||||
modelId: 'uiempty',
|
||||
entryId: 'UIEmpty',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
],
|
||||
}));
|
||||
const SLOT_W = 140, SLOT_H = 96;
|
||||
for (let i = 1; i <= MAX_MONSTERS; i++) {
|
||||
const base = `/ui/DefaultGroup/CombatHud/MonsterSlot${i}`;
|
||||
const slot = entity({
|
||||
id: guid('cmb', 40 + i),
|
||||
path: base,
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 20 + i,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: SLOT_H }, pos: { x: (i - 2.5) * 320, y: 300 } }),
|
||||
sprite({ color: { r: 0, g: 0, b: 0, a: 0.0001 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
});
|
||||
slot.jsonString.enable = false;
|
||||
combat.push(slot);
|
||||
const targetFrame = entity({
|
||||
id: guid('cmb', 220 + i), path: `${base}/TargetFrame`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 16, y: SLOT_H + 12 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: { r: 0.95, g: 0.78, b: 0.25, a: 0.28 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
targetFrame.jsonString.enable = false;
|
||||
combat.push(targetFrame);
|
||||
const targetMarker = entity({
|
||||
id: guid('cmb', 360 + i), path: `${base}/TargetMarker`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 116, y: 116 }, pos: { x: 0, y: 2 } }),
|
||||
sprite({ color: { r: 0.95, g: 0.08, b: 0.05, a: 0.92 }, type: 1 }),
|
||||
text({ value: '+', fontSize: 72, bold: true, color: { r: 1, g: 0.94, b: 0.28, a: 1 }, alignment: 4, outlineWidth: 4 }),
|
||||
],
|
||||
});
|
||||
targetMarker.jsonString.enable = false;
|
||||
combat.push(targetMarker);
|
||||
const targetLabel = entity({
|
||||
id: guid('cmb', 370 + i), path: `${base}/TargetMarker/Label`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 10,
|
||||
components: [
|
||||
transform({ parentW: 116, parentH: 116, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 112, y: 28 }, pos: { x: 0, y: -52 } }),
|
||||
sprite({ color: { r: 0.08, g: 0.02, b: 0.02, a: 0.86 }, type: 1 }),
|
||||
text({ value: 'TARGET', fontSize: 18, bold: true, color: { r: 1, g: 0.94, b: 0.28, a: 1 }, alignment: 4, outlineWidth: 3 }),
|
||||
],
|
||||
});
|
||||
targetLabel.jsonString.enable = false;
|
||||
combat.push(targetLabel);
|
||||
const actFrame = entity({
|
||||
id: guid('cmb', 240 + i), path: `${base}/ActFrame`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 16, y: SLOT_H + 12 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: { r: 0.95, g: 0.3, b: 0.25, a: 0.3 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
actFrame.jsonString.enable = false;
|
||||
combat.push(actFrame);
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 60 + i), path: `${base}/Name`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 30 }, pos: { x: 0, y: 34 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 80 + i), path: `${base}/Hp`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 26 }, pos: { x: 0, y: 6 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 20, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 100 + i), path: `${base}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: HP_BAR_W, y: 14 }, pos: { x: 0, y: -14 } }),
|
||||
sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 120 + i), path: `${base}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: HP_BAR_W, y: 14 }, pos: { x: -HP_BAR_W / 2, y: -14 } }),
|
||||
sprite({ color: { r: 0.86, g: 0.35, b: 0.32, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 140 + i), path: `${base}/Intent`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 40, y: 24 }, pos: { x: 0, y: -36 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 17, bold: true, color: { r: 1, g: 0.72, b: 0.5, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const dmgPopBase = `/ui/DefaultGroup/CombatHud/DmgPop${i}`;
|
||||
const dmgPop = entity({
|
||||
id: guid('cmb', 250 + i), path: dmgPopBase, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 80 + i,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 192, y: 56 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT, type: 1 }),
|
||||
],
|
||||
});
|
||||
dmgPop.jsonString.enable = false;
|
||||
combat.push(dmgPop);
|
||||
for (let d = 0; d < DAMAGE_POP_MAX_DIGITS; d++) {
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 380 + i * 10 + d), path: `${dmgPopBase}/Digit${d + 1}`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 90 + i,
|
||||
components: [
|
||||
transform({ parentW: 192, parentH: 56, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: DAMAGE_POP_DIGIT_W, y: DAMAGE_POP_DIGIT_H }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ dataId: DAMAGE_DIGIT_RUIDS[0], color: { r: 1, g: 1, b: 1, a: 1 }, type: 0 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
const mBlockBadge = entity({
|
||||
id: guid('cmb', 270 + i), path: `${base}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 6,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 36 }, pos: { x: -HP_BAR_W / 2 - 30, y: -14 } }),
|
||||
sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
mBlockBadge.jsonString.enable = false;
|
||||
combat.push(mBlockBadge);
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 280 + i), path: `${base}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 40, parentH: 36, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 32 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '0', fontSize: 17, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 290 + i), path: `${base}/Buffs`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 7,
|
||||
components: [
|
||||
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 60, y: 22 }, pos: { x: 0, y: -58 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 15, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
const PP = '/ui/DefaultGroup/CombatHud/PlayerPanel';
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 210), path: PP, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 96 }, pos: { x: -760, y: -494 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: PANEL_BG, type: 1 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 211), path: `${PP}/Name`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 28 }, pos: { x: 0, y: 28 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '플레이어', fontSize: 18, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 212), path: `${PP}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 16 }, pos: { x: 16, y: -6 } }),
|
||||
sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 213), path: `${PP}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: 220, y: 14 }, pos: { x: -94, y: -6 } }),
|
||||
sprite({ color: { r: 0.3, g: 0.78, b: 0.36, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 214), path: `${PP}/HpText`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 220, y: 24 }, pos: { x: 16, y: -30 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '80/80', fontSize: 16, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const blockBadge = entity({
|
||||
id: guid('cmb', 215), path: `${PP}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 44, y: 40 }, pos: { x: -122, y: -12 } }),
|
||||
sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
blockBadge.jsonString.enable = false;
|
||||
combat.push(blockBadge);
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 216), path: `${PP}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 44, parentH: 40, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 44, y: 36 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '0', fontSize: 18, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 217), path: `${PP}/Buffs`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 6,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 22 }, pos: { x: 0, y: -44 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 14, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const playerDmgPop = entity({
|
||||
id: guid('cmb', 260), path: `${PP}/DmgPop`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 30 }, pos: { x: 16, y: 40 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 22, bold: true, color: { r: 1, g: 0.4, b: 0.35, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
});
|
||||
playerDmgPop.jsonString.enable = false;
|
||||
combat.push(playerDmgPop);
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 200),
|
||||
path: '/ui/DefaultGroup/CombatHud/TopBar',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1200, y: 52 }, pos: { x: 0, y: 486 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.06, g: 0.07, b: 0.1, a: 0.82 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
const topTexts = [
|
||||
['Floor', -520, 160, '막 1/3', GOLD],
|
||||
['Gold', -360, 160, '메소 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
|
||||
];
|
||||
topTexts.forEach(([suffix, x, w, value, color], ti) => {
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 201 + ti),
|
||||
path: `/ui/DefaultGroup/CombatHud/TopBar/${suffix}`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: ti,
|
||||
components: [
|
||||
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: w, y: 40 }, pos: { x: x, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value, fontSize: 22, bold: true, color, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 209),
|
||||
path: '/ui/DefaultGroup/CombatHud/TopBar/MesoIcon',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 26, y: 26 }, pos: { x: -432, y: 0 } }),
|
||||
sprite({ color: { r: 1, g: 0.82, b: 0.2, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 300 + i),
|
||||
path: `/ui/DefaultGroup/CombatHud/TopBar/RelicSlot${i}`,
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.UITouchReceiveComponent',
|
||||
displayOrder: 3 + i,
|
||||
components: [
|
||||
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 40 }, pos: { x: -240 + (i - 1) * 48, y: 0 } }),
|
||||
sprite({ color: { r: 0.15, g: 0.16, b: 0.2, a: 0.6 }, type: 0, raycast: true }),
|
||||
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
|
||||
],
|
||||
}));
|
||||
}
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 311),
|
||||
path: '/ui/DefaultGroup/CombatHud/TopBar/RelicOverflow',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 14,
|
||||
components: [
|
||||
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 60, y: 30 }, pos: { x: 192, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 18, bold: true, color: { r: 0.8, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 320 + i),
|
||||
path: `/ui/DefaultGroup/CombatHud/TopBar/PotionSlot${i}`,
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.UITouchReceiveComponent',
|
||||
displayOrder: 14 + i,
|
||||
components: [
|
||||
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 40 }, pos: { x: 240 + (i - 1) * 44, y: 0 } }),
|
||||
sprite({ color: { r: 0.22, g: 0.25, b: 0.3, a: 0.9 }, type: 0, raycast: true }),
|
||||
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
|
||||
],
|
||||
}));
|
||||
}
|
||||
const tooltipBox = entity({
|
||||
id: guid('cmb', 330),
|
||||
path: '/ui/DefaultGroup/CombatHud/TooltipBox',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 20,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 150 }, pos: { x: 0, y: 400 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.04, g: 0.05, b: 0.08, a: 0.96 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
tooltipBox.jsonString.enable = false;
|
||||
combat.push(tooltipBox);
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 331),
|
||||
path: '/ui/DefaultGroup/CombatHud/TooltipBox/Name',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 360, parentH: 150, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 332, y: 28 }, pos: { x: 0, y: 52 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 19, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 332),
|
||||
path: '/ui/DefaultGroup/CombatHud/TooltipBox/Desc',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 360, parentH: 150, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 332, y: 102 }, pos: { x: 0, y: -18 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 15, bold: false, color: { r: 0.92, g: 0.92, b: 0.95, a: 1 }, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
const discardPrompt = entity({
|
||||
id: guid('cmb', 333),
|
||||
path: '/ui/DefaultGroup/CombatHud/DiscardPrompt',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 22,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 520, y: 48 }, pos: { x: 0, y: -260 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.86 }, type: 1 }),
|
||||
text({ value: '', fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
});
|
||||
discardPrompt.jsonString.enable = false;
|
||||
combat.push(discardPrompt);
|
||||
const potionMenu = entity({
|
||||
id: guid('cmb', 340),
|
||||
path: '/ui/DefaultGroup/CombatHud/PotionMenu',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 21,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 180 }, pos: { x: 0, y: 120 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.07, g: 0.08, b: 0.12, a: 0.97 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
potionMenu.jsonString.enable = false;
|
||||
combat.push(potionMenu);
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 341),
|
||||
path: '/ui/DefaultGroup/CombatHud/PotionMenu/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 180, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 36 }, pos: { x: 0, y: 52 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 19, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const pmButtons = [
|
||||
['Use', '사용', -120, { r: 0.32, g: 0.55, b: 0.36, a: 1 }],
|
||||
['Toss', '버리기', 0, { r: 0.6, g: 0.32, b: 0.3, a: 1 }],
|
||||
['Close', '닫기', 120, { r: 0.25, g: 0.28, b: 0.35, a: 1 }],
|
||||
];
|
||||
pmButtons.forEach(([suffix, label, x, color], bi) => {
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 342 + bi),
|
||||
path: `/ui/DefaultGroup/CombatHud/PotionMenu/${suffix}`,
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1 + bi,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 180, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 104, y: 46 }, pos: { x, y: -40 } }),
|
||||
sprite({ color, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: label, fontSize: 20, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 205),
|
||||
path: '/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton',
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1200, parentH: 52, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 140, y: 40 }, pos: { x: 528, y: 0 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '모든덱보기', fontSize: 18, bold: true, color: GOLD, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
const skillFx = entity({
|
||||
id: guid('cmb', 230), path: '/ui/DefaultGroup/CombatHud/SkillFx',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 30,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 110, y: 110 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }),
|
||||
],
|
||||
});
|
||||
skillFx.jsonString.enable = false;
|
||||
combat.push(skillFx);
|
||||
const result = entity({
|
||||
id: guid('cmb', 2),
|
||||
path: '/ui/DefaultGroup/CombatHud/Result',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 8,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
});
|
||||
result.jsonString.enable = false;
|
||||
combat.push(result);
|
||||
return combat;
|
||||
}
|
||||
160
tools/deck/legacy/hud/deckall.mjs
Normal file
160
tools/deck/legacy/hud/deckall.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildDeckAll() {
|
||||
const allDeck = [];
|
||||
const allHud = entity({
|
||||
id: guid('all', 0),
|
||||
path: '/ui/DefaultGroup/DeckAllHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 16,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.78 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
allHud.jsonString.enable = false;
|
||||
allDeck.push(allHud);
|
||||
allDeck.push(entity({
|
||||
id: guid('all', 1),
|
||||
path: '/ui/DefaultGroup/DeckAllHud/Panel',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1080, y: 800 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.08, g: 0.09, b: 0.11, a: 0.96 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
allDeck.push(entity({
|
||||
id: guid('all', 2),
|
||||
path: '/ui/DefaultGroup/DeckAllHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 760, y: 54 }, pos: { x: 0, y: 380 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '모든 덱', fontSize: 34, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
allDeck.push(entity({
|
||||
id: guid('all', 3),
|
||||
path: '/ui/DefaultGroup/DeckAllHud/Close',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 78, y: 52 }, pos: { x: 486, y: 380 } }),
|
||||
sprite({ color: { r: 0.16, g: 0.18, b: 0.22, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: 'X', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const deckTabs = [
|
||||
{ key: 'Warrior', label: '전사', x: -210 },
|
||||
{ key: 'Thief', label: '도적', x: 0 },
|
||||
{ key: 'Mage', label: '마법사', x: 210 },
|
||||
];
|
||||
for (let i = 0; i < deckTabs.length; i++) {
|
||||
const tab = deckTabs[i];
|
||||
allDeck.push(entity({
|
||||
id: guid('all', 10 + i),
|
||||
path: `/ui/DefaultGroup/DeckAllHud/${tab.key}Tab`,
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3 + i,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 170, y: 46 }, pos: { x: tab.x, y: 318 } }),
|
||||
sprite({ color: { r: 0.11, g: 0.13, b: 0.16, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: tab.label, fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
allDeck.push(entity({
|
||||
id: guid('all', 4),
|
||||
path: '/ui/DefaultGroup/DeckAllHud/Empty',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 40 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '덱이 없습니다', fontSize: 28, bold: true, color: { r: 0.82, g: 0.86, b: 0.9, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
allDeck.push(entity({
|
||||
id: guid('all', 5),
|
||||
path: '/ui/DefaultGroup/DeckAllHud/Grid',
|
||||
modelId: 'uiempty',
|
||||
entryId: 'UIEmpty',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ScrollLayoutGroupComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 980, y: 620 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT, type: 1, raycast: true }),
|
||||
scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }),
|
||||
],
|
||||
}));
|
||||
const ALL_DECK_CARD_COUNT = 120;
|
||||
const ALL_DECK_CARD_W = 158;
|
||||
const ALL_DECK_CARD_H = 214;
|
||||
// 카드 단위 엔티티 v2 네임스페이스 — DeckInspectHud 주석 참조
|
||||
for (let i = 1; i <= ALL_DECK_CARD_COUNT; i++) {
|
||||
const allBase = 6 + (i - 1) * 7;
|
||||
const cardPath = `/ui/DefaultGroup/DeckAllHud/Grid/Card${i}`;
|
||||
const card = entity({
|
||||
id: guid('all2', allBase),
|
||||
path: cardPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: i,
|
||||
components: [
|
||||
transform({ parentW: 980, parentH: 620, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: ALL_DECK_CARD_W, y: ALL_DECK_CARD_H }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
});
|
||||
card.jsonString.enable = false;
|
||||
allDeck.push(card);
|
||||
const allDeckLayout = cardFaceLayout(ALL_DECK_CARD_W);
|
||||
for (const [tIdx, [suffix, cfg]] of allDeckLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : '' }]).entries()) {
|
||||
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
|
||||
allDeck.push(entity({
|
||||
id: guid('all2', allBase + 1 + tIdx),
|
||||
path: `${cardPath}/${suffix}`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: dOrder,
|
||||
components: [
|
||||
transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
allDeck.push(entity({
|
||||
id: guid('all2', allBase + 6),
|
||||
path: `${cardPath}/Art`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: ALL_DECK_CARD_W, parentH: ALL_DECK_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: allDeckLayout.art.size, pos: allDeckLayout.art.pos }),
|
||||
sprite({ color: WHITE, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
return allDeck;
|
||||
}
|
||||
122
tools/deck/legacy/hud/deckhud.mjs
Normal file
122
tools/deck/legacy/hud/deckhud.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildDeckHud() {
|
||||
const hud = [];
|
||||
const add = (e) => hud.push(e);
|
||||
|
||||
add(entity({
|
||||
id: guid('hud', 0),
|
||||
path: '/ui/DefaultGroup/DeckHud',
|
||||
modelId: 'uiempty',
|
||||
entryId: 'UIEmpty',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1280, y: 330 }, pos: { x: 0, y: 180 }, align: ALIGN_BOTTOM_CENTER }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
],
|
||||
}));
|
||||
|
||||
for (const pile of [
|
||||
{ key: 'DrawPile', x: -590, label: '뽑을 덱', count: '10', color: { r: 0.17, g: 0.20, b: 0.25, a: 1 } },
|
||||
{ key: 'ExhaustPile', x: 430, label: '소멸 덱', count: '0', color: { r: 0.13, g: 0.13, b: 0.18, a: 1 } },
|
||||
{ key: 'DiscardPile', x: 590, label: '버린 덱', count: '0', color: { r: 0.22, g: 0.18, b: 0.16, a: 1 } },
|
||||
]) {
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: `/ui/DefaultGroup/DeckHud/${pile.key}`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: pile.key === 'DrawPile' ? 0 : pile.key === 'ExhaustPile' ? 1 : 2,
|
||||
components: [
|
||||
transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 132, y: 186 }, pos: { x: pile.x, y: 8 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: pile.color, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: `/ui/DefaultGroup/DeckHud/${pile.key}/Label`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 132, parentH: 186, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 42 }, pos: { x: 0, y: 45 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: pile.label, fontSize: 21, bold: true, color: GOLD }),
|
||||
],
|
||||
}));
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: `/ui/DefaultGroup/DeckHud/${pile.key}/Count`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 132, parentH: 186, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 72 }, pos: { x: 0, y: -20 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: pile.count, fontSize: 42, bold: true }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: '/ui/DefaultGroup/DeckHud/EndTurnButton',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 64 }, pos: { x: 560, y: 160 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '턴 종료', fontSize: 28, bold: true, color: GOLD, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: '/ui/DefaultGroup/DeckHud/EnergyOrb',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 96, y: 96 }, pos: { x: -560, y: 160 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.12, g: 0.2, b: 0.34, a: 0.95 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: '/ui/DefaultGroup/DeckHud/EnergyOrb/Value',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 96, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 92, y: 48 }, pos: { x: 0, y: 6 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '3/3', fontSize: 34, bold: true, color: { r: 0.65, g: 0.92, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
add(entity({
|
||||
id: guid('hud', hud.length),
|
||||
path: '/ui/DefaultGroup/DeckHud/EnergyOrb/Label',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 96, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 92, y: 24 }, pos: { x: 0, y: -28 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '에너지', fontSize: 14, bold: true, color: { r: 0.55, g: 0.7, b: 0.85, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
|
||||
return hud;
|
||||
}
|
||||
138
tools/deck/legacy/hud/deckinspect.mjs
Normal file
138
tools/deck/legacy/hud/deckinspect.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildDeckInspect() {
|
||||
const inspect = [];
|
||||
const inspectHud = entity({
|
||||
id: guid('ins', 0),
|
||||
path: '/ui/DefaultGroup/DeckInspectHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 15,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.78 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
inspectHud.jsonString.enable = false;
|
||||
inspect.push(inspectHud);
|
||||
inspect.push(entity({
|
||||
id: guid('ins', 1),
|
||||
path: '/ui/DefaultGroup/DeckInspectHud/Panel',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1040, y: 760 }, pos: { x: 0, y: 10 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.08, g: 0.09, b: 0.11, a: 0.96 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
inspect.push(entity({
|
||||
id: guid('ins', 2),
|
||||
path: '/ui/DefaultGroup/DeckInspectHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 720, y: 54 }, pos: { x: 0, y: 350 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '\uB371 \uBCF4\uAE30', fontSize: 34, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
inspect.push(entity({
|
||||
id: guid('ins', 3),
|
||||
path: '/ui/DefaultGroup/DeckInspectHud/Close',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 78, y: 52 }, pos: { x: 466, y: 350 } }),
|
||||
sprite({ color: { r: 0.16, g: 0.18, b: 0.22, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: 'X', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
inspect.push(entity({
|
||||
id: guid('ins', 4),
|
||||
path: '/ui/DefaultGroup/DeckInspectHud/Empty',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '\uCE74\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4', fontSize: 28, bold: true, color: { r: 0.82, g: 0.86, b: 0.9, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
inspect.push(entity({
|
||||
id: guid('ins', 5),
|
||||
path: '/ui/DefaultGroup/DeckInspectHud/Grid',
|
||||
modelId: 'uiempty',
|
||||
entryId: 'UIEmpty',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ScrollLayoutGroupComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 950, y: 610 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT, type: 1, raycast: true }),
|
||||
scrollLayoutGroup({ cellSize: { x: 158, y: 214 }, spacing: { x: 22, y: 22 }, columns: 5 }),
|
||||
],
|
||||
}));
|
||||
const INSPECT_CARD_COUNT = 60;
|
||||
const INSPECT_CARD_W = 158;
|
||||
const INSPECT_CARD_H = 214;
|
||||
// 카드 단위 엔티티는 v2 네임스페이스(ins2/all2/rwd2/shp2) — 자식 구성이 바뀌면 id를 통째로 새로 발급해야 함.
|
||||
// 구 id를 다른 path에 재사용하면 메이커 refresh의 id 기준 in-place 병합이 꼬여 자식이 소실됨 (P13 실측).
|
||||
for (let i = 1; i <= INSPECT_CARD_COUNT; i++) {
|
||||
const insBase = 6 + (i - 1) * 7;
|
||||
const cardPath = `/ui/DefaultGroup/DeckInspectHud/Grid/Card${i}`;
|
||||
const card = entity({
|
||||
id: guid('ins2', insBase),
|
||||
path: cardPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: i,
|
||||
components: [
|
||||
transform({ parentW: 950, parentH: 610, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: INSPECT_CARD_W, y: INSPECT_CARD_H }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0 }),
|
||||
],
|
||||
});
|
||||
card.jsonString.enable = false;
|
||||
inspect.push(card);
|
||||
const inspectLayout = cardFaceLayout(INSPECT_CARD_W);
|
||||
for (const [tIdx, [suffix, cfg]] of inspectLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : '' }]).entries()) {
|
||||
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
|
||||
inspect.push(entity({
|
||||
id: guid('ins2', insBase + 1 + tIdx),
|
||||
path: `${cardPath}/${suffix}`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: dOrder,
|
||||
components: [
|
||||
transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
inspect.push(entity({
|
||||
id: guid('ins2', insBase + 6),
|
||||
path: `${cardPath}/Art`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: INSPECT_CARD_W, parentH: INSPECT_CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: inspectLayout.art.size, pos: inspectLayout.art.pos }),
|
||||
sprite({ color: WHITE, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
return inspect;
|
||||
}
|
||||
51
tools/deck/legacy/hud/jobchoice.mjs
Normal file
51
tools/deck/legacy/hud/jobchoice.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildJobChoice() {
|
||||
const jobChoice = [];
|
||||
const jobChoiceHud = entity({
|
||||
id: guid('job', 0),
|
||||
path: '/ui/DefaultGroup/JobChoiceHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
jobChoiceHud.jsonString.enable = false;
|
||||
jobChoice.push(jobChoiceHud);
|
||||
jobChoice.push(entity({
|
||||
id: guid('job', 1),
|
||||
path: '/ui/DefaultGroup/JobChoiceHud/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 60 }, pos: { x: 0, y: 220 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '보스 처치 보상을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const jcButtons = [
|
||||
['RelicButton', '유물 획득', -240, { r: 0.7, g: 0.55, b: 0.85, a: 1 }],
|
||||
['JobButton', '2차 전직', 240, { r: 0.86, g: 0.6, b: 0.3, a: 1 }],
|
||||
];
|
||||
jcButtons.forEach(([suffix, label, x, color], bi) => {
|
||||
jobChoice.push(entity({
|
||||
id: guid('job', 2 + bi),
|
||||
path: `/ui/DefaultGroup/JobChoiceHud/${suffix}`,
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1 + bi,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 140 }, pos: { x, y: 0 } }),
|
||||
sprite({ color, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: label, fontSize: 32, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
return jobChoice;
|
||||
}
|
||||
89
tools/deck/legacy/hud/jobselect.mjs
Normal file
89
tools/deck/legacy/hud/jobselect.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildJobSelect() {
|
||||
const jobSelect = [];
|
||||
const jobSelectHud = entity({
|
||||
id: guid('job', 10),
|
||||
path: '/ui/DefaultGroup/JobSelectHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 10,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.94 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
jobSelectHud.jsonString.enable = false;
|
||||
jobSelect.push(jobSelectHud);
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 11),
|
||||
path: '/ui/DefaultGroup/JobSelectHud/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 60 }, pos: { x: 0, y: 300 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '2차 전직 — 직업을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
// 범용 슬롯 3개 — ShowJobSelect(Lua)가 클래스별 JOBS로 텍스트를 채움 (P10 동적화)
|
||||
const jobs = [
|
||||
['slot1', '', '', '', -440, { r: 0.82, g: 0.4, b: 0.34, a: 1 }],
|
||||
['slot2', '', '', '', 0, { r: 0.4, g: 0.55, b: 0.85, a: 1 }],
|
||||
['slot3', '', '', '', 440, { r: 0.42, g: 0.72, b: 0.46, a: 1 }],
|
||||
];
|
||||
jobs.forEach(([jobId, name, desc, starter, x, color], ji) => {
|
||||
const base = `/ui/DefaultGroup/JobSelectHud/Job_${jobId}`;
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 12 + ji * 4),
|
||||
path: base,
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 1 + ji,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 420 }, pos: { x, y: -20 } }),
|
||||
sprite({ color, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 13 + ji * 4),
|
||||
path: `${base}/Name`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 50 }, pos: { x: 0, y: 150 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: name, fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 14 + ji * 4),
|
||||
path: `${base}/Desc`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 160 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: desc, fontSize: 22, bold: false, color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 15 + ji * 4),
|
||||
path: `${base}/Starter`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 32 }, pos: { x: 0, y: -160 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: starter, fontSize: 18, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
return jobSelect;
|
||||
}
|
||||
58
tools/deck/legacy/hud/lobby.mjs
Normal file
58
tools/deck/legacy/hud/lobby.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildLobby() {
|
||||
const lobby = [];
|
||||
let lobId = 0;
|
||||
const lobbyRoot = entity({
|
||||
id: guid('lob', lobId++),
|
||||
path: '/ui/DefaultGroup/LobbyHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 11,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
// 로비가 물리 맵이 됨 — 투명 + 비레이캐스트로 맵을 가리지 않고 월드 NPC 클릭이 통과되게 함.
|
||||
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
|
||||
],
|
||||
});
|
||||
lobbyRoot.jsonString.enable = false;
|
||||
lobby.push(lobbyRoot);
|
||||
const lobTexts = [
|
||||
['Title', 0, 478, 760, '메이플 로비', 40, GOLD],
|
||||
['SoulLabel', 700, 478, 320, '영혼 0', 28, { r: 0.6, g: 0.85, b: 1, a: 1 }],
|
||||
['AscLabel', -560, 478, 380, '승천 0 / 해금 0', 22, { r: 0.9, g: 0.7, b: 0.5, a: 1 }],
|
||||
['Hint', 0, -478, 1500, 'NPC에게 다가가 ↑ 또는 클릭으로 대화 · ← → 이동 · Ctrl 공격', 20, { r: 0.72, g: 0.76, b: 0.82, a: 1 }],
|
||||
];
|
||||
for (const [suffix, x, y, w, value, fs, color] of lobTexts) {
|
||||
lobby.push(entity({
|
||||
id: guid('lob', lobId++),
|
||||
path: `/ui/DefaultGroup/LobbyHud/${suffix}`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: w, y: 56 }, pos: { x, y } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value, fontSize: fs, bold: true, color, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
for (const [suffix, x, label] of [['AscMinus', -780, '<'], ['AscPlus', -540, '>']]) {
|
||||
lobby.push(entity({
|
||||
id: guid('lob', lobId++),
|
||||
path: `/ui/DefaultGroup/LobbyHud/${suffix}`,
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 46, y: 42 }, pos: { x, y: 470 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.2, g: 0.24, b: 0.3, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: label, fontSize: 28, bold: true, color: WHITE, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
// NPC 4종은 로비 물리 맵의 월드 엔티티(map/lobby.map + LobbyNpc codeblock)로 이동. UI 버튼 행 제거.
|
||||
return lobby;
|
||||
}
|
||||
127
tools/deck/legacy/hud/mainmenu.mjs
Normal file
127
tools/deck/legacy/hud/mainmenu.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildMainMenu() {
|
||||
const menu = [];
|
||||
menu.push(entity({
|
||||
id: guid('menu', 0),
|
||||
path: '/ui/DefaultGroup/MainMenu',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 20,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.06, g: 0.09, b: 0.13, a: 1 }, type: 1, raycast: true }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 50),
|
||||
path: '/ui/DefaultGroup/MainMenu/OpaqueBackdrop',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 1),
|
||||
path: '/ui/DefaultGroup/MainMenu/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 720, y: 100 }, pos: { x: 0, y: 180 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '슬레이 메이플', fontSize: 64, bold: true, color: GOLD, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 2),
|
||||
path: '/ui/DefaultGroup/MainMenu/Subtitle',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 760, y: 48 }, pos: { x: 0, y: 104 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '카드를 뽑고, 덱을 만들고, 첨탑을 오른다', fontSize: 24, color: { r: 0.82, g: 0.86, b: 0.9, a: 1 }, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 3),
|
||||
path: '/ui/DefaultGroup/MainMenu/NewGameButton',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 260, y: 68 }, pos: { x: 0, y: -20 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.13, g: 0.15, b: 0.18, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '새 게임', fontSize: 30, bold: true, color: GOLD, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
// 승천 선택 (P11): [-] 라벨 [+]
|
||||
menu.push(entity({
|
||||
id: guid('menu', 195),
|
||||
path: '/ui/DefaultGroup/MainMenu/AscMinus',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 52, y: 52 }, pos: { x: -170, y: -185 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.13, g: 0.15, b: 0.18, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '-', fontSize: 30, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 196),
|
||||
path: '/ui/DefaultGroup/MainMenu/AscLabel',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 6,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 250, y: 40 }, pos: { x: 0, y: -185 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '승천 0 / 해금 0', fontSize: 22, bold: true, color: { r: 0.85, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 197),
|
||||
path: '/ui/DefaultGroup/MainMenu/AscPlus',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 7,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 52, y: 52 }, pos: { x: 170, y: -185 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.13, g: 0.15, b: 0.18, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '+', fontSize: 30, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
menu.push(entity({
|
||||
id: guid('menu', 4),
|
||||
path: '/ui/DefaultGroup/MainMenu/ContinueButton',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 260, y: 58 }, pos: { x: 0, y: -100 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.1, g: 0.11, b: 0.13, a: 0.78 }, type: 1, raycast: false }),
|
||||
button({ enabled: false }),
|
||||
text({ value: '이어하기', fontSize: 24, bold: true, color: { r: 0.55, g: 0.58, b: 0.62, a: 1 }, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
return menu;
|
||||
}
|
||||
162
tools/deck/legacy/hud/map.mjs
Normal file
162
tools/deck/legacy/hud/map.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildMap() {
|
||||
const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스', shop: '상점', rest: '휴식' };
|
||||
const map = [];
|
||||
const mapHud = entity({
|
||||
id: guid('map', 0),
|
||||
path: '/ui/DefaultGroup/MapHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 7,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
// 불투명 다크 배경(게임 월드 가림 보장). 그 위 BgImage 자식이 배경 스프라이트를 얹는다.
|
||||
sprite({ color: { r: 0.06, g: 0.07, b: 0.11, a: 1 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
mapHud.jsonString.enable = false;
|
||||
map.push(mapHud);
|
||||
// 배경 이미지(displayOrder 0 = 도트/타이틀/노드 아래). nodeicons.json background는 SPRITE RUID여야 렌더됨
|
||||
// — 메이플 BackgroundComponent 리소스는 UI 스프라이트로 안 뜬다. 유효 스프라이트면 풀스크린 표시, 아니면 투명(다크 배경 노출).
|
||||
map.push(entity({
|
||||
id: guid('map', 990),
|
||||
path: '/ui/DefaultGroup/MapHud/BgImage',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ dataId: NODEICONS.background, color: { r: 0.5, g: 0.52, b: 0.58, a: 1 }, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
map.push(entity({
|
||||
id: guid('map', 1),
|
||||
path: '/ui/DefaultGroup/MapHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 510 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
// 절차 생성 맵용 정적 그리드 — 가로 진행(왼→오른쪽): 행(row)=x축, 열(col)=y축 분기, 보스는 최우측 중앙.
|
||||
const nodeX = (row) => -540 + (row - 1) * 150;
|
||||
const nodeY = (col) => 180 - (col - 1) * 120;
|
||||
const BOSS_POS = { x: nodeX(MAP_ROWS) + 150, y: 0 };
|
||||
let mapN = 2;
|
||||
const pushMapNode = (id, pos, size, label) => {
|
||||
const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`;
|
||||
const nodeEnt = entity({
|
||||
id: guid('map', mapN++),
|
||||
path: nodePath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
|
||||
sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
});
|
||||
nodeEnt.jsonString.enable = false;
|
||||
map.push(nodeEnt);
|
||||
};
|
||||
for (let r = 1; r <= MAP_ROWS; r++) {
|
||||
for (let c = 1; c <= MAP_COLS; c++) {
|
||||
pushMapNode(`r${r}c${c}`, { x: nodeX(r), y: nodeY(c) }, { x: 64, y: 64 }, '');
|
||||
}
|
||||
}
|
||||
pushMapNode('boss', BOSS_POS, { x: 88, y: 88 }, '보스');
|
||||
const pushDots = (dotId, from, to) => {
|
||||
for (let k = 1; k <= 3; k++) {
|
||||
const t = k / 4;
|
||||
const dot = entity({
|
||||
id: guid('map', mapN++),
|
||||
path: `/ui/DefaultGroup/MapHud/Dot_${dotId}_${k}`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 8, y: 8 }, pos: { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t } }),
|
||||
sprite({ color: { r: 0.5, g: 0.5, b: 0.55, a: 0.8 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
dot.jsonString.enable = false;
|
||||
map.push(dot);
|
||||
}
|
||||
};
|
||||
for (let r = 1; r < MAP_ROWS; r++) {
|
||||
for (let c = 1; c <= MAP_COLS; c++) {
|
||||
for (let c2 = c - 1; c2 <= c + 1; c2++) {
|
||||
if (c2 < 1 || c2 > MAP_COLS) continue;
|
||||
pushDots(`r${r}c${c}_${c2}`, { x: nodeX(r), y: nodeY(c) }, { x: nodeX(r + 1), y: nodeY(c2) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let c = 1; c <= MAP_COLS; c++) {
|
||||
pushDots(`r${MAP_ROWS}c${c}_b`, { x: nodeX(MAP_ROWS), y: nodeY(c) }, BOSS_POS);
|
||||
}
|
||||
// 노드 종류 범례 (우측 하단) — 각 타입 아이콘 + 이름
|
||||
const LEGEND = [['combat', '전투'], ['elite', '엘리트'], ['boss', '보스'], ['shop', '상점'], ['rest', '휴식'], ['treasure', '보물']];
|
||||
const lgW = 300, lgH = 312;
|
||||
map.push(entity({
|
||||
id: guid('map', 991),
|
||||
path: '/ui/DefaultGroup/MapHud/Legend',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: lgW, y: lgH }, pos: { x: 760, y: -334 } }),
|
||||
sprite({ color: { r: 0.08, g: 0.09, b: 0.14, a: 0.86 }, type: 1, raycast: false }),
|
||||
],
|
||||
}));
|
||||
map.push(entity({
|
||||
id: guid('map', 992),
|
||||
path: '/ui/DefaultGroup/MapHud/Legend/LegendTitle',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: lgW, parentH: lgH, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: lgW - 20, y: 32 }, pos: { x: 0, y: lgH / 2 - 26 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '노드 종류', fontSize: 22, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
let lgId = 993;
|
||||
LEGEND.forEach(([t, ko], i) => {
|
||||
const rowY = lgH / 2 - 78 - i * 38;
|
||||
map.push(entity({
|
||||
id: guid('map', lgId++),
|
||||
path: `/ui/DefaultGroup/MapHud/Legend/Icon_${t}`,
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: lgW, parentH: lgH, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 36, y: 36 }, pos: { x: -lgW / 2 + 38, y: rowY } }),
|
||||
sprite({ dataId: NODEICONS.icons[t], color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
map.push(entity({
|
||||
id: guid('map', lgId++),
|
||||
path: `/ui/DefaultGroup/MapHud/Legend/Label_${t}`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: lgW, parentH: lgH, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: lgW - 110, y: 30 }, pos: { x: 32, y: rowY } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: ko, fontSize: 19, bold: false, color: { r: 0.9, g: 0.92, b: 0.96, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
61
tools/deck/legacy/hud/rest.mjs
Normal file
61
tools/deck/legacy/hud/rest.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildRest() {
|
||||
const rest = [];
|
||||
const restHud = entity({
|
||||
id: guid('rst', 0),
|
||||
path: '/ui/DefaultGroup/RestHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
restHud.jsonString.enable = false;
|
||||
rest.push(restHud);
|
||||
rest.push(entity({
|
||||
id: guid('rst', 1),
|
||||
path: '/ui/DefaultGroup/RestHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 140 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
rest.push(entity({
|
||||
id: guid('rst', 2),
|
||||
path: '/ui/DefaultGroup/RestHud/Info',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
rest.push(entity({
|
||||
id: guid('rst', 3),
|
||||
path: '/ui/DefaultGroup/RestHud/Leave',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -120 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
return rest;
|
||||
}
|
||||
98
tools/deck/legacy/hud/reward.mjs
Normal file
98
tools/deck/legacy/hud/reward.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildReward() {
|
||||
const reward = [];
|
||||
const rewardHud = entity({
|
||||
id: guid('rwd', 0),
|
||||
path: '/ui/DefaultGroup/RewardHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 6,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
rewardHud.jsonString.enable = false;
|
||||
reward.push(rewardHud);
|
||||
reward.push(entity({
|
||||
id: guid('rwd', 1),
|
||||
path: '/ui/DefaultGroup/RewardHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 64 }, pos: { x: 0, y: 300 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const rewardXs = [-300, 0, 300];
|
||||
// 카드 단위 엔티티 v2 네임스페이스 — DeckInspectHud 주석 참조
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const rwdBase = 2 + (i - 1) * 7;
|
||||
const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
|
||||
reward.push(entity({
|
||||
id: guid('rwd2', rwdBase),
|
||||
path: cardPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent',
|
||||
displayOrder: i,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: rewardXs[i - 1], y: 0 } }),
|
||||
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
|
||||
button(),
|
||||
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
|
||||
],
|
||||
}));
|
||||
const rewardLayout = cardFaceLayout(CARD_W);
|
||||
for (const [tIdx, [suffix, cfg]] of rewardLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : sfx === 'Name' ? '카드' : '' }]).entries()) {
|
||||
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
|
||||
reward.push(entity({
|
||||
id: guid('rwd2', rwdBase + 1 + tIdx),
|
||||
path: `${cardPath}/${suffix}`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: dOrder,
|
||||
components: [
|
||||
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
reward.push(entity({
|
||||
id: guid('rwd2', rwdBase + 6),
|
||||
path: `${cardPath}/Art`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: rewardLayout.art.size, pos: rewardLayout.art.pos }),
|
||||
sprite({ color: WHITE, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
let rwdN = 2 + 3 * 7; // 구 시퀀스의 루프 종료 시점 값(23) 보존 — Skip 등 후속 id 불변
|
||||
reward.push(entity({
|
||||
id: guid('rwd', rwdN++),
|
||||
path: '/ui/DefaultGroup/RewardHud/Skip',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 10,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -260 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
return reward;
|
||||
}
|
||||
203
tools/deck/legacy/hud/shop.mjs
Normal file
203
tools/deck/legacy/hud/shop.mjs
Normal file
@@ -0,0 +1,203 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildShop() {
|
||||
const shop = [];
|
||||
const shopHud = entity({
|
||||
id: guid('shp', 0),
|
||||
path: '/ui/DefaultGroup/ShopHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 8,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
shopHud.jsonString.enable = false;
|
||||
shop.push(shopHud);
|
||||
shop.push(entity({
|
||||
id: guid('shp', 1),
|
||||
path: '/ui/DefaultGroup/ShopHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 400 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', 2),
|
||||
path: '/ui/DefaultGroup/ShopHud/Gold',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '메소 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', 3),
|
||||
path: '/ui/DefaultGroup/ShopHud/MesoIcon',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 30, y: 30 }, pos: { x: -86, y: 330 } }),
|
||||
sprite({ color: { r: 1, g: 0.82, b: 0.2, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
const shopXs = [-300, 0, 300];
|
||||
// 카드 단위 엔티티 v2 네임스페이스 (stride 8: Price 포함) — DeckInspectHud 주석 참조
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const shpBase = 3 + (i - 1) * 8;
|
||||
const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`;
|
||||
shop.push(entity({
|
||||
id: guid('shp2', shpBase),
|
||||
path: cardPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent',
|
||||
displayOrder: i,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: shopXs[i - 1], y: 20 } }),
|
||||
sprite({ dataId: CARDFRAMES.frames.warrior.normal, color: WHITE, type: 0, raycast: true }),
|
||||
button(),
|
||||
{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true },
|
||||
],
|
||||
}));
|
||||
const shopLayout = cardFaceLayout(CARD_W);
|
||||
const shopTexts = shopLayout.texts.map(([sfx, c]) => [sfx, { ...c, value: sfx === 'Cost' ? '1' : sfx === 'Name' ? '카드' : '' }]);
|
||||
shopTexts.push(['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -135 }, value: '30 메소', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }]);
|
||||
for (const [tIdx, [suffix, cfg]] of shopTexts.entries()) {
|
||||
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : suffix === 'Desc' ? 8 : 9;
|
||||
shop.push(entity({
|
||||
id: guid('shp2', shpBase + 1 + tIdx),
|
||||
path: `${cardPath}/${suffix}`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: dOrder,
|
||||
components: [
|
||||
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
shop.push(entity({
|
||||
id: guid('shp2', shpBase + 7),
|
||||
path: `${cardPath}/Art`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: shopLayout.art.size, pos: shopLayout.art.pos }),
|
||||
sprite({ color: WHITE, type: 0, raycast: false }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
let shpN = 3 + 3 * 8; // 구 시퀀스의 루프 종료 시점 값(27) 보존 — Relic 등 후속 id 불변
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Relic',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 560, y: 76 }, pos: { x: 0, y: -190 } }),
|
||||
sprite({ color: { r: 0.7, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Relic/Label',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 40 }, pos: { x: 0, y: 12 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '유물', fontSize: 22, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Relic/Price',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '60 메소', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Potion',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 11,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 560, y: 76 }, pos: { x: 0, y: -278 } }),
|
||||
sprite({ color: { r: 0.45, g: 0.7, b: 0.55, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Potion/Label',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 40 }, pos: { x: 0, y: 12 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '물약', fontSize: 22, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Potion/Price',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '20 메소', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Leave',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 10,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -380 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
return shop;
|
||||
}
|
||||
90
tools/deck/legacy/hud/soulshop.mjs
Normal file
90
tools/deck/legacy/hud/soulshop.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildSoulShop() {
|
||||
const soulShop = [];
|
||||
let soulId = 0;
|
||||
const soulRoot = entity({
|
||||
id: guid('soul', soulId++),
|
||||
path: '/ui/DefaultGroup/SoulShopHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 15,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.95 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
soulRoot.jsonString.enable = false;
|
||||
soulShop.push(soulRoot);
|
||||
soulShop.push(entity({
|
||||
id: guid('soul', soulId++),
|
||||
path: '/ui/DefaultGroup/SoulShopHud/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 410 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '영혼 상점', fontSize: 44, bold: true, color: { r: 0.6, g: 0.85, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
soulShop.push(entity({
|
||||
id: guid('soul', soulId++),
|
||||
path: '/ui/DefaultGroup/SoulShopHud/Souls',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 400, y: 44 }, pos: { x: 0, y: 345 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '영혼 0', fontSize: 28, bold: true, color: { r: 0.6, g: 0.85, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
soulShop.push(entity({
|
||||
id: guid('soul', soulId++),
|
||||
path: '/ui/DefaultGroup/SoulShopHud/Close',
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -400 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.2, g: 0.24, b: 0.3, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '닫기', fontSize: 28, bold: true, color: WHITE, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const ip = `/ui/DefaultGroup/SoulShopHud/Item${i}`;
|
||||
const iy = 230 - (i - 1) * 125;
|
||||
soulShop.push(entity({
|
||||
id: guid('soul', soulId++),
|
||||
path: ip, modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 760, y: 104 }, pos: { x: 0, y: iy }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.14, g: 0.16, b: 0.22, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
for (const [suffix, x, y, w, fs, color] of [
|
||||
['Name', -180, 22, 360, 28, GOLD],
|
||||
['Desc', -180, -24, 440, 20, { r: 0.86, g: 0.9, b: 0.94, a: 1 }],
|
||||
['Status', 270, 0, 220, 22, { r: 0.6, g: 0.85, b: 1, a: 1 }],
|
||||
]) {
|
||||
soulShop.push(entity({
|
||||
id: guid('soul', soulId++),
|
||||
path: `${ip}/${suffix}`, modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 760, parentH: 104, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: w, y: 38 }, pos: { x, y } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: fs, bold: suffix === 'Name', color, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
return soulShop;
|
||||
}
|
||||
89
tools/deck/legacy/hud/treasure.mjs
Normal file
89
tools/deck/legacy/hud/treasure.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../../lib/ui-helpers.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../../lib/data.mjs';
|
||||
|
||||
export function buildTreasure() {
|
||||
const treasure = [];
|
||||
const treasureHud = entity({
|
||||
id: guid('trs', 0),
|
||||
path: '/ui/DefaultGroup/TreasureHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 8,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
treasureHud.jsonString.enable = false;
|
||||
treasure.push(treasureHud);
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 1),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 320 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '보물 상자', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 2),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Chest',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 180, y: 180 }, pos: { x: 0, y: 40 } }),
|
||||
sprite({ dataId: CHEST_CLOSED_RUID, color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 3),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Hint',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 500, y: 34 }, pos: { x: 0, y: -90 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '상자를 클릭해 여세요', fontSize: 20, bold: false, color: { r: 0.85, g: 0.85, b: 0.9, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const treasureReward = entity({
|
||||
id: guid('trs', 4),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Reward',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 44 }, pos: { x: 0, y: -160 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
});
|
||||
treasureReward.jsonString.enable = false;
|
||||
treasure.push(treasureReward);
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 5),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Leave',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -280 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
return treasure;
|
||||
}
|
||||
246
tools/deck/legacy/upsert-ui.mjs
Normal file
246
tools/deck/legacy/upsert-ui.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { CARDS, frameRuid } from '../lib/data.mjs';
|
||||
import { UI_FILE, isGeneratedUiEntity, DISABLED_STOCK_CONTROLS, uiPath, guid, entity, transform, sprite, button, text, WHITE, TRANSPARENT, CARD_W, CARD_H, cardFaceLayout, UI_APPEND_ORDER, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
import { buildDeckHud } from './hud/deckhud.mjs';
|
||||
import { buildDeckInspect } from './hud/deckinspect.mjs';
|
||||
import { buildDeckAll } from './hud/deckall.mjs';
|
||||
import { buildCombat } from './hud/combat.mjs';
|
||||
import { buildReward } from './hud/reward.mjs';
|
||||
import { buildMap } from './hud/map.mjs';
|
||||
import { buildShop } from './hud/shop.mjs';
|
||||
import { buildRest } from './hud/rest.mjs';
|
||||
import { buildTreasure } from './hud/treasure.mjs';
|
||||
import { buildJobChoice } from './hud/jobchoice.mjs';
|
||||
import { buildJobSelect } from './hud/jobselect.mjs';
|
||||
import { buildLobby } from './hud/lobby.mjs';
|
||||
import { buildBoard } from './hud/board.mjs';
|
||||
import { buildSoulShop } from './hud/soulshop.mjs';
|
||||
import { buildMainMenu } from './hud/mainmenu.mjs';
|
||||
|
||||
// ⚠️ 휴면(LEGACY): 메이커 저작 전환 후 생성기는 .ui를 안 만든다. 이 파일은 옛 DefaultGroup.ui
|
||||
// 단일 저작 로직의 롤백/참조용. import는 무해(함수만 정의), 직접 실행할 때만 .ui를 옛 생성본으로 덮어쓴다.
|
||||
|
||||
function upsertUi() {
|
||||
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
|
||||
const E = ui.ContentProto.Entities;
|
||||
// CardHand는 스톡 섹션이라 과거 생성된 단색판(NamePlate/CostPlate)이 잔존 → 프레임 이미지 도입으로 제거
|
||||
const obsoletePlate = /^\/ui\/DefaultGroup\/CardHand\/Card\d+\/(NamePlate|CostPlate)$/;
|
||||
ui.ContentProto.Entities = E.filter((e) => !isGeneratedUiEntity(e) && !obsoletePlate.test(e.path));
|
||||
|
||||
const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e]));
|
||||
const uiSections = new Map();
|
||||
const emit = (section, entities) => {
|
||||
if (uiSections.has(section)) {
|
||||
throw new Error(`[gen-slaydeck] duplicate generated UI section: ${section}`);
|
||||
}
|
||||
uiSections.set(section, entities);
|
||||
};
|
||||
|
||||
for (const path of DISABLED_STOCK_CONTROLS.map((name) => uiPath(name))) {
|
||||
const e = byPath.get(path);
|
||||
if (e != null) {
|
||||
e.jsonString.enable = false;
|
||||
e.jsonString.visible = false;
|
||||
for (const component of e.jsonString['@components'] || []) {
|
||||
component.Enable = false;
|
||||
if (component.RaycastTarget != null) component.RaycastTarget = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 카드 미리보기(초기 정적 표시 — 런타임 RenderHand가 덮어씀): 카드 종류를 순환해 다양성 표시
|
||||
const previewIds = Object.keys(CARDS.cards);
|
||||
const cards = Array.from({ length: 10 }, (_, i) => {
|
||||
const c = CARDS.cards[previewIds[i % previewIds.length]];
|
||||
return { name: c.name, cost: String(c.cost), desc: c.desc, frame: frameRuid(c) };
|
||||
});
|
||||
|
||||
// 손패 슬롯 10개 (최대 손패 한도). Card1~5는 기존 엔티티, Card6~10은 신규 생성.
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const cardPath = `/ui/DefaultGroup/CardHand/Card${i}`;
|
||||
let card = byPath.get(cardPath);
|
||||
if (!card) {
|
||||
card = entity({
|
||||
id: guid('dck', 500 + i),
|
||||
path: cardPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.UITouchReceiveComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: WHITE, type: 0, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
});
|
||||
ui.ContentProto.Entities.push(card);
|
||||
byPath.set(cardPath, card);
|
||||
}
|
||||
const tr = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent');
|
||||
const sp = card.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.SpriteGUIRendererComponent');
|
||||
const sx = -680 + (i - 1) * 150;
|
||||
tr.RectSize = { x: CARD_W, y: CARD_H };
|
||||
tr.anchoredPosition = { x: sx, y: 0 };
|
||||
tr.OffsetMin = { x: sx - CARD_W / 2, y: -CARD_H / 2 };
|
||||
tr.OffsetMax = { x: sx + CARD_W / 2, y: CARD_H / 2 };
|
||||
sp.ImageRUID = { DataId: cards[i - 1].frame };
|
||||
sp.Type = 0;
|
||||
sp.Color = WHITE;
|
||||
sp.RaycastTarget = true;
|
||||
const comps = card.jsonString['@components'];
|
||||
if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) {
|
||||
comps.push(button());
|
||||
}
|
||||
if (!card.componentNames.includes('MOD.Core.ButtonComponent')) {
|
||||
card.componentNames += ',MOD.Core.ButtonComponent';
|
||||
}
|
||||
if (!comps.some((c) => c['@type'] === 'MOD.Core.UITouchReceiveComponent')) {
|
||||
comps.push({ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true });
|
||||
}
|
||||
if (!card.componentNames.includes('MOD.Core.UITouchReceiveComponent')) {
|
||||
card.componentNames += ',MOD.Core.UITouchReceiveComponent';
|
||||
}
|
||||
card.jsonString.enable = true;
|
||||
card.jsonString.visible = true;
|
||||
|
||||
const handLayout = cardFaceLayout(CARD_W);
|
||||
const previewValues = { Cost: cards[i - 1].cost, Name: cards[i - 1].name, Desc: cards[i - 1].desc };
|
||||
const children = handLayout.texts.map(([suffix, cfg]) => [suffix, { ...cfg, value: previewValues[suffix] }]);
|
||||
for (const [suffix, cfg] of children) {
|
||||
const path = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
|
||||
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : 8;
|
||||
let child = byPath.get(path);
|
||||
if (!child) {
|
||||
child = entity({
|
||||
id: guid('dck', i * 10 + children.findIndex(([s]) => s === suffix)),
|
||||
path,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: dOrder,
|
||||
components: [
|
||||
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color, dropShadow: cfg.dropShadow, outlineWidth: cfg.outlineWidth }),
|
||||
],
|
||||
});
|
||||
ui.ContentProto.Entities.push(child);
|
||||
byPath.set(path, child);
|
||||
} else {
|
||||
child.id = guid('dck', i * 10 + children.findIndex(([s]) => s === suffix));
|
||||
child.jsonString.enable = true;
|
||||
child.jsonString.visible = true;
|
||||
child.jsonString.displayOrder = dOrder;
|
||||
const ctr = child.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent');
|
||||
if (ctr) {
|
||||
const pivot = { x: 0.5, y: 0.5 };
|
||||
ctr.RectSize = cfg.size;
|
||||
ctr.anchoredPosition = cfg.pos;
|
||||
ctr.OffsetMin = { x: cfg.pos.x - pivot.x * cfg.size.x, y: cfg.pos.y - pivot.y * cfg.size.y };
|
||||
ctr.OffsetMax = { x: cfg.pos.x + (1 - pivot.x) * cfg.size.x, y: cfg.pos.y + (1 - pivot.y) * cfg.size.y };
|
||||
}
|
||||
child.jsonString['@components'][2].Text = cfg.value;
|
||||
child.jsonString['@components'][2].FontSize = cfg.fontSize;
|
||||
child.jsonString['@components'][2].MaxSize = cfg.fontSize;
|
||||
child.jsonString['@components'][2].FontColor = cfg.color;
|
||||
child.jsonString['@components'][2].Bold = cfg.bold;
|
||||
child.jsonString['@components'][2].DropShadow = cfg.dropShadow === true;
|
||||
child.jsonString['@components'][2].DropShadowDistance = cfg.dropShadow === true ? 18 : 32;
|
||||
child.jsonString['@components'][2].OutlineWidth = cfg.outlineWidth || 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 프레임 이미지가 이름판·코스트판을 내장하므로 Art만 유지 (잔존 NamePlate/CostPlate는 upsertUi 초입에서 제거)
|
||||
const frameKids = [
|
||||
['Art', 5, handLayout.art, WHITE, 0],
|
||||
];
|
||||
for (const [suffix, dOrder, cfg, color, spriteType] of frameKids) {
|
||||
const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
|
||||
let fe = byPath.get(fPath);
|
||||
if (!fe) {
|
||||
fe = entity({
|
||||
id: guid('dck', 200 + i * 10 + dOrder),
|
||||
path: fPath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: dOrder,
|
||||
components: [
|
||||
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
|
||||
sprite({ color, type: spriteType, raycast: false }),
|
||||
],
|
||||
});
|
||||
ui.ContentProto.Entities.push(fe);
|
||||
byPath.set(fPath, fe);
|
||||
} else {
|
||||
const ftr = fe.jsonString['@components'].find((c) => c['@type'] === 'MOD.Core.UITransformComponent');
|
||||
if (ftr) {
|
||||
ftr.RectSize = cfg.size;
|
||||
ftr.anchoredPosition = cfg.pos;
|
||||
ftr.OffsetMin = { x: cfg.pos.x - cfg.size.x / 2, y: cfg.pos.y - cfg.size.y / 2 };
|
||||
ftr.OffsetMax = { x: cfg.pos.x + cfg.size.x / 2, y: cfg.pos.y + cfg.size.y / 2 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit('DeckHud', buildDeckHud());
|
||||
|
||||
emit('DeckInspectHud', buildDeckInspect());
|
||||
|
||||
emit('DeckAllHud', buildDeckAll());
|
||||
|
||||
emit('CombatHud', buildCombat());
|
||||
|
||||
emit('RewardHud', buildReward());
|
||||
|
||||
emit('MapHud', buildMap());
|
||||
|
||||
emit('ShopHud', buildShop());
|
||||
|
||||
emit('RestHud', buildRest());
|
||||
|
||||
// 유물 방 — 보물 상자 (P8)
|
||||
emit('TreasureHud', buildTreasure());
|
||||
|
||||
// 전직 선택 (P9) — 보스 보상: 유물 vs 2차 전직
|
||||
emit('JobChoiceHud', buildJobChoice());
|
||||
|
||||
emit('JobSelectHud', buildJobSelect());
|
||||
|
||||
emit('MainMenu', buildMainMenu());
|
||||
|
||||
// ── LobbyHud — 반복 런의 허브. NPC 클릭으로 런시작/도감/영혼상점/게시판 ──
|
||||
emit('LobbyHud', buildLobby());
|
||||
|
||||
// ── BoardHud — 게시판(공지/팁) ──
|
||||
emit('BoardHud', buildBoard());
|
||||
|
||||
// ── SoulShopHud — 영혼 메타 상점 (Phase 9에서 해금 항목·구매 로직 채움) ──
|
||||
emit('SoulShopHud', buildSoulShop());
|
||||
|
||||
for (const section of UI_APPEND_ORDER) {
|
||||
const entities = uiSections.get(section);
|
||||
if (entities == null) {
|
||||
throw new Error(`[gen-slaydeck] missing generated UI section: ${section}`);
|
||||
}
|
||||
appendUiSection(ui, section, entities);
|
||||
}
|
||||
|
||||
// 엔티티 id 유일성 검증 — 같은 id가 다른 path에 재배정되면 메이커 refresh 병합이 꼬임
|
||||
const seenIds = new Map();
|
||||
for (const e of ui.ContentProto.Entities) {
|
||||
const prev = seenIds.get(e.id);
|
||||
if (prev != null) throw new Error(`[gen-slaydeck] 엔티티 id 중복: ${e.id} (${prev} ↔ ${e.path})`);
|
||||
seenIds.set(e.id, e.path);
|
||||
}
|
||||
|
||||
JSON.parse(JSON.stringify(ui));
|
||||
writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// 롤백/참조용 직접 실행 시에만 동작 (import 시에는 실행 안 함)
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
upsertUi();
|
||||
}
|
||||
60
tools/deck/lib/codeblock.mjs
Normal file
60
tools/deck/lib/codeblock.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
function prop(Type, Name, DefaultValue = 'nil') {
|
||||
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
|
||||
}
|
||||
|
||||
function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') {
|
||||
return {
|
||||
Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
|
||||
Arguments,
|
||||
Code,
|
||||
Scope: 2,
|
||||
ExecSpace,
|
||||
Attributes: [],
|
||||
Name,
|
||||
};
|
||||
}
|
||||
|
||||
function codeblock(id, name, properties, methods) {
|
||||
return {
|
||||
Id: '',
|
||||
GameId: '',
|
||||
EntryKey: `codeblock://${id}`,
|
||||
ContentType: 'x-mod/codeblock',
|
||||
Content: '',
|
||||
Usage: 0,
|
||||
UsePublish: 1,
|
||||
UseService: 0,
|
||||
CoreVersion: '26.5.0.0',
|
||||
StudioVersion: '',
|
||||
DynamicLoading: 0,
|
||||
ContentProto: {
|
||||
Use: 'Json',
|
||||
Json: {
|
||||
CoreVersion: { Major: 0, Minor: 2 },
|
||||
ScriptVersion: { Major: 1, Minor: 0 },
|
||||
Description: '',
|
||||
Id: id,
|
||||
Language: 1,
|
||||
Name: name,
|
||||
Type: 1,
|
||||
Source: 0,
|
||||
Target: null,
|
||||
Properties: properties,
|
||||
Methods: methods,
|
||||
EntityEventHandlers: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RUN_LENGTH = 5;
|
||||
const GOLD_PER_WIN = 25;
|
||||
const CARD_PRICE = 30;
|
||||
const REST_HEAL = 30;
|
||||
const RELIC_PRICE = 60;
|
||||
const ACT_COUNT = 5;
|
||||
const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05'];
|
||||
const LOBBY_MAP = 'lobby';
|
||||
const LOBBY_SPAWN = 'Vector3(-5, 0.03, 0)'; // 정찰: map01 지면 좌측
|
||||
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
237
tools/deck/lib/data.mjs
Normal file
237
tools/deck/lib/data.mjs
Normal file
@@ -0,0 +1,237 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||||
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
|
||||
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
|
||||
const CLASSES = {
|
||||
warrior: { label: '전사', maxHp: 80 },
|
||||
bandit: { label: '도적', maxHp: 70 },
|
||||
magician: { label: '마법사', maxHp: 70 },
|
||||
};
|
||||
for (const cls of Object.keys(CLASSES)) {
|
||||
if (!CARDS.starterDecks?.[cls]) throw new Error(`[gen-slaydeck] starterDecks.${cls} 없음`);
|
||||
for (const id of CARDS.starterDecks[cls]) {
|
||||
if (!CARDS.cards[id]) throw new Error(`[gen-slaydeck] starterDecks.${cls}에 없는 카드 id 참조: ${id}`);
|
||||
}
|
||||
}
|
||||
// 전직 옵션 (클래스별 2차 — JobSelectHud 동적 구성·SetJob 대표 카드)
|
||||
const JOBS = {
|
||||
warrior: [
|
||||
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack' },
|
||||
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce' },
|
||||
],
|
||||
magician: [
|
||||
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow' },
|
||||
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\n썬더 볼트(전체)\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt' },
|
||||
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal' },
|
||||
],
|
||||
bandit: [
|
||||
{ id: 'shiv', name: 'Shiv', desc: 'Many small attacks\nBlade Dance\nAccuracy · After Image', starter: 'BladeDance' },
|
||||
{ id: 'poisoner', name: 'Poison', desc: 'Poison scaling\nDeadly Poison\nCatalyst · Noxious Fumes', starter: 'DeadlyPoison' },
|
||||
{ id: 'trickster', name: 'Trickster', desc: 'Draw and tempo\nAcrobatics\nAdrenaline · Tools', starter: 'Acrobatics' },
|
||||
],
|
||||
};
|
||||
for (const [cls, jobs] of Object.entries(JOBS)) {
|
||||
for (const j of jobs) {
|
||||
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
|
||||
}
|
||||
}
|
||||
// 영혼(soul) 메타 해금 — 2차 전직 후 보스 클리어로 영혼 적립, 로비 영혼상점에서 구매 → 다음 런 이점
|
||||
const SOUL_UNLOCKS = [
|
||||
{ key: 'meso', name: '두둑한 지갑', desc: '런 시작 시 메소 +60', cost: 3 },
|
||||
{ key: 'hp', name: '단련된 육체', desc: '시작 최대 HP +15', cost: 4 },
|
||||
{ key: 'trim', name: '덱 정제', desc: '시작 덱에서 기본 카드 1장 제거', cost: 5 },
|
||||
{ key: 'relic', name: '유물 수집가', desc: '런 시작 시 유물 1개 추가', cost: 6 },
|
||||
];
|
||||
function luaSoulShopTable(unlocks) {
|
||||
const items = unlocks.map((u) => `\t{ key = ${luaStr(u.key)}, name = ${luaStr(u.name)}, desc = ${luaStr(u.desc)}, cost = ${u.cost} },`).join('\n');
|
||||
return `self.SoulShopDef = {\n${items}\n}`;
|
||||
}
|
||||
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
|
||||
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
|
||||
}
|
||||
|
||||
// 카드 프레임 (사용자 제작 이미지 — 로컬 임포트 .sprite RUID, 직업 3종 × 등급 3종)
|
||||
const CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json', 'utf8'));
|
||||
const RARITIES = ['normal', 'unique', 'legend'];
|
||||
for (const [fid, fr] of Object.entries(CARDFRAMES.frames)) {
|
||||
for (const r of RARITIES) {
|
||||
if (!fr[r]) throw new Error(`[gen-slaydeck] cardframes.frames.${fid}.${r} RUID 없음`);
|
||||
}
|
||||
}
|
||||
for (const [id, c] of Object.entries(CARDS.cards)) {
|
||||
if (!RARITIES.includes(c.rarity)) throw new Error(`[gen-slaydeck] 카드 ${id} rarity 누락/오류: ${c.rarity}`);
|
||||
const fc = CARDFRAMES.classToFrame[c.class];
|
||||
if (!fc || !CARDFRAMES.frames[fc]) throw new Error(`[gen-slaydeck] 카드 ${id} class ${c.class} → 프레임 매핑 없음`);
|
||||
}
|
||||
function frameRuid(card) {
|
||||
return CARDFRAMES.frames[CARDFRAMES.classToFrame[card.class]][card.rarity];
|
||||
}
|
||||
function luaFramesTable() {
|
||||
const frames = Object.entries(CARDFRAMES.frames).map(([fid, fr]) =>
|
||||
`\t${fid} = { normal = ${luaStr(fr.normal)}, unique = ${luaStr(fr.unique)}, legend = ${luaStr(fr.legend)} },`).join('\n');
|
||||
const cls = Object.entries(CARDFRAMES.classToFrame).map(([c, f]) => `\t${c} = ${luaStr(f)},`).join('\n');
|
||||
return `self.CardFrames = {\n${frames}\n}\nself.ClassToFrame = {\n${cls}\n}`;
|
||||
}
|
||||
function luaNodeIconsTable() {
|
||||
const rows = Object.entries(NODEICONS.icons).map(([t, ruid]) => `\t${t} = ${luaStr(ruid)},`).join('\n');
|
||||
return `self.NodeIcons = {\n${rows}\n}`;
|
||||
}
|
||||
function luaCharsTable() {
|
||||
const rows = Object.entries(CHARS.portraits).map(([c, ruid]) => `\t${c} = ${luaStr(ruid)},`).join('\n');
|
||||
return `self.ClassPortraits = {\n${rows}\n}`;
|
||||
}
|
||||
|
||||
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
|
||||
const MAP_ROWS = 6; // 걷는 행 1..6, 보스 row 7 (depth 최대 7)
|
||||
const MAP_COLS = 4;
|
||||
|
||||
// 보물 상자 스프라이트 (공식 maplestory 리소스, 메이커 선별)
|
||||
const CHEST_CLOSED_RUID = '43df67920c0d43298e0d93c02c6afa71';
|
||||
const CHEST_OPEN_RUID = '09c5cee56fd640bf8ae3a18ce50f4759';
|
||||
|
||||
// 노드 맵 아이콘/배경 (공식 maplestory RUID, data/nodeicons.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||||
const NODEICONS = JSON.parse(readFileSync('data/nodeicons.json', 'utf8'));
|
||||
for (const t of ['combat', 'elite', 'boss', 'shop', 'rest', 'treasure']) {
|
||||
if (!/^[0-9a-f]{32}$/.test((NODEICONS.icons || {})[t] || '')) throw new Error(`[gen-slaydeck] nodeicons.json icons.${t} RUID 누락/형식오류`);
|
||||
}
|
||||
if (!/^[0-9a-f]{32}$/.test(NODEICONS.background || '')) throw new Error('[gen-slaydeck] nodeicons.json background RUID 누락/형식오류');
|
||||
|
||||
// 캐릭터 선택 초상화 (메이커 임포트 RUID, data/characters.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||||
const CHARS = JSON.parse(readFileSync('data/characters.json', 'utf8'));
|
||||
for (const c of ['warrior', 'magician', 'bandit']) {
|
||||
if (!/^[0-9a-f]{32}$/.test((CHARS.portraits || {})[c] || '')) throw new Error(`[gen-slaydeck] characters.json portraits.${c} RUID 누락/형식오류`);
|
||||
}
|
||||
|
||||
// 전투 카메라 고정값(StS2: 플레이어 좌·몬스터 우). KickCombatCamera가 StartCombat에서 재confine에 사용.
|
||||
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
|
||||
|
||||
const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`);
|
||||
for (const id of RELICS.relicPool) {
|
||||
if (!RELICS.relics[id]) throw new Error(`[gen-slaydeck] relicPool에 없는 유물 id: ${id}`);
|
||||
}
|
||||
function luaRelicsTable(relics) {
|
||||
const lines = Object.entries(relics).map(([id, r]) =>
|
||||
`\t${id} = { name = ${luaStr(r.name)}, desc = ${luaStr(r.desc)}, hook = ${luaStr(r.hook)}, effect = ${luaStr(r.effect)}, value = ${r.value}, icon = ${luaStr(r.icon || '')} },`);
|
||||
return `self.Relics = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
|
||||
const POTIONS = JSON.parse(readFileSync('data/potions.json', 'utf8'));
|
||||
for (const [pid, p] of Object.entries(POTIONS.potions)) {
|
||||
if (!p.name || !p.effect || p.value == null) throw new Error(`[gen-slaydeck] potion 필드 누락: ${pid}`);
|
||||
}
|
||||
function luaPotionsTable(potions) {
|
||||
const lines = Object.entries(potions).map(([id, p]) =>
|
||||
`\t${id} = { name = ${luaStr(p.name)}, desc = ${luaStr(p.desc)}, effect = ${luaStr(p.effect)}, value = ${p.value}, icon = ${luaStr(p.icon || '')} },`);
|
||||
return `self.Potions = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
|
||||
function luaIntentsArray(intents) {
|
||||
return '{ ' + intents.map((it) => {
|
||||
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value != null ? it.value : 0}`];
|
||||
if (it.effect != null) fields.push(`effect = ${luaStr(it.effect)}`);
|
||||
if (it.card != null) fields.push(`card = ${luaStr(it.card)}`);
|
||||
if (it.count != null) fields.push(`count = ${it.count}`);
|
||||
return `{ ${fields.join(', ')} }`;
|
||||
}).join(', ') + ' }';
|
||||
}
|
||||
function luaEnemiesTable(enemies) {
|
||||
const lines = Object.entries(enemies).map(([id, e]) =>
|
||||
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
|
||||
return `self.Enemies = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
// Lua 직렬화 헬퍼
|
||||
function luaStr(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
||||
}
|
||||
function luaJobsTable(jobs) {
|
||||
const cls = Object.entries(jobs).map(([clsId, list]) => {
|
||||
const items = list.map((j) => `\t\t{ id = ${luaStr(j.id)}, name = ${luaStr(j.name)}, desc = ${luaStr(j.desc)}, starter = ${luaStr(j.starter)} },`).join('\n');
|
||||
return `\t${clsId} = {\n${items}\n\t},`;
|
||||
}).join('\n');
|
||||
return `self.Jobs = {\n${cls}\n}`;
|
||||
}
|
||||
function luaCardsTable(cards) {
|
||||
const lines = Object.entries(cards).map(([id, c]) => {
|
||||
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
|
||||
if (c.damage != null) fields.push(`damage = ${c.damage}`);
|
||||
if (c.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
|
||||
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
||||
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
||||
if (c.damagePerSkillInHand != null) fields.push(`damagePerSkillInHand = ${c.damagePerSkillInHand}`);
|
||||
if (c.damagePerCardDrawnThisCombat != null) fields.push(`damagePerCardDrawnThisCombat = ${c.damagePerCardDrawnThisCombat}`);
|
||||
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
|
||||
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
|
||||
if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
|
||||
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
|
||||
if (c.attackPoison != null) fields.push(`attackPoison = ${c.attackPoison}`);
|
||||
if (c.otherHandAtLeast != null) fields.push(`otherHandAtLeast = ${c.otherHandAtLeast}`);
|
||||
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
|
||||
if (c.block != null) fields.push(`block = ${c.block}`);
|
||||
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
|
||||
if (c.strength != null) fields.push(`strength = ${c.strength}`);
|
||||
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
||||
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
||||
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`);
|
||||
if (c.weak != null) fields.push(`weak = ${c.weak}`);
|
||||
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
|
||||
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`);
|
||||
if (c.value != null) fields.push(`value = ${c.value}`);
|
||||
if (!c.class) throw new Error(`[gen-slaydeck] 카드 ${id}에 class 누락`);
|
||||
fields.push(`class = ${luaStr(c.class)}`);
|
||||
fields.push(`rarity = ${luaStr(c.rarity)}`);
|
||||
if (c.hits != null) fields.push(`hits = ${c.hits}`);
|
||||
if (c.pierce === true) fields.push('pierce = true');
|
||||
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
|
||||
if (c.draw != null) fields.push(`draw = ${c.draw}`);
|
||||
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
|
||||
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
||||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||||
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
||||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||||
if (c.discard != null) fields.push(`discard = ${c.discard}`);
|
||||
if (c.discardAll === true) fields.push('discardAll = true');
|
||||
if (c.drawPerDiscarded != null) fields.push(`drawPerDiscarded = ${c.drawPerDiscarded}`);
|
||||
if (c.addShiv != null) fields.push(`addShiv = ${c.addShiv}`);
|
||||
if (c.turnStartShiv != null) fields.push(`turnStartShiv = ${c.turnStartShiv}`);
|
||||
if (c.turnStartDraw != null) fields.push(`turnStartDraw = ${c.turnStartDraw}`);
|
||||
if (c.turnStartDiscard != null) fields.push(`turnStartDiscard = ${c.turnStartDiscard}`);
|
||||
if (c.handCostZeroThisTurn === true) fields.push('handCostZeroThisTurn = true');
|
||||
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
|
||||
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
|
||||
if (c.useAllEnergy === true) fields.push('useAllEnergy = true');
|
||||
if (c.xDamagePerEnergy != null) fields.push(`xDamagePerEnergy = ${c.xDamagePerEnergy}`);
|
||||
if (c.xWeakPerEnergy != null) fields.push(`xWeakPerEnergy = ${c.xWeakPerEnergy}`);
|
||||
if (c.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`);
|
||||
if (c.nextTurnDraw != null) fields.push(`nextTurnDraw = ${c.nextTurnDraw}`);
|
||||
if (c.nextTurnKeepBlock === true) fields.push('nextTurnKeepBlock = true');
|
||||
if (c.nextTurnAttackMultiplier != null) fields.push(`nextTurnAttackMultiplier = ${c.nextTurnAttackMultiplier}`);
|
||||
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
|
||||
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
|
||||
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
|
||||
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
|
||||
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
|
||||
if (c.innate === true) fields.push('innate = true');
|
||||
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
|
||||
if (c.sly === true) fields.push('sly = true');
|
||||
if (c.retain === true) fields.push('retain = true');
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) fields.push('exhaust = true');
|
||||
if (c.aoe === true) fields.push('aoe = true');
|
||||
if (c.unplayable === true) fields.push('unplayable = true');
|
||||
if (c.curse === true) fields.push('curse = true');
|
||||
if (c.token === true) fields.push('token = true');
|
||||
if (c.endTurnDamage != null) fields.push(`endTurnDamage = ${c.endTurnDamage}`);
|
||||
if (c.fx != null) fields.push(`fx = ${luaStr(c.fx)}`);
|
||||
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
||||
return `\t${id} = { ${fields.join(', ')} },`;
|
||||
});
|
||||
return `self.Cards = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
function luaDeckTable(deck) {
|
||||
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
|
||||
}
|
||||
|
||||
export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };
|
||||
338
tools/deck/lib/ui-helpers.mjs
Normal file
338
tools/deck/lib/ui-helpers.mjs
Normal file
@@ -0,0 +1,338 @@
|
||||
const UI_FILE = 'ui/DefaultGroup.ui';
|
||||
const COMMON_FILE = 'Global/common.gamelogic';
|
||||
const UI_ROOT = '/ui/DefaultGroup';
|
||||
const GENERATED_UI_SECTIONS = [
|
||||
'DeckHud',
|
||||
'DeckInspectHud',
|
||||
'DeckAllHud',
|
||||
'CombatHud',
|
||||
'RewardHud',
|
||||
'MapHud',
|
||||
'ShopHud',
|
||||
'RestHud',
|
||||
'TreasureHud',
|
||||
'JobChoiceHud',
|
||||
'JobSelectHud',
|
||||
'MainMenu',
|
||||
'LobbyHud',
|
||||
'BoardHud',
|
||||
'SoulShopHud',
|
||||
];
|
||||
const UI_APPEND_ORDER = [
|
||||
'DeckHud',
|
||||
'CombatHud',
|
||||
'RewardHud',
|
||||
'MapHud',
|
||||
'ShopHud',
|
||||
'RestHud',
|
||||
'TreasureHud',
|
||||
'JobChoiceHud',
|
||||
'JobSelectHud',
|
||||
'DeckInspectHud',
|
||||
'DeckAllHud',
|
||||
'MainMenu',
|
||||
'LobbyHud',
|
||||
'BoardHud',
|
||||
'SoulShopHud',
|
||||
];
|
||||
const DISABLED_STOCK_CONTROLS = ['Button_Attack', 'Button_Jump', 'UIJoystick'];
|
||||
|
||||
const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
|
||||
const DARK = { r: 0.08, g: 0.09, b: 0.11, a: 0.92 };
|
||||
const GOLD = { r: 0.94, g: 0.74, b: 0.26, a: 1 };
|
||||
const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1 };
|
||||
const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1 };
|
||||
const SKILL = { r: 0.46, g: 0.68, b: 0.52, a: 1 };
|
||||
const DAMAGE_DIGIT_RUIDS = [
|
||||
'b94c19830538447f81617035d89bcc05',
|
||||
'01b023122a6f4a5789e1d4c61ff8f430',
|
||||
'57ff71d1b9eb471b9feb1c15348770c9',
|
||||
'cab92837798a42ad9143c67e93f999e1',
|
||||
'366f271f9ca94a0684083aad9298efad',
|
||||
'5c7a6ad38491466aa84bf450e0fdcf25',
|
||||
'7d82a6838e1b4f4a8a0f7420db34c985',
|
||||
'c0765bb1e47d46ffbe1df4ac19ea9b1b',
|
||||
'6ea0bfed61e149f88a9b3f22dd79774f',
|
||||
'82ad2acaae4e4b3fb87bf73635250d22',
|
||||
];
|
||||
const DAMAGE_POP_MAX_DIGITS = 5;
|
||||
const DAMAGE_POP_DIGIT_W = 22;
|
||||
const DAMAGE_POP_DIGIT_H = 32;
|
||||
const DAMAGE_POP_DIGIT_SPACING = -4;
|
||||
|
||||
const MAX_MONSTERS = 4;
|
||||
const HEAD_OFFSET_Y = 1.4; // 몬스터 월드 원점 위로 띄울 높이(머리 위) — world→screen 변환 전 가산
|
||||
|
||||
const HP_BAR_W = 140;
|
||||
const WHITE = { r: 1, g: 1, b: 1, a: 1 };
|
||||
const CARD_NAME_TEXT = { r: 1, g: 0.92, b: 0.62, a: 1 };
|
||||
const CARD_DESC_TEXT = { r: 0.98, g: 0.96, b: 0.9, a: 1 };
|
||||
// 카드 프레임(1054×1492 원본) 슬롯 레이아웃 — 픽셀 실측을 180×250 카드 좌표로 환산한 기준값을 폭 비례 스케일.
|
||||
// 실측(워리어·메이지·밴딧 공통): 육각 중심 (120,127)→(-70,104) · 배너 본체 y55..165, x215..1015→중심 (+15,+107)
|
||||
// · 설명 박스 y~1030..1480→중심 (0,-86) · 아트 영역 y260..1030→중심 (0,+17)
|
||||
function cardFaceLayout(W) {
|
||||
const s = W / 180;
|
||||
const r = (v) => Math.round(v * s);
|
||||
return {
|
||||
texts: [
|
||||
['Cost', { size: { x: r(40), y: r(40) }, pos: { x: r(-70), y: r(104) }, fontSize: r(24), bold: true, color: WHITE, dropShadow: false, outlineWidth: 2 }],
|
||||
['Name', { size: { x: r(142), y: r(28) }, pos: { x: r(15), y: r(106) }, fontSize: r(17), bold: true, color: CARD_NAME_TEXT, dropShadow: false, outlineWidth: 2 }],
|
||||
['Desc', { size: { x: r(158), y: r(78) }, pos: { x: 0, y: r(-82) }, fontSize: r(14), bold: true, color: CARD_DESC_TEXT, dropShadow: false, outlineWidth: 2 }],
|
||||
],
|
||||
art: { size: { x: r(112), y: r(112) }, pos: { x: 0, y: r(17) } },
|
||||
};
|
||||
}
|
||||
const CARD_W = 180;
|
||||
const CARD_H = 250;
|
||||
const CARD_SPACING = 200;
|
||||
const CARD_XS = [-400, -200, 0, 200, 400];
|
||||
|
||||
const ALIGN_CENTER = 0;
|
||||
const ALIGN_BOTTOM_CENTER = 6;
|
||||
|
||||
function guid(prefix, n) {
|
||||
// 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑.
|
||||
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : prefix === 'job' ? 0xe4
|
||||
: prefix === 'ins2' ? 0xe5 : prefix === 'all2' ? 0xe6 : prefix === 'rwd2' ? 0xe7 : prefix === 'shp2' ? 0xe8 : prefix === 'lob' ? 0xe9 : prefix === 'brd' ? 0xea : prefix === 'soul' ? 0xeb : 0xfe;
|
||||
const v = (ns * 0x100000 + n) >>> 0;
|
||||
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
function transform({ parentW, parentH, anchor, pivot, size, pos, align = 0 }) {
|
||||
const offMin = { x: pos.x - pivot.x * size.x, y: pos.y - pivot.y * size.y };
|
||||
const offMax = { x: pos.x + (1 - pivot.x) * size.x, y: pos.y + (1 - pivot.y) * size.y };
|
||||
return {
|
||||
'@type': 'MOD.Core.UITransformComponent',
|
||||
ActivePlatform: 255,
|
||||
AlignmentOption: align,
|
||||
AnchorsMax: anchor,
|
||||
AnchorsMin: anchor,
|
||||
MobileOnly: false,
|
||||
OffsetMax: offMax,
|
||||
OffsetMin: offMin,
|
||||
Pivot: pivot,
|
||||
RectSize: size,
|
||||
UIMode: 1,
|
||||
UIScale: { x: 1, y: 1, z: 1 },
|
||||
UIVersion: 2,
|
||||
anchoredPosition: pos,
|
||||
Position: { x: anchor.x * parentW - parentW / 2 + pos.x, y: anchor.y * parentH - parentH / 2 + pos.y, z: 0 },
|
||||
QuaternionRotation: { x: 0, y: 0, z: 0, w: 1 },
|
||||
Scale: { x: 1, y: 1, z: 1 },
|
||||
Enable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function sprite({ dataId = '', color = TRANSPARENT, type = 1, raycast = false }) {
|
||||
return {
|
||||
'@type': 'MOD.Core.SpriteGUIRendererComponent',
|
||||
AnimClipPlayType: 0,
|
||||
EndFrameIndex: 2147483647,
|
||||
ImageRUID: { DataId: dataId },
|
||||
LocalPosition: { x: 0, y: 0 },
|
||||
LocalScale: { x: 1, y: 1 },
|
||||
OverrideSorting: false,
|
||||
PlayRate: 1,
|
||||
PreserveSprite: 0,
|
||||
StartFrameIndex: 0,
|
||||
Color: color,
|
||||
DropShadow: false,
|
||||
DropShadowAngle: 30,
|
||||
DropShadowColor: { r: 0, g: 0, b: 0, a: 0.72 },
|
||||
DropShadowDistance: 32,
|
||||
FillAmount: 1,
|
||||
FillCenter: true,
|
||||
FillClockWise: true,
|
||||
FillMethod: 0,
|
||||
FillOrigin: 0,
|
||||
FlipX: false,
|
||||
FlipY: false,
|
||||
FrameColumn: 1,
|
||||
FrameRate: 0,
|
||||
FrameRow: 1,
|
||||
Outline: false,
|
||||
OutlineColor: { r: 0, g: 0, b: 0, a: 1 },
|
||||
OutlineWidth: 3,
|
||||
RaycastTarget: raycast,
|
||||
Type: type,
|
||||
Enable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function button({ enabled = true } = {}) {
|
||||
return {
|
||||
'@type': 'MOD.Core.ButtonComponent',
|
||||
Colors: {
|
||||
NormalColor: { r: 1, g: 1, b: 1, a: 1 },
|
||||
HighlightedColor: { r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1 },
|
||||
PressedColor: { r: 0.784313738, g: 0.784313738, b: 0.784313738, a: 1 },
|
||||
SelectedColor: { r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1 },
|
||||
DisabledColor: { r: 0.784313738, g: 0.784313738, b: 0.784313738, a: 0.5019608 },
|
||||
ColorMultiplier: 1,
|
||||
FadeDuration: 0.1,
|
||||
},
|
||||
ImageRUIDs: {
|
||||
HighlightedSprite: null,
|
||||
PressedSprite: null,
|
||||
SelectedSprite: null,
|
||||
DisabledSprite: null,
|
||||
},
|
||||
KeyCode: 0,
|
||||
OverrideSorting: false,
|
||||
Transition: 1,
|
||||
Enable: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function text({ value, fontSize, bold = false, color = { r: 1, g: 1, b: 1, a: 1 }, alignment = 4, dropShadow = false, outlineWidth = 1 }) {
|
||||
return {
|
||||
'@type': 'MOD.Core.TextComponent',
|
||||
Alignment: alignment,
|
||||
Bold: bold,
|
||||
DropShadow: dropShadow,
|
||||
DropShadowAngle: 30,
|
||||
DropShadowColor: { r: 0, g: 0, b: 0, a: 0.72 },
|
||||
DropShadowDistance: dropShadow ? 18 : 32,
|
||||
Font: 0,
|
||||
FontColor: color,
|
||||
FontSize: fontSize,
|
||||
MaxSize: fontSize,
|
||||
MinSize: 8,
|
||||
OutlineColor: { r: 0.08, g: 0.08, b: 0.08, a: 1 },
|
||||
OutlineDistance: { x: 1, y: -1 },
|
||||
OutlineWidth: outlineWidth,
|
||||
Overflow: 0,
|
||||
OverrideSorting: false,
|
||||
Padding: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
SizeFit: false,
|
||||
Text: value,
|
||||
UseOutLine: true,
|
||||
Enable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function scrollLayoutGroup({ cellSize, spacing, columns }) {
|
||||
return {
|
||||
'@type': 'MOD.Core.ScrollLayoutGroupComponent',
|
||||
CellSize: cellSize,
|
||||
ChildAlignment: 0,
|
||||
Constraint: 1,
|
||||
ConstraintCount: columns,
|
||||
GridChildAlignment: 0,
|
||||
GridSpacing: spacing,
|
||||
HorizontalScrollBarDirection: 0,
|
||||
IgnoreMapLayerCheck: false,
|
||||
OrderInLayer: 0,
|
||||
OverrideSorting: false,
|
||||
Padding: { left: 16, right: 16, top: 16, bottom: 16 },
|
||||
ReverseArrangement: false,
|
||||
ScrollBarBackgroundColor: { r: 1, g: 1, b: 1, a: 0.18 },
|
||||
ScrollBarBgImageRUID: { DataId: '' },
|
||||
ScrollBarHandleColor: { r: 0.94, g: 0.74, b: 0.26, a: 0.9 },
|
||||
ScrollBarHandleImageRUID: { DataId: '' },
|
||||
ScrollBarThickness: 12,
|
||||
ScrollBarVisible: 2,
|
||||
SortingLayer: 'UI',
|
||||
Spacing: 0,
|
||||
StartAxis: 0,
|
||||
StartCorner: 0,
|
||||
Type: 2,
|
||||
UseScroll: true,
|
||||
VerticalScrollBarDirection: 1,
|
||||
Enable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function popupLayerFor(path) {
|
||||
if (path.startsWith('/ui/DefaultGroup/DeckAllHud')) return { root: '/ui/DefaultGroup/DeckAllHud', base: 4000 };
|
||||
if (path.startsWith('/ui/DefaultGroup/DeckInspectHud')) return { root: '/ui/DefaultGroup/DeckInspectHud', base: 3000 };
|
||||
return null;
|
||||
}
|
||||
|
||||
function uiOrderFor(path, displayOrder) {
|
||||
const popup = popupLayerFor(path);
|
||||
if (popup != null) {
|
||||
const relative = path.slice(popup.root.length).split('/').filter(Boolean);
|
||||
return popup.base + relative.length * 100 + displayOrder;
|
||||
}
|
||||
return displayOrder;
|
||||
}
|
||||
|
||||
function displayOrderFor(path, displayOrder) {
|
||||
return uiOrderFor(path, displayOrder);
|
||||
}
|
||||
|
||||
function applySortingOverride(path, components, displayOrder) {
|
||||
if (popupLayerFor(path) == null) return components;
|
||||
const order = uiOrderFor(path, displayOrder);
|
||||
return components.map((component) => {
|
||||
if (component['@type'] !== 'MOD.Core.SpriteGUIRendererComponent' && component['@type'] !== 'MOD.Core.TextComponent') {
|
||||
return component;
|
||||
}
|
||||
return {
|
||||
...component,
|
||||
OverrideSorting: true,
|
||||
SortingLayer: 'UI',
|
||||
OrderInLayer: order,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function entity({ id, path, modelId, entryId, componentNames, components, displayOrder }) {
|
||||
const parts = path.split('/');
|
||||
const name = parts[parts.length - 1];
|
||||
const sortedComponents = applySortingOverride(path, components, displayOrder);
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
componentNames,
|
||||
jsonString: {
|
||||
name,
|
||||
path,
|
||||
nameEditable: true,
|
||||
enable: true,
|
||||
visible: true,
|
||||
localize: true,
|
||||
displayOrder: displayOrderFor(path, displayOrder),
|
||||
pathConstraints: '/'.repeat(parts.length - 1),
|
||||
revision: 1,
|
||||
origin: {
|
||||
type: 'Model',
|
||||
entry_id: entryId,
|
||||
sub_entity_id: null,
|
||||
root_entity_id: null,
|
||||
replaced_model_id: null,
|
||||
},
|
||||
modelId,
|
||||
'@components': sortedComponents,
|
||||
'@version': 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uiPath(...parts) {
|
||||
return [UI_ROOT, ...parts].join('/');
|
||||
}
|
||||
|
||||
function sectionRoot(section) {
|
||||
return uiPath(section);
|
||||
}
|
||||
|
||||
function isGeneratedUiEntity(e) {
|
||||
return GENERATED_UI_SECTIONS.some((section) => e.path.startsWith(sectionRoot(section)));
|
||||
}
|
||||
|
||||
function appendUiSection(ui, section, entities) {
|
||||
if (!GENERATED_UI_SECTIONS.includes(section)) {
|
||||
throw new Error(`[gen-slaydeck] unknown generated UI section: ${section}`);
|
||||
}
|
||||
const root = sectionRoot(section);
|
||||
for (const e of entities) {
|
||||
if (!e.path.startsWith(root)) {
|
||||
throw new Error(`[gen-slaydeck] ${section} section emitted unexpected path: ${e.path}`);
|
||||
}
|
||||
}
|
||||
ui.ContentProto.Entities.push(...entities);
|
||||
}
|
||||
|
||||
export { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection };
|
||||
28
tools/deck/reconnect-ui-paths.mjs
Normal file
28
tools/deck/reconnect-ui-paths.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// 일회성·멱등 마이그레이션: cb/*.mjs의 UI 경로 리터럴을 메이커 재편 UIGroup으로 재연결.
|
||||
// 이미 이동된 경로는 매치 안 됨(멱등). MainMenu·Button_Attack/Jump·UIJoystick(=DefaultGroup 잔류)은 미변경.
|
||||
// 섹션→UIGroup 매핑은 tools/verify/uimap.mjs 탐색으로 검증된 실제 .ui 분포 기준.
|
||||
const MOVE = {
|
||||
CharacterSelectHud: 'SelectUIGroup', JobChoiceHud: 'SelectUIGroup', JobSelectHud: 'SelectUIGroup',
|
||||
LobbyHud: 'LobbyUIGroup', BoardHud: 'LobbyUIGroup', SoulShopHud: 'LobbyUIGroup',
|
||||
CombatHud: 'RunUIGroup', DeckHud: 'RunUIGroup', CardHand: 'RunUIGroup', MapHud: 'RunUIGroup',
|
||||
RewardHud: 'RunUIGroup', ShopHud: 'RunUIGroup', RestHud: 'RunUIGroup', TreasureHud: 'RunUIGroup',
|
||||
DeckInspectHud: 'DeckUIGroup', DeckAllHud: 'DeckUIGroup',
|
||||
};
|
||||
const CB_DIR = 'tools/deck/cb';
|
||||
let n = 0;
|
||||
for (const f of readdirSync(CB_DIR).filter((x) => x.endsWith('.mjs'))) {
|
||||
const p = join(CB_DIR, f);
|
||||
const before = readFileSync(p, 'utf8');
|
||||
let s = before;
|
||||
// 1) 몬스터 슬롯: 그룹+이름 동시 (CombatHud 일반 remap보다 먼저). 슬롯 5→4는 MAX_MONSTERS(=4)가 이미 반영.
|
||||
s = s.split('/ui/DefaultGroup/CombatHud/MonsterSlot').join('/ui/RunUIGroup/CombatHud/MonsterStatus');
|
||||
// 2) 섹션별 그룹 접두사 remap
|
||||
for (const [section, group] of Object.entries(MOVE)) {
|
||||
s = s.split(`/ui/DefaultGroup/${section}`).join(`/ui/${group}/${section}`);
|
||||
}
|
||||
if (s !== before) { writeFileSync(p, s, 'utf8'); n++; console.log(' remapped', f); }
|
||||
}
|
||||
console.log(`reconnect-ui-paths: ${n} files updated`);
|
||||
69
tools/verify/cbgap.mjs
Normal file
69
tools/verify/cbgap.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// cb 컨트롤러가 참조하는 /ui/DefaultGroup/... 경로를, 사용자가 메이커에서 재편한
|
||||
// 새 UIGroup(.ui) 구조에 대조해 "그대로 옮겨지면 해결(resolved)" vs "이름/구조가 바뀌어
|
||||
// 못 찾음(GAP)"을 분류. GAP = 사용자에게 구→신 매핑을 받아야 할 항목.
|
||||
// 출력은 경로 이름 + EXISTS/GAP boolean 뿐(엔티티 값 본문 미출력 → RULES §2 준수).
|
||||
|
||||
// 섹션(=DefaultGroup 다음 첫 세그먼트) → 이동된 UIGroup (uimap.mjs 탐색 결과 기반)
|
||||
const SECTION_TO_GROUP = {
|
||||
MainMenu: 'DefaultGroup', Button_Attack: 'DefaultGroup', Button_Jump: 'DefaultGroup', UIJoystick: 'DefaultGroup',
|
||||
CharacterSelectHud: 'SelectUIGroup', JobChoiceHud: 'SelectUIGroup', JobSelectHud: 'SelectUIGroup',
|
||||
LobbyHud: 'LobbyUIGroup', BoardHud: 'LobbyUIGroup', SoulShopHud: 'LobbyUIGroup',
|
||||
CombatHud: 'RunUIGroup', DeckHud: 'RunUIGroup', CardHand: 'RunUIGroup', MapHud: 'RunUIGroup',
|
||||
RewardHud: 'RunUIGroup', ShopHud: 'RunUIGroup', RestHud: 'RunUIGroup', TreasureHud: 'RunUIGroup',
|
||||
DeckInspectHud: 'DeckUIGroup', DeckAllHud: 'DeckUIGroup',
|
||||
};
|
||||
|
||||
// 새 .ui 로드
|
||||
const UI_DIR = 'ui';
|
||||
const ui = {};
|
||||
for (const f of readdirSync(UI_DIR).filter((x) => x.endsWith('.ui'))) {
|
||||
ui[f.replace('.ui', '')] = readFileSync(join(UI_DIR, f), 'utf8');
|
||||
}
|
||||
|
||||
// cb 소스에서 /ui/DefaultGroup/... 경로 리터럴 추출 (템플릿 ${...} 포함)
|
||||
const CB_DIR = 'tools/deck/cb';
|
||||
const re = /\/ui\/DefaultGroup\/[^"'`\s),]+/g;
|
||||
const paths = new Set();
|
||||
for (const f of readdirSync(CB_DIR).filter((x) => x.endsWith('.mjs'))) {
|
||||
const src = readFileSync(join(CB_DIR, f), 'utf8');
|
||||
for (const m of src.match(re) || []) paths.add(m);
|
||||
}
|
||||
|
||||
// 동적 세그먼트(${...}) 앞 정적 prefix만 취해 존재검사 (e.g. .../Card${i} → .../Card)
|
||||
function staticPrefix(p) {
|
||||
const i = p.indexOf('${');
|
||||
if (i === -1) return { p, dyn: false };
|
||||
// ${...} 직전까지 (마지막 세그먼트의 정적 앞부분 포함)
|
||||
return { p: p.slice(0, i), dyn: true };
|
||||
}
|
||||
|
||||
const bySection = {};
|
||||
for (const p of [...paths].sort()) {
|
||||
const rest = p.slice('/ui/DefaultGroup/'.length); // Section/child/...
|
||||
const section = rest.split('/')[0].split('${')[0];
|
||||
const group = SECTION_TO_GROUP[section] || '??';
|
||||
const newPath = group === '??' ? p : p.replace('/ui/DefaultGroup/', `/ui/${group}/`);
|
||||
const { p: probe } = staticPrefix(newPath);
|
||||
const blob = ui[group] || '';
|
||||
const exists = group !== '??' && blob.includes(probe);
|
||||
(bySection[section] ||= []).push({ old: p, neu: newPath, exists, group });
|
||||
}
|
||||
|
||||
// 보고
|
||||
let totResolved = 0, totGap = 0;
|
||||
for (const section of Object.keys(bySection).sort()) {
|
||||
const rows = bySection[section];
|
||||
const group = rows[0].group;
|
||||
const gaps = rows.filter((r) => !r.exists);
|
||||
const ok = rows.length - gaps.length;
|
||||
totResolved += ok; totGap += gaps.length;
|
||||
console.log(`\n[${section}] → ${group} 해결 ${ok} / GAP ${gaps.length} (총 ${rows.length})`);
|
||||
for (const g of gaps) {
|
||||
// GAP만 상세 출력 (사용자가 신규 이름 채워야 할 대상)
|
||||
console.log(` GAP ${g.old.replace('/ui/DefaultGroup/', '')} →(없음) ${g.neu.replace(`/ui/${group}/`, `${group}: `)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n=== 합계: 자동해결 ${totResolved} / GAP ${totGap} (distinct 경로 ${paths.size}) ===`);
|
||||
21
tools/verify/diffcheck.mjs
Normal file
21
tools/verify/diffcheck.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
// 산출물 바이트-동일 게이트: 워킹트리 vs 지정 ref(blob)를 줄바꿈 정규화 후 비교.
|
||||
// 산출물 경로를 Bash 명령줄에 노출하지 않아 settings.json deny를 회피(count.mjs와 동일 취지).
|
||||
// 사용: node tools/verify/diffcheck.mjs [ref] (ref 기본 HEAD; 예: origin/main)
|
||||
const ref = process.argv[2] || 'HEAD';
|
||||
const FILES = [
|
||||
'ui/DefaultGroup.ui',
|
||||
'RootDesk/MyDesk/SlayDeckController.codeblock',
|
||||
'Global/common.gamelogic',
|
||||
];
|
||||
let allSame = true;
|
||||
for (const f of FILES) {
|
||||
const work = readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
||||
const blob = execSync(`git show ${ref}:${f}`, { encoding: 'utf8', maxBuffer: 1 << 30 }).replace(/\r\n/g, '\n');
|
||||
const same = work === blob;
|
||||
if (!same) allSame = false;
|
||||
console.log(`${same ? 'IDENTICAL ' : 'DIFFER '} ${f}${same ? '' : ` (work=${work.length} ${ref}=${blob.length})`}`);
|
||||
}
|
||||
console.log(allSame ? `\n=> 산출물이 ${ref}와 바이트-동일` : `\n=> ${ref}와 차이 있음 (확인 필요)`);
|
||||
35
tools/verify/uimap.mjs
Normal file
35
tools/verify/uimap.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// UIGroup(.ui) 매핑 헬퍼 (RULES §2: 내용 출력 금지·카운트만).
|
||||
// 어떤 섹션/엔티티 이름이 어느 .ui 파일에 들어있는지 카운트 매트릭스로 보고.
|
||||
// 산출물 경로를 명령줄에 노출하지 않아(디렉토리 스캔) deny 회피.
|
||||
//
|
||||
// 사용: node tools/verify/uimap.mjs <pattern> [<pattern> ...]
|
||||
// 각 pattern은 정규식. 출력은 "pattern | file=count ..." 형식(본문 미출력).
|
||||
const UI_DIR = 'ui';
|
||||
const files = readdirSync(UI_DIR).filter((f) => f.endsWith('.ui'));
|
||||
const cache = {};
|
||||
for (const f of files) {
|
||||
cache[f] = readFileSync(join(UI_DIR, f), 'utf8');
|
||||
}
|
||||
// 파일별 크기/JSON 유효성 헤더
|
||||
console.log('=== ui/*.ui ===');
|
||||
for (const f of files) {
|
||||
let ok = false;
|
||||
try { JSON.parse(cache[f]); ok = true; } catch { ok = false; }
|
||||
console.log(` ${f} bytes=${cache[f].length} jsonValid=${ok}`);
|
||||
}
|
||||
const pats = process.argv.slice(2);
|
||||
if (pats.length === 0) process.exit(0);
|
||||
console.log('=== matches (file=count, 0 생략) ===');
|
||||
for (const pat of pats) {
|
||||
const re = new RegExp(pat, 'g');
|
||||
const hits = [];
|
||||
for (const f of files) {
|
||||
const m = cache[f].match(re);
|
||||
const n = m ? m.length : 0;
|
||||
if (n > 0) hits.push(`${f}=${n}`);
|
||||
}
|
||||
console.log(` /${pat}/ ${hits.length ? hits.join(' ') : '(none)'}`);
|
||||
}
|
||||
157957
ui/DeckUIGroup.ui
Normal file
157957
ui/DeckUIGroup.ui
Normal file
File diff suppressed because it is too large
Load Diff
243922
ui/DefaultGroup.ui
243922
ui/DefaultGroup.ui
File diff suppressed because it is too large
Load Diff
5414
ui/LobbyUIGroup.ui
Normal file
5414
ui/LobbyUIGroup.ui
Normal file
File diff suppressed because it is too large
Load Diff
100
ui/PopupGroup.ui
100
ui/PopupGroup.ui
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"@type": "MOD.Core.UIGroupComponent",
|
||||
"DefaultShow": false,
|
||||
"GroupOrder": 1,
|
||||
"GroupOrder": 2,
|
||||
"GroupType": 1,
|
||||
"Enable": true
|
||||
},
|
||||
@@ -585,7 +585,7 @@
|
||||
{
|
||||
"id": "94a274e4-4111-40f1-924d-c95a3a1f14d5",
|
||||
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnOK",
|
||||
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent",
|
||||
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent",
|
||||
"jsonString": {
|
||||
"name": "PopupBtnOK",
|
||||
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnOK",
|
||||
@@ -719,53 +719,6 @@
|
||||
"Type": 1,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.ButtonComponent",
|
||||
"Colors": {
|
||||
"NormalColor": {
|
||||
"r": 1.0,
|
||||
"g": 1.0,
|
||||
"b": 1.0,
|
||||
"a": 1.0
|
||||
},
|
||||
"HighlightedColor": {
|
||||
"r": 0.9607843,
|
||||
"g": 0.9607843,
|
||||
"b": 0.9607843,
|
||||
"a": 1.0
|
||||
},
|
||||
"PressedColor": {
|
||||
"r": 0.784313738,
|
||||
"g": 0.784313738,
|
||||
"b": 0.784313738,
|
||||
"a": 1.0
|
||||
},
|
||||
"SelectedColor": {
|
||||
"r": 0.9607843,
|
||||
"g": 0.9607843,
|
||||
"b": 0.9607843,
|
||||
"a": 1.0
|
||||
},
|
||||
"DisabledColor": {
|
||||
"r": 0.784313738,
|
||||
"g": 0.784313738,
|
||||
"b": 0.784313738,
|
||||
"a": 0.5019608
|
||||
},
|
||||
"ColorMultiplier": 1.0,
|
||||
"FadeDuration": 0.1
|
||||
},
|
||||
"ImageRUIDs": {
|
||||
"HighlightedSprite": null,
|
||||
"PressedSprite": null,
|
||||
"SelectedSprite": null,
|
||||
"DisabledSprite": null
|
||||
},
|
||||
"KeyCode": 0,
|
||||
"OverrideSorting": false,
|
||||
"Transition": 1,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.TextComponent",
|
||||
"Alignment": 4,
|
||||
@@ -820,7 +773,7 @@
|
||||
{
|
||||
"id": "0f5de49b-2adc-409a-816d-15aa43df8e0d",
|
||||
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnCancel",
|
||||
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent",
|
||||
"componentNames": "MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent",
|
||||
"jsonString": {
|
||||
"name": "PopupBtnCancel",
|
||||
"path": "/ui/PopupGroup/PopupBack/PopupPanel/PopupBtnCancel",
|
||||
@@ -954,53 +907,6 @@
|
||||
"Type": 1,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.ButtonComponent",
|
||||
"Colors": {
|
||||
"NormalColor": {
|
||||
"r": 1.0,
|
||||
"g": 1.0,
|
||||
"b": 1.0,
|
||||
"a": 1.0
|
||||
},
|
||||
"HighlightedColor": {
|
||||
"r": 0.9607843,
|
||||
"g": 0.9607843,
|
||||
"b": 0.9607843,
|
||||
"a": 1.0
|
||||
},
|
||||
"PressedColor": {
|
||||
"r": 0.784313738,
|
||||
"g": 0.784313738,
|
||||
"b": 0.784313738,
|
||||
"a": 1.0
|
||||
},
|
||||
"SelectedColor": {
|
||||
"r": 0.9607843,
|
||||
"g": 0.9607843,
|
||||
"b": 0.9607843,
|
||||
"a": 1.0
|
||||
},
|
||||
"DisabledColor": {
|
||||
"r": 0.784313738,
|
||||
"g": 0.784313738,
|
||||
"b": 0.784313738,
|
||||
"a": 0.5019608
|
||||
},
|
||||
"ColorMultiplier": 1.0,
|
||||
"FadeDuration": 0.1
|
||||
},
|
||||
"ImageRUIDs": {
|
||||
"HighlightedSprite": null,
|
||||
"PressedSprite": null,
|
||||
"SelectedSprite": null,
|
||||
"DisabledSprite": null
|
||||
},
|
||||
"KeyCode": 0,
|
||||
"OverrideSorting": false,
|
||||
"Transition": 1,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.TextComponent",
|
||||
"Alignment": 4,
|
||||
|
||||
69868
ui/RunUIGroup.ui
Normal file
69868
ui/RunUIGroup.ui
Normal file
File diff suppressed because it is too large
Load Diff
6311
ui/SelectUIGroup.ui
Normal file
6311
ui/SelectUIGroup.ui
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user