'
}
})()
}
// 퀴즈팩 선택 직후 노출하는 "마인크래프트/런처 종료" 강조 안내. 크게 표시.
function renderCloseNotice() {
setActiveStep(1)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
' +
'
' + escapeHtml(tt('closeNotice.badge')) + '
' +
'
' + escapeHtml(tt('closeNotice.message')) + '
' +
'
' + escapeHtml(tt('closeNotice.sub')) + '
' +
'
' +
''
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep1)
section.querySelector('#next').addEventListener('click', renderAgreement)
}
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms//index.json) 가
// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비어 있는 (terms:[])
// 정상 응답일 때만 단계 자체를 건너뛴다. 네트워크 오류/404/서버 오류는 사용자가 약관 동의
// 없이 설치로 넘어가는 것을 막기 위해 오류 화면 + 다시 시도 버튼으로 차단한다.
function renderAgreement() {
setActiveStep(1)
clearPage()
var loadingSection = document.createElement('section')
loadingSection.className = 'page'
loadingSection.innerHTML = '
' + tt('agreement.heading') + '
' +
'
' + tt('agreement.loading') + '
'
pageHost.appendChild(loadingSection)
installerApi.getTermsList().then(function (res) {
if (!res || !res.ok) {
showAgreementError((res && res.message) || 'unknown')
return
}
var terms = (res.terms || []).map(function (t) {
return { id: t.kind, tab: t.label }
})
if (terms.length === 0) {
// 명시적으로 표시 대상이 0개라고 서버가 알려준 정상 응답 → 약관 단계 스킵.
renderStep2()
return
}
clearPage()
renderAgreementWithKinds(terms)
}).catch(function (err) {
showAgreementError(err && err.message ? err.message : 'unknown')
})
}
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도/뒤로 가기 옵션을 보여준다.
// 동의 없이 설치 단계로 넘어가지 않도록 next 버튼을 두지 않는다.
function showAgreementError(message) {
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep1)
section.querySelector('#retry').addEventListener('click', renderAgreement)
}
// 약관을 한 건씩 페이지로 보여준다. 각 약관마다 "해당 약관에 동의합니다" 체크가 있고,
// 끝까지 스크롤해 읽어야 체크할 수 있으며, [다음] 을 누르면 다음 약관 페이지가 화면
// 맨 위에서부터 나온다. 마지막 약관까지 동의하면 다음 단계(step2)로 진행한다.
function renderAgreementWithKinds(KINDS, opts) {
opts = opts || {}
var onComplete = opts.onComplete || renderStep2 // 마지막 약관 동의 후
var onBack = opts.onBack || renderStep1 // 첫 약관에서 뒤로
var idx = 0
var accepted = {} // kind -> true (뒤로 갔다 와도 동의 상태 유지)
var cache = {} // kind -> 렌더된 HTML
var section = document.createElement('section')
section.className = 'page'
pageHost.appendChild(section)
function renderCurrent() {
var k = KINDS[idx]
var isLast = idx === KINDS.length - 1
section.innerHTML =
'
' +
''
var retry = section.querySelector('#agRetry')
if (retry) retry.addEventListener('click', loadTerm)
}
function loadTerm() {
if (cache[k.id]) {
body.innerHTML = cache[k.id]
afterLoad()
return
}
body.textContent = tt('agreement.loading')
installerApi.getTerm(k.id).then(function (res) {
if (!res.ok) {
showLoadError(res.message || '')
return
}
var html = renderTermsMarkdown(res.content || '')
cache[k.id] = html
body.innerHTML = html
afterLoad()
}).catch(function (err) {
showLoadError(err && err.message ? err.message : '')
})
}
loadTerm()
}
renderCurrent()
}
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다.
function renderTermsMarkdown(src) {
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>')
}
function inline(s) {
s = escHtml(s)
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/\*\*([^*]+)\*\*/g, '$1')
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1$2')
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '' + u + ''
})
return s
}
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stack = null
function closeList() { if (stack) { out.push('' + stack + '>'); stack = null } }
while (i < lines.length) {
var line = lines[i]
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []; i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('
' + escHtml(code.join('\n')) + '
')
continue
}
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]; var body2 = []; i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
if (i < lines.length) i += 1
out.push('' + inline(summary) + '' + renderTermsMarkdown(body2.join('\n')) + '')
continue
}
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
out.push('' + inline(h[2]) + '')
i += 1; continue
}
if (/^---+\s*$/.test(line)) { closeList(); out.push(''); i += 1; continue }
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
out.push('
' + renderTermsMarkdown(q.join('\n')) + '
')
continue
}
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stack !== 'ol') { closeList(); out.push(''); stack = 'ol' }
out.push('
' + inline(ol[1]) + '
'); i += 1; continue
}
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stack !== 'ul') { closeList(); out.push('
'); stack = 'ul' }
out.push('
' + inline(ul[1]) + '
'); i += 1; continue
}
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('
' +
''
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var modeButtons = section.querySelectorAll('[data-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 () {
applyMode(btn.getAttribute('data-mode'))
})
})
if (state.mode === 'single' || state.mode === 'multi') {
applyMode(state.mode)
}
nextBtn.addEventListener('click', function () {
if (!state.mode) return
state.stepDone[2] = true
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
if (state.mode === 'multi') renderStep2Role()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderAgreement)
}
function renderStep2Role() {
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
' + tt('step2.roleHeading') + '
' +
'
' +
'' +
'' +
'
' +
''
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()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
' + tt('step3.heading') + '
' +
''
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
// 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) }
function show35() {
subHost.innerHTML = ''
renderSubStep35(subHost, show34, function () {
state.stepDone[3] = true
renderStep4()
})
}
show31()
}
function renderSubStep31(host, back, done) {
host.innerHTML =
'
' + tt('step3.sub31.heading') + '
' +
'
' + tt('step3.sub31.description') + '
' +
'
' +
'
' +
'' +
''
var input = host.querySelector('#installPath')
var msg = host.querySelector('#msg')
host.querySelector('#pickFolder').addEventListener('click', async function () {
var picked = await installerApi.pickFolder()
if (picked) input.value = picked
})
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', async function () {
var result = await installerApi.validateInstallPath(input.value.trim())
if (!result.ok) {
msg.textContent = result.message || tt('step3.sub31.invalidPath')
msg.classList.add('error')
return
}
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
msg.classList.remove('error')
msg.classList.add('success')
state.serverInstall.path = input.value.trim()
done()
})
}
function renderSubStep32(host, back, done) {
// 선택한 음악퀴즈(pack)의 권장 JDK. 설치기는 이 버전을 우선 찾고/설치한다.
var selPack = (state.packs && state.packs.find)
? state.packs.find(function (p) { return p.key === state.selectedPackKey })
: null
var recommendedJdk = (selPack && selPack.pack && selPack.pack.recommendedJdk) ? selPack.pack.recommendedJdk : 25
host.innerHTML =
'
' +
'' +
'' +
'' +
''
var resultMsg = host.querySelector('#resultMsg')
var nextBtn = host.querySelector('#next')
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')
resultMsg.textContent = tt('step3.sub35.checking')
var port = Number(host.querySelector('#port').value) || 25565
try {
var result = await installerApi.checkPortForward(port)
state.serverInstall.portStatus = result
var address = formatServerAddress(result.externalIp, result.port)
if (result.status === 'open') {
// 열려 있으면 외부 접속 주소를 크게 표시.
resultMsg.innerHTML =
'
' +
'
' + escapeHtml(tt('step3.sub35.openTitle')) + '
' +
'
' + escapeHtml(address) + '
' +
'
'
} else {
// 안 열려 있으면 "직접 포트포워딩 해주세요" 를 크게 표시.
resultMsg.innerHTML =
'
' +
''
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
// 플랫폼 선택 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()
}
show42()
}
function renderSubStep42(host, back, done) {
host.innerHTML =
'
' + tt('step4.sub42.heading') + '
' +
'
' + tt('step4.sub42.description') + '
' +
'
' + tt('step4.sub42.installing') + '
' +
''
var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
// 이번에 실제로 보내야 할 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
return
}
// 페이지 진입 즉시 자동 설치
;(async function () {
try {
await installerApi.installClient(payload)
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
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')
}
})()
}
// 최종 리소스팩 설치 여부 질문 (예/아니요).
function renderResourcepackPrompt() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
' + tt('resourcepack.promptHeading') + '
' +
'
' + escapeHtml(tt('resourcepack.promptBody')) + '
' +
'
' +
' ' +
' ' +
'
'
pageHost.appendChild(section)
section.querySelector('#rpNo').addEventListener('click', function () {
state.finalRpHandled = true
renderStep5()
})
section.querySelector('#rpYes').addEventListener('click', renderResourcepackTerms)
}
// "예" → 최종 리소스팩 다운로드 약관(showInFinalResourcepack) 동의 → 다운로드.
function renderResourcepackTerms() {
setActiveStep(5)
clearPage()
var loading = document.createElement('section')
loading.className = 'page'
loading.innerHTML = '
'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderResourcepackPrompt)
section.querySelector('#retry').addEventListener('click', renderResourcepackTerms)
}
// 약관 동의 후 최종 리소스팩 실제 다운로드 → 완료되면 완료 화면으로.
function downloadFinalResourcepack() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'
' + tt('resourcepack.downloadHeading') + '
' +
'
' + tt('resourcepack.downloading') + '
' +
'
' +
' ' +
'
' +
'
0%
' +
''
pageHost.appendChild(section)
var msg = section.querySelector('#rpMsg')
var actions = section.querySelector('#rpActions')
var bar = section.querySelector('#rpBar')
var barWrap = section.querySelector('#rpBarWrap')
var percentEl = section.querySelector('#rpPercent')
// 다운로드 진행률(퍼센트) 구독. 완료/실패 시 해제.
var unsubscribe = null
if (installerApi.onFinalResourcepackProgress) {
unsubscribe = installerApi.onFinalResourcepackProgress(function (info) {
if (info && info.percent >= 0) {
bar.style.width = info.percent + '%'
percentEl.textContent = info.percent + '%'
} else if (info) {
// 전체 크기를 모르면 받은 용량(MB)만 표시.
percentEl.textContent = (info.loaded / 1048576).toFixed(1) + ' MB'
}
})
}
function stopProgress() { if (unsubscribe) { unsubscribe(); unsubscribe = null } }
function proceed() { stopProgress(); state.finalRpHandled = true; renderStep5() }
installerApi.installFinalResourcepack().then(function (res) {
stopProgress()
if (res && res.ok) {
bar.style.width = '100%'
percentEl.textContent = '100%'
msg.textContent = tt('resourcepack.downloadDone')
msg.classList.add('success')
actions.innerHTML = ''
actions.querySelector('#rpNext').addEventListener('click', proceed)
} else {
barWrap.hidden = true
percentEl.hidden = true
msg.textContent = tt('resourcepack.downloadFailed', { message: (res && res.message) || 'unknown' })
msg.classList.add('error')
actions.innerHTML =
'' +
''
actions.querySelector('#rpRetry').addEventListener('click', downloadFinalResourcepack)
actions.querySelector('#rpSkip').addEventListener('click', proceed)
}
actions.hidden = false
}).catch(function (err) {
stopProgress()
barWrap.hidden = true
percentEl.hidden = true
msg.textContent = tt('resourcepack.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
actions.innerHTML =
'' +
''
actions.querySelector('#rpRetry').addEventListener('click', downloadFinalResourcepack)
actions.querySelector('#rpSkip').addEventListener('click', proceed)
actions.hidden = false
})
}
function renderStep5() {
setActiveStep(5)
clearPage()
// 최종 리소스팩(finalResourcepackPath)이 "." 이 아니고 아직 처리하지 않았으면,
// 완료 화면 전에 "리소스팩을 설치하시겠습니까?" 를 먼저 묻는다.
var selPack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
var finalRp = (selPack && selPack.pack && selPack.pack.finalResourcepackPath) ? selPack.pack.finalResourcepackPath : '.'
if (finalRp !== '.' && !state.finalRpHandled) {
renderResourcepackPrompt()
return
}
var section = document.createElement('section')
section.className = 'page'
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
var showServerActions = state.mode === 'multi' && state.role === 'host'
section.innerHTML =
'
' + tt('step5.heading') + '
' +
'
' + tt('step5.summary') + '
' +
(showServerActions ? '
' +
'
' + tt('step5.serverHeading') + '
' +
'' +
'' +
'' +
'
' : '') +
'
' +
'
' + tt('step5.launcherHeading') + '
' +
'' +
'
' +
''
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep4)
if (showServerActions) {
section.querySelector('#openFolder').addEventListener('click', function () {
installerApi.openServerFolder()
})
}
section.querySelector('#finish').addEventListener('click', async function () {
var finishBtn = section.querySelector('#finish')
finishBtn.disabled = true
finishBtn.textContent = tt('step5.finishing')
try {
if (showServerActions) {
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
if (section.querySelector('#startServer').checked) await installerApi.startServer()
}
if (section.querySelector('#startLauncher').checked) await installerApi.startMinecraftLauncher()
} catch (err) {
// 마무리 액션 실패는 무시하고 종료 진행
}
finishBtn.textContent = tt('step5.finished')
if (installerApi.quitApp) installerApi.quitApp()
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
})
}
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
;(async function () {
try {
I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} }
applyStaticI18n()
renderIntro()
})()