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:
2026-05-12 15:36:58 +09:00
parent 9e96366956
commit 8525517a87
4 changed files with 648 additions and 142 deletions

664
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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
View 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)
})
}