Add leaderboard feature with monthly reset and top 50 limit, update version to 1.1.0

This commit is contained in:
2026-01-15 23:32:43 +08:00
parent 6d7be5c45c
commit d6a1b6bd41
7 changed files with 1343 additions and 844 deletions

View File

@@ -0,0 +1,286 @@
class Leaderboard {
constructor() {
this.init()
}
init() {
this.canvas = document.getElementById("leaderboard-canvas")
if (!this.canvas) {
return
}
this.ctx = this.canvas.getContext("2d")
var resolution = settings.getItem("resolution")
var noSmoothing = resolution === "low" || resolution === "lowest"
if (noSmoothing) {
this.ctx.imageSmoothingEnabled = false
}
this.songId = null
this.difficulty = null
this.leaderboardData = []
this.currentMonth = ""
this.visible = 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))
}
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)
// 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() {
try {
var response = await loader.ajax(
`${gameConfig.basedir || "/"}api/leaderboard/get?song_id=${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 (approximately)
var centerX = this.canvas.width / 2
var centerY = this.canvas.height / 2
var modalWidth = 800
var modalHeight = 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
setTimeout(() => {
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
var modalX = 240
var modalY = 60
var modalW = 800
var modalH = 600
this.ctx.fillStyle = "#ffffff"
this.ctx.fillRect(modalX, modalY, modalW, modalH)
this.ctx.strokeStyle = "#333333"
this.ctx.lineWidth = 4
this.ctx.strokeRect(modalX, modalY, modalW, modalH)
// Draw title
this.ctx.fillStyle = "#000000"
this.ctx.font = "bold 40px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("🏆 排行榜 Leaderboard", 640, 110)
// Draw difficulty selector
var diffX = 640
var diffY = 160
var difficulties = [
{ id: "easy", name: "簡単", color: "#00a0e9" },
{ id: "normal", name: "普通", color: "#00a040" },
{ id: "hard", name: "難しい", color: "#ff8c00" },
{ id: "oni", name: "鬼", color: "#dc143c" },
{ id: "ura", name: "裏", color: "#9400d3" }
]
this.ctx.font = "24px " + (strings.font || "sans-serif")
for (var i = 0; i < difficulties.length; i++) {
var diff = difficulties[i]
var x = diffX - 200 + i * 100
if (diff.id === this.difficulty) {
this.ctx.fillStyle = diff.color
this.ctx.fillRect(x - 45, diffY - 25, 90, 40)
this.ctx.fillStyle = "#ffffff"
} else {
this.ctx.strokeStyle = diff.color
this.ctx.lineWidth = 2
this.ctx.strokeRect(x - 45, diffY - 25, 90, 40)
this.ctx.fillStyle = diff.color
}
this.ctx.textAlign = "center"
this.ctx.fillText(diff.name, x, diffY + 5)
}
// Draw month info
this.ctx.fillStyle = "#666666"
this.ctx.font = "18px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("当月排行 " + this.currentMonth, 640, 210)
// Draw leaderboard entries
var startY = 240
var rowHeight = 35
this.ctx.font = "20px " + (strings.font || "sans-serif")
this.ctx.textAlign = "left"
if (this.leaderboardData.length === 0) {
this.ctx.fillStyle = "#999999"
this.ctx.textAlign = "center"
this.ctx.fillText("暂无排行数据", 640, startY + 100)
} 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
if (rank === 1) {
this.ctx.fillStyle = "#ffd700" // Gold
} else if (rank === 2) {
this.ctx.fillStyle = "#c0c0c0" // Silver
} else if (rank === 3) {
this.ctx.fillStyle = "#cd7f32" // Bronze
} else {
this.ctx.fillStyle = "#f5f5f5"
}
this.ctx.fillRect(modalX + 20, y - 20, modalW - 40, 30)
// Rank
this.ctx.fillStyle = rank <= 3 ? "#ffffff" : "#333333"
this.ctx.font = "bold 20px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText(rank, modalX + 60, y)
// Display name
this.ctx.fillStyle = "#000000"
this.ctx.font = "20px " + (strings.font || "sans-serif")
this.ctx.textAlign = "left"
var displayName = entry.display_name || entry.username
if (displayName.length > 15) {
displayName = displayName.substring(0, 15) + "..."
}
this.ctx.fillText(displayName, modalX + 100, y)
// Score
var score = entry.score && entry.score.score ? entry.score.score : 0
this.ctx.textAlign = "right"
this.ctx.fillText(score.toLocaleString(), modalX + modalW - 40, y)
}
}
// Draw close hint
this.ctx.fillStyle = "#666666"
this.ctx.font = "18px " + (strings.font || "sans-serif")
this.ctx.textAlign = "center"
this.ctx.fillText("按ESC或点击外部关闭 Press ESC or click outside to close", 640, 680)
this.ctx.restore()
}
clean() {
if (this.keyboard) {
this.keyboard.clean()
}
if (this.redrawRunning) {
this.redrawRunning = false
}
pageEvents.remove(this.canvas, ["mousedown", "touchstart"])
}
}

File diff suppressed because it is too large Load Diff