표창 카드 손패 생성 구현

This commit is contained in:
2026-06-16 02:19:22 +09:00
parent b23dc3868e
commit 62187db5dd
5 changed files with 178 additions and 51 deletions

File diff suppressed because one or more lines are too long

View File

@@ -417,6 +417,17 @@
"desc": "피해를 6 줍니다.",
"damage": 6
},
"Shiv": {
"name": "표창",
"cost": 0,
"kind": "Attack",
"class": "shiv",
"rarity": "normal",
"desc": "피해를 4 줍니다. 소멸.",
"damage": 4,
"exhaust": true,
"token": true
},
"DaggerSpray": {
"name": "단검 분사",
"cost": 1,
@@ -465,8 +476,9 @@
"kind": "Attack",
"class": "bandit",
"rarity": "normal",
"desc": "피해를 3 줍니다. 단도를 2장 손으로 가져옵니다.",
"damage": 3
"desc": "피해를 3 줍니다. 표창을 2장 손으로 가져옵니다.",
"damage": 3,
"addShiv": 2
},
"FollowThrough": {
"name": "완수",
@@ -530,12 +542,12 @@
"BladeDance": {
"name": "검무",
"cost": 1,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "normal",
"desc": "단도를 3장 손으로 가져옵니다. 소멸.",
"damage": 4,
"hits": 3
"desc": "표창을 3장 손으로 가져옵니다. 소멸.",
"addShiv": 3,
"exhaust": true
},
"Backflip": {
"name": "공중제비",
@@ -568,13 +580,12 @@
"CloakAndDagger": {
"name": "망토와 단검",
"cost": 1,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "normal",
"desc": "방어도를 6 얻습니다. 단도를 1장 손으로 가져옵니다.",
"desc": "방어도를 6 얻습니다. 표창을 1장 손으로 가져옵니다.",
"block": 6,
"damage": 4,
"hits": 1
"addShiv": 1
},
"DeadlyPoison": {
"name": "맹독",
@@ -727,13 +738,12 @@
"HiddenDaggers": {
"name": "숨겨진 단검",
"cost": 0,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "unique",
"desc": "카드를 2장 버립니다. 단도를 2장 손으로 가져옵니다.",
"damage": 4,
"hits": 2,
"discard": 2
"desc": "카드를 2장 버립니다. 표창을 2장 손으로 가져옵니다.",
"discard": 2,
"addShiv": 2
},
"EscapePlan": {
"name": "탈출구",
@@ -813,12 +823,11 @@
"UpMySleeve": {
"name": "비책",
"cost": 2,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "unique",
"desc": "단도를 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
"damage": 4,
"hits": 3
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
"addShiv": 3
},
"BouncingFlask": {
"name": "탄성 플라스크",
@@ -873,12 +882,11 @@
"InfiniteBlades": {
"name": "무한의 검날",
"cost": 1,
"kind": "Attack",
"kind": "Power",
"class": "bandit",
"rarity": "unique",
"desc": "내 턴 시작 시, 단도를 1장 손으로 가져옵니다.",
"damage": 4,
"hits": 1
"desc": "내 턴 시작 시, 표창을 1장 손으로 가져옵니다.",
"turnStartShiv": 1
},
"Footwork": {
"name": "발놀림",
@@ -918,7 +926,7 @@
"kind": "Power",
"class": "bandit",
"rarity": "unique",
"desc": "단도의 피해량이 4 증가합니다.",
"desc": "표창의 피해량이 4 증가합니다.",
"powerEffect": "strengthPerTurn",
"value": 1
},
@@ -928,7 +936,7 @@
"kind": "Power",
"class": "bandit",
"rarity": "unique",
"desc": "단도가 보존을 얻습니다. 매 턴마다 처음으로 사용하는 단도의 피해량이 9 증가합니다.",
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
"powerEffect": "strengthPerTurn",
"value": 1
},
@@ -1018,9 +1026,9 @@
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 단도를 손으로 가져옵니다.",
"draw": 1,
"discardAll": true
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 표창을 손으로 가져옵니다.",
"discardAll": true,
"addShivPerDiscard": true
},
"ShadowStep": {
"name": "그림자 걸음",
@@ -1053,12 +1061,11 @@
"BladeOfInk": {
"name": "잉크 칼날",
"cost": 1,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "잉크투성이 단도를 2장 손으로 가져옵니다.",
"damage": 4,
"hits": 2
"desc": "잉크투성이 표창을 2장 손으로 가져옵니다.",
"addShiv": 2
},
"Burst": {
"name": "폭주",
@@ -1075,7 +1082,7 @@
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 단도를 사용합니다.",
"desc": "대상 적에게 소멸된 카드 더미에 있는 모든 표창을 사용합니다.",
"draw": 1
},
"BulletTime": {
@@ -1164,14 +1171,13 @@
"FanOfKnives": {
"name": "칼날 부채",
"cost": 2,
"kind": "Attack",
"kind": "Skill",
"class": "bandit",
"rarity": "legend",
"desc": "단도가 이제 모든 적을 대상으로 합니다. 단도를 4장 손으로 가져옵니다.",
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
"powerEffect": "strengthPerTurn",
"value": 1,
"damage": 4,
"hits": 4
"addShiv": 4
},
"SerpentForm": {
"name": "구렁이의 형상",

View File

@@ -131,6 +131,12 @@ export function simulateCombat(data, rng, stats) {
} else hand.push(card);
}
}
function addCardsToHand(id, n) {
for (let k = 0; k < n; k++) {
if (hand.length >= 10) discard.push(id);
else hand.push(id);
}
}
const aliveList = () => mob.filter((m) => m.alive);
function resolveCardEffects(id, c, costSpent, recordStats = true) {
const alive = aliveList();
@@ -166,7 +172,7 @@ export function simulateCombat(data, rng, stats) {
}
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
} else if (c.kind === 'Power') {
if (c.powerEffect && recordStats) powers.push(id);
if (recordStats) powers.push(id);
} else {
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
if ((c.weak || c.vuln || c.poison) && alive.length) {
@@ -182,6 +188,7 @@ export function simulateCombat(data, rng, stats) {
if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (c.draw) draw(c.draw);
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
}
function triggerSly(id) {
@@ -196,12 +203,15 @@ export function simulateCombat(data, rng, stats) {
if (trigger) triggerSly(id);
}
function applyDiscardEffects(c) {
let discarded = 0;
if (c.discardAll) {
while (hand.length) discardHandCard(hand.length - 1, true);
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
} else if (c.discard) {
const n = Math.min(c.discard, hand.length);
for (let i = 0; i < n; i++) discardHandCard(hand.length - 1, true);
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
}
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
}
while (turns < MAX_TURNS) {
@@ -215,6 +225,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;
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
}
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
while (true) {

View File

@@ -447,3 +447,17 @@ test("simulateCombat: thorns reflects unblocked attack damage", () => {
assert.equal(r.turns, 1);
assert.equal(r.playerHpRemaining, 79);
});
test("simulateCombat: addShiv creates shuriken cards in hand", () => {
const data = {
cards: {
MakeShiv: { name: "MakeShiv", cost: 0, kind: "Skill", addShiv: 2 },
Shiv: { name: "표창", cost: 0, kind: "Attack", damage: 4, exhaust: true },
},
starterDeck: ["MakeShiv"],
monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});

View File

@@ -173,12 +173,16 @@ function luaCardsTable(cards) {
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');
if (c.addShiv != null) fields.push(`addShiv = ${c.addShiv}`);
if (c.turnStartShiv != null) fields.push(`turnStartShiv = ${c.turnStartShiv}`);
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
if (c.sly === true) fields.push('sly = true');
if (c.retain === true) fields.push('retain = true');
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) fields.push('exhaust = true');
if (c.aoe === true) fields.push('aoe = true');
if (c.unplayable === true) fields.push('unplayable = true');
if (c.curse === true) fields.push('curse = true');
if (c.token === true) fields.push('token = true');
if (c.endTurnDamage != null) fields.push(`endTurnDamage = ${c.endTurnDamage}`);
if (c.fx != null) fields.push(`fx = ${luaStr(c.fx)}`);
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
@@ -3001,6 +3005,8 @@ function writeCodeblocks() {
prop('string', 'PlayerJob', '""'),
prop('number', 'DiscardSelectRemaining', '0'),
prop('number', 'DiscardSelectTotal', '0'),
prop('number', 'DiscardPostShiv', '0'),
prop('number', 'DiscardShivPerPick', '0'),
], [
method('OnBeginPlay', `${luaCardsTable(CARDS.cards)}
${luaFramesTable()}
@@ -3515,6 +3521,8 @@ self.FirstHpLossDone = false
self.ClayBlockNext = 0
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self.CombatOver = false
self.DiscardPile = {}
self.ExhaustPile = {}
@@ -3868,6 +3876,9 @@ if self.PlayerPowers ~= nil then
elseif pc.powerEffect == "blockPerTurn" then
self.PlayerBlock = self.PlayerBlock + pc.value
end
if pc.turnStartShiv ~= nil then
self:AddCardsToHand("Shiv", pc.turnStartShiv)
end
end
end
end
@@ -3939,6 +3950,24 @@ end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
]),
method('AddCardsToHand', `if self.Hand == nil then
self.Hand = {}
end
if self.DiscardPile == nil then
self.DiscardPile = {}
end
for i = 1, amount do
if #self.Hand >= 10 then
table.insert(self.DiscardPile, cardId)
else
table.insert(self.Hand, cardId)
end
end
self:RenderHand(false)
self:RenderPiles()`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('RecycleDiscardIntoDraw', `if self.DiscardPile == nil or #self.DiscardPile <= 0 then
\treturn
end
@@ -4435,7 +4464,7 @@ elseif c.kind == "Skill" then
self:AddCardBlock(c.block)
end
elseif c.kind == "Power" then
if c.powerEffect ~= nil and free ~= true then
if free ~= true then
table.insert(self.PlayerPowers, cardId)
end
end
@@ -4474,6 +4503,9 @@ if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
end
if c.draw ~= nil then
self:DrawCards(c.draw, true)
end
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
self:AddCardsToHand("Shiv", c.addShiv)
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
@@ -4526,12 +4558,26 @@ if n <= 0 then
end
self.DiscardSelectRemaining = n
self.DiscardSelectTotal = n
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
if c.addShiv ~= nil then
self.DiscardPostShiv = c.addShiv
end
if c.addShivPerDiscard == true then
self.DiscardShivPerPick = 1
end
self:UpdateDiscardPrompt()
self:Toast("버릴 카드를 선택하세요")
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
method('FinishDiscardSelection', `self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
local shivCount = self.DiscardPostShiv or 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self:UpdateDiscardPrompt()
if shivCount > 0 then
self:AddCardsToHand("Shiv", shivCount)
end
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
@@ -4542,7 +4588,11 @@ end
if self.Hand == nil or self.Hand[slot] == nil then
return true
end
local discarded = self.Hand[slot]
self:DiscardHandCard(slot, true)
if discarded ~= nil and self.DiscardShivPerPick ~= nil and self.DiscardShivPerPick > 0 then
self.DiscardPostShiv = (self.DiscardPostShiv or 0) + self.DiscardShivPerPick
end
self.DiscardSelectRemaining = self.DiscardSelectRemaining - 1
if self.DiscardSelectRemaining <= 0 or #self.Hand <= 0 then
self:FinishDiscardSelection()
@@ -4965,6 +5015,8 @@ self.ExhaustPile = {}
self.Hand = {}
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self:UpdateDiscardPrompt()
self:RenderHand(false)
self:RenderPiles()`),
@@ -5433,7 +5485,7 @@ self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Floor", floorText)
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
method('CardPool', `local pool = {}
for id, c in pairs(self.Cards) do
if c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob) then
if c.token ~= true and (c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob)) then
table.insert(pool, id)
end
end