From 811c8ec2ac87b6a61119c7c6fb9fb7ec92dac157 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 12 Jun 2026 13:51:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(magician):=20=EB=8F=85=20DoT=C2=B7AoE?= =?UTF-8?q?=C2=B7=ED=9A=8C=EB=B3=B5=C2=B7=EB=93=9C=EB=A1=9C=20=EB=A9=94?= =?UTF-8?q?=EC=BB=A4=EB=8B=88=EC=A6=98=20(=EC=83=9D=EC=84=B1=EA=B8=B0+?= =?UTF-8?q?=EC=8B=9C=EB=AE=AC=20loadData)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- tools/balance/sim-balance.mjs | 3 +- tools/deck/gen-slaydeck.mjs | 118 +++++++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/tools/balance/sim-balance.mjs b/tools/balance/sim-balance.mjs index 5f4c910..eaefe4d 100644 --- a/tools/balance/sim-balance.mjs +++ b/tools/balance/sim-balance.mjs @@ -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은 밸런스 추정용 자동 플레이 휴리스틱일 뿐 diff --git a/tools/deck/gen-slaydeck.mjs b/tools/deck/gen-slaydeck.mjs index 5689279..0ff0f1b 100644 --- a/tools/deck/gen-slaydeck.mjs +++ b/tools/deck/gen-slaydeck.mjs @@ -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