Merge pull request '런 루프 코어 (TODO E1+E2) — 연속 전투·카드 보상·덱 성장' (#13) from feature/run-loop-core into main

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-06-09 02:43:13 +09:00
5 changed files with 4042 additions and 15 deletions

View File

@@ -161,6 +161,48 @@
"SyncDirection": 0, "SyncDirection": 0,
"Attributes": [], "Attributes": [],
"Name": "EnemyName" "Name": "EnemyName"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "RunDeck"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "Gold"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "Floor"
},
{
"Type": "number",
"DefaultValue": "3",
"SyncDirection": 0,
"Attributes": [],
"Name": "RunLength"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "RewardChoices"
},
{
"Type": "boolean",
"DefaultValue": "false",
"SyncDirection": 0,
"Attributes": [],
"Name": "RunActive"
} }
], ],
"Methods": [ "Methods": [
@@ -173,7 +215,7 @@
"Name": null "Name": null
}, },
"Arguments": [], "Arguments": [],
"Code": "self:StartCombat()", "Code": "self:StartRun()",
"Scope": 2, "Scope": 2,
"ExecSpace": 6, "ExecSpace": 6,
"Attributes": [], "Attributes": [],
@@ -188,7 +230,22 @@
"Name": null "Name": null
}, },
"Arguments": [], "Arguments": [],
"Code": "self.MaxEnergy = 3\nself.Turn = 0\nself.PlayerMaxHp = 80\nself.PlayerHp = self.PlayerMaxHp\nself.PlayerBlock = 0\nself.EnemyName = \"슬라임\"\nself.EnemyMaxHp = 45\nself.EnemyHp = self.EnemyMaxHp\nself.EnemyBlock = 0\nself.EnemyIntents = {\n\t{ kind = \"Attack\", value = 10 },\n\t{ kind = \"Attack\", value = 6 },\n\t{ kind = \"Defend\", value = 8 },\n}\nself.EnemyIntentIndex = 1\nself.CombatOver = false\nself.DiscardPile = {}\nself.Hand = {}\nself.Cards = {\n\tStrike = { name = \"타격\", cost = 1, desc = \"피해 6\", kind = \"Attack\", damage = 6 },\n\tDefend = { name = \"방어\", cost = 1, desc = \"방어도 5\", kind = \"Skill\", block = 5 },\n\tBash = { name = \"강타\", cost = 2, desc = \"피해 10\", kind = \"Attack\", damage = 10 },\n}\nself.DrawPile = { \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Defend\", \"Defend\", \"Defend\", \"Defend\", \"Bash\" }\nself:Shuffle(self.DrawPile)\nself:BindButtons()\nself:RenderCombat()\nself:StartPlayerTurn()", "Code": "self.PlayerMaxHp = 80\nself.PlayerHp = self.PlayerMaxHp\nself.Gold = 0\nself.Floor = 0\nself.RunLength = 3\nself.RunDeck = { \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Defend\", \"Defend\", \"Defend\", \"Defend\", \"Bash\" }\nself.RunActive = true\nself:BindButtons()\nself:StartCombat()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "StartRun"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.MaxEnergy = 3\nself.Turn = 0\nself.Floor = self.Floor + 1\nself.PlayerBlock = 0\nself.EnemyName = \"슬라임\"\nself.EnemyMaxHp = 45\nself.EnemyHp = self.EnemyMaxHp\nself.EnemyBlock = 0\nself.EnemyIntents = {\n\t{ kind = \"Attack\", value = 10 },\n\t{ kind = \"Attack\", value = 6 },\n\t{ kind = \"Defend\", value = 8 },\n}\nself.EnemyIntentIndex = 1\nself.CombatOver = false\nself.DiscardPile = {}\nself.Hand = {}\nself.Cards = {\n\tStrike = { name = \"타격\", cost = 1, desc = \"피해 6\", kind = \"Attack\", damage = 6 },\n\tDefend = { name = \"방어\", cost = 1, desc = \"방어도 5\", kind = \"Skill\", block = 5 },\n\tBash = { name = \"강타\", cost = 2, desc = \"피해 10\", kind = \"Attack\", damage = 10 },\n}\nself.DrawPile = {}\nfor i = 1, #self.RunDeck do\n\tself.DrawPile[i] = self.RunDeck[i]\nend\nself:Shuffle(self.DrawPile)\nself:RenderCombat()\nself:StartPlayerTurn()",
"Scope": 2, "Scope": 2,
"ExecSpace": 6, "ExecSpace": 6,
"Attributes": [], "Attributes": [],
@@ -226,7 +283,7 @@
"Name": null "Name": null
}, },
"Arguments": [], "Arguments": [],
"Code": "local endTurn = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/DeckHud/EndTurnButton\")\nif endTurn ~= nil and endTurn.ButtonComponent ~= nil then\n\tif self.EndTurnHandler ~= nil then\n\t\tendTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)\n\t\tself.EndTurnHandler = nil\n\tend\n\tself.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)\nend\nfor i = 1, 5 do\n\tlocal cardEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(i))\n\tif cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then\n\t\tcardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)\n\tend\nend", "Code": "local endTurn = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/DeckHud/EndTurnButton\")\nif endTurn ~= nil and endTurn.ButtonComponent ~= nil then\n\tif self.EndTurnHandler ~= nil then\n\t\tendTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)\n\t\tself.EndTurnHandler = nil\n\tend\n\tself.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)\nend\nfor i = 1, 5 do\n\tlocal cardEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(i))\n\tif cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then\n\t\tcardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)\n\tend\nend\nfor i = 1, 3 do\n\tlocal rc = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RewardHud/Reward\" .. tostring(i))\n\tif rc ~= nil and rc.ButtonComponent ~= nil then\n\t\trc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)\n\tend\nend\nlocal skip = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RewardHud/Skip\")\nif skip ~= nil and skip.ButtonComponent ~= nil then\n\tskip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)\nend",
"Scope": 2, "Scope": 2,
"ExecSpace": 6, "ExecSpace": 6,
"Attributes": [], "Attributes": [],
@@ -558,7 +615,7 @@
"Name": null "Name": null
}, },
"Arguments": [], "Arguments": [],
"Code": "if self.EnemyHp <= 0 then\n\tself.CombatOver = true\n\tself:ShowResult(\"승리!\")\n\t-- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점\nelseif self.PlayerHp <= 0 then\n\tself.CombatOver = true\n\tself:ShowResult(\"패배...\")\nend", "Code": "if self.EnemyHp <= 0 then\n\tself.CombatOver = true\n\tself.Gold = self.Gold + 15\n\tself:RenderRun()\n\tif self.Floor >= self.RunLength then\n\t\tself:ShowResult(\"런 클리어!\")\n\t\tself.RunActive = false\n\telse\n\t\tself:OfferReward()\n\tend\nelseif self.PlayerHp <= 0 then\n\tself.CombatOver = true\n\tself:ShowResult(\"패배...\")\n\tself.RunActive = false\nend",
"Scope": 2, "Scope": 2,
"ExecSpace": 6, "ExecSpace": 6,
"Attributes": [], "Attributes": [],
@@ -596,11 +653,94 @@
"Name": null "Name": null
}, },
"Arguments": [], "Arguments": [],
"Code": "self:SetText(\"/ui/DefaultGroup/CombatHud/EnemyName\", self.EnemyName)\nself:SetText(\"/ui/DefaultGroup/CombatHud/EnemyHp\", \"HP \" .. string.format(\"%d\", self.EnemyHp) .. \"/\" .. string.format(\"%d\", self.EnemyMaxHp))\nself:SetText(\"/ui/DefaultGroup/CombatHud/EnemyBlock\", \"방어 \" .. string.format(\"%d\", self.EnemyBlock))\nlocal intent = self.EnemyIntents[self.EnemyIntentIndex]\nlocal intentText = \"\"\nif intent ~= nil then\n\tif intent.kind == \"Attack\" then\n\t\tintentText = \"의도: 공격 \" .. tostring(intent.value)\n\telseif intent.kind == \"Defend\" then\n\t\tintentText = \"의도: 방어 \" .. tostring(intent.value)\n\tend\nend\nself:SetText(\"/ui/DefaultGroup/CombatHud/EnemyIntent\", intentText)\nself:SetText(\"/ui/DefaultGroup/CombatHud/PlayerHp\", \"HP \" .. string.format(\"%d\", self.PlayerHp) .. \"/\" .. string.format(\"%d\", self.PlayerMaxHp))\nself:SetText(\"/ui/DefaultGroup/CombatHud/PlayerBlock\", \"방어 \" .. string.format(\"%d\", self.PlayerBlock))", "Code": "self:SetText(\"/ui/DefaultGroup/CombatHud/EnemyName\", self.EnemyName)\nself:SetText(\"/ui/DefaultGroup/CombatHud/EnemyHp\", \"HP \" .. string.format(\"%d\", self.EnemyHp) .. \"/\" .. string.format(\"%d\", self.EnemyMaxHp))\nself:SetText(\"/ui/DefaultGroup/CombatHud/EnemyBlock\", \"방어 \" .. string.format(\"%d\", self.EnemyBlock))\nlocal intent = self.EnemyIntents[self.EnemyIntentIndex]\nlocal intentText = \"\"\nif intent ~= nil then\n\tif intent.kind == \"Attack\" then\n\t\tintentText = \"의도: 공격 \" .. tostring(intent.value)\n\telseif intent.kind == \"Defend\" then\n\t\tintentText = \"의도: 방어 \" .. tostring(intent.value)\n\tend\nend\nself:SetText(\"/ui/DefaultGroup/CombatHud/EnemyIntent\", intentText)\nself:SetText(\"/ui/DefaultGroup/CombatHud/PlayerHp\", \"HP \" .. string.format(\"%d\", self.PlayerHp) .. \"/\" .. string.format(\"%d\", self.PlayerMaxHp))\nself:SetText(\"/ui/DefaultGroup/CombatHud/PlayerBlock\", \"방어 \" .. string.format(\"%d\", self.PlayerBlock))\nself:RenderRun()",
"Scope": 2, "Scope": 2,
"ExecSpace": 6, "ExecSpace": 6,
"Attributes": [], "Attributes": [],
"Name": "RenderCombat" "Name": "RenderCombat"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self:SetText(\"/ui/DefaultGroup/CombatHud/Floor\", \"층 \" .. string.format(\"%d\", self.Floor) .. \"/\" .. string.format(\"%d\", self.RunLength))\nself:SetText(\"/ui/DefaultGroup/CombatHud/Gold\", \"골드 \" .. string.format(\"%d\", self.Gold))",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "RenderRun"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local pool = {}\nfor id, _ in pairs(self.Cards) do\n\ttable.insert(pool, id)\nend\nself.RewardChoices = {}\nfor i = 1, 3 do\n\tself.RewardChoices[i] = pool[math.random(1, #pool)]\n\tself:ApplyRewardVisual(i, self.RewardChoices[i])\nend\nlocal hud = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RewardHud\")\nif hud ~= nil then\n\thud.Enable = true\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OfferReward"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "number",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "slot"
},
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "cardId"
}
],
"Code": "local c = self.Cards[cardId]\nif c == nil then\n\treturn\nend\nlocal base = \"/ui/DefaultGroup/RewardHud/Reward\" .. tostring(slot)\nself:SetText(base .. \"/Name\", c.name)\nself:SetText(base .. \"/Cost\", tostring(c.cost))\nself:SetText(base .. \"/Desc\", c.desc)\nlocal e = _EntityService:GetEntityByPath(base)\nif e ~= nil and e.SpriteGUIRendererComponent ~= nil then\n\tif c.kind == \"Attack\" then\n\t\te.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)\n\telseif c.kind == \"Skill\" then\n\t\te.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)\n\telse\n\t\te.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)\n\tend\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ApplyRewardVisual"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "number",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "slot"
}
],
"Code": "if self.CombatOver ~= true or self.RunActive ~= true then\n\treturn\nend\nif slot ~= 0 and self.RewardChoices ~= nil then\n\tlocal id = self.RewardChoices[slot]\n\tif id ~= nil then\n\t\ttable.insert(self.RunDeck, id)\n\tend\nend\nlocal hud = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RewardHud\")\nif hud ~= nil then\n\thud.Enable = false\nend\nself:StartCombat()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "PickReward"
} }
], ],
"EntityEventHandlers": [] "EntityEventHandlers": []

