6 Commits

Author SHA1 Message Date
8c0226d3f9 installer: fix UPnP PowerShell parse error (^| -> | inside quotes)
run.bat 의 UPnP 등록/해제 PowerShell 명령에서 파이프를 cmd식 ^| 로
이스케이프했는데, 이 파이프는 powershell -Command "..." 의 큰따옴표 안이라
cmd 가 이미 리터럴로 넘긴다. 그래서 PowerShell 이 ^ 를 "예기치 않은 토큰"
으로 파싱 실패(서버는 정상 기동하나 콘솔에 에러 출력, UPnP 매핑도 실패).
^| -> | 로 교정. for/f 의 2^>nul(=cmd 레벨 리다이렉트)은 그대로 유지. v0.3.13.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-24 17:09:39 +09:00
674b9e7c87 installers: add intro notice (main) and finish notice (rp)
- 메인 설치기: 첫 페이지로 "마인크래프트 런처를 끄고 시작해주세요" 안내(renderIntro)
  추가 후 다음 버튼으로 step1 진입.
- 리소스팩 설치기: 완료(step3) 페이지 문구를 "사용자 동의하에 리소스팩이
  설치되었습니다." + "리소스팩은 직접 적용해주세요." 로 변경.
v0.3.12.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-23 23:02:38 +09:00
4a76c09f3a installer: brighten agreement text (white→pure white, gray→white)
약관 본문(.agreementBody) 글자색을 더 진하게: 제목/굵은글씨/목록은 순백(#fff),
회색이던 문단(.page p 상속)은 이전 흰색 톤(#e6edf3)으로 올림. 두 설치기가
공유하는 installer/styles.css 변경. v0.3.11.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-23 22:54:51 +09:00
48aec4e144 site: add video field and set volume default 0.5 in datapack SNBT
곡 SNBT 출력을 {volume:0.5, title, author, alias, description, video} 형식으로
변경(요청). video = MusicListEntry.url(유튜브 영상 주소). volume 기본값을
1.0 → 0.5 로 변경. 헤더 안내 주석도 새 형식에 맞춤.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-14 01:39:55 +09:00
21eadb3b20 site: reorder datapack SNBT fields to volume-first
데이터팩 songs.mcfunction 의 곡 SNBT 출력 순서를
{volume, title, author, alias, description} 로 변경(요청). volume 기본값은
1.0 유지. SNBT 는 키 순서 무관이라 데이터팩 파싱에는 영향 없음.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-14 01:35:59 +09:00
fa5da6d052 installer-rp: fix ffmpeg 404 by using rolling 'latest' tag URL
BtbN/FFmpeg-Builds 다운로드를 releases/latest/download/ (GitHub 최신 릴리스
자동 포인터)에서 releases/download/latest/ (항상 최신 자산이 붙은 롤링 latest
태그)로 변경. 전자는 갓 생성된 autobuild-<날짜> 릴리스로 리다이렉트되는데
자산이 아직/없으면 HTTP 404 로 ffmpeg 설치가 실패한다. v0.3.10.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-07 23:54:18 +09:00
9 changed files with 44 additions and 13 deletions

View File

@@ -534,6 +534,7 @@ function renderStep3() {
section.innerHTML = section.innerHTML =
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' + '<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' + '<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
'<p class="formMessage">' + escapeHtml(tt('step3.applyNotice')) + '</p>' +
(state.resourcepackPath (state.resourcepackPath
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>' ? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
: '') + : '') +

View File

@@ -96,6 +96,20 @@ function clearPage() {
pageHost.innerHTML = '' pageHost.innerHTML = ''
} }
// 첫 진입 안내 페이지: 마인크래프트 런처를 끄고 시작하도록 안내.
function renderIntro() {
setActiveStep(1)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('intro.heading') + '</h2>' +
'<p class="formMessage">' + tt('intro.message') + '</p>' +
'<div class="actionRow"><button class="primaryBtn" id="introNext">' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
section.querySelector('#introNext').addEventListener('click', renderStep1)
}
function renderStep1() { function renderStep1() {
setActiveStep(1) setActiveStep(1)
clearPage() clearPage()
@@ -972,5 +986,5 @@ function escapeHtml(s) {
I18N = (await installerApi.loadLocale()) || {} I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} } } catch (_) { I18N = {} }
applyStaticI18n() applyStaticI18n()
renderStep1() renderIntro()
})() })()

View File

@@ -181,12 +181,15 @@ main {
overflow-y: auto; overflow-y: auto;
font-size: 13px; font-size: 13px;
line-height: 1.65; line-height: 1.65;
/* 약관 본문은 더 진한 순백으로(제목/굵은글씨/목록). */
color: #fff;
} }
.agreementBody h1, .agreementBody h2, .agreementBody h3 { margin: 12px 0 6px; } .agreementBody h1, .agreementBody h2, .agreementBody h3 { margin: 12px 0 6px; }
.agreementBody h1 { font-size: 17px; } .agreementBody h1 { font-size: 17px; }
.agreementBody h2 { font-size: 15px; } .agreementBody h2 { font-size: 15px; }
.agreementBody h3 { font-size: 14px; } .agreementBody h3 { font-size: 14px; }
.agreementBody p { margin: 6px 0; } /* 본문 문단은 기존 회색(--text-muted) 대신 이전 흰색 톤(#e6edf3)으로 올린다. */
.agreementBody p { margin: 6px 0; color: #e6edf3; }
.agreementBody ul, .agreementBody ol { margin: 6px 0; padding-left: 22px; } .agreementBody ul, .agreementBody ol { margin: 6px 0; padding-left: 22px; }
.agreementBody li { margin: 2px 0; } .agreementBody li { margin: 2px 0; }
.agreementBody code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; font-family: 'Consolas', monospace; } .agreementBody code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; font-family: 'Consolas', monospace; }

