Add leaderboard feature with monthly reset and top 50 limit, update version to 1.1.0
This commit is contained in:
13
public/src/css/leaderboard.css
Normal file
13
public/src/css/leaderboard.css
Normal file
@@ -0,0 +1,13 @@
|
||||
#leaderboard {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#leaderboard-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
286
public/src/js/leaderboard.js
Normal file
286
public/src/js/leaderboard.js
Normal 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
3
public/src/views/leaderboard.html
Normal file
3
public/src/views/leaderboard.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id="leaderboard">
|
||||
<canvas id="leaderboard-canvas"></canvas>
|
||||
</div>
|
||||
Reference in New Issue
Block a user