17 KiB
P6 — 버프/디버프·Power 카드·적 방어도 UI 구현 계획
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 표준 약화·취약·힘 버프 시스템 + Power 카드 kind + 적 방어도 배지 UI를 데이터 주도로 구현.
Architecture: data/cards.json·enemies.json 스키마 확장 → tools/deck/gen-slaydeck.mjs의 Lua 생성부(상태 props·전투 메서드·UI 엔티티) 확장 → 산출물 재생성. 밸런스 시뮬(tools/balance/sim-balance.mjs)에 동일 규칙 재현.
Tech Stack: Node.js(생성기·시뮬), MSW Lua(생성물), node:test.
설계 문서: docs/superpowers/specs/2026-06-12-buffs-power-design.md
Task 1: 신규 카드 이미지 RUID 선별 (메이커)
Files: 없음 (RUID 4개 확보가 산출물)
- Step 1:
asset_search_resources(cat=sprite, source=maplestory)로 후보 수집 — 쿼리: "차지 블로우", "위협", "인레이지", "분노" (결과 빈약 시 "스킬", "버프" 등 보조 쿼리) - Step 2: 메이커 Play Test 상태에서
maker_execute_script(client)로 후보 RUID를 UIGroup에 격자 배치(아래 패턴) 후maker_screenshot으로 확인, 카드당 1개 선별
-- 후보 미리보기 패턴 (client 컨텍스트)
local ruids = { "<ruid1>", "<ruid2>", "..." }
local root = _EntityService:GetEntityByPath("/ui/DefaultGroup")
for i = 1, #ruids do
local e = _SpawnService:SpawnByModelId("model://uisprite", "RuidPreview" .. i, Vector3(0,0,0), root)
e.SpriteGUIRendererComponent.ImageRUID = ruids[i]
e.UITransformComponent.anchoredPosition = Vector2(-600 + ((i-1) % 8) * 160, 200 - math.floor((i-1) / 8) * 160)
e.UITransformComponent.RectSize = Vector2(140, 140)
end
- Step 3: 미리보기 엔티티 제거(스크립트로 Destroy) 후 플레이 종료
Task 2: 카드·적 데이터 확장
Files:
-
Modify:
data/cards.json -
Modify:
data/enemies.json -
Step 1:
data/cards.json의cards에 4종 추가 (image는 Task 1 선별값)
"ChargedBlow": { "name": "차지 블로우", "cost": 2, "kind": "Attack", "damage": 8, "vuln": 2, "desc": "피해 8, 취약 2", "image": "<선별RUID>" },
"Threaten": { "name": "위협", "cost": 0, "kind": "Skill", "weak": 2, "desc": "약화 2 부여", "image": "<선별RUID>" },
"Enrage": { "name": "인레이지", "cost": 1, "kind": "Skill", "strength": 2, "desc": "힘 +2", "image": "<선별RUID>" },
"Rage": { "name": "분노", "cost": 1, "kind": "Power", "powerEffect": "strengthPerTurn", "value": 1, "desc": "매 턴 시작 시 힘 +1", "image": "<선별RUID>" }
- Step 2:
data/enemies.json인텐트에 Debuff 추가 — mushmom에{ "kind": "Debuff", "effect": "weak", "value": 2 }(intents 2번째로 삽입), slime_elite에{ "kind": "Debuff", "effect": "weak", "value": 1 }(마지막), slime_boss·king_slime에{ "kind": "Debuff", "effect": "vuln", "value": 2 }(Defend 다음), modified_snail에{ "kind": "Debuff", "effect": "weak", "value": 1 }(마지막) - Step 3: 커밋
feat(buffs-power): 신규 카드 4종·적 디버프 인텐트 데이터
Task 3: 생성기 — 직렬화·상태·전투 규칙
Files:
-
Modify:
tools/deck/gen-slaydeck.mjs(luaCardsTable ~line 64, props ~1888, StartCombat ~2001, BuildMonsters ~2040, StartPlayerTurn ~2184, EndPlayerTurn ~2191, PlayCard ~2410, DealDamageToTarget ~2520, EnemyActStep ~2599, ApplyCardFace ~2347) -
Step 1:
luaCardsTable에 신규 필드 직렬화 추가
if (c.strength != null) fields.push(`strength = ${c.strength}`);
if (c.weak != null) fields.push(`weak = ${c.weak}`);
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`);
if (c.value != null) fields.push(`value = ${c.value}`);
- Step 2: props 추가 —
prop('number', 'PlayerStr', '0'),prop('number', 'PlayerWeak', '0'),prop('number', 'PlayerVuln', '0'),prop('any', 'PlayerPowers') - Step 3:
StartCombat에 리셋 추가 (self.PlayerBlock = 0다음 줄)
self.PlayerStr = 0
self.PlayerWeak = 0
self.PlayerVuln = 0
self.PlayerPowers = {}
- Step 4:
BuildMonsters의 몬스터 테이블 생성에str = 0, weak = 0, vuln = 0필드 추가 (기존block = 0자리 옆) - Step 5: 플레이어 공격 피해 헬퍼
CalcPlayerAttack메서드 신설 +PlayCard의 Attack 분기를 수정 —c.damage에 힘·약화 적용한 값을PlayAttackFx에 전달. 버프 필드 공통 처리(Attack/Skill 양쪽):strength/weak/vuln적용
-- method CalcPlayerAttack(base) → number
local dmg = base + self.PlayerStr
if self.PlayerWeak > 0 then
dmg = math.floor(dmg * 0.75)
end
return dmg
-- PlayCard 내 교체 (Attack 분기)
if c.kind == "Attack" then
if c.damage ~= nil then
self:PlayAttackFx(self.TargetIndex, c.image, self:CalcPlayerAttack(c.damage))
end
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
self:ApplyRelics("cardPlayed")
elseif c.kind == "Skill" then
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
elseif c.kind == "Power" then
if c.powerEffect ~= nil then
table.insert(self.PlayerPowers, cardId)
end
end
-- 공통 버프/디버프 적용 (kind 분기 아래, table.remove 위)
if c.strength ~= nil then
self.PlayerStr = self.PlayerStr + c.strength
end
if c.weak ~= nil or c.vuln ~= 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.vuln ~= nil then tm.vuln = tm.vuln + c.vuln end
end
end
- Step 6: Power 소멸 처리 —
PlayCard의table.insert(self.DiscardPile, cardId)를 조건부로
table.remove(self.Hand, slot)
if c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId)
end
- Step 7:
DealDamageToTarget에 취약 배수 (block 차감 이전)
local dmg = amount
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
- Step 8:
EnemyActStep— Debuff 인텐트 처리 + 적 공격 피해 공식 + 행동 후 적 디버프 감소
if intent.kind == "Attack" then
local atk = intent.value + m.str
if m.weak > 0 then
atk = math.floor(atk * 0.75)
end
if self.PlayerVuln > 0 then
atk = math.floor(atk * 1.5)
end
local before = self.PlayerHp
self:DealDamageToPlayer(atk)
self:ShowPlayerDmgPop(before - self.PlayerHp)
elseif intent.kind == "Defend" then
m.block = m.block + intent.value
elseif intent.kind == "Debuff" then
if intent.effect == "weak" then
self.PlayerWeak = self.PlayerWeak + intent.value
elseif intent.effect == "vuln" then
self.PlayerVuln = self.PlayerVuln + intent.value
end
end
-- intentIdx 갱신 직후
if m.weak > 0 then m.weak = m.weak - 1 end
if m.vuln > 0 then m.vuln = m.vuln - 1 end
- Step 9:
EndPlayerTurn에 플레이어 디버프 감소 (self:EnemyTurn()직전)
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
- Step 10:
StartPlayerTurn에 파워 발동 (self:ApplyRelics("turnStart")다음)
if self.PlayerPowers ~= nil then
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil and pc.powerEffect == "strengthPerTurn" then
self.PlayerStr = self.PlayerStr + pc.value
end
end
end
- Step 11:
ApplyCardFacekind 색 분기에elseif c.kind == "Power" then→Color(0.46, 0.68, 0.52, 1)명시 (기존 else를 Power로) - Step 12: 커밋
feat(buffs-power): 버프/디버프·Power 전투 규칙 (생성기)
Task 4: 생성기 — UI (적 방어도 배지·버프 라인·인텐트)
Files:
-
Modify:
tools/deck/gen-slaydeck.mjs(몬스터 슬롯 UI 생성부 ~line 916-1015, PlayerPanel ~1015-1100, RenderCombat ~2688) -
Step 1: 몬스터 슬롯 루프에 BlockBadge(guid 270+i)·Value(guid 280+i)·Buffs(guid 290+i) 엔티티 추가 (DmgPop 추가 코드 다음)
const mBlockBadge = entity({
id: guid('cmb', 270 + i), path: `${base}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 6,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 36 }, pos: { x: -HP_BAR_W / 2 - 30, y: -14 } }),
sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }),
],
});
mBlockBadge.jsonString.enable = false;
combat.push(mBlockBadge);
combat.push(entity({
id: guid('cmb', 280 + i), path: `${base}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 40, parentH: 36, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 32 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT }),
text({ value: '0', fontSize: 17, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
combat.push(entity({
id: guid('cmb', 290 + i), path: `${base}/Buffs`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 7,
components: [
transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 60, y: 22 }, pos: { x: 0, y: -58 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 15, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }),
],
}));
- Step 2: PlayerPanel에 Buffs 텍스트(guid 217) 추가 (BlockBadge/Value 다음)
combat.push(entity({
id: guid('cmb', 217), path: `${PP}/Buffs`, modelId: 'uitext', entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 6,
components: [
transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 22 }, pos: { x: 0, y: -44 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 14, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }),
],
}));
- Step 3: 버프 문자열 헬퍼 메서드
BuffsLabel신설 (Lua, str/weak/vuln → "힘+2 약화1 취약2")
-- method BuffsLabel(str, weak, vuln) → string
local parts = {}
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
return table.concat(parts, " ")
- Step 4:
RenderCombat확장 — 몬스터 루프 내(SetHpBar 다음)
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))
인텐트 분기 교체 (Attack은 최종 예상치·Debuff 추가):
local t = ""
if intent ~= nil then
if intent.kind == "Attack" then
local atk = intent.value + m.str
if m.weak > 0 then atk = math.floor(atk * 0.75) end
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
t = "공격 " .. tostring(atk)
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
elseif intent.kind == "Debuff" then
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
else t = "취약 " .. tostring(intent.value) .. " 부여" end
end
end
인텐트 색: Attack 빨강(기존), Defend 파랑(기존 else), Debuff 보라 Color(0.8, 0.5, 1, 1) 분기 추가.
플레이어 표시 (기존 BlockBadge 갱신 다음):
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln)
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
local names = {}
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil then table.insert(names, pc.name) end
end
if pb ~= "" then pb = pb .. " · " end
pb = pb .. table.concat(names, " ")
end
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Buffs", pb)
- Step 5: 커밋
feat(buffs-power): 적 방어도 배지·버프 라인·디버프 인텐트 UI (생성기)
Task 5: 밸런스 시뮬 동기화 + 테스트
Files:
-
Modify:
tools/balance/sim-balance.mjs -
Test:
tools/balance/sim-balance.test.mjs -
Step 1: 실패 테스트 먼저 추가 — 약화·취약·힘 계산 + Debuff 인텐트 + Power 동작
test('simulateCombat: 취약이 플레이어 공격을 1.5배로', () => {
const data = {
cards: { Vuln: { name: '취약기', cost: 1, kind: 'Skill', vuln: 9 }, Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 10 } },
starterDeck: ['Vuln', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 100, intents: [{ kind: 'Defend', value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
// 1턴: 공격 우선 휴리스틱 → Hit×3 (취약 미부여, 30) — 그래도 30+α로 수치 검증은 별도 단위 함수로
assert.equal(typeof r.win, 'boolean');
});
test('calcAttack: 힘·약화·취약 공식', () => {
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75)
assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5)
assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5)=13
});
test('simulateCombat: 적 Debuff 인텐트로 플레이어 약화 → 받는 피해 감소 검증', () => {
const data = {
cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } },
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.playerHpRemaining, 80); // Debuff만 하는 적 → 피해 0
});
test('simulateCombat: Power(매턴 힘) 누적', () => {
const data = {
cards: {
Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 },
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
},
starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}턴`);
});
- Step 2:
node --test tools/balance/sim-balance.test.mjs→ 신규 테스트 FAIL 확인 - Step 3:
sim-balance.mjs구현 —calcAttack(base, str, weak, vulnOnTarget)export 신설,simulateCombat에 pStr/pWeak/pVuln/powers·몬스터 str/weak/vuln 상태 추가, 규칙 재현(부여→감소 타이밍 Lua와 동일),chooseAction확장(Attack 우선 유지, 잔여 에너지로 Power→버프 Skill 사용), Debuff 인텐트 처리.formatReport의 kind 루프에 'Power' 포함(효율 계산은 plays만 표시). - Step 4:
node --test tools/balance/sim-balance.test.mjs→ 전체 PASS - Step 5: 커밋
feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화
Task 6: 산출물 재생성·시뮬 확인·푸시·PR·머지
Files:
-
Regen:
RootDesk/MyDesk/SlayDeckController.codeblock,ui/DefaultGroup.ui,Global/common.gamelogic -
Step 1:
node tools/deck/gen-slaydeck.mjs실행 성공 확인 -
Step 2:
node tools/balance/sim-balance.mjs— 승률 0%/100% 극단 아님 확인 (참고용 리포트 기록) -
Step 3: 커밋
feat(buffs-power): 산출물 재생성 (버프/디버프·Power·적 방어도 UI) -
Step 4:
git push -u origin feature/p6-buffs-power -
Step 5: Gitea API로 PR 생성 → 머지 (기존 자동화 패턴:
curl -s -X POST .../repos/gahusb/maplecontest/pulls, 토큰은.mcp.json참조 금지 —git credential또는 기존 사용 토큰 경로)
Self-Review 결과
- 설계 요구 전 항목(버프 3종·Power·적 방어도 배지·예시 카드 4종·적 디버프 인텐트·시뮬 동기화) 태스크 매핑 확인
- 타입/이름 일관성:
CalcPlayerAttack·BuffsLabel·PlayerStr/Weak/Vuln·PlayerPowers·m.str/weak/vuln통일 확인 - 플레이스홀더: 카드 image RUID만 Task 1 산출물에 의존 (의도된 순서)