diff --git a/installer/renderer.js b/installer/renderer.js index 55c5129..a7b712b 100644 --- a/installer/renderer.js +++ b/installer/renderer.js @@ -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: '', @@ -154,33 +159,71 @@ function renderStep2() { '' + '' + '' + + '' + '
' pageHost.appendChild(section) var nextBtn = section.querySelector('#next') var modeButtons = section.querySelectorAll('[data-mode]') + var roleSection = section.querySelector('#roleSection') + var roleButtons = section.querySelectorAll('[data-role]') - 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') }) + if (mode === 'multi') { + roleSection.hidden = false + // 역할이 이미 골라져 있으면 그대로, 아니면 사용자가 골라야 next 활성화. + nextBtn.disabled = !state.role + } else { + roleSection.hidden = true + state.role = null + roleButtons.forEach(function (btn) { btn.classList.remove('selected') }) + nextBtn.disabled = false + } + } + + 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 } modeButtons.forEach(function (btn) { btn.addEventListener('click', function () { - applySelection(btn.getAttribute('data-mode')) + applyMode(btn.getAttribute('data-mode')) + }) + }) + roleButtons.forEach(function (btn) { + btn.addEventListener('click', function () { + applyRole(btn.getAttribute('data-role')) }) }) - if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode) + if (state.mode === 'single' || state.mode === 'multi') { + applyMode(state.mode) + if (state.mode === 'multi' && state.role) applyRole(state.role) + } nextBtn.addEventListener('click', function () { if (!state.mode) return + if (state.mode === 'multi' && !state.role) return state.stepDone[2] = true - if (state.mode === 'single') renderStep4() - else renderStep3() + // 멀티+호스트 만 서버 설치(step3) 를 거친다. + // 싱글, 멀티+참가자 는 곧장 클라이언트(step4) 로. + if (state.mode === 'multi' && state.role === 'host') renderStep3() + else renderStep4() }) section.querySelector('#back').addEventListener('click', renderStep1) } @@ -446,20 +489,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 = '

' + tt('step3.eulaModal.fromFile') + '

' + - '
' + escapeHtml(read.content) + '
' + var fetched = await installerApi.fetchMinecraftEula() + if (fetched.html) { + bodyHtml = '

' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '

' + + '' } else { - var fetched = await installerApi.fetchMinecraftEula() - if (fetched.html) { - bodyHtml = '

' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '

' + - '' - } else { - bodyHtml = '

' + tt('step3.eulaModal.loadFailed') + '

' - } + bodyHtml = '

' + tt('step3.eulaModal.loadFailed') + '

' } return new Promise(function (resolve) { var overlay = document.createElement('div') @@ -491,12 +530,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 +568,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 +584,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 +622,25 @@ function renderStep4() { '
' 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 (서버 설치) 를 거쳤으므로 거기로 돌아간다. + // 싱글 / 멀티+참가자 는 step2 로 되돌아간다. + function backToPrevStep() { + if (state.mode === 'multi' && state.role === 'host') renderStep3() + 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 = - '

' + tt('step4.sub41.heading') + '

' + - '

' + tt('step4.sub41.vanillaInfo') + '

' + - '

' + tt('step4.sub41.vanillaNoInstall') + '

' + - '
' - host.querySelector('#back').addEventListener('click', back) - host.querySelector('#next').addEventListener('click', done) - return - } - - host.innerHTML = - '

' + tt('step4.sub41.heading') + '

' + - '

' + tt('step4.sub41.info', { platform: platformType }) + '

' + - '
' + - '' + - '' + - '
' + - '
' - - 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) { @@ -665,7 +667,9 @@ function renderSubStep42(host, back, done) { try { await installerApi.installClient({ packKey: state.selectedPackKey, - installPlatform: !!state.client.installPlatform + installPlatform: !!state.client.installPlatform, + // 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다. + skipMap: state.mode === 'multi' && state.role === 'participant' }) msg.textContent = tt('step4.sub42.done') msg.classList.add('success') @@ -683,11 +687,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 = '

' + tt('step5.heading') + '

' + '

' + tt('step5.summary') + '

' + - (multi ? '
' + + (showServerActions ? '
' + '

' + tt('step5.serverHeading') + '

' + '' + '' + @@ -700,7 +706,7 @@ function renderStep5() { '
' pageHost.appendChild(section) section.querySelector('#back').addEventListener('click', renderStep4) - if (multi) { + if (showServerActions) { section.querySelector('#openFolder').addEventListener('click', function () { installerApi.openServerFolder() }) @@ -710,7 +716,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() } diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index c1ea4f6..97228d7 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -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를 표시합니다 ({{url}}).", + "fromMojang": "마인크래프트 서버를 실행하려면 아래 EULA에 동의해야 합니다 ({{url}}).", "loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: https://www.minecraft.net/en-us/eula" }, "sub34": { @@ -107,26 +111,16 @@ "portLabel": "포트", "recheck": "재점검", "checking": "확인 중...", - "preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}", - "upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}", + "preForwarded": "포트포워딩 성공! 친구는 {{address}} 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)", + "upnpOk": "포트포워딩 성공! 친구는 {{address}} 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)", "manualHint": "직접 포트포워딩을 해주세요.", - "manualDetail": "
외부 IP: {{ip}}, 포트: {{port}}", + "manualDetail": "
외부 주소: {{address}}", "checkFailed": "점검 실패: {{message}}", "ipUnknown": "확인 불가" } }, "step4": { "heading": "클라이언트 설정", - "sub41": { - "heading": "플랫폼", - "vanillaInfo": "선택한 음악퀴즈의 플랫폼: vanilla", - "vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.", - "info": "선택한 음악퀴즈의 플랫폼: {{platform}}", - "installTitle": "권장 플랫폼 설치", - "installHint": "{{platform}} 설치", - "skipTitle": "기본 마인크래프트로 설치", - "skipHint": "설치하지 않고 바닐라로 진행합니다." - }, "sub42": { "heading": "다운로드 및 적용", "description": "클라이언트 설정", @@ -196,7 +190,7 @@ "labelServerFile": "서버 파일", "labelMap": "맵", "skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.", - "skipMapZip": "맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.", + "skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).", "skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.", "modsIndexFetch": "모드 목록 조회: {{url}}", "modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.", diff --git a/src/installer/main.ts b/src/installer/main.ts index c12515d..58635a8 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -1047,7 +1047,11 @@ 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) { + sendLog(t('log.skipMapZip')) + } else { + await downloadMapZip(pack.pack, customRoot) + } // 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를 // 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크. diff --git a/src/installer/types.ts b/src/installer/types.ts index 9cb3e67..8464792 100644 --- a/src/installer/types.ts +++ b/src/installer/types.ts @@ -25,6 +25,8 @@ export interface ServerInstallPayload { export interface ClientInstallPayload { packKey: string installPlatform: boolean + /** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */ + skipMap?: boolean } export interface RamCheckResult {