Build resource pack zip and drop it into .minecraft
Add archiver dep (v7 — v8 dropped the function-style default export
that the @types still describe) and a new src/installer-rp/pack.ts
that assembles the resource pack tree under tempDir/resourcepack/
and zips it to %appdata%/.minecraft/resourcepacks/<key>_musicquiz.zip.
The tree matches Minecraft 1.21+ painting variant + custom sound
conventions:
pack.mcmeta pack_format 34,
supported 34..75
assets/musicquiz/sounds.json stream:true per track
assets/musicquiz/sounds/track_NN.ogg from tempDir/music
assets/musicquiz/textures/painting/cover_NN.png from tempDir/painting
Music NN.ogg is renamed to track_NN.ogg at copy time so the sound
event ids stay readable. The painting_variant JSON definitions are
intentionally NOT generated here — those live in the data pack and
are owned by /op/datapack on the website.
Wire step 2-4 of the install IPC to call buildResourcepackZip with
the now-populated music/painting temp dirs. Step 2-5 is now just a
log line since buildResourcepackZip writes directly to the final
path.
Verified by a node smoke test: tempDir of two stub ogg files plus
two 256x256 PNGs produces a valid zip with the expected entries
and UTF-8 Korean strings in pack.mcmeta description.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
664
package-lock.json
generated
664
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@
|
||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ensureYtDlpExe } from './ytdlp.js'
|
||||
import { ensureFfmpegExe } from './ffmpeg.js'
|
||||
import { downloadMusicTrack } from './music.js'
|
||||
import { downloadImage, normalizeToCover, coverFileName } from './images.js'
|
||||
import { buildResourcepackZip } from './pack.js'
|
||||
|
||||
interface RpInstallerState {
|
||||
manifestUrl: string
|
||||
@@ -211,18 +212,21 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
sendLog(`${i + 1}번 사진 완료: ${path.basename(outPath)}`)
|
||||
}
|
||||
|
||||
// 2-4. 리소스팩 zip 빌드
|
||||
sendLog('리소스팩 zip 빌드 중… (TODO)')
|
||||
// 2-4. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지)
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치
|
||||
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||
const resourcepackDir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks')
|
||||
await fsp.mkdir(resourcepackDir, { recursive: true })
|
||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||
// TODO: 실제 zip 파일을 위치시킴. 지금은 빈 placeholder.
|
||||
await fsp.writeFile(resourcepackPath, '', { flag: 'a' })
|
||||
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
|
||||
await buildResourcepackZip({
|
||||
musicDir,
|
||||
paintingDir,
|
||||
packName: pack.name,
|
||||
workDir: tempRoot,
|
||||
outZipPath: resourcepackPath
|
||||
})
|
||||
|
||||
// 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(`설치 완료: ${resourcepackPath}`)
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
|
||||
106
src/installer-rp/pack.ts
Normal file
106
src/installer-rp/pack.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { promises as fs, createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import archiver from 'archiver'
|
||||
|
||||
const NAMESPACE = 'musicquiz'
|
||||
|
||||
/**
|
||||
* 1.21 (pack_format 34) 를 기준으로 하되 supported_formats 로 1.21 ~ 1.21.11
|
||||
* 까지 받아들이도록 선언. 더 신 버전이 나오면 max_inclusive 만 올리면 됨.
|
||||
*/
|
||||
const RESOURCE_PACK_FORMAT = 34
|
||||
const SUPPORTED_FORMATS = { min_inclusive: 34, max_inclusive: 75 }
|
||||
|
||||
export interface BuildResourcepackOptions {
|
||||
/** ogg 음악 파일들이 들어 있는 폴더 (01.ogg, 02.ogg, …). */
|
||||
musicDir: string
|
||||
/** cover_NN.png 파일들이 들어 있는 폴더. */
|
||||
paintingDir: string
|
||||
/** pack.mcmeta 의 description 에 들어갈 표시 이름. */
|
||||
packName: string
|
||||
/** 작업 폴더(임시). 이 안에 트리를 펼친 뒤 zip 생성. */
|
||||
workDir: string
|
||||
/** 최종 zip 출력 경로. */
|
||||
outZipPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 폴더에 리소스팩 트리를 펼치고, 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 })
|
||||
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
|
||||
const mcmeta = {
|
||||
pack: {
|
||||
description: `음악퀴즈 리소스팩 - ${opts.packName}`,
|
||||
pack_format: RESOURCE_PACK_FORMAT,
|
||||
supported_formats: SUPPORTED_FORMATS
|
||||
}
|
||||
}
|
||||
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
|
||||
|
||||
// 2) 음악 파일 복사 + sounds.json 생성
|
||||
const musicFiles = (await fs.readdir(opts.musicDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
||||
.sort()
|
||||
const soundsJson: Record<string, unknown> = {}
|
||||
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(
|
||||
path.join(root, 'assets', NAMESPACE, 'sounds.json'),
|
||||
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) => {
|
||||
// ENOENT 정도면 무시, 그 외는 reject.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return
|
||||
reject(err)
|
||||
})
|
||||
archive.on('error', reject)
|
||||
archive.pipe(output)
|
||||
archive.directory(srcDir, false)
|
||||
archive.finalize().catch(reject)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user