class Leaderboard { constructor() { this.canvas = null this.ctx = null this.songId = null this.difficulty = null this.leaderboardData = [] this.currentMonth = "" this.visible = false this.draw = null this.keyboard = null } init() { this.canvas = document.getElementById("leaderboard-canvas") if (!this.canvas) { console.error("Leaderboard canvas not found!") return false } this.ctx = this.canvas.getContext("2d") var resolution = settings.getItem("resolution") var noSmoothing = resolution === "low" || resolution === "lowest" if (noSmoothing) { this.ctx.imageSmoothingEnabled = false } this.draw = new CanvasDraw(noSmoothing) // Keyboard controls this.keyboard = new Keyboard({ confirm: ["enter", "escape", "don_l", "don_r"], back: ["escape"], left: ["left", "ka_l"], right: ["right", "ka_r"] }, this.keyPress.bind(this)) pageEvents.add(this.canvas, ["mousedown", "touchstart"], this.onClose.bind(this)) return true } keyPress(pressed, name) { if (!pressed || !this.visible) { return } if (name === "confirm" || name === "back") { this.close() } else if (name === "left") { this.changeDifficulty(-1) } else if (name === "right") { this.changeDifficulty(1) } } async display(songId, difficulty) { this.songId = songId this.difficulty = difficulty this.visible = true loader.changePage("leaderboard", false) // Initialize after page is loaded if (!this.init()) { console.error("Failed to initialize leaderboard") return } // Fetch leaderboard data await this.fetchLeaderboard() // Start rendering this.redrawRunning = true this.redrawBind = this.redraw.bind(this) this.redraw() assets.sounds["se_don"].play() } async fetchLeaderboard() { // Validate songId exists if (!this.songId) { console.error("Missing song ID for leaderboard") this.leaderboardData = [] return } try { var response = await loader.ajax( `${gameConfig.basedir || "/"}api/leaderboard/get?song_id=${encodeURIComponent(this.songId)}&difficulty=${this.difficulty}` ) var data = JSON.parse(response) if (data.status === "ok") { this.leaderboardData = data.leaderboard || [] this.currentMonth = data.month || "" } else { this.leaderboardData = [] } } catch (e) { console.error("Failed to fetch leaderboard:", e) this.leaderboardData = [] } } changeDifficulty(direction) { var difficulties = ["easy", "normal", "hard", "oni", "ura"] var currentIndex = difficulties.indexOf(this.difficulty) if (currentIndex === -1) { return } var newIndex = (currentIndex + direction + difficulties.length) % difficulties.length this.difficulty = difficulties[newIndex] this.fetchLeaderboard().then(() => { assets.sounds["se_ka"].play() }) } onClose(event) { if (!this.visible) { return } var rect = this.canvas.getBoundingClientRect() var x = (event.offsetX || event.touches[0].pageX - rect.left) var y = (event.offsetY || event.touches[0].pageY - rect.top) // Check if clicked outside modal - updated dimensions var centerX = this.canvas.width / 2 var centerY = this.canvas.height / 2 var modalWidth = 880 // Updated from 800 var modalHeight = 640 // Updated from 600 if (x < centerX - modalWidth / 2 || x > centerX + modalWidth / 2 || y < centerY - modalHeight / 2 || y > centerY + modalHeight / 2) { this.close() } } close() { this.visible = false this.redrawRunning = false if (this.keyboard) { this.keyboard.clean() } assets.sounds["se_cancel"].play() // Return to song select - get touchEnabled from global or default to false setTimeout(() => { var touchEnabled = typeof window.touchEnabled !== 'undefined' ? window.touchEnabled : false new SongSelect(false, false, touchEnabled) }, 100) } redraw() { if (!this.redrawRunning) { return } requestAnimationFrame(this.redrawBind) var winW = innerWidth var winH = innerHeight var ratio = winH / 720 this.canvas.width = winW this.canvas.height = winH this.ctx.save() // Draw semi-transparent background this.ctx.fillStyle = "rgba(0, 0, 0, 0.8)" this.ctx.fillRect(0, 0, winW, winH) this.ctx.scale(ratio, ratio) // Draw modal background with gradient var modalX = 200 var modalY = 40 var modalW = 880 var modalH = 640 // Gradient background var bgGradient = this.ctx.createLinearGradient(modalX, modalY, modalX, modalY + modalH) bgGradient.addColorStop(0, "#ffffff") bgGradient.addColorStop(1, "#f0f4f8") this.ctx.fillStyle = bgGradient this.ctx.shadowColor = "rgba(0, 0, 0, 0.3)" this.ctx.shadowBlur = 30 this.ctx.shadowOffsetX = 0 this.ctx.shadowOffsetY = 10 this.ctx.fillRect(modalX, modalY, modalW, modalH) this.ctx.shadowBlur = 0 // Modal border with gradient var borderGradient = this.ctx.createLinearGradient(modalX, modalY, modalX + modalW, modalY + modalH) borderGradient.addColorStop(0, "#4a90e2") borderGradient.addColorStop(0.5, "#7b68ee") borderGradient.addColorStop(1, "#ff6b9d") this.ctx.strokeStyle = borderGradient this.ctx.lineWidth = 6 this.ctx.strokeRect(modalX, modalY, modalW, modalH) // Draw title with gradient var titleGradient = this.ctx.createLinearGradient(0, 90, 0, 130) titleGradient.addColorStop(0, "#4a90e2") titleGradient.addColorStop(1, "#7b68ee") this.ctx.fillStyle = titleGradient this.ctx.font = "bold 48px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.shadowColor = "rgba(0, 0, 0, 0.2)" this.ctx.shadowBlur = 5 this.ctx.shadowOffsetX = 2 this.ctx.shadowOffsetY = 2 this.ctx.fillText("🏆 排行榜 Leaderboard", 640, 100) this.ctx.shadowBlur = 0 // Draw difficulty selector with improved styling var diffX = 640 var diffY = 155 var difficulties = [ { id: "easy", name: "簡単", color: "#00a0e9", glow: "#00d4ff" }, { id: "normal", name: "普通", color: "#00a040", glow: "#00ff66" }, { id: "hard", name: "難しい", color: "#ff8c00", glow: "#ffb347" }, { id: "oni", name: "鬼", color: "#dc143c", glow: "#ff6b9d" }, { id: "ura", name: "裏", color: "#9400d3", glow: "#da70d6" } ] this.ctx.font = "bold 22px " + (strings.font || "sans-serif") for (var i = 0; i < difficulties.length; i++) { var diff = difficulties[i] var x = diffX - 220 + i * 110 if (diff.id === this.difficulty) { // Selected difficulty with glow effect this.ctx.shadowColor = diff.glow this.ctx.shadowBlur = 15 var gradient = this.ctx.createLinearGradient(x - 50, diffY - 30, x + 50, diffY + 20) gradient.addColorStop(0, diff.color) gradient.addColorStop(1, diff.glow) this.ctx.fillStyle = gradient this.ctx.fillRect(x - 50, diffY - 30, 100, 50) this.ctx.shadowBlur = 0 this.ctx.fillStyle = "#ffffff" this.ctx.shadowColor = "rgba(0, 0, 0, 0.5)" this.ctx.shadowBlur = 3 } else { // Unselected difficulty this.ctx.strokeStyle = diff.color this.ctx.lineWidth = 3 this.ctx.strokeRect(x - 50, diffY - 30, 100, 50) this.ctx.fillStyle = diff.color } this.ctx.textAlign = "center" this.ctx.fillText(diff.name, x, diffY) this.ctx.shadowBlur = 0 } // Draw month info with style this.ctx.fillStyle = "#666666" this.ctx.font = "20px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("📅 当月排行 " + this.currentMonth, 640, 215) // Header bar var headerY = 250 var headerGradient = this.ctx.createLinearGradient(modalX, headerY, modalX, headerY + 35) headerGradient.addColorStop(0, "#e8eef5") headerGradient.addColorStop(1, "#d0dae8") this.ctx.fillStyle = headerGradient this.ctx.fillRect(modalX + 20, headerY, modalW - 40, 35) this.ctx.fillStyle = "#333333" this.ctx.font = "bold 18px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("排名", modalX + 80, headerY + 23) this.ctx.textAlign = "left" this.ctx.fillText("玩家", modalX + 140, headerY + 23) this.ctx.textAlign = "right" this.ctx.fillText("分数", modalX + modalW - 60, headerY + 23) // Draw leaderboard entries var startY = 295 var rowHeight = 38 this.ctx.font = "22px " + (strings.font || "sans-serif") this.ctx.textAlign = "left" if (this.leaderboardData.length === 0) { this.ctx.fillStyle = "#999999" this.ctx.font = "24px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("暂无排行数据", 640, startY + 120) } else { for (var i = 0; i < Math.min(this.leaderboardData.length, 15); i++) { var entry = this.leaderboardData[i] var y = startY + i * rowHeight var rank = entry.rank // Rank background color with gradient var gradient if (rank === 1) { // Gold gradient for 1st place gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10) gradient.addColorStop(0, "#ffd700") gradient.addColorStop(1, "#ffed4e") this.ctx.fillStyle = gradient this.ctx.shadowColor = "#ffd700" this.ctx.shadowBlur = 20 } else if (rank === 2) { // Silver gradient for 2nd place gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10) gradient.addColorStop(0, "#c0c0c0") gradient.addColorStop(1, "#e8e8e8") this.ctx.fillStyle = gradient this.ctx.shadowColor = "#c0c0c0" this.ctx.shadowBlur = 15 } else if (rank === 3) { // Bronze gradient for 3rd place gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10) gradient.addColorStop(0, "#cd7f32") gradient.addColorStop(1, "#e9a86a") this.ctx.fillStyle = gradient this.ctx.shadowColor = "#cd7f32" this.ctx.shadowBlur = 12 } else { // Regular entries with subtle gradient gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10) gradient.addColorStop(0, "#ffffff") gradient.addColorStop(1, "#f8f9fa") this.ctx.fillStyle = gradient } // Rounded rectangle effect this.ctx.fillRect(modalX + 20, y - 25, modalW - 40, 35) this.ctx.shadowBlur = 0 // Border for entries if (rank <= 3) { this.ctx.strokeStyle = rank === 1 ? "#ffd700" : rank === 2 ? "#c0c0c0" : "#cd7f32" this.ctx.lineWidth = 2 this.ctx.strokeRect(modalX + 20, y - 25, modalW - 40, 35) } // Rank with medal emoji for top 3 if (rank === 1) { this.ctx.fillStyle = "#8b6914" this.ctx.font = "bold 24px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("🥇", modalX + 80, y + 2) } else if (rank === 2) { this.ctx.fillStyle = "#5c5c5c" this.ctx.font = "bold 24px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("🥈", modalX + 80, y + 2) } else if (rank === 3) { this.ctx.fillStyle = "#6d4423" this.ctx.font = "bold 24px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("🥉", modalX + 80, y + 2) } else { this.ctx.fillStyle = rank <= 3 ? "#ffffff" : "#555555" this.ctx.font = "bold 22px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("#" + rank, modalX + 80, y + 2) } // Display name this.ctx.fillStyle = rank <= 3 ? "#1a1a1a" : "#333333" this.ctx.font = (rank <= 3 ? "bold " : "") + "20px " + (strings.font || "sans-serif") this.ctx.textAlign = "left" var displayName = entry.display_name || entry.username if (displayName.length > 18) { displayName = displayName.substring(0, 18) + "..." } this.ctx.fillText(displayName, modalX + 140, y + 2) // Score with formatting // Score with formatting var score = entry.score_value !== undefined ? entry.score_value : (entry.score && entry.score.score ? entry.score.score : 0) this.ctx.fillStyle = rank <= 3 ? "#1a1a1a" : "#555555" this.ctx.font = (rank <= 3 ? "bold " : "") + "20px " + (strings.font || "sans-serif") this.ctx.textAlign = "right" this.ctx.fillText(score.toLocaleString(), modalX + modalW - 60, y + 2) } } // Draw close hint with icon this.ctx.fillStyle = "#888888" this.ctx.font = "18px " + (strings.font || "sans-serif") this.ctx.textAlign = "center" this.ctx.fillText("⌨️ 按ESC或点击外部关闭 Press ESC or click outside to close", 640, 665) this.ctx.restore() } clean() { if (this.keyboard) { this.keyboard.clean() } if (this.redrawRunning) { this.redrawRunning = false } pageEvents.remove(this.canvas, ["mousedown", "touchstart"]) } }