데이터→sim(TDD)→CombatMonster 마커→컨트롤러 멀티 전투(상태/PlayCard/EnemyTurn/승리/렌더)→슬롯 UI→재생성·플레이테스트. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
42 KiB
맵 몬스터 카드 전투 구현 계획
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는 유지):
{
"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
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 전체를 아래로 교체:
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 교체(타겟 분리, 공격 우선):
// 손패에서 낼 카드 인덱스(-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 배열):
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 교체(멀티 몬스터):
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의 적 이름 표기는 인카운터 요약으로 교체:
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 첫 줄을 인카운터 요약으로 교체(나머지 카드 통계 로직은 유지):
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
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 매핑은 이름 기반 + 기본값.
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
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').
추가(같은 위치에):
prop('any', 'Monsters'),
prop('any', 'Registered'),
prop('number', 'TargetIndex', '1'),
prop('any', 'SlotPos'),
- Step 2: 슬롯 좌표 로드 + 컨트롤러에 주입
파일 상단 데이터 로드부(다른 JSON.parse(readFileSync(...)) 옆)에 추가:
const SLOTS = JSON.parse(readFileSync('data/monster-slots.json', 'utf8'));
StartRun 메서드 본문(현재 self.CurrentNodeId = "" 위쪽 적절한 위치)에 SlotPos 주입을 추가:
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:
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 교체:
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 스탯·막 배율 → 부활·슬롯 배치):
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 추가:
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
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)를 타겟 몬스터 공격으로 교체:
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 메서드를 삭제하고 아래 두 메서드를 추가:
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
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을 생존 몬스터 각자 행동으로 교체:
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
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"으로 교체(이후 보상/노드/막 분기는 그대로 유지):
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 교체(몬스터 슬롯 렌더)
단일 적 패널 갱신 부분을 몬스터 슬롯 렌더로 교체. 플레이어 패널/런 갱신은 유지:
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):
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 화면좌표로 슬롯 배치):
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 추가:
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 메서드 본문 끝부분에 슬롯 버튼 바인딩 추가:
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
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).
[
{ "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 생성 이전 적당한 위치):
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는cmb0약 15번대를 사용한다. 위에서 40140+ 대역을 써서 충돌을 피한다. 기존 코드가 더 큰 번호를 쓰면 대역을 올린다(생성 후JSON.parse검증으로 중복 id 없는지 확인).
- Step 3: upsertUi가 SLOTS 길이와 MAX_MONSTERS 일치 가정 — 검증 주석/단언 추가
upsertUi() 시작부에 안전 단언 추가:
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
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(루트에서 순서대로):
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. 확인 항목:
- 전투 진입 시 맵 몬스터 위에 슬롯(이름·HP바·의도)이 뜨는가. (안 맞으면
data/monster-slots.json좌표 튜닝 →node tools/deck/gen-slaydeck.mjs재실행 → reload) - 몬스터 클릭 시
[타겟]표시가 이동하는가. - 공격 카드가 현재 타겟의 HP를 깎고, HP 0이면 die 애니 후 사라지는가.
- 적 턴에 생존 몬스터가 각자 플레이어를 공격하는가.
- 모든 몬스터 처치 시 승리 → 보상/맵 흐름 진입, 플레이어 HP 0이면 패배.
월드 API 리스크 검증:
TransformComponent.WorldPosition, 외부 엔티티StateComponent:ChangeState,SetVisible/SetEnable동작 여부. 미동작 시: (a) 정렬용 x를Position으로 대체, (b) 사망 연출을 가시성만으로 처리, (c) 슬롯 위치는 좌표 데이터로 이미 독립.
- Step 7: 좌표 튜닝 반영 후 최종 커밋
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 47), 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에서 검증·폴백.