Merge pull request 'feat(combat): 노드 타입별 몬스터 그룹 (일반/엘리트/보스)' (#31) from feature/node-type-monster-groups into main

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-06-10 22:35:23 +09:00
18 changed files with 595 additions and 48 deletions

View File

@@ -36,6 +36,13 @@
"Attributes": [],
"Name": "EnemyId"
},
{
"Type": "string",
"DefaultValue": "\"combat\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "Group"
},
{
"Type": "number",
"DefaultValue": "0",
@@ -54,7 +61,7 @@
"Name": null
},
"Arguments": [],
"Code": "self.RegTries = 0\nlocal eventId = 0\nlocal function reg()\n\tself.RegTries = self.RegTries + 1\n\tlocal c = _EntityService:GetEntityByPath(\"/common\")\n\tif c ~= nil and c.SlayDeckController ~= nil then\n\t\tc.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId)\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.RegTries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(reg, 0.1)",
"Code": "self.RegTries = 0\nlocal eventId = 0\nlocal function reg()\n\tself.RegTries = self.RegTries + 1\n\tlocal c = _EntityService:GetEntityByPath(\"/common\")\n\tif c ~= nil and c.SlayDeckController ~= nil then\n\t\tc.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group)\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.RegTries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(reg, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],

View File

@@ -225,6 +225,13 @@
"Attributes": [],
"Name": "SlotPos"
},
{
"Type": "any",
"DefaultValue": "nil",
"SyncDirection": 0,
"Attributes": [],
"Name": "ActiveSlotPos"
},
{
"Type": "any",
"DefaultValue": "nil",
@@ -520,7 +527,7 @@
"Name": null
},
"Arguments": [],
"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\torange_mushroom = { name = \"주황버섯\", maxHp = 16, intents = { { kind = \"Attack\", value = 5 }, { kind = \"Defend\", value = 4 }, { kind = \"Attack\", value = 7 } } },\n\tblue_mushroom = { name = \"파란버섯\", maxHp = 22, intents = { { kind = \"Attack\", value = 8 }, { kind = \"Attack\", value = 4 } } },\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.SlotPos = { { x = 430, y = 140 }, { x = 600, y = 140 }, { x = 770, y = 140 }, { x = 900, y = 140 } }\nself.CurrentNodeId = \"\"\nself.CurrentEnemyId = \"\"\nself:BindButtons()\nself:AddRelic(\"ironHeart\")\nself:ShowMap()",
"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\torange_mushroom = { name = \"주황버섯\", maxHp = 16, intents = { { kind = \"Attack\", value = 5 }, { kind = \"Defend\", value = 4 }, { kind = \"Attack\", value = 7 } } },\n\tblue_mushroom = { name = \"파란버섯\", maxHp = 22, intents = { { kind = \"Attack\", value = 8 }, { kind = \"Attack\", value = 4 } } },\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.SlotPos = { combat = { { x = 430, y = 140 }, { x = 600, y = 140 }, { x = 770, y = 140 }, { x = 900, y = 140 } }, elite = { { x = 430, y = 160 }, { x = 650, y = 160 }, { x = 850, y = 160 }, { x = 980, y = 160 } }, boss = { { x = 520, y = 200 }, { x = 760, y = 160 }, { x = 940, y = 150 }, { x = 1040, y = 150 } } }\nself.CurrentNodeId = \"\"\nself.CurrentEnemyId = \"\"\nself:BindButtons()\nself:AddRelic(\"ironHeart\")\nself:ShowMap()",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -563,9 +570,16 @@
"SyncDirection": 0,
"Attributes": [],
"Name": "enemyId"
},
{
"Type": "string",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": "group"
}
],
"Code": "if self.Registered == nil then\n\tself.Registered = {}\nend\ntable.insert(self.Registered, { entity = monster, enemyId = enemyId })",
"Code": "if self.Registered == nil then\n\tself.Registered = {}\nend\nlocal g = group\nif g == nil or g == \"\" then g = \"combat\" end\ntable.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g })",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -580,7 +594,7 @@
"Name": null
},
"Arguments": [],
"Code": "self.Monsters = {}\nlocal reg = self.Registered or {}\nlocal list = {}\nfor i = 1, #reg do\n\tlocal r = reg[i]\n\tif r.entity ~= nil and isvalid(r.entity) then\n\t\tlocal x = 0\n\t\tif r.entity.TransformComponent ~= nil then\n\t\t\tx = r.entity.TransformComponent.WorldPosition.x\n\t\tend\n\t\ttable.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x })\n\tend\nend\ntable.sort(list, function(a, b) return a.x < b.x end)\nlocal mult = 1 + (self.Floor - 1) * 0.6\nlocal n = #list\nif n > 4 then n = 4 end\nfor i = 1, n do\n\tlocal item = list[i]\n\tlocal e = self.Enemies[item.enemyId]\n\tif e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = \"Attack\", value = 5 } } } end\n\tlocal intents = {}\n\tfor k = 1, #e.intents do\n\t\tintents[k] = { kind = e.intents[k].kind, value = math.floor(e.intents[k].value * mult) }\n\tend\n\tlocal maxHp = math.floor(e.maxHp * mult)\n\tself.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,\n\t\thp = maxHp, maxHp = maxHp, block = 0, intents = intents, intentIdx = 1, alive = true, slot = i }\n\tself:ReviveMonsterEntity(item.entity)\n\tself:PositionMonsterSlot(i)\nend\nself.TargetIndex = 1",
"Code": "self.Monsters = {}\nlocal g = \"combat\"\nlocal node = self.MapNodes[self.CurrentNodeId]\nif node ~= nil and node.type ~= nil then g = node.type end\nself.ActiveSlotPos = self.SlotPos[g]\nlocal reg = self.Registered or {}\nfor i = 1, #reg do\n\tif reg[i].entity ~= nil and isvalid(reg[i].entity) then\n\t\treg[i].entity:SetVisible(false)\n\tend\nend\nlocal list = {}\nfor i = 1, #reg do\n\tlocal r = reg[i]\n\tif r.entity ~= nil and isvalid(r.entity) and r.group == g then\n\t\tlocal x = 0\n\t\tif r.entity.TransformComponent ~= nil then\n\t\t\tx = r.entity.TransformComponent.WorldPosition.x\n\t\tend\n\t\ttable.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x })\n\tend\nend\ntable.sort(list, function(a, b) return a.x < b.x end)\nlocal mult = 1 + (self.Floor - 1) * 0.6\nlocal n = #list\nif n > 4 then n = 4 end\nfor i = 1, n do\n\tlocal item = list[i]\n\tlocal e = self.Enemies[item.enemyId]\n\tif e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = \"Attack\", value = 5 } } } end\n\tlocal intents = {}\n\tfor k = 1, #e.intents do\n\t\tintents[k] = { kind = e.intents[k].kind, value = math.floor(e.intents[k].value * mult) }\n\tend\n\tlocal maxHp = math.floor(e.maxHp * mult)\n\tself.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,\n\t\thp = maxHp, maxHp = maxHp, block = 0, intents = intents, intentIdx = 1, alive = true, slot = i }\n\tself:ReviveMonsterEntity(item.entity)\n\tself:PositionMonsterSlot(i)\nend\nself.TargetIndex = 1",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
@@ -1267,7 +1281,7 @@
"Name": "slot"
}
],
"Code": "local sp = self.SlotPos\nif sp == nil or sp[slot] == nil then\n\treturn\nend\nlocal e = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CombatHud/MonsterSlot\" .. tostring(slot))\nif e ~= nil and e.UITransformComponent ~= nil then\n\te.UITransformComponent.anchoredPosition = Vector2(sp[slot].x, sp[slot].y)\nend",
"Code": "local sp = self.ActiveSlotPos\nif sp == nil or sp[slot] == nil then\n\treturn\nend\nlocal e = _EntityService:GetEntityByPath(\"/ui/DefaultGroup/CombatHud/MonsterSlot\" .. tostring(slot))\nif e ~= nil and e.UITransformComponent ~= nil then\n\te.UITransformComponent.anchoredPosition = Vector2(sp[slot].x, sp[slot].y)\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],

