도적 다음 스킬 반복 효과 추가

This commit is contained in:
2026-06-21 17:55:25 +09:00
parent 16ebf304a5
commit 278007f908
11 changed files with 123 additions and 18 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1165,6 +1165,7 @@
"rarity": "legend", "rarity": "legend",
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.", "desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
"draw": 1, "draw": 1,
"nextSkillRepeatCount": 1,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f" "image": "91a2d1c16cb041549adbf1a0d7b1f37f"
}, },
"KnifeTrap": { "KnifeTrap": {

View File

@@ -10,7 +10,7 @@
## 구현됨 ## 구현됨
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `Pinpoint` `Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `Pinpoint`
공용 메모: 공용 메모:
@@ -73,8 +73,6 @@
`BladeOfInk`: 전용 표창 생성 `BladeOfInk`: 전용 표창 생성
`Burst`: 다음 스킬 1회 추가 사용
`KnifeTrap`: 소멸된 표창 전부 사용 `KnifeTrap`: 소멸된 표창 전부 사용
`BulletTime`: 드로우 금지 + 손패 무료 사용 `BulletTime`: 드로우 금지 + 손패 무료 사용

View File

@@ -66,6 +66,7 @@
- `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수 - `nextTurnCopies`: 다음 턴에 손패에서 가져올 복사본 수
- `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택 - `nextTurnSelectHandCard`: 현재 손패에서 카드 1장 선택
- `nextTurnSelectPrompt`: 선택 UI 문구 - `nextTurnSelectPrompt`: 선택 UI 문구
- `nextSkillRepeatCount`: 다음 스킬 카드의 효과를 추가 횟수만큼 다시 적용
- `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦 - `nextSkillCostZero`: 다음 스킬 카드 비용을 0으로 만듦
- `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소 - `skillCostReductionThisTurn`: 이번 턴 스킬 카드 비용을 일정량 감소

12
docs/next-skill-repeat.md Normal file
View File

@@ -0,0 +1,12 @@
# Next Skill Repeat
`nextSkillRepeatCount`는 다음에 사용하는 스킬 카드의 효과를 추가 횟수만큼 다시 적용하는 공용 필드입니다.
현재 구현은 카드가 발동할 때 이 수치를 전역 상태에 누적해 두고, 다음 스킬 카드가 실제로 사용되면 그 효과를 같은 카드에 대해 다시 한 번 이상 적용합니다. 카드 종류는 고정하지 않았기 때문에, 같은 필드를 다른 카드에도 그대로 붙일 수 있습니다.
예시:
- `Burst`
- `nextSkillRepeatCount = 1`
- 다음 스킬을 한 번 더 적용

View File

@@ -153,6 +153,7 @@ export function simulateCombat(data, rng, stats) {
let handCostZeroThisTurn = false; let handCostZeroThisTurn = false;
let drawDisabledThisTurn = false; let drawDisabledThisTurn = false;
let nextSkillCostZero = false; let nextSkillCostZero = false;
let nextSkillRepeatCount = 0;
let skillCostReductionThisTurn = 0; let skillCostReductionThisTurn = 0;
let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false; let nextTurnBlock = 0, nextTurnDraw = 0, nextTurnKeepBlock = false;
let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1; let nextTurnAttackMultiplier = 1, turnAttackMultiplier = 1;
@@ -281,6 +282,7 @@ export function simulateCombat(data, rng, stats) {
let blockGained = 0; let blockGained = 0;
if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier; if (c.blockGainMultiplier && c.blockGainMultiplier > 0) blockGainMultiplier *= c.blockGainMultiplier;
if (c.nextSkillCostZero === true) nextSkillCostZero = true; if (c.nextSkillCostZero === true) nextSkillCostZero = true;
if (c.nextSkillRepeatCount && c.nextSkillRepeatCount > 0) nextSkillRepeatCount += c.nextSkillRepeatCount;
if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn; if (c.skillCostReductionThisTurn && c.skillCostReductionThisTurn > 0) skillCostReductionThisTurn += c.skillCostReductionThisTurn;
if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true; if (c.handCostZeroThisTurn === true) handCostZeroThisTurn = true;
if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true; if (c.drawDisabledThisTurn === true) drawDisabledThisTurn = true;
@@ -460,14 +462,60 @@ export function simulateCombat(data, rng, stats) {
if (idx < 0) break; if (idx < 0) break;
const id = hand[idx], c = cards[id]; const id = hand[idx], c = cards[id];
const skillFree = c.kind === 'Skill' && nextSkillCostZero === true; const skillFree = c.kind === 'Skill' && nextSkillCostZero === true;
const skillRepeat = c.kind === 'Skill' ? nextSkillRepeatCount : 0;
const baseCost = c.cost || 0; const baseCost = c.cost || 0;
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost))); const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
energy -= cost; energy -= cost;
resolveCardEffects(id, c, cost); resolveCardEffects(id, c, cost);
if (c.kind === 'Attack') turnAttackCardsPlayed++;
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
const playedBlock = powerFieldTotal('cardPlayedBlock'); const playedBlock = powerFieldTotal('cardPlayedBlock');
if (playedBlock > 0) addBlock(playedBlock); if (playedBlock > 0) addBlock(playedBlock);
if (c.cardPlayedDamage && alive.length) {
const target = chooseTarget(aliveList(), 0);
if (target && target.alive) {
target.hp -= c.cardPlayedDamage;
dmg += c.cardPlayedDamage;
if (target.hp <= 0) target.alive = false;
}
}
if (c.cardPlayedRandomDamage && alive.length) {
const pool = aliveList();
if (pool.length) {
const target = pool[Math.floor(rng() * pool.length)];
if (target) {
target.hp -= c.cardPlayedRandomDamage;
dmg += c.cardPlayedRandomDamage;
if (target.hp <= 0) target.alive = false;
}
}
}
if (skillRepeat > 0) {
nextSkillRepeatCount = Math.max(0, nextSkillRepeatCount - skillRepeat);
for (let r = 0; r < skillRepeat; r++) {
resolveCardEffects(id, c, cost);
if (playedBlock > 0) addBlock(playedBlock);
if (c.cardPlayedDamage && alive.length) {
const target = chooseTarget(aliveList(), 0);
if (target && target.alive) {
target.hp -= c.cardPlayedDamage;
dmg += c.cardPlayedDamage;
if (target.hp <= 0) target.alive = false;
}
}
if (c.cardPlayedRandomDamage && alive.length) {
const pool = aliveList();
if (pool.length) {
const target = pool[Math.floor(rng() * pool.length)];
if (target) {
target.hp -= c.cardPlayedRandomDamage;
dmg += c.cardPlayedRandomDamage;
if (target.hp <= 0) target.alive = false;
}
}
}
}
}
if (c.kind === 'Attack') turnAttackCardsPlayed++;
if (skillFree === true && c.nextSkillCostZero !== true) nextSkillCostZero = false;
hand.splice(idx, 1); hand.splice(idx, 1);
queueSelectedReserve(c); queueSelectedReserve(c);
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id); if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);

View File

@@ -713,6 +713,28 @@ test("simulateCombat: nextSkillCostZero makes the next skill free", () => {
assert.equal(r.playerHpRemaining, 80); assert.equal(r.playerHpRemaining, 80);
}); });
test("simulateCombat: nextSkillRepeatCount repeats the next skill effect", () => {
const shared = {
cards: {
Burst: { name: "Burst", cost: 1, kind: "Skill", draw: 1, block: 5, nextSkillRepeatCount: 1 },
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },
},
starterDeck: ["Burst", "Guard"],
monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 15 }] }],
};
const withBurst = simulateCombat(shared, () => 0.999999);
const withoutBurst = simulateCombat({
...shared,
cards: {
Burst: { name: "Burst", cost: 1, kind: "Skill", draw: 1, block: 5 },
Guard: shared.cards.Guard,
},
}, () => 0.999999);
assert.equal(withBurst.draw, true);
assert.equal(withBurst.playerHpRemaining, 80);
assert.ok(withBurst.playerHpRemaining > withoutBurst.playerHpRemaining);
});
test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => { test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => {
const cards = { const cards = {
Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 }, Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 },

View File

@@ -46,6 +46,7 @@ if self:CanPlayCardNow(c) ~= true then
end end
local cost = c.cost or 0 local cost = c.cost or 0
local skillFree = false local skillFree = false
local skillRepeat = 0
if self.HandCostZeroThisTurn == true then if self.HandCostZeroThisTurn == true then
cost = 0 cost = 0
elseif c.useAllEnergy == true then elseif c.useAllEnergy == true then
@@ -58,6 +59,9 @@ end
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
cost = math.max(0, cost - self.SkillCostReductionThisTurn) cost = math.max(0, cost - self.SkillCostReductionThisTurn)
end end
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
skillRepeat = self.NextSkillRepeatCount
end
if self.Energy < cost then if self.Energy < cost then
self:Toast("에너지가 부족합니다") self:Toast("에너지가 부족합니다")
return return
@@ -65,6 +69,29 @@ end
self.Energy = self.Energy - cost self.Energy = self.Energy - cost
self.ActiveKillReward = c.rewardOnKill or 0 self.ActiveKillReward = c.rewardOnKill or 0
self:ResolveCardEffects(cardId, slot, c, false, cost) self:ResolveCardEffects(cardId, slot, c, false, cost)
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
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, slot, c, false, cost)
applyCardPlayHooks()
end
end
if c.kind == "Attack" then if c.kind == "Attack" then
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1 self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
end end
@@ -73,15 +100,6 @@ if skillFree == true then
self.NextSkillCostZero = false self.NextSkillCostZero = false
end end
end end
if self:HasPowerField("cardPlayedBlock") == true then
self:AddCardBlock(self:AddPowerFieldTotal("cardPlayedBlock"))
end
if c.cardPlayedDamage ~= nil and c.cardPlayedDamage > 0 then
self:DealDirectDamageToTarget(c.cardPlayedDamage)
end
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
end
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
self.ActiveKillReward = 0 self.ActiveKillReward = 0
end end

View File

@@ -382,6 +382,9 @@ end
if c.nextSkillCostZero == true then if c.nextSkillCostZero == true then
self.NextSkillCostZero = true self.NextSkillCostZero = true
end end
if c.nextSkillRepeatCount ~= nil and c.nextSkillRepeatCount > 0 then
self.NextSkillRepeatCount = (self.NextSkillRepeatCount or 0) + c.nextSkillRepeatCount
end
if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then if c.skillCostReductionThisTurn ~= nil and c.skillCostReductionThisTurn > 0 then
self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn self.SkillCostReductionThisTurn = (self.SkillCostReductionThisTurn or 0) + c.skillCostReductionThisTurn
end end

View File

@@ -72,6 +72,7 @@ self.CardsDrawnThisCombat = 0
self.HandCostZeroThisTurn = false self.HandCostZeroThisTurn = false
self.DrawDisabledThisTurn = false self.DrawDisabledThisTurn = false
self.NextSkillCostZero = false self.NextSkillCostZero = false
self.NextSkillRepeatCount = 0
self.SkillCostReductionThisTurn = 0 self.SkillCostReductionThisTurn = 0
self.PlayerStr = 0 self.PlayerStr = 0
self.PlayerDex = 0 self.PlayerDex = 0

View File

@@ -215,6 +215,7 @@ function luaCardsTable(cards) {
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`); if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true'); if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`); if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
if (c.nextSkillRepeatCount != null) fields.push(`nextSkillRepeatCount = ${c.nextSkillRepeatCount}`);
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true'); if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`); if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
if (c.innate === true) fields.push('innate = true'); if (c.innate === true) fields.push('innate = true');