feature/slay-deck-controller #14

Merged
gahusb merged 8 commits from feature/slay-deck-controller into main 2026-06-09 13:39:17 +09:00
36 changed files with 23126 additions and 3741 deletions
Showing only changes of commit 861442e2c1 - Show all commits

View File

@@ -43,14 +43,15 @@ git pull
```
slaymaple/
├── Global/ # 월드 전역 설정 · 공용 모델 · 게임로직
│ ├── common.gamelogic # SlayCardCatalog / SlayRunState / SlayCombatManager 부착 지점
│ ├── common.gamelogic # SlayDeckController 부착 지점 (카드 UI 전투)
│ ├── Player.model # 플레이어 모델
│ ├── *.model # 몬스터 등 공용 모델
│ ├── WorldConfig.config # 월드 설정
│ └── ...
├── RootDesk/
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·모델·타일셋
│ ├── Monster.codeblock
│ ├── SlayDeckController.codeblock # 카드 UI 전투 컨트롤러 (생성물)
│ ├── Monster.codeblock # 필드 액션 몬스터 (HP·피격·리스폰, 카드 전투와 별개)
│ ├── MonsterAttack.codeblock
│ ├── PlayerAttack.codeblock
│ ├── PlayerHit.codeblock
@@ -58,7 +59,11 @@ slaymaple/
│ ├── UIToast.codeblock
│ └── RectTileData_Henesys.tileset
├── map/
── map01.map # 메인 맵
── map01.map ~ map11.map # 맵 11종 (공식 배경 + STS풍 우측 배치)
├── tools/ # 결정적 생성기 (단일 소스)
│ ├── gen-slaydeck.mjs # 카드/덱 UI · SlayDeckController · common 생성
│ ├── gen-cardhand.mjs # 손패 카드 엔티티 생성
│ └── gen-maps.mjs # 맵 생성
├── ui/ # UI 그룹 (Default / Popup / Toast)
├── docs/
│ └── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
@@ -71,42 +76,61 @@ slaymaple/
## 게임 프레임워크 현황
`Global/common.gamelogic``/common` 엔티티에 부착된 세 컴포넌트가 전투의 핵심입니다.
현재 전투는 `Global/common.gamelogic``/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. 모든 카드/덱/전투 관련 산출물(`ui/DefaultGroup.ui` · `RootDesk/MyDesk/SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(직접 편집 금지, 결정적 출력).
| 컴포넌트 | 역할 |
|---|---|
| `SlayCardCatalog` | 카드 데이터, 시작 덱 구성, 보상 풀, 카드 복제 정의 |
| `SlayRunState` | HP·골드·층수·덱·유물·카드 보상 등 런(run) 영속 데이터 관리 |
| `SlayCombatManager` | 턴 진행, 드로우/버림/소멸 더미, 에너지, 적 의도, 방어도, 데미지, 승패 처리 |
| 컴포넌트 | 상태 | 역할 |
|---|---|---|
| `SlayDeckController` | ✅ 구현됨 | 카드 손패 UI 전투 — 드로우/버림/재셔플, 에너지, 카드 효과(데미지/방어), 적 HP·방어·의도, 턴 진행, 승패 |
| `Monster.codeblock` | ✅ 구현됨 | 필드 액션 몬스터(HP·피격·리스폰) — 카드 전투와는 **별개** 시스템 |
### 프로토타입 흐름
1. `SlayRunState`가 HP 80 · 10장 시작 덱으로 새 런 시작
2. `SlayCombatManager`가 데모 전투 자동 시작
3. 매 플레이어 턴: 에너지 3 회복, 방어도 초기화, 적 의도 갱신, 5장 드로우
4. 카드 사용 시 에너지 소모 → 데미지/방어/드로우/에너지/상태이상 적용 → 버림 또는 소멸
5. 턴 종료 시 손패 버림, 적 의도 실행, 상태이상 처리, 다음 턴 시작
6. 전투 승리 시 잔여 HP 저장, 골드 15 지급, 카드 보상 3종 생성
### 구현된 카드 전투 (단일 전투 루프)
- **카드 손패 UI**: 에너지 3, 매 턴 5장 드로우, 버림 더미·재셔플, 카드 클릭 사용, 종류별 색상.
- **카드 3종**: 타격(피해 6) · 방어(방어도 5) · 강타(피해 10). 각 카드에 `damage`/`block` 수치 필드. 시작 덱 10장.
- **전투 상태**: 플레이어 `HP`/`Block`, 적 `HP`/`Block`/`Intent(의도)`. 적 의도는 **결정적 사이클**(공격10 → 공격6 → 방어8)로 다음 행동을 미리 표시.
- **규칙**: 데미지는 방어도 먼저 차감 후 잔여만 HP에 적용. 플레이어 Block은 턴 시작 시 리셋.
- **턴 흐름**: 카드 사용(`Attack`→적 HP↓, `Skill`→플레이어 Block↑) → 턴 종료 → 적 턴(의도 실행) → 다음 플레이어 턴.
- **승패**: 적 HP 0 → 승리, 플레이어 HP 0 → 패배. 승패 시 입력 잠금 + 결과 표시(전투 보상 훅 자리 = E 예정).
- **UI(CombatHud)**: 적 패널(이름·HP·방어·의도)·플레이어 패널(HP·방어)·승패 결과 텍스트.
> ⚠️ 플레이어 HP(80)·적 HP(45)·의도 수치(10·6·8)는 **루프 검증용 임시 placeholder**입니다.
> 향후 캐릭터 특성별/몬스터별 데이터로 분리할 예정입니다(아래 D 참조).
### 유용한 스크립트 호출
`/common` 엔티티에 붙은 스크립트에서:
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
```lua
self.Entity.SlayCombatManager:PlayCard(1, 1)
self.Entity.SlayCombatManager:EndPlayerTurn()
self.Entity.SlayCombatManager:DebugPlayFirstPlayable()
self.Entity.SlayRunState:PickReward(1)
self.Entity.SlayCombatManager:StartCombat("elite")
local c = _EntityService:GetEntityByPath("/common").SlayDeckController
c:PlayCard(1) -- 손패 slot 카드 사용
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
c:StartCombat() -- 전투 재시작(상태 초기화)
```
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 참조.
---
## 향후 설계 (미구현 — 목표 아키텍처)
아래는 로그라이크 메타까지 확장했을 때의 **목표 컴포넌트 구조**로, 현재는 미구현입니다. (현재는 `SlayDeckController` 하나가 카드 전투만 담당)
| 컴포넌트 (계획) | 역할 |
|---|---|
| `SlayCardCatalog` | 카드 데이터, 시작 덱 구성, 보상 풀, 카드 복제 정의 |
| `SlayRunState` | HP·골드·층수·덱·유물·카드 보상 등 런(run) 영속 데이터 관리 |
| `SlayCombatManager` | 턴 진행, 드로우/버림/소멸 더미, 에너지, 적 의도, 방어도, 데미지, 승패 처리 |
> 위 구조로 가더라도 카드/적 데이터는 `tools/`의 결정적 생성기를 단일 소스로 유지하는 방향을 권장합니다.
---
## 다음 구현 단계
- [ ] HP·방어도·에너지·적 의도·손패 5버튼을 렌더링하는 전투 UI
- [ ] 전투/엘리트/상점/휴식/이벤트/보스 노드를 가진 맵 노드 UI
- [ ] `OnCombatStart` / `OnCardPlayed` / `OnTurnStart` / `OnCombatReward` 훅을 가진 유물 시스템
- [ ] 적 행동 패턴을 데이터로 정의 (현재 단순 의도 패턴 → 무브셋)
- [ ] 런 루프 완성 후 저장/불러오기
- [x] HP·방어도·에너지·적 의도·손패 카드를 렌더링하는 전투 UI **(완료 — `SlayDeckController` + CombatHud)**
- [x] 카드 사용이 실제 데미지/방어/적 의도/승패에 반영되는 단일 전투 루프 **(완료)**
- [ ] 카드/적 데이터를 `data/cards.json` · `data/enemies.json`로 외부화 (D)
- [ ] 전투를 N회 자동 시뮬레이션하는 밸런스 검증 도구 `tools/sim-balance.mjs` (F, D 선행)
- [ ] 전투/엘리트/상점/휴식/이벤트/보스 노드를 가진 맵 노드 UI (E)
- [ ] `OnCombatStart` / `OnCardPlayed` / `OnTurnStart` / `OnCombatReward` 훅을 가진 유물 시스템 (E)
- [ ] 적 행동 패턴을 데이터로 정의 (현재 단순 결정적 의도 사이클 → 무브셋)
- [ ] 런 영속(HP/골드/층/덱/유물) + 저장/불러오기 (E, 루프 end-to-end 후)
---

View File

@@ -91,6 +91,209 @@
"SyncDirection": 0,
"Attributes": [],
"Name": "NewGameHandler"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "Cards"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "PlayerHp"
},
{
"Type": "number",
"DefaultValue": "80",
"SyncDirection": 0,
"Attributes": [],
"Name": "PlayerMaxHp"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "PlayerBlock"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyHp"
},
{
"Type": "number",
"DefaultValue": "45",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyMaxHp"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyBlock"
},
{
"Type": "number",
"DefaultValue": "1",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyIntentIndex"
},
{
"Type": "boolean",
"DefaultValue": "false",
"SyncDirection": 0,
"Attributes": [],
"Name": "CombatOver"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyIntents"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"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"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "Enemies"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "MapNodes"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "MapStart"
},
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "CurrentNodeId"
},
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "CurrentEnemyId"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "ShopChoices"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "ShopBought"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "Relics"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "RunRelics"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "RelicPool"
},
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "ShopRelic"
},
{
"Type": "boolean",
"DefaultValue": "false",
"SyncDirection": 0,
"Attributes": [],
"Name": "ShopRelicBought"
}
],
"Methods": [
@@ -118,7 +321,7 @@
"Name": null
},
"Arguments": [],
"Code": "self:SetEntityEnabled(\"/ui/DefaultGroup/MainMenu\", true)\nself:SetEntityEnabled(\"/ui/DefaultGroup/CardHand\", false)\nself:SetEntityEnabled(\"/ui/DefaultGroup/DeckHud\", false)\nself:BindMenuButtons()",
"Code": "self:SetEntityEnabled(\"/ui/DefaultGroup/MainMenu\", true)\nself:BindMenuButtons()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -133,7 +336,7 @@
"Name": null
},
"Arguments": [],
"Code": "local buttonEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/MainMenu/NewGameButton\")\nif buttonEntity == nil or buttonEntity.ButtonComponent == nil then\n\treturn\nend\nif self.NewGameHandler ~= nil then\n\tbuttonEntity:DisconnectEvent(ButtonClickEvent, self.NewGameHandler)\n\tself.NewGameHandler = nil\nend\nself.NewGameHandler = buttonEntity:ConnectEvent(ButtonClickEvent, self.StartNewGame)",
"Code": "local buttonEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/MainMenu/NewGameButton\")\nif buttonEntity == nil or buttonEntity.ButtonComponent == nil then\n\treturn\nend\nif self.NewGameHandler ~= nil then\n\tbuttonEntity:DisconnectEvent(ButtonClickEvent, self.NewGameHandler)\n\tself.NewGameHandler = nil\nend\nself.NewGameHandler = buttonEntity:ConnectEvent(ButtonClickEvent, function() self:StartNewGame() end)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -148,12 +351,42 @@
"Name": null
},
"Arguments": [],
"Code": "self:SetEntityEnabled(\"/ui/DefaultGroup/MainMenu\", false)\nself:SetEntityEnabled(\"/ui/DefaultGroup/CardHand\", true)\nself:SetEntityEnabled(\"/ui/DefaultGroup/DeckHud\", true)\nself:ConfigureTurnBasedMonsters()\nself:ConfigureTurnBasedPlayer()\nself:StartCombat()",
"Code": "self:SetEntityEnabled(\"/ui/DefaultGroup/MainMenu\", false)\nself:StartRun()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "StartNewGame"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "path"
},
{
"Type": "boolean",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "enabled"
}
],
"Code": "local e = _EntityService:GetEntityByPath(path)\nif e ~= nil then\n\te.Enable = enabled\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "SetEntityEnabled"
},
{
"Return": {
"Type": "void",
@@ -163,65 +396,27 @@
"Name": null
},
"Arguments": [],
"Code": "self.MaxEnergy = 3\nself.Turn = 0\nself.DiscardPile = {}\nself.Hand = {}\nself.DrawPile = { \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Defend\", \"Defend\", \"Defend\", \"Defend\", \"Bash\" }\nself:Shuffle(self.DrawPile)\nself:BindButtons()\nself:StartPlayerTurn()",
"Code": "self.PlayerMaxHp = 80\nself.PlayerHp = self.PlayerMaxHp\nself.Gold = 0\nself.Floor = 1\nself.RunLength = 3\nself.RunDeck = { \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Strike\", \"Defend\", \"Defend\", \"Defend\", \"Defend\", \"Bash\" }\nself.RunActive = true\nself.RunRelics = {}\nself.Relics = {\n\tironHeart = { name = \"강철 심장\", desc = \"전투 시작 시 방어도 +6\", hook = \"combatStart\", effect = \"block\", value = 6 },\n\tenergyCore = { name = \"에너지 코어\", desc = \"턴 시작 시 에너지 +1\", hook = \"turnStart\", effect = \"energy\", value = 1 },\n\tvampire = { name = \"흡혈 송곳니\", desc = \"공격 카드 사용 시 HP +1\", hook = \"cardPlayed\", effect = \"healOnAttack\", value = 1 },\n\tgoldIdol = { name = \"황금 우상\", desc = \"전투 승리 시 골드 +10\", hook = \"combatReward\", effect = \"gold\", value = 10 },\n}\nself.RelicPool = { \"energyCore\", \"vampire\", \"goldIdol\" }\nself.Enemies = {\n\tslime = { name = \"슬라임\", maxHp = 45, intents = { { kind = \"Attack\", value = 10 }, { kind = \"Attack\", value = 6 }, { kind = \"Defend\", value = 8 } } },\n\tslime_elite = { name = \"정예 슬라임\", maxHp = 70, intents = { { kind = \"Attack\", value = 14 }, { kind = \"Attack\", value = 8 }, { kind = \"Defend\", value = 10 } } },\n\tslime_boss = { name = \"슬라임 킹\", maxHp = 120, intents = { { kind = \"Attack\", value = 18 }, { kind = \"Defend\", value = 12 }, { kind = \"Attack\", value = 10 }, { kind = \"Attack\", value = 22 } } },\n}\nself.MapNodes = {\n\tA = { type = \"combat\", enemy = \"slime\", row = 1, col = -1, next = { \"C\", \"D\" } },\n\tB = { type = \"combat\", enemy = \"slime\", row = 1, col = 1, next = { \"C\", \"D\" } },\n\tC = { type = \"rest\", row = 2, col = -1, next = { \"E\", \"F\" } },\n\tD = { type = \"shop\", row = 2, col = 1, next = { \"E\", \"F\" } },\n\tE = { type = \"elite\", enemy = \"slime_elite\", row = 3, col = -1, next = { \"BOSS\" } },\n\tF = { type = \"combat\", enemy = \"slime\", row = 3, col = 1, next = { \"BOSS\" } },\n\tBOSS = { type = \"boss\", enemy = \"slime_boss\", row = 4, col = 0, next = { } },\n}\nself.MapStart = { \"A\", \"B\" }\nself.CurrentNodeId = \"\"\nself.CurrentEnemyId = \"\"\nself:BindButtons()\nself:AddRelic(\"ironHeart\")\nself:ShowMap()",
"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\nlocal enemy = self.Enemies[self.CurrentEnemyId]\nlocal mult = 1 + (self.Floor - 1) * 0.6\nself.PlayerBlock = 0\nself.EnemyName = enemy.name\nself.EnemyMaxHp = math.floor(enemy.maxHp * mult)\nself.EnemyHp = self.EnemyMaxHp\nself.EnemyBlock = 0\nself.EnemyIntents = {}\nfor i = 1, #enemy.intents do\n\tself.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) }\nend\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()\nself:ApplyRelics(\"combatStart\")\nself:RenderCombat()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "StartCombat"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "for mapIndex = 1, 11 do\n\tlocal mapName = \"map\" .. string.format(\"%02d\", mapIndex)\n\tfor i = 1, 6 do\n\t\tself:ConfigureMonsterForTurnCombat(_EntityService:GetEntityByPath(\"/maps/\" .. mapName .. \"/Monster\" .. tostring(i)))\n\tend\n\tself:ConfigureMonsterForTurnCombat(_EntityService:GetEntityByPath(\"/maps/\" .. mapName .. \"/StaticMonsterTemplate\"))\n\tself:ConfigureMonsterForTurnCombat(_EntityService:GetEntityByPath(\"/maps/\" .. mapName .. \"/MoveMonsterTemplate\"))\n\tself:ConfigureMonsterForTurnCombat(_EntityService:GetEntityByPath(\"/maps/\" .. mapName .. \"/ChaseMonsterTemplate\"))\n\tself:ConfigureMonsterForTurnCombat(_EntityService:GetEntityByPath(\"/maps/\" .. mapName .. \"/monster-43\"))\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ConfigureTurnBasedMonsters"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "Entity",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "monster"
}
],
"Code": "if monster == nil then\n\treturn\nend\nif monster.AIWanderComponent ~= nil then\n\tmonster.AIWanderComponent.Enable = false\nend\nif monster.AIChaseComponent ~= nil then\n\tmonster.AIChaseComponent.Enable = false\nend\nif monster.MovementComponent ~= nil then\n\tmonster.MovementComponent.Enable = false\nend\nif monster.RigidbodyComponent ~= nil then\n\tmonster.RigidbodyComponent.MoveVelocity = Vector2.zero\n\tmonster.RigidbodyComponent.RealMoveVelocity = Vector2.zero\nend\nif monster.TransformComponent ~= nil then\n\tlocal scale = monster.TransformComponent.Scale\n\tmonster.TransformComponent.Scale = Vector3(math.abs(scale.x), math.abs(scale.y), scale.z)\nend\nif monster.StateAnimationComponent ~= nil and monster.SpriteRendererComponent ~= nil then\n\tlocal stand = monster.StateAnimationComponent.ActionSheet[\"stand\"]\n\tif stand ~= nil and stand ~= \"\" then\n\t\tmonster.SpriteRendererComponent.SpriteRUID = stand\n\tend\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ConfigureMonsterForTurnCombat"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local player = nil\npcall(function()\n\tif _UserService ~= nil and _UserService.LocalPlayer ~= nil then\n\t\tplayer = _UserService.LocalPlayer\n\tend\nend)\npcall(function()\n\tif player == nil and _UserService ~= nil and _UserService.LocalPlayerEntity ~= nil then\n\t\tplayer = _UserService.LocalPlayerEntity\n\tend\nend)\npcall(function()\n\tif player == nil and _UserService ~= nil and _UserService.GetLocalPlayer ~= nil then\n\t\tplayer = _UserService:GetLocalPlayer()\n\tend\nend)\nif player ~= nil and player.Entity ~= nil then\n\tplayer = player.Entity\nend\nif player == nil then\n\treturn\nend\nif player.PlayerControllerComponent ~= nil then\n\tplayer.PlayerControllerComponent.Enable = false\n\tpcall(function() player.PlayerControllerComponent.LookDirectionX = 1 end)\nend\nif player.MovementComponent ~= nil then\n\tplayer.MovementComponent.Enable = false\n\tpcall(function() player.MovementComponent.InputSpeed = 0 end)\n\tpcall(function() player.MovementComponent.JumpForce = 0 end)\nend\nif player.RigidbodyComponent ~= nil then\n\tplayer.RigidbodyComponent.MoveVelocity = Vector2.zero\n\tplayer.RigidbodyComponent.RealMoveVelocity = Vector2.zero\nend\nif player.TransformComponent ~= nil then\n\tlocal scale = player.TransformComponent.Scale\n\tplayer.TransformComponent.Scale = Vector3(math.abs(scale.x), math.abs(scale.y), scale.z)\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ConfigureTurnBasedPlayer"
},
{
"Return": {
"Type": "void",
@@ -254,7 +449,7 @@
"Name": null
},
"Arguments": [],
"Code": "local buttonEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/DeckHud/EndTurnButton\")\nif buttonEntity == nil or buttonEntity.ButtonComponent == nil then\n\treturn\nend\nif self.EndTurnHandler ~= nil then\n\tbuttonEntity:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)\n\tself.EndTurnHandler = nil\nend\nself.EndTurnHandler = buttonEntity:ConnectEvent(ButtonClickEvent, self.EndPlayerTurn)",
"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\nlocal mapNodeIds = { \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"BOSS\" }\nfor i = 1, #mapNodeIds do\n\tlocal nid = mapNodeIds[i]\n\tlocal mn = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/MapHud/Node_\" .. nid)\n\tif mn ~= nil and mn.ButtonComponent ~= nil then\n\t\tmn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)\n\tend\nend\nfor i = 1, 3 do\n\tlocal sc = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/ShopHud/Card\" .. tostring(i))\n\tif sc ~= nil and sc.ButtonComponent ~= nil then\n\t\tsc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)\n\tend\nend\nlocal shopLeave = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/ShopHud/Leave\")\nif shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then\n\tshopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)\nend\nlocal shopRelic = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/ShopHud/Relic\")\nif shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then\n\tshopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)\nend\nlocal restLeave = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RestHud/Leave\")\nif restLeave ~= nil and restLeave.ButtonComponent ~= nil then\n\trestLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -269,7 +464,7 @@
"Name": null
},
"Arguments": [],
"Code": "self.Turn = self.Turn + 1\nself.Energy = self.MaxEnergy\nself:DrawCards(5)\nself:RenderHand(true)",
"Code": "self.Turn = self.Turn + 1\nself.Energy = self.MaxEnergy\nself:ApplyRelics(\"turnStart\")\nself.PlayerBlock = 0\nself:DrawCards(5)\nself:RenderHand(true)\nself:RenderCombat()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -284,7 +479,7 @@
"Name": null
},
"Arguments": [],
"Code": "for i = 1, #self.Hand do\n\ttable.insert(self.DiscardPile, self.Hand[i])\nend\nself.Hand = {}\nself:RenderHand(false)\nself:RenderPiles()\n_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)",
"Code": "if self.CombatOver == true then\n\treturn\nend\nfor i = 1, #self.Hand do\n\ttable.insert(self.DiscardPile, self.Hand[i])\nend\nself.Hand = {}\nself:RenderHand(false)\nself:RenderPiles()\nself:EnemyTurn()\nself:CheckCombatEnd()\nif self.CombatOver == true then\n\treturn\nend\n_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -337,7 +532,7 @@
"Name": null
},
"Arguments": [],
"Code": "self:SetText(\"/ui/DefaultGroup/DeckHud/DrawPile/Count\", tostring(#self.DrawPile))\nself:SetText(\"/ui/DefaultGroup/DeckHud/DiscardPile/Count\", tostring(#self.DiscardPile))\nself:SetText(\"/ui/DefaultGroup/DeckHud/Energy\", \"에너지 \" .. tostring(self.Energy) .. \"/\" .. tostring(self.MaxEnergy))",
"Code": "self:SetText(\"/ui/DefaultGroup/DeckHud/DrawPile/Count\", tostring(#self.DrawPile))\nself:SetText(\"/ui/DefaultGroup/DeckHud/DiscardPile/Count\", tostring(#self.DiscardPile))\nself:SetText(\"/ui/DefaultGroup/DeckHud/Energy\", \"에너지 \" .. string.format(\"%d\", self.Energy) .. \"/\" .. string.format(\"%d\", self.MaxEnergy))",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -390,7 +585,7 @@
"Name": "cardId"
}
],
"Code": "local name = cardId\nlocal cost = 0\nlocal desc = \"\"\nlocal kind = \"Skill\"\nif cardId == \"Strike\" then\n\tname = \"타격\"\n\tcost = 1\n\tdesc = \"피해 6\"\n\tkind = \"Attack\"\nelseif cardId == \"Defend\" then\n\tname = \"방어\"\n\tcost = 1\n\tdesc = \"방어도 5\"\n\tkind = \"Skill\"\nelseif cardId == \"Bash\" then\n\tname = \"강타\"\n\tcost = 2\n\tdesc = \"피해 10\"\n\tkind = \"Attack\"\nend\nself:SetText(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot) .. \"/Cost\", tostring(cost))\nself:SetText(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot) .. \"/Name\", name)\nself:SetText(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot) .. \"/Desc\", desc)\nlocal cardEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot))\nif cardEntity ~= nil and cardEntity.SpriteGUIRendererComponent ~= nil then\n\tlocal ok = false\n\tlocal color = nil\n\tif kind == \"Attack\" then\n\t\tok, color = pcall(function() return Color(0.86, 0.42, 0.38, 1) end)\n\telseif kind == \"Skill\" then\n\t\tok, color = pcall(function() return Color(0.42, 0.55, 0.85, 1) end)\n\tend\n\tif ok == true and color ~= nil then\n\t\tcardEntity.SpriteGUIRendererComponent.Color = color\n\tend\nend",
"Code": "local c = self.Cards[cardId]\nif c == nil then\n\tc = { name = cardId, cost = 0, desc = \"\", kind = \"Skill\" }\nend\nself:SetText(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot) .. \"/Cost\", tostring(c.cost))\nself:SetText(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot) .. \"/Name\", c.name)\nself:SetText(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot) .. \"/Desc\", c.desc)\nlocal cardEntity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CardHand/Card\" .. tostring(slot))\nif cardEntity ~= nil and cardEntity.SpriteGUIRendererComponent ~= nil then\n\tif c.kind == \"Attack\" then\n\t\tcardEntity.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)\n\telseif c.kind == \"Skill\" then\n\t\tcardEntity.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)\n\telse\n\t\tcardEntity.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)\n\tend\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -426,36 +621,6 @@
"Attributes": [],
"Name": "SetText"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "path"
},
{
"Type": "boolean",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "enabled"
}
],
"Code": "local entity = _EntityService:GetEntityByPath(path)\nif entity ~= nil then\n\tentity.Enable = enabled\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "SetEntityEnabled"
},
{
"Return": {
"Type": "void",
@@ -499,6 +664,484 @@
"ExecSpace": 6,
"Attributes": [],
"Name": "AnimateCardFrom"
},
{
"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 then\n\treturn\nend\nif self.Hand == nil then\n\treturn\nend\nlocal cardId = self.Hand[slot]\nif cardId == nil then\n\treturn\nend\nlocal c = self.Cards[cardId]\nif c == nil then\n\treturn\nend\nif self.Energy < c.cost then\n\tself:Toast(\"에너지가 부족합니다\")\n\treturn\nend\nself.Energy = self.Energy - c.cost\nif c.kind == \"Attack\" then\n\tif c.damage ~= nil then\n\t\tself:DealDamageToEnemy(c.damage)\n\tend\n\tself:ApplyRelics(\"cardPlayed\")\nelseif c.kind == \"Skill\" then\n\tif c.block ~= nil then\n\t\tself.PlayerBlock = self.PlayerBlock + c.block\n\tend\nend\ntable.remove(self.Hand, slot)\ntable.insert(self.DiscardPile, cardId)\nself:RenderHand(false)\nself:RenderPiles()\nself:RenderCombat()\nself:CheckCombatEnd()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "PlayCard"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "message"
}
],
"Code": "log(message)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "Toast"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "number",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "amount"
}
],
"Code": "local dmg = amount\nif self.EnemyBlock > 0 then\n\tlocal absorbed = math.min(self.EnemyBlock, dmg)\n\tself.EnemyBlock = self.EnemyBlock - absorbed\n\tdmg = dmg - absorbed\nend\nself.EnemyHp = self.EnemyHp - dmg\nif self.EnemyHp < 0 then\n\tself.EnemyHp = 0\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "DealDamageToEnemy"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "number",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "amount"
}
],
"Code": "local dmg = amount\nif self.PlayerBlock > 0 then\n\tlocal absorbed = math.min(self.PlayerBlock, dmg)\n\tself.PlayerBlock = self.PlayerBlock - absorbed\n\tdmg = dmg - absorbed\nend\nself.PlayerHp = self.PlayerHp - dmg\nif self.PlayerHp < 0 then\n\tself.PlayerHp = 0\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "DealDamageToPlayer"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.EnemyBlock = 0\nlocal intent = self.EnemyIntents[self.EnemyIntentIndex]\nif intent ~= nil then\n\tif intent.kind == \"Attack\" then\n\t\tself:DealDamageToPlayer(intent.value)\n\telseif intent.kind == \"Defend\" then\n\t\tself.EnemyBlock = self.EnemyBlock + intent.value\n\tend\nend\nself.EnemyIntentIndex = self.EnemyIntentIndex + 1\nif self.EnemyIntentIndex > #self.EnemyIntents then\n\tself.EnemyIntentIndex = 1\nend\nself:RenderCombat()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "EnemyTurn"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "if self.EnemyHp <= 0 then\n\tself.CombatOver = true\n\tself.Gold = self.Gold + 15\n\tself:ApplyRelics(\"combatReward\")\n\tself:RenderRun()\n\tlocal node = self.MapNodes[self.CurrentNodeId]\n\tif node ~= nil and node.type == \"elite\" then\n\t\tself:AddRelic(self.RelicPool[math.random(1, #self.RelicPool)])\n\tend\n\tif node ~= nil and node.type == \"boss\" then\n\t\tif self.Floor < self.RunLength then\n\t\t\tself.Floor = self.Floor + 1\n\t\t\tself.CurrentNodeId = \"\"\n\t\t\tself.CurrentEnemyId = \"\"\n\t\t\tself:RenderRun()\n\t\t\tself:ShowMap()\n\t\telse\n\t\t\tself:ShowResult(\"런 클리어!\")\n\t\t\tself.RunActive = false\n\t\tend\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,
"ExecSpace": 6,
"Attributes": [],
"Name": "CheckCombatEnd"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "text"
}
],
"Code": "self:SetText(\"/ui/DefaultGroup/CombatHud/Result\", text)\nlocal entity = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CombatHud/Result\")\nif entity ~= nil then\n\tentity.Enable = true\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ShowResult"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"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))\nself:RenderRun()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"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:ShowMap()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "PickReward"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "hook"
}
],
"Code": "if self.RunRelics == nil then\n\treturn\nend\nfor i = 1, #self.RunRelics do\n\tlocal r = self.Relics[self.RunRelics[i]]\n\tif r ~= nil and r.hook == hook then\n\t\tif r.effect == \"block\" then\n\t\t\tself.PlayerBlock = self.PlayerBlock + r.value\n\t\telseif r.effect == \"energy\" then\n\t\t\tself.Energy = self.Energy + r.value\n\t\telseif r.effect == \"healOnAttack\" then\n\t\t\tself.PlayerHp = self.PlayerHp + r.value\n\t\t\tif self.PlayerHp > self.PlayerMaxHp then\n\t\t\t\tself.PlayerHp = self.PlayerMaxHp\n\t\t\tend\n\t\telseif r.effect == \"gold\" then\n\t\t\tself.Gold = self.Gold + r.value\n\t\tend\n\tend\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ApplyRelics"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "id"
}
],
"Code": "if self.RunRelics == nil then\n\tself.RunRelics = {}\nend\ntable.insert(self.RunRelics, id)\nself:RenderRelics()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "AddRelic"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local names = \"\"\nif self.RunRelics ~= nil then\n\tfor i = 1, #self.RunRelics do\n\t\tlocal r = self.Relics[self.RunRelics[i]]\n\t\tif r ~= nil then\n\t\t\tif names == \"\" then\n\t\t\t\tnames = r.name\n\t\t\telse\n\t\t\t\tnames = names .. \", \" .. r.name\n\t\t\tend\n\t\tend\n\tend\nend\nif names == \"\" then\n\tnames = \"없음\"\nend\nself:SetText(\"/ui/DefaultGroup/CombatHud/Relics\", \"유물: \" .. names)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "RenderRelics"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self:RenderMap()\nlocal hud = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/MapHud\")\nif hud ~= nil then\n\thud.Enable = true\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ShowMap"
},
{
"Return": {
"Type": "boolean",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "id"
}
],
"Code": "local list\nif self.CurrentNodeId == \"\" then\n\tlist = self.MapStart\nelse\n\tlocal node = self.MapNodes[self.CurrentNodeId]\n\tif node == nil then\n\t\treturn false\n\tend\n\tlist = node.next\nend\nfor i = 1, #list do\n\tif list[i] == id then\n\t\treturn true\n\tend\nend\nreturn false",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "IsReachable"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "for id, node in pairs(self.MapNodes) do\n\tlocal e = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/MapHud/Node_\" .. id)\n\tif e ~= nil then\n\t\tlocal reachable = self:IsReachable(id)\n\t\tif e.SpriteGUIRendererComponent ~= nil then\n\t\t\tif reachable then\n\t\t\t\te.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1)\n\t\t\telse\n\t\t\t\te.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)\n\t\t\tend\n\t\tend\n\t\tif e.ButtonComponent ~= nil then\n\t\t\te.ButtonComponent.Enable = reachable\n\t\tend\n\tend\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "RenderMap"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "id"
}
],
"Code": "if self.RunActive ~= true then\n\treturn\nend\nif self:IsReachable(id) ~= true then\n\treturn\nend\nself.CurrentNodeId = id\nlocal hud = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/MapHud\")\nif hud ~= nil then\n\thud.Enable = false\nend\nlocal node = self.MapNodes[id]\nif node.type == \"shop\" then\n\tself:ShowShop()\nelseif node.type == \"rest\" then\n\tself:ShowRest()\nelse\n\tself.CurrentEnemyId = node.enemy\n\tself:StartCombat()\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "PickNode"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local pool = {}\nfor cid, _ in pairs(self.Cards) do\n\ttable.insert(pool, cid)\nend\nself.ShopChoices = {}\nself.ShopBought = { false, false, false }\nfor i = 1, 3 do\n\tself.ShopChoices[i] = pool[math.random(1, #pool)]\nend\nself.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]\nself.ShopRelicBought = false\nself:RenderShop()\nlocal hud = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/ShopHud\")\nif hud ~= nil then\n\thud.Enable = true\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ShowShop"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self:SetText(\"/ui/DefaultGroup/ShopHud/Gold\", \"골드 \" .. string.format(\"%d\", self.Gold))\nfor i = 1, 3 do\n\tlocal cid = self.ShopChoices[i]\n\tlocal c = self.Cards[cid]\n\tlocal base = \"/ui/DefaultGroup/ShopHud/Card\" .. tostring(i)\n\tif c ~= nil then\n\t\tself:SetText(base .. \"/Name\", c.name)\n\t\tself:SetText(base .. \"/Cost\", tostring(c.cost))\n\t\tself:SetText(base .. \"/Desc\", c.desc)\n\t\tself:SetText(base .. \"/Price\", string.format(\"%d\", 30) .. \" 골드\")\n\t\tlocal e = _EntityService:GetEntityByPath(base)\n\t\tif e ~= nil and e.SpriteGUIRendererComponent ~= nil then\n\t\t\tif self.ShopBought[i] == true then\n\t\t\t\te.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)\n\t\t\telseif c.kind == \"Attack\" then\n\t\t\t\te.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)\n\t\t\telseif c.kind == \"Skill\" then\n\t\t\t\te.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)\n\t\t\telse\n\t\t\t\te.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)\n\t\t\tend\n\t\tend\n\tend\nend\nlocal rr = self.Relics[self.ShopRelic]\nif rr ~= nil then\n\tself:SetText(\"/ui/DefaultGroup/ShopHud/Relic/Label\", rr.name .. \" — \" .. rr.desc)\n\tself:SetText(\"/ui/DefaultGroup/ShopHud/Relic/Price\", string.format(\"%d\", 60) .. \" 골드\")\n\tlocal re = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/ShopHud/Relic\")\n\tif re ~= nil and re.SpriteGUIRendererComponent ~= nil then\n\t\tif self.ShopRelicBought == true then\n\t\t\tre.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)\n\t\telse\n\t\t\tre.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)\n\t\tend\n\tend\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "RenderShop"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "if self.ShopRelicBought == true then\n\treturn\nend\nif self.Gold < 60 then\n\treturn\nend\nself.Gold = self.Gold - 60\nself:AddRelic(self.ShopRelic)\nself.ShopRelicBought = true\nself:RenderShop()\nself:RenderRun()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "BuyRelic"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [
{
"Type": "number",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "slot"
}
],
"Code": "if self.ShopBought == nil or self.ShopBought[slot] == true then\n\treturn\nend\nif self.Gold < 30 then\n\treturn\nend\nself.Gold = self.Gold - 30\ntable.insert(self.RunDeck, self.ShopChoices[slot])\nself.ShopBought[slot] = true\nself:RenderShop()\nself:RenderRun()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "BuyCard"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local old = self.PlayerHp\nself.PlayerHp = self.PlayerHp + 30\nif self.PlayerHp > self.PlayerMaxHp then\n\tself.PlayerHp = self.PlayerMaxHp\nend\nlocal healed = self.PlayerHp - old\nself:SetText(\"/ui/DefaultGroup/RestHud/Info\", \"HP \" .. string.format(\"%d\", old) .. \" → \" .. string.format(\"%d\", self.PlayerHp) .. \" (+\" .. string.format(\"%d\", healed) .. \")\")\nself:RenderCombat()\nlocal hud = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RestHud\")\nif hud ~= nil then\n\thud.Enable = true\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "ShowRest"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local s = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/ShopHud\")\nif s ~= nil then\n\ts.Enable = false\nend\nlocal r = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/RestHud\")\nif r ~= nil then\n\tr.Enable = false\nend\nself:ShowMap()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "LeaveNode"
}
],
"EntityEventHandlers": []

View File

@@ -1,23 +0,0 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://eab37efa7f0d400f94259a2df836eb8a",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/eab37efa7f0d400f94259a2df836eb8a/639163110552472416",
"upload_hash": "AAD8C7C4E500FF8E001E85EAB181F3B19605BA9D8C8368DB28919B419515003D",
"name": "invincible belief",
"resource_guid": "eab37efa7f0d400f94259a2df836eb8a",
"resource_version": "6a238b0f1a7908d59b5d8fe4"
}
}
}

8
data/cards.json Normal file
View File

@@ -0,0 +1,8 @@
{
"cards": {
"Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" },
"Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" },
"Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" }
},
"starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"]
}

33
data/enemies.json Normal file
View File

@@ -0,0 +1,33 @@
{
"enemies": {
"slime": {
"name": "슬라임",
"maxHp": 45,
"intents": [
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 8 }
]
},
"slime_elite": {
"name": "정예 슬라임",
"maxHp": 70,
"intents": [
{ "kind": "Attack", "value": 14 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Defend", "value": 10 }
]
},
"slime_boss": {
"name": "슬라임 킹",
"maxHp": 120,
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 12 },
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 22 }
]
}
},
"activeEnemy": "slime"
}

12
data/map.json Normal file
View File

@@ -0,0 +1,12 @@
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
}
}

10
data/relics.json Normal file
View File

@@ -0,0 +1,10 @@
{
"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"]
}

View File

@@ -1,42 +1,84 @@
# SlayMaple Basic Framework
This project now has a small deckbuilder roguelike foundation inspired by turn-based card combat games.
This project has a working single-combat deckbuilder loop inspired by turn-based
card combat games. Card play is wired to real combat state (enemy/player HP,
block, enemy intent, win/lose).
## Components
## Current Components (implemented)
- `SlayCardCatalog`: Defines card data, starter deck composition, reward pool, and card cloning.
- `SlayRunState`: Owns persistent run data such as HP, gold, floor, deck, relics, and card rewards.
- `SlayCombatManager`: Runs combat turns, draw/discard/exhaust piles, energy, enemy intents, block, damage, victory, and defeat.
- `SlayDeckController`: The single combat component, attached to the `/common`
entity in `Global/common.gamelogic`. Handles the card-hand UI (draw/discard/
reshuffle, energy, card-click play), card effects (damage/block), enemy
HP/block/intent, turn flow, and victory/defeat.
- `Monster.codeblock`: A separate field-action monster system (HP, hit event,
respawn). **Not** part of the card combat.
All three components are attached to the `/common` entity in `Global/common.gamelogic`.
All card/deck/combat artifacts (`ui/DefaultGroup.ui`,
`RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`) are
**generated from a single source, `tools/gen-slaydeck.mjs`** (deterministic
output — do not hand-edit; change the generator and re-run `node
tools/gen-slaydeck.mjs`).
If the Maker session was already open before these files were added, reopen or reload the local workspace so the new codeblock files are imported into the editor state.
If the Maker session was already open before these files changed, reload the
local workspace so the regenerated codeblock/UI files are imported into the
editor state.
## Prototype Flow
## Implemented Combat Loop
1. `SlayRunState` starts a new run with 80 HP and a 10-card starter deck.
2. `SlayCombatManager` starts a demo combat automatically.
3. Each player turn refreshes energy to 3, clears block, rolls enemy intent, and draws 5 cards.
4. Playing a card spends energy, applies damage/block/draw/energy/status effects, then sends the card to discard or exhaust.
5. Ending the turn discards the hand, resolves enemy intent, ticks statuses, and starts the next turn.
6. Winning combat stores the remaining HP back into the run, grants 15 gold, and generates 3 card reward options.
1. `StartCombat` initializes player (HP 80, Block 0) and a single enemy
(HP 45, Block 0) with a deterministic 3-step intent cycle
(Attack 10 → Attack 6 → Defend 8), then starts the first player turn.
2. Each player turn refreshes energy to 3, resets player block, and draws 5
cards. The enemy's upcoming intent is shown in advance.
3. Cards: Strike (damage 6), Defend (block 5), Bash (damage 10). Each card has
numeric `damage`/`block` fields; starter deck is 10 cards.
4. Playing a card spends energy: `Attack` reduces enemy HP (block absorbs
first); `Skill` adds player block. The card moves to the discard pile.
5. Ending the turn discards the hand, runs the enemy turn (executes the current
intent — attack the player or gain block), advances the intent index, then
starts the next player turn.
6. Enemy HP 0 → victory; player HP 0 → defeat. On combat end, input is locked
and a result text is shown (combat-reward hook reserved for the roguelike
meta — see Planned below).
> Player HP (80), enemy HP (45), and intent values (10/6/8) are temporary
> placeholders for loop verification. They will move to per-character /
> per-enemy data (see "Data externalization" under Planned).
## Useful Script Calls
From a script attached to the same `/common` entity:
From the `/common` entity (or a Play Test context):
```lua
self.Entity.SlayCombatManager:PlayCard(1, 1)
self.Entity.SlayCombatManager:EndPlayerTurn()
self.Entity.SlayCombatManager:DebugPlayFirstPlayable()
self.Entity.SlayRunState:PickReward(1)
self.Entity.SlayCombatManager:StartCombat("elite")
local c = _EntityService:GetEntityByPath("/common").SlayDeckController
c:PlayCard(1) -- play the hand card in the given slot
c:EndPlayerTurn() -- end turn → enemy turn → next turn
c:StartCombat() -- restart combat (reset state)
```
## Planned (not yet implemented) — Target Architecture
The originally envisioned component split for the full roguelike. Currently
**none of these exist**; `SlayDeckController` covers only the card combat above.
- `SlayCardCatalog`: Card data, starter deck composition, reward pool, card cloning.
- `SlayRunState`: Persistent run data — HP, gold, floor, deck, relics, card rewards.
- `SlayCombatManager`: Turn flow, draw/discard/exhaust piles, energy, enemy
intents, block, damage, victory, and defeat (the role currently played by
`SlayDeckController`).
## Next Implementation Steps
- Add a combat UI that renders HP, block, energy, enemy intent, and 5 hand-card buttons.
- Add a map node UI with combat, elite, shop, rest, event, and boss node types.
- Add relic definitions and hooks such as `OnCombatStart`, `OnCardPlayed`, `OnTurnStart`, and `OnCombatReward`.
- Add enemy move sets as data instead of the current simple intent pattern.
- Add save/load once the run loop is playable end to end.
- [x] Combat UI rendering HP, block, energy, enemy intent, and hand cards
(done — `SlayDeckController` + CombatHud).
- [x] Card play wired to real damage/block/intent/win-lose (done).
- [ ] Externalize card/enemy data to `data/cards.json` / `data/enemies.json`,
injected by the generator.
- [ ] Monte-Carlo balance simulator `tools/sim-balance.mjs` (requires the data
externalization above).
- [ ] Map node UI with combat, elite, shop, rest, event, and boss node types.
- [ ] Relic definitions and hooks (`OnCombatStart`, `OnCardPlayed`,
`OnTurnStart`, `OnCombatReward`).
- [ ] Enemy move sets as data instead of the current deterministic intent cycle.
- [ ] Run persistence (HP/gold/floor/deck/relics) + save/load once the loop is
playable end to end.

View File

@@ -0,0 +1,481 @@
# 카드 전투 통합 (TODO B) 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:** 카드 사용이 실제 적 HP·플레이어 Block·적 의도·승패에 반영되는 단일 전투 루프를 완성한다.
**Architecture:** 모든 변경은 `tools/gen-slaydeck.mjs` 단일 생성기에서 만든다. 적/플레이어 전투 상태는 `SlayDeckController` codeblock 내부 속성으로 보유(필드 `Monster.codeblock`과 분리). UI는 `CombatHud` 그룹으로 DeckHud와 별도 생성. 수치(플레이어 80 / 적 45 / 의도 10·6·방8)는 임시 placeholder.
**Tech Stack:** Node.js ESM 생성기(`gen-slaydeck.mjs`), MSW Lua codeblock, MSW UI JSON. 검증은 `node --check` + 재생성 + sha1 결정성 + 메이커 Play.
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — 유일한 변경 대상.
- `upsertUi()`: `CombatHud` 그룹(적/플레이어 패널·결과 텍스트) 생성 추가, 정리 필터 확장.
- `writeCodeblocks()`: `SlayDeckController` 속성·메서드 추가/수정.
- 생성물(자동, 직접 편집 금지): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`.
검증 한계: MSW codeblock Lua는 단위 테스트 러너가 없다. 자동 검증은 생성기 문법·재생성·결정성·JSON 유효성까지, 실제 동작은 메이커 Play(사용자)로 확인.
---
### Task 1: 카드 데이터 수치화 (Cards 테이블 + UI 카드 배열)
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`upsertUi``cards` 배열, `writeCodeblocks``StartCombat``self.Cards`)
- [ ] **Step 1: `upsertUi`의 카드 배열은 표시용 그대로 두되, codeblock `Cards`에 수치 필드 추가**
`writeCodeblocks()``StartCombat` 메서드 코드에서 `self.Cards` 정의를 아래로 교체:
```lua
self.Cards = {
Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 },
Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 },
Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 },
}
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음 (출력 없음, exit 0)
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): 카드 데이터에 damage/block 수치 필드 추가"
```
---
### Task 2: 전투 상태 속성 + StartCombat 초기화
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` 속성 배열, `StartCombat` 메서드)
- [ ] **Step 1: codeblock 속성 추가**
`codeblock('SlayDeckController', ...)`의 properties 배열 끝에 추가:
```js
prop('number', 'PlayerHp', '0'),
prop('number', 'PlayerMaxHp', '80'),
prop('number', 'PlayerBlock', '0'),
prop('number', 'EnemyHp', '0'),
prop('number', 'EnemyMaxHp', '45'),
prop('number', 'EnemyBlock', '0'),
prop('number', 'EnemyIntentIndex', '1'),
prop('boolean', 'CombatOver', 'false'),
prop('any', 'EnemyIntents'),
prop('any', 'EnemyName'),
```
- [ ] **Step 2: `StartCombat`에 전투 상태 초기화 추가**
`StartCombat` 코드의 맨 위(`self.MaxEnergy = 3` 직후)에 삽입:
```lua
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0
self.EnemyName = "슬라임"
self.EnemyMaxHp = 45
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = {
{ kind = "Attack", value = 10 },
{ kind = "Attack", value = 6 },
{ kind = "Defend", value = 8 },
}
self.EnemyIntentIndex = 1
self.CombatOver = false
```
그리고 `StartCombat` 끝(`self:StartPlayerTurn()` 직전)에 `self:RenderCombat()` 추가.
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): 플레이어/적 전투 상태 속성·초기화 추가"
```
---
### Task 3: 전투 헬퍼 메서드 (데미지/적턴/승패/렌더)
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` methods 배열에 신규 메서드 추가)
`SetText`는 엔티티 nil 가드가 있어, 참조하는 UI가 Task 5에서 생성되기 전이어도 안전(no-op).
- [ ] **Step 1: 신규 메서드들을 methods 배열에 추가 (`Toast` 메서드 정의 뒤)**
```js
method('DealDamageToEnemy', `local dmg = amount
if self.EnemyBlock > 0 then
local absorbed = math.min(self.EnemyBlock, dmg)
self.EnemyBlock = self.EnemyBlock - absorbed
dmg = dmg - absorbed
end
self.EnemyHp = self.EnemyHp - dmg
if self.EnemyHp < 0 then
self.EnemyHp = 0
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
method('DealDamageToPlayer', `local dmg = amount
if self.PlayerBlock > 0 then
local absorbed = math.min(self.PlayerBlock, dmg)
self.PlayerBlock = self.PlayerBlock - absorbed
dmg = dmg - absorbed
end
self.PlayerHp = self.PlayerHp - dmg
if self.PlayerHp < 0 then
self.PlayerHp = 0
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
method('EnemyTurn', `self.EnemyBlock = 0
local intent = self.EnemyIntents[self.EnemyIntentIndex]
if intent ~= nil then
if intent.kind == "Attack" then
self:DealDamageToPlayer(intent.value)
elseif intent.kind == "Defend" then
self.EnemyBlock = self.EnemyBlock + intent.value
end
end
self.EnemyIntentIndex = self.EnemyIntentIndex + 1
if self.EnemyIntentIndex > #self.EnemyIntents then
self.EnemyIntentIndex = 1
end
self:RenderCombat()`),
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true
self:ShowResult("승리!")
-- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점
elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:ShowResult("패배...")
end`),
method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text)
local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result")
if entity ~= nil then
entity.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
method('RenderCombat', `self:SetText("/ui/DefaultGroup/CombatHud/EnemyName", self.EnemyName)
self:SetText("/ui/DefaultGroup/CombatHud/EnemyHp", "HP " .. tostring(self.EnemyHp) .. "/" .. tostring(self.EnemyMaxHp))
self:SetText("/ui/DefaultGroup/CombatHud/EnemyBlock", "방어 " .. tostring(self.EnemyBlock))
local intent = self.EnemyIntents[self.EnemyIntentIndex]
local intentText = ""
if intent ~= nil then
if intent.kind == "Attack" then
intentText = "의도: 공격 " .. tostring(intent.value)
elseif intent.kind == "Defend" then
intentText = "의도: 방어 " .. tostring(intent.value)
end
end
self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. tostring(self.PlayerHp) .. "/" .. tostring(self.PlayerMaxHp))
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. tostring(self.PlayerBlock))`),
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): 데미지/적턴/승패/전투렌더 헬퍼 메서드 추가"
```
---
### Task 4: 턴 흐름 배선 (PlayCard 효과·EndPlayerTurn·StartPlayerTurn)
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`StartPlayerTurn`, `EndPlayerTurn`, `PlayCard` 메서드 코드)
- [ ] **Step 1: `StartPlayerTurn` 교체**
```lua
self.Turn = self.Turn + 1
self.Energy = self.MaxEnergy
self.PlayerBlock = 0
self:DrawCards(5)
self:RenderHand(true)
self:RenderCombat()
```
- [ ] **Step 2: `EndPlayerTurn` 교체**
```lua
if self.CombatOver == true then
return
end
for i = 1, #self.Hand do
table.insert(self.DiscardPile, self.Hand[i])
end
self.Hand = {}
self:RenderHand(false)
self:RenderPiles()
self:EnemyTurn()
self:CheckCombatEnd()
if self.CombatOver == true then
return
end
_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)
```
- [ ] **Step 3: `PlayCard` 효과 분기 교체**
`PlayCard` 코드를 아래로 교체(에너지 차감 후 Toast 대신 효과 적용):
```lua
if self.CombatOver == true then
return
end
if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if self.Energy < c.cost then
self:Toast("에너지가 부족합니다")
return
end
self.Energy = self.Energy - c.cost
if c.kind == "Attack" then
if c.damage ~= nil then
self:DealDamageToEnemy(c.damage)
end
elseif c.kind == "Skill" then
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
end
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()
```
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): PlayCard 효과 분기·적턴·승패 턴흐름 배선"
```
---
### Task 5: CombatHud UI 엔티티 생성
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`: 정리 필터 확장 + CombatHud 그룹 생성)
- [ ] **Step 1: 정리 필터 확장**
`upsertUi()` 시작부의 필터를 CombatHud도 제거하도록 교체:
```js
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud'));
```
- [ ] **Step 2: DeckHud `hud` push 직후, CombatHud 엔티티 생성 블록 추가**
`ui.ContentProto.Entities.push(...hud);` 직전에 아래 블록 삽입(헬퍼 `entity`/`transform`/`sprite`/`text`/`guid` 재사용):
```js
const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 };
const combat = [];
combat.push(entity({
id: guid('cmb', 0),
path: '/ui/DefaultGroup/CombatHud',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
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: TRANSPARENT }),
],
}));
// 적 패널 배경
combat.push(entity({
id: guid('cmb', 1),
path: '/ui/DefaultGroup/CombatHud/EnemyBg',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 0,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 170 }, pos: { x: 0, y: 300 }, align: ALIGN_CENTER }),
sprite({ color: PANEL_BG, type: 1 }),
],
}));
const enemyTexts = [
['EnemyName', { x: 0, y: 58 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD, 1],
['EnemyHp', { x: 0, y: 16 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }, 2],
['EnemyBlock', { x: 0, y: -20 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }, 3],
['EnemyIntent', { x: 0, y: -56 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }, 4],
];
let cmbN = 2;
for (const [suffix, pos, size, value, fontSize, bold, color] of enemyTexts) {
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: enemyTexts.findIndex(([s]) => s === suffix) + 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize, bold, color }),
],
}));
}
// 플레이어 패널 배경 + 텍스트
combat.push(entity({
id: guid('cmb', cmbN++),
path: '/ui/DefaultGroup/CombatHud/PlayerBg',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 110 }, pos: { x: -760, y: -260 }, align: ALIGN_CENTER }),
sprite({ color: PANEL_BG, type: 1 }),
],
}));
const playerTexts = [
['PlayerHp', { x: -760, y: -238 }, { x: 280, y: 44 }, 'HP 80/80', 26, true, { r: 1, g: 1, b: 1, a: 1 }],
['PlayerBlock', { x: -760, y: -284 }, { x: 280, y: 38 }, '방어 0', 22, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
];
for (const [suffix, pos, size, value, fontSize, bold, color] of playerTexts) {
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: 6 + playerTexts.findIndex(([s]) => s === suffix),
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize, bold, color }),
],
}));
}
// 결과 텍스트 (기본 숨김)
const result = entity({
id: guid('cmb', cmbN++),
path: '/ui/DefaultGroup/CombatHud/Result',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 8,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }),
],
});
result.jsonString.enable = false;
combat.push(result);
ui.ContentProto.Entities.push(...combat);
```
`guid` 프리픽스 `'cmb'`를 위해 `guid()`의 ns 매핑에 분기 추가:
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : 0xfe;
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): CombatHud(적/플레이어 패널·결과) UI 엔티티 생성"
```
---
### Task 6: 재생성 + 검증
**Files:** 생성물 3종 (생성기 실행 결과)
- [ ] **Step 1: 생성기 실행**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 생성물 JSON 유효성 확인**
Run: `node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); console.log('JSON OK')"`
Expected: `JSON OK`
- [ ] **Step 3: 결정성 확인 (2회 실행 동일)**
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: CombatHud 엔티티·전투 메서드 생성 확인**
Run: `grep -c "CombatHud" ui/DefaultGroup.ui; grep -c "DealDamageToEnemy\|EnemyTurn\|RenderCombat" RootDesk/MyDesk/SlayDeckController.codeblock`
Expected: 두 값 모두 > 0
- [ ] **Step 5: 의도한 파일만 변경됐는지 확인**
Run: `git status --short`
Expected: `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (그리고 필요 시 `Global/common.gamelogic`)만 변경.
- [ ] **Step 6: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic
git commit -m "재생성(B): 카드 전투 통합 — 적/플레이어 전투 상태·CombatHud 반영"
```
- [ ] **Step 7: 메이커 Play 수동 검증 (사용자)**
메이커에서 로컬 워크스페이스 reload 후 Play:
- 타격 카드 클릭 → 적 HP 감소(적 Block 있으면 먼저 차감).
- 방어 카드 클릭 → 플레이어 `방어` 수치 증가.
- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어, 다음 의도 갱신.
- 적 HP 0 → "승리!" 표시·입력 잠금 / 플레이어 HP 0 → "패배..." 표시·입력 잠금.
---
## Self-Review
- **Spec coverage:** 전투 상태(Task 2), 카드 수치화(Task 1), 효과 분기(Task 4), 적 의도·적 턴(Task 3·4), 승패(Task 3·4), UI 노출(Task 5) — 스펙 5개 절 모두 태스크로 매핑됨. 검증은 Task 6.
- **Placeholder scan:** 모든 코드 단계에 실제 코드 포함. "TODO(E)"는 의도된 미래 훅 주석(스펙 명시)으로 placeholder 아님.
- **Type consistency:** UI 경로(`/ui/DefaultGroup/CombatHud/EnemyHp` 등)가 codeblock `RenderCombat`/`ShowResult`와 Task 5 생성 경로에서 동일. 메서드명(`DealDamageToEnemy`/`DealDamageToPlayer`/`EnemyTurn`/`CheckCombatEnd`/`ShowResult`/`RenderCombat`)이 호출부(Task 4)와 정의부(Task 3)에서 일치. 카드 필드(`damage`/`block`/`kind`)가 Cards 정의(Task 1)와 PlayCard 사용(Task 4)에서 일치.

