docs(E5): 유물 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
400
docs/superpowers/plans/2026-06-09-relics.md
Normal file
400
docs/superpowers/plans/2026-06-09-relics.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# 유물 (TODO E5) Implementation Plan
|
||||
|
||||
> **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:** 훅 기반 유물 패시브 + 3획득 경로(시작/엘리트/상점)를 추가한다.
|
||||
|
||||
**Architecture:** `data/relics.json`을 생성기가 주입(self.Relics). `ApplyRelics(hook)`을 전투시작/턴시작/카드사용/보상 4지점에서 호출. `AddRelic`을 3경로가 공유. ShopHud 유물 슬롯·상단 유물 바 UI. 모두 `gen-slaydeck.mjs`에서 생성.
|
||||
|
||||
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
- Create: `data/relics.json`.
|
||||
- Modify: `tools/gen-slaydeck.mjs` — 로드/검증/직렬화, 상수, 속성, 훅 메서드(ApplyRelics/AddRelic/RenderRelics), 4지점 통합, 상점 유물(BuyRelic), UI(유물 바·상점 유물 슬롯).
|
||||
|
||||
검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 데이터 + 로드/직렬화 + 상수/속성 + 훅 메서드
|
||||
|
||||
**Files:** Create `data/relics.json`; Modify `tools/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: `data/relics.json` 작성**
|
||||
|
||||
```json
|
||||
{
|
||||
"relics": {
|
||||
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6 },
|
||||
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1 },
|
||||
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1 },
|
||||
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 골드 +10", "hook": "combatReward", "effect": "gold", "value": 10 }
|
||||
},
|
||||
"startingRelic": "ironHeart",
|
||||
"relicPool": ["energyCore", "vampire", "goldIdol"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 로드·검증·직렬화 헬퍼** — `const MAP = ...` 로드 블록 다음(MAX_ROW 정의 뒤)에 추가:
|
||||
|
||||
```js
|
||||
const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`);
|
||||
for (const id of RELICS.relicPool) {
|
||||
if (!RELICS.relics[id]) throw new Error(`[gen-slaydeck] relicPool에 없는 유물 id: ${id}`);
|
||||
}
|
||||
function luaRelicsTable(relics) {
|
||||
const lines = Object.entries(relics).map(([id, r]) =>
|
||||
`\t${id} = { name = ${luaStr(r.name)}, desc = ${luaStr(r.desc)}, hook = ${luaStr(r.hook)}, effect = ${luaStr(r.effect)}, value = ${r.value} },`);
|
||||
return `self.Relics = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: RELIC_PRICE 상수** — `const REST_HEAL = 30;` 다음에:
|
||||
|
||||
```js
|
||||
const RELIC_PRICE = 60;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 속성 추가** — `prop('any', 'ShopBought'),` 다음에:
|
||||
|
||||
```js
|
||||
prop('any', 'Relics'),
|
||||
prop('any', 'RunRelics'),
|
||||
prop('any', 'RelicPool'),
|
||||
prop('string', 'ShopRelic', '""'),
|
||||
prop('boolean', 'ShopRelicBought', 'false'),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 훅 메서드 추가** — PickReward 메서드 다음(ShowMap 앞 아무 곳, 마지막 `]);` 전 임의 위치)에 삽입:
|
||||
|
||||
```js
|
||||
method('ApplyRelics', `if self.RunRelics == nil then
|
||||
return
|
||||
end
|
||||
for i = 1, #self.RunRelics do
|
||||
local r = self.Relics[self.RunRelics[i]]
|
||||
if r ~= nil and r.hook == hook then
|
||||
if r.effect == "block" then
|
||||
self.PlayerBlock = self.PlayerBlock + r.value
|
||||
elseif r.effect == "energy" then
|
||||
self.Energy = self.Energy + r.value
|
||||
elseif r.effect == "healOnAttack" then
|
||||
self.PlayerHp = self.PlayerHp + r.value
|
||||
if self.PlayerHp > self.PlayerMaxHp then
|
||||
self.PlayerHp = self.PlayerMaxHp
|
||||
end
|
||||
elseif r.effect == "gold" then
|
||||
self.Gold = self.Gold + r.value
|
||||
end
|
||||
end
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hook' }]),
|
||||
method('AddRelic', `if self.RunRelics == nil then
|
||||
self.RunRelics = {}
|
||||
end
|
||||
table.insert(self.RunRelics, id)
|
||||
self:RenderRelics()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('RenderRelics', `local names = ""
|
||||
if self.RunRelics ~= nil then
|
||||
for i = 1, #self.RunRelics do
|
||||
local r = self.Relics[self.RunRelics[i]]
|
||||
if r ~= nil then
|
||||
if names == "" then
|
||||
names = r.name
|
||||
else
|
||||
names = names .. ", " .. r.name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if names == "" then
|
||||
names = "없음"
|
||||
end
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/Relics", "유물: " .. names)`),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: JSON·문법 검사**
|
||||
|
||||
Run: `node -e "JSON.parse(require('fs').readFileSync('data/relics.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs`
|
||||
Expected: `JSON OK` + 오류 없음
|
||||
|
||||
- [ ] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add data/relics.json tools/gen-slaydeck.mjs
|
||||
git commit -m "data(E5): 유물 데이터 + 훅 시스템(ApplyRelics/AddRelic/RenderRelics)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 훅 4지점 통합 + 시작/엘리트 획득
|
||||
|
||||
**Files:** Modify `tools/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: StartRun에 유물 주입·시작 유물** — StartRun 코드에서 `self.RunActive = true` 다음에 삽입:
|
||||
|
||||
```
|
||||
self.RunRelics = {}
|
||||
${luaRelicsTable(RELICS.relics)}
|
||||
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
|
||||
```
|
||||
그리고 StartRun의 `self:ShowMap()` **직전**에 삽입:
|
||||
```
|
||||
self:AddRelic("${RELICS.startingRelic}")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: StartCombat에 combatStart 훅** — StartCombat 끝 `self:StartPlayerTurn()`를 아래로 교체:
|
||||
|
||||
```
|
||||
self:StartPlayerTurn()
|
||||
self:ApplyRelics("combatStart")
|
||||
self:RenderCombat()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: StartPlayerTurn에 turnStart 훅** — `self.Energy = self.MaxEnergy` 다음 줄에 삽입:
|
||||
|
||||
```
|
||||
self:ApplyRelics("turnStart")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: PlayCard Attack 분기에 cardPlayed 훅** — PlayCard의 Attack 분기를 교체:
|
||||
|
||||
```
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
self:DealDamageToEnemy(c.damage)
|
||||
end
|
||||
self:ApplyRelics("cardPlayed")
|
||||
elseif c.kind == "Skill" then
|
||||
```
|
||||
(기존: `if c.kind == "Attack" then\n\tif c.damage ~= nil then\n\t\tself:DealDamageToEnemy(c.damage)\n\tend\nelseif c.kind == "Skill" then` 에서 `end` 다음에 `\n\tself:ApplyRelics("cardPlayed")` 추가)
|
||||
|
||||
- [ ] **Step 5: CheckCombatEnd에 combatReward 훅 + 엘리트 유물** — CheckCombatEnd 승리부를 교체:
|
||||
|
||||
```
|
||||
if self.EnemyHp <= 0 then
|
||||
self.CombatOver = true
|
||||
self.Gold = self.Gold + ${GOLD_PER_WIN}
|
||||
self:ApplyRelics("combatReward")
|
||||
self:RenderRun()
|
||||
local node = self.MapNodes[self.CurrentNodeId]
|
||||
if node ~= nil and node.type == "elite" then
|
||||
self:AddRelic(self.RelicPool[math.random(1, #self.RelicPool)])
|
||||
end
|
||||
if node ~= nil and node.type == "boss" then
|
||||
self:ShowResult("런 클리어!")
|
||||
self.RunActive = false
|
||||
else
|
||||
self:OfferReward()
|
||||
end
|
||||
elseif self.PlayerHp <= 0 then
|
||||
self.CombatOver = true
|
||||
self:ShowResult("패배...")
|
||||
self.RunActive = false
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 문법 검사**
|
||||
|
||||
Run: `node --check tools/gen-slaydeck.mjs`
|
||||
Expected: 오류 없음
|
||||
|
||||
- [ ] **Step 7: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/gen-slaydeck.mjs
|
||||
git commit -m "gen-slaydeck(E5): 훅 4지점 통합·시작/엘리트 유물 획득"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 상점 유물 (ShowShop/RenderShop/BuyRelic) + 바인딩
|
||||
|
||||
**Files:** Modify `tools/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1: ShowShop에 유물 선택 추가** — ShowShop의 `self.ShopBought = { false, false, false }` 다음에:
|
||||
|
||||
```
|
||||
self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]
|
||||
self.ShopRelicBought = false
|
||||
```
|
||||
|
||||
- [ ] **Step 2: RenderShop 끝에 유물 슬롯 렌더 + BuyRelic 메서드** — RenderShop 코드의 마지막 카드 for-loop `end` 다음(닫는 백틱 직전)에 추가:
|
||||
|
||||
```
|
||||
local rr = self.Relics[self.ShopRelic]
|
||||
if rr ~= nil then
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc)
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 골드")
|
||||
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopRelicBought == true then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
그리고 RenderShop 메서드 다음에 BuyRelic 메서드 추가:
|
||||
|
||||
```js
|
||||
method('BuyRelic', `if self.ShopRelicBought == true then
|
||||
return
|
||||
end
|
||||
if self.Gold < ${RELIC_PRICE} then
|
||||
return
|
||||
end
|
||||
self.Gold = self.Gold - ${RELIC_PRICE}
|
||||
self:AddRelic(self.ShopRelic)
|
||||
self.ShopRelicBought = true
|
||||
self:RenderShop()
|
||||
self:RenderRun()`),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: BindButtons에 유물 슬롯 바인딩** — BindButtons의 shopLeave 바인딩 다음(restLeave 앞)에 삽입:
|
||||
|
||||
```
|
||||
local shopRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
|
||||
if shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then
|
||||
shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)
|
||||
end
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 문법 검사**
|
||||
|
||||
Run: `node --check tools/gen-slaydeck.mjs`
|
||||
Expected: 오류 없음
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/gen-slaydeck.mjs
|
||||
git commit -m "gen-slaydeck(E5): 상점 유물 슬롯·BuyRelic·바인딩"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: UI — 유물 바 + 상점 유물 슬롯
|
||||
|
||||
**Files:** Modify `tools/gen-slaydeck.mjs` (`upsertUi`)
|
||||
|
||||
- [ ] **Step 1: CombatHud 유물 바 추가** — CombatHud의 Floor/Gold for-loop 다음(`const result = entity({` 앞)에 삽입:
|
||||
|
||||
```js
|
||||
combat.push(entity({
|
||||
id: guid('cmb', cmbN++),
|
||||
path: '/ui/DefaultGroup/CombatHud/Relics',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1000, y: 40 }, pos: { x: 0, y: 430 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '유물: 없음', fontSize: 22, bold: true, color: { r: 0.8, g: 0.7, b: 0.95, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ShopHud 유물 슬롯 추가** — ShopHud의 Leave 버튼 push 직전에 삽입:
|
||||
|
||||
```js
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Relic',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 9,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 560, y: 76 }, pos: { x: 0, y: -190 } }),
|
||||
sprite({ color: { r: 0.7, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Relic/Label',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 40 }, pos: { x: 0, y: 12 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '유물', fontSize: 22, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
id: guid('shp', shpN++),
|
||||
path: '/ui/DefaultGroup/ShopHud/Relic/Price',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '60 골드', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 문법 검사**
|
||||
|
||||
Run: `node --check tools/gen-slaydeck.mjs`
|
||||
Expected: 오류 없음
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/gen-slaydeck.mjs
|
||||
git commit -m "gen-slaydeck(E5): 유물 바·상점 유물 슬롯 UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 재생성 + 검증
|
||||
|
||||
**Files:** 생성물
|
||||
|
||||
- [ ] **Step 1: 생성**
|
||||
|
||||
Run: `node tools/gen-slaydeck.mjs`
|
||||
Expected: `Slay deck UI and combat codeblocks generated.`
|
||||
|
||||
- [ ] **Step 2: 메서드·UI·데이터 확인**
|
||||
|
||||
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ApplyRelics','AddRelic','RenderRelics','BuyRelic'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sr=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/ironHeart/.test(sr)&&/강철 심장/.test(sr)?'RELICS OK':'NO RELICS'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/CombatHud/Relics')&&has('/ui/DefaultGroup/ShopHud/Relic/Label')?'UI OK':'UI MISSING')"`
|
||||
Expected: `METHODS OK` / `RELICS OK` / `UI OK`
|
||||
|
||||
- [ ] **Step 3: 결정성**
|
||||
|
||||
Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC`
|
||||
Expected: `DETERMINISTIC`
|
||||
|
||||
- [ ] **Step 4: git status**
|
||||
|
||||
Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short`
|
||||
Expected: `data/relics.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs).
|
||||
|
||||
- [ ] **Step 5: 생성물 커밋**
|
||||
|
||||
```bash
|
||||
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
|
||||
git commit -m "재생성(E5): 유물 시스템·UI 반영"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)**
|
||||
|
||||
reload→Play: 시작 유물(강철심장)→전투 시작 PlayerBlock 6 / energyCore 보유 시 턴 에너지 4 / vampire 보유 시 공격 HP+1 / goldIdol 승리 골드+25 / 엘리트 승리→유물 획득(바 갱신) / 상점 유물 구매(골드-60). MCP는 AddRelic/BuyRelic/PlayCard/PickNode 직접 호출 + 로그.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
- **Spec coverage:** 데이터/로드/훅메서드(Task1), 4지점통합·시작·엘리트(Task2), 상점유물(Task3), UI(Task4), 검증(Task5). 스펙 전 항목 매핑.
|
||||
- **Placeholder scan:** 모든 단계 실제 코드/명령.
|
||||
- **Type consistency:** 메서드 `ApplyRelics/AddRelic/RenderRelics/BuyRelic` 정의·호출·바인딩 일치. 속성 `Relics/RunRelics/RelicPool/ShopRelic/ShopRelicBought` 정의(Task1·1)·사용(Task2·3) 일치. UI 경로 `/CombatHud/Relics`·`/ShopHud/Relic/{Label,Price}`가 codeblock(RenderRelics/RenderShop)·생성(Task4)에서 동일. 유물 필드 `name/desc/hook/effect/value` 데이터·luaRelicsTable·ApplyRelics 일치. 상수 `RELIC_PRICE` Task1 정의·Task3 사용.
|
||||
69
docs/superpowers/specs/2026-06-09-relics-design.md
Normal file
69
docs/superpowers/specs/2026-06-09-relics-design.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 유물 (TODO E5) — 설계
|
||||
|
||||
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E5) + 기존 전투/맵/상점 구조.
|
||||
> 선행: E1~E4 완료. 후속: E6(저장/다음 층). 사용자 요청: 획득 3경로(시작·엘리트·상점) 모두.
|
||||
|
||||
## 문제
|
||||
|
||||
런 영속·맵·보상·상점은 됐으나 유물(패시브 빌드 요소)이 없다. 훅 기반 패시브 + 다양한 획득 경로가 필요하다.
|
||||
|
||||
## 설계
|
||||
|
||||
### 데이터 `data/relics.json`
|
||||
```json
|
||||
{
|
||||
"relics": {
|
||||
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6 },
|
||||
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1 },
|
||||
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1 },
|
||||
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 골드 +10", "hook": "combatReward", "effect": "gold", "value": 10 }
|
||||
},
|
||||
"startingRelic": "ironHeart",
|
||||
"relicPool": ["energyCore", "vampire", "goldIdol"]
|
||||
}
|
||||
```
|
||||
- `relicPool` = 엘리트/상점에서 무작위로 줄 후보(시작 유물 제외). 중복 허용(스택).
|
||||
|
||||
### 파라미터 (생성기 상수)
|
||||
- `RELIC_PRICE = 60`.
|
||||
|
||||
### 상태 추가
|
||||
- `Relics`(any) — 전체 유물 정의(주입).
|
||||
- `RunRelics`(any) — 보유 유물 id 목록.
|
||||
- `ShopRelic`(string) — 상점 제시 유물 id.
|
||||
- `ShopRelicBought`(boolean).
|
||||
|
||||
### 훅 시스템
|
||||
- `ApplyRelics(hook)`: RunRelics 순회, `hook` 일치 유물의 effect 적용:
|
||||
- `block`→PlayerBlock+=value, `energy`→Energy+=value, `healOnAttack`→PlayerHp+=value(상한 클램프), `gold`→Gold+=value.
|
||||
- 연결 지점:
|
||||
- `combatStart` → StartCombat 끝(StartPlayerTurn 호출 뒤 — 방어도 리셋 이후 적용 → RenderCombat).
|
||||
- `turnStart` → StartPlayerTurn(에너지 회복 직후).
|
||||
- `cardPlayed` → PlayCard의 Attack 분기(데미지 적용 후).
|
||||
- `combatReward` → CheckCombatEnd 승리(기본 골드 += 후).
|
||||
|
||||
### 획득 (공통 `AddRelic(id)` → RunRelics 추가·RenderRelics)
|
||||
- **C 시작**: `StartRun`에서 `RunRelics={}` → `AddRelic(startingRelic)`.
|
||||
- **A 엘리트**: `CheckCombatEnd` 승리 시 노드 `type=="elite"`면 `relicPool`에서 무작위 `AddRelic`(보스는 런 종료라 제외).
|
||||
- **B 상점**: `ShowShop`에서 `ShopRelic = relicPool 무작위`, ShopRelicBought=false; `BuyRelic`(ShopRelicBought거나 Gold<RELIC_PRICE면 무시; 아니면 Gold-=60·AddRelic·비활성).
|
||||
|
||||
### UI
|
||||
- 상단 유물 바: `/ui/DefaultGroup/CombatHud/Relics` 텍스트, `RenderRelics`가 보유 유물 이름을 ", "로 join해 "유물: …" 표시(없으면 "유물: 없음").
|
||||
- ShopHud에 유물 슬롯: `/ui/DefaultGroup/ShopHud/Relic`(sprite+button) + Name/Desc/Price 자식. `RenderShop`이 ShopRelic 비주얼·가격·구매상태 갱신.
|
||||
- 엘리트 유물 획득은 유물 바 갱신으로 표시.
|
||||
|
||||
### 단일 소스
|
||||
모든 변경은 `tools/gen-slaydeck.mjs`에서 생성. relics.json은 데이터 단일 소스.
|
||||
|
||||
## 검증 (메이커 Play)
|
||||
- 시작 유물(강철심장) → 전투 시작 시 PlayerBlock 6.
|
||||
- energyCore 보유 → 턴 시작 에너지 4(3+1).
|
||||
- vampire 보유 → 공격 카드 사용 시 HP +1(상한).
|
||||
- goldIdol 보유 → 승리 시 골드 +25(15+10).
|
||||
- 엘리트 승리 → relicPool 유물 1개 RunRelics 추가(바 갱신).
|
||||
- 상점 유물 구매 → 골드 -60·RunRelics 추가·슬롯 비활성. 골드 부족/재구매 무시.
|
||||
- 생성기 결정적·JSON 유효.
|
||||
- (버튼은 런타임 — MCP는 AddRelic/BuyRelic/PlayCard 등 직접 호출 + 상태 로그로 검증.)
|
||||
|
||||
## 범위 밖 (금지)
|
||||
- 부정적 유물·복합/조건부 효과·유물 제거·보스 유물·유물 등급/툴팁. 카드 제거(별도).
|
||||
Reference in New Issue
Block a user