From 45540f3db7578f97774a8c5f0f2c76699a777119 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 13 May 2026 00:37:00 +0900 Subject: [PATCH] feat(installer-rp): use registered base resourcepack as overlay base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /manifest/.json 의 resourcepackPath 가 비어있지 않으면 /file/resourcepacks/ 의 zip 을 받아 임시 폴더에 풀고, 그 위에 음악·사진·sounds.json·pack.mcmeta 를 얹어 최종 zip 을 만든다. - types.ts: RpFetchedPack 에 resourcepackPath 필드 추가 - main.ts: 로드 시 normalizePackDefinition 으로 resourcepackPath 캡처, 설치 시 베이스 zip 다운로드 → tempRoot/base.zip → buildResourcepackZip 에 전달 - pack.ts: baseZipPath 옵션 추가. extract-zip 으로 베이스 압축 해제 후 위에 얹기. sounds.json 은 기존 항목과 병합, pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어씀. 베이스가 없으면(빈 문자열) 기존처럼 새 리소스팩을 처음부터 생성. Co-Authored-By: Claude Opus 4.7 --- src/installer-rp/main.ts | 40 ++++++++++++++++++++++++++++++------- src/installer-rp/pack.ts | 42 +++++++++++++++++++++++++++++---------- src/installer-rp/types.ts | 5 +++++ 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index 17d1123..f14ac84 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -188,10 +188,16 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi music: Array.isArray(listRaw.music) ? listRaw.music : [], images: Array.isArray(listRaw.images) ? listRaw.images : [] } - const mcVersion = packRaw - ? normalizePackDefinition(packRaw as Partial).mcVersion - : '' - results.push({ key: entry.file, name: entry.name || entry.file, mcVersion, list }) + const normalized = packRaw ? normalizePackDefinition(packRaw as Partial) : null + const mcVersion = normalized?.mcVersion ?? '' + const resourcepackPath = normalized?.resourcepackPath ?? '' + results.push({ + key: entry.file, + name: entry.name || entry.file, + mcVersion, + resourcepackPath, + list + }) } catch (error) { sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`) } @@ -334,13 +340,32 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' }) } - // 2-4. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지) + // 2-4. 베이스 리소스팩 다운로드 (있을 때만) + throwIfCancelled() + let baseZipPath: string | undefined + if (pack.resourcepackPath) { + const baseUrl = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}` + baseZipPath = path.join(tempRoot, 'base.zip') + sendLog(`베이스 리소스팩 다운로드: ${pack.resourcepackPath}`) + sendProgress({ phase: 'package', message: '베이스 리소스팩 다운로드 중' }) + try { + const buf = await fetchBuffer(baseUrl) + await fsp.writeFile(baseZipPath, buf) + sendLog(`베이스 리소스팩 받음 (${(buf.length / 1024).toFixed(1)} KB)`) + } catch (err) { + throw new Error(`베이스 리소스팩 다운로드 실패: ${(err as Error).message}`) + } + } else { + sendLog('베이스 리소스팩 없음 — 새 리소스팩으로 생성') + } + + // 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기) throwIfCancelled() const resourcepackName = `${state.selectedKey}_musicquiz.zip` const resourcepackDir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks') const resourcepackPath = path.join(resourcepackDir, resourcepackName) sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`) - sendProgress({ phase: 'package', message: 'zip 빌드 중' }) + sendProgress({ phase: 'package', message: baseZipPath ? '베이스에 음악·사진 추가 중' : 'zip 빌드 중' }) await buildResourcepackZip({ musicDir, paintingDir, @@ -348,10 +373,11 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string mcVersion: pack.mcVersion, workDir: tempRoot, outZipPath: resourcepackPath, + baseZipPath, log: sendLog }) - // 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장) + // 2-6. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장) sendLog(`설치 완료: ${resourcepackPath}`) sendProgress({ phase: 'package', message: '설치 완료', done: true }) return { resourcepackPath } diff --git a/src/installer-rp/pack.ts b/src/installer-rp/pack.ts index 01ea052..b915d6c 100644 --- a/src/installer-rp/pack.ts +++ b/src/installer-rp/pack.ts @@ -1,6 +1,7 @@ import { promises as fs, createWriteStream } from 'node:fs' import path from 'node:path' import archiver from 'archiver' +import extract from 'extract-zip' import { resolveResourcePackFormat } from './packFormat.js' const NAMESPACE = 'musicquiz' @@ -18,6 +19,11 @@ export interface BuildResourcepackOptions { workDir: string /** 최종 zip 출력 경로. */ outZipPath: string + /** + * 베이스 리소스팩 zip 경로 (선택). 지정하면 이 zip 의 내용을 먼저 풀고 + * 그 위에 음악·사진·sounds.json·pack.mcmeta 를 덮어/병합한다. + */ + baseZipPath?: string /** 진단용 로그 콜백 (선택). */ log?: (line: string) => void } @@ -33,15 +39,22 @@ export interface BuildResourcepackOptions { */ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise { 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?.(`베이스 리소스팩 압축 해제: ${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 으로 resource pack format 을 결정. 알 수 없는 버전이면 가장 최근 - // 알려진 포맷으로 폴백 (resolveResourcePackFormat 가 알아서 처리). + // 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니). const resolved = resolveResourcePackFormat(opts.mcVersion) if (resolved.matched) { opts.log?.(`pack_format = ${resolved.format} (mcVersion ${resolved.matched})`) @@ -57,11 +70,23 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom } await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n') - // 2) 음악 파일 복사 + sounds.json 생성 + // 2) 음악 파일 복사 + sounds.json 생성/병합 const musicFiles = (await fs.readdir(opts.musicDir)) .filter((n) => n.toLowerCase().endsWith('.ogg')) .sort() - const soundsJson: Record = {} + // 베이스의 sounds.json 이 있으면 읽어서 우리 트랙을 덧붙인다. + const soundsJsonPath = path.join(root, 'assets', NAMESPACE, 'sounds.json') + let soundsJson: Record = {} + try { + const existing = await fs.readFile(soundsJsonPath, 'utf8') + const parsed = JSON.parse(existing) + if (parsed && typeof parsed === 'object') { + soundsJson = parsed as Record + opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`) + } + } catch { + // 없으면 새로 생성. + } for (const fname of musicFiles) { // NN.ogg → track_NN.ogg 로 리네임해 패키지. const stem = path.basename(fname, path.extname(fname)) // "01" @@ -73,12 +98,9 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom ] } } - await fs.writeFile( - path.join(root, 'assets', NAMESPACE, 'sounds.json'), - JSON.stringify(soundsJson, null, 2) + '\n' - ) + await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n') - // 3) painting 텍스처 복사 (이미 cover_NN.png 형태) + // 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀. const paintingFiles = (await fs.readdir(opts.paintingDir)) .filter((n) => n.toLowerCase().endsWith('.png')) .sort() diff --git a/src/installer-rp/types.ts b/src/installer-rp/types.ts index cbf92b2..c7161cd 100644 --- a/src/installer-rp/types.ts +++ b/src/installer-rp/types.ts @@ -5,6 +5,11 @@ export interface RpFetchedPack { name: string /** /manifest/.json 의 mcVersion (예: "1.21.6", "26.1.2"). */ mcVersion: string + /** + * /manifest/.json 의 resourcepackPath. 비어있지 않으면 베이스 zip 으로 사용. + * 빈 문자열이면 새 리소스팩을 처음부터 생성. + */ + resourcepackPath: string /** /file/list/.json 의 음악·사진 목록. */ list: PackList }