Merge pull request 'feat(job): 전직 시스템 코어 + 전사 2차 (배포 퀄리티 P9)' (#44) from feature/p9-job-advancement into main
This commit was merged in pull request #44.
This commit is contained in:
File diff suppressed because one or more lines are too long
115
data/cards.json
115
data/cards.json
@@ -6,7 +6,8 @@
|
||||
"kind": "Attack",
|
||||
"damage": 6,
|
||||
"desc": "피해 6",
|
||||
"image": "a71b116807904ef2b38e1dc013e2f9a2"
|
||||
"image": "a71b116807904ef2b38e1dc013e2f9a2",
|
||||
"class": "warrior"
|
||||
},
|
||||
"Defend": {
|
||||
"name": "아이언 바디",
|
||||
@@ -14,7 +15,8 @@
|
||||
"kind": "Skill",
|
||||
"block": 5,
|
||||
"desc": "방어도 5",
|
||||
"image": "1ae9b6741c5947a8b528a0f515b50e3e"
|
||||
"image": "1ae9b6741c5947a8b528a0f515b50e3e",
|
||||
"class": "warrior"
|
||||
},
|
||||
"Bash": {
|
||||
"name": "슬래시 블러스트",
|
||||
@@ -22,7 +24,8 @@
|
||||
"kind": "Attack",
|
||||
"damage": 10,
|
||||
"desc": "피해 10",
|
||||
"image": "d5bc2953fcab4cfe9062af81c35aff86"
|
||||
"image": "d5bc2953fcab4cfe9062af81c35aff86",
|
||||
"class": "warrior"
|
||||
},
|
||||
"WarLeap": {
|
||||
"name": "워 리프",
|
||||
@@ -31,7 +34,8 @@
|
||||
"damage": 4,
|
||||
"block": 3,
|
||||
"desc": "피해 4, 방어도 3",
|
||||
"image": "992dabf6aff2400e92b2f4f705d8ebe7"
|
||||
"image": "992dabf6aff2400e92b2f4f705d8ebe7",
|
||||
"class": "warrior"
|
||||
},
|
||||
"Brandish": {
|
||||
"name": "브랜디시",
|
||||
@@ -39,7 +43,8 @@
|
||||
"kind": "Attack",
|
||||
"damage": 13,
|
||||
"desc": "피해 13",
|
||||
"image": "21af4bccc5054a5dbc8245dfa7f08681"
|
||||
"image": "21af4bccc5054a5dbc8245dfa7f08681",
|
||||
"class": "warrior"
|
||||
},
|
||||
"ChargedBlow": {
|
||||
"name": "차지 블로우",
|
||||
@@ -48,7 +53,8 @@
|
||||
"damage": 8,
|
||||
"vuln": 2,
|
||||
"desc": "피해 8, 취약 2",
|
||||
"image": "fe83c7635b0e49ed83d75a2833adb53e"
|
||||
"image": "fe83c7635b0e49ed83d75a2833adb53e",
|
||||
"class": "warrior"
|
||||
},
|
||||
"Threaten": {
|
||||
"name": "위협",
|
||||
@@ -56,7 +62,8 @@
|
||||
"kind": "Skill",
|
||||
"weak": 2,
|
||||
"desc": "약화 2 부여",
|
||||
"image": "64daadf1a98e490d9c14ef52ec776e63"
|
||||
"image": "64daadf1a98e490d9c14ef52ec776e63",
|
||||
"class": "warrior"
|
||||
},
|
||||
"Enrage": {
|
||||
"name": "인레이지",
|
||||
@@ -64,7 +71,8 @@
|
||||
"kind": "Skill",
|
||||
"strength": 2,
|
||||
"desc": "힘 +2",
|
||||
"image": "09370fc7551e47238fd103a80fba558e"
|
||||
"image": "09370fc7551e47238fd103a80fba558e",
|
||||
"class": "warrior"
|
||||
},
|
||||
"Rage": {
|
||||
"name": "분노",
|
||||
@@ -73,7 +81,96 @@
|
||||
"powerEffect": "strengthPerTurn",
|
||||
"value": 1,
|
||||
"desc": "매 턴 시작 시 힘 +1",
|
||||
"image": "379d86e3de064959aa4612f71e84ccfb"
|
||||
"image": "379d86e3de064959aa4612f71e84ccfb",
|
||||
"class": "warrior"
|
||||
},
|
||||
"ComboAttack": {
|
||||
"name": "콤보 어택",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "fighter",
|
||||
"damage": 5,
|
||||
"hits": 2,
|
||||
"desc": "피해 5 × 2회",
|
||||
"image": "1bc3e52b330648faae9eafd5a205e37b"
|
||||
},
|
||||
"Berserk": {
|
||||
"name": "버서크",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "fighter",
|
||||
"powerEffect": "energyPerTurn",
|
||||
"value": 1,
|
||||
"selfVuln": 1,
|
||||
"desc": "매턴 에너지 +1, 취약 1 자가",
|
||||
"image": "cef30ea340c74e768bcee4e2cbe0577a"
|
||||
},
|
||||
"RisingAttack": {
|
||||
"name": "라이징 어택",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "fighter",
|
||||
"damage": 12,
|
||||
"desc": "피해 12",
|
||||
"image": "3a3d4b8bb5bd4137847caf883e4bf38e"
|
||||
},
|
||||
"ThunderCharge": {
|
||||
"name": "썬더 차지",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "page",
|
||||
"damage": 7,
|
||||
"weak": 1,
|
||||
"desc": "피해 7, 약화 1",
|
||||
"image": "f1b7e3041909411eb67af884b446e1e1"
|
||||
},
|
||||
"BlizzardCharge": {
|
||||
"name": "블리자드 차지",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "page",
|
||||
"damage": 7,
|
||||
"vuln": 1,
|
||||
"desc": "피해 7, 취약 1",
|
||||
"image": "7915c70952ad432f99519ad79bf929a4"
|
||||
},
|
||||
"PowerGuard": {
|
||||
"name": "파워 가드",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "page",
|
||||
"block": 10,
|
||||
"desc": "방어도 10",
|
||||
"image": "90a9bf8eeb844b578b4e2d93ac43fedf"
|
||||
},
|
||||
"Pierce": {
|
||||
"name": "피어스",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "spearman",
|
||||
"damage": 9,
|
||||
"pierce": true,
|
||||
"desc": "피해 9, 방어 무시",
|
||||
"image": "e312e535a2bc4fed82d36f9c6027c9db"
|
||||
},
|
||||
"IronWall": {
|
||||
"name": "아이언 월",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "spearman",
|
||||
"block": 12,
|
||||
"desc": "방어도 12",
|
||||
"image": "92021d62341a4bce9cfd09d1b4b865db"
|
||||
},
|
||||
"HyperBody": {
|
||||
"name": "하이퍼 바디",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "spearman",
|
||||
"powerEffect": "blockPerTurn",
|
||||
"value": 3,
|
||||
"desc": "매턴 방어도 +3",
|
||||
"image": "b4020dbadee6401f9893a020fe4154b1"
|
||||
}
|
||||
},
|
||||
"starterDeck": [
|
||||
|
||||
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 전 호출부 갱신 명시 ✓
|
||||
- 하네스: 산출물 검증 카운트만 ✓
|
||||
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 리소스 메이커 선별 (기존 절차)
|
||||
- 클래스 필터로 "해당 클래스만 획득" 충족 — 사용 제한은 별도 불요 (얻을 수 없으면 못 씀)
|
||||
@@ -121,12 +121,17 @@ export function simulateCombat(data, rng, stats) {
|
||||
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (등록된 파워가 매턴 힘 누적)
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
pBlock = 0;
|
||||
let energyBonus = 0;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (pc && pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
|
||||
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;
|
||||
}
|
||||
let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE);
|
||||
let energy = ENERGY + energyBonus; hand = []; draw(HAND_SIZE);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
@@ -139,12 +144,22 @@ export function simulateCombat(data, rng, stats) {
|
||||
// 카드 디버프는 피해보다 먼저 적용 — Lua PlayCard(즉시 부여) + 지연 데미지(0.35s) 동기화
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
const dmg = calcAttack(c.damage || 0, pStr, pWeak, target.vuln);
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
// 다단히트: 타격마다 힘·약화 적용 합산, 취약은 합산값에 1회 (Lua 동기화)
|
||||
const hitN = c.hits || 1;
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
|
||||
const 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) pBlock += c.block;
|
||||
if (c.strength) pStr += c.strength;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (stats) stats[id] = bump(stats[id], c.cost, dmg, c.block || 0);
|
||||
} else if (c.kind === 'Power') {
|
||||
if (c.powerEffect) powers.push(id);
|
||||
@@ -152,6 +167,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
} else {
|
||||
pBlock += c.block || 0;
|
||||
if (c.strength) pStr += c.strength;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (c.weak || c.vuln) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
|
||||
@@ -200,3 +200,83 @@ test('simulateCombat: 적 약화 인텐트 → 적 공격력 감소는 적용
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -68,6 +68,11 @@ function luaCardsTable(cards) {
|
||||
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
|
||||
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`);
|
||||
if (c.value != null) fields.push(`value = ${c.value}`);
|
||||
if (!c.class) throw new Error(`[gen-slaydeck] 카드 ${id}에 class 누락`);
|
||||
fields.push(`class = ${luaStr(c.class)}`);
|
||||
if (c.hits != null) fields.push(`hits = ${c.hits}`);
|
||||
if (c.pierce === true) fields.push('pierce = true');
|
||||
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
|
||||
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
||||
return `\t${id} = { ${fields.join(', ')} },`;
|
||||
});
|
||||
@@ -90,6 +95,8 @@ const GENERATED_UI_SECTIONS = [
|
||||
'ShopHud',
|
||||
'RestHud',
|
||||
'TreasureHud',
|
||||
'JobChoiceHud',
|
||||
'JobSelectHud',
|
||||
'MainMenu',
|
||||
'CharacterSelectHud',
|
||||
];
|
||||
@@ -101,6 +108,8 @@ const UI_APPEND_ORDER = [
|
||||
'ShopHud',
|
||||
'RestHud',
|
||||
'TreasureHud',
|
||||
'JobChoiceHud',
|
||||
'JobSelectHud',
|
||||
'DeckInspectHud',
|
||||
'DeckAllHud',
|
||||
'MainMenu',
|
||||
@@ -129,7 +138,7 @@ const ALIGN_BOTTOM_CENTER = 6;
|
||||
|
||||
function guid(prefix, n) {
|
||||
// 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑.
|
||||
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : 0xfe;
|
||||
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : prefix === 'job' ? 0xe4 : 0xfe;
|
||||
const v = (ns * 0x100000 + n) >>> 0;
|
||||
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
@@ -1869,6 +1878,138 @@ function upsertUi() {
|
||||
}));
|
||||
emit('TreasureHud', treasure);
|
||||
|
||||
// 전직 선택 (P9) — 보스 보상: 유물 vs 2차 전직
|
||||
const jobChoice = [];
|
||||
const jobChoiceHud = entity({
|
||||
id: guid('job', 0),
|
||||
path: '/ui/DefaultGroup/JobChoiceHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
jobChoiceHud.jsonString.enable = false;
|
||||
jobChoice.push(jobChoiceHud);
|
||||
jobChoice.push(entity({
|
||||
id: guid('job', 1),
|
||||
path: '/ui/DefaultGroup/JobChoiceHud/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 60 }, pos: { x: 0, y: 220 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '보스 처치 보상을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const jcButtons = [
|
||||
['RelicButton', '유물 획득', -240, { r: 0.7, g: 0.55, b: 0.85, a: 1 }],
|
||||
['JobButton', '2차 전직', 240, { r: 0.86, g: 0.6, b: 0.3, a: 1 }],
|
||||
];
|
||||
jcButtons.forEach(([suffix, label, x, color], bi) => {
|
||||
jobChoice.push(entity({
|
||||
id: guid('job', 2 + bi),
|
||||
path: `/ui/DefaultGroup/JobChoiceHud/${suffix}`,
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1 + bi,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 140 }, pos: { x, y: 0 } }),
|
||||
sprite({ color, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: label, fontSize: 32, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
emit('JobChoiceHud', jobChoice);
|
||||
|
||||
const jobSelect = [];
|
||||
const jobSelectHud = entity({
|
||||
id: guid('job', 10),
|
||||
path: '/ui/DefaultGroup/JobSelectHud',
|
||||
modelId: 'uisprite', entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 10,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.94 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
jobSelectHud.jsonString.enable = false;
|
||||
jobSelect.push(jobSelectHud);
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 11),
|
||||
path: '/ui/DefaultGroup/JobSelectHud/Title',
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 60 }, pos: { x: 0, y: 300 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '2차 전직 — 직업을 선택하세요', fontSize: 36, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const jobs = [
|
||||
['fighter', '파이터', '공격 특화\n콤보 어택 · 버서크\n라이징 어택', '대표 카드: 콤보 어택', -440, { r: 0.82, g: 0.4, b: 0.34, a: 1 }],
|
||||
['page', '페이지', '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', '대표 카드: 썬더 차지', 0, { r: 0.4, g: 0.55, b: 0.85, a: 1 }],
|
||||
['spearman', '스피어맨', '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', '대표 카드: 피어스', 440, { r: 0.42, g: 0.72, b: 0.46, a: 1 }],
|
||||
];
|
||||
jobs.forEach(([jobId, name, desc, starter, x, color], ji) => {
|
||||
const base = `/ui/DefaultGroup/JobSelectHud/Job_${jobId}`;
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 12 + ji * 4),
|
||||
path: base,
|
||||
modelId: 'uibutton', entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 1 + ji,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 420 }, pos: { x, y: -20 } }),
|
||||
sprite({ color, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 13 + ji * 4),
|
||||
path: `${base}/Name`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 50 }, pos: { x: 0, y: 150 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: name, fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 14 + ji * 4),
|
||||
path: `${base}/Desc`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 160 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: desc, fontSize: 22, bold: false, color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
jobSelect.push(entity({
|
||||
id: guid('job', 15 + ji * 4),
|
||||
path: `${base}/Starter`,
|
||||
modelId: 'uitext', entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 380, parentH: 420, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 340, y: 32 }, pos: { x: 0, y: -160 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: starter, fontSize: 18, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
});
|
||||
emit('JobSelectHud', jobSelect);
|
||||
|
||||
const menu = [];
|
||||
menu.push(entity({
|
||||
id: guid('menu', 0),
|
||||
@@ -2237,6 +2378,7 @@ function writeCodeblocks() {
|
||||
prop('number', 'Depth', '0'),
|
||||
prop('any', 'VisitedNodes'),
|
||||
prop('boolean', 'ChestOpened', 'false'),
|
||||
prop('string', 'PlayerJob', '""'),
|
||||
], [
|
||||
method('OnBeginPlay', `self:ShowMainMenu()`),
|
||||
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
|
||||
@@ -2250,6 +2392,8 @@ self:SetEntityEnabled("/ui/DefaultGroup/MapHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/RestHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckInspectHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckAllHud", false)`),
|
||||
method('ShowState', `self:HideGameHud()
|
||||
@@ -2346,6 +2490,7 @@ self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
|
||||
${luaEnemiesTable(ENEMIES.enemies)}
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self.PlayerJob = ""
|
||||
self:GenerateMap()
|
||||
self:BindButtons()
|
||||
self:AddRelic("${RELICS.startingRelic}")
|
||||
@@ -2355,6 +2500,7 @@ self:ShowMap()`),
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/Result", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PotionMenu", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/TooltipBox", false)
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
self.MaxEnergy = 3
|
||||
self.Turn = 0
|
||||
self.PlayerBlock = 0
|
||||
@@ -2608,23 +2754,45 @@ 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
|
||||
local jobIds = { "fighter", "page", "spearman" }
|
||||
for i = 1, #jobIds do
|
||||
local jid = jobIds[i]
|
||||
local jb = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobSelectHud/Job_" .. jid)
|
||||
if jb ~= nil and jb.ButtonComponent ~= nil then
|
||||
jb:ConnectEvent(ButtonClickEvent, function() self:SetJob(jid) end)
|
||||
end
|
||||
end`),
|
||||
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
||||
self.Energy = self.MaxEnergy
|
||||
self:ApplyRelics("turnStart")
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
self:DrawCards(5)
|
||||
self:RenderHand(true)
|
||||
self:RenderCombat()`),
|
||||
@@ -2889,7 +3057,12 @@ end
|
||||
self.Energy = self.Energy - c.cost
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
self:PlayAttackFx(self.TargetIndex, c.image, self:CalcPlayerAttack(c.damage))
|
||||
local total = 0
|
||||
local hitN = c.hits or 1
|
||||
for h = 1, hitN do
|
||||
total = total + self:CalcPlayerAttack(c.damage)
|
||||
end
|
||||
self:PlayAttackFx(self.TargetIndex, c.image, total, c.pierce == true)
|
||||
end
|
||||
if c.block ~= nil then
|
||||
self.PlayerBlock = self.PlayerBlock + c.block
|
||||
@@ -2907,6 +3080,9 @@ end
|
||||
if c.strength ~= nil then
|
||||
self.PlayerStr = self.PlayerStr + c.strength
|
||||
end
|
||||
if c.selfVuln ~= nil then
|
||||
self.PlayerVuln = self.PlayerVuln + c.selfVuln
|
||||
end
|
||||
if c.weak ~= nil or c.vuln ~= nil then
|
||||
local tm = self.Monsters[self.TargetIndex]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
@@ -3014,7 +3190,7 @@ local dmg = amount
|
||||
if m.vuln > 0 then
|
||||
dmg = math.floor(dmg * 1.5)
|
||||
end
|
||||
if m.block > 0 then
|
||||
if m.block > 0 and pierce ~= true then
|
||||
local absorbed = math.min(m.block, dmg)
|
||||
m.block = m.block - absorbed
|
||||
dmg = dmg - absorbed
|
||||
@@ -3023,10 +3199,13 @@ m.hp = m.hp - dmg
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
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)
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
return
|
||||
@@ -3052,7 +3231,7 @@ _TimerService:SetTimerOnce(function()
|
||||
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
|
||||
shown = math.floor(damage * 1.5)
|
||||
end
|
||||
self:DealDamageToTarget(damage)
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
self:ShowDmgPop(targetIndex, shown)
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
@@ -3060,6 +3239,7 @@ 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('KillMonster', `local m = self.Monsters[slot]
|
||||
if m == nil then
|
||||
@@ -3185,26 +3365,18 @@ if anyAlive == false then
|
||||
end
|
||||
end
|
||||
if node ~= nil and node.type == "boss" then
|
||||
local bid = self:PickNewRelic()
|
||||
if bid ~= "" then
|
||||
self:AddRelic(bid)
|
||||
local br = self.Relics[bid]
|
||||
if br ~= nil then
|
||||
self:Toast("유물 획득: " .. br.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
if node ~= nil and node.type == "boss" then
|
||||
if self.Floor < self.RunLength then
|
||||
self.Floor = self.Floor + 1
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self:GenerateMap()
|
||||
self:RenderRun()
|
||||
self:TeleportToActMap()
|
||||
self:ShowMap()
|
||||
if self.PlayerJob == "" and self.Floor < self.RunLength then
|
||||
self:ShowJobChoice()
|
||||
else
|
||||
self:EndRun("런 클리어!")
|
||||
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()
|
||||
@@ -3213,6 +3385,64 @@ 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`),
|
||||
method('ShowJobChoice', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", true)`),
|
||||
method('PickJobReward', `self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", false)
|
||||
if kind == "relic" then
|
||||
local bid = self:PickNewRelic()
|
||||
if bid ~= "" then
|
||||
self:AddRelic(bid)
|
||||
local br = self.Relics[bid]
|
||||
if br ~= nil then
|
||||
self:Toast("유물 획득: " .. br.name)
|
||||
end
|
||||
end
|
||||
self:ContinueAfterBoss()
|
||||
else
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", true)
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
|
||||
method('JobLabel', `if self.PlayerJob == "fighter" then
|
||||
return "파이터"
|
||||
elseif self.PlayerJob == "page" then
|
||||
return "페이지"
|
||||
elseif self.PlayerJob == "spearman" then
|
||||
return "스피어맨"
|
||||
end
|
||||
if self.SelectedClass == "warrior" then
|
||||
return "전사"
|
||||
end
|
||||
return "플레이어"`, [], 0, 'string'),
|
||||
method('SetJob', `self.PlayerJob = jobId
|
||||
local starter = ""
|
||||
if jobId == "fighter" then
|
||||
starter = "ComboAttack"
|
||||
elseif jobId == "page" then
|
||||
starter = "ThunderCharge"
|
||||
elseif jobId == "spearman" then
|
||||
starter = "Pierce"
|
||||
end
|
||||
if starter ~= "" then
|
||||
table.insert(self.RunDeck, starter)
|
||||
local sc = self.Cards[starter]
|
||||
if sc ~= nil then
|
||||
self:Toast("2차 전직: " .. self:JobLabel() .. "! 신규 카드 — " .. sc.name)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", false)
|
||||
self:ContinueAfterBoss()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'jobId' }]),
|
||||
method('TeleportToActMap', `local maps = { ${ACT_MAPS.map((m) => `"${m}"`).join(', ')} }
|
||||
local target = maps[self.Floor]
|
||||
if target == nil then
|
||||
@@ -3350,12 +3580,17 @@ end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], N
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층")
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
method('CardPool', `local pool = {}
|
||||
for id, c in pairs(self.Cards) do
|
||||
if c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob) then
|
||||
table.insert(pool, id)
|
||||
end
|
||||
end
|
||||
table.sort(pool)
|
||||
return pool`, [], 0, 'any'),
|
||||
method('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
|
||||
local pool = {}
|
||||
for id, _ in pairs(self.Cards) do
|
||||
table.insert(pool, id)
|
||||
end
|
||||
local pool = self:CardPool()
|
||||
self.RewardChoices = {}
|
||||
for i = 1, 3 do
|
||||
self.RewardChoices[i] = pool[math.random(1, #pool)]
|
||||
@@ -3530,7 +3765,7 @@ 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:DealDamageToTarget(p.value, false)
|
||||
self:ShowDmgPop(self.TargetIndex, p.value)
|
||||
elseif p.effect == "strength" then
|
||||
self.PlayerStr = self.PlayerStr + p.value
|
||||
@@ -3850,10 +4085,7 @@ else
|
||||
self.CurrentEnemyId = ""
|
||||
self:StartCombat()
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('ShowShop', `local pool = {}
|
||||
for cid, _ in pairs(self.Cards) do
|
||||
table.insert(pool, cid)
|
||||
end
|
||||
method('ShowShop', `local pool = self:CardPool()
|
||||
self.ShopChoices = {}
|
||||
self.ShopBought = { false, false, false }
|
||||
for i = 1, 3 do
|
||||
|
||||
3384
ui/DefaultGroup.ui
3384
ui/DefaultGroup.ui
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user