Files
maplecontest/docs/superpowers/plans/2026-06-10-map-monster-combat.md
gahusb 0cbcf4c70d docs(map-monster-combat): 구현 계획 (9개 태스크)
데이터→sim(TDD)→CombatMonster 마커→컨트롤러 멀티 전투(상태/PlayCard/EnemyTurn/승리/렌더)→슬롯 UI→재생성·플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:45:40 +09:00

1065 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 맵 몬스터 카드 전투 구현 계획
> **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에서 검증·폴백.