231 lines
14 KiB
JavaScript
231 lines
14 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
||
|
||
const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||
|
||
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
|
||
const CLASSES = {
|
||
warrior: { label: '전사', maxHp: 80 },
|
||
bandit: { label: '도적', maxHp: 70 },
|
||
magician: { label: '마법사', maxHp: 70 },
|
||
};
|
||
for (const cls of Object.keys(CLASSES)) {
|
||
if (!CARDS.starterDecks?.[cls]) throw new Error(`[gen-slaydeck] starterDecks.${cls} 없음`);
|
||
for (const id of CARDS.starterDecks[cls]) {
|
||
if (!CARDS.cards[id]) throw new Error(`[gen-slaydeck] starterDecks.${cls}에 없는 카드 id 참조: ${id}`);
|
||
}
|
||
}
|
||
// 전직 옵션 (클래스별 2차 — JobSelectHud 동적 구성·SetJob 대표 카드)
|
||
const JOBS = {
|
||
warrior: [
|
||
{ id: 'fighter', name: '파이터', desc: '공격 특화\n콤보 어택 · 버서크\n라이징 어택', starter: 'ComboAttack' },
|
||
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge' },
|
||
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce' },
|
||
],
|
||
magician: [
|
||
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow' },
|
||
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\n썬더 볼트(전체)\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt' },
|
||
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal' },
|
||
],
|
||
bandit: [
|
||
{ id: 'shiv', name: 'Shiv', desc: 'Many small attacks\nBlade Dance\nAccuracy · After Image', starter: 'BladeDance' },
|
||
{ id: 'poisoner', name: 'Poison', desc: 'Poison scaling\nDeadly Poison\nCatalyst · Noxious Fumes', starter: 'DeadlyPoison' },
|
||
{ id: 'trickster', name: 'Trickster', desc: 'Draw and tempo\nAcrobatics\nAdrenaline · Tools', starter: 'Acrobatics' },
|
||
],
|
||
};
|
||
for (const [cls, jobs] of Object.entries(JOBS)) {
|
||
for (const j of jobs) {
|
||
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
|
||
}
|
||
}
|
||
// 영혼(soul) 메타 해금 — 2차 전직 후 보스 클리어로 영혼 적립, 로비 영혼상점에서 구매 → 다음 런 이점
|
||
const SOUL_UNLOCKS = [
|
||
{ key: 'meso', name: '두둑한 지갑', desc: '런 시작 시 메소 +60', cost: 3 },
|
||
{ key: 'hp', name: '단련된 육체', desc: '시작 최대 HP +15', cost: 4 },
|
||
{ key: 'trim', name: '덱 정제', desc: '시작 덱에서 기본 카드 1장 제거', cost: 5 },
|
||
{ key: 'relic', name: '유물 수집가', desc: '런 시작 시 유물 1개 추가', cost: 6 },
|
||
];
|
||
function luaSoulShopTable(unlocks) {
|
||
const items = unlocks.map((u) => `\t{ key = ${luaStr(u.key)}, name = ${luaStr(u.name)}, desc = ${luaStr(u.desc)}, cost = ${u.cost} },`).join('\n');
|
||
return `self.SoulShopDef = {\n${items}\n}`;
|
||
}
|
||
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
|
||
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
|
||
}
|
||
|
||
// 카드 프레임 (사용자 제작 이미지 — 로컬 임포트 .sprite RUID, 직업 3종 × 등급 3종)
|
||
const CARDFRAMES = JSON.parse(readFileSync('data/cardframes.json', 'utf8'));
|
||
const RARITIES = ['normal', 'unique', 'legend'];
|
||
for (const [fid, fr] of Object.entries(CARDFRAMES.frames)) {
|
||
for (const r of RARITIES) {
|
||
if (!fr[r]) throw new Error(`[gen-slaydeck] cardframes.frames.${fid}.${r} RUID 없음`);
|
||
}
|
||
}
|
||
for (const [id, c] of Object.entries(CARDS.cards)) {
|
||
if (!RARITIES.includes(c.rarity)) throw new Error(`[gen-slaydeck] 카드 ${id} rarity 누락/오류: ${c.rarity}`);
|
||
const fc = CARDFRAMES.classToFrame[c.class];
|
||
if (!fc || !CARDFRAMES.frames[fc]) throw new Error(`[gen-slaydeck] 카드 ${id} class ${c.class} → 프레임 매핑 없음`);
|
||
}
|
||
function frameRuid(card) {
|
||
return CARDFRAMES.frames[CARDFRAMES.classToFrame[card.class]][card.rarity];
|
||
}
|
||
function luaFramesTable() {
|
||
const frames = Object.entries(CARDFRAMES.frames).map(([fid, fr]) =>
|
||
`\t${fid} = { normal = ${luaStr(fr.normal)}, unique = ${luaStr(fr.unique)}, legend = ${luaStr(fr.legend)} },`).join('\n');
|
||
const cls = Object.entries(CARDFRAMES.classToFrame).map(([c, f]) => `\t${c} = ${luaStr(f)},`).join('\n');
|
||
return `self.CardFrames = {\n${frames}\n}\nself.ClassToFrame = {\n${cls}\n}`;
|
||
}
|
||
function luaNodeIconsTable() {
|
||
const rows = Object.entries(NODEICONS.icons).map(([t, ruid]) => `\t${t} = ${luaStr(ruid)},`).join('\n');
|
||
return `self.NodeIcons = {\n${rows}\n}`;
|
||
}
|
||
function luaCharsTable() {
|
||
const rows = Object.entries(CHARS.portraits).map(([c, ruid]) => `\t${c} = ${luaStr(ruid)},`).join('\n');
|
||
return `self.ClassPortraits = {\n${rows}\n}`;
|
||
}
|
||
|
||
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
|
||
const MAP_ROWS = 6; // 걷는 행 1..6, 보스 row 7 (depth 최대 7)
|
||
const MAP_COLS = 4;
|
||
|
||
// 보물 상자 스프라이트 (공식 maplestory 리소스, 메이커 선별)
|
||
const CHEST_CLOSED_RUID = '43df67920c0d43298e0d93c02c6afa71';
|
||
const CHEST_OPEN_RUID = '09c5cee56fd640bf8ae3a18ce50f4759';
|
||
|
||
// 노드 맵 아이콘/배경 (공식 maplestory RUID, data/nodeicons.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||
const NODEICONS = JSON.parse(readFileSync('data/nodeicons.json', 'utf8'));
|
||
for (const t of ['combat', 'elite', 'boss', 'shop', 'rest', 'treasure']) {
|
||
if (!/^[0-9a-f]{32}$/.test((NODEICONS.icons || {})[t] || '')) throw new Error(`[gen-slaydeck] nodeicons.json icons.${t} RUID 누락/형식오류`);
|
||
}
|
||
if (!/^[0-9a-f]{32}$/.test(NODEICONS.background || '')) throw new Error('[gen-slaydeck] nodeicons.json background RUID 누락/형식오류');
|
||
|
||
// 캐릭터 선택 초상화 (메이커 임포트 RUID, data/characters.json 단일 소스 — 교체 시 이 파일만 수정 후 재생성)
|
||
const CHARS = JSON.parse(readFileSync('data/characters.json', 'utf8'));
|
||
for (const c of ['warrior', 'magician', 'bandit']) {
|
||
if (!/^[0-9a-f]{32}$/.test((CHARS.portraits || {})[c] || '')) throw new Error(`[gen-slaydeck] characters.json portraits.${c} RUID 누락/형식오류`);
|
||
}
|
||
|
||
// 전투 카메라 고정값(StS2: 플레이어 좌·몬스터 우). KickCombatCamera가 StartCombat에서 재confine에 사용.
|
||
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
|
||
|
||
const RELICS = JSON.parse(readFileSync('data/relics.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}`);
|
||
}
|
||
function luaRelicsTable(relics) {
|
||
const lines = Object.entries(relics).map(([id, r]) =>
|
||
`\t${id} = { name = ${luaStr(r.name)}, desc = ${luaStr(r.desc)}, hook = ${luaStr(r.hook)}, effect = ${luaStr(r.effect)}, value = ${r.value}, icon = ${luaStr(r.icon || '')} },`);
|
||
return `self.Relics = {\n${lines.join('\n')}\n}`;
|
||
}
|
||
|
||
const POTIONS = JSON.parse(readFileSync('data/potions.json', 'utf8'));
|
||
for (const [pid, p] of Object.entries(POTIONS.potions)) {
|
||
if (!p.name || !p.effect || p.value == null) throw new Error(`[gen-slaydeck] potion 필드 누락: ${pid}`);
|
||
}
|
||
function luaPotionsTable(potions) {
|
||
const lines = Object.entries(potions).map(([id, p]) =>
|
||
`\t${id} = { name = ${luaStr(p.name)}, desc = ${luaStr(p.desc)}, effect = ${luaStr(p.effect)}, value = ${p.value}, icon = ${luaStr(p.icon || '')} },`);
|
||
return `self.Potions = {\n${lines.join('\n')}\n}`;
|
||
}
|
||
|
||
function luaIntentsArray(intents) {
|
||
return '{ ' + intents.map((it) => {
|
||
const fields = [`kind = ${luaStr(it.kind)}`, `value = ${it.value != null ? it.value : 0}`];
|
||
if (it.effect != null) fields.push(`effect = ${luaStr(it.effect)}`);
|
||
if (it.card != null) fields.push(`card = ${luaStr(it.card)}`);
|
||
if (it.count != null) fields.push(`count = ${it.count}`);
|
||
return `{ ${fields.join(', ')} }`;
|
||
}).join(', ') + ' }';
|
||
}
|
||
function luaEnemiesTable(enemies) {
|
||
const lines = Object.entries(enemies).map(([id, e]) =>
|
||
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
|
||
return `self.Enemies = {\n${lines.join('\n')}\n}`;
|
||
}
|
||
// Lua 직렬화 헬퍼
|
||
function luaStr(s) {
|
||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
||
}
|
||
function luaJobsTable(jobs) {
|
||
const cls = Object.entries(jobs).map(([clsId, list]) => {
|
||
const items = list.map((j) => `\t\t{ id = ${luaStr(j.id)}, name = ${luaStr(j.name)}, desc = ${luaStr(j.desc)}, starter = ${luaStr(j.starter)} },`).join('\n');
|
||
return `\t${clsId} = {\n${items}\n\t},`;
|
||
}).join('\n');
|
||
return `self.Jobs = {\n${cls}\n}`;
|
||
}
|
||
function luaCardsTable(cards) {
|
||
const lines = Object.entries(cards).map(([id, c]) => {
|
||
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
|
||
if (c.damage != null) fields.push(`damage = ${c.damage}`);
|
||
if (c.damagePerOtherHandCard != null) fields.push(`damagePerOtherHandCard = ${c.damagePerOtherHandCard}`);
|
||
if (c.damagePerAttackPlayedThisTurn != null) fields.push(`damagePerAttackPlayedThisTurn = ${c.damagePerAttackPlayedThisTurn}`);
|
||
if (c.damagePerDiscardedThisTurn != null) fields.push(`damagePerDiscardedThisTurn = ${c.damagePerDiscardedThisTurn}`);
|
||
if (c.damagePerSkillInHand != null) fields.push(`damagePerSkillInHand = ${c.damagePerSkillInHand}`);
|
||
if (c.damagePerTurn != null) fields.push(`damagePerTurn = ${c.damagePerTurn}`);
|
||
if (c.poisonPerTurn != null) fields.push(`poisonPerTurn = ${c.poisonPerTurn}`);
|
||
if (c.otherHandAtLeast != null) fields.push(`otherHandAtLeast = ${c.otherHandAtLeast}`);
|
||
if (c.bonusHitsWhenOtherHandAtLeast != null) fields.push(`bonusHitsWhenOtherHandAtLeast = ${c.bonusHitsWhenOtherHandAtLeast}`);
|
||
if (c.block != null) fields.push(`block = ${c.block}`);
|
||
if (c.blockGainMultiplier != null) fields.push(`blockGainMultiplier = ${c.blockGainMultiplier}`);
|
||
if (c.strength != null) fields.push(`strength = ${c.strength}`);
|
||
if (c.dex != null) fields.push(`dex = ${c.dex}`);
|
||
if (c.thorns != null) fields.push(`thorns = ${c.thorns}`);
|
||
if (c.cardPlayedBlock != null) fields.push(`cardPlayedBlock = ${c.cardPlayedBlock}`);
|
||
if (c.weak != null) fields.push(`weak = ${c.weak}`);
|
||
if (c.vuln != null) fields.push(`vuln = ${c.vuln}`);
|
||
if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`);
|
||
if (c.value != null) fields.push(`value = ${c.value}`);
|
||
if (!c.class) throw new Error(`[gen-slaydeck] 카드 ${id}에 class 누락`);
|
||
fields.push(`class = ${luaStr(c.class)}`);
|
||
fields.push(`rarity = ${luaStr(c.rarity)}`);
|
||
if (c.hits != null) fields.push(`hits = ${c.hits}`);
|
||
if (c.pierce === true) fields.push('pierce = true');
|
||
if (c.selfVuln != null) fields.push(`selfVuln = ${c.selfVuln}`);
|
||
if (c.draw != null) fields.push(`draw = ${c.draw}`);
|
||
if (c.drawUntilHandSize != null) fields.push(`drawUntilHandSize = ${c.drawUntilHandSize}`);
|
||
if (c.drawSkillBlock != null) fields.push(`drawSkillBlock = ${c.drawSkillBlock}`);
|
||
if (c.heal != null) fields.push(`heal = ${c.heal}`);
|
||
if (c.gainEnergy != null) fields.push(`gainEnergy = ${c.gainEnergy}`);
|
||
if (c.poison != null) fields.push(`poison = ${c.poison}`);
|
||
if (c.discard != null) fields.push(`discard = ${c.discard}`);
|
||
if (c.discardAll === true) fields.push('discardAll = true');
|
||
if (c.drawPerDiscarded != null) fields.push(`drawPerDiscarded = ${c.drawPerDiscarded}`);
|
||
if (c.addShiv != null) fields.push(`addShiv = ${c.addShiv}`);
|
||
if (c.turnStartShiv != null) fields.push(`turnStartShiv = ${c.turnStartShiv}`);
|
||
if (c.turnStartDraw != null) fields.push(`turnStartDraw = ${c.turnStartDraw}`);
|
||
if (c.turnStartDiscard != null) fields.push(`turnStartDiscard = ${c.turnStartDiscard}`);
|
||
if (c.handCostZeroThisTurn === true) fields.push('handCostZeroThisTurn = true');
|
||
if (c.drawDisabledThisTurn === true) fields.push('drawDisabledThisTurn = true');
|
||
if (c.addShivPerDiscard === true) fields.push('addShivPerDiscard = true');
|
||
if (c.nextTurnBlock != null) fields.push(`nextTurnBlock = ${c.nextTurnBlock}`);
|
||
if (c.nextTurnDraw != null) fields.push(`nextTurnDraw = ${c.nextTurnDraw}`);
|
||
if (c.nextTurnKeepBlock === true) fields.push('nextTurnKeepBlock = true');
|
||
if (c.nextTurnAttackMultiplier != null) fields.push(`nextTurnAttackMultiplier = ${c.nextTurnAttackMultiplier}`);
|
||
if (c.nextTurnCopies != null) fields.push(`nextTurnCopies = ${c.nextTurnCopies}`);
|
||
if (c.nextTurnSelectHandCard === true) fields.push('nextTurnSelectHandCard = true');
|
||
if (c.nextTurnSelectPrompt != null) fields.push(`nextTurnSelectPrompt = ${luaStr(c.nextTurnSelectPrompt)}`);
|
||
if (c.nextSkillCostZero === true) fields.push('nextSkillCostZero = true');
|
||
if (c.skillCostReductionThisTurn != null) fields.push(`skillCostReductionThisTurn = ${c.skillCostReductionThisTurn}`);
|
||
if (c.innate === true) fields.push('innate = true');
|
||
if (c.playableWhenDrawPileEmpty === true) fields.push('playableWhenDrawPileEmpty = true');
|
||
if (c.sly === true) fields.push('sly = true');
|
||
if (c.retain === true) fields.push('retain = true');
|
||
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) fields.push('exhaust = true');
|
||
if (c.aoe === true) fields.push('aoe = true');
|
||
if (c.unplayable === true) fields.push('unplayable = true');
|
||
if (c.curse === true) fields.push('curse = true');
|
||
if (c.token === true) fields.push('token = true');
|
||
if (c.endTurnDamage != null) fields.push(`endTurnDamage = ${c.endTurnDamage}`);
|
||
if (c.fx != null) fields.push(`fx = ${luaStr(c.fx)}`);
|
||
if (c.image != null) fields.push(`image = ${luaStr(c.image)}`);
|
||
return `\t${id} = { ${fields.join(', ')} },`;
|
||
});
|
||
return `self.Cards = {\n${lines.join('\n')}\n}`;
|
||
}
|
||
function luaDeckTable(deck) {
|
||
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
|
||
}
|
||
|
||
export { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, luaSoulShopTable, CARDFRAMES, RARITIES, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, luaRelicsTable, POTIONS, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable };
|