Compare commits
35 Commits
f92dc02879
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 794ad9b778 | |||
| f810719d92 | |||
| ae771668de | |||
| 40c47fbeb3 | |||
| 6e170646a7 | |||
| 3017e77479 | |||
| c8da4207fc | |||
|
|
dfb60046c8 | ||
|
|
6472b12d58 | ||
| bc974ecd24 | |||
| 132700720d | |||
| c527efc42f | |||
| 4ee0a59f2b | |||
| 06b35abcb1 | |||
| ca1c5f237f | |||
| 5ea9b49630 | |||
| 49f320fa71 | |||
| 848fac500e | |||
| 212e70cd56 | |||
| 3ca93abae9 | |||
| a8b9b689c2 | |||
| 1665f05c55 | |||
| 40b2ff81f5 | |||
| 9cb7c05b43 | |||
| 671831535b | |||
| 506e506cfa | |||
| 9db70d0bea | |||
| c8911a9a62 | |||
| 2a500a381f | |||
| ea72051e43 | |||
| c0472bb57b | |||
| de08f9a810 | |||
| af884706d4 | |||
| 2344c4b8d2 | |||
| f9cf373550 |
4
.env.build
Normal file
4
.env.build
Normal file
@@ -0,0 +1,4 @@
|
||||
# 빌드용 환경변수 — `npm run dist:win` / `npm run dist:win:rp` 로 패키징될 때
|
||||
# 설치기 exe 의 `resources/.env.build` 로 함께 배포되어 런타임에 로드됨.
|
||||
# 서버 운영용(PORT/HOST/SESSION_SECRET) 값은 여기 두지 말고 `.env` 에.
|
||||
SITE_BASE_URL=https://mc.tkrmagid.kr
|
||||
37
.env.build.example
Normal file
37
.env.build.example
Normal file
@@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# 음악퀴즈 통합 패키지 — 빌드용 환경변수 템플릿
|
||||
#
|
||||
# 이 파일은 `npm run dist:win` / `npm run dist:win:rp` 로 exe 를 패키징할 때
|
||||
# 설치기(installer / installer-rp) 안에 함께 묶이는 값들입니다.
|
||||
# 개발 실행에서 쓰는 `.env` 와는 분리되어 있어, 운영 도메인 같은 값을 빌드용
|
||||
# 으로만 관리할 수 있습니다.
|
||||
#
|
||||
# 사용법:
|
||||
# 1) 이 파일을 복사해 `.env.build` 로 만든다.
|
||||
# 2) 운영 도메인 등 배포에 들어갈 값으로 채운다.
|
||||
# 3) `npm run dist:win` 또는 `npm run dist:win:rp` 로 빌드한다.
|
||||
# → electron-builder 가 `.env.build` 를 패키지된 exe 의
|
||||
# `resources/.env.build` 로 함께 배포.
|
||||
# → 런타임에서 `env.ts` 가 우선 로드.
|
||||
#
|
||||
# `.env.build` 는 .gitignore 로 제외되어 있습니다.
|
||||
# 서버(express) 운영용 PORT / HOST / SESSION_SECRET 같은 변수는 여기 두지 말고
|
||||
# 서버 측 `.env` 에 두세요. 이 파일은 설치기 exe 에 묶이는 값 전용입니다.
|
||||
# =============================================================================
|
||||
|
||||
# ----- 사이트 도메인(설치기가 manifest 를 받아갈 주소) -----
|
||||
|
||||
# 설치기 두 종(installer / installer-rp) 이 첫 화면에서 자동으로 채워 넣는
|
||||
# manifest 의 호스트. 프로토콜 + 호스트(+포트) 까지만 적고 슬래시는 끝에 붙이지 않음.
|
||||
# 예) 운영 도메인 : https://mq.example.com
|
||||
# 로컬 개발 : http://127.0.0.1:3000
|
||||
SITE_BASE_URL=https://mq.example.com
|
||||
|
||||
# 위 SITE_BASE_URL 로부터 자동으로 `${SITE_BASE_URL}/manifest.json` 이 생성됩니다.
|
||||
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
|
||||
# MANIFEST_URL=https://mq.example.com/manifest.json
|
||||
|
||||
# ----- 리소스팩 설치기 -----
|
||||
|
||||
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.
|
||||
# MUSIC_CONCURRENCY=
|
||||
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
41
electron-builder-rp.yml
Normal file
41
electron-builder-rp.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
appId: kr.tkrmagid.musicquiz.installer-rp
|
||||
productName: MusicQuizResourcepackInstaller
|
||||
# 루트 package.json 의 "main" 은 메인 설치기를 가리키므로, 패키지된 앱이
|
||||
# 리소스팩 설치기를 진입점으로 쓰도록 빌드 시 main 을 덮어쓴다.
|
||||
extraMetadata:
|
||||
main: dist/installer-rp/main.js
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer-rp/**
|
||||
- dist/shared/**
|
||||
- installer-rp/**
|
||||
# rp 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함
|
||||
# (`<link href="../installer/styles.css">`). asar 안에 해당 파일이 없으면
|
||||
# UI 가 무스타일로 렌더링되므로 그 한 파일만 명시적으로 포함.
|
||||
- installer/styles.css
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
|
||||
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
|
||||
- "!node_modules/@img/sharp-linux-*"
|
||||
- "!node_modules/@img/sharp-linuxmusl-*"
|
||||
- "!node_modules/@img/sharp-libvips-linux-*"
|
||||
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
|
||||
# 메인 설치기와 동일하게 빌드 전용 `.env.build` 와 locales 를 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env.build
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: portable
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
icon: build/icon.ico
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
@@ -2,28 +2,37 @@ appId: kr.tkrmagid.musicquiz.installer
|
||||
productName: MusicQuizInstaller
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer/**
|
||||
- dist/shared/**
|
||||
- installer/**
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
|
||||
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
|
||||
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
|
||||
- "!node_modules/@img/sharp-linux-*"
|
||||
- "!node_modules/@img/sharp-linuxmusl-*"
|
||||
- "!node_modules/@img/sharp-libvips-linux-*"
|
||||
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
|
||||
# 빌드 전용 `.env.build` 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# `.env` 는 서버/개발 실행용이라 빌드 산출물에는 포함되지 않으며, 패키지된 exe
|
||||
# 는 `resources/.env.build` 를 우선 로드함(없으면 `resources/.env` 로 폴백).
|
||||
# 패키징 후 운영자가 `resources/.env.build` 만 교체해서 도메인을 바꿀 수 있음.
|
||||
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
|
||||
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env
|
||||
- .env.build
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-Setup.${ext}
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
target: portable
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
icon: build/icon.ico
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
|
||||
Binary file not shown.
@@ -24,6 +24,11 @@ const state = {
|
||||
packs: [],
|
||||
selectedPackKey: null,
|
||||
mode: null, // 'single' | 'multi'
|
||||
// mode==='multi' 일 때만 의미가 있다.
|
||||
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
|
||||
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
|
||||
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
|
||||
role: null, // 'host' | 'participant' | null
|
||||
serverInstall: {
|
||||
path: '',
|
||||
jdk: '',
|
||||
@@ -159,32 +164,82 @@ function renderStep2() {
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var modeButtons = section.querySelectorAll('[data-mode]')
|
||||
|
||||
function applySelection(mode) {
|
||||
function applyMode(mode) {
|
||||
state.mode = mode
|
||||
modeButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
// 모드가 바뀌면 이전에 골랐던 역할은 의미가 없어진다. 멀티→싱글 전환 시 잔존하던
|
||||
// role 이 다음 단계 분기에 영향 주지 않도록 명시적으로 초기화.
|
||||
if (mode !== 'multi') state.role = null
|
||||
}
|
||||
|
||||
modeButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applySelection(btn.getAttribute('data-mode'))
|
||||
applyMode(btn.getAttribute('data-mode'))
|
||||
})
|
||||
})
|
||||
|
||||
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
|
||||
if (state.mode === 'single' || state.mode === 'multi') {
|
||||
applyMode(state.mode)
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.mode) return
|
||||
state.stepDone[2] = true
|
||||
if (state.mode === 'single') renderStep4()
|
||||
else renderStep3()
|
||||
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
|
||||
if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep4()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
}
|
||||
|
||||
function renderStep2Role() {
|
||||
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
|
||||
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
|
||||
setActiveStep(2)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + tt('step2.roleHeading') + '</h2>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
|
||||
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var roleButtons = section.querySelectorAll('[data-role]')
|
||||
|
||||
function applyRole(role) {
|
||||
state.role = role
|
||||
roleButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
}
|
||||
|
||||
roleButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyRole(btn.getAttribute('data-role'))
|
||||
})
|
||||
})
|
||||
|
||||
if (state.role === 'host' || state.role === 'participant') applyRole(state.role)
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.role) return
|
||||
// 호스트는 서버 설치(step3) 부터, 참가자는 클라이언트(step4) 로 바로.
|
||||
if (state.role === 'host') renderStep3()
|
||||
else renderStep4()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep2)
|
||||
}
|
||||
|
||||
function renderStep3() {
|
||||
setActiveStep(3)
|
||||
clearPage()
|
||||
@@ -196,7 +251,8 @@ function renderStep3() {
|
||||
pageHost.appendChild(section)
|
||||
var subHost = section.querySelector('#subHost')
|
||||
|
||||
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
|
||||
// step3 는 멀티+호스트 만 진입하므로 sub31 의 back 은 역할 선택 탭으로.
|
||||
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2Role, show32) }
|
||||
function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
|
||||
function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
|
||||
function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
|
||||
@@ -446,20 +502,16 @@ function renderSubStep33(host, back, done) {
|
||||
}
|
||||
|
||||
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
|
||||
async function openEulaPopup(installPath) {
|
||||
var read = await installerApi.readEula(installPath)
|
||||
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
|
||||
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
|
||||
async function openEulaPopup(_installPath) {
|
||||
var bodyHtml = ''
|
||||
if (read.exists) {
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromFile') + '</p>' +
|
||||
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
|
||||
var fetched = await installerApi.fetchMinecraftEula()
|
||||
if (fetched.html) {
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
|
||||
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
|
||||
} else {
|
||||
var fetched = await installerApi.fetchMinecraftEula()
|
||||
if (fetched.html) {
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
|
||||
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
|
||||
} else {
|
||||
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
|
||||
}
|
||||
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
|
||||
}
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = document.createElement('div')
|
||||
@@ -491,12 +543,6 @@ async function openEulaPopup(installPath) {
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text).replace(/[&<>"']/g, function (ch) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
|
||||
})
|
||||
}
|
||||
|
||||
function escapeAttr(text) {
|
||||
return String(text).replace(/&/g, '&').replace(/"/g, '"')
|
||||
}
|
||||
@@ -535,6 +581,14 @@ function renderSubStep35(host, back, done) {
|
||||
var runBtn = host.querySelector('#run')
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
|
||||
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
|
||||
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
|
||||
function formatServerAddress(ip, port) {
|
||||
var safeIp = ip || tt('step3.sub35.ipUnknown')
|
||||
if (Number(port) === 25565) return safeIp
|
||||
return safeIp + ':' + port
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
runBtn.disabled = true
|
||||
resultMsg.classList.remove('success', 'warn', 'error')
|
||||
@@ -543,16 +597,16 @@ function renderSubStep35(host, back, done) {
|
||||
try {
|
||||
var result = await installerApi.checkPortForward(port)
|
||||
state.serverInstall.portStatus = result
|
||||
var address = formatServerAddress(result.externalIp, result.port)
|
||||
if (result.status === 'preForwarded') {
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { ip: result.externalIp, port: result.port })
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else if (result.status === 'upnpOk') {
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { ip: result.externalIp, port: result.port })
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else {
|
||||
var ip = result.externalIp || tt('step3.sub35.ipUnknown')
|
||||
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
|
||||
tt('step3.sub35.manualDetail', { ip: ip, port: result.port })
|
||||
tt('step3.sub35.manualDetail', { address: address })
|
||||
resultMsg.classList.add('warn')
|
||||
}
|
||||
nextBtn.disabled = false
|
||||
@@ -581,64 +635,26 @@ function renderStep4() {
|
||||
'<div class="subStep" id="subHost"></div>'
|
||||
pageHost.appendChild(section)
|
||||
var subHost = section.querySelector('#subHost')
|
||||
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
|
||||
|
||||
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, goStep5) }
|
||||
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
|
||||
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
state.client.installPlatform = platformType !== 'vanilla'
|
||||
|
||||
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
|
||||
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
|
||||
function backToPrevStep() {
|
||||
if (state.mode === 'multi' && state.role === 'host') renderStep3()
|
||||
else if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep2()
|
||||
}
|
||||
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
|
||||
function goStep5() {
|
||||
state.stepDone[4] = true
|
||||
renderStep5()
|
||||
}
|
||||
show41()
|
||||
}
|
||||
|
||||
function renderSubStep41(host, pack, back, done) {
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
if (platformType === 'vanilla') {
|
||||
state.client.installPlatform = false
|
||||
host.innerHTML =
|
||||
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.vanillaInfo') + '</p>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.vanillaNoInstall') + '</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
return
|
||||
}
|
||||
|
||||
host.innerHTML =
|
||||
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.info', { platform: platformType }) + '</p>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-choice="install"><strong>' + tt('step4.sub41.installTitle') + '</strong><br><small>' + tt('step4.sub41.installHint', { platform: platformType }) + '</small></button>' +
|
||||
'<button type="button" data-choice="skip"><strong>' + tt('step4.sub41.skipTitle') + '</strong><br><small>' + tt('step4.sub41.skipHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var choiceButtons = host.querySelectorAll('[data-choice]')
|
||||
|
||||
function applyChoice(choice) {
|
||||
state.client.installPlatform = choice === 'install'
|
||||
choiceButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
}
|
||||
|
||||
choiceButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyChoice(btn.getAttribute('data-choice'))
|
||||
})
|
||||
})
|
||||
|
||||
if (typeof state.client.installPlatform === 'boolean') {
|
||||
applyChoice(state.client.installPlatform ? 'install' : 'skip')
|
||||
}
|
||||
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
nextBtn.addEventListener('click', done)
|
||||
show42()
|
||||
}
|
||||
|
||||
function renderSubStep42(host, back, done) {
|
||||
@@ -652,8 +668,20 @@ function renderSubStep42(host, back, done) {
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
nextBtn.addEventListener('click', done)
|
||||
|
||||
// 이미 설치됐다면 다시 돌리지 않음
|
||||
if (state.client.clientInstalled) {
|
||||
// 이번에 실제로 보내야 할 payload. 이전 진입에서 같은 payload 로 이미 끝났으면
|
||||
// 다시 돌리지 않지만, packKey / installPlatform / skipMap 중 하나라도 다르면
|
||||
// (예: 참가자 → 싱글 로 뒤로가서 변경) 재설치한다.
|
||||
var payload = {
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform,
|
||||
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
|
||||
skipMap: state.mode === 'multi' && state.role === 'participant'
|
||||
}
|
||||
var last = state.client.lastInstall
|
||||
if (last
|
||||
&& last.packKey === payload.packKey
|
||||
&& last.installPlatform === payload.installPlatform
|
||||
&& last.skipMap === payload.skipMap) {
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
nextBtn.disabled = false
|
||||
@@ -663,15 +691,14 @@ function renderSubStep42(host, back, done) {
|
||||
// 페이지 진입 즉시 자동 설치
|
||||
;(async function () {
|
||||
try {
|
||||
await installerApi.installClient({
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform
|
||||
})
|
||||
await installerApi.installClient(payload)
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
state.client.clientInstalled = true
|
||||
state.client.lastInstall = payload
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
|
||||
state.client.lastInstall = null
|
||||
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
@@ -683,11 +710,13 @@ function renderStep5() {
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
var multi = state.mode === 'multi'
|
||||
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
|
||||
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
|
||||
var showServerActions = state.mode === 'multi' && state.role === 'host'
|
||||
section.innerHTML =
|
||||
'<h2>' + tt('step5.heading') + '</h2>' +
|
||||
'<p>' + tt('step5.summary') + '</p>' +
|
||||
(multi ? '<div class="subStep">' +
|
||||
(showServerActions ? '<div class="subStep">' +
|
||||
'<h3>' + tt('step5.serverHeading') + '</h3>' +
|
||||
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
|
||||
@@ -700,7 +729,7 @@ function renderStep5() {
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep4)
|
||||
if (multi) {
|
||||
if (showServerActions) {
|
||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||
installerApi.openServerFolder()
|
||||
})
|
||||
@@ -710,7 +739,7 @@ function renderStep5() {
|
||||
finishBtn.disabled = true
|
||||
finishBtn.textContent = tt('step5.finishing')
|
||||
try {
|
||||
if (multi) {
|
||||
if (showServerActions) {
|
||||
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
|
||||
if (section.querySelector('#startServer').checked) await installerApi.startServer()
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
"mcVersionLabel": "마인크래프트 {{version}} · ",
|
||||
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
"tooManyRedirects": "너무 많은 요청."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "1단계. 음악퀴즈 선택"
|
||||
"heading": "음악퀴즈 선택"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "2단계. 리소스팩 설치",
|
||||
"heading": "리소스팩 설치",
|
||||
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
|
||||
"chipYtdlp": "yt-dlp 준비",
|
||||
"chipFfmpeg": "ffmpeg 준비",
|
||||
@@ -48,7 +48,7 @@
|
||||
"cardError": "실패"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "3단계. 완료",
|
||||
"heading": "완료",
|
||||
"message": "리소스팩 설치를 완료했습니다."
|
||||
},
|
||||
"log": {
|
||||
@@ -86,8 +86,10 @@
|
||||
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
|
||||
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
|
||||
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
|
||||
"baseShaderOverrideStripped": "베이스 리소스팩의 vanilla 셰이더 오버라이드 제거: assets/minecraft/shaders/{{path}} — mcVersion {{mc}} (pack_format {{format}}) 의 새 GLSL API 와 호환되지 않아 결과 zip 에서 제외했습니다.",
|
||||
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
|
||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
||||
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||
"ytdlpLine": "yt-dlp> {{line}}"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,12 @@
|
||||
"singleTitle": "싱글",
|
||||
"singleHint": "싱글 맵으로 혼자 플레이할때",
|
||||
"multiTitle": "멀티",
|
||||
"multiHint": "버킷 서버로 친구들과 같이 플레이할때"
|
||||
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
|
||||
"roleHeading": "호스트 / 참가자",
|
||||
"hostTitle": "호스트",
|
||||
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
|
||||
"participantTitle": "참가자",
|
||||
"participantHint": "친구가 연 서버에 접속만 할 때"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "서버 관련 설정",
|
||||
@@ -90,8 +95,7 @@
|
||||
},
|
||||
"eulaModal": {
|
||||
"title": "Minecraft EULA 동의",
|
||||
"fromFile": "서버 파일에 포함된 eula.txt 내용입니다.",
|
||||
"fromMojang": "서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
|
||||
"fromMojang": "마인크래프트 서버를 실행하려면 아래 EULA에 동의해야 합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
|
||||
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
|
||||
},
|
||||
"sub34": {
|
||||
@@ -107,26 +111,16 @@
|
||||
"portLabel": "포트",
|
||||
"recheck": "재점검",
|
||||
"checking": "확인 중...",
|
||||
"preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}",
|
||||
"upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}",
|
||||
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
|
||||
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
|
||||
"manualHint": "직접 포트포워딩을 해주세요.",
|
||||
"manualDetail": "<br><small>외부 IP: {{ip}}, 포트: {{port}}</small>",
|
||||
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
|
||||
"checkFailed": "점검 실패: {{message}}",
|
||||
"ipUnknown": "확인 불가"
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"heading": "클라이언트 설정",
|
||||
"sub41": {
|
||||
"heading": "플랫폼",
|
||||
"vanillaInfo": "선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong>",
|
||||
"vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.",
|
||||
"info": "선택한 음악퀴즈의 플랫폼: <strong>{{platform}}</strong>",
|
||||
"installTitle": "권장 플랫폼 설치",
|
||||
"installHint": "{{platform}} 설치",
|
||||
"skipTitle": "기본 마인크래프트로 설치",
|
||||
"skipHint": "설치하지 않고 바닐라로 진행합니다."
|
||||
},
|
||||
"sub42": {
|
||||
"heading": "다운로드 및 적용",
|
||||
"description": "클라이언트 설정",
|
||||
@@ -196,7 +190,10 @@
|
||||
"labelServerFile": "서버 파일",
|
||||
"labelMap": "맵",
|
||||
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
|
||||
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
|
||||
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
|
||||
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
|
||||
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
|
||||
"modsIndexFetch": "모드 목록 조회: {{url}}",
|
||||
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",
|
||||
@@ -255,6 +252,7 @@
|
||||
"javaUsed": "Java 사용: {{path}}",
|
||||
"fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}",
|
||||
"fabricInstallDone": "Fabric 자동 설치 완료.",
|
||||
"fabricAlreadyInstalled": "Fabric 이미 설치돼 있어 건너뜁니다: {{id}} ({{dir}})",
|
||||
"launcherProfilesMissing": "launcher_profiles.json을 찾을 수 없습니다: {{path}}",
|
||||
"javaArgsUpdated": "JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): \"{{before}}\" → \"{{after}}\"",
|
||||
"lastVersionId": "launcher_profiles 의 lastVersionId = {{id}}",
|
||||
@@ -262,7 +260,7 @@
|
||||
"launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}",
|
||||
"minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.",
|
||||
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
|
||||
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 보존(이미 존재) {{skipped}}개.",
|
||||
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.",
|
||||
"settingCopyError": "기존 설정 복사 중 오류: {{message}}",
|
||||
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.",
|
||||
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
|
||||
|
||||
@@ -69,6 +69,14 @@
|
||||
"titleFallback": "(제목 없음)",
|
||||
"artistFallback": "(가수 미상)",
|
||||
"rowEditTooltip": "더블클릭해서 수정",
|
||||
"aliasBtn": "별칭",
|
||||
"aliasBtnWithCount": "별칭 ({{count}})",
|
||||
"aliasModalTitle": "별칭 - {{title}}",
|
||||
"aliasBack": "← 돌아가기",
|
||||
"aliasAdd": "별칭 추가",
|
||||
"aliasPlaceholder": "별칭 입력",
|
||||
"aliasRemove": "삭제",
|
||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||
"metaLoading": "메타데이터 가져오는 중…",
|
||||
"metaFailedShort": "메타 조회 실패",
|
||||
"metaFailedTitle": "메타데이터 조회 실패",
|
||||
@@ -116,8 +124,11 @@
|
||||
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
|
||||
"modsFolder": "모드 폴더 이름",
|
||||
"modsFolderHint": "/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
|
||||
"resourcepackPath": "리소스팩 (.zip)",
|
||||
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
|
||||
"resourcepackPath": "베이스 리소스팩 (.zip)",
|
||||
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
|
||||
"outputPackName": "생성되는 리소스팩 이름",
|
||||
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
|
||||
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" < > |)는 자동으로 _ 로 바뀝니다.",
|
||||
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
|
||||
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
|
||||
},
|
||||
@@ -128,13 +139,18 @@
|
||||
"pickedNone": "선택된 음악퀴즈 없음",
|
||||
"pickedLabel": "선택: {{name}}",
|
||||
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
|
||||
"export": "데이터팩 출력",
|
||||
"hint": "music_quiz 데이터팩의 data/mq/function/init/songs.mcfunction 파일에 아래 코드를 그대로 덮어쓰세요.",
|
||||
"export": "코드 출력",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"exporting": "출력 중…",
|
||||
"exported": "출력 완료",
|
||||
"failed": "실패: {{message}}",
|
||||
"modalPickTitle": "음악퀴즈 선택"
|
||||
"modalPickTitle": "음악퀴즈 선택",
|
||||
"imagesZip": "이미지.zip 출력",
|
||||
"imagesZipSizeLabel": "크기",
|
||||
"imagesZipDownloading": "이미지.zip 생성 중…",
|
||||
"imagesZipDone": "이미지.zip 다운로드 완료"
|
||||
},
|
||||
"errors": {
|
||||
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
|
||||
@@ -153,14 +169,5 @@
|
||||
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
|
||||
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
},
|
||||
"datapackOutput": {
|
||||
"header": "# === musicquiz: {{name}} ===",
|
||||
"summary": "# 총 {{musicCount}}곡 / 사진 {{imageCount}}장",
|
||||
"initLine": "say [musicquiz] 데이터팩 초기화",
|
||||
"placeholder": "# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.",
|
||||
"trackLine": "# {{index}}. {{title}} - {{artist}} ({{duration}}s)",
|
||||
"titleFallback": "(제목 없음)",
|
||||
"artistFallback": "(가수 미상)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
@@ -9,7 +9,9 @@
|
||||
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||
"installer": "tsc -p tsconfig.installer.json && electron .",
|
||||
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
|
||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||
"preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5",
|
||||
"dist:win": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
|
||||
"dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
|
||||
@@ -99,6 +99,10 @@
|
||||
li.dataset.index = String(idx)
|
||||
// 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON.
|
||||
// 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다.
|
||||
var aliasCount = Array.isArray(entry.aliases) ? entry.aliases.length : 0
|
||||
var aliasLabel = aliasCount > 0
|
||||
? tt('aliasBtnWithCount', { count: aliasCount })
|
||||
: tt('aliasBtn')
|
||||
li.innerHTML =
|
||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||
@@ -110,9 +114,13 @@
|
||||
escapeHtml(entry.artist || '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(aliasLabel) +
|
||||
'</button>' +
|
||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||
attachDraggable(li, 'music', idx)
|
||||
attachInlineEdit(li, idx)
|
||||
attachAliasBtn(li, idx)
|
||||
ol.appendChild(li)
|
||||
})
|
||||
}
|
||||
@@ -340,6 +348,19 @@
|
||||
if (e.target === m) closeAllModals()
|
||||
})
|
||||
})
|
||||
// ESC 로 열린 모달 닫기. 별칭 모달은 "돌아가기" 와 같은 저장 후 닫기 의미.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return
|
||||
var aliasOpen = aliasModal && !aliasModal.hidden
|
||||
var anyOpen = document.querySelector('.modalOverlay:not([hidden])')
|
||||
if (!anyOpen) return
|
||||
e.preventDefault()
|
||||
if (aliasOpen) {
|
||||
closeAliasModalSaving()
|
||||
return
|
||||
}
|
||||
closeAllModals()
|
||||
})
|
||||
|
||||
document.getElementById('edit-music-save').addEventListener('click', function () {
|
||||
var url = document.getElementById('edit-music-url').value.trim()
|
||||
@@ -402,6 +423,110 @@
|
||||
renderImage()
|
||||
})
|
||||
|
||||
// ── 별칭 모달 ─────────────────────────────────────
|
||||
// 음악 행의 "별칭" 버튼을 누르면 열린다. 헤더의 "← 돌아가기" 버튼 (또는 닫기 동작)이
|
||||
// 호출되면 현재 인풋박스들에 입력된 값을 정규화해 state.music[idx].aliases 에 저장.
|
||||
var aliasModal = document.getElementById('aliasModal')
|
||||
var aliasRowsHost = document.getElementById('alias-rows')
|
||||
var aliasModalTitleEl = document.getElementById('alias-modal-title')
|
||||
var aliasBackBtn = document.getElementById('alias-back')
|
||||
var aliasAddBtn = document.getElementById('alias-add')
|
||||
var aliasEditingIdx = -1
|
||||
|
||||
function attachAliasBtn(li, idx) {
|
||||
var btn = li.querySelector('[data-alias-open]')
|
||||
if (!btn) return
|
||||
// 버튼에서 시작하는 mousedown 은 행 드래그로 전파되지 않도록 차단.
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation()
|
||||
openAliasModal(idx)
|
||||
})
|
||||
}
|
||||
|
||||
function openAliasModal(idx) {
|
||||
if (!state.music[idx]) return
|
||||
aliasEditingIdx = idx
|
||||
var entry = state.music[idx]
|
||||
aliasModalTitleEl.textContent = tt('aliasModalTitle', { title: entry.title || tt('titleFallback') })
|
||||
aliasRowsHost.innerHTML = ''
|
||||
var existing = Array.isArray(entry.aliases) ? entry.aliases : []
|
||||
if (existing.length === 0) {
|
||||
// 빈 상태에서도 입력 시작을 쉽게 하려고 첫 줄 하나는 미리 만들어 둔다.
|
||||
appendAliasRow('')
|
||||
} else {
|
||||
existing.forEach(function (a) { appendAliasRow(a) })
|
||||
}
|
||||
aliasModal.hidden = false
|
||||
}
|
||||
|
||||
function appendAliasRow(value) {
|
||||
var row = document.createElement('div')
|
||||
row.className = 'aliasRow'
|
||||
var input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.className = 'textInput aliasInput'
|
||||
input.placeholder = tt('aliasPlaceholder')
|
||||
input.value = value || ''
|
||||
var removeBtn = document.createElement('button')
|
||||
removeBtn.type = 'button'
|
||||
removeBtn.className = 'aliasRowRemove'
|
||||
removeBtn.title = tt('aliasRemove')
|
||||
removeBtn.textContent = '−'
|
||||
removeBtn.addEventListener('click', function () { row.remove() })
|
||||
row.appendChild(input)
|
||||
row.appendChild(removeBtn)
|
||||
aliasRowsHost.appendChild(row)
|
||||
return input
|
||||
}
|
||||
|
||||
function readAliasInputs() {
|
||||
var seen = Object.create(null)
|
||||
var out = []
|
||||
var inputs = aliasRowsHost.querySelectorAll('.aliasInput')
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
var v = (inputs[i].value || '').trim()
|
||||
if (!v) continue
|
||||
if (seen[v]) continue
|
||||
seen[v] = true
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function closeAliasModalSaving() {
|
||||
if (aliasEditingIdx < 0 || !state.music[aliasEditingIdx]) {
|
||||
aliasModal.hidden = true
|
||||
aliasEditingIdx = -1
|
||||
return
|
||||
}
|
||||
var nextAliases = readAliasInputs()
|
||||
var prev = state.music[aliasEditingIdx].aliases || []
|
||||
var changed = prev.length !== nextAliases.length
|
||||
if (!changed) {
|
||||
for (var i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== nextAliases[i]) { changed = true; break }
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
state.music[aliasEditingIdx].aliases = nextAliases
|
||||
markDirty()
|
||||
renderMusic()
|
||||
}
|
||||
aliasModal.hidden = true
|
||||
aliasEditingIdx = -1
|
||||
}
|
||||
|
||||
aliasAddBtn.addEventListener('click', function () {
|
||||
var input = appendAliasRow('')
|
||||
input.focus()
|
||||
})
|
||||
aliasBackBtn.addEventListener('click', closeAliasModalSaving)
|
||||
// 모달 바깥 클릭으로 닫혀도 입력값은 보존(저장)되도록 처리.
|
||||
aliasModal.addEventListener('click', function (e) {
|
||||
if (e.target === aliasModal) closeAliasModalSaving()
|
||||
})
|
||||
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||
if (state.music.length === 0) {
|
||||
|
||||
@@ -407,12 +407,42 @@ body.siteBody.centerLayout {
|
||||
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.trackRow {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 80px 1fr auto;
|
||||
grid-template-columns: 36px 80px 1fr auto auto;
|
||||
gap: 12px; align-items: center;
|
||||
padding: 8px 12px; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.aliasBtn {
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aliasBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* 별칭 모달 */
|
||||
.aliasModalHeader {
|
||||
display: grid !important;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.aliasModalHeader h3 { text-align: center; }
|
||||
.aliasModalHeader .ghostLink {
|
||||
background: transparent; border: none; color: var(--accent); cursor: pointer;
|
||||
font-size: 13px; padding: 4px 8px;
|
||||
}
|
||||
.aliasModalHeader .ghostLink:hover { text-decoration: underline; }
|
||||
.aliasRowList { display: flex; flex-direction: column; gap: 8px; }
|
||||
.aliasRow { display: flex; gap: 8px; align-items: center; }
|
||||
.aliasRow .aliasInput { flex: 1; }
|
||||
.aliasRowRemove {
|
||||
background: var(--bg-card); border: 1px solid var(--border); color: var(--danger);
|
||||
width: 32px; height: 32px; border-radius: 6px; cursor: pointer;
|
||||
font-size: 16px; line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.aliasRowRemove:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
|
||||
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
|
||||
.rowMeta { min-width: 0; }
|
||||
|
||||
@@ -35,6 +35,20 @@ interface RpInstallerState {
|
||||
activeChildren: Set<ChildProcess>
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
|
||||
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
|
||||
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
|
||||
* 호출 측에서 폴백을 결정한다.
|
||||
*/
|
||||
function sanitizeOutputPackName(name: string): string {
|
||||
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
cleaned = cleaned.replace(/[ .]+$/, '')
|
||||
if (!cleaned) return ''
|
||||
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
|
||||
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
|
||||
@@ -58,9 +72,9 @@ function pickMusicConcurrency(): number {
|
||||
* - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임.
|
||||
* - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로
|
||||
* 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임.
|
||||
* - 너무 길면 동시성 이득을 깎아먹음. 2.5s 가 체감/속도 균형점.
|
||||
* - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점.
|
||||
*/
|
||||
const MUSIC_START_STAGGER_MS = 2500
|
||||
const MUSIC_START_STAGGER_MS = 2000
|
||||
|
||||
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
|
||||
let musicStartChain: Promise<void> = Promise.resolve()
|
||||
@@ -98,9 +112,12 @@ function deriveBaseUrl(manifestUrl: string): string {
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -198,11 +215,13 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
||||
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
|
||||
const mcVersion = normalized?.mcVersion ?? ''
|
||||
const resourcepackPath = normalized?.resourcepackPath ?? ''
|
||||
const outputPackName = normalized?.outputPackName ?? ''
|
||||
results.push({
|
||||
key: entry.file,
|
||||
name: entry.name || entry.file,
|
||||
mcVersion,
|
||||
resourcepackPath,
|
||||
outputPackName,
|
||||
list
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -380,7 +399,11 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
|
||||
throwIfCancelled()
|
||||
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||
// 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
|
||||
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
|
||||
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
|
||||
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
|
||||
const resourcepackName = `${resourcepackBaseName}.zip`
|
||||
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
|
||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||
sendLog(t('log.buildingZip', { name: resourcepackName }))
|
||||
|
||||
@@ -2,7 +2,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'
|
||||
import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } from './packFormat.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
@@ -64,14 +64,48 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
} else {
|
||||
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
|
||||
}
|
||||
const mcmeta = {
|
||||
pack: {
|
||||
description: t('pack.description', { name: opts.packName }),
|
||||
pack_format: resolved.format,
|
||||
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
|
||||
|
||||
// 호환 범위는 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))
|
||||
|
||||
@@ -24,6 +24,12 @@ const TABLE: Array<readonly [string, number]> = [
|
||||
/** 테이블에서 마지막(=최신) 항목의 포맷. 알 수 없는 mcVersion 에 대한 폴백. */
|
||||
export const LATEST_KNOWN_FORMAT: number = TABLE[TABLE.length - 1][1]
|
||||
|
||||
/**
|
||||
* 리소스팩이 호환된다고 선언할 최소 pack_format.
|
||||
* 1.21.6 (=63) 부터를 지원 범위 하한으로 둔다.
|
||||
*/
|
||||
export const MIN_SUPPORTED_FORMAT = 63
|
||||
|
||||
export interface ResolvedFormat {
|
||||
/** 매칭된 mcVersion 키 (없으면 null). */
|
||||
matched: string | null
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface RpFetchedPack {
|
||||
* 빈 문자열이면 새 리소스팩을 처음부터 생성.
|
||||
*/
|
||||
resourcepackPath: string
|
||||
/**
|
||||
* /manifest/<key>.json 의 outputPackName. 관리 사이트에서 설정한 "생성되는
|
||||
* 리소스팩 이름". 비어 있으면 설치기가 `<key>_resourcepack` 형식으로 폴백.
|
||||
* 파일명으로 쓰기 전에 Windows 금지 문자(\<\>:"/\\|?*) 는 `_` 로 치환.
|
||||
*/
|
||||
outputPackName: string
|
||||
/** /file/list/<key>.json 의 음악·사진 목록. */
|
||||
list: PackList
|
||||
}
|
||||
|
||||
@@ -63,9 +63,13 @@ function deriveBaseUrl(manifestUrl: string): string {
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 패키징 시 build/icon.ico, dev 실행 시 build/icon.png 모두 동일 경로에서 발견되도록
|
||||
// 프로젝트 루트의 build/ 를 가리킨다. 파일이 없으면 Electron 이 기본 아이콘으로 fallback.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 980,
|
||||
height: 720,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -395,21 +399,121 @@ async function downloadServerZip(pack: PackDefinition, targetDir: string): Promi
|
||||
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치러가 saves/ 에 풀어놓은 최상위 폴더(또는 파일) 목록을 기록하는 마커 파일.
|
||||
* 재설치 시 잔여물을 안전하게 정리하고, 싱글→참가자 전환 시에도
|
||||
* 사용자가 직접 만든 월드는 보존한 채 설치러가 만든 맵만 제거하기 위함이다.
|
||||
*/
|
||||
const INSTALLER_MAP_MARKER = '.musicquiz-installer-map.json'
|
||||
|
||||
async function readInstallerMapMarker(customRoot: string): Promise<string[]> {
|
||||
const markerPath = path.join(customRoot, 'saves', INSTALLER_MAP_MARKER)
|
||||
try {
|
||||
const raw = await fsp.readFile(markerPath, 'utf8')
|
||||
const data = JSON.parse(raw) as { entries?: unknown }
|
||||
if (Array.isArray(data.entries)) {
|
||||
return data.entries.filter((s): s is string => typeof s === 'string')
|
||||
}
|
||||
} catch {
|
||||
// 마커가 없거나 파싱 실패 — 빈 목록 반환
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async function writeInstallerMapMarker(customRoot: string, entries: string[]): Promise<void> {
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
await fsp.mkdir(savesDir, { recursive: true })
|
||||
const markerPath = path.join(savesDir, INSTALLER_MAP_MARKER)
|
||||
await fsp.writeFile(markerPath, JSON.stringify({ entries }, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
async function cleanupInstallerMap(customRoot: string): Promise<void> {
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
const entries = await readInstallerMapMarker(customRoot)
|
||||
if (entries.length === 0) return
|
||||
sendLog(t('log.cleanupInstallerMap', { count: entries.length }))
|
||||
for (const name of entries) {
|
||||
// 안전장치: 경로 구분자/상대경로 토큰이 섞인 항목은 무시
|
||||
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') continue
|
||||
const target = path.join(savesDir, name)
|
||||
await fsp.rm(target, { recursive: true, force: true })
|
||||
}
|
||||
await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 폴더 이름으로 쓸 수 없는 문자를 모두 `_` 로 치환.
|
||||
* 금지 문자: `<>:"/\|?*` 와 제어 문자(0x00~0x1f)
|
||||
* 추가 제한: 끝의 공백/마침표 제거, 빈 문자열 fallback, 예약 이름(CON, NUL 등) 회피.
|
||||
* 참고: https://learn.microsoft.com/windows/win32/fileio/naming-a-file
|
||||
*/
|
||||
function sanitizeMapFolderName(name: string): string {
|
||||
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
cleaned = cleaned.replace(/[ .]+$/, '')
|
||||
if (!cleaned) cleaned = 'map'
|
||||
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
|
||||
return cleaned
|
||||
}
|
||||
|
||||
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.mapPath) {
|
||||
sendLog(t('log.skipMapZip'))
|
||||
return
|
||||
}
|
||||
// 이전 설치러가 풀어놓은 맵이 남아 있으면 먼저 제거 (다른 팩/재설치 시 잔여물 방지).
|
||||
await cleanupInstallerMap(customRoot)
|
||||
const url = resolveManifestRelative(pack.mapPath, 'maps')
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), savesDir)
|
||||
await fsp.mkdir(savesDir, { recursive: true })
|
||||
|
||||
// zip 의 최상위 구조(단일 폴더 / 루트에 level.dat) 와 관계없이 최종 폴더 이름이
|
||||
// 항상 퀴즈 이름이 되도록, 우선 saves/ 안의 임시 폴더에 풀고 적절히 옮긴다.
|
||||
// saves 와 같은 디렉터리에서 만들기 때문에 rename 이 cross-device 실패 없이 동작.
|
||||
const tempExtractDir = await fsp.mkdtemp(path.join(savesDir, '.mq-map-extract-'))
|
||||
try {
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), tempExtractDir)
|
||||
|
||||
// zip 이 단일 최상위 폴더면 그 안을 월드 콘텐츠로, 아니면 임시 디렉터리 자체가
|
||||
// 월드 콘텐츠(level.dat 등이 루트). 어느 쪽이든 결과적으로 saves/<퀴즈이름>/ 로.
|
||||
const entries = await fsp.readdir(tempExtractDir)
|
||||
let sourceDir = tempExtractDir
|
||||
if (entries.length === 1) {
|
||||
const candidate = path.join(tempExtractDir, entries[0])
|
||||
const stat = await fsp.stat(candidate).catch(() => null)
|
||||
if (stat?.isDirectory()) sourceDir = candidate
|
||||
}
|
||||
|
||||
const desired = sanitizeMapFolderName(pack.name)
|
||||
// 사용자가 직접 만든 동명 월드와 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피.
|
||||
let target = desired
|
||||
let suffix = 2
|
||||
while (fs.existsSync(path.join(savesDir, target))) {
|
||||
target = `${desired}_${suffix}`
|
||||
suffix++
|
||||
}
|
||||
const targetDir = path.join(savesDir, target)
|
||||
await fsp.rename(sourceDir, targetDir)
|
||||
sendLog(t('log.mapInstalledAs', { name: target }))
|
||||
await writeInstallerMapMarker(customRoot, [target])
|
||||
} finally {
|
||||
// sourceDir 가 tempExtractDir 자체였다면 rename 으로 사라졌고, 단일 하위 폴더였다면
|
||||
// 비어 있는 껍데기만 남아 있다. 어느 경우든 안전하게 정리.
|
||||
await fsp.rm(tempExtractDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
// 바닐라 팩(modsFolder 비어 있음)은 모드 자체와 무관하므로 기존 mods/ 를 건드리지
|
||||
// 않는다 — 사용자가 다른 곳에서 받아 둔 모드까지 지워버리는 부작용 방지.
|
||||
if (!pack.modsFolder) {
|
||||
sendLog(t('log.skipModsFolder'))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
// 모드팩인 경우엔 이전 버전/이전 팩 모드가 섞이면 로딩이 실패하므로 매번 비우고 받는다.
|
||||
sendLog(t('log.clearMods', { dir: modsDir }))
|
||||
await fsp.rm(modsDir, { recursive: true, force: true })
|
||||
await fsp.mkdir(modsDir, { recursive: true })
|
||||
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
||||
sendLog(t('log.modsIndexFetch', { url: indexUrl }))
|
||||
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
|
||||
@@ -420,8 +524,6 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro
|
||||
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
await fsp.mkdir(modsDir, { recursive: true })
|
||||
for (const fileName of files) {
|
||||
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
|
||||
const target = path.join(modsDir, fileName)
|
||||
@@ -1047,7 +1149,14 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
|
||||
await downloadModsFolder(pack.pack, customRoot)
|
||||
await downloadResourcepackZip(pack.pack, customRoot)
|
||||
|
||||
await downloadMapZip(pack.pack, customRoot)
|
||||
if (payload.skipMap) {
|
||||
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
|
||||
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
|
||||
await cleanupInstallerMap(customRoot)
|
||||
sendLog(t('log.skipMapZip'))
|
||||
} else {
|
||||
await downloadMapZip(pack.pack, customRoot)
|
||||
}
|
||||
|
||||
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
|
||||
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
|
||||
@@ -1068,6 +1177,19 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
throw new Error(t('errors.fabricLoaderRequired'))
|
||||
}
|
||||
|
||||
// 0) 이미 설치돼 있으면 건너뛴다. fabric-installer 는 매번 jar 를 지우고
|
||||
// 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고
|
||||
// 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면
|
||||
// 재실행할 필요가 없으므로 그냥 통과.
|
||||
const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}`
|
||||
const versionDir = path.join(customRoot, 'versions', versionId)
|
||||
const versionJar = path.join(versionDir, `${versionId}.jar`)
|
||||
const versionJson = path.join(versionDir, `${versionId}.json`)
|
||||
if (fs.existsSync(versionJar) && fs.existsSync(versionJson)) {
|
||||
sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir }))
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 최신 fabric-installer 메타데이터 조회.
|
||||
sendLog(t('log.fabricFetchInstallerList'))
|
||||
const installerList = await fetchJson<FabricInstallerMeta[]>('https://meta.fabricmc.net/v2/versions/installer')
|
||||
@@ -1341,9 +1463,18 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
/**
|
||||
* 사용자가 기존에 .minecraft 에 만들어둔 설정 파일들(options.txt, optionsof.txt,
|
||||
* servers.dat, usercache.json 등 최상위 파일 전부)을 .mc_custom 으로 복사한다.
|
||||
* 이미 .mc_custom 에 같은 이름의 파일이 있으면 보존(덮어쓰지 않음).
|
||||
* 기본 규칙은 "이미 .mc_custom 에 같은 이름의 파일이 있으면 보존" 이지만,
|
||||
* ALWAYS_SYNC_FILES 목록에 든 파일(=사용자가 원래 .minecraft 에서 쓰던
|
||||
* 설정을 그대로 이어 쓰고 싶은 옵션 파일들)은 매번 .minecraft 쪽으로
|
||||
* 덮어써서 동기화한다.
|
||||
* 디렉터리(mods/saves/versions/assets 등)는 각자 별도 처리하므로 여기서는 건드리지 않는다.
|
||||
*/
|
||||
const ALWAYS_SYNC_FILES = new Set([
|
||||
'options.txt',
|
||||
'optionsof.txt',
|
||||
'optionsshaders.txt'
|
||||
])
|
||||
|
||||
async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
const mcRoot = path.join(getAppDataDir(), '.minecraft')
|
||||
if (!fs.existsSync(mcRoot)) {
|
||||
@@ -1352,24 +1483,28 @@ async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
}
|
||||
let copied = 0
|
||||
let skipped = 0
|
||||
let synced = 0
|
||||
try {
|
||||
const entries = await fsp.readdir(mcRoot, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
const src = path.join(mcRoot, entry.name)
|
||||
const dst = path.join(customRoot, entry.name)
|
||||
if (fs.existsSync(dst)) {
|
||||
const dstExists = fs.existsSync(dst)
|
||||
const alwaysSync = ALWAYS_SYNC_FILES.has(entry.name)
|
||||
if (dstExists && !alwaysSync) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await fsp.copyFile(src, dst)
|
||||
copied += 1
|
||||
if (dstExists) synced += 1
|
||||
else copied += 1
|
||||
} catch (err) {
|
||||
sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
sendLog(t('log.settingCopySummary', { copied, skipped }))
|
||||
sendLog(t('log.settingCopySummary', { copied, skipped, synced }))
|
||||
} catch (err) {
|
||||
sendLog(t('log.settingCopyError', { message: (err as Error).message }))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
|
||||
export interface ClientInstallPayload {
|
||||
packKey: string
|
||||
installPlatform: boolean
|
||||
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
|
||||
skipMap?: boolean
|
||||
}
|
||||
|
||||
export interface RamCheckResult {
|
||||
|
||||
45
src/server/datapack.ts
Normal file
45
src/server/datapack.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { MusicListEntry, PackList } from '../shared/types.js'
|
||||
|
||||
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */
|
||||
function escapeSnbtString(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
|
||||
function aliasListSnbt(aliases: string[]): string {
|
||||
if (!Array.isArray(aliases) || aliases.length === 0) return '[]'
|
||||
const parts = aliases.map((a) => `"${escapeSnbtString(a)}"`)
|
||||
return `[${parts.join(',')}]`
|
||||
}
|
||||
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */
|
||||
function entrySnbt(entry: MusicListEntry): string {
|
||||
const title = escapeSnbtString(entry.title ?? '')
|
||||
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
|
||||
const author = escapeSnbtString(entry.artist ?? '')
|
||||
const alias = aliasListSnbt(entry.aliases ?? [])
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* list.music 으로부터 `data/mq/function/init/songs.mcfunction` 본문을 생성.
|
||||
* 운영자는 mc_datapack 의 music_quiz 데이터팩에서 이 파일만 이 내용으로
|
||||
* 덮어쓰면 된다 — 나머지 파일은 launcher 가 관여하지 않는다.
|
||||
*/
|
||||
export function buildSongsMcfunction(list: PackList): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# 곡 한 개 = 한 줄.')
|
||||
lines.push('# 필수 — title, author, alias')
|
||||
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
|
||||
lines.push('# 의 audio.volume 사용)')
|
||||
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}')
|
||||
lines.push('data modify storage mq:main songs set value []')
|
||||
for (const entry of list.music) {
|
||||
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('# 곡 개수는 songs 배열 길이에서 자동 계산됨')
|
||||
lines.push('execute store result storage mq:main max_index int 1 run data get storage mq:main songs')
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
deletePackKeys,
|
||||
@@ -17,6 +18,7 @@ import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import type { PackDefinition, PackList } from '../../shared/types.js'
|
||||
import { t } from '../i18n.js'
|
||||
import { buildSongsMcfunction } from '../datapack.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
@@ -223,17 +225,19 @@ opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||
opRouter.get('/op/datapack', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const keys = await listPackKeys()
|
||||
const items = await Promise.all(keys.map(async (key) => ({
|
||||
key,
|
||||
definition: await loadPackDefinition(key)
|
||||
})))
|
||||
const items = await Promise.all(keys.map(async (key) => {
|
||||
const definition = await loadPackDefinition(key)
|
||||
const list = await loadPackList(key)
|
||||
return { key, definition, musicCount: list.music.length }
|
||||
}))
|
||||
res.render('op/datapack', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 데이터팩 출력: 임시 포맷의 mcfunction 텍스트를 반환.
|
||||
// 데이터팩 출력: list.music 으로부터 init/songs.mcfunction 본문만 만들어
|
||||
// text/plain 으로 반환한다. 운영자가 mc_datapack 의 해당 파일에 붙여넣는다.
|
||||
opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
@@ -243,25 +247,49 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
|
||||
return
|
||||
}
|
||||
const list = await loadPackList(packKey)
|
||||
const lines: string[] = []
|
||||
lines.push(t('datapackOutput.header', { name: definition.name }))
|
||||
lines.push(t('datapackOutput.summary', {
|
||||
musicCount: list.music.length,
|
||||
imageCount: list.images.length
|
||||
}))
|
||||
lines.push(t('datapackOutput.initLine'))
|
||||
lines.push(t('datapackOutput.placeholder'))
|
||||
list.music.forEach((entry, index) => {
|
||||
const title = entry.title || t('datapackOutput.titleFallback')
|
||||
const artist = entry.artist || t('datapackOutput.artistFallback')
|
||||
lines.push(t('datapackOutput.trackLine', {
|
||||
index: index + 1,
|
||||
title,
|
||||
artist,
|
||||
duration: entry.durationSec
|
||||
}))
|
||||
})
|
||||
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||
res.type('text/plain; charset=utf-8').send(buildSongsMcfunction(list))
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// painting_variant JSON 들을 zip 으로 묶어 내려준다.
|
||||
// query.size 로 width/height (블록 단위, 기본 4, 1~16) 지정. 음악 개수만큼 cover_NN.json 생성.
|
||||
opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
|
||||
return
|
||||
}
|
||||
const sizeRaw = Number(pickFirstValue(req.query.size))
|
||||
const size = Number.isFinite(sizeRaw) && sizeRaw >= 1 && sizeRaw <= 16
|
||||
? Math.floor(sizeRaw)
|
||||
: 4
|
||||
const list = await loadPackList(packKey)
|
||||
const total = list.music.length
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip')
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${packKey}-painting-variants.zip"`
|
||||
)
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
archive.on('error', (err) => next(err))
|
||||
archive.pipe(res)
|
||||
for (let i = 1; i <= total; i++) {
|
||||
const nn = String(i).padStart(2, '0')
|
||||
const json = {
|
||||
asset_id: `musicquiz:cover_${nn}`,
|
||||
width: size,
|
||||
height: size,
|
||||
title: { text: `Cover ${nn}` },
|
||||
author: { text: 'music quiz' }
|
||||
}
|
||||
archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` })
|
||||
}
|
||||
await archive.finalize()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
@@ -286,6 +314,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
} as PackDefinition['platform'] & { loaderVersion?: string },
|
||||
modsFolder: pickFirstValue(req.body.modsFolder),
|
||||
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
|
||||
outputPackName: pickFirstValue(req.body.outputPackName),
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||
|
||||
@@ -4,30 +4,42 @@ import dotenv from 'dotenv'
|
||||
import { projectRoot } from './paths.js'
|
||||
|
||||
/**
|
||||
* `.env` 를 읽어 `process.env` 에 주입.
|
||||
* `.env` / `.env.build` 를 읽어 `process.env` 에 주입.
|
||||
*
|
||||
* 탐색 순서(처음 발견된 것만 사용):
|
||||
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/.env`
|
||||
* — electron-builder 의 extraResources 로 빌드 시점 `.env` 가 함께 배포됨.
|
||||
* 2. `<프로젝트 루트>/.env`
|
||||
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용.
|
||||
* 여러 파일을 순서대로 읽되 `override:false` 로 병합하므로 **먼저 로드된 값이
|
||||
* 우선**. 두 도메인(패키지 빌드용 vs 개발/서버용) 이 한 함수에서 자연스럽게
|
||||
* 분리됨:
|
||||
*
|
||||
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 우선).
|
||||
* - 파일이 없으면 조용히 통과.
|
||||
* 1. 패키징된 Electron 앱: `process.resourcesPath/.env.build`
|
||||
* — electron-builder 가 빌드 시점 `.env.build` 를 함께 배포. 패키지된 exe
|
||||
* 에서 가장 먼저 적용되는 값.
|
||||
* 2. 패키징된 Electron 앱: `process.resourcesPath/.env`
|
||||
* — 운영자가 패키징 후 직접 `.env` 를 옆에 두고 덮어쓰는 경우 폴백.
|
||||
* 3. `<프로젝트 루트>/.env`
|
||||
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용. 서버의
|
||||
* `PORT/HOST/SESSION_SECRET` 처럼 dev 에서 반드시 살아 있어야 하는 값들이
|
||||
* 있어, `.env.build` 보다 먼저 로드해 우선권을 줌.
|
||||
* 4. `<프로젝트 루트>/.env.build`
|
||||
* — dev 환경에서 빌드용 값(예: 운영 도메인 SITE_BASE_URL)을 테스트하고
|
||||
* 싶을 때 사용. `.env` 에 없는 키만 채움.
|
||||
*
|
||||
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 최우선).
|
||||
* - 존재하지 않는 후보는 조용히 건너뜀.
|
||||
* - 서버/설치기/리소스팩설치기 진입점에서 한 번씩 호출.
|
||||
*/
|
||||
export function loadEnv(): void {
|
||||
const candidates: string[] = []
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||
candidates.push(path.join(resourcesPath, '.env.build'))
|
||||
candidates.push(path.join(resourcesPath, '.env'))
|
||||
}
|
||||
candidates.push(path.join(projectRoot, '.env'))
|
||||
candidates.push(path.join(projectRoot, '.env.build'))
|
||||
|
||||
for (const envPath of candidates) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath, override: false, quiet: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||
export const fileDirPath = path.join(projectRoot, 'file')
|
||||
export const fileListDirPath = path.join(fileDirPath, 'list')
|
||||
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
|
||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||
export const publicDirPath = path.join(projectRoot, 'public')
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
|
||||
platform: { type: 'vanilla' },
|
||||
modsFolder: '',
|
||||
resourcepackPath: '',
|
||||
outputPackName: '',
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 2048,
|
||||
@@ -81,8 +82,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
||||
: fallback.mcVersion,
|
||||
platform: {
|
||||
type: platformType,
|
||||
// fabric 은 downloadUrl 을 쓰지 않고 loaderVersion 기반으로 자동 설치한다.
|
||||
downloadUrl: platformType !== 'fabric'
|
||||
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
|
||||
downloadUrl: platformType !== 'vanilla'
|
||||
&& typeof platform.downloadUrl === 'string'
|
||||
&& platform.downloadUrl.trim().length > 0
|
||||
? platform.downloadUrl.trim()
|
||||
@@ -95,6 +96,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
||||
},
|
||||
modsFolder: sanitizeFolderName(input.modsFolder),
|
||||
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
|
||||
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
|
||||
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
|
||||
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||
@@ -229,6 +232,21 @@ function sanitizeNumber(value: unknown): number {
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
|
||||
function sanitizeAliases(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of value) {
|
||||
const s = sanitizeStr(item)
|
||||
if (!s) continue
|
||||
if (seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function normalizePackList(input: unknown): PackList {
|
||||
const fallback = defaultPackList()
|
||||
if (!input || typeof input !== 'object') return fallback
|
||||
@@ -244,7 +262,8 @@ export function normalizePackList(input: unknown): PackList {
|
||||
url: sanitizeStr(entry.url),
|
||||
title: sanitizeStr(entry.title),
|
||||
artist: sanitizeStr(entry.artist),
|
||||
durationSec: sanitizeNumber(entry.durationSec)
|
||||
durationSec: sanitizeNumber(entry.durationSec),
|
||||
aliases: sanitizeAliases(entry.aliases)
|
||||
}))
|
||||
.filter((entry) => entry.url.length > 0),
|
||||
images: images
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface PackDefinition {
|
||||
modsFolder: string
|
||||
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
|
||||
resourcepackPath: string
|
||||
/**
|
||||
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
|
||||
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
|
||||
* 마인크래프트 리소스팩 목록에서 사용자에게 제목처럼 보이는 값이므로
|
||||
* 한글 등 자유 입력을 그대로 보존하고, 파일 시스템에서 사용할 때 금지 문자만
|
||||
* `_` 로 치환한다(치환 책임은 설치기 측에 있음).
|
||||
*/
|
||||
outputPackName: string
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
@@ -47,6 +55,8 @@ export interface MusicListEntry {
|
||||
artist: string
|
||||
/** 노래 길이 (초). */
|
||||
durationSec: number
|
||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export interface ImageListEntry {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('datapack.hint') %></p>
|
||||
|
||||
<section class="dpControls">
|
||||
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
|
||||
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
|
||||
@@ -25,6 +27,9 @@
|
||||
<p class="muted" id="countLabel"></p>
|
||||
|
||||
<section class="dpActions" hidden id="dpActions">
|
||||
<button type="button" class="secondaryButton" id="imagesZipBtn"><%= t('datapack.imagesZip') %></button>
|
||||
<label class="muted" for="imagesZipSize" style="margin-left:4px;"><%= t('datapack.imagesZipSizeLabel') %></label>
|
||||
<input type="number" id="imagesZipSize" value="4" min="1" max="16" style="width:60px;" />
|
||||
<button type="button" class="secondaryButton" id="exportBtn"><%= t('datapack.export') %></button>
|
||||
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
|
||||
<span class="statusText" id="dp-status"></span>
|
||||
@@ -42,7 +47,10 @@
|
||||
<div class="modalBody">
|
||||
<div class="cardRow horizontalScroll" id="pickList">
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard pickable" data-key="<%= item.key %>" data-name="<%= item.definition ? item.definition.name : item.key %>">
|
||||
<article class="packCard pickable"
|
||||
data-key="<%= item.key %>"
|
||||
data-name="<%= item.definition ? item.definition.name : item.key %>"
|
||||
data-music-count="<%= item.musicCount %>">
|
||||
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||
<p class="muted"><%= item.key %>.json</p>
|
||||
<% if (item.definition) { %>
|
||||
@@ -60,8 +68,6 @@
|
||||
|
||||
<script>
|
||||
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
|
||||
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -76,16 +82,26 @@
|
||||
pickModal.addEventListener('click', function (e) {
|
||||
if (e.target === pickModal) pickModal.hidden = true
|
||||
})
|
||||
// ESC 로 닫기.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !pickModal.hidden) {
|
||||
pickModal.hidden = true
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
document.querySelectorAll('#pickList .pickable').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
pickedKey = card.getAttribute('data-key')
|
||||
var name = card.getAttribute('data-name')
|
||||
var count = card.getAttribute('data-music-count') || '0'
|
||||
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
|
||||
document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', count)
|
||||
pickModal.hidden = true
|
||||
document.getElementById('dpActions').hidden = false
|
||||
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
||||
document.getElementById('countLabel').textContent = ''
|
||||
document.getElementById('dp-status').textContent = ''
|
||||
document.getElementById('dp-status').classList.remove('error')
|
||||
document.getElementById('codeOut').hidden = true
|
||||
document.getElementById('codeOut').textContent = ''
|
||||
})
|
||||
})
|
||||
document.getElementById('exportBtn').addEventListener('click', function () {
|
||||
@@ -102,13 +118,25 @@
|
||||
var out = document.getElementById('codeOut')
|
||||
out.textContent = res.text
|
||||
out.hidden = false
|
||||
// 첫줄/둘째줄에서 곡 개수를 추출해 카운트 라벨에 표시.
|
||||
var m = res.text.match(/총\s+(\d+)곡/)
|
||||
if (m) document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', m[1])
|
||||
s.textContent = I18N.exported
|
||||
})
|
||||
.catch(function (err) { s.textContent = I18N.failed.replace('{{message}}', err.message); s.classList.add('error') })
|
||||
})
|
||||
document.getElementById('imagesZipBtn').addEventListener('click', function () {
|
||||
if (!pickedKey) return
|
||||
var sizeInput = document.getElementById('imagesZipSize')
|
||||
var size = parseInt(sizeInput.value, 10)
|
||||
if (!isFinite(size) || size < 1) size = 4
|
||||
if (size > 16) size = 16
|
||||
sizeInput.value = String(size)
|
||||
var s = document.getElementById('dp-status')
|
||||
s.textContent = I18N.imagesZipDownloading; s.classList.remove('error')
|
||||
// 브라우저 기본 다운로드로 위임. 인증 쿠키는 자동으로 따라간다.
|
||||
var url = '/op/datapack/' + encodeURIComponent(pickedKey) + '/images-zip?size=' + size
|
||||
window.location.href = url
|
||||
// 다운로드 시작은 비동기지만, 사용자에게 즉시 피드백.
|
||||
setTimeout(function () { s.textContent = I18N.imagesZipDone }, 500)
|
||||
})
|
||||
document.getElementById('copyBtn').addEventListener('click', function () {
|
||||
var out = document.getElementById('codeOut')
|
||||
if (out.hidden) return
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformDownloadField">
|
||||
<label class="fullSpan" id="platformDownloadField"<%= pack.platform.type === 'vanilla' ? ' hidden' : '' %>>
|
||||
<span><%= t('editor.platformDownloadUrl') %></span>
|
||||
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
|
||||
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformLoaderField" hidden>
|
||||
<label class="fullSpan" id="platformLoaderField"<%= pack.platform.type === 'fabric' ? '' : ' hidden' %>>
|
||||
<span><%= t('editor.platformLoaderVersion') %></span>
|
||||
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
|
||||
<option value=""><%= t('common.loading') %></option>
|
||||
@@ -98,6 +98,11 @@
|
||||
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
|
||||
<small class="muted"><%= t('editor.resourcepackHint') %></small>
|
||||
</label>
|
||||
<label class="fullSpan">
|
||||
<span><%= t('editor.outputPackName') %></span>
|
||||
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
|
||||
<small class="muted"><%= t('editor.outputPackNameHint') %></small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
|
||||
@@ -131,9 +136,8 @@
|
||||
function syncPlatformVisibility() {
|
||||
var type = platformSelect.value
|
||||
if (type === 'fabric') {
|
||||
downloadField.removeAttribute('hidden')
|
||||
loaderField.removeAttribute('hidden')
|
||||
downloadField.setAttribute('hidden', '')
|
||||
downloadField.querySelector('input').value = ''
|
||||
loadFabricLoaders()
|
||||
} else if (type === 'vanilla') {
|
||||
downloadField.setAttribute('hidden', '')
|
||||
|
||||
@@ -106,6 +106,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alias modal (music) -->
|
||||
<div class="modalOverlay" id="aliasModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header class="aliasModalHeader">
|
||||
<button type="button" class="ghostLink" id="alias-back"><%= t('listEditor.aliasBack') %></button>
|
||||
<h3 id="alias-modal-title"></h3>
|
||||
<span></span>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.aliasHint') %></p>
|
||||
<div id="alias-rows" class="aliasRowList"></div>
|
||||
<div>
|
||||
<button type="button" class="secondaryButton" id="alias-add"><%= t('listEditor.aliasAdd') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (image) -->
|
||||
<div class="modalOverlay" id="editImageModal" hidden>
|
||||
<div class="modalCard">
|
||||
|
||||
Reference in New Issue
Block a user