Compare commits
200 Commits
fb5f0d6d5b
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dd016e16d | |||
| fbf4d8d02d | |||
| a141939675 | |||
| 42eb33b579 | |||
| 9f7713267c | |||
| bfa86f0f28 | |||
| c5bb8c18a9 | |||
| 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 | |||
| 4da934585c | |||
| 49069a16cf | |||
| bda35eefc7 | |||
| 44010e0fce | |||
| efa32d0a8f | |||
| 7c776864e2 | |||
| 72370aab23 | |||
| 5377112826 | |||
| 8a5b0d4f8d | |||
| 6c35d959ac | |||
| 67d21a9619 | |||
| b1d0af311a | |||
| 5b41eb78a4 | |||
| 3902c9b1ee | |||
| d1e51878c3 | |||
| cc945fce8b | |||
| 9966065409 | |||
| bc9bc78cef | |||
| 9cb5e1abff | |||
| 1fce0b284a | |||
| e269154d17 | |||
| b65d4af1eb | |||
| d5318ac86b | |||
| bd91c67483 | |||
| b43ee02014 | |||
| 6427d23f50 | |||
| b40c8d11d8 | |||
| f9e7bc3603 | |||
| 256433d3f3 | |||
| 05a06644cf | |||
| 709e6f8f99 | |||
| a88c1d344c | |||
| a24f3592c4 | |||
| 3db11f5d82 | |||
| 6e1f1cf990 | |||
| 304b2f3c2a | |||
| 15bc17b351 | |||
| 6f436ef3eb | |||
| cf193bf51a | |||
| 1e87be2cd6 | |||
| 6cc008e894 | |||
| 760b856576 | |||
| 91bbe7d200 | |||
| 989e3fe000 | |||
| 0e064cc1e9 | |||
| de1e69de7d | |||
| a683f186d4 | |||
| a309da2a99 | |||
| 82bf22d4cc | |||
| f36bc0d14e | |||
| 1f0a8099ee | |||
| a5f6a4509d | |||
| d3ae6c1c62 | |||
| 4d3f6fc0af | |||
| a2b8d6bfb9 | |||
| 8f233296af | |||
| 5f89d61a8b | |||
| 8879647b26 | |||
| 7ee323ea8b | |||
| 7cc311ee91 | |||
| 5cde11647f | |||
| 8296775e21 | |||
| d546d62755 | |||
| 8f58a90746 | |||
| 8fa1079548 | |||
| 9e16465218 | |||
| f67471435e | |||
| fd57e0d56d | |||
| afe995a895 | |||
| 6a6b64cbc5 | |||
| b2693be111 | |||
| 675616bf51 | |||
| 1e48fa35b3 | |||
| aaa68ebe07 | |||
| 35dfcbaffe | |||
| 9aa4721790 | |||
| e553ebe666 | |||
| a814bf2c4b | |||
| 9e162d6e2d | |||
| 8baa97bde8 | |||
| 66c1ac8ee1 | |||
| abd6d00052 | |||
| 2cd672b474 | |||
| 56d958fe19 | |||
| 7aed1943b7 | |||
| 9989a61675 | |||
| 2c28935d95 | |||
| b635cb3a63 | |||
| bcdf9457c8 | |||
| 89056e903d | |||
| fc0a96fcb7 | |||
| 7b6e181cb0 | |||
| e1d298f972 | |||
| 811c8ec2ac | |||
| 6b6037739f | |||
| 80c5daabbf | |||
| 6e8d1a88f5 | |||
| 13d1ccb771 | |||
| d0b8fbe091 | |||
| 2c9a1b351e | |||
| 74e3a70a19 | |||
| 15b342972d | |||
| 1925144f85 | |||
| 2a0ec0ef21 | |||
| 65ad2fe854 | |||
| d7e4e88182 | |||
| 52c03b208e | |||
| 76aaafcf1b | |||
| c69a17abe0 | |||
| 1abd9f7987 | |||
| 1624ef6f3b | |||
| 443aaf83d2 | |||
| 67e8b4c848 | |||
| 52d808eacd | |||
| b5a648fc23 | |||
| 62b2193f2e | |||
| 2a42c4a372 | |||
| 42f9143f73 | |||
| 239dd6caf3 | |||
| e4f7ff10d7 | |||
| 6b8db0b871 | |||
| 4c3be95c86 | |||
| 88a9136398 | |||
| 0a96a6955a | |||
| 00844857ee | |||
| 6df3fcf01c | |||
| 2bf5716fce | |||
| 12f3928ab4 | |||
| eb06663758 | |||
| 02aa73dda3 | |||
| 134353d374 | |||
| 013f946c6b | |||
| 8ff50b428d | |||
| 1de8fac893 | |||
| 248677759c | |||
| 2af67b3b2b | |||
| 7890f4c029 | |||
| 32a0b2437b | |||
| dd2a814eeb | |||
| 4a1c66c5fe | |||
| 869e4d88df | |||
| e876a8ce3a | |||
| f88e6ffeeb | |||
| 5921dfbeb8 | |||
| 4b7ad753a4 | |||
| c80020a464 | |||
| 858f9727dd | |||
| 1c2062b8cc | |||
| d4ec8757ce | |||
| 76cb2cd65e | |||
| d2aba643fa | |||
| d783cccea1 | |||
| c4ad8fc068 | |||
| 4ea9bfe14b | |||
| 18729a2047 | |||
| 50462f3d8a | |||
| 2f73910e47 | |||
| 659ae2a60f | |||
| b5facb7763 | |||
| 1762112296 | |||
| 389e7f36cf | |||
| 24e6aca305 | |||
| d1f894a802 | |||
| c5e28062eb | |||
| c7d795f839 | |||
| 2265bd7fa1 |
20
.claude/settings.json
Normal file
20
.claude/settings.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./ui/*.ui)",
|
||||
"Read(./map/*.map)",
|
||||
"Read(./RootDesk/MyDesk/*.codeblock)",
|
||||
"Edit(./ui/*.ui)",
|
||||
"Edit(./map/*.map)",
|
||||
"Edit(./RootDesk/MyDesk/*.codeblock)",
|
||||
"Edit(./Global/common.gamelogic)",
|
||||
"Edit(./Global/SectorConfig.config)",
|
||||
"Write(./ui/*.ui)",
|
||||
"Write(./map/*.map)",
|
||||
"Write(./RootDesk/MyDesk/*.codeblock)",
|
||||
"Write(./Global/common.gamelogic)",
|
||||
"Write(./Global/SectorConfig.config)"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,8 +4,9 @@
|
||||
# Codex CLI 로컬 설정 — Authorization Bearer 토큰 포함
|
||||
.codex/
|
||||
.agents/
|
||||
# Claude Code 로컬 설정
|
||||
.claude/
|
||||
# Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조)
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
|
||||
# === OS / 에디터 잡파일 ===
|
||||
Thumbs.db
|
||||
|
||||
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# SlayMaple — CLAUDE.md
|
||||
|
||||
MapleStory Worlds 기반 Slay the Spire 풍 덱빌더. 게임 전체가 데이터(`data/*.json`) + 생성기(`tools/`) 단일 소스이고, `ui/DefaultGroup.ui`(~7.1MB)·codeblock·map 파일은 **생성 산출물**이다.
|
||||
|
||||
@RULES.md
|
||||
|
||||
위 RULES.md의 하네스 규칙(산출물 Read/Edit 금지·카운트 검증·gitea-pr.mjs PR 절차)을 항상 따른다. `.claude/settings.json`이 산출물에 대한 Read/Edit/Write를 도구 수준에서 차단한다.
|
||||
@@ -24,12 +24,7 @@
|
||||
"map://map03",
|
||||
"map://map04",
|
||||
"map://map05",
|
||||
"map://map06",
|
||||
"map://map07",
|
||||
"map://map08",
|
||||
"map://map09",
|
||||
"map://map10",
|
||||
"map://map11"
|
||||
"map://lobby"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
{
|
||||
"@type": "script.SlayDeckController",
|
||||
"Enable": true,
|
||||
"Energy": 0,
|
||||
"MaxEnergy": 3,
|
||||
"Turn": 0,
|
||||
"TweenEventId": 0
|
||||
"Energy": 0.0,
|
||||
"MaxEnergy": 3.0,
|
||||
"Turn": 0.0,
|
||||
"TweenEventId": 0.0
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
|
||||
152
README.md
152
README.md
@@ -1,7 +1,7 @@
|
||||
# SlayMaple
|
||||
|
||||
[MapleStory Worlds(MSW)](https://maplestoryworlds.nexon.com/) 기반으로 제작하는 **Slay the Spire 풍 덱빌더 로그라이크** 월드.
|
||||
턴제 카드 전투, 덱 구성, 보상 선택, 맵 노드 진행을 메이플 월드 위에서 구현하는 것을 목표로 합니다.
|
||||
로비 마을에서 NPC와 상호작용해 런을 시작하고, 턴제 카드 전투·덱 구성·보상 선택·절차 생성 맵 진행·전직·영혼 메타 성장을 메이플 월드 위에서 구현합니다.
|
||||
|
||||
> 이 저장소는 MSW **로컬 워크스페이스(Local Workspace)** 데이터를 git으로 형상관리하기 위한 것입니다.
|
||||
> 공동작업자는 이 저장소를 통해 월드 데이터를 주고받습니다. (클라우드 공동제작 모드 미사용)
|
||||
@@ -32,9 +32,10 @@ git push
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델 파일이 에디터 상태로 반영됩니다.
|
||||
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델/맵 파일이 에디터 상태로 반영됩니다.
|
||||
|
||||
> 💡 같은 파일을 동시에 수정하면 git 충돌이 날 수 있으니, **서로 다른 맵/codeblock/UI를 나눠서** 작업하는 것을 권장합니다.
|
||||
> ⚠️ git pull 후 reload를 빠뜨리면 메이커의 stale 상태가 디스크를 덮어쓸 수 있습니다. 재생성 후에도 reload → 빌드 콘솔 0 에러 확인.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,95 +43,145 @@ git pull
|
||||
|
||||
```
|
||||
slaymaple/
|
||||
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입)
|
||||
│ ├── cards.json # 카드 정의 + 시작 덱
|
||||
│ ├── enemies.json # 적 정의(슬라임/정예/보스) + activeEnemy
|
||||
│ ├── map.json # 분기 맵 DAG (노드 type/enemy/row/col/next)
|
||||
│ └── relics.json # 유물 정의 + 시작 유물 + 풀
|
||||
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
|
||||
│ ├── cards.json # 카드 122장(클래스·2차전직별 + 저주) + 클래스별 시작 덱
|
||||
│ ├── enemies.json # 적 12종(일반/정예/보스, 디버프 인텐트 포함)
|
||||
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
|
||||
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
|
||||
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
|
||||
│ └── camera.json # 맵별 카메라 설정값(줌·오프셋·고정 영역)
|
||||
├── Global/ # 월드 전역 설정 · 공용 모델 · 게임로직
|
||||
│ ├── common.gamelogic # SlayDeckController 부착 지점 (카드 UI 전투)
|
||||
│ ├── common.gamelogic # SlayDeckController 부착 지점 (산출물)
|
||||
│ ├── DefaultPlayer.model # 플레이어 모델 (턴전투용 이동 정지 freeze 적용)
|
||||
│ ├── *.model # 몬스터 등 공용 모델 (freeze 적용)
|
||||
│ ├── ChaseMonster.model · MoveMonster.model # 몬스터 공용 모델
|
||||
│ ├── SectorConfig.config # 섹터/맵 등록 (lobby + map01~05 = 6 entries)
|
||||
│ ├── WorldConfig.config # 월드 설정
|
||||
│ └── ...
|
||||
├── RootDesk/
|
||||
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·모델·타일셋
|
||||
│ ├── SlayDeckController.codeblock # 게임 전체 컨트롤러 (생성물, 직접 편집 금지)
|
||||
│ ├── Monster.codeblock # 필드 액션 몬스터 (HP·피격·리스폰, 카드 전투와 별개)
|
||||
│ ├── MonsterAttack.codeblock · PlayerAttack.codeblock · PlayerHit.codeblock
|
||||
│ ├── UIPopup.codeblock · UIToast.codeblock
|
||||
│ └── RectTileData_Henesys.tileset
|
||||
├── map/
|
||||
│ └── map01.map ~ map11.map # 맵 11종 (공식 배경 + STS풍 우측 배치)
|
||||
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·타일셋
|
||||
│ ├── SlayDeckController.codeblock # 게임 전체 컨트롤러 (★산출물, 직접 편집 금지)
|
||||
│ ├── Monster.codeblock · MonsterAttack.codeblock # 필드 액션 몬스터 (카드 전투와 별개)
|
||||
│ ├── PlayerAttack.codeblock · PlayerHit.codeblock · UIPopup.codeblock · UIToast.codeblock
|
||||
│ ├── CombatMonster.codeblock # 맵 몬스터 EnemyId 마커 + /common 자기등록
|
||||
│ ├── MapCamera.codeblock # 맵별 카메라 적용
|
||||
│ ├── PlayerLock.codeblock # 전투맵 플레이어 입력·이동 잠금
|
||||
│ ├── LobbyNpc.codeblock # 로비 NPC 상호작용(근접·클릭)
|
||||
│ └── LobbyMobility.codeblock # 로비 이동·공격 해제 + 카메라 추종
|
||||
├── map/ # 맵 6종 (산출물)
|
||||
│ ├── lobby.map # 로비 허브 맵 (마을 배경, NPC 4종, 전투 없음)
|
||||
│ └── map01.map ~ map05.map # 5막 전투/맵 노드 (공식 배경 + STS풍 우측 배치)
|
||||
├── tools/ # 결정적 생성기·도구 (주체별 폴더, 단일 소스)
|
||||
│ ├── deck/ # gen-slaydeck.mjs(★게임 전체 생성: 카드/덱·맵·상점·유물·메인메뉴 UI+SlayDeckController+common) · gen-cardhand.mjs(손패 초기 생성)
|
||||
│ ├── map/ # gen-maps.mjs(맵 생성)
|
||||
│ ├── deck/ # gen-slaydeck.mjs(★게임 전체 생성: 카드/덱·전투·맵노드·상점·유물·로비·메뉴 UI + SlayDeckController + common) · gen-cardhand.mjs
|
||||
│ ├── map/ # gen-maps.mjs(맵 배경/타일) · gen-lobby-map.mjs(로비 맵+NPC) · gen-map-encounters.mjs(노드별 몬스터 그룹) · rogue-map.mjs(절차 생성 JS 미러)+test
|
||||
│ ├── camera/ # gen-camera.mjs(맵별 고정 카메라 codeblock)
|
||||
│ ├── player/ # freeze-turn-player.mjs(이동 정지) · gen-player-lock.mjs(입력 차단·시선 고정 codeblock)
|
||||
│ ├── monster/ # freeze-turn-monsters.mjs(필드 몬스터 AI/이동 정지)
|
||||
│ └── balance/ # sim-balance.mjs(밸런스 시뮬·몬테카를로) · sim-balance.test.mjs
|
||||
├── ui/ # UI 그룹 (Default / Popup / Toast)
|
||||
│ ├── 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(산출물 카운트 검증 헬퍼 — 경로 내장)
|
||||
│ └── git/ # gitea-pr.mjs(UTF-8 안전 PR 생성/수정/머지 — RULES.md 참조)
|
||||
├── ui/ # UI 그룹 (DefaultGroup 8.3MB 산출물 / PopupGroup / ToastGroup)
|
||||
├── docs/
|
||||
│ ├── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
|
||||
│ └── superpowers/specs|plans/ # 각 기능 설계·구현 계획 문서
|
||||
│ ├── ui-generation-structure.md # UI 생성 구조 문서
|
||||
│ └── superpowers/specs|plans/ # 각 기능 설계·구현 계획 문서(P1~P15)
|
||||
├── RULES.md # 협업·AI 에이전트 하네스 규칙 (토큰 가드·검증·PR 절차)
|
||||
├── CLAUDE.md # Claude Code 자동 로드 (RULES.md 임포트)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
> ⚠️ **`map/*.map` · `ui/DefaultGroup.ui` · `*.codeblock` · `Global/*.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 다음 재생성 때 사라집니다. 게임 변경은 `data/*.json` 또는 `tools/`의 생성기를 고친 뒤 재생성하세요(자세한 규칙은 [`RULES.md`](RULES.md)).
|
||||
> `.mcp.json`, `.codex/` 는 **Authorization 토큰이 포함**되어 있어 git에서 제외됩니다(`.gitignore`). 각자 로컬에서 직접 구성하세요.
|
||||
|
||||
---
|
||||
|
||||
## 게임 프레임워크 현황
|
||||
|
||||
**STS풍 덱빌더 런이 end-to-end로 완성**됐습니다 — 메인 메뉴 → 분기 맵 → 전투/엘리트/상점/휴식 → 카드 보상·덱 성장 → 유물 → 보스 → 다음 막 → 런 클리어. 게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작하며, 모든 산출물(`ui/DefaultGroup.ui` · `SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(직접 편집 금지, 결정적 출력). 게임 데이터는 **`data/*.json`** 가 단일 소스.
|
||||
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다:
|
||||
|
||||
### 구현된 기능
|
||||
```
|
||||
로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
|
||||
→ 전투/엘리트/상점/휴식/유물 방 → 보상·전직·덱 성장 → 보스 → 다음 막
|
||||
→ 런 클리어(승천 해금) → 로비 복귀(영혼 정산) → 다음 런 …
|
||||
```
|
||||
|
||||
게임 전체는 `/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 미러).
|
||||
|
||||
### 구현된 기능 (배포 퀄리티 P1~P15, PR #34~#57)
|
||||
|
||||
| 영역 | 내용 |
|
||||
|---|---|
|
||||
| **메인 메뉴** | "슬레이 메이플" 타이틀 + "새 게임" → 런 시작 (`OnBeginPlay`→`ShowMainMenu`) |
|
||||
| **카드 전투** | 에너지 3·매 턴 5장 드로우·버림/재셔플·카드 클릭 사용. 카드 3종(타격/방어/강타, `damage`/`block` 수치). 적 HP/방어/의도(결정적 사이클·다음 행동 미리 표시). 데미지는 방어 우선 차감. 승/패·입력 잠금 |
|
||||
| **런 영속** | HP·골드·`RunDeck`(보유 카드)가 전투 간 유지. 시작 덱 10장 |
|
||||
| **전투 보상** | 승리 시 카드 3중 1택(덱 추가)·골드, 또는 건너뛰기 |
|
||||
| **분기 맵** | 작성된 DAG(`data/map.json`)에서 다음 노드 선택. 노드 타입 전투/엘리트/보스(적 데이터 차등), 상점/휴식. 도달 가능 노드만 선택 |
|
||||
| **상점/휴식** | 상점=골드로 카드·유물 구매. 휴식=HP 회복 |
|
||||
| **유물** | 훅 패시브(`combatStart`/`turnStart`/`cardPlayed`/`combatReward`). 획득 3경로(시작·엘리트 승리·상점). 유물 4종 |
|
||||
| **멀티 act** | 보스 클리어→다음 막(적 스케일 `1+(막-1)*0.6`), 최종 막 보스에서 런 클리어. 막 수 3 |
|
||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 몬테카를로 N회 전투로 승률·턴·OP 카드 리포트 |
|
||||
| **턴전투 freeze** | 카드 전투 중 필드 몬스터/플레이어 이동·AI 정지(`freeze-turn-*.mjs`) |
|
||||
| **로비 마을** | 전용 물리 맵 `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**(버림 트리거) |
|
||||
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 −25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
|
||||
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
|
||||
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. 점선 경로·상태 4단·층 카운터. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
|
||||
| **유물 19종 / 물약 6종** | 유물: StS 효과 × 메이플 장비 외형, TopBar 아이콘 + 마우스오버 툴팁, 8종 훅. 물약: 승리 40% 드랍·상점·슬롯 메뉴. 보물 방=상자 연출 → 유물+메소 |
|
||||
| **카드 프레임·등급** | 커스텀 프레임 3종(전사/마법사/도적 × normal/unique/legend), 카드 5개 사이트 통합 레이아웃. 보상 등급 가중 추첨 70/25/5 |
|
||||
| **영혼(Soul) 메타 성장** | 승천과 별개의 영구 강화 화폐. 2차 전직 상태로 보스 클리어 시 적립 → 로비 영혼 상점 4종 해금(시작 메소 +60·HP +15·덱 정제·시작 유물 +1). **UserDataStorage 영구 저장** |
|
||||
| **승천(Ascension)** | A1~A10 누적 모디파이어(적 강화·시작 HP 감소·보상 감소). UserDataStorage 유저별 영구 저장, 런 클리어 시 다음 단계 해금 |
|
||||
| **멀티 act** | **5막** 진행(보스 클리어→다음 막 텔레포트, 맵·인카운터 변경, 적 스케일 `1+(막-1)*0.45`), 5막 클리어 시 런 종료 |
|
||||
| **경제** | 화폐 표기 **메소**(코인 아이콘), 카드/유물/물약 메소 가격. 내부 식별자는 Gold 유지 |
|
||||
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
|
||||
|
||||
> ⚠️ 플레이어 HP(80)·적 수치·골드/카드값(15/30/유물60)·막 배율 등은 **밸런싱 미조정 placeholder**입니다. 추후 카드·적은 **메이플스토리 IP**에 맞춰 디벨롭 예정이며, 밸런싱은 `sim-balance.mjs`로 검증합니다.
|
||||
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
|
||||
> ℹ️ 도적(Silent) 카드 88장은 효과·프레임은 적용됐으나 **카드 아이콘(image/fx) 미할당** 상태입니다(전사·마법사 카드는 실 스킬 아이콘 적용 완료).
|
||||
|
||||
### 유용한 스크립트 호출
|
||||
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
|
||||
```lua
|
||||
local c = _EntityService:GetEntityByPath("/common").SlayDeckController
|
||||
c:StartNewGame() -- 메뉴 → 런 시작
|
||||
c:PickNode("A") -- 맵 노드 선택 → 전투/상점/휴식
|
||||
-- 로비
|
||||
c:OnLobbyNpcInteract("run") -- 모험가(런 시작) / "codex"(도감) / "shop"(영혼상점) / "board"(게시판)
|
||||
c:ShowLobby() -- 로비 맵 복귀 + 상태 초기화
|
||||
-- 런
|
||||
c:SelectClass("warrior") -- "warrior" / "bandit" / "magician"
|
||||
c:StartNewGame() -- 캐릭터 선택 → 런 시작(map01 텔레포트)
|
||||
c:PickNode("r1c2") -- 맵 노드 선택(절차 생성 그리드 id) / "boss"
|
||||
c:PlayCard(1) -- 손패 slot 카드 사용
|
||||
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
|
||||
c:PickReward(1) -- 보상 카드 1택(0=건너뛰기)
|
||||
c:BuyCard(1) / c:BuyRelic() -- 상점 구매
|
||||
c:BuyCard(1) / c:BuyRelic() / c:BuyPotion() -- 상점 구매(메소)
|
||||
c:SetJob("fighter") -- 전직 (보스 보상 선택 화면)
|
||||
c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1
|
||||
```
|
||||
|
||||
밸런스 검증: `node tools/balance/sim-balance.mjs [N] [--seed S]` · 테스트: `node --test tools/balance/sim-balance.test.mjs`.
|
||||
밸런스 검증: `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/` 참조.
|
||||
|
||||
### 산출물 재생성
|
||||
```bash
|
||||
node tools/deck/gen-slaydeck.mjs # 게임 전체(UI·컨트롤러·common·맵 인카운터)
|
||||
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)
|
||||
node tools/camera/gen-camera.mjs # 맵별 카메라
|
||||
node tools/player/gen-player-lock.mjs # 전투맵 입력 잠금
|
||||
node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
|
||||
```
|
||||
> 산출물 검증은 내용 출력 없이 카운트만: `node tools/verify/count.mjs <ui|cb|common> <regex>...` (자세한 가드는 [`RULES.md`](RULES.md)).
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 메모
|
||||
|
||||
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계 문서가 제안한 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 시스템이 더 커지면 그때 분리를 고려합니다. 카드/적/맵/유물 데이터는 이미 `data/*.json`로 외부화돼 있습니다.
|
||||
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다.
|
||||
|
||||
> ⚠️ **전투 규칙과 맵 생성은 Lua(gen-slaydeck 내장)와 JS 미러(sim-balance/rogue-map)로 이중 구현**입니다. 한쪽을 고치면 반드시 다른 쪽도 동기화하고 테스트하세요(`RULES.md` §6).
|
||||
|
||||
---
|
||||
|
||||
## 다음 구현 단계 (후속 후보)
|
||||
- [x] 단일 전투 루프 · 데이터 외부화 · 밸런스 시뮬 · 런 루프 · 분기 맵 · 상점/휴식 · 유물 · 멀티 act **(완료)**
|
||||
- [ ] 저장/불러오기(런 영속 직렬화) — MSW 저장 API 필요
|
||||
- [ ] 카드 제거(상점) — 덱 전체 보기 UI 필요
|
||||
- [ ] 경제 밸런싱 튜닝(골드/승리 15 < 카드값 30 < 유물 60) — `sim-balance.mjs` 활용
|
||||
- [ ] 메이플스토리 IP 기반 카드·적·배경 콘텐츠 확장
|
||||
- [ ] 막별 다른 맵 디자인·이벤트 노드
|
||||
## 향후 개선 계획 (후속 후보)
|
||||
- [x] 전투 루프 · 런 루프 · 절차 생성 맵 · 상점/휴식/유물 방 · 유물 19종 · 물약 · 버프/디버프 · Power · 전직(전사/법사/도적 2차) · 승천+개인 저장 · 전투 모션 · 커스텀 프레임 · **반복 런·로비 맵·NPC·영혼·메소·카메라 추종 (P1~P15 완료)**
|
||||
- [ ] **도적 카드 아이콘** — Silent 88장에 실 스킬 아이콘(image/fx) 할당, 2차 전직 설명 한글화
|
||||
- [ ] **런 이어하기** — 진행 중 런 직렬화 저장(UserDataStorage 확장, 메뉴 "이어하기" 활성화)
|
||||
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
|
||||
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)
|
||||
- [ ] **3차 전직** — 후반 막 보상으로 확장
|
||||
- [ ] **궁수 등 추가 클래스** — 캐릭터 선택 슬롯 확장
|
||||
- [ ] **정밀 밸런싱** — 첫 인카운터 승률 완화·직업별 카드 효율 튜닝(`sim-balance.mjs` 리포트 기반)
|
||||
- [ ] **상점 보장 규칙** — 막당 상점 최소 1회 등장
|
||||
- [ ] **연출 보강** — 사운드(타격·획득), 맵 화면에 유물/물약 표시
|
||||
|
||||
---
|
||||
|
||||
@@ -141,4 +192,5 @@ c:BuyCard(1) / c:BuyRelic() -- 상점 구매
|
||||
```
|
||||
2. MSW Maker에서 이 폴더를 **로컬 워크스페이스 경로**로 지정해 월드 열기
|
||||
3. `.mcp.json` / `.codex/` 는 git에 없으므로, 본인 토큰으로 직접 생성 (MCP·Codex 사용 시)
|
||||
4. 작업 전 항상 `git pull`, 작업 후 `git add/commit/push`
|
||||
4. 작업 전 항상 `git pull` + 메이커 reload, 작업 후 `git add/commit/push`
|
||||
5. **AI 에이전트(Claude Code 등)로 작업한다면 [`RULES.md`](RULES.md) 필독** — 생성 산출물 접근 금지(토큰 가드)·검증 절차·PR 도구(`tools/git/gitea-pr.mjs`) 규칙. Claude Code는 `CLAUDE.md`가 자동 적용
|
||||
|
||||
87
RULES.md
Normal file
87
RULES.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# RULES.md — SlayMaple 하네스 엔지니어링 규칙
|
||||
|
||||
AI 에이전트(Claude Code 등)와 협업자가 이 저장소에서 **토큰을 낭비하지 않고 안전하게** 작업하기 위한 공용 규칙.
|
||||
Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된다. 다른 도구(Codex 등)를 쓰면 세션 시작 시 이 파일을 읽혀라.
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성 산출물은 읽지도, 고치지도 않는다 (가장 중요)
|
||||
|
||||
이 저장소의 큰 파일들은 전부 **생성기 산출물**이다. 직접 읽으면 토큰이 증발하고, 직접 고치면 다음 재생성 때 사라진다.
|
||||
|
||||
| 산출물 (절대 Read/Edit 금지) | 크기 | 단일 소스 (여기만 편집) | 재생성 명령 |
|
||||
|---|---|---|---|
|
||||
| `ui/DefaultGroup.ui` | **~7.1MB** | `data/*.json` + `tools/deck/`(`gen-slaydeck.mjs`+`lib/`+`hud/`) | `node tools/deck/gen-slaydeck.mjs` |
|
||||
| `RootDesk/MyDesk/SlayDeckController.codeblock` | ~270KB | 〃 | 〃 |
|
||||
| `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` |
|
||||
| `RootDesk/MyDesk/PlayerLock.codeblock` | ~2KB | `tools/player/gen-player-lock.mjs` | `node tools/player/gen-player-lock.mjs` |
|
||||
| `RootDesk/MyDesk/MapCamera.codeblock` | ~2KB | `tools/camera/gen-camera.mjs` (값: `data/camera.json`) | `node tools/camera/gen-camera.mjs` |
|
||||
| `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`(오케스트레이터 + codeblock Lua) 또는 `data/*.json`(데이터)을 수정** → 재생성 → 산출물은 통째로 커밋.
|
||||
- **UI emit은 HUD별 모듈** `tools/deck/hud/*.mjs`(charselect·shop·combat·map·deckall·soulshop 등 16종), **codeblock 메서드(Lua)는 기능별 모듈** `tools/deck/cb/*.mjs`(boot·state·combat·hand·deckview·items·map·shop 등 17종, 메서드 161개를 연속런으로). **공유분**: UI 헬퍼·상수·데이터·lua 테이블 = `tools/deck/lib/ui-helpers.mjs`·`tools/deck/lib/data.mjs`, method/prop/codeblock 헬퍼·writeCodeblocks 상수 = `tools/deck/lib/codeblock.mjs`. 특정 화면 UI 수정은 `hud/<name>.mjs`, 특정 메서드 수정은 `cb/<name>.mjs`만(의존: orchestrator→{hud,cb}→lib 단방향). prop 103개는 오케스트레이터 writeCodeblocks에 유지. **cb 모듈은 원본 메서드 순서 보존이 바이트동일 조건** — 새 메서드는 해당 기능 모듈의 알맞은 위치에 추가하고 writeCodeblocks의 spread 순서 유지.
|
||||
- 리팩터 시 **출력 바이트-동일 검증**: `node tools/deck/gen-slaydeck.mjs` 후 `node tools/verify/diffcheck.mjs [ref]`(워킹트리 vs ref(기본 HEAD) 줄바꿈 정규화 비교 — 산출물 경로를 명령줄에 노출 안 해 deny 회피). 산출물 ` M`은 보통 autocrlf churn이니 `git checkout --`로 복원.
|
||||
- **머지 충돌(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 템플릿 클론)
|
||||
- `tools/map/gen-lobby-map.mjs` → `map/lobby.map` + `SectorConfig.config`
|
||||
- `tools/map/gen-map-encounters.mjs` → map01~05 노드 타입별 몬스터 그룹 재구성
|
||||
- `tools/monster/gen-combat-monster.mjs` → `CombatMonster.codeblock` + map01~05 부착
|
||||
- `tools/monster/freeze-turn-monsters.mjs` → 몬스터 `.model`·맵 AI 컴포넌트 제거
|
||||
- `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` 카드핸드 보조 패처
|
||||
|
||||
## 2. 산출물 검증은 카운트로, 내용 출력 금지
|
||||
|
||||
재생성 결과 확인이 필요하면 **본문을 출력하지 말고** 존재/개수만 확인한다:
|
||||
|
||||
```bash
|
||||
node tools/deck/gen-slaydeck.mjs # 성공 메시지 1줄
|
||||
grep -c "TreasureHud" ui/DefaultGroup.ui # 개수만
|
||||
grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
```
|
||||
|
||||
- ⚠️ codeblock은 **한 줄이 수만 자**(JSON 직렬화)다. `grep`(내용 출력)·`sed -n` 광역 범위 출력은 한 줄만 걸려도 토큰 폭발 → `grep -c`/`grep -l`/`grep -o '짧은패턴'`만 사용.
|
||||
- Claude Code의 Grep 도구를 산출물에 쓸 때는 `output_mode: count` 또는 `files_with_matches`만. content 모드 금지.
|
||||
- 진짜 내용 확인이 필요하면 좁은 `grep -o` 또는 `python`으로 슬라이스해서 **수 줄 이내**로.
|
||||
|
||||
## 3. 탐색 규칙
|
||||
|
||||
- 코드 탐색은 `tools/`·`data/`·`docs/`만 대상으로. 저장소 전체 grep은 산출물이 걸리므로 경로를 지정한다.
|
||||
- `git add -A` 전 `git status --short`로 산출물 외 의도치 않은 변경이 없는지 확인 (산출물 diff는 보지 않는다 — 생성기가 결정적이므로 소스 리뷰로 충분).
|
||||
- 게임 동작 확인은 메이커 플레이테스트(스크린샷·로그)로 한다. 산출물 정독으로 동작을 추론하지 않는다.
|
||||
|
||||
## 4. Git/PR 절차
|
||||
|
||||
- 브랜치 → 커밋(기능 단위) → 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) 연동 주의
|
||||
|
||||
- git pull 후 메이커에서 **로컬 워크스페이스 reload** 필수 (안 하면 메이커의 stale 상태가 디스크를 덮어씀).
|
||||
- 재생성 후 메이커가 켜져 있으면 refresh → 빌드 콘솔 0 에러 확인.
|
||||
- 카드/유물/물약 이미지는 **공식 maplestory 리소스 RUID**만 (계정 업로드 리소스는 로컬 워크스페이스에서 흰 박스).
|
||||
|
||||
## 6. 밸런스·맵 규칙 동기화
|
||||
|
||||
전투 규칙과 맵 생성은 Lua(생성기 내장)와 JS가 **이중 구현**이다. 한쪽을 고치면 반드시 다른 쪽도:
|
||||
|
||||
| 영역 | Lua (gen-slaydeck.mjs 내) | JS 미러 | 테스트 |
|
||||
|---|---|---|---|
|
||||
| 전투 규칙 | 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` 같은 포맷 헬퍼를 우선 사용한다.
|
||||
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.
|
||||
@@ -61,7 +61,7 @@
|
||||
"Name": null
|
||||
},
|
||||
"Arguments": [],
|
||||
"Code": "self.RegTries = 0\nlocal eventId = 0\nlocal function reg()\n\tself.RegTries = self.RegTries + 1\n\tlocal c = _EntityService:GetEntityByPath(\"/common\")\n\tif c ~= nil and c.SlayDeckController ~= nil then\n\t\tc.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group)\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.RegTries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(reg, 0.1)",
|
||||
"Code": "self.RegTries = 0\nlocal eventId = 0\nlocal function reg()\n\tself.RegTries = self.RegTries + 1\n\tlocal c = _EntityService:GetEntityByPath(\"/common\")\n\tif c ~= nil and c.SlayDeckController ~= nil then\n\t\tlocal mapName = \"\"\n\t\tif self.Entity.CurrentMapName ~= nil then\n\t\t\tmapName = self.Entity.CurrentMapName\n\t\tend\n\t\tc.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName)\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.RegTries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(reg, 0.1)",
|
||||
"Scope": 2,
|
||||
"ExecSpace": 6,
|
||||
"Attributes": [],
|
||||
|
||||
60
RootDesk/MyDesk/LobbyMobility.codeblock
Normal file
60
RootDesk/MyDesk/LobbyMobility.codeblock
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "codeblock://lobbymobility",
|
||||
"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": "LobbyMobility",
|
||||
"Language": 1,
|
||||
"Name": "LobbyMobility",
|
||||
"Type": 1,
|
||||
"Source": 0,
|
||||
"Target": null,
|
||||
"Properties": [
|
||||
{
|
||||
"Type": "number",
|
||||
"DefaultValue": "0",
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": "Tries"
|
||||
}
|
||||
],
|
||||
"Methods": [
|
||||
{
|
||||
"Return": {
|
||||
"Type": "void",
|
||||
"DefaultValue": null,
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": null
|
||||
},
|
||||
"Arguments": [],
|
||||
"Code": "self.Tries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.Tries = self.Tries + 1\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil and lp.PlayerControllerComponent ~= nil then\n\t\tlocal pc = lp.PlayerControllerComponent\n\t\tpc.Enable = true\n\t\tpc.FixedLookAt = 0\n\t\tlocal rb = lp.RigidbodyComponent\n\t\tif rb ~= nil then rb.WalkAcceleration = 0.7 end\n\t\tlocal mv = lp.MovementComponent\n\t\tif mv ~= nil then\n\t\t\tmv.InputSpeed = 1.4\n\t\t\tmv.JumpForce = 1.23\n\t\tend\n\t\tlocal cam = lp.CameraComponent\n\t\tif cam == nil then cam = _CameraService:GetCurrentCameraComponent() end\n\t\tif cam ~= nil then\n\t\t\tcam.ZoomRatio = 90\n\t\t\tcam.ConfineCameraArea = false\n\t\t\tcam.ScreenOffset = Vector2(0.5, 0.5)\n\t\t\tcam.CameraOffset = Vector2(0, 0)\n\t\tend\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.Tries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
|
||||
"Scope": 2,
|
||||
"ExecSpace": 6,
|
||||
"Attributes": [],
|
||||
"Name": "OnBeginPlay"
|
||||
}
|
||||
],
|
||||
"EntityEventHandlers": []
|
||||
}
|
||||
}
|
||||
}
|
||||
89
RootDesk/MyDesk/LobbyNpc.codeblock
Normal file
89
RootDesk/MyDesk/LobbyNpc.codeblock
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "codeblock://lobbynpc",
|
||||
"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": "LobbyNpc",
|
||||
"Language": 1,
|
||||
"Name": "LobbyNpc",
|
||||
"Type": 1,
|
||||
"Source": 0,
|
||||
"Target": null,
|
||||
"Properties": [
|
||||
{
|
||||
"Type": "string",
|
||||
"DefaultValue": "\"\"",
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": "NpcId"
|
||||
},
|
||||
{
|
||||
"Type": "string",
|
||||
"DefaultValue": "\"\"",
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": "MarkName"
|
||||
},
|
||||
{
|
||||
"Type": "boolean",
|
||||
"DefaultValue": "false",
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": "InRange"
|
||||
}
|
||||
],
|
||||
"Methods": [
|
||||
{
|
||||
"Return": {
|
||||
"Type": "void",
|
||||
"DefaultValue": null,
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": null
|
||||
},
|
||||
"Arguments": [],
|
||||
"Code": "self.InRange = false\nlocal mark = _EntityService:GetEntityByPath(\"/maps/lobby/\" .. self.MarkName)\nif mark ~= nil then mark:SetVisible(false) end\nself.Entity:ConnectEvent(TouchEvent, function(e)\n\tself:Interact()\nend)\n_InputService:ConnectEvent(KeyDownEvent, function(e)\n\tif self.InRange and e.key == KeyboardKey.UpArrow then\n\t\tself:Interact()\n\tend\nend)\nlocal eventId = 0\nlocal function tick()\n\tlocal lp = _UserService.LocalPlayer\n\tif lp == nil then return end\n\tif mark == nil then mark = _EntityService:GetEntityByPath(\"/maps/lobby/\" .. self.MarkName) end\n\tlocal a = lp.TransformComponent.WorldPosition\n\tlocal b = self.Entity.TransformComponent.WorldPosition\n\tlocal d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y))\n\tlocal near = d < 1.2\n\tif near ~= self.InRange then\n\t\tself.InRange = near\n\t\tif mark ~= nil then mark:SetVisible(near) end\n\tend\nend\neventId = _TimerService:SetTimerRepeat(tick, 0.15)",
|
||||
"Scope": 2,
|
||||
"ExecSpace": 6,
|
||||
"Attributes": [],
|
||||
"Name": "OnBeginPlay"
|
||||
},
|
||||
{
|
||||
"Return": {
|
||||
"Type": "void",
|
||||
"DefaultValue": null,
|
||||
"SyncDirection": 0,
|
||||
"Attributes": [],
|
||||
"Name": null
|
||||
},
|
||||
"Arguments": [],
|
||||
"Code": "local c = _EntityService:GetEntityByPath(\"/common\")\nif c ~= nil and c.SlayDeckController ~= nil then\n\tc.SlayDeckController:OnLobbyNpcInteract(self.NpcId)\nend",
|
||||
"Scope": 2,
|
||||
"ExecSpace": 6,
|
||||
"Attributes": [],
|
||||
"Name": "Interact"
|
||||
}
|
||||
],
|
||||
"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": [],
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"Name": null
|
||||
},
|
||||
"Arguments": [],
|
||||
"Code": "self.LockTries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.LockTries = self.LockTries + 1\n\tlocal pc = nil\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil then\n\t\tpc = lp.PlayerControllerComponent\n\tend\n\tif pc ~= nil then\n\t\tpc.LookDirectionX = 1\n\t\tpc.FixedLookAt = true\n\t\tpc.Enable = false\n\tend\n\tif pc ~= nil then\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.LockTries > 30 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
|
||||
"Code": "self.LockTries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.LockTries = self.LockTries + 1\n\tlocal pc = nil\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil then\n\t\tpc = lp.PlayerControllerComponent\n\tend\n\tif pc ~= nil then\n\t\tpc.LookDirectionX = 1\n\t\tpc.FixedLookAt = true\n\t\tpc.Enable = false\n\tend\n\tif lp ~= nil then\n\t\tif lp.RigidbodyComponent ~= nil then lp.RigidbodyComponent.WalkAcceleration = 0 end\n\t\tif lp.MovementComponent ~= nil then lp.MovementComponent.InputSpeed = 0; lp.MovementComponent.JumpForce = 0 end\n\tend\n\tif pc ~= nil then\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.LockTries > 30 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
|
||||
"Scope": 2,
|
||||
"ExecSpace": 6,
|
||||
"Attributes": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
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/bandit_legend.sprite
Normal file
23
RootDesk/MyDesk/bandit_legend.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://c357d2daf31a489d95b8fa47e50dd879",
|
||||
"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/c357d2daf31a489d95b8fa47e50dd879/639168711224334184",
|
||||
"upload_hash": "A1639991C7A8CB1025C97E6BE93F618088971DC609F03106C50D8B6EA145F6A2",
|
||||
"name": "bandit_legend",
|
||||
"resource_guid": "c357d2daf31a489d95b8fa47e50dd879",
|
||||
"resource_version": "6a2c16d2cc7e89479f128ffb"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/bandit_normal.sprite
Normal file
23
RootDesk/MyDesk/bandit_normal.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://9487b06867bc46269ed1d855420f457f",
|
||||
"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/9487b06867bc46269ed1d855420f457f/639168711224307886",
|
||||
"upload_hash": "FD9F2140D16EFC7A77F0B09CA230702CA6CA25E3178AAF6ACD63EF07D5C2C83E",
|
||||
"name": "bandit_normal",
|
||||
"resource_guid": "9487b06867bc46269ed1d855420f457f",
|
||||
"resource_version": "6a2c16d23d5de2eb0c7d16a2"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/bandit_unique.sprite
Normal file
23
RootDesk/MyDesk/bandit_unique.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://b3081fb2fb1445fa90b12b01481a78ef",
|
||||
"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/b3081fb2fb1445fa90b12b01481a78ef/639168711224396185",
|
||||
"upload_hash": "1F5218270148F873D060223A617DFC184AEEE61344D26C85705E40EECC086D5E",
|
||||
"name": "bandit_unique",
|
||||
"resource_guid": "b3081fb2fb1445fa90b12b01481a78ef",
|
||||
"resource_version": "6a2c16d21a7908d59b5dc059"
|
||||
}
|
||||
}
|
||||
}
|
||||
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/mage_legend.sprite
Normal file
23
RootDesk/MyDesk/mage_legend.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://cff71f2e472041ce80c6fbd296f42e2d",
|
||||
"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/cff71f2e472041ce80c6fbd296f42e2d/639168711224422293",
|
||||
"upload_hash": "040CA63C5D3D52E1661F3A008D229A182B1A7C09D919BB74E23D1305B1AA56A7",
|
||||
"name": "mage_legend",
|
||||
"resource_guid": "cff71f2e472041ce80c6fbd296f42e2d",
|
||||
"resource_version": "6a2c16d2a0766b148f66d799"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/mage_normal.sprite
Normal file
23
RootDesk/MyDesk/mage_normal.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://d788d09f6f50467ebc67f01dec45f9e2",
|
||||
"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/d788d09f6f50467ebc67f01dec45f9e2/639168711224258598",
|
||||
"upload_hash": "5D91E6E333564F15A76554AE07402DC0ED39ABC02439F65C2CCB818C71FB6994",
|
||||
"name": "mage_normal",
|
||||
"resource_guid": "d788d09f6f50467ebc67f01dec45f9e2",
|
||||
"resource_version": "6a2c16d2e75b0d4ccdfcee8b"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/mage_unique.sprite
Normal file
23
RootDesk/MyDesk/mage_unique.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://f5def2e8022b4e59a17d3c16414034fe",
|
||||
"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/f5def2e8022b4e59a17d3c16414034fe/639168711224271729",
|
||||
"upload_hash": "B07075BE5F13B1D7BFB4AE36F4C47D97A10A3CC7EF763F02DD2A11C2AFC61E67",
|
||||
"name": "mage_unique",
|
||||
"resource_guid": "f5def2e8022b4e59a17d3c16414034fe",
|
||||
"resource_version": "6a2c16d2e75b0d4ccdfcee8a"
|
||||
}
|
||||
}
|
||||
}
|
||||
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/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/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/warior_legend.sprite
Normal file
23
RootDesk/MyDesk/warior_legend.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://6d741a60c60743cb98ee740a1e2dbfed",
|
||||
"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/6d741a60c60743cb98ee740a1e2dbfed/639168711258121433",
|
||||
"upload_hash": "80E2D8FFC8E56ECE4694BED5A387413F49633841DD37B7AA9FA9AC713962602C",
|
||||
"name": "warior_legend",
|
||||
"resource_guid": "6d741a60c60743cb98ee740a1e2dbfed",
|
||||
"resource_version": "6a2c16d59c3c6c308bfd4bf8"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/warior_normal.sprite
Normal file
23
RootDesk/MyDesk/warior_normal.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://4bb57ef88ef449fdaf958f6cf37fe44b",
|
||||
"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/4bb57ef88ef449fdaf958f6cf37fe44b/639168711258172386",
|
||||
"upload_hash": "2832F3B6C4C9530DE69DF061E590FD314D8646BE6BDB65931AFBE68D38DBB0ED",
|
||||
"name": "warior_normal",
|
||||
"resource_guid": "4bb57ef88ef449fdaf958f6cf37fe44b",
|
||||
"resource_version": "6a2c16d53d5de2eb0c7d16a3"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
RootDesk/MyDesk/warior_unique.sprite
Normal file
23
RootDesk/MyDesk/warior_unique.sprite
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "sprite://4f71c124c8bc4e13b5e9fad392995f68",
|
||||
"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/4f71c124c8bc4e13b5e9fad392995f68/639168711257524633",
|
||||
"upload_hash": "F27B2D713455FB18C10E924D381C4582E5E039D5DCCA9CECD2FB0D8E9A4C8135",
|
||||
"name": "warior_unique",
|
||||
"resource_guid": "4f71c124c8bc4e13b5e9fad392995f68",
|
||||
"resource_version": "6a2c16d5a43134e1b8a34b65"
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
data/cardframes.json
Normal file
39
data/cardframes.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"frames": {
|
||||
"warrior": {
|
||||
"normal": "4bb57ef88ef449fdaf958f6cf37fe44b",
|
||||
"unique": "4f71c124c8bc4e13b5e9fad392995f68",
|
||||
"legend": "6d741a60c60743cb98ee740a1e2dbfed"
|
||||
},
|
||||
"magician": {
|
||||
"normal": "d788d09f6f50467ebc67f01dec45f9e2",
|
||||
"unique": "f5def2e8022b4e59a17d3c16414034fe",
|
||||
"legend": "cff71f2e472041ce80c6fbd296f42e2d"
|
||||
},
|
||||
"bandit": {
|
||||
"normal": "9487b06867bc46269ed1d855420f457f",
|
||||
"unique": "b3081fb2fb1445fa90b12b01481a78ef",
|
||||
"legend": "c357d2daf31a489d95b8fa47e50dd879"
|
||||
}
|
||||
},
|
||||
"classToFrame": {
|
||||
"warrior": "warrior",
|
||||
"fighter": "warrior",
|
||||
"page": "warrior",
|
||||
"spearman": "warrior",
|
||||
"magician": "magician",
|
||||
"firepoison": "magician",
|
||||
"icelightning": "magician",
|
||||
"cleric": "magician",
|
||||
"bandit": "bandit",
|
||||
"curse": "bandit",
|
||||
"shiv": "bandit",
|
||||
"poisoner": "bandit",
|
||||
"trickster": "bandit"
|
||||
},
|
||||
"rewardWeights": {
|
||||
"normal": 70,
|
||||
"unique": 25,
|
||||
"legend": 5
|
||||
}
|
||||
}
|
||||
1284
data/cards.json
1284
data/cards.json
File diff suppressed because it is too large
Load Diff
7
data/characters.json
Normal file
7
data/characters.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"portraits": {
|
||||
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
|
||||
"magician": "3b9ea1f066a744bb859df47fef817277",
|
||||
"bandit": "efa920e58d31426486ef974106e7dc8b"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 14 },
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Defend", "value": 10 }
|
||||
{ "kind": "Defend", "value": 10 },
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 1 }
|
||||
]
|
||||
},
|
||||
"slime_boss": {
|
||||
@@ -24,6 +25,7 @@
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 18 },
|
||||
{ "kind": "Defend", "value": 12 },
|
||||
{ "kind": "Debuff", "effect": "vuln", "value": 2 },
|
||||
{ "kind": "Attack", "value": 10 },
|
||||
{ "kind": "Attack", "value": 22 }
|
||||
]
|
||||
@@ -32,17 +34,20 @@
|
||||
"name": "주황버섯",
|
||||
"maxHp": 16,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 5 },
|
||||
{ "kind": "Attack", "value": 5 },
|
||||
{ "kind": "Defend", "value": 4 },
|
||||
{ "kind": "Attack", "value": 7 }
|
||||
{ "kind": "Attack", "value": 8 }
|
||||
]
|
||||
},
|
||||
"blue_mushroom": {
|
||||
"name": "파란버섯",
|
||||
"maxHp": 22,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Attack", "value": 4 }
|
||||
{ "kind": "Attack", "value": 4 },
|
||||
{ "kind": "Attack", "value": 4 },
|
||||
{ "kind": "Attack", "value": 10 },
|
||||
{ "kind": "AddCard", "card": "Wound", "count": 1 }
|
||||
]
|
||||
},
|
||||
"pig": {
|
||||
@@ -50,7 +55,8 @@
|
||||
"maxHp": 18,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Defend", "value": 3 }
|
||||
{ "kind": "Attack", "value": 6 },
|
||||
{ "kind": "Defend", "value": 5 }
|
||||
]
|
||||
},
|
||||
"green_mushroom": {
|
||||
@@ -58,16 +64,38 @@
|
||||
"maxHp": 20,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 7 },
|
||||
{ "kind": "Attack", "value": 4 }
|
||||
{ "kind": "Defend", "value": 3 },
|
||||
{ "kind": "Attack", "value": 9 }
|
||||
]
|
||||
},
|
||||
"red_snail": {
|
||||
"name": "빨간 달팽이",
|
||||
"maxHp": 14,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 5 },
|
||||
{ "kind": "Defend", "value": 6 },
|
||||
{ "kind": "Attack", "value": 7 }
|
||||
]
|
||||
},
|
||||
"stump": {
|
||||
"name": "나무토막",
|
||||
"maxHp": 19,
|
||||
"intents": [
|
||||
{ "kind": "Defend", "value": 5 },
|
||||
{ "kind": "Attack", "value": 8 },
|
||||
{ "kind": "Attack", "value": 6 }
|
||||
]
|
||||
},
|
||||
"mushmom": {
|
||||
"name": "머쉬맘",
|
||||
"maxHp": 75,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 14 },
|
||||
{ "kind": "Defend", "value": 10 },
|
||||
{ "kind": "Attack", "value": 9 }
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 2 },
|
||||
{ "kind": "Attack", "value": 16 },
|
||||
{ "kind": "Attack", "value": 9 },
|
||||
{ "kind": "Defend", "value": 6 },
|
||||
{ "kind": "AddCard", "card": "Burn", "count": 1 }
|
||||
]
|
||||
},
|
||||
"modified_snail": {
|
||||
@@ -75,8 +103,10 @@
|
||||
"maxHp": 60,
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 12 },
|
||||
{ "kind": "Defend", "value": 8 },
|
||||
{ "kind": "Attack", "value": 7 },
|
||||
{ "kind": "Defend", "value": 8 }
|
||||
{ "kind": "Attack", "value": 14 },
|
||||
{ "kind": "Debuff", "effect": "weak", "value": 1 }
|
||||
]
|
||||
},
|
||||
"king_slime": {
|
||||
@@ -85,6 +115,7 @@
|
||||
"intents": [
|
||||
{ "kind": "Attack", "value": 18 },
|
||||
{ "kind": "Defend", "value": 14 },
|
||||
{ "kind": "Debuff", "effect": "vuln", "value": 2 },
|
||||
{ "kind": "Attack", "value": 12 },
|
||||
{ "kind": "Attack", "value": 24 }
|
||||
]
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"start": ["A", "B"],
|
||||
"nodes": {
|
||||
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
|
||||
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
|
||||
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
|
||||
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
|
||||
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
|
||||
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
|
||||
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
|
||||
}
|
||||
}
|
||||
11
data/nodeicons.json
Normal file
11
data/nodeicons.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"icons": {
|
||||
"combat": "f98db6823e894a4f90308d61f75894ac",
|
||||
"elite": "793ed8a757534b89a82f460747d2df24",
|
||||
"boss": "423056cdbbc04f4da131b9721c404d96",
|
||||
"shop": "da37e1fac55d455b9ade08569f09f798",
|
||||
"rest": "b86c1b0568bd45f3ae4a4b97e1b4a594",
|
||||
"treasure": "f8a6d58e20f54e2ca899485055df1ce4"
|
||||
},
|
||||
"background": "ef89906dd9844fcbaafc0b2313812eca"
|
||||
}
|
||||
14
data/potions.json
Normal file
14
data/potions.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"potions": {
|
||||
"redPotion": { "name": "빨간 포션", "desc": "HP 20 회복", "effect": "heal", "value": 20, "icon": "393e2a0d8da544899eaa8b22c97f832b" },
|
||||
"firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "7ddb464c2574456289a4eb72ce86f193" },
|
||||
"warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "7cfbd410581e4073815daaf5f3e6c72f" },
|
||||
"guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "8f8402dfa0f746e18bf606ed74302c0a" },
|
||||
"manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "ec2778c366f6477ab0f8e7f06bcd73f4" },
|
||||
"cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "a9a2763fdb6849dcba3028c737487680" }
|
||||
},
|
||||
"dropChance": 0.4,
|
||||
"baseSlots": 3,
|
||||
"beltSlots": 5,
|
||||
"shopPrice": 20
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
{
|
||||
"relics": {
|
||||
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6 },
|
||||
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1 },
|
||||
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1 },
|
||||
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 골드 +10", "hook": "combatReward", "effect": "gold", "value": 10 }
|
||||
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6, "icon": "e555b3a62f3c49dbb2c53784e6bd481f" },
|
||||
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1, "icon": "a41014f28b47434ab9f49ef104523862" },
|
||||
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1, "icon": "ed64cde7e6c44b9e99502847e54f04e9" },
|
||||
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 메소 +10", "hook": "combatReward", "effect": "gold", "value": 10, "icon": "03bb05c92b8f45edb0f3dad2e118fd5a" },
|
||||
"potionBelt": { "name": "장인의 벨트", "desc": "물약 슬롯이 5칸으로 늘어난다", "hook": "passive", "effect": "potionSlots", "value": 5, "icon": "36725b4566ac40d4902e2ab2113c2096" },
|
||||
"burningBlood": { "name": "자쿰의 투구", "desc": "전투 승리 시 HP 6 회복", "hook": "combatEnd", "effect": "healOnWin", "value": 6, "icon": "07f994825ce34131b419d43e890c878d" },
|
||||
"vajra": { "name": "미스릴 해머", "desc": "전투 시작 시 힘 +1", "hook": "combatStart", "effect": "strength", "value": 1, "icon": "59d2579d46dc41d590a9e6b141ad458b" },
|
||||
"anchor": { "name": "메이플 실드", "desc": "첫 턴 방어도 +10", "hook": "combatStart", "effect": "block", "value": 10, "icon": "6349413e08cc49848862591863d056a0" },
|
||||
"bagOfPrep": { "name": "모험가의 배낭", "desc": "첫 턴 드로우 +2", "hook": "combatStart", "effect": "draw", "value": 2, "icon": "77b240cb8af245b4801a714380267ae9" },
|
||||
"bloodVial": { "name": "피의 목걸이", "desc": "전투 시작 시 HP 2 회복", "hook": "combatStart", "effect": "heal", "value": 2, "icon": "c782e949506a42c49eb139c7e65527d7" },
|
||||
"bronzeScales": { "name": "브론즈 체인메일", "desc": "피격 시 공격자에게 3 반사", "hook": "onPlayerDamaged", "effect": "thorns", "value": 3, "icon": "87272346b145412391622cf803f888d1" },
|
||||
"strawberry": { "name": "건강의 반지", "desc": "획득 시 최대 HP +7", "hook": "passive", "effect": "maxHp", "value": 7, "icon": "58f643e29c354c2783a5ce9a72ec155c" },
|
||||
"penNib": { "name": "황금 깃펜", "desc": "10번째 공격마다 피해 2배", "hook": "attackCalc", "effect": "penNib", "value": 10, "icon": "4d38d721cc064d14b31b9e9a92754139" },
|
||||
"boot": { "name": "브론즈 부츠", "desc": "5 미만 공격 피해가 5로", "hook": "attackCalc", "effect": "boot", "value": 5, "icon": "d572b3aa4dac4162aa0d9e551b055dce" },
|
||||
"akabeko": { "name": "황소 투구", "desc": "전투 첫 공격 피해 +8", "hook": "attackCalc", "effect": "akabeko", "value": 8, "icon": "eb3330a6e2274eff958639f8792119d3" },
|
||||
"centennialPuzzle": { "name": "백년의 부적", "desc": "전투 첫 피격 시 드로우 3", "hook": "onPlayerDamaged", "effect": "firstLossDraw", "value": 3, "icon": "cfe5ed6556b944fc83ab58b774bb2b73" },
|
||||
"meatOnBone": { "name": "고기 망치", "desc": "승리 시 HP 50% 이하면 12 회복", "hook": "combatEnd", "effect": "healIfLow", "value": 12, "icon": "a93e8e87f184411c98c96b877d9f8b10" },
|
||||
"selfFormingClay": { "name": "점토 갑옷", "desc": "피해를 받으면 다음 턴 방어 +3", "hook": "onPlayerDamaged", "effect": "clayBlock", "value": 3, "icon": "bb446793c5204d5db7d33563fe79f648" },
|
||||
"championBelt": { "name": "챔피언 벨트", "desc": "취약 부여 시 약화 1 추가", "hook": "cardDebuff", "effect": "vulnAddsWeak", "value": 1, "icon": "7ca8c63026034113a561d6adf679fed2" }
|
||||
},
|
||||
"startingRelic": "ironHeart",
|
||||
"relicPool": ["energyCore", "vampire", "goldIdol"]
|
||||
"relicPool": [
|
||||
"energyCore", "vampire", "goldIdol",
|
||||
"potionBelt", "burningBlood", "vajra", "anchor", "bagOfPrep", "bloodVial",
|
||||
"bronzeScales", "strawberry", "penNib", "boot", "akabeko",
|
||||
"centennialPuzzle", "meatOnBone", "selfFormingClay", "championBelt"
|
||||
]
|
||||
}
|
||||
|
||||
160
docs/superpowers/plans/2026-06-11-act-maps.md
Normal file
160
docs/superpowers/plans/2026-06-11-act-maps.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 막별 맵 전환 + 맵별 인카운터 (P4) 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. T3·T4는 컨트롤러 직접.
|
||||
|
||||
**Goal:** 보스 클리어 시 다음 막의 맵(map02, map03)으로 텔레포트하고, 각 맵에 테마 몬스터 그룹(combat3/elite2/boss1)을 자동 구성.
|
||||
|
||||
**Architecture:** 신규 `tools/map/gen-map-encounters.mjs`가 map02~11 몬스터를 전면 교체(결정론). 컨트롤러는 `RegisterMonster`에 mapName 차원 추가 + `BuildMonsters` 플레이어 맵 필터 + 보스 클리어 시 `TeleportToActMap`.
|
||||
|
||||
---
|
||||
|
||||
## 배경 (구현자용)
|
||||
- 생성물은 단일 소스 규칙(gen-slaydeck → ui/codeblock/common). 맵 파일은 전용 생성기가 직접 패치. 산출물(slaydeck 3종)은 마지막에 일괄, **맵 파일은 T1에서 바로 커밋**.
|
||||
- 현재 `RegisterMonster(monster, enemyId, group)`(3인자), `BuildMonsters`는 `r.group == g` 필터. `CombatMonster.codeblock`은 `tools/monster/gen-combat-monster.mjs`가 생성(OnBeginPlay에서 3인자 등록).
|
||||
- gen-maps의 `MONSTER_VARIANTS` 9종(sprite/stand/hit/die RUID)과 `mapGuid(nn, idx)`·`rng(seed)` 패턴 참조: `tools/map/gen-maps.mjs`.
|
||||
- CheckCombatEnd 보스 분기: `self.Floor = self.Floor + 1 ... self:ShowMap()`.
|
||||
- JS 상수: writeCodeblocks 안 `ACT_COUNT = 3` 존재.
|
||||
|
||||
## Task 1: gen-map-encounters.mjs (map02~11 인카운터)
|
||||
|
||||
**Files:** Create `tools/map/gen-map-encounters.mjs`; Modify(산출) `map/map02.map`~`map11.map`
|
||||
|
||||
- [ ] **Step 1: 생성기 작성.** `tools/map/gen-maps.mjs`를 READ해 `MONSTER_VARIANTS`(9종 배열 — 그대로 복사)·`rng`·`mapGuid` 패턴을 가져와 아래 구조로 작성:
|
||||
```js
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성.
|
||||
// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론).
|
||||
const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom'];
|
||||
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
||||
const BOSS_POOL = ['king_slime', 'slime_boss'];
|
||||
const LAYOUT = [
|
||||
{ group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 },
|
||||
{ group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 },
|
||||
{ group: 'boss', x: 4.0 },
|
||||
];
|
||||
const MONSTER_VARIANTS = [ /* gen-maps.mjs에서 9종 그대로 복사 */ ];
|
||||
|
||||
function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
|
||||
function encGuid(nn, idx) {
|
||||
const n = (nn * 1000 + 500 + idx) >>> 0; // gen-maps의 mapGuid(idx 0~)와 비충돌(+500 오프셋)
|
||||
return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster');
|
||||
const compOf = (e, t) => e.jsonString['@components'].find((c) => c['@type'] === t);
|
||||
|
||||
function pick(rand, pool) { return pool[Math.floor(rand() * pool.length)]; }
|
||||
function pickN(rand, pool, n) { // 중복 없는 n개(부족하면 순환)
|
||||
const a = pool.slice();
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (a.length === 0) a.push(...pool);
|
||||
out.push(a.splice(Math.floor(rand() * a.length), 1)[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function patchMap(nn) {
|
||||
const tag = String(nn).padStart(2, '0');
|
||||
const file = `map/map${tag}.map`;
|
||||
const map = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const ents = map.ContentProto.Entities;
|
||||
const monsters = ents.filter(isMonster);
|
||||
if (monsters.length === 0) throw new Error(`[gen-map-encounters] ${file} 몬스터 템플릿 없음`);
|
||||
const template = monsters[0];
|
||||
map.ContentProto.Entities = ents.filter((e) => !isMonster(e));
|
||||
const rand = rng(nn * 7919 + 17);
|
||||
const combatIds = pickN(rand, COMBAT_POOL, 3);
|
||||
const eliteIds = pickN(rand, ELITE_POOL, 2);
|
||||
const bossId = pick(rand, BOSS_POOL);
|
||||
const variants = pickN(rand, MONSTER_VARIANTS, 6);
|
||||
LAYOUT.forEach((slot, idx) => {
|
||||
const m = JSON.parse(JSON.stringify(template));
|
||||
const enemyId = slot.group === 'combat' ? combatIds[idx] : slot.group === 'elite' ? eliteIds[idx - 3] : bossId;
|
||||
const name = `${slot.group}_${idx + 1}`;
|
||||
m.id = encGuid(nn, idx);
|
||||
m.path = `/maps/map${tag}/${name}`;
|
||||
m.jsonString.path = m.path;
|
||||
m.jsonString.name = name;
|
||||
const o = m.jsonString.origin;
|
||||
if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; }
|
||||
const tr = compOf(m, 'MOD.Core.TransformComponent');
|
||||
if (tr && tr.Position) tr.Position.x = slot.x;
|
||||
const v = variants[idx];
|
||||
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
|
||||
if (sp) sp.SpriteRUID = v.stand;
|
||||
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
|
||||
if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die };
|
||||
let cm = compOf(m, 'script.CombatMonster');
|
||||
if (!cm) {
|
||||
cm = { '@type': 'script.CombatMonster', Enable: true };
|
||||
m.jsonString['@components'].push(cm);
|
||||
const names = (m.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster');
|
||||
names.push('script.CombatMonster');
|
||||
m.componentNames = names.join(',');
|
||||
}
|
||||
cm.EnemyId = enemyId;
|
||||
cm.Group = slot.group;
|
||||
map.ContentProto.Entities.push(m);
|
||||
});
|
||||
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
|
||||
return `map${tag}(${combatIds.join('/')}|${eliteIds.join('/')}|${bossId})`;
|
||||
}
|
||||
|
||||
const made = MAP_NUMBERS.map(patchMap);
|
||||
console.log('Encounters:', made.join(', '));
|
||||
```
|
||||
- [ ] **Step 2:** 실행 + 검증: 각 맵 6마리·그룹 3/2/1·EnemyId 전부 enemies.json 존재·dup guid 0:
|
||||
`node tools/map/gen-map-encounters.mjs && node -e "const en=JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')).enemies;let bad=0;for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const m=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));const g={combat:0,elite:0,boss:0};for(const e of ms){const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');g[c.Group]++;if(!en[c.EnemyId]){bad++;console.log('BAD enemy',t,c.EnemyId);}}if(!(g.combat===3&&g.elite===2&&g.boss===1)){bad++;console.log('BAD groups',t,JSON.stringify(g));}const ids=m.ContentProto.Entities.map(e=>e.id);if(ids.length!==new Set(ids).size){bad++;console.log('DUP guid',t);}}console.log(bad===0?'all maps OK':'BAD:'+bad)"`
|
||||
2회 실행 동일(결정론) 확인.
|
||||
- [ ] **Step 3:** Commit: `git add tools/map/gen-map-encounters.mjs map/ && git commit -m "feat(act-maps): map02~11 인카운터 자동 구성 (combat3/elite2/boss1·맵별 테마)"`
|
||||
|
||||
## Task 2: 컨트롤러 — 맵 필터 + 막 텔레포트
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`, `tools/monster/gen-combat-monster.mjs`
|
||||
|
||||
- [ ] **Step 1 (gen-combat-monster):** OnBeginPlay 등록 호출을 4인자로 — 자기 맵 이름 전달:
|
||||
```
|
||||
local mapName = ""
|
||||
if self.Entity.CurrentMapName ~= nil then
|
||||
mapName = self.Entity.CurrentMapName
|
||||
end
|
||||
c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName)
|
||||
```
|
||||
(reg 함수 내 기존 RegisterMonster 줄 교체. CurrentMapName 미지원이면 빈 문자열 — BuildMonsters에서 빈 값은 항상 통과시켜 하위 호환.)
|
||||
- [ ] **Step 2 (gen-slaydeck):** `RegisterMonster`에 4번째 인자 `mapName`(string) 추가, 저장 항목에 `map = mapName`(nil/빈 처리: `local mp = mapName; if mp == nil then mp = "" end`).
|
||||
- [ ] **Step 3 (gen-slaydeck BuildMonsters):** 그룹 필터 줄을 확장:
|
||||
```
|
||||
local pmap = ""
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end
|
||||
```
|
||||
(reg 수집 루프 앞에 추가) 그리고 필터 조건을 `r.group == g and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap)` 로.
|
||||
- [ ] **Step 4 (gen-slaydeck 막 전환):** writeCodeblocks에 `const ACT_MAPS = ['map01', 'map02', 'map03'];` 추가(ACT_COUNT 옆). `CheckCombatEnd` 보스 분기의 `self:RenderRun()` 다음, `self:ShowMap()` **앞**에 `self:TeleportToActMap()` 삽입. 신규 메서드:
|
||||
```js
|
||||
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)`),
|
||||
```
|
||||
- [ ] **Step 5:** `node --check` 둘 다 → gen-combat-monster 실행(코드블록 재생성+맵 no-clobber 확인) → gen-slaydeck 실행 → codeblock에 TeleportToActMap·4인자 등록·맵 필터 확인 → **slaydeck 산출물 복원**(codeblock/ui/common), CombatMonster.codeblock은 커밋 대상.
|
||||
- [ ] **Step 6:** Commit: `git add tools/deck/gen-slaydeck.mjs tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ && git commit -m "feat(act-maps): 막별 맵 텔레포트 + 등록 맵 필터"` (map/은 gen-combat-monster 재실행이 기존 맵 값 보존하므로 변화 없을 것 — 변화 있으면 확인 후 포함)
|
||||
|
||||
## Task 3 (컨트롤러 직접): 재생성·검증·커밋
|
||||
P2/P3 T5와 동일: gen-slaydeck 재생성→dup0·심볼(TeleportToActMap)·결정성·sim→산출물 커밋.
|
||||
|
||||
## Task 4 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지
|
||||
1막 보스 처치(스크립트)→Floor2 텔레포트→map02 도착(스크린샷: 새 배경·새 몬스터들)→전투 진입(combat 그룹 3마리·새 EnemyId/외형)→registered 맵 필터 로그. 통과 후 푸시→PR→머지.
|
||||
|
||||
## Self-Review
|
||||
- 스펙 §2.1→T2 Step4, §2.2→T2 1~3, §2.3→T1. encGuid +500 오프셋은 gen-maps idx(몬스터 2~)와 비충돌. CombatMonster 값은 T1이 직접 태그(no-clobber 생성기와 호환 — 이미 존재라 keep). CurrentMapName 불확실성은 빈 값 통과 폴백으로 하위 호환(T4 검증).
|
||||
212
docs/superpowers/plans/2026-06-11-card-visuals.md
Normal file
212
docs/superpowers/plans/2026-06-11-card-visuals.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 메이플 스킬 카드 비주얼 (P2) 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Task 1·6은 메이커 MCP 인터랙티브 작업 — 컨트롤러가 직접 수행.**
|
||||
|
||||
**Goal:** 카드(손패/보상/상점/인스펙터/모든덱)에 메이플 스킬 이미지+프레임을 입히고 이름을 전사 스킬명으로 바꾼다 (효과·밸런스 불변).
|
||||
|
||||
**Architecture:** `data/cards.json`에 `image`(공식 RUID)·새 `name`. `gen-slaydeck.mjs`가 카드 자식 엔티티(Art/NamePlate/CostPlate)를 5표면에 생성하고, 런타임 렌더는 새 `ApplyCardFace(base, cardId)` 단일 헬퍼로 통일(기존 4개 Apply* 함수가 위임). 공식 RUID는 로컬 워크스페이스에서 렌더됨이 실측 검증됨(스펙 §1).
|
||||
|
||||
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock, asset_search_resources MCP(수확), maker MCP(선별·검증).
|
||||
|
||||
---
|
||||
|
||||
## 배경 (구현자용)
|
||||
|
||||
- 생성물 3종은 `tools/deck/gen-slaydeck.mjs` 단일 소스(직접 편집 금지). 루트에서 `node tools/deck/gen-slaydeck.mjs`. 각 Task 검증 후 산출물 복원(`git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`), 산출물 커밋은 T5.
|
||||
- 카드 렌더 함수 4종(현재 동일 모양): `ApplyCardVisual`(손패 `/ui/DefaultGroup/CardHand/Card{slot}`), `ApplyRewardVisual`(`/RewardHud/Reward{slot}`), `ApplyInspectCardVisual`(`/DeckInspectHud/Grid/Card{slot}`), `ApplyAllDeckCardVisual`(`/DeckAllHud/Grid/Card{slot}`). 각각 Cost/Name/Desc SetText + 루트 색.
|
||||
- 카드 크기: 손패/보상/상점 180×250(`CARD_W`/`CARD_H`), 그리드(인스펙터/모든덱) 셀 158×214.
|
||||
- 색: ATTACK {0.86,0.42,0.38} / DEFEND {0.42,0.55,0.85} / SKILL {0.46,0.68,0.52} (luaCardsTable의 kind 기반).
|
||||
|
||||
## 파일 구조
|
||||
| 파일 | 책임 |
|
||||
|---|---|
|
||||
| `data/cards.json` | name 3종 변경 + image RUID 3종 (T1) |
|
||||
| `tools/deck/gen-slaydeck.mjs` | luaCardsTable image·ApplyCardFace·4함수 통일(T2), 카드 프레임 엔티티 5표면(T3·T4) |
|
||||
| 산출물 | T5 재생성·커밋 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 (컨트롤러 직접): RUID 수확 + cards.json
|
||||
|
||||
메이커 MCP 인터랙티브 — subagent 금지, 컨트롤러가 수행.
|
||||
|
||||
- [ ] **Step 1:** `asset_search_resources`(cat=sprite, source=maplestory)로 후보 수집: 질의 "파워 스트라이크"(이미 10건 확보), "슬래시 블러스트", "아이언 바디". 후보 부족 시 보조 질의("슬래시", "강철", "워리어").
|
||||
- [ ] **Step 2:** 메이커 Play→전투 진입 후, 후보 RUID를 Card1 Art 자리(`SpriteGUIRendererComponent.ImageRUID`, Type=0)에 순회 주입 + 스크린샷으로 스킬당 1개 선별(일러스트 적합성 기준: 식별 가능·단일 컷·과도한 투명 여백 없음).
|
||||
- [ ] **Step 3:** `data/cards.json`을 다음 형태로 갱신(RUID는 선별값으로):
|
||||
```json
|
||||
{
|
||||
"cards": {
|
||||
"Strike": { "name": "파워 스트라이크", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6", "image": "<선별RUID>" },
|
||||
"Defend": { "name": "아이언 바디", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5", "image": "<선별RUID>" },
|
||||
"Bash": { "name": "슬래시 블러스트", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10", "image": "<선별RUID>" }
|
||||
},
|
||||
"starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"]
|
||||
}
|
||||
```
|
||||
- [ ] **Step 4:** `node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8'));console.log('ok')"` → ok. sim 회귀: `node --test tools/balance/sim-balance.test.mjs` → 14/14 (fixture 자체 카드라 무관).
|
||||
- [ ] **Step 5:** Commit: `git add data/cards.json && git commit -m "feat(card-visuals): 카드를 전사 스킬로 리네임 + 공식 스킬 이미지 RUID"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ApplyCardFace 렌더 일원화
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: luaCardsTable에 image 직렬화.** `function luaCardsTable(cards)`의 fields 구성에 추가:
|
||||
```js
|
||||
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
||||
```
|
||||
(`if (c.block != null) ...` 줄 다음에.)
|
||||
|
||||
- [ ] **Step 2: ApplyCardFace 메서드 추가** (`ApplyCardVisual` 메서드 정의 바로 앞에):
|
||||
```js
|
||||
method('ApplyCardFace', `local c = self.Cards[cardId]
|
||||
if c == nil then
|
||||
c = { name = cardId, cost = 0, desc = "", kind = "Skill" }
|
||||
end
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if c.kind == "Attack" then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
|
||||
elseif c.kind == "Skill" then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Cost", string.format("%d", c.cost))
|
||||
self:SetText(base .. "/Name", c.name)
|
||||
self:SetText(base .. "/Desc", 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' },
|
||||
]),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 기존 4개 함수를 위임으로 교체.** 각 메서드 본문 전체를:
|
||||
- `ApplyCardVisual`: `self:ApplyCardFace("/ui/DefaultGroup/CardHand/Card" .. tostring(slot), cardId)`
|
||||
- `ApplyRewardVisual`: `self:ApplyCardFace("/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot), cardId)`
|
||||
- `ApplyInspectCardVisual`: `self:ApplyCardFace("/ui/DefaultGroup/DeckInspectHud/Grid/Card" .. tostring(slot), cardId)`
|
||||
- `ApplyAllDeckCardVisual`: 기존 본문에서 카드 면 설정부를 `self:ApplyCardFace("/ui/DefaultGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)`로 교체(본문에 수량 배지 등 추가 로직이 있으면 그 부분은 유지 — 현재 본문을 읽고 카드면(Cost/Name/Desc/색) 설정부만 위임).
|
||||
(인자 목록은 변경 없음.)
|
||||
|
||||
- [ ] **Step 4: RenderShop 카드부 위임.** `RenderShop` 본문에서 상점 카드 면 설정부(Card{i}의 Cost/Name/Desc SetText + 색 설정)를 `self:ApplyCardFace(base, cid)` 호출로 교체(가격(Price) SetText·구매 상태 처리는 유지). 본문을 읽고 해당 부분만 정확히 치환.
|
||||
|
||||
- [ ] **Step 5: 검증.** `node --check` → OK. `node tools/deck/gen-slaydeck.mjs` 후:
|
||||
`node -e "const cb=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const s=JSON.stringify(cb);const names=cb.ContentProto.Json.Methods.map(m=>m.Name);console.log('face:',names.includes('ApplyCardFace'),'| image in Cards:',s.includes('image = '),'| delegates:',(s.match(/ApplyCardFace\(/g)||[]).length>=5)"`
|
||||
Expected: 모두 true. 산출물 복원.
|
||||
|
||||
- [ ] **Step 6: Commit:** `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): ApplyCardFace 렌더 일원화 + Cards image 직렬화"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 손패 카드 프레임 (Card1~5)
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs` (upsertUi의 손패 카드 갱신부)
|
||||
|
||||
- [ ] **Step 1: 현재 손패 카드 빌드부 읽기.** upsertUi에서 `for (let i = 1; i <= 5; i++)`로 `/ui/DefaultGroup/CardHand/Card{i}`를 byPath 갱신하고 children(['Cost'...],['Name'...],['Desc'...])을 생성/갱신하는 블록을 찾는다.
|
||||
|
||||
- [ ] **Step 2: children 배열을 프레임 배치로 교체.** 기존 children 항목의 cfg를:
|
||||
```js
|
||||
const children = [
|
||||
['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: cards[i - 1].cost, fontSize: 26, bold: true }],
|
||||
['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: cards[i - 1].name, fontSize: 20, bold: true }],
|
||||
['Desc', { size: { x: 164, y: 70 }, pos: { x: 0, y: -62 }, value: cards[i - 1].desc, fontSize: 18, bold: false }],
|
||||
];
|
||||
```
|
||||
로 교체(기존 child 갱신 분기에서도 cfg의 size/pos를 반영하도록 — 기존 갱신 분기가 Text/FontSize만 갱신한다면 transform도 cfg로 갱신하는 줄 추가:
|
||||
```js
|
||||
const tr0 = child.jsonString['@components'][0];
|
||||
tr0.RectSize = cfg.size;
|
||||
tr0.anchoredPosition = cfg.pos;
|
||||
tr0.OffsetMin = { x: cfg.pos.x - cfg.size.x / 2, y: cfg.pos.y - cfg.size.y / 2 };
|
||||
tr0.OffsetMax = { x: cfg.pos.x + cfg.size.x / 2, y: cfg.pos.y + cfg.size.y / 2 };
|
||||
```
|
||||
)
|
||||
|
||||
- [ ] **Step 3: 프레임 자식 추가(없으면 생성, byPath 패턴 동일).** children 루프 뒤에 카드별로:
|
||||
```js
|
||||
const frameKids = [
|
||||
['NamePlate', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 3,
|
||||
{ size: { x: 168, y: 34 }, pos: { x: 0, y: -8 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.92 }],
|
||||
['CostPlate', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 4,
|
||||
{ size: { x: 44, y: 44 }, pos: { x: -68, y: 103 } }, { r: 0.07, g: 0.08, b: 0.1, a: 0.95 }],
|
||||
['Art', 'uisprite', 'UISprite', 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', 5,
|
||||
{ size: { x: 96, y: 96 }, pos: { x: 0, y: 52 } }, { r: 1, g: 1, b: 1, a: 1 }],
|
||||
];
|
||||
for (const [suffix, modelId, entryId, componentNames, dOrder, cfg, color] of frameKids) {
|
||||
const fPath = `/ui/DefaultGroup/CardHand/Card${i}/${suffix}`;
|
||||
if (!byPath.get(fPath)) {
|
||||
const fe = entity({
|
||||
id: guid('dck', 100 + i * 10 + dOrder),
|
||||
path: fPath, modelId, entryId, componentNames,
|
||||
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(suffix === 'Art' ? { color, type: 0, raycast: false } : { color, type: 1, raycast: false }),
|
||||
],
|
||||
});
|
||||
ui.ContentProto.Entities.push(fe);
|
||||
byPath.set(fPath, fe);
|
||||
}
|
||||
}
|
||||
```
|
||||
주의: `guid('dck', N)` 기존 사용 대역 확인(grep `guid('dck'`) 후 충돌 시 200+로 이동. Cost/Name/Desc 텍스트가 NamePlate/CostPlate **위에** 그려지도록 displayOrder 관계 확인(텍스트 0/1/2 < 플레이트 3/4면 플레이트가 위 — **플레이트가 텍스트를 가리면 안 되므로** 텍스트 displayOrder를 6/7/8로 올리고 플레이트 3/4·Art 5 유지: Cost→7, Name→6, Desc→8로 child 생성/갱신 분기에서 displayOrder 설정).
|
||||
|
||||
- [ ] **Step 4: 검증.** `node --check` → 실행 → 확인:
|
||||
`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('art:',[1,2,3,4,5].every(i=>p.includes('/ui/DefaultGroup/CardHand/Card'+i+'/Art')),'| plates:',[1,2,3,4,5].every(i=>p.includes('/ui/DefaultGroup/CardHand/Card'+i+'/NamePlate')))"` → 모두 true. dup id 0 확인. 산출물 복원.
|
||||
|
||||
- [ ] **Step 5: Commit:** `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): 손패 카드 프레임(Art·NamePlate·CostPlate)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 보상/상점/그리드 카드 프레임
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: 보상 카드(Reward1~3, 180×250).** 보상 카드 빌더(자식 Cost/Name/Desc 생성 루프)에서 자식 cfg를 Task 3 Step 2와 동일 좌표로 바꾸고(Cost 44×44@(-68,103) f26 / Name 168×30@(0,-8) f20 / Desc 164×70@(0,-62) f18), 동일한 NamePlate/CostPlate/Art 3종을 push(부모 RewardHud/Reward{i}, guid('rwd', 100+i*10+dOrder), displayOrder: 텍스트 6/7/8·플레이트 3/4·Art 5).
|
||||
|
||||
- [ ] **Step 2: 상점 카드(ShopHud/Card1~3, 180×250).** 동일 적용하되 Desc는 `{ size: { x: 164, y: 56 }, pos: { x: 0, y: -58 } }` (Price (0,-105)와 1px 간격 — Price·구매로직 불변). guid('shp', 100+i*10+dOrder).
|
||||
|
||||
- [ ] **Step 3: 그리드 카드(DeckInspectHud/Grid/Card{n}·DeckAllHud/Grid/Card{n}, 158×214).** 두 빌더(line≈661, 783)에 비례 축소 프레임: Art 84×84@(0,44) / NamePlate 148×30@(0,-8) / CostPlate 38×38@(-58,86); 텍스트 cfg: Cost 38×38@(-58,86) f22 / Name 148×26@(0,-8) f17 / Desc 144×60@(0,-54) f15. guid는 각 빌더의 기존 네임스페이스 시퀀스 이어쓰기(중복 검증으로 확인).
|
||||
|
||||
- [ ] **Step 4: 검증.** 실행 후:
|
||||
`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=ui.ContentProto.Entities;const cnt=s=>E.filter(e=>e.path.includes(s)&&e.path.endsWith('/Art')).length;console.log('reward:',cnt('/RewardHud/Reward'),'shop:',cnt('/ShopHud/Card'),'inspect:',cnt('/DeckInspectHud/Grid/'),'alldeck:',cnt('/DeckAllHud/Grid/'));const ids=E.map(e=>e.id);console.log('dup:',ids.filter((x,i)=>ids.indexOf(x)!==i).length)"`
|
||||
Expected: reward:3 shop:3 inspect:(그리드 수) alldeck:(그리드 수), dup:0. 산출물 복원.
|
||||
|
||||
- [ ] **Step 5: Commit:** `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(card-visuals): 보상·상점·그리드 카드 프레임 적용"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 재생성·검증·산출물 커밋
|
||||
|
||||
- [ ] **Step 1:** `node tools/deck/gen-slaydeck.mjs` → exit 0.
|
||||
- [ ] **Step 2:** JSON 3종 파스 + dup 0 + 손패/보상/상점/그리드 Art 존재 + codeblock에 ApplyCardFace·image 직렬화(`image = `)·새 카드명("파워 스트라이크") 포함 확인:
|
||||
`node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('face+image+name:',cb.includes('ApplyCardFace')&&cb.includes('image = ')&&cb.includes('파워 스트라이크'))"` → true.
|
||||
- [ ] **Step 3:** 결정성(재실행 빈 diff) + sim 14/14.
|
||||
- [ ] **Step 4:** Commit: `git add ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock && git commit -m "feat(card-visuals): 산출물 재생성"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6 (컨트롤러 직접): 메이커 플레이테스트 + 푸시 + PR
|
||||
|
||||
- refresh → build 0 → play. 4표면 스크린샷: ①손패(전투, 스킬 이미지+프레임+새 이름) ②보상 ③상점 ④모든덱/인스펙터. 이미지 미적용/프레임 깨짐/그리드 셀 영향 발견 시 좌표·RUID 수정 → 재생성 → 재확인.
|
||||
- 통과 후: `git push -u origin feature/p2-card-visuals`(인증 실패 시 1회 재시도) → PR 링크+메시지 제공.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
- **스펙 커버리지**: §2→T1, §3→T1, §4→T3·T4, §5→T2, §6→T1~T5, §8→T5·T6. 전부 매핑.
|
||||
- **플레이스홀더**: T1의 `<선별RUID>`는 수확 절차의 산출물로 정의됨(절차 명시). 그 외 실제 코드.
|
||||
- **일관성**: `ApplyCardFace(base, cardId)` 시그니처·자식명(Art/NamePlate/CostPlate)·displayOrder 규칙(플레이트3/4·Art5·텍스트6/7/8) Task 간 일치. guid 충돌은 각 Task 검증(dup 0)으로 강제.
|
||||
- **주의**: 손패 byPath 갱신 분기·RenderShop/ApplyAllDeckCardVisual 본문은 구현 시 현재 코드를 읽고 지정 부분만 치환(앵커 명시됨).
|
||||
289
docs/superpowers/plans/2026-06-11-combat-feel.md
Normal file
289
docs/superpowers/plans/2026-06-11-combat-feel.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 전투 연출 (P3) 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Task 6은 메이커 MCP — 컨트롤러 직접.
|
||||
|
||||
**Goal:** 카드 드래그→몬스터 지정, 공격 이펙트 후 데미지, 적 개별 차례, 데미지 팝업.
|
||||
|
||||
**Architecture:** 카드에 `UITouchReceiveComponent`(공식 드래그 이벤트). 연출은 컨트롤러 타이머 체인(`FxBusy`/`TurnBusy` 가드). 모든 변경은 `tools/deck/gen-slaydeck.mjs` 단일 소스.
|
||||
|
||||
**Tech Stack:** Node ESM 생성기, MSW Lua, UITouchReceive/UILogic/TimerService.
|
||||
|
||||
---
|
||||
|
||||
## 배경 (구현자용)
|
||||
- 루트에서 `node tools/deck/gen-slaydeck.mjs`. 각 Task: `node --check` → 생성 → 확인 → **산출물 복원**(`git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`) → 소스만 커밋. 산출물 커밋은 T5.
|
||||
- 손패 카드: `/ui/DefaultGroup/CardHand/Card{1..5}`, 원위치 x=`CARD_XS[i]`(-400..400), y=0, 부모 CardHand는 화면 UI좌표 (0,-360) 중심(앵커 bottom-center pos y180).
|
||||
- 몬스터: `self.Monsters[i] = {entity, ..., alive, slot}`; world→screen은 `_UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y+1.4))` 패턴(PositionMonsterSlot 참조).
|
||||
- 기존: `BindButtons`에 카드 클릭 `ConnectEvent(ButtonClickEvent, ... PlayCard(i))` 루프 존재(제거 대상). `PlayCard`는 즉시 `DealDamageToTarget`. `EndPlayerTurn`은 손패 버림→`EnemyTurn()`→`CheckCombatEnd`→타이머로 `StartPlayerTurn`. `KillMonster`는 즉시 `SetVisible(false)`.
|
||||
- guid 'cmb' 사용 대역: 0~10·41~144(+221~224 TargetFrame)·200~216. **신규: SkillFx=230, ActFrame=240+i, DmgPop slot=250+i, player DmgPop=260.**
|
||||
|
||||
## Task 1: 카드 드래그 타겟팅
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: 카드 엔티티에 UITouchReceiveComponent.** upsertUi 손패 카드(byPath 갱신 분기)에서 Card{i} 루트의 componentNames에 `MOD.Core.UITouchReceiveComponent`가 없으면 추가하고 `@components`에 `{ '@type': 'MOD.Core.UITouchReceiveComponent', Enable: true }` push (기존 ButtonComponent 추가 패턴과 동일한 create-if-missing 방식 — 그 코드를 읽고 모방).
|
||||
|
||||
- [ ] **Step 2: 드래그 상태 prop 추가.** SlayDeckController prop 배열에:
|
||||
```js
|
||||
prop('number', 'DragSlot', '0'),
|
||||
prop('boolean', 'FxBusy', 'false'),
|
||||
prop('boolean', 'TurnBusy', 'false'),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: BindButtons — 카드 클릭 제거 + 드래그 연결.** 기존 `for i = 1, 5 do ... PlayCard(i) ... end`(카드 ButtonClickEvent 루프)를 다음으로 교체:
|
||||
```lua
|
||||
for i = 1, 5 do
|
||||
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
|
||||
if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then
|
||||
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)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 드래그 메서드 3종 + ResolveCardDrop 추가** (CARD_XS는 JS 상수 — 보간으로 Lua 테이블 굽기):
|
||||
```js
|
||||
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
|
||||
self.DragSlot = slot`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('OnCardDrag', `if self.DragSlot ~= slot then
|
||||
return
|
||||
end
|
||||
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/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`, [
|
||||
{ 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 cardXs = { ${CARD_XS.join(', ')} }
|
||||
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
|
||||
if e ~= nil and e.UITransformComponent ~= nil then
|
||||
e.UITransformComponent.anchoredPosition = Vector2(cardXs[slot], 0)
|
||||
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.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 = 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
|
||||
if best > 0 then
|
||||
self.TargetIndex = best
|
||||
self:PlayCard(slot)
|
||||
end
|
||||
else
|
||||
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' },
|
||||
]),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 검증.** node --check → 생성 → `node -e "const cb=require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('drag:',cb.includes('OnCardDragBegin')&&cb.includes('UITouchBeginDragEvent'),'| no card click PlayCard loop:',!/Card\\\" \.\. tostring\(i\)\)[\s\S]{0,220}ButtonClickEvent[\s\S]{0,80}PlayCard\(i\)/.test(cb))"` (두 번째 체크가 어려우면 수동으로 BindButtons에서 카드 ButtonClickEvent 루프 부재 확인). ui에 UITouchReceiveComponent 5장 확인:
|
||||
`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log(ui.ContentProto.Entities.filter(e=>/CardHand\/Card[1-5]$/.test(e.path)&&e.componentNames.includes('UITouchReceiveComponent')).length)"` → 5. 산출물 복원.
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(combat-feel): 카드 드래그 타겟팅 (UITouchReceive·ResolveCardDrop)`
|
||||
|
||||
## Task 2: 공격 이펙트 → 지연 데미지
|
||||
|
||||
- [ ] **Step 1: SkillFx 엔티티** (upsertUi CombatHud, Result 이전):
|
||||
```js
|
||||
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);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: PlayCard Attack 분기 교체.** 기존:
|
||||
```
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
self:DealDamageToTarget(c.damage)
|
||||
end
|
||||
self:ApplyRelics("cardPlayed")
|
||||
```
|
||||
→
|
||||
```
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
self:PlayAttackFx(self.TargetIndex, c.image, c.damage)
|
||||
end
|
||||
self:ApplyRelics("cardPlayed")
|
||||
```
|
||||
그리고 PlayCard 끝의 `self:CheckCombatEnd()`는 유지(Skill 경로용 — Attack은 PlayAttackFx 완료 시 재호출).
|
||||
|
||||
- [ ] **Step 3: PlayAttackFx 추가:**
|
||||
```js
|
||||
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)
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
return
|
||||
end
|
||||
self.FxBusy = true
|
||||
local fx = _EntityService:GetEntityByPath("/ui/DefaultGroup/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
|
||||
self:DealDamageToTarget(damage)
|
||||
self:ShowDmgPop(targetIndex, damage)
|
||||
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' },
|
||||
]),
|
||||
```
|
||||
주의: `ShowDmgPop`은 T3에서 추가 — T2 커밋 시점엔 생성기 실행이 깨지지 않음(문자열일 뿐). PlayCard/EndPlayerTurn 시작 가드에 `or self.FxBusy == true or self.TurnBusy == true` 추가(기존 `if self.CombatOver == true then return end`를 확장).
|
||||
|
||||
- [ ] **Step 4: 검증** (codeblock에 PlayAttackFx·SkillFx 존재, PlayCard에 PlayAttackFx 호출). 산출물 복원, 커밋 `feat(combat-feel): 공격 이펙트 후 지연 데미지 (SkillFx·FxBusy)`
|
||||
|
||||
## Task 3: 데미지 팝업 + 사망 지연
|
||||
|
||||
- [ ] **Step 1: DmgPop 엔티티.** 몬스터 슬롯 루프에 자식 추가(dOrder 9, guid cmb 250+i): 텍스트 120×30 @(0, 60), fontSize 24 bold, 색 {1,0.35,0.3,1}, value '', enable=false. PlayerPanel에도 동일(guid cmb 260, 경로 `PlayerPanel/DmgPop`, pos (16, 40), 색 {1,0.4,0.35,1}).
|
||||
- [ ] **Step 2: ShowDmgPop / ShowPlayerDmgPop:**
|
||||
```js
|
||||
method('ShowDmgPop', `local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot) .. "/DmgPop"
|
||||
self:SetText(base, "-" .. string.format("%d", amount))
|
||||
self:SetEntityEnabled(base, true)
|
||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('ShowPlayerDmgPop', `local base = "/ui/DefaultGroup/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' }]),
|
||||
```
|
||||
- [ ] **Step 3: KillMonster 사망 지연.** `m.entity:SetVisible(false)` 즉시 호출을:
|
||||
```
|
||||
local ent = m.entity
|
||||
_TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.4)
|
||||
```
|
||||
로 교체.
|
||||
- [ ] **Step 4: 검증·복원·커밋** `feat(combat-feel): 데미지 팝업·사망 지연`
|
||||
|
||||
## Task 4: 적 개별 차례
|
||||
|
||||
- [ ] **Step 1: ActFrame 엔티티.** 몬스터 슬롯 루프에 자식(dOrder 0보다 아래는 불가하니 TargetFrame처럼 dOrder 0, guid cmb 240+i — TargetFrame과 별도): 156×108 @(0,0), 색 {0.95,0.3,0.25,0.3}, enable=false. (TargetFrame과 같은 위치 — 적 턴 중에는 TargetFrame 대신 표시됨.)
|
||||
- [ ] **Step 2: EnemyTurn 시퀀스 교체.** `EnemyTurn` 전체를:
|
||||
```js
|
||||
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/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx)
|
||||
self:SetEntityEnabled(base .. "/ActFrame", true)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
m.block = 0
|
||||
local intent = m.intents[m.intentIdx]
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
local before = self.PlayerHp
|
||||
self:DealDamageToPlayer(intent.value)
|
||||
self:ShowPlayerDmgPop(before - self.PlayerHp)
|
||||
elseif intent.kind == "Defend" then
|
||||
m.block = m.block + intent.value
|
||||
end
|
||||
end
|
||||
m.intentIdx = m.intentIdx + 1
|
||||
if m.intentIdx > #m.intents then
|
||||
m.intentIdx = 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)`),
|
||||
```
|
||||
- [ ] **Step 3: EndPlayerTurn 후반 정리.** 기존 EndPlayerTurn에서 `self:EnemyTurn()` 호출 이후의 `self:CheckCombatEnd()`/CombatOver 체크/`SetTimerOnce(... StartPlayerTurn ...)` 블록 **삭제**(FinishEnemyTurn이 담당). 가드 확장 확인(T2에서 FxBusy/TurnBusy).
|
||||
- [ ] **Step 4: RenderCombat의 ActFrame 정리.** RenderCombat 몬스터 루프의 else(사망/없음) 분기는 슬롯 통째 비활성이므로 ActFrame 잔존 위험 없음 — 확인만.
|
||||
- [ ] **Step 5: 검증·복원·커밋** `feat(combat-feel): 적 개별 차례 시퀀스 (ActFrame·EnemyActStep)`
|
||||
|
||||
## Task 5: 재생성·검증·산출물 커밋
|
||||
P2 T5와 동일 절차(생성→JSON·dup0·핵심 심볼(OnCardDragBegin/PlayAttackFx/EnemyActStep/DmgPop)·결정성·sim 14/14→산출물 커밋 `feat(combat-feel): 산출물 재생성`).
|
||||
|
||||
## Task 6 (컨트롤러 직접): 메이커 검증 + 푸시 + PR + 머지
|
||||
- mouse_input 드래그로: 공격 카드→몬스터2 드롭(타겟 변경+이펙트+팝업+HP 감소), Skill 카드 위로 드롭(방어), 빈 곳 드롭 취소, 턴 종료→순차 행동(ActFrame)+플레이어 팝업, 전체 처치 승리. 스크린샷 evidence.
|
||||
- 푸시→Gitea API PR(상세 메시지)→머지.
|
||||
|
||||
## Self-Review
|
||||
- 요구 4종(드래그/모션 후 데미지/개별 차례/팝업·사망) ↔ T1/T2/T4/T3 매핑 완료. ResolveCardDrop의 `TargetIndex` 직접 대입은 SetTarget(RenderCombat 포함)과 달리 렌더 없이 PlayCard로 직행 — PlayCard가 RenderCombat 수행하므로 OK.
|
||||
- 시그니처 일관: PlayAttackFx(targetIndex,image,damage)·ShowDmgPop(slot,amount)·EnemyActStep(fromIndex). guid 230/240+i/250+i/260 비충돌(기존 0~224).
|
||||
- 리스크는 T6 메이커 검증에서 흡수(드래그 좌표 보정 +360, 거리 임계 200, ui.y>-180 스윕 기준 — 실측 튜닝 가능).
|
||||
462
docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md
Normal file
462
docs/superpowers/plans/2026-06-11-combat-ui-overhaul.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# 전투 화면 UI/HUD 전면 정비 (P1) 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 전투 화면을 STS2 배치로 재구성해 UI 겹침 0·시각 위계 확립 (기능 불변, 레이아웃·표시 로직만).
|
||||
|
||||
**Architecture:** 모든 UI/컨트롤러는 `tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성(직접 편집 금지, 루트에서 `node tools/deck/gen-slaydeck.mjs`). UI 엔티티는 `upsertUi()`의 `entity()/transform()/sprite()/text()/button()` 헬퍼, 컨트롤러 Lua는 `method(Name, Code, Args?)` 템플릿 문자열(`${...}`=JS 보간). 좌표계: 부모 중심 원점, anchoredPosition.
|
||||
|
||||
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock, JSON 산출물.
|
||||
|
||||
---
|
||||
|
||||
## 배경 (구현자용)
|
||||
|
||||
- 화면 1920×1080 중심좌표(±960, ±540). `DeckHud`=하단 1280×330(y180 bottom-center), `CardHand`=카드 5장(y180), `CombatHud`=전체 1920×1080.
|
||||
- 확인된 겹침: `DeckHud/EndTurnButton`(0,135,170×58) ↔ `DeckHud/Energy`(0,90,220×42) 5px; `AllDeckButton`(470,135)이 버린덱(590,8,132×186)과 5px 간격.
|
||||
- 기존 가시성: `HideGameHud`(전투 HUD 일괄 off)가 이미 존재(사용자 PR) — ShowState는 이를 재사용해 확장.
|
||||
- guid 네임스페이스: `guid('cmb', N)` — 기존 사용 대역: 0~10(순차 cmbN), 41~144(슬롯 6종×4). **신규는 200+ 사용**(TopBar 200~209, PlayerPanel 210~219, TargetFrame 221~224).
|
||||
- 생성기 실행 검증은 매 Task: `node --check` + `node tools/deck/gen-slaydeck.mjs` 성공 + 해당 확인 스크립트. 산출물 커밋은 Task 6에서 일괄(중간 Task는 소스만 커밋하고 산출물은 `git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`로 복원).
|
||||
|
||||
## 파일 구조
|
||||
| 파일 | 책임 |
|
||||
|---|---|
|
||||
| `tools/deck/gen-slaydeck.mjs` | 전 변경(UI 좌표·신규 엔티티·컨트롤러 표시 로직) |
|
||||
| 산출물 3종 | Task 6에서 재생성·커밋 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 하단 HUD — 에너지 오브(좌)·턴 종료(우)
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: Energy 텍스트 엔티티를 EnergyOrb 패널로 교체**
|
||||
|
||||
`upsertUi()`에서 `path: '/ui/DefaultGroup/DeckHud/Energy'` 엔티티 push 블록(`add(entity({ ... '에너지 3/3' ... }))`) 전체를 삭제하고, 그 자리에:
|
||||
```js
|
||||
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: 130 }, 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 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: EndTurnButton 이동·확대**
|
||||
|
||||
`path: '/ui/DefaultGroup/DeckHud/EndTurnButton'` 엔티티의 transform 줄을
|
||||
```js
|
||||
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: 130 }, align: ALIGN_CENTER }),
|
||||
```
|
||||
로 교체하고, 같은 엔티티의 `text({ value: '턴 종료', fontSize: 25, ...` 를 `fontSize: 28,` 로.
|
||||
|
||||
- [ ] **Step 3: RenderPiles 에너지 경로/포맷 갱신**
|
||||
|
||||
`method('RenderPiles', ...)` 안의
|
||||
```
|
||||
self:SetText("/ui/DefaultGroup/DeckHud/Energy", "에너지 " .. string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
|
||||
```
|
||||
를
|
||||
```
|
||||
self:SetText("/ui/DefaultGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
|
||||
```
|
||||
로 교체.
|
||||
|
||||
- [ ] **Step 4: 검증** — `node --check tools/deck/gen-slaydeck.mjs` 후 실행:
|
||||
`node tools/deck/gen-slaydeck.mjs && node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('orb:',p.includes('/ui/DefaultGroup/DeckHud/EnergyOrb'),'| oldEnergy gone:',!p.includes('/ui/DefaultGroup/DeckHud/Energy'))"`
|
||||
Expected: `orb: true | oldEnergy gone: true`. 그 후 산출물 복원: `git checkout -- ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock`
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 에너지 오브(좌)·턴 종료 버튼(우) 재배치"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 상단 TopBar (막·골드·유물·모든덱보기 통합)
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: 기존 Floor/Gold/Relics 엔티티 제거**
|
||||
|
||||
`upsertUi()` CombatHud 빌드에서 `['Floor', { x: -820, y: 480 }, ...]`/`['Gold', { x: 820, y: 480 }, ...]` 루프 블록과 `path: '/ui/DefaultGroup/CombatHud/Relics'` push 블록을 삭제.
|
||||
|
||||
- [ ] **Step 2: DeckHud의 AllDeckButton 엔티티 제거**
|
||||
|
||||
`path: '/ui/DefaultGroup/DeckHud/AllDeckButton'` push 블록(레이블 텍스트 포함 엔티티 1개) 삭제.
|
||||
|
||||
- [ ] **Step 3: CombatHud에 TopBar 추가** (Result push 이전 위치에):
|
||||
```js
|
||||
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 }],
|
||||
['Relics', 60, 560, '유물: 없음', { r: 0.8, g: 0.7, b: 0.95, 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: suffix === 'Relics' ? 18 : 22, bold: true, color, 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: 150, y: 40 }, pos: { x: 510, y: 0 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '모든덱보기', fontSize: 18, bold: true, color: GOLD, alignment: 0 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 컨트롤러 경로 갱신** (정확 치환 3건)
|
||||
- `RenderRun`: `"/ui/DefaultGroup/CombatHud/Floor"` → `"/ui/DefaultGroup/CombatHud/TopBar/Floor"`, `"/ui/DefaultGroup/CombatHud/Gold"` → `"/ui/DefaultGroup/CombatHud/TopBar/Gold"`
|
||||
- `RenderRelics`(끝부분 SetText): `"/ui/DefaultGroup/CombatHud/Relics"` → `"/ui/DefaultGroup/CombatHud/TopBar/Relics"`
|
||||
- `BindButtons`: `"/ui/DefaultGroup/DeckHud/AllDeckButton"` → `"/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton"`
|
||||
|
||||
- [ ] **Step 5: 검증** — `node --check` 후 실행:
|
||||
`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('topbar:',['/TopBar','/TopBar/Floor','/TopBar/Gold','/TopBar/Relics','/TopBar/AllDeckButton'].every(s=>p.includes('/ui/DefaultGroup/CombatHud'+s)),'| old gone:',!p.includes('/ui/DefaultGroup/CombatHud/Floor')&&!p.includes('/ui/DefaultGroup/CombatHud/Relics')&&!p.includes('/ui/DefaultGroup/DeckHud/AllDeckButton'));const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('paths updated:',cb.includes('TopBar/Floor')&&cb.includes('TopBar/Relics')&&cb.includes('TopBar/AllDeckButton')&&!cb.includes('DeckHud/AllDeckButton'))"`
|
||||
Expected: 모두 true. 산출물 복원(Task 1과 동일 명령).
|
||||
|
||||
- [ ] **Step 6: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 상단 TopBar (막·골드·유물·모든덱보기 통합)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 플레이어 패널 + SetHpBar 폭 인자
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: SetHpBar에 width 인자 추가**
|
||||
|
||||
`method('SetHpBar', ...)` 전체를 다음으로 교체:
|
||||
```js
|
||||
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' },
|
||||
]),
|
||||
```
|
||||
그리고 `RenderCombat` 안의 기존 호출 `self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp)` 를 `self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})` 로 교체.
|
||||
|
||||
- [ ] **Step 2: PlayerBg/PlayerHp/PlayerBlock 엔티티 제거 → PlayerPanel 추가**
|
||||
|
||||
`upsertUi()`에서 `path: '/ui/DefaultGroup/CombatHud/PlayerBg'` push 블록과 `playerTexts` 배열+루프를 삭제하고, 그 자리에:
|
||||
```js
|
||||
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: -480 }, 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: 16 }, 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 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: RenderCombat 플레이어부 교체**
|
||||
|
||||
`RenderCombat` 끝의
|
||||
```
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock))
|
||||
```
|
||||
를
|
||||
```
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetHpBar("/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||
```
|
||||
로 교체.
|
||||
|
||||
- [ ] **Step 4: 검증** — `node --check` 후 실행+확인:
|
||||
`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const p=ui.ContentProto.Entities.map(e=>e.path);console.log('panel:',p.includes('/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill'),'| old gone:',!p.includes('/ui/DefaultGroup/CombatHud/PlayerHp'));const cb=JSON.parse(fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const m=cb.ContentProto.Json.Methods.find(x=>x.Name==='SetHpBar');console.log('width arg:',m.Arguments.length===4)"`
|
||||
Expected: 모두 true. 산출물 복원.
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 플레이어 패널(HP바·방어 뱃지) + SetHpBar 폭 인자"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 타겟 프레임 + 몬스터 슬롯 가독성 + 의도 색상
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: 슬롯 루프에 TargetFrame 추가 + 가독성 조정**
|
||||
|
||||
`upsertUi()` 몬스터 슬롯 루프(`for (let i = 1; i <= MAX_MONSTERS; i++)`)에서:
|
||||
1. 슬롯 컨테이너 push 직후, Name push 이전에 추가:
|
||||
```js
|
||||
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);
|
||||
```
|
||||
2. Name/Hp/HpBarBg/HpBarFill/Intent의 `displayOrder`를 각각 1/2/3/4/5로 +1.
|
||||
3. Name `fontSize: 20` → `22`, Hp `fontSize: 18` → `20`.
|
||||
4. 파일 상단 `const HP_BAR_W = 120;` → `const HP_BAR_W = 140;` (몬스터 바 폭 확대 — HpBarBg/Fill·RenderCombat 보간이 모두 이 상수 사용).
|
||||
|
||||
- [ ] **Step 2: RenderCombat 몬스터부 — [타겟] 제거·TargetFrame·의도 색상**
|
||||
|
||||
`RenderCombat`의 몬스터 루프 본문에서
|
||||
```
|
||||
if i == self.TargetIndex then t = "[타겟] " .. t end
|
||||
self:SetText(base .. "/Intent", t)
|
||||
```
|
||||
를
|
||||
```
|
||||
self:SetText(base .. "/Intent", t)
|
||||
self:SetEntityEnabled(base .. "/TargetFrame", i == self.TargetIndex)
|
||||
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)
|
||||
else
|
||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||
end
|
||||
end
|
||||
```
|
||||
로 교체.
|
||||
|
||||
- [ ] **Step 3: 검증** — `node --check` 후 실행+확인:
|
||||
`node tools/deck/gen-slaydeck.mjs && node -e "const fs=require('fs');const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const tf=ui.ContentProto.Entities.filter(e=>e.path.endsWith('/TargetFrame')).length;const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');console.log('frames:',tf,'| no [타겟]:',!cb.includes('[타겟]'),'| color:',cb.includes('FontColor = Color(1, 0.45'))"`
|
||||
Expected: `frames: 4 | no [타겟]: true | color: true`. 산출물 복원.
|
||||
|
||||
- [ ] **Step 4: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): 타겟 프레임·몬스터 슬롯 가독성·의도 색상"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: ShowState 가시성 통일 + Result 정리
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: ShowState 메서드 추가** (`HideGameHud` 메서드 바로 다음에):
|
||||
```js
|
||||
method('ShowState', `self:HideGameHud()
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", state == "menu")
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", state == "charselect")
|
||||
if state == "map" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MapHud", true)
|
||||
elseif state == "combat" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud", true)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", true)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CardHand", true)
|
||||
elseif state == "shop" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", true)
|
||||
elseif state == "rest" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/RestHud", true)
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'state' }]),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 호출부 치환** (각각 정확 치환)
|
||||
1. `ShowMainMenu`:
|
||||
```
|
||||
self:HideGameHud()
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", true)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", false)
|
||||
```
|
||||
→ `self:ShowState("menu")`
|
||||
2. `ShowCharacterSelect`:
|
||||
```
|
||||
self:HideGameHud()
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", true)
|
||||
```
|
||||
→ `self:ShowState("charselect")`
|
||||
3. `StartNewGame`의
|
||||
```
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MainMenu", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CharacterSelectHud", false)
|
||||
```
|
||||
→ 삭제(StartRun→ShowMap이 ShowState("map")으로 처리).
|
||||
4. `StartCombat` 첫 4줄
|
||||
```
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MapHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", true)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CardHand", true)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud", true)
|
||||
```
|
||||
→
|
||||
```
|
||||
self:ShowState("combat")
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/Result", false)
|
||||
```
|
||||
5. `ShowMap` 첫 3줄(`DeckHud/CardHand/CombatHud` off) → `self:ShowState("map")` (그 아래 `RenderMap`·MapHud enable 블록에서 MapHud enable 부분은 중복되지만 무해 — 기존 `local hud = ...MapHud... hud.Enable = true` 블록은 삭제).
|
||||
6. `ShowShop` 끝의 ShopHud enable 블록(`local hud = ...ShopHud ... end`) → `self:ShowState("shop")` (RenderShop 호출은 유지).
|
||||
7. `ShowRest` 끝의 RestHud enable 블록 → `self:ShowState("rest")` (텍스트·RenderCombat 호출 유지).
|
||||
8. `LeaveNode`는 기존 그대로(ShowMap 경유).
|
||||
|
||||
- [ ] **Step 3: 검증** — `node --check` 후 실행+확인:
|
||||
`node tools/deck/gen-slaydeck.mjs && node -e "const cb=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const names=cb.ContentProto.Json.Methods.map(m=>m.Name);const s=JSON.stringify(cb);console.log('ShowState:',names.includes('ShowState'),'| StartCombat resets Result:',/ShowState\(\\\"combat\\\"\)[\s\S]{0,200}Result/.test(s))"`
|
||||
Expected: 둘 다 true. 산출물 복원.
|
||||
|
||||
- [ ] **Step 4: Commit** — `git add tools/deck/gen-slaydeck.mjs && git commit -m "feat(combat-ui): ShowState 가시성 통일 + 전투 시작 시 Result 초기화"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 재생성 · 겹침 정적 검사 · 산출물 커밋
|
||||
|
||||
**Files:** 산출물 3종
|
||||
|
||||
- [ ] **Step 1: 재생성** — `node tools/deck/gen-slaydeck.mjs` (exit 0)
|
||||
|
||||
- [ ] **Step 2: JSON·중복 id·결정성**
|
||||
`node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const ids=ui.ContentProto.Entities.map(e=>e.id);console.log('dup:',ids.filter((x,i)=>ids.indexOf(x)!==i).length)"` → `dup: 0`
|
||||
그리고 `git add -A` 후 `node tools/deck/gen-slaydeck.mjs` 재실행 → `git diff --stat` 비어있음(결정적).
|
||||
|
||||
- [ ] **Step 3: 하단 HUD 겹침 정적 검사** (AABB 페어와이즈):
|
||||
`node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=ui.ContentProto.Entities;const get=p=>E.find(e=>e.path===p);const box=p=>{const e=get(p);const t=e.jsonString['@components'].find(c=>c['@type']==='MOD.Core.UITransformComponent');return {p,x:t.anchoredPosition.x,y:t.anchoredPosition.y,w:t.RectSize.x,h:t.RectSize.y};};const items=['/ui/DefaultGroup/DeckHud/EnergyOrb','/ui/DefaultGroup/DeckHud/EndTurnButton','/ui/DefaultGroup/DeckHud/DrawPile','/ui/DefaultGroup/DeckHud/DiscardPile'].map(box);const hit=(a,b)=>Math.abs(a.x-b.x)*2<(a.w+b.w)&&Math.abs(a.y-b.y)*2<(a.h+b.h);let bad=0;for(let i=0;i<items.length;i++)for(let j=i+1;j<items.length;j++)if(hit(items[i],items[j])){bad++;console.log('OVERLAP',items[i].p,items[j].p);}console.log(bad===0?'no overlap ✓':'OVERLAPS:'+bad)"`
|
||||
Expected: `no overlap ✓`
|
||||
|
||||
- [ ] **Step 4: sim 회귀** — `node --test tools/balance/sim-balance.test.mjs` → 14/14
|
||||
|
||||
- [ ] **Step 5: Commit** — `git add ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock && git commit -m "feat(combat-ui): UI 정비 산출물 재생성"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 메이커 플레이테스트 (수동/MCP — 컨트롤러 주도)
|
||||
|
||||
maker MCP: refresh → build log 0 → play. `execute_script`로 상태 전환하며 스크린샷:
|
||||
1. 초기(menu): 메인메뉴만, 전투 HUD 비침 없음
|
||||
2. `s:ShowCharacterSelect()` → 캐릭터선택만
|
||||
3. `s.SelectedClass="warrior"; s:StartNewGame()` → 맵(MapHud만)
|
||||
4. `s:PickNode("A")` → 전투: TopBar(막/골드/유물/모든덱보기)·에너지 오브(좌)·턴종료(우)·플레이어 패널(HP바)·타겟 프레임(1번 슬롯 골드 하이라이트)
|
||||
5. `s:SetTarget(2)` → 프레임 이동 확인, `s:PlayCard(<방어 슬롯>)` → 방어 뱃지 표시
|
||||
6. 전체 처치 → 보상 → `s:PickReward(1)` → 맵 복귀
|
||||
7. 상점(D)·휴식(C) 화면
|
||||
겹침·비침 발견 시 좌표 조정 → 재생성 → reload → 재확인 → 산출물 커밋(`fix(combat-ui): 플레이테스트 좌표 튜닝`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
- **스펙 커버리지**: §3.1→T1, §3.2→T2, §3.3→T3, §3.4→T4, §3.5→T5(HideGameHud 재사용으로 구현 — 스펙 의도 동일), §3.6→T7에서 확인(채팅 숨김 API는 비차단), 검증§6→T6·T7. 전 항목 매핑.
|
||||
- **플레이스홀더 없음**: 모든 단계 실제 코드/명령 포함.
|
||||
- **타입/이름 일관성**: `EnergyOrb/Value`·`TopBar/{Floor,Gold,Relics,AllDeckButton}`·`PlayerPanel/{Name,HpBarBg,HpBarFill,HpText,BlockBadge/Value}`·`TargetFrame`·`SetHpBar(path,hp,maxHp,width)`·`ShowState(state)` — Task 간 일치. guid 대역 200~224 기존(0~10·41~144)과 비충돌.
|
||||
- **주의**: T2 Step 1에서 Floor/Gold 루프 제거 시 그 루프가 쓰던 `cmbN` 증가가 사라져 후속 Relics/Result id가 변함 — 전부 재생성이라 무해. T5의 ShowMap 치환 시 기존 MapHud enable 블록 삭제 누락하면 중복(무해하나 정리).
|
||||
79
docs/superpowers/plans/2026-06-11-system-gaps.md
Normal file
79
docs/superpowers/plans/2026-06-11-system-gaps.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 시스템 갭 보완 (P5) 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. T3·T5는 컨트롤러 직접.
|
||||
|
||||
**Goal:** 경제 밸런스(첫 상점 구매 가능), 신규 카드 2종+복합 효과, 적 패턴 보강, 런 종료 후 메뉴 복귀.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 데이터+상수 (경제·적 패턴·신규 카드 골격)
|
||||
|
||||
**Files:** `data/enemies.json`, `data/cards.json`, `tools/deck/gen-slaydeck.mjs`(상수·elite 골드)
|
||||
|
||||
- [ ] enemies.json 의도 패턴 교체(스펙 §2.C 표 그대로; slime 3종·king_slime 유지).
|
||||
- [ ] cards.json에 추가(이미지는 T3에서 채움 — 일단 필드 생략):
|
||||
```json
|
||||
"WarLeap": { "name": "워 리프", "cost": 1, "kind": "Attack", "damage": 4, "block": 3, "desc": "피해 4, 방어도 3" },
|
||||
"Brandish": { "name": "브랜디시", "cost": 2, "kind": "Attack", "damage": 13, "desc": "피해 13" }
|
||||
```
|
||||
- [ ] gen-slaydeck: `GOLD_PER_WIN = 15` → `25`; CheckCombatEnd elite 분기(AddRelic 줄 옆)에 `self.Gold = self.Gold + 15` 추가.
|
||||
- [ ] 검증: JSON 파스, gen-slaydeck 실행 OK(산출물 복원), sim 통과(기존 fixture 무관).
|
||||
- [ ] Commit: `feat(system-gaps): 경제 상향(승리25·엘리트+15)·적 패턴 보강·신규 카드 2종 데이터`
|
||||
|
||||
## Task 2: 복합 카드 로직 + EndRun 복귀 + sim
|
||||
|
||||
**Files:** `tools/deck/gen-slaydeck.mjs`, `tools/balance/sim-balance.mjs`, `tools/balance/sim-balance.test.mjs`
|
||||
|
||||
- [ ] **sim 테스트 먼저** (test 파일에 추가):
|
||||
```js
|
||||
test('simulateCombat: Attack 카드의 block 필드도 적용(복합 카드)', () => {
|
||||
const data = {
|
||||
cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 4, block: 3 } },
|
||||
starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'],
|
||||
monsters: [{ name: '적', maxHp: 9, intents: [{ kind: 'Attack', value: 5 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true); // 3코스트 내 3장: 12딤>9 → 1턴 승리(블록 적용 여부와 무관하게 승리하지만)
|
||||
assert.equal(r.playerHpRemaining, 80); // 피해 받기 전 승리 — 블록 검증은 아래 시나리오로
|
||||
});
|
||||
test('simulateCombat: 복합 카드 블록이 적 공격을 흡수', () => {
|
||||
const data = {
|
||||
cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 1, block: 3 } },
|
||||
starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'],
|
||||
monsters: [{ name: '적', maxHp: 100, intents: [{ kind: 'Attack', value: 9 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
// 1턴: 3장 사용 → 블록 9 → 적 공격 9 전부 흡수 → 2턴 시작 HP 80 유지 확인 위해 2턴 후 비교 불가(루프) — 간접: MAX_TURNS 도달 draw, hp가 (80 - 0*몇턴)...
|
||||
// 단순 명제: 블록 미적용이면 매턴 -9 → 100/9≈11턴 내 사망. 블록 적용이면 매턴 9블록=무피해 → draw.
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
```
|
||||
- [ ] sim 구현: simulateCombat Attack 분기에 `if (c.block) pBlock += c.block;` 추가(스탯 bump의 block 합산도 `c.block || 0`로). 테스트 통과.
|
||||
- [ ] gen-slaydeck PlayCard Attack 분기에 추가(PlayAttackFx 호출 다음 줄):
|
||||
```
|
||||
if c.block ~= nil then
|
||||
self.PlayerBlock = self.PlayerBlock + c.block
|
||||
end
|
||||
```
|
||||
- [ ] gen-slaydeck EndRun: 신규 메서드 + CheckCombatEnd 두 지점 교체:
|
||||
```js
|
||||
method('EndRun', `self:ShowResult(text)
|
||||
self.RunActive = false
|
||||
_TimerService:SetTimerOnce(function() self:ShowMainMenu() end, 4)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
```
|
||||
교체: `self:ShowResult("런 클리어!")` + `self.RunActive = false` → `self:EndRun("런 클리어!")`; `self:ShowResult("패배...")` + `self.RunActive = false` → `self:EndRun("패배...")`.
|
||||
- [ ] 검증: sim 전체 통과(16개), gen 실행·심볼 확인 후 산출물 복원.
|
||||
- [ ] Commit: `feat(system-gaps): 복합 카드(피해+방어)·런 종료 후 메뉴 복귀(EndRun)`
|
||||
|
||||
## Task 3 (컨트롤러 직접): 신규 카드 이미지 수확
|
||||
- "워 리프"/"브랜디시" 검색→메이커 선별→cards.json image 채움→커밋.
|
||||
|
||||
## Task 4: 재생성·검증·산출물 커밋 (T3 이후)
|
||||
- 표준 절차 + `node tools/balance/sim-balance.mjs 2000` 결과 기록(참고).
|
||||
|
||||
## Task 5 (컨트롤러 직접): 메이커 검증+푸시+PR+머지
|
||||
- 보상/상점 신규 카드(이미지)·복합 카드 효과·엘리트 골드·패배→4s 메뉴 복귀. 스크린샷.
|
||||
|
||||
## Self-Review
|
||||
- §2.A→T1, §2.B→T1(데이터)+T2(로직)+T3(이미지), §2.C→T1, §2.D→T2. 시그니처 일관(EndRun(text)). 복합 카드 sim 테스트는 블록 적용을 draw/hp로 결정적으로 판별.
|
||||
23
docs/superpowers/plans/2026-06-12-ascension.md
Normal file
23
docs/superpowers/plans/2026-06-12-ascension.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# P11 — 승천 + UserDataStorage 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-ascension-design.md`
|
||||
|
||||
### Task 1: 생성기 — ExecSpace 보존 + 서버 RPC 3종
|
||||
- [ ] `for m of Methods: m.ExecSpace = 6` → `if (m.ExecSpace === 0) m.ExecSpace = 6;` (명시값 보존)
|
||||
- [ ] props: `AscensionLevel`(number 0)·`AscensionUnlocked`(number 0)
|
||||
- [ ] `ReqLoadAscension(userId)`[ExecSpace 1]·`RecvAscension(n, userId)`[2]·`SaveAscension(n, userId)`[1] — 설계 코드 그대로, OnBeginPlay(6)에서 LocalPlayer.UserId로 ReqLoad
|
||||
- [ ] 커밋
|
||||
|
||||
### Task 2: 생성기 — 모디파이어·해금·메뉴 UI
|
||||
- [ ] 헬퍼 5종(AscHpMult/AscAtkMult/AscEliteBonus/AscGoldMult/AscStartHpPenalty) + StartRun/BuildMonsters/CheckCombatEnd/RenderRun 적용
|
||||
- [ ] EndRun 클리어 분기: 해금+1·SaveAscension·"런 클리어! 승천 N 해금!"
|
||||
- [ ] MainMenu `AscMinus/AscLabel/AscPlus` + `AdjustAscension`/`RenderAscension` + BindMenuButtons 연결
|
||||
- [ ] 커밋
|
||||
|
||||
### Task 3: 재생성·메이커 검증·PR
|
||||
- [ ] 재생성·테스트 44건 유지·grep -c 카운트 → 커밋
|
||||
- [ ] 메이커: 메뉴 승천 라벨/[-][+]·승천2로 런 시작(HP·적 배율 로그 확인)·강제 클리어→해금+1·재플레이 로드 → 스크린샷
|
||||
- [ ] push → gitea-pr.mjs PR·머지 → main pull → 메모리 갱신
|
||||
|
||||
## Self-Review
|
||||
- RPC 파라미터 any 금지(허용 타입: string/number) 준수 ✓ / RecvAscension 마지막 인자 userId(특정 클라 응답) ✓ / 시뮬 비대상 명시 ✓
|
||||
377
docs/superpowers/plans/2026-06-12-buffs-power.md
Normal file
377
docs/superpowers/plans/2026-06-12-buffs-power.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# P6 — 버프/디버프·Power 카드·적 방어도 UI 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** StS 표준 약화·취약·힘 버프 시스템 + Power 카드 kind + 적 방어도 배지 UI를 데이터 주도로 구현.
|
||||
|
||||
**Architecture:** `data/cards.json`·`enemies.json` 스키마 확장 → `tools/deck/gen-slaydeck.mjs`의 Lua 생성부(상태 props·전투 메서드·UI 엔티티) 확장 → 산출물 재생성. 밸런스 시뮬(`tools/balance/sim-balance.mjs`)에 동일 규칙 재현.
|
||||
|
||||
**Tech Stack:** Node.js(생성기·시뮬), MSW Lua(생성물), node:test.
|
||||
|
||||
설계 문서: `docs/superpowers/specs/2026-06-12-buffs-power-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 신규 카드 이미지 RUID 선별 (메이커)
|
||||
|
||||
**Files:** 없음 (RUID 4개 확보가 산출물)
|
||||
|
||||
- [ ] **Step 1**: `asset_search_resources`(cat=sprite, source=maplestory)로 후보 수집 — 쿼리: "차지 블로우", "위협", "인레이지", "분노" (결과 빈약 시 "스킬", "버프" 등 보조 쿼리)
|
||||
- [ ] **Step 2**: 메이커 Play Test 상태에서 `maker_execute_script`(client)로 후보 RUID를 UIGroup에 격자 배치(아래 패턴) 후 `maker_screenshot`으로 확인, 카드당 1개 선별
|
||||
|
||||
```lua
|
||||
-- 후보 미리보기 패턴 (client 컨텍스트)
|
||||
local ruids = { "<ruid1>", "<ruid2>", "..." }
|
||||
local root = _EntityService:GetEntityByPath("/ui/DefaultGroup")
|
||||
for i = 1, #ruids do
|
||||
local e = _SpawnService:SpawnByModelId("model://uisprite", "RuidPreview" .. i, Vector3(0,0,0), root)
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ruids[i]
|
||||
e.UITransformComponent.anchoredPosition = Vector2(-600 + ((i-1) % 8) * 160, 200 - math.floor((i-1) / 8) * 160)
|
||||
e.UITransformComponent.RectSize = Vector2(140, 140)
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 3**: 미리보기 엔티티 제거(스크립트로 Destroy) 후 플레이 종료
|
||||
|
||||
### Task 2: 카드·적 데이터 확장
|
||||
|
||||
**Files:**
|
||||
- Modify: `data/cards.json`
|
||||
- Modify: `data/enemies.json`
|
||||
|
||||
- [ ] **Step 1**: `data/cards.json`의 `cards`에 4종 추가 (image는 Task 1 선별값)
|
||||
|
||||
```json
|
||||
"ChargedBlow": { "name": "차지 블로우", "cost": 2, "kind": "Attack", "damage": 8, "vuln": 2, "desc": "피해 8, 취약 2", "image": "<선별RUID>" },
|
||||
"Threaten": { "name": "위협", "cost": 0, "kind": "Skill", "weak": 2, "desc": "약화 2 부여", "image": "<선별RUID>" },
|
||||
"Enrage": { "name": "인레이지", "cost": 1, "kind": "Skill", "strength": 2, "desc": "힘 +2", "image": "<선별RUID>" },
|
||||
"Rage": { "name": "분노", "cost": 1, "kind": "Power", "powerEffect": "strengthPerTurn", "value": 1, "desc": "매 턴 시작 시 힘 +1", "image": "<선별RUID>" }
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: `data/enemies.json` 인텐트에 Debuff 추가 — mushmom에 `{ "kind": "Debuff", "effect": "weak", "value": 2 }` (intents 2번째로 삽입), slime_elite에 `{ "kind": "Debuff", "effect": "weak", "value": 1 }` (마지막), slime_boss·king_slime에 `{ "kind": "Debuff", "effect": "vuln", "value": 2 }` (Defend 다음), modified_snail에 `{ "kind": "Debuff", "effect": "weak", "value": 1 }` (마지막)
|
||||
- [ ] **Step 3**: 커밋 `feat(buffs-power): 신규 카드 4종·적 디버프 인텐트 데이터`
|
||||
|
||||
### Task 3: 생성기 — 직렬화·상태·전투 규칙
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/deck/gen-slaydeck.mjs` (luaCardsTable ~line 64, props ~1888, StartCombat ~2001, BuildMonsters ~2040, StartPlayerTurn ~2184, EndPlayerTurn ~2191, PlayCard ~2410, DealDamageToTarget ~2520, EnemyActStep ~2599, ApplyCardFace ~2347)
|
||||
|
||||
- [ ] **Step 1**: `luaCardsTable`에 신규 필드 직렬화 추가
|
||||
|
||||
```js
|
||||
if (c.strength != null) fields.push(`strength = ${c.strength}`);
|
||||
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}`);
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: props 추가 — `prop('number', 'PlayerStr', '0')`, `prop('number', 'PlayerWeak', '0')`, `prop('number', 'PlayerVuln', '0')`, `prop('any', 'PlayerPowers')`
|
||||
- [ ] **Step 3**: `StartCombat`에 리셋 추가 (`self.PlayerBlock = 0` 다음 줄)
|
||||
|
||||
```lua
|
||||
self.PlayerStr = 0
|
||||
self.PlayerWeak = 0
|
||||
self.PlayerVuln = 0
|
||||
self.PlayerPowers = {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4**: `BuildMonsters`의 몬스터 테이블 생성에 `str = 0, weak = 0, vuln = 0` 필드 추가 (기존 `block = 0` 자리 옆)
|
||||
- [ ] **Step 5**: 플레이어 공격 피해 헬퍼 `CalcPlayerAttack` 메서드 신설 + `PlayCard`의 Attack 분기를 수정 — `c.damage`에 힘·약화 적용한 값을 `PlayAttackFx`에 전달. 버프 필드 공통 처리(Attack/Skill 양쪽): `strength`/`weak`/`vuln` 적용
|
||||
|
||||
```lua
|
||||
-- method CalcPlayerAttack(base) → number
|
||||
local dmg = base + self.PlayerStr
|
||||
if self.PlayerWeak > 0 then
|
||||
dmg = math.floor(dmg * 0.75)
|
||||
end
|
||||
return dmg
|
||||
```
|
||||
|
||||
```lua
|
||||
-- PlayCard 내 교체 (Attack 분기)
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
self:PlayAttackFx(self.TargetIndex, c.image, self:CalcPlayerAttack(c.damage))
|
||||
end
|
||||
if c.block ~= nil then
|
||||
self.PlayerBlock = self.PlayerBlock + c.block
|
||||
end
|
||||
self:ApplyRelics("cardPlayed")
|
||||
elseif c.kind == "Skill" then
|
||||
if c.block ~= nil then
|
||||
self.PlayerBlock = self.PlayerBlock + c.block
|
||||
end
|
||||
elseif c.kind == "Power" then
|
||||
if c.powerEffect ~= nil then
|
||||
table.insert(self.PlayerPowers, cardId)
|
||||
end
|
||||
end
|
||||
-- 공통 버프/디버프 적용 (kind 분기 아래, table.remove 위)
|
||||
if c.strength ~= nil then
|
||||
self.PlayerStr = self.PlayerStr + c.strength
|
||||
end
|
||||
if c.weak ~= nil or c.vuln ~= nil then
|
||||
local tm = self.Monsters[self.TargetIndex]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
if c.weak ~= nil then tm.weak = tm.weak + c.weak end
|
||||
if c.vuln ~= nil then tm.vuln = tm.vuln + c.vuln end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 6**: Power 소멸 처리 — `PlayCard`의 `table.insert(self.DiscardPile, cardId)`를 조건부로
|
||||
|
||||
```lua
|
||||
table.remove(self.Hand, slot)
|
||||
if c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 7**: `DealDamageToTarget`에 취약 배수 (block 차감 **이전**)
|
||||
|
||||
```lua
|
||||
local dmg = amount
|
||||
if m.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 8**: `EnemyActStep` — Debuff 인텐트 처리 + 적 공격 피해 공식 + 행동 후 적 디버프 감소
|
||||
|
||||
```lua
|
||||
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
|
||||
local before = self.PlayerHp
|
||||
self:DealDamageToPlayer(atk)
|
||||
self:ShowPlayerDmgPop(before - self.PlayerHp)
|
||||
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
|
||||
end
|
||||
-- intentIdx 갱신 직후
|
||||
if m.weak > 0 then m.weak = m.weak - 1 end
|
||||
if m.vuln > 0 then m.vuln = m.vuln - 1 end
|
||||
```
|
||||
|
||||
- [ ] **Step 9**: `EndPlayerTurn`에 플레이어 디버프 감소 (`self:EnemyTurn()` 직전)
|
||||
|
||||
```lua
|
||||
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
|
||||
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
|
||||
```
|
||||
|
||||
- [ ] **Step 10**: `StartPlayerTurn`에 파워 발동 (`self:ApplyRelics("turnStart")` 다음)
|
||||
|
||||
```lua
|
||||
if self.PlayerPowers ~= nil then
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil and pc.powerEffect == "strengthPerTurn" then
|
||||
self.PlayerStr = self.PlayerStr + pc.value
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 11**: `ApplyCardFace` kind 색 분기에 `elseif c.kind == "Power" then` → `Color(0.46, 0.68, 0.52, 1)` 명시 (기존 else를 Power로)
|
||||
- [ ] **Step 12**: 커밋 `feat(buffs-power): 버프/디버프·Power 전투 규칙 (생성기)`
|
||||
|
||||
### Task 4: 생성기 — UI (적 방어도 배지·버프 라인·인텐트)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/deck/gen-slaydeck.mjs` (몬스터 슬롯 UI 생성부 ~line 916-1015, PlayerPanel ~1015-1100, RenderCombat ~2688)
|
||||
|
||||
- [ ] **Step 1**: 몬스터 슬롯 루프에 BlockBadge(guid 270+i)·Value(guid 280+i)·Buffs(guid 290+i) 엔티티 추가 (DmgPop 추가 코드 다음)
|
||||
|
||||
```js
|
||||
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 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: PlayerPanel에 Buffs 텍스트(guid 217) 추가 (BlockBadge/Value 다음)
|
||||
|
||||
```js
|
||||
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 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3**: 버프 문자열 헬퍼 메서드 `BuffsLabel` 신설 (Lua, str/weak/vuln → "힘+2 약화1 취약2")
|
||||
|
||||
```lua
|
||||
-- method BuffsLabel(str, weak, vuln) → string
|
||||
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
|
||||
return table.concat(parts, " ")
|
||||
```
|
||||
|
||||
- [ ] **Step 4**: `RenderCombat` 확장 — 몬스터 루프 내(SetHpBar 다음)
|
||||
|
||||
```lua
|
||||
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))
|
||||
```
|
||||
|
||||
인텐트 분기 교체 (Attack은 최종 예상치·Debuff 추가):
|
||||
|
||||
```lua
|
||||
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
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
인텐트 색: Attack 빨강(기존), Defend 파랑(기존 else), Debuff 보라 `Color(0.8, 0.5, 1, 1)` 분기 추가.
|
||||
|
||||
플레이어 표시 (기존 BlockBadge 갱신 다음):
|
||||
|
||||
```lua
|
||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln)
|
||||
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/DefaultGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||
```
|
||||
|
||||
- [ ] **Step 5**: 커밋 `feat(buffs-power): 적 방어도 배지·버프 라인·디버프 인텐트 UI (생성기)`
|
||||
|
||||
### Task 5: 밸런스 시뮬 동기화 + 테스트
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/balance/sim-balance.mjs`
|
||||
- Test: `tools/balance/sim-balance.test.mjs`
|
||||
|
||||
- [ ] **Step 1**: 실패 테스트 먼저 추가 — 약화·취약·힘 계산 + Debuff 인텐트 + Power 동작
|
||||
|
||||
```js
|
||||
test('simulateCombat: 취약이 플레이어 공격을 1.5배로', () => {
|
||||
const data = {
|
||||
cards: { Vuln: { name: '취약기', cost: 1, kind: 'Skill', vuln: 9 }, Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 10 } },
|
||||
starterDeck: ['Vuln', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 100, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
// 1턴: 공격 우선 휴리스틱 → Hit×3 (취약 미부여, 30) — 그래도 30+α로 수치 검증은 별도 단위 함수로
|
||||
assert.equal(typeof r.win, 'boolean');
|
||||
});
|
||||
|
||||
test('calcAttack: 힘·약화·취약 공식', () => {
|
||||
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
|
||||
assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75)
|
||||
assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5)
|
||||
assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5)=13
|
||||
});
|
||||
|
||||
test('simulateCombat: 적 Debuff 인텐트로 플레이어 약화 → 받는 피해 감소 검증', () => {
|
||||
const data = {
|
||||
cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } },
|
||||
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.playerHpRemaining, 80); // Debuff만 하는 적 → 피해 0
|
||||
});
|
||||
|
||||
test('simulateCombat: Power(매턴 힘) 누적', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 },
|
||||
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
|
||||
},
|
||||
starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}턴`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: `node --test tools/balance/sim-balance.test.mjs` → 신규 테스트 FAIL 확인
|
||||
- [ ] **Step 3**: `sim-balance.mjs` 구현 — `calcAttack(base, str, weak, vulnOnTarget)` export 신설, `simulateCombat`에 pStr/pWeak/pVuln/powers·몬스터 str/weak/vuln 상태 추가, 규칙 재현(부여→감소 타이밍 Lua와 동일), `chooseAction` 확장(Attack 우선 유지, 잔여 에너지로 Power→버프 Skill 사용), Debuff 인텐트 처리. `formatReport`의 kind 루프에 'Power' 포함(효율 계산은 plays만 표시).
|
||||
- [ ] **Step 4**: `node --test tools/balance/sim-balance.test.mjs` → 전체 PASS
|
||||
- [ ] **Step 5**: 커밋 `feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화`
|
||||
|
||||
### Task 6: 산출물 재생성·시뮬 확인·푸시·PR·머지
|
||||
|
||||
**Files:**
|
||||
- Regen: `RootDesk/MyDesk/SlayDeckController.codeblock`, `ui/DefaultGroup.ui`, `Global/common.gamelogic`
|
||||
|
||||
- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` 실행 성공 확인
|
||||
- [ ] **Step 2**: `node tools/balance/sim-balance.mjs` — 승률 0%/100% 극단 아님 확인 (참고용 리포트 기록)
|
||||
- [ ] **Step 3**: 커밋 `feat(buffs-power): 산출물 재생성 (버프/디버프·Power·적 방어도 UI)`
|
||||
- [ ] **Step 4**: `git push -u origin feature/p6-buffs-power`
|
||||
- [ ] **Step 5**: Gitea API로 PR 생성 → 머지 (기존 자동화 패턴: `curl -s -X POST .../repos/gahusb/maplecontest/pulls`, 토큰은 `.mcp.json` 참조 금지 — `git credential` 또는 기존 사용 토큰 경로)
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
- 설계 요구 전 항목(버프 3종·Power·적 방어도 배지·예시 카드 4종·적 디버프 인텐트·시뮬 동기화) 태스크 매핑 확인
|
||||
- 타입/이름 일관성: `CalcPlayerAttack`·`BuffsLabel`·`PlayerStr/Weak/Vuln`·`PlayerPowers`·`m.str/weak/vuln` 통일 확인
|
||||
- 플레이스홀더: 카드 image RUID만 Task 1 산출물에 의존 (의도된 순서)
|
||||
79
docs/superpowers/plans/2026-06-12-card-frames.md
Normal file
79
docs/superpowers/plans/2026-06-12-card-frames.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# P13 — 커스텀 카드 프레임 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-card-frames-design.md`
|
||||
|
||||
**Goal:** 사용자 제작 프레임 이미지(직업×등급)를 카드 UI 전체에 적용하고 등급을 보상 확률에 반영.
|
||||
|
||||
**Architecture:** 단일 소스(`data/*.json` + `gen-slaydeck.mjs`) → 산출물 재생성. 카드 배경 스프라이트를 프레임 ImageRUID로 교체(A안), `ApplyCardFace` 중앙 함수에서 class×rarity 조회.
|
||||
|
||||
**Tech Stack:** Node.js 생성기, MSW Lua, node --test.
|
||||
|
||||
### Task 1: 리소스 커밋
|
||||
- [ ] `.sprite` 9종 커밋: `git add RootDesk/MyDesk/*.sprite && git commit -m "feat(card-frames): 카드 프레임 스프라이트 9종 로컬 임포트 (warior·mage·bandit × normal·unique·legend)"`
|
||||
|
||||
### Task 2: 데이터 — rarity + cardframes.json
|
||||
- [ ] `data/cardframes.json` 신설 (설계서 JSON 그대로)
|
||||
- [ ] `data/cards.json` 32종에 `"rarity"` 추가 (설계서 표 그대로 — node 스크립트로 일괄 주입 권장)
|
||||
- [ ] 커밋: `feat(card-frames): 카드 등급 배정·프레임 RUID 매핑 데이터`
|
||||
|
||||
### Task 3: 생성기 — 프레임 렌더링
|
||||
- [ ] `CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json'))` 로드, 카드별 검증(throw): rarity ∈ {normal,unique,legend}, class ∈ classToFrame
|
||||
- [ ] `luaCardsTable`: `fields.push(\`rarity = ${luaStr(c.rarity)}\`)`
|
||||
- [ ] OnBeginPlay 주입(luaCardsTable 옆): `luaFramesTable()` — `self.CardFrames = {...}` + `self.ClassToFrame = {...}` / `prop('any','CardFrames')`·`prop('any','ClassToFrame')` 선언
|
||||
- [ ] `ApplyCardFace` Lua: kind 틴트 분기 → 프레임 적용
|
||||
|
||||
```lua
|
||||
local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"]
|
||||
local ruid = frames ~= nil and frames[c.rarity or "normal"] or nil
|
||||
if ruid ~= nil then
|
||||
e.SpriteGUIRendererComponent.ImageRUID = ruid
|
||||
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] `cardFaceLayout(W)` 헬퍼 신설(s=W/180): Cost pos(-68s,103s)/size 44s/font 26s · Name pos(4s,97s)/size(150s,26s)/font 18s · Art pos(0,16s)/size 110s · Desc pos(0,-85s)/size(152s,64s)/font 16s
|
||||
- [ ] 카드 생성 5곳(upsertUi 손패 ~523 · 조회 ~787 · 전체덱 ~928 · 보상 ~1443 · 상점 ~1660)에 헬퍼 적용, NamePlate/CostPlate 생성 제거, 카드 스프라이트 type 0·흰색·프리뷰 프레임 RUID
|
||||
- [ ] CardHand 잔존 단색판 제거: upsertUi 초입 필터에 `/ui/DefaultGroup/CardHand/Card\d+/(NamePlate|CostPlate)` 경로 제거 추가
|
||||
- [ ] 커밋: `feat(card-frames): 생성기 — 프레임 렌더링·레이아웃 통합`
|
||||
|
||||
### Task 4: 보상 가중 추첨 (TDD)
|
||||
- [ ] `tools/balance/sim-balance.test.mjs`에 실패 테스트: `rarityForRoll(70)==='normal'`, `(71)==='unique'`, `(95)==='unique'`, `(96)==='legend'` → 실행해 FAIL 확인
|
||||
- [ ] `tools/balance/sim-balance.mjs`: `export function rarityForRoll(roll){ if (roll > 95) return 'legend'; if (roll > 70) return 'unique'; return 'normal'; }` → PASS 확인
|
||||
- [ ] `OfferReward` Lua 교체:
|
||||
|
||||
```lua
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] 커밋: `feat(card-frames): 보상 등급 가중 추첨 70/25/5 (+JS 미러 테스트)`
|
||||
|
||||
### Task 5: 재생성·검증·산출물 커밋
|
||||
- [ ] `node tools/deck/gen-slaydeck.mjs` → `grep -c "CardFrames" RootDesk/MyDesk/SlayDeckController.codeblock` ≥1, `grep -c "4bb57ef88ef449fdaf958f6cf37fe44b" ui/DefaultGroup.ui` ≥1
|
||||
- [ ] `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs` 전건 통과
|
||||
- [ ] 커밋: `feat(card-frames): 산출물 재생성`
|
||||
|
||||
### Task 6: 메이커 검증·튜닝
|
||||
- [ ] maker_refresh_workspace → 빌드 콘솔 0에러 → 플레이: 손패 프레임·등급 구분, `_ResourceService` 로드 확인, 보상·덱 조회 스크린샷
|
||||
- [ ] 텍스트/아트 위치 어긋나면 `cardFaceLayout` 수치 조정 → 재생성 → 재확인 (수정 시 커밋)
|
||||
|
||||
### Task 7: PR·머지·메모리
|
||||
- [ ] push → `node tools/git/gitea-pr.mjs create <spec.json>` → merge → main pull → 메모리 갱신 (slaymaple-build-status에 P13 추가)
|
||||
|
||||
## Self-Review
|
||||
- 설계 전 항목에 대응 Task 존재 ✓ / 코드 블록 placeholder 없음 ✓ / CardFrames·ClassToFrame·rarityForRoll 명칭 일관 ✓ / maker_save 덮어쓰기 주의(설계서 '주의' 절) Task 6에서 refresh만 사용 ✓
|
||||
18
docs/superpowers/plans/2026-06-12-combat-motion.md
Normal file
18
docs/superpowers/plans/2026-06-12-combat-motion.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# P12 — 전투 모션 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `2026-06-12-combat-motion-design.md`
|
||||
|
||||
### Task 1: 아바타 액션 프로브 (메이커)
|
||||
- [ ] play 상태에서 `AvatarBodyActionSelectorComponent` 존재·`MapleAvatarBodyActionState.swingO1`(및 stabO1) 대입 pcall 성공 여부 로그 → 성공 멤버 베이크 / 전부 실패 시 런지 폴백만 사용
|
||||
|
||||
### Task 2: 생성기 — 모션 메서드 4종 + 훅
|
||||
- [ ] `PlayerAttackMotion`(아바타 ActionState pcall+복귀 / 폴백 런지) · `PlayerHitMotion`(넉백 틱) · `MonsterLunge(idx)` · `MonsterHitMotion(slot)`(hitClip 캐시 사용·stand 복귀·흔들림 폴백, `m.motionBusy` 가드)
|
||||
- [ ] BuildMonsters: `hitClip`/`standClip` pcall 캐시 + `motionBusy=false`
|
||||
- [ ] 훅 연결: PlayCard(공격)·EnemyActStep(런지·넉백·독틱)·DealDamageToTarget·PlayAoeFx·체인메일 반사
|
||||
- [ ] 커밋
|
||||
|
||||
### Task 3: 재생성·메이커 검증·PR
|
||||
- [ ] 재생성·테스트 40건 유지 → refresh·빌드 0에러 → 플레이테스트(공격 스윙/몬스터 hit 클립/런지·넉백/독 틱) → 커밋·push → gitea-pr.mjs PR·머지 → 메모리 갱신
|
||||
|
||||
## Self-Review
|
||||
- 모든 복귀 타이머 isvalid/alive 가드 ✓ / 시뮬 비대상 명시 ✓ / 산출물 검증 카운트만 ✓
|
||||
89
docs/superpowers/plans/2026-06-12-job-advancement.md
Normal file
89
docs/superpowers/plans/2026-06-12-job-advancement.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# P9 — 전직 시스템 코어 + 전사 2차 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 카드 클래스 모델·전직 선택 흐름·전사 2차 3직업(전용 카드 9종 + 신규 메커니즘 4종).
|
||||
|
||||
**Architecture:** cards.json `class`/`hits`/`pierce`/`selfVuln` 스키마 확장 → gen-slaydeck.mjs (직렬화·CardPool 필터·전투 메커니즘·JobChoiceHud/JobSelectHud·ContinueAfterBoss 추출) → sim-balance 동기화. RULES.md 하네스 준수 (산출물 검증은 grep -c).
|
||||
|
||||
설계: `docs/superpowers/specs/2026-06-12-job-advancement-design.md` (승인 완료)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 카드 이미지 RUID 9종 선별 (메이커)
|
||||
|
||||
- [ ] **Step 1**: asset_search(source=maplestory, sprite) 쿼리 — "콤보", "버서크", "라이징", "썬더", "블리자드", "파워 가드", "창", "철벽", "하이퍼" (빈약 시 보조 쿼리)
|
||||
- [ ] **Step 2**: SkillFx 복제 격자 미리보기 → 9종 확정 → 정리·종료 (기존 절차)
|
||||
|
||||
### Task 2: 데이터 — cards.json 확장
|
||||
|
||||
- [ ] **Step 1**: 기존 카드 9종 전부에 `"class": "warrior"` 추가
|
||||
- [ ] **Step 2**: 신규 9종 추가 (설계 표 그대로, image=Task 1 선별값):
|
||||
|
||||
```json
|
||||
"ComboAttack": { "name": "콤보 어택", "cost": 1, "kind": "Attack", "class": "fighter", "damage": 5, "hits": 2, "desc": "피해 5 × 2회", "image": "<RUID>" },
|
||||
"Berserk": { "name": "버서크", "cost": 2, "kind": "Power", "class": "fighter", "powerEffect": "energyPerTurn", "value": 1, "selfVuln": 1, "desc": "매턴 에너지 +1, 취약 1 자가", "image": "<RUID>" },
|
||||
"RisingAttack": { "name": "라이징 어택", "cost": 2, "kind": "Attack", "class": "fighter", "damage": 12, "desc": "피해 12", "image": "<RUID>" },
|
||||
"ThunderCharge": { "name": "썬더 차지", "cost": 1, "kind": "Attack", "class": "page", "damage": 7, "weak": 1, "desc": "피해 7, 약화 1", "image": "<RUID>" },
|
||||
"BlizzardCharge": { "name": "블리자드 차지", "cost": 1, "kind": "Attack", "class": "page", "damage": 7, "vuln": 1, "desc": "피해 7, 취약 1", "image": "<RUID>" },
|
||||
"PowerGuard": { "name": "파워 가드", "cost": 1, "kind": "Skill", "class": "page", "block": 10, "desc": "방어도 10", "image": "<RUID>" },
|
||||
"Pierce": { "name": "피어스", "cost": 1, "kind": "Attack", "class": "spearman", "damage": 9, "pierce": true, "desc": "피해 9, 방어 무시", "image": "<RUID>" },
|
||||
"IronWall": { "name": "아이언 월", "cost": 2, "kind": "Skill", "class": "spearman", "block": 12, "desc": "방어도 12", "image": "<RUID>" },
|
||||
"HyperBody": { "name": "하이퍼 바디", "cost": 1, "kind": "Power", "class": "spearman", "powerEffect": "blockPerTurn", "value": 3, "desc": "매턴 방어도 +3", "image": "<RUID>" }
|
||||
```
|
||||
|
||||
- [ ] **Step 3**: 커밋 `feat(job): 전사 2차 카드 9종·클래스 필드 데이터`
|
||||
|
||||
### Task 3: 생성기 — 직렬화·전투 메커니즘
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: luaCardsTable에 `class`(필수 — 누락 시 throw)·`hits`·`pierce`·`selfVuln` 직렬화
|
||||
- [ ] **Step 2**: prop `PlayerJob`(string "") 추가, StartRun에 `self.PlayerJob = ""` 리셋
|
||||
- [ ] **Step 3**: PlayCard Attack 분기 — 다단히트·pierce·selfVuln:
|
||||
|
||||
```lua
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
local total = 0
|
||||
local n = c.hits or 1
|
||||
for h = 1, n do
|
||||
total = total + self:CalcPlayerAttack(c.damage)
|
||||
end
|
||||
self:PlayAttackFx(self.TargetIndex, c.image, total, c.pierce == true)
|
||||
end
|
||||
...
|
||||
end
|
||||
-- 공통부 (버프 적용 옆): if c.selfVuln ~= nil then self.PlayerVuln = self.PlayerVuln + c.selfVuln end
|
||||
```
|
||||
|
||||
- [ ] **Step 4**: `PlayAttackFx(targetIndex, image, damage, pierce)` / `DealDamageToTarget(amount, pierce)` 시그니처 확장 — pierce면 block 차감 생략. 기존 호출부(물약 화염병 포함) `false` 전달
|
||||
- [ ] **Step 5**: StartPlayerTurn 파워 루프 확장 — `energyPerTurn`→Energy, `blockPerTurn`→PlayerBlock (ClayBlockNext 처리 뒤)
|
||||
- [ ] **Step 6**: 커밋 `feat(job): 다단히트·방어무시·자가취약·파워 2종 (생성기)`
|
||||
|
||||
### Task 4: 생성기 — 풀 필터·전직 흐름·UI
|
||||
|
||||
- [ ] **Step 1**: `CardPool()` 헬퍼 (정렬된 id 배열 반환 — class 필터), OfferReward·ShowShop이 사용
|
||||
- [ ] **Step 2**: CheckCombatEnd 보스 분기 → `ContinueAfterBoss()` 추출. 분기: `PlayerJob == "" and Floor < RunLength` → `ShowJobChoice()`, else 유물+`ContinueAfterBoss()`
|
||||
- [ ] **Step 3**: `ShowJobChoice`/`PickJobReward(kind)` (relic→유물+Continue / job→ShowJobSelect), `ShowJobSelect`/`SetJob(jobId)` (PlayerJob·대표 카드 지급·토스트·Continue), `JobLabel()` 헬퍼 (전사/파이터/페이지/스피어맨)
|
||||
- [ ] **Step 4**: UI — guid 'job'=0xe4: `JobChoiceHud`(타이틀+버튼 2)·`JobSelectHud`(3패널: 직업명·설명·대표 카드명). HideGameHud·BindButtons 등록
|
||||
- [ ] **Step 5**: PlayerPanel/Name 갱신 — StartCombat·SetJob에서 `JobLabel()`
|
||||
- [ ] **Step 6**: 커밋 `feat(job): 클래스 풀 필터·전직 선택 흐름·전직 HUD (생성기)`
|
||||
|
||||
### Task 5: 시뮬 동기화 (TDD)
|
||||
|
||||
- [ ] **Step 1**: 실패 테스트 — hits 합산(힘 타격마다)·pierce(블록 무시)·selfVuln·energyPerTurn·blockPerTurn 5건
|
||||
- [ ] **Step 2**: sim-balance.mjs 재현 → 전체 PASS (기존 21+5, rogue-map 9)
|
||||
- [ ] **Step 3**: 커밋 `feat(job): 시뮬 신규 메커니즘 동기화`
|
||||
|
||||
### Task 6: 재생성·메이커 검증·PR
|
||||
|
||||
- [ ] **Step 1**: 재생성 + `grep -c "PlayerJob\|JobChoiceHud" 산출물` 카운트 확인 + 전체 테스트
|
||||
- [ ] **Step 2**: 메이커 refresh→빌드 0에러→플레이테스트: 보스 클리어→선택 화면→전직(파이터)→전용 카드 풀 편입·직업명 표기·콤보/피어스 동작 스크린샷
|
||||
- [ ] **Step 3**: 커밋·푸시 → `gitea-pr.mjs`로 PR(UTF-8 spec)·머지 → main pull
|
||||
|
||||
## Self-Review
|
||||
|
||||
- 설계 전 항목 매핑 ✓ (클래스 모델 T2/T4, 전직 흐름 T4, 카드 9종 T1/T2, 메커니즘 T3/T5, 표기 T4)
|
||||
- 시그니처 일관성: PlayAttackFx/DealDamageToTarget pierce 전 호출부 갱신 명시 ✓
|
||||
- 하네스: 산출물 검증 카운트만 ✓
|
||||
38
docs/superpowers/plans/2026-06-12-magician.md
Normal file
38
docs/superpowers/plans/2026-06-12-magician.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# P10 — 법사 클래스 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox 구문.
|
||||
|
||||
**Goal:** 법사 클래스(1차 5종 + 2차 3계열 9종)·신규 메커니즘 4종(독/AoE/회복/드로)·캐릭터 선택 오픈·전직 화면 동적화.
|
||||
|
||||
설계: `docs/superpowers/specs/2026-06-12-magician-design.md`
|
||||
|
||||
### Task 1: 이미지 RUID 10종 선별 (4종은 기존 후보 재사용)
|
||||
- [ ] 재사용 확정: FireArrow=78b9be4e(큰 불꽃)·ThunderBolt=c6685d33(낙뢰)·ColdBeam=e8f7c148(얼음)·ChillingStep=b2a7274d(빙수림)
|
||||
- [ ] 검색(마법/독/회복/빛/포털/정령) → 메이커 격자 미리보기 → EnergyBolt·MagicGuard·MagicClaw·Teleport·Slow·PoisonBreath·ElementAmp·Heal·Bless·HolyArrow 확정
|
||||
|
||||
### Task 2: 데이터 — cards.json
|
||||
- [ ] `starterDeck` → `starterDecks{warrior, magician}` (마법사: EnergyBolt×5·MagicGuard×4·MagicClaw×1), 생성기 검증 갱신
|
||||
- [ ] 신규 14종 추가 (설계 표 그대로: class=magician/firepoison/icelightning/cleric, draw/heal/poison/aoe 필드) → 커밋
|
||||
|
||||
### Task 3: 생성기 — 메커니즘 (Lua)
|
||||
- [ ] 직렬화: draw·heal·poison·aoe + starterDecks 주입(StartRun 클래스 분기: MaxHp 80/70·RunDeck)
|
||||
- [ ] PlayCard: `aoe` → `PlayAoeFx(image, total)` (단일 대상 로직과 동일 합산, 0.35s 후 전 생존 적에 각자 취약/방어 적용·슬롯별 팝업·KillMonster·CheckCombatEnd) / 공통부: heal(상한 클램프)·draw(`DrawCards`)·poison(타겟 `tm.poison += N`)
|
||||
- [ ] BuildMonsters `poison = 0` 초기화, EnemyActStep 행동 타이머 시작부에 독 틱(피해 팝업·사망 시 행동 생략 후 체인 계속), BuffsLabel 4번째 인자 poison(`독N`) — RenderCombat 호출부 갱신(플레이어는 0)
|
||||
- [ ] 커밋
|
||||
|
||||
### Task 4: 생성기 — 클래스 선택·전직 동적화
|
||||
- [ ] classCards Mage 활성화(enabled·tint·desc '마법 원거리 딜러'), BindMenuButtons MageButton→`SelectClass("magician")`, RenderCharacterSelect 2클래스 하이라이트·상태 텍스트, StartNewGame 가드 warrior|magician
|
||||
- [ ] JobSelectHud 패널 경로 `Job_slot{1..3}` 범용화, `ShowJobSelect`(JOBS 상수→JobOpts prop, 슬롯 텍스트 채움) 신설 — PickJobReward("job")가 호출, 바인딩은 슬롯 인덱스→`SetJob(self.JobOpts[i].id)`
|
||||
- [ ] SetJob 대표 카드 매핑(JOBS 테이블에 starter 포함: firepoison→FireArrow·icelightning→ThunderBolt·cleric→Heal), JobLabel 확장(마법사·위자드(불·독)·위자드(썬·콜)·클레릭)
|
||||
- [ ] 커밋
|
||||
|
||||
### Task 5: 시뮬 동기화 (TDD)
|
||||
- [ ] 실패 테스트: poison 틱·사망 / aoe 전체 피해 / heal 클램프 / draw / 법사 시작 덱은 시뮬 무관(주석) → 구현 → 전체 PASS → 커밋
|
||||
|
||||
### Task 6: 재생성·메이커 검증·PR
|
||||
- [ ] 재생성 + grep -c 카운트 + 전체 테스트 → 커밋
|
||||
- [ ] 메이커: 법사 선택 시작(HP70·시작 덱), 전직 화면 마법사 3직업 표기, 클레릭 전직→힐 동작, 독/AoE 실측 → 스크린샷
|
||||
- [ ] push → gitea-pr.mjs PR·머지 → main pull
|
||||
|
||||
## Self-Review
|
||||
- 설계 전 항목 매핑 ✓ / JobSelect 동적화로 P9 고정 경로 제거 명시 ✓ / BuffsLabel 시그니처 변경 시 호출부(몬스터·플레이어) 동시 갱신 명시 ✓
|
||||
285
docs/superpowers/plans/2026-06-12-potions-relics.md
Normal file
285
docs/superpowers/plans/2026-06-12-potions-relics.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# P7 — 물약 시스템·유물 강화 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** StS 풀세트 물약 6종 + 유물 19종(메이플 장비 외형·StS 효과) + 아이콘 행·마우스오버 툴팁 UI.
|
||||
|
||||
**Architecture:** `data/potions.json` 신설·`relics.json` 확장(icon RUID) → `gen-slaydeck.mjs` 생성부 확장(상태·효과 훅·물약 로직·TopBar 아이콘 UI·툴팁) → 산출물 재생성. 시뮬 변경 없음(물약·유물은 시뮬 범위 밖 — 기존 정책 동일).
|
||||
|
||||
**Tech Stack:** Node.js 생성기, MSW Lua, UITouchReceiveComponent(UITouchEnter/Exit/Down).
|
||||
|
||||
설계 문서: `docs/superpowers/specs/2026-06-12-potions-relics-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 아이콘 RUID 선별 (메이커, 유물 19 + 물약 6)
|
||||
|
||||
- [ ] **Step 1**: `asset_search_resources`(cat=sprite, source=maplestory) 검색어별 후보 5개: 투구/방패/벨트/목걸이/갑옷/반지/부츠/도끼/가방/부적/깃털/망치/심장/송곳니/우상/포션/엘릭서/병
|
||||
- [ ] **Step 2**: P6과 동일한 SkillFx 복제 격자 미리보기로 스크린샷 → 유물 19·물약 6 아이콘 확정 (모자란 항목은 보조 검색어로 보충)
|
||||
- [ ] **Step 3**: 미리보기 정리, 플레이 종료
|
||||
|
||||
### Task 2: 데이터 — potions.json 신설·relics.json 확장
|
||||
|
||||
**Files:** Create `data/potions.json`, Modify `data/relics.json`
|
||||
|
||||
- [ ] **Step 1**: `data/potions.json` 작성 (icon은 Task 1 선별값)
|
||||
|
||||
```json
|
||||
{
|
||||
"potions": {
|
||||
"redPotion": { "name": "빨간 포션", "desc": "HP 20 회복", "effect": "heal", "value": 20, "icon": "<RUID>" },
|
||||
"firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "<RUID>" },
|
||||
"warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "<RUID>" },
|
||||
"guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "<RUID>" },
|
||||
"manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "<RUID>" },
|
||||
"cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "<RUID>" }
|
||||
},
|
||||
"dropChance": 0.4,
|
||||
"baseSlots": 3,
|
||||
"beltSlots": 5,
|
||||
"shopPrice": 20
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: `data/relics.json` — 기존 4종에 icon 추가 + 신규 15종 (설계 표의 hook/effect/value, 전부 icon 포함). relicPool = 기존 3종 + 신규 15종 (ironHeart는 시작 유물).
|
||||
|
||||
```json
|
||||
"potionBelt": { "name": "장인의 벨트", "desc": "물약 슬롯이 5칸으로 늘어난다", "hook": "passive", "effect": "potionSlots", "value": 5 },
|
||||
"burningBlood": { "name": "자쿰의 투구", "desc": "전투 승리 시 HP 6 회복", "hook": "combatEnd", "effect": "healOnWin", "value": 6 },
|
||||
"vajra": { "name": "미스릴 액스", "desc": "전투 시작 시 힘 +1", "hook": "combatStart", "effect": "strength", "value": 1 },
|
||||
"anchor": { "name": "메이플 실드", "desc": "첫 턴 방어도 +10", "hook": "combatStart", "effect": "block", "value": 10 },
|
||||
"bagOfPrep": { "name": "모험가의 배낭", "desc": "첫 턴 드로우 +2", "hook": "combatStart", "effect": "draw", "value": 2 },
|
||||
"bloodVial": { "name": "피의 목걸이", "desc": "전투 시작 시 HP 2 회복", "hook": "combatStart", "effect": "heal", "value": 2 },
|
||||
"bronzeScales": { "name": "브론즈 체인메일", "desc": "피격 시 공격자에게 3 반사", "hook": "onPlayerDamaged", "effect": "thorns", "value": 3 },
|
||||
"strawberry": { "name": "건강의 반지", "desc": "획득 시 최대 HP +7", "hook": "passive", "effect": "maxHp", "value": 7 },
|
||||
"penNib": { "name": "황금 깃펜", "desc": "10번째 공격마다 피해 2배", "hook": "attackCalc", "effect": "penNib", "value": 10 },
|
||||
"boot": { "name": "브론즈 부츠", "desc": "5 미만 공격 피해가 5로", "hook": "attackCalc", "effect": "boot", "value": 5 },
|
||||
"akabeko": { "name": "황소 투구", "desc": "전투 첫 공격 피해 +8", "hook": "attackCalc", "effect": "akabeko", "value": 8 },
|
||||
"centennialPuzzle": { "name": "백년의 부적", "desc": "전투 첫 피격 시 드로우 3", "hook": "onPlayerDamaged", "effect": "firstLossDraw", "value": 3 },
|
||||
"meatOnBone": { "name": "고기 망치", "desc": "승리 시 HP 50% 이하면 12 회복","hook": "combatEnd", "effect": "healIfLow", "value": 12 },
|
||||
"selfFormingClay": { "name": "점토 갑옷", "desc": "피해를 받으면 다음 턴 방어 +3","hook": "onPlayerDamaged", "effect": "clayBlock", "value": 3 },
|
||||
"championBelt": { "name": "챔피언 벨트", "desc": "취약 부여 시 약화 1 추가", "hook": "cardDebuff", "effect": "vulnAddsWeak", "value": 1 }
|
||||
```
|
||||
|
||||
- [ ] **Step 3**: JSON 파싱 확인 + 커밋 `feat(potions-relics): 물약 6종·유물 15종 데이터 (아이콘 RUID 포함)`
|
||||
|
||||
### Task 3: 생성기 — 로드·직렬화·상태
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: 상단에 potions 로드·검증 (RELICS 로드 다음)
|
||||
|
||||
```js
|
||||
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}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: `luaRelicsTable`에 `icon = ${luaStr(r.icon || '')}` 필드 추가
|
||||
- [ ] **Step 3**: props 추가 — `prop('any', 'Potions')`, `prop('any', 'RunPotions')`, `prop('number', 'PotionSlots', '3')`, `prop('string', 'ShopPotion', '""')`, `prop('boolean', 'ShopPotionBought', 'false')`, `prop('number', 'FightAttackCount', '0')`, `prop('boolean', 'FirstHpLossDone', 'false')`, `prop('number', 'ClayBlockNext', '0')`, `prop('number', 'PotionMenuSlot', '0')`
|
||||
- [ ] **Step 4**: `StartRun`에 `self.RunPotions = {}` `self.PotionSlots = ${POTIONS.baseSlots}` `${luaPotionsTable(POTIONS.potions)}` 추가 (RunRelics 초기화 옆) + `self:RenderPotions()` (BindButtons 후)
|
||||
- [ ] **Step 5**: `StartCombat`에 `self.FightAttackCount = 0` `self.FirstHpLossDone = false` `self.ClayBlockNext = 0` 리셋 추가
|
||||
|
||||
### Task 4: 생성기 — 유물 효과 로직
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: `HasRelic` 헬퍼 신설 (boolean 반환)
|
||||
|
||||
```lua
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: `ApplyRelics` 확장 — 기존 effect에 추가: `strength`(PlayerStr += v), `heal`(HP 회복), `draw`(DrawCards(v) + RenderHand(false)), `healOnWin`(HP 회복), `healIfLow`(HP ≤ 50%면 회복)
|
||||
- [ ] **Step 3**: `AddRelic` 확장 — passive 즉시 적용
|
||||
|
||||
```lua
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 4**: `PickNewRelic` 신설 — 미보유 풀 추첨, 없으면 골드 +25 후 빈 문자열 반환. elite/boss 보상부의 `self:AddRelic(self.RelicPool[...])`를 `local nid = self:PickNewRelic() if nid ~= "" then self:AddRelic(nid) end`로 교체, boss 분기에도 동일 추가
|
||||
- [ ] **Step 5**: `CalcPlayerAttack` 유물 보정 — 공격 카드에서만 호출되므로 내부에서 카운트
|
||||
|
||||
```lua
|
||||
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 dmg > 0 and dmg < 5 and self:HasRelic("boot") then
|
||||
dmg = 5
|
||||
end
|
||||
if dmg < 0 then
|
||||
dmg = 0
|
||||
end
|
||||
return dmg
|
||||
```
|
||||
|
||||
- [ ] **Step 6**: `DealDamageToPlayer`에 attacker 인자 추가 + onPlayerDamaged 유물 (HP 실손실 시)
|
||||
|
||||
```lua
|
||||
-- 시그니처: (amount, attackerSlot) — EnemyActStep 호출부에 idx 전달
|
||||
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
|
||||
if self:HasRelic("bronzeScales") and attackerSlot ~= nil and attackerSlot > 0 then
|
||||
local am = self.Monsters[attackerSlot]
|
||||
if am ~= nil and am.alive == true then
|
||||
am.hp = am.hp - 3
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 7**: `StartPlayerTurn` — `self.PlayerBlock = 0` 직후 `if self.ClayBlockNext > 0 then self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext self.ClayBlockNext = 0 end`
|
||||
- [ ] **Step 8**: `PlayCard` 디버프 적용부 — championBelt: `if c.vuln ~= nil and self:HasRelic("championBelt") then tm.weak = tm.weak + 1 end`
|
||||
- [ ] **Step 9**: `CheckCombatEnd` 승리 분기 — `self:ApplyRelics("combatReward")` 앞에 `self:ApplyRelics("combatEnd")`, 뒤에 물약 드랍(Task 5의 `MaybeDropPotion`)
|
||||
- [ ] **Step 10**: 커밋 `feat(potions-relics): 유물 15종 효과 훅 (생성기)`
|
||||
|
||||
### Task 5: 생성기 — 물약 로직
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: 메서드 신설 — `AddPotion(pid)` (슬롯 검사·토스트), `MaybeDropPotion()` (`math.random() <= dropChance` 시 랜덤 지급), `RenderPotions()` (슬롯 5칸: 아이콘/빈칸/잠금), `OpenPotionMenu(slot)`/`ClosePotionMenu()`, `UsePotion()`, `TossPotion()`
|
||||
|
||||
```lua
|
||||
-- AddPotion(pid)
|
||||
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
|
||||
```
|
||||
|
||||
```lua
|
||||
-- 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
|
||||
```
|
||||
|
||||
```lua
|
||||
-- UsePotion() — PotionMenuSlot 대상. 전투 중이 아니면 무시.
|
||||
local combat = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud")
|
||||
if combat == nil or combat.Enable ~= true or self.CombatOver == 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)
|
||||
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:ClosePotionMenu()
|
||||
self:RenderPotions()
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
```
|
||||
|
||||
- [ ] **Step 2**: 상점 — `ShowShop`에 `self.ShopPotion = <정렬 키 랜덤>` `self.ShopPotionBought = false`, `RenderShop`에 Potion 라벨/가격/색, `BuyPotion` (가격 ${POTIONS.shopPrice}, AddPotion 실패 시 환불 없음 방지 — 슬롯 검사 먼저)
|
||||
- [ ] **Step 3**: 커밋 `feat(potions-relics): 물약 사용·드랍·상점 로직 (생성기)`
|
||||
|
||||
### Task 6: 생성기 — UI (아이콘 행·물약 슬롯·툴팁·물약 메뉴·상점)
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: TopBar — `Relics` 텍스트 항목 제거(topTexts에서 삭제), `RelicSlot1..10` (UISprite 40×40, x = -240 + (i-1)*48, guid cmb 300+i, UITouchReceiveComponent 포함, 기본 비표시 색), `RelicOverflow` 텍스트(guid cmb 311, 10번째 칸 위치), `PotionSlot1..5` (UISprite 40×40, x = 270 + (i-1)*44, guid cmb 320+i, UITouchReceiveComponent)
|
||||
- [ ] **Step 2**: `TooltipBox` (guid cmb 330: bg 280×76 + Name + Desc, displayOrder 20, enable=false, CombatHud 직속)
|
||||
- [ ] **Step 3**: `PotionMenu` 팝업 (guid cmb 340대: bg 320×180 중앙 + Title + [사용][버리기][닫기] 버튼 3개, enable=false)
|
||||
- [ ] **Step 4**: ShopHud — Relic 블록 뒤 `Potion` 엔티티(Label/Price 동일 패턴, y=-270, Leave는 y=-360으로 이동)
|
||||
- [ ] **Step 5**: `BindButtons` — RelicSlot/PotionSlot에 UITouchEnter/Exit(툴팁), PotionSlot UITouchDown(OpenPotionMenu), PotionMenu 버튼 3개, ShopHud/Potion 클릭(BuyPotion) 연결. `ShowTooltip`/`HideTooltip` 메서드 신설
|
||||
- [ ] **Step 6**: `RenderPotions`/`RenderRelics`(아이콘 행으로 재작성 — names 텍스트 제거) 구현 확인
|
||||
- [ ] **Step 7**: 커밋 `feat(potions-relics): 유물 아이콘 행·물약 슬롯·툴팁·물약 메뉴 UI (생성기)`
|
||||
|
||||
### Task 7: 산출물 재생성·검증
|
||||
|
||||
- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` 성공, `node --test tools/balance/sim-balance.test.mjs` 21건 통과 유지
|
||||
- [ ] **Step 2**: 메이커 refresh → 빌드 콘솔 0 에러 → 플레이테스트: 유물 아이콘 표시·툴팁 hover·물약 지급(`AddPotion`)·사용·벨트 5칸 (`AddRelic("potionBelt")`) 스크립트 확인 + 스크린샷
|
||||
- [ ] **Step 3**: 커밋 `feat(potions-relics): 산출물 재생성 (물약·유물·툴팁)`
|
||||
|
||||
### Task 8: 푸시·PR·머지
|
||||
|
||||
- [ ] **Step 1**: `git push -u origin feature/p7-potions-relics`
|
||||
- [ ] **Step 2**: Gitea API PR 생성(종합 메시지) → 머지 → main pull
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
- 설계 전 항목 매핑: 물약 6종·드랍·상점·사용/버리기(Task 2/5/6), 유물 15종 효과(Task 4), 아이콘+툴팁(Task 1/6), 벨트 5칸(Task 3 props + Task 4 passive) ✓
|
||||
- 이름 일관성: `RunPotions`/`PotionSlots`/`FightAttackCount`/`ClayBlockNext`/`HasRelic`/`PickNewRelic`/`MaybeDropPotion` 통일 ✓
|
||||
- 의존 순서: 아이콘 RUID(Task 1) → 데이터(Task 2) → 로직(3~5) → UI(6) → 검증(7) ✓
|
||||
71
docs/superpowers/plans/2026-06-12-rogue-map.md
Normal file
71
docs/superpowers/plans/2026-06-12-rogue-map.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# P8 — 로그라이크 절차 생성 맵·층 시스템·유물 방 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 막마다 8층×4열 맵을 Lua 런타임 절차 생성, 층별 타입 규칙·점선 맵 UI·유물 방(상자 연출) 추가.
|
||||
|
||||
**Architecture:** `data/map.json` 정적 주입 제거 → `GenerateMap` Lua 메서드(StS 경로-걷기 4개) + JS 미러(`tools/map/rogue-map.mjs`, node:test). MapHud는 정적 그리드(28노드+보스+도트 192)로 재작성, RenderMap이 런타임 토글. TreasureHud 신설(타이머 체인 흔들림 + RUID 교체).
|
||||
|
||||
**Tech Stack:** Node.js 생성기, MSW Lua, mulberry32(JS 테스트 전용 — Lua는 math.random).
|
||||
|
||||
설계: `docs/superpowers/specs/2026-06-12-rogue-map-design.md` (사용자 승인 완료)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: JS 미러 + 단위 테스트 (TDD)
|
||||
|
||||
**Files:** Create `tools/map/rogue-map.mjs`, Create `tools/map/rogue-map.test.mjs`
|
||||
|
||||
- [ ] **Step 1**: 테스트 먼저 작성 — `generateMap(rng)` import, 케이스: ①동일 시드 결정성 ②모든 노드가 시작점에서 BFS 도달 + 모든 노드에서 boss 도달 ③1~2행 combat만 ④elite·treasure는 4행부터 ⑤간선은 row+1·|Δcol|≤1 (boss 제외) ⑥elite 부모를 가진 노드는 elite 아님 ⑦boss는 row8 단일·7행 노드 전부 boss로 연결 ⑧MapStart ≥ 2개
|
||||
- [ ] **Step 2**: `node --test tools/map/rogue-map.test.mjs` → FAIL 확인
|
||||
- [ ] **Step 3**: `rogue-map.mjs` 구현 — 설계 알고리즘 그대로 (시작열 셔플 앞2 + 랜덤2, 경로 4개 걷기, 행 오름차순 가중 타입 배정·elite 부모 금지). 가중치 표는 설계 문서와 동일. ⚠️ 주석에 "Lua GenerateMap과 동기화 유지" 명시
|
||||
- [ ] **Step 4**: 테스트 PASS → 커밋 `feat(rogue-map): 절차 생성 알고리즘 JS 미러 + 테스트`
|
||||
|
||||
### Task 2: 생성기 — 정적 맵 제거 + GenerateMap(Lua) + 층 시스템
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`, Delete `data/map.json`
|
||||
|
||||
- [ ] **Step 1**: `MAP` 로드(16행)·`MAX_ROW`(26행)·`luaMapNodesTable`·`luaStartArray` 제거. `StartRun`의 `${luaMapNodesTable(...)}`/`${luaStartArray(...)}` → `self:GenerateMap()` 호출로 교체. `data/map.json` 삭제 (`git rm`)
|
||||
- [ ] **Step 2**: props 추가 — `prop('number', 'Depth', '0')`, `prop('any', 'VisitedNodes')`
|
||||
- [ ] **Step 3**: `GenerateMap` 메서드 신설 (설계 알고리즘의 Lua 구현 — MapNodes/MapStart/VisitedNodes/Depth 리셋, 경로 4개, 행 3~7 가중 타입 배정+elite 부모 금지, boss 노드)
|
||||
- [ ] **Step 4**: `PickNode` — `VisitedNodes` 추가·`Depth = node.row`·`RenderRun()`·`treasure → ShowTreasure` 분기·`self.CurrentEnemyId = node.enemy` → `""`
|
||||
- [ ] **Step 5**: `RenderRun`의 Floor 텍스트 → `"막 F/3 · D층"` (`self.Depth`)
|
||||
- [ ] **Step 6**: `CheckCombatEnd` 보스 클리어 분기에 `self:GenerateMap()` 추가 (Floor++ 후, TeleportToActMap 전)
|
||||
- [ ] **Step 7**: `BindButtons`의 `mapNodeIds` 정적 배열 → 그리드 28개+boss 루프 생성으로 교체
|
||||
- [ ] **Step 8**: 커밋 `feat(rogue-map): GenerateMap 런타임 절차 생성 + 층 시스템 (생성기)`
|
||||
|
||||
### Task 3: 생성기 — MapHud 그리드·점선 UI + RenderMap 재작성
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs` (MapHud 섹션 ~1449행, RenderMap ~3492행)
|
||||
|
||||
- [ ] **Step 1**: MapHud 섹션 재작성 — 기존 `MAP.nodes` 루프 삭제, 정적 생성:
|
||||
- `Node_r{r}c{c}` (r=1..7, c=1..4): 56×56 uisprite+button, pos x=-270+(c-1)*180, y=-330+(r-1)*105, 기본 enable=false, `Label` 자식(타입명, fontSize 16)
|
||||
- `Node_boss`: 72×72, pos (0, 405), `Label` "보스"
|
||||
- 도트: r=1..6, c=1..4, c'∈{c-1,c,c+1}∩[1,4] → `Dot_r{r}c{c}_{c'}_{k}` k=1..3 (8×8 uisprite, 노드 중심 보간 t=k/4, enable=false) + r=7 → `Dot_r7c{c}_b_{k}` (boss로)
|
||||
- guid('map') 인덱스는 결정적 루프 순서로 재배정 (섹션 전체 교체라 충돌 없음)
|
||||
- [ ] **Step 2**: `RenderMap` 재작성 — 타입색 헬퍼(전투/엘리트/상점/휴식/보물/보스), 상태 4단(현재=골드·방문=어둡게·도달가능=타입색+버튼 활성·잠김=45% 어둡게+비활성), 도트 토글(간선 존재)·현재 노드 발신 간선 골드
|
||||
- [ ] **Step 3**: `node tools/deck/gen-slaydeck.mjs` 성공 확인 → 커밋 `feat(rogue-map): 맵 그리드·점선 도트 UI + RenderMap 상태 4단 (생성기)`
|
||||
|
||||
### Task 4: 상자 RUID 선별 + TreasureHud + 메소 표기
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: `asset_search_resources`("보물상자"/"상자", source=maplestory) → 메이커 격자 미리보기(기존 패턴) → 닫힘/열림 RUID 2종 확정. 생성기 상수 `CHEST_CLOSED_RUID`/`CHEST_OPEN_RUID`
|
||||
- [ ] **Step 2**: guid 맵에 `'trs': 0xe3` 추가, TreasureHud 섹션 신설 — root(hidden 패널)·Title("보물 상자")·`Chest`(160×160 uisprite+button, 닫힘 RUID, y=40)·`Reward` 텍스트(hidden, y=-120)·`Leave` 버튼(y=-260). `emit('TreasureHud', ...)`
|
||||
- [ ] **Step 3**: `HideGameHud`에 TreasureHud 추가, `ShowState`에 `elseif state == "treasure"` 분기
|
||||
- [ ] **Step 4**: 메서드 — `ShowTreasure`(ChestOpened 리셋·닫힘 RUID·Reward 숨김·ShowState), `OpenChest`(1회 가드 → 흔들림 타이머 체인 ±8px 0.08s×6 → 0.55s 후 열림 RUID + 메소 40+random(0..20) + `PickNewRelic` 유물/소진 시 메소+30 + Reward 표시), prop `ChestOpened`
|
||||
- [ ] **Step 5**: `BindButtons` — Chest 클릭→`OpenChest`, TreasureHud/Leave→`LeaveNode`
|
||||
- [ ] **Step 6**: 메소 표기 — 표시 문자열 전수 교체: TopBar/ShopHud "골드 N"→"메소 N", 가격 "N 골드"→"N 메소", PickNewRelic 토스트 "골드 +25"→"메소 +25" (내부 prop Gold 유지)
|
||||
- [ ] **Step 7**: 커밋 `feat(rogue-map): 유물 방 상자 연출·TreasureHud·메소 표기 (생성기)`
|
||||
|
||||
### Task 5: 재생성·검증·푸시·PR·머지
|
||||
|
||||
- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` + `node --test tools/map/rogue-map.test.mjs tools/balance/sim-balance.test.mjs` 전체 PASS
|
||||
- [ ] **Step 2**: 커밋 `feat(rogue-map): 산출물 재생성` → 메이커 refresh → 빌드 0에러 → 플레이테스트: 맵 생성(점선·상태색)·노드 진행(층 증가)·유물 방(흔들림→열림→보상)·보스 → 다음 막 새 맵, 스크린샷 확보
|
||||
- [ ] **Step 3**: push → Gitea API PR(종합 메시지) → 머지 → main pull → 메모리 갱신
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
- 설계 전 항목 매핑: 절차 생성(T1/T2)·층 시스템(T2)·점선 UI+상태 4단(T3)·유물 방+상자 모션(T4)·메소(T4) ✓
|
||||
- 이름 일관성: `GenerateMap`/`Depth`/`VisitedNodes`/`ShowTreasure`/`OpenChest`/`ChestOpened`/`Dot_<fid>_<c'>_<k>` 통일 ✓
|
||||
- 리스크: MapHud 섹션 전체 교체로 guid('map') 재배정 — 섹션 단위 emit이라 안전. RenderMap pairs 순회 제거(그리드 고정 루프) ✓
|
||||
524
docs/superpowers/plans/2026-06-14-lobby-map-npc.md
Normal file
524
docs/superpowers/plans/2026-06-14-lobby-map-npc.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# P15 — 로비 맵 + 월드 NPC 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md`. 산출물(`map/*.map`,`ui/DefaultGroup.ui`,`*.codeblock`,`Global/*`)은 Read/Edit 금지 — 생성기 소스(`tools/`)만 수정 후 재생성. 검증은 `grep -c`(카운트)와 메이커 플레이테스트.
|
||||
|
||||
**Goal:** UI 패널 로비를 폐기하고, 전용 물리 맵 `lobby`에 공식 메이플 NPC 4종을 월드 엔티티로 배치해 근접(↑키)·클릭으로 기능을 열며, 이동·공격 모션은 로비 맵에서만 풀린다.
|
||||
|
||||
**Architecture:** 단일 소스(`tools/*` 생성기 + `data/*.json`) → 산출물 재생성. 신규 생성기 2개(`gen-lobby-map.mjs`=맵+NPC 엔티티, `gen-lobby-npc.mjs`=LobbyNpc+LobbyMobility codeblock) + `gen-slaydeck.mjs`(흐름·UI) + `gen-player-lock.mjs`(전투맵 이동 재잠금 보강) 수정. 기존 기능 패널(CharacterSelect/Codex/SoulShop/Board)·전투 흐름 재사용.
|
||||
|
||||
**Tech Stack:** Node.js ESM 생성기, MSW Lua(codeblock), MSW MCP(플레이테스트·asset).
|
||||
|
||||
**확정 사실(조사):**
|
||||
- gen-slaydeck 편집 지점: OnBeginPlay `2830-2840`, ShowLobby `2986-2993`, LobbyHud npcs배열 `2469-2474`+버튼루프 `2475-2524`, lobTexts `2433-2439`, Asc버튼 `2454-2468`, BindLobbyButtons `2997-3014`, ShowState `2906-2922`, StartRun `3199-3232`, EndRun `4391-4403`, TeleportToActMap `4373-4385`, PlayerAttackMotion `4491-4500`, guid prefix `244-245`, ACT_MAPS `2745`.
|
||||
- **1막 텔레포트 공백**: StartRun(`3199-3232`)에 map01 텔레포트가 없음 → `self:TeleportToActMap()` 추가 필요(`RenderPotions` 다음, `ShowMap` 직전). `TeleportToActMap`은 `maps[self.Floor]` 사용 + 가드 `if lp.CurrentMapName==target then return`(멱등).
|
||||
- **NPC 공식 RUID**(maplestory, 흰박스 위험 없음): 모험가 `122095fd155c4633867b0da4f375bc3c`, 사서 `4c264be6a64f4ac3970b2e6818d04e40`, 상인 `69987ccdc486423f8bedd786bd6cb5d9`, 안내원 `8a99bd87d667482cb1f3b2193f8a19c1`.
|
||||
- **MSW API**: 월드 클릭 = 엔티티에 `TouchReceiveComponent` + `self.Entity:ConnectEvent(TouchEvent, fn)`. 키 = `_InputService:ConnectEvent(KeyDownEvent, fn)` + `KeyboardKey.UpArrow`(273)/`Space`(32)/`LeftControl`. 거리 = `Vector2.Distance(Vector2(a.x,a.y),Vector2(b.x,b.y))`. 이동복원 = `pc.Enable=true; pc.FixedLookAt=false; mv.InputSpeed=<V>; mv.JumpForce=<J>`(client 공간). 표시토글 = `entity:SetVisible(bool)`.
|
||||
- **맵 생성 패턴**(gen-maps.mjs): `JSON.parse(readFileSync('map/map01.map'))` → deep clone → 경로 `/maps/map01`→`/maps/lobby` 치환 → GUID 재발급(+origin fixup) → `compOf(e,'MOD.Core.X')`로 컴포넌트 접근 → `writeFileSync('map/lobby.map', JSON.stringify(map,null,2))`. 배경=`/Background`의 `BackgroundComponent.TemplateRUID`, 타일=`/TileMap`의 `TileMapComponent.TileSetRUID={DataId}`. 컴포넌트 부착=`@components` push + `componentNames` CSV 둘 다. SectorConfig=`Sectors[0].entries`에 `map://lobby` push.
|
||||
- **codeblock 패턴**(gen-combat-monster.mjs): `prop()/method()` 팩토리 + 봉투(`CoreVersion:'26.5.0.0'`, `EntryKey:'codeblock://x'`) → `writeFileSync('RootDesk/MyDesk/X.codeblock', JSON.stringify(cb,null,2))`. 컨트롤러 호출=`_EntityService:GetEntityByPath("/common").SlayDeckController:Method(...)`. 폴 idiom=`_TimerService:SetTimerRepeat(fn,0.1)`+try카운트 가드+`:ClearTimer(id)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 메이커 사전 정찰 (이동값·키·바디 컴포넌트·스폰좌표 확정)
|
||||
|
||||
**목적:** LobbyMobility의 이동 복원 수치·공격 키·바디 컴포넌트 종류·로비 스폰 좌표를 추측이 아니라 실측으로 확정. 산출물 작성 전 선행.
|
||||
|
||||
- [ ] **Step 1:** 메이커가 켜져 있는지 확인하고 현재 빌드 플레이. `mcp__msw-maker-mcp__maker_play` → `maker_screenshot`로 현재 화면(UI 로비) 확인.
|
||||
|
||||
- [ ] **Step 2:** execute_script로 LocalPlayer 컴포넌트·이동값·바디 종류 덤프:
|
||||
|
||||
```lua
|
||||
local lp = _UserService.LocalPlayer
|
||||
local s = "pc="..tostring(lp.PlayerControllerComponent ~= nil)
|
||||
local mv = lp.MovementComponent
|
||||
if mv ~= nil then s = s.." InputSpeed="..tostring(mv.InputSpeed).." JumpForce="..tostring(mv.JumpForce) end
|
||||
s = s.." Rigidbody="..tostring(lp.RigidbodyComponent ~= nil)
|
||||
s = s.." Sideviewbody="..tostring(lp.SideviewbodyComponent ~= nil)
|
||||
local p = lp.TransformComponent.WorldPosition
|
||||
s = s.." pos=("..tostring(p.x)..","..tostring(p.y)..","..tostring(p.z)..")"
|
||||
s = s.." map="..tostring(lp.CurrentMapName)
|
||||
log(s)
|
||||
return s
|
||||
```
|
||||
|
||||
Run via `maker_execute_script`. 기대: 현재 InputSpeed/JumpForce(0일 것), 어떤 바디 컴포넌트가 존재하는지(Rigidbody vs Sideviewbody), 현재 맵 이름·좌표.
|
||||
|
||||
- [ ] **Step 3:** 이동 복원값 실측 — execute_script로 직접 켜 보고 걸어지는지 확인:
|
||||
|
||||
```lua
|
||||
local lp = _UserService.LocalPlayer
|
||||
lp.PlayerControllerComponent.Enable = true
|
||||
lp.PlayerControllerComponent.FixedLookAt = false
|
||||
lp.MovementComponent.InputSpeed = 5
|
||||
lp.MovementComponent.JumpForce = 5
|
||||
return "applied: try walking with arrow keys"
|
||||
```
|
||||
|
||||
`maker_keyboard_input`로 방향키를 눌러 실제 이동 여부 확인(screenshot 비교). 걸으면 InputSpeed 값 후보 = 5. 안 걸으면 RigidbodyComponent.WalkSpeed/WalkJump 등도 set해보고(아래) 동작하는 최소 set을 기록.
|
||||
|
||||
```lua
|
||||
local rb = _UserService.LocalPlayer.RigidbodyComponent
|
||||
if rb ~= nil then rb.Enable = true end
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** 공격 키 enum 확정 — `mlua_api_retriever`(이미 검증됨: UpArrow=273, Space=32)에서 공격용 키 `LeftControl`의 정확한 enum 멤버명 확인(예: `KeyboardKey.LeftControl`). 확인 안 되면 공격 키를 `KeyboardKey.Space`로 폴백(이동 점프는 MSW 기본 Alt 가정).
|
||||
|
||||
- [ ] **Step 5:** 결정 기록 — 이 plan 파일 하단 "정찰 결과" 섹션에 확정값 적기:
|
||||
- `WALK_SPEED` = (Step3에서 걸어진 InputSpeed), `JUMP_FORCE` = (걸어진 JumpForce), `BODY_KIND` = Rigidbody|Sideviewbody|none, 추가 바디 set 필요 여부, `ATTACK_KEY` = LeftControl|Space, `LOBBY_SPAWN` = 적당한 지면 좌표(현재 map 좌표 참고, 예 `Vector3(0, 0.03, 0)`).
|
||||
- 이후 Task에서 이 값을 JS 상수로 사용.
|
||||
|
||||
- [ ] **Step 6:** `maker_stop`으로 플레이 종료(상태 churn 방지).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `gen-lobby-map.mjs` — 로비 맵 + NPC 엔티티 생성
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/map/gen-lobby-map.mjs`
|
||||
- Output(산출물, 직접 편집 금지): `map/lobby.map`, `Global/SectorConfig.config`(갱신)
|
||||
|
||||
NPC 4종 + `!` 마크 4종을 월드 엔티티로 배치. 마크는 자식이 아니라 **형제 엔티티**(NPC 위 고정 위치, 정적이라 무방). 각 NPC에 `TouchReceiveComponent` + `script.LobbyNpc`(NpcId), 맵 루트에 `script.LobbyMobility` 부착.
|
||||
|
||||
- [ ] **Step 1:** `tools/map/gen-maps.mjs`를 참고 헤더로 새 파일 생성. 상수:
|
||||
|
||||
```js
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const TEMPLATE = 'map/map01.map';
|
||||
const OUT = 'map/lobby.map';
|
||||
const SECTOR = 'Global/SectorConfig.config';
|
||||
const TOWN_BG = '<gen-maps.mjs BACKGROUNDS 풀에서 타운(헤네시스 등) RUID 1개 복사>'; // Task1 Step2에서 확정
|
||||
const NPCS = [
|
||||
{ name: 'NpcRun', id: 'run', x: -4.5, ruid: '122095fd155c4633867b0da4f375bc3c' },
|
||||
{ name: 'NpcCodex', id: 'codex', x: -1.5, ruid: '4c264be6a64f4ac3970b2e6818d04e40' },
|
||||
{ name: 'NpcShop', id: 'shop', x: 1.5, ruid: '69987ccdc486423f8bedd786bd6cb5d9' },
|
||||
{ name: 'NpcBoard', id: 'board', x: 4.5, ruid: '8a99bd87d667482cb1f3b2193f8a19c1' },
|
||||
];
|
||||
const MARK_RUID = '<Task1 Step2: asset_search로 "!" 말풍선/느낌표 공식 스프라이트 RUID, 못찾으면 NPC와 구분되는 작은 공식 스프라이트>';
|
||||
const NPC_Y = 0.0; // 지면 (Task0 좌표 참고로 조정)
|
||||
const MARK_DY = 1.6; // NPC 머리 위 오프셋
|
||||
|
||||
function compOf(e, type) { return e.jsonString['@components'].find((c) => c['@type'] === type); }
|
||||
function lobbyGuid(idx) {
|
||||
const n = (900000 + idx) >>> 0; // 기존 생성기와 충돌 없는 고유 오프셋
|
||||
return `${n.toString(16).padStart(8,'0')}-0000-4000-8000-${n.toString(16).padStart(12,'0')}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** TOWN_BG·MARK_RUID 확정 — `gen-maps.mjs`를 열어 `BACKGROUNDS` 배열에서 타운 느낌 RUID 하나 골라 `TOWN_BG`에 박는다. MARK_RUID는 메이커 MCP `asset_search_resources`(source=maplestory, query "느낌표"/"balloon"/"emotion")로 1개 확정(못 찾으면 `!` 대신 작은 화살표/별 공식 스프라이트, 최후엔 NPC RUID 재사용+tint).
|
||||
|
||||
- [ ] **Step 3:** 맵 로드·클론·정리(몬스터 제거)·배경:
|
||||
|
||||
```js
|
||||
const map = JSON.parse(JSON.stringify(JSON.parse(readFileSync(TEMPLATE, 'utf8'))));
|
||||
map.EntryKey = 'map://lobby';
|
||||
let ents = map.ContentProto.Entities;
|
||||
const isMonster = (e) => typeof e.componentNames === 'string' && (e.componentNames.includes('script.Monster') || e.componentNames.includes('script.CombatMonster'));
|
||||
// 경로/이름 치환
|
||||
for (const e of ents) {
|
||||
if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', '/maps/lobby');
|
||||
if (e.jsonString) {
|
||||
if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', '/maps/lobby');
|
||||
if (e.jsonString.name === 'map01') e.jsonString.name = 'lobby';
|
||||
}
|
||||
if ((e.path || '').endsWith('/Background')) { const bg = compOf(e, 'MOD.Core.BackgroundComponent'); if (bg) bg.TemplateRUID = TOWN_BG; }
|
||||
}
|
||||
// 몬스터 엔티티 제거 + PlayerLock/MapCamera는 유지(로비엔 PlayerLock 불필요하니 루트에서 제거)
|
||||
ents = ents.filter((e) => !isMonster(e));
|
||||
const root = ents.find((e) => e.path === '/maps/lobby');
|
||||
if (!root) throw new Error('[gen-lobby-map] 맵 루트 없음');
|
||||
// 로비엔 PlayerLock 컴포넌트가 있으면 제거(이동 잠금 방지)
|
||||
root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.PlayerLock');
|
||||
{ const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.PlayerLock'); root.componentNames = names.join(','); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** NPC 엔티티 + 마크 엔티티 생성(몬스터 템플릿을 클론해 몬스터 컴포넌트 제거 후 재사용). 몬스터 템플릿은 클론 전에 원본 ents(`map.ContentProto.Entities`)에서 확보:
|
||||
|
||||
```js
|
||||
const orig = JSON.parse(readFileSync(TEMPLATE, 'utf8')).ContentProto.Entities;
|
||||
const tmpl = orig.find((e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster'));
|
||||
if (!tmpl) throw new Error('[gen-lobby-map] 몬스터 템플릿(스프라이트 엔티티) 없음');
|
||||
let gi = 1;
|
||||
function makeSpriteEntity(name, x, y, ruid, extraComps, extraNames, visible) {
|
||||
const m = JSON.parse(JSON.stringify(tmpl));
|
||||
m.id = lobbyGuid(gi++);
|
||||
m.path = `/maps/lobby/${name}`;
|
||||
m.jsonString.path = m.path;
|
||||
m.jsonString.name = name;
|
||||
const o = m.jsonString.origin; if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; }
|
||||
const tr = compOf(m, 'MOD.Core.TransformComponent'); if (tr) { tr.Position.x = x; tr.Position.y = y; }
|
||||
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent'); if (sp) sp.SpriteRUID = ruid;
|
||||
// 몬스터/전투 컴포넌트 전부 제거
|
||||
m.jsonString['@components'] = m.jsonString['@components'].filter((c) => !['script.Monster','script.CombatMonster'].includes(c['@type']));
|
||||
let names = (m.componentNames || '').split(',').filter((s) => s && !['script.Monster','script.CombatMonster'].includes(s));
|
||||
// StateAnimationComponent가 있으면 die/hit 시트 제거(정적 stand)
|
||||
for (const [comp, props] of extraComps) { m.jsonString['@components'].push({ '@type': comp, Enable: true, ...props }); names.push(comp); }
|
||||
names = names.concat(extraNames).filter(Boolean);
|
||||
m.componentNames = names.join(',');
|
||||
// 마크 숨김은 Enable=false 금지(SetVisible가 안 먹음). codeblock OnBeginPlay가 SetVisible(false)로 숨기므로
|
||||
// 여기선 별도 처리 안 함. (한 프레임 깜빡임 우려 시 SpriteRendererComponent.Visible=false 시도 — 필드 확인 후.)
|
||||
void visible;
|
||||
return m;
|
||||
}
|
||||
const added = [];
|
||||
for (const npc of NPCS) {
|
||||
// NPC: TouchReceiveComponent(자동맞춤) + script.LobbyNpc(NpcId)
|
||||
added.push(makeSpriteEntity(npc.name, npc.x, NPC_Y, npc.ruid,
|
||||
[['MOD.Core.TouchReceiveComponent', { AutoFitToSize: true }], ['script.LobbyNpc', { NpcId: npc.id, Tries: 0, InRange: false, MarkName: npc.name + 'Mark' }]],
|
||||
['MOD.Core.TouchReceiveComponent', 'script.LobbyNpc'], true));
|
||||
// 마크: NPC 위, 기본 숨김
|
||||
added.push(makeSpriteEntity(npc.name + 'Mark', npc.x, NPC_Y + MARK_DY, MARK_RUID, [], [], false));
|
||||
}
|
||||
ents = ents.concat(added);
|
||||
```
|
||||
|
||||
> 주: `script.LobbyNpc` props(NpcId/MarkName 등)는 Task2의 codeblock 속성 정의와 **이름이 정확히 일치**해야 한다.
|
||||
|
||||
- [ ] **Step 5:** 맵 루트에 `script.LobbyMobility` 부착 + 쓰기 + SectorConfig 등록:
|
||||
|
||||
```js
|
||||
root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.LobbyMobility');
|
||||
root.jsonString['@components'].push({ '@type': 'script.LobbyMobility', Enable: true, Tries: 0 });
|
||||
{ const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.LobbyMobility'); names.push('script.LobbyMobility'); root.componentNames = names.join(','); }
|
||||
map.ContentProto.Entities = ents;
|
||||
writeFileSync(OUT, JSON.stringify(map, null, 2), 'utf8');
|
||||
// SectorConfig: map://lobby 등록(멱등) + 시작 섹터를 lobby로
|
||||
const sector = JSON.parse(readFileSync(SECTOR, 'utf8'));
|
||||
const sec0 = sector.ContentProto.Json.Sectors[0];
|
||||
if (!sec0.entries.includes('map://lobby')) sec0.entries.push('map://lobby');
|
||||
writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8');
|
||||
console.log('[gen-lobby-map] lobby.map 생성 + SectorConfig 등록 완료');
|
||||
```
|
||||
|
||||
- [ ] **Step 6:** 실행 + 카운트 검증(내용 출력 금지):
|
||||
|
||||
```bash
|
||||
node tools/map/gen-lobby-map.mjs
|
||||
grep -c "script.LobbyNpc" map/lobby.map # 4 기대
|
||||
grep -c "script.LobbyMobility" map/lobby.map # 1 기대
|
||||
grep -c "TouchReceiveComponent" map/lobby.map # 4(+ 템플릿 잔존 가능) 기대
|
||||
grep -lc "map://lobby" Global/SectorConfig.config
|
||||
node tools/verify/count.mjs 2>/dev/null || true
|
||||
```
|
||||
|
||||
기대: LobbyNpc=4, LobbyMobility=1. 어긋나면 생성기 수정.
|
||||
|
||||
- [ ] **Step 7:** 커밋:
|
||||
|
||||
```bash
|
||||
git add tools/map/gen-lobby-map.mjs map/lobby.map Global/SectorConfig.config
|
||||
git commit -m "feat(lobby): 로비 전용 맵 + NPC 4종 월드 엔티티 생성기 (P15)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `gen-lobby-npc.mjs` — LobbyNpc + LobbyMobility codeblock
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/player/gen-lobby-npc.mjs`
|
||||
- Output(산출물): `RootDesk/MyDesk/LobbyNpc.codeblock`, `RootDesk/MyDesk/LobbyMobility.codeblock`
|
||||
|
||||
`gen-combat-monster.mjs`의 `prop()/method()`/봉투 패턴을 그대로 복사. **Lua 문자열은 실제 탭 들여쓰기 사용**(RULES.md 메모리: 실탭↔`\t` 혼재 금지 — 템플릿 리터럴 안 실제 탭).
|
||||
|
||||
- [ ] **Step 1:** 헤더·팩토리(gen-combat-monster.mjs:9-17 복사) + 봉투 함수:
|
||||
|
||||
```js
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const WALK_SPEED = /* Task0 정찰값 */ 5;
|
||||
const JUMP_FORCE = /* Task0 정찰값 */ 5;
|
||||
const ATTACK_KEY = /* Task0: 'LeftControl' 또는 'Space' */ 'LeftControl';
|
||||
|
||||
function prop(Type, Name, DefaultValue = 'nil') { return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; }
|
||||
function method(Name, Code, Arguments = [], ExecSpace = 6) {
|
||||
return { Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name };
|
||||
}
|
||||
function writeCodeblock(id, name, properties, methods) {
|
||||
const cb = { Id: '', GameId: '', EntryKey: `codeblock://${id.toLowerCase()}`, 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: name, Language: 1, Name: name, Type: 1, Source: 0, Target: null, Properties: properties, Methods: methods, EntityEventHandlers: [] } } };
|
||||
writeFileSync(`RootDesk/MyDesk/${name}.codeblock`, JSON.stringify(cb, null, 2), 'utf8');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** LobbyNpc codeblock — 근접 폴링 + 마크 토글 + Touch/Key → Interact. (아래 Lua의 들여쓰기는 실제 탭으로 입력)
|
||||
|
||||
```js
|
||||
const npcInteract = method('Interact', `local c = _EntityService:GetEntityByPath("/common")
|
||||
if c ~= nil and c.SlayDeckController ~= nil then
|
||||
c.SlayDeckController:OnLobbyNpcInteract(self.NpcId)
|
||||
end`);
|
||||
|
||||
const npcBegin = method('OnBeginPlay', `self.Tries = 0
|
||||
self.InRange = false
|
||||
local mark = _EntityService:GetEntityByPath("/maps/lobby/" .. self.MarkName)
|
||||
if mark ~= nil then mark:SetVisible(false) end
|
||||
self.Entity:ConnectEvent(TouchEvent, function(e) self:Interact() end)
|
||||
_InputService:ConnectEvent(KeyDownEvent, function(e)
|
||||
if self.InRange and e.key == KeyboardKey.UpArrow then self:Interact() end
|
||||
end)
|
||||
local eventId = 0
|
||||
local function tick()
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp == nil then return end
|
||||
local a = lp.TransformComponent.WorldPosition
|
||||
local b = self.Entity.TransformComponent.WorldPosition
|
||||
local d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y))
|
||||
local near = d < 1.8
|
||||
if near ~= self.InRange then
|
||||
self.InRange = near
|
||||
if mark ~= nil then mark:SetVisible(near) end
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(tick, 0.15)`);
|
||||
|
||||
writeCodeblock('LobbyNpc', 'LobbyNpc', [
|
||||
prop('string', 'NpcId', '""'),
|
||||
prop('string', 'MarkName', '""'),
|
||||
prop('boolean', 'InRange', 'false'),
|
||||
prop('number', 'Tries', '0'),
|
||||
], [npcBegin, npcInteract]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** LobbyMobility codeblock — 이동 복원 + 공격 키. (들여쓰기 실제 탭)
|
||||
|
||||
```js
|
||||
const mobBegin = method('OnBeginPlay', `self.Tries = 0
|
||||
local eventId = 0
|
||||
local function apply()
|
||||
self.Tries = self.Tries + 1
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil and lp.PlayerControllerComponent ~= nil then
|
||||
local pc = lp.PlayerControllerComponent
|
||||
pc.Enable = true
|
||||
pc.FixedLookAt = false
|
||||
local mv = lp.MovementComponent
|
||||
if mv ~= nil then
|
||||
mv.InputSpeed = ${WALK_SPEED}
|
||||
mv.JumpForce = ${JUMP_FORCE}
|
||||
end
|
||||
local rb = lp.RigidbodyComponent
|
||||
if rb ~= nil then rb.Enable = true end
|
||||
_TimerService:ClearTimer(eventId)
|
||||
elseif self.Tries > 50 then
|
||||
_TimerService:ClearTimer(eventId)
|
||||
end
|
||||
end
|
||||
eventId = _TimerService:SetTimerRepeat(apply, 0.1)
|
||||
_InputService:ConnectEvent(KeyDownEvent, function(e)
|
||||
if e.key == KeyboardKey.${ATTACK_KEY} then
|
||||
local c = _EntityService:GetEntityByPath("/common")
|
||||
if c ~= nil and c.SlayDeckController ~= nil then
|
||||
c.SlayDeckController:PlayerAttackMotion()
|
||||
end
|
||||
end
|
||||
end)`);
|
||||
|
||||
writeCodeblock('LobbyMobility', 'LobbyMobility', [prop('number', 'Tries', '0')], [mobBegin]);
|
||||
console.log('[gen-lobby-npc] LobbyNpc/LobbyMobility codeblock 생성 완료');
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** 실행 + 카운트 검증:
|
||||
|
||||
```bash
|
||||
node tools/player/gen-lobby-npc.mjs
|
||||
grep -c "OnLobbyNpcInteract" RootDesk/MyDesk/LobbyNpc.codeblock # >=1
|
||||
grep -c "PlayerAttackMotion" RootDesk/MyDesk/LobbyMobility.codeblock # >=1
|
||||
ls -la RootDesk/MyDesk/LobbyNpc.codeblock RootDesk/MyDesk/LobbyMobility.codeblock
|
||||
```
|
||||
|
||||
- [ ] **Step 5:** 커밋:
|
||||
|
||||
```bash
|
||||
git add tools/player/gen-lobby-npc.mjs RootDesk/MyDesk/LobbyNpc.codeblock RootDesk/MyDesk/LobbyMobility.codeblock
|
||||
git commit -m "feat(lobby): LobbyNpc(근접·클릭 상호작용)·LobbyMobility(이동·공격 해제) codeblock (P15)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `gen-player-lock.mjs` — 전투맵 이동 재잠금 보강 (방어)
|
||||
|
||||
**Files:** Modify `tools/player/gen-player-lock.mjs`
|
||||
|
||||
로비에서 푼 이동이 텔레포트 후 전투맵에 누설돼도, 전투맵 PlayerLock이 런타임으로 MovementComponent를 0으로 재설정해 확실히 잠그도록 보강.
|
||||
|
||||
- [ ] **Step 1:** `gen-player-lock.mjs`의 PlayerLock Lua에서 `pc.Enable = false` 직후 라인을 추가(생성기 내 해당 Lua 템플릿 리터럴, 실제 탭 들여쓰기):
|
||||
|
||||
```lua
|
||||
pc.Enable = false
|
||||
local mv = lp.MovementComponent
|
||||
if mv ~= nil then mv.InputSpeed = 0; mv.JumpForce = 0 end
|
||||
```
|
||||
|
||||
(정확한 삽입 지점은 `gen-player-lock.mjs`에서 `pc.Enable`가 들어간 Lua 문자열. `LocalPlayer.PlayerControllerComponent`를 `lp`로 잡는 변수명이 기존 코드와 일치하는지 확인 — 다르면 기존 변수명 사용.)
|
||||
|
||||
- [ ] **Step 2:** 재생성 + 카운트:
|
||||
|
||||
```bash
|
||||
node tools/player/gen-player-lock.mjs
|
||||
grep -c "InputSpeed = 0" RootDesk/MyDesk/PlayerLock.codeblock # >=1 기대(파일명은 생성기 출력명 확인)
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** 커밋:
|
||||
|
||||
```bash
|
||||
git add tools/player/gen-player-lock.mjs RootDesk/MyDesk/PlayerLock.codeblock map/map0*.map
|
||||
git commit -m "fix(lobby): 전투맵 PlayerLock에 이동값 런타임 0 재설정 보강 (P15)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `gen-slaydeck.mjs` — 흐름·UI 통합
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** guid prefix 등록(`244-245`) — 신규 prefix 불필요(LobbyHud 슬림화만, 기존 `lob` 재사용). 확인만.
|
||||
|
||||
- [ ] **Step 2:** ACT_MAPS 아래(`2745`)에 로비 상수 추가:
|
||||
|
||||
```js
|
||||
const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05'];
|
||||
const LOBBY_MAP = 'lobby';
|
||||
const LOBBY_SPAWN = 'Vector3(0, 0.03, 0)'; // Task0 정찰 좌표로 조정
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** LobbyHud 슬림화 — `npcs` 배열(`2469-2474`)과 버튼 생성 루프(`2475-2524`) **삭제**. `lobTexts`(`2433-2439`)는 SoulLabel/AscLabel + 안내문(Hint)만 남기고 Title/Subtitle은 "마을" 정도로 축소 or 제거. AscMinus/AscPlus(`2454-2468`)는 유지. → LobbyHud가 상단 정보바(영혼/승천)만 남음.
|
||||
|
||||
- [ ] **Step 4:** BindLobbyButtons(`2997-3014`) — NPC 4개 `bindClick` 라인 **삭제**(NpcRun/NpcCodex/NpcShop/NpcBoard). AscMinus/AscPlus/BoardHud.Close/SoulShopHud.Close bindClick은 유지.
|
||||
|
||||
- [ ] **Step 5:** ShowLobby(`2986-2993`) — 끝에 로비 맵 텔레포트 추가:
|
||||
|
||||
```js
|
||||
method('ShowLobby', `self.SelectedClass = ""
|
||||
self:RenderAscension()
|
||||
self:RenderSoulLabel()
|
||||
self:ShowState("lobby")
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/BoardHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/SoulShopHud", false)
|
||||
self:BindLobbyButtons()
|
||||
self:BindMenuButtons()
|
||||
self:GoLobbyMap()`),
|
||||
```
|
||||
|
||||
- [ ] **Step 6:** 신규 method `GoLobbyMap`(ShowLobby 근처에 추가, ExecSpace 기본):
|
||||
|
||||
```js
|
||||
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)`),
|
||||
```
|
||||
|
||||
- [ ] **Step 7:** 신규 method `OnLobbyNpcInteract`(인자 id) — NPC codeblock이 호출:
|
||||
|
||||
```js
|
||||
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' }]),
|
||||
```
|
||||
|
||||
(인자 객체 형태는 기존 `EndRun`의 `text` 인자/`ShowState`의 `state` 인자 정의를 참고해 동일 구조로.)
|
||||
|
||||
- [ ] **Step 8:** StartRun(`3199-3232`) — `RenderPotions()` 다음, `ShowMap()` 직전에 1막 텔레포트 추가:
|
||||
|
||||
```js
|
||||
// ... self:RenderPotions() (기존) 다음 줄에
|
||||
self:TeleportToActMap()
|
||||
// ... self:ShowMap() (기존)
|
||||
```
|
||||
|
||||
(StartRun의 Lua 문자열 내부에 `self:TeleportToActMap()` 한 줄 삽입. Floor=1이 이미 세팅돼 map01 타깃.)
|
||||
|
||||
- [ ] **Step 9:** EndRun(`4391-4403`) 복귀 — 기존 타이머 `self:ShowLobby()`가 GoLobbyMap을 호출하므로 **별도 변경 불필요**(ShowLobby가 로비 맵 텔레포트 포함). 확인만.
|
||||
|
||||
- [ ] **Step 10:** 재생성 + 카운트 검증:
|
||||
|
||||
```bash
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
grep -c "OnLobbyNpcInteract" RootDesk/MyDesk/SlayDeckController.codeblock # >=1 (이 파일엔 정의만; 호출은 LobbyNpc.codeblock)
|
||||
grep -c "GoLobbyMap" RootDesk/MyDesk/SlayDeckController.codeblock # >=2 (정의+ShowLobby 호출)
|
||||
grep -c "TeleportToActMap" RootDesk/MyDesk/SlayDeckController.codeblock # >=3 (정의+ContinueAfterBoss+StartRun)
|
||||
grep -c "NpcRun" ui/DefaultGroup.ui # 0 기대(버튼-행 제거됨)
|
||||
```
|
||||
|
||||
- [ ] **Step 11:** 커밋:
|
||||
|
||||
```bash
|
||||
git add tools/deck/gen-slaydeck.mjs ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic map/lobby.map map/map0*.map
|
||||
git commit -m "feat(lobby): 로비 맵 흐름 통합 — OnBeginPlay/EndRun 텔레포트·NPC 상호작용 디스패치·StartRun map01 텔레포트·LobbyHud 슬림화 (P15)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 미러/회귀 테스트
|
||||
|
||||
전투 규칙·맵 그래프 알고리즘 미변경 → 미러 동기화 불필요. 기존 테스트 회귀만 확인.
|
||||
|
||||
- [ ] **Step 1:** 기존 테스트 실행:
|
||||
|
||||
```bash
|
||||
node --test tools/balance/sim-balance.test.mjs
|
||||
node --test tools/map/rogue-map.test.mjs
|
||||
```
|
||||
|
||||
기대: 전부 PASS(이번 변경은 전투/맵그래프 무관이라 회귀 없어야 함).
|
||||
|
||||
- [ ] **Step 2:** `git status --short`로 의도치 않은 산출물 변경 없는지 확인(산출물 diff는 보지 않음).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 메이커 플레이테스트 검증
|
||||
|
||||
- [ ] **Step 1:** git 상태 정리 후 메이커에서 **로컬 워크스페이스 refresh**(RULES.md §5 — 안 하면 stale 상태가 디스크 덮어씀). `maker_refresh_workspace` → 빌드 콘솔 0 에러 확인(`maker_logs`).
|
||||
|
||||
- [ ] **Step 2:** `maker_play` → `maker_screenshot`. 검증 시나리오(스크린샷·로그로):
|
||||
1. 월드 시작 → **로비 맵에 스폰**(타운 배경, NPC 4명 보임), 방향키로 **이동됨**, 공격 키로 **공격 모션** 나옴.
|
||||
2. NPC 근접 → 머리 위 `!` 표시 → `↑`키로 기능 패널 오픈. NPC `maker_mouse_input` 클릭으로도 오픈(버튼 클릭 불가 메모리 주의 — 월드 엔티티 TouchEvent라 mouse_input 좌표 클릭 시도, 안 되면 ↑키 경로로 검증).
|
||||
3. 모험가→직업선택→런 시작 → **map01로 텔레포트**, 이동/공격 **잠김**. 1막 전투 몬스터 정상 등장(CurrentMapName 필터 통과).
|
||||
4. 사서→도감, 상인→영혼상점, 안내원→게시판 각각 오픈/닫기.
|
||||
5. 런 종료(빠른 패배 유도: execute_script로 `c.Combat.PlayerHp=0` 등 or 정상 진행) → 4초 후 **로비 맵 복귀**, 이동/공격 재해제.
|
||||
6. 상단 미니 HUD에 영혼/승천 표시 정상.
|
||||
|
||||
- [ ] **Step 2b:** 실패 시 디버깅 — 이동 안 됨→Task0 값 재확認/RigidbodyComponent 추가 set, 클릭 안 됨→TouchReceiveComponent 필드/근접↑키 폴백, 몬스터 안 나옴→StartRun 텔레포트·spawn 좌표 확인. 생성기 수정→재생성→refresh→재플레이.
|
||||
|
||||
- [ ] **Step 3:** `maker_stop`. 스크린샷을 사용자에게 공유.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: PR
|
||||
|
||||
- [ ] **Step 1:** push:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/p15-lobby-map-npc
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** PR spec JSON(UTF-8) 작성 후 `node tools/git/gitea-pr.mjs create <spec.json>` (RULES.md §4 — 인라인 curl 한글 금지). 제목 예: "feat: P15 — 로비 맵 + 월드 NPC(근접·클릭) + 로비 전용 이동·공격". 본문에 변경 요약·검증 결과·스크린샷 언급.
|
||||
|
||||
- [ ] **Step 3:** 사용자에게 PR 번호 보고 + 머지 여부 확인.
|
||||
|
||||
---
|
||||
|
||||
## 정찰 결과 (Task0 실측 완료)
|
||||
- **이동 레버 = `RigidbodyComponent.WalkAcceleration` (freeze가 0으로 만든 값). 복원값 0.7로 이동·점프 정상 확인** (InputSpeed/JumpForce는 무관 — WalkSpeed=1.4·WalkJump=1.23는 freeze가 안 건드림).
|
||||
- 이동 해제 = `pc.Enable=true; pc.FixedLookAt=false; rb.WalkAcceleration=0.7` (rb.Enable는 이미 true).
|
||||
- BODY_KIND = Rigidbody가 구동(Sideviewbody도 존재하나 WalkSpeed=nil). 추가 바디 set 불필요.
|
||||
- ATTACK_KEY = `LeftControl` (KeyboardKey.LeftControl 유효, PlayerAttackMotion() 호출 정상).
|
||||
- 상호작용 키 = `UpArrow` 유효. 클릭 = TouchReceiveComponent+TouchEvent.
|
||||
- 현재 플레이어 위치 map01 (-5,-0.039,0) → LOBBY_SPAWN = `Vector3(-5, 0.03, 0)`. NPC x = -3 / -0.5 / 2 / 4.5, 근접 임계 1.2.
|
||||
- TOWN_BG = Task1에서 gen-maps BACKGROUNDS 풀에서 선택, MARK_RUID = Task1 asset 검색.
|
||||
227
docs/superpowers/plans/2026-06-15-node-map-ui.md
Normal file
227
docs/superpowers/plans/2026-06-15-node-map-ui.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 노드 맵 UI 강화 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. 설계: `docs/superpowers/specs/2026-06-15-node-map-ui-design.md`. 산출물(`ui/DefaultGroup.ui`·`*.codeblock`)은 Read/Edit 금지 — `tools/deck/gen-slaydeck.mjs` 소스·`data/*.json`만 수정 후 재생성. 검증은 `node tools/verify/count.mjs`(카운트)와 메이커 플레이테스트.
|
||||
|
||||
**Goal:** 맵 노드 선택 화면(MapHud)을 단색 박스+텍스트 → 공식 메이플 아이콘 노드 + 배경 이미지로 강화하고, 아이콘/배경 RUID를 `data/nodeicons.json`로 외부화해 교체를 쉽게 한다.
|
||||
|
||||
**Architecture:** 단일 소스(`data/nodeicons.json` + `tools/deck/gen-slaydeck.mjs`) → 산출물 재생성. 노드 = 아이콘 스프라이트(타입별 ImageRUID 런타임 주입, 상태는 Color 틴트), 배경 = MapHud 루트 이미지 + 반투명 오버레이. 절차 랜덤 배치·간선·버튼 바인딩 불변.
|
||||
|
||||
**Tech Stack:** Node.js ESM 생성기, MSW Lua(codeblock).
|
||||
|
||||
**확정 RUID** (공식 maplestory, 썸네일 검수): combat=`f98db6823e894a4f90308d61f75894ac`, elite=`793ed8a757534b89a82f460747d2df24`, boss=`423056cdbbc04f4da131b9721c404d96`, shop=`da37e1fac55d455b9ade08569f09f798`, rest=`b86c1b0568bd45f3ae4a4b97e1b4a594`, treasure=`f8a6d58e20f54e2ca899485055df1ce4`, background=`d84241f17de344a097f5b96ac914f1d2`.
|
||||
|
||||
**현재 코드 기준선**(gen-slaydeck.mjs): MapHud emit `1662~1763`(루트 `1664`, pushMapNode `1696`, 그리드 `1727`, 도트 displayOrder 1), RenderMapNode `5615~5677`, luaFramesTable `72`, OnBeginPlay 주입 `2906`, StartRun 주입 `3361`, CardFrames prop `2854`, CHEST 상수 `84`, sprite 헬퍼 `297`(dataId→ImageRUID, type 0=이미지).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `data/nodeicons.json` + 생성기 로드·검증·직렬화
|
||||
|
||||
**Files:** Create `data/nodeicons.json` · Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** `data/nodeicons.json` 생성:
|
||||
|
||||
```json
|
||||
{
|
||||
"icons": {
|
||||
"combat": "f98db6823e894a4f90308d61f75894ac",
|
||||
"elite": "793ed8a757534b89a82f460747d2df24",
|
||||
"boss": "423056cdbbc04f4da131b9721c404d96",
|
||||
"shop": "da37e1fac55d455b9ade08569f09f798",
|
||||
"rest": "b86c1b0568bd45f3ae4a4b97e1b4a594",
|
||||
"treasure": "f8a6d58e20f54e2ca899485055df1ce4"
|
||||
},
|
||||
"background": "d84241f17de344a097f5b96ac914f1d2"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `gen-slaydeck.mjs` CHEST 상수(`85`) 아래에 로드+검증 추가:
|
||||
|
||||
```js
|
||||
// 노드 맵 아이콘/배경 (공식 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 누락/형식오류');
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** `luaFramesTable`(`77`) 직후에 직렬화 헬퍼 추가:
|
||||
|
||||
```js
|
||||
function luaNodeIconsTable() {
|
||||
const rows = Object.entries(NODEICONS.icons).map(([t, ruid]) => `\t${t} = ${luaStr(ruid)},`).join('\n');
|
||||
return `self.NodeIcons = {\n${rows}\n}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** prop 선언 추가 — `prop('any', 'CardFrames'),`(`2854`) 아래에 `prop('any', 'NodeIcons'),`.
|
||||
|
||||
- [ ] **Step 5:** OnBeginPlay 주입 — `2906`의 `${luaFramesTable()}` 줄 **아래**에 `${luaNodeIconsTable()}` 추가. StartRun 주입(`3361`)의 `${luaFramesTable()}` 아래에도 동일 추가.
|
||||
|
||||
- [ ] **Step 6:** 로드 검증(아직 산출물 미변경이라 생성만 확인):
|
||||
|
||||
```bash
|
||||
node -e "const n=require('./data/nodeicons.json'); console.log('icons',Object.keys(n.icons).join(','),'| bg',n.background.length)"
|
||||
```
|
||||
기대: `icons combat,elite,boss,shop,rest,treasure | bg 32`
|
||||
|
||||
- [ ] **Step 7:** 커밋:
|
||||
|
||||
```bash
|
||||
git add data/nodeicons.json tools/deck/gen-slaydeck.mjs
|
||||
git commit -m "feat(node-map): nodeicons.json 외부화 + 생성기 로드·검증·NodeIcons 직렬화"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MapHud emit — 배경 이미지 + 오버레이 + 아이콘 노드
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** MapHud 루트 sprite(`1673`)를 **배경 이미지**로 변경:
|
||||
|
||||
```js
|
||||
sprite({ dataId: NODEICONS.background, color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: false }),
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** 루트 push(`1677` `map.push(mapHud);`) 직후, Title push 앞에 **반투명 오버레이 자식** 추가:
|
||||
|
||||
```js
|
||||
map.push(entity({
|
||||
id: guid('map', 990),
|
||||
path: '/ui/DefaultGroup/MapHud/Overlay',
|
||||
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: { r: 0.04, g: 0.05, b: 0.09, a: 0.5 }, type: 1, raycast: true }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
(guid 'map',990 은 노드 그리드·도트가 쓰는 mapN(2~약189)보다 충분히 높아 충돌 없음. 빌드 끝 id 유일성 검증이 잡아줌.)
|
||||
|
||||
- [ ] **Step 3:** Title displayOrder를 오버레이(0) 위로 — Title 엔티티(`1684` `displayOrder: 0,`)를 `displayOrder: 2,`로 변경.
|
||||
|
||||
- [ ] **Step 4:** `pushMapNode`(`1696~1726`) — 노드 본체를 **아이콘**으로 + Label 자식 제거:
|
||||
- 본체 sprite(`1707`)를 `sprite({ color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),`로 변경(단색 박스 → 이미지, 런타임에 ImageRUID 주입).
|
||||
- Label 자식 push 블록(`1713~1725`, `map.push(entity({ ... /Label ... }))` 전체)을 **삭제**.
|
||||
|
||||
- [ ] **Step 5:** 노드 크기 키움 — 그리드 호출(`1729`)의 `{ x: 56, y: 56 }`을 `{ x: 64, y: 64 }`로, 보스 호출(`1732`)의 `{ x: 72, y: 72 }`을 `{ x: 88, y: 88 }`로 변경.
|
||||
|
||||
- [ ] **Step 6:** 커밋(아직 RenderMapNode 미수정 — 다음 Task와 함께 재생성/검증):
|
||||
|
||||
```bash
|
||||
git add tools/deck/gen-slaydeck.mjs
|
||||
git commit -m "feat(node-map): MapHud 배경 이미지+오버레이, 노드 아이콘화(라벨 제거·확대)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RenderMapNode Lua — ImageRUID + 상태 틴트
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1:** `RenderMapNode` 메서드 본문(`5615~5677`)을 아래로 **교체**(타입별 박스색/라벨 → 아이콘 ImageRUID + 상태 틴트). Lua 들여쓰기는 기존과 동일하게 실제 탭:
|
||||
|
||||
```lua
|
||||
local base = "/ui/DefaultGroup/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.4, 0.4, 0.45, 0.45)
|
||||
end
|
||||
end
|
||||
if e.ButtonComponent ~= nil then
|
||||
e.ButtonComponent.Enable = reachable
|
||||
end
|
||||
```
|
||||
(메서드 시그니처 `[{Type:'string',...,Name:'id'}]`는 유지. `self:SetText(base.."/Label", ...)` 호출은 라벨 제거로 사라짐 — RenderMapDots/RenderMap는 불변.)
|
||||
|
||||
- [ ] **Step 2:** 재생성:
|
||||
|
||||
```bash
|
||||
node tools/deck/gen-slaydeck.mjs
|
||||
```
|
||||
기대: "Slay deck UI and combat codeblocks generated."
|
||||
|
||||
- [ ] **Step 3:** 카운트 검증(내용 출력 금지, node fs):
|
||||
|
||||
```bash
|
||||
node -e "const fs=require('fs');const cb=fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8');const ui=fs.readFileSync('ui/DefaultGroup.ui','utf8');const c=(s,p)=>(s.match(new RegExp(p,'g'))||[]).length;console.log('NodeIcons inject:',c(cb,'self.NodeIcons ='),'(>=2: OnBeginPlay+StartRun)','| ImageRUID in RenderMapNode:',c(cb,'NodeIcons\\\\[node.type\\\\]'),'| UI MapHud/Overlay:',c(ui,'MapHud/Overlay'),'(1)','| UI Label nodes(0 기대):',c(ui,'Node_r1c1/Label'),'| bg RUID:',c(ui,'d84241f17de344a097f5b96ac914f1d2'));"
|
||||
```
|
||||
기대: NodeIcons inject ≥2, ImageRUID ≥1, Overlay 1, Label 0, bg RUID ≥1.
|
||||
|
||||
- [ ] **Step 4:** 커밋:
|
||||
|
||||
```bash
|
||||
git add tools/deck/gen-slaydeck.mjs ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
git commit -m "feat(node-map): RenderMapNode 아이콘 ImageRUID+상태 틴트, 재생성"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 미러/회귀 테스트
|
||||
|
||||
- [ ] **Step 1:** 전투/맵그래프 미러 미변경 확인 — 테스트 실행:
|
||||
|
||||
```bash
|
||||
node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
|
||||
```
|
||||
기대: 전부 PASS(이 변경은 UI만, 전투/맵그래프 무관).
|
||||
|
||||
- [ ] **Step 2:** `git status --short`로 의도치 않은 산출물 변경 없는지 확인.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 메이커 플레이테스트
|
||||
|
||||
- [ ] **Step 1:** `maker_refresh_workspace` → `maker_logs build`로 빌드 에러 0 확인(기존 BuySoulUnlock Info 경고는 무관).
|
||||
|
||||
- [ ] **Step 2:** `maker_play` → 런 시작(`SelectClass`+`StartNewGame`) → 맵 화면 `maker_screenshot`. 검증:
|
||||
- 배경 이미지(리스항구) + 어두운 오버레이 위에 노드들.
|
||||
- 노드가 **타입별 아이콘**(주황버섯/골렘/발록/돈주머니/모닥불/상자)으로 표시, 라벨 텍스트 없음.
|
||||
- 상태 틴트: 현재=금색, 도달가능=원색(밝게), 잠김=어둡고 흐릿.
|
||||
- 도달 가능 노드 클릭 시 진행(`PickNode`/마우스). 랜덤 배치 정상.
|
||||
- 아이콘 잘림/왜곡 점검(특히 보스 발록·골렘). 잘리면 해당 노드 size 또는 아이콘 RUID 조정.
|
||||
|
||||
- [ ] **Step 2b:** 실패 시 디버깅 — 흰박스→RUID/리로드 확인, 아이콘 안 뜸→ImageRUID 주입·NodeIcons 시드 확인, 가독성→오버레이 알파/틴트 튜닝. 생성기 수정→재생성→refresh→재플레이.
|
||||
|
||||
- [ ] **Step 3:** `maker_stop`. 스크린샷 사용자 공유.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: PR
|
||||
|
||||
- [ ] **Step 1:** `git push -u origin feature/node-map-ui`(인증 실패 시 `GCM_INTERACTIVE=never GIT_TERMINAL_PROMPT=0 git push`로 재시도).
|
||||
- [ ] **Step 2:** UTF-8 spec JSON 작성 후 `node tools/git/gitea-pr.mjs create <spec.json>`. 제목 "feat: 노드 맵 UI 강화 — 아이콘 노드 + 배경 이미지(nodeicons.json 외부화)".
|
||||
- [ ] **Step 3:** 사용자에게 PR 번호 보고. (변경 용이성: `data/nodeicons.json` RUID만 바꾸고 `node tools/deck/gen-slaydeck.mjs` 재실행하면 교체됨을 명시.)
|
||||
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 필수(산출물 디스크 반영).
|
||||
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)로 순환 없음.
|
||||
46
docs/superpowers/specs/2026-06-11-act-maps-design.md
Normal file
46
docs/superpowers/specs/2026-06-11-act-maps-design.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 막별 맵 전환 + 맵별 인카운터 (P4) — 설계
|
||||
|
||||
- 날짜: 2026-06-11
|
||||
- 상태: 승인됨(사용자 사전 위임). 로드맵 P4/5.
|
||||
|
||||
## 1. 배경/목표
|
||||
|
||||
map02~11은 SectorConfig에 등록만 되고 게임에서 미사용(모든 전투가 map01). 막(act)이 바뀌어도 같은 맵·같은 몬스터.
|
||||
**목표**: ① 막별로 다른 물리 맵 사용(맵 차별화가 실제 게임에 보이도록) ② 각 맵에 노드 타입별 몬스터 그룹(combat/elite/boss)을 맵별 테마로 자동 구성.
|
||||
|
||||
## 2. 설계
|
||||
|
||||
### 2.1 막→맵 매핑 + 텔레포트
|
||||
- `ACT_MAPS = ['map01','map02','map03']`(ACT_COUNT=3과 일치, 생성기 상수→Lua 주입).
|
||||
- `CheckCombatEnd` 보스 클리어(다음 막 진행) 분기에서 `Floor` 증가 후 `self:TeleportToActMap()`:
|
||||
- `_TeleportService:TeleportToMapPosition(_UserService.LocalPlayer, Vector3(-6, 0.03, 0), ACT_MAPS[self.Floor])` (UILogic 공식 예제의 API; 위치는 map01 플레이어 시작권 좌측 지면 — 메이커 검증으로 조정).
|
||||
- 새 맵 로드 시 그 맵 몬스터들의 `CombatMonster.OnBeginPlay`가 자기등록(기존 0.1s×50 재시도 — 텔레포트 직후는 맵 화면이라 전투 진입 전 등록 여유 충분).
|
||||
|
||||
### 2.2 등록 풀의 맵 필터 (크로스맵 오염 방지)
|
||||
- 텔레포트 후 구 맵 몬스터가 언로드되지 않고 등록 풀에 남을 가능성 대비:
|
||||
- `RegisterMonster(entity, enemyId, group, mapName)` — CombatMonster가 자기 소속 맵 이름을 전달(`self.Entity.CurrentMapName` 우선, nil이면 부모 체인에서 `/maps/` 직계 자식 이름; 구현 검증).
|
||||
- `BuildMonsters`: `local pmap = _UserService.LocalPlayer.CurrentMapName` — `r.map == pmap`인 등록만 사용(+기존 isvalid·group 필터).
|
||||
|
||||
### 2.3 맵별 인카운터 자동 구성 (`tools/map/gen-map-encounters.mjs` 신규)
|
||||
- 대상: map02~map11 (map01은 사용자 저작 유지).
|
||||
- 각 맵: 기존 `script.Monster` 엔티티 전부 제거 → 그 맵의 첫 몬스터 엔티티를 템플릿으로 6마리 생성:
|
||||
| Group | 수 | x 위치 | EnemyId(맵 번호 순환) |
|
||||
|---|---|---|---|
|
||||
| combat | 3 | 2.3 / 3.8 / 5.2 | orange_mushroom·green_mushroom·pig·blue_mushroom 풀에서 3종 |
|
||||
| elite | 2 | 3.0 / 5.0 | mushmom·modified_snail 중 |
|
||||
| boss | 1 | 4.0 | king_slime·slime_boss 중 |
|
||||
- 외형: gen-maps의 `MONSTER_VARIANTS`(공식 수확 9종 sprite/stand/hit/die) 풀에서 맵 시드(`nn*7919`) 결정론 선택(맵마다 다른 조합) — SpriteRenderer/StateAnimation 덮어쓰기.
|
||||
- `script.CombatMonster` Group/EnemyId 태그 포함, GUID 결정론(`mapGuid` 패턴), idempotent(전체 교체 방식이라 재실행 동일).
|
||||
- enemies.json 변경 없음(기존 8타입 재사용 — 스탯 일관).
|
||||
|
||||
### 2.4 비범위
|
||||
- 4막+ / 맵별 배경·노드 그래프 차별화(이미 배경·타일은 맵별 상이), 이벤트 노드(P5).
|
||||
|
||||
## 3. 검증
|
||||
- 생성기 결정론(2회 동일), 각 맵 그룹 구성 JSON 검사(3/2/1·EnemyId·변형 다양성).
|
||||
- 메이커: 1막 보스 처치→Floor 2→**map02 텔레포트**(카메라/PlayerLock은 전 맵 부착됨)→맵 화면→전투 진입 시 map02 몬스터(combat 3, 새 외형)만 등장·구 맵 미오염→엘리트/보스 노드도 그룹 정상.
|
||||
|
||||
## 4. 리스크
|
||||
- `Entity.CurrentMapName`/플레이어 CurrentMapName 형식("map02"?) — 구현 시 메이커 확인, 불일치 시 경로 기반 폴백.
|
||||
- 텔레포트 직후 카메라/입력 재설정(MapCamera·PlayerLock OnBeginPlay가 맵 로드마다 도는지) — 검증.
|
||||
- 구 맵 몬스터 isvalid 동작 — 맵 필터가 1차 방어라 비차단.
|
||||
77
docs/superpowers/specs/2026-06-11-card-visuals-design.md
Normal file
77
docs/superpowers/specs/2026-06-11-card-visuals-design.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 메이플 스킬 카드 비주얼 (P2) — 설계
|
||||
|
||||
- 날짜: 2026-06-11
|
||||
- 대상: `data/cards.json`, `tools/deck/gen-slaydeck.mjs`(카드 엔티티 구조·ApplyCardVisual류), 생성물
|
||||
- 상태: 승인됨 (배포 퀄리티 로드맵 P2/5 — P1 UI 정비 머지됨 #34)
|
||||
|
||||
## 1. 배경 / 타당성 (검증 완료)
|
||||
|
||||
카드가 단색 사각형+텍스트(코스트/이름/설명)뿐이라 게임 정체성이 약하다. 메이플 스킬 이미지를 카드에 넣는다.
|
||||
|
||||
**타당성 검증 완료(메이커 실측)**: `asset_search_resources`(source=maplestory, cat=sprite)로 "파워 스트라이크" 검색 → 공식 RUID 10+개. 그중 `37ed94ffd1a64a22ad91a6ae14774718`를 Play 중 `SpriteGUIRendererComponent.ImageRUID`(Type=0)에 주입 → **로컬 워크스페이스에서 정상 렌더 확인**(흰 박스 아님 — 흰 박스 문제는 클라우드 '계정' 리소스에만 해당, 공식 리소스는 OK).
|
||||
주의: 검색 결과는 아이콘이 아니라 **스킬 이펙트 컷**일 수 있음 → 후보 중 선별 단계 필요.
|
||||
|
||||
## 2. 카드 → 메이플 스킬 매핑 (데이터)
|
||||
|
||||
`data/cards.json`의 각 카드에 선택 필드 추가:
|
||||
- `image`: 공식 스프라이트 RUID(string). 없으면 현행 단색 폴백.
|
||||
- 카드 `name`을 메이플 스킬명으로 변경(효과·코스트·밸런스 불변):
|
||||
|
||||
| 카드 id | 기존 이름 | 새 이름(전사 스킬) | 효과 |
|
||||
|---|---|---|---|
|
||||
| Strike | 타격 | 파워 스트라이크 | 피해 6 |
|
||||
| Bash | 강타 | 슬래시 블러스트 | 피해 10 |
|
||||
| Defend | 방어 | 아이언 바디 | 방어도 5 |
|
||||
|
||||
(id는 기존 유지 — RunDeck/starterDeck 호환. 표시명만 변경.)
|
||||
|
||||
## 3. RUID 수확 워크플로 (구현 선행 태스크)
|
||||
|
||||
1. `asset_search_resources`(source=maplestory)로 스킬별 후보 수집: "파워 스트라이크", "슬래시 블러스트", "아이언 바디" (+필요시 "워리어", "스킬" 등 보조 질의).
|
||||
2. 메이커 Play에서 후보 RUID를 카드 Art에 순회 주입 + 스크린샷으로 선별(§1에서 검증한 방법). 카드 일러스트로 보기 좋은 컷 1개/스킬 확정.
|
||||
3. 확정 RUID를 `data/cards.json`에 기록. (RUID 문자열만 저장 — 공식 콘텐츠 정책 기존 관행과 동일)
|
||||
4. 적합 컷이 없으면 폴백: 그 카드들은 이펙트 컷 중 베스트, 그래도 없으면 image 필드 생략(단색 유지).
|
||||
|
||||
## 4. 카드 프레임 구조 (upsertUi — 손패 Card1~5 기준)
|
||||
|
||||
기존: 루트(단색 패널) + Cost/Name/Desc 텍스트 3개.
|
||||
변경(카드 180×250 기준):
|
||||
```
|
||||
Card{i} (루트: 종류색 패널 — 테두리 역할, 기존 ATTACK/DEFEND/SKILL 색 유지)
|
||||
├─ Art 96×96 중앙상단(pos 0, 52): ImageRUID, Type=0, 흰색
|
||||
├─ NamePlate 168×34 (pos 0, -8): 어두운 띠 {0.07,0.08,0.1,0.92}
|
||||
│ └─ (Name 텍스트를 NamePlate 위치로 이동, fontSize 20)
|
||||
├─ Cost 44×44 좌상(pos -68, 103): 어두운 원판 패널 + 숫자 26 (기존 위치 강조형)
|
||||
└─ Desc (pos 0, -62, fontSize 18) 하단 효과 텍스트
|
||||
```
|
||||
- 구현은 기존 자식 텍스트(Cost/Name/Desc) 위치·크기 조정 + 신규 Art/NamePlate/CostPlate 스프라이트 추가.
|
||||
- 동일 구조를 **RewardHud/Reward{1~3}**, **ShopHud/Card{1~3}**, **DeckInspectHud/Grid/Card{n}**, **DeckAllHud/Grid/Card{n}** 카드에도 적용(폭이 다른 그리드 카드(158×214)는 비례 축소 좌표).
|
||||
|
||||
## 5. 런타임 렌더 일원화
|
||||
|
||||
- 신규 헬퍼 `ApplyCardFace(basePath, cardId)`(Lua): Cards[cardId]에서 name/cost/desc/kind/image를 읽어 — 루트 색(kind), Art ImageRUID(있으면 표시·없으면 Art 숨김), Name/Cost/Desc 텍스트 설정.
|
||||
- 기존 `ApplyCardVisual`(손패)·`ApplyRewardVisual`(보상)·상점 렌더(`RenderShop` 내 카드부)·`ApplyInspectCardVisual`(인스펙터)·모든덱 렌더가 **ApplyCardFace를 호출**하도록 통일(경로만 다름).
|
||||
- Lua 카드 테이블(`luaCardsTable`)에 `image` 필드 직렬화 추가(없으면 생략).
|
||||
|
||||
## 6. 변경 파일
|
||||
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `data/cards.json` | name 3종 변경 + image RUID 3종 추가 |
|
||||
| `tools/deck/gen-slaydeck.mjs` | luaCardsTable image, 카드 엔티티 프레임(5표면), ApplyCardFace + 호출부 통일 |
|
||||
| 생성물 | `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` 재생성 |
|
||||
| `tools/balance/sim-balance.mjs` | 불변(이름 표시는 data에서 읽음 — 리포트에 새 이름 자동 반영) |
|
||||
|
||||
## 7. 범위 제외 (후속)
|
||||
|
||||
의도 아이콘(P3), 카드 호버 확대·사용 연출(P3), 희귀도 프레임 색(P5), 도적/마법사 카드(후속 콘텐츠).
|
||||
|
||||
## 8. 검증
|
||||
|
||||
- 생성 결정성·dup id 0·sim 14/14(이름 변경이 테스트에 영향 없는지 확인 — 테스트는 자체 fixture 사용이라 무관).
|
||||
- 메이커: 손패/보상/상점/덱보기 4표면 스크린샷 — 스킬 이미지·프레임·이름 일관 표시, image 누락 카드 폴백 정상.
|
||||
|
||||
## 9. 리스크
|
||||
|
||||
- 후보 RUID가 멀티프레임 애니 시트일 수 있음 → SpriteGUIRenderer가 첫 프레임 표시(기존 옵션 FrameColumn/Row 1) — 선별 단계에서 확인.
|
||||
- 그리드 카드(인스펙터/모든덱)는 ScrollLayoutGroup 셀 — 자식 추가가 셀 레이아웃에 영향 없는지 메이커 확인.
|
||||
63
docs/superpowers/specs/2026-06-11-combat-feel-design.md
Normal file
63
docs/superpowers/specs/2026-06-11-combat-feel-design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 전투 연출 (P3) — 설계
|
||||
|
||||
- 날짜: 2026-06-11
|
||||
- 대상: `tools/deck/gen-slaydeck.mjs`(카드 드래그·연출·적 턴 시퀀스), 생성물
|
||||
- 상태: 승인됨(사용자 사전 위임 — P2~P5 일괄 진행 지시). 로드맵 P3/5.
|
||||
|
||||
## 1. 목표 (사용자 요구)
|
||||
|
||||
1. **카드 드래그→몬스터 지정**: 카드를 끌어 특정 몬스터에 놓아 사용(클릭 대체).
|
||||
2. **공격 모션 후 데미지**: 공격 카드 사용 시 연출(스킬 이펙트) 후 몬스터가 피해.
|
||||
3. **몬스터 개별 차례**: 적 턴에 몬스터가 한 마리씩 순서대로 행동(행동자 표시).
|
||||
4. 데미지 숫자 표시·사망 연출 등 게임필 보강.
|
||||
|
||||
## 2. 타당성 (probe 완료)
|
||||
|
||||
- `maker_mouse_input` down → `ScreenTouchEvent` 발화 + `ScreenToUIPosition` 변환 실측 일치(카드2 위치).
|
||||
- **`MOD.Core.UITouchReceiveComponent`**: UI 엔티티에 부착 시 `UITouchBeginDragEvent`/`UITouchDragEvent`/`UITouchEndDragEvent`/`UITouchDownEvent`/`UITouchUpEvent` 제공(공식, Client). 드래그는 이걸 사용.
|
||||
- 몬스터 world→screen(`_UILogic:WorldToScreenPosition`)은 P1에서 검증됨 — 드롭 판정에 재사용.
|
||||
|
||||
## 3. 설계
|
||||
|
||||
### 3.1 카드 드래그 타겟팅
|
||||
- 손패 Card1~5 엔티티에 `MOD.Core.UITouchReceiveComponent` 추가(생성기 componentNames+컴포넌트).
|
||||
- 컨트롤러 상태: `DragSlot`(0=없음), `DragOrigin`(원위치 Vector2), `DragMoved`(boolean).
|
||||
- `BindButtons`에서 카드별로 connect:
|
||||
- `UITouchBeginDragEvent` → CombatOver 아니고 손패에 카드 있으면 `DragSlot=i`, 원위치 저장(`CARD_XS[i]` 상수로 복원 가능하므로 저장은 단순화 가능 — 원위치 = (CARD_XS[i], 0)).
|
||||
- `UITouchDragEvent` → 카드 `anchoredPosition = ScreenToUIPosition(TouchPoint) - CardHandOffset` (CardHand 부모 중심의 UI 좌표 보정값은 런타임 계산: 카드 부모 CardHand의 화면상 중심 = UI(0, -360) → 보정 상수로 굽기).
|
||||
- `UITouchEndDragEvent` → `ResolveCardDrop(i, TouchPoint)` 후 카드 위치 복원.
|
||||
- `ResolveCardDrop(slot, screenPoint)`:
|
||||
- 카드 kind 조회. **Attack**: 생존 몬스터 중 화면 거리(몬스터 world→screen vs screenPoint) 최소이고 임계(예: 200px) 이내인 몬스터 → `SetTarget(그 몬스터)` 후 `PlayCard(slot)`. 임계 밖이면 취소(복귀만).
|
||||
- **Skill**: 드롭 위치가 손패 위(화면 y 기준 카드 영역 위쪽, 예: screen y > 화면 40%)면 `PlayCard(slot)`, 아니면 취소.
|
||||
- 기존 카드 ButtonComponent 클릭 `PlayCard` 바인딩 **제거**(드래그와 충돌 방지, 사용은 드래그로 일원화 — STS 방식). 몬스터 슬롯 클릭 SetTarget은 유지(타겟만 바꾸는 보조 수단).
|
||||
|
||||
### 3.2 공격 연출 → 데미지 (PlayCard Attack 시퀀스)
|
||||
- `CombatHud/SkillFx` 엔티티 1개(96×96 이미지 스프라이트, 평소 숨김).
|
||||
- PlayCard(Attack) 흐름 변경: 에너지 차감·손패 제거·렌더는 즉시, **데미지는 지연**:
|
||||
1. `ShowSkillFx(targetIndex, c.image)`: 타겟 몬스터 world→screen 위치에 SkillFx 표시(ImageRUID=카드 이미지).
|
||||
2. 0.35s 타이머 → SkillFx 숨김 + `DealDamageToTarget(damage)` + 데미지 팝업 + RenderCombat + CheckCombatEnd.
|
||||
- 연출 중 입력 보호: `FxBusy=true` 동안 PlayCard/EndPlayerTurn 무시(0.35s).
|
||||
|
||||
### 3.3 데미지 숫자 팝업
|
||||
- `MonsterSlot{i}/DmgPop`(텍스트, 숨김 기본): `ShowDmgPop(slot, amount)` — "-N" 표시 → 0.6s 후 숨김(타이머; 위치 고정 단순화).
|
||||
- `PlayerPanel/DmgPop` 동일(적 공격 시 "-N", 방어 흡수로 0이면 "막음").
|
||||
|
||||
### 3.4 적 개별 차례 (EnemyTurn 시퀀스화)
|
||||
- `EnemyTurn` → 비동기 체인으로 재작성:
|
||||
- `EnemyActIndex=0`; `EnemyActStep()`: 다음 생존 몬스터 찾기 → 없으면 `FinishEnemyTurn()`.
|
||||
- 행동 몬스터 슬롯에 `ActFrame`(적색 하이라이트 — TargetFrame과 별도 자식, 156×108 적색 a0.3) 표시 → 0.45s 타이머 → 의도 적용(Attack: DealDamageToPlayer+플레이어 DmgPop / Defend: block+슬롯 의도 갱신) → ActFrame 숨김 → 다음 `EnemyActStep()` (0.15s 간격).
|
||||
- 플레이어 사망 시 즉시 `FinishEnemyTurn()`.
|
||||
- `FinishEnemyTurn()`: `CheckCombatEnd` 후 미종료면 0.45s 뒤 `StartPlayerTurn`(기존 EndPlayerTurn 후반부 이동).
|
||||
- `EndPlayerTurn`: 손패 버림+렌더 후 `EnemyTurn()` 호출로 종료(후속 로직은 FinishEnemyTurn으로 이동). `TurnBusy=true`로 적 턴 중 입력 차단(FxBusy와 함께 가드).
|
||||
|
||||
### 3.5 사망 연출
|
||||
- `KillMonster`: 즉시 SetVisible(false) → **0.4s 지연**으로 변경(DmgPop과 겹쳐 보이게), 슬롯 비활성은 즉시 유지.
|
||||
|
||||
## 4. 검증
|
||||
- 생성 결정성·dup 0·sim 14/14(규칙 불변 — 연출 지연만 추가, 데미지 계산 동일).
|
||||
- 메이커: ①카드를 몬스터2에 드래그→타겟 변경+이펙트→데미지 팝업→HP 감소 ②Skill 카드 위로 드래그→방어 ③드롭 취소(빈 곳) ④턴 종료→적들이 한 마리씩 순차 행동(ActFrame 이동)+플레이어 팝업 ⑤전체 처치 승리 정상.
|
||||
|
||||
## 5. 리스크
|
||||
- UITouchReceiveComponent와 ButtonComponent 공존(슬롯 클릭/드래그 간섭) — 카드에서 Button 제거하므로 카드는 안전; 몬스터 슬롯은 Button 유지(드래그 없음).
|
||||
- UITouchDragEvent 빈도/좌표계 — 구현 후 메이커 검증(§4①). 드래그 좌표 보정 상수는 실측 튜닝.
|
||||
- 비동기 체인 중 상태 변화(연출 중 사망 등) — FxBusy/TurnBusy 가드 + 각 스텝에서 alive/CombatOver 재확인.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 전투 화면 UI/HUD 전면 정비 (P1) — 설계
|
||||
|
||||
- 날짜: 2026-06-11
|
||||
- 대상: `tools/deck/gen-slaydeck.mjs`(UI 좌표·신규 엔티티·SlayDeckController 표시 로직), 생성물(`ui/DefaultGroup.ui`·`SlayDeckController.codeblock`)
|
||||
- 상태: 승인됨 (배포 퀄리티 로드맵 P1/5)
|
||||
- 로드맵: P1 UI 정비(본 문서) → P2 카드 비주얼(메이플 스킬) → P3 전투 연출(드래그 타겟·개별 턴·공격 모션) → P4 맵 차별화 → P5 시스템 갭
|
||||
|
||||
## 1. 배경 / 문제
|
||||
|
||||
전투 화면 HUD가 기능별로 따로 추가되며 겹침·산만함 발생. 정량 확인된 문제:
|
||||
- `DeckHud/EndTurnButton`(y106~164) ↔ `DeckHud/Energy`(y69~111) **5px 겹침** (둘 다 중앙 상단)
|
||||
- `DeckHud/AllDeckButton`(모든덱보기, x376~564·y106~164)이 버린덱(x524~656·y-85~101)과 5px 간격으로 답답
|
||||
- 막/골드가 화면 모서리(±820, 480) — MSW 시스템 크롬(좌상 채팅, 우상 메뉴)과 시각 충돌
|
||||
- 플레이어 HP/방어가 좌하단 텍스트 2줄(패널감 없음)
|
||||
- 타겟 표시가 의도 텍스트의 `[타겟]` 프리픽스뿐, 몬스터 슬롯 이름/HP 가독성 낮음
|
||||
- 메뉴/캐릭터선택 화면 뒤로 전투 HUD가 비치는 흐름 버그(가시성 호출 산발)
|
||||
|
||||
## 2. 목표
|
||||
|
||||
STS2 스타일 배치로 전투 화면을 재구성해 겹침 0·시각 위계 확립. 기능 변경 없음(레이아웃·시각·표시 로직만).
|
||||
|
||||
## 3. 설계
|
||||
|
||||
### 3.1 하단 HUD 재배치 (DeckHud, parent 1280×330)
|
||||
| 요소 | 현재 | 변경 |
|
||||
|---|---|---|
|
||||
| 에너지 | 중앙 (0,90) 텍스트 | **좌측 오브 패널** (-560,40) 96×96 어두운 원형 패널 + "3/3" 대형(36) + "에너지" 소라벨 |
|
||||
| 턴 종료 | 중앙 (0,135) 170×58 | **우측 대형 버튼** (560,40) 200×64, fontSize 28 |
|
||||
| 뽑을덱 | (-590,8) | 유지, 라벨 위치 정리 |
|
||||
| 버린덱 | (590,8) | 유지 |
|
||||
| 모든덱보기 | (470,135) | **상단 바로 이동** (§3.2) |
|
||||
- 에너지 오브(-560±48=-608~-512)와 뽑을덱(-590±66=-656~-524)은 y로 분리: 뽑을덱 y8±93=-85~101, 오브 y40±48=-8~88 → x 겹침 구간에서 y도 겹침 → **오브를 (-560, 130)으로** 배치(뽑을덱 위). 같은 식으로 턴종료 (560, 130)(버린덱 위). 카드(CardHand y180 중심, 카드 h250 → y55~305)와 x 비겹침(카드 x -500~500, 오브/버튼 x>±512).
|
||||
|
||||
### 3.2 상단 HUD 바 (CombatHud 신규 `TopBar`)
|
||||
- 반투명 패널 1200×52, (0, 486). 자식: `Floor`(좌, x -540), `Gold`(좌, x -380), `Relics`(중앙, 폭 560), `AllDeckButton`(우, x 520, 150×40).
|
||||
- 기존 CombatHud의 Floor(±820,480)·Gold·Relics(0,430) 엔티티를 TopBar 자식으로 대체(기존 경로 제거, 컨트롤러 SetText 경로 갱신).
|
||||
- AllDeckButton은 DeckHud에서 TopBar로 이동(바인딩 경로 갱신).
|
||||
|
||||
### 3.3 플레이어 패널 (CombatHud `PlayerPanel`, 좌하)
|
||||
- 패널 300×96, (-760, -480) [화면 좌하단, DeckHud 영역 밖]. 자식: 이름라벨("플레이어"), HP바(HpBarBg/HpBarFill, 폭 220 — `SetHpBar` 재사용, 폭 파라미터화), HP 텍스트("71/80"), 방어 뱃지(56×40 청색 패널+숫자, 방어 0이면 숨김).
|
||||
- 기존 PlayerHp/PlayerBlock 텍스트 엔티티 제거, RenderCombat 갱신.
|
||||
- ⚠️ SetHpBar가 현재 HP_BAR_W=120 고정 → `SetHpBar(path, hp, maxHp, width)`로 폭 인자 추가(몬스터 120/140, 플레이어 220).
|
||||
|
||||
### 3.4 몬스터 슬롯 가독성 + 타겟 프레임
|
||||
- `MonsterSlot{i}`에 `TargetFrame` 자식 추가: 슬롯보다 약간 큰 **단일 반투명 골드 패널**(156×108, displayOrder 최하 — 슬롯 내용 뒤 배경 하이라이트). RenderCombat에서 `i==TargetIndex`인 슬롯만 enable.
|
||||
- 의도 텍스트에서 `[타겟] ` 프리픽스 제거.
|
||||
- 이름 fontSize 20→22, Hp 18→20, HP바 폭 120→140(HpBarBg/Fill·SetHpBar 호출 일치), Intent 색상: kind=Attack→(1,0.45,0.35), Defend→(0.5,0.75,1).
|
||||
|
||||
### 3.5 HUD 가시성 상태 통일 (`ShowState`)
|
||||
- 컨트롤러에 `ShowState(state)` 단일 메서드: state별 HUD on/off 표.
|
||||
|
||||
| state | 켜짐 |
|
||||
|---|---|
|
||||
| `menu` | MainMenu |
|
||||
| `charselect` | CharacterSelectHud |
|
||||
| `map` | MapHud만 (층/골드는 MapHud 진입 전 RenderRun으로 갱신된 TopBar가 꺼져도 무방 — 맵 화면 자체 표시는 현행 유지) |
|
||||
| `combat` | CombatHud, DeckHud, CardHand |
|
||||
| `reward` | RewardHud (+CombatHud 유지) |
|
||||
| `shop` / `rest` | ShopHud / RestHud |
|
||||
- `OnBeginPlay` 시작 시 전 HUD off → `ShowState("menu")`. 기존 산발적 `SetEntityEnabled` 호출을 ShowState 호출로 치환(ShowMainMenu/StartNewGame/ShowMap/PickNode/StartCombat/OfferReward/PickReward/ShowShop/ShowRest/LeaveNode/CheckCombatEnd).
|
||||
- 흐름 버그(메뉴 뒤 카드 비침)는 이걸로 해소.
|
||||
|
||||
### 3.6 시스템 UI
|
||||
- 조이스틱/공격·점프 버튼: 기존 숨김 유지(upsertUi).
|
||||
- 채팅/우상단 메뉴 숨김은 구현 단계에서 MSW API(`_UIService`/WorldConfig) 확인, 불가하면 본 레이아웃(모서리 회피)으로 충분 — 실패해도 P1 완료 조건에 미포함.
|
||||
|
||||
## 4. 변경 파일
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `tools/deck/gen-slaydeck.mjs` | upsertUi 좌표/엔티티 재구성(TopBar·PlayerPanel·TargetFrame·오브·버튼 이동), SlayDeckController(ShowState·RenderCombat·SetHpBar(width)·바인딩 경로) |
|
||||
| 생성물 | `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` 재생성. `common.gamelogic` 불변 예상 |
|
||||
|
||||
## 5. 범위 제외 (후속 페이즈)
|
||||
카드 아트/스킬 아이콘·의도 아이콘(P2), 드래그 타겟·공격 모션·개별 턴·부채꼴 손패(P3), 맵(P4), 시스템(P5).
|
||||
|
||||
## 6. 검증
|
||||
- 생성기 2회 실행 동일(결정적), JSON·중복 id 없음, sim 14/14(불변).
|
||||
- **좌표 겹침 정적 검사**: 하단 HUD 요소 AABB 페어와이즈 겹침 0 (검증 스크립트로 확인).
|
||||
- 메이커 플레이테스트: 메뉴→캐릭터선택→맵→전투(타겟 프레임 이동·에너지/턴종료 위치)→보상→상점→휴식 전 화면 스크린샷, 겹침·비침 0 확인.
|
||||
|
||||
## 7. 리스크
|
||||
- SetHpBar 시그니처 변경 → 호출 3곳(몬스터·플레이어) 일치 필요.
|
||||
- 기존 경로 제거(Floor/Gold/Relics/PlayerHp/PlayerBlock/AllDeckButton) 시 컨트롤러 SetText/바인딩 경로 누락 주의(grep로 구경로 0 확인).
|
||||
- 사용자 PR(deck inspector·character select)이 추가한 UI와의 상호작용 — DeckAll/DeckInspect sortingOrder 오버라이드(2000/1900) 유지.
|
||||
53
docs/superpowers/specs/2026-06-11-system-gaps-design.md
Normal file
53
docs/superpowers/specs/2026-06-11-system-gaps-design.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 시스템 갭 보완 (P5) — 설계
|
||||
|
||||
- 날짜: 2026-06-11
|
||||
- 상태: 승인됨(사용자 사전 위임). 로드맵 P5/5 (선별 범위).
|
||||
|
||||
## 1. 선별 항목 (임팩트/리스크 기준)
|
||||
|
||||
| # | 항목 | 문제 | 해결 |
|
||||
|---|---|---|---|
|
||||
| A | 경제 밸런스 | 골드/승리 15 < 카드 30 → 첫 상점 구매 불가 | `GOLD_PER_WIN` 15→25, 엘리트 승리 보너스 골드 +15(유물에 더해) |
|
||||
| B | 카드 풀 | 3종뿐 — 보상/상점 단조 | 신규 2종 + **복합 효과(damage+block 동시) 지원** |
|
||||
| C | 적 패턴 | 신규 몬스터 의도 2~3스텝 단조 | enemies.json 패턴 보강(3~4스텝, 강공 텔레그래프) |
|
||||
| D | 런 루프 미완결 | 클리어/패배 후 Result 텍스트만(메뉴 복귀 없음) | 결과 표시 4s 후 `ShowMainMenu` 자동 복귀 |
|
||||
|
||||
범위 제외: 저장(E6b 사용자 보류), 카드 제거/업그레이드/포션/이벤트 노드(후속).
|
||||
|
||||
## 2. 설계
|
||||
|
||||
### 2.A 경제
|
||||
- `GOLD_PER_WIN = 25` (gen-slaydeck 상수).
|
||||
- `CheckCombatEnd` elite 분기에 `self.Gold = self.Gold + 15` 추가(유물 지급 유지).
|
||||
|
||||
### 2.B 복합 카드
|
||||
- 규칙 확장: **Attack 카드에 block 필드가 있으면 방어도 함께 적용** (Skill은 기존대로 block만).
|
||||
- Lua `PlayCard`: Attack 분기에서 `if c.block ~= nil then self.PlayerBlock = self.PlayerBlock + c.block end` 추가(데미지는 기존 PlayAttackFx 경로).
|
||||
- sim `simulateCombat`: Attack 분기에 동일 추가 + 테스트 1종.
|
||||
- 신규 카드(`data/cards.json`):
|
||||
- `WarLeap`(워 리프): cost 1, Attack, damage 4, block 3, desc "피해 4, 방어도 3" — 복합.
|
||||
- `Brandish`(브랜디시): cost 2, Attack, damage 13, desc "피해 13" — 고코스트 딜.
|
||||
- 이미지: 공식 RUID 수확(검증된 워크플로 — "워 리프"/"브랜디시" 질의, 부적합 시 기존 보조 질의).
|
||||
- 시작 덱 불변(신규 카드는 보상/상점 풀에서 등장 — 풀은 `self.Cards` 전체이므로 자동 포함).
|
||||
|
||||
### 2.C 적 패턴 (enemies.json)
|
||||
- orange_mushroom: [공5, 방4, 공7] → [공5, 공5, 방4, 공8] (빌드업)
|
||||
- green_mushroom: [공7, 공4] → [공7, 방3, 공9]
|
||||
- pig: [공6, 방3] → [공6, 공6, 방5]
|
||||
- blue_mushroom: [공8, 공4] → [공4, 공4, 공10] (텔레그래프형)
|
||||
- mushmom: [공14, 방10, 공9] → [방10, 공16, 공9, 방6]
|
||||
- modified_snail: [공12, 공7, 방8] → [공12, 방8, 공7, 공14]
|
||||
- king_slime: 유지(이미 4스텝). slime/slime_elite/slime_boss: 유지(레거시 호환).
|
||||
|
||||
### 2.D 런 종료 복귀
|
||||
- `ShowResult(text)` 호출 후 `RunActive=false`가 되는 두 곳(런 클리어·패배)에서: 4초 타이머 → `self:ShowMainMenu()`.
|
||||
- 구현: `ShowResult`에 두 번째 동작 추가 대신 **새 메서드 `EndRun(text)`** = ShowResult(text) + RunActive=false + 4s 타이머 ShowMainMenu. CheckCombatEnd의 두 지점("런 클리어!"/"패배...")을 EndRun 호출로 교체.
|
||||
- ShowMainMenu는 ShowState("menu")로 전 HUD 정리(기존) — Result는 CombatHud 자식이라 같이 숨겨짐. 단 Result.Enable 자체는 StartCombat에서 리셋(기존).
|
||||
|
||||
## 3. 검증
|
||||
- sim: 복합 카드 테스트 추가, 전체 통과. `node tools/balance/sim-balance.mjs 2000`으로 새 패턴 승률 출력(참고 기록 — 100%면 여전히 약함이나 P5 범위는 구조, 수치 정밀 튜닝은 후속).
|
||||
- 메이커: 보상/상점에서 신규 카드 등장(이미지 포함), 복합 카드 사용 시 피해+방어 동시, 패배 또는 클리어 → 4s 후 메뉴 복귀, 엘리트 승리 골드 +15+25.
|
||||
|
||||
## 4. 리스크
|
||||
- 복합 카드의 sim AI(chooseAction)는 Attack 우선 로직 그대로(블록 가치 미평가) — 밸런스 추정 약간 보수적, 허용.
|
||||
- EndRun 타이머 중 사용자가 이미 메뉴로 못 가는 상태(입력 잠금) — CombatOver=true가 가드.
|
||||
47
docs/superpowers/specs/2026-06-12-ascension-design.md
Normal file
47
docs/superpowers/specs/2026-06-12-ascension-design.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# P11 — 승천 시스템 + UserDataStorage 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 — P9/P10/P11 중 3단계)
|
||||
브랜치: `feature/p11-ascension`
|
||||
|
||||
## 범위
|
||||
|
||||
1. **개인별 승천 저장** — `_DataStorageService:GetUserDataStorage(userId)` (유저별 영구, 메이커↔배포 분리). 이 프로젝트 첫 서버-클라 RPC.
|
||||
2. **승천 선택 UI** — 메인 메뉴에서 0~해금치 선택([-]/[+]), 런 클리어 시 해금 +1 (최대 10), 클리어 문구 "승천 N 해금!"
|
||||
3. **승천 모디파이어 A1~A10** (누적):
|
||||
|
||||
| 단계 | 효과 | 단계 | 효과 |
|
||||
|---|---|---|---|
|
||||
| A1 | 적 HP +10% | A6 | 적 HP 추가 +10% |
|
||||
| A2 | 적 피해 +10% | A7 | 적 피해 추가 +10% |
|
||||
| A3 | 시작 HP -10 | A8 | 시작 HP 추가 -10 |
|
||||
| A4 | 정예·보스 배율 +0.2 | A9 | 정예·보스 배율 추가 +0.2 |
|
||||
| A5 | 승리 메소 -25% | A10 | 승리 메소 추가 -25% |
|
||||
|
||||
4. TopBar에 `· 승천N` 표시 (0이면 생략)
|
||||
|
||||
## 서버-클라 구조 (ExecSpace)
|
||||
|
||||
codeblock JSON ExecSpace 실측(프로브): **Server=5**(클라→서버), **Client=6**(서버→클라·마지막 인자 userId로 특정 유저 라우팅), 1=ServerOnly(클라 호출 무시), 2=ClientOnly(서버 호출 무시). 기존 메서드의 6은 Client라 클라 호출 시 제자리 실행으로 동작 동일:
|
||||
|
||||
- `ReqLoadAscension(userId)` **[Server=5]** — 클라 OnBeginPlay에서 호출 → 서버에서 `GetAndWait("ascensionUnlocked")` → `RecvAscension(n, userId)` 호출
|
||||
- `RecvAscension(n, userId)` **[Client=6]** — 마지막 파라미터 userId로 **요청한 클라이언트에만** 응답 (MSW 공식 패턴) → `AscensionUnlocked` 갱신·메뉴 렌더
|
||||
- `SaveAscension(n, userId)` **[Server=5]** — `SetAndWait`
|
||||
- 생성기의 `m.ExecSpace = 6` 일괄 적용을 "명시값(≠0)은 보존"으로 수정
|
||||
|
||||
## 적용 지점
|
||||
|
||||
- `StartRun`: 시작 HP에서 A3/A8 차감, `AscensionLevel`은 메뉴 선택값 유지
|
||||
- `BuildMonsters`: maxHp ×(1+A1/A6), Attack 인텐트 ×(1+A2/A7), elite/boss 그룹이면 막 배율 +A4/A9
|
||||
- `CheckCombatEnd`: 승리 메소 ×(1-A5/A10) floor
|
||||
- `EndRun("런 클리어!")`: `AscensionLevel >= AscensionUnlocked and Unlocked < 10`이면 해금+1·저장·결과 문구 교체
|
||||
- 헬퍼: `AscHpMult`/`AscAtkMult`/`AscEliteBonus`/`AscGoldMult`/`AscStartHpPenalty`
|
||||
|
||||
## UI (MainMenu)
|
||||
|
||||
- `AscRow`: `AscMinus`[-] · `AscLabel`("승천 L / 해금 U") · `AscPlus`[+] — 새 게임 버튼 아래
|
||||
- `AdjustAscension(delta)` clamp 0..Unlocked, `RenderAscension`
|
||||
|
||||
## 검증
|
||||
|
||||
1. 시뮬: 모디파이어는 Lua 전용(런 메타) — 시뮬 비대상 명시. 기존 44건 유지
|
||||
2. 메이커: 로드(첫 0)→승천 2 선택→적 HP/피해 배율·시작 HP 확인→클리어→해금+1·저장→재시작 후 로드 확인(메이커 스토리지), 빌드·런타임 0에러
|
||||
107
docs/superpowers/specs/2026-06-12-buffs-power-design.md
Normal file
107
docs/superpowers/specs/2026-06-12-buffs-power-design.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# P6 — 버프/디버프·Power 카드·적 방어도 UI 설계
|
||||
|
||||
날짜: 2026-06-12
|
||||
브랜치: `feature/p6-buffs-power`
|
||||
근거: 덱빌딩 코어 확장 — Slay the Spire 2의 약화·취약·힘 시스템과 Power 카드 종류를 메이플 IP로 이식. 적 방어도 미표시 문제 해결.
|
||||
|
||||
## 범위
|
||||
|
||||
1. **버프/디버프 3종** — 약화(Weak)·취약(Vulnerable)·힘(Strength), StS 표준 수치
|
||||
2. **Power 카드 kind** — 전투 동안 지속되는 효과, 사용 시 소멸(전투 한정)
|
||||
3. **적 방어도 UI** — 적 슬롯에 플레이어와 동일한 방어도 배지 표시
|
||||
4. **예시 카드 4종** — 메이플 스킬명, StS 카드 효과 매핑
|
||||
5. **적 디버프 인텐트** — 일부 적이 플레이어에게 약화/취약 부여 (양방향 시스템)
|
||||
|
||||
비범위: 물약·유물 강화(P7), 카드 강화(업그레이드), 민첩(Dexterity).
|
||||
|
||||
## 규칙 (StS 표준)
|
||||
|
||||
| 상태 | 효과 | 지속 |
|
||||
|------|------|------|
|
||||
| 힘(Strength) | 공격 피해 +N | 전투 동안 영구 |
|
||||
| 약화(Weak) | 주는 공격 피해 25% 감소 (floor) | N턴, 자기 턴 종료 시 1 감소 |
|
||||
| 취약(Vulnerable) | 받는 공격 피해 50% 증가 (floor) | N턴, 자기 턴 종료 시 1 감소 |
|
||||
|
||||
피해 공식 (플레이어 공격): `floor( floor((base + 힘) × (약화 ? 0.75 : 1)) × (대상 취약 ? 1.5 : 1) )`
|
||||
피해 공식 (적 공격): 동일 공식을 적 힘·적 약화·플레이어 취약으로 적용.
|
||||
|
||||
감소 타이밍:
|
||||
- 플레이어 약화/취약 → `EndPlayerTurn`에서 1 감소
|
||||
- 적 약화/취약 → 적 개별 행동(`EnemyActStep`) 종료 시 1 감소
|
||||
|
||||
## 상태 모델
|
||||
|
||||
- 플레이어: `PlayerStr` / `PlayerWeak` / `PlayerVuln` (number props), `PlayerPowers` (any)
|
||||
- 몬스터: `m.str` / `m.weak` / `m.vuln` (BuildMonsters에서 0 초기화)
|
||||
- 전투 시작(`StartCombat`) 시 전부 리셋
|
||||
|
||||
## 카드 데이터 스키마 확장 (`data/cards.json`)
|
||||
|
||||
| 필드 | 의미 |
|
||||
|------|------|
|
||||
| `kind: "Power"` | 파워 카드 — 사용 시 소멸, 전투 동안 지속 효과 |
|
||||
| `weak: N` | 대상 적에게 약화 N 부여 |
|
||||
| `vuln: N` | 대상 적에게 취약 N 부여 |
|
||||
| `strength: N` | 자신에게 힘 +N |
|
||||
| `powerEffect: "strengthPerTurn"`, `value: N` | 파워: 매 턴 시작 시 힘 +N |
|
||||
|
||||
`luaCardsTable`이 신규 필드를 직렬화하도록 확장.
|
||||
|
||||
## 예시 카드 4종 (메이플 스킬명 × StS 효과)
|
||||
|
||||
| id | 이름 | kind | 코스트 | 효과 | StS 원본 |
|
||||
|----|------|------|--------|------|----------|
|
||||
| ChargedBlow | 차지 블로우 | Attack | 2 | 피해 8, 취약 2 | Bash |
|
||||
| Threaten | 위협 | Skill | 0 | 타겟 적에게 약화 2 | Intimidate 계열 |
|
||||
| Enrage | 인레이지 | Skill | 1 | 힘 +2 | Inflame |
|
||||
| Rage | 분노 | Power | 1 | 매 턴 시작 시 힘 +1 | Demon Form(경량) |
|
||||
|
||||
- 보상 풀은 `self.Cards` 전체 자동 편입이므로 추가 작업 없음. 시작 덱 변경 없음.
|
||||
- 카드 이미지는 MSW 공식 리소스 RUID를 검색·선별해 사용 (계정 업로드 리소스는 로컬 워크스페이스에서 흰 박스 — 금지).
|
||||
- 타겟팅: Attack은 기존 드래그 타겟, 디버프 Skill(위협)은 현재 `TargetIndex` 적에게 적용 (적 클릭으로 타겟 변경 가능).
|
||||
|
||||
## Power 카드 동작
|
||||
|
||||
- `PlayCard`에서 `kind == "Power"` 분기: `powerEffect` 등록(`PlayerPowers`에 push) 후 **버린 덱에 넣지 않고 소멸** (RunDeck에는 유지 — 다음 전투에서 다시 사용 가능)
|
||||
- `StartPlayerTurn`: `PlayerPowers` 순회, `strengthPerTurn`이면 `PlayerStr += value`
|
||||
- 카드 면 색: 기존 `ApplyCardFace`의 else 분기(초록)가 Power에 적용됨 — 명시적으로 `elseif c.kind == "Power"` 초록 지정
|
||||
|
||||
## 적 디버프 인텐트 (`data/enemies.json`)
|
||||
|
||||
인텐트 스키마 확장: `{ "kind": "Debuff", "effect": "weak"|"vuln", "value": N }`
|
||||
|
||||
| 적 | 추가 인텐트 |
|
||||
|----|------------|
|
||||
| mushmom (머쉬맘) | 포자 — 약화 2 |
|
||||
| slime_elite (정예 슬라임) | 약화 1 |
|
||||
| slime_boss (슬라임 킹) | 취약 2 |
|
||||
| king_slime (킹 슬라임) | 취약 2 |
|
||||
| modified_snail (변형된 달팽이) | 약화 1 |
|
||||
|
||||
`EnemyActStep`에서 `kind == "Debuff"` 처리: `PlayerWeak/PlayerVuln += value`.
|
||||
|
||||
## UI 변경 (`gen-slaydeck.mjs` UI 생성부)
|
||||
|
||||
1. **적 방어도 배지**: 각 `MonsterSlot{i}`에 `BlockBadge`(파란 사각 44×40, HP바 좌측 x=-HP_BAR_W/2-32, y=-14) + `BlockBadge/Value` 텍스트. `RenderCombat`에서 `m.block > 0`일 때만 표시 — 플레이어 배지와 동일 패턴.
|
||||
2. **적 버프 라인**: `MonsterSlot{i}/Buffs` 텍스트 (Intent 아래 y=-58, fontSize 15, 보라). 예: `힘+2 약화1 취약2`. 없으면 빈 문자열.
|
||||
3. **플레이어 버프 라인**: `PlayerPanel/Buffs` 텍스트 (y=-44, fontSize 14). 파워 활성 시 `분노` 포함. 예: `힘+3 취약1 · 분노`.
|
||||
4. **인텐트 표시 확장**: `Debuff` 인텐트 → `약화 2 부여` / `취약 2 부여`, 보라색. Attack 인텐트 숫자는 힘·약화·플레이어 취약 반영한 **최종 예상치** 표시 (StS 동일).
|
||||
|
||||
## 밸런스 시뮬 동기화 (`tools/balance/sim-balance.mjs`)
|
||||
|
||||
- 카드: `strength`/`weak`/`vuln`/Power 처리 재현 (chooseAction은 Attack 우선 휴리스틱 유지, Power/버프 스킬은 에너지 남을 때 사용하는 단순 규칙 추가)
|
||||
- 적: `Debuff` 인텐트 재현 (플레이어 weak/vuln)
|
||||
- 피해 공식 양방향 동일 적용, 감소 타이밍 동일
|
||||
- 기존 테스트(`sim-balance.test.mjs`) 통과 + 신규 케이스(약화/취약/힘 계산 단위 테스트) 추가
|
||||
|
||||
## 검증
|
||||
|
||||
1. `node tools/deck/gen-slaydeck.mjs` 성공 (산출물 재생성: SlayDeckController.codeblock·DefaultGroup.ui·common.gamelogic)
|
||||
2. `node --test tools/balance/sim-balance.test.mjs` 통과
|
||||
3. `node tools/balance/sim-balance.mjs` 실행 — 승률이 0%/100% 극단으로 붕괴하지 않는지 확인
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 민첩(Dexterity)은 도입하지 않음 (방어 카드 수가 적어 효용 낮음 — YAGNI)
|
||||
- 기존 카드(슬래시 블러스트 등) 수치 변경 없음 — 신규 카드로만 확장
|
||||
- Power는 1종으로 시작, 물약/유물(P7)과의 연계는 P7에서
|
||||
81
docs/superpowers/specs/2026-06-12-card-frames-design.md
Normal file
81
docs/superpowers/specs/2026-06-12-card-frames-design.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# P13 — 커스텀 카드 프레임 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 완료)
|
||||
브랜치: `feature/p13-card-frames`
|
||||
|
||||
## 범위
|
||||
|
||||
사용자 제작 카드 프레임 이미지(직업 3종 × 등급 3종)를 인게임 카드 UI 전체(손패·보상·상점·덱 조회)에 적용한다. 카드에 등급(rarity)을 도입하고 전투 보상 추첨 확률에 반영한다.
|
||||
|
||||
## 리소스 (임포트 완료 — RUID 수확됨)
|
||||
|
||||
원본: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\card\*.png` (263×366, 카드 비율 180×250과 동일한 0.72)
|
||||
메이커 로컬 임포트 → `RootDesk/MyDesk/<name>.sprite` 디스크립터 9종 (커밋 대상).
|
||||
|
||||
| 프레임 | normal | unique | legend |
|
||||
|---|---|---|---|
|
||||
| warior | `4bb57ef88ef449fdaf958f6cf37fe44b` | `4f71c124c8bc4e13b5e9fad392995f68` | `6d741a60c60743cb98ee740a1e2dbfed` |
|
||||
| mage | `d788d09f6f50467ebc67f01dec45f9e2` | `f5def2e8022b4e59a17d3c16414034fe` | `cff71f2e472041ce80c6fbd296f42e2d` |
|
||||
| bandit | `9487b06867bc46269ed1d855420f457f` | `b3081fb2fb1445fa90b12b01481a78ef` | `c357d2daf31a489d95b8fa47e50dd879` |
|
||||
|
||||
bandit은 RUID 등록만 하고 보류 (도적 클래스 추가 시 사용).
|
||||
|
||||
프레임 슬롯 구조: 좌상단 육각 코스트 · 상단 이름 배너 · 중앙 아트 영역 · 하단 설명 박스.
|
||||
|
||||
## 데이터
|
||||
|
||||
### `data/cardframes.json` (신설)
|
||||
|
||||
```json
|
||||
{
|
||||
"frames": {
|
||||
"warrior": { "normal": "4bb57ef88ef449fdaf958f6cf37fe44b", "unique": "4f71c124c8bc4e13b5e9fad392995f68", "legend": "6d741a60c60743cb98ee740a1e2dbfed" },
|
||||
"magician": { "normal": "d788d09f6f50467ebc67f01dec45f9e2", "unique": "f5def2e8022b4e59a17d3c16414034fe", "legend": "cff71f2e472041ce80c6fbd296f42e2d" },
|
||||
"bandit": { "normal": "9487b06867bc46269ed1d855420f457f", "unique": "b3081fb2fb1445fa90b12b01481a78ef", "legend": "c357d2daf31a489d95b8fa47e50dd879" }
|
||||
},
|
||||
"classToFrame": {
|
||||
"warrior": "warrior", "fighter": "warrior", "page": "warrior", "spearman": "warrior",
|
||||
"magician": "magician", "firepoison": "magician", "icelightning": "magician", "cleric": "magician"
|
||||
},
|
||||
"rewardWeights": { "normal": 70, "unique": 25, "legend": 5 }
|
||||
}
|
||||
```
|
||||
|
||||
### `data/cards.json` — 전 카드에 `rarity` 추가
|
||||
|
||||
| 등급 | 카드 (32종) |
|
||||
|---|---|
|
||||
| normal (10) | Strike, Defend, Bash, WarLeap, Threaten, EnergyBolt, MagicGuard, MagicClaw, Teleport, Slow |
|
||||
| unique (17) | Brandish, ChargedBlow, Enrage, ComboAttack, RisingAttack, ThunderCharge, BlizzardCharge, PowerGuard, Pierce, IronWall, FireArrow, PoisonBreath, ColdBeam, ChillingStep, Heal, Bless, HolyArrow |
|
||||
| legend (5) | Rage, Berserk, HyperBody, ElementAmp, ThunderBolt |
|
||||
|
||||
기준: 시작 덱·기본기 = normal / 강화·2차 전직 주력기 = unique / 파워 카드·전체 공격 = legend.
|
||||
|
||||
생성기 검증: `rarity` 누락 또는 normal|unique|legend 외 값이면 throw. 카드 class가 `classToFrame`에 없으면 throw.
|
||||
|
||||
## 렌더링 (생성기 — A안: 카드 배경 교체)
|
||||
|
||||
- 카드 루트 스프라이트: 단색 틴트(kind별) → 프레임 `ImageRUID`(Type 0, 흰색). NamePlate/CostPlate 단색판 제거 — RewardHud 등 생성 섹션은 생성 중단으로 충분, **CardHand는 .ui에 잔존하므로 upsert 시 경로 매칭으로 명시 제거**.
|
||||
- `ApplyCardFace`(Lua): kind 틴트 분기 제거 → `self.CardFrames[self.ClassToFrame[c.class]][c.rarity]` 적용. `CardFrames`/`ClassToFrame`는 OnBeginPlay에서 Lua 테이블 주입 + `prop('any', …)` 선언(LIA 1114 예방).
|
||||
- 자식 레이아웃 공용 헬퍼 `cardFaceLayout(W)` 신설 — 중복 5곳(손패 523·조회 787·전체덱 928·보상 1443·상점 1660 부근) 일괄 적용. 180×250 기준값(스케일 s=W/180):
|
||||
- Cost: pos(-68, 103)·size 44·font 26 (현 위치와 거의 일치)
|
||||
- Name: pos(4, 97)·size 150×26·font 18 — 상단 배너로 이동
|
||||
- Art: pos(0, 16)·size 110 — 중앙 아트 영역 확대
|
||||
- Desc: pos(0, -85)·size 152×64·font 16 — 하단 박스
|
||||
- 초깃값이며 메이커 스크린샷으로 미세 튜닝.
|
||||
- 정적 프리뷰(Card1~5)도 동일 프레임 적용.
|
||||
|
||||
## 보상 가중 추첨
|
||||
|
||||
`OfferReward`(Lua): 풀을 rarity 버킷으로 분류 후 1~100 롤 — ≤70 normal / ≤95 unique / >95 legend. 해당 버킷이 비면 전체 풀 폴백. 상점·전투 계산은 변경 없음 (sim-balance 전투 미러 무관).
|
||||
|
||||
JS 미러: `tools/balance/sim-balance.mjs`에 `rarityForRoll(roll)` export + 경계 테스트(70/71/95/96).
|
||||
|
||||
## 검증
|
||||
|
||||
재생성 → `grep -c` 카운트(CardFrames·rarity) → 기존 테스트 40건 + 신규 통과 → 메이커 refresh·빌드 0에러 → 플레이 스크린샷(손패 프레임·등급 색 구분·보상·덱 조회) → 텍스트 위치 튜닝.
|
||||
|
||||
## 주의 (이번 세션 실측)
|
||||
|
||||
- maker_save 시 메이커가 산출물을 재직렬화(0→0.0 등)하고 `Mislocated/`로 엔티티를 옮길 수 있음 → 임포트 후 `.sprite`만 남기고 산출물은 `git restore`로 복원했음. 재발 시 동일 절차.
|
||||
- sprite RUID는 map01.map에 등록되지 않고 `.sprite` 디스크립터 자체가 등록 메커니즘.
|
||||
36
docs/superpowers/specs/2026-06-12-combat-motion-design.md
Normal file
36
docs/superpowers/specs/2026-06-12-combat-motion-design.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# P12 — 전투 모션 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 완료)
|
||||
브랜치: `feature/p12-combat-motion`
|
||||
|
||||
## 범위
|
||||
|
||||
플레이어·몬스터의 공격/피격 모션 (독 틱 피해 포함). 순수 클라이언트 연출 — 전투 수치·시뮬 비대상.
|
||||
|
||||
## 모션 매핑
|
||||
|
||||
| 상황 | 대상 | 모션 |
|
||||
|---|---|---|
|
||||
| 카드 공격(단일·AoE) 사용 | 플레이어 | 아바타 공격 스윙 (`AvatarBodyActionSelectorComponent.ActionState`, pcall 가드 — 실패 시 전방 런지 폴백) → 0.4s 후 복귀 |
|
||||
| 적 공격 행동 | 몬스터 | 플레이어 방향 런지 (x −0.35 → 0.18s 복귀) — 몹 다수가 공격 클립 미보유 → StS식 채택 |
|
||||
| 몬스터 피격 (카드·AoE·물약·**독 틱**·체인메일 반사) | 몬스터 | `hit` 클립 재생(`SpriteRendererComponent.SpriteRUID` ← `StateAnimationComponent.ActionSheet["hit"]`, BuildMonsters에서 pcall 캐시) → 0.5s 후 stand 복귀. 클립 없으면 좌우 흔들림 폴백 |
|
||||
| 플레이어 피격 (적 공격) | 플레이어 | 넉백 틱 (x −0.15 → 0.15s 복귀) |
|
||||
|
||||
## 훅 지점
|
||||
|
||||
- `PlayCard` Attack 분기 → `PlayerAttackMotion()`
|
||||
- `EnemyActStep` Attack 인텐트 → `MonsterLunge(idx)` + 피해 후 `PlayerHitMotion()`; 독 틱 → `MonsterHitMotion(idx)`
|
||||
- `DealDamageToTarget` 피해 적용 후 → `MonsterHitMotion(slot)` (물약 화염병 포함 자동)
|
||||
- `PlayAoeFx` 대상 루프 → `MonsterHitMotion(i)`
|
||||
- `DealDamageToPlayer` 브론즈 체인메일 반사 → `MonsterHitMotion(attackerSlot)`
|
||||
- 사망 연출은 기존(KillMonster SetVisible) 유지. 모션 중 사망 시 isvalid·alive 가드로 복귀 타이머 무해화
|
||||
|
||||
## 구현 메모
|
||||
|
||||
- `BuildMonsters`에서 `m.hitClip`/`m.standClip` pcall 캐시 (SyncDictionary 인덱싱 실패 대비)
|
||||
- 모든 위치 복귀는 캡처한 원위치 기준 (이중 발동 시 어긋남 방지를 위해 모션 중 재발동은 위치 캡처 생략 — `m.motionBusy` 플래그)
|
||||
- 아바타 enum `MapleAvatarBodyActionState` 멤버는 메이커 프로브로 확정 후 베이크 (후보: swingO1·stabO1)
|
||||
|
||||
## 검증
|
||||
|
||||
메이커 플레이테스트: 카드 공격 시 아바타 스윙(또는 폴백) 로그·몬스터 hit 클립 전환 로그, 적 턴 런지·플레이어 넉백, 독 틱 모션. 빌드·런타임 0에러, 기존 테스트 40건 유지.
|
||||
64
docs/superpowers/specs/2026-06-12-job-advancement-design.md
Normal file
64
docs/superpowers/specs/2026-06-12-job-advancement-design.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# P9 — 전직 시스템 코어 + 전사 2차 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 완료 — P9/P10/P11 3단계 중 1단계)
|
||||
브랜치: `feature/p9-job-advancement`
|
||||
|
||||
## 범위
|
||||
|
||||
1. **클래스 모델** — 카드 `class` 필드, 클래스별 카드 풀 필터 (보상·상점)
|
||||
2. **전직 선택 흐름** — 보스 클리어 시 1차 상태면 [유물] vs [2차 전직] 선택, 전직 시 파이터/페이지/스피어맨 3택
|
||||
3. **전사 2차 전용 카드 9종** + 신규 메커니즘: 다단히트(`hits`)·방어 무시(`pierce`)·자가 디버프(`selfVuln`)·파워 2종(`energyPerTurn`/`blockPerTurn`)
|
||||
4. 플레이어 패널·캐릭터 선택의 직업명 표기
|
||||
|
||||
비범위: 법사(P10), 승천(P11), 3차 전직.
|
||||
|
||||
## 데이터 (data/cards.json)
|
||||
|
||||
- 모든 카드에 `class` 필드. 기존 9종 → `"warrior"`.
|
||||
- 신규 필드: `hits`(타격 횟수), `pierce`(true=방어 무시), `selfVuln`(사용 시 자신에게 취약 N), powerEffect 추가값 `energyPerTurn`/`blockPerTurn`.
|
||||
|
||||
신규 카드 9종 (메이플 2차 스킬명 × StS 효과):
|
||||
|
||||
| id | 직업 | 이름 | 코스트 | 효과 | StS 참조 |
|
||||
|----|------|------|--------|------|----------|
|
||||
| ComboAttack | fighter | 콤보 어택 | 1 | 피해 5 × 2회 | Twin Strike |
|
||||
| Berserk | fighter | 버서크 | 2 | Power: 매턴 에너지 +1, 사용 시 취약 1 자가 | Berserk |
|
||||
| RisingAttack | fighter | 라이징 어택 | 2 | 피해 12 | Carnage(경량) |
|
||||
| ThunderCharge | page | 썬더 차지 | 1 | 피해 7, 약화 1 | Clothesline(경량) |
|
||||
| BlizzardCharge | page | 블리자드 차지 | 1 | 피해 7, 취약 1 | Bash(경량) |
|
||||
| PowerGuard | page | 파워 가드 | 1 | 방어도 10 | Shrug It Off(경량) |
|
||||
| Pierce | spearman | 피어스 | 1 | 피해 9, **방어 무시** | — |
|
||||
| IronWall | spearman | 아이언 월 | 2 | 방어도 12 | Impervious(경량) |
|
||||
| HyperBody | spearman | 하이퍼 바디 | 1 | Power: 매턴 방어도 +3 | Metallicize |
|
||||
|
||||
전직 시 대표 카드 1장 즉시 지급: fighter→콤보 어택, page→썬더 차지, spearman→피어스.
|
||||
|
||||
## 전투 규칙 확장 (Lua + sim 동기화)
|
||||
|
||||
- **다단히트**: `total = Σ CalcPlayerAttack(c.damage)` (hits회 반복 — 힘이 타격마다 적용, 펜닙 카운터도 타격마다 증가), 이펙트·팝업은 합산 1회. 취약 배수는 합산값에 적용(단순화 명시).
|
||||
- **방어 무시**: `DealDamageToTarget(amount, pierce)` — pierce면 block 차감 생략. `PlayAttackFx`에 pierce 전달.
|
||||
- **selfVuln**: 카드 사용 시 `PlayerVuln += selfVuln`.
|
||||
- **파워 확장**: StartPlayerTurn 파워 루프에 `energyPerTurn`(Energy +v) · `blockPerTurn`(PlayerBlock +v — 블록 리셋·점토 처리 후).
|
||||
|
||||
## 전직 흐름
|
||||
|
||||
- 컨트롤러 prop: `PlayerJob`(string, ""=1차). StartRun에서 리셋.
|
||||
- **카드 풀 필터** (`CardPool` 헬퍼 신설): `c.class == self.SelectedClass or (PlayerJob ~= "" and c.class == PlayerJob)`. OfferReward·ShowShop이 사용.
|
||||
- **보스 클리어 분기** (CheckCombatEnd): 보스 진행 로직을 `ContinueAfterBoss()`로 추출.
|
||||
- `PlayerJob == "" and Floor < RunLength` → `ShowJobChoice()` (선택 후 ContinueAfterBoss)
|
||||
- 그 외 → 기존 유물 지급 + ContinueAfterBoss (최종 막 클리어 시 전직 무의미 — 생략)
|
||||
- **JobChoiceHud**: "보스 보상 선택" — [유물 획득](PickNewRelic+AddRelic) / [2차 전직] 버튼 2개.
|
||||
- **JobSelectHud**: 파이터/페이지/스피어맨 3패널 (직업명·설명·대표 카드명). 선택 → `SetJob(jobId)`: PlayerJob 설정, 대표 카드 RunDeck 추가, 토스트, 패널 닫고 ContinueAfterBoss.
|
||||
- guid 네임스페이스 `'job'` = 0xe4 (JobChoiceHud·JobSelectHud).
|
||||
- **직업명 표기**: PlayerPanel/Name = "전사" → 전직 후 "파이터/페이지/스피어맨" (`JobLabel` 헬퍼, StartCombat·SetJob에서 갱신).
|
||||
|
||||
## 검증
|
||||
|
||||
1. sim-balance: hits/pierce/selfVuln/energyPerTurn/blockPerTurn 재현 + 신규 테스트 5건. rogue-map 9건·기존 21건 유지.
|
||||
2. 메이커: 빌드 0에러 + 플레이테스트 — 보스 클리어→선택 화면→전직→전용 카드 보상 풀 편입·패널 직업명, 유물 선택 경로, 다단히트/방어무시 동작.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 전직은 런당 1회 (PlayerJob 비가역), 최종 막 보스에선 선택 생략
|
||||
- 카드 이미지 9종: 공식 maplestory 리소스 메이커 선별 (기존 절차)
|
||||
- 클래스 필터로 "해당 클래스만 획득" 충족 — 사용 제한은 별도 불요 (얻을 수 없으면 못 씀)
|
||||
63
docs/superpowers/specs/2026-06-12-magician-design.md
Normal file
63
docs/superpowers/specs/2026-06-12-magician-design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# P10 — 법사 클래스 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 — P9/P10/P11 중 2단계)
|
||||
브랜치: `feature/p10-magician`
|
||||
선행: P9 (클래스 모델·전직 흐름·CardPool 필터)
|
||||
|
||||
## 범위
|
||||
|
||||
1. **캐릭터 선택 오픈** — 시작 화면 전사/법사 2택 (법사 시작 HP 70, 전용 시작 덱)
|
||||
2. **법사 1차 카드 5종** + **2차 3계열 9종** (위자드(불·독)/위자드(썬·콜)/클레릭 — 실제 메이플 직업)
|
||||
3. **신규 메커니즘 4종**: 독(DoT)·전체 공격(AoE)·회복 카드·드로 카드 (Lua + 시뮬 동기화)
|
||||
4. 전직 선택 화면을 **클래스별 동적 구성**으로 리팩터 (P9의 고정 3패널 → 슬롯 3개 + 런타임 채움)
|
||||
|
||||
## 데이터
|
||||
|
||||
- `cards.json`: `starterDeck` → **`starterDecks`** `{ warrior: [...], magician: [에너지 볼트×5, 매직 가드×4, 매직 클로×1] }`
|
||||
- 신규 카드 필드: `draw`(드로 N)·`heal`(HP 회복)·`poison`(적에게 독 N)·`aoe`(true=전체 공격)
|
||||
- 클래스 상수(생성기): warrior HP 80 / magician HP 70
|
||||
|
||||
법사 카드 14종 (메이플 스킬명):
|
||||
|
||||
| id | 직업 | 이름 | 코 | 효과 |
|
||||
|----|------|------|----|------|
|
||||
| EnergyBolt | magician | 에너지 볼트 | 1 | 피해 6 |
|
||||
| MagicGuard | magician | 매직 가드 | 1 | 방어 5 |
|
||||
| MagicClaw | magician | 매직 클로 | 1 | 피해 3 × 2회 |
|
||||
| Teleport | magician | 텔레포트 | 1 | 방어 3, 드로 1 |
|
||||
| Slow | magician | 슬로우 | 1 | 약화 2 부여 |
|
||||
| FireArrow | firepoison | 파이어 애로우 | 1 | 피해 8 |
|
||||
| PoisonBreath | firepoison | 포이즌 브레스 | 1 | **독 4** 부여 |
|
||||
| ElementAmp | firepoison | 엘레멘트 앰플 | 1 | Power: 매턴 힘 +1 |
|
||||
| ThunderBolt | icelightning | 썬더 볼트 | 2 | **전체 적** 피해 6 |
|
||||
| ColdBeam | icelightning | 콜드 빔 | 2 | 피해 7, 약화 2 |
|
||||
| ChillingStep | icelightning | 칠링 스텝 | 1 | 방어 8 |
|
||||
| Heal | cleric | 힐 | 1 | **HP 10 회복** |
|
||||
| Bless | cleric | 블레스 | 1 | 힘 +1, 방어 5 |
|
||||
| HolyArrow | cleric | 홀리 애로우 | 1 | 피해 8 |
|
||||
|
||||
(설계 초안 대비 수치 미세 조정: 힐 12→10·블레스 방어 6→5·홀리 애로우 9→8 — 1코 효율 정렬)
|
||||
|
||||
## 신규 메커니즘 규칙
|
||||
|
||||
- **독**: 적 디버프. 해당 적 행동 시작 시 `hp -= poison` 후 `poison -= 1` (StS 동일). 방어 무시. 독 사망 시 행동 생략·체인 계속. 버프 라인에 `독N` 표시.
|
||||
- **AoE**(`aoe: true`): 생존 적 전원에게 각자 취약/방어 적용해 피해. 중앙 이펙트 1회(`PlayAoeFx`), 슬롯별 팝업.
|
||||
- **회복**(`heal`): `PlayerHp = min(+N, Max)`.
|
||||
- **드로**(`draw`): 사용 시 N장 드로 (손패 상한 5 초과분은 기존 DrawCards 동작 따름).
|
||||
|
||||
## 전직 화면 동적화
|
||||
|
||||
- `JobSelectHud`의 패널을 `Job_slot1..3`(범용)으로 변경, `ShowJobSelect`가 `SelectedClass`별 옵션 테이블(JOBS 상수 주입)로 이름/설명/대표 카드 텍스트를 채움. 클릭 → `SetJob(JobOpts[i].id)`.
|
||||
- JOBS: warrior=[fighter/page/spearman], magician=[firepoison(위자드 불·독)/icelightning(위자드 썬·콜)/cleric(클레릭)]
|
||||
- 대표 카드: firepoison→파이어 애로우, icelightning→썬더 볼트, cleric→힐
|
||||
- `JobLabel` 확장: 마법사/위자드(불·독)/위자드(썬·콜)/클레릭
|
||||
|
||||
## 캐릭터 선택
|
||||
|
||||
- 기존 `MageButton`(잠금) → 활성: key Mage, `SelectClass("magician")`, 하이라이트·상태 텍스트 클래스 공용화, `StartNewGame` 가드 warrior|magician 허용
|
||||
- `StartRun`: 클래스별 MaxHp·RunDeck 분기
|
||||
|
||||
## 검증
|
||||
|
||||
1. 시뮬: poison/aoe/heal/draw 재현 + 테스트 4건 이상 (전체 40건+)
|
||||
2. 메이커: 법사 선택→시작 덱 확인→전직(클레릭 등)→전용 카드 풀·독/AoE/힐 실동작, 빌드·런타임 0에러
|
||||
103
docs/superpowers/specs/2026-06-12-potions-relics-design.md
Normal file
103
docs/superpowers/specs/2026-06-12-potions-relics-design.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# P7 — 물약 시스템·유물 강화 설계
|
||||
|
||||
날짜: 2026-06-12
|
||||
브랜치: `feature/p7-potions-relics`
|
||||
선행: P6 (버프/디버프·Power — 물약·유물 효과가 힘/약화/취약을 참조)
|
||||
|
||||
## 범위
|
||||
|
||||
1. **물약 시스템 (StS 풀세트)** — 전투 보상 확률 드랍 + 상점 구매 + 전투 중 사용 + 버리기, 슬롯 기본 3칸
|
||||
2. **유물 19종** — 기존 4종 유지 + 신규 15종 (StS 효과 그대로, 메이플 장비 외형·이름)
|
||||
3. **유물 아이콘 행 + 마우스오버 툴팁** — 텍스트 나열 → 장비 아이콘, hover 시 효과 설명 창
|
||||
4. **물약 슬롯 5칸 유물(장인의 벨트)** ★ 대표 필수
|
||||
|
||||
비범위: 밸런스 시뮬의 물약/유물 재현(시뮬은 카드·적 규칙만 동기화 — 기존과 동일), 맵/휴식 화면 유물 표시.
|
||||
|
||||
## 물약 (data/potions.json 신설)
|
||||
|
||||
| id | 이름 | 효과 | StS 원본 |
|
||||
|----|------|------|----------|
|
||||
| redPotion | 빨간 포션 | HP 20 회복 | Health 계열 |
|
||||
| firebomb | 화염병 | 타겟 적에게 피해 20 | Fire Potion |
|
||||
| warriorElixir | 전사의 물약 | 힘 +2 (전투 동안) | Strength Potion |
|
||||
| guardPotion | 수호의 물약 | 방어도 +12 | Block Potion |
|
||||
| manaElixir | 마나 엘릭서 | 에너지 +2 | Energy Potion |
|
||||
| cursedVial | 저주의 병 | 타겟 적에게 약화 3 | Weakness Potion |
|
||||
|
||||
- 슬롯: 기본 3칸, `장인의 벨트` 보유 시 5칸. UI는 항상 5칸 그리고 벨트 없으면 4·5번째 칸 잠금 표시.
|
||||
- 획득: 전투 승리 시 40% 확률(`dropChance`)로 랜덤 1개. 슬롯 가득이면 토스트 안내 후 미지급. 상점에서 랜덤 1종 20골드 판매(`ShopPotion`, 유물 패턴 동일).
|
||||
- 사용: 물약 슬롯 클릭 → 미니 메뉴(사용/버리기/닫기). **사용은 전투 중에만** (전투 외 클릭 시 사용 버튼 무시 + 토스트), 버리기는 언제나 가능.
|
||||
- 타겟형 물약(화염병·저주의 병)은 현재 `TargetIndex` 적에게 적용.
|
||||
- 스키마: `{ potions: { id: { name, desc, effect, value, icon } }, dropChance: 0.4, baseSlots: 3, beltSlots: 5, shopPrice: 20 }`
|
||||
- effect 종류: `heal` `damage` `strength` `block` `energy` `weak`
|
||||
- 상태: `RunPotions` (id 배열), `PotionSlots` (3|5). StartRun에서 초기화.
|
||||
|
||||
## 유물 19종 (data/relics.json 확장)
|
||||
|
||||
기존 4종(강철 심장·에너지 코어·흡혈 송곳니·황금 우상) 유지. 신규 15종 — StS 효과 그대로, 메이플 장비 이름:
|
||||
|
||||
| id | 장비명 | 효과 | StS 원본 | 구현 지점 |
|
||||
|----|--------|------|----------|----------|
|
||||
| potionBelt | 장인의 벨트 | 물약 슬롯 3→5 ★ | Potion Belt | AddRelic |
|
||||
| burningBlood | 자쿰의 투구 | 전투 승리 시 HP 6 회복 | Burning Blood | combatEnd |
|
||||
| vajra | 미스릴 액스 | 전투 시작 시 힘 +1 | Vajra | combatStart |
|
||||
| anchor | 메이플 실드 | 첫 턴 방어도 +10 | Anchor | combatStart(block) |
|
||||
| bagOfPrep | 모험가의 배낭 | 첫 턴 드로우 +2 | Bag of Preparation | combatStart |
|
||||
| bloodVial | 피의 목걸이 | 전투 시작 시 HP 2 회복 | Blood Vial | combatStart |
|
||||
| bronzeScales | 브론즈 체인메일 | 적 공격에 피격 시 공격자에게 3 반사 | Bronze Scales | onPlayerDamaged |
|
||||
| strawberry | 건강의 반지 | 획득 시 최대 HP +7 | Strawberry | AddRelic |
|
||||
| penNib | 황금 깃펜 | 10번째 공격 카드 피해 2배 | Pen Nib | CalcPlayerAttack |
|
||||
| boot | 브론즈 부츠 | 5 미만 공격 피해를 5로 | The Boot | CalcPlayerAttack |
|
||||
| akabeko | 황소 투구 | 전투 첫 공격 카드 피해 +8 | Akabeko | CalcPlayerAttack |
|
||||
| centennialPuzzle | 백년의 부적 | 전투 중 처음 HP를 잃으면 드로우 3 | Centennial Puzzle | onPlayerDamaged |
|
||||
| meatOnBone | 고기 망치 | 전투 종료 시 HP 50% 이하면 12 회복 | Meat on the Bone | combatEnd |
|
||||
| selfFormingClay | 점토 갑옷 | 피해를 받으면 다음 턴 방어도 +3 | Self-Forming Clay | onPlayerDamaged + StartPlayerTurn |
|
||||
| championBelt | 챔피언 벨트 | 카드로 취약 부여 시 약화 1 추가 | Champion Belt | PlayCard 디버프 적용부 |
|
||||
|
||||
규칙 세부:
|
||||
- penNib 카운터는 **전투 내** 공격 카드 사용 횟수 기준(StS는 런 전체 누적이나 단순화). 10·20·30…번째 2배.
|
||||
- boot 은 최종 계산값이 1~4일 때 5로 보정 (0은 그대로).
|
||||
- akabeko 는 전투당 1회, 첫 공격 카드의 기본 피해에 +8 (힘 적용 전 base에 합산).
|
||||
- bronzeScales 반사는 공격한 적이 생존 중일 때 3 피해 (그 적의 block 무시하지 않음 — DealDamage 재사용, 취약 배수는 미적용하도록 직접 hp 차감).
|
||||
- 적용 순서(CalcPlayerAttack): base + akabeko → penNib 2배 → 힘 → 약화 → boot 보정. 취약은 기존대로 명중 시.
|
||||
- 유물 상태 props: `FightAttackCount`(펜닙·아카베코 겸용), `FirstHpLossDone`(퍼즐), `ClayBlockNext`(점토).
|
||||
- 획득 경로(기존 유지 + 개선): 정예 승리·상점 + **보스 클리어 시 1개 추가**. 풀에서 **미보유 유물만** 추첨(`PickNewRelic`), 전부 보유 시 골드 +25 대체.
|
||||
- relicPool에 신규 15종 전부 + 기존 3종(에너지 코어·흡혈 송곳니·황금 우상) 포함. 시작 유물은 ironHeart 유지.
|
||||
- 스키마 확장: 각 유물에 `icon`(RUID) 추가. 신규 hook 값: `combatEnd`, `onPlayerDamaged`, `passive`(AddRelic 시 1회).
|
||||
|
||||
## UI
|
||||
|
||||
### 유물 아이콘 행 (CombatHud TopBar)
|
||||
- 기존 `TopBar/Relics` 텍스트 제거 → `TopBar/RelicSlot1..10` (UISprite 40×40, x -240부터 48px 간격).
|
||||
- `RenderRelics`: 보유 유물 순서대로 아이콘 표시, 10개 초과분은 10번째 칸을 `+N` 텍스트로 대체.
|
||||
- 각 슬롯에 `UITouchReceiveComponent` + `UITouchEnterEvent/ExitEvent` → 툴팁 표시/숨김.
|
||||
|
||||
### 물약 슬롯 (CombatHud TopBar 우측)
|
||||
- `TopBar/PotionSlot1..5` (UISprite 40×40, x 270부터 44px 간격, AllDeckButton(x 510) 앞에서 종료).
|
||||
- 빈 칸은 어두운 배경, 잠금 칸(벨트 미보유 4·5번)은 자물쇠 느낌의 더 어두운 색.
|
||||
- 클릭(ButtonClickEvent 대신 UITouchDownEvent) → `PotionMenu` 팝업: 물약명·설명 + [사용] [버리기] [닫기].
|
||||
- hover 툴팁 동일 적용.
|
||||
|
||||
### 툴팁 (TooltipBox)
|
||||
- `/ui/DefaultGroup/CombatHud/TooltipBox` — bg(260×72) + Name + Desc 텍스트, displayOrder 최상위, 기본 비활성.
|
||||
- Enter 시 대상 슬롯 인덱스에 따라 x 위치 조정해 표시, Exit 시 숨김. 공용 메서드 `ShowTooltip(name, desc, x, y)` / `HideTooltip()`.
|
||||
|
||||
### 상점 (ShopHud)
|
||||
- 기존 `ShopHud/Relic` 아래 `ShopHud/Potion` 추가 — 라벨·가격(20골드)·구매 처리 `BuyPotion` (ShopRelic 패턴 복제, 슬롯 가득 시 구매 거부 토스트).
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
`StartRun`: `RunPotions = {}`, `PotionSlots = baseSlots`, 유물 초기화(기존) → `RenderRelics`·`RenderPotions`.
|
||||
`CheckCombatEnd`(승리): combatEnd 유물 → 물약 드랍 판정 → 기존 보상 흐름.
|
||||
`DealDamageToPlayer`: HP 실손실 시 onPlayerDamaged 유물 발동 (공격자 slot 인자 추가).
|
||||
|
||||
## 검증
|
||||
|
||||
1. `node tools/deck/gen-slaydeck.mjs` 성공, `node --test` 통과 (기존 21건 — 시뮬 변경 없음)
|
||||
2. 메이커 빌드 콘솔 0 에러 + 플레이테스트: 유물 아이콘·툴팁 hover·물약 사용/버리기·벨트 5칸 확인
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 물약 아이콘·유물 아이콘 RUID는 공식 maplestory 리소스에서 메이커 미리보기로 선별 (계정 리소스 금지)
|
||||
- 물약 6종으로 시작 (StS 핵심 6역할), 추가는 데이터만으로 확장 가능
|
||||
- penNib 전투 내 카운터·bronzeScales 단순 반사 등 경량화는 표에 명시한 대로
|
||||
92
docs/superpowers/specs/2026-06-12-rogue-map-design.md
Normal file
92
docs/superpowers/specs/2026-06-12-rogue-map-design.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# P8 — 로그라이크 절차 생성 맵·층 시스템·유물 방 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 완료)
|
||||
브랜치: `feature/p8-rogue-map`
|
||||
선행: P7 (유물 19종 — 유물 방이 `PickNewRelic` 재사용)
|
||||
|
||||
## 범위
|
||||
|
||||
1. **절차 생성 맵** — 막 시작마다 8층×최대 4열 DAG를 Lua 런타임 생성 (런·막마다 다른 맵). `data/map.json` 정적 맵 제거
|
||||
2. **층(depth) 시스템** — 노드를 지날 때마다 층 +1 (층 = 행), 층별 노드 타입 등장 규칙
|
||||
3. **유물 방(보물 노드)** — 상자 열리는 모션 + 유물(`PickNewRelic`)·메소 획득, 노드 타입 `treasure` 추가
|
||||
4. **맵 UI 정비** — 노드 연결 점선(도트 3개 보간), 타입별 아이콘 색/라벨, 상태 4단(방문/현재/도달 가능/잠김)
|
||||
5. **메소 표기** — 화폐 표시 텍스트 "골드" → "메소" (메이플 IP, 표시만 — 내부 변수 Gold 유지)
|
||||
|
||||
비범위: 이벤트 노드(?), 경로 교차 방지(시각 교차 허용 — 점선이라 식별 가능), 저장.
|
||||
|
||||
## 절차 생성 알고리즘 (StS 방식)
|
||||
|
||||
그리드: 행 1~7 × 열 1~4 + 8행 보스 단일 노드. 노드 id = `"r{row}c{col}"`, 보스 = `"boss"`.
|
||||
|
||||
1. 시작 열: {1,2,3,4} 셔플 후 앞 2개 = 경로 1·2의 시작(서로 다름 보장), 경로 3·4는 랜덤
|
||||
2. 경로 4개를 각각 1행→7행으로 걸어 올림: 다음 열 = `clamp(열 + random(-1..1), 1, 4)`. 지나는 칸마다 노드 생성(중복은 병합), `(r,c) → (r+1,c')` 간선 추가(중복 간선 병합)
|
||||
3. 7행의 모든 노드 → 보스 간선
|
||||
4. `MapStart` = 1행에 생성된 노드들
|
||||
|
||||
### 타입 배정 (행 오름차순)
|
||||
|
||||
| 행 | 규칙 |
|
||||
|---|---|
|
||||
| 1~2행 | combat 고정 |
|
||||
| 3행 | combat 45 / shop 12 / rest 12 (가중 추첨, 합계로 정규화) |
|
||||
| 4~6행 | combat 45 / elite 16 / shop 12 / rest 12 / treasure 15 |
|
||||
| 7행 (보스 직전) | rest 50 / combat 25 / shop 10 / elite 8 / treasure 7 |
|
||||
| 8행 | boss 고정 |
|
||||
|
||||
추가 제약: **부모(간선으로 들어오는 이전 행 노드) 중 elite가 있으면 해당 노드는 elite 금지** (연속 엘리트 방지).
|
||||
|
||||
### 층 카운터
|
||||
|
||||
`Depth` prop: `PickNode` 시 `Depth = node.row`. 막 전환·런 시작 시 0. TopBar Floor 텍스트를 `막 F/3 · D층`으로 확장.
|
||||
|
||||
### 노드 enemy 필드 제거
|
||||
|
||||
몬스터는 P4 이후 `node.type` 그룹 필터(`BuildMonsters`)로 결정되므로 `enemy` 필드·`CurrentEnemyId` 대입은 제거(`CurrentEnemyId = ""` 유지). `data/map.json`·`luaMapNodesTable`·`luaStartArray`·`MAX_ROW` 제거.
|
||||
|
||||
## 유물 방 (TreasureHud)
|
||||
|
||||
- `PickNode` 분기에 `treasure` → `ShowTreasure` 추가 (`ShowState("treasure")`·`HideGameHud` 등록)
|
||||
- UI: 어두운 패널 + 타이틀("보물 상자") + 중앙 상자 버튼(닫힌 상자 RUID) + 보상 텍스트(초기 숨김) + 나가기 버튼
|
||||
- `OpenChest`: 1회 가드(`ChestOpened`) → **흔들림 모션**(anchoredPosition ±8px, 0.08s × 6스텝 타이머 체인) → **열린 상자 RUID로 교체** → 보상 지급·표시
|
||||
- 메소: `40 + random(0..20)`
|
||||
- 유물: `PickNewRelic()` — 미보유 추첨, 전부 보유 시 메소 +30 대체
|
||||
- 보상 텍스트 예: `유물 획득: 황소 투구 · 메소 +52`
|
||||
- 상자 닫힘/열림 스프라이트는 공식 maplestory 리소스 검색("상자"/"보물상자") 후 메이커 선별
|
||||
- 나가기 → `LeaveNode`(기존 → ShowMap)
|
||||
|
||||
## 맵 UI
|
||||
|
||||
### 정적 그리드 (생성기, MapHud 섹션 재작성)
|
||||
|
||||
- 노드 버튼 28개(`Node_r{1..7}c{1..4}`, 56×56) + `Node_boss`(72×72, 상단 중앙) + 각 노드 `Label`(타입 한글)
|
||||
- 배치: 행 y = `-330 + (row-1)*105` (보스 y=405), 열 x = `-270 + (col-1)*180`, 보스 x=0
|
||||
- 점선 도트: 모든 가능 간선(행 1~6: `c→c±1/c`, 행 7→보스)에 대해 도트 3개(8×8, t=0.25/0.5/0.75 보간 위치). 엔티티 `Dot_r{r}c{c}_{c'}_{k}` (보스행은 `Dot_r7c{c}_b_{k}`)
|
||||
- 모든 노드·도트는 기본 비활성, `RenderMap`이 토글
|
||||
|
||||
### RenderMap 재작성 (상태 4단 + 점선)
|
||||
|
||||
- 노드: 맵에 없으면 숨김. 있으면 Label = 타입명, 색:
|
||||
- 방문(`VisitedNodes`에 포함) → 어둡게 `(0.18,0.19,0.22)`
|
||||
- 현재 위치 → 골드 `(0.95,0.8,0.3)`
|
||||
- 도달 가능(IsReachable) → 타입색 밝게 + 버튼 활성
|
||||
- 잠김 → 타입색 45% 어둡게 + 버튼 비활성
|
||||
- 타입색: 전투 `(0.78,0.36,0.32)` / 엘리트 `(0.62,0.4,0.85)` / 상점 `(0.9,0.75,0.35)` / 휴식 `(0.4,0.75,0.45)` / 보물 `(0.35,0.7,0.75)` / 보스 `(0.85,0.25,0.25)`
|
||||
- 도트: 간선 존재 시 표시. 현재 노드(또는 시작 전 1행 진입선)에서 나가는 간선 = 골드, 그 외 = 회색 `(0.5,0.5,0.55)`
|
||||
- `VisitedNodes`(any prop): PickNode 시 추가
|
||||
|
||||
### 메소 표기
|
||||
|
||||
표시 문자열 "골드" → "메소": TopBar Gold·ShopHud Gold·상점 가격(`N 메소`)·유물 소진 토스트. 내부 prop `Gold` 유지.
|
||||
|
||||
## 검증
|
||||
|
||||
1. **JS 미러 + 단위 테스트**: `tools/map/rogue-map.mjs`에 `generateMap(rng)` 동일 로직(시드 PRNG 주입) + `rogue-map.test.mjs` — 모든 노드가 시작점에서 도달 가능·보스 수렴·1~2행 combat만·elite/treasure 4행부터·간선 인접 열만·elite 부모 연속 금지·결정성(동일 시드 동일 맵). ⚠️ Lua `GenerateMap`과 로직 동기화 유지(sim-balance 패턴)
|
||||
2. 기존 `sim-balance` 21건 유지 (맵과 무관)
|
||||
3. 메이커: 빌드 0에러, 플레이테스트 — 맵 생성/점선/상태색, 노드 진행(층 증가), 유물 방 상자 연출·보상, 보스 클리어 → 다음 막 새 맵
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 경로 4개·8층×4열 (사용자 승인 규모)
|
||||
- 점선 도트 방식 채택 (UI 회전 리스크 회피, StS 원작 미감)
|
||||
- 시각적 간선 교차는 허용 (점선이라 추적 가능 — YAGNI)
|
||||
- `RUN_LENGTH`/`ACT_MAPS` 막 시스템은 변경 없음 (막마다 새 맵 생성만 추가)
|
||||
@@ -0,0 +1,97 @@
|
||||
# P14 — 반복 런 · 로비 · 영혼 · 도적 · 몬스터 랜덤성 설계
|
||||
|
||||
> 작성 2026-06-13. 사용자 자율 실행 지시(Phase별 커밋 → 최종 push/PR)에 따라 인터랙티브 승인 게이트 없이
|
||||
> 설계 결정을 본 문서에 기록·커밋하고 순차 구현한다. 산출물(`ui/DefaultGroup.ui`·`*.codeblock`·`*.map`·
|
||||
> `*.gamelogic`)은 생성기(`tools/`)·데이터(`data/`)에서 100% 생성되므로 본 작업은 전부 소스만 수정한다(RULES.md).
|
||||
|
||||
## 목표
|
||||
|
||||
기존 P1~P13 단발 런 구조를, **로비 허브를 중심으로 반복 수행하는 로그라이트 루프**로 재편하고
|
||||
도적 직업·몬스터 랜덤성·영혼 메타 성장·카드 UX를 추가한다.
|
||||
|
||||
## 핵심 설계 결정 (요약)
|
||||
|
||||
1. **맵 5개 + 반복 루프**: 런은 map01~map05 5막. 최종 보스 클리어 시 무한 진행이 아니라 **로비로 복귀**해
|
||||
영혼을 정산하고 다음 런을 준비. "반복 수행이 메인"을 *로비 기점 반복 런*으로 해석.
|
||||
2. **로비 = 스크린 HUD**: 게임 전체가 이미 스크린 HUD(MapHud/ShopHud/RewardHud) 구동이고 물리 맵은 배경일 뿐이다.
|
||||
로비도 동일하게 `LobbyHud`(스크린)로 구현하고, NPC는 클릭 가능한 스프라이트 버튼으로 배치한다.
|
||||
NPC 4종: **도감(Codex)·상점(영혼 메타)·런 시작·게시판(채팅 대용)**. 첫 실행/패배/클리어 시 진입점.
|
||||
3. **영혼(Soul)**: 승천(패널티 누적)과 역할 분리된 **영구 강화 메타 화폐**.
|
||||
*2차 전직을 한 상태로* 맵 보스를 클리어할 때마다 누적. 로비 상점 NPC에서 해금 구매 → 다음 런에 이점.
|
||||
저장은 승천 RPC 패턴 복제(`UserDataStorage`, key `soulPoints`/`soulUnlocks`).
|
||||
4. **도적**: `bandit` 프레임이 이미 데이터에 준비됨. 도적 1차 + 2차(어쌔신/시프) 카드·스타터덱 신규.
|
||||
5. **몬스터 랜덤성**: 구성(일반 1~3 / 엘리트 1+일반 0~2 / 보스 1)과 행동(정의된 intent 중 랜덤)을 런타임 추첨.
|
||||
StS2식 "덱 오염" intent(`AddCard`)와 저주 카드 신규.
|
||||
6. **메소**: 표면 문자열은 이미 메소. 잔존 `goldIdol.desc` 정정 + 메소 코인 아이콘 추가(내부 식별자 `Gold`는 유지).
|
||||
|
||||
## Phase 구성 (각 Phase = 1+ 커밋, 소스 수정 → 재생성 → 카운트/테스트 검증)
|
||||
|
||||
### Phase 1 — 맵 5막 · depth 7 · 노드 인접 규칙
|
||||
- `gen-slaydeck.mjs`: `MAP_ROWS 7→6`(걷는행6+보스=총7), `ACT_COUNT 3→5`, `ACT_MAPS [map01..map05]`, `RUN_LENGTH 3→5`.
|
||||
- 노드 타입 인접 금지 확장: 현재 elite만 부모-자식 연속 금지 → **rest·shop·elite** 3종 모두 금지.
|
||||
`GenerateMap`(Lua 4333-4358) + `rogue-map.mjs`(67-72) 미러 + `rogue-map.test.mjs` 단언 추가.
|
||||
- 막 배율 `1+(Floor-1)*0.6`(`:2776`)을 5막 기준 `1+(Floor-1)*0.45`로 완화.
|
||||
- 맵 파일 11→5: 생성기 카운트(`gen-maps`/`gen-map-encounters`/`gen-combat-monster`/`freeze-turn-monsters`/
|
||||
`gen-camera`/`gen-player-lock`) `[2..11]→[2..5]`, `length:11→5`. `git rm map/map06..11.map`.
|
||||
`Global/SectorConfig.config`에서 map06~11 엔트리 제거(생성기가 재구성하도록 보정 또는 수동 정리).
|
||||
|
||||
### Phase 2 — 노드 가로 레이아웃(왼→오른쪽)
|
||||
- `gen-slaydeck.mjs:1536-1538` 좌표 함수 row↔x·col↔y 스왑 + 호출부(1573/1600/1605) 인자 스왑. Lua 무수정.
|
||||
보스는 최우측 중앙. 도트 보간·간선·라벨 자동 추종.
|
||||
|
||||
### Phase 3 — 몬스터 랜덤 구성 · 랜덤 행동 · AddCard · map01 배치
|
||||
- `data/enemies.json`: 종별 `tier`(normal/elite/boss) 추가, 일부 종에 `AddCard` intent 추가.
|
||||
map01용 일반 5종 + 엘리트 1종 보장(slime/orange/blue/green mushroom/pig + mushmom 등 기존 활용).
|
||||
- `data/cards.json`: 저주/상태 카드 신규(`kind:"Status"`, `unplayable:true`, `curse:true`, `endTurnDamage?`).
|
||||
- `gen-slaydeck.mjs`: `BuildMonsters`(2780) 노드타입별 랜덤 구성, `EnemyActStep`(3603) 랜덤 intent 선택
|
||||
(예고=확정: 턴 종료 시 다음 행동 추첨 저장), `AddCard` intent 처리, `PlayCard` unplayable 가드,
|
||||
카드 직렬화(`luaCardsTable`)에 신규 필드, intent 직렬화(`luaIntentsArray`)에 `card`/`count`.
|
||||
- `sim-balance.mjs`+test: 랜덤 행동(rng)·AddCard 미러, 결정성 테스트 유지, 저주 unplayable 필터.
|
||||
- `gen-map-encounters.mjs`: map01 편입 + 일반5/엘리트1 레이아웃(오른쪽 배치). 엘리트 맵에 일반 혼합용 변형 배치.
|
||||
|
||||
### Phase 4 — 도적 클래스 + 2차(어쌔신/시프)
|
||||
- `data/cards.json`: 도적 1차(class `thief`) + 어쌔신(class `assassin`) + 시프(class `bandit`) 카드 + 스타터덱.
|
||||
- `data/cardframes.json`: `classToFrame`에 thief/assassin/bandit → `bandit` 프레임 매핑.
|
||||
- `gen-slaydeck.mjs`: `CLASSES.thief`(maxHp 75), `JOBS.thief`(어쌔신/시프), CharacterSelectHud Thief 해금,
|
||||
`BindMenuButtons` ThiefButton, `RenderCharacterSelect`/`StartNewGame`/`StartRun`/`JobLabel` 도적 분기.
|
||||
- 전사/법사 2차는 완비 확인됨(수정 불요).
|
||||
|
||||
### Phase 5 — 카드 스킬 아이콘 · 피격 이펙트 분리
|
||||
- `data/cards.json`: 공격 카드에 `fx`(이펙트 RUID) 필드 추가, `image`는 스킬 아이콘 유지.
|
||||
- `gen-slaydeck.mjs`: `luaCardsTable` fx 직렬화, `PlayCard`(3296-3298) FX 인자를 `c.fx or c.image`로.
|
||||
- RUID는 MSW 공식 리소스 asset 검색으로 수급(계정 업로드 금지·RULES §5).
|
||||
|
||||
### Phase 6 — 카드 UX: 핸드 최대 10 · 마우스오버 확대/툴팁
|
||||
- `gen-slaydeck.mjs`: CardHand 슬롯 5→10 확장, `RenderHand` 동적 간격(장수 비례), `DrawCards` 10장 상한
|
||||
(초과 분 자동 버림), 카드 hover 이벤트 바인딩(enter→`ShowTooltip`+스케일업, exit→복귀), 드래그 중 가드.
|
||||
- `sim-balance.mjs`+test: 드로우 상한 미러.
|
||||
|
||||
### Phase 7 — 메소 전환 + 메소 아이콘
|
||||
- `data/relics.json`: `goldIdol.desc` "골드"→"메소".
|
||||
- `gen-slaydeck.mjs`: TopBar·ShopHud 메소 텍스트 옆 코인 아이콘 sprite 추가(공식 RUID).
|
||||
|
||||
### Phase 8 — 로비 + NPC + 반복 루프
|
||||
- `gen-slaydeck.mjs`: `LobbyHud` 섹션(배경 + NPC 4종 스프라이트 버튼), `ShowLobby`/`ShowState("lobby")`,
|
||||
NPC 핸들러(Codex→CodexHud, Shop→영혼상점, RunStart→CharacterSelect, Board→게시판 패널).
|
||||
`CodexHud`: 전 카드 도감(클래스별 그리드). `OnBeginPlay`→`ShowLobby`(메뉴 대체), `EndRun`→`ShowLobby`.
|
||||
첫 실행/패배/클리어 모두 로비 기점.
|
||||
|
||||
### Phase 9 — 영혼(Soul) 시스템
|
||||
- `gen-slaydeck.mjs`: `SoulPoints`/`SoulUnlocks` 프로퍼티, RPC `ReqLoadSouls`/`SaveSouls`/`RecvSouls`(ExecSpace 5/6),
|
||||
보스 클리어 & `PlayerJob~=""`일 때 영혼 가산(`ContinueAfterBoss`/`CheckCombatEnd`), 로비 영혼 상점 UI·구매,
|
||||
해금 효과를 `StartRun`에 적용(시작 메소/시작 유물/시작 HP/덱 강화 등 덱빌딩 이점).
|
||||
|
||||
### Final — 전체 재생성 · 테스트 · push · PR
|
||||
- 전 생성기 재생성, `node --test`(rogue-map·sim-balance), 카운트 검증, 가능 시 메이커 플레이테스트.
|
||||
- `tools/git/gitea-pr.mjs`로 UTF-8 spec JSON 작성 후 PR 생성(RULES §4).
|
||||
|
||||
## 검증 원칙 (RULES §2·§6)
|
||||
- 산출물 본문 출력 금지 — `grep -c`/JSON parse/카운트만.
|
||||
- 전투·맵 규칙 수정 시 Lua↔JS 미러 동시 수정 + `node --test` 통과.
|
||||
- 커밋은 기능 단위, 산출물 재생성은 메시지에 명시.
|
||||
|
||||
## 알려진 제약 / 결정 근거
|
||||
- **로비 "돌아다니기"**: 물리 맵 walkable 로비는 player 이동이 전역 freeze(턴제)라 위험·고비용 → 스크린 HUD
|
||||
NPC 클릭으로 동일 기능 제공(아키텍처 정합). 추후 walkable 로비는 확장 슬롯.
|
||||
- **카드 아이콘/이펙트**: 현재 `c.image`가 카드 아트 겸 피격 FX로 이중 사용 중 → `fx` 분리로 의도 달성.
|
||||
- **영혼 vs 승천**: 승천=적 강화 패널티 토글, 영혼=플레이어 영구 강화 → 같은 저장소 다른 key로 공존.
|
||||
104
docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md
Normal file
104
docs/superpowers/specs/2026-06-14-lobby-map-npc-design.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 로비 맵 + 월드 NPC 설계 (P15)
|
||||
|
||||
작성일: 2026-06-14
|
||||
브랜치: `feature/p15-lobby-map-npc`
|
||||
|
||||
## 목표
|
||||
|
||||
기존 **UI 패널 로비**(P14-8 `LobbyHud` — 색칠된 `UIButton` 4개 행)를 폐기하고,
|
||||
**전용 로비 맵**에 **월드 NPC 엔티티 4종**을 배치한다. NPC를 누르면 각 기능이 실행되며,
|
||||
플레이어 **이동·공격 모션은 로비 맵에서만** 풀린다(전투/런 맵에서는 기존대로 잠김 유지).
|
||||
|
||||
요청 원문: "로비를 UI로 만들지 말고, map을 추가해서 로비에 각각 기능을 할 수 있는 npc를 추가하고,
|
||||
그 npc를 누르면 각 기능을 할 수 있도록 추가. 플레이어는 반드시 로비맵에서만 이동과 공격 모션을 풀어줘."
|
||||
|
||||
## 확정된 결정 (브레인스토밍)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| NPC 상호작용 | **근접 프롬프트+키(Up/Space) AND 직접 클릭** 둘 다 지원 |
|
||||
| NPC 기능 | **기존 4종 유지** + 외형을 **정식 maplestory NPC 스프라이트(공식 RUID)** 로 교체 |
|
||||
| 로비 맵 | **새 전용 로비 맵 추가**(`map/lobby.map`), 런 맵(map01~) 인덱스 불변 |
|
||||
|
||||
## 현재 상태 (조사 결과)
|
||||
|
||||
- **로비 = UI 패널** `LobbyHud`. NPC 4종은 색칠 `UIButton`, 전부 `tools/deck/gen-slaydeck.mjs`에 하드코딩.
|
||||
- `NpcRun`(모험가→런 시작), `NpcCodex`(사서→카드 도감), `NpcShop`(상인→영혼 상점), `NpcBoard`(안내원→게시판).
|
||||
- 정의 ~`gen-slaydeck.mjs:2469-2487`, 클릭 바인딩 `BindLobbyButtons()` ~`2997-3014`.
|
||||
- 기능 진입 메서드: `ShowCharacterSelect()`(~3147), `ShowCodex()`, `ShowSoulShop()`, `ShowBoard()` — **재사용 대상**.
|
||||
- **이동/공격 이중 잠금**:
|
||||
- `tools/player/freeze-turn-player.mjs` → `Global/DefaultPlayer.model` speed/jump/accel = 0 (**전역**).
|
||||
- `tools/player/gen-player-lock.mjs` → `PlayerLock` codeblock(`pc.Enable=false`, `FixedLookAt=true`)를 map01~05에 주입.
|
||||
- 공격은 순수 모션 `PlayerAttackMotion()`(StateComponent ATTACK→IDLE), 필드 몬스터와 무관(전투는 데이터 배열 UI 전투).
|
||||
- 플레이어 스폰: `TeleportToActMap()` → `Vector3(-6, 0.03, 0)`, Floor별 map 선택.
|
||||
- **맵 파이프라인**: map01 = 저작 템플릿, map02~05 = 복제 생성. 포털 없이 `TeleportToMapPosition` 전환.
|
||||
- `Global/SectorConfig.config`의 valid 목록에 맵 등록(현재 gen-maps.mjs가 갱신).
|
||||
- 컴포넌트 부착 패턴: `gen-combat-monster.mjs`가 맵 몬스터에 `script.CombatMonster`를 붙이고, 해당 codeblock이 OnBeginPlay에서 `/common` 컨트롤러에 자가등록.
|
||||
- **흐름**: `OnBeginPlay`→`ShowLobby()`(UI). `EndRun(text)`→4초 후 `ShowLobby()`.
|
||||
|
||||
## 접근법
|
||||
|
||||
**A. 새 로비 맵 + 월드 NPC 엔티티 (채택)** — 맵 템플릿 복제 재사용, NPC를 월드 엔티티로 배치하고
|
||||
각 NPC의 codeblock이 근접+클릭을 감지해 **기존 기능 패널**을 띄움. 이동/공격 해제는 로비 맵 전용 codeblock.
|
||||
전투맵은 손대지 않아 잠금 유지. (B: 몬스터 배치기 재활용 → 로직 혼재로 비채택. C: 화면고정 UI 버튼 → 거부된 "UI 로비"라 제외.)
|
||||
|
||||
## 상세 설계
|
||||
|
||||
### 1) 로비 맵 — `tools/map/gen-lobby-map.mjs` → `map/lobby.map`
|
||||
- map01 템플릿 deep clone → 경로 `/maps/lobby`로 치환, GUID 재발급(결정론 시드).
|
||||
- 마을/타운 배경 RUID + 타일셋 적용. **`script.Monster`/`script.CombatMonster` 엔티티 전부 제거**(전투 없음).
|
||||
- NPC 4종을 x축으로 벌려 월드 엔티티로 배치. 각 NPC:
|
||||
- 스프라이트 = 공식 maplestory NPC RUID(계정 업로드 금지 — 흰 박스). 구현 단계에서 asset 검색으로 4개 확정.
|
||||
- `script.LobbyNpc` 컴포넌트 + `NpcId`(`run`/`codex`/`shop`/`board`) + 머리 위 이름/`!` 프롬프트용 텍스트 노드.
|
||||
- 플레이어 스폰 지점(맵 중앙-좌측).
|
||||
- `Global/SectorConfig.config` valid 목록에 `map://lobby` 추가 — **SectorConfig 단일 소유자는 gen-maps.mjs**로 유지하고 lobby 항목을 그 상수에 포함(두 생성기 충돌 방지).
|
||||
|
||||
### 2) NPC 상호작용 — `tools/player/gen-lobby-npc.mjs` → `LobbyNpc` codeblock
|
||||
- **근접+키**: 매 틱(타이머) 로컬 플레이어와의 x거리 측정 → 임계 거리 내면 `!` 프롬프트 노드 활성 + `Up`/`Space` 입력 시 트리거.
|
||||
- **직접 클릭**: NPC 엔티티 클릭/터치 → 동일 트리거. (MSW 월드 엔티티 클릭 API는 구현 시 `mlua_api_retriever`로 확정: 엔티티 TouchEvent vs 스크린 오버레이 버튼 중 검증된 방식.)
|
||||
- 트리거 시 `_EntityService:GetEntityByPath("/common").SlayDeckController:OnLobbyNpcInteract(NpcId)` 호출(경로 기반 크로스 codeblock — CombatMonster 자가등록과 동일 패턴).
|
||||
- 한 번에 하나만 상호작용(다른 패널 열려 있으면 무시).
|
||||
|
||||
### 3) 이동·공격 잠금 해제 (로비 맵 한정) — `LobbyMobility` codeblock
|
||||
- **`map/lobby.map`에만** 주입(전투맵 PlayerLock/전역 freeze는 불변).
|
||||
- OnBeginPlay 런타임 복원: 로컬 플레이어 `MovementComponent`(InputSpeed/JumpForce) 정상값, `PlayerController.Enable=true`, `FixedLookAt=false`.
|
||||
- 공격 입력(키/클릭) → 기존 `PlayerAttackMotion()`(코스메틱) 바인딩. **필드 타격 없음**.
|
||||
- 전투맵 텔레포트 시 모델 기본값(speed=0)+PlayerLock 재적용 → **"로비맵에서만"을 구조적으로 보장**.
|
||||
- 런타임 이동/공격 복원 정확한 API는 구현 단계에서 `mlua_api_retriever`로 확정.
|
||||
- 생성기 배치는 `gen-lobby-npc.mjs`에 함께 둘지 별도 `gen-lobby-unlock.mjs`로 분리할지는 계획에서 결정(둘 다 lobby 맵 전용 codeblock).
|
||||
|
||||
### 4) 흐름 통합 — `tools/deck/gen-slaydeck.mjs`
|
||||
- **OnBeginPlay**: `ShowLobby()`(UI) → **로비 맵 텔레포트** + 경량 "lobby" 상태(전투/상점/맵 HUD 숨김).
|
||||
- **EndRun**: 4초 후 `ShowLobby()` → **로비 맵 텔레포트 복귀**.
|
||||
- **OnLobbyNpcInteract(id)** 신규: `run`→`ShowCharacterSelect()`, `codex`→`ShowCodex()`, `shop`→`ShowSoulShop()`, `board`→`ShowBoard()`(전부 기존 메서드 재사용, 패널은 로비 맵 위 팝업).
|
||||
- **제거**: `LobbyHud` 버튼-행 허브 패널 + `BindLobbyButtons`.
|
||||
- **유지**: 영혼 포인트·승천 표시는 화면 모서리 **미니 HUD**(정보 표시 필요). 기능 패널 4종은 NPC 트리거.
|
||||
- 런 시작(`StartRun`/`TeleportToActMap`)·전투 흐름은 불변.
|
||||
|
||||
### 5) 미러/테스트 영향
|
||||
- 이동/공격 해제·NPC 배치는 **전투 규칙도 맵 그래프 생성 알고리즘도 아님** → `sim-balance.mjs`/`rogue-map.mjs` JS 미러 동기화 **불필요**(RULES.md §6은 그 둘만 요구).
|
||||
- 검증(카운트만): `lobby.map` 내 NPC 엔티티 수, 산출물의 `LobbyNpc`/`LobbyMobility`/`OnLobbyNpcInteract` 개수, SectorConfig `map://lobby` 존재. 내용 출력 금지.
|
||||
- 동작 검증: 메이커 플레이테스트.
|
||||
|
||||
## 검증 시나리오 (메이커)
|
||||
1. 월드 시작 → **로비 맵에 스폰**, 이동 가능, 공격 모션 가능.
|
||||
2. NPC 근접 → `!` 프롬프트 → `Up/Space`로 기능 패널 오픈. 직접 클릭으로도 오픈.
|
||||
3. 4종 각각: 모험가→직업선택→런 시작, 사서→도감, 상인→영혼상점, 안내원→게시판.
|
||||
4. 런 시작 → map01 텔레포트, **이동/공격 잠김**.
|
||||
5. 런 종료(클리어/패배) → **로비 맵 복귀**, 이동/공격 재해제.
|
||||
6. 미니 HUD에 영혼/승천 표시 정상.
|
||||
|
||||
## 리스크
|
||||
- MSW 런타임 이동 재활성 API 가용성 → 계획 단계 `mlua_api_retriever` 검증.
|
||||
- MSW 월드 엔티티 클릭 감지 방식 → 동일 검증(불가 시 근접+키만으로 폴백, 직접 클릭은 스크린 오버레이 버튼으로 구현).
|
||||
- 텔레포트 복귀 좌표/스폰 위치 정합.
|
||||
- 메이커 stale 상태 — git pull 후 로컬 워크스페이스 reload 필수(RULES.md §5).
|
||||
|
||||
## 생성기/파일 변경 요약
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `tools/map/gen-lobby-map.mjs` | **신규** — lobby.map(배경/타일/NPC 엔티티/스폰), SectorConfig 조율 |
|
||||
| `tools/player/gen-lobby-npc.mjs` | **신규** — LobbyNpc 상호작용 codeblock(+LobbyMobility 또는 분리) |
|
||||
| `tools/deck/gen-slaydeck.mjs` | OnBeginPlay/EndRun 로비맵 전환, OnLobbyNpcInteract, 버튼-행 허브 제거, 미니 HUD |
|
||||
| `Global/SectorConfig.config` | map://lobby 등록(생성 산출물) |
|
||||
| `map/lobby.map`, `ui/DefaultGroup.ui`, `*.codeblock` | 재생성 산출물 |
|
||||
96
docs/superpowers/specs/2026-06-15-node-map-ui-design.md
Normal file
96
docs/superpowers/specs/2026-06-15-node-map-ui-design.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 노드 맵 UI 강화 설계
|
||||
|
||||
작성일: 2026-06-15
|
||||
브랜치: `feature/node-map-ui`
|
||||
|
||||
## 목표
|
||||
|
||||
맵 노드 선택 화면(`MapHud`)을 **단색 박스+텍스트** → **공식 메이플 아이콘 노드 + 배경 이미지**로 강화한다.
|
||||
절차 랜덤 배치·간선·진행 로직은 그대로. 아이콘/배경은 **`data/nodeicons.json` 한 파일로 외부화**해 나중에 RUID만 바꿔 재생성하면 교체되도록 한다.
|
||||
|
||||
요청 원문: "노드 창이 단순 네모 박스안에 텍스트 … 백그라운드 이미지 삽입하고 특정 아이콘을 지정해서 노드로 … 랜덤 배치 … 노드 맵 UI 강화. 내가 나중에 변경할 수도 있으니 변경이 쉽게 가능하도록."
|
||||
|
||||
## 확정된 결정 (브레인스토밍)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| 노드 표현 | **아이콘만**(박스 제거). 상태는 아이콘 틴트로 |
|
||||
| 배경 | **공식 메이플 배경 이미지** + 반투명 어두운 오버레이 |
|
||||
| 아이콘 세트 | 사용자 확정(아래 표). 공식 maplestory RUID, 썸네일 검수 완료 |
|
||||
| 변경 용이성 | 모든 RUID를 `data/nodeicons.json`로 외부화 → 편집+재생성으로 교체 |
|
||||
|
||||
### 확정 아이콘/배경 (공식 maplestory, 흰박스 위험 없음)
|
||||
|
||||
| 노드 타입 | 아이콘 | RUID |
|
||||
|---|---|---|
|
||||
| combat(전투) | 주황버섯 | `f98db6823e894a4f90308d61f75894ac` |
|
||||
| elite(엘리트) | 돌골렘(Stumpy) | `793ed8a757534b89a82f460747d2df24` |
|
||||
| boss(보스) | 주니어 발록 | `423056cdbbc04f4da131b9721c404d96` |
|
||||
| shop(상점) | 보라 돈주머니 | `da37e1fac55d455b9ade08569f09f798` |
|
||||
| rest(휴식) | 모닥불 | `b86c1b0568bd45f3ae4a4b97e1b4a594` |
|
||||
| treasure(보물) | 금별 보물상자 | `f8a6d58e20f54e2ca899485055df1ce4` |
|
||||
| **background** | 리스항구 | `d84241f17de344a097f5b96ac914f1d2` |
|
||||
|
||||
## 현재 구조 (조사 결과)
|
||||
|
||||
- `MapHud` 루트 = 1920×1080 **단색** 패널(`gen-slaydeck.mjs:1664`, 배경 이미지 없음) + 타이틀.
|
||||
- 노드 = `pushMapNode(id,pos,size,label)`(`:1696`) — `Node_{id}` 단색 박스(56×56, 보스 72×72) + `Label` 텍스트 자식. 그리드 `r1c1~r6c4`(24) + `boss`(`:1727`).
|
||||
- 타입 6종: combat/elite/shop/rest/treasure/boss. 타입→색/라벨은 **Lua `RenderMapNode`**(`:5626~5677`)가 런타임에 박스 `Color` + Label 텍스트로 채움. 상태 4단(현재 금색/방문 회색/도달 타입색/잠김 어둡게).
|
||||
- 절차 생성 `GenerateMap`(`:5505`) → `self.MapNodes[id]={type,row,col,next}`, id `r{r}c{c}`가 UI 엔티티와 1:1. 버튼 바인딩(`:3597`)은 경로 기반.
|
||||
- 이미지 주입 패턴: emit `sprite({dataId: RUID, type:0})`(`sprite()` 헬퍼 `:297`) / 런타임 `e.SpriteGUIRendererComponent.ImageRUID = "<ruid>"`(`ApplyCardFace :4089`, chest `:5874`). 카드 프레임은 `data/cardframes.json`→`luaFramesTable()`(`:72`)→`self.CardFrames` Lua 테이블.
|
||||
|
||||
## 상세 설계
|
||||
|
||||
### 1) `data/nodeicons.json` (신설 — 단일 소스)
|
||||
```json
|
||||
{
|
||||
"icons": {
|
||||
"combat": "f98db6823e894a4f90308d61f75894ac",
|
||||
"elite": "793ed8a757534b89a82f460747d2df24",
|
||||
"boss": "423056cdbbc04f4da131b9721c404d96",
|
||||
"shop": "da37e1fac55d455b9ade08569f09f798",
|
||||
"rest": "b86c1b0568bd45f3ae4a4b97e1b4a594",
|
||||
"treasure": "f8a6d58e20f54e2ca899485055df1ce4"
|
||||
},
|
||||
"background": "d84241f17de344a097f5b96ac914f1d2"
|
||||
}
|
||||
```
|
||||
- 사용자가 나중에 RUID만 바꾸고 `node tools/deck/gen-slaydeck.mjs` 재실행하면 교체됨. (README/주석에 명시.)
|
||||
|
||||
### 2) `gen-slaydeck.mjs` — 로드·검증·직렬화
|
||||
- 상단에서 `NODEICONS = JSON.parse(readFileSync('data/nodeicons.json'))` 로드.
|
||||
- **fail-fast 검증**: `icons`에 6타입(combat/elite/boss/shop/rest/treasure) 전부 존재 + 32hex RUID, `background` 존재. 누락 시 throw(카드프레임 검증과 동일 패턴).
|
||||
- `luaNodeIconsTable()` 헬퍼: `self.NodeIcons = { combat="...", ... }` Lua 테이블 문자열. OnBeginPlay init에 주입(CardFrames 패턴, `:2906/3361` 인접). `prop('any','NodeIcons')` 선언.
|
||||
|
||||
### 3) MapHud emit 변경
|
||||
- **배경 자식 `MapHud/Bg`**: 루트 직후 push. `uisprite`, 1920×1080, `dataId = NODEICONS.background`, `type:0`, 흰색, `raycast:false`, displayOrder 최하(0). 항상 enable.
|
||||
- **루트 오버레이**: 기존 루트 단색을 **반투명 어두운 오버레이**로(예: `{r:0.04,g:0.05,b:0.08,a:0.55}`)— 배경이 비치되 노드 가독성 확보. raycast 유지(뒤 월드 클릭 차단).
|
||||
- **`pushMapNode` → 아이콘 노드**: `Node_{id}` 본체를 박스 대신 **아이콘 스프라이트**로 — `sprite({ color:{1,1,1,1}, type:0, raycast:true })`(emit 시 dataId 미지정, 런타임에 타입별 ImageRUID 주입) + `button()`. **`Label` 자식 제거**(아이콘만). 노드 크기 키움: 그리드 64×64, 보스 88×88. (좌표 헬퍼 `nodeX/nodeY`·그리드 생성 루프·버튼 바인딩은 불변.)
|
||||
|
||||
### 4) RenderMapNode Lua 변경
|
||||
- 타입→박스색/라벨 매핑(`:5630~5656`) 제거. 대신:
|
||||
- `e.SpriteGUIRendererComponent.ImageRUID = self.NodeIcons[type]` (없으면 combat 폴백).
|
||||
- 상태별 `Color` 틴트(박스가 아니라 **아이콘**에):
|
||||
- 현재(`CurrentNodeId`): `Color(1, 0.82, 0.3, 1)` 금색
|
||||
- 도달가능: `Color(1, 1, 1, 1)` 원색 + `ButtonComponent.Enable=true`
|
||||
- 방문: `Color(0.5, 0.5, 0.55, 0.9)` 회색
|
||||
- 잠김: `Color(0.4, 0.4, 0.45, 0.45)` 어둡고 반투명 + 버튼 비활성
|
||||
- `SetText(.../Label ...)` 호출 제거(라벨 없음). 간선 도트(`RenderMapDots`)·`RenderMap` 루프는 불변.
|
||||
|
||||
### 5) 미러/테스트 영향
|
||||
- 전투 규칙·맵 그래프 알고리즘 **미변경** → `sim-balance`/`rogue-map` 미러 동기화 불필요.
|
||||
- 검증(카운트): `MapHud/Bg` 1개, `NodeIcons` 주입, 노드 ImageRUID 주입 코드 존재, 6 RUID 등장. 내용 출력 금지(`tools/verify/count.mjs`).
|
||||
- 동작: 메이커 플레이테스트(아이콘 렌더·상태 틴트·랜덤 배치·노드 클릭 진행).
|
||||
|
||||
## 리스크
|
||||
- **아이콘 비정사각/큰 스프라이트** → 64px UI에서 잘림/왜곡 가능(보스 발록은 확인됨, 골렘·버섯은 정사각 양호). type 0 렌더의 aspect 처리 확인, 필요 시 노드별 size 패딩 조정.
|
||||
- **아이콘만 상태 가독성**: 잠김/방문 틴트 대비가 약하면 플레이테스트로 알파/명도 튜닝.
|
||||
- **배경 오버레이 알파**: 너무 밝으면 노드가 묻힘 — 0.5~0.65 사이 튜닝.
|
||||
- 흰박스: 전부 공식 maplestory(검증) — 위험 없음. 단 로컬 워크스페이스 reload 필요.
|
||||
|
||||
## 변경 파일 요약
|
||||
| 파일 | 변경 |
|
||||
|---|---|
|
||||
| `data/nodeicons.json` | **신설** — 아이콘 6 + 배경 RUID (단일 소스) |
|
||||
| `tools/deck/gen-slaydeck.mjs` | 로드·검증·luaNodeIconsTable, MapHud Bg/오버레이, pushMapNode 아이콘화, RenderMapNode ImageRUID+틴트 |
|
||||
| `ui/DefaultGroup.ui`·`SlayDeckController.codeblock` | 재생성 산출물 |
|
||||
@@ -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,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` | **무변경**(바이트 동일이 합격 기준) |
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Id": "",
|
||||
"GameId": "",
|
||||
"EntryKey": "map://map09",
|
||||
"EntryKey": "map://lobby",
|
||||
"ContentType": "x-mod/map",
|
||||
"Content": "",
|
||||
"Usage": 0,
|
||||
@@ -14,12 +14,12 @@
|
||||
"Use": "Binary",
|
||||
"Entities": [
|
||||
{
|
||||
"id": "00002328-0000-4000-8000-000000002328",
|
||||
"path": "/maps/map09",
|
||||
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.PlayerLock,script.MapCamera",
|
||||
"id": "000dbba0-0000-4000-8000-0000000dbba0",
|
||||
"path": "/maps/lobby",
|
||||
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.LobbyMobility",
|
||||
"jsonString": {
|
||||
"name": "map09",
|
||||
"path": "/maps/map09",
|
||||
"name": "lobby",
|
||||
"path": "/maps/lobby",
|
||||
"nameEditable": false,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -1105,11 +1105,7 @@
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.PlayerLock",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.MapCamera",
|
||||
"@type": "script.LobbyMobility",
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
@@ -1117,12 +1113,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00002329-0000-4000-8000-000000002329",
|
||||
"path": "/maps/map09/Background",
|
||||
"id": "000dbba1-0000-4000-8000-0000000dbba1",
|
||||
"path": "/maps/lobby/Background",
|
||||
"componentNames": "MOD.Core.BackgroundComponent",
|
||||
"jsonString": {
|
||||
"name": "Background",
|
||||
"path": "/maps/map09/Background",
|
||||
"path": "/maps/lobby/Background",
|
||||
"nameEditable": false,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -1140,7 +1136,7 @@
|
||||
"b": 0.5019608,
|
||||
"a": 0.7058824
|
||||
},
|
||||
"TemplateRUID": "f9e546932b014c0e867365a796c8dc91",
|
||||
"TemplateRUID": "65c4167ea7484196b890022354e5a4a4",
|
||||
"Type": 1,
|
||||
"WebUrl": "eab37efa7f0d400f94259a2df836eb8a",
|
||||
"Enable": true
|
||||
@@ -1150,12 +1146,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000232a-0000-4000-8000-00000000232a",
|
||||
"path": "/maps/map09/MapleMapLayer",
|
||||
"id": "000dbba2-0000-4000-8000-0000000dbba2",
|
||||
"path": "/maps/lobby/MapleMapLayer",
|
||||
"componentNames": "MOD.Core.MapLayerComponent",
|
||||
"jsonString": {
|
||||
"name": "MapleMapLayer",
|
||||
"path": "/maps/map09/MapleMapLayer",
|
||||
"path": "/maps/lobby/MapleMapLayer",
|
||||
"nameEditable": false,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -1186,12 +1182,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000232b-0000-4000-8000-00000000232b",
|
||||
"path": "/maps/map09/TileMap",
|
||||
"id": "000dbba3-0000-4000-8000-0000000dbba3",
|
||||
"path": "/maps/lobby/TileMap",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.TileMapComponent",
|
||||
"jsonString": {
|
||||
"name": "TileMap",
|
||||
"path": "/maps/map09/TileMap",
|
||||
"path": "/maps/lobby/TileMap",
|
||||
"nameEditable": false,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -1245,7 +1241,7 @@
|
||||
"SortingLayer": "MapLayer0",
|
||||
"TileMapVersion": 1,
|
||||
"TileSetRUID": {
|
||||
"DataId": "48afa7d90aa24fadae9c52f30977342e"
|
||||
"DataId": "9dfea3808bbd49a5877d8624df21b1c7"
|
||||
},
|
||||
"Tiles": [
|
||||
{
|
||||
@@ -6288,12 +6284,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000232c-0000-4000-8000-00000000232c",
|
||||
"path": "/maps/map09/SpawnLocation",
|
||||
"id": "000dbba4-0000-4000-8000-0000000dbba4",
|
||||
"path": "/maps/lobby/SpawnLocation",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.SpriteRendererComponent,MOD.Core.SpawnLocationComponent",
|
||||
"jsonString": {
|
||||
"name": "SpawnLocation",
|
||||
"path": "/maps/map09/SpawnLocation",
|
||||
"path": "/maps/lobby/SpawnLocation",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6364,12 +6360,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000232d-0000-4000-8000-00000000232d",
|
||||
"path": "/maps/map09/Monster1",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack,script.CombatMonster",
|
||||
"id": "000dbba5-0000-4000-8000-0000000dbba5",
|
||||
"path": "/maps/lobby/NpcRun",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
|
||||
"jsonString": {
|
||||
"name": "Monster1",
|
||||
"path": "/maps/map09/Monster1",
|
||||
"name": "NpcRun",
|
||||
"path": "/maps/lobby/NpcRun",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6379,17 +6375,17 @@
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "StaticMonster",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "0000232d-0000-4000-8000-00000000232d",
|
||||
"root_entity_id": "000dbba5-0000-4000-8000-0000000dbba5",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "staticmonster",
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 3.5,
|
||||
"x": -3,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
@@ -6409,9 +6405,9 @@
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "ed3908e24d694bb786023fc1ed073489",
|
||||
"hit": "4763c9bebc9245998c9c499b6316aa9f",
|
||||
"die": "b168793b92a844a3a3a6f4ce647a14d2"
|
||||
"stand": "122095fd155c4633867b0da4f375bc3c",
|
||||
"hit": "122095fd155c4633867b0da4f375bc3c",
|
||||
"die": "122095fd155c4633867b0da4f375bc3c"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
@@ -6421,109 +6417,37 @@
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "ed3908e24d694bb786023fc1ed073489",
|
||||
"SpriteRUID": "122095fd155c4633867b0da4f375bc3c",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.78,
|
||||
"y": 0.86
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.03999999,
|
||||
"y": 0.43
|
||||
},
|
||||
"CollisionGroup": {
|
||||
"Id": "8992acd1e8cd45838db6f10a7b41df09"
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"Enable": false,
|
||||
"InputSpeed": 0
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"@type": "MOD.Core.TouchReceiveComponent",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
"AutoFitToSize": true
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"@type": "script.LobbyNpc",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "orange_mushroom",
|
||||
"Group": "combat"
|
||||
"NpcId": "run",
|
||||
"MarkName": "NpcRunMark"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0000232e-0000-4000-8000-00000000232e",
|
||||
"path": "/maps/map09/Monster2",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack,script.CombatMonster",
|
||||
"id": "000dbba6-0000-4000-8000-0000000dbba6",
|
||||
"path": "/maps/lobby/NpcRunMark",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
|
||||
"jsonString": {
|
||||
"name": "Monster2",
|
||||
"path": "/maps/map09/Monster2",
|
||||
"name": "NpcRunMark",
|
||||
"path": "/maps/lobby/NpcRunMark",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6533,17 +6457,88 @@
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "StaticMonster",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "0000232e-0000-4000-8000-00000000232e",
|
||||
"root_entity_id": "000dbba6-0000-4000-8000-0000000dbba6",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "staticmonster",
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 5.5,
|
||||
"x": -3,
|
||||
"y": 1.6349999800000001,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"hit": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"die": "bd4afdde295f40318fceb4166978ebaa"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "000dbba7-0000-4000-8000-0000000dbba7",
|
||||
"path": "/maps/lobby/NpcCodex",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
|
||||
"jsonString": {
|
||||
"name": "NpcCodex",
|
||||
"path": "/maps/lobby/NpcCodex",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "000dbba7-0000-4000-8000-0000000dbba7",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": -0.5,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
@@ -6563,9 +6558,9 @@
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "48c10437ae8344a9b2a1d3f36185728f",
|
||||
"hit": "9044063647854f5e9128efcf80e909be",
|
||||
"die": "f414577d18c94cc387c275df4abdbc3b"
|
||||
"stand": "4c264be6a64f4ac3970b2e6818d04e40",
|
||||
"hit": "4c264be6a64f4ac3970b2e6818d04e40",
|
||||
"die": "4c264be6a64f4ac3970b2e6818d04e40"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
@@ -6575,97 +6570,402 @@
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "48c10437ae8344a9b2a1d3f36185728f",
|
||||
"SpriteRUID": "4c264be6a64f4ac3970b2e6818d04e40",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.78,
|
||||
"y": 0.86
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.03999999,
|
||||
"y": 0.43
|
||||
},
|
||||
"CollisionGroup": {
|
||||
"Id": "8992acd1e8cd45838db6f10a7b41df09"
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"Enable": false,
|
||||
"InputSpeed": 0
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"@type": "MOD.Core.TouchReceiveComponent",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
"AutoFitToSize": true
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"@type": "script.LobbyNpc",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
"NpcId": "codex",
|
||||
"MarkName": "NpcCodexMark"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"id": "000dbba8-0000-4000-8000-0000000dbba8",
|
||||
"path": "/maps/lobby/NpcCodexMark",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
|
||||
"jsonString": {
|
||||
"name": "NpcCodexMark",
|
||||
"path": "/maps/lobby/NpcCodexMark",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "000dbba8-0000-4000-8000-0000000dbba8",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": -0.5,
|
||||
"y": 1.6349999800000001,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"hit": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"die": "bd4afdde295f40318fceb4166978ebaa"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "000dbba9-0000-4000-8000-0000000dbba9",
|
||||
"path": "/maps/lobby/NpcShop",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
|
||||
"jsonString": {
|
||||
"name": "NpcShop",
|
||||
"path": "/maps/lobby/NpcShop",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "000dbba9-0000-4000-8000-0000000dbba9",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 2,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "69987ccdc486423f8bedd786bd6cb5d9",
|
||||
"hit": "69987ccdc486423f8bedd786bd6cb5d9",
|
||||
"die": "69987ccdc486423f8bedd786bd6cb5d9"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "69987ccdc486423f8bedd786bd6cb5d9",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.TouchReceiveComponent",
|
||||
"Enable": true,
|
||||
"EnemyId": "orange_mushroom",
|
||||
"Group": "combat"
|
||||
"AutoFitToSize": true
|
||||
},
|
||||
{
|
||||
"@type": "script.LobbyNpc",
|
||||
"Enable": true,
|
||||
"NpcId": "shop",
|
||||
"MarkName": "NpcShopMark"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "000dbbaa-0000-4000-8000-0000000dbbaa",
|
||||
"path": "/maps/lobby/NpcShopMark",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
|
||||
"jsonString": {
|
||||
"name": "NpcShopMark",
|
||||
"path": "/maps/lobby/NpcShopMark",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "000dbbaa-0000-4000-8000-0000000dbbaa",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 2,
|
||||
"y": 1.6349999800000001,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"hit": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"die": "bd4afdde295f40318fceb4166978ebaa"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "000dbbab-0000-4000-8000-0000000dbbab",
|
||||
"path": "/maps/lobby/NpcBoard",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
|
||||
"jsonString": {
|
||||
"name": "NpcBoard",
|
||||
"path": "/maps/lobby/NpcBoard",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "000dbbab-0000-4000-8000-0000000dbbab",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 4.5,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "8a99bd87d667482cb1f3b2193f8a19c1",
|
||||
"hit": "8a99bd87d667482cb1f3b2193f8a19c1",
|
||||
"die": "8a99bd87d667482cb1f3b2193f8a19c1"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "8a99bd87d667482cb1f3b2193f8a19c1",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.TouchReceiveComponent",
|
||||
"Enable": true,
|
||||
"AutoFitToSize": true
|
||||
},
|
||||
{
|
||||
"@type": "script.LobbyNpc",
|
||||
"Enable": true,
|
||||
"NpcId": "board",
|
||||
"MarkName": "NpcBoardMark"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "000dbbac-0000-4000-8000-0000000dbbac",
|
||||
"path": "/maps/lobby/NpcBoardMark",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
|
||||
"jsonString": {
|
||||
"name": "NpcBoardMark",
|
||||
"path": "/maps/lobby/NpcBoardMark",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "000dbbac-0000-4000-8000-0000000dbbac",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 4.5,
|
||||
"y": 1.6349999800000001,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"hit": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"die": "bd4afdde295f40318fceb4166978ebaa"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
928
map/map01.map
928
map/map01.map
File diff suppressed because it is too large
Load Diff
1098
map/map02.map
1098
map/map02.map
File diff suppressed because it is too large
Load Diff
856
map/map03.map
856
map/map03.map
@@ -16,7 +16,7 @@
|
||||
{
|
||||
"id": "00000bb8-0000-4000-8000-000000000bb8",
|
||||
"path": "/maps/map03",
|
||||
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.PlayerLock,script.MapCamera",
|
||||
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.MapCamera,script.PlayerLock",
|
||||
"jsonString": {
|
||||
"name": "map03",
|
||||
"path": "/maps/map03",
|
||||
@@ -1105,11 +1105,11 @@
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.PlayerLock",
|
||||
"@type": "script.MapCamera",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.MapCamera",
|
||||
"@type": "script.PlayerLock",
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
@@ -6364,12 +6364,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000bbd-0000-4000-8000-000000000bbd",
|
||||
"path": "/maps/map03/Monster1",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack,script.CombatMonster",
|
||||
"id": "00000dac-0000-4000-8000-000000000dac",
|
||||
"path": "/maps/map03/combat_1",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "Monster1",
|
||||
"path": "/maps/map03/Monster1",
|
||||
"name": "combat_1",
|
||||
"path": "/maps/map03/combat_1",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6379,17 +6379,17 @@
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "StaticMonster",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000bbd-0000-4000-8000-000000000bbd",
|
||||
"root_entity_id": "00000dac-0000-4000-8000-000000000dac",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "staticmonster",
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 3.5,
|
||||
"x": 2.3,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
@@ -6409,9 +6409,9 @@
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
|
||||
"hit": "c3cf643b618346c7bfa6574187b396f9",
|
||||
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
|
||||
"stand": "ed3908e24d694bb786023fc1ed073489",
|
||||
"hit": "4763c9bebc9245998c9c499b6316aa9f",
|
||||
"die": "b168793b92a844a3a3a6f4ce647a14d2"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
@@ -6421,42 +6421,10 @@
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
|
||||
"SpriteRUID": "ed3908e24d694bb786023fc1ed073489",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.78,
|
||||
"y": 0.86
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.03999999,
|
||||
"y": 0.43
|
||||
},
|
||||
"CollisionGroup": {
|
||||
"Id": "8992acd1e8cd45838db6f10a7b41df09"
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
@@ -6469,26 +6437,33 @@
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"Enable": false,
|
||||
"InputSpeed": 0
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
@@ -6507,10 +6482,33 @@
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "orange_mushroom",
|
||||
"EnemyId": "pig",
|
||||
"Group": "combat"
|
||||
}
|
||||
],
|
||||
@@ -6518,12 +6516,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000bbe-0000-4000-8000-000000000bbe",
|
||||
"path": "/maps/map03/Monster2",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack,script.CombatMonster",
|
||||
"id": "00000dad-0000-4000-8000-000000000dad",
|
||||
"path": "/maps/map03/combat_2",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "Monster2",
|
||||
"path": "/maps/map03/Monster2",
|
||||
"name": "combat_2",
|
||||
"path": "/maps/map03/combat_2",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6533,17 +6531,169 @@
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "StaticMonster",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000bbe-0000-4000-8000-000000000bbe",
|
||||
"root_entity_id": "00000dad-0000-4000-8000-000000000dad",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "staticmonster",
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 5.5,
|
||||
"x": 3.8,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "a2204a21d88942b281d2cac6053ffbaa",
|
||||
"hit": "afc08936b8a64b26bc3dd8c03ead1f26",
|
||||
"die": "fc1c6d9ba9bc413ab53b6dbfae3ac45b"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "a2204a21d88942b281d2cac6053ffbaa",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "red_snail",
|
||||
"Group": "combat"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000dae-0000-4000-8000-000000000dae",
|
||||
"path": "/maps/map03/combat_3",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "combat_3",
|
||||
"path": "/maps/map03/combat_3",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000dae-0000-4000-8000-000000000dae",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 5.2,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
@@ -6579,38 +6729,6 @@
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.78,
|
||||
"y": 0.86
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.03999999,
|
||||
"y": 0.43
|
||||
},
|
||||
"CollisionGroup": {
|
||||
"Id": "8992acd1e8cd45838db6f10a7b41df09"
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
@@ -6623,26 +6741,33 @@
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"Enable": false,
|
||||
"InputSpeed": 0
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
@@ -6661,6 +6786,29 @@
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
@@ -6670,6 +6818,462 @@
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000daf-0000-4000-8000-000000000daf",
|
||||
"path": "/maps/map03/elite_4",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "elite_4",
|
||||
"path": "/maps/map03/elite_4",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000daf-0000-4000-8000-000000000daf",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 3,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
|
||||
"hit": "c3cf643b618346c7bfa6574187b396f9",
|
||||
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "modified_snail",
|
||||
"Group": "elite"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000db0-0000-4000-8000-000000000db0",
|
||||
"path": "/maps/map03/elite_5",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "elite_5",
|
||||
"path": "/maps/map03/elite_5",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000db0-0000-4000-8000-000000000db0",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 5,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "4ca39dbfa1c6492283ba8bd352d12b0a",
|
||||
"hit": "7ac78511036e4ebe988b97c35fc275d1",
|
||||
"die": "740f3f2b2e7a4b71bec5eac84e8539f9"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "4ca39dbfa1c6492283ba8bd352d12b0a",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "mushmom",
|
||||
"Group": "elite"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000db1-0000-4000-8000-000000000db1",
|
||||
"path": "/maps/map03/boss_6",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "boss_6",
|
||||
"path": "/maps/map03/boss_6",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000db1-0000-4000-8000-000000000db1",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 4,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "f86992ba9c41487c8480fcb893fcbda6",
|
||||
"hit": "d305b942b1704c8084548108ff3b7a6b",
|
||||
"die": "5a563e5fd98c4132b61057dc6bb8aaf2"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "f86992ba9c41487c8480fcb893fcbda6",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "slime_boss",
|
||||
"Group": "boss"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
862
map/map04.map
862
map/map04.map
@@ -16,7 +16,7 @@
|
||||
{
|
||||
"id": "00000fa0-0000-4000-8000-000000000fa0",
|
||||
"path": "/maps/map04",
|
||||
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.PlayerLock,script.MapCamera",
|
||||
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.MapCamera,script.PlayerLock",
|
||||
"jsonString": {
|
||||
"name": "map04",
|
||||
"path": "/maps/map04",
|
||||
@@ -1105,11 +1105,11 @@
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.PlayerLock",
|
||||
"@type": "script.MapCamera",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.MapCamera",
|
||||
"@type": "script.PlayerLock",
|
||||
"Enable": true
|
||||
}
|
||||
],
|
||||
@@ -6364,12 +6364,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000fa5-0000-4000-8000-000000000fa5",
|
||||
"path": "/maps/map04/Monster1",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack,script.CombatMonster",
|
||||
"id": "00001194-0000-4000-8000-000000001194",
|
||||
"path": "/maps/map04/combat_1",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "Monster1",
|
||||
"path": "/maps/map04/Monster1",
|
||||
"name": "combat_1",
|
||||
"path": "/maps/map04/combat_1",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6379,17 +6379,473 @@
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "StaticMonster",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000fa5-0000-4000-8000-000000000fa5",
|
||||
"root_entity_id": "00001194-0000-4000-8000-000000001194",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "staticmonster",
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 3.5,
|
||||
"x": 2.3,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "f86992ba9c41487c8480fcb893fcbda6",
|
||||
"hit": "d305b942b1704c8084548108ff3b7a6b",
|
||||
"die": "5a563e5fd98c4132b61057dc6bb8aaf2"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "f86992ba9c41487c8480fcb893fcbda6",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "blue_mushroom",
|
||||
"Group": "combat"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00001195-0000-4000-8000-000000001195",
|
||||
"path": "/maps/map04/combat_2",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "combat_2",
|
||||
"path": "/maps/map04/combat_2",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00001195-0000-4000-8000-000000001195",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 3.8,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
|
||||
"hit": "c3cf643b618346c7bfa6574187b396f9",
|
||||
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "stump",
|
||||
"Group": "combat"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00001196-0000-4000-8000-000000001196",
|
||||
"path": "/maps/map04/combat_3",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "combat_3",
|
||||
"path": "/maps/map04/combat_3",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00001196-0000-4000-8000-000000001196",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 5.2,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "a2204a21d88942b281d2cac6053ffbaa",
|
||||
"hit": "afc08936b8a64b26bc3dd8c03ead1f26",
|
||||
"die": "fc1c6d9ba9bc413ab53b6dbfae3ac45b"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "a2204a21d88942b281d2cac6053ffbaa",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "green_mushroom",
|
||||
"Group": "combat"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00001197-0000-4000-8000-000000001197",
|
||||
"path": "/maps/map04/elite_4",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "elite_4",
|
||||
"path": "/maps/map04/elite_4",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00001197-0000-4000-8000-000000001197",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 3,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
@@ -6425,38 +6881,6 @@
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.78,
|
||||
"y": 0.86
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.03999999,
|
||||
"y": 0.43
|
||||
},
|
||||
"CollisionGroup": {
|
||||
"Id": "8992acd1e8cd45838db6f10a7b41df09"
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
@@ -6469,26 +6893,33 @@
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"Enable": false,
|
||||
"InputSpeed": 0
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
@@ -6507,23 +6938,46 @@
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "orange_mushroom",
|
||||
"Group": "combat"
|
||||
"EnemyId": "modified_snail",
|
||||
"Group": "elite"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00000fa6-0000-4000-8000-000000000fa6",
|
||||
"path": "/maps/map04/Monster2",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack,script.CombatMonster",
|
||||
"id": "00001198-0000-4000-8000-000000001198",
|
||||
"path": "/maps/map04/elite_5",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "Monster2",
|
||||
"path": "/maps/map04/Monster2",
|
||||
"name": "elite_5",
|
||||
"path": "/maps/map04/elite_5",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
@@ -6533,17 +6987,17 @@
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "StaticMonster",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00000fa6-0000-4000-8000-000000000fa6",
|
||||
"root_entity_id": "00001198-0000-4000-8000-000000001198",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "staticmonster",
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 5.5,
|
||||
"x": 5,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
@@ -6563,9 +7017,9 @@
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "3109357701ae41a4bcc7543f52f1f4c3",
|
||||
"hit": "ce0269079e884545b5bb6ea075e2a67f",
|
||||
"die": "a5e65650e00e47878cac1be7a5b999a0"
|
||||
"stand": "ed3908e24d694bb786023fc1ed073489",
|
||||
"hit": "4763c9bebc9245998c9c499b6316aa9f",
|
||||
"die": "b168793b92a844a3a3a6f4ce647a14d2"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
@@ -6575,42 +7029,10 @@
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "3109357701ae41a4bcc7543f52f1f4c3",
|
||||
"SpriteRUID": "ed3908e24d694bb786023fc1ed073489",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.78,
|
||||
"y": 0.86
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.03999999,
|
||||
"y": 0.43
|
||||
},
|
||||
"CollisionGroup": {
|
||||
"Id": "8992acd1e8cd45838db6f10a7b41df09"
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
@@ -6623,26 +7045,33 @@
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"Enable": false,
|
||||
"InputSpeed": 0
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
@@ -6661,11 +7090,186 @@
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "orange_mushroom",
|
||||
"Group": "combat"
|
||||
"EnemyId": "mushmom",
|
||||
"Group": "elite"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "00001199-0000-4000-8000-000000001199",
|
||||
"path": "/maps/map04/boss_6",
|
||||
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
|
||||
"jsonString": {
|
||||
"name": "boss_6",
|
||||
"path": "/maps/map04/boss_6",
|
||||
"nameEditable": true,
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": false,
|
||||
"displayOrder": 4,
|
||||
"pathConstraints": "///",
|
||||
"revision": 2,
|
||||
"origin": {
|
||||
"type": "Model",
|
||||
"entry_id": "ChaseMonster",
|
||||
"sub_entity_id": null,
|
||||
"root_entity_id": "00001199-0000-4000-8000-000000001199",
|
||||
"replaced_model_id": null
|
||||
},
|
||||
"modelId": "chasemonster",
|
||||
"@components": [
|
||||
{
|
||||
"@type": "MOD.Core.TransformComponent",
|
||||
"Position": {
|
||||
"x": 4,
|
||||
"y": 0.03499998,
|
||||
"z": 999.999
|
||||
},
|
||||
"QuaternionRotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"w": 1
|
||||
},
|
||||
"Scale": {
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
"z": 1
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateAnimationComponent",
|
||||
"ActionSheet": {
|
||||
"stand": "48c10437ae8344a9b2a1d3f36185728f",
|
||||
"hit": "9044063647854f5e9128efcf80e909be",
|
||||
"die": "f414577d18c94cc387c275df4abdbc3b"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SpriteRendererComponent",
|
||||
"ActionSheet": {},
|
||||
"EndFrameIndex": 0,
|
||||
"RenderSetting": 1,
|
||||
"SortingLayer": "MapLayer0",
|
||||
"SpriteRUID": "48c10437ae8344a9b2a1d3f36185728f",
|
||||
"StartFrameIndex": 0,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.RigidbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"RealMoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.MovementComponent",
|
||||
"InputSpeed": 0,
|
||||
"JumpForce": 6,
|
||||
"Enable": false
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.StateComponent",
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.HitComponent",
|
||||
"BoxSize": {
|
||||
"x": 0.63,
|
||||
"y": 0.58
|
||||
},
|
||||
"ColliderOffset": {
|
||||
"x": 0.0449999869,
|
||||
"y": 0.29
|
||||
},
|
||||
"IsLegacy": false,
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSpawnerComponent",
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.Monster",
|
||||
"Enable": true,
|
||||
"IsDead": false
|
||||
},
|
||||
{
|
||||
"@type": "script.MonsterAttack",
|
||||
"Enable": true,
|
||||
"SpriteSize": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"PositionOffset": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.KinematicbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.SideviewbodyComponent",
|
||||
"MoveVelocity": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "MOD.Core.DamageSkinSettingComponent",
|
||||
"DamageSkinId": {
|
||||
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
|
||||
},
|
||||
"Enable": true
|
||||
},
|
||||
{
|
||||
"@type": "script.CombatMonster",
|
||||
"Enable": true,
|
||||
"EnemyId": "slime_boss",
|
||||
"Group": "boss"
|
||||
}
|
||||
],
|
||||
"@version": 1
|
||||
|
||||
1098
map/map05.map
1098
map/map05.map
File diff suppressed because it is too large
Load Diff
6676
map/map06.map
6676
map/map06.map
File diff suppressed because it is too large
Load Diff
6676
map/map07.map
6676
map/map07.map
File diff suppressed because it is too large
Load Diff
6676
map/map08.map
6676
map/map08.map
File diff suppressed because it is too large
Load Diff
6676
map/map10.map
6676
map/map10.map
File diff suppressed because it is too large
Load Diff
6676
map/map11.map
6676
map/map11.map
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,23 @@ export function shuffle(arr, rng) {
|
||||
return a;
|
||||
}
|
||||
|
||||
// 공격 피해 공식 — 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
|
||||
export function rarityForRoll(roll) {
|
||||
if (roll > 95) return 'legend';
|
||||
if (roll > 70) return 'unique';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
export function calcAttack(base, str, weak, vulnOnTarget) {
|
||||
let dmg = base + str;
|
||||
if (weak > 0) dmg = Math.floor(dmg * 0.75);
|
||||
if (vulnOnTarget > 0) dmg = Math.floor(dmg * 1.5);
|
||||
if (dmg < 0) dmg = 0;
|
||||
return dmg;
|
||||
}
|
||||
|
||||
// 방어 우선 차감 후 hp 적용 → { hp, block }
|
||||
export function applyDamage(hp, block, amount) {
|
||||
let dmg = amount;
|
||||
@@ -49,19 +66,22 @@ export function loadData() {
|
||||
if (!e) throw new Error(`simEncounter 적 없음: ${id}`);
|
||||
return { name: e.name, maxHp: e.maxHp, intents: e.intents };
|
||||
});
|
||||
return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, monsters };
|
||||
// 시뮬 기본 덱은 전사 시작 덱 (클래스별 시뮬은 starterDeck 직접 주입으로 가능)
|
||||
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
|
||||
}
|
||||
|
||||
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
|
||||
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
||||
// 손패에서 낼 카드 인덱스(-1=종료). 공격 우선, 없으면 스킬.
|
||||
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
||||
export function chooseAction(hand, cards, energy) {
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy);
|
||||
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
|
||||
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) / cards[x.id].cost;
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost;
|
||||
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 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;
|
||||
if (skills.length) return bestBy(skills, blkEff).i;
|
||||
return -1;
|
||||
@@ -88,10 +108,13 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
let drawPile = shuffle(starterDeck, rng);
|
||||
let discard = [];
|
||||
const exhaust = [];
|
||||
let hand = [];
|
||||
let pHp = PLAYER_HP, pBlock = 0;
|
||||
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
|
||||
const powers = [];
|
||||
const mob = monsters.map((m) => ({
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0,
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
|
||||
intents: m.intents, intentIdx: 0, alive: true,
|
||||
}));
|
||||
let turns = 0;
|
||||
@@ -100,14 +123,111 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
|
||||
if (drawPile.length === 0) break;
|
||||
hand.push(drawPile.pop());
|
||||
const card = drawPile.pop();
|
||||
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
||||
if (hand.length >= 10) {
|
||||
discard.push(card);
|
||||
triggerSly(card);
|
||||
} else hand.push(card);
|
||||
}
|
||||
}
|
||||
function addCardsToHand(id, n) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (hand.length >= 10) discard.push(id);
|
||||
else hand.push(id);
|
||||
}
|
||||
}
|
||||
const aliveList = () => mob.filter((m) => m.alive);
|
||||
function resolveCardEffects(id, c, costSpent, recordStats = true) {
|
||||
const alive = aliveList();
|
||||
let dmg = 0;
|
||||
let blockGained = 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && c.damage) {
|
||||
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
|
||||
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);
|
||||
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;
|
||||
if (m2.hp <= 0) m2.alive = false;
|
||||
}
|
||||
} else {
|
||||
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
if (c.pierce === true) {
|
||||
target.hp -= dmg;
|
||||
if (target.hp < 0) target.hp = 0;
|
||||
} else {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
}
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
||||
} else if (c.kind === 'Power') {
|
||||
if (recordStats) powers.push(id);
|
||||
} else {
|
||||
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
||||
if ((c.weak || c.vuln || c.poison) && alive.length) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
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 (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
||||
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
||||
}
|
||||
function triggerSly(id) {
|
||||
const c = cards[id];
|
||||
if (!c?.sly) return;
|
||||
resolveCardEffects(id, c, 0, false);
|
||||
}
|
||||
function discardHandCard(idx, trigger = true) {
|
||||
const [id] = hand.splice(idx, 1);
|
||||
if (!id) return;
|
||||
discard.push(id);
|
||||
if (trigger) triggerSly(id);
|
||||
}
|
||||
function applyDiscardEffects(c) {
|
||||
let discarded = 0;
|
||||
if (c.discardAll) {
|
||||
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
} else if (c.discard) {
|
||||
const n = Math.min(c.discard, hand.length);
|
||||
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
}
|
||||
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
}
|
||||
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE);
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
pBlock = 0;
|
||||
let energyBonus = 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;
|
||||
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
|
||||
}
|
||||
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
@@ -115,31 +235,65 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
energy -= c.cost;
|
||||
if (c.kind === 'Attack') {
|
||||
const target = chooseTarget(alive, c.damage || 0);
|
||||
const r = applyDamage(target.hp, target.block, c.damage || 0);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0);
|
||||
} else {
|
||||
pBlock += c.block || 0;
|
||||
if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0);
|
||||
}
|
||||
hand.splice(idx, 1); discard.push(id);
|
||||
resolveCardEffects(id, c, c.cost);
|
||||
hand.splice(idx, 1);
|
||||
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 };
|
||||
}
|
||||
discard.push(...hand); hand = [];
|
||||
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
|
||||
let burn = 0;
|
||||
for (const hid of hand) { const hc = cards[hid]; if (hc && hc.endTurnDamage) burn += hc.endTurnDamage; }
|
||||
if (burn > 0) { pHp -= burn; if (pHp < 0) pHp = 0; }
|
||||
const kept = [];
|
||||
for (const hid of hand) {
|
||||
const hc = cards[hid];
|
||||
if (hc?.retain === true) kept.push(hid);
|
||||
else discard.push(hid);
|
||||
}
|
||||
hand = kept;
|
||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
||||
if (pWeak > 0) pWeak--;
|
||||
if (pVuln > 0) pVuln--;
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
||||
const it = m.intents[m.intentIdx];
|
||||
if (it) {
|
||||
if (it.kind === 'Attack') { const r = applyDamage(pHp, pBlock, it.value); pHp = r.hp; pBlock = r.block; }
|
||||
else if (it.kind === 'Defend') { m.block += it.value; }
|
||||
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
||||
if (m.poison > 0) {
|
||||
m.hp -= m.poison;
|
||||
m.poison--;
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
||||
}
|
||||
m.intentIdx = (m.intentIdx + 1) % m.intents.length;
|
||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
||||
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
|
||||
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
||||
if (it) {
|
||||
if (it.kind === 'Attack') {
|
||||
const atk = calcAttack(it.value, 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;
|
||||
else if (it.effect === 'vuln') pVuln += it.value;
|
||||
} else if (it.kind === 'AddCard') {
|
||||
// StS2식 덱 오염 — 저주 카드를 버린 더미에 추가 (Lua 동기화)
|
||||
const cnt = it.count || 1;
|
||||
for (let k = 0; k < cnt; k++) discard.push(it.card);
|
||||
}
|
||||
}
|
||||
// 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후)
|
||||
if (m.weak > 0) m.weak--;
|
||||
if (m.vuln > 0) m.vuln--;
|
||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||
}
|
||||
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
|
||||
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp };
|
||||
}
|
||||
return { win: false, turns, playerHpRemaining: pHp, draw: true };
|
||||
}
|
||||
@@ -187,11 +341,11 @@ export function formatReport(r) {
|
||||
const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy;
|
||||
return { id, name: r.cards[id].name, kind, plays: s.plays, eff };
|
||||
});
|
||||
for (const kind of ['Attack', 'Skill']) {
|
||||
for (const kind of ['Attack', 'Skill', 'Power']) {
|
||||
const kr = rows.filter((x) => x.kind === kind);
|
||||
if (!kr.length) continue;
|
||||
const med = median(kr.map((x) => x.eff));
|
||||
const unit = kind === 'Attack' ? '뎀/E' : '블록/E';
|
||||
const unit = kind === 'Attack' ? '뎀/E' : kind === 'Power' ? '(지속)' : '블록/E';
|
||||
for (const x of kr) {
|
||||
const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : '';
|
||||
L.push(` ${x.name}(${x.id}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch,
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
|
||||
assert.equal(rarityForRoll(1), 'normal');
|
||||
assert.equal(rarityForRoll(70), 'normal');
|
||||
assert.equal(rarityForRoll(71), 'unique');
|
||||
assert.equal(rarityForRoll(95), 'unique');
|
||||
assert.equal(rarityForRoll(96), 'legend');
|
||||
assert.equal(rarityForRoll(100), 'legend');
|
||||
});
|
||||
|
||||
test('applyDamage: 방어 우선 차감 후 hp', () => {
|
||||
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
|
||||
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
|
||||
@@ -118,3 +127,337 @@ test('runBatch: 집계 필드·승률 범위', () => {
|
||||
test('runBatch: 동일 시드 동일 결과', () => {
|
||||
assert.deepEqual(runBatch(100, 7), runBatch(100, 7));
|
||||
});
|
||||
|
||||
test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡수', () => {
|
||||
const data = {
|
||||
cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 1, block: 3 } },
|
||||
starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 9 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
// 매 턴 3장(에너지3) → 블록 9 = 적 공격 9 전부 흡수 → 무피해로 MAX_TURNS 도달(draw), HP 유지.
|
||||
// 블록 미적용이면 매턴 -9로 사망(win=false, draw 아님).
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test('calcAttack: 힘·약화·취약 공식 (Lua CalcPlayerAttack·DealDamageToTarget 동기화)', () => {
|
||||
assert.equal(calcAttack(6, 0, 0, 0), 6); // 기본
|
||||
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
|
||||
assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75)
|
||||
assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5)
|
||||
assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5))=13
|
||||
});
|
||||
|
||||
test('simulateCombat: 적 Debuff 인텐트만 사용 → 플레이어 무피해', () => {
|
||||
const data = {
|
||||
cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } },
|
||||
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test('simulateCombat: 플레이어 약화 시 공격 피해 감소 반영', () => {
|
||||
// 약화 영구 부여 적: 4피해 카드가 floor(4*0.75)=3으로 감소
|
||||
const data = {
|
||||
cards: { Hit: { name: '타격', cost: 3, kind: 'Attack', damage: 4 } },
|
||||
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Debuff', effect: 'weak', value: 99 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
// 턴1: 4 (약화 전), 이후 매턴 3 → 10피해 도달 = 턴3 (4+3+3)
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 3);
|
||||
});
|
||||
|
||||
test('simulateCombat: 카드 취약 부여가 같은 카드 피해에 선적용 (Lua 동기화)', () => {
|
||||
const data = {
|
||||
cards: { CB: { name: '차지', cost: 3, kind: 'Attack', damage: 8, vuln: 2 } },
|
||||
starterDeck: ['CB', 'CB', 'CB', 'CB', 'CB'],
|
||||
monsters: [{ name: '적', maxHp: 12, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
// 취약 선적용이면 floor(8*1.5)=12 → 1턴 처치. 후적용이면 8 → 2턴.
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test('simulateCombat: Power(매턴 힘) 누적', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 },
|
||||
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
|
||||
},
|
||||
starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}턴`);
|
||||
});
|
||||
|
||||
test('simulateCombat: 적 약화 인텐트 → 적 공격력 감소는 적용 안 됨(적 자신은 약화 안 걸림)', () => {
|
||||
// 회귀 가드: Debuff 인텐트는 플레이어에게만 적용
|
||||
const data = {
|
||||
cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 } },
|
||||
starterDeck: ['Skip', 'Skip', 'Skip', 'Skip', 'Skip'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'vuln', value: 1 }, { kind: 'Attack', value: 10 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
// 턴1: 취약1 부여 → 플레이어 취약. 턴1 종료 시 1 감소 → 0. 턴2: 공격 10 (취약 소멸) → 정확히 10만 피해.
|
||||
// MAX_TURNS 동안 2턴 주기 공격 → 사망까지 충분 → win=false
|
||||
assert.equal(r.win, false);
|
||||
});
|
||||
|
||||
test('simulateCombat: 다단히트(hits) — 힘이 타격마다 적용, 취약은 합산 1회 (Lua 동기화)', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Buff: { name: '버프', cost: 1, kind: 'Skill', strength: 2 },
|
||||
Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 5, hits: 2 },
|
||||
},
|
||||
starterDeck: ['Buff', 'Combo', 'Combo', 'Combo', 'Combo'],
|
||||
monsters: [{ name: '적', maxHp: 200, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// 공격 우선 휴리스틱: 턴1 콤보×3 (힘0) = 10×3 = 30
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(typeof r.win, 'boolean'); // 동작 보장 (수치는 아래 단위 검증)
|
||||
});
|
||||
|
||||
test('hits 수치: 힘+2일 때 5×2회 = (5+2)*2 = 14', () => {
|
||||
const data = {
|
||||
cards: { Combo: { name: '콤보', cost: 3, kind: 'Attack', damage: 5, hits: 2, strength: 0 } },
|
||||
starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'],
|
||||
monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// 턴1: 10 피해 → 정확히 처치 (5×2)
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test('simulateCombat: pierce — 적 방어도 무시', () => {
|
||||
const data = {
|
||||
cards: { P: { name: '피어스', cost: 3, kind: 'Attack', damage: 9, pierce: true } },
|
||||
starterDeck: ['P', 'P', 'P', 'P', 'P'],
|
||||
monsters: [{ name: '적', maxHp: 18, intents: [{ kind: 'Defend', value: 50 }] }],
|
||||
};
|
||||
// 턴1: 9 (방어 없음), 적이 방어 50. 턴2: pierce 9 → 처치. 비관통이면 흡수돼 불가.
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test('simulateCombat: selfVuln — 자가 취약으로 받는 피해 증가', () => {
|
||||
const data = {
|
||||
cards: { B: { name: '버서크류', cost: 1, kind: 'Skill', selfVuln: 9, block: 0 } },
|
||||
starterDeck: ['B', 'B', 'B', 'B', 'B'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 2 }] }],
|
||||
};
|
||||
// 매턴 스킬 사용으로 취약 유지 → 적 공격 2 → floor(2*1.5)=3 → 80/3 ≈ 27턴 사망 (취약 없으면 40턴)
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, false);
|
||||
assert.ok(r.turns <= 30, `취약 반영 시 30턴 내 사망, 실제 ${r.turns}`);
|
||||
});
|
||||
|
||||
test('simulateCombat: energyPerTurn 파워 — 다음 턴부터 에너지 증가', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
E: { name: '버서크', cost: 1, kind: 'Power', powerEffect: 'energyPerTurn', value: 1 },
|
||||
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
|
||||
},
|
||||
starterDeck: ['E', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 14, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// 턴1: 파워+히트2 = 2, 턴2~4: 에너지4·손패 히트4 = 4/턴 → 2+4+4+4 = 14 → 턴4 처치
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 4);
|
||||
});
|
||||
|
||||
test('simulateCombat: blockPerTurn 파워 — 매턴 방어로 약공 무효', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
B: { name: '하이퍼 바디', cost: 1, kind: 'Power', powerEffect: 'blockPerTurn', value: 3 },
|
||||
S: { name: '대기', cost: 3, kind: 'Skill', block: 0 },
|
||||
},
|
||||
starterDeck: ['B', 'S', 'S', 'S', 'S'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 3 }] }],
|
||||
};
|
||||
// 턴1: 파워 설치, 적 3 피해(방어 없음) → 77. 턴2부터 매턴 방어3 = 공격3 전부 흡수 → draw, HP 77 유지
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 77);
|
||||
});
|
||||
|
||||
test('simulateCombat: poison — 적 행동 시작 시 틱·1 감소·독 사망 시 승리 처리', () => {
|
||||
const data = {
|
||||
cards: { PB: { name: '포이즌', cost: 3, kind: 'Skill', poison: 4 } },
|
||||
starterDeck: ['PB', 'PB', 'PB', 'PB', 'PB'],
|
||||
monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// T1: 독4 부여 → 틱 4 (hp 6, 독 3). T2: +4 → 7 틱 → hp 0 사망 → 승리
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
});
|
||||
|
||||
test('simulateCombat: aoe — 모든 생존 적에게 피해', () => {
|
||||
const data = {
|
||||
cards: { TB: { name: '썬더 볼트', cost: 3, kind: 'Attack', damage: 6, aoe: true } },
|
||||
starterDeck: ['TB', 'TB', 'TB', 'TB', 'TB'],
|
||||
monsters: [
|
||||
{ name: 'A', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] },
|
||||
{ name: 'B', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] },
|
||||
{ name: 'C', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test('simulateCombat: heal — 최대 HP 클램프', () => {
|
||||
const data = {
|
||||
cards: { H: { name: '힐', cost: 1, kind: 'Skill', heal: 10 } },
|
||||
starterDeck: ['H', 'H', 'H', 'H', 'H'],
|
||||
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 10 }] }],
|
||||
};
|
||||
// 매턴: 힐로 80까지 회복(클램프) → 적 10 → 70. MAX_TURNS 도달 시 hp 70
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 70);
|
||||
});
|
||||
|
||||
test('simulateCombat: draw — 카드 드로로 손패 보충', () => {
|
||||
const data = {
|
||||
cards: {
|
||||
D: { name: '텔레포트류', cost: 0, kind: 'Skill', draw: 1, block: 0 },
|
||||
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
|
||||
},
|
||||
starterDeck: ['D', 'D', 'D', 'D', 'D', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '적', maxHp: 4, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// 드로 덕에 첫 턴 히트 3장 전부 접근 → 늦어도 2턴 내 처치 (시드 무관)
|
||||
for (let s = 1; s <= 10; s++) {
|
||||
const r = simulateCombat(data, mulberry32(s));
|
||||
assert.equal(r.win, true, `seed ${s}`);
|
||||
assert.ok(r.turns <= 2, `seed ${s}: ${r.turns}턴`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chooseAction: unplayable(저주) 카드는 건너뜀', () => {
|
||||
const cards = { Strike: { cost: 1, kind: 'Attack', damage: 6 }, Wound: { cost: 0, kind: 'Status', unplayable: true } };
|
||||
assert.equal(chooseAction(['Wound', 'Strike'], cards, 3), 1); // Strike 선택
|
||||
assert.equal(chooseAction(['Wound'], cards, 3), -1); // 낼 카드 없음
|
||||
});
|
||||
|
||||
test('simulateCombat: AddCard intent가 저주를 덱에 추가(오염)', () => {
|
||||
const data = {
|
||||
cards: { Hit: { name: '히트', cost: 1, kind: 'Attack', damage: 1 }, Wound: { name: '상처', cost: 0, kind: 'Status', unplayable: true } },
|
||||
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
|
||||
monsters: [{ name: '오염자', maxHp: 9999, intents: [{ kind: 'AddCard', card: 'Wound', count: 1 }] }],
|
||||
};
|
||||
// 적은 공격 안 하고 매 턴 저주만 추가 → 플레이어 무피해(승리 불가, 9999hp) → 무승부, 사망 아님
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, false);
|
||||
assert.equal(r.draw, true);
|
||||
});
|
||||
|
||||
test('simulateCombat: endTurnDamage(화상)이 턴 종료 시 누적 피해', () => {
|
||||
const data = {
|
||||
cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 }, Burn: { name: '화상', cost: 0, kind: 'Status', unplayable: true, endTurnDamage: 2 } },
|
||||
starterDeck: ['Burn', 'Skip', 'Skip', 'Skip', 'Skip'],
|
||||
monsters: [{ name: '무공격', maxHp: 9999, intents: [{ kind: 'Defend', value: 0 }] }],
|
||||
};
|
||||
// 적은 방어만(무피해). 손패의 Burn이 매 턴 -2 → 80hp 잠식 → MAX_TURNS 전 사망 → win false(draw 아님)
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, false);
|
||||
assert.notEqual(r.draw, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: sly discarded card resolves for free", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Toss: { name: "Toss", cost: 1, kind: "Skill", discardAll: true },
|
||||
SlyHit: { name: "SlyHit", cost: 99, kind: "Attack", damage: 10, sly: true },
|
||||
Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Toss", "SlyHit", "Blank", "Blank", "Blank"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: retain keeps card in hand across turns", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Boost: { name: "Boost", cost: 3, kind: "Power", powerEffect: "energyPerTurn", value: 98 },
|
||||
Hold: { name: "Hold", cost: 100, kind: "Attack", damage: 10, retain: true },
|
||||
Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Blank", "Blank", "Blank", "Blank", "Blank", "Boost", "Hold", "Blank", "Blank", "Blank"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
// 새 CameraComponent를 만들지 않고(엔진 소유) 기존 카메라 속성만 런타임 설정한다.
|
||||
// 플레이어 입력 차단·시선 고정은 tools/player/gen-player-lock.mjs(script.PlayerLock)로 분리됨.
|
||||
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
|
||||
const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11
|
||||
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
|
||||
|
||||
function prop(Type, Name, DefaultValue = 'nil') {
|
||||
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
|
||||
|
||||
73
tools/deck/cb/boot.mjs
Normal file
73
tools/deck/cb/boot.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
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 bootMethods = [
|
||||
method('OnBeginPlay', `${luaCardsTable(CARDS.cards)}
|
||||
${luaFramesTable()}
|
||||
${luaNodeIconsTable()}
|
||||
${luaSoulShopTable(SOUL_UNLOCKS)}
|
||||
self.SoulUnlocks = {}
|
||||
self.SoulPoints = self.SoulPoints or 0
|
||||
self:ShowLobby()
|
||||
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
|
||||
local lp2 = _UserService.LocalPlayer
|
||||
if lp2 ~= nil and lp2.CurrentMapName == "${LOBBY_MAP}" and self.RunActive ~= true then
|
||||
self:PlayerAttackMotion()
|
||||
end
|
||||
end
|
||||
end)`),
|
||||
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/DefaultGroup/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'),
|
||||
];
|
||||
58
tools/deck/cb/charselect.mjs
Normal file
58
tools/deck/cb/charselect.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
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 warrior = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/WarriorButton")
|
||||
if warrior ~= nil and warrior.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == "warrior" then
|
||||
warrior.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
else
|
||||
warrior.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
|
||||
end
|
||||
end
|
||||
local mage = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/MageButton")
|
||||
if mage ~= nil and mage.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == "magician" then
|
||||
mage.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
else
|
||||
mage.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
|
||||
end
|
||||
end
|
||||
local thief = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/ThiefButton")
|
||||
if thief ~= nil and thief.SpriteGUIRendererComponent ~= nil then
|
||||
if self.SelectedClass == "bandit" then
|
||||
thief.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
|
||||
else
|
||||
thief.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
|
||||
end
|
||||
end
|
||||
if self.SelectedClass == "warrior" then
|
||||
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "전사 선택됨")
|
||||
elseif self.SelectedClass == "bandit" then
|
||||
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "도적 선택됨")
|
||||
elseif self.SelectedClass == "magician" then
|
||||
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "마법사 선택됨")
|
||||
else
|
||||
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "직업을 선택하고 시작하세요")
|
||||
end`),
|
||||
method('StartNewGame', `if self.SelectedClass ~= "warrior" and self.SelectedClass ~= "bandit" and self.SelectedClass ~= "magician" then
|
||||
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "직업을 먼저 선택하세요")
|
||||
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' },
|
||||
]),
|
||||
];
|
||||
480
tools/deck/cb/combat.mjs
Normal file
480
tools/deck/cb/combat.mjs
Normal file
@@ -0,0 +1,480 @@
|
||||
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('PlayCard', `if self:IsDiscardSelecting() == true then
|
||||
self:SelectDiscardSlot(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.Energy < c.cost then
|
||||
self:Toast("에너지가 부족합니다")
|
||||
return
|
||||
end
|
||||
self.Energy = self.Energy - c.cost
|
||||
self:ResolveCardEffects(cardId, c, false)
|
||||
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
|
||||
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)
|
||||
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/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) .. "/TargetFrame", active)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) .. "/TargetMarker", active and dragActive)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. 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/DefaultGroup/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/DefaultGroup/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/DefaultGroup/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.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
|
||||
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('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/DefaultGroup/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/DefaultGroup/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
|
||||
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/DefaultGroup/CombatHud/MonsterSlot" .. 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/DefaultGroup/CombatHud/MonsterSlot" .. 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: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`),
|
||||
];
|
||||
344
tools/deck/cb/deckturn.mjs
Normal file
344
tools/deck/cb/deckturn.mjs
Normal file
@@ -0,0 +1,344 @@
|
||||
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/DefaultGroup/DeckHud/EndTurnButton")
|
||||
if endTurn ~= nil and endTurn.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/DefaultGroup/DeckHud/DrawPile")
|
||||
if drawPile ~= nil and drawPile.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/DefaultGroup/DeckHud/DiscardPile")
|
||||
if discardPile ~= nil and discardPile.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/DefaultGroup/DeckHud/ExhaustPile")
|
||||
if exhaustPile ~= nil and exhaustPile.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/DefaultGroup/DeckInspectHud/Close")
|
||||
if inspectClose ~= nil and inspectClose.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/DefaultGroup/CombatHud/TopBar/AllDeckButton")
|
||||
if allDeckButton ~= nil and allDeckButton.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/DefaultGroup/DeckAllHud/Close")
|
||||
if allDeckClose ~= nil and allDeckClose.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, 10 do
|
||||
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
|
||||
if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then
|
||||
local cardPath = "/ui/DefaultGroup/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 then
|
||||
cardEntity:ConnectEvent(ButtonClickEvent, function() self:OnCardButton(i) end)
|
||||
end
|
||||
end
|
||||
end
|
||||
for i = 1, 3 do
|
||||
local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i))
|
||||
if rc ~= nil and rc.ButtonComponent ~= nil then
|
||||
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
|
||||
if rc.UITouchReceiveComponent ~= nil then
|
||||
local cardPath = "/ui/DefaultGroup/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/DefaultGroup/RewardHud/Skip")
|
||||
if skip ~= nil and skip.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/DefaultGroup/MapHud/Node_" .. nid)
|
||||
if mn ~= nil and mn.ButtonComponent ~= nil then
|
||||
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
|
||||
end
|
||||
end
|
||||
for i = 1, 3 do
|
||||
local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i))
|
||||
if sc ~= nil and sc.ButtonComponent ~= nil then
|
||||
sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)
|
||||
if sc.UITouchReceiveComponent ~= nil then
|
||||
local cardPath = "/ui/DefaultGroup/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/DefaultGroup/ShopHud/Leave")
|
||||
if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then
|
||||
shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
local shopRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
|
||||
if shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then
|
||||
shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)
|
||||
end
|
||||
local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave")
|
||||
if restLeave ~= nil and restLeave.ButtonComponent ~= nil then
|
||||
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
for i = 1, ${MAX_MONSTERS} do
|
||||
local ms = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i))
|
||||
if ms ~= nil and ms.ButtonComponent ~= nil then
|
||||
ms:ConnectEvent(ButtonClickEvent, function() self:SetTarget(i) end)
|
||||
end
|
||||
end
|
||||
for i = 1, 10 do
|
||||
local rs = _EntityService:GetEntityByPath("/ui/DefaultGroup/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/DefaultGroup/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/DefaultGroup/CombatHud/PotionMenu/Use")
|
||||
if pmUse ~= nil and pmUse.ButtonComponent ~= nil then
|
||||
pmUse:ConnectEvent(ButtonClickEvent, function() self:UsePotion() end)
|
||||
end
|
||||
local pmToss = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/PotionMenu/Toss")
|
||||
if pmToss ~= nil and pmToss.ButtonComponent ~= nil then
|
||||
pmToss:ConnectEvent(ButtonClickEvent, function() self:TossPotion() end)
|
||||
end
|
||||
local pmClose = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/PotionMenu/Close")
|
||||
if pmClose ~= nil and pmClose.ButtonComponent ~= nil then
|
||||
pmClose:ConnectEvent(ButtonClickEvent, function() self:ClosePotionMenu() end)
|
||||
end
|
||||
local shopPotion = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Potion")
|
||||
if shopPotion ~= nil and shopPotion.ButtonComponent ~= nil then
|
||||
shopPotion:ConnectEvent(ButtonClickEvent, function() self:BuyPotion() end)
|
||||
end
|
||||
local chest = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Chest")
|
||||
if chest ~= nil and chest.ButtonComponent ~= nil then
|
||||
chest:ConnectEvent(ButtonClickEvent, function() self:OpenChest() end)
|
||||
end
|
||||
local treasureLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Leave")
|
||||
if treasureLeave ~= nil and treasureLeave.ButtonComponent ~= nil then
|
||||
treasureLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
local jcRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobChoiceHud/RelicButton")
|
||||
if jcRelic ~= nil and jcRelic.ButtonComponent ~= nil then
|
||||
jcRelic:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("relic") end)
|
||||
end
|
||||
local jcJob = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobChoiceHud/JobButton")
|
||||
if jcJob ~= nil and jcJob.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/DefaultGroup/JobSelectHud/Job_slot" .. tostring(i))
|
||||
if jb ~= nil and jb.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.Energy = self.MaxEnergy
|
||||
self:ApplyRelics("turnStart")
|
||||
self.PlayerBlock = 0
|
||||
if self.ClayBlockNext > 0 then
|
||||
self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext
|
||||
self.ClayBlockNext = 0
|
||||
end
|
||||
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
|
||||
end
|
||||
if pc.turnStartShiv ~= nil then
|
||||
self:AddCardsToHand("Shiv", pc.turnStartShiv)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
self:DrawCards(5)
|
||||
self:RenderHand(true)
|
||||
self:RenderCombat()`),
|
||||
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
|
||||
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 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()`),
|
||||
method('DrawCards', `local drawnSlots = {}
|
||||
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)
|
||||
\tif #self.Hand >= 10 then
|
||||
\t\ttable.insert(self.DiscardPile, cardId)
|
||||
\t\tself:TriggerSly(cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.Hand, cardId)
|
||||
\t\tif #self.Hand <= 5 then
|
||||
\t\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\t\tend
|
||||
\tend
|
||||
end
|
||||
self:RenderPiles()
|
||||
if animate == true and #drawnSlots > 0 then
|
||||
\tself:RenderHand(false)
|
||||
\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
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
]),
|
||||
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/DefaultGroup/DeckHud/DrawPile/Count", self:FormatNumber(#self.DrawPile))
|
||||
self:SetText("/ui/DefaultGroup/DeckHud/DiscardPile/Count", self:FormatNumber(#self.DiscardPile))
|
||||
self:SetText("/ui/DefaultGroup/DeckHud/ExhaustPile/Count", self:FormatNumber(#(self.ExhaustPile or {})))
|
||||
self:SetText("/ui/DefaultGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
|
||||
local inspect = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud")
|
||||
if inspect ~= nil and inspect.Enable == true and self.DeckInspectKind ~= "" then
|
||||
self:OpenDeckInspect(self.DeckInspectKind)
|
||||
end`),
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user