Update deployment scripts, add leaderboard feature, and bump version to 2.0.0

This commit is contained in:
2026-01-26 20:06:38 +08:00
parent 8d58bf683f
commit f3ce687ab8
13 changed files with 2777 additions and 1672 deletions

View File

@@ -0,0 +1,266 @@
/* Leaderboard Overlay */
#leaderboard-overlay,
#leaderboard-submit-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
/* Leaderboard Modal */
#leaderboard-modal {
background: linear-gradient(180deg, #ff9f18 0%, #ff6b00 100%);
border: 4px solid #000;
border-radius: 15px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
#leaderboard-header {
background: linear-gradient(180deg, #ff4500 0%, #d63000 100%);
border-radius: 11px 11px 0 0;
padding: 15px 20px;
text-align: center;
border-bottom: 3px solid #000;
}
#leaderboard-title {
font-size: 1.5em;
font-weight: bold;
color: #fff;
text-shadow: 2px 2px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
}
#leaderboard-song-title {
font-size: 1.1em;
color: #fff;
margin-top: 5px;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-difficulty {
font-size: 0.9em;
color: #ffe100;
margin-top: 3px;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-content {
flex: 1;
overflow-y: auto;
padding: 10px;
background: #fff8e8;
}
#leaderboard-loading,
#leaderboard-empty,
#leaderboard-error {
text-align: center;
padding: 40px 20px;
font-size: 1.2em;
color: #666;
}
#leaderboard-error {
color: #c00;
}
/* Leaderboard Table */
#leaderboard-table {
width: 100%;
}
.leaderboard-row {
display: flex;
padding: 8px 10px;
border-bottom: 1px solid #ddd;
align-items: center;
}
.leaderboard-header-row {
background: #ff9f18;
font-weight: bold;
color: #fff;
text-shadow: 1px 1px 0 #000;
border-radius: 5px;
margin-bottom: 5px;
}
.leaderboard-row:not(.leaderboard-header-row):hover {
background: #ffe8c8;
}
.leaderboard-rank {
width: 50px;
text-align: center;
font-weight: bold;
}
.leaderboard-name {
flex: 1;
padding: 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-score {
width: 100px;
text-align: right;
font-weight: bold;
font-family: monospace;
}
/* Top 3 ranks styling */
.leaderboard-rank-1 {
background: linear-gradient(90deg, #ffd700 0%, #fff8e8 30%);
}
.leaderboard-rank-1 .leaderboard-rank {
color: #d4a500;
font-size: 1.2em;
}
.leaderboard-rank-2 {
background: linear-gradient(90deg, #c0c0c0 0%, #fff8e8 30%);
}
.leaderboard-rank-2 .leaderboard-rank {
color: #888;
font-size: 1.1em;
}
.leaderboard-rank-3 {
background: linear-gradient(90deg, #cd7f32 0%, #fff8e8 30%);
}
.leaderboard-rank-3 .leaderboard-rank {
color: #8b4513;
font-size: 1.05em;
}
#leaderboard-footer {
padding: 15px;
text-align: center;
border-top: 2px solid #000;
}
#leaderboard-close-btn {
background: linear-gradient(180deg, #666 0%, #444 100%);
color: #fff;
border: 2px solid #000;
border-radius: 8px;
padding: 10px 30px;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s;
}
#leaderboard-close-btn:hover {
transform: scale(1.05);
background: linear-gradient(180deg, #888 0%, #555 100%);
}
/* Submit Modal */
#leaderboard-submit-modal {
background: linear-gradient(180deg, #4CAF50 0%, #388E3C 100%);
border: 4px solid #000;
border-radius: 15px;
padding: 25px;
text-align: center;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
}
#leaderboard-submit-title {
font-size: 1.5em;
font-weight: bold;
color: #fff;
text-shadow: 2px 2px 0 #000;
margin-bottom: 10px;
}
#leaderboard-submit-score {
font-size: 1.8em;
font-weight: bold;
color: #ffe100;
text-shadow: 2px 2px 0 #000;
margin-bottom: 20px;
}
#leaderboard-submit-form {
margin-bottom: 20px;
}
#leaderboard-submit-form label {
display: block;
color: #fff;
margin-bottom: 10px;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-name-input {
width: 100%;
padding: 12px;
font-size: 1.2em;
border: 3px solid #000;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
}
#leaderboard-name-input.error {
border-color: #f00;
background: #ffe8e8;
}
#leaderboard-submit-buttons {
display: flex;
gap: 10px;
justify-content: center;
}
#leaderboard-submit-btn,
#leaderboard-cancel-btn {
padding: 12px 25px;
font-size: 1.1em;
font-weight: bold;
border: 2px solid #000;
border-radius: 8px;
cursor: pointer;
transition: transform 0.1s;
}
#leaderboard-submit-btn {
background: linear-gradient(180deg, #ff9f18 0%, #ff6b00 100%);
color: #fff;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-submit-btn:hover:not(:disabled) {
transform: scale(1.05);
}
#leaderboard-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#leaderboard-cancel-btn {
background: linear-gradient(180deg, #666 0%, #444 100%);
color: #fff;
}
#leaderboard-cancel-btn:hover {
transform: scale(1.05);
background: linear-gradient(180deg, #888 0%, #555 100%);
}

