# 맵 몬스터 카드 전투 구현 계획 > **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에서 검증·폴백.