View File

@@ -1,6 +1,20 @@
[
{ "x": 430, "y": 140 },
{ "x": 600, "y": 140 },
{ "x": 770, "y": 140 },
{ "x": 900, "y": 140 }
]
{
"combat": [
{ "x": 430, "y": 140 },
{ "x": 600, "y": 140 },
{ "x": 770, "y": 140 },
{ "x": 900, "y": 140 }
],
"elite": [
{ "x": 430, "y": 160 },
{ "x": 650, "y": 160 },
{ "x": 850, "y": 160 },
{ "x": 980, "y": 160 }
],
"boss": [
{ "x": 520, "y": 200 },
{ "x": 760, "y": 160 },
{ "x": 940, "y": 150 },
{ "x": 1040, "y": 150 }
]
}

View File

@@ -0,0 +1,361 @@
# 노드 타입별 몬스터 그룹 구현 계획
> **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:** 한 맵에서 노드 타입(combat/elite/boss)에 따라 해당 그룹의 몬스터만 등장시킨다.
**Architecture:** 각 맵 몬스터의 `script.CombatMonster``Group` 태그를 두고, 전투 시작 시 `BuildMonsters`가 현재 노드 타입으로 필터해 일치 그룹만 표시·전투 구성한다. HP바 슬롯 좌표는 `data/monster-slots.json`에 그룹별로 둔다. Group/EnemyId는 메이커 인스펙터에서 저작하므로 생성기는 값을 덮어쓰지 않는다.
**Tech Stack:** Node.js ESM 생성기(.mjs), MSW Lua codeblock, JSON(.codeblock/.map/data).
---
## 배경 / 현재 코드 (구현자용)
- 생성물(`SlayDeckController.codeblock`·`ui/DefaultGroup.ui`·`common.gamelogic`)은 `tools/deck/gen-slaydeck.mjs`에서만, `CombatMonster.codeblock`+맵 패치는 `tools/monster/gen-combat-monster.mjs`에서만 생성한다(직접 편집 금지). 루트에서 `node tools/<폴더>/<파일>.mjs` 실행.
- 현재 `BuildMonsters`는 등록된 몬스터를 노드 타입과 무관하게 전부 사용. 노드 타입은 `self.MapNodes[self.CurrentNodeId].type`로 접근(combat/elite/boss).
- 현재 `data/monster-slots.json`은 평면 배열 `[{x,y}×4]`. `MAX_MONSTERS = 4`(gen-slaydeck.mjs 상수).
- 전투 규칙(타겟/공격/적턴/승리)은 이 기능에서 **변경하지 않는다**. 따라서 sim/테스트 변경 없음(회귀만 확인).
## 파일 구조
| 파일 | 책임 | 변경 |
|------|------|------|
| `data/monster-slots.json` | 그룹별 HP바 슬롯 좌표 | 평면 배열 → `{combat,elite,boss}` 객체 |
| `tools/monster/gen-combat-monster.mjs` | CombatMonster 코드블록 + 맵 부착 | Group 프로퍼티·등록 인자 추가, 부착을 **값 보존(no-clobber)**로 변경 |
| `RootDesk/MyDesk/CombatMonster.codeblock` | 생성물 | Group 프로퍼티·register(group) |
| `tools/deck/gen-slaydeck.mjs` | 컨트롤러 생성 | SLOTS 객체 처리·StartRun 그룹별 주입·`ActiveSlotPos`·`PositionMonsterSlot`·`RegisterMonster(group)`·`BuildMonsters` 필터 |
| 생성물 | `SlayDeckController.codeblock` 재생성 | (ui 변경 없음) |
| `map/map01.map` 외 | 몬스터 그룹 태그 | 메이커 저작(코드 외) |
---
## Task 1: monster-slots.json 그룹별 구조
**Files:** Modify `data/monster-slots.json`
- [ ] **Step 1: 그룹별 좌표 객체로 교체**
`data/monster-slots.json` 전체를 아래로 교체(각 그룹 4좌표; 추후 플레이테스트로 튜닝):
```json
{
"combat": [
{ "x": 430, "y": 140 },
{ "x": 600, "y": 140 },
{ "x": 770, "y": 140 },
{ "x": 900, "y": 140 }
],
"elite": [
{ "x": 430, "y": 160 },
{ "x": 650, "y": 160 },
{ "x": 850, "y": 160 },
{ "x": 980, "y": 160 }
],
"boss": [
{ "x": 520, "y": 200 },
{ "x": 760, "y": 160 },
{ "x": 940, "y": 150 },
{ "x": 1040, "y": 150 }
]
}
```
- [ ] **Step 2: JSON 유효성 확인**
Run: `node -e "const s=JSON.parse(require('fs').readFileSync('data/monster-slots.json','utf8'));console.log(['combat','elite','boss'].map(g=>g+':'+s[g].length).join(' '))"`
Expected: `combat:4 elite:4 boss:4`
- [ ] **Step 3: Commit**
```bash
git add data/monster-slots.json
git commit -m "feat(node-groups): monster-slots.json 을 그룹별 좌표 구조로"
```
---
## Task 2: CombatMonster 에 Group + 생성기 no-clobber
**Files:** Modify `tools/monster/gen-combat-monster.mjs` (생성물 `RootDesk/MyDesk/CombatMonster.codeblock`, `map/*.map`)
- [ ] **Step 1: 코드블록에 Group 프로퍼티 추가**
`tools/monster/gen-combat-monster.mjs``Properties` 줄을 교체:
```js
Properties: [prop('string', 'EnemyId', '""'), prop('string', 'Group', '"combat"'), prop('number', 'RegTries', '0')],
```
- [ ] **Step 2: OnBeginPlay 등록 호출에 Group 전달**
`OnBeginPlay` Lua의 등록 줄을 교체:
```
c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group)
```
(나머지 OnBeginPlay 본문은 그대로)
- [ ] **Step 3: patchMap 을 값 보존(no-clobber)로 교체**
`patchMap` 함수 전체를 아래로 교체. 기존 `script.CombatMonster`가 있으면 사용자가 인스펙터에서 설정한 `EnemyId`/`Group`을 보존하고, 없을 때만 기본값으로 부착한다:
```js
function patchMap(nn) {
const tag = String(nn).padStart(2, '0');
const file = `map/map${tag}.map`;
const map = JSON.parse(readFileSync(file, 'utf8'));
let added = 0, kept = 0;
for (const e of map.ContentProto.Entities.filter(isMonster)) {
const comps = e.jsonString && e.jsonString['@components'];
if (!Array.isArray(comps)) {
console.warn(`[gen-combat-monster] entity "${(e.jsonString && e.jsonString.name) || e.path}" has no @components — skipped`);
continue;
}
const name = (e.jsonString && e.jsonString.name) || '';
const existing = comps.find((c) => c['@type'] === 'script.CombatMonster');
if (existing) {
// 사용자가 메이커에서 설정한 값 보존 — 누락된 키만 기본값 채움
if (existing.Enable === undefined) existing.Enable = true;
if (existing.EnemyId === undefined) existing.EnemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY;
if (existing.Group === undefined) existing.Group = 'combat';
kept++;
} else {
comps.push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: NAME_TO_ENEMY[name] || DEFAULT_ENEMY, Group: 'combat' });
added++;
}
const names = (e.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster');
names.push('script.CombatMonster');
e.componentNames = names.join(',');
}
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}(+${added}/keep${kept})`;
}
```
- [ ] **Step 4: 실행 + 값 보존 검증**
Run: `node tools/monster/gen-combat-monster.mjs`
Expected: `... patched maps: map01(+0/keep3), map02(+0/keep2), ...` (기존 몬스터는 이미 CombatMonster 보유 → keep, EnemyId 보존 + Group 기본값 주입).
Run: `node -e "const m=JSON.parse(require('fs').readFileSync('map/map01.map','utf8'));const ms=m.ContentProto.Entities.filter(e=>(e.componentNames||'').includes('script.CombatMonster'));console.log(ms.map(e=>{const c=e.jsonString['@components'].find(x=>x['@type']==='script.CombatMonster');return e.jsonString.name+':'+c.EnemyId+'/'+c.Group;}).join(', '))"`
Expected: 각 몬스터 `이름:EnemyId/combat` (기존 EnemyId 보존, Group=combat 주입).
- [ ] **Step 5: 멱등 + 코드블록 Group 확인**
Run: `node tools/monster/gen-combat-monster.mjs` (2회차) — map 재실행에도 값 동일(no-clobber).
Run: `node -e "const c=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/CombatMonster.codeblock','utf8'));const p=c.ContentProto.Json.Properties.map(x=>x.Name);console.log('props:',p.join(','),'| register has Group:',/RegisterMonster\(self.Entity, self.EnemyId, self.Group\)/.test(c.ContentProto.Json.Methods[0].Code))"`
Expected: `props: EnemyId,Group,RegTries | register has Group: true`
- [ ] **Step 6: Commit**
```bash
git add tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/
git commit -m "feat(node-groups): CombatMonster 에 Group + 생성기 값 보존(no-clobber)"
```
---
## Task 3: gen-slaydeck — SLOTS 객체 플러밍
**Files:** Modify `tools/deck/gen-slaydeck.mjs`. **생성기는 Task 5까지 실행 금지**, `node --check`만.
- [ ] **Step 1: upsertUi 슬롯 단언 교체**
`upsertUi()` 시작부의 단언을 SLOTS 객체용으로 교체:
```js
for (const g of ['combat', 'elite', 'boss']) {
if (!Array.isArray(SLOTS[g]) || SLOTS[g].length < 1) {
throw new Error(`[gen-slaydeck] monster-slots.json 의 "${g}" 그룹 좌표가 없습니다`);
}
}
```
(기존 `if (SLOTS.length < MAX_MONSTERS) { throw ... }` 블록을 위 코드로 대체)
- [ ] **Step 2: StartRun 의 SlotPos 주입을 그룹별로 교체**
`StartRun` Lua의 다음 줄
```
self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} }
```
을 아래 헬퍼 기반 그룹별 주입으로 교체. 파일 상단(다른 헬퍼 함수 근처)에 헬퍼 추가:
```js
function luaSlotGroup(arr) {
return '{ ' + arr.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ') + ' }';
}
```
그리고 StartRun 주입 줄을:
```js
self.SlotPos = { combat = ${luaSlotGroup(SLOTS.combat)}, elite = ${luaSlotGroup(SLOTS.elite)}, boss = ${luaSlotGroup(SLOTS.boss)} }
```
- [ ] **Step 3: ActiveSlotPos 프로퍼티 추가**
prop 목록에서 `prop('any', 'SlotPos'),` 다음 줄에 추가:
```js
prop('any', 'ActiveSlotPos'),
```
- [ ] **Step 4: PositionMonsterSlot 이 ActiveSlotPos 를 쓰도록 교체**
`PositionMonsterSlot` 메서드 본문 첫 줄 `local sp = self.SlotPos` 를 교체:
```
local sp = self.ActiveSlotPos
```
(나머지 본문 동일 — `sp[slot]` 사용)
- [ ] **Step 5: 구문 점검**
Run: `node --check tools/deck/gen-slaydeck.mjs` → 출력 없음(유효).
Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');console.log('luaSlotGroup:',s.includes('function luaSlotGroup'),'| ActiveSlotPos prop:',s.includes(\"'ActiveSlotPos'\"),'| SlotPos combat=:',s.includes('combat = '))"`
Expected: 모두 true.
- [ ] **Step 6: Commit**
```bash
git add tools/deck/gen-slaydeck.mjs
git commit -m "feat(node-groups): 그룹별 슬롯 좌표 플러밍 (SlotPos/ActiveSlotPos)"
```
---
## Task 4: gen-slaydeck — RegisterMonster(group) + BuildMonsters 필터
**Files:** Modify `tools/deck/gen-slaydeck.mjs`. 생성기 실행 금지, `node --check`만.
- [ ] **Step 1: RegisterMonster 에 group 인자 추가**
기존
```js
method('RegisterMonster', `if self.Registered == nil then
self.Registered = {}
end
table.insert(self.Registered, { entity = monster, enemyId = enemyId })`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' },
]),
```
를 아래로 교체:
```js
method('RegisterMonster', `if self.Registered == nil then
self.Registered = {}
end
local g = group
if g == nil or g == "" then g = "combat" end
table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g })`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'group' },
]),
```
- [ ] **Step 2: BuildMonsters 를 노드 타입 필터로 교체**
`BuildMonsters` 메서드 전체를 아래로 교체(현재 노드 타입으로 그룹 결정, 전체 숨김 후 일치 그룹만 표시, 그룹 슬롯 좌표 사용). `${MAX_MONSTERS}`는 JS 상수 보간:
```js
method('BuildMonsters', `self.Monsters = {}
local g = "combat"
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil and node.type ~= nil then g = node.type end
self.ActiveSlotPos = self.SlotPos[g]
local reg = self.Registered or {}
-- 모든 등록 몬스터 숨김
for i = 1, #reg do
if reg[i].entity ~= nil and isvalid(reg[i].entity) then
reg[i].entity:SetVisible(false)
end
end
-- 현재 그룹만 추려 월드 x 정렬
local list = {}
for i = 1, #reg do
local r = reg[i]
if r.entity ~= nil and isvalid(r.entity) and r.group == g then
local x = 0
if r.entity.TransformComponent ~= nil then
x = r.entity.TransformComponent.WorldPosition.x
end
table.insert(list, { entity = r.entity, enemyId = r.enemyId, x = x })
end
end
table.sort(list, function(a, b) return a.x < b.x end)
local mult = 1 + (self.Floor - 1) * 0.6
local n = #list
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
for i = 1, n do
local item = list[i]
local e = self.Enemies[item.enemyId]
if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end
local intents = {}
for k = 1, #e.intents do
intents[k] = { kind = e.intents[k].kind, value = math.floor(e.intents[k].value * mult) }
end
local maxHp = math.floor(e.maxHp * mult)
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
hp = maxHp, maxHp = maxHp, block = 0, intents = intents, intentIdx = 1, alive = true, slot = i }
self:ReviveMonsterEntity(item.entity)
self:PositionMonsterSlot(i)
end
self.TargetIndex = 1`),
```
- [ ] **Step 3: 구문 점검 + 필터 반영 확인**
Run: `node --check tools/deck/gen-slaydeck.mjs` → 유효.
Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');const i=s.indexOf(\"'BuildMonsters'\");const seg=s.slice(i,i+1200);console.log('node.type group:',seg.includes('node.type'),'| group filter:',seg.includes('r.group == g'),'| hide all:',seg.includes('SetVisible(false)'),'| ActiveSlotPos:',seg.includes('self.ActiveSlotPos = self.SlotPos[g]'));console.log('RegisterMonster 3 args:',/RegisterMonster[\\s\\S]{0,400}Name: 'group'/.test(s));"`
Expected: 모두 true.
- [ ] **Step 4: Commit**
```bash
git add tools/deck/gen-slaydeck.mjs
git commit -m "feat(node-groups): RegisterMonster(group) + BuildMonsters 노드 타입 필터"
```
---
## Task 5: 재생성 · 검증 · 플레이테스트
**Files:** 산출 `SlayDeckController.codeblock`
- [ ] **Step 1: 생성기 재실행**
Run:
```bash
node tools/monster/gen-combat-monster.mjs
node tools/deck/gen-slaydeck.mjs
```
Expected: 둘 다 에러 없이 완료.
- [ ] **Step 2: 산출물 검증**
Run: `node -e "const fs=require('fs');for(const f of ['ui/DefaultGroup.ui','Global/common.gamelogic','RootDesk/MyDesk/SlayDeckController.codeblock'])JSON.parse(fs.readFileSync(f,'utf8'));const c=JSON.parse(fs.readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const sc=JSON.stringify(c);console.log('SlotPos combat/elite/boss:',sc.includes('combat = {')&&sc.includes('elite = {')&&sc.includes('boss = {'),'| group filter:',sc.includes('r.group == g'));"`
Expected: `SlotPos combat/elite/boss: true | group filter: true`
- [ ] **Step 3: 결정성**
Run: `git add -A && node tools/deck/gen-slaydeck.mjs && git diff --stat RootDesk/MyDesk/SlayDeckController.codeblock ui/DefaultGroup.ui Global/common.gamelogic`
Expected: 비어 있음(결정적).
- [ ] **Step 4: sim 회귀**
Run: `node --test tools/balance/sim-balance.test.mjs`
Expected: 전체 통과(규칙 불변).
- [ ] **Step 5: Commit (산출물)**
```bash
git add RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "feat(node-groups): 컨트롤러 재생성 (그룹 필터·그룹 슬롯)"
```
- [ ] **Step 6: 메이커 플레이테스트 (수동/MCP)**
먼저 map01에 그룹을 저작(메이커): 일반/엘리트/보스 몬스터를 배치하고 각 `CombatMonster`의 Group(combat/elite/boss)·EnemyId 지정. (또는 검증 목적이면 기존 3마리에 Group을 각각 combat/elite/boss로 임시 지정.)
그 후 reload→Play 확인:
1. combat 노드 진입 → Group=combat 몬스터만 표시, 나머지 숨김.
2. elite 노드 → 엘리트(+졸개) 그룹만.
3. boss 노드 → 보스(+졸개) 그룹만.
4. 각 그룹 슬롯(HP바·의도)이 해당 몬스터 위에 표시(좌표 안 맞으면 `data/monster-slots.json` 그룹 좌표 튜닝 → 재생성 → reload).
5. 전투/타겟/승리 흐름 정상.
> MCP 검증 보조: `execute_script`로 `RegisterMonster` 후 `self.Registered[i].group` 확인, `CurrentNodeId`를 각 타입 노드로 두고 `BuildMonsters()` 호출 → `self.Monsters` 가 해당 그룹만 담는지 로그.
---
## Self-Review 결과 (작성자 점검)
- **스펙 커버리지**: Group 태그(Task2), 그룹별 슬롯 좌표(Task1·3), no-clobber 저작(Task2), RegisterMonster(group)(Task2·4), BuildMonsters 노드 타입 필터·전체 숨김·그룹 슬롯(Task4), 재생성·검증(Task5) — 전부 매핑됨. sim 불변(Task5 회귀).
- **플레이스홀더**: 슬롯 좌표는 초기값+튜닝 명시. 코드 단계는 실제 코드 포함. 메이커 그룹 저작은 코드 외 수동 단계로 명시.
- **타입/이름 일관성**: `Group`(string), `RegisterMonster(monster, enemyId, group)`, `Registered{entity,enemyId,group}`, `ActiveSlotPos`, `SlotPos.{combat,elite,boss}`, `BuildMonsters``g = node.type` — Task 간 일치. `MAX_MONSTERS` 보간 유지.
- **리스크**: 그룹 좌표 수 < 몬스터 수면 초과 슬롯 미배치(기본 4좌표로 완화). 비combat/rest 노드만 StartCombat 호출되므로 g∈{combat,elite,boss}. MSW 월드 API는 기존과 동일(검증됨).

