From 0cbcf4c70ddce25e176cf539c1b36ae6af067504 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 10 Jun 2026 00:45:40 +0900 Subject: [PATCH] =?UTF-8?q?docs(map-monster-combat):=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=20(9=EA=B0=9C=20=ED=83=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 데이터→sim(TDD)→CombatMonster 마커→컨트롤러 멀티 전투(상태/PlayCard/EnemyTurn/승리/렌더)→슬롯 UI→재생성·플레이테스트. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-10-map-monster-combat.md | 1064 +++++++++++++++++ 1 file changed, 1064 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-map-monster-combat.md diff --git a/docs/superpowers/plans/2026-06-10-map-monster-combat.md b/docs/superpowers/plans/2026-06-10-map-monster-combat.md new file mode 100644 index 0000000..71c59ed --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-map-monster-combat.md @@ -0,0 +1,1064 @@ +# 맵 몬스터 카드 전투 구현 계획 + +> **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:** 전투 상태는 `SlayDeckController`(Lua)가 단일 소유한다. 맵 몬스터는 `script.CombatMonster`(EnemyId 보유)를 달고 BeginPlay 시 컨트롤러에 자기등록한다. 전투 규칙은 `tools/balance/sim-balance.mjs`(JS)로 TDD하고, 동일 규칙을 `tools/deck/gen-slaydeck.mjs`가 생성하는 Lua로 미러링한다. HP바/의도/타겟버튼은 카메라 고정을 활용해 `data/monster-slots.json` 화면좌표로 배치한다. + +**Tech Stack:** Node.js ESM 생성기(.mjs), node:test, MSW Lua codeblock, JSON(.map/.ui/.gamelogic/data). + +--- + +## 배경 / 현재 규칙 (구현자용) + +- 생성물(`ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`)은 **`tools/deck/gen-slaydeck.mjs` 단일 소스**에서만 생성한다(직접 편집 금지). 바꿀 땐 생성기를 고치고 `node tools/deck/gen-slaydeck.mjs` 재실행. +- 모든 생성기는 **저장소 루트에서** 실행한다: `node tools/<폴더>/<파일>.mjs`. +- 현재 전투(단일 적): `SlayDeckController`가 `EnemyHp/EnemyMaxHp/EnemyBlock/EnemyName/EnemyIntents/EnemyIntentIndex`를 갖고, `PlayCard`(Attack)→`DealDamageToEnemy`→`CheckCombatEnd`(EnemyHp<=0 승리). 적 데이터는 `data/enemies.json`, `Floor` 배율 `1+(Floor-1)*0.6`. +- 밸런스 sim(`tools/balance/sim-balance.mjs`)은 이 규칙을 JS로 재현하며 `sim-balance.test.mjs`로 검증한다. **전투 규칙 변경 시 sim과 Lua를 함께 바꾼다.** + +## 파일 구조 + +| 파일 | 책임 | 변경 | +|------|------|------| +| `data/enemies.json` | 적 타입 데이터 | 맵 몬스터 타입 + `simEncounter` 추가 | +| `data/monster-slots.json` | 몬스터 UI 슬롯 화면좌표 | **신규** | +| `tools/balance/sim-balance.mjs` | 전투 규칙 JS 재현 | 멀티 몬스터화 | +| `tools/balance/sim-balance.test.mjs` | 규칙 테스트 | 멀티 몬스터 테스트로 교체 | +| `tools/monster/gen-combat-monster.mjs` | CombatMonster 코드블록 생성 + 맵 몬스터 패치 | **신규** | +| `RootDesk/MyDesk/CombatMonster.codeblock` | 몬스터 적ID 마커+자기등록 | **신규 생성물** | +| `tools/deck/gen-slaydeck.mjs` | 컨트롤러+UI 생성 | 멀티 몬스터 전투 + 몬스터 슬롯 UI | +| `map/map01.map`~`map11.map` | 맵 | 몬스터에 `script.CombatMonster` 부착(생성기 산출) | +| `ui/DefaultGroup.ui` | UI | 몬스터 슬롯 추가(생성기 산출) | +| `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic` | 생성물 | gen-slaydeck 산출 | + +--- + +## Task 1: enemies.json — 맵 몬스터 타입 + simEncounter + +**Files:** +- Modify: `data/enemies.json` + +- [ ] **Step 1: 적 타입과 simEncounter 추가** + +`enemies` 객체 안에 두 타입을 추가하고, 최상위에 `simEncounter`를 추가한다(기존 slime/elite/boss, activeEnemy는 유지): + +```json +{ + "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 } ] }, + "orange_mushroom": { "name": "주황버섯", "maxHp": 16, "intents": [ { "kind": "Attack", "value": 5 }, { "kind": "Defend", "value": 4 }, { "kind": "Attack", "value": 7 } ] }, + "blue_mushroom": { "name": "파란버섯", "maxHp": 22, "intents": [ { "kind": "Attack", "value": 8 }, { "kind": "Attack", "value": 4 } ] } + }, + "activeEnemy": "slime", + "simEncounter": ["orange_mushroom", "orange_mushroom", "blue_mushroom"] +} +``` + +- [ ] **Step 2: JSON 유효성 확인** + +Run: `node -e "JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('ok')"` +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add data/enemies.json +git commit -m "feat(combat-data): 맵 몬스터 적 타입(주황/파란버섯) + simEncounter 추가" +``` + +--- + +## Task 2: sim-balance.mjs — 멀티 몬스터 규칙 (TDD) + +전투 규칙을 멀티 몬스터로 바꾸고, 이 sim을 Lua 미러링의 정답지로 삼는다. **테스트 먼저.** + +**Files:** +- Modify: `tools/balance/sim-balance.mjs` +- Test: `tools/balance/sim-balance.test.mjs` (전면 교체) + +- [ ] **Step 1: 실패하는 테스트 작성** + +`tools/balance/sim-balance.test.mjs` 전체를 아래로 교체: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mulberry32, applyDamage, chooseAction, chooseTarget, 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(['Defend', 'Strike'], CARDS, 3); + assert.equal(idx, 1); // Strike +}); + +test('chooseAction: 공격 없으면 스킬 선택', () => { + const idx = chooseAction(['Defend'], CARDS, 3); + assert.equal(idx, 0); +}); + +test('chooseAction: 사용 가능 카드 없으면 -1', () => { + const idx = chooseAction(['Bash'], CARDS, 1); + assert.equal(idx, -1); +}); + +test('chooseTarget: 이번 타격으로 처치 가능한 최소 체력 우선', () => { + const mob = [ + { hp: 20, block: 0, alive: true }, + { hp: 5, block: 0, alive: true }, + { hp: 8, block: 0, alive: true }, + ]; + assert.equal(chooseTarget(mob, 6), mob[1]); // 5<=6 처치 가능, 최소 +}); + +test('chooseTarget: 처치 불가면 유효체력 최소 선택', () => { + const mob = [ + { hp: 20, block: 0, alive: true }, + { hp: 12, block: 5, alive: true }, + { hp: 14, block: 0, alive: true }, + ]; + assert.equal(chooseTarget(mob, 6), mob[2]); // 유효 14 < 17 < 20 +}); + +const DATA = { + cards: CARDS, + starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'], + monsters: [ + { name: '주황버섯', maxHp: 16, intents: [{ kind: 'Attack', value: 5 }, { kind: 'Defend', value: 4 }] }, + { name: '파란버섯', maxHp: 12, intents: [{ kind: 'Attack', 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('simulateCombat: 강한 다수 적이면 패배 가능', () => { + const hard = { + cards: CARDS, + starterDeck: DATA.starterDeck, + monsters: Array.from({ length: 4 }, () => ({ name: '슬라임', maxHp: 60, intents: [{ kind: 'Attack', value: 12 }] })), + }; + let losses = 0; + for (let i = 0; i < 30; i++) if (!simulateCombat(hard, mulberry32(i + 1)).win) losses++; + assert.ok(losses >= 1, `강한 적엔 패배가 나와야 함, 실제 패 ${losses}/30`); +}); + +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/balance/sim-balance.test.mjs` +Expected: FAIL — `chooseTarget` export 없음 / `chooseAction` 시그니처 불일치 / `simulateCombat` monsters 미사용 등. + +- [ ] **Step 3: sim 멀티 몬스터로 구현** + +`tools/balance/sim-balance.mjs`에서 `loadData`, `chooseAction`, `simulateCombat`, `runBatch`를 아래로 교체하고 `chooseTarget`를 추가한다. `mulberry32`/`shuffle`/`applyDamage`/`bump`/`mean`/`median`/`formatReport`의 시그니처 의존 부분만 맞춘다. + +`chooseAction` 교체(타겟 분리, 공격 우선): + +```js +// 손패에서 낼 카드 인덱스(-1=종료). 공격 우선, 없으면 스킬. +export function chooseAction(hand, cards, energy) { + 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]; + if (attacks.length) return bestBy(attacks, dmgEff).i; + if (skills.length) return bestBy(skills, blkEff).i; + return -1; +} + +// 공격 타겟 선택: 이번 타격으로 처치 가능한 최소 유효체력, 없으면 유효체력 최소. +export function chooseTarget(aliveMonsters, plannedDamage) { + const eff = (m) => m.hp + m.block; + const killable = aliveMonsters.filter((m) => eff(m) <= plannedDamage); + const pool = killable.length ? killable : aliveMonsters; + return pool.slice().sort((a, b) => eff(a) - eff(b))[0]; +} +``` + +`loadData` 교체(simEncounter → monsters 배열): + +```js +export function loadData() { + const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8')); + const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8')); + const ids = enemiesData.simEncounter || [enemiesData.activeEnemy]; + const monsters = ids.map((id) => { + const e = enemiesData.enemies[id]; + if (!e) throw new Error(`simEncounter 적 없음: ${id}`); + return { name: e.name, maxHp: e.maxHp, intents: e.intents }; + }); + return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, monsters }; +} +``` + +`simulateCombat` 교체(멀티 몬스터): + +```js +export function simulateCombat(data, rng, stats) { + const { cards, starterDeck, monsters } = data; + let drawPile = shuffle(starterDeck, rng); + let discard = []; + let hand = []; + let pHp = PLAYER_HP, pBlock = 0; + const mob = monsters.map((m) => ({ + name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, + intents: m.intents, intentIdx: 0, alive: true, + })); + 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()); + } + } + const aliveList = () => mob.filter((m) => m.alive); + + while (turns < MAX_TURNS) { + turns++; + let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); + while (true) { + const alive = aliveList(); + if (alive.length === 0) break; + const idx = chooseAction(hand, cards, energy); + if (idx < 0) break; + const id = hand[idx], c = cards[id]; + energy -= c.cost; + if (c.kind === 'Attack') { + const target = chooseTarget(alive, c.damage || 0); + const r = applyDamage(target.hp, target.block, c.damage || 0); + target.hp = r.hp; target.block = r.block; + if (target.hp <= 0) target.alive = false; + 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 (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp }; + } + discard.push(...hand); hand = []; + for (const m of mob) { + if (!m.alive) continue; + m.block = 0; + const it = m.intents[m.intentIdx]; + if (it) { + if (it.kind === 'Attack') { const r = applyDamage(pHp, pBlock, it.value); pHp = r.hp; pBlock = r.block; } + else if (it.kind === 'Defend') { m.block += it.value; } + } + m.intentIdx = (m.intentIdx + 1) % m.intents.length; + if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; + } + } + return { win: false, turns, playerHpRemaining: pHp, draw: true }; +} +``` + +`runBatch`에서 `data.enemy` 참조를 제거하고 `data.monsters` 기반으로 바꾼다. `formatReport`의 적 이름 표기는 인카운터 요약으로 교체: + +```js +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, monsters: data.monsters, seed, + }; +} +``` + +`formatReport` 첫 줄을 인카운터 요약으로 교체(나머지 카드 통계 로직은 유지): + +```js + L.push(`=== 밸런스 시뮬레이션 (인카운터: ${r.monsters.map((m) => `${m.name}(${m.maxHp})`).join(', ')}) ===`); +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `node --test tools/balance/sim-balance.test.mjs` +Expected: PASS (전체 통과, `tests N / pass N / fail 0`). + +- [ ] **Step 5: CLI 동작 확인** + +Run: `node tools/balance/sim-balance.mjs 500` +Expected: 인카운터 요약 + 승률/턴/카드 통계 출력, 에러 없음. + +- [ ] **Step 6: Commit** + +```bash +git add tools/balance/sim-balance.mjs tools/balance/sim-balance.test.mjs +git commit -m "feat(sim): 전투 규칙을 멀티 몬스터로 (타겟 선택·각자 의도·전체 처치 승리)" +``` + +--- + +## Task 3: CombatMonster 코드블록 + gen-combat-monster.mjs + +맵 몬스터에 `EnemyId`를 부여하고, BeginPlay 시 컨트롤러에 자기등록하는 경량 스크립트를 만든다. + +**Files:** +- Create: `tools/monster/gen-combat-monster.mjs` +- Create(생성물): `RootDesk/MyDesk/CombatMonster.codeblock` +- Modify(산출): `map/map01.map`~`map11.map` + +- [ ] **Step 1: gen-combat-monster.mjs 작성** + +`tools/camera/gen-camera.mjs`의 prop/method/writeCodeblock/patchMap 구조를 따른다. EnemyId 매핑은 이름 기반 + 기본값. + +```js +import { readFileSync, writeFileSync } from 'node:fs'; + +// 맵 몬스터에 적 타입(EnemyId)을 부여하고, BeginPlay 시 /common 컨트롤러에 자기등록하는 마커. +// 카드 전투 시 컨트롤러가 등록 목록으로 인카운터를 구성한다. +const MAP_NUMBERS = Array.from({ length: 11 }, (_, i) => i + 1); // map01~11 +const NAME_TO_ENEMY = { '주황버섯': 'orange_mushroom', '파란버섯': 'blue_mushroom' }; +const DEFAULT_ENEMY = 'orange_mushroom'; + +function prop(Type, Name, DefaultValue = 'nil') { + return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; +} +function method(Name, Code, Arguments = [], ExecSpace = 6) { + return { + Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null }, + Arguments, Code, Scope: 2, ExecSpace, Attributes: [], Name, + }; +} + +function writeCodeblock() { + const cb = { + Id: '', GameId: '', EntryKey: 'codeblock://combatmonster', ContentType: 'x-mod/codeblock', + Content: '', Usage: 0, UsePublish: 1, UseService: 0, CoreVersion: '26.5.0.0', StudioVersion: '', DynamicLoading: 0, + 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')], + Methods: [ + method('OnBeginPlay', `self.RegTries = 0 +local eventId = 0 +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) + _TimerService:ClearTimer(eventId) + elseif self.RegTries > 50 then + _TimerService:ClearTimer(eventId) + end +end +eventId = _TimerService:SetTimerRepeat(reg, 0.1)`), + ], + EntityEventHandlers: [], + } }, + }; + writeFileSync('RootDesk/MyDesk/CombatMonster.codeblock', JSON.stringify(cb, null, 2), 'utf8'); +} + +const isMonster = (e) => (e.componentNames || '').includes('script.Monster'); + +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; + for (const e of map.ContentProto.Entities.filter(isMonster)) { + const name = (e.jsonString && e.jsonString.name) || ''; + const enemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY; + const comps = e.jsonString['@components']; + e.jsonString['@components'] = comps.filter((c) => c['@type'] !== 'script.CombatMonster'); + e.jsonString['@components'].push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: enemyId }); + 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})`; +} + +writeCodeblock(); +const patched = MAP_NUMBERS.map(patchMap); +console.log('CombatMonster codeblock written; patched maps:', patched.join(', ')); +``` + +- [ ] **Step 2: 실행** + +Run: `node tools/monster/gen-combat-monster.mjs` +Expected: `CombatMonster codeblock written; patched maps: map01(N), ...` (map01은 주황버섯 포함 마릿수). + +- [ ] **Step 3: 산출 검증** + +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=>e.jsonString.name+':'+e.jsonString['@components'].find(c=>c['@type']==='script.CombatMonster').EnemyId).join(', '))"` +Expected: 각 몬스터 `이름:enemyId` 출력(주황버섯:orange_mushroom 등). JSON.parse 성공. + +- [ ] **Step 4: 멱등성 확인** + +Run: `node tools/monster/gen-combat-monster.mjs` (재실행) 후 `git diff --stat map/` 변화 없음(2회차 = 1회차 동일). +Expected: 재실행 후 추가 변경 없음. + +- [ ] **Step 5: Commit** + +```bash +git add tools/monster/gen-combat-monster.mjs RootDesk/MyDesk/CombatMonster.codeblock map/ +git commit -m "feat(monster): CombatMonster 마커(EnemyId·자기등록) + 11맵 몬스터 패치" +``` + +--- + +## Task 4: gen-slaydeck — 멀티 몬스터 상태 + 등록/StartCombat + +여기서부터 `tools/deck/gen-slaydeck.mjs`의 `writeCodeblocks()` 안 `SlayDeckController` 정의를 수정한다. **상수 추가**: 파일 상단 근처(다른 const와 함께)에 `const MAX_MONSTERS = 4;`. + +**Files:** +- Modify: `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: 속성(prop) 교체** + +`SlayDeckController`의 prop 목록에서 단일 적 속성을 멀티로 교체한다. + +제거: `prop('number','EnemyHp','0')`, `prop('number','EnemyMaxHp',...)`, `prop('number','EnemyBlock','0')`, `prop('number','EnemyIntentIndex','1')`, `prop('any','EnemyIntents')`, `prop('any','EnemyName')`. + +추가(같은 위치에): + +```js + prop('any', 'Monsters'), + prop('any', 'Registered'), + prop('number', 'TargetIndex', '1'), + prop('any', 'SlotPos'), +``` + +- [ ] **Step 2: 슬롯 좌표 로드 + 컨트롤러에 주입** + +파일 상단 데이터 로드부(다른 `JSON.parse(readFileSync(...))` 옆)에 추가: + +```js +const SLOTS = JSON.parse(readFileSync('data/monster-slots.json', 'utf8')); +``` + +`StartRun` 메서드 본문(현재 `self.CurrentNodeId = ""` 위쪽 적절한 위치)에 SlotPos 주입을 추가: + +```js +self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} } +``` + +> data/monster-slots.json은 Task 8에서 생성한다. 이 Task를 먼저 구현하더라도 생성기 실행은 Task 8 이후에 한다(실행 순서는 Task 9). + +- [ ] **Step 3: RegisterMonster + StartCombat 작성** + +`StartCombat` 메서드를 아래로 교체한다(단일 적 셋업 → 등록 몬스터 기반 인카운터 구성). 그리고 `RegisterMonster` 메서드를 새로 추가한다. + +`RegisterMonster`: + +```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' }, + ]), +``` + +`StartCombat` 교체: + +```js + method('StartCombat', `self.MaxEnergy = 3 +self.Turn = 0 +self.PlayerBlock = 0 +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:BuildMonsters() +self:RenderCombat() +self:StartPlayerTurn() +self:ApplyRelics("combatStart") +self:RenderCombat()`), +``` + +`BuildMonsters` 추가(등록 몬스터 → 좌→우 정렬 → 최대 MAX → enemies.json 스탯·막 배율 → 부활·슬롯 배치): + +```js + method('BuildMonsters', `self.Monsters = {} +local reg = self.Registered or {} +-- 살아있는(유효) 등록 엔티티만, 월드 x 기준 좌->우 정렬 +local list = {} +for i = 1, #reg do + local r = reg[i] + if r.entity ~= nil and isvalid(r.entity) 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`), +``` + +> 위 `${'${'}MAX_MONSTERS}`는 JS 템플릿에서 상수 `MAX_MONSTERS` 값을 박아넣기 위한 것이다. 실제 작성 시 `${MAX_MONSTERS}`로 쓴다(이 문서의 escape 표기 주의). + +`ReviveMonsterEntity` 추가: + +```js + method('ReviveMonsterEntity', `if monster == nil or not isvalid(monster) then + return +end +monster:SetEnable(true) +monster:SetVisible(true) +if monster.StateComponent ~= nil then + monster.StateComponent:ChangeState("IDLE") +end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }]), +``` + +- [ ] **Step 4: 구문 점검(생성기 실행은 Task 9에서)** + +Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` +(이 Task에서는 JS 문법만 깨지지 않았는지 확인. 전체 실행/산출 검증은 Task 8·9에서.) +Expected: `file ok` + +- [ ] **Step 5: Commit** + +```bash +git add tools/deck/gen-slaydeck.mjs +git commit -m "feat(combat): 컨트롤러 멀티 몬스터 상태 + 등록/BuildMonsters/부활" +``` + +--- + +## Task 5: gen-slaydeck — PlayCard 타겟 공격 + 사망 + +**Files:** +- Modify: `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: PlayCard 공격 분기 교체** + +`PlayCard`에서 Attack 분기의 `self:DealDamageToEnemy(c.damage)`를 타겟 몬스터 공격으로 교체: + +```js +if c.kind == "Attack" then + if c.damage ~= nil then + self:DealDamageToTarget(c.damage) + end + self:ApplyRelics("cardPlayed") +elseif c.kind == "Skill" then + if c.block ~= nil then + self.PlayerBlock = self.PlayerBlock + c.block + end +end +``` + +- [ ] **Step 2: DealDamageToTarget / KillMonster 추가, DealDamageToEnemy 제거** + +`DealDamageToEnemy` 메서드를 삭제하고 아래 두 메서드를 추가: + +```js + method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex] +if m == nil or m.alive ~= true then + m = nil + for i = 1, #self.Monsters do + if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end + end +end +if m == nil then + return +end +local dmg = amount +if m.block > 0 then + local absorbed = math.min(m.block, dmg) + m.block = m.block - absorbed + dmg = dmg - absorbed +end +m.hp = m.hp - dmg +if m.hp <= 0 then + m.hp = 0 + self:KillMonster(m.slot) +end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]), + method('KillMonster', `local m = self.Monsters[slot] +if m == nil then + return +end +m.alive = false +if m.entity ~= nil and isvalid(m.entity) then + if m.entity.StateComponent ~= nil then + m.entity.StateComponent:ChangeState("DEAD") + end + local ent = m.entity + _TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.6) +end +self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot), false) +-- 다음 생존 타겟 자동 선택 +for i = 1, #self.Monsters do + if self.Monsters[i].alive == true then self.TargetIndex = i; break end +end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), +``` + +- [ ] **Step 3: 구문 점검** + +Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` +Expected: `file ok` + +- [ ] **Step 4: Commit** + +```bash +git add tools/deck/gen-slaydeck.mjs +git commit -m "feat(combat): PlayCard 타겟 몬스터 공격 + 사망 처리/연출" +``` + +--- + +## Task 6: gen-slaydeck — EnemyTurn 멀티 몬스터 + +**Files:** +- Modify: `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: EnemyTurn 교체** + +기존 단일 적 `EnemyTurn`을 생존 몬스터 각자 행동으로 교체: + +```js + method('EnemyTurn', `for i = 1, #self.Monsters do + local m = self.Monsters[i] + if m.alive == true then + m.block = 0 + local intent = m.intents[m.intentIdx] + if intent ~= nil then + if intent.kind == "Attack" then + self:DealDamageToPlayer(intent.value) + elseif intent.kind == "Defend" then + m.block = m.block + intent.value + end + end + m.intentIdx = m.intentIdx + 1 + if m.intentIdx > #m.intents then + m.intentIdx = 1 + end + end +end +self:RenderCombat()`), +``` + +- [ ] **Step 2: 구문 점검** + +Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` +Expected: `file ok` + +- [ ] **Step 3: Commit** + +```bash +git add tools/deck/gen-slaydeck.mjs +git commit -m "feat(combat): EnemyTurn 생존 몬스터 각자 행동" +``` + +--- + +## Task 7: gen-slaydeck — CheckCombatEnd · RenderCombat · SetTarget · 바인딩 + +**Files:** +- Modify: `tools/deck/gen-slaydeck.mjs` + +- [ ] **Step 1: CheckCombatEnd 승리 조건 교체** + +`if self.EnemyHp <= 0 then` 을 "생존 몬스터 0"으로 교체(이후 보상/노드/막 분기는 그대로 유지): + +```js + method('CheckCombatEnd', `local anyAlive = false +for i = 1, #self.Monsters do + if self.Monsters[i].alive == true then anyAlive = true; break end +end +if anyAlive == false 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 + 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 +elseif self.PlayerHp <= 0 then + self.CombatOver = true + self:ShowResult("패배...") + self.RunActive = false +end`), +``` + +- [ ] **Step 2: RenderCombat 교체(몬스터 슬롯 렌더)** + +단일 적 패널 갱신 부분을 몬스터 슬롯 렌더로 교체. 플레이어 패널/런 갱신은 유지: + +```js + method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do + local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) + local m = self.Monsters[i] + if m ~= nil and m.alive == true then + self:SetEntityEnabled(base, true) + self:SetText(base .. "/Name", m.name) + self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp)) + local intent = m.intents[m.intentIdx] + local t = "" + if intent ~= nil then + if intent.kind == "Attack" then t = "공격 " .. tostring(intent.value) + elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value) end + end + if i == self.TargetIndex then t = "[타겟] " .. t end + self:SetText(base .. "/Intent", t) + self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp) + else + self:SetEntityEnabled(base, false) + end +end +self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp)) +self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock)) +self:RenderRun()`), +``` + +`SetHpBar` 추가(채움 너비 = 비율 × 기준폭 120): + +```js + method('SetHpBar', `local e = _EntityService:GetEntityByPath(path) +if e == nil or e.UITransformComponent == nil then + return +end +local ratio = 0 +if maxHp > 0 then ratio = hp / maxHp end +if ratio < 0 then ratio = 0 end +local w = 120 * ratio +e.UITransformComponent.RectSize = Vector2(w, 14)`, [ + { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' }, + { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' }, + { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' }, + ]), +``` + +`PositionMonsterSlot` 추가(SlotPos 화면좌표로 슬롯 배치): + +```js + method('PositionMonsterSlot', `local sp = self.SlotPos +if sp == nil or sp[slot] == nil then + return +end +local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot)) +if e ~= nil and e.UITransformComponent ~= nil then + e.UITransformComponent.anchoredPosition = Vector2(sp[slot].x, sp[slot].y) +end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), +``` + +`SetTarget` 추가: + +```js + method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then + self.TargetIndex = slot + self:RenderCombat() +end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), +``` + +- [ ] **Step 3: BindButtons에 몬스터 슬롯 타겟 클릭 추가** + +`BindButtons` 메서드 본문 끝부분에 슬롯 버튼 바인딩 추가: + +```js +for i = 1, ${MAX_MONSTERS} do + local ms = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i)) + if ms ~= nil and ms.ButtonComponent ~= nil then + ms:ConnectEvent(ButtonClickEvent, function() self:SetTarget(i) end) + end +end +``` + +- [ ] **Step 4: 잔여 단일 적 참조 제거 확인** + +Run: `node -e "const s=require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8');const bad=['EnemyHp','EnemyMaxHp','EnemyIntentIndex','DealDamageToEnemy'].filter(k=>s.includes(k));console.log(bad.length?('잔여:'+bad.join(',')):'clean')"` +Expected: `clean` (CombatHud의 EnemyName/EnemyHp UI 엔티티를 Task 8에서 슬롯으로 대체하므로, 그 외 단일 적 상태 참조는 모두 사라져야 함). + +> 참고: 기존 `upsertUi()`가 만들던 CombatHud의 `EnemyName/EnemyHp/EnemyBlock/EnemyIntent` 엔티티는 Task 8에서 제거한다. RenderCombat에서 더 이상 참조하지 않으므로 남아 있어도 무해하나, 정리한다. + +- [ ] **Step 5: Commit** + +```bash +git add tools/deck/gen-slaydeck.mjs +git commit -m "feat(combat): 승리조건(전체 처치)·몬스터 슬롯 렌더·HP바·타겟 클릭" +``` + +--- + +## Task 8: gen-slaydeck — 몬스터 슬롯 UI + monster-slots.json + +**Files:** +- Create: `data/monster-slots.json` +- Modify: `tools/deck/gen-slaydeck.mjs` (`upsertUi()` 내 CombatHud 생성부) + +- [ ] **Step 1: monster-slots.json 생성(초기 좌표, 추후 튜닝)** + +화면 상단을 좌→우로 4등분한 초기값. 메이커 플레이로 몬스터 머리 위에 맞게 튜닝(Task 9). + +```json +[ + { "x": -480, "y": 300 }, + { "x": -160, "y": 300 }, + { "x": 160, "y": 300 }, + { "x": 480, "y": 300 } +] +``` + +- [ ] **Step 2: CombatHud의 단일 적 엔티티 제거 + 몬스터 슬롯 생성** + +`upsertUi()`에서 `EnemyBg`/`EnemyName`/`EnemyHp`/`EnemyBlock`/`EnemyIntent` 엔티티 생성 블록(`enemyTexts` 루프 및 EnemyBg push)을 삭제한다. 대신 `MAX_MONSTERS`개의 MonsterSlot을 생성하는 블록을 `combat` 배열에 추가한다(`PlayerBg` 생성 이전 적당한 위치): + +```js + const SLOT_W = 140, SLOT_H = 96; + for (let i = 1; i <= MAX_MONSTERS; i++) { + const base = `/ui/DefaultGroup/CombatHud/MonsterSlot${i}`; + // 슬롯 컨테이너(투명 + 버튼 = 타겟 클릭 영역) + const slot = entity({ + id: guid('cmb', 40 + i), + path: base, + modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', + displayOrder: 20 + i, + components: [ + transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: SLOT_H }, pos: { x: (i - 2.5) * 320, y: 300 } }), + sprite({ color: { r: 0, g: 0, b: 0, a: 0.0001 }, type: 1, raycast: true }), + button(), + ], + }); + slot.jsonString.enable = false; + combat.push(slot); + // 이름 + combat.push(entity({ + id: guid('cmb', 60 + i), path: `${base}/Name`, modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 0, + components: [ + transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 30 }, pos: { x: 0, y: 34 } }), + sprite({ color: TRANSPARENT }), + text({ value: '', fontSize: 20, bold: true, color: GOLD, alignment: 4 }), + ], + })); + // HP 텍스트 + combat.push(entity({ + id: guid('cmb', 80 + i), path: `${base}/Hp`, modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 1, + components: [ + transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W, y: 26 }, pos: { x: 0, y: 6 } }), + sprite({ color: TRANSPARENT }), + text({ value: '', fontSize: 18, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), + ], + })); + // HP바 배경 + combat.push(entity({ + id: guid('cmb', 100 + i), path: `${base}/HpBarBg`, modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 2, + components: [ + transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 120, y: 14 }, pos: { x: 0, y: -14 } }), + sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }), + ], + })); + // HP바 채움(좌측 정렬: pivot x=0) + combat.push(entity({ + id: guid('cmb', 120 + i), path: `${base}/HpBarFill`, modelId: 'uisprite', entryId: 'UISprite', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', + displayOrder: 3, + components: [ + transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0, y: 0.5 }, size: { x: 120, y: 14 }, pos: { x: -60, y: -14 } }), + sprite({ color: { r: 0.86, g: 0.35, b: 0.32, a: 1 }, type: 1 }), + ], + })); + // 의도 텍스트 + combat.push(entity({ + id: guid('cmb', 140 + i), path: `${base}/Intent`, modelId: 'uitext', entryId: 'UIText', + componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', + displayOrder: 4, + components: [ + transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 40, y: 24 }, pos: { x: 0, y: -36 } }), + sprite({ color: TRANSPARENT }), + text({ value: '', fontSize: 17, bold: true, color: { r: 1, g: 0.72, b: 0.5, a: 1 }, alignment: 4 }), + ], + })); + } +``` + +> `guid('cmb', N)` 충돌 주의: 기존 CombatHud는 `cmb` 0~약 15번대를 사용한다. 위에서 40~140+ 대역을 써서 충돌을 피한다. 기존 코드가 더 큰 번호를 쓰면 대역을 올린다(생성 후 `JSON.parse` 검증으로 중복 id 없는지 확인). + +- [ ] **Step 3: upsertUi가 SLOTS 길이와 MAX_MONSTERS 일치 가정 — 검증 주석/단언 추가** + +`upsertUi()` 시작부에 안전 단언 추가: + +```js + if (SLOTS.length < MAX_MONSTERS) { + throw new Error(`[gen-slaydeck] monster-slots.json 항목(${SLOTS.length}) < MAX_MONSTERS(${MAX_MONSTERS})`); + } +``` + +- [ ] **Step 4: 구문 점검** + +Run: `node -e "require('fs').readFileSync('tools/deck/gen-slaydeck.mjs','utf8'); console.log('file ok')"` +Expected: `file ok` + +- [ ] **Step 5: Commit** + +```bash +git add data/monster-slots.json tools/deck/gen-slaydeck.mjs +git commit -m "feat(combat-ui): 몬스터 슬롯 UI(HP바·의도·타겟버튼) + monster-slots.json" +``` + +--- + +## Task 9: 재생성 · 결정성 검증 · 메이커 플레이테스트 + +**Files:** +- 산출: `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`, `map/*.map` + +- [ ] **Step 1: 전체 생성기 재실행** + +Run(루트에서 순서대로): +```bash +node tools/monster/gen-combat-monster.mjs +node tools/deck/gen-slaydeck.mjs +``` +Expected: 둘 다 에러 없이 완료. gen-slaydeck는 UI/codeblock/common 재생성. + +- [ ] **Step 2: 산출물 JSON 유효성 + 중복 id 없음** + +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 ui=JSON.parse(fs.readFileSync('ui/DefaultGroup.ui','utf8'));const ids=ui.ContentProto.Entities.map(e=>e.id);const dup=ids.filter((x,i)=>ids.indexOf(x)!==i);console.log(dup.length?('중복 id:'+dup.join(',')):'ok no dup')"` +Expected: `ok no dup` + +- [ ] **Step 3: 몬스터 슬롯 엔티티 생성 확인** + +Run: `node -e "const ui=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log(ui.ContentProto.Entities.filter(e=>e.path.includes('/CombatHud/MonsterSlot')).map(e=>e.path).join('\n'))"` +Expected: MonsterSlot1~4 및 각 하위(Name/Hp/HpBarBg/HpBarFill/Intent) 경로 출력. + +- [ ] **Step 4: 결정성(2회 실행 동일)** + +Run: `node tools/deck/gen-slaydeck.mjs && git diff --stat ui/ Global/ RootDesk/` +Expected: 1회차 대비 추가 변경 없음(결정적). + +- [ ] **Step 5: sim 테스트 재확인** + +Run: `node --test tools/balance/sim-balance.test.mjs` +Expected: PASS. + +- [ ] **Step 6: 메이커 플레이테스트 (수동/MCP)** + +MSW Maker에서 로컬 워크스페이스 reload 후 Play. 확인 항목: +1. 전투 진입 시 맵 몬스터 위에 슬롯(이름·HP바·의도)이 뜨는가. (안 맞으면 `data/monster-slots.json` 좌표 튜닝 → `node tools/deck/gen-slaydeck.mjs` 재실행 → reload) +2. 몬스터 클릭 시 `[타겟]` 표시가 이동하는가. +3. 공격 카드가 현재 타겟의 HP를 깎고, HP 0이면 die 애니 후 사라지는가. +4. 적 턴에 생존 몬스터가 각자 플레이어를 공격하는가. +5. 모든 몬스터 처치 시 승리 → 보상/맵 흐름 진입, 플레이어 HP 0이면 패배. + +> 월드 API 리스크 검증: `TransformComponent.WorldPosition`, 외부 엔티티 `StateComponent:ChangeState`, `SetVisible/SetEnable` 동작 여부. 미동작 시: (a) 정렬용 x를 `Position`으로 대체, (b) 사망 연출을 가시성만으로 처리, (c) 슬롯 위치는 좌표 데이터로 이미 독립. + +- [ ] **Step 7: 좌표 튜닝 반영 후 최종 커밋** + +```bash +git add data/monster-slots.json ui/DefaultGroup.ui Global/common.gamelogic RootDesk/MyDesk/SlayDeckController.codeblock RootDesk/MyDesk/CombatMonster.codeblock map/ +git commit -m "feat(combat): 맵 몬스터 카드 전투 재생성 + 슬롯 좌표 튜닝" +``` + +--- + +## Self-Review 결과 (작성자 점검) + +- **스펙 커버리지**: 타겟 클릭(Task 7 SetTarget/바인딩), 멀티 HP+의도(Task 2 sim·Task 4~6 Lua), 런 연동(Task 7 CheckCombatEnd 기존 분기 유지), 스탯 enemies.json+막배율(Task 1·4 BuildMonsters), 월드 HP바 표시(Task 7·8), 컨트롤러 단일 소유(Task 4~7), CombatMonster 매핑(Task 3) — 전 항목 매핑됨. 스펙의 "노드 타입 배율(선택)"은 MVP에서 막 배율만 적용(BuildMonsters), 노드 타입 가산은 후속. +- **플레이스홀더**: enemies.json 수치는 의도된 placeholder(§sim으로 튜닝). 슬롯 좌표는 초기값+튜닝 단계 명시. 코드 단계는 실제 코드 포함. +- **타입/이름 일관성**: `Monsters`/`Registered`/`TargetIndex`/`SlotPos`, 메서드 `RegisterMonster`/`BuildMonsters`/`ReviveMonsterEntity`/`DealDamageToTarget`/`KillMonster`/`EnemyTurn`/`CheckCombatEnd`/`RenderCombat`/`SetHpBar`/`PositionMonsterSlot`/`SetTarget` — Task 간 명칭 일치. UI 경로 `/ui/DefaultGroup/CombatHud/MonsterSlot{i}/{Name,Hp,HpBarBg,HpBarFill,Intent}` 일치. +- **알려진 리스크**: MSW 월드 API(WorldPosition/StateComponent/SetVisible)는 Task 9 Step 6에서 검증·폴백.