feat(magician): 독 DoT·AoE·회복·드로 메커니즘 (생성기+시뮬 loadData)

- PlayCard: aoe→PlayAoeFx(전 생존 적 각자 취약/방어·슬롯별 팝업), heal/draw/poison
- EnemyActStep 행동 시작 독 틱(피해 팝업·사망 시 행동 생략·체인 계속)
- BuffsLabel 독N 표시, 클래스별 StartRun(HP 80/70·시작 덱), JOBS 상수·검증
- sim loadData: starterDecks.warrior 사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:51:15 +09:00
parent 6b6037739f
commit 811c8ec2ac
2 changed files with 110 additions and 11 deletions

View File

@@ -59,7 +59,8 @@ export function loadData() {
if (!e) throw new Error(`simEncounter 적 없음: ${id}`); if (!e) throw new Error(`simEncounter 적 없음: ${id}`);
return { name: e.name, maxHp: e.maxHp, intents: e.intents }; return { name: e.name, maxHp: e.maxHp, intents: e.intents };
}); });
return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, monsters }; // 시뮬 기본 덱은 전사 시작 덱 (클래스별 시뮬은 starterDeck 직접 주입으로 가능)
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
} }
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐 // 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐

View File

@@ -4,9 +4,32 @@ const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8')); const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
// 검증 (fail-fast): 잘못된 데이터면 생성 중단 // 검증 (fail-fast): 잘못된 데이터면 생성 중단
for (const id of CARDS.starterDeck) { const CLASSES = {
if (!CARDS.cards[id]) { warrior: { label: '전사', maxHp: 80 },
throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`); magician: { label: '마법사', maxHp: 70 },
};
for (const cls of Object.keys(CLASSES)) {
if (!CARDS.starterDecks?.[cls]) throw new Error(`[gen-slaydeck] starterDecks.${cls} 없음`);
for (const id of CARDS.starterDecks[cls]) {
if (!CARDS.cards[id]) throw new Error(`[gen-slaydeck] starterDecks.${cls}에 없는 카드 id 참조: ${id}`);
}
}
// 전직 옵션 (클래스별 2차 — JobSelectHud 동적 구성·SetJob 대표 카드)
const JOBS = {
warrior: [
{ id: 'fighter', name: '파이터', desc: '공격 특화\\n콤보 어택 · 버서크\\n라이징 어택', starter: 'ComboAttack' },
{ id: 'page', name: '페이지', desc: '속성 차지 특화\\n썬더/블리자드 차지\\n파워 가드', starter: 'ThunderCharge' },
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\\n피어스 · 아이언 월\\n하이퍼 바디', starter: 'Pierce' },
],
magician: [
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\\n파이어 애로우\\n포이즌 브레스 · 앰플', starter: 'FireArrow' },
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\\n썬더 볼트(전체)\\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt' },
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\\n힐 · 블레스\\n홀리 애로우', starter: 'Heal' },
],
};
for (const [cls, jobs] of Object.entries(JOBS)) {
for (const j of jobs) {
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
} }
} }
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) { if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
@@ -73,6 +96,10 @@ function luaCardsTable(cards) {
if (c.hits != null) fields.push(`hits = ${c.hits}`); if (c.hits != null) fields.push(`hits = ${c.hits}`);
if (c.pierce === true) fields.push('pierce = true'); if (c.pierce === true) fields.push('pierce = true');
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`); if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
if (c.draw != null) fields.push(`draw = ${c.draw}`);
if (c.heal != null) fields.push(`heal = ${c.heal}`);
if (c.poison != null) fields.push(`poison = ${c.poison}`);
if (c.aoe === true) fields.push('aoe = true');
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`); if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
return `\t${id} = { ${fields.join(', ')} },`; return `\t${id} = { ${fields.join(', ')} },`;
}); });
@@ -2474,12 +2501,17 @@ end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' }, { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enabled' }, { Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enabled' },
]), ]),
method('StartRun', `self.PlayerMaxHp = 80 method('StartRun', `if self.SelectedClass == "magician" then
self.PlayerMaxHp = ${CLASSES.magician.maxHp}
self.RunDeck = { ${CARDS.starterDecks.magician.map(luaStr).join(', ')} }
else
self.PlayerMaxHp = ${CLASSES.warrior.maxHp}
self.RunDeck = { ${CARDS.starterDecks.warrior.map(luaStr).join(', ')} }
end
self.PlayerHp = self.PlayerMaxHp self.PlayerHp = self.PlayerMaxHp
self.Gold = 0 self.Gold = 0
self.Floor = 1 self.Floor = 1
self.RunLength = ${ACT_COUNT} self.RunLength = ${ACT_COUNT}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true self.RunActive = true
self.RunRelics = {} self.RunRelics = {}
self.RunPotions = {} self.RunPotions = {}
@@ -2580,7 +2612,7 @@ for i = 1, n do
end end
local maxHp = math.floor(e.maxHp * mult) local maxHp = math.floor(e.maxHp * mult)
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name, self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0,
intents = intents, intentIdx = 1, alive = true, slot = i } intents = intents, intentIdx = 1, alive = true, slot = i }
self:ReviveMonsterEntity(item.entity) self:ReviveMonsterEntity(item.entity)
self:PositionMonsterSlot(i) self:PositionMonsterSlot(i)
@@ -3062,7 +3094,11 @@ if c.kind == "Attack" then
for h = 1, hitN do for h = 1, hitN do
total = total + self:CalcPlayerAttack(c.damage) total = total + self:CalcPlayerAttack(c.damage)
end end
self:PlayAttackFx(self.TargetIndex, c.image, total, c.pierce == true) if c.aoe == true then
self:PlayAoeFx(c.image, total)
else
self:PlayAttackFx(self.TargetIndex, c.image, total, c.pierce == true)
end
end end
if c.block ~= nil then if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block self.PlayerBlock = self.PlayerBlock + c.block
@@ -3083,10 +3119,14 @@ end
if c.selfVuln ~= nil then if c.selfVuln ~= nil then
self.PlayerVuln = self.PlayerVuln + c.selfVuln self.PlayerVuln = self.PlayerVuln + c.selfVuln
end end
if c.weak ~= nil or c.vuln ~= nil then if c.heal ~= nil then
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
end
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
local tm = self.Monsters[self.TargetIndex] local tm = self.Monsters[self.TargetIndex]
if tm ~= nil and tm.alive == true then if tm ~= nil and tm.alive == true then
if c.weak ~= nil then tm.weak = tm.weak + c.weak end if c.weak ~= nil then tm.weak = tm.weak + c.weak end
if c.poison ~= nil then tm.poison = (tm.poison or 0) + c.poison end
if c.vuln ~= nil then if c.vuln ~= nil then
tm.vuln = tm.vuln + c.vuln tm.vuln = tm.vuln + c.vuln
if self:HasRelic("championBelt") then if self:HasRelic("championBelt") then
@@ -3099,6 +3139,9 @@ table.remove(self.Hand, slot)
if c.kind ~= "Power" then if c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId) table.insert(self.DiscardPile, cardId)
end end
if c.draw ~= nil then
self:DrawCards(c.draw)
end
self:RenderHand(false) self:RenderHand(false)
self:RenderPiles() self:RenderPiles()
self:RenderCombat() self:RenderCombat()
@@ -3241,6 +3284,46 @@ end, 0.35)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' }, { Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
]), ]),
method('PlayAoeFx', `self.FxBusy = true
local fx = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/SkillFx")
if fx ~= nil then
if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then
fx.SpriteGUIRendererComponent.ImageRUID = image
end
if fx.UITransformComponent ~= nil then
fx.UITransformComponent.anchoredPosition = Vector2(300, 60)
end
fx.Enable = true
end
_TimerService:SetTimerOnce(function()
if fx ~= nil then fx.Enable = false end
self.FxBusy = false
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
local dmg = damage
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.block > 0 then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
dmg = dmg - absorbed
end
m.hp = m.hp - dmg
self:ShowDmgPop(i, dmg)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end
end
end
self:RenderCombat()
self:CheckCombatEnd()
end, 0.35)`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
]),
method('KillMonster', `local m = self.Monsters[slot] method('KillMonster', `local m = self.Monsters[slot]
if m == nil then if m == nil then
return return
@@ -3301,6 +3384,19 @@ local m = self.Monsters[idx]
local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx) local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx)
self:SetEntityEnabled(base .. "/ActFrame", true) self:SetEntityEnabled(base .. "/ActFrame", true)
_TimerService:SetTimerOnce(function() _TimerService:SetTimerOnce(function()
if m.poison ~= nil and m.poison > 0 then
m.hp = m.hp - m.poison
self:ShowDmgPop(idx, m.poison)
m.poison = m.poison - 1
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
self:RenderCombat()
self:SetEntityEnabled(base .. "/ActFrame", false)
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
return
end
end
m.block = 0 m.block = 0
local intent = m.intents[m.intentIdx] local intent = m.intents[m.intentIdx]
if intent ~= nil then if intent ~= nil then
@@ -3468,10 +3564,12 @@ _TimerService:SetTimerOnce(function() self:ShowMainMenu() end, 4)`, [{ Type: 'st
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
return table.concat(parts, " ")`, [ return table.concat(parts, " ")`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
], 0, 'string'), ], 0, 'string'),
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i)
@@ -3509,7 +3607,7 @@ return table.concat(parts, " ")`, [
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W}) self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0) self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block)) self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln)) self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
else else
self:SetEntityEnabled(base, false) self:SetEntityEnabled(base, false)
end end
@@ -3518,7 +3616,7 @@ self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/HpText", string.format("%d"
self:SetHpBar("/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220) self:SetHpBar("/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0) self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock)) self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln) local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
local names = {} local names = {}
for i = 1, #self.PlayerPowers do for i = 1, #self.PlayerPowers do