View File

@@ -0,0 +1,103 @@
# 노드 타입별 몬스터 그룹 — 설계
- 날짜: 2026-06-10
- 대상: `RootDesk/MyDesk/CombatMonster.codeblock`(+`tools/monster/gen-combat-monster.mjs`), `tools/deck/gen-slaydeck.mjs`(SlayDeckController), `data/monster-slots.json`, map 몬스터 엔티티(메이커 저작)
- 상태: 승인됨 (브레인스토밍 → 본 스펙)
## 1. 배경
맵 몬스터 카드 전투가 구현돼 있다. 현재 `BuildMonsters`는 맵에 등록된 `script.CombatMonster` 몬스터를 **노드 타입과 무관하게 전부** 전투에 투입한다. 그래서 일반/엘리트/보스 노드를 어디로 가든 같은 몬스터와 싸운다.
흐름: `PickNode(id)``self.CurrentNodeId = id`, `self.CurrentEnemyId = node.enemy`, `StartCombat()``BuildMonsters()`. 노드 타입은 `self.MapNodes[self.CurrentNodeId].type`(`combat`/`elite`/`boss`/`shop`/`rest`)로 접근 가능.
## 2. 목표
한 맵 안에서 **노드 타입에 따라 다른 몬스터 그룹**이 등장하도록 한다.
- 일반(`combat`) 노드 → 일반 몬스터 그룹
- 엘리트(`elite`) 노드 → 엘리트(+졸개) 그룹
- 보스(`boss`) 노드 → 보스(+졸개) 그룹
## 3. 확정 요구사항 (브레인스토밍 결과)
1. **구성**: 노드 타입별 그룹. 엘리트/보스 그룹은 졸개(일반 몬스터)를 포함할 수 있다. 그룹당 몬스터 수는 `MAX_MONSTERS`(4) 이하.
2. **선택 단위**: 노드 **타입**(모든 `combat` 노드는 동일한 일반 그룹, `elite`는 엘리트 그룹…). 노드별 개별 구성은 후속.
3. **배치**: 세 그룹을 맵 내 **서로 다른 위치**에 배치. HP바 슬롯 좌표는 **그룹별**로 둔다.
4. **메커니즘**: 각 몬스터에 `Group` 태그 + `BuildMonsters`에서 현재 노드 타입으로 필터. (MSW Layer는 렌더 z-순서용이라 부적합 — 사용 안 함)
5. **저작**: 각 몬스터의 `Group`/`EnemyId`는 **메이커 인스펙터**에서 직접 설정. 생성기는 컴포넌트 존재만 보장하고 사용자 설정 값을 덮어쓰지 않는다.
## 4. 데이터·컴포넌트 변경
### 4.1 `script.CombatMonster` (codeblock)
- 속성 추가: `Group`(string, 기본 `"combat"`). 기존 `EnemyId`(string)·`RegTries`(number) 유지.
- `OnBeginPlay`: 등록 호출에 Group 추가 → `c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group)`.
### 4.2 `tools/monster/gen-combat-monster.mjs` (클로버 금지로 변경)
- `script.Monster` 엔티티에 `script.CombatMonster`**없을 때만** 부착(기본값 `Group="combat"`, `EnemyId=DEFAULT_ENEMY`).
- 이미 `script.CombatMonster`가 있으면 **그 인스턴스의 Group/EnemyId 값을 보존**(필터-후-재삽입으로 값을 날리지 않음). `Enable`·componentNames만 정합 유지.
- 결과: 신규 배치 몬스터엔 컴포넌트가 자동 생기고, 메이커에서 설정한 값은 유지된다.
### 4.3 `data/monster-slots.json` (그룹별 좌표)
평면 배열 → 그룹 키 객체:
```json
{
"combat": [ { "x": 430, "y": 140 }, ... ],
"elite": [ ... ],
"boss": [ ... ]
}
```
각 배열 길이는 해당 그룹의 몬스터 수(≤ MAX_MONSTERS)를 커버. 좌표는 플레이테스트로 튜닝.
### 4.4 런타임 상태 (SlayDeckController)
- `Registered` 원소: `{ entity, enemyId, group }` (group 추가).
- `SlotPos`: 그룹별 좌표 테이블(`SlotPos.combat`/`.elite`/`.boss`)로 주입(StartRun).
## 5. 런타임 흐름
- **RegisterMonster(monster, enemyId, group)**: `Registered``{entity, enemyId, group}` append.
- **BuildMonsters**:
1. `local g = self.MapNodes[self.CurrentNodeId].type` (combat/elite/boss)
2. 등록된 모든 몬스터 엔티티 **숨김**(`SetVisible(false)`).
3. `r.group == g`인 항목만 추려 월드 x 정렬 → 최대 `MAX_MONSTERS`.
4. 각 몬스터: `enemies.json[enemyId]` 스탯 + 막 배율(`1+(Floor-1)*0.6`)로 `Monsters[i]` 구성, `ReviveMonsterEntity`(표시), `PositionMonsterSlot(i)`.
5. 슬롯 좌표는 `SlotPos[g]` 사용.
6. `TargetIndex = 1`.
- **PositionMonsterSlot(slot)**: 현재 그룹 좌표(`self.ActiveSlotPos` 또는 `SlotPos[g]`)에서 위치 설정. (BuildMonsters가 현재 그룹 좌표를 임시 보관)
- 나머지(SetTarget·PlayCard·DealDamageToTarget·KillMonster·EnemyTurn·CheckCombatEnd·RenderCombat)는 **변경 없음**.
> 구현 메모: `PositionMonsterSlot`이 그룹 좌표를 알 수 있도록, BuildMonsters에서 `self.ActiveSlotPos = self.SlotPos[g]`를 설정하고 PositionMonsterSlot은 `self.ActiveSlotPos[slot]`을 참조한다.
## 6. 저작 워크플로 (메이커)
1. map01에 일반/엘리트/보스 몬스터를 서로 다른 위치에 배치(엘리트/보스 그룹은 졸개 포함 가능).
2. 각 몬스터의 `CombatMonster`에서 `Group`(combat/elite/boss)·`EnemyId`(enemies.json id) 지정.
3. `data/monster-slots.json`에 그룹별 슬롯 좌표 입력.
4. `node tools/monster/gen-combat-monster.mjs``node tools/deck/gen-slaydeck.mjs` → 메이커 reload.
## 7. 변경 파일 요약
| 파일 | 변경 |
|------|------|
| `tools/monster/gen-combat-monster.mjs` | CombatMonster에 `Group` 프로퍼티 추가, 부착을 **없을 때만**(값 보존) 으로 변경, OnBeginPlay 등록에 Group 전달 |
| `RootDesk/MyDesk/CombatMonster.codeblock` | 생성물(Group 프로퍼티·등록 인자) |
| `data/monster-slots.json` | 그룹별 좌표 구조로 변경 |
| `tools/deck/gen-slaydeck.mjs` | `RegisterMonster`(group 인자)·`BuildMonsters`(노드 타입 필터·전체 숨김·그룹 슬롯)·`SlotPos` 주입·`PositionMonsterSlot`(활성 그룹 좌표) |
| 생성물 | `SlayDeckController.codeblock` 재생성. `ui/DefaultGroup.ui`**변경 없음**(기존 4개 슬롯을 그룹 간 재사용, 좌표만 런타임 변경) |
| `map/map01.map` | 그룹별 몬스터 배치·CombatMonster 태그(메이커 저작) |
## 8. 알려진 한계
- 노드 타입 단위 구성(노드별 개별 인카운터 아님).
- 모든 그룹 합산이 많아도 UI 슬롯은 `MAX_MONSTERS`(4) 동시 표시 한도. 그룹당 ≤4.
- 전투 외(맵 UI 오버레이) 구간엔 필드에 세 그룹이 모두 보일 수 있음(허용). 원하면 StartRun에서 전체 숨김(후속).
## 9. 리스크
- `gen-combat-monster`의 "값 보존" 로직: 기존 인스턴스 값을 정확히 유지하면서 componentNames 정합을 깨지 않도록 주의.
- 그룹 슬롯 좌표 미설정 시 기본 폴백 필요(좌표 없으면 슬롯 위치 미변경).
- 보스 노드 클리어 시 막 진행 로직(`CheckCombatEnd`)은 기존 그대로 동작해야 함(그룹 변경과 독립).
## 10. 검증
- 생성기 2회 실행 결과 동일(결정적), JSON 유효·중복 id 없음.
- 메이커 플레이테스트: combat 노드 → 일반 그룹만, elite 노드 → 엘리트(+졸개)만, boss 노드 → 보스(+졸개)만 등장. 비활성 그룹은 숨김. 각 그룹 슬롯이 해당 몬스터 위에 표시.
- sim 테스트는 기존대로 통과(규칙 불변).

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6651,7 +6652,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6792,7 +6794,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -6510,7 +6510,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
@@ -6663,7 +6664,8 @@
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom"
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1

