베이스팩의 vanilla 셰이더는 manifest 의 mcVersion(resolved.format) 이 64 이하 라도, 우리가 supported_formats/max_format 으로 1.21.9+ 까지 호환을 선언하면 새 GLSL API 환경에서 로드돼 "리소스 새로고침 실패" 가 다시 난다. 셰이더 제거 판정 기준을 resolved.format > 64 에서 maxFmt > 64 로 옮기고, 그 계산을 mcmeta 작성보다 먼저 수행한다. 로그의 format 값도 maxFmt 를 표시해 어떤 호환 상한 때문에 제거됐는지 추적 가능하게 했다. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
172 lines
7.3 KiB
TypeScript
172 lines
7.3 KiB
TypeScript
import { promises as fs, createWriteStream } from 'node:fs'
|
|
import path from 'node:path'
|
|
import archiver from 'archiver'
|
|
import extract from 'extract-zip'
|
|
import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } from './packFormat.js'
|
|
import { loadComponentI18n } from '../shared/i18n.js'
|
|
|
|
const { t } = loadComponentI18n('installer-rp')
|
|
|
|
const NAMESPACE = 'musicquiz'
|
|
|
|
export interface BuildResourcepackOptions {
|
|
/** ogg 음악 파일들이 들어 있는 폴더 (01.ogg, 02.ogg, …). */
|
|
musicDir: string
|
|
/** cover_NN.png 파일들이 들어 있는 폴더. */
|
|
paintingDir: string
|
|
/** pack.mcmeta 의 description 에 들어갈 표시 이름. */
|
|
packName: string
|
|
/** /manifest/<key>.json 의 mcVersion. pack_format 결정용. */
|
|
mcVersion: string
|
|
/** 작업 폴더(임시). 이 안에 트리를 펼친 뒤 zip 생성. */
|
|
workDir: string
|
|
/** 최종 zip 출력 경로. */
|
|
outZipPath: string
|
|
/**
|
|
* 베이스 리소스팩 zip 경로 (선택). 지정하면 이 zip 의 내용을 먼저 풀고
|
|
* 그 위에 음악·사진·sounds.json·pack.mcmeta 를 덮어/병합한다.
|
|
*/
|
|
baseZipPath?: string
|
|
/** 진단용 로그 콜백 (선택). */
|
|
log?: (line: string) => void
|
|
}
|
|
|
|
/**
|
|
* 임시 폴더에 리소스팩 트리를 펼치고, archiver 로 zip 으로 묶어 outZipPath 에 저장.
|
|
*
|
|
* 트리 구조:
|
|
* pack.mcmeta
|
|
* assets/musicquiz/sounds.json
|
|
* assets/musicquiz/sounds/track_NN.ogg ← musicDir/NN.ogg 에서 옮김
|
|
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
|
|
*/
|
|
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
|
|
const root = path.join(opts.workDir, 'resourcepack')
|
|
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
|
|
await fs.rm(root, { recursive: true, force: true })
|
|
await fs.mkdir(root, { recursive: true })
|
|
|
|
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
|
|
if (opts.baseZipPath) {
|
|
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
|
|
await extract(opts.baseZipPath, { dir: root })
|
|
}
|
|
|
|
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
|
|
const paintingOutDir = path.join(root, 'assets', NAMESPACE, 'textures', 'painting')
|
|
await fs.mkdir(soundsDir, { recursive: true })
|
|
await fs.mkdir(paintingOutDir, { recursive: true })
|
|
|
|
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
|
|
const resolved = resolveResourcePackFormat(opts.mcVersion)
|
|
if (resolved.matched) {
|
|
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
|
|
} else {
|
|
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
|
|
}
|
|
|
|
// 호환 범위는 1.21.6 (=MIN_SUPPORTED_FORMAT) 부터 알려진 최신까지 선언한다.
|
|
// 빌드 타깃이 LATEST_KNOWN_FORMAT 보다 높으면(테이블 갱신 전 신버전) 그 값까지 확장.
|
|
// (셰이더 제거 판정에도 maxFmt 를 쓰므로 mcmeta 작성보다 먼저 계산해 둔다.)
|
|
const minFmt = Math.min(MIN_SUPPORTED_FORMAT, resolved.format)
|
|
const maxFmt = Math.max(LATEST_KNOWN_FORMAT, resolved.format)
|
|
|
|
// 1-a) 선언 호환 범위의 max 가 64 를 넘으면(=1.21.9+ 클라이언트에서도 로드 가능)
|
|
// 구버전 베이스팩의 assets/minecraft/shaders/* 가 새 GLSL API 와 충돌해 컴파일에
|
|
// 실패한다. 결과적으로 "리소스 새로고침 실패" 가 다시 뜨므로, 이 경우엔 해당
|
|
// 디렉터리를 결과 zip 에서 제거한다. 텍스처/모델 등 나머지 자산은 그대로 유지.
|
|
if (opts.baseZipPath && maxFmt > 64) {
|
|
const vanillaShaderDir = path.join(root, 'assets', 'minecraft', 'shaders')
|
|
try {
|
|
const stat = await fs.stat(vanillaShaderDir)
|
|
if (stat.isDirectory()) {
|
|
const entries = await fs.readdir(vanillaShaderDir)
|
|
if (entries.length > 0) {
|
|
await fs.rm(vanillaShaderDir, { recursive: true, force: true })
|
|
opts.log?.(t('log.baseShaderOverrideStripped', {
|
|
path: entries.join(', '),
|
|
mc: opts.mcVersion,
|
|
format: maxFmt
|
|
}))
|
|
}
|
|
}
|
|
} catch {
|
|
// 없으면 정상. 무시.
|
|
}
|
|
}
|
|
// pack_format <= 64 인 MC 는 supported_formats 를, > 64 인 MC 는 min_format/max_format 을
|
|
// 읽는다. 어느 한쪽만 두면 반대편 클라이언트에서 거부되므로 양쪽 모두 기록한다.
|
|
const packMeta: Record<string, unknown> = {
|
|
description: t('pack.description', { name: opts.packName }),
|
|
pack_format: resolved.format,
|
|
supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt },
|
|
min_format: minFmt,
|
|
max_format: maxFmt
|
|
}
|
|
const mcmeta = { pack: packMeta }
|
|
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
|
|
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
|
|
|
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
|
const musicFiles = (await fs.readdir(opts.musicDir))
|
|
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
|
.sort()
|
|
// 베이스의 sounds.json 이 있으면 읽어서 우리 트랙을 덧붙인다.
|
|
const soundsJsonPath = path.join(root, 'assets', NAMESPACE, 'sounds.json')
|
|
let soundsJson: Record<string, unknown> = {}
|
|
try {
|
|
const existing = await fs.readFile(soundsJsonPath, 'utf8')
|
|
const parsed = JSON.parse(existing)
|
|
if (parsed && typeof parsed === 'object') {
|
|
soundsJson = parsed as Record<string, unknown>
|
|
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
|
|
}
|
|
} catch {
|
|
// 없으면 새로 생성.
|
|
}
|
|
for (const fname of musicFiles) {
|
|
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
|
const stem = path.basename(fname, path.extname(fname)) // "01"
|
|
const trackId = `track_${stem}`
|
|
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`))
|
|
soundsJson[trackId] = {
|
|
sounds: [
|
|
{ name: `${NAMESPACE}:${trackId}`, stream: true }
|
|
]
|
|
}
|
|
}
|
|
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
|
|
|
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
|
|
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
|
.filter((n) => n.toLowerCase().endsWith('.png'))
|
|
.sort()
|
|
for (const fname of paintingFiles) {
|
|
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
|
|
}
|
|
|
|
// 4) zip 으로 묶기
|
|
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
|
|
await zipDirectory(root, opts.outZipPath)
|
|
|
|
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
|
|
}
|
|
|
|
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const output = createWriteStream(outZipPath)
|
|
const archive = archiver('zip', { zlib: { level: 9 } })
|
|
output.on('close', () => resolve())
|
|
output.on('error', reject)
|
|
archive.on('warning', (err: Error & { code?: string }) => {
|
|
// ENOENT 정도면 무시, 그 외는 reject.
|
|
if (err.code === 'ENOENT') return
|
|
reject(err)
|
|
})
|
|
archive.on('error', reject)
|
|
archive.pipe(output)
|
|
archive.directory(srcDir, false)
|
|
archive.finalize().catch(reject)
|
|
})
|
|
}
|