Merge pull request 'Map monster combat' from feature/map-monster-combat
# Conflicts: # RootDesk/MyDesk/SlayDeckController.codeblock
This commit is contained in:
@@ -43,36 +43,38 @@ export function applyDamage(hp, block, amount) {
|
||||
export function loadData() {
|
||||
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||||
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
const enemy = enemiesData.enemies[enemiesData.activeEnemy];
|
||||
if (!enemy) throw new Error(`activeEnemy 없음: ${enemiesData.activeEnemy}`);
|
||||
return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, enemy };
|
||||
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 };
|
||||
}
|
||||
|
||||
// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열.
|
||||
export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) {
|
||||
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
|
||||
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
||||
// 손패에서 낼 카드 인덱스(-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];
|
||||
|
||||
// 1) 치사: 에너지 한도 내 효율순 공격 데미지 합 >= 적 유효 hp?
|
||||
let e = energy, lethalDmg = 0;
|
||||
for (const x of attacks.slice().sort((a, b) => dmgEff(b) - dmgEff(a))) {
|
||||
if (cards[x.id].cost <= e) { e -= cards[x.id].cost; lethalDmg += cards[x.id].damage || 0; }
|
||||
}
|
||||
if (attacks.length && lethalDmg >= enemyHp + enemyBlock) return bestBy(attacks, dmgEff).i;
|
||||
|
||||
// 2) 적 공격 의도면 방어 우선
|
||||
if (enemyIntent && enemyIntent.kind === 'Attack' && skills.length) return bestBy(skills, blkEff).i;
|
||||
|
||||
// 3) 공격 우선, 없으면 스킬, 없으면 종료
|
||||
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) || pool.indexOf(a) - pool.indexOf(b))[0];
|
||||
}
|
||||
|
||||
function bump(s, cost, dmg, blk) {
|
||||
s = s || { plays: 0, energy: 0, damage: 0, block: 0 };
|
||||
s.plays++; s.energy += cost; s.damage += dmg; s.block += blk;
|
||||
@@ -82,12 +84,16 @@ function bump(s, cost, dmg, blk) {
|
||||
// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적.
|
||||
// 반환: { win, turns, playerHpRemaining, draw? }
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, enemy } = data;
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
let drawPile = shuffle(starterDeck, rng);
|
||||
let discard = [];
|
||||
let hand = [];
|
||||
let pHp = PLAYER_HP, pBlock = 0;
|
||||
let eHp = enemy.maxHp, eBlock = 0, intentIdx = 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) {
|
||||
@@ -97,33 +103,43 @@ export function simulateCombat(data, rng, stats) {
|
||||
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 intent = enemy.intents[intentIdx];
|
||||
const idx = chooseAction(hand, cards, energy, eHp, eBlock, intent);
|
||||
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 r = applyDamage(eHp, eBlock, c.damage || 0); eHp = r.hp; eBlock = r.block;
|
||||
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 (eHp <= 0) return { win: true, turns, playerHpRemaining: pHp };
|
||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
||||
}
|
||||
discard.push(...hand); hand = [];
|
||||
eBlock = 0;
|
||||
const intent = enemy.intents[intentIdx];
|
||||
if (intent.kind === 'Attack') { const r = applyDamage(pHp, pBlock, intent.value); pHp = r.hp; pBlock = r.block; }
|
||||
else if (intent.kind === 'Defend') { eBlock += intent.value; }
|
||||
intentIdx = (intentIdx + 1) % enemy.intents.length;
|
||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||
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 };
|
||||
}
|
||||
@@ -152,13 +168,13 @@ export function runBatch(N, seed) {
|
||||
winRate: wins / N,
|
||||
avgTurns: mean(turnsArr), medianTurns: median(turnsArr),
|
||||
avgHpOnWin: mean(hpArr),
|
||||
cardStats, cards: data.cards, enemy: data.enemy, seed,
|
||||
cardStats, cards: data.cards, monsters: data.monsters, seed,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatReport(r) {
|
||||
const L = [];
|
||||
L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`);
|
||||
L.push(`=== 밸런스 시뮬레이션 (인카운터: ${r.monsters.map((m) => `${m.name}(${m.maxHp})`).join(', ')}) ===`);
|
||||
L.push(`시뮬 ${r.N}회 (seed=${r.seed})`);
|
||||
L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`);
|
||||
L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mulberry32, applyDamage, chooseAction, simulateCombat, runBatch,
|
||||
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
test('applyDamage: 방어 우선 차감 후 hp', () => {
|
||||
@@ -23,34 +23,46 @@ const CARDS = {
|
||||
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
|
||||
};
|
||||
|
||||
test('chooseAction: 치사 가능하면 공격 선택', () => {
|
||||
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 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: 치사 불가 + 적 공격 의도면 방어 선택', () => {
|
||||
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 });
|
||||
assert.equal(idx, 1);
|
||||
});
|
||||
|
||||
test('chooseAction: 적 방어 의도면 공격 우선', () => {
|
||||
const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 });
|
||||
assert.equal(idx, 1);
|
||||
});
|
||||
|
||||
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
|
||||
const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 });
|
||||
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'],
|
||||
enemy: {
|
||||
name: '슬라임', maxHp: 45, intents: [
|
||||
{ kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', value: 8 },
|
||||
],
|
||||
},
|
||||
monsters: [
|
||||
{ name: '주황버섯', maxHp: 16, intents: [{ kind: 'Attack', value: 5 }, { kind: 'Defend', value: 4 }] },
|
||||
{ name: '파란버섯', maxHp: 12, intents: [{ kind: 'Attack', value: 8 }] },
|
||||
],
|
||||
};
|
||||
|
||||
test('simulateCombat: 결정적 결과(동일 시드)', () => {
|
||||
@@ -61,12 +73,40 @@ test('simulateCombat: 결정적 결과(동일 시드)', () => {
|
||||
assert.ok(r1.turns >= 1);
|
||||
});
|
||||
|
||||
test('simulateCombat: 약한 적이면 대체로 승리', () => {
|
||||
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('simulateCombat: 턴 상한 초과 시 draw 반환', () => {
|
||||
const immortal = {
|
||||
cards: { Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 } },
|
||||
starterDeck: Array(10).fill('Defend'),
|
||||
monsters: [{ name: '불사', maxHp: 9999, intents: [{ kind: 'Attack', value: 1 }] }],
|
||||
};
|
||||
const r = simulateCombat(immortal, mulberry32(1));
|
||||
assert.equal(r.draw, true);
|
||||
assert.equal(r.win, false);
|
||||
});
|
||||
|
||||
test('simulateCombat: 몬스터 없으면 즉시 승리', () => {
|
||||
const r = simulateCombat({ cards: {}, starterDeck: [], monsters: [] }, mulberry32(1));
|
||||
assert.equal(r.win, true);
|
||||
assert.equal(r.turns, 0);
|
||||
});
|
||||
|
||||
test('runBatch: 집계 필드·승률 범위', () => {
|
||||
const r = runBatch(100, 1);
|
||||
assert.equal(r.N, 100);
|
||||
|
||||
@@ -12,7 +12,6 @@ for (const id of CARDS.starterDeck) {
|
||||
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
|
||||
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
|
||||
}
|
||||
const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy];
|
||||
|
||||
const MAP = JSON.parse(readFileSync('data/map.json', 'utf8'));
|
||||
for (const id of MAP.start) {
|
||||
@@ -27,6 +26,7 @@ for (const [id, n] of Object.entries(MAP.nodes)) {
|
||||
const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row));
|
||||
|
||||
const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
const SLOTS = JSON.parse(readFileSync('data/monster-slots.json', 'utf8'));
|
||||
if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`);
|
||||
for (const id of RELICS.relicPool) {
|
||||
if (!RELICS.relics[id]) throw new Error(`[gen-slaydeck] relicPool에 없는 유물 id: ${id}`);
|
||||
@@ -73,15 +73,6 @@ function luaCardsTable(cards) {
|
||||
function luaDeckTable(deck) {
|
||||
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
|
||||
}
|
||||
function luaIntentsTable(intents) {
|
||||
const lines = intents.map((it) => `\t{ kind = ${luaStr(it.kind)}, value = ${it.value} },`);
|
||||
return `self.EnemyIntents = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
function intentText(it) {
|
||||
if (it.kind === 'Attack') return `의도: 공격 ${it.value}`;
|
||||
if (it.kind === 'Defend') return `의도: 방어 ${it.value}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
const UI_FILE = 'ui/DefaultGroup.ui';
|
||||
const COMMON_FILE = 'Global/common.gamelogic';
|
||||
@@ -93,6 +84,9 @@ const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1 };
|
||||
const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1 };
|
||||
const SKILL = { r: 0.46, g: 0.68, b: 0.52, a: 1 };
|
||||
|
||||
const MAX_MONSTERS = 4;
|
||||
|
||||
const HP_BAR_W = 120;
|
||||
const CARD_W = 180;
|
||||
const CARD_H = 250;
|
||||
const CARD_SPACING = 200;
|
||||
@@ -285,6 +279,9 @@ function entity({ id, path, modelId, entryId, componentNames, components, displa
|
||||
}
|
||||
|
||||
function upsertUi() {
|
||||
if (SLOTS.length < MAX_MONSTERS) {
|
||||
throw new Error(`[gen-slaydeck] monster-slots.json 항목(${SLOTS.length}) < MAX_MONSTERS(${MAX_MONSTERS})`);
|
||||
}
|
||||
const ui = JSON.parse(readFileSync(UI_FILE, 'utf8'));
|
||||
const E = ui.ContentProto.Entities;
|
||||
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/DeckInspectHud') && !e.path.startsWith('/ui/DefaultGroup/DeckAllHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud') && !e.path.startsWith('/ui/DefaultGroup/MainMenu'));
|
||||
@@ -721,40 +718,73 @@ function upsertUi() {
|
||||
sprite({ color: TRANSPARENT }),
|
||||
],
|
||||
}));
|
||||
combat.push(entity({
|
||||
id: guid('cmb', 1),
|
||||
path: '/ui/DefaultGroup/CombatHud/EnemyBg',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 170 }, pos: { x: 0, y: 300 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: PANEL_BG, type: 1 }),
|
||||
],
|
||||
}));
|
||||
const enemyTexts = [
|
||||
['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, ACTIVE_ENEMY.name, 28, true, GOLD],
|
||||
['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, `HP ${ACTIVE_ENEMY.maxHp}/${ACTIVE_ENEMY.maxHp}`, 24, true, { r: 1, g: 1, b: 1, a: 1 }],
|
||||
['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
|
||||
['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, intentText(ACTIVE_ENEMY.intents[0]), 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
|
||||
];
|
||||
let cmbN = 2;
|
||||
for (const [suffix, pos, size, value, fontSize, bold, color] of enemyTexts) {
|
||||
combat.push(entity({
|
||||
id: guid('cmb', cmbN++),
|
||||
path: `/ui/DefaultGroup/CombatHud/${suffix}`,
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: enemyTexts.findIndex(([s]) => s === suffix) + 1,
|
||||
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, pos }),
|
||||
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, bold, color }),
|
||||
text({ value: '', fontSize: 20, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
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 }),
|
||||
],
|
||||
}));
|
||||
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: HP_BAR_W, y: 14 }, pos: { x: 0, y: -14 } }),
|
||||
sprite({ color: { r: 0.18, g: 0.05, b: 0.06, a: 1 }, type: 1 }),
|
||||
],
|
||||
}));
|
||||
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: HP_BAR_W, y: 14 }, pos: { x: -HP_BAR_W / 2, 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 }),
|
||||
],
|
||||
}));
|
||||
}
|
||||
let cmbN = 2;
|
||||
combat.push(entity({
|
||||
id: guid('cmb', cmbN++),
|
||||
path: '/ui/DefaultGroup/CombatHud/PlayerBg',
|
||||
@@ -1318,13 +1348,11 @@ function writeCodeblocks() {
|
||||
prop('number', 'PlayerHp', '0'),
|
||||
prop('number', 'PlayerMaxHp', '80'),
|
||||
prop('number', 'PlayerBlock', '0'),
|
||||
prop('number', 'EnemyHp', '0'),
|
||||
prop('number', 'EnemyMaxHp', String(ACTIVE_ENEMY.maxHp)),
|
||||
prop('number', 'EnemyBlock', '0'),
|
||||
prop('number', 'EnemyIntentIndex', '1'),
|
||||
prop('boolean', 'CombatOver', 'false'),
|
||||
prop('any', 'EnemyIntents'),
|
||||
prop('any', 'EnemyName'),
|
||||
prop('any', 'Monsters'),
|
||||
prop('any', 'Registered'),
|
||||
prop('number', 'TargetIndex', '1'),
|
||||
prop('any', 'SlotPos'),
|
||||
prop('any', 'RunDeck'),
|
||||
prop('number', 'Gold', '0'),
|
||||
prop('number', 'Floor', '0'),
|
||||
@@ -1378,6 +1406,7 @@ self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
|
||||
${luaEnemiesTable(ENEMIES.enemies)}
|
||||
${luaMapNodesTable(MAP.nodes)}
|
||||
${luaStartArray(MAP.start)}
|
||||
self.SlotPos = { ${SLOTS.map((s) => `{ x = ${s.x}, y = ${s.y} }`).join(', ')} }
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self:BindButtons()
|
||||
@@ -1385,18 +1414,7 @@ self:AddRelic("${RELICS.startingRelic}")
|
||||
self:ShowMap()`),
|
||||
method('StartCombat', `self.MaxEnergy = 3
|
||||
self.Turn = 0
|
||||
local enemy = self.Enemies[self.CurrentEnemyId]
|
||||
local mult = 1 + (self.Floor - 1) * 0.6
|
||||
self.PlayerBlock = 0
|
||||
self.EnemyName = enemy.name
|
||||
self.EnemyMaxHp = math.floor(enemy.maxHp * mult)
|
||||
self.EnemyHp = self.EnemyMaxHp
|
||||
self.EnemyBlock = 0
|
||||
self.EnemyIntents = {}
|
||||
for i = 1, #enemy.intents do
|
||||
self.EnemyIntents[i] = { kind = enemy.intents[i].kind, value = math.floor(enemy.intents[i].value * mult) }
|
||||
end
|
||||
self.EnemyIntentIndex = 1
|
||||
self.CombatOver = false
|
||||
self.DiscardPile = {}
|
||||
self.Hand = {}
|
||||
@@ -1406,10 +1424,55 @@ 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()`),
|
||||
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' },
|
||||
]),
|
||||
method('BuildMonsters', `self.Monsters = {}
|
||||
local reg = self.Registered or {}
|
||||
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`),
|
||||
method('ReviveMonsterEntity', `if monster == nil or not isvalid(monster) then
|
||||
return
|
||||
end
|
||||
monster:SetEnable(true)
|
||||
monster:SetVisible(true)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }]),
|
||||
method('Shuffle', `if list == nil then
|
||||
\treturn
|
||||
end
|
||||
@@ -1506,6 +1569,12 @@ end
|
||||
local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave")
|
||||
if restLeave ~= nil and restLeave.ButtonComponent ~= nil then
|
||||
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end
|
||||
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`),
|
||||
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
||||
self.Energy = self.MaxEnergy
|
||||
@@ -1772,7 +1841,7 @@ end
|
||||
self.Energy = self.Energy - c.cost
|
||||
if c.kind == "Attack" then
|
||||
if c.damage ~= nil then
|
||||
self:DealDamageToEnemy(c.damage)
|
||||
self:DealDamageToTarget(c.damage)
|
||||
end
|
||||
self:ApplyRelics("cardPlayed")
|
||||
elseif c.kind == "Skill" then
|
||||
@@ -1787,16 +1856,39 @@ self:RenderPiles()
|
||||
self:RenderCombat()
|
||||
self:CheckCombatEnd()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
|
||||
method('DealDamageToEnemy', `local dmg = amount
|
||||
if self.EnemyBlock > 0 then
|
||||
local absorbed = math.min(self.EnemyBlock, dmg)
|
||||
self.EnemyBlock = self.EnemyBlock - absorbed
|
||||
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
|
||||
self.EnemyHp = self.EnemyHp - dmg
|
||||
if self.EnemyHp < 0 then
|
||||
self.EnemyHp = 0
|
||||
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
|
||||
m.entity:SetVisible(false)
|
||||
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' }]),
|
||||
method('DealDamageToPlayer', `local dmg = amount
|
||||
if self.PlayerBlock > 0 then
|
||||
local absorbed = math.min(self.PlayerBlock, dmg)
|
||||
@@ -1807,21 +1899,30 @@ self.PlayerHp = self.PlayerHp - dmg
|
||||
if self.PlayerHp < 0 then
|
||||
self.PlayerHp = 0
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
|
||||
method('EnemyTurn', `self.EnemyBlock = 0
|
||||
local intent = self.EnemyIntents[self.EnemyIntentIndex]
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
self:DealDamageToPlayer(intent.value)
|
||||
elseif intent.kind == "Defend" then
|
||||
self.EnemyBlock = self.EnemyBlock + intent.value
|
||||
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.EnemyIntentIndex = self.EnemyIntentIndex + 1
|
||||
if self.EnemyIntentIndex > #self.EnemyIntents then
|
||||
self.EnemyIntentIndex = 1
|
||||
end
|
||||
self:RenderCombat()`),
|
||||
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
|
||||
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")
|
||||
@@ -1854,22 +1955,54 @@ local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result
|
||||
if entity ~= nil then
|
||||
entity.Enable = true
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
|
||||
method('RenderCombat', `self:SetText("/ui/DefaultGroup/CombatHud/EnemyName", self.EnemyName)
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/EnemyHp", "HP " .. string.format("%d", self.EnemyHp) .. "/" .. string.format("%d", self.EnemyMaxHp))
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/EnemyBlock", "방어 " .. string.format("%d", self.EnemyBlock))
|
||||
local intent = self.EnemyIntents[self.EnemyIntentIndex]
|
||||
local intentText = ""
|
||||
if intent ~= nil then
|
||||
if intent.kind == "Attack" then
|
||||
intentText = "의도: 공격 " .. tostring(intent.value)
|
||||
elseif intent.kind == "Defend" then
|
||||
intentText = "의도: 방어 " .. tostring(intent.value)
|
||||
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/EnemyIntent", intentText)
|
||||
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()`),
|
||||
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 = ${HP_BAR_W} * 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' },
|
||||
]),
|
||||
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' }]),
|
||||
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' }]),
|
||||
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`),
|
||||
method('OfferReward', `local pool = {}
|
||||
|
||||
76
tools/monster/gen-combat-monster.mjs
Normal file
76
tools/monster/gen-combat-monster.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
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) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
const isMonster = (e) => typeof e.componentNames === 'string' && 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 comps = e.jsonString && e.jsonString['@components'];
|
||||
if (!Array.isArray(comps)) {
|
||||
console.warn(`[gen-combat-monster] entity "${(e.jsonString && e.jsonString.name) || e.path}" has no @components — skipped`);
|
||||
continue;
|
||||
}
|
||||
const name = (e.jsonString && e.jsonString.name) || '';
|
||||
const enemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY;
|
||||
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(', '));
|
||||
Reference in New Issue
Block a user