Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 271fc52e82 | |||
| 1038fc85b9 | |||
| 76a3d52098 | |||
| d6a1b6bd41 |
155
app.py
155
app.py
@@ -105,6 +105,9 @@ db.users.create_index('username', unique=True)
|
||||
db.songs.create_index('id', unique=True)
|
||||
db.songs.create_index('song_type')
|
||||
db.scores.create_index('username')
|
||||
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('month', 1), ('score_value', -1)])
|
||||
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('username', 1), ('month', 1)], unique=True)
|
||||
db.leaderboards.create_index('month')
|
||||
|
||||
|
||||
class HashException(Exception):
|
||||
@@ -746,6 +749,158 @@ def route_api_scores_get():
|
||||
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
|
||||
|
||||
|
||||
@app.route(basedir + 'api/leaderboard/submit', methods=['POST'])
|
||||
@login_required
|
||||
def route_api_leaderboard_submit():
|
||||
data = request.get_json()
|
||||
if not schema.validate(data, schema.leaderboard_submit):
|
||||
return abort(400)
|
||||
|
||||
username = session.get('username')
|
||||
user = db.users.find_one({'username': username})
|
||||
if not user:
|
||||
return api_error('user_not_found')
|
||||
|
||||
song_id = data.get('song_id')
|
||||
difficulty = data.get('difficulty')
|
||||
score_data = data.get('score')
|
||||
|
||||
# Validate difficulty
|
||||
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
|
||||
if difficulty not in valid_difficulties:
|
||||
return api_error('invalid_difficulty')
|
||||
|
||||
# Get current month (YYYY-MM format)
|
||||
current_month = time.strftime('%Y-%m', time.gmtime())
|
||||
|
||||
# Check if user already has a record for this song/difficulty/month
|
||||
existing = db.leaderboards.find_one({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'username': username,
|
||||
'month': current_month
|
||||
})
|
||||
|
||||
# Parse score (assuming it's in the same format as the scores collection)
|
||||
try:
|
||||
if isinstance(score_data, str):
|
||||
import json as json_module
|
||||
score_obj = json_module.loads(score_data)
|
||||
else:
|
||||
score_obj = score_data
|
||||
|
||||
score_value = int(score_obj.get('score', 0))
|
||||
except:
|
||||
return api_error('invalid_score_format')
|
||||
|
||||
if existing:
|
||||
# Update only if new score is higher
|
||||
existing_score = existing.get('score_value', 0)
|
||||
if score_value > existing_score:
|
||||
db.leaderboards.update_one(
|
||||
{'_id': existing['_id']},
|
||||
{'$set': {
|
||||
'score': score_data,
|
||||
'score_value': score_value,
|
||||
'display_name': user['display_name'],
|
||||
'submitted_at': time.time()
|
||||
}}
|
||||
)
|
||||
return jsonify({'status': 'ok', 'message': 'score_updated'})
|
||||
else:
|
||||
return jsonify({'status': 'ok', 'message': 'score_not_higher'})
|
||||
else:
|
||||
# Check if this score would be in top 50
|
||||
count = db.leaderboards.count_documents({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
})
|
||||
|
||||
if count >= 50:
|
||||
# Find the 50th score
|
||||
leaderboard = list(db.leaderboards.find({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
}).sort('score_value', -1).limit(50))
|
||||
|
||||
if len(leaderboard) >= 50:
|
||||
last_score = leaderboard[49].get('score_value', 0)
|
||||
if score_value <= last_score:
|
||||
return jsonify({'status': 'ok', 'message': 'score_too_low'})
|
||||
|
||||
# Insert new record
|
||||
db.leaderboards.insert_one({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'username': username,
|
||||
'display_name': user['display_name'],
|
||||
'score': score_data,
|
||||
'score_value': score_value,
|
||||
'submitted_at': time.time(),
|
||||
'month': current_month
|
||||
})
|
||||
|
||||
# Remove entries beyond 50th place
|
||||
if count >= 50:
|
||||
# Get all entries sorted by score
|
||||
all_entries = list(db.leaderboards.find({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
}).sort('score_value', -1))
|
||||
|
||||
# Delete entries beyond 50
|
||||
if len(all_entries) > 50:
|
||||
for entry in all_entries[50:]:
|
||||
db.leaderboards.delete_one({'_id': entry['_id']})
|
||||
|
||||
return jsonify({'status': 'ok', 'message': 'score_submitted'})
|
||||
|
||||
|
||||
@app.route(basedir + 'api/leaderboard/get')
|
||||
def route_api_leaderboard_get():
|
||||
song_id = request.args.get('song_id', None)
|
||||
difficulty = request.args.get('difficulty', None)
|
||||
|
||||
if not song_id or not difficulty:
|
||||
return abort(400)
|
||||
|
||||
try:
|
||||
song_id = int(song_id)
|
||||
except:
|
||||
return abort(400)
|
||||
|
||||
# Validate difficulty
|
||||
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
|
||||
if difficulty not in valid_difficulties:
|
||||
return abort(400)
|
||||
|
||||
# Get current month
|
||||
current_month = time.strftime('%Y-%m', time.gmtime())
|
||||
|
||||
# Get top 50 scores
|
||||
leaderboard = list(db.leaderboards.find({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
}, {
|
||||
'_id': False,
|
||||
'username': True,
|
||||
'display_name': True,
|
||||
'score': True,
|
||||
'score_value': True,
|
||||
'submitted_at': True
|
||||
}).sort('score_value', -1).limit(50))
|
||||
|
||||
# Add rank to each entry
|
||||
for i, entry in enumerate(leaderboard):
|
||||
entry['rank'] = i + 1
|
||||
|
||||
return jsonify({'status': 'ok', 'leaderboard': leaderboard, 'month': current_month})
|
||||
|
||||
|
||||
@app.route(basedir + 'privacy')
|
||||
def route_api_privacy():
|
||||
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
|
||||
|
||||
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%;
|
||||
}
|
||||
390
public/src/js/leaderboard.js
Normal file
390
public/src/js/leaderboard.js
Normal file
@@ -0,0 +1,390 @@
|
||||
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 - 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
|
||||
var score = 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"])
|
||||
}
|
||||
}
|
||||
@@ -926,6 +926,7 @@ class Scoresheet{
|
||||
var title = this.controller.selectedSong.originalTitle
|
||||
var hash = this.controller.selectedSong.hash
|
||||
var difficulty = this.resultsObj.difficulty
|
||||
var songId = this.controller.selectedSong.id
|
||||
var oldScore = scoreStorage.get(hash, difficulty, true)
|
||||
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
|
||||
var crown = ""
|
||||
@@ -940,7 +941,10 @@ class Scoresheet{
|
||||
delete this.resultsObj.title
|
||||
delete this.resultsObj.difficulty
|
||||
delete this.resultsObj.gauge
|
||||
scoreStorage.add(hash, difficulty, this.resultsObj, true, title).catch(() => {
|
||||
scoreStorage.add(hash, difficulty, this.resultsObj, true, title).then(() => {
|
||||
// Auto-submit to leaderboard if logged in and has song ID
|
||||
this.submitToLeaderboard(songId, difficulty, this.resultsObj)
|
||||
}).catch(() => {
|
||||
this.showWarning = { name: "scoreSaveFailed" }
|
||||
})
|
||||
} else if (oldScore && (crown === "gold" && oldScore.crown !== "gold" || crown && !oldScore.crown)) {
|
||||
@@ -953,6 +957,28 @@ class Scoresheet{
|
||||
this.scoreSaved = true
|
||||
}
|
||||
|
||||
submitToLeaderboard(songId, difficulty, scoreObj) {
|
||||
// Only submit if user is logged in and song has valid ID
|
||||
if (!account.loggedIn || !songId) {
|
||||
return
|
||||
}
|
||||
|
||||
loader.getCsrfToken().then(token => {
|
||||
var request = new XMLHttpRequest()
|
||||
request.open("POST", "api/leaderboard/submit")
|
||||
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8")
|
||||
request.setRequestHeader("X-CSRFToken", token)
|
||||
request.send(JSON.stringify({
|
||||
song_id: songId,
|
||||
difficulty: difficulty,
|
||||
score: scoreObj
|
||||
}))
|
||||
}).catch(() => {
|
||||
// Silently fail - leaderboard submission is optional
|
||||
console.log("Leaderboard submission failed")
|
||||
})
|
||||
}
|
||||
|
||||
clean() {
|
||||
this.keyboard.clean()
|
||||
this.gamepad.clean()
|
||||
|
||||
@@ -310,6 +310,12 @@ class SongSelect{
|
||||
iconName: "download",
|
||||
iconFill: "#e7cbe1",
|
||||
letterSpacing: 4
|
||||
}, {
|
||||
text: "排行榜",
|
||||
fill: "#ffd700",
|
||||
iconName: "ranking",
|
||||
iconFill: "#fff4b5",
|
||||
letterSpacing: 4
|
||||
}]
|
||||
this.optionsList = [strings.none, strings.auto, strings.netplay]
|
||||
|
||||
@@ -583,12 +589,16 @@ class SongSelect{
|
||||
} else if (name === "confirm") {
|
||||
if (this.selectedDiff === 0) {
|
||||
this.toSongSelect()
|
||||
} else if (this.selectedDiff === 1) {
|
||||
this.toOptions(1)
|
||||
} else if (this.selectedDiff === 2) {
|
||||
this.toDownload()
|
||||
} else if (this.selectedDiff === 3) {
|
||||
this.toLeaderboard()
|
||||
} else if (this.selectedDiff === 4) {
|
||||
this.toDelete()
|
||||
}else if(this.selectedDiff === 1){
|
||||
this.toOptions(1)
|
||||
} else if (false) {
|
||||
// Moved above
|
||||
} else {
|
||||
this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl)
|
||||
}
|
||||
@@ -688,12 +698,14 @@ class SongSelect{
|
||||
} else if (moveBy === 0) {
|
||||
this.selectedDiff = 0
|
||||
this.toSongSelect()
|
||||
} else if (moveBy === 1) {
|
||||
this.toOptions(1)
|
||||
} else if (moveBy === 2) {
|
||||
this.toDownload()
|
||||
} else if (moveBy === 3) {
|
||||
this.toLeaderboard()
|
||||
} else if (moveBy === 4) {
|
||||
this.toDelete()
|
||||
}else if(moveBy === 1){
|
||||
this.toOptions(1)
|
||||
} else if (moveBy === "maker") {
|
||||
window.open(this.songs[this.selectedSong].maker.url)
|
||||
} else if (moveBy === this.diffOptions.length + 4) {
|
||||
@@ -3322,4 +3334,19 @@ class SongSelect{
|
||||
alert(text);
|
||||
});
|
||||
}
|
||||
toLeaderboard() {
|
||||
if (!this.songs[this.selectedSong].id) {
|
||||
return
|
||||
}
|
||||
// Default to first available difficulty if not in a valid difficulty selection
|
||||
var selectedDiff = this.selectedDiff - this.diffOptions.length
|
||||
if (selectedDiff < 0) {
|
||||
selectedDiff = 3 // Default to oni
|
||||
}
|
||||
var diffId = this.difficultyId[selectedDiff]
|
||||
|
||||
this.clean()
|
||||
this.playSound("se_don")
|
||||
new Leaderboard().display(this.songs[this.selectedSong].id, diffId)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
11
schema.py
11
schema.py
@@ -80,3 +80,14 @@ scores_save = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leaderboard_submit = {
|
||||
'$schema': 'http://json-schema.org/schema#',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'song_id': {'type': 'number'},
|
||||
'difficulty': {'type': 'string'},
|
||||
'score': {'type': 'object'}
|
||||
},
|
||||
'required': ['song_id', 'difficulty', 'score']
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
|
||||
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<meta name="description" content="2025年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
|
||||
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
|
||||
<meta name="description"
|
||||
content="2026年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
|
||||
<meta name="keywords"
|
||||
content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
|
||||
<meta name="robots" content="notranslate">
|
||||
<meta name="robots" content="noimageindex">
|
||||
<meta name="color-scheme" content="only light">
|
||||
@@ -24,14 +27,17 @@
|
||||
<script src="src/js/lib/jszip.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="assets"></div>
|
||||
<div id="screen" class="pattern-bg"></div>
|
||||
<div data-nosnippet id="version">
|
||||
{% if version.version and version.commit_short and version.commit %}
|
||||
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
|
||||
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link"
|
||||
class="stroke-sub" alt="vLightNova 1.1.0">vLightNova 1.1.0</a>
|
||||
{% else %}
|
||||
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
|
||||
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.1.0">vLightNova
|
||||
1.1.0</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
|
||||
@@ -43,4 +49,5 @@
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user