15 KiB
P7 — 물약 시스템·유물 강화 구현 계획
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: StS 풀세트 물약 6종 + 유물 19종(메이플 장비 외형·StS 효과) + 아이콘 행·마우스오버 툴팁 UI.
Architecture: data/potions.json 신설·relics.json 확장(icon RUID) → gen-slaydeck.mjs 생성부 확장(상태·효과 훅·물약 로직·TopBar 아이콘 UI·툴팁) → 산출물 재생성. 시뮬 변경 없음(물약·유물은 시뮬 범위 밖 — 기존 정책 동일).
Tech Stack: Node.js 생성기, MSW Lua, UITouchReceiveComponent(UITouchEnter/Exit/Down).
설계 문서: docs/superpowers/specs/2026-06-12-potions-relics-design.md
Task 1: 아이콘 RUID 선별 (메이커, 유물 19 + 물약 6)
- Step 1:
asset_search_resources(cat=sprite, source=maplestory) 검색어별 후보 5개: 투구/방패/벨트/목걸이/갑옷/반지/부츠/도끼/가방/부적/깃털/망치/심장/송곳니/우상/포션/엘릭서/병 - Step 2: P6과 동일한 SkillFx 복제 격자 미리보기로 스크린샷 → 유물 19·물약 6 아이콘 확정 (모자란 항목은 보조 검색어로 보충)
- Step 3: 미리보기 정리, 플레이 종료
Task 2: 데이터 — potions.json 신설·relics.json 확장
Files: Create data/potions.json, Modify data/relics.json
- Step 1:
data/potions.json작성 (icon은 Task 1 선별값)
{
"potions": {
"redPotion": { "name": "빨간 포션", "desc": "HP 20 회복", "effect": "heal", "value": 20, "icon": "<RUID>" },
"firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "<RUID>" },
"warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "<RUID>" },
"guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "<RUID>" },
"manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "<RUID>" },
"cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "<RUID>" }
},
"dropChance": 0.4,
"baseSlots": 3,
"beltSlots": 5,
"shopPrice": 20
}
- Step 2:
data/relics.json— 기존 4종에 icon 추가 + 신규 15종 (설계 표의 hook/effect/value, 전부 icon 포함). relicPool = 기존 3종 + 신규 15종 (ironHeart는 시작 유물).
"potionBelt": { "name": "장인의 벨트", "desc": "물약 슬롯이 5칸으로 늘어난다", "hook": "passive", "effect": "potionSlots", "value": 5 },
"burningBlood": { "name": "자쿰의 투구", "desc": "전투 승리 시 HP 6 회복", "hook": "combatEnd", "effect": "healOnWin", "value": 6 },
"vajra": { "name": "미스릴 액스", "desc": "전투 시작 시 힘 +1", "hook": "combatStart", "effect": "strength", "value": 1 },
"anchor": { "name": "메이플 실드", "desc": "첫 턴 방어도 +10", "hook": "combatStart", "effect": "block", "value": 10 },
"bagOfPrep": { "name": "모험가의 배낭", "desc": "첫 턴 드로우 +2", "hook": "combatStart", "effect": "draw", "value": 2 },
"bloodVial": { "name": "피의 목걸이", "desc": "전투 시작 시 HP 2 회복", "hook": "combatStart", "effect": "heal", "value": 2 },
"bronzeScales": { "name": "브론즈 체인메일", "desc": "피격 시 공격자에게 3 반사", "hook": "onPlayerDamaged", "effect": "thorns", "value": 3 },
"strawberry": { "name": "건강의 반지", "desc": "획득 시 최대 HP +7", "hook": "passive", "effect": "maxHp", "value": 7 },
"penNib": { "name": "황금 깃펜", "desc": "10번째 공격마다 피해 2배", "hook": "attackCalc", "effect": "penNib", "value": 10 },
"boot": { "name": "브론즈 부츠", "desc": "5 미만 공격 피해가 5로", "hook": "attackCalc", "effect": "boot", "value": 5 },
"akabeko": { "name": "황소 투구", "desc": "전투 첫 공격 피해 +8", "hook": "attackCalc", "effect": "akabeko", "value": 8 },
"centennialPuzzle": { "name": "백년의 부적", "desc": "전투 첫 피격 시 드로우 3", "hook": "onPlayerDamaged", "effect": "firstLossDraw", "value": 3 },
"meatOnBone": { "name": "고기 망치", "desc": "승리 시 HP 50% 이하면 12 회복","hook": "combatEnd", "effect": "healIfLow", "value": 12 },
"selfFormingClay": { "name": "점토 갑옷", "desc": "피해를 받으면 다음 턴 방어 +3","hook": "onPlayerDamaged", "effect": "clayBlock", "value": 3 },
"championBelt": { "name": "챔피언 벨트", "desc": "취약 부여 시 약화 1 추가", "hook": "cardDebuff", "effect": "vulnAddsWeak", "value": 1 }
- Step 3: JSON 파싱 확인 + 커밋
feat(potions-relics): 물약 6종·유물 15종 데이터 (아이콘 RUID 포함)
Task 3: 생성기 — 로드·직렬화·상태
Files: Modify tools/deck/gen-slaydeck.mjs
- Step 1: 상단에 potions 로드·검증 (RELICS 로드 다음)
const POTIONS = JSON.parse(readFileSync('data/potions.json', 'utf8'));
for (const [pid, p] of Object.entries(POTIONS.potions)) {
if (!p.name || !p.effect || p.value == null) throw new Error(`[gen-slaydeck] potion 필드 누락: ${pid}`);
}
function luaPotionsTable(potions) {
const lines = Object.entries(potions).map(([id, p]) =>
`\t${id} = { name = ${luaStr(p.name)}, desc = ${luaStr(p.desc)}, effect = ${luaStr(p.effect)}, value = ${p.value}, icon = ${luaStr(p.icon || '')} },`);
return `self.Potions = {\n${lines.join('\n')}\n}`;
}
- Step 2:
luaRelicsTable에icon = ${luaStr(r.icon || '')}필드 추가 - Step 3: props 추가 —
prop('any', 'Potions'),prop('any', 'RunPotions'),prop('number', 'PotionSlots', '3'),prop('string', 'ShopPotion', '""'),prop('boolean', 'ShopPotionBought', 'false'),prop('number', 'FightAttackCount', '0'),prop('boolean', 'FirstHpLossDone', 'false'),prop('number', 'ClayBlockNext', '0'),prop('number', 'PotionMenuSlot', '0') - Step 4:
StartRun에self.RunPotions = {}self.PotionSlots = ${POTIONS.baseSlots}${luaPotionsTable(POTIONS.potions)}추가 (RunRelics 초기화 옆) +self:RenderPotions()(BindButtons 후) - Step 5:
StartCombat에self.FightAttackCount = 0self.FirstHpLossDone = falseself.ClayBlockNext = 0리셋 추가
Task 4: 생성기 — 유물 효과 로직
Files: Modify tools/deck/gen-slaydeck.mjs
- Step 1:
HasRelic헬퍼 신설 (boolean 반환)
if self.RunRelics == nil then
return false
end
for i = 1, #self.RunRelics do
if self.RunRelics[i] == id then
return true
end
end
return false
- Step 2:
ApplyRelics확장 — 기존 effect에 추가:strength(PlayerStr += v),heal(HP 회복),draw(DrawCards(v) + RenderHand(false)),healOnWin(HP 회복),healIfLow(HP ≤ 50%면 회복) - Step 3:
AddRelic확장 — passive 즉시 적용
local r = self.Relics[id]
if r ~= nil and r.hook == "passive" then
if r.effect == "potionSlots" then
self.PotionSlots = r.value
self:RenderPotions()
elseif r.effect == "maxHp" then
self.PlayerMaxHp = self.PlayerMaxHp + r.value
self.PlayerHp = self.PlayerHp + r.value
end
end
- Step 4:
PickNewRelic신설 — 미보유 풀 추첨, 없으면 골드 +25 후 빈 문자열 반환. elite/boss 보상부의self:AddRelic(self.RelicPool[...])를local nid = self:PickNewRelic() if nid ~= "" then self:AddRelic(nid) end로 교체, boss 분기에도 동일 추가 - Step 5:
CalcPlayerAttack유물 보정 — 공격 카드에서만 호출되므로 내부에서 카운트
local base2 = base
self.FightAttackCount = self.FightAttackCount + 1
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then
base2 = base2 + 8
end
local dmg = base2 + self.PlayerStr
if self:HasRelic("penNib") and self.FightAttackCount % 10 == 0 then
dmg = dmg * 2
end
if self.PlayerWeak > 0 then
dmg = math.floor(dmg * 0.75)
end
if dmg > 0 and dmg < 5 and self:HasRelic("boot") then
dmg = 5
end
if dmg < 0 then
dmg = 0
end
return dmg
- Step 6:
DealDamageToPlayer에 attacker 인자 추가 + onPlayerDamaged 유물 (HP 실손실 시)
-- 시그니처: (amount, attackerSlot) — EnemyActStep 호출부에 idx 전달
local dmg = amount
if self.PlayerBlock > 0 then
local absorbed = math.min(self.PlayerBlock, dmg)
self.PlayerBlock = self.PlayerBlock - absorbed
dmg = dmg - absorbed
end
if dmg > 0 then
self.PlayerHp = self.PlayerHp - dmg
if self:HasRelic("bronzeScales") and attackerSlot ~= nil and attackerSlot > 0 then
local am = self.Monsters[attackerSlot]
if am ~= nil and am.alive == true then
am.hp = am.hp - 3
if am.hp <= 0 then am.hp = 0 self:KillMonster(am.slot) end
end
end
if self:HasRelic("selfFormingClay") then
self.ClayBlockNext = self.ClayBlockNext + 3
end
if self:HasRelic("centennialPuzzle") and self.FirstHpLossDone == false then
self.FirstHpLossDone = true
self:DrawCards(3)
self:RenderHand(false)
end
end
if self.PlayerHp < 0 then
self.PlayerHp = 0
end
- Step 7:
StartPlayerTurn—self.PlayerBlock = 0직후if self.ClayBlockNext > 0 then self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext self.ClayBlockNext = 0 end - Step 8:
PlayCard디버프 적용부 — championBelt:if c.vuln ~= nil and self:HasRelic("championBelt") then tm.weak = tm.weak + 1 end - Step 9:
CheckCombatEnd승리 분기 —self:ApplyRelics("combatReward")앞에self:ApplyRelics("combatEnd"), 뒤에 물약 드랍(Task 5의MaybeDropPotion) - Step 10: 커밋
feat(potions-relics): 유물 15종 효과 훅 (생성기)
Task 5: 생성기 — 물약 로직
Files: Modify tools/deck/gen-slaydeck.mjs
- Step 1: 메서드 신설 —
AddPotion(pid)(슬롯 검사·토스트),MaybeDropPotion()(math.random() <= dropChance시 랜덤 지급),RenderPotions()(슬롯 5칸: 아이콘/빈칸/잠금),OpenPotionMenu(slot)/ClosePotionMenu(),UsePotion(),TossPotion()
-- AddPotion(pid)
if self.RunPotions == nil then self.RunPotions = {} end
if #self.RunPotions >= self.PotionSlots then
self:Toast("물약 슬롯이 가득 찼습니다")
return false
end
table.insert(self.RunPotions, pid)
self:RenderPotions()
return true
-- MaybeDropPotion()
if math.random() > ${POTIONS.dropChance} then
return
end
local keys = {}
for pid, _ in pairs(self.Potions) do table.insert(keys, pid) end
table.sort(keys)
local pid = keys[math.random(1, #keys)]
if self:AddPotion(pid) == true then
local p = self.Potions[pid]
self:Toast("물약 획득: " .. p.name)
end
-- UsePotion() — PotionMenuSlot 대상. 전투 중이 아니면 무시.
local combat = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud")
if combat == nil or combat.Enable ~= true or self.CombatOver == true then
self:Toast("전투 중에만 사용할 수 있습니다")
return
end
local pid = self.RunPotions[self.PotionMenuSlot]
if pid == nil then return end
local p = self.Potions[pid]
if p == nil then return end
if p.effect == "heal" then
self.PlayerHp = math.min(self.PlayerHp + p.value, self.PlayerMaxHp)
elseif p.effect == "damage" then
self:DealDamageToTarget(p.value)
self:ShowDmgPop(self.TargetIndex, p.value)
elseif p.effect == "strength" then
self.PlayerStr = self.PlayerStr + p.value
elseif p.effect == "block" then
self.PlayerBlock = self.PlayerBlock + p.value
elseif p.effect == "energy" then
self.Energy = self.Energy + p.value
elseif p.effect == "weak" then
local tm = self.Monsters[self.TargetIndex]
if tm ~= nil and tm.alive == true then
tm.weak = tm.weak + p.value
end
end
table.remove(self.RunPotions, self.PotionMenuSlot)
self:ClosePotionMenu()
self:RenderPotions()
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()
- Step 2: 상점 —
ShowShop에self.ShopPotion = <정렬 키 랜덤>self.ShopPotionBought = false,RenderShop에 Potion 라벨/가격/색,BuyPotion(가격 ${POTIONS.shopPrice}, AddPotion 실패 시 환불 없음 방지 — 슬롯 검사 먼저) - Step 3: 커밋
feat(potions-relics): 물약 사용·드랍·상점 로직 (생성기)
Task 6: 생성기 — UI (아이콘 행·물약 슬롯·툴팁·물약 메뉴·상점)
Files: Modify tools/deck/gen-slaydeck.mjs
- Step 1: TopBar —
Relics텍스트 항목 제거(topTexts에서 삭제),RelicSlot1..10(UISprite 40×40, x = -240 + (i-1)*48, guid cmb 300+i, UITouchReceiveComponent 포함, 기본 비표시 색),RelicOverflow텍스트(guid cmb 311, 10번째 칸 위치),PotionSlot1..5(UISprite 40×40, x = 270 + (i-1)*44, guid cmb 320+i, UITouchReceiveComponent) - Step 2:
TooltipBox(guid cmb 330: bg 280×76 + Name + Desc, displayOrder 20, enable=false, CombatHud 직속) - Step 3:
PotionMenu팝업 (guid cmb 340대: bg 320×180 중앙 + Title + [사용][버리기][닫기] 버튼 3개, enable=false) - Step 4: ShopHud — Relic 블록 뒤
Potion엔티티(Label/Price 동일 패턴, y=-270, Leave는 y=-360으로 이동) - Step 5:
BindButtons— RelicSlot/PotionSlot에 UITouchEnter/Exit(툴팁), PotionSlot UITouchDown(OpenPotionMenu), PotionMenu 버튼 3개, ShopHud/Potion 클릭(BuyPotion) 연결.ShowTooltip/HideTooltip메서드 신설 - Step 6:
RenderPotions/RenderRelics(아이콘 행으로 재작성 — names 텍스트 제거) 구현 확인 - Step 7: 커밋
feat(potions-relics): 유물 아이콘 행·물약 슬롯·툴팁·물약 메뉴 UI (생성기)
Task 7: 산출물 재생성·검증
- Step 1:
node tools/deck/gen-slaydeck.mjs성공,node --test tools/balance/sim-balance.test.mjs21건 통과 유지 - Step 2: 메이커 refresh → 빌드 콘솔 0 에러 → 플레이테스트: 유물 아이콘 표시·툴팁 hover·물약 지급(
AddPotion)·사용·벨트 5칸 (AddRelic("potionBelt")) 스크립트 확인 + 스크린샷 - Step 3: 커밋
feat(potions-relics): 산출물 재생성 (물약·유물·툴팁)
Task 8: 푸시·PR·머지
- Step 1:
git push -u origin feature/p7-potions-relics - Step 2: Gitea API PR 생성(종합 메시지) → 머지 → main pull
Self-Review 결과
- 설계 전 항목 매핑: 물약 6종·드랍·상점·사용/버리기(Task 2/5/6), 유물 15종 효과(Task 4), 아이콘+툴팁(Task 1/6), 벨트 5칸(Task 3 props + Task 4 passive) ✓
- 이름 일관성:
RunPotions/PotionSlots/FightAttackCount/ClayBlockNext/HasRelic/PickNewRelic/MaybeDropPotion통일 ✓ - 의존 순서: 아이콘 RUID(Task 1) → 데이터(Task 2) → 로직(3~5) → UI(6) → 검증(7) ✓