View File

@@ -63,7 +63,8 @@
}, },
"step3": { "step3": {
"heading": "완료", "heading": "완료",
"message": "리소스팩 설치를 완료했습니다." "message": "사용자 동의하에 리소스팩 설치되었습니다.",
"applyNotice": "리소스팩은 직접 적용해주세요."
}, },
"install": { "install": {
"errorMessage": "설치 중 오류가 발생했습니다: {{message}}", "errorMessage": "설치 중 오류가 발생했습니다: {{message}}",

View File

@@ -1,4 +1,8 @@
{ {
"intro": {
"heading": "시작하기 전에",
"message": "마인크래프트 런처를 끄고 시작해주세요."
},
"common": { "common": {
"back": "이전", "back": "이전",
"next": "다음", "next": "다음",

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.3.9", "version": "0.3.13",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {

View File

@@ -39,9 +39,15 @@ async function migrateLegacyExe(target: string): Promise<void> {
} }
} }
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */ /**
* BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음.
* `releases/download/latest/` 형태(=항상 최신 자산이 붙어 있는 롤링 `latest` 태그)를
* 쓴다. `releases/latest/download/`(GitHub 의 "최신 릴리스" 자동 포인터)는 갓
* 만들어진 `autobuild-<날짜>` 릴리스로 리다이렉트되는데, 그 릴리스에 자산이 아직
* 업로드되지 않았거나 없으면 HTTP 404 가 나서 ffmpeg 설치가 실패한다.
*/
const FFMPEG_ZIP_URL = const FFMPEG_ZIP_URL =
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip' 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip'
let installPromise: Promise<string> | null = null let installPromise: Promise<string> | null = null

View File

@@ -657,13 +657,13 @@ async function injectUpnpToRunBat(installPath: string): Promise<void> {
'for /f "tokens=2 delims==" %%a in (\'findstr /b /c:"server-port=" server.properties 2^>nul\') do set "_MQ_PORT=%%a"', 'for /f "tokens=2 delims==" %%a in (\'findstr /b /c:"server-port=" server.properties 2^>nul\') do set "_MQ_PORT=%%a"',
'set "_MQ_PORT=%_MQ_PORT: =%"', 'set "_MQ_PORT=%_MQ_PORT: =%"',
'echo [MusicQuiz] UPnP 등록 시도: TCP %_MQ_PORT%', 'echo [MusicQuiz] UPnP 등록 시도: TCP %_MQ_PORT%',
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue ^| Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} ^| Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') ^| Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"' 'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue | Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} | Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') | Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') | Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"'
] ]
const removeBlock = [ const removeBlock = [
'REM 서버 종료 후: UPnP 매핑 해제.', 'REM 서버 종료 후: UPnP 매핑 해제.',
'echo [MusicQuiz] UPnP 해제 시도: TCP %_MQ_PORT%', 'echo [MusicQuiz] UPnP 해제 시도: TCP %_MQ_PORT%',
'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"' 'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') | Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"'
] ]
const merged: string[] = [] const merged: string[] = []

View File

@@ -21,16 +21,18 @@ function aliasListSnbt(aliases: string[]): string {
return `[${parts.join(',')}]` return `[${parts.join(',')}]`
} }
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...], description:"...", volume:1.0}` SNBT. */ /** 한 곡(MusicListEntry) → `{volume:0.5, title:"...", author:"...", alias:[...], description:"...", video:"..."}` SNBT. */
function entrySnbt(entry: MusicListEntry): string { function entrySnbt(entry: MusicListEntry): string {
const title = escapeSnbtString(entry.title ?? '') const title = escapeSnbtString(entry.title ?? '')
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다. // launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
const author = escapeSnbtString(entry.artist ?? '') const author = escapeSnbtString(entry.artist ?? '')
const alias = aliasListSnbt(entry.aliases ?? []) const alias = aliasListSnbt(entry.aliases ?? [])
const description = escapeSnbtString(entry.description ?? '') const description = escapeSnbtString(entry.description ?? '')
// launcher 가 생성하는 항목에는 volume 기본값 1.0 을 항상 넣는다. // video = 곡의 유튜브 영상 주소(MusicListEntry.url).
const video = escapeSnbtString(entry.url ?? '')
// launcher 가 생성하는 항목에는 volume 기본값 0.5 를 항상 넣는다.
// 운영자는 생성된 mcfunction 에서 곡별로 직접 값을 바꿔 사용한다. // 운영자는 생성된 mcfunction 에서 곡별로 직접 값을 바꿔 사용한다.
return `{title:"${title}", author:"${author}", alias:${alias}, description:"${description}", volume:1.0}` return `{volume:0.5, title:"${title}", author:"${author}", alias:${alias}, description:"${description}", video:"${video}"}`
} }
/** /**
@@ -41,11 +43,11 @@ function entrySnbt(entry: MusicListEntry): string {
export function buildSongsMcfunction(list: PackList): string { export function buildSongsMcfunction(list: PackList): string {
const lines: string[] = [] const lines: string[] = []
lines.push('# 곡 한 개 = 한 줄.') lines.push('# 곡 한 개 = 한 줄.')
lines.push('# 필수 — title, author, alias, description') lines.push('# 필수 — title, author, alias, description, video')
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction') lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
lines.push('# 의 audio.volume 사용)') lines.push('# 의 audio.volume 사용)')
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.') lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], description:"...", volume:1.0}') lines.push('# 예) {volume:0.5, title:"Quiet Song", author:"...", alias:[...], description:"...", video:"..."}')
lines.push('data modify storage mq:main songs set value []') lines.push('data modify storage mq:main songs set value []')
for (const entry of list.music) { for (const entry of list.music) {
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`) lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)