View File

@@ -0,0 +1,438 @@
# 런 루프 코어 (TODO E1+E2) 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:** 단일 전투를 연속 N전투 런으로 확장 — 런 상태(HP/골드/덱) 영속 + 승리 후 카드 1택 보상 + 다음 전투 + N전투 후 "런 클리어".
**Architecture:** 기존 `SlayDeckController`(gen-slaydeck.mjs 생성)에 런 상태·보상 메서드 추가. StartRun(영속 초기화·버튼 1회 바인딩) vs StartCombat(전투별 초기화, RunDeck에서 드로) 분리. RewardHud UI 생성.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — 유일 변경 대상.
- `writeCodeblocks`: 런 상수, 새 속성, OnBeginPlay/StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat.
- `upsertUi`: CombatHud에 Floor/Gold, RewardHud 그룹 생성, 필터 확장, guid 'rwd' 분기.
MSW Lua 단위 테스트 불가 → 검증은 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: 런 상수·속성·StartRun
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 런 상수 추가**`writeCodeblocks()` 함수 본문 첫 줄에 삽입:
```js
const RUN_LENGTH = 3;
const GOLD_PER_WIN = 15;
```
- [ ] **Step 2: 새 속성 추가** — 속성 배열의 `prop('any', 'EnemyName'),` 다음에:
```js
prop('any', 'RunDeck'),
prop('number', 'Gold', '0'),
prop('number', 'Floor', '0'),
prop('number', 'RunLength', String(RUN_LENGTH)),
prop('any', 'RewardChoices'),
prop('boolean', 'RunActive', 'false'),
```
- [ ] **Step 3: OnBeginPlay → StartRun**`method('OnBeginPlay', \`self:StartCombat()\`),` 를:
```js
method('OnBeginPlay', `self:StartRun()`),
```
- [ ] **Step 4: StartRun 메서드 추가** — OnBeginPlay 다음에 삽입:
```js
method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
self.RunLength = ${RUN_LENGTH}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
self:BindButtons()
self:StartCombat()`),
```
- [ ] **Step 5: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E1): 런 상태 속성·StartRun 추가"
```
---
### Task 2: StartCombat 수정 + BindButtons 수정
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: StartCombat 본문 교체**`method('StartCombat', \`...\`)`의 코드를 아래로(HP 보존·Floor++·RunDeck에서 드로·BindButtons 호출 제거):
```js
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
self.Floor = self.Floor + 1
self.PlayerBlock = 0
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
${luaIntentsTable(ACTIVE_ENEMY.intents)}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:RenderCombat()
self:StartPlayerTurn()`),
```
- [ ] **Step 2: BindButtons에 보상 버튼 바인딩 추가** — BindButtons 코드 끝(마지막 `end` 다음)에 추가. 현재 마지막 부분:
```
for i = 1, 5 do
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
end
end
```
뒤에 이어붙이도록 BindButtons 코드를 아래 전체로 교체:
```js
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and endTurn.ButtonComponent ~= nil then
if self.EndTurnHandler ~= nil then
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
self.EndTurnHandler = nil
end
self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
end
for i = 1, 5 do
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
end
end
for i = 1, 3 do
local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i))
if rc ~= nil and rc.ButtonComponent ~= nil then
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
end
end
local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip")
if skip ~= nil and skip.ButtonComponent ~= nil then
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
end`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E1): StartCombat 런 분리(HP보존·RunDeck드로)·BindButtons 1회+보상버튼"
```
---
### Task 3: CheckCombatEnd·OfferReward·PickReward·RenderRun
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: CheckCombatEnd 교체** — 보상/런클리어/패배 분기:
```js
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true
self.Gold = self.Gold + ${GOLD_PER_WIN}
self:RenderRun()
if self.Floor >= self.RunLength 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 2: OfferReward·ApplyRewardVisual·PickReward·RenderRun 추가** — RenderCombat 메서드 다음에 삽입:
```js
method('OfferReward', `local pool = {}
for id, _ in pairs(self.Cards) do
table.insert(pool, id)
end
self.RewardChoices = {}
for i = 1, 3 do
self.RewardChoices[i] = pool[math.random(1, #pool)]
self:ApplyRewardVisual(i, self.RewardChoices[i])
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = true
end`),
method('ApplyRewardVisual', `local c = self.Cards[cardId]
if c == nil then
return
end
local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot)
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Cost", tostring(c.cost))
self:SetText(base .. "/Desc", c.desc)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if c.kind == "Attack" then
e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
elseif c.kind == "Skill" then
e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
end
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return
end
if slot ~= 0 and self.RewardChoices ~= nil then
local id = self.RewardChoices[slot]
if id ~= nil then
table.insert(self.RunDeck, id)
end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = false
end
self:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`),
```
- [ ] **Step 3: RenderCombat 끝에 RenderRun 호출 추가** — RenderCombat 코드의 마지막 줄(`...PlayerBlock...`) 다음에 `\nself:RenderRun()` 추가. 즉 RenderCombat 마지막을:
```
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock))
self:RenderRun()
```
로. (Edit: 기존 마지막 줄 끝에 `\nself:RenderRun()` 삽입)
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E2): 보상(OfferReward/PickReward)·런 클리어·층/골드 렌더"
```
---
### Task 4: UI — CombatHud 층/골드 + RewardHud
**Files:** Modify `tools/gen-slaydeck.mjs` (`upsertUi`, `guid`)
- [ ] **Step 1: guid 'rwd' 분기 추가** — guid()의 ns 매핑을:
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : 0xfe;
```
- [ ] **Step 2: 정리 필터 확장** — upsertUi 시작부 필터를:
```js
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud'));
```
- [ ] **Step 3: CombatHud에 Floor·Gold 텍스트 추가**`const result = entity({` 선언 직전(즉 result 추가 전)에 삽입:
```js
for (const [suffix, pos, value, color] of [
['Floor', { x: -820, y: 480 }, '층 1/3', GOLD],
['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
]) {
combat.push(entity({
id: guid('cmb', cmbN++),
path: `/ui/DefaultGroup/CombatHud/${suffix}`,
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: 240, y: 44 }, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize: 26, bold: true, color, alignment: 4 }),
],
}));
}
```
- [ ] **Step 4: RewardHud 그룹 생성**`ui.ContentProto.Entities.push(...combat);` 직후, `JSON.parse(JSON.stringify(ui));` 직전에 삽입:
```js
const reward = [];
const rewardHud = entity({
id: guid('rwd', 0),
path: '/ui/DefaultGroup/RewardHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 6,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }),
],
});
rewardHud.jsonString.enable = false;
reward.push(rewardHud);
reward.push(entity({
id: guid('rwd', 1),
path: '/ui/DefaultGroup/RewardHud/Title',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 64 }, pos: { x: 0, y: 300 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
let rwdN = 2;
const rewardXs = [-300, 0, 300];
for (let i = 1; i <= 3; i++) {
const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
reward.push(entity({
id: guid('rwd', rwdN++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: rewardXs[i - 1], y: 0 } }),
sprite({ color: ATTACK, type: 1, raycast: true }),
button(),
],
}));
for (const [suffix, cfg] of [
['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true }],
['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }],
['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }],
]) {
reward.push(entity({
id: guid('rwd', rwdN++),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }),
],
}));
}
}
reward.push(entity({
id: guid('rwd', rwdN++),
path: '/ui/DefaultGroup/RewardHud/Skip',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 10,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -260 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...reward);
```
- [ ] **Step 5: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E2): CombatHud 층/골드 + RewardHud(보상 카드 3+건너뛰기) UI"
```
---
### Task 5: 재생성 + 검증
**Files:** 생성물 2종
- [ ] **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(['StartRun','OfferReward','PickReward','RenderRun','ApplyRewardVisual'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); console.log(u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/RewardHud')&&u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/CombatHud/Gold')?'UI OK':'UI MISSING')"`
Expected: `METHODS 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: `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 "재생성(E1+E2): 런 루프·보상 UI 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (사용자/MCP)**
reload→Play: 승리 → RewardHud 카드 3장·골드+15·층 표시 → 1택 시 RunDeck+1·다음 전투(HP 유지) → 3전투째 승리 시 "런 클리어!". 패배 시 "패배...". MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출 + 상태 로그로 검증.
---
## Self-Review
- **Spec coverage:** 상수·속성·StartRun(Task1), StartCombat분리·BindButtons1회(Task2), 보상·런클리어·렌더(Task3), 층/골드·RewardHud UI(Task4), 검증(Task5). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** 메서드명 `StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat` 정의·호출 일치. UI 경로 `/ui/DefaultGroup/RewardHud/Reward{1..3}/{Name,Cost,Desc}`·`/Skip`·`/CombatHud/{Floor,Gold}`가 codeblock(ApplyRewardVisual/RenderRun/BindButtons)과 생성(Task4) 일치. 속성 `RunDeck/Gold/Floor/RunLength/RewardChoices/RunActive` 정의(Task1)·사용(Task2·3) 일치.

View File

@@ -0,0 +1,68 @@
# 런 루프 코어 (TODO E1+E2) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 E(분해) + SlayDeckController 분석.
> E(로그라이크 메타)의 첫 하위 프로젝트. 선행: B·D 완료. 후속: E3(맵)·E4(상점)·E5(유물)·E6(보스/저장).
## 문제
단일 전투(B)는 닫혔으나 승리 후 보상·다음 전투·덱 성장이 없다(보상 훅 자리만 비어 있음).
전투를 한 "런"으로 확장해야 덱빌딩 로그라이크가 된다.
## 범위 (이 슬라이스)
전투를 **연속 N전투 런**으로 확장: 런 상태 영속(HP/골드/덱) + 승리 후 카드 1택 보상 +
다음 전투 연결 + 고정 N전투 후 "런 클리어". **맵 노드·상점·유물·보스·저장은 범위 밖**(후속 E3~E6).
아키텍처: 기존 `SlayDeckController` 확장(별도 RunState 분리는 후속).
## 설계
### 런 파라미터 (생성기 상수 — 향후 외부화)
- `RUN_LENGTH = 3` (런당 전투 수), `GOLD_PER_WIN = 15`.
### 새 상태 (SlayDeckController 속성)
- `RunDeck`(any) — 보유 카드 id 누적 배열(영속).
- `Gold`(number) — 누적 골드.
- `Floor`(number) — 현재 전투 번호(1-base).
- `RunLength`(number) — 런당 전투 수.
- `RewardChoices`(any) — 현재 제시 중인 보상 카드 id 3개.
- `RunActive`(boolean) — 런 진행 중.
- 플레이어 HP는 전투 간 **유지**(StartCombat에서 리셋 안 함).
### 메서드
- `OnBeginPlay``self:StartRun()`.
- **`StartRun`**(신규): `PlayerMaxHp=80`, `PlayerHp=PlayerMaxHp`, `Gold=0`, `Floor=0`,
`RunLength=RUN_LENGTH`, `RunDeck = starterDeck 복사`, `RunActive=true``BindButtons()`(1회) → `StartCombat()`.
- **`StartCombat`**(수정): `Floor += 1`; 적 데이터(activeEnemy) 세팅; 전투별 리셋(Energy/Turn/Block/
EnemyHp/EnemyBlock/EnemyIntentIndex/DiscardPile/Hand/CombatOver); `DrawPile = RunDeck 복사` → Shuffle;
`Cards` 테이블 세팅. **HP·Gold·RunDeck 보존, BindButtons 호출 제거.** → RenderCombat → StartPlayerTurn.
- **`BindButtons`**(수정): EndTurn·카드5·**보상카드3·건너뛰기** 버튼을 1회 바인딩(StartRun에서 호출).
- **`CheckCombatEnd`**(수정):
- 적 HP≤0(승리): `Gold += GOLD_PER_WIN`; `CombatOver=true`;
`Floor >= RunLength`이면 `ShowResult("런 클리어!")` + `RunActive=false`;
아니면 `self:OfferReward()`.
- 플레이어 HP≤0(패배): `CombatOver=true`; `ShowResult("패배...")`; `RunActive=false`.
- **`OfferReward`**(신규): `RewardChoices = 카드풀에서 3개 무작위`(math.random); 각 보상 카드 UI 갱신
(이름/코스트/설명/색); RewardHud 표시(Enable).
- **`PickReward(slot)`**(신규): `slot`(1~3)이면 `RewardChoices[slot]``RunDeck`에 추가; `slot=0`(건너뛰기)이면 추가 안 함;
RewardHud 숨김 → `StartCombat()`(다음 층).
- **`RenderRun`**(신규): `층 Floor/RunLength`·`골드 Gold` 텍스트 갱신. RenderCombat에서 호출.
### UI (생성기 신규)
- `RewardHud`(평소 숨김): 제목 "보상 카드 선택" + 보상 카드 3장(UISprite+버튼, 이름/코스트/설명 자식) + "건너뛰기" 버튼.
- HUD 표시 추가: `/ui/DefaultGroup/CombatHud/Floor`("층 1/3"), `/Gold`("골드 0").
- 보상 카드 클릭 → `PickReward(slot)`, 건너뛰기 → `PickReward(0)`.
### 버그 예방
- `BindButtons`가 매 전투(StartCombat)마다 카드 버튼에 `ConnectEvent` → 런에서 핸들러 중첩.
**StartRun에서 1회만 바인딩**으로 이동(StartCombat의 BindButtons 호출 제거).
## 검증 (메이커 Play)
- 전투 승리 → RewardHud에 카드 3장 표시; 골드 +15·층 표시.
- 보상 1택 → RunDeck +1(다음 전투 손패/덱에 등장 가능), RewardHud 숨김, 다음 전투 시작(HP 유지).
- 건너뛰기 → 덱 변화 없이 다음 전투.
- 3전투째 승리 → "런 클리어!"·런 종료. 도중 패배 → "패배..."·런 종료.
- 카드/보상 버튼 클릭은 런타임(MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출로 검증).
- 생성기 결정적, JSON 유효.
## 범위 밖 (금지)
- 맵 노드(E3)·상점/휴식(E4)·유물(E5)·보스/층전환/저장(E6). 골드 소비(E4). 보상 풀 확장(메이플 IP 추후).

View File

@@ -60,7 +60,7 @@ const ALIGN_BOTTOM_CENTER = 6;
function guid(prefix, n) { function guid(prefix, n) {
// 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑. // 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑.
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : 0xfe; const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : 0xfe;
const v = (ns * 0x100000 + n) >>> 0; const v = (ns * 0x100000 + n) >>> 0;
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`; return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
} }
@@ -212,7 +212,7 @@ function entity({ id, path, modelId, entryId, componentNames, components, displa
function upsertUi() { function upsertUi() {
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8')); const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
const E = ui.ContentProto.Entities; const E = ui.ContentProto.Entities;
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud')); ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud'));
const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e])); const byPath = new Map(ui.ContentProto.Entities.map((e) => [e.path, e]));
@@ -451,6 +451,24 @@ function upsertUi() {
], ],
})); }));
} }
for (const [suffix, pos, value, color] of [
['Floor', { x: -820, y: 480 }, '층 1/3', GOLD],
['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
]) {
combat.push(entity({
id: guid('cmb', cmbN++),
path: `/ui/DefaultGroup/CombatHud/${suffix}`,
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: 240, y: 44 }, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize: 26, bold: true, color, alignment: 4 }),
],
}));
}
const result = entity({ const result = entity({
id: guid('cmb', cmbN++), id: guid('cmb', cmbN++),
path: '/ui/DefaultGroup/CombatHud/Result', path: '/ui/DefaultGroup/CombatHud/Result',
@@ -468,6 +486,87 @@ function upsertUi() {
combat.push(result); combat.push(result);
ui.ContentProto.Entities.push(...combat); ui.ContentProto.Entities.push(...combat);
const reward = [];
const rewardHud = entity({
id: guid('rwd', 0),
path: '/ui/DefaultGroup/RewardHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 6,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
sprite({ color: { r: 0.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }),
],
});
rewardHud.jsonString.enable = false;
reward.push(rewardHud);
reward.push(entity({
id: guid('rwd', 1),
path: '/ui/DefaultGroup/RewardHud/Title',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 64 }, pos: { x: 0, y: 300 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
let rwdN = 2;
const rewardXs = [-300, 0, 300];
for (let i = 1; i <= 3; i++) {
const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
reward.push(entity({
id: guid('rwd', rwdN++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: rewardXs[i - 1], y: 0 } }),
sprite({ color: ATTACK, type: 1, raycast: true }),
button(),
],
}));
for (const [suffix, cfg] of [
['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true }],
['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }],
['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }],
]) {
reward.push(entity({
id: guid('rwd', rwdN++),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }),
],
}));
}
}
reward.push(entity({
id: guid('rwd', rwdN++),
path: '/ui/DefaultGroup/RewardHud/Skip',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 10,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -260 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...reward);
JSON.parse(JSON.stringify(ui)); JSON.parse(JSON.stringify(ui));
writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8'); writeFileSync(UI_FILE, JSON.stringify(ui, null, 2), 'utf8');
} }
@@ -522,6 +621,8 @@ function codeblock(id, name, properties, methods) {
} }
function writeCodeblocks() { function writeCodeblocks() {
const RUN_LENGTH = 3;
const GOLD_PER_WIN = 15;
const combat = codeblock('SlayDeckController', 'SlayDeckController', [ const combat = codeblock('SlayDeckController', 'SlayDeckController', [
prop('any', 'DrawPile'), prop('any', 'DrawPile'),
prop('any', 'DiscardPile'), prop('any', 'DiscardPile'),
@@ -542,12 +643,26 @@ function writeCodeblocks() {
prop('boolean', 'CombatOver', 'false'), prop('boolean', 'CombatOver', 'false'),
prop('any', 'EnemyIntents'), prop('any', 'EnemyIntents'),
prop('any', 'EnemyName'), prop('any', 'EnemyName'),
prop('any', 'RunDeck'),
prop('number', 'Gold', '0'),
prop('number', 'Floor', '0'),
prop('number', 'RunLength', String(RUN_LENGTH)),
prop('any', 'RewardChoices'),
prop('boolean', 'RunActive', 'false'),
], [ ], [
method('OnBeginPlay', `self:StartCombat()`), method('OnBeginPlay', `self:StartRun()`),
method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
self.RunLength = ${RUN_LENGTH}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
self:BindButtons()
self:StartCombat()`),
method('StartCombat', `self.MaxEnergy = 3 method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0 self.Turn = 0
self.PlayerMaxHp = 80 self.Floor = self.Floor + 1
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0 self.PlayerBlock = 0
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)} self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp} self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
@@ -559,9 +674,11 @@ self.CombatOver = false
self.DiscardPile = {} self.DiscardPile = {}
self.Hand = {} self.Hand = {}
${luaCardsTable(CARDS.cards)} ${luaCardsTable(CARDS.cards)}
${luaDeckTable(CARDS.starterDeck)} self.DrawPile = {}
for i = 1, #self.RunDeck do
self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile) self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat() self:RenderCombat()
self:StartPlayerTurn()`), self:StartPlayerTurn()`),
method('Shuffle', `if list == nil then method('Shuffle', `if list == nil then
@@ -584,6 +701,16 @@ for i = 1, 5 do
if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end) cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
end end
end
for i = 1, 3 do
local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i))
if rc ~= nil and rc.ButtonComponent ~= nil then
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
end
end
local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip")
if skip ~= nil and skip.ButtonComponent ~= nil then
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
end`), end`),
method('StartPlayerTurn', `self.Turn = self.Turn + 1 method('StartPlayerTurn', `self.Turn = self.Turn + 1
self.Energy = self.MaxEnergy self.Energy = self.MaxEnergy
@@ -766,11 +893,18 @@ end
self:RenderCombat()`), self:RenderCombat()`),
method('CheckCombatEnd', `if self.EnemyHp <= 0 then method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true self.CombatOver = true
self:ShowResult("승리!") self.Gold = self.Gold + ${GOLD_PER_WIN}
-- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점 self:RenderRun()
if self.Floor >= self.RunLength then
self:ShowResult("런 클리어!")
self.RunActive = false
else
self:OfferReward()
end
elseif self.PlayerHp <= 0 then elseif self.PlayerHp <= 0 then
self.CombatOver = true self.CombatOver = true
self:ShowResult("패배...") self:ShowResult("패배...")
self.RunActive = false
end`), end`),
method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text) method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text)
local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result") local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result")
@@ -791,7 +925,58 @@ if intent ~= nil then
end end
self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText) self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock))`), self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock))
self:RenderRun()`),
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`),
method('OfferReward', `local pool = {}
for id, _ in pairs(self.Cards) do
table.insert(pool, id)
end
self.RewardChoices = {}
for i = 1, 3 do
self.RewardChoices[i] = pool[math.random(1, #pool)]
self:ApplyRewardVisual(i, self.RewardChoices[i])
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = true
end`),
method('ApplyRewardVisual', `local c = self.Cards[cardId]
if c == nil then
return
end
local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot)
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Cost", tostring(c.cost))
self:SetText(base .. "/Desc", c.desc)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if c.kind == "Attack" then
e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
elseif c.kind == "Skill" then
e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
end
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return
end
if slot ~= 0 and self.RewardChoices ~= nil then
local id = self.RewardChoices[slot]
if id ~= nil then
table.insert(self.RunDeck, id)
end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = false
end
self:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
]); ]);
for (const m of combat.ContentProto.Json.Methods) { for (const m of combat.ContentProto.Json.Methods) {
m.ExecSpace = 6; m.ExecSpace = 6;

File diff suppressed because it is too large Load Diff