View File

@@ -0,0 +1,256 @@
# 덱 컨트롤러 코드리뷰 수정 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:** 코드리뷰 6건(①self바인딩 ②Card5통일 ③카드클릭=사용 ④카드데이터단일화 ⑤매직넘버 ⑥pcall)을 `tools/gen-slaydeck.mjs`에서 수정·재생성한다.
**Architecture:** 모든 산출물(카드 UI·DeckHud·`SlayDeckController.codeblock`·`common.gamelogic`)을 생성하는 `tools/gen-slaydeck.mjs` 단일 소스를 수정하고 재실행한다. DRY는 카드 정의를 codeblock의 `self.Cards` 테이블 프로퍼티로 단일화하고, 카드 클릭은 카드 엔티티에 `ButtonComponent`를 추가한 뒤 `PlayCard(slot)` 메서드를 클로저로 연결해 구현한다.
**Tech Stack:** Node.js 생성기, MSW codeblock(MapleScript/Lua), msw-maker-mcp(검증).
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — 모든 수정의 단일 소스.
- 재생성(출력): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`.
기준: codeblock 메서드는 `method('Name', `<lua>`, [args])`로 정의되고 끝에서 전부 `ExecSpace=6`로 설정됨. 카드 엔티티(Card1~5)는 `upsertUi`의 루프가 스타일링함. `button()` 헬퍼 존재.
---
### Task 1: 생성기 수정 (① ③ ④ ⑥ + ⑤ 일부)
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 카드에 ButtonComponent + raycast 추가 (③ 클릭 가능)**
`upsertUi`의 카드 루프에서 `sp.Color = cards[i - 1].tint;` 줄 바로 다음에 아래를 추가:
```js
sp.RaycastTarget = true;
const comps = card.jsonString['@components'];
if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) {
comps.push(button());
}
if (!card.componentNames.includes('MOD.Core.ButtonComponent')) {
card.componentNames += ',MOD.Core.ButtonComponent';
}
```
- [ ] **Step 2: `Cards` 프로퍼티 추가 (④ 단일화 준비)**
`writeCodeblocks`의 properties 배열(`prop('any', 'EndTurnHandler')` 가 있는 배열)에 항목 추가:
```js
prop('any', 'Cards'),
```
- [ ] **Step 3: StartCombat 메서드 교체 (④ 카드 테이블 정의)**
`method('StartCombat', ...)` 의 Lua 본문을 아래로 교체:
```
self.MaxEnergy = 3
self.Turn = 0
self.DiscardPile = {}
self.Hand = {}
self.Cards = {
Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack" },
Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill" },
Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack" },
}
self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" }
self:Shuffle(self.DrawPile)
self:BindButtons()
self:StartPlayerTurn()
```
- [ ] **Step 4: BindButtons 교체 (① 클로저 + ③ 카드 클릭 바인딩)**
`method('BindButtons', ...)` 의 Lua 본문을 아래로 교체:
```
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
```
- [ ] **Step 5: ApplyCardVisual 교체 (④ self.Cards 사용 + ⑥ pcall 제거)**
`method('ApplyCardVisual', ...)` 의 Lua 본문을 아래로 교체(인자 slot, cardId 유지):
```
local c = self.Cards[cardId]
if c == nil then
c = { name = cardId, cost = 0, desc = "", kind = "Skill" }
end
self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Cost", tostring(c.cost))
self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Name", c.name)
self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Desc", c.desc)
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if cardEntity ~= nil and cardEntity.SpriteGUIRendererComponent ~= nil then
if c.kind == "Attack" then
cardEntity.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
elseif c.kind == "Skill" then
cardEntity.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
else
cardEntity.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
end
end
```
- [ ] **Step 6: PlayCard + Toast 메서드 추가 (③)**
`method('AnimateCardFrom', ...)` 항목 다음(메서드 배열 안)에 두 메서드를 추가:
```js
method('PlayCard', `if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if self.Energy < c.cost then
self:Toast("에너지가 부족합니다")
return
end
self.Energy = self.Energy - c.cost
self:Toast(c.name .. " — " .. c.desc)
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
self:RenderHand(false)
self:RenderPiles()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
```
(⑤: 손패/슬롯 수 5는 UI 카드 엔티티가 정확히 5개라 고정값으로 둠 — 별도 상수 불필요. 시작 에너지/MaxEnergy는 이미 프로퍼티.)
- [ ] **Step 7: 구문 확인 + 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node --check tools/gen-slaydeck.mjs
git add tools/gen-slaydeck.mjs
git commit -m "덱 컨트롤러 생성기: 핸들러 클로저화·카드데이터 단일화·카드클릭 사용·pcall 제거"
```
Expected: `node --check` 무출력(exit 0).
---
### Task 2: 재생성 + 데이터 검증
**Files:** Modify `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`
- [ ] **Step 1: 재생성**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-slaydeck.mjs
```
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: codeblock 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const ms=j.ContentProto.Json.Methods;const names=ms.map(m=>m.Name);console.log('has PlayCard:',names.includes('PlayCard'));console.log('has Toast:',names.includes('Toast'));const bind=ms.find(m=>m.Name==='BindButtons').Code;console.log('endturn closure:',bind.includes('function() self:EndPlayerTurn() end'));console.log('card click bind:',bind.includes('function() self:PlayCard(i) end'));const av=ms.find(m=>m.Name==='ApplyCardVisual').Code;console.log('no pcall:',!av.includes('pcall'));console.log('uses self.Cards:',av.includes('self.Cards[cardId]'));const sc=ms.find(m=>m.Name==='StartCombat').Code;console.log('Cards table:',sc.includes('self.Cards ='))"
```
Expected: 모두 `true`.
- [ ] **Step 3: UI 검증 (카드 버튼 + Card5 통일)**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=j.ContentProto.Entities;let okBtn=true,okImg=true;for(let i=1;i<=5;i++){const c=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card'+i);if(!c){okBtn=false;continue;}if(!(c.componentNames||'').includes('MOD.Core.ButtonComponent'))okBtn=false;const sp=c.jsonString['@components'].find(x=>x['@type']==='MOD.Core.SpriteGUIRendererComponent');if(sp.ImageRUID.DataId!=='')okImg=false;}const c5=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card5');const hasDesc=E.some(e=>e.path==='/ui/DefaultGroup/CardHand/Card5/Desc');console.log('all cards have Button:',okBtn);console.log('all cards no image (uniform):',okImg);console.log('Card5 has Desc child:',hasDesc)"
```
Expected: `all cards have Button: true`, `all cards no image (uniform): true`, `Card5 has Desc child: true`.
- [ ] **Step 4: JSON 유효성 + 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));JSON.parse(require('fs').readFileSync('Global/common.gamelogic','utf8'));console.log('JSON ok')"
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic
git commit -m "재생성: 카드 클릭 사용·균일 카드·핸들러 수정 반영"
```
Expected: `JSON ok`.
---
### Task 3: Maker Play 검증 (컨트롤러)
**Files:** 없음
- [ ] **Step 1: reload**: `maker_refresh_workspace`.
- [ ] **Step 2: 시작 맵 활성화 확인**: `maker_get_current_map`. (어느 맵이든 카드 UI는 전역이라 표시됨)
- [ ] **Step 3: play**: `maker_play`.
- [ ] **Step 4: 클릭 시뮬레이션 + 상태 확인**: `maker_execute_script`(client)로 PlayCard 직접 호출해 동작 확인:
```lua
local ctrl = _EntityService:GetEntityByPath("/common")
-- 초기 상태
local c = ctrl.SlayDeckController
log("BEFORE energy="..tostring(c.Energy).." hand="..tostring(#c.Hand).." discard="..tostring(#c.DiscardPile))
c:PlayCard(1)
log("AFTER energy="..tostring(c.Energy).." hand="..tostring(#c.Hand).." discard="..tostring(#c.DiscardPile))
```
→ `maker_logs(normal)`에서 카드 사용 후 energy 감소·hand 감소·discard 증가 확인. (또는 `maker_mouse_input`으로 카드 클릭)
- [ ] **Step 5: screenshot**: `maker_screenshot` → Read로 5장 균일·DeckHud(에너지/덱 카운트) 확인.
- [ ] **Step 6: stop**: `maker_stop`.
문제 시: 핸들러 self·PlayCard 동작 로그로 진단 후 Task 1 수정·재생성.
---
### Task 4: stash 복구 + 무결성 검증
**Files:** `map/map02.map`, `map/map05.map`, `map/map06.map`, `map/map07.map`, `map/map10.map`, `map/map11.map` (복구 대상)
- [ ] **Step 1: stash 적용**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git stash list
git stash apply 2>&1 | head -20
```
(충돌 시 해당 파일은 main 버전 유지하고 stash 변경만 수동 반영하거나, 무의미하면 제외 — 아래 검증으로 판단)
- [ ] **Step 2: 무결성 검증 (몬스터/타일셋 유지 확인)**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];for(const t of ['02','05','06','07','10','11']){const j=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);const okNoOld=sprs.every(s=>!old.includes(s));const ts=E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId;console.log('map'+t,'monsters='+ms.length,'noOldSprite='+okNoOld,'tileset='+(ts!=='9dfea3808bbd49a5877d8624df21b1c7'))}"
```
Expected: 각 맵 `monsters=2`, `noOldSprite=true`, `tileset=true`. (= 몬스터/타일셋 작업 유지됨)
- [ ] **Step 3: 판정 및 커밋**
- 무결성 OK → 복구분 커밋:
```bash
git add map/map02.map map/map05.map map/map06.map map/map07.map map/map10.map map/map11.map
git commit -m "Maker 세션 재저장분(맵 02/05/06/07/10/11) 복구 포함"
git stash drop
```
- 무결성 실패(작업 되돌려짐/손상) → 복구 취소하고 사용자에게 보고:
```bash
git checkout -- map/map02.map map/map05.map map/map06.map map/map07.map map/map10.map map/map11.map
```
(stash는 보존)
---
## 검증 요약
- 생성기 `node --check` 통과
- codeblock: PlayCard/Toast 존재, EndTurn·카드클릭 클로저, self.Cards 사용, pcall 없음
- UI: Card1~5 ButtonComponent+raycast, 5장 균일(이미지 없음·Desc 존재)
- Maker Play: PlayCard 호출 시 energy↓·hand↓·discard↑, 5장 균일 렌더
- stash 복구분 무결성(몬스터2·old미사용·타일셋교체) 검증 후 포함

View File

@@ -0,0 +1,475 @@
# AI 전투 시뮬레이터 (TODO F) 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:** `data/*.json`을 입력으로 전투를 몬테카를로 N회 시뮬레이션해 승률·턴·OP 카드 리포트를 출력하는 오프라인 CLI `tools/sim-balance.mjs`.
**Architecture:** 순수 함수(PRNG·applyDamage·chooseAction·simulateCombat·runBatch)로 분리해 `node:test`로 단위 테스트. CLI main은 직접 실행 시에만 동작. 전투 규칙은 gen-slaydeck.mjs의 Lua를 JS로 미러, 데이터는 D의 JSON 공유.
**Tech Stack:** Node.js ESM, `node:test`+`node:assert`. 검증은 단위 테스트 + CLI 실행 + 결정성 + 데이터 반영.
---
## File Structure
- Create: `tools/sim-balance.mjs` — 시뮬레이터(엔진·정책·집계·리포트·CLI). 순수 함수 export.
- Create: `tools/sim-balance.test.mjs` — 단위 테스트(node:test).
전투 규칙은 `tools/gen-slaydeck.mjs` Lua와 중복 → 파일 상단 동기화 주석.
---
### Task 1: PRNG·applyDamage·loadData (기반 순수 함수)
**Files:**
- Create: `tools/sim-balance.mjs`
- Create: `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 작성 `tools/sim-balance.test.mjs`**
```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mulberry32, applyDamage } from './sim-balance.mjs';
test('applyDamage: 방어 우선 차감 후 hp', () => {
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 });
assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 });
});
test('mulberry32: 동일 시드 동일 수열', () => {
const a = mulberry32(1), b = mulberry32(1);
assert.equal(a(), b());
assert.equal(a(), b());
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`Cannot find module './sim-balance.mjs'` 또는 export 없음)
- [ ] **Step 3: `tools/sim-balance.mjs` 작성(기반부)**
```js
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
// ⚠️ 전투 규칙은 tools/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
import { readFileSync } from 'node:fs';
export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치)
export const ENERGY = 3;
export const HAND_SIZE = 5;
export const MAX_TURNS = 100;
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// 방어 우선 차감 후 hp 적용 → { hp, block }
export function applyDamage(hp, block, amount) {
let dmg = amount;
if (block > 0) {
const absorbed = Math.min(block, dmg);
block -= absorbed;
dmg -= absorbed;
}
hp -= dmg;
if (hp < 0) hp = 0;
return { hp, block };
}
export function loadData() {
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
const enemy = enemiesData.enemies[enemiesData.activeEnemy];
if (!enemy) throw new Error(`activeEnemy 없음: ${enemiesData.activeEnemy}`);
return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, enemy };
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (2 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): PRNG·applyDamage·loadData 기반 함수 + 테스트"
```
---
### Task 2: chooseAction 정책 (휴리스틱 A)
**Files:**
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 추가 (test.mjs 하단)**
```js
import { chooseAction } from './sim-balance.mjs';
const CARDS = {
Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 },
Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 },
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
};
test('chooseAction: 치사 가능하면 공격 선택', () => {
// 적 hp 5, block 0, 손패 Strike(6) → 공격(인덱스 0)
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, 0);
});
test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => {
// 적 hp 40(이번 턴 못 죽임), 의도 공격 → Defend(인덱스 1)
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, 1);
});
test('chooseAction: 적 방어 의도면 공격 우선', () => {
const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 });
assert.equal(idx, 1);
});
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, -1);
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`chooseAction is not a function`)
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
```js
// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열.
export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy);
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost;
const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost;
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
// 1) 치사: 에너지 한도 내 효율순 공격 데미지 합 >= 적 유효 hp?
let e = energy, lethalDmg = 0;
for (const x of attacks.slice().sort((a, b) => dmgEff(b) - dmgEff(a))) {
if (cards[x.id].cost <= e) { e -= cards[x.id].cost; lethalDmg += cards[x.id].damage || 0; }
}
if (attacks.length && lethalDmg >= enemyHp + enemyBlock) return bestBy(attacks, dmgEff).i;
// 2) 적 공격 의도면 방어 우선
if (enemyIntent && enemyIntent.kind === 'Attack' && skills.length) return bestBy(skills, blkEff).i;
// 3) 공격 우선, 없으면 스킬, 없으면 종료
if (attacks.length) return bestBy(attacks, dmgEff).i;
if (skills.length) return bestBy(skills, blkEff).i;
return -1;
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (6 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): 플레이어 휴리스틱 정책 chooseAction + 테스트"
```
---
### Task 3: simulateCombat 엔진
**Files:**
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 추가**
```js
import { simulateCombat, mulberry32 as m32 } from './sim-balance.mjs';
const DATA = {
cards: {
Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 },
Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 },
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
},
starterDeck: ['Strike','Strike','Strike','Strike','Strike','Defend','Defend','Defend','Defend','Bash'],
enemy: { name: '슬라임', maxHp: 45, intents: [
{ kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', value: 8 },
] },
};
test('simulateCombat: 결정적 결과(동일 시드)', () => {
const r1 = simulateCombat(DATA, m32(1));
const r2 = simulateCombat(DATA, m32(1));
assert.deepEqual(r1, r2);
assert.equal(typeof r1.win, 'boolean');
assert.ok(r1.turns >= 1);
});
test('simulateCombat: 약한 적이면 대체로 승리', () => {
let wins = 0;
for (let i = 0; i < 50; i++) if (simulateCombat(DATA, m32(i + 1)).win) wins++;
assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`);
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`simulateCombat is not a function`)
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
```js
function bump(s, cost, dmg, blk) {
s = s || { plays: 0, energy: 0, damage: 0, block: 0 };
s.plays++; s.energy += cost; s.damage += dmg; s.block += blk;
return s;
}
// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적.
// 반환: { win, turns, playerHpRemaining, draw? }
export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, enemy } = data;
let drawPile = shuffle(starterDeck, rng);
let discard = [];
let hand = [];
let pHp = PLAYER_HP, pBlock = 0;
let eHp = enemy.maxHp, eBlock = 0, intentIdx = 0;
let turns = 0;
function draw(n) {
for (let k = 0; k < n; k++) {
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
if (drawPile.length === 0) break;
hand.push(drawPile.pop());
}
}
while (turns < MAX_TURNS) {
turns++;
let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE);
while (true) {
const intent = enemy.intents[intentIdx];
const idx = chooseAction(hand, cards, energy, eHp, eBlock, intent);
if (idx < 0) break;
const id = hand[idx], c = cards[id];
energy -= c.cost;
if (c.kind === 'Attack') {
const r = applyDamage(eHp, eBlock, c.damage || 0); eHp = r.hp; eBlock = r.block;
if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0);
} else {
pBlock += c.block || 0;
if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0);
}
hand.splice(idx, 1); discard.push(id);
if (eHp <= 0) return { win: true, turns, playerHpRemaining: pHp };
}
discard.push(...hand); hand = [];
eBlock = 0;
const intent = enemy.intents[intentIdx];
if (intent.kind === 'Attack') { const r = applyDamage(pHp, pBlock, intent.value); pHp = r.hp; pBlock = r.block; }
else if (intent.kind === 'Defend') { eBlock += intent.value; }
intentIdx = (intentIdx + 1) % enemy.intents.length;
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
}
return { win: false, turns, playerHpRemaining: pHp, draw: true };
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (8 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): 단일 전투 시뮬 엔진 simulateCombat + 테스트"
```
---
### Task 4: runBatch·리포트·OP 탐지·CLI
**Files:**
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 추가**
```js
import { runBatch } from './sim-balance.mjs';
test('runBatch: 집계 필드·승률 범위', () => {
const r = runBatch(100, 1);
assert.equal(r.N, 100);
assert.ok(r.winRate >= 0 && r.winRate <= 1);
assert.ok(r.avgTurns > 0);
assert.ok(r.cardStats.Strike.plays > 0);
});
test('runBatch: 동일 시드 동일 결과', () => {
assert.deepEqual(runBatch(100, 7), runBatch(100, 7));
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`runBatch is not a function`)
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
```js
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }
function median(a) {
if (!a.length) return 0;
const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
export function runBatch(N, seed) {
const data = loadData();
const rng = mulberry32(seed);
const cardStats = {};
let wins = 0, draws = 0;
const turnsArr = [], hpArr = [];
for (let i = 0; i < N; i++) {
const r = simulateCombat(data, rng, cardStats);
if (r.draw) draws++;
if (r.win) { wins++; hpArr.push(r.playerHpRemaining); }
turnsArr.push(r.turns);
}
return {
N, wins, draws, losses: N - wins - draws,
winRate: wins / N,
avgTurns: mean(turnsArr), medianTurns: median(turnsArr),
avgHpOnWin: mean(hpArr),
cardStats, cards: data.cards, enemy: data.enemy, seed,
};
}
export function formatReport(r) {
const L = [];
L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`);
L.push(`시뮬 ${r.N}회 (seed=${r.seed})`);
L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`);
L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`);
L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`);
if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`);
L.push('');
L.push('카드별:');
// 효율 계산 + kind별 중앙값으로 OP 플래그
const rows = Object.entries(r.cardStats).map(([id, s]) => {
const kind = r.cards[id].kind;
const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy;
return { id, name: r.cards[id].name, kind, plays: s.plays, eff };
});
for (const kind of ['Attack', 'Skill']) {
const kr = rows.filter((x) => x.kind === kind);
if (!kr.length) continue;
const med = median(kr.map((x) => x.eff));
for (const x of kr) {
const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : '';
const unit = kind === 'Attack' ? '뎀/E' : '블록/E';
L.push(` ${x.name}(${id2(x.id)}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`);
}
}
const sorted = rows.slice().sort((a, b) => b.plays - a.plays);
if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`);
return L.join('\n');
}
function id2(id) { return id; }
function main() {
const args = process.argv.slice(2);
let N = 2000, seed = 1;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--seed') seed = parseInt(args[++i], 10);
else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10);
}
console.log(formatReport(runBatch(N, seed)));
}
if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main();
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (10 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): runBatch·리포트·OP 탐지·CLI + 테스트"
```
---
### Task 5: 검증 (CLI 실행·결정성·데이터 반영)
**Files:** 없음(실행 검증)
- [ ] **Step 1: 전체 테스트**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (10 tests, 0 fail)
- [ ] **Step 2: CLI 실행 (기본)**
Run: `node tools/sim-balance.mjs 2000`
Expected: 승률·평균턴·승리시 잔여HP·카드별 효율 리포트 출력.
- [ ] **Step 3: 결정성 (동일 시드 동일 출력)**
Run: `node tools/sim-balance.mjs 500 --seed 3 > /tmp/r1.txt && node tools/sim-balance.mjs 500 --seed 3 > /tmp/r2.txt && diff /tmp/r1.txt /tmp/r2.txt && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: 데이터 반영 (강타 데미지↑ → 승률·턴 변동)**
Run: `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률` (기준값 기록) → `data/cards.json`에서 Bash.damage 10→20으로 임시 변경 → `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률`(변동 확인) → `git checkout -- data/cards.json`(원복).
Expected: 두 승률/턴 수치가 다름(데이터 반영). 원복 후 기준 복귀.
- [ ] **Step 5: 최종 커밋(있다면 없음 — 검증 전용)**
검증 전용 태스크. 변경 없음. `git status``data/cards.json` 원복 확인.
---
## Self-Review
- **Spec coverage:** PRNG·applyDamage·loadData(Task1), 정책(Task2), 엔진(Task3), 집계·리포트·OP·CLI(Task4), 검증·데이터반영(Task5). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령. 동기화 주석은 의도된 문서.
- **Type consistency:** `mulberry32/shuffle/applyDamage/loadData/chooseAction/simulateCombat/runBatch/formatReport` 시그니처가 정의(Task1·2·3·4)와 사용(테스트·CLI)에서 일치. `cardStats` 형태 `{plays,energy,damage,block}``bump`·`runBatch`·`formatReport`에서 일치. 카드 필드 `kind/damage/block/cost`가 데이터·정책·엔진에서 일치.

View File

@@ -0,0 +1,341 @@
# 카드/적 데이터 외부화 (TODO D) 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:** 카드·적 데이터를 `data/cards.json`·`data/enemies.json`로 분리하고, `gen-slaydeck.mjs`가 읽어 codeblock·UI에 주입한다(데이터만 바꿔 재생성하면 반영).
**Architecture:** 신규 JSON 2개가 데이터 단일 소스. 생성기는 상단에서 JSON을 로드·검증하고, Lua 직렬화 헬퍼로 `self.Cards`/`self.DrawPile`/적 상태를 만들어 `StartCombat`에 주입한다. DeckHud 카드 미리보기·CombatHud 초기 텍스트도 동일 데이터에서 파생.
**Tech Stack:** Node.js ESM 생성기, JSON 데이터, MSW Lua codeblock/UI JSON. 검증은 `node --check`+재생성+sha1 결정성+데이터변경 반영 확인+메이커 Play.
---
## File Structure
- Create: `data/cards.json` — 카드 정의(`cards`) + 시작 덱(`starterDeck`).
- Create: `data/enemies.json` — 적 정의(`enemies`) + 활성 적(`activeEnemy`).
- Modify: `tools/gen-slaydeck.mjs` — JSON 로드·검증·Lua 직렬화 헬퍼, `StartCombat`/`upsertUi`/속성 데이터화.
검증 한계: MSW Lua 단위 테스트 러너 없음 → 자동 검증은 생성기 문법·재생성·결정성·데이터 반영·JSON 유효성. 실제 동작은 메이커 Play(사용자).
---
### Task 1: 데이터 파일 생성
**Files:**
- Create: `data/cards.json`
- Create: `data/enemies.json`
- [ ] **Step 1: `data/cards.json` 작성**
```json
{
"cards": {
"Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" },
"Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" },
"Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" }
},
"starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"]
}
```
- [ ] **Step 2: `data/enemies.json` 작성**
```json
{
"enemies": {
"slime": {
"name": "슬라임",
"maxHp": 45,
"intents": [
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 8 }
]
}
},
"activeEnemy": "slime"
}
```
- [ ] **Step 3: JSON 유효성 확인**
Run: `node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')"`
Expected: `JSON OK`
- [ ] **Step 4: 커밋**
```bash
git add data/cards.json data/enemies.json
git commit -m "data(D): 카드/적 데이터 JSON 외부화 파일 추가"
```
---
### Task 2: 생성기에 JSON 로드·검증·Lua 직렬화 헬퍼 추가
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (상단 import 직후)
- [ ] **Step 1: 파일 상단 `import { readFileSync, writeFileSync } from 'node:fs';` 바로 다음에 추가**
```js
const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
for (const id of CARDS.starterDeck) {
if (!CARDS.cards[id]) {
throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`);
}
}
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
}
const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy];
// Lua 직렬화 헬퍼
function luaStr(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function luaCardsTable(cards) {
const lines = Object.entries(cards).map(([id, c]) => {
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
if (c.damage != null) fields.push(`damage = ${c.damage}`);
if (c.block != null) fields.push(`block = ${c.block}`);
return `\t${id} = { ${fields.join(', ')} },`;
});
return `self.Cards = {\n${lines.join('\n')}\n}`;
}
function luaDeckTable(deck) {
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
}
function luaIntentsTable(intents) {
const lines = intents.map((it) => `\t{ kind = ${luaStr(it.kind)}, value = ${it.value} },`);
return `self.EnemyIntents = {\n${lines.join('\n')}\n}`;
}
function intentText(it) {
if (it.kind === 'Attack') return `의도: 공격 ${it.value}`;
if (it.kind === 'Defend') return `의도: 방어 ${it.value}`;
return '';
}
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): JSON 로드·검증·Lua 직렬화 헬퍼 추가"
```
---
### Task 3: StartCombat·EnemyMaxHp 속성을 데이터에서 생성
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`prop('number', 'EnemyMaxHp', ...)`, `method('StartCombat', ...)`)
- [ ] **Step 1: EnemyMaxHp 속성 기본값을 데이터로**
`prop('number', 'EnemyMaxHp', '45'),` 를 아래로 교체:
```js
prop('number', 'EnemyMaxHp', String(ACTIVE_ENEMY.maxHp)),
```
- [ ] **Step 2: `StartCombat` 메서드 본문을 데이터 주입형으로 교체**
기존 `method('StartCombat', \`...\`)` 호출 전체(아래 "현재" 블록)를 "신규"로 교체.
현재(교체 대상):
```
self.MaxEnergy = 3
self.Turn = 0
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0
self.EnemyName = "슬라임"
self.EnemyMaxHp = 45
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = {
{ kind = "Attack", value = 10 },
{ kind = "Attack", value = 6 },
{ kind = "Defend", value = 8 },
}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
self.Cards = {
Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 },
Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 },
Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 },
}
self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" }
self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat()
self:StartPlayerTurn()
```
신규 — `method('StartCombat', ...)`의 코드 인자를 템플릿으로 생성:
```js
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
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)}
${luaDeckTable(CARDS.starterDeck)}
self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat()
self:StartPlayerTurn()`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): StartCombat·EnemyMaxHp를 데이터에서 생성"
```
---
### Task 4: DeckHud 카드 미리보기·CombatHud 초기 텍스트를 데이터에서 파생
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`의 `cards` 배열, `enemyTexts` 초기값)
- [ ] **Step 1: `upsertUi`의 카드 미리보기 배열을 데이터 파생으로 교체**
기존:
```js
const cards = [
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK },
];
```
교체:
```js
const cards = CARDS.starterDeck.slice(0, 5).map((id) => {
const c = CARDS.cards[id];
return { name: c.name, cost: String(c.cost), desc: c.desc, tint: c.kind === 'Attack' ? ATTACK : DEFEND };
});
```
- [ ] **Step 2: CombatHud `enemyTexts` 초기값을 데이터에서 파생**
기존:
```js
const enemyTexts = [
['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD],
['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }],
['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
];
```
교체:
```js
const enemyTexts = [
['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, ACTIVE_ENEMY.name, 28, true, GOLD],
['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, `HP ${ACTIVE_ENEMY.maxHp}/${ACTIVE_ENEMY.maxHp}`, 24, true, { r: 1, g: 1, b: 1, a: 1 }],
['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, intentText(ACTIVE_ENEMY.intents[0]), 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
];
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): 카드 미리보기·CombatHud 초기 텍스트 데이터 파생"
```
---
### Task 5: 재생성 + 검증
**Files:** 생성물 3종 (생성기 실행 결과)
- [ ] **Step 1: 생성기 실행**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 생성물이 B와 동치인지 — codeblock에 데이터 값이 반영됐는지 확인**
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\".*damage = 6/.test(sc) && /슬라임/.test(sc) && /value = 10/.test(sc) ? 'DATA INJECTED OK' : 'MISMATCH')"`
Expected: `DATA INJECTED 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: 데이터 변경이 반영되는지 확인 (D의 핵심 검증)**
Run: `node -e "const fs=require('fs'); const f='data/cards.json'; const o=JSON.parse(fs.readFileSync(f,'utf8')); o.cards.Strike.damage=9; o.cards.Strike.desc='피해 9'; fs.writeFileSync(f, JSON.stringify(o,null,2));" && node tools/gen-slaydeck.mjs >/dev/null && node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\", cost = 1, desc = \"피해 9\", kind = \"Attack\", damage = 9/.test(sc) ? 'CHANGE REFLECTED' : 'NOT REFLECTED')"`
Expected: `CHANGE REFLECTED`
- [ ] **Step 5: 변경 되돌리고 재생성 (원복)**
Run: `git checkout -- data/cards.json && node tools/gen-slaydeck.mjs >/dev/null && echo reverted`
Expected: `reverted`
- [ ] **Step 6: 잘못된 데이터 fail-fast 확인**
Run: `node -e "const fs=require('fs'); const o=JSON.parse(fs.readFileSync('data/enemies.json','utf8')); o.activeEnemy='nope'; fs.writeFileSync('/tmp/bad-enemies.json', JSON.stringify(o));" && cp data/enemies.json /tmp/enemies.bak && cp /tmp/bad-enemies.json data/enemies.json; node tools/gen-slaydeck.mjs; echo "exit=$?"; cp /tmp/enemies.bak data/enemies.json`
Expected: 에러 메시지 `activeEnemy가 enemies에 없음: nope` + `exit=1`, 이후 원복
- [ ] **Step 7: 최종 재생성 + git status 확인**
Run: `node tools/gen-slaydeck.mjs >/dev/null; git checkout -- Global/common.gamelogic 2>/dev/null; git status --short`
Expected: `data/*.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`만 변경(내용 동일한 common 제외).
- [ ] **Step 8: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(D): 데이터 기반 카드/적 주입 반영"
```
- [ ] **Step 9: 메이커 Play 수동 검증 (사용자)**
메이커 reload→Play: 기존 B 동작과 동일(데이터 동치라 회귀 없음). 적 슬라임 HP 45·의도 공격10, 카드 3종 효과 정상.
---
## Self-Review
- **Spec coverage:** cards.json/enemies.json 생성(Task1), 로드·검증·직렬화(Task2), StartCombat·속성 데이터화(Task3), UI 파생(Task4), 검증·데이터변경 반영(Task5). 스펙 전 항목 매핑됨.
- **Placeholder scan:** 모든 단계 실제 코드/명령 포함. "TODO(E)"류 미래 훅은 본 작업 범위 아님.
- **Type consistency:** `luaStr`/`luaCardsTable`/`luaDeckTable`/`luaIntentsTable`/`intentText`/`ACTIVE_ENEMY`/`CARDS`/`ENEMIES` 명칭이 정의부(Task2)와 사용부(Task3·4)에서 일치. 카드 필드(`name/cost/kind/damage/block/desc`)가 데이터(Task1)·직렬화(Task2)·검증(Task5)에서 일치.

View File

@@ -0,0 +1,206 @@
# 다음 층 / 멀티 act (TODO E6a) 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:** 보스 클리어 시 즉시 종료 대신 다음 막으로 진행(적 스케일), 최종 막 보스에서 진짜 런 클리어.
**Architecture:** `Floor`를 막 카운터로 재정의(1..ACT_COUNT). StartCombat에서 적을 막 배율로 스케일, CheckCombatEnd 보스 승리 시 다음 막(같은 맵 재사용)으로. 모두 `gen-slaydeck.mjs`에서 생성.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — ACT_COUNT 상수, StartRun(Floor=1·RunLength=ACT_COUNT), StartCombat(Floor 제거·적 스케일), CheckCombatEnd(보스 다음 막), RenderRun(막 라벨).
검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: ACT_COUNT 상수 + StartRun
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: ACT_COUNT 상수**`const RELIC_PRICE = 60;` 다음에:
```js
const ACT_COUNT = 3;
```
- [ ] **Step 2: StartRun의 Floor·RunLength 변경** — StartRun 코드에서 아래 두 줄을 교체:
기존:
```
self.Floor = 0
self.RunLength = ${MAX_ROW}
```
신규:
```
self.Floor = 1
self.RunLength = ${ACT_COUNT}
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E6a): ACT_COUNT·StartRun 막 카운터 초기화"
```
---
### Task 2: StartCombat — Floor 제거 + 적 막 스케일
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: Floor=node.row 블록 제거 + 적 스케일 적용** — StartCombat 코드의 아래 블록을 교체:
기존:
```
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil then
self.Floor = node.row
end
local enemy = self.Enemies[self.CurrentEnemyId]
self.PlayerBlock = 0
self.EnemyName = enemy.name
self.EnemyMaxHp = enemy.maxHp
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = enemy.intents
self.EnemyIntentIndex = 1
```
신규:
```
local enemy = self.Enemies[self.CurrentEnemyId]
local mult = 1 + (self.Floor - 1) * 0.6
self.PlayerBlock = 0
self.EnemyName = enemy.name
self.EnemyMaxHp = math.floor(enemy.maxHp * mult)
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = {}
for i = 1, #enemy.intents do
self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) }
end
self.EnemyIntentIndex = 1
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E6a): StartCombat 적 막 스케일·Floor 제거"
```
---
### Task 3: CheckCombatEnd 보스 다음 막 + RenderRun 막 라벨
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: CheckCombatEnd 보스 분기 교체** — 아래 블록을 교체:
기존:
```
if node ~= nil and node.type == "boss" then
self:ShowResult("런 클리어!")
self.RunActive = false
else
self:OfferReward()
end
```
신규:
```
if node ~= nil and node.type == "boss" then
if self.Floor < self.RunLength then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:RenderRun()
self:ShowMap()
else
self:ShowResult("런 클리어!")
self.RunActive = false
end
else
self:OfferReward()
end
```
- [ ] **Step 2: RenderRun 막 라벨** — RenderRun의 Floor 텍스트 줄을 교체:
기존:
```
self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
```
신규:
```
self:SetText("/ui/DefaultGroup/CombatHud/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E6a): 보스 승리 다음 막 진행·막 라벨"
```
---
### Task 4: 재생성 + 검증
**Files:** 생성물
- [ ] **Step 1: 생성**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 스케일·막 진행 코드 확인**
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/mult = 1 \+ \(self.Floor - 1\) \* 0.6/.test(sc)&&/math.floor\(enemy.maxHp \* mult\)/.test(sc)?'SCALE OK':'NO SCALE'); const cc=j.ContentProto.Json.Methods.find(m=>m.Name==='CheckCombatEnd').Code; console.log(/self.Floor = self.Floor \+ 1/.test(cc)?'NEXT-ACT OK':'NO NEXT-ACT')"`
Expected: `SCALE OK` / `NEXT-ACT 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). (data 변경 없음)
- [ ] **Step 5: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(E6a): 멀티 act·적 스케일 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)**
reload→Play: 1막 보스(슬라임 킹 120) 처치 → 2막 맵(Floor 2)·적 HP 스케일(슬라임 72·보스 192) → 3막 보스 처치 → "런 클리어!". HP/골드/덱/유물 막 간 유지. MCP는 PickNode/PlayCard/CheckCombatEnd 직접 호출 + 로그.
---
## Self-Review
- **Spec coverage:** ACT_COUNT·StartRun(Task1), StartCombat 스케일·Floor제거(Task2), 보스 다음막·막라벨(Task3), 검증(Task4). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** `Floor`(막 카운터)·`RunLength`(=ACT_COUNT)·`mult` 사용 일관. `EnemyIntents` 새 테이블 생성(공유 변형 없음). CheckCombatEnd의 `node`는 기존 정의 사용. ACT_COUNT 상수 Task1 정의·Task1·3 사용.

View File

@@ -0,0 +1,493 @@
# 분기 맵 노드 진행 (TODO E3) 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:** 플레이어가 작성된 분기 맵(DAG)에서 다음 노드를 선택해 전투/엘리트/보스로 진행, 보스 클리어 시 "런 클리어".
**Architecture:** `data/map.json`(그래프)·`data/enemies.json`(다중 적)을 `gen-slaydeck.mjs`가 로드·주입. SlayDeckController에 맵 상태·네비게이션 메서드 추가, MapHud UI 생성. 자동 진행 대신 ShowMap→PickNode→StartCombat→보상→ShowMap 루프.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Create: `data/map.json` — 분기 맵.
- Modify: `data/enemies.json` — slime_elite·slime_boss 추가.
- Modify: `tools/gen-slaydeck.mjs` — 맵/적 로드·검증·직렬화 헬퍼, method() returnType, 속성·메서드·MapHud UI.
검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: 데이터 + 로드·검증·직렬화 헬퍼
**Files:** Create `data/map.json`; Modify `data/enemies.json`, `tools/gen-slaydeck.mjs`
- [ ] **Step 1: `data/map.json` 작성**
```json
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["D", "E"] },
"C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] },
"D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] },
"E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] }
}
}
```
- [ ] **Step 2: `data/enemies.json`에 엘리트·보스 추가**`slime` 항목 다음에:
```json
"slime_elite": {
"name": "정예 슬라임",
"maxHp": 70,
"intents": [
{ "kind": "Attack", "value": 14 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Defend", "value": 10 }
]
},
"slime_boss": {
"name": "슬라임 킹",
"maxHp": 120,
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 12 },
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 22 }
]
}
```
- [ ] **Step 3: 생성기 상단에 map 로드·검증·헬퍼 추가**`const ACTIVE_ENEMY = ...;` 다음에:
```js
const MAP = JSON.parse(readFileSync('data/map.json', 'utf8'));
for (const id of MAP.start) {
if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`);
}
for (const [id, n] of Object.entries(MAP.nodes)) {
if (!ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`);
for (const nx of n.next) {
if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`);
}
}
const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row));
function luaIntentsArray(intents) {
return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }';
}
function luaEnemiesTable(enemies) {
const lines = Object.entries(enemies).map(([id, e]) =>
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
return `self.Enemies = {\n${lines.join('\n')}\n}`;
}
function luaMapNodesTable(nodes) {
const lines = Object.entries(nodes).map(([id, n]) => {
const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }';
return `\t${id} = { type = ${luaStr(n.type)}, enemy = ${luaStr(n.enemy)}, row = ${n.row}, col = ${n.col}, next = ${nx} },`;
});
return `self.MapNodes = {\n${lines.join('\n')}\n}`;
}
function luaStartArray(start) {
return 'self.MapStart = { ' + start.map(luaStr).join(', ') + ' }';
}
```
- [ ] **Step 4: method()에 ReturnType 파라미터 추가** — 기존 method 함수를:
```js
function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') {
return {
Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
Arguments,
Code,
Scope: 2,
ExecSpace,
Attributes: [],
Name,
};
}
```
- [ ] **Step 5: JSON·문법 검사**
Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs`
Expected: `JSON OK` + 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add data/map.json data/enemies.json tools/gen-slaydeck.mjs
git commit -m "data(E3): 분기 맵 map.json·엘리트/보스 적 + 직렬화 헬퍼"
```
---
### Task 2: 맵 속성 + StartRun(맵 빌드·ShowMap)
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 맵 상태 속성 추가**`prop('boolean', 'RunActive', 'false'),` 다음에:
```js
prop('any', 'Enemies'),
prop('any', 'MapNodes'),
prop('any', 'MapStart'),
prop('string', 'CurrentNodeId', '""'),
prop('string', 'CurrentEnemyId', '""'),
```
- [ ] **Step 2: StartRun 교체** — 맵 빌드 + ShowMap:
```js
method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
self.RunLength = ${MAX_ROW}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
${luaEnemiesTable(ENEMIES.enemies)}
${luaMapNodesTable(MAP.nodes)}
${luaStartArray(MAP.start)}
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:BindButtons()
self:ShowMap()`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): 맵 상태 속성·StartRun 맵 빌드/ShowMap"
```
---
### Task 3: StartCombat·CheckCombatEnd·PickReward (맵 연동)
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: StartCombat 교체** — 적을 self.Enemies에서 로드, Floor=노드 row:
```js
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil then
self.Floor = node.row
end
local enemy = self.Enemies[self.CurrentEnemyId]
self.PlayerBlock = 0
self.EnemyName = enemy.name
self.EnemyMaxHp = enemy.maxHp
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = 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: CheckCombatEnd 교체** — 보스 노드면 런 클리어:
```js
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true
self.Gold = self.Gold + ${GOLD_PER_WIN}
self:RenderRun()
local node = self.MapNodes[self.CurrentNodeId]
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 3: PickReward 마지막을 ShowMap으로** — PickReward 코드의 마지막 `self:StartCombat()``self:ShowMap()`로 교체. (그 외 동일)
```js
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:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
```
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): StartCombat 적 데이터화·보스 런클리어·보상후 맵복귀"
```
---
### Task 4: ShowMap·IsReachable·PickNode·RenderMap + BindButtons
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 맵 메서드 추가** — PickReward 메서드 다음(마지막 `]);` 직전)에 삽입:
```js
method('ShowMap', `self:RenderMap()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = true
end`),
method('IsReachable', `local list
if self.CurrentNodeId == "" then
list = self.MapStart
else
local node = self.MapNodes[self.CurrentNodeId]
if node == nil then
return false
end
list = node.next
end
for i = 1, #list do
if list[i] == id then
return true
end
end
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
method('RenderMap', `for id, node in pairs(self.MapNodes) do
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. id)
if e ~= nil then
local reachable = self:IsReachable(id)
if e.SpriteGUIRendererComponent ~= nil then
if reachable then
e.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
end
end
if e.ButtonComponent ~= nil then
e.ButtonComponent.Enable = reachable
end
end
end`),
method('PickNode', `if self.RunActive ~= true then
return
end
if self:IsReachable(id) ~= true then
return
end
self.CurrentNodeId = id
self.CurrentEnemyId = self.MapNodes[id].enemy
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = false
end
self:StartCombat()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
```
- [ ] **Step 2: BindButtons에 맵 노드 버튼 바인딩 추가** — BindButtons 코드의 마지막 `end`(skip 바인딩) 다음에 추가. BindButtons 끝부분의 skip 블록 다음에 붙이도록, skip 블록을 아래로 교체:
```js
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
local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} }
for i = 1, #mapNodeIds do
local nid = mapNodeIds[i]
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
if mn ~= nil and mn.ButtonComponent ~= nil then
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
end
end`),
```
(BindButtons 전체에서 기존 skip 블록 `local skip = ... 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(E3): ShowMap/IsReachable/PickNode/RenderMap·맵 노드 바인딩"
```
---
### Task 5: MapHud UI 생성
**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`)
- [ ] **Step 1: guid 'map' 분기** — ns 매핑에 추가:
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : 0xfe;
```
- [ ] **Step 2: 필터 확장** — upsertUi 필터에 MapHud 추가:
```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') && !e.path.startsWith('/ui/DefaultGroup/MapHud'));
```
- [ ] **Step 3: MapHud 그룹 생성** — `ui.ContentProto.Entities.push(...reward);` 다음에 삽입:
```js
const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스' };
const map = [];
const mapHud = entity({
id: guid('map', 0),
path: '/ui/DefaultGroup/MapHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 7,
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.05, g: 0.06, b: 0.09, a: 0.9 }, type: 1, raycast: true }),
],
});
mapHud.jsonString.enable = false;
map.push(mapHud);
map.push(entity({
id: guid('map', 1),
path: '/ui/DefaultGroup/MapHud/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: 60 }, pos: { x: 0, y: 510 } }),
sprite({ color: TRANSPARENT }),
text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
],
}));
let mapN = 2;
for (const [id, node] of Object.entries(MAP.nodes)) {
const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`;
const pos = { x: node.col * 180, y: node.row * 170 - 80 };
map.push(entity({
id: guid('map', mapN++),
path: nodePath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: node.row,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 80 }, pos }),
sprite({ color: { r: 0.3, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }),
button(),
],
}));
map.push(entity({
id: guid('map', mapN++),
path: `${nodePath}/Label`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: 150, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 144, y: 72 }, pos: { x: 0, y: 0 } }),
sprite({ color: TRANSPARENT }),
text({ value: `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}`, fontSize: 20, bold: true }),
],
}));
}
ui.ContentProto.Entities.push(...map);
```
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): MapHud 노드 맵 UI 생성"
```
---
### Task 6: 재생성 + 검증
**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(['ShowMap','PickNode','IsReachable','RenderMap'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/slime_boss/.test(sc)&&/슬라임 킹/.test(sc)?'ENEMIES OK':'NO ENEMIES'); 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/MapHud')&&has('/ui/DefaultGroup/MapHud/Node_BOSS')&&has('/ui/DefaultGroup/MapHud/Node_A/Label')?'UI OK':'UI MISSING')"`
Expected: `METHODS OK` / `ENEMIES 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/*`, `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 "재생성(E3): 분기 맵·다중 적 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)**
reload→Play: StartRun → MapHud(A·B만 클릭 가능) → PickNode("A") → 슬라임 전투 → 승리 → 보상 → 맵(C·D 활성) → PickNode("C") → 정예 슬라임(HP70) → ... → BOSS → 슬라임킹(HP120) → 승리 → "런 클리어!". 도달 불가 노드 PickNode → 무시. MCP는 `PickNode`/`PlayCard`/`PickReward` 직접 호출 + 상태 로그로 검증.
---
## Self-Review
- **Spec coverage:** map.json/적(Task1), 맵 상태·StartRun(Task2), StartCombat 적데이터·보스클리어·보상후맵(Task3), Show/Pick/Reachable/RenderMap·바인딩(Task4), MapHud UI(Task5), 검증(Task6). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** 메서드 `StartRun/ShowMap/IsReachable/PickNode/RenderMap/StartCombat/CheckCombatEnd/PickReward` 정의·호출 일치. 속성 `Enemies/MapNodes/MapStart/CurrentNodeId/CurrentEnemyId` 정의(Task2)·사용(Task3·4) 일치. UI 경로 `/ui/DefaultGroup/MapHud/Node_{id}`·`/Label`가 codeblock(RenderMap/PickNode/BindButtons)·생성(Task5)에서 동일(노드 id는 map.json 키). `IsReachable`는 boolean 반환(method returnType param, Task1). enemy 필드 `name/maxHp/intents`가 데이터·luaEnemiesTable·StartCombat에서 일치.

View 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 사용.

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,488 @@
# 상점/휴식 노드 (TODO E4) 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:** 맵에 상점(골드→카드)·휴식(HP 회복) 노드를 추가하고, 진입 시 전투 대신 상점/휴식 UI로 분기.
**Architecture:** `data/map.json`에 shop/rest 노드 추가(enemy 없음). SlayDeckController에 상점/휴식 메서드, PickNode 타입 분기, ShopHud/RestHud UI. 모두 `gen-slaydeck.mjs`에서 생성.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Modify: `data/map.json` — 4행, shop/rest 노드.
- Modify: `tools/gen-slaydeck.mjs` — 검증 완화, enemy 조건부 직렬화, 상수, 속성, PickNode 분기, 상점/휴식 메서드, ShopHud/RestHud UI, MapHud y 중앙정렬.
검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: 데이터 + 검증완화 + enemy 조건부 직렬화 + 상수·속성
**Files:** Modify `data/map.json`, `tools/gen-slaydeck.mjs`
- [ ] **Step 1: `data/map.json` 교체**
```json
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
}
}
```
- [ ] **Step 2: 검증 완화 (enemy 선택적)** — 생성기의 맵 검증 루프를 교체:
```js
for (const [id, n] of Object.entries(MAP.nodes)) {
if (n.enemy && !ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`);
for (const nx of n.next) {
if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`);
}
}
```
- [ ] **Step 3: luaMapNodesTable enemy 조건부** — 함수를 교체:
```js
function luaMapNodesTable(nodes) {
const lines = Object.entries(nodes).map(([id, n]) => {
const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }';
const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : '';
return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`;
});
return `self.MapNodes = {\n${lines.join('\n')}\n}`;
}
```
- [ ] **Step 4: 상수 추가**`writeCodeblocks()``const GOLD_PER_WIN = 15;` 다음에:
```js
const CARD_PRICE = 30;
const REST_HEAL = 30;
```
- [ ] **Step 5: 상점 상태 속성 추가**`prop('string', 'CurrentEnemyId', '""'),` 다음에:
```js
prop('any', 'ShopChoices'),
prop('any', 'ShopBought'),
```
- [ ] **Step 6: JSON·문법 검사**
Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs`
Expected: `JSON OK` + 오류 없음
- [ ] **Step 7: 커밋**
```bash
git add data/map.json tools/gen-slaydeck.mjs
git commit -m "data(E4): 상점/휴식 노드 맵 + enemy 선택적 검증/직렬화 + 상수/속성"
```
---
### Task 2: PickNode 분기 + 상점/휴식 메서드
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: PickNode 교체 (타입 분기)**
```js
method('PickNode', `if self.RunActive ~= true then
return
end
if self:IsReachable(id) ~= true then
return
end
self.CurrentNodeId = id
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = false
end
local node = self.MapNodes[id]
if node.type == "shop" then
self:ShowShop()
elseif node.type == "rest" then
self:ShowRest()
else
self.CurrentEnemyId = node.enemy
self:StartCombat()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
```
- [ ] **Step 2: 상점/휴식 메서드 추가** — PickNode 메서드 다음에 삽입:
```js
method('ShowShop', `local pool = {}
for cid, _ in pairs(self.Cards) do
table.insert(pool, cid)
end
self.ShopChoices = {}
self.ShopBought = { false, false, false }
for i = 1, 3 do
self.ShopChoices[i] = pool[math.random(1, #pool)]
end
self:RenderShop()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud")
if hud ~= nil then
hud.Enable = true
end`),
method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "골드 " .. string.format("%d", self.Gold))
for i = 1, 3 do
local cid = self.ShopChoices[i]
local c = self.Cards[cid]
local base = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i)
if c ~= nil then
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Cost", tostring(c.cost))
self:SetText(base .. "/Desc", c.desc)
self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 골드")
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if self.ShopBought[i] == true then
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
elseif 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
end
end`),
method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then
return
end
if self.Gold < ${CARD_PRICE} then
return
end
self.Gold = self.Gold - ${CARD_PRICE}
table.insert(self.RunDeck, self.ShopChoices[slot])
self.ShopBought[slot] = true
self:RenderShop()
self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('ShowRest', `local old = self.PlayerHp
self.PlayerHp = self.PlayerHp + ${REST_HEAL}
if self.PlayerHp > self.PlayerMaxHp then
self.PlayerHp = self.PlayerMaxHp
end
local healed = self.PlayerHp - old
self:SetText("/ui/DefaultGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")")
self:RenderCombat()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud")
if hud ~= nil then
hud.Enable = true
end`),
method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud")
if s ~= nil then
s.Enable = false
end
local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud")
if r ~= nil then
r.Enable = false
end
self:ShowMap()`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E4): PickNode 타입 분기·상점(구매)/휴식(회복) 메서드"
```
---
### Task 3: BindButtons 바인딩
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: BindButtons 맵 노드 루프 다음에 상점/휴식 바인딩 추가** — BindButtons 코드의 맵 노드 for-loop(`...PickNode(nid)...end\nend`) 다음, 닫는 백틱 직전에 삽입. 맵 노드 루프 끝 부분을 아래로 교체:
```js
for i = 1, #mapNodeIds do
local nid = mapNodeIds[i]
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
if mn ~= nil and mn.ButtonComponent ~= nil then
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
end
end
for i = 1, 3 do
local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i))
if sc ~= nil and sc.ButtonComponent ~= nil then
sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)
end
end
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 restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave")
if restLeave ~= nil and restLeave.ButtonComponent ~= nil then
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end`),
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E4): 상점 구매/나가기·휴식 나가기 버튼 바인딩"
```
---
### Task 4: ShopHud·RestHud UI + MapHud 4행 정렬
**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`)
- [ ] **Step 1: guid 'shp'·'rst' 분기** — ns 매핑에 추가(map 분기 다음):
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : 0xfe;
```
- [ ] **Step 2: 필터 확장** — upsertUi 필터에 ShopHud·RestHud 추가:
```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') && !e.path.startsWith('/ui/DefaultGroup/MapHud') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud'));
```
- [ ] **Step 3: MapHud 노드 y 중앙정렬** — upsertUi의 노드 pos 계산을 교체:
```js
const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 };
```
- [ ] **Step 4: ShopHud·RestHud 생성**`ui.ContentProto.Entities.push(...map);` 다음에 삽입:
```js
const shop = [];
const shopHud = entity({
id: guid('shp', 0),
path: '/ui/DefaultGroup/ShopHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 8,
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.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
],
});
shopHud.jsonString.enable = false;
shop.push(shopHud);
shop.push(entity({
id: guid('shp', 1),
path: '/ui/DefaultGroup/ShopHud/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: 60 }, pos: { x: 0, y: 400 } }),
sprite({ color: TRANSPARENT }),
text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
shop.push(entity({
id: guid('shp', 2),
path: '/ui/DefaultGroup/ShopHud/Gold',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }),
sprite({ color: TRANSPARENT }),
text({ value: '골드 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
],
}));
let shpN = 3;
const shopXs = [-300, 0, 300];
for (let i = 1; i <= 3; i++) {
const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`;
shop.push(entity({
id: guid('shp', shpN++),
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: shopXs[i - 1], y: 20 } }),
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, color: { r: 1, g: 1, b: 1, a: 1 } }],
['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }],
['Desc', { size: { x: 160, y: 60 }, pos: { x: 0, y: -50 }, value: '', fontSize: 20, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }],
['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 골드', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }],
]) {
shop.push(entity({
id: guid('shp', shpN++),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : suffix === 'Desc' ? 2 : 3,
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, color: cfg.color }),
],
}));
}
}
shop.push(entity({
id: guid('shp', shpN++),
path: '/ui/DefaultGroup/ShopHud/Leave',
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: -300 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...shop);
const rest = [];
const restHud = entity({
id: guid('rst', 0),
path: '/ui/DefaultGroup/RestHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 9,
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.05, g: 0.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }),
],
});
restHud.jsonString.enable = false;
rest.push(restHud);
rest.push(entity({
id: guid('rst', 1),
path: '/ui/DefaultGroup/RestHud/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: 60 }, pos: { x: 0, y: 140 } }),
sprite({ color: TRANSPARENT }),
text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
rest.push(entity({
id: guid('rst', 2),
path: '/ui/DefaultGroup/RestHud/Info',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }),
sprite({ color: TRANSPARENT }),
text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
rest.push(entity({
id: guid('rst', 3),
path: '/ui/DefaultGroup/RestHud/Leave',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 2,
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: -120 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...rest);
```
- [ ] **Step 5: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E4): ShopHud/RestHud UI·MapHud 4행 중앙정렬"
```
---
### 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(['ShowShop','BuyCard','ShowRest','LeaveNode','RenderShop'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); 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/ShopHud/Card1/Price')&&has('/ui/DefaultGroup/RestHud/Info')&&has('/ui/DefaultGroup/MapHud/Node_D')?'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: `data/map.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 "재생성(E4): 상점/휴식 노드·UI 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)**
reload→Play: 맵(4행, 2행에 휴식C·상점D) → PickNode("D")→상점(카드3·골드) → BuyCard(골드≥30이면 -30·RunDeck+1·비활성; 부족하면 무시) → LeaveNode→맵 / PickNode("C")→휴식(HP+30 클램프)→LeaveNode→맵 / 전투·보스·런 클리어 회귀 확인. MCP는 PickNode/BuyCard/LeaveNode 직접 호출 + 로그.
---
## Self-Review
- **Spec coverage:** 맵/검증/직렬화/상수/속성(Task1), PickNode분기·상점·휴식(Task2), 바인딩(Task3), UI·MapHud정렬(Task4), 검증(Task5). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** 메서드 `PickNode/ShowShop/RenderShop/BuyCard/ShowRest/LeaveNode` 정의·호출·바인딩 일치. 속성 `ShopChoices/ShopBought` 정의(Task1)·사용(Task2) 일치. UI 경로 `/ui/DefaultGroup/ShopHud/Card{1..3}/{Name,Cost,Desc,Price}`·`/Gold`·`/Leave`, `/RestHud/{Info,Leave}`가 codeblock(RenderShop/ShowRest/BindButtons)·생성(Task4)에서 동일. 상수 `CARD_PRICE/REST_HEAL` Task1 정의·Task2 사용 일치.

View File

@@ -0,0 +1,74 @@
# 카드 전투 통합 (TODO 항목 B) — 설계
> 작성: 2026-06-08 / 상태: 승인됨 / 근거: TODO.md 항목 B + 코드 직접 분석.
> 선행 작업: 항목 C(미커밋 노이즈 정리) 완료 — 작업 트리 클린.
## 문제
현재 `SlayDeckController.codeblock``PlayCard`는 에너지만 차감하고 `Toast(log)`만 띄운다.
실제 전투 상태(적 HP/방어, 플레이어 HP/Block, 적 의도, 승패)가 없어 STS식 덱빌딩 루프가
닫히지 않는다. 필드 액션 몬스터(`Monster.codeblock` — HP·피격·리스폰)는 카드 시스템과 분리돼 있다.
## 범위
**포함**: 단일 적 카드 전투 루프(데미지·방어·적 의도·턴 진행·승패), 카드 수치화, DeckHud UI 노출.
**제외(금지)**: 로그라이크 메타(E), 신규 카드 대량 추가, 전체 데이터 외부화(D — 본 작업은 인라인 수치화까지).
## 단일 소스 원칙
모든 변경은 `tools/gen-slaydeck.mjs`에서 생성한다. `SlayDeckController.codeblock` /
`ui/DefaultGroup.ui` / `Global/common.gamelogic`을 직접 손으로 편집하지 않는다.
변경 = 생성기 수정 → `node tools/gen-slaydeck.mjs` 재실행.
## 수치는 임시 placeholder
> 플레이어 수치는 향후 **캐릭터 특성별**, 몬스터 수치는 **몬스터별**로 다르게 설정 예정.
> 본 작업의 값(플레이어 80 / 적 45 / 의도 10·6·방8)은 루프 검증용 임시값이며,
> D(데이터 외부화) 단계에서 캐릭터/몬스터별 데이터로 분리한다.
## 설계
### 1) 전투 상태 (codeblock 속성 추가)
- 플레이어: `PlayerHp`, `PlayerMaxHp`(임시 80), `PlayerBlock`
- 적: `EnemyHp`, `EnemyMaxHp`(임시 45), `EnemyBlock`, `EnemyIntentIndex`
- `CombatOver`(승패 후 입력 잠금)
- 적은 codeblock 내부 상태로 보유(필드 `Monster.codeblock`과 분리).
### 2) 카드 데이터 수치화 (desc 파싱 폐기)
| id | 이름 | cost | kind | 효과 |
|----|------|------|------|------|
| Strike | 타격 | 1 | Attack | damage 6 |
| Defend | 방어 | 1 | Skill | block 5 |
| Bash | 강타 | 2 | Attack | damage 10 |
`desc`는 표시용으로만 유지. 효과는 `damage`/`block` 숫자 필드로 처리.
시작 덱: Strike×5, Defend×4, Bash×1 (10장).
### 3) 적 의도 — 결정적 사이클 (사용자 선택: A안)
- 의도 사이클(3스텝 회전): `[공격 10] → [공격 6] → [방어 8]`
- 매 플레이어 턴 시작 시 **다음 의도를 미리 표시**.
- 결정적이라 F(밸런스 시뮬레이터)에서 동일 규칙 재현 가능.
### 4) 전투 규칙 (STS 관례)
- 데미지는 **방어도 먼저 차감** 후 잔여만 HP에 적용.
- 플레이어 Block은 **플레이어 턴 시작 시 0 리셋**, 적 Block은 **적 턴 시작 시 리셋**.
- `PlayCard(slot)`: `kind=="Attack"` → 적 HP 감소(적 Block 우선 차감);
`kind=="Skill"` → 플레이어 Block 증가.
- `EndPlayerTurn` → 적 턴: 적 Block 리셋 → 현재 의도 실행(공격이면 플레이어에 피해,
방어면 적 Block↑) → 의도 인덱스 전진 → 패배 체크 → 다음 플레이어 턴(Block/에너지 리셋·드로우)
→ 다음 의도 표시.
- 승패: 적 HP≤0 → 승리 / 플레이어 HP≤0 → 패배. 승패 시 `CombatOver=true`로 입력 잠금 +
결과 텍스트 표시 + **보상 훅 자리(E용 주석)**.
### 5) UI — DeckHud 엔티티 추가 (생성기 생성)
- 상단 적 패널: 적 이름 · `HP 45/45` · `방어 0` · `의도: 공격 10`
- 좌측 플레이어 패널: `HP 80/80` · `방어 0`
- 승패 결과 텍스트(중앙, 평소 숨김 → 승패 시 표시).
## 검증 (메이커 Play)
- 타격 카드 → 적 HP 감소(적 Block 있으면 먼저 차감).
- 방어 카드 → 플레이어 Block 증가.
- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어.
- 적 HP 0 → 승리 / 플레이어 HP 0 → 패배, 입력 잠금.
- `node tools/gen-slaydeck.mjs` 2회 실행 결과 동일(결정적).
- `git status` — 의도한 생성물만 변경.

View File

@@ -0,0 +1,37 @@
# 덱 컨트롤러 코드리뷰 수정 설계
- 날짜: 2026-06-08
- 브랜치: feature/deck-controller-fixes (main 기준)
- 대상: `tools/gen-slaydeck.mjs` (단일 소스) → 재생성으로 `ui/DefaultGroup.ui`·`RootDesk/MyDesk/SlayDeckController.codeblock`·`Global/common.gamelogic` 갱신
## 배경
PR #6의 `SlayDeckController` 코드 리뷰에서 6건을 발견. 모든 산출물(카드 UI·DeckHud·codeblock·common 패치)은 `tools/gen-slaydeck.mjs` 한 곳에서 생성되므로, 이 생성기를 고치고 재실행하면 전부 반영된다.
## 수정 항목
- **① [Important] EndTurn 핸들러 self 바인딩**: `buttonEntity:ConnectEvent(ButtonClickEvent, self.EndPlayerTurn)``ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)`. 메서드 직접 전달 시 self가 event로 잘못 바인딩되는 문제 제거. (타이머는 이미 클로저 사용 — 일관성)
- **② [Important] Card5 이미지 충돌**: 이미 `gen-slaydeck.upsertUi`가 Card1~5를 동일 텍스트 카드로 통일(ImageRUID='', 틴트, Cost/Name/Desc 추가)하므로 재생성으로 해결됨. 추가 코드 변경 없음 — 검증만.
- **③ [기능] 카드 클릭 = 사용**:
- `upsertUi`의 카드 스타일 루프에서 Card1~5에 `ButtonComponent` 추가 + 카드 스프라이트 `RaycastTarget=true`.
- codeblock에 `PlayCard(slot)` 메서드 추가: `Hand[slot]`의 카드 코스트를 `CARDS`에서 조회 → `Energy >= cost``Energy -= cost`, 효과 표시(토스트/로그, 예: "타격 — 피해 6"), `Hand`에서 제거 후 `DiscardPile`에 삽입, `RenderHand(false)`+`RenderPiles()`. 부족하면 사용 불가(토스트/로그).
- `BindButtons`에서 각 카드의 `ButtonClickEvent``function() self:PlayCard(i) end` 클로저로 연결(루프 변수 i는 Lua에서 반복마다 새 지역변수라 안전). 재연결 전 이전 핸들러 해제.
- **④ [Minor] 카드 데이터 단일화**: `CARDS = { Strike={name,cost,desc,kind}, Defend={...}, Bash={...} }` 테이블을 codeblock 상단에 두고, 시작덱 구성·`ApplyCardVisual`·`PlayCard`가 공유(if/elseif 중복 제거).
- **⑤ [Minor] 매직넘버 상수화**: 손패/드로우 수(5), 시작 에너지(3) 등 의미 있는 상수로.
- **⑥ [Nit] pcall 제거**: `ApplyCardVisual``pcall(function() return Color(...) end)` → 직접 `Color(...)` 호출(틴트는 `CARDS[id].kind`별 색).
## 효과 표시(③)
적/데미지 시스템이 없으므로 카드 사용 효과는 **토스트 또는 로그**로만 표현(예: `log("타격 — 피해 6")` 또는 UIToast). 실제 데미지 적용은 범위 밖.
## 재생성·검증
1. `node --check tools/gen-slaydeck.mjs``node tools/gen-slaydeck.mjs`
2. 검증(데이터): codeblock에 `PlayCard` 존재, `BindButtons`/EndTurn이 클로저, `CARDS` 단일 테이블, `ApplyCardVisual`에 pcall 없음. DefaultGroup.ui의 Card1~5에 `ButtonComponent` + RaycastTarget true, Card5가 균일 텍스트 카드(ImageRUID 빈값·Cost/Name/Desc 존재).
3. Maker Play: 카드 클릭 → 에너지 감소·카드가 버림더미로·재렌더, EndTurn 버튼 동작, 5장 균일.
## stash 복구
이전 Maker 세션에서 stash해 둔 로컬 맵 변경(map02/05/06/07/10/11)을 이 브랜치에 복구해 포함. 단 복구분이 몬스터/타일셋 작업을 유지하는지(되돌리지 않는지) 무결성 검증 후 커밋. 손상/무의미하면 사용자에게 알리고 제외.
## 범위 밖 (YAGNI)
적 턴, 카드 효과의 실제 전투 적용, 신규 카드 종류.

View File

@@ -0,0 +1,68 @@
# AI 전투 시뮬레이터 (TODO 항목 F) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 F + gen-slaydeck.mjs 전투 규칙 + D 데이터.
> 선행: D(데이터 외부화) 완료.
## 문제
박재오 강점(백엔드·AI 자동화) 활용처로 기획됐으나 코드 없음. 카드/적 밸런싱을 손으로
검증해야 한다. 데이터 기반 자동 밸런스 검증 도구가 필요하다.
## 목표
`data/cards.json`·`data/enemies.json`를 입력으로, 전투를 몬테카를로로 N회 자동 시뮬레이션해
승률·평균 턴·OP 카드 탐지 리포트를 출력하는 오프라인 Node CLI(`tools/sim-balance.mjs`).
## 설계
### 구조
`tools/sim-balance.mjs` 단일 파일, 섹션 분리:
1. **데이터 로드**: `data/cards.json`·`data/enemies.json`(D와 동일 소스). `activeEnemy` 사용.
2. **시드 PRNG**: mulberry32(시드 고정 → 재현 가능, 데이터 바꾸면 결과 변동).
3. **전투 엔진**(Lua 규칙 미러): 아래 규칙을 JS로 재현.
4. **플레이어 정책**(휴리스틱 A).
5. **집계·리포트**.
6. **CLI 파싱·출력**.
### 전투 규칙 (gen-slaydeck.mjs Lua와 동일)
- 시작: 플레이어 `hp=PLAYER_HP(상수 80)`, `block=0`; 적 `hp=maxHp`, `block=0`, `intentIdx=0`(0-base).
덱 = `starterDeck` 셔플(PRNG).
- 플레이어 턴 시작: `energy=3`, `block=0`, 5장 드로우(덱 소진 시 버림 더미 셔플해 재활용).
- 플레이어 행동: 정책이 카드 선택 → 사용 시 `energy -= cost`, `Attack`→적에 `damage`(적 block 우선 차감),
`Skill`→플레이어 `block += block`. 사용 카드는 버림. 더 둘 수 없으면 턴 종료.
- 적 턴: 적 `block=0` → 현재 의도 실행(`Attack`→플레이어에 피해(플레이어 block 우선 차감),
`Defend`→적 `block += value`) → `intentIdx=(intentIdx+1)%len`.
- 승패: 적 hp≤0 승리, 플레이어 hp≤0 패배. 턴 상한 `MAX_TURNS=100`(초과 시 무승부로 집계, 경고).
### 플레이어 정책 (휴리스틱 A)
매 플레이어 행동 루프:
1. **치사 판단**: 손패의 Attack 카드들로 이번 턴 낼 수 있는 최대 데미지(에너지 한도 내) ≥
`적 hp + 적 block` 이면 → 그 Attack들을 사용(킬).
2. 아니면 **적 의도가 Attack**이면 → 손패 Defend(Skill+block) 카드를 사용(에너지 닿는 한),
이후 잔여 에너지로 Attack 사용.
3. 아니면(적 Defend 의도) → Attack 우선 사용.
4. 사용 가능한 카드(에너지≥cost)가 없으면 턴 종료.
- 동률 선택은 에너지 효율(뎀/E 또는 블록/E) 높은 카드 우선.
### 리포트 지표
- 전체: 승률(%), 평균·중앙값 턴 수, 승리 시 평균 잔여 HP, 패배율, (무승부 시 경고).
- 카드별: 사용 횟수, 누적 데미지/방어, **에너지당 효율**(Attack=총뎀/총E, Skill=총블록/총E).
- **OP 탐지**: 같은 kind 내 효율이 그 kind 중앙값의 ≥1.5배인 카드를 ⚠️로 플래그. 최다/최소 사용 카드 표기.
### CLI
`node tools/sim-balance.mjs [N] [--seed S]` — 기본 `N=2000`, `seed=1`. 표 형식 출력.
### 동기화 위험
JS 전투 규칙은 Lua(`gen-slaydeck.mjs`)와 **중복**이다(공유 불가). 데이터(JSON)는 공유.
파일 상단에 "전투 규칙 변경 시 gen-slaydeck.mjs Lua와 동기화" 주석 명시.
## 검증 (TDD + CLI)
- 전투 엔진/정책 핵심을 순수 함수로 분리해 단위 테스트(Node 내장 `node:test`):
데미지 방어차감, 치사 판단, 적 의도 사이클, 승/패 종료.
- `node tools/sim-balance.mjs` → 승률·턴·카드 통계 출력.
- `data/cards.json`에서 강타 damage↑ → 승률·강타 효율 상승(데이터 반영).
- 동일 시드 2회 → 동일 출력(결정성).
## 범위 밖 (금지)
- 상태이상·드로우·복합효과, 다중 적, 로그라이크 메타. 메이커 런타임 연동.
- 새 카드/적 추가(현 데이터로 검증). 정책 고도화(MCTS 등).

View File

@@ -0,0 +1,89 @@
# 카드/적 데이터 외부화 (TODO 항목 D) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 D + gen-slaydeck.mjs 분석.
> 선행: B(전투 통합)·A(문서 정합) 완료. F(밸런스 시뮬레이터)의 선행 조건.
## 문제
카드 정의(`self.Cards`)·시작 덱·적 정의(이름/HP/의도)가 `gen-slaydeck.mjs``StartCombat`
Lua 문자열에 하드코딩돼 있다. 카드/적 추가·밸런싱이 생성기 코드 수정을 요구한다.
## 목표
카드·적 데이터를 외부 JSON으로 분리하고, 생성기가 읽어 codeblock·UI에 주입한다.
데이터만 바꿔 재생성하면 게임에 반영(코드 수정 없이).
## 향후 방향 (참고)
추후 카드·적 공격은 **메이플스토리 IP**에 맞춰 디벨롭 예정. 본 스키마는 명시적 `desc`
키 기반 확장으로 이를 수용한다(새 카드/적은 JSON 항목 추가로 확장). 본 작업은 현 3종+적1
기준 **최소 스키마**까지만 — 새 효과 필드(상태이상/드로우 등)는 추가하지 않는다(YAGNI).
## 단일 소스 원칙
생성물(`SlayDeckController.codeblock` · `ui/DefaultGroup.ui` · `common.gamelogic`)은
`gen-slaydeck.mjs`가 생성한다. D 이후 **데이터의 단일 소스는 `data/*.json`**, 생성 로직의
단일 소스는 `gen-slaydeck.mjs`. 결정적 출력 유지.
## 설계
### 신규 파일
**`data/cards.json`**
```json
{
"cards": {
"Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" },
"Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" },
"Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" }
},
"starterDeck": ["Strike","Strike","Strike","Strike","Strike","Defend","Defend","Defend","Defend","Bash"]
}
```
**`data/enemies.json`**
```json
{
"enemies": {
"slime": {
"name": "슬라임", "maxHp": 45,
"intents": [
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 8 }
]
}
},
"activeEnemy": "slime"
}
```
- `desc`는 명시적(작성자 작성). `kind``"Attack"` 또는 `"Skill"`. 효과는 `damage`/`block`.
- `activeEnemy`로 현재 단일 전투의 적을 데이터에서 지정. 향후 E(맵 노드)에서 노드별 선택으로 확장.
### 생성기(`gen-slaydeck.mjs`) 변경
1. 상단에서 `readFileSync`+`JSON.parse``data/cards.json`·`data/enemies.json` 로드.
2. **검증(fail-fast)**: `starterDeck`의 모든 id가 `cards`에 존재해야 함; `activeEnemy`
`enemies`에 존재해야 함. 아니면 명확한 에러로 `process.exit(1)`(또는 throw).
3. `writeCodeblocks()``StartCombat`에서:
- `self.Cards = {...}``cards`에서 생성(Lua 테이블 직렬화 헬퍼).
- `self.DrawPile = {...}``starterDeck`에서 생성.
- `self.EnemyName`/`EnemyMaxHp`/`EnemyIntents`/`EnemyIntentIndex``enemies[activeEnemy]`에서 생성.
- codeblock 속성 `EnemyMaxHp` DefaultValue도 데이터 값으로.
4. `upsertUi()`의 DeckHud 카드 미리보기 배열·CombatHud 초기 텍스트(적 이름·`HP n/n`·첫 의도)를
동일 데이터에서 파생.
5. Lua 문자열 직렬화 시 한글/따옴표 이스케이프 주의(데이터 값은 따옴표·역슬래시 없는 단순 문자열 가정,
필요 시 escape 헬퍼).
### 데이터 흐름
`data/*.json``gen-slaydeck.mjs`(로드·검증·직렬화) → `SlayDeckController.codeblock`(Lua 테이블)
+ `ui/DefaultGroup.ui`(초기 텍스트) → 메이커 런타임.
## 검증
- `node tools/gen-slaydeck.mjs` 정상; JSON 유효; 2회 실행 결정적.
- `data/cards.json`에서 카드 1장 수치만 변경 → 재생성 → codeblock의 해당 카드 수치 변경
(생성기/codeblock 직접 수정 없이).
- 잘못된 데이터(starterDeck에 없는 id, 잘못된 activeEnemy) → 생성기가 명확히 실패.
- 메이커 Play: 기존 B 동작과 동일(데이터 동치이므로 회귀 없음).
## 범위 밖 (금지)
- 새 효과 필드(상태이상·드로우·복합효과) 추가. 새 카드 종류 대량 추가. F(시뮬레이터)·E(메타).

View File

@@ -0,0 +1,64 @@
# 다음 층 / 멀티 act (TODO E6a) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E6a) + 기존 맵/전투 구조.
> 선행: E1~E5 완료. 제외: E6b 저장/불러오기(사용자가 안 함으로 결정 — MSW 저장 API 필요).
## 문제
현재 보스 클리어 = 즉시 런 종료. 로그라이크의 "여러 층(act)을 점점 깊이 진행" 느낌이 없다.
보스 클리어 후 다음 막으로 이어지고, 최종 막 보스에서 진짜 런 클리어가 필요하다.
## 설계
### 파라미터 (생성기 상수)
- `ACT_COUNT = 3` (막 수).
- 적 스케일: `mult = 1 + (Act-1)*0.6` → 1막 ×1, 2막 ×1.6, 3막 ×2.2.
### 상태 재정의
- 기존 `Floor`**현재 막 카운터**(1..ACT_COUNT)로 사용. `RunLength = ACT_COUNT`.
- 맵 내 행 진행은 맵 UI가 표현 → 별도 숫자 표시 없음.
### 메서드 변경
- `StartRun`: `Floor = 1`, `RunLength = ${ACT_COUNT}`. (맵 1회 빌드는 그대로.)
- `StartCombat`: `self.Floor = node.row`**제거**. 적 로드 시 막 스케일 적용:
```lua
local mult = 1 + (self.Floor - 1) * 0.6
self.EnemyMaxHp = math.floor(enemy.maxHp * mult)
self.EnemyHp = self.EnemyMaxHp
self.EnemyIntents = {}
for i = 1, #enemy.intents do
self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) }
end
```
(공유 enemy.intents 변형 금지 — 새 테이블 생성.)
- `CheckCombatEnd` 보스 승리 분기:
```lua
if node ~= nil and node.type == "boss" then
if self.Floor < self.RunLength then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:RenderRun()
self:ShowMap()
else
self:ShowResult("런 클리어!")
self.RunActive = false
end
else
self:OfferReward()
end
```
(다음 막은 같은 맵 구조 재사용 — CurrentNodeId 리셋만. 적은 막 스케일로 강해짐.)
- HP·골드·덱·유물은 막 간 유지(기존 영속). combatStart 유물은 전투마다 재적용.
### UI
- `RenderRun`: 층 텍스트를 `"막 " .. Floor .. "/" .. RunLength`로 (라벨 "층"→"막"). 골드 표시 유지.
## 검증 (메이커 Play)
- 1막 보스(슬라임 킹 120) 처치 → 2막 맵·Floor 2 → 적 HP 스케일(슬라임 45→72, 보스 120→192).
- 3막 보스 처치 → "런 클리어!". HP/골드/덱/유물 막 간 유지.
- 패배 시 종료. 생성기 결정적·JSON 유효.
- (버튼 런타임 — MCP는 PickNode/PlayCard/CheckCombatEnd 직접 호출 + 상태 로그.)
## 범위 밖 (금지)
- 저장/불러오기(E6b). 막별 다른 맵 디자인·신규 적/배경·막별 보상 차등.

View File

@@ -0,0 +1,82 @@
# 분기 맵 노드 진행 (TODO E3) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E3) + SlayDeckController/run-loop-core 분석.
> 선행: E1+E2(런 루프 코어) 완료. 후속: E4(상점/휴식)·E5(유물)·E6(보스 연출/저장).
## 문제
E1+E2는 보상 후 자동으로 다음 전투로 넘어간다(고정 N). 로그라이크는 **플레이어가 맵에서 경로를
선택**해야 한다. 분기 노드 맵과 노드별 적 차등이 필요하다.
## 범위
플레이어가 **분기 맵(작성된 DAG)** 에서 다음 노드를 선택 → 노드 타입(전투/엘리트/보스)대로 전투
(적은 데이터로 차등) → 보상 → 맵으로 복귀 → 보스 클리어 시 "런 클리어". **상점/휴식·유물·저장·
절차적 생성·연결선 그리기는 범위 밖**. 맵 스키마는 상점/휴식 타입을 미래 수용.
## 설계
### 데이터
**`data/map.json`** (분기 DAG):
```json
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["D", "E"] },
"C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] },
"D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] },
"E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] }
}
}
```
- `type` ∈ {combat, elite, boss} (이 슬라이스). `enemy`는 enemies.json id. `row`(1=시작), `col`(레이아웃 x 단위), `next`(도달 노드 ids).
**`data/enemies.json`** 확장:
```json
"slime_elite": { "name": "정예 슬라임", "maxHp": 70,
"intents": [ {Attack 14}, {Attack 8}, {Defend 10} ] },
"slime_boss": { "name": "슬라임 킹", "maxHp": 120,
"intents": [ {Attack 18}, {Defend 12}, {Attack 10}, {Attack 22} ] }
```
(`activeEnemy`는 유지하되 런은 맵 노드의 enemy로 전투. F 시뮬레이터는 여전히 activeEnemy 기준 — 맵 적 시뮬은 후속.)
### 상태 (SlayDeckController 속성 추가)
- `Enemies`(any) — 전체 적 테이블(id→정의). 생성기가 enemies.json 전체 주입.
- `MapNodes`(any) — 그래프(id→{type, enemy, row, col, next}).
- `MapStart`(any) — 1행 노드 id 배열.
- `CurrentNodeId`(string) — 현재 위치("" = 시작 전).
- `CurrentEnemyId`(string) — 현재 전투 적 id.
### 메서드
- `StartRun`(수정): 런 상태 초기화 + `Enemies`/`MapNodes`/`MapStart` 세팅 + `CurrentNodeId=""` +
BindButtons(맵 노드 버튼 포함, 1회) → `self:ShowMap()` (기존 StartCombat 대신).
- `ShowMap`(신규): 선택 가능 노드 결정(CurrentNodeId=="" 면 MapStart, 아니면 MapNodes[CurrentNodeId].next).
각 노드 버튼 활성/비활성·라벨 갱신, 전투 UI 가리고 MapHud 표시(Enable).
- `IsReachable(id)`(헬퍼) — 현재 선택 가능 목록에 id 포함 여부.
- `PickNode(id)`(신규): `IsReachable(id)` 아니면 무시. `CurrentNodeId=id`,
`CurrentEnemyId=MapNodes[id].enemy`, MapHud 숨김 → `StartCombat()`.
- `StartCombat`(수정): 적을 `self.Enemies[self.CurrentEnemyId]`에서 로드(이름/HP/의도). Floor 증가 로직 제거.
- `CheckCombatEnd`(수정): 승리 시 골드+15 → 현재 노드 `type=="boss"``ShowResult("런 클리어!")`+RunActive=false;
아니면 `OfferReward`. 패배 → "패배..."+RunActive=false.
- `PickReward`(수정): 카드 처리 후 `StartCombat` 대신 `self:ShowMap()`.
### UI (MapHud, 신규)
- 평소 숨김. 풀스크린 모달 배경 + 제목 "다음 노드 선택".
- 노드 버튼 6개: 위치 = (col×스페이싱, 화면중앙+row×행간), 라벨(전투/엘리트/보스 + 적 이름).
- 선택 가능 노드만 밝게·클릭, 나머지 어둡게(반투명). 클릭 → `PickNode(id)`.
- 연결선은 생략(도달성=활성/비활성으로 표현; 연결선 그리기는 후속 폴리시).
### 단일 소스
모든 변경은 `tools/gen-slaydeck.mjs`에서 생성. map.json/enemies.json은 데이터 단일 소스.
## 검증 (메이커 Play)
- StartRun → MapHud, 1행 A·B만 선택 가능(나머지 비활성).
- A 선택 → 슬라임 전투 → 승리 → 보상 → 맵 복귀, 이제 C·D 선택 가능(B쪽 E는 불가).
- 엘리트 노드 → 정예 슬라임(HP 70) 전투. 보스 노드 → 슬라임 킹(HP 120).
- 보스 승리 → "런 클리어!". 패배 → "패배...". 도달 불가 노드 클릭 → 무시.
- 생성기 결정적, JSON 유효. (버튼 클릭은 런타임 — MCP는 PickNode/PlayCard/PickReward 직접 호출로 검증.)
## 범위 밖 (금지)
- 상점/휴식 노드 동작(E4)·유물(E5)·저장(E6). 절차적 맵·무작위 분기·연결선 그리기. 새 카드.

View 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 등 직접 호출 + 상태 로그로 검증.)
## 범위 밖 (금지)
- 부정적 유물·복합/조건부 효과·유물 제거·보스 유물·유물 등급/툴팁. 카드 제거(별도).

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

@@ -0,0 +1,70 @@
# 상점/휴식 노드 (TODO E4) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E4) + E3 맵 노드 구조.
> 선행: E3(분기 맵) 완료. 후속: E5(유물)·E6(저장). 카드 제거는 덱 보기 UI 필요 → 후속 분리.
## 문제
E3로 분기 맵은 됐으나 모든 노드가 전투다. 골드는 적립만 되고 소비처가 없다. 상점(골드→카드)·
휴식(HP 회복) 노드가 필요하다.
## 범위
맵에 상점/휴식 노드 추가, 진입 시 전투 대신 상점/휴식 UI. 상점 = 카드 구매(골드). 휴식 = HP 회복.
**카드 제거(덱 보기 UI 필요)·유물·저장·휴식 업그레이드는 범위 밖.**
## 설계
### 데이터 (`data/map.json` 교체 — 4행)
```json
{
"start": ["A", "B"],
"nodes": {
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
}
}
```
- rest/shop 노드는 `enemy` 없음. 생성기 검증을 "`enemy` 있을 때만 ENEMIES 확인"으로 완화.
Lua MapNodes 직렬화도 enemy 있을 때만 `enemy = "..."` 포함.
### 파라미터 (생성기 상수)
- `CARD_PRICE = 30`, `REST_HEAL = 30`.
### 상태 추가
- `ShopChoices`(any) — 상점 제시 카드 id 3개.
- `ShopBought`(any) — 슬롯별 구매 여부 {bool×3}.
### 메서드
- `PickNode`(수정): CurrentNodeId 세팅·맵 숨김 후 타입 분기 —
`shop``ShowShop`, `rest``ShowRest`, 그 외→`CurrentEnemyId=node.enemy`·`StartCombat`.
- `ShowShop`(신규): 카드 풀에서 3개 무작위→ShopChoices, ShopBought 초기화(false),
각 슬롯 비주얼·가격·골드 갱신, ShopHud 표시.
- `BuyCard(slot)`(신규): ShopBought[slot]==true 또는 Gold<CARD_PRICE면 무시. 아니면
Gold-=CARD_PRICE, RunDeck에 ShopChoices[slot] 추가, ShopBought[slot]=true, 해당 카드 어둡게, 골드 갱신.
- `ShowRest`(신규): `PlayerHp = min(PlayerMaxHp, PlayerHp + REST_HEAL)`, RestHud에 "HP 옛→새 (+회복)" 표시, RestHud 표시.
- `LeaveNode`(신규): ShopHud·RestHud 숨김 → `ShowMap`. (상점·휴식 나가기 공용)
- `RenderShop`(신규): 3 카드 비주얼/가격/구매상태 + 골드 텍스트 갱신.
### UI (신규)
- `ShopHud`(모달, 숨김): 제목 "상점", 골드 텍스트, 카드 3장(sprite+button + Name/Cost/Desc/Price 자식), "나가기" 버튼.
- `RestHud`(모달, 숨김): 제목 "휴식", 정보 텍스트(런타임), "나가기" 버튼.
- BindButtons: 상점 카드 버튼 3(→BuyCard i)·상점 나가기(→LeaveNode)·휴식 나가기(→LeaveNode) 바인딩.
### MapHud (4행 대응)
- 노드 y = `(row - (MAX_ROW+1)/2) * 140` (행 수에 맞춰 세로 중앙 정렬). col×180 유지.
## 검증 (메이커 Play)
- 맵→상점(D) 진입 → 카드 3장·가격·골드 표시 → 구매 시 골드 -30·RunDeck +1·해당 카드 비활성 →
골드 부족 시 구매 무시 → 나가기 → 맵(다음 노드).
- 맵→휴식(C) 진입 → HP +30(상한 클램프) → 나가기 → 맵.
- 전투/엘리트/보스/런 클리어/패배 회귀 없음. 생성기 결정적·JSON 유효.
- (버튼 클릭은 런타임 — MCP는 PickNode/BuyCard/ShowRest/LeaveNode 직접 호출로 검증.)
## 범위 밖 (금지)
- 카드 제거·덱 보기 UI(후속)·유물(E5)·저장(E6)·휴식 업그레이드·상점 유물/물약.

View File

@@ -47,7 +47,7 @@
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"Length": 1.29000092,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
@@ -62,15 +62,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.940001,
"y": -0.0500000119
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
"x": -7.64999962,
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -93,15 +93,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -124,15 +124,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -155,15 +155,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -186,15 +186,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -217,15 +217,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -248,15 +248,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -264,7 +264,7 @@
}
},
{
"Length": 0.899999738,
"Length": 0.9,
"NextFootholdId": 9,
"PreviousFootholdId": 7,
"groupID": 1,
@@ -279,15 +279,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
"x": -1.34999979,
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -310,15 +310,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -341,15 +341,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -372,15 +372,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -403,15 +403,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -434,15 +434,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
"x": 2.24999976,
"y": -0.0500000119
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
"x": 3.14999986,
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -465,15 +465,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -496,15 +496,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -527,15 +527,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
"x": 4.95,
"y": -0.0500000119
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
"x": 5.84999943,
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -558,15 +558,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -574,7 +574,7 @@
}
},
{
"Length": 1.27999973,
"Length": 1.289999,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
@@ -589,15 +589,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
"y": -0.0500000119
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
"x": 8.039999,
"y": -0.0500000119
},
"Variance": {
"x": 1,
@@ -605,7 +605,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.849999964,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
@@ -620,14 +620,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
"x": 8.039999,
"y": -0.0500000119
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -0.9
},
"Variance": {
@@ -651,14 +651,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 20,
"StartPoint": {
"x": 8.03,
"x": 8.039999,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -1.50000012
},
"Variance": {
@@ -682,14 +682,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 21,
"StartPoint": {
"x": 8.03,
"x": 8.039999,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -2.10000014
},
"Variance": {
@@ -713,14 +713,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 22,
"StartPoint": {
"x": 8.03,
"x": 8.039999,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -2.7
},
"Variance": {
@@ -744,15 +744,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 23,
"StartPoint": {
"x": 8.03,
"y": -2.70000029
"x": 8.039999,
"y": -2.7
},
"EndPoint": {
"x": 8.03,
"y": -3.30000019
"x": 8.039999,
"y": -3.3
},
"Variance": {
"x": 0,
@@ -775,14 +775,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 24,
"StartPoint": {
"x": 8.03,
"x": 8.039999,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -3.9
},
"Variance": {
@@ -806,14 +806,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 25,
"StartPoint": {
"x": 8.03,
"x": 8.039999,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -4.50000048
},
"Variance": {
@@ -837,14 +837,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 26,
"StartPoint": {
"x": 8.03,
"x": 8.039999,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"x": 8.039999,
"y": -5.10000038
},
"Variance": {
@@ -853,7 +853,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.849999964,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
@@ -868,15 +868,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 27,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.940001,
"y": -0.0500000119
},
"Variance": {
"x": 0,
@@ -899,14 +899,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 28,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"x": -8.940001,
"y": -0.9000001
},
"Variance": {
@@ -930,14 +930,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 29,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"x": -8.940001,
"y": -1.50000012
},
"Variance": {
@@ -961,14 +961,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 30,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"x": -8.940001,
"y": -2.10000014
},
"Variance": {
@@ -992,15 +992,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 31,
"StartPoint": {
"x": -8.93,
"y": -3.30000019
"x": -8.940001,
"y": -3.3
},
"EndPoint": {
"x": -8.93,
"y": -2.70000029
"x": -8.940001,
"y": -2.7
},
"Variance": {
"x": 0,
@@ -1023,14 +1023,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 32,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"x": -8.940001,
"y": -3.30000019
},
"Variance": {
@@ -1054,14 +1054,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 33,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"x": -8.940001,
"y": -3.90000033
},
"Variance": {
@@ -1085,14 +1085,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "000007d3-0000-4000-8000-0000000007d3",
"Id": 34,
"StartPoint": {
"x": -8.93,
"x": -8.940001,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"x": -8.940001,
"y": -4.5
},
"Variance": {
@@ -6381,8 +6381,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"x": 3.488205,
"y": 0.0249999911,
"z": 999.999
},
"QuaternionRotation": {
@@ -6514,7 +6514,7 @@
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"displayOrder": 5,
"pathConstraints": "///",
"revision": 2,
"origin": {
@@ -6530,7 +6530,7 @@
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"y": 0.03499998,
"y": 0.0249999911,
"z": 999.999
},
"QuaternionRotation": {

View File

@@ -47,7 +47,7 @@
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"Length": 1.18000031,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
@@ -62,15 +62,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.83,
"y": -0.08000001
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
"x": -7.64999962,
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -93,15 +93,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -124,15 +124,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -155,15 +155,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -186,15 +186,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -217,15 +217,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -248,15 +248,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -264,7 +264,7 @@
}
},
{
"Length": 0.899999738,
"Length": 0.9,
"NextFootholdId": 9,
"PreviousFootholdId": 7,
"groupID": 1,
@@ -279,15 +279,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
"x": -1.34999979,
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -310,15 +310,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -341,15 +341,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -372,15 +372,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -403,15 +403,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -434,15 +434,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
"x": 2.24999976,
"y": -0.08000001
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
"x": 3.14999986,
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -465,15 +465,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -496,15 +496,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -527,15 +527,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
"x": 4.95,
"y": -0.08000001
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
"x": 5.84999943,
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -558,15 +558,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -574,7 +574,7 @@
}
},
{
"Length": 1.27999973,
"Length": 1.17999983,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
@@ -589,15 +589,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
"y": -0.08000001
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.93,
"y": -0.08000001
},
"Variance": {
"x": 1,
@@ -605,7 +605,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.819999933,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
@@ -620,14 +620,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.93,
"y": -0.08000001
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -0.9
},
"Variance": {
@@ -651,14 +651,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 20,
"StartPoint": {
"x": 8.03,
"x": 7.93,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -1.50000012
},
"Variance": {
@@ -682,14 +682,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 21,
"StartPoint": {
"x": 8.03,
"x": 7.93,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -2.10000014
},
"Variance": {
@@ -713,14 +713,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 22,
"StartPoint": {
"x": 8.03,
"x": 7.93,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -2.7
},
"Variance": {
@@ -744,15 +744,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 23,
"StartPoint": {
"x": 8.03,
"y": -2.70000029
"x": 7.93,
"y": -2.7
},
"EndPoint": {
"x": 8.03,
"y": -3.30000019
"x": 7.93,
"y": -3.3
},
"Variance": {
"x": 0,
@@ -775,14 +775,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 24,
"StartPoint": {
"x": 8.03,
"x": 7.93,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -3.9
},
"Variance": {
@@ -806,14 +806,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 25,
"StartPoint": {
"x": 8.03,
"x": 7.93,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -4.50000048
},
"Variance": {
@@ -837,14 +837,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 26,
"StartPoint": {
"x": 8.03,
"x": 7.93,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"x": 7.93,
"y": -5.10000038
},
"Variance": {
@@ -853,7 +853,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.819999933,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
@@ -868,15 +868,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 27,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.83,
"y": -0.08000001
},
"Variance": {
"x": 0,
@@ -899,14 +899,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 28,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"x": -8.83,
"y": -0.9000001
},
"Variance": {
@@ -930,14 +930,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 29,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"x": -8.83,
"y": -1.50000012
},
"Variance": {
@@ -961,14 +961,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 30,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"x": -8.83,
"y": -2.10000014
},
"Variance": {
@@ -992,15 +992,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 31,
"StartPoint": {
"x": -8.93,
"y": -3.30000019
"x": -8.83,
"y": -3.3
},
"EndPoint": {
"x": -8.93,
"y": -2.70000029
"x": -8.83,
"y": -2.7
},
"Variance": {
"x": 0,
@@ -1023,14 +1023,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 32,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"x": -8.83,
"y": -3.30000019
},
"Variance": {
@@ -1054,14 +1054,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 33,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"x": -8.83,
"y": -3.90000033
},
"Variance": {
@@ -1085,14 +1085,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "0000138b-0000-4000-8000-00000000138b",
"Id": 34,
"StartPoint": {
"x": -8.93,
"x": -8.83,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"x": -8.83,
"y": -4.5
},
"Variance": {
@@ -6381,8 +6381,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"x": 3.52359,
"y": -0.00500001,
"z": 999.999
},
"QuaternionRotation": {
@@ -6514,7 +6514,7 @@
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"displayOrder": 5,
"pathConstraints": "///",
"revision": 2,
"origin": {
@@ -6529,8 +6529,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"y": 0.03499998,
"x": 5.18743134,
"y": -0.00500001,
"z": 999.999
},
"QuaternionRotation": {

View File

@@ -47,7 +47,7 @@
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"Length": 1.13000011,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
@@ -62,15 +62,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.78,
"y": -0.15
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
"x": -7.64999962,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -93,15 +93,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -124,15 +124,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -155,15 +155,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -186,15 +186,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -217,15 +217,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -248,15 +248,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -264,7 +264,7 @@
}
},
{
"Length": 0.899999738,
"Length": 0.9,
"NextFootholdId": 9,
"PreviousFootholdId": 7,
"groupID": 1,
@@ -279,15 +279,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
"x": -1.34999979,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -310,15 +310,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -341,15 +341,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -372,15 +372,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -403,15 +403,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -434,15 +434,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
"x": 2.24999976,
"y": -0.15
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
"x": 3.14999986,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -465,15 +465,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -496,15 +496,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -527,15 +527,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
"x": 4.95,
"y": -0.15
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
"x": 5.84999943,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -558,15 +558,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -574,7 +574,7 @@
}
},
{
"Length": 1.27999973,
"Length": 1.12999964,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
@@ -589,15 +589,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.87999964,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -605,7 +605,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.75,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
@@ -620,14 +620,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.87999964,
"y": -0.15
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -0.9
},
"Variance": {
@@ -651,14 +651,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 20,
"StartPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -1.50000012
},
"Variance": {
@@ -682,14 +682,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 21,
"StartPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -2.10000014
},
"Variance": {
@@ -713,14 +713,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 22,
"StartPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -2.7
},
"Variance": {
@@ -744,15 +744,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 23,
"StartPoint": {
"x": 8.03,
"y": -2.70000029
"x": 7.87999964,
"y": -2.7
},
"EndPoint": {
"x": 8.03,
"y": -3.30000019
"x": 7.87999964,
"y": -3.3
},
"Variance": {
"x": 0,
@@ -775,14 +775,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 24,
"StartPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -3.9
},
"Variance": {
@@ -806,14 +806,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 25,
"StartPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -4.50000048
},
"Variance": {
@@ -837,14 +837,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 26,
"StartPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"x": 7.87999964,
"y": -5.10000038
},
"Variance": {
@@ -853,7 +853,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.75,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
@@ -868,15 +868,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 27,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.78,
"y": -0.15
},
"Variance": {
"x": 0,
@@ -899,14 +899,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 28,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"x": -8.78,
"y": -0.9000001
},
"Variance": {
@@ -930,14 +930,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 29,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"x": -8.78,
"y": -1.50000012
},
"Variance": {
@@ -961,14 +961,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 30,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"x": -8.78,
"y": -2.10000014
},
"Variance": {
@@ -992,15 +992,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 31,
"StartPoint": {
"x": -8.93,
"y": -3.30000019
"x": -8.78,
"y": -3.3
},
"EndPoint": {
"x": -8.93,
"y": -2.70000029
"x": -8.78,
"y": -2.7
},
"Variance": {
"x": 0,
@@ -1023,14 +1023,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 32,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"x": -8.78,
"y": -3.30000019
},
"Variance": {
@@ -1054,14 +1054,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 33,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"x": -8.78,
"y": -3.90000033
},
"Variance": {
@@ -1085,14 +1085,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001773-0000-4000-8000-000000001773",
"Id": 34,
"StartPoint": {
"x": -8.93,
"x": -8.78,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"x": -8.78,
"y": -4.5
},
"Variance": {
@@ -6381,8 +6381,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"x": 3.51769257,
"y": -0.075,
"z": 999.999
},
"QuaternionRotation": {
@@ -6514,7 +6514,7 @@
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"displayOrder": 5,
"pathConstraints": "///",
"revision": 2,
"origin": {
@@ -6530,7 +6530,7 @@
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"y": 0.03499998,
"y": -0.075,
"z": 999.999
},
"QuaternionRotation": {

View File

@@ -47,7 +47,7 @@
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"Length": 1.250001,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
@@ -62,15 +62,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.900001,
"y": -0.0600000173
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
"x": -7.64999962,
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -93,15 +93,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -124,15 +124,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -155,15 +155,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -186,15 +186,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -217,15 +217,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -248,15 +248,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -264,7 +264,7 @@
}
},
{
"Length": 0.899999738,
"Length": 0.9,
"NextFootholdId": 9,
"PreviousFootholdId": 7,
"groupID": 1,
@@ -279,15 +279,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
"x": -1.34999979,
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -310,15 +310,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -341,15 +341,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -372,15 +372,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -403,15 +403,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -434,15 +434,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
"x": 2.24999976,
"y": -0.0600000173
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
"x": 3.14999986,
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -465,15 +465,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -496,15 +496,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -527,15 +527,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
"x": 4.95,
"y": -0.0600000173
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
"x": 5.84999943,
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -558,15 +558,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -574,7 +574,7 @@
}
},
{
"Length": 1.27999973,
"Length": 1.24999952,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
@@ -589,15 +589,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
"y": -0.0600000173
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.99999952,
"y": -0.0600000173
},
"Variance": {
"x": 1,
@@ -605,7 +605,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.84,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
@@ -620,14 +620,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.99999952,
"y": -0.0600000173
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -0.9
},
"Variance": {
@@ -651,14 +651,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 20,
"StartPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -1.50000012
},
"Variance": {
@@ -682,14 +682,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 21,
"StartPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -2.10000014
},
"Variance": {
@@ -713,14 +713,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 22,
"StartPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -2.7
},
"Variance": {
@@ -744,15 +744,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 23,
"StartPoint": {
"x": 8.03,
"y": -2.70000029
"x": 7.99999952,
"y": -2.7
},
"EndPoint": {
"x": 8.03,
"y": -3.30000019
"x": 7.99999952,
"y": -3.3
},
"Variance": {
"x": 0,
@@ -775,14 +775,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 24,
"StartPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -3.9
},
"Variance": {
@@ -806,14 +806,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 25,
"StartPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -4.50000048
},
"Variance": {
@@ -837,14 +837,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 26,
"StartPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"x": 7.99999952,
"y": -5.10000038
},
"Variance": {
@@ -853,7 +853,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.84,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
@@ -868,15 +868,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 27,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.900001,
"y": -0.0600000173
},
"Variance": {
"x": 0,
@@ -899,14 +899,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 28,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"x": -8.900001,
"y": -0.9000001
},
"Variance": {
@@ -930,14 +930,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 29,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"x": -8.900001,
"y": -1.50000012
},
"Variance": {
@@ -961,14 +961,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 30,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"x": -8.900001,
"y": -2.10000014
},
"Variance": {
@@ -992,15 +992,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 31,
"StartPoint": {
"x": -8.93,
"y": -3.30000019
"x": -8.900001,
"y": -3.3
},
"EndPoint": {
"x": -8.93,
"y": -2.70000029
"x": -8.900001,
"y": -2.7
},
"Variance": {
"x": 0,
@@ -1023,14 +1023,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 32,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"x": -8.900001,
"y": -3.30000019
},
"Variance": {
@@ -1054,14 +1054,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 33,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"x": -8.900001,
"y": -3.90000033
},
"Variance": {
@@ -1085,14 +1085,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00001b5b-0000-4000-8000-000000001b5b",
"Id": 34,
"StartPoint": {
"x": -8.93,
"x": -8.900001,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"x": -8.900001,
"y": -4.5
},
"Variance": {
@@ -6381,8 +6381,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"x": 3.50589752,
"y": 0.0149999857,
"z": 999.999
},
"QuaternionRotation": {
@@ -6514,7 +6514,7 @@
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"displayOrder": 5,
"pathConstraints": "///",
"revision": 2,
"origin": {
@@ -6529,8 +6529,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"y": 0.03499998,
"x": 5.48230743,
"y": 0.0149999857,
"z": 999.999
},
"QuaternionRotation": {

View File

@@ -47,7 +47,7 @@
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"Length": 1.22999954,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
@@ -62,15 +62,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.88,
"y": 0
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -93,15 +93,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -124,15 +124,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -155,15 +155,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -186,15 +186,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -217,15 +217,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -248,15 +248,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -279,15 +279,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -310,15 +310,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -341,15 +341,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -372,15 +372,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -403,15 +403,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -434,15 +434,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -465,15 +465,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -496,15 +496,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -527,15 +527,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -558,15 +558,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
"y": 0
},
"Variance": {
"x": 1,
@@ -574,7 +574,7 @@
}
},
{
"Length": 1.27999973,
"Length": 1.23,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
@@ -589,15 +589,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
"y": 0
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.98,
"y": 0
},
"Variance": {
"x": 1,
@@ -605,7 +605,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.9,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
@@ -620,14 +620,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.98,
"y": 0
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -0.9
},
"Variance": {
@@ -651,14 +651,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 20,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -1.50000012
},
"Variance": {
@@ -682,14 +682,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 21,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.10000014
},
"Variance": {
@@ -713,14 +713,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 22,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.7
},
"Variance": {
@@ -744,14 +744,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 23,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.70000029
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.30000019
},
"Variance": {
@@ -775,14 +775,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 24,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.9
},
"Variance": {
@@ -806,14 +806,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 25,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -4.50000048
},
"Variance": {
@@ -837,14 +837,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 26,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -5.10000038
},
"Variance": {
@@ -853,7 +853,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.9,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
@@ -868,15 +868,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 27,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.88,
"y": 0
},
"Variance": {
"x": 0,
@@ -899,14 +899,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 28,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -0.9000001
},
"Variance": {
@@ -930,14 +930,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 29,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -1.50000012
},
"Variance": {
@@ -961,14 +961,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 30,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.10000014
},
"Variance": {
@@ -992,14 +992,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 31,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.30000019
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.70000029
},
"Variance": {
@@ -1023,14 +1023,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 32,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.30000019
},
"Variance": {
@@ -1054,14 +1054,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 33,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.90000033
},
"Variance": {
@@ -1085,14 +1085,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002713-0000-4000-8000-000000002713",
"Id": 34,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -4.5
},
"Variance": {
@@ -6355,154 +6355,6 @@
"@version": 1
}
},
{
"id": "00002715-0000-4000-8000-000000002715",
"path": "/maps/map10/Monster1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"jsonString": {
"name": "Monster1",
"path": "/maps/map10/Monster1",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"sub_entity_id": null,
"root_entity_id": "00002715-0000-4000-8000-000000002715",
"replaced_model_id": null
},
"modelId": "staticmonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "3109357701ae41a4bcc7543f52f1f4c3",
"hit": "ce0269079e884545b5bb6ea075e2a67f",
"die": "a5e65650e00e47878cac1be7a5b999a0"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "3109357701ae41a4bcc7543f52f1f4c3",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"Enable": false,
"InputSpeed": 0
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
}
],
"@version": 1
}
},
{
"id": "00002716-0000-4000-8000-000000002716",
"path": "/maps/map10/Monster2",
@@ -6650,6 +6502,154 @@
],
"@version": 1
}
},
{
"id": "00002715-0000-4000-8000-000000002715",
"path": "/maps/map10/Monster1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"jsonString": {
"name": "Monster1",
"path": "/maps/map10/Monster1",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 5,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"sub_entity_id": null,
"root_entity_id": "00002715-0000-4000-8000-000000002715",
"replaced_model_id": null
},
"modelId": "staticmonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "3109357701ae41a4bcc7543f52f1f4c3",
"hit": "ce0269079e884545b5bb6ea075e2a67f",
"die": "a5e65650e00e47878cac1be7a5b999a0"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "3109357701ae41a4bcc7543f52f1f4c3",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"Enable": false,
"InputSpeed": 0
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
}
],
"@version": 1
}
}
]
}

View File

@@ -47,7 +47,7 @@
"FootholdsByLayer": {
"1": [
{
"Length": 1.27999973,
"Length": 1.2300005,
"NextFootholdId": 2,
"PreviousFootholdId": 27,
"groupID": 1,
@@ -62,15 +62,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 1,
"StartPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.88,
"y": -0.15
},
"EndPoint": {
"x": -7.65000057,
"y": -0.04000002
"x": -7.64999962,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -93,15 +93,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 2,
"StartPoint": {
"x": -7.64999962,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -6.75,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -124,15 +124,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 3,
"StartPoint": {
"x": -6.74999952,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -5.85,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -155,15 +155,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 4,
"StartPoint": {
"x": -5.84999943,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -186,15 +186,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 5,
"StartPoint": {
"x": -4.95,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -217,15 +217,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 6,
"StartPoint": {
"x": -4.05,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -248,15 +248,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 7,
"StartPoint": {
"x": -3.14999986,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -264,7 +264,7 @@
}
},
{
"Length": 0.899999738,
"Length": 0.9,
"NextFootholdId": 9,
"PreviousFootholdId": 7,
"groupID": 1,
@@ -279,15 +279,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 8,
"StartPoint": {
"x": -2.24999976,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -1.35,
"y": -0.04000002
"x": -1.34999979,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -310,15 +310,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 9,
"StartPoint": {
"x": -1.35,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": -0.449999958,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -341,15 +341,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 10,
"StartPoint": {
"x": -0.45,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 0.449999958,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -372,15 +372,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 11,
"StartPoint": {
"x": 0.450000018,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 1.35,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -403,15 +403,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 12,
"StartPoint": {
"x": 1.34999979,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 2.25,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -434,15 +434,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 13,
"StartPoint": {
"x": 2.25,
"y": -0.04000002
"x": 2.24999976,
"y": -0.15
},
"EndPoint": {
"x": 3.15,
"y": -0.04000002
"x": 3.14999986,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -465,15 +465,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 14,
"StartPoint": {
"x": 3.14999986,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 4.04999971,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -496,15 +496,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 15,
"StartPoint": {
"x": 4.05,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 4.95,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -527,15 +527,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 16,
"StartPoint": {
"x": 4.95000029,
"y": -0.04000002
"x": 4.95,
"y": -0.15
},
"EndPoint": {
"x": 5.85,
"y": -0.04000002
"x": 5.84999943,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -558,15 +558,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 17,
"StartPoint": {
"x": 5.85,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 6.74999952,
"y": -0.04000002
"y": -0.15
},
"Variance": {
"x": 1,
@@ -574,7 +574,7 @@
}
},
{
"Length": 1.27999973,
"Length": 1.23,
"NextFootholdId": 19,
"PreviousFootholdId": 17,
"groupID": 1,
@@ -589,15 +589,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 18,
"StartPoint": {
"x": 6.75,
"y": -0.04000002
"y": -0.15
},
"EndPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.98,
"y": -0.15
},
"Variance": {
"x": 1,
@@ -605,7 +605,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.75,
"NextFootholdId": 20,
"PreviousFootholdId": 18,
"groupID": 1,
@@ -620,14 +620,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 19,
"StartPoint": {
"x": 8.03,
"y": -0.04000002
"x": 7.98,
"y": -0.15
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -0.9
},
"Variance": {
@@ -651,14 +651,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 20,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -0.9000001
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -1.50000012
},
"Variance": {
@@ -682,14 +682,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 21,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -1.50000012
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.10000014
},
"Variance": {
@@ -713,14 +713,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 22,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.10000014
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -2.7
},
"Variance": {
@@ -744,15 +744,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 23,
"StartPoint": {
"x": 8.03,
"y": -2.70000029
"x": 7.98,
"y": -2.7
},
"EndPoint": {
"x": 8.03,
"y": -3.30000019
"x": 7.98,
"y": -3.3
},
"Variance": {
"x": 0,
@@ -775,14 +775,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 24,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.30000019
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.9
},
"Variance": {
@@ -806,14 +806,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 25,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -3.90000033
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -4.50000048
},
"Variance": {
@@ -837,14 +837,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 26,
"StartPoint": {
"x": 8.03,
"x": 7.98,
"y": -4.5
},
"EndPoint": {
"x": 8.03,
"x": 7.98,
"y": -5.10000038
},
"Variance": {
@@ -853,7 +853,7 @@
}
},
{
"Length": 0.859999955,
"Length": 0.75,
"NextFootholdId": 1,
"PreviousFootholdId": 28,
"groupID": 1,
@@ -868,15 +868,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 27,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -0.9
},
"EndPoint": {
"x": -8.93,
"y": -0.04000002
"x": -8.88,
"y": -0.15
},
"Variance": {
"x": 0,
@@ -899,14 +899,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 28,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -1.50000012
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -0.9000001
},
"Variance": {
@@ -930,14 +930,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 29,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.10000014
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -1.50000012
},
"Variance": {
@@ -961,14 +961,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 30,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.7
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -2.10000014
},
"Variance": {
@@ -992,15 +992,15 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 31,
"StartPoint": {
"x": -8.93,
"y": -3.30000019
"x": -8.88,
"y": -3.3
},
"EndPoint": {
"x": -8.93,
"y": -2.70000029
"x": -8.88,
"y": -2.7
},
"Variance": {
"x": 0,
@@ -1023,14 +1023,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 32,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.9
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.30000019
},
"Variance": {
@@ -1054,14 +1054,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 33,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -4.50000048
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -3.90000033
},
"Variance": {
@@ -1085,14 +1085,14 @@
"isCustomFoothold": false,
"inertiaOption": 0
},
"OwnerId": "c9a3018a-f6fa-4c4b-b91e-404ac5ce9858",
"OwnerId": "00002afb-0000-4000-8000-000000002afb",
"Id": 34,
"StartPoint": {
"x": -8.93,
"x": -8.88,
"y": -5.10000038
},
"EndPoint": {
"x": -8.93,
"x": -8.88,
"y": -4.5
},
"Variance": {
@@ -6381,8 +6381,8 @@
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"y": 0.03499998,
"x": 3.50589752,
"y": -0.075,
"z": 999.999
},
"QuaternionRotation": {
@@ -6514,7 +6514,7 @@
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"displayOrder": 5,
"pathConstraints": "///",
"revision": 2,
"origin": {
@@ -6530,7 +6530,7 @@
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"y": 0.03499998,
"y": -0.075,
"z": 999.999
},
"QuaternionRotation": {

File diff suppressed because it is too large Load Diff

199
tools/sim-balance.mjs Normal file
View File

@@ -0,0 +1,199 @@
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
// ⚠️ 전투 규칙은 tools/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현)
import { readFileSync } from 'node:fs';
export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치)
export const ENERGY = 3;
export const HAND_SIZE = 5;
export const MAX_TURNS = 100;
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// 방어 우선 차감 후 hp 적용 → { hp, block }
export function applyDamage(hp, block, amount) {
let dmg = amount;
if (block > 0) {
const absorbed = Math.min(block, dmg);
block -= absorbed;
dmg -= absorbed;
}
hp -= dmg;
if (hp < 0) hp = 0;
return { hp, block };
}
export function loadData() {
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
const enemy = enemiesData.enemies[enemiesData.activeEnemy];
if (!enemy) throw new Error(`activeEnemy 없음: ${enemiesData.activeEnemy}`);
return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, enemy };
}
// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열.
export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy);
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost;
const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost;
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
// 1) 치사: 에너지 한도 내 효율순 공격 데미지 합 >= 적 유효 hp?
let e = energy, lethalDmg = 0;
for (const x of attacks.slice().sort((a, b) => dmgEff(b) - dmgEff(a))) {
if (cards[x.id].cost <= e) { e -= cards[x.id].cost; lethalDmg += cards[x.id].damage || 0; }
}
if (attacks.length && lethalDmg >= enemyHp + enemyBlock) return bestBy(attacks, dmgEff).i;
// 2) 적 공격 의도면 방어 우선
if (enemyIntent && enemyIntent.kind === 'Attack' && skills.length) return bestBy(skills, blkEff).i;
// 3) 공격 우선, 없으면 스킬, 없으면 종료
if (attacks.length) return bestBy(attacks, dmgEff).i;
if (skills.length) return bestBy(skills, blkEff).i;
return -1;
}
function bump(s, cost, dmg, blk) {
s = s || { plays: 0, energy: 0, damage: 0, block: 0 };
s.plays++; s.energy += cost; s.damage += dmg; s.block += blk;
return s;
}
// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적.
// 반환: { win, turns, playerHpRemaining, draw? }
export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, enemy } = data;
let drawPile = shuffle(starterDeck, rng);
let discard = [];
let hand = [];
let pHp = PLAYER_HP, pBlock = 0;
let eHp = enemy.maxHp, eBlock = 0, intentIdx = 0;
let turns = 0;
function draw(n) {
for (let k = 0; k < n; k++) {
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
if (drawPile.length === 0) break;
hand.push(drawPile.pop());
}
}
while (turns < MAX_TURNS) {
turns++;
let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE);
while (true) {
const intent = enemy.intents[intentIdx];
const idx = chooseAction(hand, cards, energy, eHp, eBlock, intent);
if (idx < 0) break;
const id = hand[idx], c = cards[id];
energy -= c.cost;
if (c.kind === 'Attack') {
const r = applyDamage(eHp, eBlock, c.damage || 0); eHp = r.hp; eBlock = r.block;
if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0);
} else {
pBlock += c.block || 0;
if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0);
}
hand.splice(idx, 1); discard.push(id);
if (eHp <= 0) return { win: true, turns, playerHpRemaining: pHp };
}
discard.push(...hand); hand = [];
eBlock = 0;
const intent = enemy.intents[intentIdx];
if (intent.kind === 'Attack') { const r = applyDamage(pHp, pBlock, intent.value); pHp = r.hp; pBlock = r.block; }
else if (intent.kind === 'Defend') { eBlock += intent.value; }
intentIdx = (intentIdx + 1) % enemy.intents.length;
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
}
return { win: false, turns, playerHpRemaining: pHp, draw: true };
}
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }
function median(a) {
if (!a.length) return 0;
const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
export function runBatch(N, seed) {
const data = loadData();
const rng = mulberry32(seed);
const cardStats = {};
let wins = 0, draws = 0;
const turnsArr = [], hpArr = [];
for (let i = 0; i < N; i++) {
const r = simulateCombat(data, rng, cardStats);
if (r.draw) draws++;
if (r.win) { wins++; hpArr.push(r.playerHpRemaining); }
turnsArr.push(r.turns);
}
return {
N, wins, draws, losses: N - wins - draws,
winRate: wins / N,
avgTurns: mean(turnsArr), medianTurns: median(turnsArr),
avgHpOnWin: mean(hpArr),
cardStats, cards: data.cards, enemy: data.enemy, seed,
};
}
export function formatReport(r) {
const L = [];
L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`);
L.push(`시뮬 ${r.N}회 (seed=${r.seed})`);
L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`);
L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`);
L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`);
if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`);
L.push('');
L.push('카드별:');
const rows = Object.entries(r.cardStats).map(([id, s]) => {
const kind = r.cards[id].kind;
const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy;
return { id, name: r.cards[id].name, kind, plays: s.plays, eff };
});
for (const kind of ['Attack', 'Skill']) {
const kr = rows.filter((x) => x.kind === kind);
if (!kr.length) continue;
const med = median(kr.map((x) => x.eff));
const unit = kind === 'Attack' ? '뎀/E' : '블록/E';
for (const x of kr) {
const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : '';
L.push(` ${x.name}(${x.id}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`);
}
}
const sorted = rows.slice().sort((a, b) => b.plays - a.plays);
if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`);
return L.join('\n');
}
function main() {
const args = process.argv.slice(2);
let N = 2000, seed = 1;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--seed') seed = parseInt(args[++i], 10);
else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10);
}
console.log(formatReport(runBatch(N, seed)));
}
if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main();

View File

@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
mulberry32, applyDamage, chooseAction, simulateCombat, runBatch,
} from './sim-balance.mjs';
test('applyDamage: 방어 우선 차감 후 hp', () => {
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 });
assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 });
});
test('mulberry32: 동일 시드 동일 수열', () => {
const a = mulberry32(1), b = mulberry32(1);
assert.equal(a(), b());
assert.equal(a(), b());
});
const CARDS = {
Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 },
Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 },
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
};
test('chooseAction: 치사 가능하면 공격 선택', () => {
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, 0);
});
test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => {
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, 1);
});
test('chooseAction: 적 방어 의도면 공격 우선', () => {
const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 });
assert.equal(idx, 1);
});
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, -1);
});
const DATA = {
cards: CARDS,
starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'],
enemy: {
name: '슬라임', maxHp: 45, intents: [
{ kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', value: 8 },
],
},
};
test('simulateCombat: 결정적 결과(동일 시드)', () => {
const r1 = simulateCombat(DATA, mulberry32(1));
const r2 = simulateCombat(DATA, mulberry32(1));
assert.deepEqual(r1, r2);
assert.equal(typeof r1.win, 'boolean');
assert.ok(r1.turns >= 1);
});
test('simulateCombat: 약한 적이면 대체로 승리', () => {
let wins = 0;
for (let i = 0; i < 50; i++) if (simulateCombat(DATA, mulberry32(i + 1)).win) wins++;
assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`);
});
test('runBatch: 집계 필드·승률 범위', () => {
const r = runBatch(100, 1);
assert.equal(r.N, 100);
assert.ok(r.winRate >= 0 && r.winRate <= 1);
assert.ok(r.avgTurns > 0);
assert.ok(r.cardStats.Strike.plays > 0);
});
test('runBatch: 동일 시드 동일 결과', () => {
assert.deepEqual(runBatch(100, 7), runBatch(100, 7));
});

File diff suppressed because it is too large Load Diff