Add exhaust pile and restore keyword tooltips

This commit is contained in:
2026-06-15 23:34:26 +09:00
parent 72370aab23
commit bda35eefc7
6 changed files with 840 additions and 52 deletions

View File

@@ -108,6 +108,7 @@ export function simulateCombat(data, rng, stats) {
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
let drawPile = shuffle(starterDeck, rng);
let discard = [];
const exhaust = [];
let hand = [];
let pHp = PLAYER_HP, pBlock = 0;
let pStr = 0, pWeak = 0, pVuln = 0;
@@ -222,7 +223,8 @@ export function simulateCombat(data, rng, stats) {
energy -= c.cost;
resolveCardEffects(id, c, c.cost);
hand.splice(idx, 1);
if (c.kind !== 'Power') discard.push(id);
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
else if (c.kind !== 'Power') discard.push(id);
applyDiscardEffects(c);
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
}

View File

@@ -405,3 +405,16 @@ test("simulateCombat: retain keeps card in hand across turns", () => {
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test("simulateCombat: exhaust cards do not return through discard reshuffle", () => {
const data = {
cards: {
BurnOut: { name: "BurnOut", cost: 1, kind: "Attack", damage: 10, exhaust: true },
},
starterDeck: ["BurnOut"],
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Defend", value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.equal(r.draw, true);
});

View File

@@ -164,6 +164,7 @@ function luaCardsTable(cards) {
if (c.discardAll === true) fields.push('discardAll = 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');
@@ -699,6 +700,7 @@ function upsertUi() {
for (const pile of [
{ key: 'DrawPile', x: -590, label: '뽑을 덱', count: '10', color: { r: 0.17, g: 0.20, b: 0.25, a: 1 } },
{ key: 'ExhaustPile', x: 430, label: '소멸 덱', count: '0', color: { r: 0.13, g: 0.13, b: 0.18, a: 1 } },
{ key: 'DiscardPile', x: 590, label: '버린 덱', count: '0', color: { r: 0.22, g: 0.18, b: 0.16, a: 1 } },
]) {
add(entity({
@@ -707,7 +709,7 @@ function upsertUi() {
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: pile.key === 'DrawPile' ? 0 : 1,
displayOrder: pile.key === 'DrawPile' ? 0 : pile.key === 'ExhaustPile' ? 1 : 2,
components: [
transform({ parentW: 1280, parentH: 330, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 132, y: 186 }, pos: { x: pile.x, y: 8 }, align: ALIGN_CENTER }),
sprite({ color: pile.color, type: 1, raycast: true }),
@@ -1445,7 +1447,7 @@ function upsertUi() {
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 20,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 80 }, pos: { x: 0, y: 400 }, align: ALIGN_CENTER }),
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 360, y: 150 }, pos: { x: 0, y: 400 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.08, a: 0.96 }, type: 1 }),
],
});
@@ -1458,7 +1460,7 @@ function upsertUi() {
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 300, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 28 }, pos: { x: 0, y: 18 } }),
transform({ parentW: 360, parentH: 150, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 332, y: 28 }, pos: { x: 0, y: 52 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 19, bold: true, color: GOLD, alignment: 4 }),
],
@@ -1470,9 +1472,9 @@ function upsertUi() {
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 300, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 284, y: 30 }, pos: { x: 0, y: -14 } }),
transform({ parentW: 360, parentH: 150, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 332, y: 102 }, pos: { x: 0, y: -18 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 15, bold: false, color: { r: 0.92, g: 0.92, b: 0.95, a: 1 }, alignment: 4 }),
text({ value: '', fontSize: 15, bold: false, color: { r: 0.92, g: 0.92, b: 0.95, a: 1 }, alignment: 0 }),
],
}));
const discardPrompt = entity({
@@ -2873,6 +2875,7 @@ function writeCodeblocks() {
const combat = codeblock('SlayDeckController', 'SlayDeckController', [
prop('any', 'DrawPile'),
prop('any', 'DiscardPile'),
prop('any', 'ExhaustPile'),
prop('any', 'Hand'),
prop('number', 'Energy', '0'),
prop('number', 'MaxEnergy', '3'),
@@ -2901,6 +2904,7 @@ function writeCodeblocks() {
prop('string', 'SelectedClass', '""'),
prop('any', 'DrawPileHandler'),
prop('any', 'DiscardPileHandler'),
prop('any', 'ExhaustPileHandler'),
prop('any', 'DeckInspectCloseHandler'),
prop('any', 'AllDeckHandler'),
prop('any', 'AllDeckCloseHandler'),
@@ -3458,6 +3462,7 @@ self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.CombatOver = false
self.DiscardPile = {}
self.ExhaustPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
@@ -3610,6 +3615,14 @@ if discardPile ~= nil and discardPile.ButtonComponent ~= nil then
end
self.DiscardPileHandler = discardPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("discard") end)
end
local exhaustPile = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/ExhaustPile")
if exhaustPile ~= nil and exhaustPile.ButtonComponent ~= nil then
if self.ExhaustPileHandler ~= nil then
exhaustPile:DisconnectEvent(ButtonClickEvent, self.ExhaustPileHandler)
self.ExhaustPileHandler = nil
end
self.ExhaustPileHandler = exhaustPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("exhaust") end)
end
local inspectClose = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud/Close")
if inspectClose ~= nil and inspectClose.ButtonComponent ~= nil then
if self.DeckInspectCloseHandler ~= nil then
@@ -3880,8 +3893,9 @@ for i = 1, #self.DiscardPile do
end
self.DiscardPile = {}
self:Shuffle(self.DrawPile)`),
method('RenderPiles', `self:SetText("/ui/DefaultGroup/DeckHud/DrawPile/Count", tostring(#self.DrawPile))
self:SetText("/ui/DefaultGroup/DeckHud/DiscardPile/Count", tostring(#self.DiscardPile))
method('RenderPiles', `self:SetText("/ui/DefaultGroup/DeckHud/DrawPile/Count", self:FormatNumber(#self.DrawPile))
self:SetText("/ui/DefaultGroup/DeckHud/DiscardPile/Count", self:FormatNumber(#self.DiscardPile))
self:SetText("/ui/DefaultGroup/DeckHud/ExhaustPile/Count", self:FormatNumber(#(self.ExhaustPile or {})))
self:SetText("/ui/DefaultGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
local inspect = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud")
if inspect ~= nil and inspect.Enable == true and self.DeckInspectKind ~= "" then
@@ -3900,6 +3914,9 @@ local title = ""
if kind == "discard" then
pile = self.DiscardPile or {}
title = "버린 덱"
elseif kind == "exhaust" then
pile = self.ExhaustPile or {}
title = "소멸 덱"
else
pile = self.DrawPile or {}
title = "뽑을 덱"
@@ -4274,6 +4291,17 @@ end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' },
]),
method('FormatNumber', `if value == nil then
return ""
end
local n = tonumber(value)
if n == nil then
return tostring(value)
end
if math.abs(n - math.floor(n)) < 0.00001 then
return string.format("%d", math.floor(n))
end
return tostring(n)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' }], 0, 'string'),
method('AnimateCardFrom', `local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if cardEntity == nil or cardEntity.UITransformComponent == nil then
\treturn
@@ -4409,7 +4437,7 @@ if e == nil then
end
if self:IsDiscardSelecting() == true then
local picked = self.DiscardSelectTotal - self.DiscardSelectRemaining
self:SetText("/ui/DefaultGroup/CombatHud/DiscardPrompt", "버릴 카드 선택 " .. tostring(picked + 1) .. "/" .. tostring(self.DiscardSelectTotal))
self:SetText("/ui/DefaultGroup/CombatHud/DiscardPrompt", "버릴 카드 선택 " .. self:FormatNumber(picked + 1) .. "/" .. self:FormatNumber(self.DiscardSelectTotal))
e.Enable = true
else
e.Enable = false
@@ -4484,7 +4512,10 @@ end
self.Energy = self.Energy - c.cost
self:ResolveCardEffects(cardId, c, false)
table.remove(self.Hand, slot)
if c.kind ~= "Power" then
if c.exhaust == true then
if self.ExhaustPile == nil then self.ExhaustPile = {} end
table.insert(self.ExhaustPile, cardId)
elseif c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId)
end
self:RenderHand(false)
@@ -4855,6 +4886,7 @@ end
_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)`),
method('ClearCombatCards', `self.DrawPile = {}
self.DiscardPile = {}
self.ExhaustPile = {}
self.Hand = {}
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
@@ -5572,6 +5604,58 @@ if count > 10 then
of = "+" .. tostring(count - 9)
end
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/RelicOverflow", of)`),
method('BuildCardKeywordTooltip', `if c == nil then
return ""
end
local lines = {}
local function add(name, desc)
for i = 1, #lines do
if string.find(lines[i], name .. ":", 1, true) == 1 then
return
end
end
table.insert(lines, name .. ": " .. desc)
end
local cardDesc = c.desc or ""
if c.sly == true or string.find(cardDesc, "교활", 1, true) ~= nil then
add("교활", "버려지면 비용 없이 사용됩니다.")
end
if c.retain == true or string.find(cardDesc, "보존", 1, true) ~= nil then
add("보존", "턴 종료 시 버려지지 않고 손에 남습니다.")
end
if c.exhaust == true or string.find(cardDesc, "소멸.", 1, true) ~= nil then
add("소멸", "사용 후 소멸 덱으로 이동해 이번 전투 동안 다시 나오지 않습니다.")
end
if string.find(cardDesc, "선천성", 1, true) ~= nil then
add("선천성", "전투 시작 시 손패에 들어옵니다.")
end
if c.vuln ~= nil and c.vuln > 0 then
add("취약", "받는 공격 피해가 50% 증가합니다.")
end
if c.weak ~= nil and c.weak > 0 then
add("약화", "주는 공격 피해가 25% 감소합니다.")
end
if c.poison ~= nil and c.poison > 0 then
add("중독", "턴 시작 시 체력을 잃고 수치가 1 감소합니다.")
end
if c.pierce == true then
add("관통", "방어도를 무시하고 피해를 줍니다.")
end
if c.aoe == true then
add("전체", "모든 적에게 적용됩니다.")
end
if c.kind == "Power" then
add("파워", "사용하면 전투 동안 지속 효과로 남습니다.")
end
if c.unplayable == true then
add("저주", "사용할 수 없고 손패를 방해합니다.")
end
local out = ""
for i = 1, #lines do
if i > 1 then out = out .. "\\n" end
out = out .. lines[i]
end
return out`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'string'),
method('HoverCard', `if self.DragSlot ~= nil and self.DragSlot > 0 then
return
end
@@ -5587,25 +5671,40 @@ if e ~= nil and e.UITransformComponent ~= nil then
end
local c = self.Cards[cardId]
if c ~= nil then
self:ShowTooltip(c.name, c.desc, tx)
local tip = self:BuildCardKeywordTooltip(c)
if tip ~= "" then
local tipX = tx + 270
if tx > 180 then tipX = tx - 270 end
if tipX > 760 then tipX = tx - 270 end
if tipX < -760 then tipX = tx + 270 end
self:ShowTooltipAt("키워드", tip, tipX, 90)
else
self:HideTooltip()
end
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('UnhoverCard', `local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
end
self:HideTooltip()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('ShowTooltip', `self:SetText("/ui/DefaultGroup/CombatHud/TooltipBox/Name", name)
method('ShowTooltip', `self:ShowTooltipAt(name, desc, x, 400)`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'name' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'x' },
]),
method('ShowTooltipAt', `self:SetText("/ui/DefaultGroup/CombatHud/TooltipBox/Name", name)
self:SetText("/ui/DefaultGroup/CombatHud/TooltipBox/Desc", desc)
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/TooltipBox")
if e ~= nil then
if e.UITransformComponent ~= nil then
e.UITransformComponent.anchoredPosition = Vector2(x, 400)
e.UITransformComponent.anchoredPosition = Vector2(x, y)
end
e.Enable = true
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'name' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'desc' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'x' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'y' },
]),
method('HideTooltip', `self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/TooltipBox", false)`),
method('ShowMap', `self:ShowState("map")