사냥 처치 보상 추가

This commit is contained in:
2026-06-21 15:43:47 +09:00
parent 5b7f7bb69f
commit 16ebf304a5
9 changed files with 89 additions and 30 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1069,6 +1069,7 @@
"rarity": "legend", "rarity": "legend",
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.", "desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
"damage": 10, "damage": 10,
"rewardOnKill": 1,
"image": "b1360ed0c4b942309d240634b8f36872" "image": "b1360ed0c4b942309d240634b8f36872"
}, },
"Murder": { "Murder": {

5
docs/reward-on-kill.md Normal file
View File

@@ -0,0 +1,5 @@
# 처치 보상
`rewardOnKill`은 해당 카드가 적을 처치했을 때 전투 보상 화면을 한 번 더 이어서 보여주는 공용 필드입니다. 현재 보상 UI는 3장 선택을 유지하고, 보상 화면만 추가로 한 번 더 열립니다.
`TheHunt`는 이 규칙을 사용합니다. 같은 패턴이 필요한 다른 카드에도 그대로 붙일 수 있습니다.

View File

@@ -159,6 +159,8 @@ export function simulateCombat(data, rng, stats) {
let nextTurnAddCards = []; let nextTurnAddCards = [];
let turnAttackCardsPlayed = 0, turnDiscardedCards = 0; let turnAttackCardsPlayed = 0, turnDiscardedCards = 0;
let cardsDrawnThisCombat = 0; let cardsDrawnThisCombat = 0;
let bonusRewardScreens = 0;
let activeKillReward = 0;
let energy = 0; let energy = 0;
const powers = []; const powers = [];
const mob = monsters.map((m) => ({ const mob = monsters.map((m) => ({
@@ -303,7 +305,10 @@ export function simulateCombat(data, rng, stats) {
m2.hp = r2.hp; m2.block = r2.block; m2.hp = r2.hp; m2.block = r2.block;
const attackPoison = powerFieldTotal('attackPoison'); const attackPoison = powerFieldTotal('attackPoison');
if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison; if (d2 > 0 && attackPoison > 0) m2.poison += attackPoison;
if (m2.hp <= 0) m2.alive = false; if (m2.hp <= 0) {
m2.alive = false;
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
}
} }
} else { } else {
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv; dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
@@ -316,7 +321,10 @@ export function simulateCombat(data, rng, stats) {
} }
const attackPoison = powerFieldTotal('attackPoison'); const attackPoison = powerFieldTotal('attackPoison');
if (dmg > 0 && attackPoison > 0) target.poison += attackPoison; if (dmg > 0 && attackPoison > 0) target.poison += attackPoison;
if (target.hp <= 0) target.alive = false; if (target.hp <= 0) {
target.alive = false;
if (c.rewardOnKill) bonusRewardScreens += c.rewardOnKill;
}
} }
} }
if (c.block) blockGained = addBlock(c.block); if (c.block) blockGained = addBlock(c.block);
@@ -338,6 +346,7 @@ export function simulateCombat(data, rng, stats) {
if (c.selfVuln) pVuln += c.selfVuln; if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP); if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (c.gainEnergy) energy += c.gainEnergy; if (c.gainEnergy) energy += c.gainEnergy;
activeKillReward = c.rewardOnKill || 0;
if (c.intangible) pIntangible += c.intangible; if (c.intangible) pIntangible += c.intangible;
queueNextTurnEffects(c); queueNextTurnEffects(c);
let drawnCards = []; let drawnCards = [];
@@ -464,7 +473,7 @@ export function simulateCombat(data, rng, stats) {
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id); if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
else if (c.kind !== 'Power') discard.push(id); else if (c.kind !== 'Power') discard.push(id);
applyDiscardEffects(c); applyDiscardEffects(c);
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp }; if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
} }
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화) // 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
let burn = 0; let burn = 0;
@@ -527,9 +536,9 @@ export function simulateCombat(data, rng, stats) {
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
} }
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화) // 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp }; if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp, bonusRewardScreens };
} }
return { win: false, turns, playerHpRemaining: pHp, draw: true }; return { win: false, turns, playerHpRemaining: pHp, draw: true, bonusRewardScreens };
} }
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; } function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }

View File

