From 6e82d0f128bc873e9f7c0059a58d1452f24e0659 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 13:55:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20=EC=9D=B8=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20encounters.json=20=EB=A1=9C=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EA=B8=B0=EB=B0=98=20=EB=AA=A8=EB=8D=B8=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EB=B0=B0=EC=B9=98=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MONSTER_VARIANTS 랜덤 외형 제거(외형=enemies.json appearance로 정체성 고정). buildMonsterInstance로 종별 모델(monster-) 인스턴스 배치, 준비도 가드로 appearance 미보유 로스터 맵은 보존(Task 2 RUID 수확 후 재생성). gen-combat-monster는 codeblock 생성만(맵 부착은 encounters 생성기로 흡수). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011xhLoQbJvQYL65kBtDNDTy --- tools/map/gen-map-encounters.mjs | 131 ++++++++------------------- tools/monster/gen-combat-monster.mjs | 46 +--------- 2 files changed, 44 insertions(+), 133 deletions(-) diff --git a/tools/map/gen-map-encounters.mjs b/tools/map/gen-map-encounters.mjs index ffc7694..16dc8f6 100644 --- a/tools/map/gen-map-encounters.mjs +++ b/tools/map/gen-map-encounters.mjs @@ -1,108 +1,55 @@ import { readFileSync, writeFileSync } from 'node:fs'; +import { buildMonsterInstance } from '../monster/lib/monster-model.mjs'; -// map02~11에 노드 타입별 몬스터 그룹(combat3/elite2/boss1)을 맵별 테마로 자동 구성. -// 기존 몬스터 엔티티를 전부 제거하고 첫 몬스터를 템플릿으로 6마리 재생성(결정론). -const MAP_NUMBERS = [1, 2, 3, 4, 5]; -const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump']; -const ELITE_POOL = ['mushmom', 'modified_snail']; -const BOSS_POOL = ['king_slime', 'slime_boss']; -// 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' }, -]; +// map01~05에 data/encounters.json 로스터대로 종별 모델 인스턴스를 배치(결정론). +// 기존 몬스터 엔티티 전부 제거 후 로스터 전체를 그룹별 x 균등 분포로 재생성. +// 준비도 가드: 로스터에 appearance 미보유 적이 있는 맵은 재생성을 건너뛴다(기존 맵 보존). +const enemies = JSON.parse(readFileSync('data/enemies.json', 'utf8')).enemies; +const encounters = JSON.parse(readFileSync('data/encounters.json', 'utf8')); +const X_RANGE = { combat: [2.3, 6.6], elite: [3.0, 5.6], boss: [4.6, 4.6] }; -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) { const n = (nn * 1000 + 500 + idx) >>> 0; - const h8 = n.toString(16).padStart(8, '0'); - const h12 = n.toString(16).padStart(12, '0'); - return `${h8}-0000-4000-8000-${h12}`; + return `${n.toString(16).padStart(8, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; } -const isMonster = (e) => typeof e.componentNames === 'string' && e.componentNames.includes('script.Monster'); -const compOf = (e, t) => e.jsonString['@components'].find((c) => c['@type'] === t); - -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 slotX(group, i, count) { + const [lo, hi] = X_RANGE[group]; + return count <= 1 ? (lo + hi) / 2 : lo + (i * (hi - lo)) / (count - 1); } function patchMap(nn) { const tag = String(nn).padStart(2, '0'); 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 ents = map.ContentProto.Entities; - const monsters = ents.filter(isMonster); - if (monsters.length === 0) throw new Error(`[gen-map-encounters] ${file} 몬스터 템플릿 없음`); - const template = monsters[0]; - map.ContentProto.Entities = ents.filter((e) => !isMonster(e)); - const rand = rng(nn * 7919 + 17); - const layout = layoutFor(nn); - const nCombat = layout.filter((s) => s.group === 'combat').length; - const nElite = layout.filter((s) => s.group === 'elite').length; - const combatIds = pickN(rand, COMBAT_POOL, nCombat); - const eliteIds = pickN(rand, ELITE_POOL, nElite); - const bossId = pick(rand, BOSS_POOL); - 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); - }); + map.ContentProto.Entities = map.ContentProto.Entities.filter((e) => !isMonster(e)); + const nameCount = {}; + let idx = 0; + for (const group of ['combat', 'elite', 'boss']) { + const ids = roster[group] || []; + ids.forEach((enemyId, i) => { + nameCount[enemyId] = (nameCount[enemyId] || 0) + 1; + const name = nameCount[enemyId] > 1 ? `${enemyId}_${nameCount[enemyId]}` : enemyId; + map.ContentProto.Entities.push(buildMonsterInstance({ + enemyId, enemy: enemies[enemyId], name, guid: encGuid(nn, idx), mapTag: tag, x: slotX(group, i, ids.length), group, + })); + idx += 1; + }); + } 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(', ')); diff --git a/tools/monster/gen-combat-monster.mjs b/tools/monster/gen-combat-monster.mjs index dbfd959..df14200 100644 --- a/tools/monster/gen-combat-monster.mjs +++ b/tools/monster/gen-combat-monster.mjs @@ -1,10 +1,8 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; -// 맵 몬스터에 적 타입(EnemyId)을 부여하고, BeginPlay 시 /common 컨트롤러에 자기등록하는 마커. -// 카드 전투 시 컨트롤러가 등록 목록으로 인카운터를 구성한다. -const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05 -const NAME_TO_ENEMY = { '주황버섯': 'orange_mushroom', '파란버섯': 'blue_mushroom' }; -const DEFAULT_ENEMY = 'orange_mushroom'; +// 카드 전투용 자기등록 마커 codeblock(CombatMonster) 생성. +// BeginPlay 시 /common 컨트롤러에 자기등록해 인카운터를 구성한다. +// 맵 부착 값(EnemyId/Group)은 gen-map-encounters.mjs가 인스턴스에 직접 기록한다. function prop(Type, Name, DefaultValue = 'nil') { 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'); } -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(); -const patched = MAP_NUMBERS.map(patchMap); -console.log('CombatMonster codeblock written; patched maps:', patched.join(', ')); +console.log('CombatMonster codeblock written.');