데이터→sim(TDD)→CombatMonster 마커→컨트롤러 멀티 전투(상태/PlayCard/EnemyTurn/승리/렌더)→슬롯 UI→재생성·플레이테스트. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1065 lines
42 KiB
Markdown
1065 lines
42 KiB
Markdown
# 맵 몬스터 카드 전투 구현 계획
|
||
|
||
> **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에서 검증·폴백.
|