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:
@@ -59,7 +59,8 @@ export function loadData() {
|
||||
if (!e) throw new Error(`simEncounter 적 없음: ${id}`);
|
||||
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은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
|
||||
|
||||
@@ -4,9 +4,32 @@ const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||||
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
|
||||
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
|
||||
for (const id of CARDS.starterDeck) {
|
||||
if (!CARDS.cards[id]) {
|
||||
throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`);
|
||||
const CLASSES = {
|
||||
warrior: { label: '전사', maxHp: 80 },
|
||||
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]) {
|
||||
@@ -73,6 +96,10 @@ function luaCardsTable(cards) {
|
||||
if (c.hits != null) fields.push(`hits = ${c.hits}`);
|
||||
if (c.pierce === true) fields.push('pierce = true');
|
||||
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)}`);
|
||||
return `\t${id} = { ${fields.join(', ')} },`;
|
||||
});
|
||||
@@ -2474,12 +2501,17 @@ end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
|
||||
{ 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.Gold = 0
|
||||
self.Floor = 1
|
||||
self.RunLength = ${ACT_COUNT}
|
||||
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
|
||||
self.RunActive = true
|
||||
self.RunRelics = {}
|
||||
self.RunPotions = {}
|
||||
@@ -2580,7 +2612,7 @@ for i = 1, n do
|
||||
end
|
||||
local maxHp = math.floor(e.maxHp * mult)
|
||||
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 }
|
||||
self:ReviveMonsterEntity(item.entity)
|
||||
self:PositionMonsterSlot(i)
|
||||
@@ -3062,7 +3094,11 @@ if c.kind == "Attack" then
|
||||
for h = 1, hitN do
|
||||
total = total + self:CalcPlayerAttack(c.damage)
|
||||
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
|
||||
if c.block ~= nil then
|
||||
self.PlayerBlock = self.PlayerBlock + c.block
|
||||
@@ -3083,10 +3119,14 @@ end
|
||||
if c.selfVuln ~= nil then
|
||||
self.PlayerVuln = self.PlayerVuln + c.selfVuln
|
||||
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]
|
||||
if tm ~= nil and tm.alive == true then
|
||||
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
|
||||
tm.vuln = tm.vuln + c.vuln
|
||||
if self:HasRelic("championBelt") then
|
||||
@@ -3099,6 +3139,9 @@ table.remove(self.Hand, slot)
|
||||
if c.kind ~= "Power" then
|
||||
table.insert(self.DiscardPile, cardId)
|
||||
end
|
||||
if c.draw ~= nil then
|
||||
self:DrawCards(c.draw)
|
||||
end
|
||||
self:RenderHand(false)
|
||||
self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
@@ -3241,6 +3284,46 @@ end, 0.35)`, [
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
|
||||
{ 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]
|
||||
if m == nil then
|
||||
return
|
||||
@@ -3301,6 +3384,19 @@ local m = self.Monsters[idx]
|
||||
local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx)
|
||||
self:SetEntityEnabled(base .. "/ActFrame", true)
|
||||
_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
|
||||
local intent = m.intents[m.intentIdx]
|
||||
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 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 poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
|
||||
return table.concat(parts, " ")`, [
|
||||
{ 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: 'vuln' },
|
||||
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
|
||||
], 0, 'string'),
|
||||
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
|
||||
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:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
|
||||
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
|
||||
self:SetEntityEnabled(base, false)
|
||||
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:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
|
||||
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
|
||||
local names = {}
|
||||
for i = 1, #self.PlayerPowers do
|
||||
|
||||
Reference in New Issue
Block a user