View File

@@ -39,7 +39,8 @@ var assets = {
"abstractfile.js",
"idb.js",
"plugins.js",
"search.js"
"search.js",
"leaderboard.js"
],
"css": [
"main.css",
@@ -49,7 +50,8 @@ var assets = {
"debug.css",
"songbg.css",
"view.css",
"search.css"
"search.css",
"leaderboard.css"
],
"img": [
"notes.png",
@@ -98,7 +100,7 @@ var assets = {
"audioSfx": [
"se_pause.ogg",
"se_calibration.ogg",
"v_results.ogg",
"v_sanka.ogg",
"v_songsel.ogg",
@@ -112,14 +114,14 @@ var assets = {
"se_don.ogg",
"se_ka.ogg",
"se_jump.ogg",
"se_balloon.ogg",
"se_gameclear.ogg",
"se_gamefail.ogg",
"se_gamefullcombo.ogg",
"se_results_countup.ogg",
"se_results_crown.ogg",
"v_fullcombo.ogg",
"v_renda.ogg",
"v_results_fullcombo.ogg",
@@ -153,7 +155,7 @@ var assets = {
"customsongs.html",
"search.html"
],
"songs": [],
"sounds": {},
"image": {},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
class Leaderboard {
constructor(songId, difficulty, songTitle, onClose) {
this.songId = songId
this.difficulty = difficulty
this.songTitle = songTitle
this.onClose = onClose
this.entries = []
this.loading = true
this.error = null
this.font = strings.font
this.init()
}
init() {
this.createModal()
this.fetchLeaderboard()
this.addEventListeners()
}
createModal() {
// Create overlay
this.overlay = document.createElement("div")
this.overlay.id = "leaderboard-overlay"
this.overlay.innerHTML = `
<div id="leaderboard-modal">
<div id="leaderboard-header">
<div id="leaderboard-title">${strings.leaderboardTitle}</div>
<div id="leaderboard-song-title">${this.songTitle}</div>
<div id="leaderboard-difficulty">${this.getDifficultyName()}</div>
</div>
<div id="leaderboard-content">
<div id="leaderboard-loading">${strings.loading}</div>
</div>
<div id="leaderboard-footer">
<button id="leaderboard-close-btn">${strings.close}</button>
</div>
</div>
`
document.body.appendChild(this.overlay)
}
getDifficultyName() {
const diffNames = {
easy: strings.easy,
normal: strings.normal,
hard: strings.hard,
oni: strings.oni,
ura: strings.oni
}
return diffNames[this.difficulty] || this.difficulty
}
addEventListeners() {
this.overlay.querySelector("#leaderboard-close-btn").addEventListener("click", () => this.close())
this.overlay.addEventListener("click", (e) => {
if (e.target === this.overlay) {
this.close()
}
})
// Keyboard support
this.keyHandler = (e) => {
if (e.key === "Escape") {
this.close()
}
}
document.addEventListener("keydown", this.keyHandler)
}
fetchLeaderboard() {
const url = `api/leaderboard?song_id=${encodeURIComponent(this.songId)}&difficulty=${encodeURIComponent(this.difficulty)}`
loader.ajax(url).then(response => {
const data = JSON.parse(response)
if (data.status === "ok") {
this.entries = data.entries
this.monthKey = data.month_key
this.loading = false
this.render()
} else {
throw new Error("Failed to load leaderboard")
}
}).catch(error => {
console.error("Leaderboard fetch error:", error)
this.error = error
this.loading = false
this.render()
})
}
render() {
const content = this.overlay.querySelector("#leaderboard-content")
if (this.error) {
content.innerHTML = `<div id="leaderboard-error">${strings.errorOccured}</div>`
return
}
if (this.entries.length === 0) {
content.innerHTML = `<div id="leaderboard-empty">${strings.noEntries}</div>`
return
}
// Build entries table
let html = `
<div id="leaderboard-table">
<div class="leaderboard-row leaderboard-header-row">
<div class="leaderboard-rank">${strings.leaderboardRank}</div>
<div class="leaderboard-name">${strings.leaderboardPlayer}</div>
<div class="leaderboard-score">${strings.leaderboardScore}</div>
</div>
`
for (const entry of this.entries) {
const rankClass = entry.rank <= 3 ? `leaderboard-rank-${entry.rank}` : ""
html += `
<div class="leaderboard-row ${rankClass}">
<div class="leaderboard-rank">${entry.rank}</div>
<div class="leaderboard-name">${this.escapeHtml(entry.username)}</div>
<div class="leaderboard-score">${entry.score_value.toLocaleString()}</div>
</div>
`
}
html += `</div>`
content.innerHTML = html
}
escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
close() {
document.removeEventListener("keydown", this.keyHandler)
this.overlay.remove()
if (this.onClose) {
this.onClose()
}
}
}
// Score submission modal for when players qualify for leaderboard
class LeaderboardSubmit {
constructor(songId, difficulty, score, rank, onSubmit, onClose) {
this.songId = songId
this.difficulty = difficulty
this.score = score
this.rank = rank
this.onSubmit = onSubmit
this.onClose = onClose
this.submitting = false
this.init()
}
init() {
this.createModal()
this.addEventListeners()
}
createModal() {
this.overlay = document.createElement("div")
this.overlay.id = "leaderboard-submit-overlay"
const rankText = strings.newRecord.replace("%s", this.rank)
this.overlay.innerHTML = `
<div id="leaderboard-submit-modal">
<div id="leaderboard-submit-title">${rankText}</div>
<div id="leaderboard-submit-score">${this.score.toLocaleString()} ${strings.points}</div>
<div id="leaderboard-submit-form">
<label for="leaderboard-name-input">${strings.enterName}</label>
<input type="text" id="leaderboard-name-input" maxlength="10" autocomplete="off">
</div>
<div id="leaderboard-submit-buttons">
<button id="leaderboard-submit-btn">${strings.submit}</button>
<button id="leaderboard-cancel-btn">${strings.cancel}</button>
</div>
</div>
`
document.body.appendChild(this.overlay)
// Focus input
setTimeout(() => {
this.overlay.querySelector("#leaderboard-name-input").focus()
}, 100)
}
addEventListeners() {
const submitBtn = this.overlay.querySelector("#leaderboard-submit-btn")
const cancelBtn = this.overlay.querySelector("#leaderboard-cancel-btn")
const input = this.overlay.querySelector("#leaderboard-name-input")
submitBtn.addEventListener("click", () => this.submit())
cancelBtn.addEventListener("click", () => this.close())
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
this.submit()
} else if (e.key === "Escape") {
this.close()
}
})
}
submit() {
if (this.submitting) return
const input = this.overlay.querySelector("#leaderboard-name-input")
const username = input.value.trim()
if (username.length < 1 || username.length > 10) {
input.classList.add("error")
return
}
this.submitting = true
const submitBtn = this.overlay.querySelector("#leaderboard-submit-btn")
submitBtn.disabled = true
submitBtn.textContent = strings.loading
const data = {
song_id: String(this.songId),
difficulty: this.difficulty,
username: username,
score: this.score
}
const xhr = new XMLHttpRequest()
xhr.open("POST", "api/score/leaderboard/save")
xhr.setRequestHeader("Content-Type", "application/json")
xhr.onload = () => {
if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText)
if (result.status === "ok") {
this.close()
if (this.onSubmit) {
this.onSubmit(username)
}
} else {
handleError(result.message || "Submit failed")
}
} catch (e) {
handleError("Invalid response")
}
} else {
handleError("Server error " + xhr.status)
}
}
xhr.onerror = () => {
handleError("Network error")
}
const handleError = (msg) => {
console.error("Leaderboard submit error:", msg)
this.submitting = false
submitBtn.disabled = false
submitBtn.textContent = strings.submit
input.classList.add("error")
}
xhr.send(JSON.stringify(data))
}
close() {
this.overlay.remove()
if (this.onClose) {
this.onClose()
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ var translations = {
tw: "zh-Hant",
ko: "ko"
},
taikoWeb: {
ja: "たいこウェブ",
en: "Taiko Web",
@@ -438,7 +438,79 @@ var translations = {
tw: "連打數",
ko: "연타 횟수"
},
// Leaderboard strings
leaderboard: {
ja: "ランキング",
en: "Leaderboard",
cn: "排行榜",
tw: "排行榜",
ko: "순위"
},
leaderboardTitle: {
ja: "月間ランキング",
en: "Monthly Leaderboard",
cn: "月度排行榜",
tw: "月度排行榜",
ko: "월간 순위"
},
leaderboardRank: {
ja: "順位",
en: "Rank",
cn: "排名",
tw: "排名",
ko: "순위"
},
leaderboardScore: {
ja: "スコア",
en: "Score",
cn: "分数",
tw: "分數",
ko: "점수"
},
leaderboardPlayer: {
ja: "プレイヤー",
en: "Player",
cn: "玩家",
tw: "玩家",
ko: "플레이어"
},
newRecord: {
ja: "新記録!ランキング %s 位!",
en: "New Record! Rank %s!",
cn: "新纪录!排名第 %s 名!",
tw: "新紀錄!排名第 %s 名!",
ko: "신기록! %s 위!"
},
enterName: {
ja: "名前を入力してください1〜10文字",
en: "Enter your name (1-10 characters)",
cn: "请输入你的名字1-10个字符",
tw: "請輸入你的名字1-10個字符",
ko: "이름을 입력하세요 (1-10자)"
},
submit: {
ja: "登録",
en: "Submit",
cn: "提交",
tw: "提交",
ko: "제출"
},
close: {
ja: "閉じる",
en: "Close",
cn: "关闭",
tw: "關閉",
ko: "닫기"
},
noEntries: {
ja: "まだ記録がありません",
en: "No entries yet",
cn: "暂无记录",
tw: "暫無記錄",
ko: "아직 기록이 없습니다"
},
errorOccured: {
ja: "エラーが発生しました。再読み込みしてください。",
en: "An error occurred, please refresh",
@@ -879,7 +951,7 @@ var translations = {
en: "Audio Latency Calibration",
tw: "聲音延遲校正",
ko: "오디오 레이턴시 조절"
},
content: {
ja: "背景で鳴っている音を聴いてみましょう。\n\n音が聞こえたら、太鼓の面%sまたは%sをたたこう",
@@ -1472,26 +1544,26 @@ var translations = {
}
}
var allStrings = {}
function separateStrings(){
for(var j in languageList){
function separateStrings() {
for (var j in languageList) {
var lang = languageList[j]
allStrings[lang] = {
id: lang
}
var str = allStrings[lang]
var translateObj = function(obj, name, str){
if("en" in obj){
for(var i in obj){
var translateObj = function (obj, name, str) {
if ("en" in obj) {
for (var i in obj) {
str[name] = obj[lang] || obj.en
}
}else if(obj){
} else if (obj) {
str[name] = {}
for(var i in obj){
for (var i in obj) {
translateObj(obj[i], i, str[name])
}
}
}
for(var i in translations){
for (var i in translations) {
translateObj(translations[i], i, str)
}
}