feat(E5): 유물 시스템 — 훅 패시브 + 3획득경로

훅 기반 유물(패시브) + 시작/엘리트/상점 획득.

- data/relics.json: 유물 4종(강철심장 combatStart 방어+6, 에너지코어 turnStart 에너지+1, 흡혈 cardPlayed HP+1, 황금우상 combatReward 골드+10) + startingRelic + relicPool
- ApplyRelics(hook): RunRelics 순회·effect 적용. 4지점 연결(StartCombat/StartPlayerTurn/PlayCard Attack/CheckCombatEnd)
- 획득: AddRelic 공용 — StartRun 시작 유물(C), 엘리트 승리 무작위(A), 상점 BuyRelic 골드-60(B)
- UI: CombatHud 유물 바(RenderRelics)·ShopHud 유물 슬롯
- 생성기: relics.json 로드/검증/luaRelicsTable, RELIC_PRICE=60
- 메이커 Play 검증: 방어+6·에너지4·공격HP+1·승리골드+25·엘리트/상점 유물 획득
- 범위 밖: 부정 유물·조건부 효과·유물 제거·보스 유물

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 03:53:37 +09:00
parent edbc717426
commit 5ebc781f81
4 changed files with 1035 additions and 9 deletions

View File

@@ -26,6 +26,17 @@ for (const [id, n] of Object.entries(MAP.nodes)) {
}
const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row));
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}`;
}
function luaIntentsArray(intents) {
return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }';
}
@@ -501,6 +512,19 @@ function upsertUi() {
],
}));
}
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 }),
],
}));
const result = entity({
id: guid('cmb', cmbN++),
path: '/ui/DefaultGroup/CombatHud/Result',
@@ -740,6 +764,45 @@ function upsertUi() {
}));
}
}
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 }),
],
}));
shop.push(entity({
id: guid('shp', shpN++),
path: '/ui/DefaultGroup/ShopHud/Leave',
@@ -871,6 +934,7 @@ function writeCodeblocks() {
const GOLD_PER_WIN = 15;
const CARD_PRICE = 30;
const REST_HEAL = 30;
const RELIC_PRICE = 60;
const combat = codeblock('SlayDeckController', 'SlayDeckController', [
prop('any', 'DrawPile'),
prop('any', 'DiscardPile'),
@@ -904,6 +968,11 @@ function writeCodeblocks() {
prop('string', 'CurrentEnemyId', '""'),
prop('any', 'ShopChoices'),
prop('any', 'ShopBought'),
prop('any', 'Relics'),
prop('any', 'RunRelics'),
prop('any', 'RelicPool'),
prop('string', 'ShopRelic', '""'),
prop('boolean', 'ShopRelicBought', 'false'),
], [
method('OnBeginPlay', `self:StartRun()`),
method('StartRun', `self.PlayerMaxHp = 80
@@ -913,12 +982,16 @@ self.Floor = 0
self.RunLength = ${MAX_ROW}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
self.RunRelics = {}
${luaRelicsTable(RELICS.relics)}
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
${luaEnemiesTable(ENEMIES.enemies)}
${luaMapNodesTable(MAP.nodes)}
${luaStartArray(MAP.start)}
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:BindButtons()
self:AddRelic("${RELICS.startingRelic}")
self:ShowMap()`),
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
@@ -944,7 +1017,9 @@ for i = 1, #self.RunDeck do
end
self:Shuffle(self.DrawPile)
self:RenderCombat()
self:StartPlayerTurn()`),
self:StartPlayerTurn()
self:ApplyRelics("combatStart")
self:RenderCombat()`),
method('Shuffle', `if list == nil then
\treturn
end
@@ -994,12 +1069,17 @@ local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave
if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then
shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
local shopRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
if shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then
shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)
end
local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave")
if restLeave ~= nil and restLeave.ButtonComponent ~= nil then
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end`),
method('StartPlayerTurn', `self.Turn = self.Turn + 1
self.Energy = self.MaxEnergy
self:ApplyRelics("turnStart")
self.PlayerBlock = 0
self:DrawCards(5)
self:RenderHand(true)
@@ -1131,6 +1211,7 @@ 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.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
@@ -1180,8 +1261,12 @@ self:RenderCombat()`),
method('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
@@ -1264,6 +1349,48 @@ if hud ~= nil then
hud.Enable = false
end
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
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)`),
method('ShowMap', `self:RenderMap()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
@@ -1330,6 +1457,8 @@ self.ShopBought = { false, false, false }
for i = 1, 3 do
self.ShopChoices[i] = pool[math.random(1, #pool)]
end
self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]
self.ShopRelicBought = false
self:RenderShop()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud")
if hud ~= nil then
@@ -1358,7 +1487,31 @@ for i = 1, 3 do
end
end
end
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 re = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
if re ~= nil and re.SpriteGUIRendererComponent ~= nil then
if self.ShopRelicBought == true then
re.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
else
re.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)
end
end
end`),
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()`),
method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then
return
end