feat(map): 인카운터를 encounters.json 로스터 기반 모델 인스턴스 배치로 개편

MONSTER_VARIANTS 랜덤 외형 제거(외형=enemies.json appearance로 정체성 고정).
buildMonsterInstance로 종별 모델(monster-<id>) 인스턴스 배치, 준비도 가드로
appearance 미보유 로스터 맵은 보존(Task 2 RUID 수확 후 재생성).
gen-combat-monster는 codeblock 생성만(맵 부착은 encounters 생성기로 흡수).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xhLoQbJvQYL65kBtDNDTy
This commit is contained in:
2026-07-02 13:55:46 +09:00
parent 1390b9ec50
commit 6e82d0f128
2 changed files with 44 additions and 133 deletions

View File

@@ -1,108 +1,55 @@
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { buildMonsterInstance } from '../monster/lib/monster-model.mjs';
// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성. // map01~05에 data/encounters.json 로스터대로 종별 모델 인스턴스를 배치(결정론).
// 기존 몬스터 엔티티 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론). // 기존 몬스터 엔티티 전부 제거 후 로스터 전체를 그룹별 x 균등 분포로 재생성.
const MAP_NUMBERS = [1, 2, 3, 4, 5]; // 준비도 가드: 로스터에 appearance 미보유 적이 있는 맵은 재생성을 건너뛴다(기존 맵 보존).
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump']; const enemies = JSON.parse(readFileSync('data/enemies.json', 'utf8')).enemies;
const ELITE_POOL = ['mushmom', 'modified_snail']; const encounters = JSON.parse(readFileSync('data/encounters.json', 'utf8'));
const BOSS_POOL = ['king_slime', 'slime_boss']; const X_RANGE = { combat: [2.3, 6.6], elite: [3.0, 5.6], boss: [4.6, 4.6] };
// map01: StS2식 일반 5종 + 엘리트 1 + 보스 1(보스 노드용, 화면 우측 포메이션).
// 그 외 맵: 일반 3 + 엘리트 2 + 보스 1. 전투 시 BuildMonsters가 노드 타입별로 1~3마리 랜덤 추첨.
const LAYOUT_MAP01 = [
{ group: 'combat', x: 2.6 }, { group: 'combat', x: 3.6 }, { group: 'combat', x: 4.6 },
{ group: 'combat', x: 5.6 }, { group: 'combat', x: 6.6 },
{ group: 'elite', x: 4.6 },
{ group: 'boss', x: 4.6 },
];
const LAYOUT_DEFAULT = [
{ group: 'combat', x: 2.3 }, { group: 'combat', x: 3.8 }, { group: 'combat', x: 5.2 },
{ group: 'elite', x: 3.0 }, { group: 'elite', x: 5.0 },
{ group: 'boss', x: 4.0 },
];
const layoutFor = (nn) => (nn === 1 ? LAYOUT_MAP01 : LAYOUT_DEFAULT);
const MONSTER_VARIANTS = [
{ sprite: '96e955c1bf27415e84f96deea200a8f1', stand: '96e955c1bf27415e84f96deea200a8f1', hit: 'aec9504d5dc24aceb5646b79d30abad4', die: '65a2bfb039614f2e9e4ccc354340153d' },
{ sprite: 'f86992ba9c41487c8480fcb893fcbda6', stand: 'f86992ba9c41487c8480fcb893fcbda6', hit: 'd305b942b1704c8084548108ff3b7a6b', die: '5a563e5fd98c4132b61057dc6bb8aaf2' },
{ sprite: 'a2204a21d88942b281d2cac6053ffbaa', stand: 'a2204a21d88942b281d2cac6053ffbaa', hit: 'afc08936b8a64b26bc3dd8c03ead1f26', die: 'fc1c6d9ba9bc413ab53b6dbfae3ac45b' },
{ sprite: 'd8f014043ce8418f96700c2b6c9ebf6c', stand: 'd8f014043ce8418f96700c2b6c9ebf6c', hit: 'c3cf643b618346c7bfa6574187b396f9', die: 'a88d9b3d60f941e4890dc89a6ccaa8ee' },
{ sprite: '17b55730c26f4fd6b8fcfa288da388de', stand: '17b55730c26f4fd6b8fcfa288da388de', hit: 'eac48e84a9fc4580a4018de5cf52ddb3', die: '51c2f4b59a2c413db26035aa57002fc8' },
{ sprite: '48c10437ae8344a9b2a1d3f36185728f', stand: '48c10437ae8344a9b2a1d3f36185728f', hit: '9044063647854f5e9128efcf80e909be', die: 'f414577d18c94cc387c275df4abdbc3b' },
{ sprite: '4ca39dbfa1c6492283ba8bd352d12b0a', stand: '4ca39dbfa1c6492283ba8bd352d12b0a', hit: '7ac78511036e4ebe988b97c35fc275d1', die: '740f3f2b2e7a4b71bec5eac84e8539f9' },
{ sprite: 'ed3908e24d694bb786023fc1ed073489', stand: 'ed3908e24d694bb786023fc1ed073489', hit: '4763c9bebc9245998c9c499b6316aa9f', die: 'b168793b92a844a3a3a6f4ce647a14d2' },
{ sprite: '3109357701ae41a4bcc7543f52f1f4c3', stand: '3109357701ae41a4bcc7543f52f1f4c3', hit: 'ce0269079e884545b5bb6ea075e2a67f', die: 'a5e65650e00e47878cac1be7a5b999a0' },
];
function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster');
function encGuid(nn, idx) { function encGuid(nn, idx) {
const n = (nn * 1000 + 500 + idx) >>> 0; const n = (nn * 1000 + 500 + idx) >>> 0;
const h8 = n.toString(16).padStart(8, '0'); return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
const h12 = n.toString(16).padStart(12, '0');
return `${h8}-0000-4000-8000-${h12}`;
} }
const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster'); function slotX(group, i, count) {
const compOf = (e, t) => e.jsonString['@components'].find((c) => c['@type'] === t); const [lo, hi] = X_RANGE[group];
return count <= 1 ? (lo + hi) / 2 : lo + (i * (hi - lo)) / (count - 1);
function pick(rand, pool) { return pool[Math.floor(rand() * pool.length)]; }
function pickN(rand, pool, n) {
const a = pool.slice();
const out = [];
for (let i = 0; i < n; i++) {
if (a.length === 0) a.push(...pool);
out.push(a.splice(Math.floor(rand() * a.length), 1)[0]);
}
return out;
} }
function patchMap(nn) { function patchMap(nn) {
const tag = String(nn).padStart(2, '0'); const tag = String(nn).padStart(2, '0');
const file = `map/map${tag}.map`; const file = `map/map${tag}.map`;
const roster = encounters[`map${tag}`];
if (!roster) throw new Error(`[gen-map-encounters] encounters.json에 map${tag} 없음`);
const rosterIds = ['combat', 'elite', 'boss'].flatMap((g) => roster[g] || []);
for (const id of rosterIds) {
if (!enemies[id]) throw new Error(`[gen-map-encounters] map${tag} 로스터에 없는 적: ${id}`);
}
// 준비도 가드: appearance 미보유 적이 하나라도 있으면 이 맵은 보존(스킵)
const missing = rosterIds.filter((id) => !enemies[id].appearance);
if (missing.length) return `map${tag}(SKIP: appearance 없음 ${[...new Set(missing)].join('/')})`;
const map = JSON.parse(readFileSync(file, 'utf8')); const map = JSON.parse(readFileSync(file, 'utf8'));
const ents = map.ContentProto.Entities; map.ContentProto.Entities = map.ContentProto.Entities.filter((e) => !isMonster(e));
const monsters = ents.filter(isMonster); const nameCount = {};
if (monsters.length === 0) throw new Error(`[gen-map-encounters] ${file} 몬스터 템플릿 없음`); let idx = 0;
const template = monsters[0]; for (const group of ['combat', 'elite', 'boss']) {
map.ContentProto.Entities = ents.filter((e) => !isMonster(e)); const ids = roster[group] || [];
const rand = rng(nn * 7919 + 17); ids.forEach((enemyId, i) => {
const layout = layoutFor(nn); nameCount[enemyId] = (nameCount[enemyId] || 0) + 1;
const nCombat = layout.filter((s) => s.group === 'combat').length; const name = nameCount[enemyId] > 1 ? `${enemyId}_${nameCount[enemyId]}` : enemyId;
const nElite = layout.filter((s) => s.group === 'elite').length; map.ContentProto.Entities.push(buildMonsterInstance({
const combatIds = pickN(rand, COMBAT_POOL, nCombat); enemyId, enemy: enemies[enemyId], name, guid: encGuid(nn, idx), mapTag: tag, x: slotX(group, i, ids.length), group,
const eliteIds = pickN(rand, ELITE_POOL, nElite); }));
const bossId = pick(rand, BOSS_POOL); idx += 1;
const variants = pickN(rand, MONSTER_VARIANTS, layout.length); });
let ci = 0, ei = 0; }
layout.forEach((slot, idx) => {
const m = JSON.parse(JSON.stringify(template));
const enemyId = slot.group === 'combat' ? combatIds[ci++] : slot.group === 'elite' ? eliteIds[ei++] : bossId;
const name = `${slot.group}_${idx + 1}`;
m.id = encGuid(nn, idx);
m.path = `/maps/map${tag}/${name}`;
m.jsonString.path = m.path;
m.jsonString.name = name;
const o = m.jsonString.origin;
if (o) { if (o.root_entity_id) o.root_entity_id = m.id; if (o.sub_entity_id) o.sub_entity_id = m.id; }
const tr = compOf(m, 'MOD.Core.TransformComponent');
if (tr && tr.Position) tr.Position.x = slot.x;
const v = variants[idx];
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = v.stand;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die };
let cm = compOf(m, 'script.CombatMonster');
if (!cm) {
cm = { '@type': 'script.CombatMonster', Enable: true };
m.jsonString['@components'].push(cm);
const names = (m.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster');
names.push('script.CombatMonster');
m.componentNames = names.join(',');
}
cm.EnemyId = enemyId;
cm.Group = slot.group;
map.ContentProto.Entities.push(m);
});
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}(${combatIds.join('/')}|${eliteIds.join('/')}|${bossId})`; const counts = ['combat', 'elite', 'boss'].map((g) => `${g}${(roster[g] || []).length}`).join('/');
return `map${tag}(${counts})`;
} }
const made = MAP_NUMBERS.map(patchMap); const made = [1, 2, 3, 4, 5].map(patchMap);
console.log('Encounters:', made.join(', ')); console.log('Encounters:', made.join(', '));

View File

@@ -1,10 +1,8 @@
import { readFileSync, writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
// 맵 몬스터에 적 타입(EnemyId)을 부여하고, BeginPlay 시 /common 컨트롤러에 자기등록하는 마커. // 카드 전투용 자기등록 마커 codeblock(CombatMonster) 생성.
// 카드 전투 시 컨트롤러가 등록 목록으로 인카운터를 구성한다. // BeginPlay 시 /common 컨트롤러에 자기등록해 인카운터를 구성한다.
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05 // 맵 부착 값(EnemyId/Group)은 gen-map-encounters.mjs가 인스턴스에 직접 기록한다.
const NAME_TO_ENEMY = { '주황버섯': 'orange_mushroom', '파란버섯': 'blue_mushroom' };
const DEFAULT_ENEMY = 'orange_mushroom';
function prop(Type, Name, DefaultValue = 'nil') { function prop(Type, Name, DefaultValue = 'nil') {
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name }; return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
@@ -49,39 +47,5 @@ eventId = _TimerService:SetTimerRepeat(reg, 0.1)`),
writeFileSync('RootDesk/MyDesk/CombatMonster.codeblock', JSON.stringify(cb, null, 2) + '\n', 'utf8'); 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 added = 0, kept = 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 existing = comps.find((c) => c['@type'] === 'script.CombatMonster');
if (existing) {
// 사용자가 메이커에서 설정한 값 보존 — 누락된 키만 기본값 채움
if (existing.Enable === undefined) existing.Enable = true;
if (existing.EnemyId == null) existing.EnemyId = NAME_TO_ENEMY[name] || DEFAULT_ENEMY;
if (existing.Group == null) existing.Group = 'combat';
kept++;
} else {
comps.push({ '@type': 'script.CombatMonster', Enable: true, EnemyId: NAME_TO_ENEMY[name] || DEFAULT_ENEMY, Group: 'combat' });
added++;
}
const names = (e.componentNames || '').split(',').filter((s) => s && s !== 'script.CombatMonster');
names.push('script.CombatMonster');
e.componentNames = names.join(',');
}
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}(+${added}/keep${kept})`;
}
writeCodeblock(); writeCodeblock();
const patched = MAP_NUMBERS.map(patchMap); console.log('CombatMonster codeblock written.');
console.log('CombatMonster codeblock written; patched maps:', patched.join(', '));