From 0d517617a3d76322af636f6c22fd5e76ad1103d2 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:29:04 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs(node-monster-groups):=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=ED=83=80=EC=9E=85=EB=B3=84=20=EB=AA=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EA=B7=B8=EB=A3=B9=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 맵에 일반/엘리트/보스 그룹 배치 → 노드 타입으로 필터해 해당 그룹만 등장. CombatMonster에 Group 태그(메이커 인스펙터 저작) + BuildMonsters 필터 + 그룹별 슬롯 좌표. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-10-node-type-monster-groups-design.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-node-type-monster-groups-design.md diff --git a/docs/superpowers/specs/2026-06-10-node-type-monster-groups-design.md b/docs/superpowers/specs/2026-06-10-node-type-monster-groups-design.md new file mode 100644 index 0000000..7d552a1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-node-type-monster-groups-design.md @@ -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 테스트는 기존대로 통과(규칙 불변). -- 2.49.1 From 59c699c04bca6900e98a4234ddc7ae075f1afd87 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:32:46 +0900 Subject: [PATCH 2/9] =?UTF-8?q?docs(node-monster-groups):=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D=20(5=EA=B0=9C=20=ED=83=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slots 그룹화 → CombatMonster Group+no-clobber → 슬롯 플러밍 → RegisterMonster(group)+BuildMonsters 필터 → 재생성·플레이테스트. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-10-node-type-monster-groups.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-node-type-monster-groups.md diff --git a/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md b/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md new file mode 100644 index 0000000..2588c72 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-node-type-monster-groups.md @@ -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는 기존과 동일(검증됨). -- 2.49.1 From 271a7991d156339d658ab8b6439d50c4f3ed2558 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:36:42 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(node-groups):=20monster-slots.json=20?= =?UTF-8?q?=EC=9D=84=20=EA=B7=B8=EB=A3=B9=EB=B3=84=20=EC=A2=8C=ED=91=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/monster-slots.json | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/data/monster-slots.json b/data/monster-slots.json index 8bbc468..378dd10 100644 --- a/data/monster-slots.json +++ b/data/monster-slots.json @@ -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 } + ] +} -- 2.49.1 From 428bdc8a2e8df4f1cc1ed12aec0508a093ffbb12 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:38:39 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(node-groups):=20CombatMonster=20?= =?UTF-8?q?=EC=97=90=20Group=20+=20=EC=83=9D=EC=84=B1=EA=B8=B0=20=EA=B0=92?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B4(no-clobber)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RootDesk/MyDesk/CombatMonster.codeblock | 9 ++++++++- map/map01.map | 9 ++++++--- map/map02.map | 6 ++++-- map/map03.map | 6 ++++-- map/map04.map | 6 ++++-- map/map05.map | 6 ++++-- map/map06.map | 6 ++++-- map/map07.map | 6 ++++-- map/map08.map | 6 ++++-- map/map09.map | 6 ++++-- map/map10.map | 6 ++++-- map/map11.map | 6 ++++-- tools/monster/gen-combat-monster.mjs | 23 +++++++++++++++-------- 13 files changed, 69 insertions(+), 32 deletions(-) diff --git a/RootDesk/MyDesk/CombatMonster.codeblock b/RootDesk/MyDesk/CombatMonster.codeblock index 1989060..de3f3d8 100644 --- a/RootDesk/MyDesk/CombatMonster.codeblock +++ b/RootDesk/MyDesk/CombatMonster.codeblock @@ -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": [], diff --git a/map/map01.map b/map/map01.map index 9b33943..a9a8ef4 100644 --- a/map/map01.map +++ b/map/map01.map @@ -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 diff --git a/map/map02.map b/map/map02.map index 4c953a5..52cf8b2 100644 --- a/map/map02.map +++ b/map/map02.map @@ -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 diff --git a/map/map03.map b/map/map03.map index 19f7458..621cd3f 100644 --- a/map/map03.map +++ b/map/map03.map @@ -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 diff --git a/map/map04.map b/map/map04.map index fb6147b..17062a5 100644 --- a/map/map04.map +++ b/map/map04.map @@ -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 diff --git a/map/map05.map b/map/map05.map index 861f25f..25edc3e 100644 --- a/map/map05.map +++ b/map/map05.map @@ -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 diff --git a/map/map06.map b/map/map06.map index fb9adf2..e90ee5c 100644 --- a/map/map06.map +++ b/map/map06.map @@ -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 diff --git a/map/map07.map b/map/map07.map index ff78649..58a8083 100644 --- a/map/map07.map +++ b/map/map07.map @@ -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 diff --git a/map/map08.map b/map/map08.map index 50e9fe2..9f05b4b 100644 --- a/map/map08.map +++ b/map/map08.map @@ -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 diff --git a/map/map09.map b/map/map09.map index bfbbe7c..de82c84 100644 --- a/map/map09.map +++ b/map/map09.map @@ -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 diff --git a/map/map10.map b/map/map10.map index a396993..71dd819 100644 --- a/map/map10.map +++ b/map/map10.map @@ -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 diff --git a/map/map11.map b/map/map11.map index 6753e25..cf593b5 100644 --- a/map/map11.map +++ b/map/map11.map @@ -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 diff --git a/tools/monster/gen-combat-monster.mjs b/tools/monster/gen-combat-monster.mjs index 16b33dc..4270164 100644 --- a/tools/monster/gen-combat-monster.mjs +++ b/tools/monster/gen-combat-monster.mjs @@ -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 === 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(','); - count++; } writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); - return `map${tag}(${count})`; + return `map${tag}(+${added}/keep${kept})`; } writeCodeblock(); -- 2.49.1 From 6feb2526742a06385ed7e6d7f0936312bf988735 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:43:14 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(node-groups):=20no-clobber=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=3D=3D=20null=20=EB=A1=9C=20(null=20?= =?UTF-8?q?=EA=B0=92=EB=8F=84=20=EB=B3=B4=EC=A1=B4=20=EC=B2=98=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/monster/gen-combat-monster.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/monster/gen-combat-monster.mjs b/tools/monster/gen-combat-monster.mjs index 4270164..b5afece 100644 --- a/tools/monster/gen-combat-monster.mjs +++ b/tools/monster/gen-combat-monster.mjs @@ -63,8 +63,8 @@ function patchMap(nn) { 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'; + 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' }); -- 2.49.1 From c614b105661a92fac5872bf95d9c910a5b3fbab0 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:44:59 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(node-groups):=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EB=B3=84=20=EC=8A=AC=EB=A1=AF=20=EC=A2=8C=ED=91=9C=20=ED=94=8C?= =?UTF-8?q?=EB=9F=AC=EB=B0=8D=20(SlotPos/ActiveSlotPos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tools/deck/gen-slaydeck.mjs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/deck/gen-slaydeck.mjs b/tools/deck/gen-slaydeck.mjs index 22e1994..77e92d6 100644 --- a/tools/deck/gen-slaydeck.mjs +++ b/tools/deck/gen-slaydeck.mjs @@ -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 < 1) { + throw new Error(`[gen-slaydeck] monster-slots.json 의 "${g}" 그룹 좌표가 없습니다`); + } } 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() @@ -2267,7 +2273,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 -- 2.49.1 From 903a06d2335e3a420e438ede881c63662105ff25 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:47:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat(node-groups):=20RegisterMonster(group)?= =?UTF-8?q?=20+=20BuildMonsters=20=EB=85=B8=EB=93=9C=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=ED=95=84=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tools/deck/gen-slaydeck.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tools/deck/gen-slaydeck.mjs b/tools/deck/gen-slaydeck.mjs index 77e92d6..8ee0fea 100644 --- a/tools/deck/gen-slaydeck.mjs +++ b/tools/deck/gen-slaydeck.mjs @@ -1707,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 -- 2.49.1 From 6eea4f9e17b8eb3eddb1c7f0f98cb28ed019e880 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:52:10 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(node-groups):=20=EC=8A=AC=EB=A1=AF=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=20=EB=8B=A8=EC=96=B8=EC=9D=84=20MAX=5FMONSTE?= =?UTF-8?q?RS=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20(fail-fast)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/deck/gen-slaydeck.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/deck/gen-slaydeck.mjs b/tools/deck/gen-slaydeck.mjs index 8ee0fea..ca011f0 100644 --- a/tools/deck/gen-slaydeck.mjs +++ b/tools/deck/gen-slaydeck.mjs @@ -312,8 +312,8 @@ function entity({ id, path, modelId, entryId, componentNames, components, displa function upsertUi() { 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 (!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')); -- 2.49.1 From d0353fb81fd7be3374d1c0067ee0bed46f1493d3 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 21:53:38 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(node-groups):=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9E=AC=EC=83=9D=EC=84=B1=20(=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=ED=95=84=ED=84=B0=C2=B7=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EC=8A=AC=EB=A1=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- RootDesk/MyDesk/SlayDeckController.codeblock | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/RootDesk/MyDesk/SlayDeckController.codeblock b/RootDesk/MyDesk/SlayDeckController.codeblock index 6bc4f9d..39bca2e 100644 --- a/RootDesk/MyDesk/SlayDeckController.codeblock +++ b/RootDesk/MyDesk/SlayDeckController.codeblock @@ -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": [], -- 2.49.1