Files
maplecontest/docs/superpowers/plans/2026-06-12-potions-relics.md
gahusb e4f7ff10d7 docs(potions-relics): P7 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:20:53 +09:00

15 KiB
Raw Permalink Blame History

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: luaRelicsTableicon = ${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: StartRunself.RunPotions = {} self.PotionSlots = ${POTIONS.baseSlots} ${luaPotionsTable(POTIONS.potions)} 추가 (RunRelics 초기화 옆) + self:RenderPotions() (BindButtons 후)
  • Step 5: StartCombatself.FightAttackCount = 0 self.FirstHpLossDone = false self.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: StartPlayerTurnself.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: 상점 — ShowShopself.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.mjs 21건 통과 유지
  • 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) ✓