12 Commits

Author SHA1 Message Date
2928e2b45f Merge pull request '타격 시퀀스와 카메라 쉐이크 타이밍 정리' (#114) from codex/attack-sequence-feel-fix into main
Reviewed-on: #114
2026-07-10 00:38:07 +09:00
9bc71c0e32 Improve attack hit timing and camera shake 2026-07-10 00:35:50 +09:00
701769fcd7 Merge pull request '데미지 팝업 순차 표시 및 전투 UI 반영 정리' (#112) from codex/dmg-pop-sequence-fix into main
Reviewed-on: #112
2026-07-09 22:48:39 +09:00
a7a6b2123a Fix damage popup sequencing 2026-07-08 02:35:01 +09:00
fc37d81350 Merge pull request '전사·도적 전직 카드 풀 및 성장 밸런스 조정' (#111) from codex/warrior-card-pool-rebalance into main
Reviewed-on: #111
2026-07-05 20:02:04 +09:00
d5931b1822 balance: 도적 계열 성장 곡선 조정 2026-07-05 04:03:13 +09:00
fe92b8bbb8 balance: 전사 카드를 전직 계열별로 재배치 2026-07-05 03:47:28 +09:00
f89cd1ad63 Merge pull request '메이플 전사 전직 스킬 카드 및 연계 기믹 구현' (#110) from codex/maple-warrior-skill-cards into main
Reviewed-on: #110
2026-07-04 02:29:12 +09:00
758ebd19f2 balance: 전사 계열 5섹션 성장 곡선 조정 2026-07-04 02:22:17 +09:00
90494232bc feat: 메이플 전사 전직 카드와 연계 기믹 추가 2026-07-04 02:15:01 +09:00
ecadf3606e Merge pull request '워리어 카드 풀 및 공용 전투 효과 구현' (#109) from codex/warrior-card-effects-fixes into main
Reviewed-on: #109
2026-07-04 01:40:00 +09:00
4b559ca7fa feat: 워리어 카드와 공용 전투 효과 구현 2026-07-03 23:07:41 +09:00
30 changed files with 21048 additions and 4481 deletions

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,9 @@
"classToFrame": {
"warrior": "warrior",
"fighter": "warrior",
"crusader": "warrior",
"page": "warrior",
"knight": "warrior",
"spearman": "warrior",
"magician": "magician",
"firepoison": "magician",

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,10 @@ import {
simulateCombat,
} from './sim-balance.mjs';
const ROGUE_CLASSES = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
const AUDITED_CLASSES = new Set([
'rogue', 'thief', 'thiefmaster', 'assassin', 'hermit',
'warrior', 'fighter', 'crusader', 'page', 'knight',
]);
const CONTEXT_DECKS = {
rogue: [
@@ -38,6 +41,27 @@ const CONTEXT_DECKS = {
'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 = {
@@ -46,6 +70,11 @@ const ENCOUNTER_SCALE = {
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) => {
@@ -172,7 +201,7 @@ export function auditCardEfficiency({ runs = 300, seed = 20260701 } = {}) {
const rows = [];
for (const [id, card] of Object.entries(cards)) {
if (!ROGUE_CLASSES.has(card.class)) continue;
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);
@@ -204,7 +233,7 @@ function formatPercent(value) {
export function formatEfficiencyReport(report) {
const lines = [];
lines.push(`도적 카드 효율 검증: 카드 ${report.rows.length}장, 카드당 ${report.runs}`);
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)}`);

View File

@@ -6,7 +6,7 @@ const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
const relicsData = JSON.parse(readFileSync('data/relics.json', 'utf8'));
const PLAYER_MAX_HP = 70;
const PLAYER_MAX_HP = { rogue: 70, warrior: 80 };
const REST_HEAL = 30;
const SECTION_COUNT = 5;
const NORMAL_FIGHTS = 4;
@@ -18,6 +18,8 @@ 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 = {
@@ -26,12 +28,17 @@ const LINEAGES = {
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 'rogue';
if (section <= 1) return JOBS[branch].root || 'rogue';
if (section === 2) return JOBS[branch].tier2;
return JOBS[branch].tier3;
}
@@ -102,11 +109,22 @@ function branchCardValue(card, branch, deck, id) {
value += card.sly ? 5 : 0;
value += (card.discard || 0) * 2 + (card.drawPerDiscarded || 0) * 4;
value += (card.poisonApplicationBurstDamage || 0) * 1.5;
} else {
} 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);
@@ -192,7 +210,8 @@ function fight(state, branch, kind, section, rng, options) {
state.turns += result.turns;
if (!result.win) return false;
healFromRelics(state, 'combatEnd');
if (kind !== 'boss') offerReward(state.job, branch, state.deck, rng, options.minimumRewardValue);
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;
}
@@ -203,11 +222,13 @@ export function simulateCampaign(branch, rng, {
minimumRewardValue = 10,
} = {}) {
if (!JOBS[branch]) throw new Error(`지원하지 않는 도적 분기: ${branch}`);
const root = JOBS[branch].root || 'rogue';
const maxHp = PLAYER_MAX_HP[root];
const state = {
hp: PLAYER_MAX_HP,
maxHp: PLAYER_MAX_HP,
deck: cardsData.starterDecks.rogue.slice(),
job: 'rogue',
hp: maxHp,
maxHp,
deck: cardsData.starterDecks[root].slice(),
job: root,
turns: 0,
sectionCleared: 0,
diedAt: '',
@@ -306,7 +327,7 @@ function main() {
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']) {
for (const branch of ['thief', 'assassin', 'fighter', 'page']) {
console.log(formatCampaignReport(runCampaignBatch(branch, runs, seed, { restHeal, sectionHeal, scaleStep, minimumRewardValue })));
}
}

View File

@@ -17,6 +17,16 @@ test('도적 전직 시점: 1섹션 Rogue, 2섹션 2차, 3섹션부터 3차', ()
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차 이후 더 빠르게 증가', () => {

View File

@@ -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,7 +127,48 @@ 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)) {
@@ -144,8 +185,12 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
}
}
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;
}
@@ -167,13 +212,14 @@ function bump(s, cost, dmg, blk) {
// 반환: { win, turns, playerHpRemaining, draw? }
export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, monsters } = data;
const playerMaxHp = data.playerMaxHp || 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 = [];
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;
@@ -200,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) => ({
@@ -215,6 +264,14 @@ 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) => {
@@ -315,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);
@@ -380,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;
@@ -433,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;
@@ -461,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;
@@ -476,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;
@@ -493,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 };
};
@@ -534,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) {
@@ -570,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, playerMaxHp);
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);
@@ -588,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) {
@@ -609,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));
}
@@ -639,7 +844,48 @@ export function simulateCombat(data, rng, stats) {
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;
@@ -655,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;
@@ -668,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') {
@@ -677,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;
@@ -701,8 +952,14 @@ export function simulateCombat(data, rng, stats) {
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];
@@ -714,6 +971,7 @@ 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);
@@ -729,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;
@@ -781,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;

View File

@@ -649,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", () => {
@@ -666,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", () => {
@@ -1099,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: {
@@ -1124,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: {
@@ -1215,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);
});

View File

@@ -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
@@ -288,6 +297,7 @@ if m.block > 0 and pierce ~= true then
m.block = m.block - absorbed
dmg = dmg - absorbed
end
self:ShakeCombatCamera()
m.hp = m.hp - dmg
if dmg > 0 then
local poison = self:AddPowerFieldTotal("attackPoison")
@@ -301,6 +311,13 @@ 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`, [
@@ -317,6 +334,7 @@ end
if m == nil then
return false
end
self:ShakeCombatCamera()
m.hp = m.hp - amount
self:ShowDmgPop(m.slot, amount)
self:MonsterHitMotion(m.slot)
@@ -343,6 +361,7 @@ local m = alive[math.random(1, #alive)]
if m == nil then
return false
end
self:ShakeCombatCamera()
m.hp = m.hp - amount
self:ShowDmgPop(m.slot, amount)
self:MonsterHitMotion(m.slot)
@@ -368,7 +387,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' },
@@ -377,6 +396,17 @@ end`, [
method('DealDamageToAllMonsters', `if self.Monsters == nil then
return false
end
local anyAlive = false
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
anyAlive = true
break
end
end
if anyAlive == true and isAttack == true then
self:ShakeCombatCamera()
end
local killCount = 0
for i = 1, #self.Monsters do
local m = self.Monsters[i]
@@ -412,6 +442,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`, [
@@ -420,6 +455,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()
@@ -451,12 +493,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)`, [
@@ -511,10 +550,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()
@@ -536,6 +573,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
@@ -544,8 +586,20 @@ end
if dmg > 0 and self.PlayerIntangible ~= nil and self.PlayerIntangible > 0 and dmg > 1 then
dmg = 1
end
self:ShakeCombatCamera()
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
@@ -567,7 +621,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

View File

@@ -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'),

View File

@@ -181,7 +181,6 @@ end
self.DeckInspectKind = ""
self.ClassDeckMode = false
self.ClassDeckClass = ""
self.CodexMode = false
self.DebugCardPickerMode = false
self:RenderClassDeckTabs()
self.DeckAllOpen = true
@@ -212,9 +211,6 @@ local title = "모든 덱"
if self.ClassDeckMode == true then
pile = self.ClassDeckCards or {}
title = self.ClassDeckTitle
if self.CodexMode == true then
title = "카드 도감"
end
if self.DebugCardPickerMode == true then
title = title .. " - 테스트 카드 추가"
end

View File

@@ -313,6 +313,108 @@ if amount < 0 then
end
self.PlayerBlock = self.PlayerBlock + amount
return amount`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
method('RunAttackSequence', `if hitDamages == nil or #hitDamages <= 0 then
return
end
local function countAliveMonsters()
local n = 0
if self.Monsters ~= nil then
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then
n = n + 1
end
end
end
return n
end
local function randomAliveMonsterIndex()
local alive = {}
if self.Monsters ~= nil then
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then
table.insert(alive, mi)
end
end
end
if #alive <= 0 then
return 0
end
return alive[math.random(1, #alive)]
end
self.FxBusy = true
local hitIndex = 1
local lockedTargetIndex = self.TargetIndex or 0
local function finishSequence()
self.FxBusy = false
self.ActiveAttackDamageVsWeakMultiplier = 1
self:RenderCombat()
self:CheckCombatEnd()
end
local function step()
if self.CombatOver == true then
finishSequence()
return
end
if countAliveMonsters() <= 0 then
finishSequence()
return
end
if hitIndex == 1 then
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + (roundPlannedDamage or 0)
end
local roundKilled = false
if useAoe == true then
roundKilled = self:DealDamageToAllMonsters(hitDamages[hitIndex] or 0, true)
else
local targetIdx = lockedTargetIndex
if randomTargetEachHit == true then
targetIdx = randomAliveMonsterIndex()
end
if targetIdx ~= nil and targetIdx > 0 then
if randomTargetEachHit ~= true then
local lockedMonster = nil
if self.Monsters ~= nil then
lockedMonster = self.Monsters[targetIdx]
end
if lockedMonster == nil or lockedMonster.alive ~= true then
finishSequence()
return
end
end
local prev = self.TargetIndex
self.TargetIndex = targetIdx
roundKilled = self:DealDamageToTarget(hitDamages[hitIndex] or 0, pierce == true)
self.TargetIndex = prev
end
end
self:RenderCombat()
self:CheckCombatEnd()
hitIndex = hitIndex + 1
if self.CombatOver == true then
finishSequence()
return
end
if hitIndex <= #hitDamages then
_TimerService:SetTimerOnce(step, 0.2)
return
end
if repeatOnKill == true and roundKilled == true and countAliveMonsters() > 0 then
hitIndex = 1
_TimerService:SetTimerOnce(step, 0.2)
return
end
finishSequence()
end
step()`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hitDamages' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'roundPlannedDamage' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'useAoe' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'randomTargetEachHit' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'repeatOnKill' },
]),
method('CountOtherHandSkills', `if self.Hand == nil then
return 0
end
@@ -326,7 +428,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 +564,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 +878,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 +899,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
@@ -514,70 +910,17 @@ if c.kind == "Attack" then
if c.class == "shiv" and self.ShivFirstDamageBonusUsed ~= true and self:HasPowerField("firstShivDamageBonus") == true then
self.ShivFirstDamageBonusUsed = true
end
local function countAliveMonsters()
local n = 0
if self.Monsters ~= nil then
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then n = n + 1 end
end
end
return n
end
local function randomAliveMonsterIndex()
local alive = {}
if self.Monsters ~= nil then
for mi = 1, #self.Monsters do
local om = self.Monsters[mi]
if om ~= nil and om.alive == true then
table.insert(alive, mi)
end
end
end
if #alive <= 0 then
return 0
end
return alive[math.random(1, #alive)]
end
local function resolveAttackRound()
local roundKilled = false
if useAoe == true then
local killed = self:DealDamageToAllMonsters(total, true)
if killed == true then roundKilled = true end
elseif c.randomTargetEachHit == true then
for h = 1, hitN do
local targetIdx = randomAliveMonsterIndex()
if targetIdx ~= nil and targetIdx > 0 then
local prev = self.TargetIndex
self.TargetIndex = targetIdx
local killed = self:DealDamageToTarget(math.floor(total / hitN), c.pierce == true)
self.TargetIndex = prev
if killed == true then roundKilled = true end
end
end
else
local killed = self:DealDamageToTarget(total, c.pierce == true)
if killed == true then roundKilled = true end
end
return roundKilled
end
local totalDamage = 0
local roundKilled = false
repeat
roundKilled = resolveAttackRound()
totalDamage = totalDamage + total
until c.repeatOnKill ~= true or roundKilled ~= true or countAliveMonsters() <= 0
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + totalDamage
self:RunAttackSequence(hitDamages, total, useAoe, c.randomTargetEachHit == true, c.pierce == true, c.repeatOnKill == true)
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 +940,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 +1070,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' },

View File

@@ -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

View File

@@ -80,6 +80,14 @@ 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
@@ -92,13 +100,21 @@ 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
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
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
local popSeq = self.DmgPopSeq
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)))
@@ -106,6 +122,7 @@ 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
@@ -124,7 +141,7 @@ for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
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)
table.insert(digitPathsToEnable, digitPath)
else
self:SetEntityEnabled(digitPath, false)
end
@@ -143,7 +160,7 @@ else
popPos = Vector2(sp.x, sp.y + 76)
end
end
if pop ~= nil and pop.UITransformComponent ~= nil then
if pop.UITransformComponent ~= nil then
if popPos ~= nil then
pop.UITransformComponent.anchoredPosition = popPos
else
@@ -151,24 +168,31 @@ if pop ~= nil and pop.UITransformComponent ~= nil then
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()
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)
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()
if self.DmgPopSeq ~= popSeq then
return
end
self:SetEntityEnabled(base, false)
end, 0.48)`, [
end, 0.3)
end
showNow()`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
@@ -180,6 +204,48 @@ else
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('ShakeCombatCamera', `local lp = _UserService.LocalPlayer
local cam = nil
if lp ~= nil then
cam = lp.CameraComponent
end
if cam == nil then
cam = _CameraService:GetCurrentCameraComponent()
end
if cam == nil then
return
end
self.CombatCameraShakeSeq = (self.CombatCameraShakeSeq or 0) + 1
local seq = self.CombatCameraShakeSeq
local baseCameraOffset = Vector2(${CAM.cameraOffsetX}, ${CAM.cameraOffsetY})
cam.CameraOffset = baseCameraOffset
local offsets = {
Vector2(-0.22, 0),
Vector2(0.2, 0),
Vector2(-0.12, 0),
Vector2(0.06, 0),
Vector2(0, 0),
}
for i = 1, #offsets do
local delta = offsets[i]
_TimerService:SetTimerOnce(function()
local lp2 = _UserService.LocalPlayer
local cam2 = nil
if lp2 ~= nil then
cam2 = lp2.CameraComponent
end
if cam2 == nil then
cam2 = _CameraService:GetCurrentCameraComponent()
end
if cam2 == nil then
return
end
if seq ~= self.CombatCameraShakeSeq then
return
end
cam2.CameraOffset = Vector2(baseCameraOffset.x + delta.x, baseCameraOffset.y + delta.y)
end, 0.028 * (i - 1))
end`, []),
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
if lp == nil then
return
@@ -293,4 +359,5 @@ if self.AscensionLevel > 0 then
end
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Floor", floorText)
self:SetText("/ui/RunUIGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
];

View File

@@ -47,7 +47,7 @@ if slot ~= 0 and self.RewardChoices ~= nil then
table.insert(self.RunDeck, id)
end
end
if self.BonusRewardScreens ~= nil and self.BonusRewardScreens > 0 then
if self.BonusRewardScreens ~= nil and self.BonusRewardScreens > 0 and slot ~= 0 then
self.BonusRewardScreens = self.BonusRewardScreens - 1
self:OfferReward()
return

View File

@@ -95,13 +95,19 @@ 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.CombatCameraShakeSeq = 0
self.DmgPopSlotQueue = {}
self.FirstHpLossDone = false
self.ClayBlockNext = 0
self.DiscardSelectRemaining = 0
@@ -118,6 +124,7 @@ self.TurnAttackMultiplier = 1
self.NextTurnSelectPrompt = ""
self.NextTurnSelectCopies = 0
self.NextTurnAddCards = {}
self.ZeroCostCardIdsThisTurn = {}
self.CombatOver = false
self.DiscardPile = {}
self.ExhaustPile = {}

View File

@@ -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)
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
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
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 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")

View File

@@ -9,17 +9,7 @@ self.ShopBought = { false, false, false }
for i = 1, 3 do
self.ShopChoices[i] = pool[math.random(1, #pool)]
end
local relicChoices = {}
for i = 1, #self.RelicPool do
if self:HasRelic(self.RelicPool[i]) == false then
table.insert(relicChoices, self.RelicPool[i])
end
end
if #relicChoices > 0 then
self.ShopRelic = relicChoices[math.random(1, #relicChoices)]
else
self.ShopRelic = ""
end
self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]
self.ShopRelicBought = false
local pkeys = {}
for pid, _ in pairs(self.Potions) do
@@ -48,7 +38,6 @@ for i = 1, 3 do
end
local rr = self.Relics[self.ShopRelic]
if rr ~= nil then
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud/Relic", true)
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc)
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 메소")
local re = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Relic")
@@ -59,8 +48,6 @@ if rr ~= nil then
re.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)
end
end
else
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud/Relic", false)
end
local pp = self.Potions[self.ShopPotion]
if pp ~= nil then
@@ -78,9 +65,6 @@ end`),
method('BuyRelic', `if self.ShopRelicBought == true then
return
end
if self.ShopRelic == nil or self.ShopRelic == "" then
return
end
if self.Gold < ${RELIC_PRICE} then
return
end

View File

@@ -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`),

View File

@@ -36,6 +36,8 @@ function writeCodeblocks() {
prop('number', 'TweenEventId', '0'),
prop('number', 'CardHoverTweenId', '0'),
prop('number', 'DmgPopSeq', '0'),
prop('number', 'CombatCameraShakeSeq', '0'),
prop('any', 'DmgPopSlotQueue'),
prop('any', 'EndTurnHandler'),
prop('any', 'NewGameHandler'),
prop('any', 'WarriorSelectHandler'),
@@ -142,10 +144,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 +179,7 @@ function writeCodeblocks() {
prop('boolean', 'NextSkillCostZero', 'false'),
prop('number', 'NextSkillRepeatCount', '0'),
prop('any', 'NextTurnAddCards'),
prop('any', 'ZeroCostCardIdsThisTurn'),
], [
...bootMethods,
...screensMethods,

View File

@@ -19,18 +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: 'Fighter의 3차 전직\n콤보 압박과 화력 심화\n파이터 카드 계승', starter: '', tier: 3, parent: 'fighter' },
{ id: 'crusader', name: '크루세이더', desc: '파이터의 3차 전직\n콤보 상한과 연계 피해 강화\n파이터 카드 계승', starter: 'ComboSynergy', tier: 3, parent: 'fighter' },
],
page: [
{ id: 'knight', name: '나이트', desc: 'Page의 3차 전직\n방어와 차지 운영 심화\n페이지 카드 계승', starter: '', tier: 3, parent: 'page' },
{ id: 'knight', name: '나이트', desc: '페이지의 3차 전직\n홀리 차지를 공격·방어·회복으로 전환\n페이지 카드 계승', starter: 'DivineCharge', tier: 3, parent: 'page' },
],
spearman: [
{ id: 'berserker', name: '버서커', desc: 'Spearman의 3차 전직\n관통과 생존 운영 심화\n스피어맨 카드 계승', starter: '', tier: 3, parent: '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' },
@@ -224,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}`);
@@ -232,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}`);
@@ -247,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)}`);
@@ -262,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');

View File

@@ -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)) {

View File

@@ -24,7 +24,7 @@
"enable": true,
"visible": true,
"localize": true,
"displayOrder": 4,
"displayOrder": 6,
"pathConstraints": "//",
"revision": 1,
"origin": {

View File

@@ -24,7 +24,7 @@
"enable": true,
"visible": true,
"localize": true,
"displayOrder": 5,
"displayOrder": 1,
"pathConstraints": "//",
"revision": 1,
"origin": {

View File

@@ -24,7 +24,7 @@
"enable": true,
"visible": true,
"localize": true,
"displayOrder": 1,
"displayOrder": 2,
"pathConstraints": "//",
"revision": 1,
"origin": {

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"enable": true,
"visible": true,
"localize": true,
"displayOrder": 2,
"displayOrder": 3,
"pathConstraints": "//",
"revision": 1,
"origin": {