@@ -823,6 +823,19 @@ test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play",
assert.equal(r.win, true); assert.equal(r.win, true);
}); });
test("simulateCombat: rewardOnKill grants an extra reward screen when an attack kills", () => {
const data = {
cards: {
TheHunt: { name: "TheHunt", cost: 1, kind: "Attack", damage: 10, rewardOnKill: 1 },
},
starterDeck: ["TheHunt"],
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.bonusRewardScreens, 1);
});
test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => { test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => {
const data = { const data = {
cards: { cards: {

View File

@@ -63,6 +63,7 @@ if self.Energy < cost then
return return
end end
self.Energy = self.Energy - cost self.Energy = self.Energy - cost
self.ActiveKillReward = c.rewardOnKill or 0
self:ResolveCardEffects(cardId, slot, c, false, cost) self:ResolveCardEffects(cardId, slot, c, false, cost)
if c.kind == "Attack" then if c.kind == "Attack" then
self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1 self.TurnAttackCardsPlayed = (self.TurnAttackCardsPlayed or 0) + 1
@@ -81,6 +82,9 @@ end
if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then if c.cardPlayedRandomDamage ~= nil and c.cardPlayedRandomDamage > 0 then
self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage) self:DealDirectDamageToRandomMonster(c.cardPlayedRandomDamage)
end end
if self.ActiveKillReward ~= nil and self.ActiveKillReward <= 0 then
self.ActiveKillReward = 0
end
table.remove(self.Hand, slot) table.remove(self.Hand, slot)
if c.exhaust == true then if c.exhaust == true then
if self.ExhaustPile == nil then self.ExhaustPile = {} end if self.ExhaustPile == nil then self.ExhaustPile = {} end
@@ -235,7 +239,7 @@ end`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }, { Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
]), ]),
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]), method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex] method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex]
if m == nil or m.alive ~= true then if m == nil or m.alive ~= true then
m = nil m = nil
for i = 1, #self.Monsters do for i = 1, #self.Monsters do
@@ -243,7 +247,7 @@ if m == nil or m.alive ~= true then
end end
end end
if m == nil then if m == nil then
return return false
end end
local dmg = amount local dmg = amount
if m.vuln > 0 then if m.vuln > 0 then
@@ -262,13 +266,16 @@ if dmg > 0 then
end end
end end
self:MonsterHitMotion(m.slot) self:MonsterHitMotion(m.slot)
local killed = false
if m.hp <= 0 then if m.hp <= 0 then
m.hp = 0 m.hp = 0
self:KillMonster(m.slot) self:KillMonster(m.slot)
end`, [ killed = true
end
return killed`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' }, { Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
]), ], 0, 'boolean'),
method('DealDirectDamageToTarget', `local m = self.Monsters[self.TargetIndex] method('DealDirectDamageToTarget', `local m = self.Monsters[self.TargetIndex]
if m == nil or m.alive ~= true then if m == nil or m.alive ~= true then
m = nil m = nil
@@ -277,17 +284,20 @@ if m == nil or m.alive ~= true then
end end
end end
if m == nil then if m == nil then
return return false
end end
m.hp = m.hp - amount m.hp = m.hp - amount
self:ShowDmgPop(m.slot, amount) self:ShowDmgPop(m.slot, amount)
self:MonsterHitMotion(m.slot) self:MonsterHitMotion(m.slot)
local killed = false
if m.hp <= 0 then if m.hp <= 0 then
m.hp = 0 m.hp = 0
self:KillMonster(m.slot) self:KillMonster(m.slot)
end`, [ killed = true
end
return killed`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]), ], 0, 'boolean'),
method('DealDirectDamageToRandomMonster', `local alive = {} method('DealDirectDamageToRandomMonster', `local alive = {}
for i = 1, #self.Monsters do for i = 1, #self.Monsters do
local m = self.Monsters[i] local m = self.Monsters[i]
@@ -296,21 +306,24 @@ for i = 1, #self.Monsters do
end end
end end
if #alive <= 0 then if #alive <= 0 then
return return false
end end
local m = alive[math.random(1, #alive)] local m = alive[math.random(1, #alive)]
if m == nil then if m == nil then
return return false
end end
m.hp = m.hp - amount m.hp = m.hp - amount
self:ShowDmgPop(m.slot, amount) self:ShowDmgPop(m.slot, amount)
self:MonsterHitMotion(m.slot) self:MonsterHitMotion(m.slot)
local killed = false
if m.hp <= 0 then if m.hp <= 0 then
m.hp = 0 m.hp = 0
self:KillMonster(m.slot) self:KillMonster(m.slot)
end`, [ killed = true
end
return killed`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]), ], 0, 'boolean'),
method('PlayAttackFx', `local m = self.Monsters[targetIndex] method('PlayAttackFx', `local m = self.Monsters[targetIndex]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
self:DealDamageToTarget(damage, pierce) self:DealDamageToTarget(damage, pierce)
@@ -339,7 +352,11 @@ _TimerService:SetTimerOnce(function()
if mt ~= nil and mt.alive == true and mt.vuln > 0 then if mt ~= nil and mt.alive == true and mt.vuln > 0 then
shown = math.floor(damage * 1.5) shown = math.floor(damage * 1.5)
end end
self:DealDamageToTarget(damage, pierce) 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:ShowDmgPop(targetIndex, shown) self:ShowDmgPop(targetIndex, shown)
self:RenderCombat() self:RenderCombat()
self:CheckCombatEnd() self:CheckCombatEnd()
@@ -363,6 +380,7 @@ end
_TimerService:SetTimerOnce(function() _TimerService:SetTimerOnce(function()
if fx ~= nil then fx.Enable = false end if fx ~= nil then fx.Enable = false end
self.FxBusy = false self.FxBusy = false
local killCount = 0
for i = 1, #self.Monsters do for i = 1, #self.Monsters do
local m = self.Monsters[i] local m = self.Monsters[i]
if m ~= nil and m.alive == true then if m ~= nil and m.alive == true then
@@ -387,9 +405,14 @@ _TimerService:SetTimerOnce(function()
if m.hp <= 0 then if m.hp <= 0 then
m.hp = 0 m.hp = 0
self:KillMonster(m.slot) self:KillMonster(m.slot)
killCount = killCount + 1
end end
end 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:RenderCombat() self:RenderCombat()
self:CheckCombatEnd() self:CheckCombatEnd()
end, 0.35)`, [ end, 0.35)`, [

View File

@@ -38,7 +38,7 @@ end`),
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]), ]),
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return return
end end
if slot ~= 0 and self.RewardChoices ~= nil then if slot ~= 0 and self.RewardChoices ~= nil then
@@ -47,6 +47,11 @@ if slot ~= 0 and self.RewardChoices ~= nil then
table.insert(self.RunDeck, id) table.insert(self.RunDeck, id)
end end
end end
if self.BonusRewardScreens ~= nil and self.BonusRewardScreens > 0 and slot ~= 0 then
self.BonusRewardScreens = self.BonusRewardScreens - 1
self:OfferReward()
return
end
local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud") local hud = _EntityService:GetEntityByPath("/ui/RunUIGroup/RewardHud")
if hud ~= nil then if hud ~= nil then
hud.Enable = false hud.Enable = false

View File

@@ -79,6 +79,8 @@ self.PlayerThorns = 0
self.PlayerWeak = 0 self.PlayerWeak = 0
self.PlayerVuln = 0 self.PlayerVuln = 0
self.PlayerIntangible = 0 self.PlayerIntangible = 0
self.BonusRewardScreens = 0
self.ActiveKillReward = 0
self.PlayerPowers = {} self.PlayerPowers = {}
self.FightAttackCount = 0 self.FightAttackCount = 0
self.TurnAttackCardsPlayed = 0 self.TurnAttackCardsPlayed = 0

View File

@@ -166,6 +166,7 @@ function luaCardsTable(cards) {
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`); if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`); if (c.cardPlayedDamage != null) fields.push(`cardPlayedDamage = ${c.cardPlayedDamage}`);
if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`); if (c.cardPlayedRandomDamage != null) fields.push(`cardPlayedRandomDamage = ${c.cardPlayedRandomDamage}`);
if (c.rewardOnKill != null) fields.push(`rewardOnKill = ${c.rewardOnKill}`);
if (c.intangible != null) fields.push(`intangible = ${c.intangible}`); if (c.intangible != null) fields.push(`intangible = ${c.intangible}`);
if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`); if (c.endTurnDexLoss != null) fields.push(`endTurnDexLoss = ${c.endTurnDexLoss}`);
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`); if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);