View File

@@ -73,6 +73,9 @@ function luaCardsTable(cards) {
function luaDeckTable(deck) {
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
}
function luaSlotGroup(arr) {
return '{ ' + arr.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ') + ' }';
}
const UI_FILE = 'ui/DefaultGroup.ui';
const COMMON_FILE = 'Global/common.gamelogic';
@@ -308,8 +311,10 @@ function entity({ id, path, modelId, entryId, componentNames, components, displa
}
function upsertUi() {
if (SLOTS.length < MAX_MONSTERS) {
throw new Error(`[gen-slaydeck] monster-slots.json 항목(${SLOTS.length}) < MAX_MONSTERS(${MAX_MONSTERS})`);
for (const g of ['combat', 'elite', 'boss']) {
if (!Array.isArray(SLOTS[g]) || SLOTS[g].length < MAX_MONSTERS) {
throw new Error(`[gen-slaydeck] monster-slots.json "${g}" 그룹 좌표(${SLOTS[g] ? SLOTS[g].length : 0}) < MAX_MONSTERS(${MAX_MONSTERS})`);
}
}
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
const E = ui.ContentProto.Entities;
@@ -1560,6 +1565,7 @@ function writeCodeblocks() {
prop('any', 'Registered'),
prop('number', 'TargetIndex', '1'),
prop('any', 'SlotPos'),
prop('any', 'ActiveSlotPos'),
prop('any', 'RunDeck'),
prop('number', 'Gold', '0'),
prop('number', 'Floor', '0'),
@@ -1671,7 +1677,7 @@ self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
${luaEnemiesTable(ENEMIES.enemies)}
${luaMapNodesTable(MAP.nodes)}
${luaStartArray(MAP.start)}
self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} }
self.SlotPos = { combat = ${luaSlotGroup(SLOTS.combat)}, elite = ${luaSlotGroup(SLOTS.elite)}, boss = ${luaSlotGroup(SLOTS.boss)} }
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:BindButtons()
@@ -1701,16 +1707,28 @@ self:RenderCombat()`),
method('RegisterMonster', `if self.Registered == nil then
self.Registered = {}
end
table.insert(self.Registered, { entity = monster, enemyId = enemyId })`, [
local g = group
if g == nil or g == "" then g = "combat" end
table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g })`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'group' },
]),
method('BuildMonsters', `self.Monsters = {}
local g = "combat"
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil and node.type ~= nil then g = node.type end
self.ActiveSlotPos = self.SlotPos[g]
local reg = self.Registered or {}
for i = 1, #reg do
if reg[i].entity ~= nil and isvalid(reg[i].entity) then
reg[i].entity:SetVisible(false)
end
end
local list = {}
for i = 1, #reg do
local r = reg[i]
if r.entity ~= nil and isvalid(r.entity) then
if r.entity ~= nil and isvalid(r.entity) and r.group == g then
local x = 0
if r.entity.TransformComponent ~= nil then
x = r.entity.TransformComponent.WorldPosition.x
@@ -2267,7 +2285,7 @@ e.UITransformComponent.RectSize = Vector2(w, 14)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
]),
method('PositionMonsterSlot', `local sp = self.SlotPos
method('PositionMonsterSlot', `local sp = self.ActiveSlotPos
if sp == nil or sp[slot] == nil then
return
end

View File

@@ -23,7 +23,7 @@ function writeCodeblock() {
ContentProto: { Use: 'Json', Json: {
CoreVersion: { Major: 0, Minor: 2 }, ScriptVersion: { Major: 1, Minor: 0 },
Description: '', Id: 'CombatMonster', Language: 1, Name: 'CombatMonster', Type: 1, Source: 0, Target: null,
Properties: [prop('string', 'EnemyId', '""'), prop('number', 'RegTries', '0')],
Properties: [prop('string', 'EnemyId', '""'), prop('string', 'Group', '"combat"'), prop('number', 'RegTries', '0')],
Methods: [
method('OnBeginPlay', `self.RegTries = 0
local eventId = 0
@@ -31,7 +31,7 @@ local function reg()
self.RegTries = self.RegTries + 1
local c = _EntityService:GetEntityByPath("/common")
if c ~= nil and c.SlayDeckController ~= nil then
c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId)
c.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group)
_TimerService:ClearTimer(eventId)
elseif self.RegTries > 50 then
_TimerService:ClearTimer(eventId)
@@ -51,7 +51,7 @@ function patchMap(nn) {
const tag = String(nn).padStart(2, '0');
const file = `map/map${tag}.map`;
const map = JSON.parse(readFileSync(file, 'utf8'));
let count = 0;
let added = 0, kept = 0;
for (const e of map.ContentProto.Entities.filter(isMonster)) {
const comps = e.jsonString && e.jsonString['@components'];
if (!Array.isArray(comps)) {
@@ -59,16 +59,23 @@ function patchMap(nn) {
continue;
}
const name = (e.jsonString && e.jsonString.name) || '';
const enemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY;
e.jsonString['@components'] = comps.filter((c) => c['@type'] !== 'script.CombatMonster');
e.jsonString['@components'].push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: enemyId });
const existing = comps.find((c) => c['@type'] === 'script.CombatMonster');
if (existing) {
// 사용자가 메이커에서 설정한 값 보존 — 누락된 키만 기본값 채움
if (existing.Enable === undefined) existing.Enable = true;
if (existing.EnemyId == null) existing.EnemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY;
if (existing.Group == null) existing.Group = 'combat';
kept++;
} else {
comps.push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: NAME_TO_ENEMY[name] || DEFAULT_ENEMY, Group: 'combat' });
added++;
}
const names = (e.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster');
names.push('script.CombatMonster');
e.componentNames = names.join(',');
count++;
}
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}(${count})`;
return `map${tag}(+${added}/keep${kept})`;
}
writeCodeblock();