Compare commits
15 Commits
ffcec649fa
...
codex/dmg-
| Author | SHA1 | Date | |
|---|---|---|---|
| a7a6b2123a | |||
| fc37d81350 | |||
| d5931b1822 | |||
| fe92b8bbb8 | |||
| f89cd1ad63 | |||
| 758ebd19f2 | |||
| 90494232bc | |||
| ecadf3606e | |||
| 4b559ca7fa | |||
| 30c73c9899 | |||
| 47e954266c | |||
| 8b4ece766a | |||
| 5297922f99 | |||
| e3a75c33a3 | |||
| 0a040837d9 |
File diff suppressed because one or more lines are too long
@@ -19,7 +19,9 @@
|
||||
"classToFrame": {
|
||||
"warrior": "warrior",
|
||||
"fighter": "warrior",
|
||||
"crusader": "warrior",
|
||||
"page": "warrior",
|
||||
"knight": "warrior",
|
||||
"spearman": "warrior",
|
||||
"magician": "magician",
|
||||
"firepoison": "magician",
|
||||
|
||||
1983
data/cards.json
1983
data/cards.json
File diff suppressed because it is too large
Load Diff
BIN
data/cards.xlsx
BIN
data/cards.xlsx
Binary file not shown.
@@ -1,10 +1,7 @@
|
||||
# 공격 적중 독
|
||||
# 공격 중독
|
||||
|
||||
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
|
||||
|
||||
동작:
|
||||
|
||||
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
|
||||
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
|
||||
- `Envenom` 같은 카드가 이 필드를 사용합니다.
|
||||
`attackPoison`은 전투 동안 유지되는 공용 카드 효과 필드입니다.
|
||||
|
||||
- 공격 카드가 실제 체력 피해를 주면 대상에게 지정된 수치만큼 중독을 부여합니다.
|
||||
- 광역 공격은 피해를 받은 각 적에게 중독을 부여합니다.
|
||||
- 현재 Thief Master와 Hermit의 `베놈`이 이 효과를 사용합니다.
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
# Rogue Card Audit
|
||||
# 도적 카드 구성 및 밸런스 기록
|
||||
|
||||
Current status of rogue cards and shared effect hooks.
|
||||
도적 계보의 카드 역할, 직업 이동 금지 대상, 공용 효과 필드를 정리합니다.
|
||||
|
||||
## Implemented
|
||||
## 직업별 컨셉
|
||||
|
||||
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `BouncingFlask`, `Accuracy`, `PhantomBlades`, `Speedster`, `CorrosiveWave`, `Tracking`, `FanOfKnives`, `Strangle`, `Mirage`, `Accelerant`, `MasterPlanner`, `Outbreak`, `EscapePlan`, `HandTrick`, `NoxiousFumes`, `Pinpoint`, `TheHunt`, `Murder`, `Malaise`, `BladeOfInk`, `KnifeTrap`, `BulletTime`, `Envenom`, `SerpentForm`, `WraithForm`, `Skewer`, `Ricochet`, `Anticipate`, `PiercingWail`, `Expose`, `UpMySleeve`, `EchoingSlash`, `BubbleBubble`
|
||||
- `rogue`: 시작 카드, 1차 스킬, 기초 공격·회피·방어
|
||||
- `thief`: 단검 난타, 교활, 버리기, 중독의 시작
|
||||
- `thiefmaster`: 교활·버리기 연계 완성, 광역 난타, 중독 증폭
|
||||
- `assassin`: 표창 생성, 표창 연속 공격, 표창 비용·피해 보조
|
||||
- `hermit`: 표창 보존·광역화·지속 생성 등 표창 빌드 완성
|
||||
|
||||
Shared hooks already in use:
|
||||
Rogue 단계에서도 분기 방향을 미리 경험할 수 있도록 약한 입문 카드를 유지합니다.
|
||||
|
||||
- 중독: `PoisonedStab`
|
||||
- 표창: `LeadingStrike`
|
||||
- 교활: `Untouchable`
|
||||
|
||||
## 스킬 카드 고정
|
||||
|
||||
실제 직업 스킬을 바탕으로 추가한 아래 카드는 다른 차수나 계열로 이동하지 않습니다.
|
||||
|
||||
- Rogue: `DoubleStab`, `LuckySeven`, `Haste`, `DarkSight`, `FlashJump`, `NimbleBody`
|
||||
- Thief: `SavageBlow` 포함 9장
|
||||
- Thief Master: `EdgeCarnival` 포함 11장
|
||||
- Assassin: `ShurikenBurst` 포함 10장
|
||||
- Hermit: `TripleThrow` 포함 9장
|
||||
|
||||
나머지 비스킬 카드는 컨셉에 맞춰 상위 직업으로 이동할 수 있습니다. 상위 직업은 하위 직업 카드를 함께 사용하므로, 이동은 해당 분기의 보상 풀을 제한하는 역할을 합니다.
|
||||
|
||||
## 공용 효과 필드
|
||||
|
||||
- `poison`, `innate`, `playableWhenDrawPileEmpty`
|
||||
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne`
|
||||
@@ -17,6 +39,34 @@ Shared hooks already in use:
|
||||
- `firstCardDamageBonus`
|
||||
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`, `skillSlyOnPlay`, `extraPoisonTicks`, `poisonApplicationBurstEvery`, `poisonApplicationBurstDamage`
|
||||
|
||||
## Open questions
|
||||
## 중복 제거 및 보정
|
||||
|
||||
None at the moment.
|
||||
- 삭제: `Mirage`, `Accuracy`, `PhantomBlades`, `Adrenaline`, `Afterimage`, `Accelerant`, `Envenom`, `Tracking`
|
||||
- 이유: 상위 직업 스킬 카드와 효과가 같거나, 비용 대비 열세라 별도 선택지가 되지 못함
|
||||
- `Anticipate`: 턴 종료 시 얻은 민첩을 잃도록 실제 효과와 설명을 일치시킴
|
||||
- `Backstab`, `Assassinate`, `TheHunt`, `PiercingWail`: 설명에 있던 소멸을 실제 필드에 반영
|
||||
- 2차 지급: Thief `DaggerAcceleration`, Assassin `JavelinAcceleration`
|
||||
- 3차 지급: Thief Master `Venom`, Hermit `SpiritJavelin`
|
||||
|
||||
## 카드 효율 검증
|
||||
|
||||
`node tools/balance/card-efficiency.mjs --runs 1000`으로 도적 계열 카드 전체를 검증합니다.
|
||||
|
||||
- 각 직업의 기준 덱에서 같은 종류의 카드 한 장을 교체하고 동일 시드로 반복 전투합니다.
|
||||
- 승률, 승리 시 체력, 전투 턴을 합친 점수를 같은 직업·희귀도 중앙값과 비교합니다.
|
||||
- 0코스트 에너지 생성, 재사용 가능한 영구 능력치, 저비용 2배 증폭처럼 자동 플레이가 놓치기 쉬운 구조도 별도로 검사합니다.
|
||||
- 교활, 조건부 중독, 카드 보존처럼 플레이 순서 의존성이 큰 효과는 자동 시뮬레이션 하위권만으로 상향하지 않습니다.
|
||||
|
||||
2026-07-01 검증 결과 구조적 위험은 0장입니다. 주요 조정은 `독맥 터뜨리기`, `메아리 칼자국`, `소리 없는 제압`, `그리드`, `그림자 속도전`, `스틸`, 두 계열의 `피지컬 트레이닝`, `마크 오브 어쌔신`, `자벨린 액셀레이션`, `비장의 패`에 반영했습니다.
|
||||
|
||||
비스킬 카드 78장의 메이플풍 표시 이름은 `docs/rogue-card-names.md`에서 관리합니다. 메이플 원본 스킬 카드 45장의 이름은 변경하지 않습니다.
|
||||
|
||||
## 5섹션 캠페인 검증
|
||||
|
||||
`node tools/balance/rogue-campaign.mjs --runs 5000 --reward-min 5`로 전체 런을 검증합니다.
|
||||
|
||||
- 섹션마다 일반전 4회, 엘리트 1회, 보스 1회를 진행합니다.
|
||||
- 1섹션은 Rogue, 2섹션은 2차 직업, 3~5섹션은 3차 직업 카드 풀을 사용합니다.
|
||||
- 실제 카드 보상 확률, 전직 지급 카드, 시작·획득 유물, 체력 유지와 휴식 회복을 반영합니다.
|
||||
- 몬스터 배율은 `1.00 → 1.075 → 1.15 → 1.30 → 1.45`이며 런타임과 시뮬레이터가 같은 공용 상수를 사용합니다.
|
||||
- 5,000회 결과: Thief Master 완주 2.9%, Hermit 완주 3.6%. 자동 플레이와 일부 공격형 유물 미구현을 감안한 보수적 결과입니다.
|
||||
|
||||
@@ -6,15 +6,25 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
## Damage
|
||||
|
||||
- `damage`: base attack damage
|
||||
- `damageFromCurrentBlock`: add current block times this value to attack damage
|
||||
- `damageNameMatch`: substring to match against owned card names
|
||||
- `damagePerOwnedNameMatch`: bonus damage per owned card whose name matches `damageNameMatch`
|
||||
- `damagePerOtherHandCard`: bonus damage per other card in hand
|
||||
- `damagePerAttackPlayedThisTurn`: bonus damage per attack played this turn
|
||||
- `damagePerDiscardedThisTurn`: bonus damage per card discarded this turn
|
||||
- `damagePerSkillInHand`: bonus damage per skill card in hand
|
||||
- `damagePerCardDrawnThisCombat`: bonus damage per card drawn this combat
|
||||
- `damagePerCombo`: bonus base damage per current Combo
|
||||
- `damagePerHolyCharge`: bonus base damage per current Holy Charge
|
||||
- `attackDamagePerCombo`: Power field that adds base damage per current Combo to all Attacks
|
||||
- `attackPlayedDamage`: Power field that deals extra damage after an Attack card is played
|
||||
- `damagePerTurn`: damage applied at turn start
|
||||
- `cardPlayedDamage`: damage when the card is played
|
||||
- `cardPlayedRandomDamage`: random damage when the card is played
|
||||
- `drawOnExhaust`: draw when a card is exhausted
|
||||
- `rewardOnKill`: gain bonus reward screens when the card kills
|
||||
- `maxHpOnKill`: gain max HP when the attack kills
|
||||
- `drawNameMatchAutoPlay`: auto-play drawn cards whose names contain this substring
|
||||
- `randomTargetEachHit`: choose a random alive enemy for each hit
|
||||
- `repeatOnKill`: repeat the attack when it kills at least one enemy
|
||||
- `firstCardDamageBonus`: bonus damage for the first card played this turn
|
||||
@@ -39,6 +49,17 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `drawUntilHandSize`: draw until hand reaches a target size
|
||||
- `drawSkillBlock`: gain block for each Skill drawn
|
||||
- `drawPoison`: apply poison when a card is drawn
|
||||
- `exhaustHandNonAttack`: exhaust every non-Attack card in hand
|
||||
- `exhaustHandAll`: exhaust every card in hand
|
||||
- `drawPerExhausted`: draw cards equal to exhausted cards
|
||||
- `blockPerExhaustedCard`: gain block for each card exhausted by the current effect
|
||||
- `addRandomCardCount`: add random cards to hand
|
||||
- `addRandomCardPerExhausted`: add random cards equal to exhausted cards
|
||||
- `addRandomCardKind`: filter random added cards by kind
|
||||
- `addRandomCardSameClass`: restrict random added cards to the source card class
|
||||
- `addedCardsCostZeroThisTurn`: cards added by this effect cost 0 this turn
|
||||
- `playTopDrawPileCount`: play cards from the top of the draw pile
|
||||
- `playTopDrawPileCountPerEnergy`: play cards from the top of the draw pile per energy spent
|
||||
- `handCostZeroThisTurn`: make hand cards cost 0 this turn
|
||||
- `drawDisabledThisTurn`: disable draw for the rest of the turn
|
||||
- `heal`: heal immediately
|
||||
@@ -48,6 +69,21 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `thorns`: gain Thorns
|
||||
- `selfVuln`: apply Vulnerable to self
|
||||
- `extraPoisonTicks`: add extra poison ticks at enemy turn start
|
||||
- `comboGain`: gain Combo when the card resolves; Attack cards gain it after dealing damage
|
||||
- `comboOnAttack`: Power field that gains Combo whenever an Attack card is played
|
||||
- `comboMax`: Power field that raises the maximum Combo above the default 5
|
||||
- `attackWeak`: Power field that applies Weak after an Attack card is played
|
||||
- `removePlayerDebuffs`: remove player Weak and Vulnerable
|
||||
- `holyForce`: marks a card as Holy Force for Holy Charge Power triggers
|
||||
- `holyChargeGain`: gain Holy Charge directly
|
||||
- `holyChargeOnHolyForce`: Power field that gains Holy Charge after a Holy Force card
|
||||
- `holyChargeMax`: Power field that raises the maximum Holy Charge above the default 3
|
||||
- `blockPerHolyCharge`: gain additional block per current Holy Charge
|
||||
- `healPerHolyCharge`: heal additional HP per current Holy Charge
|
||||
- `holyChargeSpendAll`: consume all Holy Charge after resolving the card
|
||||
- `damageTakenReduction`: Power field that reduces incoming damage; total reduction is capped at 75%
|
||||
- `blockOnDamaged`: Power field that grants block after taking HP damage
|
||||
- `strengthOnDamagedOnce`: Power field that grants Strength on the first HP damage each combat
|
||||
|
||||
## Status
|
||||
|
||||
@@ -91,7 +127,9 @@ The goal is to keep card behavior reusable instead of hardcoding one-off card na
|
||||
- `powerEffect: "blockPerTurn"`
|
||||
- `powerEffect: "poisonPerTurn"`
|
||||
- `powerEffect: "damagePerTurn"`
|
||||
- `powerEffect: "healPerTurn"`
|
||||
- `powerEffect: "retainOne"`
|
||||
- `powerEffect: "keepBlock"`
|
||||
- `turnStartShiv`: create Shivs at turn start
|
||||
- `turnStartDraw`: draw cards at turn start
|
||||
- `turnStartDiscard`: discard cards at turn start
|
||||
|
||||
97
docs/rogue-card-names.md
Normal file
97
docs/rogue-card-names.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 도적 비스킬 카드 이름
|
||||
|
||||
메이플스토리 원본 스킬을 바탕으로 만든 카드 45장은 이름을 고정합니다.
|
||||
아래 78장은 다른 직업의 스킬명을 점유하지 않도록 도적 계열의 독자적인 이름으로 변경했습니다.
|
||||
|
||||
## Rogue
|
||||
|
||||
- `Neutralize`: 무력화 -> 커닝식 견제
|
||||
- `SilentStrike`: 타격 -> 초보 도적의 칼끝
|
||||
- `Survivor`: 생존자 -> 골목길 생존술
|
||||
- `SilentDefend`: 수비 -> 낡은 가죽 방패
|
||||
- `Slice`: 칼질 -> 짧은 단검질
|
||||
- `PoisonedStab`: 독 찌르기 -> 초록 독단검
|
||||
- `SuckerPunch`: 불의의 일격 -> 골목 기습
|
||||
- `LeadingStrike`: 선제 타격 -> 초보 표창 던지기
|
||||
- `Anticipate`: 예측 -> 럭키 예감
|
||||
- `Deflect`: 튕겨내기 -> 단검 쳐내기
|
||||
- `Backflip`: 공중제비 -> 커닝 곡예
|
||||
- `DodgeAndRoll`: 구르기 -> 골목 구르기
|
||||
- `Untouchable`: 범접 불가 -> 연막 속 숨기
|
||||
- `Backstab`: 배신 -> 그림자 등찌르기
|
||||
- `EscapePlan`: 탈출구 -> 비상용 연막탄
|
||||
|
||||
## Thief
|
||||
|
||||
- `DaggerSpray`: 단검 분사 -> 단검비
|
||||
- `DaggerThrow`: 단검 투척 -> 비도 투척
|
||||
- `FollowThrough`: 완수 -> 연달아 찌르기
|
||||
- `FlickFlack`: 재주넘기 -> 커닝 난무
|
||||
- `Prepared`: 예비 -> 비장의 패
|
||||
- `PiercingWail`: 귀를 찢는 비명 -> 골목의 살기
|
||||
- `DeadlyPoison`: 맹독 -> 맹독 조제
|
||||
- `Snakebite`: 뱀 물기 -> 독니 단검
|
||||
- `PreciseCut`: 정밀한 베기 -> 급소 절개
|
||||
- `Finisher`: 마무리 -> 마지막 칼끝
|
||||
- `MementoMori`: 메멘토 모리 -> 사신의 장부
|
||||
- `Strangle`: 목 조르기 -> 그림자 올가미
|
||||
- `Dash`: 돌진 -> 뒷골목 돌파
|
||||
- `CalculatedGamble`: 계산된 도박 -> 메소 건 승부
|
||||
- `Expose`: 들춰내기 -> 약점 들추기
|
||||
- `Acrobatics`: 곡예 -> 지붕 위 곡예
|
||||
- `HandTrick`: 손기술 -> 재빠른 손놀림
|
||||
- `Expertise`: 전문성 -> 노련한 단검술
|
||||
- `BubbleBubble`: 차오르는 독 -> 독액 농축
|
||||
- `Blur`: 흐릿함 -> 흐린 잔영
|
||||
- `LegSweep`: 다리 걸기 -> 발목 베기
|
||||
- `Reflex`: 반사신경 -> 찰나의 반응
|
||||
- `Tactician`: 전략가 -> 골목길 책략
|
||||
- `WellLaidPlans`: 괜찮은 전략 -> 빈틈없는 작전
|
||||
- `Footwork`: 발놀림 -> 사뿐한 발놀림
|
||||
- `NoxiousFumes`: 유독 가스 -> 숨막히는 독연기
|
||||
|
||||
## Thief Master
|
||||
|
||||
- `BouncingFlask`: 탄성 플라스크 -> 통통 독병
|
||||
- `Haze`: 아지랑이 -> 보랏빛 독연기
|
||||
- `Outbreak`: 발병 -> 독맥 터뜨리기
|
||||
- `Speedster`: 스피드스터 -> 그림자 속도전
|
||||
- `GrandFinale`: 대단원의 막 -> 커닝의 대단원
|
||||
- `Assassinate`: 암살 -> 어둠 속 급소
|
||||
- `EchoingSlash`: 메아리 참격 -> 메아리 칼자국
|
||||
- `Murder`: 살해 -> 쌓여가는 살의
|
||||
- `Malaise`: 불쾌 -> 기운 빼는 독
|
||||
- `ShadowStep`: 그림자 걸음 -> 그림자 발자국
|
||||
- `Shadowmeld`: 그림자 은신 -> 연막 속 은신
|
||||
- `CorrosiveWave`: 부식성 파도 -> 부식 독물결
|
||||
- `Burst`: 폭주 -> 연속 술수
|
||||
- `KnifeTrap`: 칼날 함정 -> 숨은 칼날덫
|
||||
- `BulletTime`: 불릿 타임 -> 멈춘 듯한 순간
|
||||
- `Nightmare`: 악몽 -> 검은 꿈
|
||||
- `ToolsOfTheTrade`: 작업 도구 -> 도적의 연장통
|
||||
- `MasterPlanner`: 설계의 대가 -> 작전의 달인
|
||||
- `SerpentForm`: 구렁이의 형상 -> 독사의 몸놀림
|
||||
- `Abrasive`: 연마 -> 거친 숫돌질
|
||||
- `Suppress`: 진압 -> 소리 없는 제압
|
||||
- `WraithForm`: 유령의 형상 -> 유령 같은 몸놀림
|
||||
|
||||
## Assassin
|
||||
|
||||
- `Ricochet`: 도탄 -> 통통 튀는 표창
|
||||
- `BladeDance`: 검무 -> 표창 별무리
|
||||
- `CloakAndDagger`: 망토와 단검 -> 망토 속 별
|
||||
- `Skewer`: 꼬챙이 -> 꿰뚫는 표창
|
||||
- `Flechettes`: 프레췌 -> 표창 셈법
|
||||
- `Pounce`: 덮치기 -> 어둠을 가르는 도약
|
||||
- `Predator`: 천적 -> 표창 끝의 추격
|
||||
- `Pinpoint`: 정밀 사격 -> 한 점 겨냥
|
||||
- `HiddenDaggers`: 숨겨진 표창 -> 숨겨둔 표창
|
||||
- `UpMySleeve`: 비책 -> 소매 속 표창
|
||||
- `InfiniteBlades`: 무한의 검날 -> 끝없는 표창통
|
||||
- `TheHunt`: 사냥 -> 커닝 현상금
|
||||
- `StormOfSteel`: 강철의 폭풍 -> 쇠별 폭풍
|
||||
|
||||
## Hermit
|
||||
|
||||
- `BladeOfInk`: 잉크 칼날 -> 먹빛 표창
|
||||
- `FanOfKnives`: 칼날 부채 -> 사방 표창비
|
||||
900
map/map01.map
900
map/map01.map
File diff suppressed because it is too large
Load Diff
275
tools/balance/card-efficiency.mjs
Normal file
275
tools/balance/card-efficiency.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
PLAYER_HP,
|
||||
loadData,
|
||||
mulberry32,
|
||||
simulateCombat,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
const AUDITED_CLASSES = new Set([
|
||||
'rogue', 'thief', 'thiefmaster', 'assassin', 'hermit',
|
||||
'warrior', 'fighter', 'crusader', 'page', 'knight',
|
||||
]);
|
||||
|
||||
const CONTEXT_DECKS = {
|
||||
rogue: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'DoubleStab', 'Backflip',
|
||||
],
|
||||
thief: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'SavageBlow', 'DaggerAcceleration',
|
||||
'DeadlyPoison', 'Acrobatics',
|
||||
],
|
||||
thiefmaster: [
|
||||
'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend',
|
||||
'Survivor', 'SavageBlow', 'DaggerAcceleration', 'DeadlyPoison',
|
||||
'Acrobatics', 'EdgeCarnival', 'PickPocket', 'Venom',
|
||||
],
|
||||
assassin: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'LeadingStrike', 'BladeDance',
|
||||
'JavelinAcceleration', 'JavelinMastery',
|
||||
],
|
||||
hermit: [
|
||||
'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend',
|
||||
'Survivor', 'LeadingStrike', 'BladeDance', 'JavelinAcceleration',
|
||||
'JavelinMastery', 'TripleThrow', 'SpiritJavelin', 'SkilledJavelin',
|
||||
],
|
||||
warrior: [
|
||||
'Strike', 'Strike', 'Strike', 'Strike',
|
||||
'Defend', 'Defend', 'Defend', 'Defend',
|
||||
'Bash', 'SlashBlast', 'IronBody', 'WarriorMastery',
|
||||
],
|
||||
fighter: [
|
||||
'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend',
|
||||
'Bash', 'SlashBlast', 'ComboAttack', 'Brandish', 'WeaponMastery', 'FlashSlash',
|
||||
],
|
||||
crusader: [
|
||||
'Strike', 'Strike', 'Defend', 'Defend', 'Bash', 'SlashBlast',
|
||||
'ComboAttack', 'Brandish', 'WeaponMastery', 'BraveSlash', 'ComboSynergy', 'Rush',
|
||||
],
|
||||
page: [
|
||||
'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend',
|
||||
'Bash', 'SlashBlast', 'HolyCharge', 'DivineSwing', 'PageOrder', 'PageStance',
|
||||
],
|
||||
knight: [
|
||||
'Strike', 'Strike', 'Defend', 'Defend', 'Bash', 'SlashBlast',
|
||||
'HolyCharge', 'DivineSwing', 'PageOrder', 'DivineCharge', 'KnightRush', 'Restoration',
|
||||
],
|
||||
};
|
||||
|
||||
const ENCOUNTER_SCALE = {
|
||||
rogue: { hp: 1.9, attack: 1.5 },
|
||||
thief: { hp: 2.2, attack: 1.6 },
|
||||
assassin: { hp: 2.25, attack: 1.65 },
|
||||
thiefmaster: { hp: 2.4, attack: 1.5 },
|
||||
hermit: { hp: 2.6, attack: 1.65 },
|
||||
warrior: { hp: 1.9, attack: 1.5 },
|
||||
fighter: { hp: 2.2, attack: 1.6 },
|
||||
crusader: { hp: 2.6, attack: 1.7 },
|
||||
page: { hp: 2.2, attack: 1.6 },
|
||||
knight: { hp: 2.6, attack: 1.7 },
|
||||
};
|
||||
|
||||
const median = (values) => {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
};
|
||||
|
||||
function validateContextDecks(cards) {
|
||||
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
||||
for (const cardId of deck) {
|
||||
if (!cards[cardId]) throw new Error(`${classId} 효율 기준 덱에 없는 카드: ${cardId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeScore(result) {
|
||||
if (result.draw) return -60;
|
||||
if (!result.win) return -100 - result.turns;
|
||||
return 100 + (result.playerHpRemaining / PLAYER_HP) * 30 - result.turns * 2;
|
||||
}
|
||||
|
||||
function scaledEncounter(data, classId) {
|
||||
const scale = ENCOUNTER_SCALE[classId];
|
||||
return {
|
||||
...data,
|
||||
monsters: data.monsters.map((monster) => ({
|
||||
...monster,
|
||||
maxHp: Math.round(monster.maxHp * scale.hp),
|
||||
intents: monster.intents.map((intent) => intent.kind === 'Attack'
|
||||
? { ...intent, value: Math.round(intent.value * scale.attack) }
|
||||
: { ...intent }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function simulateDeck(baseData, deck, runs, seed, trackedCardId = null) {
|
||||
let wins = 0;
|
||||
let totalTurns = 0;
|
||||
let totalHp = 0;
|
||||
let totalScore = 0;
|
||||
let totalPlays = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const stats = {};
|
||||
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
||||
const result = simulateCombat({ ...baseData, starterDeck: deck }, rng, stats);
|
||||
if (result.win) {
|
||||
wins++;
|
||||
totalHp += result.playerHpRemaining;
|
||||
}
|
||||
totalTurns += result.turns;
|
||||
totalScore += outcomeScore(result);
|
||||
if (trackedCardId && stats[trackedCardId]) totalPlays += stats[trackedCardId].plays;
|
||||
}
|
||||
return {
|
||||
winRate: wins / runs,
|
||||
avgTurns: totalTurns / runs,
|
||||
avgHpOnWin: wins > 0 ? totalHp / wins : 0,
|
||||
score: totalScore / runs,
|
||||
avgPlays: totalPlays / runs,
|
||||
};
|
||||
}
|
||||
|
||||
function replacementIndex(deck, cards, candidate) {
|
||||
const preferredKind = candidate.kind === 'Attack' ? 'Attack' : 'Skill';
|
||||
const preferred = deck.findIndex((id) => cards[id]?.kind === preferredKind);
|
||||
if (preferred >= 0) return preferred;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function structuralRisks(card) {
|
||||
const risks = [];
|
||||
const cost = card.cost || 0;
|
||||
const exhaust = card.exhaust === true;
|
||||
const permanentDex = Math.max(0, (card.dex || 0) - (card.endTurnDexLoss || 0));
|
||||
const permanentStats = (card.strength || 0) + permanentDex + (card.thorns || 0);
|
||||
const generatedCards = (card.addShiv || 0) + (card.addShivPerDiscard ? 1 : 0);
|
||||
|
||||
if (cost === 0 && !exhaust && (card.gainEnergy || 0) > 0) {
|
||||
risks.push('0코스트 비소멸 카드가 에너지를 생성');
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.draw || 0) >= 2 && generatedCards > 0) {
|
||||
risks.push('0코스트 비소멸 카드가 2장 이상 드로우하면서 카드를 생성');
|
||||
}
|
||||
if (card.kind !== 'Power' && !exhaust && permanentStats > 0) {
|
||||
risks.push('재사용 가능한 카드가 영구 능력치를 누적');
|
||||
}
|
||||
if (card.kind === 'Power' && (card.attackDamageVsWeakMultiplier || 0) >= 2 && cost <= 1) {
|
||||
risks.push('저비용 지속 효과가 공격 피해를 2배 이상 증폭');
|
||||
}
|
||||
if ((card.poisonApplicationBurstEvery || 0) > 0) {
|
||||
const burstPerApplication = (card.poisonApplicationBurstDamage || 0) / card.poisonApplicationBurstEvery;
|
||||
if (burstPerApplication > 3 && cost <= 1) {
|
||||
risks.push('저비용 독 누적 폭발 피해가 부여 1회당 3을 초과');
|
||||
}
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.block || 0) + (card.nextTurnBlock || 0) >= 8) {
|
||||
risks.push('0코스트 비소멸 카드의 현재·다음 턴 방어 합계가 8 이상');
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.blockPerDamageDealtThisTurn || 0) >= 1) {
|
||||
risks.push('0코스트 비소멸 카드가 이번 턴 누적 피해 전부를 방어로 전환');
|
||||
}
|
||||
if (!exhaust && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0 && generatedCards > 0) {
|
||||
risks.push('에너지 손실 없이 드로우와 카드 생성을 동시에 수행');
|
||||
}
|
||||
if (!exhaust && (card.skillCostReductionThisTurn || 0) > 0 && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0) {
|
||||
risks.push('에너지 손실 없이 드로우하고 이번 턴 스킬 비용까지 감소');
|
||||
}
|
||||
return risks;
|
||||
}
|
||||
|
||||
export function auditCardEfficiency({ runs = 300, seed = 20260701 } = {}) {
|
||||
const data = loadData();
|
||||
const cards = data.cards;
|
||||
validateContextDecks(cards);
|
||||
|
||||
const baselines = {};
|
||||
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
||||
baselines[classId] = simulateDeck(scaledEncounter(data, classId), deck, runs, seed);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const [id, card] of Object.entries(cards)) {
|
||||
if (!AUDITED_CLASSES.has(card.class)) continue;
|
||||
const deck = CONTEXT_DECKS[card.class].slice();
|
||||
deck[replacementIndex(deck, cards, card)] = id;
|
||||
const result = simulateDeck(scaledEncounter(data, card.class), deck, runs, seed, id);
|
||||
rows.push({
|
||||
id,
|
||||
name: card.name,
|
||||
classId: card.class,
|
||||
rarity: card.rarity,
|
||||
kind: card.kind,
|
||||
cost: card.cost || 0,
|
||||
delta: result.score - baselines[card.class].score,
|
||||
...result,
|
||||
risks: structuralRisks(card),
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const peers = rows.filter((other) => other.classId === row.classId && other.rarity === row.rarity);
|
||||
row.peerMedianDelta = median(peers.map((peer) => peer.delta));
|
||||
row.peerGap = row.delta - row.peerMedianDelta;
|
||||
}
|
||||
|
||||
return { runs, seed, baselines, rows };
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatEfficiencyReport(report) {
|
||||
const lines = [];
|
||||
lines.push(`카드 효율 검증: 카드 ${report.rows.length}장, 카드당 ${report.runs}회`);
|
||||
lines.push('기준 덱:');
|
||||
for (const [classId, baseline] of Object.entries(report.baselines)) {
|
||||
lines.push(` ${classId}: 승률 ${formatPercent(baseline.winRate)}, 평균 ${baseline.avgTurns.toFixed(2)}턴, 승리 HP ${baseline.avgHpOnWin.toFixed(1)}`);
|
||||
}
|
||||
|
||||
const risky = report.rows.filter((row) => row.risks.length > 0);
|
||||
lines.push('');
|
||||
lines.push(`구조적 위험 ${risky.length}장:`);
|
||||
for (const row of risky) {
|
||||
lines.push(` ${row.name}(${row.id}, ${row.classId}): ${row.risks.join(' / ')}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('동급 대비 효율 상위:');
|
||||
for (const row of report.rows.slice().sort((a, b) => b.peerGap - a.peerGap).slice(0, 10)) {
|
||||
lines.push(` ${row.name}(${row.id}): 중앙값 대비 +${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('동급 대비 효율 하위:');
|
||||
for (const row of report.rows.slice().sort((a, b) => a.peerGap - b.peerGap).slice(0, 10)) {
|
||||
lines.push(` ${row.name}(${row.id}): 중앙값 대비 ${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let runs = 300;
|
||||
let seed = 20260701;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
||||
}
|
||||
const report = auditCardEfficiency({ runs, seed });
|
||||
console.log(formatEfficiencyReport(report));
|
||||
if (report.rows.some((row) => row.risks.length > 0)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('card-efficiency.mjs')) main();
|
||||
30
tools/balance/card-efficiency.test.mjs
Normal file
30
tools/balance/card-efficiency.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { structuralRisks } from './card-efficiency.mjs';
|
||||
|
||||
test('0코스트 에너지 생성 카드를 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 0, kind: 'Skill', gainEnergy: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('에너지를 생성')));
|
||||
});
|
||||
|
||||
test('재사용 가능한 영구 능력치 스킬을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 1, kind: 'Skill', strength: 1, dex: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('영구 능력치')));
|
||||
});
|
||||
|
||||
test('소멸하거나 파워인 능력치 카드는 허용', () => {
|
||||
assert.deepEqual(structuralRisks({ cost: 1, kind: 'Skill', strength: 1, exhaust: true }), []);
|
||||
assert.deepEqual(structuralRisks({ cost: 1, kind: 'Power', dex: 1 }), []);
|
||||
assert.deepEqual(structuralRisks({ cost: 0, kind: 'Skill', dex: 2, endTurnDexLoss: 2 }), []);
|
||||
});
|
||||
|
||||
test('저비용 2배 피해 증폭을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 1, kind: 'Power', attackDamageVsWeakMultiplier: 2 });
|
||||
assert.ok(risks.some((risk) => risk.includes('2배')));
|
||||
});
|
||||
|
||||
test('0코스트 누적 피해 전체 방어 전환을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 0, kind: 'Skill', blockPerDamageDealtThisTurn: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('누적 피해')));
|
||||
assert.deepEqual(structuralRisks({ cost: 0, kind: 'Skill', blockPerDamageDealtThisTurn: 0.5 }), []);
|
||||
});
|
||||
335
tools/balance/rogue-campaign.mjs
Normal file
335
tools/balance/rogue-campaign.mjs
Normal file
@@ -0,0 +1,335 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mulberry32, rarityForRoll, simulateCombat } from './sim-balance.mjs';
|
||||
import { ACT_DIFFICULTY_MULTIPLIERS } from '../deck/lib/codeblock.mjs';
|
||||
|
||||
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||||
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
const relicsData = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
|
||||
const PLAYER_MAX_HP = { rogue: 70, warrior: 80 };
|
||||
const REST_HEAL = 30;
|
||||
const SECTION_COUNT = 5;
|
||||
const NORMAL_FIGHTS = 4;
|
||||
export const DEFAULT_SECTION_MULTIPLIERS = ACT_DIFFICULTY_MULTIPLIERS;
|
||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump'];
|
||||
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
||||
const BOSS_POOL = ['king_slime', 'slime_boss'];
|
||||
|
||||
const JOBS = {
|
||||
thief: { tier2: 'thief', tier3: 'thiefmaster', tier2Starter: 'DaggerAcceleration', tier3Starter: 'Venom' },
|
||||
assassin: { tier2: 'assassin', tier3: 'hermit', tier2Starter: 'JavelinAcceleration', tier3Starter: 'SpiritJavelin' },
|
||||
fighter: { root: 'warrior', tier2: 'fighter', tier3: 'crusader', tier2Starter: 'ComboAttack', tier3Starter: 'ComboSynergy' },
|
||||
page: { root: 'warrior', tier2: 'page', tier3: 'knight', tier2Starter: 'HolyCharge', tier3Starter: 'DivineCharge' },
|
||||
};
|
||||
|
||||
const LINEAGES = {
|
||||
rogue: ['rogue'],
|
||||
thief: ['rogue', 'thief'],
|
||||
thiefmaster: ['rogue', 'thief', 'thiefmaster'],
|
||||
assassin: ['rogue', 'assassin'],
|
||||
hermit: ['rogue', 'assassin', 'hermit'],
|
||||
warrior: ['warrior'],
|
||||
fighter: ['warrior', 'fighter'],
|
||||
crusader: ['warrior', 'fighter', 'crusader'],
|
||||
page: ['warrior', 'page'],
|
||||
knight: ['warrior', 'page', 'knight'],
|
||||
};
|
||||
|
||||
const pick = (rng, values) => values[Math.floor(rng() * values.length)];
|
||||
|
||||
export function campaignJobAtSection(branch, section) {
|
||||
if (section <= 1) return JOBS[branch].root || 'rogue';
|
||||
if (section === 2) return JOBS[branch].tier2;
|
||||
return JOBS[branch].tier3;
|
||||
}
|
||||
|
||||
export function playableClassesForJob(job) {
|
||||
return LINEAGES[job] || [job];
|
||||
}
|
||||
|
||||
export function scaleEnemy(enemy, section, rng = () => 0, scaleStep = null) {
|
||||
const multiplier = scaleStep == null
|
||||
? (DEFAULT_SECTION_MULTIPLIERS[section - 1] || DEFAULT_SECTION_MULTIPLIERS.at(-1))
|
||||
: 1 + (section - 1) * scaleStep;
|
||||
const offset = enemy.intents.length > 0 ? Math.floor(rng() * enemy.intents.length) : 0;
|
||||
const rotatedIntents = enemy.intents.map((_, index) => enemy.intents[(index + offset) % enemy.intents.length]);
|
||||
return {
|
||||
...enemy,
|
||||
maxHp: Math.floor(enemy.maxHp * multiplier),
|
||||
intents: rotatedIntents.map((intent) => ({
|
||||
...intent,
|
||||
value: intent.kind === 'Debuff' || intent.value == null
|
||||
? intent.value
|
||||
: Math.floor(intent.value * multiplier),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEncounter(kind, section, rng, scaleStep) {
|
||||
const ids = [];
|
||||
if (kind === 'normal') {
|
||||
const count = 1 + Math.floor(rng() * 3);
|
||||
for (let i = 0; i < count; i++) ids.push(pick(rng, COMBAT_POOL));
|
||||
} else if (kind === 'elite') {
|
||||
ids.push(pick(rng, ELITE_POOL));
|
||||
const extra = Math.floor(rng() * 3);
|
||||
for (let i = 0; i < extra; i++) ids.push(pick(rng, COMBAT_POOL));
|
||||
} else {
|
||||
ids.push(pick(rng, BOSS_POOL));
|
||||
}
|
||||
return ids.map((id) => scaleEnemy(enemiesData.enemies[id], section, rng, scaleStep));
|
||||
}
|
||||
|
||||
function baseCardValue(card) {
|
||||
const hits = card.hits || 1;
|
||||
const targets = card.aoe ? 1.7 : 1;
|
||||
let value = (card.damage || 0) * hits * targets;
|
||||
value += (card.block || 0) + (card.nextTurnBlock || 0) * 0.7;
|
||||
value += (card.poison || 0) * (card.poisonHits || 1) * (card.affectsAllEnemies ? 2 : 1) * 1.5;
|
||||
value += (card.draw || 0) * 4 + (card.gainEnergy || 0) * 5;
|
||||
value += (card.addShiv || 0) * 4;
|
||||
value += (card.strength || 0) * 6 + (card.dex || 0) * 5;
|
||||
value += (card.weak || 0) * 3 + (card.vuln || 0) * 4;
|
||||
value += (card.intangible || 0) * 12;
|
||||
value += (card.turnStartShiv || 0) * 8 + (card.shivDamageBonus || 0) * 4;
|
||||
value += (card.cardPlayedBlock || 0) * 8 + (card.attackPoison || 0) * 8;
|
||||
value += (card.powerEffect ? 7 : 0) + (card.retain ? 2 : 0) + (card.sly ? 3 : 0);
|
||||
value += (card.damagePerDiscardedThisTurn || 0) * 2;
|
||||
value += (card.damagePerAttackPlayedThisTurn || 0) * 2;
|
||||
value += (card.firstShivDamageBonus || 0) * 2;
|
||||
value -= (card.cost || 0) * 5;
|
||||
if (card.exhaust) value -= 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
function branchCardValue(card, branch, deck, id) {
|
||||
let value = baseCardValue(card);
|
||||
if (branch === 'thief') {
|
||||
value += (card.poison || 0) * 1.5 + (card.attackPoison || 0) * 8;
|
||||
value += card.sly ? 5 : 0;
|
||||
value += (card.discard || 0) * 2 + (card.drawPerDiscarded || 0) * 4;
|
||||
value += (card.poisonApplicationBurstDamage || 0) * 1.5;
|
||||
} else if (branch === 'assassin') {
|
||||
value += (card.addShiv || 0) * 3 + (card.turnStartShiv || 0) * 8;
|
||||
value += (card.shivDamageBonus || 0) * 6 + (card.firstShivDamageBonus || 0) * 3;
|
||||
value += card.shivAoe ? 12 : 0;
|
||||
value += card.shivRetain ? 5 : 0;
|
||||
} else if (branch === 'fighter') {
|
||||
value += (card.hits || 1) * 1.5;
|
||||
value += (card.comboGain || 0) * 5 + (card.comboOnAttack || 0) * 10;
|
||||
value += (card.damagePerCombo || 0) * 8 + (card.attackDamagePerCombo || 0) * 12;
|
||||
value += (card.attackPlayedDamage || 0) * 5;
|
||||
} else if (branch === 'page') {
|
||||
value += (card.block || 0) * 0.5 + (card.cardPlayedBlock || 0) * 7;
|
||||
value += (card.holyChargeOnHolyForce || 0) * 12 + (card.damagePerHolyCharge || 0) * 7;
|
||||
value += (card.blockPerHolyCharge || 0) * 6 + (card.healPerHolyCharge || 0) * 3;
|
||||
value += (card.damageTakenReduction || 0) * 40;
|
||||
value += (card.blockOnDamaged || 0) * 3 + (card.strengthOnDamagedOnce || 0) * 5;
|
||||
}
|
||||
const copies = deck.filter((cardId) => cardId === id).length;
|
||||
value -= copies * (card.kind === 'Power' ? 10 : 3);
|
||||
return value;
|
||||
}
|
||||
|
||||
function rewardPool(job) {
|
||||
const classes = new Set(playableClassesForJob(job));
|
||||
return Object.entries(cardsData.cards)
|
||||
.filter(([, card]) => classes.has(card.class) && card.token !== true && card.unplayable !== true);
|
||||
}
|
||||
|
||||
function offerReward(job, branch, deck, rng, minimumValue) {
|
||||
const pool = rewardPool(job);
|
||||
const choices = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const rarity = rarityForRoll(1 + Math.floor(rng() * 100));
|
||||
const bucket = pool.filter(([, card]) => card.rarity === rarity);
|
||||
choices.push(pick(rng, bucket.length > 0 ? bucket : pool));
|
||||
}
|
||||
choices.sort((a, b) => branchCardValue(b[1], branch, deck, b[0]) - branchCardValue(a[1], branch, deck, a[0]));
|
||||
const [id, card] = choices[0];
|
||||
if (branchCardValue(card, branch, deck, id) >= minimumValue) deck.push(id);
|
||||
}
|
||||
|
||||
function relicModifiers(state) {
|
||||
const result = {
|
||||
playerStartBlock: 0,
|
||||
playerStrength: 0,
|
||||
playerThorns: 0,
|
||||
energyBonus: 0,
|
||||
openingDrawBonus: 0,
|
||||
healOnAttack: 0,
|
||||
};
|
||||
for (const id of state.relics) {
|
||||
const relic = relicsData.relics[id];
|
||||
if (!relic) continue;
|
||||
if (relic.hook === 'combatStart' && relic.effect === 'block') result.playerStartBlock += relic.value;
|
||||
else if (relic.hook === 'combatStart' && relic.effect === 'strength') result.playerStrength += relic.value;
|
||||
else if (relic.hook === 'turnStart' && relic.effect === 'energy') result.energyBonus += relic.value;
|
||||
else if (relic.hook === 'combatStart' && relic.effect === 'draw') result.openingDrawBonus += relic.value;
|
||||
else if (relic.effect === 'thorns') result.playerThorns += relic.value;
|
||||
else if (relic.effect === 'healOnAttack') result.healOnAttack += relic.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function healFromRelics(state, hook) {
|
||||
for (const id of state.relics) {
|
||||
const relic = relicsData.relics[id];
|
||||
if (!relic || relic.hook !== hook) continue;
|
||||
if (relic.effect === 'heal') state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
else if (relic.effect === 'healOnWin') state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
else if (relic.effect === 'healIfLow' && state.hp <= state.maxHp * 0.5) state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
}
|
||||
}
|
||||
|
||||
function acquireRelic(state, rng) {
|
||||
const available = relicsData.relicPool.filter((id) => !state.relics.includes(id));
|
||||
if (available.length === 0) return;
|
||||
const id = pick(rng, available);
|
||||
state.relics.push(id);
|
||||
const relic = relicsData.relics[id];
|
||||
if (relic?.effect === 'maxHp') {
|
||||
state.maxHp += relic.value;
|
||||
state.hp += relic.value;
|
||||
}
|
||||
}
|
||||
|
||||
function fight(state, branch, kind, section, rng, options) {
|
||||
const monsters = buildEncounter(kind, section, rng, options.scaleStep);
|
||||
healFromRelics(state, 'combatStart');
|
||||
const result = simulateCombat({
|
||||
cards: cardsData.cards,
|
||||
starterDeck: state.deck,
|
||||
monsters,
|
||||
playerHp: state.hp,
|
||||
playerMaxHp: state.maxHp,
|
||||
smartPlayer: true,
|
||||
...relicModifiers(state),
|
||||
}, rng);
|
||||
state.hp = result.playerHpRemaining;
|
||||
state.turns += result.turns;
|
||||
if (!result.win) return false;
|
||||
healFromRelics(state, 'combatEnd');
|
||||
const rewardStrategy = state.job === 'warrior' || state.job === 'rogue' ? state.job : branch;
|
||||
if (kind !== 'boss') offerReward(state.job, rewardStrategy, state.deck, rng, options.minimumRewardValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function simulateCampaign(branch, rng, {
|
||||
restHeal = REST_HEAL,
|
||||
sectionHeal = 0,
|
||||
scaleStep = null,
|
||||
minimumRewardValue = 10,
|
||||
} = {}) {
|
||||
if (!JOBS[branch]) throw new Error(`지원하지 않는 도적 분기: ${branch}`);
|
||||
const root = JOBS[branch].root || 'rogue';
|
||||
const maxHp = PLAYER_MAX_HP[root];
|
||||
const state = {
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
deck: cardsData.starterDecks[root].slice(),
|
||||
job: root,
|
||||
turns: 0,
|
||||
sectionCleared: 0,
|
||||
diedAt: '',
|
||||
hpAfterSections: [],
|
||||
relics: [relicsData.startingRelic],
|
||||
};
|
||||
const options = { scaleStep, minimumRewardValue };
|
||||
|
||||
for (let section = 1; section <= SECTION_COUNT; section++) {
|
||||
state.job = campaignJobAtSection(branch, section);
|
||||
for (let fightIndex = 1; fightIndex <= NORMAL_FIGHTS; fightIndex++) {
|
||||
if (!fight(state, branch, 'normal', section, rng, options)) {
|
||||
state.diedAt = `${section}-normal`;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
state.hp = Math.min(state.maxHp, state.hp + restHeal);
|
||||
if (!fight(state, branch, 'elite', section, rng, options)) {
|
||||
state.diedAt = `${section}-elite`;
|
||||
return state;
|
||||
}
|
||||
acquireRelic(state, rng);
|
||||
if (!fight(state, branch, 'boss', section, rng, options)) {
|
||||
state.diedAt = `${section}-boss`;
|
||||
return state;
|
||||
}
|
||||
state.sectionCleared = section;
|
||||
state.hpAfterSections.push(state.hp);
|
||||
if (section === 1) state.deck.push(JOBS[branch].tier2Starter);
|
||||
if (section === 2) state.deck.push(JOBS[branch].tier3Starter);
|
||||
if (section >= 3) acquireRelic(state, rng);
|
||||
if (section < SECTION_COUNT) state.hp = Math.min(state.maxHp, state.hp + sectionHeal);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function runCampaignBatch(branch, runs = 1000, seed = 20260701, options = {}) {
|
||||
const sectionReached = Array(SECTION_COUNT).fill(0);
|
||||
const sectionClears = Array(SECTION_COUNT).fill(0);
|
||||
const deaths = {};
|
||||
let fullClears = 0;
|
||||
let totalDeckSize = 0;
|
||||
let totalFinalHp = 0;
|
||||
let totalTurns = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
||||
const result = simulateCampaign(branch, rng, options);
|
||||
for (let section = 0; section < SECTION_COUNT; section++) {
|
||||
if (result.sectionCleared >= section) sectionReached[section]++;
|
||||
if (result.sectionCleared >= section + 1) sectionClears[section]++;
|
||||
}
|
||||
if (result.sectionCleared === SECTION_COUNT) {
|
||||
fullClears++;
|
||||
totalFinalHp += result.hp;
|
||||
}
|
||||
if (result.diedAt) deaths[result.diedAt] = (deaths[result.diedAt] || 0) + 1;
|
||||
totalDeckSize += result.deck.length;
|
||||
totalTurns += result.turns;
|
||||
}
|
||||
return {
|
||||
branch,
|
||||
runs,
|
||||
fullClearRate: fullClears / runs,
|
||||
avgFinalHp: fullClears > 0 ? totalFinalHp / fullClears : 0,
|
||||
avgDeckSize: totalDeckSize / runs,
|
||||
avgTurns: totalTurns / runs,
|
||||
sectionConditionalClearRates: sectionClears.map((clears, index) => sectionReached[index] > 0 ? clears / sectionReached[index] : 0),
|
||||
sectionReachRates: sectionReached.map((reached) => reached / runs),
|
||||
deaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCampaignReport(result) {
|
||||
const lines = [];
|
||||
lines.push(`${result.branch} 캠페인 ${result.runs}회`);
|
||||
lines.push(` 전체 클리어 ${(result.fullClearRate * 100).toFixed(1)}%, 클리어 HP ${result.avgFinalHp.toFixed(1)}, 평균 덱 ${result.avgDeckSize.toFixed(1)}장`);
|
||||
result.sectionConditionalClearRates.forEach((rate, index) => {
|
||||
lines.push(` 섹션 ${index + 1}: 도달 ${(result.sectionReachRates[index] * 100).toFixed(1)}%, 도달자 클리어 ${(rate * 100).toFixed(1)}%`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let runs = 1000;
|
||||
let seed = 20260701;
|
||||
let restHeal = REST_HEAL;
|
||||
let sectionHeal = 0;
|
||||
let scaleStep = null;
|
||||
let minimumRewardValue = 10;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--rest-heal') restHeal = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--section-heal') sectionHeal = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--scale-step') scaleStep = Number.parseFloat(args[++i]);
|
||||
else if (args[i] === '--reward-min') minimumRewardValue = Number.parseFloat(args[++i]);
|
||||
}
|
||||
for (const branch of ['thief', 'assassin', 'fighter', 'page']) {
|
||||
console.log(formatCampaignReport(runCampaignBatch(branch, runs, seed, { restHeal, sectionHeal, scaleStep, minimumRewardValue })));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('rogue-campaign.mjs')) main();
|
||||
38
tools/balance/rogue-campaign.test.mjs
Normal file
38
tools/balance/rogue-campaign.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
campaignJobAtSection,
|
||||
playableClassesForJob,
|
||||
scaleEnemy,
|
||||
} from './rogue-campaign.mjs';
|
||||
|
||||
test('도적 전직 시점: 1섹션 Rogue, 2섹션 2차, 3섹션부터 3차', () => {
|
||||
assert.equal(campaignJobAtSection('thief', 1), 'rogue');
|
||||
assert.equal(campaignJobAtSection('thief', 2), 'thief');
|
||||
assert.equal(campaignJobAtSection('thief', 3), 'thiefmaster');
|
||||
assert.equal(campaignJobAtSection('assassin', 2), 'assassin');
|
||||
assert.equal(campaignJobAtSection('assassin', 5), 'hermit');
|
||||
});
|
||||
|
||||
test('3차 직업은 자기 계보 카드만 사용', () => {
|
||||
assert.deepEqual(playableClassesForJob('thiefmaster'), ['rogue', 'thief', 'thiefmaster']);
|
||||
assert.deepEqual(playableClassesForJob('hermit'), ['rogue', 'assassin', 'hermit']);
|
||||
assert.deepEqual(playableClassesForJob('crusader'), ['warrior', 'fighter', 'crusader']);
|
||||
assert.deepEqual(playableClassesForJob('knight'), ['warrior', 'page', 'knight']);
|
||||
});
|
||||
|
||||
test('전사 전직 시점: 1섹션 Warrior, 2섹션 2차, 3섹션부터 3차', () => {
|
||||
assert.equal(campaignJobAtSection('fighter', 1), 'warrior');
|
||||
assert.equal(campaignJobAtSection('fighter', 2), 'fighter');
|
||||
assert.equal(campaignJobAtSection('fighter', 3), 'crusader');
|
||||
assert.equal(campaignJobAtSection('page', 2), 'page');
|
||||
assert.equal(campaignJobAtSection('page', 5), 'knight');
|
||||
});
|
||||
|
||||
test('섹션 난이도는 3차 이후 더 빠르게 증가', () => {
|
||||
const enemy = { maxHp: 100, intents: [{ kind: 'Attack', value: 10 }, { kind: 'Debuff', value: 2 }] };
|
||||
const scaled = scaleEnemy(enemy, 3, () => 0);
|
||||
assert.equal(scaled.maxHp, 114);
|
||||
assert.equal(scaled.intents[0].value, 11);
|
||||
assert.equal(scaled.intents[1].value, 2);
|
||||
});
|
||||
@@ -99,7 +99,7 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const card = cards[x.id];
|
||||
if (!card || card.unplayable || !canPlayCardNow(card, ctx)) return false;
|
||||
let effectiveCost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) effectiveCost = 0;
|
||||
if (ctx.handCostZeroThisTurn === true || ctx.zeroCostCardIdsThisTurn?.has(x.id) === true) effectiveCost = 0;
|
||||
else if (card.useAllEnergy === true) effectiveCost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) effectiveCost = 0;
|
||||
@@ -116,7 +116,7 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const effectiveCost = (x) => {
|
||||
const card = cards[x.id];
|
||||
let cost = card.cost || 0;
|
||||
if (ctx.handCostZeroThisTurn === true) cost = 0;
|
||||
if (ctx.handCostZeroThisTurn === true || ctx.zeroCostCardIdsThisTurn?.has(x.id) === true) cost = 0;
|
||||
else if (card.useAllEnergy === true) cost = 1;
|
||||
else if (card.kind === 'Skill') {
|
||||
if (ctx.nextSkillCostZero === true) cost = 0;
|
||||
@@ -127,12 +127,70 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||
const countOtherHandSkills = (currentId) => {
|
||||
let n = 0;
|
||||
let skippedSelf = false;
|
||||
for (const id of hand) {
|
||||
if (!skippedSelf && id === currentId) {
|
||||
skippedSelf = true;
|
||||
continue;
|
||||
}
|
||||
if (cards[id]?.kind === 'Skill') n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const countOwnedNameMatches = (match) => {
|
||||
if (!match) return 0;
|
||||
let n = 0;
|
||||
for (const id of hand) {
|
||||
const name = cards[id]?.name || '';
|
||||
if (name.includes(match)) n++;
|
||||
}
|
||||
for (const pile of [ctx.drawPileCards || [], ctx.discardCards || [], ctx.exhaustCards || []]) {
|
||||
for (const id of pile) {
|
||||
const name = cards[id]?.name || '';
|
||||
if (name.includes(match)) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const attackBaseEstimate = (x) => {
|
||||
const card = cards[x.id];
|
||||
let base = card.damage || 0;
|
||||
base += (ctx.currentBlock || 0) * (card.damageFromCurrentBlock || 0);
|
||||
if (card.damageNameMatch && card.damagePerOwnedNameMatch) {
|
||||
base += countOwnedNameMatches(card.damageNameMatch) * card.damagePerOwnedNameMatch;
|
||||
}
|
||||
base += Math.max(0, hand.length - 1) * (card.damagePerOtherHandCard || 0);
|
||||
base += (ctx.turnAttackCardsPlayed || 0) * (card.damagePerAttackPlayedThisTurn || 0);
|
||||
base += countOtherHandSkills(x.id) * (card.damagePerSkillInHand || 0);
|
||||
base += (ctx.cardsDrawnThisCombat || 0) * (card.damagePerCardDrawnThisCombat || 0);
|
||||
if (base < 0) base = 0;
|
||||
return base;
|
||||
};
|
||||
const dmgEff = (x) => attackBaseEstimate(x) / Math.max(effectiveCost(x), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(x), 1);
|
||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||
if ((ctx.incomingDamage || 0) > (ctx.currentBlock || 0)) {
|
||||
const defensive = entries.filter((x) => {
|
||||
const card = cards[x.id];
|
||||
return (card.block || 0) > 0 || (card.intangible || 0) > 0 || (card.enemyStrengthLossThisTurn || 0) > 0;
|
||||
});
|
||||
if (defensive.length) {
|
||||
return bestBy(defensive, (x) => {
|
||||
const card = cards[x.id];
|
||||
const protection = (card.block || 0) + (card.intangible || 0) * 15 + (card.enemyStrengthLossThisTurn || 0) * 2;
|
||||
return protection / Math.max(effectiveCost(x), 1);
|
||||
}).i;
|
||||
}
|
||||
}
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
if (attacks.length) {
|
||||
const bestAttack = bestBy(attacks, dmgEff);
|
||||
if (bestAttack && dmgEff(bestAttack) > 0) return bestAttack.i;
|
||||
}
|
||||
if (skills.length) return bestBy(skills, blkEff).i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -154,13 +212,16 @@ function bump(s, cost, dmg, blk) {
|
||||
// 반환: { win, turns, playerHpRemaining, draw? }
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
let playerMaxHp = data.playerMaxHp || PLAYER_HP;
|
||||
const startingPlayerHp = Math.min(data.playerHp ?? playerMaxHp, playerMaxHp);
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: startingPlayerHp };
|
||||
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
|
||||
let discard = [];
|
||||
const exhaust = [];
|
||||
let hand = [];
|
||||
let pHp = PLAYER_HP, pBlock = 0;
|
||||
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||
const zeroCostCardIdsThisTurn = new Set();
|
||||
let pHp = startingPlayerHp, pBlock = data.playerStartBlock || 0;
|
||||
let pStr = data.playerStrength || 0, pDex = 0, pThorns = data.playerThorns || 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||
let blockGainMultiplier = 1;
|
||||
let handCostZeroThisTurn = false;
|
||||
let drawDisabledThisTurn = false;
|
||||
@@ -185,6 +246,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
let cardsDrawnThisCombat = 0;
|
||||
let bonusRewardScreens = 0;
|
||||
let activeKillReward = 0;
|
||||
let comboCount = 0;
|
||||
let holyChargeCount = 0;
|
||||
let damagePowerStrengthUsed = false;
|
||||
let energy = 0;
|
||||
const powers = [];
|
||||
const mob = monsters.map((m) => ({
|
||||
@@ -200,6 +264,24 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (!alive.length) return null;
|
||||
return alive[Math.floor(rng() * alive.length)];
|
||||
};
|
||||
const randomCardPool = (sourceCard) => Object.entries(cards)
|
||||
.filter(([, rc]) => {
|
||||
if (!rc || rc.token === true || rc.curse === true || rc.unplayable === true) return false;
|
||||
if (sourceCard.addRandomCardKind && rc.kind !== sourceCard.addRandomCardKind) return false;
|
||||
if (sourceCard.addRandomCardSameClass === true && rc.class !== sourceCard.class) return false;
|
||||
return true;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
const expectedIncomingDamage = () => mob.filter((m) => m.alive).reduce((total, m) => {
|
||||
if (!m.intents || m.intents.length === 0) return total;
|
||||
const expected = m.intents.reduce((sum, intent) => {
|
||||
if (intent.kind !== 'Attack') return sum;
|
||||
let amount = calcEnemyAttack(intent.value, m.str, m.weak, pVuln, enemyStrengthLossThisTurn);
|
||||
if (pIntangible > 0 && amount > 1) amount = 1;
|
||||
return sum + amount;
|
||||
}, 0) / m.intents.length;
|
||||
return total + expected;
|
||||
}, 0);
|
||||
const removeEnemyBlock = (target) => {
|
||||
if (target) target.block = 0;
|
||||
};
|
||||
@@ -290,10 +372,26 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (hand.length >= 10) {
|
||||
discard.push(card);
|
||||
triggerSly(card);
|
||||
} else hand.push(card);
|
||||
} else {
|
||||
hand.push(card);
|
||||
triggerDrawNameMatchAutoPlay(card);
|
||||
}
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
function triggerDrawNameMatchAutoPlay(drawnId) {
|
||||
const drawnCard = cards[drawnId];
|
||||
const drawnName = drawnCard?.name || '';
|
||||
if (!drawnName) return;
|
||||
for (const pid of powers) {
|
||||
const pc = cards[pid];
|
||||
if (!pc?.drawNameMatchAutoPlay || !drawnName.includes(pc.drawNameMatchAutoPlay)) continue;
|
||||
const idx = hand.indexOf(drawnId);
|
||||
if (idx < 0) continue;
|
||||
hand.splice(idx, 1);
|
||||
autoPlayCardFromEffect(drawnId, 0);
|
||||
}
|
||||
}
|
||||
function addCardsToHand(id, n) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (hand.length >= 10) discard.push(id);
|
||||
@@ -308,10 +406,30 @@ export function simulateCombat(data, rng, stats) {
|
||||
pBlock += amount;
|
||||
return amount;
|
||||
}
|
||||
function smartDiscardIndex() {
|
||||
if (hand.length === 0) return -1;
|
||||
if (data.smartPlayer !== true) return hand.length - 1;
|
||||
const ranked = hand.map((id, index) => {
|
||||
const card = cards[id] || {};
|
||||
const isSly = card.sly === true || skillSlyOnPlayCards.has(id) || turnSkillSlyCards.has(id);
|
||||
const utility = (card.damage || 0) * (card.hits || 1)
|
||||
+ (card.block || 0)
|
||||
+ (card.draw || 0) * 4
|
||||
+ (card.addShiv || 0) * 4
|
||||
+ (card.poison || 0) * 2;
|
||||
return { index, isSly, unplayable: card.unplayable === true, tooExpensive: (card.cost || 0) > energy, utility };
|
||||
});
|
||||
ranked.sort((a, b) => Number(b.isSly) - Number(a.isSly)
|
||||
|| Number(b.unplayable) - Number(a.unplayable)
|
||||
|| Number(b.tooExpensive) - Number(a.tooExpensive)
|
||||
|| a.utility - b.utility
|
||||
|| a.index - b.index);
|
||||
return ranked[0].index;
|
||||
}
|
||||
function discardForTurnStart(n) {
|
||||
const cnt = Math.min(n, hand.length);
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const idx = hand
|
||||
const idx = data.smartPlayer === true ? smartDiscardIndex() : hand
|
||||
.map((id, k) => ({ id, k, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ac = a.card?.cost || 0;
|
||||
@@ -335,8 +453,26 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function countOwnedNameMatches(match) {
|
||||
if (!match) return 0;
|
||||
let n = 0;
|
||||
for (const pile of [hand, drawPile, discard, exhaust]) {
|
||||
for (const id of pile) {
|
||||
const name = cards[id]?.name || '';
|
||||
if (name.includes(match)) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
function attackBaseForCard(id, c) {
|
||||
let base = c.damage || 0;
|
||||
if (c.damageNameMatch && c.damagePerOwnedNameMatch) {
|
||||
base += countOwnedNameMatches(c.damageNameMatch) * c.damagePerOwnedNameMatch;
|
||||
}
|
||||
if (c.damageFromCurrentBlock) base += pBlock * c.damageFromCurrentBlock;
|
||||
const comboScale = (c.damagePerCombo || 0) + powerFieldTotal('attackDamagePerCombo');
|
||||
if (comboScale) base += comboCount * comboScale;
|
||||
if (c.damagePerHolyCharge) base += holyChargeCount * c.damagePerHolyCharge;
|
||||
const otherHand = Math.max(0, hand.length - 1);
|
||||
if (c.damagePerOtherHandCard) base += otherHand * c.damagePerOtherHandCard;
|
||||
if (c.damagePerAttackPlayedThisTurn) base += turnAttackCardsPlayed * c.damagePerAttackPlayedThisTurn;
|
||||
@@ -388,6 +524,69 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function powerFieldMax(field) {
|
||||
let best = 0;
|
||||
for (const pid of powers) best = Math.max(best, cards[pid]?.[field] || 0);
|
||||
return best;
|
||||
}
|
||||
function comboMax() {
|
||||
return Math.max(5, powerFieldMax('comboMax'));
|
||||
}
|
||||
function gainCombo(amount) {
|
||||
if (amount > 0) comboCount = Math.min(comboMax(), comboCount + amount);
|
||||
}
|
||||
function holyChargeMax() {
|
||||
return Math.max(3, powerFieldMax('holyChargeMax'));
|
||||
}
|
||||
function gainHolyCharge(amount) {
|
||||
if (amount > 0) holyChargeCount = Math.min(holyChargeMax(), holyChargeCount + amount);
|
||||
}
|
||||
function triggerExhaust(count = 1) {
|
||||
const drawOnExhaust = powerFieldTotal('drawOnExhaust');
|
||||
if (drawOnExhaust > 0 && count > 0) draw(drawOnExhaust * count);
|
||||
}
|
||||
function addRandomCardsFromEffect(sourceCard, count) {
|
||||
if (!count || count <= 0) return [];
|
||||
const pool = randomCardPool(sourceCard);
|
||||
if (!pool.length) return [];
|
||||
const added = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = pool[Math.floor(rng() * pool.length)];
|
||||
if (!id) continue;
|
||||
addCardsToHand(id, 1);
|
||||
if (sourceCard.addedCardsCostZeroThisTurn === true) zeroCostCardIdsThisTurn.add(id);
|
||||
added.push(id);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
function exhaustHandNonAttackEffects(c) {
|
||||
if (c.exhaustHandNonAttack !== true || hand.length === 0) return 0;
|
||||
let exhaustedCount = 0;
|
||||
for (let i = hand.length - 1; i >= 0; i--) {
|
||||
const id = hand[i];
|
||||
const hc = cards[id];
|
||||
if (hc?.kind === 'Attack') continue;
|
||||
hand.splice(i, 1);
|
||||
exhaust.push(id);
|
||||
exhaustedCount++;
|
||||
}
|
||||
if (exhaustedCount > 0) {
|
||||
if (c.blockPerExhaustedCard) addBlock(exhaustedCount * c.blockPerExhaustedCard);
|
||||
triggerExhaust(exhaustedCount);
|
||||
}
|
||||
return exhaustedCount;
|
||||
}
|
||||
function exhaustHandAllEffects(c) {
|
||||
if (c.exhaustHandAll !== true || hand.length === 0) return 0;
|
||||
let exhaustedCount = 0;
|
||||
while (hand.length > 0) {
|
||||
const id = hand.pop();
|
||||
exhaust.push(id);
|
||||
exhaustedCount++;
|
||||
}
|
||||
if (exhaustedCount > 0) triggerExhaust(exhaustedCount);
|
||||
return exhaustedCount;
|
||||
}
|
||||
function resolveCardEffects(id, c, costSpent, recordStats = true) {
|
||||
const alive = aliveList();
|
||||
let dmg = 0;
|
||||
@@ -416,7 +615,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
const xEnergy = costSpent || 0;
|
||||
if (c.kind === 'Attack') {
|
||||
if (alive.length && (c.damage || c.xDamagePerEnergy)) {
|
||||
if (alive.length && (c.damage != null || c.xDamagePerEnergy != null || c.damageFromCurrentBlock != null)) {
|
||||
const baseDamage = c.xDamagePerEnergy ? xEnergy * c.xDamagePerEnergy : attackBaseForCard(id, c);
|
||||
const bonusHits = (c.otherHandAtLeast && c.bonusHitsWhenOtherHandAtLeast && Math.max(0, hand.length - 1) >= c.otherHandAtLeast)
|
||||
? c.bonusHitsWhenOtherHandAtLeast : 0;
|
||||
@@ -431,8 +630,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (!target || !target.alive) return { killed: false, dealt: 0 };
|
||||
let dealt = amount;
|
||||
if (target.vuln > 0) dealt = Math.floor(dealt * 1.5);
|
||||
if (target.weak > 0 && c.attackDamageVsWeakMultiplier && c.attackDamageVsWeakMultiplier > 1) {
|
||||
dealt = Math.floor(dealt * c.attackDamageVsWeakMultiplier);
|
||||
const weakMultiplier = Math.max(c.attackDamageVsWeakMultiplier || 1, powerFieldMax('attackDamageVsWeakMultiplier'));
|
||||
if (target.weak > 0 && weakMultiplier > 1) {
|
||||
dealt = Math.floor(dealt * weakMultiplier);
|
||||
}
|
||||
if (c.pierce === true) {
|
||||
target.hp -= dealt;
|
||||
@@ -448,6 +648,10 @@ export function simulateCombat(data, rng, stats) {
|
||||
target.alive = false;
|
||||
killed = true;
|
||||
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
|
||||
if (c.maxHpOnKill) {
|
||||
playerMaxHp += c.maxHpOnKill;
|
||||
pHp += c.maxHpOnKill;
|
||||
}
|
||||
}
|
||||
return { killed, dealt };
|
||||
};
|
||||
@@ -489,11 +693,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
roundKilled = resolveAttackRound();
|
||||
} while (c.repeatOnKill === true && roundKilled === true && countAliveMonsters() > 0);
|
||||
}
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
if (c.block) blockGained = addBlock(c.block + holyChargeCount * (c.blockPerHolyCharge || 0));
|
||||
} else if (c.kind === 'Power') {
|
||||
if (recordStats) powers.push(id);
|
||||
powers.push(id);
|
||||
} else {
|
||||
if (c.block) blockGained = addBlock(c.block);
|
||||
if (c.block) blockGained = addBlock(c.block + holyChargeCount * (c.blockPerHolyCharge || 0));
|
||||
const weakAmount = (c.weak || 0) + (c.xWeakPerEnergy || 0) * xEnergy;
|
||||
const vulnAmount = c.vuln || 0;
|
||||
if ((weakAmount || vulnAmount || c.poison || c.removeEnemyBlock || c.removeEnemyArtifact || c.enemyStrengthLossThisTurn) && alive.length) {
|
||||
@@ -525,8 +729,10 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.dex) pDex += c.dex;
|
||||
if (c.thorns) pThorns += c.thorns;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal + holyChargeCount * (c.healPerHolyCharge || 0), playerMaxHp);
|
||||
if (c.gainEnergy) energy += c.gainEnergy;
|
||||
if (c.kind !== 'Attack' && c.comboGain) gainCombo(c.comboGain);
|
||||
if (c.removePlayerDebuffs === true) { pWeak = 0; pVuln = 0; }
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
if (c.intangible) pIntangible += c.intangible;
|
||||
queueNextTurnEffects(c);
|
||||
@@ -543,6 +749,28 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
}
|
||||
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
||||
const exhaustedCount = exhaustHandNonAttackEffects(c);
|
||||
const exhaustedAllCount = exhaustHandAllEffects(c);
|
||||
const totalExhausted = exhaustedCount + exhaustedAllCount;
|
||||
if (exhaustedCount > 0 && c.blockPerExhaustedCard) {
|
||||
blockGained += exhaustedCount * c.blockPerExhaustedCard;
|
||||
}
|
||||
if (c.drawPerExhausted && totalExhausted > 0) {
|
||||
draw(totalExhausted * c.drawPerExhausted);
|
||||
}
|
||||
if (c.addRandomCardCount) addRandomCardsFromEffect(c, c.addRandomCardCount);
|
||||
if (c.addRandomCardPerExhausted) {
|
||||
if (totalExhausted > 0) addRandomCardsFromEffect(c, totalExhausted * c.addRandomCardPerExhausted);
|
||||
}
|
||||
const topPlayCount = (c.playTopDrawPileCount || 0) + ((c.playTopDrawPileCountPerEnergy || 0) * xEnergy);
|
||||
if (topPlayCount > 0) {
|
||||
for (let i = 0; i < topPlayCount; i++) {
|
||||
if (drawPile.length <= 0) break;
|
||||
const topId = drawPile.pop();
|
||||
if (!topId) break;
|
||||
autoPlayCardFromEffect(topId, 0);
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedDamage && alive.length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
@@ -564,6 +792,28 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (c.kind === 'Attack') {
|
||||
gainCombo((c.comboGain || 0) + powerFieldTotal('comboOnAttack'));
|
||||
const extraDamage = powerFieldTotal('attackPlayedDamage');
|
||||
if (extraDamage > 0) {
|
||||
const target = chooseTarget(aliveList(), extraDamage);
|
||||
if (target) {
|
||||
const r = applyDamage(target.hp, target.block, extraDamage);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
damageDealtThisTurn += extraDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
const attackWeak = powerFieldTotal('attackWeak');
|
||||
if (attackWeak > 0) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target) applyMonsterWeak(target, attackWeak);
|
||||
}
|
||||
}
|
||||
let holyGain = c.holyChargeGain || 0;
|
||||
if (c.holyForce === true) holyGain += powerFieldTotal('holyChargeOnHolyForce');
|
||||
gainHolyCharge(holyGain);
|
||||
if (c.holyChargeSpendAll === true) holyChargeCount = 0;
|
||||
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
|
||||
blockGained += addBlock(Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn));
|
||||
}
|
||||
@@ -588,13 +838,54 @@ export function simulateCombat(data, rng, stats) {
|
||||
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
} else if (c.discard) {
|
||||
const n = Math.min(c.discard, hand.length);
|
||||
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
for (let i = 0; i < n; i++) { discardHandCard(smartDiscardIndex(), true); discarded++; }
|
||||
}
|
||||
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
if (c.drawPerDiscarded) draw(discarded * c.drawPerDiscarded);
|
||||
}
|
||||
|
||||
function autoPlayCardFromEffect(id, energySpent = 0) {
|
||||
const c = cards[id];
|
||||
if (!c) return false;
|
||||
const skillFree = c.kind === 'Skill' && c.useAllEnergy !== true && nextSkillCostZero === true;
|
||||
const skillRepeat = c.kind === 'Skill' && (nextSkillRepeatCount || 0) > 0 ? nextSkillRepeatCount : 0;
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
resolveCardEffects(id, c, energySpent, false);
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (c.cardPlayedDamage && aliveList().length) {
|
||||
const target = chooseTarget(aliveList(), 0);
|
||||
if (target && target.alive) {
|
||||
target.hp -= c.cardPlayedDamage;
|
||||
damageDealtThisTurn += c.cardPlayedDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (c.cardPlayedRandomDamage && aliveList().length) {
|
||||
const target = randomAliveMonster();
|
||||
if (target) {
|
||||
target.hp -= c.cardPlayedRandomDamage;
|
||||
damageDealtThisTurn += c.cardPlayedRandomDamage;
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
}
|
||||
if (skillRepeat > 0) {
|
||||
nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat);
|
||||
for (let r = 0; r < skillRepeat; r++) {
|
||||
resolveCardEffects(id, c, energySpent, false);
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
}
|
||||
}
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) {
|
||||
exhaust.push(id);
|
||||
triggerExhaust(1);
|
||||
} else if (c.kind !== 'Power') {
|
||||
discard.push(id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
turnAttackCardsPlayed = 0;
|
||||
@@ -610,7 +901,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
drawDisabledThisTurn = false;
|
||||
skillCostReductionThisTurn = 0;
|
||||
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
||||
zeroCostCardIdsThisTurn.clear();
|
||||
if (nextTurnKeepBlock === true) nextTurnKeepBlock = false;
|
||||
else if (powers.some((pid) => cards[pid]?.powerEffect === 'keepBlock')) {}
|
||||
else pBlock = 0;
|
||||
turnAttackMultiplier = nextTurnAttackMultiplier;
|
||||
nextTurnAttackMultiplier = 1;
|
||||
@@ -623,6 +916,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
|
||||
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
||||
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
||||
else if (pc.powerEffect === 'keepBlock') {}
|
||||
else if (pc.powerEffect === 'poisonPerTurn') {
|
||||
for (const m of mob) if (m.alive) applyPoisonToMonster(m, pc.value);
|
||||
} else if (pc.powerEffect === 'damagePerTurn') {
|
||||
@@ -632,6 +926,8 @@ export function simulateCombat(data, rng, stats) {
|
||||
m.hp = r.hp; m.block = r.block;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
}
|
||||
} else if (pc.powerEffect === 'healPerTurn') {
|
||||
pHp = Math.min(playerMaxHp, pHp + (pc.value || 0));
|
||||
}
|
||||
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
|
||||
if (pc.turnStartDraw) powerTurnDraw += pc.turnStartDraw;
|
||||
@@ -642,15 +938,29 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount);
|
||||
nextTurnAddCards = [];
|
||||
}
|
||||
energy = ENERGY + energyBonus;
|
||||
energy = ENERGY + (data.energyBonus || 0) + energyBonus;
|
||||
const drawBonus = nextTurnDraw + powerTurnDraw;
|
||||
nextTurnDraw = 0;
|
||||
draw(HAND_SIZE + drawBonus);
|
||||
draw(HAND_SIZE + drawBonus + (turns === 1 ? (data.openingDrawBonus || 0) : 0));
|
||||
if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
|
||||
const idx = chooseAction(hand, cards, energy, {
|
||||
drawPileCount: drawPile.length,
|
||||
nextSkillCostZero,
|
||||
skillCostReductionThisTurn,
|
||||
handCostZeroThisTurn,
|
||||
combatCardCostReduction,
|
||||
zeroCostCardIdsThisTurn,
|
||||
incomingDamage: data.smartPlayer === true ? expectedIncomingDamage() : 0,
|
||||
currentBlock: pBlock,
|
||||
turnAttackCardsPlayed,
|
||||
cardsDrawnThisCombat,
|
||||
drawPileCards: drawPile,
|
||||
discardCards: discard,
|
||||
exhaustCards: exhaust,
|
||||
});
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
let dmg = 0;
|
||||
@@ -661,7 +971,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
|
||||
const finalCost = c.useAllEnergy === true ? cost : Math.max(0, cost - combatReduction);
|
||||
energy -= finalCost;
|
||||
hand.splice(idx, 1);
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
if (c.kind === 'Attack' && (data.healOnAttack || 0) > 0) {
|
||||
pHp = Math.min(playerMaxHp, pHp + data.healOnAttack);
|
||||
}
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (skillRepeat > 0) {
|
||||
@@ -673,9 +987,11 @@ export function simulateCombat(data, rng, stats) {
|
||||
}
|
||||
if (c.kind === 'Attack') turnAttackCardsPlayed++;
|
||||
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
|
||||
hand.splice(idx, 1);
|
||||
queueSelectedReserve(c);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
||||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) {
|
||||
exhaust.push(id);
|
||||
triggerExhaust(1);
|
||||
}
|
||||
else if (c.kind !== 'Power') discard.push(id);
|
||||
if (c.combatCostReductionOnPlay && c.combatCostReductionOnPlay > 0) {
|
||||
combatCardCostReduction[id] = (combatCardCostReduction[id] || 0) + c.combatCostReductionOnPlay;
|
||||
@@ -725,8 +1041,18 @@ export function simulateCombat(data, rng, stats) {
|
||||
const atk = calcEnemyAttack(it.value, m.str, m.weak, pVuln, enemyStrengthLossThisTurn);
|
||||
const beforeHp = pHp;
|
||||
let incoming = atk;
|
||||
const reduction = Math.min(0.75, powerFieldTotal('damageTakenReduction'));
|
||||
if (reduction > 0) incoming = Math.floor(incoming * (1 - reduction));
|
||||
if (pIntangible > 0 && incoming > 1) incoming = 1;
|
||||
const r = applyDamage(pHp, pBlock, incoming); pHp = r.hp; pBlock = r.block;
|
||||
if (beforeHp > pHp) {
|
||||
const reactiveBlock = powerFieldTotal('blockOnDamaged');
|
||||
if (reactiveBlock > 0) addBlock(reactiveBlock);
|
||||
if (!damagePowerStrengthUsed) {
|
||||
const reactiveStrength = powerFieldTotal('strengthOnDamagedOnce');
|
||||
if (reactiveStrength > 0) { pStr += reactiveStrength; damagePowerStrengthUsed = true; }
|
||||
}
|
||||
}
|
||||
if (beforeHp > pHp && pThorns > 0) {
|
||||
m.hp -= pThorns;
|
||||
if (m.hp <= 0) m.alive = false;
|
||||
|
||||
@@ -121,6 +121,14 @@ test('chooseAction: 공격 없으면 스킬 선택', () => {
|
||||
assert.equal(idx, 0);
|
||||
});
|
||||
|
||||
test('chooseAction: 예상 피해가 남으면 방어 카드를 우선 선택', () => {
|
||||
const cards = {
|
||||
Hit: { kind: 'Attack', cost: 1, damage: 12 },
|
||||
Guard: { kind: 'Skill', cost: 1, block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(['Hit', 'Guard'], cards, 1, { incomingDamage: 8, currentBlock: 0 }), 1);
|
||||
});
|
||||
|
||||
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
|
||||
const idx = chooseAction(['Bash'], CARDS, 1);
|
||||
assert.equal(idx, -1);
|
||||
@@ -220,6 +228,21 @@ test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test('simulateCombat: 캠페인 시작 체력과 유물 전투 보너스를 반영', () => {
|
||||
const data = {
|
||||
cards: { Guard: { name: 'Guard', cost: 1, kind: 'Skill', block: 1 } },
|
||||
starterDeck: ['Guard'],
|
||||
monsters: [{ name: 'Dummy', maxHp: 1, intents: [{ kind: 'Attack', value: 1 }] }],
|
||||
playerHp: 37,
|
||||
playerMaxHp: 70,
|
||||
playerStartBlock: 6,
|
||||
energyBonus: 1,
|
||||
openingDrawBonus: 2,
|
||||
};
|
||||
const result = simulateCombat(data, mulberry32(3));
|
||||
assert.ok(result.playerHpRemaining <= 37);
|
||||
});
|
||||
|
||||
test('calcAttack: 힘·약화·취약 공식 (Lua CalcPlayerAttack·DealDamageToTarget 동기화)', () => {
|
||||
assert.equal(calcAttack(6, 0, 0, 0), 6); // 기본
|
||||
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
|
||||
@@ -626,9 +649,10 @@ test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => {
|
||||
starterDeck: ["Hit", "Finisher"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 2);
|
||||
assert.ok((stats.Finisher?.damage || 0) >= 6);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => {
|
||||
@@ -643,9 +667,11 @@ test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applie
|
||||
starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"],
|
||||
monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0);
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 5);
|
||||
assert.ok((stats.Precise?.damage || 0) >= 5);
|
||||
assert.ok((stats.Flechettes?.damage || 0) >= 10);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => {
|
||||
@@ -1076,6 +1102,90 @@ test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: damageFromCurrentBlock uses current block as attack damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Guard: { name: "Guard", cost: 1, kind: "Skill", block: 5 },
|
||||
BodySlam: { name: "BodySlam", cost: 1, kind: "Attack", damageFromCurrentBlock: 1 },
|
||||
},
|
||||
starterDeck: ["Guard", "BodySlam"],
|
||||
monsters: [{ name: "Dummy", maxHp: 5, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: damagePerOwnedNameMatch counts matching owned cards across combat piles", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Strike1: { name: "타격", cost: 99, kind: "Attack", damage: 0 },
|
||||
Strike2: { name: "타격", cost: 99, kind: "Attack", damage: 0 },
|
||||
Perfected: { name: "완벽한 타격", cost: 0, kind: "Attack", damage: 6, damageNameMatch: "타격", damagePerOwnedNameMatch: 2 },
|
||||
},
|
||||
starterDeck: ["Strike1", "Strike2", "Perfected"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok((stats.Perfected?.damage || 0) >= 12);
|
||||
});
|
||||
|
||||
test("simulateCombat: exhaustHandNonAttack exhausts only non-attacks and grants block per exhausted card", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
SecondWind: { name: "기사회생", cost: 0, kind: "Skill", exhaustHandNonAttack: true, blockPerExhaustedCard: 5 },
|
||||
Guard1: { name: "수비1", cost: 99, kind: "Skill", block: 0 },
|
||||
Guard2: { name: "수비2", cost: 99, kind: "Skill", block: 0 },
|
||||
Hit: { name: "타격", cost: 99, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Guard1", "Guard2", "Hit", "SecondWind"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
simulateCombat(data, () => 0.999999, stats);
|
||||
assert.ok((stats.SecondWind?.block || 0) >= 10);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawOnExhaust draws when cards are exhausted", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Embrace: { name: "어둠의 포옹", cost: 0, kind: "Power", drawOnExhaust: 1 },
|
||||
Burn: { name: "소각", cost: 0, kind: "Skill", exhaust: true },
|
||||
Hit: { name: "타격", cost: 0, kind: "Attack", damage: 6 },
|
||||
},
|
||||
starterDeck: ["Embrace", "Burn", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: keepBlock power preserves block across turns", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Barricade: { name: "바리케이드", cost: 0, kind: "Power", powerEffect: "keepBlock", value: 0 },
|
||||
Guard: { name: "수비", cost: 0, kind: "Skill", block: 5 },
|
||||
Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 },
|
||||
},
|
||||
starterDeck: ["Barricade", "Guard", "Pass"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test("chooseAction: damageFromCurrentBlock values attack using current block", () => {
|
||||
const cards = {
|
||||
Guard: { name: "Guard", cost: 1, kind: "Skill", block: 5 },
|
||||
BodySlam: { name: "BodySlam", cost: 1, kind: "Attack", damageFromCurrentBlock: 1 },
|
||||
};
|
||||
assert.equal(chooseAction(["BodySlam", "Guard"], cards, 1, { currentBlock: 6 }), 0);
|
||||
});
|
||||
|
||||
test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
@@ -1101,6 +1211,95 @@ test("simulateCombat: rewardOnKill grants an extra reward screen when an attack
|
||||
assert.equal(r.bonusRewardScreens, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: maxHpOnKill increases max hp and heals when attack kills", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Feed: { name: "포식", cost: 1, kind: "Attack", damage: 10, maxHpOnKill: 3 },
|
||||
},
|
||||
starterDeck: ["Feed"],
|
||||
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
playerHp: 50,
|
||||
playerMaxHp: 80,
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.playerHpRemaining, 53);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawNameMatchAutoPlay auto-plays matching drawn cards", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Hellraiser: { name: "지옥검무", cost: 0, kind: "Power", drawNameMatchAutoPlay: "타격" },
|
||||
Strike: { name: "강타격", cost: 99, kind: "Attack", damage: 9 },
|
||||
Pass1: { name: "대기1", cost: 99, kind: "Skill" },
|
||||
Pass2: { name: "대기2", cost: 99, kind: "Skill" },
|
||||
Pass3: { name: "대기3", cost: 99, kind: "Skill" },
|
||||
Pass4: { name: "대기4", cost: 99, kind: "Skill" },
|
||||
},
|
||||
starterDeck: ["Hellraiser", "Pass1", "Pass2", "Pass3", "Pass4", "Strike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 9, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: addRandomCardCount can add same-class attack as zero-cost this turn", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
InfernalBlade: {
|
||||
name: "지옥검",
|
||||
cost: 0,
|
||||
kind: "Skill",
|
||||
addRandomCardCount: 1,
|
||||
addRandomCardKind: "Attack",
|
||||
addRandomCardSameClass: true,
|
||||
addedCardsCostZeroThisTurn: true,
|
||||
class: "warrior",
|
||||
},
|
||||
BigHit: { name: "큰 일격", cost: 2, kind: "Attack", damage: 12, class: "warrior" },
|
||||
OffClass: { name: "외부 공격", cost: 0, kind: "Attack", damage: 1, class: "rogue" },
|
||||
},
|
||||
starterDeck: ["InfernalBlade"],
|
||||
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: drawPerExhausted draws for each exhausted card", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Stoke: { name: "화력 증폭", cost: 0, kind: "Skill", exhaustHandAll: true, drawPerExhausted: 1 },
|
||||
Filler1: { name: "채우기1", cost: 99, kind: "Skill" },
|
||||
Filler2: { name: "채우기2", cost: 99, kind: "Skill" },
|
||||
Hit: { name: "일격", cost: 0, kind: "Attack", damage: 8 },
|
||||
},
|
||||
starterDeck: ["Stoke", "Filler1", "Filler2", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: playTopDrawPileCountPerEnergy auto-plays top draw pile cards", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Cascade: { name: "연쇄", cost: 0, kind: "Skill", useAllEnergy: true, playTopDrawPileCountPerEnergy: 1, innate: true },
|
||||
Filler1: { name: "준비1", cost: 99, kind: "Skill", innate: true },
|
||||
Filler2: { name: "준비2", cost: 99, kind: "Skill", innate: true },
|
||||
Filler3: { name: "준비3", cost: 99, kind: "Skill", innate: true },
|
||||
Filler4: { name: "준비4", cost: 99, kind: "Skill", innate: true },
|
||||
Hit1: { name: "타격1", cost: 99, kind: "Attack", damage: 6 },
|
||||
Hit2: { name: "타격2", cost: 99, kind: "Attack", damage: 6 },
|
||||
Hit3: { name: "타격3", cost: 99, kind: "Attack", damage: 6 },
|
||||
},
|
||||
starterDeck: ["Cascade", "Filler1", "Filler2", "Filler3", "Filler4", "Hit1", "Hit2", "Hit3"],
|
||||
monsters: [{ name: "Dummy", maxHp: 18, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
});
|
||||
|
||||
test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
@@ -1192,3 +1391,102 @@ test("simulateCombat: shivAoe makes Shivs hit all enemies", () => {
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 1);
|
||||
});
|
||||
|
||||
test("simulateCombat: comboGain and damagePerCombo scale repeated attacks", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Brandish: { name: "브랜디쉬", cost: 1, kind: "Attack", damage: 2, hits: 2, comboGain: 1, damagePerCombo: 1 },
|
||||
},
|
||||
starterDeck: ["Brandish"],
|
||||
monsters: [{ name: "Dummy", maxHp: 14, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(stats.Brandish.damage > stats.Brandish.plays * 4);
|
||||
});
|
||||
|
||||
test("simulateCombat: comboMax power raises the combo cap", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
ComboSynergy: { name: "콤보 시너지", cost: 0, kind: "Power", comboMax: 8, comboOnAttack: 2, attackDamagePerCombo: 1, innate: true },
|
||||
Hit: { name: "연속 베기", cost: 0, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["ComboSynergy", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 40, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(stats.Hit.damage > stats.Hit.plays);
|
||||
});
|
||||
|
||||
test("simulateCombat: healPerTurn power restores hp at turn start", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Recovery: { name: "셀프 리커버리", cost: 0, kind: "Power", powerEffect: "healPerTurn", value: 3 },
|
||||
Hit: { name: "마무리", cost: 1, kind: "Attack", damage: 1 },
|
||||
},
|
||||
starterDeck: ["Recovery", "Hit"],
|
||||
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 2 }] }],
|
||||
playerHp: 70,
|
||||
playerMaxHp: 80,
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(r.playerHpRemaining >= 69);
|
||||
});
|
||||
|
||||
test("simulateCombat: Holy Charge scales repeated Holy Force attacks", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
ChargeStrike: { name: "차지 타격", cost: 1, kind: "Attack", damage: 2, holyChargeGain: 1, damagePerHolyCharge: 1 },
|
||||
},
|
||||
starterDeck: ["ChargeStrike"],
|
||||
monsters: [{ name: "Dummy", maxHp: 14, intents: [{ kind: "Attack", value: 0 }] }],
|
||||
};
|
||||
const stats = {};
|
||||
const r = simulateCombat(data, () => 0.999999, stats);
|
||||
assert.equal(r.win, true);
|
||||
assert.ok(stats.ChargeStrike.damage > stats.ChargeStrike.plays * 2);
|
||||
});
|
||||
|
||||
test("simulateCombat: damageTakenReduction lowers incoming HP damage", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Achilles: { name: "아킬레스", cost: 3, kind: "Power", damageTakenReduction: 0.25, innate: true },
|
||||
Wait1: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Wait2: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Wait3: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Wait4: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Finish: { name: "마무리", cost: 3, kind: "Power", cardPlayedDamage: 2 },
|
||||
},
|
||||
starterDeck: ["Finish", "Achilles", "Wait1", "Wait2", "Wait3", "Wait4"],
|
||||
monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 10 }] }],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.playerHpRemaining, 73);
|
||||
});
|
||||
|
||||
test("simulateCombat: blockOnDamaged protects against later attackers", () => {
|
||||
const data = {
|
||||
cards: {
|
||||
Armor: { name: "블레싱 아머", cost: 3, kind: "Power", blockOnDamaged: 6, strengthOnDamagedOnce: 2, innate: true },
|
||||
Wait1: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Wait2: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Wait3: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Wait4: { name: "대기", cost: 99, kind: "Status", unplayable: true, innate: true },
|
||||
Finish1: { name: "마무리", cost: 3, kind: "Power", cardPlayedDamage: 1 },
|
||||
Finish2: { name: "마무리", cost: 3, kind: "Power", cardPlayedDamage: 1 },
|
||||
},
|
||||
starterDeck: ["Finish1", "Finish2", "Armor", "Wait1", "Wait2", "Wait3", "Wait4"],
|
||||
monsters: [
|
||||
{ name: "A", maxHp: 1, intents: [{ kind: "Attack", value: 5 }] },
|
||||
{ name: "B", maxHp: 1, intents: [{ kind: "Attack", value: 5 }] },
|
||||
],
|
||||
};
|
||||
const r = simulateCombat(data, () => 0.999999);
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.playerHpRemaining, 70);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ if c == nil then
|
||||
return
|
||||
end
|
||||
if c.unplayable == true then
|
||||
self:Toast("사용할 수 없는 카드입니다")
|
||||
self:Toast("사용할 수 없는 카드입니다")
|
||||
return
|
||||
end
|
||||
if self:CanPlayCardNow(c) ~= true then
|
||||
@@ -49,6 +49,8 @@ local skillFree = false
|
||||
local skillRepeat = 0
|
||||
if self.HandCostZeroThisTurn == true then
|
||||
cost = 0
|
||||
elseif self.ZeroCostCardIdsThisTurn ~= nil and self.ZeroCostCardIdsThisTurn[cardId] == true then
|
||||
cost = 0
|
||||
elseif c.useAllEnergy == true then
|
||||
cost = self.Energy
|
||||
end
|
||||
@@ -71,6 +73,8 @@ if self.Energy < cost then
|
||||
end
|
||||
self.Energy = self.Energy - cost
|
||||
self.ActiveKillReward = c.rewardOnKill or 0
|
||||
self.ActiveKillMaxHpGain = c.maxHpOnKill or 0
|
||||
table.remove(self.Hand, slot)
|
||||
self:ResolveCardEffects(cardId, slot, c, false, cost)
|
||||
local function applyCardPlayHooks()
|
||||
if self:HasPowerField("cardPlayedBlock") == true then
|
||||
@@ -82,6 +86,8 @@ local function applyCardPlayHooks()
|
||||
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
|
||||
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
|
||||
end
|
||||
self:ApplyAttackCardPlayHooks(c)
|
||||
self:ApplyHolyForceCardPlayHooks(c)
|
||||
end
|
||||
applyCardPlayHooks()
|
||||
if skillRepeat > 0 then
|
||||
@@ -106,16 +112,19 @@ end
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
|
||||
self.ActiveKillReward = 0
|
||||
end
|
||||
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain <= 0 then
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
end
|
||||
if c.combatCostReductionOnPlay ~= nil and c.combatCostReductionOnPlay > 0 then
|
||||
if self.CombatCardCostReduction == nil then
|
||||
self.CombatCardCostReduction = {}
|
||||
end
|
||||
self.CombatCardCostReduction[cardId] = (self.CombatCardCostReduction[cardId] or 0) + c.combatCostReductionOnPlay
|
||||
end
|
||||
table.remove(self.Hand, slot)
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId)
|
||||
self:TriggerExhaustEffects(1)
|
||||
elseif c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
@@ -295,11 +304,19 @@ if dmg > 0 then
|
||||
self:ApplyPoisonToMonster(m, poison)
|
||||
end
|
||||
end
|
||||
self:ShowDmgPop(m.slot, dmg)
|
||||
self:MonsterHitMotion(m.slot)
|
||||
local killed = false
|
||||
if m.hp <= 0 then
|
||||
m.hp = 0
|
||||
self:KillMonster(m.slot)
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + self.ActiveKillReward
|
||||
end
|
||||
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain > 0 then
|
||||
self.PlayerMaxHp = self.PlayerMaxHp + self.ActiveKillMaxHpGain
|
||||
self.PlayerHp = self.PlayerHp + self.ActiveKillMaxHpGain
|
||||
end
|
||||
killed = true
|
||||
end
|
||||
return killed`, [
|
||||
@@ -367,7 +384,7 @@ local burstEvery = self:AddPowerFieldTotal("poisonApplicationBurstEvery")
|
||||
local burstDamage = self:AddPowerFieldTotal("poisonApplicationBurstDamage")
|
||||
if burstEvery ~= nil and burstEvery > 0 and burstDamage ~= nil and burstDamage > 0 then
|
||||
if (self.PoisonApplicationsThisCombat % burstEvery) == 0 then
|
||||
self:DealDamageToAllMonsters(burstDamage)
|
||||
self:DealDamageToAllMonsters(burstDamage, false)
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'target' },
|
||||
@@ -411,6 +428,11 @@ end
|
||||
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
|
||||
end
|
||||
if killCount > 0 and self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain > 0 then
|
||||
local gain = killCount * self.ActiveKillMaxHpGain
|
||||
self.PlayerMaxHp = self.PlayerMaxHp + gain
|
||||
self.PlayerHp = self.PlayerHp + gain
|
||||
end
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
return killCount > 0`, [
|
||||
@@ -419,6 +441,13 @@ return killCount > 0`, [
|
||||
], 0, 'boolean'),
|
||||
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
local shown = damage
|
||||
if m ~= nil and m.alive == true and m.vuln > 0 then
|
||||
shown = math.floor(damage * 1.5)
|
||||
end
|
||||
if m ~= nil and m.alive == true and m.weak > 0 and self.ActiveAttackDamageVsWeakMultiplier ~= nil and self.ActiveAttackDamageVsWeakMultiplier > 1 then
|
||||
shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier)
|
||||
end
|
||||
self:DealDamageToTarget(damage, pierce)
|
||||
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self:RenderCombat()
|
||||
@@ -450,12 +479,9 @@ _TimerService:SetTimerOnce(function()
|
||||
shown = math.floor(shown * self.ActiveAttackDamageVsWeakMultiplier)
|
||||
end
|
||||
local killed = self:DealDamageToTarget(damage, pierce)
|
||||
if killed == true and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + self.ActiveKillReward
|
||||
end
|
||||
self.ActiveKillReward = 0
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self:ShowDmgPop(targetIndex, shown)
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
end, 0.35)`, [
|
||||
@@ -510,10 +536,8 @@ _TimerService:SetTimerOnce(function()
|
||||
end
|
||||
end
|
||||
end
|
||||
if killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then
|
||||
self.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)
|
||||
end
|
||||
self.ActiveKillReward = 0
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()
|
||||
@@ -535,6 +559,11 @@ 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
|
||||
local reduction = self:AddPowerFieldTotal("damageTakenReduction")
|
||||
if reduction ~= nil and reduction > 0 then
|
||||
reduction = math.min(0.75, reduction)
|
||||
dmg = math.floor(dmg * (1 - reduction))
|
||||
end
|
||||
if self.PlayerBlock > 0 then
|
||||
local absorbed = math.min(self.PlayerBlock, dmg)
|
||||
self.PlayerBlock = self.PlayerBlock - absorbed
|
||||
@@ -545,6 +574,17 @@ if dmg > 0 and self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 and dm
|
||||
end
|
||||
if dmg > 0 then
|
||||
self.PlayerHp = self.PlayerHp - dmg
|
||||
local reactiveBlock = self:AddPowerFieldTotal("blockOnDamaged")
|
||||
if reactiveBlock ~= nil and reactiveBlock > 0 then
|
||||
self:AddCardBlock(reactiveBlock)
|
||||
end
|
||||
if self.DamagePowerStrengthUsed ~= true then
|
||||
local reactiveStrength = self:AddPowerFieldTotal("strengthOnDamagedOnce")
|
||||
if reactiveStrength ~= nil and reactiveStrength > 0 then
|
||||
self.PlayerStr = self.PlayerStr + reactiveStrength
|
||||
self.DamagePowerStrengthUsed = true
|
||||
end
|
||||
end
|
||||
local reflect = self.PlayerThorns or 0
|
||||
if self:HasRelic("bronzeScales") then
|
||||
reflect = reflect + 3
|
||||
@@ -566,7 +606,7 @@ if dmg > 0 then
|
||||
end
|
||||
if self:HasRelic("centennialPuzzle") and self.FirstHpLossDone == false then
|
||||
self.FirstHpLossDone = true
|
||||
self:DrawCards(3)
|
||||
self:DrawCards(3, true)
|
||||
self:RenderHand(false)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -243,6 +243,7 @@ self.BlockGainMultiplier = 1
|
||||
self:ApplyRelics("turnStart")
|
||||
if self.NextTurnKeepBlock == true then
|
||||
self.NextTurnKeepBlock = false
|
||||
elseif self:HasPowerEffect("keepBlock") == true then
|
||||
else
|
||||
self.PlayerBlock = 0
|
||||
end
|
||||
@@ -258,6 +259,7 @@ self.ActiveAttackDamageVsWeakMultiplier = 1
|
||||
self.DrawDamageThisTurn = 0
|
||||
self.DrawPoisonThisTurn = 0
|
||||
self.ShivAoeThisCombat = false
|
||||
self.ZeroCostCardIdsThisTurn = {}
|
||||
self.SkillSlyOnPlayCards = self.SkillSlyOnPlayCards or {}
|
||||
self.TurnSkillSlyCards = {}
|
||||
self.EnemyStrengthLossThisTurn = 0
|
||||
@@ -275,6 +277,7 @@ if self.PlayerPowers ~= nil then
|
||||
self.Energy = self.Energy + pc.value
|
||||
elseif pc.powerEffect == "blockPerTurn" then
|
||||
self.PlayerBlock = self.PlayerBlock + pc.value
|
||||
elseif pc.powerEffect == "keepBlock" then
|
||||
elseif pc.powerEffect == "poisonPerTurn" then
|
||||
if self.Monsters ~= nil then
|
||||
for j = 1, #self.Monsters do
|
||||
@@ -288,6 +291,8 @@ if self.PlayerPowers ~= nil then
|
||||
if self.Monsters ~= nil then
|
||||
self:PlayAoeFx(pc.fx or pc.image, pc.value or 0)
|
||||
end
|
||||
elseif pc.powerEffect == "healPerTurn" then
|
||||
self.PlayerHp = math.min(self.PlayerMaxHp, self.PlayerHp + pc.value)
|
||||
end
|
||||
if pc.turnStartShiv ~= nil then
|
||||
self:AddCardsToHand("Shiv", pc.turnStartShiv)
|
||||
@@ -316,7 +321,7 @@ if self.NextTurnAddCards ~= nil then
|
||||
end
|
||||
local drawN = 5 + (self.NextTurnDraw or 0) + powerTurnDraw
|
||||
self.NextTurnDraw = 0
|
||||
self:DrawCards(drawN)
|
||||
self:DrawCards(drawN, true)
|
||||
self:RenderHand(true)
|
||||
self:RenderCombat()
|
||||
if powerTurnDiscard > 0 then
|
||||
@@ -481,8 +486,11 @@ for i = 1, amount do
|
||||
\t\tself:TriggerSly(cardId)
|
||||
\telse
|
||||
\t\ttable.insert(self.Hand, cardId)
|
||||
\t\tdrewAny = true
|
||||
\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\t\tlocal autoPlayed = self:TriggerDrawnCardAutoPlay(cardId)
|
||||
\t\tif autoPlayed ~= true then
|
||||
\t\t\tdrewAny = true
|
||||
\t\t\ttable.insert(drawnSlots, #self.Hand)
|
||||
\t\tend
|
||||
\tend
|
||||
end
|
||||
self:RenderPiles()
|
||||
@@ -495,8 +503,9 @@ if animate == true and #drawnSlots > 0 then
|
||||
\t\tlocal slot = drawnSlots[i]
|
||||
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
|
||||
\tend
|
||||
end
|
||||
return drawnCards
|
||||
end`, [
|
||||
`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
|
||||
], 0, 'any'),
|
||||
|
||||
@@ -326,7 +326,104 @@ for i = 1, #self.Hand do
|
||||
end
|
||||
end
|
||||
return n`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
|
||||
method('CountOwnedNameMatches', `if match == nil or match == "" then
|
||||
return 0
|
||||
end
|
||||
local n = 0
|
||||
local function countPile(pile)
|
||||
if pile == nil then return end
|
||||
for i = 1, #pile do
|
||||
local c2 = self.Cards[pile[i]]
|
||||
local name = ""
|
||||
if c2 ~= nil and c2.name ~= nil then name = c2.name end
|
||||
if string.find(name, match, 1, true) ~= nil then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
countPile(self.Hand)
|
||||
countPile(self.DrawPile)
|
||||
countPile(self.DiscardPile)
|
||||
countPile(self.ExhaustPile)
|
||||
return n`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'match' }], 0, 'number'),
|
||||
method('MaxPowerField', `local best = 0
|
||||
if self.PlayerPowers == nil then
|
||||
return best
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local powerCard = self.Cards[self.PlayerPowers[i]]
|
||||
if powerCard ~= nil and powerCard[field] ~= nil and powerCard[field] > best then
|
||||
best = powerCard[field]
|
||||
end
|
||||
end
|
||||
return best`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'field' }], 0, 'number'),
|
||||
method('GetComboMax', `local comboMax = 5
|
||||
local powerMax = self:MaxPowerField("comboMax")
|
||||
if powerMax ~= nil and powerMax > comboMax then
|
||||
comboMax = powerMax
|
||||
end
|
||||
return comboMax`, [], 0, 'number'),
|
||||
method('GainCombo', `if amount == nil or amount <= 0 then
|
||||
return
|
||||
end
|
||||
self.ComboCount = math.min(self:GetComboMax(), (self.ComboCount or 0) + amount)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('GetHolyChargeMax', `local chargeMax = 3
|
||||
local powerMax = self:MaxPowerField("holyChargeMax")
|
||||
if powerMax ~= nil and powerMax > chargeMax then
|
||||
chargeMax = powerMax
|
||||
end
|
||||
return chargeMax`, [], 0, 'number'),
|
||||
method('GainHolyCharge', `if amount == nil or amount <= 0 then
|
||||
return
|
||||
end
|
||||
self.HolyChargeCount = math.min(self:GetHolyChargeMax(), (self.HolyChargeCount or 0) + amount)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('ApplyHolyForceCardPlayHooks', `if c == nil then
|
||||
return
|
||||
end
|
||||
local gain = c.holyChargeGain or 0
|
||||
if c.holyForce == true then
|
||||
gain = gain + self:AddPowerFieldTotal("holyChargeOnHolyForce")
|
||||
end
|
||||
self:GainHolyCharge(gain)
|
||||
if c.holyChargeSpendAll == true then
|
||||
self.HolyChargeCount = 0
|
||||
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }]),
|
||||
method('ApplyAttackCardPlayHooks', `if c == nil or c.kind ~= "Attack" then
|
||||
return
|
||||
end
|
||||
local comboGain = c.comboGain or 0
|
||||
comboGain = comboGain + self:AddPowerFieldTotal("comboOnAttack")
|
||||
self:GainCombo(comboGain)
|
||||
local extraDamage = self:AddPowerFieldTotal("attackPlayedDamage")
|
||||
if extraDamage ~= nil and extraDamage > 0 then
|
||||
self:DealDirectDamageToTarget(extraDamage)
|
||||
end
|
||||
local weakAmount = self:AddPowerFieldTotal("attackWeak")
|
||||
if weakAmount ~= nil and weakAmount > 0 and self.Monsters ~= nil then
|
||||
local target = self.Monsters[self.TargetIndex]
|
||||
if target ~= nil and target.alive == true then
|
||||
if target.artifact ~= nil and target.artifact > 0 then
|
||||
target.artifact = target.artifact - 1
|
||||
else
|
||||
target.weak = (target.weak or 0) + weakAmount
|
||||
end
|
||||
end
|
||||
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }]),
|
||||
method('AttackBaseForCard', `local base2 = c.damage or 0
|
||||
if c.damageNameMatch ~= nil and c.damagePerOwnedNameMatch ~= nil then
|
||||
base2 = base2 + self:CountOwnedNameMatches(c.damageNameMatch) * c.damagePerOwnedNameMatch
|
||||
end
|
||||
if c.damageFromCurrentBlock ~= nil and c.damageFromCurrentBlock ~= 0 then
|
||||
base2 = base2 + (self.PlayerBlock or 0) * c.damageFromCurrentBlock
|
||||
end
|
||||
local comboScale = c.damagePerCombo or 0
|
||||
comboScale = comboScale + self:AddPowerFieldTotal("attackDamagePerCombo")
|
||||
if comboScale ~= 0 then
|
||||
base2 = base2 + (self.ComboCount or 0) * comboScale
|
||||
end
|
||||
if c.damagePerHolyCharge ~= nil and c.damagePerHolyCharge ~= 0 then
|
||||
base2 = base2 + (self.HolyChargeCount or 0) * c.damagePerHolyCharge
|
||||
end
|
||||
local otherHand = 0
|
||||
if self.Hand ~= nil then
|
||||
otherHand = #self.Hand - 1
|
||||
@@ -365,6 +462,200 @@ return base2`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
], 0, 'number'),
|
||||
method('TriggerExhaustEffects', `if count == nil or count <= 0 then
|
||||
return
|
||||
end
|
||||
local drawOnExhaust = self:AddPowerFieldTotal("drawOnExhaust")
|
||||
if drawOnExhaust ~= nil and drawOnExhaust > 0 then
|
||||
self:DrawCards(drawOnExhaust * count, true)
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'count' }]),
|
||||
method('MarkCardCostZeroThisTurn', `if cardId == nil or cardId == "" then
|
||||
return
|
||||
end
|
||||
if self.ZeroCostCardIdsThisTurn == nil then
|
||||
self.ZeroCostCardIdsThisTurn = {}
|
||||
end
|
||||
self.ZeroCostCardIdsThisTurn[cardId] = true`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
|
||||
method('AutoPlayCardId', `local c = self.Cards[cardId]
|
||||
if c == nil then
|
||||
return false
|
||||
end
|
||||
local spent = energySpent or 0
|
||||
local skillFree = false
|
||||
local skillRepeat = 0
|
||||
if c.kind == "Skill" and c.useAllEnergy ~= true and self.NextSkillCostZero == true then
|
||||
skillFree = true
|
||||
end
|
||||
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
|
||||
skillRepeat = self.NextSkillRepeatCount
|
||||
end
|
||||
self.ActiveKillReward = c.rewardOnKill or 0
|
||||
self.ActiveKillMaxHpGain = c.maxHpOnKill or 0
|
||||
self:ResolveCardEffects(cardId, 0, c, false, spent)
|
||||
local function applyCardPlayHooks()
|
||||
if self:HasPowerField("cardPlayedBlock") == true then
|
||||
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
|
||||
end
|
||||
if c.cardPlayedDamage ~= nil and c.cardPlayedDamage > 0 then
|
||||
self:DealDirectDamageToTarget(c.cardPlayedDamage)
|
||||
end
|
||||
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
|
||||
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
|
||||
end
|
||||
self:ApplyAttackCardPlayHooks(c)
|
||||
self:ApplyHolyForceCardPlayHooks(c)
|
||||
end
|
||||
applyCardPlayHooks()
|
||||
if skillRepeat > 0 then
|
||||
local remaining = (self.NextSkillRepeatCount or 0) - skillRepeat
|
||||
if remaining < 0 then remaining = 0 end
|
||||
self.NextSkillRepeatCount = remaining
|
||||
for i = 1, skillRepeat do
|
||||
self:ResolveCardEffects(cardId, 0, c, false, spent)
|
||||
applyCardPlayHooks()
|
||||
end
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
|
||||
end
|
||||
if skillFree == true and c.nextSkillCostZero ~= true then
|
||||
self.NextSkillCostZero = false
|
||||
end
|
||||
if c.exhaust == true then
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId)
|
||||
self:TriggerExhaustEffects(1)
|
||||
elseif c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
|
||||
self.ActiveKillReward = 0
|
||||
end
|
||||
if self.ActiveKillMaxHpGain ~= nil and self.ActiveKillMaxHpGain <= 0 then
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
end
|
||||
return true`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
], 0, 'boolean'),
|
||||
method('TriggerDrawnCardAutoPlay', `if cardId == nil or cardId == "" or self.Hand == nil or self.PlayerPowers == nil then
|
||||
return false
|
||||
end
|
||||
local c = self.Cards[cardId]
|
||||
if c == nil or c.name == nil or c.name == "" then
|
||||
return false
|
||||
end
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local powerCard = self.Cards[self.PlayerPowers[i]]
|
||||
if powerCard ~= nil and powerCard.drawNameMatchAutoPlay ~= nil and powerCard.drawNameMatchAutoPlay ~= "" then
|
||||
if string.find(c.name, powerCard.drawNameMatchAutoPlay, 1, true) ~= nil then
|
||||
local foundSlot = 0
|
||||
for hi = 1, #self.Hand do
|
||||
if self.Hand[hi] == cardId then
|
||||
foundSlot = hi
|
||||
break
|
||||
end
|
||||
end
|
||||
if foundSlot <= 0 then
|
||||
return false
|
||||
end
|
||||
table.remove(self.Hand, foundSlot)
|
||||
self:AutoPlayCardId(cardId, 0)
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }], 0, 'boolean'),
|
||||
method('PlayTopDrawPileCards', `if c == nil or self.DrawPile == nil then
|
||||
return 0
|
||||
end
|
||||
local count = c.playTopDrawPileCount or 0
|
||||
if c.playTopDrawPileCountPerEnergy ~= nil and c.playTopDrawPileCountPerEnergy > 0 then
|
||||
count = count + ((energySpent or 0) * c.playTopDrawPileCountPerEnergy)
|
||||
end
|
||||
if count <= 0 then
|
||||
return 0
|
||||
end
|
||||
local played = 0
|
||||
for i = 1, count do
|
||||
if #self.DrawPile <= 0 then
|
||||
break
|
||||
end
|
||||
local topCardId = table.remove(self.DrawPile)
|
||||
if topCardId ~= nil then
|
||||
self:AutoPlayCardId(topCardId, 0)
|
||||
played = played + 1
|
||||
end
|
||||
end
|
||||
return played`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'energySpent' },
|
||||
], 0, 'number'),
|
||||
method('AddRandomCardsFromEffect', `if c == nil or count == nil or count <= 0 then
|
||||
return 0
|
||||
end
|
||||
local pool = {}
|
||||
for id, rc in pairs(self.Cards) do
|
||||
if rc ~= nil and rc.token ~= true and rc.curse ~= true and rc.unplayable ~= true then
|
||||
local ok = true
|
||||
if c.addRandomCardKind ~= nil and rc.kind ~= c.addRandomCardKind then ok = false end
|
||||
if c.addRandomCardSameClass == true and rc.class ~= c.class then ok = false end
|
||||
if ok == true then table.insert(pool, id) end
|
||||
end
|
||||
end
|
||||
if #pool <= 0 then
|
||||
return 0
|
||||
end
|
||||
local added = 0
|
||||
for i = 1, count do
|
||||
local cardId2 = pool[math.random(1, #pool)]
|
||||
if cardId2 ~= nil then
|
||||
self:AddCardsToHand(cardId2, 1)
|
||||
if c.addedCardsCostZeroThisTurn == true then
|
||||
self:MarkCardCostZeroThisTurn(cardId2)
|
||||
end
|
||||
added = added + 1
|
||||
end
|
||||
end
|
||||
return added`, [
|
||||
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'count' },
|
||||
], 0, 'number'),
|
||||
method('ExhaustHandNonAttack', `if c == nil or c.exhaustHandNonAttack ~= true or self.Hand == nil or #self.Hand <= 0 then
|
||||
return 0
|
||||
end
|
||||
local exhausted = 0
|
||||
for i = #self.Hand, 1, -1 do
|
||||
local cardId2 = self.Hand[i]
|
||||
local hc = self.Cards[cardId2]
|
||||
if hc == nil or hc.kind ~= "Attack" then
|
||||
table.remove(self.Hand, i)
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId2)
|
||||
exhausted = exhausted + 1
|
||||
end
|
||||
end
|
||||
if exhausted > 0 then
|
||||
if c.blockPerExhaustedCard ~= nil and c.blockPerExhaustedCard > 0 then
|
||||
self:AddCardBlock(exhausted * c.blockPerExhaustedCard)
|
||||
end
|
||||
self:TriggerExhaustEffects(exhausted)
|
||||
end
|
||||
return exhausted`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'number'),
|
||||
method('ExhaustHandAll', `if c == nil or c.exhaustHandAll ~= true or self.Hand == nil or #self.Hand <= 0 then
|
||||
return 0
|
||||
end
|
||||
local exhausted = 0
|
||||
while #self.Hand > 0 do
|
||||
local cardId2 = table.remove(self.Hand)
|
||||
if self.ExhaustPile == nil then self.ExhaustPile = {} end
|
||||
table.insert(self.ExhaustPile, cardId2)
|
||||
exhausted = exhausted + 1
|
||||
end
|
||||
if exhausted > 0 then
|
||||
self:TriggerExhaustEffects(exhausted)
|
||||
end
|
||||
return exhausted`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'number'),
|
||||
method('CalcPlayerAttack', `local base2 = base
|
||||
self.FightAttackCount = self.FightAttackCount + 1
|
||||
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then
|
||||
@@ -485,14 +776,15 @@ if c.xWeakPerEnergy ~= nil and c.xWeakPerEnergy > 0 then
|
||||
weakAmount = weakAmount + xEnergy * c.xWeakPerEnergy
|
||||
end
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil or c.xDamagePerEnergy ~= nil then
|
||||
if c.damage ~= nil or c.xDamagePerEnergy ~= nil or c.damageFromCurrentBlock ~= nil then
|
||||
self:PlayerAttackMotion()
|
||||
local baseDmg = self:AttackBaseForCard(slot, c)
|
||||
self.ActiveAttackDamageVsWeakMultiplier = c.attackDamageVsWeakMultiplier or 1
|
||||
self.ActiveAttackDamageVsWeakMultiplier = math.max(c.attackDamageVsWeakMultiplier or 1, self:MaxPowerField("attackDamageVsWeakMultiplier"))
|
||||
if c.xDamagePerEnergy ~= nil and c.xDamagePerEnergy > 0 then
|
||||
baseDmg = xEnergy * c.xDamagePerEnergy
|
||||
end
|
||||
local total = 0
|
||||
local hitDamages = {}
|
||||
local hitN = c.hits or 1
|
||||
if c.otherHandAtLeast ~= nil and c.bonusHitsWhenOtherHandAtLeast ~= nil then
|
||||
local otherHand = 0
|
||||
@@ -505,7 +797,9 @@ if c.kind == "Attack" then
|
||||
end
|
||||
end
|
||||
for h = 1, hitN do
|
||||
total = total + self:CalcPlayerAttack(baseDmg)
|
||||
local hitDamage = self:CalcPlayerAttack(baseDmg)
|
||||
hitDamages[h] = hitDamage
|
||||
total = total + hitDamage
|
||||
end
|
||||
local useAoe = c.aoe == true
|
||||
if c.class == "shiv" and (self.ShivAoeThisCombat == true or self:HasPowerField("shivAoe") == true) then
|
||||
@@ -550,11 +844,16 @@ if c.kind == "Attack" then
|
||||
if targetIdx ~= nil and targetIdx > 0 then
|
||||
local prev = self.TargetIndex
|
||||
self.TargetIndex = targetIdx
|
||||
local killed = self:DealDamageToTarget(total / hitN, c.pierce == true)
|
||||
local killed = self:DealDamageToTarget(hitDamages[h] or 0, c.pierce == true)
|
||||
self.TargetIndex = prev
|
||||
if killed == true then roundKilled = true end
|
||||
end
|
||||
end
|
||||
elseif hitN > 1 then
|
||||
for h = 1, hitN do
|
||||
local killed = self:DealDamageToTarget(hitDamages[h] or 0, c.pierce == true)
|
||||
if killed == true then roundKilled = true end
|
||||
end
|
||||
else
|
||||
local killed = self:DealDamageToTarget(total, c.pierce == true)
|
||||
if killed == true then roundKilled = true end
|
||||
@@ -570,14 +869,14 @@ if c.kind == "Attack" then
|
||||
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + totalDamage
|
||||
end
|
||||
if c.block ~= nil then
|
||||
self:AddCardBlock(c.block)
|
||||
self:AddCardBlock(c.block + (self.HolyChargeCount or 0) * (c.blockPerHolyCharge or 0))
|
||||
end
|
||||
if free ~= true then
|
||||
self:ApplyRelics("cardPlayed")
|
||||
end
|
||||
elseif c.kind == "Skill" then
|
||||
if c.block ~= nil then
|
||||
self:AddCardBlock(c.block)
|
||||
self:AddCardBlock(c.block + (self.HolyChargeCount or 0) * (c.blockPerHolyCharge or 0))
|
||||
end
|
||||
elseif c.kind == "Power" then
|
||||
if free ~= true then
|
||||
@@ -597,11 +896,19 @@ if c.selfVuln ~= nil then
|
||||
self.PlayerVuln = self.PlayerVuln + c.selfVuln
|
||||
end
|
||||
if c.heal ~= nil then
|
||||
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
|
||||
local healAmount = c.heal + (self.HolyChargeCount or 0) * (c.healPerHolyCharge or 0)
|
||||
self.PlayerHp = math.min(self.PlayerHp + healAmount, self.PlayerMaxHp)
|
||||
end
|
||||
if c.gainEnergy ~= nil and c.gainEnergy ~= 0 then
|
||||
self.Energy = self.Energy + c.gainEnergy
|
||||
end
|
||||
if c.kind ~= "Attack" and c.comboGain ~= nil and c.comboGain > 0 then
|
||||
self:GainCombo(c.comboGain)
|
||||
end
|
||||
if c.removePlayerDebuffs == true then
|
||||
self.PlayerWeak = 0
|
||||
self.PlayerVuln = 0
|
||||
end
|
||||
if c.intangible ~= nil and c.intangible > 0 then
|
||||
self.PlayerIntangible = (self.PlayerIntangible or 0) + c.intangible
|
||||
end
|
||||
@@ -719,6 +1026,23 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
|
||||
end
|
||||
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
|
||||
self:AddCardsToHand("Shiv", c.addShiv)
|
||||
end
|
||||
local exhaustedNonAttack = self:ExhaustHandNonAttack(c)
|
||||
local exhaustedAll = self:ExhaustHandAll(c)
|
||||
local totalExhausted = exhaustedNonAttack + exhaustedAll
|
||||
if c.drawPerExhausted ~= nil and c.drawPerExhausted > 0 and totalExhausted > 0 then
|
||||
self:DrawCards(totalExhausted * c.drawPerExhausted, true)
|
||||
end
|
||||
if c.addRandomCardCount ~= nil and c.addRandomCardCount > 0 then
|
||||
self:AddRandomCardsFromEffect(c, c.addRandomCardCount)
|
||||
end
|
||||
if c.addRandomCardPerExhausted ~= nil and c.addRandomCardPerExhausted > 0 then
|
||||
if totalExhausted > 0 then
|
||||
self:AddRandomCardsFromEffect(c, totalExhausted * c.addRandomCardPerExhausted)
|
||||
end
|
||||
end
|
||||
if (c.playTopDrawPileCount ~= nil and c.playTopDrawPileCount > 0) or (c.playTopDrawPileCountPerEnergy ~= nil and c.playTopDrawPileCountPerEnergy > 0) then
|
||||
self:PlayTopDrawPileCards(c, xEnergy)
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
|
||||
@@ -25,7 +25,7 @@ for i = 1, #self.RunRelics do
|
||||
elseif r.effect == "strength" then
|
||||
self.PlayerStr = self.PlayerStr + r.value
|
||||
elseif r.effect == "draw" then
|
||||
self:DrawCards(r.value)
|
||||
self:DrawCards(r.value, true)
|
||||
self:RenderHand(false)
|
||||
elseif r.effect == "heal" or r.effect == "healOnAttack" or r.effect == "healOnWin" then
|
||||
self.PlayerHp = self.PlayerHp + r.value
|
||||
@@ -151,7 +151,6 @@ if p.effect == "heal" then
|
||||
self.PlayerHp = math.min(self.PlayerHp + p.value, self.PlayerMaxHp)
|
||||
elseif p.effect == "damage" then
|
||||
self:DealDamageToTarget(p.value, false)
|
||||
self:ShowDmgPop(self.TargetIndex, p.value)
|
||||
elseif p.effect == "strength" then
|
||||
self.PlayerStr = self.PlayerStr + p.value
|
||||
elseif p.effect == "block" then
|
||||
|
||||
@@ -77,9 +77,14 @@ for i = 1, 3 do
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", o.name)
|
||||
self:SetText(base .. "/Desc", o.desc)
|
||||
local sc = self.Cards[o.starter]
|
||||
local sc = nil
|
||||
if o.starter ~= nil and o.starter ~= "" then
|
||||
sc = self.Cards[o.starter]
|
||||
end
|
||||
if sc ~= nil then
|
||||
self:SetText(base .. "/Starter", "대표 카드: " .. sc.name)
|
||||
else
|
||||
self:SetText(base .. "/Starter", "대표 카드: 계보 유지")
|
||||
end
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
@@ -111,6 +116,8 @@ if starter ~= "" then
|
||||
if sc ~= nil then
|
||||
self:Toast(tostring(tier) .. "차 전직: " .. self:JobLabel() .. "! 신규 카드 - " .. sc.name)
|
||||
end
|
||||
else
|
||||
self:Toast(tostring(tier) .. "차 전직: " .. self:JobLabel() .. "!")
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
|
||||
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", false)
|
||||
|
||||
@@ -1,296 +1,328 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const renderMethods = [
|
||||
method('BuffsLabel', `local parts = {}
|
||||
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
|
||||
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
|
||||
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
|
||||
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||
return table.concat(parts, " ")`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||
], 0, 'string'),
|
||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", m.name)
|
||||
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
||||
local intent = m.intents[m.intentIdx]
|
||||
local t = ""
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
local atk = intent.value + m.str
|
||||
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
||||
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
||||
t = "공격 " .. tostring(atk)
|
||||
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
||||
elseif intent.kind == "Debuff" then
|
||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||
elseif intent.kind == "AddCard" then
|
||||
t = "저주 카드 추가"
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Intent", t)
|
||||
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||
local shownTarget = self.TargetIndex
|
||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
||||
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
||||
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
||||
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||
elseif intent.kind == "Debuff" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||
elseif intent.kind == "AddCard" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||
else
|
||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||
end
|
||||
end
|
||||
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
||||
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
|
||||
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
||||
if self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "불가침" .. tostring(self.PlayerIntangible)
|
||||
end
|
||||
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
||||
end
|
||||
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
||||
end
|
||||
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
||||
local names = {}
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil then table.insert(names, pc.name) end
|
||||
end
|
||||
if pb ~= "" then pb = pb .. " · " end
|
||||
pb = pb .. table.concat(names, " ")
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||
self:RenderRun()`),
|
||||
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
||||
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey
|
||||
local pop = _EntityService:GetEntityByPath(base)
|
||||
if pop == nil then
|
||||
return
|
||||
end
|
||||
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
|
||||
local popSeq = self.DmgPopSeq
|
||||
self:SetText(base, "")
|
||||
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
||||
local shown = tostring(math.max(0, math.floor(amount)))
|
||||
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
||||
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
||||
end
|
||||
local digits = {}
|
||||
for i = 1, string.len(shown) do
|
||||
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
||||
end
|
||||
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
||||
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
||||
end
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
local digitPath = base .. "/Digit" .. tostring(i)
|
||||
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
||||
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||
if digits[i] ~= nil then
|
||||
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
if digitEntity.UITransformComponent ~= nil then
|
||||
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
||||
end
|
||||
self:SetEntityEnabled(digitPath, true)
|
||||
else
|
||||
self:SetEntityEnabled(digitPath, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
local popPos = nil
|
||||
local m = self.Monsters[slot]
|
||||
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
||||
popPos = _UILogic:ScreenToUIPosition(screen)
|
||||
else
|
||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||
local sp = slotEntity.UITransformComponent.anchoredPosition
|
||||
popPos = Vector2(sp.x, sp.y + 76)
|
||||
end
|
||||
end
|
||||
if pop ~= nil and pop.UITransformComponent ~= nil then
|
||||
if popPos ~= nil then
|
||||
pop.UITransformComponent.anchoredPosition = popPos
|
||||
else
|
||||
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
for i = 1, 6 do
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
local p = _EntityService:GetEntityByPath(base)
|
||||
if p ~= nil and p.UITransformComponent ~= nil then
|
||||
local cur = p.UITransformComponent.anchoredPosition
|
||||
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
||||
end
|
||||
end, 0.045 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if self.DmgPopSeq ~= popSeq then
|
||||
return
|
||||
end
|
||||
self:SetEntityEnabled(base, false)
|
||||
end, 0.48)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
||||
if amount > 0 then
|
||||
self:SetText(base, "-" .. string.format("%d", amount))
|
||||
else
|
||||
self:SetText(base, "막음")
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent == nil then
|
||||
return
|
||||
end
|
||||
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
||||
end
|
||||
end, 0.5)`),
|
||||
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
||||
end
|
||||
local tr = lp.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
||||
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
end, 0.15)`),
|
||||
method('MonsterLunge', `local m = self.Monsters[idx]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local e = m.entity
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
m.motionBusy = false
|
||||
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
||||
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local e = m.entity
|
||||
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
||||
end
|
||||
end, 0.5)
|
||||
else
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
local seq = { 0.12, -0.12, 0 }
|
||||
for i = 1, #seq do
|
||||
local dx = seq[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
||||
end
|
||||
if i == #seq then
|
||||
m.motionBusy = false
|
||||
end
|
||||
end, 0.06 * i)
|
||||
end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
||||
if e == nil or e.UITransformComponent == nil then
|
||||
return
|
||||
end
|
||||
local ratio = 0
|
||||
if maxHp > 0 then ratio = hp / maxHp end
|
||||
if ratio < 0 then ratio = 0 end
|
||||
local w = width * ratio
|
||||
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
||||
]),
|
||||
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
|
||||
self.TargetIndex = slot
|
||||
self:RenderCombat()
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
|
||||
if self.AscensionLevel > 0 then
|
||||
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
export const renderMethods = [
|
||||
method('BuffsLabel', `local parts = {}
|
||||
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
|
||||
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
|
||||
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
|
||||
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||
return table.concat(parts, " ")`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||
], 0, 'string'),
|
||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||
local base = "/ui/RunUIGroup/CombatHud/MonsterStatus" .. tostring(i)
|
||||
local m = self.Monsters[i]
|
||||
if m ~= nil and m.alive == true then
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", m.name)
|
||||
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
|
||||
local intent = m.intents[m.intentIdx]
|
||||
local t = ""
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
local atk = intent.value + m.str
|
||||
if m.weak > 0 then atk = math.floor(atk * 0.75) end
|
||||
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
|
||||
t = "공격 " .. tostring(atk)
|
||||
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
|
||||
elseif intent.kind == "Debuff" then
|
||||
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
|
||||
else t = "취약 " .. tostring(intent.value) .. " 부여" end
|
||||
elseif intent.kind == "AddCard" then
|
||||
t = "저주 카드 추가"
|
||||
end
|
||||
end
|
||||
self:SetText(base .. "/Intent", t)
|
||||
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
|
||||
local shownTarget = self.TargetIndex
|
||||
if dragActive == true then shownTarget = self.DragTargetIndex end
|
||||
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
|
||||
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
|
||||
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
|
||||
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
|
||||
elseif intent.kind == "Debuff" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
|
||||
elseif intent.kind == "AddCard" then
|
||||
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
|
||||
else
|
||||
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
|
||||
end
|
||||
end
|
||||
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
|
||||
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
|
||||
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
|
||||
else
|
||||
self:SetEntityEnabled(base, false)
|
||||
end
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
|
||||
self:SetHpBar("/ui/RunUIGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
|
||||
self:SetEntityEnabled("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
|
||||
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
|
||||
if self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "불가침" .. tostring(self.PlayerIntangible)
|
||||
end
|
||||
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
|
||||
end
|
||||
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "가시" .. tostring(self.PlayerThorns)
|
||||
end
|
||||
if self.ComboCount ~= nil and self.ComboCount > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "콤보 " .. tostring(self.ComboCount) .. "/" .. tostring(self:GetComboMax())
|
||||
end
|
||||
if self.HolyChargeCount ~= nil and self.HolyChargeCount > 0 then
|
||||
if pb ~= "" then pb = pb .. " " end
|
||||
pb = pb .. "홀리 차지 " .. tostring(self.HolyChargeCount) .. "/" .. tostring(self:GetHolyChargeMax())
|
||||
end
|
||||
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
|
||||
local names = {}
|
||||
for i = 1, #self.PlayerPowers do
|
||||
local pc = self.Cards[self.PlayerPowers[i]]
|
||||
if pc ~= nil then table.insert(names, pc.name) end
|
||||
end
|
||||
if pb ~= "" then pb = pb .. " · " end
|
||||
pb = pb .. table.concat(names, " ")
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Buffs", pb)
|
||||
self:RenderRun()`),
|
||||
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
|
||||
if self.DmgPopSlotQueue == nil then
|
||||
self.DmgPopSlotQueue = {}
|
||||
end
|
||||
local popIndex = (self.DmgPopSlotQueue[slotKey] or 0) + 1
|
||||
if popIndex > 5 then
|
||||
popIndex = 1
|
||||
end
|
||||
self.DmgPopSlotQueue[slotKey] = popIndex
|
||||
local base = "/ui/RunUIGroup/CombatHud/DmgPop" .. slotKey .. "_" .. tostring(popIndex)
|
||||
local pop = _EntityService:GetEntityByPath(base)
|
||||
if pop == nil then
|
||||
return
|
||||
end
|
||||
local startDelay = 0.2 * (popIndex - 1)
|
||||
local function showNow()
|
||||
self:SetEntityEnabled(base, false)
|
||||
self:SetText(base, "")
|
||||
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
|
||||
local shown = tostring(math.max(0, math.floor(amount)))
|
||||
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
|
||||
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
|
||||
end
|
||||
local digits = {}
|
||||
local digitPathsToEnable = {}
|
||||
for i = 1, string.len(shown) do
|
||||
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
|
||||
end
|
||||
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
|
||||
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
|
||||
end
|
||||
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
local digitPath = base .. "/Digit" .. tostring(i)
|
||||
local digitEntity = _EntityService:GetEntityByPath(digitPath)
|
||||
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||
if digits[i] ~= nil then
|
||||
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
|
||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
|
||||
if digitEntity.UITransformComponent ~= nil then
|
||||
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
|
||||
end
|
||||
table.insert(digitPathsToEnable, digitPath)
|
||||
else
|
||||
self:SetEntityEnabled(digitPath, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
local popPos = nil
|
||||
local m = self.Monsters[slot]
|
||||
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
|
||||
local wp = m.entity.TransformComponent.WorldPosition
|
||||
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
|
||||
popPos = _UILogic:ScreenToUIPosition(screen)
|
||||
else
|
||||
local slotEntity = _EntityService:GetEntityByPath("/ui/RunUIGroup/CombatHud/MonsterStatus" .. slotKey)
|
||||
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
|
||||
local sp = slotEntity.UITransformComponent.anchoredPosition
|
||||
popPos = Vector2(sp.x, sp.y + 76)
|
||||
end
|
||||
end
|
||||
if pop.UITransformComponent ~= nil then
|
||||
if popPos ~= nil then
|
||||
pop.UITransformComponent.anchoredPosition = popPos
|
||||
else
|
||||
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
for i = 1, #digitPathsToEnable do
|
||||
self:SetEntityEnabled(digitPathsToEnable[i], true)
|
||||
end
|
||||
for i = 1, 6 do
|
||||
_TimerService:SetTimerOnce(function()
|
||||
local p = _EntityService:GetEntityByPath(base)
|
||||
if p ~= nil and p.UITransformComponent ~= nil then
|
||||
local cur = p.UITransformComponent.anchoredPosition
|
||||
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
|
||||
end
|
||||
local alpha = 1 - (i / 6)
|
||||
if alpha < 0 then alpha = 0 end
|
||||
for di = 1, ${DAMAGE_POP_MAX_DIGITS} do
|
||||
local digitEntity = _EntityService:GetEntityByPath(base .. "/Digit" .. tostring(di))
|
||||
if digitEntity ~= nil and digitEntity.Enable == true and digitEntity.SpriteGUIRendererComponent ~= nil then
|
||||
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, alpha)
|
||||
end
|
||||
end
|
||||
end, 0.05 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
self:SetEntityEnabled(base, false)
|
||||
end, 0.3)
|
||||
end
|
||||
if startDelay > 0 then
|
||||
_TimerService:SetTimerOnce(function()
|
||||
showNow()
|
||||
end, startDelay)
|
||||
else
|
||||
showNow()
|
||||
end`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
|
||||
]),
|
||||
method('ShowPlayerDmgPop', `local base = "/ui/RunUIGroup/CombatHud/PlayerPanel/DmgPop"
|
||||
if amount > 0 then
|
||||
self:SetText(base, "-" .. string.format("%d", amount))
|
||||
else
|
||||
self:SetText(base, "막음")
|
||||
end
|
||||
self:SetEntityEnabled(base, true)
|
||||
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent == nil then
|
||||
return
|
||||
end
|
||||
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
|
||||
end
|
||||
end, 0.5)`),
|
||||
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
|
||||
if lp == nil then
|
||||
return
|
||||
end
|
||||
if lp.StateComponent ~= nil then
|
||||
pcall(function() lp.StateComponent:ChangeState("HIT") end)
|
||||
end
|
||||
local tr = lp.TransformComponent
|
||||
if tr == nil then
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
|
||||
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
end, 0.15)`),
|
||||
method('MonsterLunge', `local m = self.Monsters[idx]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local e = m.entity
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
|
||||
end
|
||||
m.motionBusy = false
|
||||
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
|
||||
method('MonsterHitMotion', `local m = self.Monsters[slot]
|
||||
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
|
||||
return
|
||||
end
|
||||
local e = m.entity
|
||||
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.hitClip
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
|
||||
e.SpriteRendererComponent.SpriteRUID = m.standClip
|
||||
end
|
||||
end, 0.5)
|
||||
else
|
||||
if m.motionBusy == true then
|
||||
return
|
||||
end
|
||||
m.motionBusy = true
|
||||
local tr = e.TransformComponent
|
||||
if tr == nil then
|
||||
m.motionBusy = false
|
||||
return
|
||||
end
|
||||
local p = tr.Position
|
||||
local seq = { 0.12, -0.12, 0 }
|
||||
for i = 1, #seq do
|
||||
local dx = seq[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if isvalid(e) and e.TransformComponent ~= nil then
|
||||
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
|
||||
end
|
||||
if i == #seq then
|
||||
m.motionBusy = false
|
||||
end
|
||||
end, 0.06 * i)
|
||||
end
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
|
||||
if e == nil or e.UITransformComponent == nil then
|
||||
return
|
||||
end
|
||||
local ratio = 0
|
||||
if maxHp > 0 then ratio = hp / maxHp end
|
||||
if ratio < 0 then ratio = 0 end
|
||||
local w = width * ratio
|
||||
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
|
||||
]),
|
||||
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
|
||||
self.TargetIndex = slot
|
||||
self:RenderCombat()
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
|
||||
if self.AscensionLevel > 0 then
|
||||
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
|
||||
end
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
|
||||
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, ACT_DIFFICULTY_MULTIPLIERS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, JOB_META, CLASS_GROUPS, CLASS_LINEAGES, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaClassGroupsTable, luaClassLineagesTable, luaJobMetaTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
@@ -95,13 +95,18 @@ self.PlayerVuln = 0
|
||||
self.PlayerIntangible = 0
|
||||
self.BonusRewardScreens = 0
|
||||
self.ActiveKillReward = 0
|
||||
self.ActiveKillMaxHpGain = 0
|
||||
self.PlayerPowers = {}
|
||||
self.FightAttackCount = 0
|
||||
self.TurnAttackCardsPlayed = 0
|
||||
self.TurnDiscardedCards = 0
|
||||
self.TurnCardsPlayedThisTurn = 0
|
||||
self.ComboCount = 0
|
||||
self.HolyChargeCount = 0
|
||||
self.DamagePowerStrengthUsed = false
|
||||
self.DamageDealtThisTurn = 0
|
||||
self.DmgPopSeq = 0
|
||||
self.DmgPopSlotQueue = {}
|
||||
self.FirstHpLossDone = false
|
||||
self.ClayBlockNext = 0
|
||||
self.DiscardSelectRemaining = 0
|
||||
@@ -118,6 +123,7 @@ self.TurnAttackMultiplier = 1
|
||||
self.NextTurnSelectPrompt = ""
|
||||
self.NextTurnSelectCopies = 0
|
||||
self.NextTurnAddCards = {}
|
||||
self.ZeroCostCardIdsThisTurn = {}
|
||||
self.CombatOver = false
|
||||
self.DiscardPile = {}
|
||||
self.ExhaustPile = {}
|
||||
@@ -211,7 +217,8 @@ end
|
||||
if #chosen == 0 then takeFrom(g, 1) end
|
||||
if #chosen == 0 then takeFrom("combat", 1) end
|
||||
table.sort(chosen, function(a, b) return a.x < b.x end)
|
||||
local mult = 1 + (self.Floor - 1) * 0.45
|
||||
local actMultipliers = { ${ACT_DIFFICULTY_MULTIPLIERS.join(', ')} }
|
||||
local mult = actMultipliers[self.Floor] or actMultipliers[#actMultipliers]
|
||||
if g == "elite" or g == "boss" then
|
||||
mult = mult + self:AscEliteBonus()
|
||||
end
|
||||
|
||||
@@ -49,9 +49,9 @@ end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], N
|
||||
method('ShowMainMenu', `self.SelectedClass = ""
|
||||
self:RenderAscension()
|
||||
self:ShowState("menu")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/Title", "메이플 덱 어드벤처")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/Subtitle", "캐릭터를 고르고 덱을 만들어 모험을 시작하세요")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/NewGameButton", "새 게임")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/Title", "Maple Deck Adventure")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/Subtitle", "Choose your character and begin your run")
|
||||
self:SetText("/ui/DefaultGroup/MainMenu/NewGameButton", "New Game")
|
||||
self:BindMenuButtons()`),
|
||||
method('BindMenuButtons', `local buttonEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/MainMenu/NewGameButton")
|
||||
if buttonEntity ~= nil and (buttonEntity.ButtonComponent ~= nil or buttonEntity:AddComponent("ButtonComponent") ~= nil) then
|
||||
@@ -136,22 +136,28 @@ self:BindLobbyButtons()
|
||||
self:BindMenuButtons()
|
||||
self:GoLobbyMap()`),
|
||||
method('RenderSoulLabel', `local soulPoints = self.SoulPoints or 0
|
||||
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "영혼 " .. string.format("%d", soulPoints))
|
||||
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "영혼 " .. string.format("%d", soulPoints))`),
|
||||
self:SetText("/ui/LobbyUIGroup/LobbyHud/SoulLabel", "Soul " .. string.format("%d", soulPoints))
|
||||
self:SetText("/ui/LobbyUIGroup/SoulShopHud/Souls", "Soul " .. string.format("%d", soulPoints))`),
|
||||
method('BindLobbyButtons', `if self.LobbyBound == true then
|
||||
return
|
||||
end
|
||||
self.LobbyBound = true
|
||||
local function bindClick(path, handler)
|
||||
local entity = _EntityService:GetEntityByPath(path)
|
||||
if entity ~= nil and (entity.ButtonComponent ~= nil or entity:AddComponent("ButtonComponent") ~= nil) then
|
||||
entity:ConnectEvent(ButtonClickEvent, handler)
|
||||
end
|
||||
local ascMinus = _EntityService:GetEntityByPath("/ui/LobbyUIGroup/LobbyHud/AscMinus")
|
||||
if ascMinus ~= nil and (ascMinus.ButtonComponent ~= nil or ascMinus:AddComponent("ButtonComponent") ~= nil) then
|
||||
ascMinus:ConnectEvent(ButtonClickEvent, function() self:AdjustAscension(-1) end)
|
||||
end
|
||||
bindClick("/ui/LobbyUIGroup/LobbyHud/AscMinus", function() self:AdjustAscension(-1) end)
|
||||
bindClick("/ui/LobbyUIGroup/LobbyHud/AscPlus", function() self:AdjustAscension(1) end)
|
||||
bindClick("/ui/LobbyUIGroup/BoardHud/Close", function() self:CloseBoard() end)
|
||||
bindClick("/ui/LobbyUIGroup/SoulShopHud/Close", function() self:CloseSoulShop() end)`),
|
||||
local ascPlus = _EntityService:GetEntityByPath("/ui/LobbyUIGroup/LobbyHud/AscPlus")
|
||||
if ascPlus ~= nil and (ascPlus.ButtonComponent ~= nil or ascPlus:AddComponent("ButtonComponent") ~= nil) then
|
||||
ascPlus:ConnectEvent(ButtonClickEvent, function() self:AdjustAscension(1) end)
|
||||
end
|
||||
local boardClose = _EntityService:GetEntityByPath("/ui/LobbyUIGroup/BoardHud/Close")
|
||||
if boardClose ~= nil and (boardClose.ButtonComponent ~= nil or boardClose:AddComponent("ButtonComponent") ~= nil) then
|
||||
boardClose:ConnectEvent(ButtonClickEvent, function() self:CloseBoard() end)
|
||||
end
|
||||
local soulClose = _EntityService:GetEntityByPath("/ui/LobbyUIGroup/SoulShopHud/Close")
|
||||
if soulClose ~= nil and (soulClose.ButtonComponent ~= nil or soulClose:AddComponent("ButtonComponent") ~= nil) then
|
||||
soulClose:ConnectEvent(ButtonClickEvent, function() self:CloseSoulShop() end)
|
||||
end`),
|
||||
method('ShowCodex', `self.CodexMode = true
|
||||
self.ClassDeckMode = true
|
||||
local close = _EntityService:GetEntityByPath("/ui/DeckUIGroup/DeckAllHud/Close")
|
||||
|
||||
@@ -41,24 +41,36 @@ if lp ~= nil then
|
||||
end
|
||||
self:RenderSoulLabel()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "n" }]),
|
||||
method('BuySoulUnlock', `local d = nil
|
||||
if self.SoulShopDef ~= nil then d = self.SoulShopDef[slot] end
|
||||
if d == nil then return end
|
||||
if self.SoulUnlocks ~= nil and self.SoulUnlocks[d.key] == true then
|
||||
self:Toast("이미 보유 중입니다")
|
||||
if self.SoulShopDef ~= nil then
|
||||
d = self.SoulShopDef[slot]
|
||||
end
|
||||
if d == nil then
|
||||
return
|
||||
end
|
||||
if (self.SoulPoints or 0) < d.cost then
|
||||
self:Toast("영혼이 부족합니다")
|
||||
local unlockKey = d.key
|
||||
local unlockCost = d.cost
|
||||
local unlockName = d.name or "Unlock"
|
||||
if unlockKey == nil or unlockCost == nil then
|
||||
return
|
||||
end
|
||||
self.SoulPoints = self.SoulPoints - d.cost
|
||||
if self.SoulUnlocks == nil then self.SoulUnlocks = {} end
|
||||
self.SoulUnlocks[d.key] = true
|
||||
if self.SoulUnlocks ~= nil and self.SoulUnlocks[unlockKey] == true then
|
||||
self:Toast("Already owned")
|
||||
return
|
||||
end
|
||||
if (self.SoulPoints or 0) < unlockCost then
|
||||
self:Toast("Not enough soul")
|
||||
return
|
||||
end
|
||||
self.SoulPoints = self.SoulPoints - unlockCost
|
||||
if self.SoulUnlocks == nil then
|
||||
self.SoulUnlocks = {}
|
||||
end
|
||||
self.SoulUnlocks[unlockKey] = true
|
||||
local lp = _UserService.LocalPlayer
|
||||
if lp ~= nil then
|
||||
self:SaveSouls(self.SoulPoints, self:SerializeUnlocks(), lp.PlayerComponent.UserId)
|
||||
end
|
||||
self:Toast(d.name .. " 해금!")
|
||||
self:Toast(unlockName .. " unlocked")
|
||||
self:RenderSoulLabel()
|
||||
self:RenderSoulShop()`, [{ Type: "number", DefaultValue: null, SyncDirection: 0, Attributes: [], Name: "slot" }]),
|
||||
method('RenderSoulShop', `local defs = self.SoulShopDef or {}
|
||||
@@ -68,16 +80,20 @@ for i = 1, 4 do
|
||||
if d == nil then
|
||||
self:SetEntityEnabled(base, false)
|
||||
else
|
||||
local itemName = d.name or "Unlock"
|
||||
local itemDesc = d.desc or ""
|
||||
local itemKey = d.key
|
||||
local itemCost = d.cost or 0
|
||||
self:SetEntityEnabled(base, true)
|
||||
self:SetText(base .. "/Name", d.name)
|
||||
self:SetText(base .. "/Desc", d.desc)
|
||||
local owned = self.SoulUnlocks ~= nil and self.SoulUnlocks[d.key] == true
|
||||
self:SetText(base .. "/Name", itemName)
|
||||
self:SetText(base .. "/Desc", itemDesc)
|
||||
local owned = itemKey ~= nil and self.SoulUnlocks ~= nil and self.SoulUnlocks[itemKey] == true
|
||||
if owned then
|
||||
self:SetText(base .. "/Status", "보유 중")
|
||||
elseif (self.SoulPoints or 0) >= d.cost then
|
||||
self:SetText(base .. "/Status", tostring(d.cost) .. " 영혼 · 구매")
|
||||
self:SetText(base .. "/Status", "Owned")
|
||||
elseif (self.SoulPoints or 0) >= itemCost then
|
||||
self:SetText(base .. "/Status", tostring(itemCost) .. " soul · buy")
|
||||
else
|
||||
self:SetText(base .. "/Status", tostring(d.cost) .. " 영혼 · 부족")
|
||||
self:SetText(base .. "/Status", tostring(itemCost) .. " soul · low")
|
||||
end
|
||||
end
|
||||
end`),
|
||||
|
||||
@@ -36,6 +36,7 @@ function writeCodeblocks() {
|
||||
prop('number', 'TweenEventId', '0'),
|
||||
prop('number', 'CardHoverTweenId', '0'),
|
||||
prop('number', 'DmgPopSeq', '0'),
|
||||
prop('any', 'DmgPopSlotQueue'),
|
||||
prop('any', 'EndTurnHandler'),
|
||||
prop('any', 'NewGameHandler'),
|
||||
prop('any', 'WarriorSelectHandler'),
|
||||
@@ -142,10 +143,14 @@ function writeCodeblocks() {
|
||||
prop('number', 'PoisonApplicationsThisCombat', '0'),
|
||||
prop('number', 'EnemyStrengthLossThisTurn', '0'),
|
||||
prop('number', 'ActiveKillReward', '0'),
|
||||
prop('number', 'ActiveKillMaxHpGain', '0'),
|
||||
prop('number', 'BonusRewardScreens', '0'),
|
||||
prop('number', 'FightAttackCount', '0'),
|
||||
prop('number', 'TurnAttackCardsPlayed', '0'),
|
||||
prop('number', 'TurnCardsPlayedThisTurn', '0'),
|
||||
prop('number', 'ComboCount', '0'),
|
||||
prop('number', 'HolyChargeCount', '0'),
|
||||
prop('boolean', 'DamagePowerStrengthUsed', 'false'),
|
||||
prop('number', 'DamageDealtThisTurn', '0'),
|
||||
prop('number', 'TurnDiscardedCards', '0'),
|
||||
prop('boolean', 'FirstHpLossDone', 'false'),
|
||||
@@ -173,6 +178,7 @@ function writeCodeblocks() {
|
||||
prop('boolean', 'NextSkillCostZero', 'false'),
|
||||
prop('number', 'NextSkillRepeatCount', '0'),
|
||||
prop('any', 'NextTurnAddCards'),
|
||||
prop('any', 'ZeroCostCardIdsThisTurn'),
|
||||
], [
|
||||
...bootMethods,
|
||||
...screensMethods,
|
||||
|
||||
@@ -54,7 +54,8 @@ const REST_HEAL = 30;
|
||||
const RELIC_PRICE = 60;
|
||||
const ACT_COUNT = 5;
|
||||
const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05'];
|
||||
const ACT_DIFFICULTY_MULTIPLIERS = [1, 1.075, 1.15, 1.3, 1.45];
|
||||
const LOBBY_MAP = 'lobby';
|
||||
const LOBBY_SPAWN = 'Vector3(-5, 0.03, 0)'; // 정찰: map01 지면 좌측
|
||||
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, ACT_DIFFICULTY_MULTIPLIERS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
|
||||
@@ -19,9 +19,18 @@ for (const cls of Object.keys(CLASSES)) {
|
||||
// 전직 옵션
|
||||
const JOBS = {
|
||||
warrior: [
|
||||
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack', tier: 2, parent: 'warrior' },
|
||||
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge', tier: 2, parent: 'warrior' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce', tier: 2, parent: 'warrior' },
|
||||
{ id: 'fighter', name: '파이터', desc: '콤보와 다단 공격 특화\n공격으로 콤보를 쌓고\n추가타로 압박', starter: 'ComboAttack', tier: 2, parent: 'warrior' },
|
||||
{ id: 'page', name: '페이지', desc: '홀리 포스와 방어 특화\n홀리 차지를 쌓아\n공격과 생존 강화', starter: 'HolyCharge', tier: 2, parent: 'warrior' },
|
||||
{ id: 'spearman', name: '스피어맨', desc: '광역·장기전 계열\n대화재 · 소용돌이\n불의 심장', starter: 'Conflagration', tier: 2, parent: 'warrior' },
|
||||
],
|
||||
fighter: [
|
||||
{ id: 'crusader', name: '크루세이더', desc: '파이터의 3차 전직\n콤보 상한과 연계 피해 강화\n파이터 카드 계승', starter: 'ComboSynergy', tier: 3, parent: 'fighter' },
|
||||
],
|
||||
page: [
|
||||
{ id: 'knight', name: '나이트', desc: '페이지의 3차 전직\n홀리 차지를 공격·방어·회복으로 전환\n페이지 카드 계승', starter: 'DivineCharge', tier: 3, parent: 'page' },
|
||||
],
|
||||
spearman: [
|
||||
{ id: 'berserker', name: '버서커', desc: '스피어맨의 3차 전직\n아이언클래드 장기전 풀 계승\n전사 카드 사용', starter: '', tier: 3, parent: 'spearman' },
|
||||
],
|
||||
magician: [
|
||||
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow', tier: 2, parent: 'magician' },
|
||||
@@ -29,24 +38,24 @@ const JOBS = {
|
||||
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal', tier: 2, parent: 'magician' },
|
||||
],
|
||||
rogue: [
|
||||
{ id: 'assassin', name: 'Assassin', desc: '표창 중심 전직\n단일 화력과 독 압박\n빠른 마무리', starter: 'DeadlyPoison', tier: 2, parent: 'rogue' },
|
||||
{ id: 'thief', name: 'Thief', desc: '단검 중심 전직\n드로우와 운영 강화\n빠른 연계', starter: 'Acrobatics', tier: 2, parent: 'rogue' },
|
||||
{ id: 'assassin', name: 'Assassin', desc: '표창 중심 전직\n표창 생성과 연속 공격\n빠른 마무리', starter: 'JavelinAcceleration', tier: 2, parent: 'rogue' },
|
||||
{ id: 'thief', name: 'Thief', desc: '단검 중심 전직\n드로우와 운영 강화\n빠른 연계', starter: 'DaggerAcceleration', tier: 2, parent: 'rogue' },
|
||||
],
|
||||
assassin: [
|
||||
{ id: 'hermit', name: 'Hermit', desc: 'Assassin의 3차 전직\n표창과 독 운영 심화\n누적 압박 강화', starter: 'NoxiousFumes', tier: 3, parent: 'assassin' },
|
||||
{ id: 'hermit', name: 'Hermit', desc: 'Assassin의 3차 전직\n표창 생성과 강화 심화\n연속 공격 완성', starter: 'SpiritJavelin', tier: 3, parent: 'assassin' },
|
||||
],
|
||||
thief: [
|
||||
{ id: 'thiefmaster', name: 'Thief Master', desc: 'Thief의 3차 전직\n단검 운영 심화\n드로우와 템포 강화', starter: 'ToolsOfTheTrade', tier: 3, parent: 'thief' },
|
||||
{ id: 'thiefmaster', name: 'Thief Master', desc: 'Thief의 3차 전직\n단검·교활·중독 심화\n연계 운영 완성', starter: 'Venom', tier: 3, parent: 'thief' },
|
||||
],
|
||||
};
|
||||
for (const [cls, jobs] of Object.entries(JOBS)) {
|
||||
for (const j of jobs) {
|
||||
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
|
||||
if (j.starter && !CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
|
||||
}
|
||||
}
|
||||
|
||||
const CLASS_GROUPS = {
|
||||
warrior: ['warrior', 'fighter', 'page', 'spearman'],
|
||||
warrior: ['warrior', 'fighter', 'crusader', 'page', 'knight', 'spearman', 'berserker'],
|
||||
magician: ['magician', 'firepoison', 'icelightning', 'cleric'],
|
||||
rogue: ['rogue', 'assassin', 'hermit', 'thief', 'thiefmaster'],
|
||||
};
|
||||
@@ -54,8 +63,11 @@ const CLASS_GROUPS = {
|
||||
const CLASS_LINEAGES = {
|
||||
warrior: ['warrior'],
|
||||
fighter: ['warrior', 'fighter'],
|
||||
crusader: ['warrior', 'fighter', 'crusader'],
|
||||
page: ['warrior', 'page'],
|
||||
knight: ['warrior', 'page', 'knight'],
|
||||
spearman: ['warrior', 'spearman'],
|
||||
berserker: ['warrior', 'spearman', 'berserker'],
|
||||
magician: ['magician'],
|
||||
firepoison: ['magician', 'firepoison'],
|
||||
icelightning: ['magician', 'icelightning'],
|
||||
@@ -212,6 +224,12 @@ function luaCardsTable(cards) {
|
||||
const lines = Object.entries(cards).map(([id, c]) => {
|
||||
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
|
||||
if (c.damage != null) fields.push(`damage = ${c.damage}`);
|
||||
if (c.damageFromCurrentBlock != null) fields.push(`damageFromCurrentBlock = ${c.damageFromCurrentBlock}`);
|
||||
if (c.damagePerCombo != null) fields.push(`damagePerCombo = ${c.damagePerCombo}`);
|
||||
if (c.damagePerHolyCharge != null) fields.push(`damagePerHolyCharge = ${c.damagePerHolyCharge}`);
|
||||
if (c.attackDamagePerCombo != null) fields.push(`attackDamagePerCombo = ${c.attackDamagePerCombo}`);
|
||||
if (c.damageNameMatch != null) fields.push(`damageNameMatch = ${luaStr(c.damageNameMatch)}`);
|
||||
if (c.damagePerOwnedNameMatch != null) fields.push(`damagePerOwnedNameMatch = ${c.damagePerOwnedNameMatch}`);
|
||||
if (c.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
|
||||
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
||||
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
||||
@@ -220,8 +238,10 @@ function luaCardsTable(cards) {
|
||||
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
|
||||
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
|
||||
if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
|
||||
if (c.attackPlayedDamage != null) fields.push(`attackPlayedDamage = ${c.attackPlayedDamage}`);
|
||||
if (c.firstCardDamageBonus != null) fields.push(`firstCardDamageBonus = ${c.firstCardDamageBonus}`);
|
||||
if (c.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`);
|
||||
if (c.maxHpOnKill != null) fields.push(`maxHpOnKill = ${c.maxHpOnKill}`);
|
||||
if (c.intangible != null) fields.push(`intangible = ${c.intangible}`);
|
||||
if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`);
|
||||
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
|
||||
@@ -235,6 +255,16 @@ function luaCardsTable(cards) {
|
||||
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
||||
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
||||
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`);
|
||||
if (c.comboOnAttack != null) fields.push(`comboOnAttack = ${c.comboOnAttack}`);
|
||||
if (c.comboMax != null) fields.push(`comboMax = ${c.comboMax}`);
|
||||
if (c.attackWeak != null) fields.push(`attackWeak = ${c.attackWeak}`);
|
||||
if (c.holyChargeOnHolyForce != null) fields.push(`holyChargeOnHolyForce = ${c.holyChargeOnHolyForce}`);
|
||||
if (c.holyChargeMax != null) fields.push(`holyChargeMax = ${c.holyChargeMax}`);
|
||||
if (c.damageTakenReduction != null) fields.push(`damageTakenReduction = ${c.damageTakenReduction}`);
|
||||
if (c.blockOnDamaged != null) fields.push(`blockOnDamaged = ${c.blockOnDamaged}`);
|
||||
if (c.strengthOnDamagedOnce != null) fields.push(`strengthOnDamagedOnce = ${c.strengthOnDamagedOnce}`);
|
||||
if (c.drawOnExhaust != null) fields.push(`drawOnExhaust = ${c.drawOnExhaust}`);
|
||||
if (c.drawNameMatchAutoPlay != null) fields.push(`drawNameMatchAutoPlay = ${luaStr(c.drawNameMatchAutoPlay)}`);
|
||||
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)}`);
|
||||
@@ -250,8 +280,26 @@ function luaCardsTable(cards) {
|
||||
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
||||
if (c.drawDamage != null) fields.push(`drawDamage = ${c.drawDamage}`);
|
||||
if (c.drawPoison != null) fields.push(`drawPoison = ${c.drawPoison}`);
|
||||
if (c.exhaustHandNonAttack === true) fields.push('exhaustHandNonAttack = true');
|
||||
if (c.exhaustHandAll === true) fields.push('exhaustHandAll = true');
|
||||
if (c.drawPerExhausted != null) fields.push(`drawPerExhausted = ${c.drawPerExhausted}`);
|
||||
if (c.blockPerExhaustedCard != null) fields.push(`blockPerExhaustedCard = ${c.blockPerExhaustedCard}`);
|
||||
if (c.addRandomCardCount != null) fields.push(`addRandomCardCount = ${c.addRandomCardCount}`);
|
||||
if (c.addRandomCardPerExhausted != null) fields.push(`addRandomCardPerExhausted = ${c.addRandomCardPerExhausted}`);
|
||||
if (c.addRandomCardKind != null) fields.push(`addRandomCardKind = ${luaStr(c.addRandomCardKind)}`);
|
||||
if (c.addRandomCardSameClass === true) fields.push('addRandomCardSameClass = true');
|
||||
if (c.addedCardsCostZeroThisTurn === true) fields.push('addedCardsCostZeroThisTurn = true');
|
||||
if (c.playTopDrawPileCount != null) fields.push(`playTopDrawPileCount = ${c.playTopDrawPileCount}`);
|
||||
if (c.playTopDrawPileCountPerEnergy != null) fields.push(`playTopDrawPileCountPerEnergy = ${c.playTopDrawPileCountPerEnergy}`);
|
||||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||||
if (c.healPerHolyCharge != null) fields.push(`healPerHolyCharge = ${c.healPerHolyCharge}`);
|
||||
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
||||
if (c.comboGain != null) fields.push(`comboGain = ${c.comboGain}`);
|
||||
if (c.removePlayerDebuffs === true) fields.push('removePlayerDebuffs = true');
|
||||
if (c.holyForce === true) fields.push('holyForce = true');
|
||||
if (c.holyChargeGain != null) fields.push(`holyChargeGain = ${c.holyChargeGain}`);
|
||||
if (c.blockPerHolyCharge != null) fields.push(`blockPerHolyCharge = ${c.blockPerHolyCharge}`);
|
||||
if (c.holyChargeSpendAll === true) fields.push('holyChargeSpendAll = true');
|
||||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||||
if (c.discard != null) fields.push(`discard = ${c.discard}`);
|
||||
if (c.discardAll === true) fields.push('discardAll = true');
|
||||
|
||||
@@ -17,6 +17,10 @@ const POWER_FIELDS = [
|
||||
'shivDamageBonus', 'firstShivDamageBonus', 'shivRetain', 'shivAoe',
|
||||
'attackPoison', 'drawDamage', 'drawPoison', 'attackDamageVsWeakMultiplier',
|
||||
'cardPlayedBlock', 'cardPlayedDamage', 'cardPlayedRandomDamage',
|
||||
'comboOnAttack', 'comboMax', 'attackDamagePerCombo', 'attackPlayedDamage', 'attackWeak',
|
||||
'holyChargeOnHolyForce', 'holyChargeMax', 'damageTakenReduction',
|
||||
'blockOnDamaged', 'strengthOnDamagedOnce',
|
||||
'drawOnExhaust', 'drawNameMatchAutoPlay',
|
||||
'extraPoisonTicks', 'poisonApplicationBurstEvery', 'poisonApplicationBurstDamage',
|
||||
'skillSlyOnPlay', 'endTurnDexLoss',
|
||||
];
|
||||
@@ -28,7 +32,7 @@ for (const [id, c] of Object.entries(cards)) {
|
||||
issues.push(`${id}(${c.name}): 미지원 kind="${c.kind}"`);
|
||||
continue;
|
||||
}
|
||||
if (c.kind === 'Attack' && c.damage == null && c.xDamagePerEnergy == null) {
|
||||
if (c.kind === 'Attack' && c.damage == null && c.xDamagePerEnergy == null && c.damageFromCurrentBlock == null) {
|
||||
issues.push(`${id}(${c.name}): kind=Attack인데 damage 없음 → 몬스터 드롭 라우팅 불가(방어/유틸이면 kind=Skill)`);
|
||||
}
|
||||
if (c.kind === 'Power' && !POWER_FIELDS.some((f) => c[f] != null)) {
|
||||
|
||||
82
tools/verify/rogue-card-names.mjs
Normal file
82
tools/verify/rogue-card-names.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const cards = JSON.parse(readFileSync('data/cards.json', 'utf8')).cards;
|
||||
const rogueClasses = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
|
||||
|
||||
const mapleSkillCards = {
|
||||
DoubleStab: '더블 스탭',
|
||||
LuckySeven: '럭키 세븐',
|
||||
Haste: '헤이스트',
|
||||
DarkSight: '다크 사이트',
|
||||
FlashJump: '플래시 점프',
|
||||
NimbleBody: '님블 바디',
|
||||
SavageBlow: '새비지 블로우',
|
||||
CriticalEdge: '크리티컬 엣지',
|
||||
Steal: '스틸',
|
||||
DaggerAcceleration: '대거 액셀레이션',
|
||||
Karma: '카르마',
|
||||
DaggerMastery: '대거 마스터리',
|
||||
PhysicalTraining: '피지컬 트레이닝',
|
||||
ShieldMastery: '실드 마스터리',
|
||||
ThiefAgility: '시프 어질리티',
|
||||
EdgeCarnival: '엣지 카니발',
|
||||
MuspelHeim: '무스펠 하임',
|
||||
MesoExplosion: '메소 익스플로젼',
|
||||
DarkFlare: '다크 플레어',
|
||||
PickPocket: '픽 파킷',
|
||||
ShadowPartner: '쉐도우 파트너',
|
||||
AdvancedDarkSight: '어드밴스드 다크 사이트',
|
||||
IntoDarkness: '인투 다크니스',
|
||||
Venom: '베놈',
|
||||
Grid: '그리드',
|
||||
RadicalDarkness: '래디컬 다크니스',
|
||||
ShurikenBurst: '슈리켄 버스트',
|
||||
WindTalisman: '윈드 탈리스만',
|
||||
MarkOfAssassin: '마크 오브 어쌔신',
|
||||
ShadowRush: '쉐도우 러쉬',
|
||||
ShadowLeap: '쉐도우 리프',
|
||||
ShadowBlink: '쉐도우 블링크',
|
||||
JavelinMastery: '자벨린 마스터리',
|
||||
JavelinAcceleration: '자벨린 액셀레이션',
|
||||
CriticalThrow: '크리티컬 스로우',
|
||||
AssassinPhysicalTraining: '피지컬 트레이닝',
|
||||
TripleThrow: '트리플 스로우',
|
||||
ShurikenChallenge: '슈리켄 챌린지',
|
||||
HermitDarkFlare: '다크 플레어',
|
||||
HermitShadowPartner: '쉐도우 파트너',
|
||||
SpiritJavelin: '스피릿 자벨린',
|
||||
HermitRadicalDarkness: '래디컬 다크니스',
|
||||
HermitVenom: '베놈',
|
||||
SkilledJavelin: '숙련된 표창술',
|
||||
HermitAdrenaline: '아드레날린',
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
for (const [id, expectedName] of Object.entries(mapleSkillCards)) {
|
||||
if (!cards[id]) errors.push(`원본 스킬 카드 없음: ${id}`);
|
||||
else if (cards[id].name !== expectedName) errors.push(`원본 스킬명 변경: ${id} (${cards[id].name} != ${expectedName})`);
|
||||
}
|
||||
|
||||
const customCards = Object.entries(cards).filter(([id, card]) => rogueClasses.has(card.class) && !mapleSkillCards[id]);
|
||||
if (customCards.length !== 78) errors.push(`도적 비스킬 카드 수 불일치: ${customCards.length} != 78`);
|
||||
|
||||
const names = new Map();
|
||||
for (const [id, card] of Object.entries(cards)) {
|
||||
if (!names.has(card.name)) names.set(card.name, []);
|
||||
names.get(card.name).push(id);
|
||||
}
|
||||
|
||||
const nonRogueNames = new Set(Object.values(cards).filter((card) => !rogueClasses.has(card.class) && card.class !== 'shiv').map((card) => card.name));
|
||||
for (const [id, card] of customCards) {
|
||||
const sameNameIds = names.get(card.name) || [];
|
||||
if (sameNameIds.length > 1) errors.push(`비스킬 카드명 중복: ${id} ${card.name} (${sameNameIds.join(', ')})`);
|
||||
if (nonRogueNames.has(card.name)) errors.push(`다른 직업 카드명 충돌: ${id} ${card.name}`);
|
||||
}
|
||||
|
||||
console.log(`메이플 원본 스킬명 고정 ${Object.keys(mapleSkillCards).length}장 | 도적 비스킬 고유 이름 ${customCards.length}장`);
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) console.error(`ERROR: ${error}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('RESULT: 도적 카드 이름 규칙 이상 0');
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": true,
|
||||
"displayOrder": 4,
|
||||
"displayOrder": 6,
|
||||
"pathConstraints": "//",
|
||||
"revision": 1,
|
||||
"origin": {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": true,
|
||||
"displayOrder": 5,
|
||||
"displayOrder": 1,
|
||||
"pathConstraints": "//",
|
||||
"revision": 1,
|
||||
"origin": {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": true,
|
||||
"displayOrder": 1,
|
||||
"displayOrder": 2,
|
||||
"pathConstraints": "//",
|
||||
"revision": 1,
|
||||
"origin": {
|
||||
|
||||
20448
ui/RunUIGroup.ui
20448
ui/RunUIGroup.ui
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
||||
"enable": true,
|
||||
"visible": true,
|
||||
"localize": true,
|
||||
"displayOrder": 2,
|
||||
"displayOrder": 3,
|
||||
"pathConstraints": "//",
|
||||
"revision": 1,
|
||||
"origin": {
|
||||
|
||||
Reference in New Issue
Block a user