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

42 KiB
Raw Blame History

맵 몬스터 카드 전투 구현 계획

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.
  • 현재 전투(단일 적): SlayDeckControllerEnemyHp/EnemyMaxHp/EnemyBlock/EnemyName/EnemyIntents/EnemyIntentIndex를 갖고, PlayCard(Attack)→DealDamageToEnemyCheckCombatEnd(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.mjswriteCodeblocks()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는 cmb 0약 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. 확인 항목:

  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: 좌표 튜닝 반영 후 최종 커밋
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 46 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에서 검증·폴백.