3 Commits
Bang3 ... F-B

20 changed files with 1049 additions and 1402 deletions

View File

@@ -0,0 +1,20 @@
I will optimize the loading process by implementing a **Multi-Threaded Worker Loader**. This involves creating a pool of Web Workers to fetch assets (JavaScript, Audio, Images, Views) in parallel, offloading the network initiation and handling from the main thread.
### Plan:
1. **Create `public/src/js/loader-worker.js`**:
* This worker will handle `fetch` requests for different resource types (`text`, `blob`, `arraybuffer`).
* It will transfer the data back to the main thread (using zero-copy transfer for `ArrayBuffer`).
2. **Modify `public/src/js/loader.js`**:
* **Initialize Worker Pool**: Create a pool of workers (defaulting to 4) in the `Loader` class.
* **Implement `workerFetch(url, type)`**: A method to distribute fetch tasks to the worker pool.
* **Override `ajax(url, ...)`**: Intercept requests for static assets (`src/`, `assets/`, etc.) and route them through `workerFetch`. Keep API calls (`api/`) on the main thread to ensure session stability.
* **Update `loadScript(url)`**: Change it to fetch the script content via `workerFetch` and inject it using a `<script>` tag with inline content. This ensures JS files are also loaded via the "multi-process" mechanism.
* **Update `loadSound` and `RemoteFile` logic**: Since `RemoteFile` uses `loader.ajax`, routing `ajax` to workers will automatically parallelize audio loading.
### Technical Details:
* **Concurrency**: 4 Workers will be used to maximize throughput without overloading the browser's connection limit per domain.
* **Resource Types**:
* **JS/Views**: Fetched as `text`.
* **Images**: Fetched as `blob` -> `URL.createObjectURL`.
* **Audio**: Fetched as `arraybuffer` -> `AudioContext.decodeAudioData`.

233
app.py
View File

@@ -13,6 +13,7 @@ import requests
import schema
import os
import time
from datetime import datetime
# -- カスタム --
import traceback
@@ -105,9 +106,13 @@ 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')
db.leaderboard.create_index([
('song_id', 1),
('difficulty', 1),
('month', 1),
('score', -1)
])
db.leaderboard.create_index('username')
class HashException(Exception):
@@ -755,159 +760,125 @@ 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')
current_month = datetime.now().strftime('%Y-%m')
# Validate difficulty
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
if difficulty not in valid_difficulties:
return api_error('invalid_difficulty')
# Handle song_id type
song_id = data['song_id']
# 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({
query = {
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'month': current_month
})
'difficulty': data['difficulty'],
'month': current_month,
'username': username
}
# 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
# Check for 'score' first, then 'points'
# Frontend usually sends 'points'
score_val = score_obj.get('score')
if score_val is None:
score_val = score_obj.get('points')
score_value = int(score_val or 0)
except:
return api_error('invalid_score_format')
existing = db.leaderboard.find_one(query)
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({
new_record = False
rank_info = None
if not existing or data['score'] > existing['score']:
entry = {
'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,
'difficulty': data['difficulty'],
'month': current_month,
'username': username,
'display_name': user['display_name'],
'score': score_data,
'score_value': score_value,
'submitted_at': time.time(),
'month': current_month
'don': get_db_don(user),
'score': data['score'],
'good': data.get('good', 0),
'ok': data.get('ok', 0),
'bad': data.get('bad', 0),
'maxCombo': data.get('maxCombo', 0),
'timestamp': datetime.now()
}
db.leaderboard.update_one(query, {'$set': entry}, upsert=True)
new_record = True
# Get rank
count = db.leaderboard.count_documents({
'song_id': song_id,
'difficulty': data['difficulty'],
'month': current_month,
'score': {'$gt': data['score']}
})
# 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'})
rank = count + 1
rank_info = rank
return jsonify({
'status': 'ok',
'new_record': new_record,
'rank': rank_info
})
@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)
song_id = request.args.get('song_id')
difficulty = request.args.get('difficulty')
if not song_id or not difficulty:
return abort(400)
# Try convert song_id to int if it looks like one
if re.match('^[0-9]+$', str(song_id)):
song_id = int(song_id)
current_month = datetime.now().strftime('%Y-%m')
# Accept both numeric IDs and hash strings
# Try to convert to int if possible, otherwise use as string
try:
song_id = int(song_id)
except (ValueError, TypeError):
# Keep as string (hash ID)
pass
# 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({
query = {
'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
# Get top 50
leaderboard = list(db.leaderboard.find(query, {'_id': False}).sort('score', -1).limit(50))
return jsonify({'status': 'ok', 'leaderboard': leaderboard, 'month': current_month})
user_rank = None
if session.get('username'):
username = session.get('username')
user_entry = db.leaderboard.find_one({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month,
'username': username
}, {'_id': False})
if user_entry:
# Calculate rank
count = db.leaderboard.count_documents({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month,
'score': {'$gt': user_entry['score']}
})
user_rank = {
'rank': count + 1,
'entry': user_entry
}
return jsonify({
'status': 'ok',
'leaderboard': leaderboard,
'user_rank': user_rank
})
@app.route(basedir + 'api/leaderboard/reset', methods=['POST'])
@admin_required(level=100)
def route_api_leaderboard_reset():
current_month = datetime.now().strftime('%Y-%m')
result = db.leaderboard.delete_many({'month': {'$ne': current_month}})
return jsonify({
'status': 'ok',
'deleted_count': result.deleted_count,
'current_month': current_month
})
@app.route(basedir + 'privacy')

View File

@@ -1,201 +0,0 @@
import time
import json
import base64
# Mock DB and objects
class MockCollection:
def __init__(self, name):
self.name = name
self.data = []
def find_one(self, query):
for item in self.data:
match = True
for k, v in query.items():
if item.get(k) != v:
match = False
break
if match:
return item
return None
def find(self, query, projection=None):
results = []
for item in self.data:
match = True
for k, v in query.items():
if item.get(k) != v:
match = False
break
if match:
if projection:
filtered = {k: v for k, v in item.items() if projection.get(k, False)}
if projection.get('_id') is None or projection.get('_id') is True:
filtered['_id'] = item.get('_id')
results.append(filtered)
else:
results.append(item)
return MockCursor(results)
def insert_one(self, doc):
doc['_id'] = len(self.data) + 1
print(f"[{self.name}] Inserted: {doc}")
self.data.append(doc)
def count_documents(self, query):
return len(self.data)
class MockCursor:
def __init__(self, data):
self.data = data
def sort(self, key, direction):
self.data.sort(key=lambda x: x.get(key, 0), reverse=(direction < 0))
return self
def limit(self, n):
self.data = self.data[:n]
return self.data
class MockDB:
def __init__(self):
self.users = MockCollection('users')
self.leaderboards = MockCollection('leaderboards')
db = MockDB()
db.users.insert_one({'username': 'testuser', 'display_name': 'Test User'})
# --- Logic from app.py rewritten for standalone test ---
def logic_submit(data, username):
print(f"\n--- Submitting {data.get('song_id')} ---")
user = db.users.find_one({'username': username})
if not user:
return {'error': 'user_not_found'}
song_id = data.get('song_id')
difficulty = data.get('difficulty')
score_data = data.get('score')
current_month = time.strftime('%Y-%m', time.gmtime())
existing = db.leaderboards.find_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'month': current_month
})
# Patched Logic from app.py
try:
if isinstance(score_data, str):
score_obj = json.loads(score_data)
else:
score_obj = score_data
# Check for 'score' first, then 'points'
score_val = score_obj.get('score')
if score_val is None:
score_val = score_obj.get('points')
score_value = int(score_val or 0)
print(f" Parsed Score Value: {score_value}")
except Exception as e:
print(f"Error parsing score: {e}")
return {'error': 'invalid_score_format'}
if existing:
print(" Existing record found (simulated update).")
else:
print(" Inserting 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
})
return {'status': 'ok'}
def logic_get(args):
print(f"\n--- Getting {args.get('song_id')} ---")
song_id = args.get('song_id', None)
difficulty = args.get('difficulty', None)
if not song_id or not difficulty:
return {'error': 'missing args'}
try:
song_id = int(song_id)
except (ValueError, TypeError):
pass
current_month = time.strftime('%Y-%m', time.gmtime())
leaderboard = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}, {
'_id': False,
'username': True,
'score_value': True
}).sort('score_value', -1).limit(50))
return {'status': 'ok', 'leaderboard': leaderboard}
# --- Test Cases ---
# Case 1: Hash ID
print("=== TEST CASE 1: Hash ID ===")
logic_submit({
'song_id': "hash123",
'difficulty': 'oni',
'score': {'score': 1000000}
}, 'testuser')
res1 = logic_get({'song_id': "hash123", 'difficulty': 'oni'})
if len(res1['leaderboard']) > 0: print("✅ Success")
else: print("❌ Failed")
# Case 2: Numeric ID
print("\n=== TEST CASE 2: Numeric ID ===")
logic_submit({
'song_id': 123,
'difficulty': 'oni',
'score': {'score': 2000000}
}, 'testuser')
res2 = logic_get({'song_id': "123", 'difficulty': 'oni'})
if len(res2['leaderboard']) > 0: print("✅ Success")
else: print("❌ Failed")
# Case 3: Points Only (Frontend Payload Simulation)
print("\n=== TEST CASE 3: Points Only (Frontend Payload) ===")
# This simulates what the JS 'this.resultsObj' looks like (it has 'points', not 'score')
logic_submit({
'song_id': "points_song",
'difficulty': 'hard',
'score': {'points': 345678, 'bad': 0, 'good': 100} # No 'score' key
}, 'testuser')
res3 = logic_get({'song_id': "points_song", 'difficulty': 'hard'})
passed = False
if len(res3.get('leaderboard', [])) > 0:
val = res3['leaderboard'][0].get('score_value')
if val == 345678:
print(f"✅ SUCCESS: Correctly parsed 'points' -> {val}")
passed = True
else:
print(f"❌ FAILED: Score value mismatch. Expected 345678, got {val}")
else:
print("❌ FAILED: No record found")
if passed:
print("\n---------------------------------------------------")
print("🎉 ALL LOCAL TESTS PASSED. BACKEND LOGIC VERIFIED.")
print("---------------------------------------------------")
else:
print("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
print("🛑 TESTS FAILED. CHECK LOGS.")
print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")

View File

@@ -1,13 +0,0 @@
#leaderboard {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1000;
}
#leaderboard-canvas {
width: 100%;
height: 100%;
}

View File

@@ -6,8 +6,8 @@ var assets = {
"parseosu.js",
"titlescreen.js",
"scoresheet.js",
"leaderboard_ui.js",
"songselect.js",
"leaderboard.js",
"keyboard.js",
"gameinput.js",
"game.js",
@@ -141,7 +141,6 @@ var assets = {
},
"views": [
"game.html",
"leaderboard.html",
"loadsong.html",
"songselect.html",
"titlescreen.html",

View File

@@ -1,410 +0,0 @@
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"])
}
}

View File

@@ -0,0 +1,286 @@
class LeaderboardUI {
constructor(songSelect) {
this.songSelect = songSelect
this.canvasCache = new CanvasCache()
this.nameplateCache = new CanvasCache()
this.visible = false
this.loading = false
this.leaderboardData = []
this.userRank = null
this.scroll = 0
this.maxScroll = 0
this.songId = null
this.difficulty = null
this.font = strings.font
this.numbersFont = "TnT, Meiryo, sans-serif"
// UI Constants
this.width = 800
this.height = 500
this.itemHeight = 60
this.headerHeight = 80
this.padding = 20
this.closeBtn = {
x: 0, y: 0, w: 60, h: 60
}
}
show(songId, difficulty) {
this.visible = true
this.loading = true
this.songId = songId
this.difficulty = difficulty
this.leaderboardData = []
this.userRank = null
this.scroll = 0
this.fetchData(songId, difficulty)
}
hide() {
this.visible = false
this.songSelect.leaderboardActive = false
}
fetchData(songId, difficulty) {
loader.ajax(gameConfig.basedir + `api/leaderboard/get?song_id=${songId}&difficulty=${difficulty}`).then(response => {
const data = JSON.parse(response)
if (data.status === 'ok') {
this.leaderboardData = data.leaderboard
this.userRank = data.userRank
this.loading = false
// Calculate max scroll
const totalHeight = this.leaderboardData.length * this.itemHeight
const viewHeight = this.height - this.headerHeight - this.padding * 2
this.maxScroll = Math.max(0, totalHeight - viewHeight)
}
}).catch(e => {
console.error("Leaderboard fetch error:", e)
this.loading = false
})
}
draw(ctx, winW, winH, pixelRatio) {
if (!this.visible) return
const x = (winW - this.width) / 2
const y = (winH - this.height) / 2
// Draw Overlay
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"
ctx.fillRect(0, 0, winW, winH)
// Draw Window
this.songSelect.draw.roundedRect({
ctx: ctx,
x: x,
y: y,
w: this.width,
h: this.height,
radius: 20
})
ctx.fillStyle = "#fff"
ctx.fill()
// Header
ctx.fillStyle = "#ff6600"
ctx.beginPath()
ctx.moveTo(x + 20, y)
ctx.lineTo(x + this.width - 20, y)
ctx.quadraticCurveTo(x + this.width, y, x + this.width, y + 20)
ctx.lineTo(x + this.width, y + this.headerHeight)
ctx.lineTo(x, y + this.headerHeight)
ctx.lineTo(x, y + 20)
ctx.quadraticCurveTo(x, y, x + 20, y)
ctx.fill()
// Title
const diffText = strings[this.difficulty === "ura" ? "oni" : this.difficulty]
const titleText = strings.leaderboardTitle.replace("%s", diffText)
this.songSelect.draw.layeredText({
ctx: ctx,
text: titleText,
fontSize: 40,
fontFamily: this.font,
x: x + this.width / 2,
y: y + 54,
align: "center",
width: this.width - 100
}, [
{ outline: "#fff", letterBorder: 2 },
{ fill: "#000" }
])
// Close Button
this.closeBtn.x = x + this.width - 50
this.closeBtn.y = y - 10
ctx.fillStyle = "#ff0000"
ctx.beginPath()
ctx.arc(this.closeBtn.x, this.closeBtn.y, 20, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = "#fff"
ctx.lineWidth = 3
ctx.stroke()
ctx.fillStyle = "#fff"
ctx.font = "bold 24px Arial"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("X", this.closeBtn.x, this.closeBtn.y)
// Content Area
const contentX = x + this.padding
const contentY = y + this.headerHeight + this.padding
const contentW = this.width - this.padding * 2
const contentH = this.height - this.headerHeight - this.padding * 2
ctx.save()
ctx.beginPath()
ctx.rect(contentX, contentY, contentW, contentH)
ctx.clip()
if (this.loading) {
ctx.fillStyle = "#000"
ctx.font = `30px ${this.font}`
ctx.textAlign = "center"
ctx.fillText(strings.loadingLeaderboard, x + this.width / 2, y + this.height / 2)
} else if (this.leaderboardData.length === 0) {
ctx.fillStyle = "#666"
ctx.font = `30px ${this.font}`
ctx.textAlign = "center"
ctx.fillText(strings.noScores, x + this.width / 2, y + this.height / 2)
} else {
// Draw List
let currentY = contentY - this.scroll
// Header Row
/*
ctx.fillStyle = "#eee"
ctx.fillRect(contentX, contentY, contentW, 40)
ctx.fillStyle = "#000"
ctx.font = `bold 20px ${this.font}`
ctx.textAlign = "left"
ctx.fillText(strings.rank, contentX + 20, contentY + 28)
ctx.fillText(strings.playerName, contentX + 100, contentY + 28)
ctx.textAlign = "right"
ctx.fillText(strings.score, contentX + contentW - 20, contentY + 28)
currentY += 40
*/
this.leaderboardData.forEach((entry, index) => {
if (currentY + this.itemHeight < contentY) {
currentY += this.itemHeight
return
}
if (currentY > contentY + contentH) return
const isUser = this.userRank && this.userRank.entry.username === entry.username
// Background
if (index % 2 === 0) {
ctx.fillStyle = isUser ? "#fff8e1" : "#f9f9f9"
} else {
ctx.fillStyle = isUser ? "#fff3cd" : "#fff"
}
ctx.fillRect(contentX, currentY, contentW, this.itemHeight)
// Rank
const rank = index + 1
let rankColor = "#000"
if (rank === 1) rankColor = "#ffd700"
else if (rank === 2) rankColor = "#c0c0c0"
else if (rank === 3) rankColor = "#cd7f32"
if (rank <= 3) {
ctx.fillStyle = rankColor
ctx.beginPath()
ctx.arc(contentX + 40, currentY + this.itemHeight / 2, 18, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = "#fff"
ctx.font = "bold 20px Arial"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(rank, contentX + 40, currentY + this.itemHeight / 2)
} else {
ctx.fillStyle = "#000"
ctx.font = "20px Arial"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(rank, contentX + 40, currentY + this.itemHeight / 2)
}
// Name
ctx.fillStyle = "#000"
ctx.font = `24px ${this.font}`
ctx.textAlign = "left"
ctx.fillText(entry.display_name, contentX + 90, currentY + this.itemHeight / 2)
// Score
ctx.font = `bold 28px ${this.numbersFont}`
ctx.textAlign = "right"
ctx.fillStyle = "#ff4500"
ctx.fillText(entry.score.toLocaleString(), contentX + contentW - 20, currentY + this.itemHeight / 2 + 2)
// Max Combo (small)
ctx.font = `16px ${this.font}`
ctx.fillStyle = "#666"
ctx.fillText(`${strings.maxCombo}: ${entry.maxCombo}`, contentX + contentW - 180, currentY + this.itemHeight / 2 + 5)
currentY += this.itemHeight
})
}
ctx.restore()
// Draw User Rank at Bottom if exists
if (this.userRank) {
const footerY = y + this.height - 50
ctx.fillStyle = "#333"
ctx.fillRect(x, footerY, this.width, 50)
ctx.fillStyle = "#fff"
ctx.font = `20px ${this.font}`
ctx.textAlign = "left"
ctx.textBaseline = "middle"
const rankText = strings.yourRank.replace("%s", this.userRank.rank)
ctx.fillText(rankText, x + 20, footerY + 25)
ctx.textAlign = "right"
ctx.font = `bold 24px ${this.numbersFont}`
ctx.fillText(this.userRank.entry.score.toLocaleString(), x + this.width - 20, footerY + 25)
}
}
mouseMove(x, y) {
// Handle hover if needed
}
mouseDown(x, y) {
if (!this.visible) return false
// Check Close Button
const dx = x - this.closeBtn.x
const dy = y - this.closeBtn.y
if (dx * dx + dy * dy < 20 * 20) {
this.hide()
assets.sounds["se_cancel"].play()
return true
}
// Check window click (consume event)
// Adjust coordinates
const winX = (innerWidth * (window.devicePixelRatio || 1) - this.width) / 2
/* Note: simple check logic here, might need more precise logic mapping similar to mouseWheel */
return true
}
wheel(delta) {
if (!this.visible) return
this.scroll += delta * 50
this.scroll = Math.max(0, Math.min(this.scroll, this.maxScroll))
}
}

View File

@@ -0,0 +1,26 @@
self.addEventListener('message', async e => {
const { id, url, type } = e.data
try{
const response = await fetch(url)
if(!response.ok){
throw new Error(response.status + " " + response.statusText)
}
let data
if(type === "arraybuffer"){
data = await response.arrayBuffer()
}else if(type === "blob"){
data = await response.blob()
}else{
data = await response.text()
}
self.postMessage({
id: id,
data: data
}, type === "arraybuffer" ? [data] : undefined)
}catch(e){
self.postMessage({
id: id,
error: e.toString()
})
}
})

View File

@@ -11,6 +11,8 @@ class Loader{
this.errorMessages = []
this.songSearchGradient = "linear-gradient(to top, rgba(245, 246, 252, 0.08), #ff5963), "
this.initWorkers()
var promises = []
promises.push(this.ajax("src/views/loader.html").then(page => {
@@ -23,6 +25,78 @@ class Loader{
Promise.all(promises).then(this.run.bind(this))
}
initWorkers(){
this.workers = []
this.workerQueue = []
this.workerCallbacks = {}
this.workerId = 0
var concurrency = navigator.hardwareConcurrency || 4
for(var i = 0; i < concurrency; i++){
var worker = new Worker("src/js/loader-worker.js")
worker.onmessage = this.onWorkerMessage.bind(this)
this.workers.push({
worker: worker,
active: 0
})
}
}
onWorkerMessage(e){
var data = e.data
var callback = this.workerCallbacks[data.id]
if(callback){
delete this.workerCallbacks[data.id]
if(data.error){
callback.reject(data.error)
}else{
callback.resolve(data.data)
}
this.workerFree(callback.workerIndex)
}
}
workerFetch(url, type){
return new Promise((resolve, reject) => {
var id = ++this.workerId
this.workerQueue.push({
id: id,
url: new URL(url, location.href).href,
type: type,
resolve: resolve,
reject: reject
})
this.workerRun()
})
}
workerRun(){
if(this.workerQueue.length === 0){
return
}
var workerIndex = -1
var minActive = Infinity
for(var i = 0; i < this.workers.length; i++){
if(this.workers[i].active < minActive){
minActive = this.workers[i].active
workerIndex = i
}
}
if(workerIndex !== -1){
var task = this.workerQueue.shift()
var workerObj = this.workers[workerIndex]
workerObj.active++
this.workerCallbacks[task.id] = task
task.workerIndex = workerIndex
workerObj.worker.postMessage({
id: task.id,
url: task.url,
type: task.type
})
}
}
workerFree(index){
if(this.workers[index]){
this.workers[index].active--
this.workerRun()
}
}
run(){
this.promises = []
this.loaderDiv = document.querySelector("#loader")
@@ -538,6 +612,17 @@ class Loader{
return css.join("\n")
}
ajax(url, customRequest, customResponse){
if(!customResponse && (url.startsWith("src/") || url.startsWith("assets/") || url.indexOf("img/") !== -1 || url.indexOf("audio/") !== -1 || url.indexOf("fonts/") !== -1 || url.indexOf("views/") !== -1)){
var type = "text"
if(customRequest){
var reqStub = {}
customRequest(reqStub)
if(reqStub.responseType){
type = reqStub.responseType
}
}
return this.workerFetch(url, type)
}
var request = new XMLHttpRequest()
request.open("GET", url)
var promise = pageEvents.load(request)
@@ -557,12 +642,13 @@ class Loader{
return promise
}
loadScript(url){
var script = document.createElement("script")
var url = url + this.queryString
var promise = pageEvents.load(script)
script.src = url
document.head.appendChild(script)
return promise
return this.workerFetch(url, "text").then(code => {
var script = document.createElement("script")
code += "\n//# sourceURL=" + url
script.text = code
document.head.appendChild(script)
})
}
getCsrfToken(){
return this.ajax("api/csrftoken").then(response => {

View File

@@ -178,94 +178,8 @@ class Scoresheet {
}
this.game.appendChild(this.tetsuoHana)
}
// Add leaderboard submit button if user is logged in
if (account.loggedIn && !this.multiplayer) {
this.leaderboardBtn = document.createElement("div")
this.leaderboardBtn.id = "leaderboard-submit-btn"
this.leaderboardBtn.innerHTML = "🏆 提交排行榜<br><small>Submit to Leaderboard</small>"
this.leaderboardBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
background: linear-gradient(135deg, #ff6b9d, #c44db3);
color: white;
border-radius: 15px;
font-size: 18px;
font-family: ${strings.font || "sans-serif"};
text-align: center;
cursor: pointer;
z-index: 1000;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
border: 3px solid #fff;
transition: all 0.2s ease;
user-select: none;
`
this.leaderboardBtn.onmouseover = () => {
if (!this.leaderboardSubmitted) {
this.leaderboardBtn.style.transform = "scale(1.05)"
this.leaderboardBtn.style.boxShadow = "0 6px 20px rgba(0,0,0,0.4)"
}
}
this.leaderboardBtn.onmouseout = () => {
this.leaderboardBtn.style.transform = "scale(1)"
this.leaderboardBtn.style.boxShadow = "0 4px 15px rgba(0,0,0,0.3)"
}
this.leaderboardBtn.onclick = () => this.onLeaderboardBtnClick()
this.game.appendChild(this.leaderboardBtn)
}
}
onLeaderboardBtnClick() {
if (this.leaderboardSubmitted) {
return
}
// Fallback: This is CRITICAL for cases where saveScore() failed or was skipped
if (!this.leaderboardData) {
console.warn("Leaderboard data missing, attempting to reconstruct...")
var song = this.controller.selectedSong
var results = this.resultsObj
if (song && results) {
var songId = song.id || song.hash
if (songId) {
this.leaderboardData = {
songId: songId,
difficulty: results.difficulty,
scoreObj: Object.assign({ score: results.points }, results)
}
// Clean up scoreObj
if (this.leaderboardData.scoreObj) {
delete this.leaderboardData.scoreObj.title
delete this.leaderboardData.scoreObj.difficulty
delete this.leaderboardData.scoreObj.gauge
}
console.log("Leaderboard data reconstructed:", this.leaderboardData)
}
}
}
if (!this.leaderboardData || !this.leaderboardData.songId) {
this.showLeaderboardNotification("no_song_id")
return
}
// Disable button and show submitting state
this.leaderboardBtn.innerHTML = "⏳ 提交中..."
this.leaderboardBtn.style.background = "linear-gradient(135deg, #888, #666)"
this.leaderboardBtn.style.cursor = "default"
this.submitToLeaderboard(
this.leaderboardData.songId,
this.leaderboardData.difficulty,
this.leaderboardData.scoreObj
)
}
redraw() {
if (!this.redrawRunning) {
return
@@ -935,6 +849,29 @@ class Scoresheet {
}
ctx.restore()
}
if (this.leaderboardResult) {
ctx.save()
var text = strings.recordBroken.replace("%s", this.leaderboardResult.rank)
ctx.textAlign = "center"
var y = 130
if (this.multiplayer) {
y = 400
}
this.draw.layeredText({
ctx: ctx,
text: text,
x: 640,
y: y,
fontSize: 40,
fontFamily: strings.font,
align: "center"
}, [
{ outline: "#fff", letterBorder: 5 },
{ fill: "#ff0000" }
])
ctx.restore()
}
ctx.restore()
if (this.session && !this.state.scoreNext && this.state.screen === "scoresShown" && ms - this.state.screenMS >= 10000) {
this.state.scoreNext = true
@@ -1012,24 +949,12 @@ class Scoresheet {
var title = this.controller.selectedSong.originalTitle
var hash = this.controller.selectedSong.hash
var difficulty = this.resultsObj.difficulty
// Use id if available, otherwise use hash
var songId = this.controller.selectedSong.id || hash
var oldScore = scoreStorage.get(hash, difficulty, true)
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
var crown = ""
if (clearReached) {
crown = this.resultsObj.bad === 0 ? "gold" : "silver"
}
// Store data for manual leaderboard submission
this.leaderboardData = {
songId: songId,
difficulty: difficulty,
scoreObj: Object.assign({ score: this.resultsObj.points }, this.resultsObj)
}
this.leaderboardSubmitted = false
if (!oldScore || oldScore.points <= this.resultsObj.points) {
if (oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)) {
crown = oldScore.crown
@@ -1048,133 +973,56 @@ class Scoresheet {
})
}
}
if (!this.controller.autoPlayEnabled && account.loggedIn && !this.multiplayer) {
this.submitToLeaderboard()
}
this.scoreSaved = true
}
submitToLeaderboard(songId, difficulty, scoreObj) {
// Only submit if user is logged in and song has an ID
if (!account.loggedIn || !songId) {
submitToLeaderboard() {
if (!this.resultsObj || !this.controller.selectedSong) {
return
}
var song = this.controller.selectedSong
var body = {
song_id: song.id,
difficulty: this.resultsObj.difficulty,
score: this.resultsObj.points,
good: this.resultsObj.good,
ok: this.resultsObj.ok,
bad: this.resultsObj.bad,
maxCombo: this.resultsObj.maxCombo,
hash: song.hash
}
var self = this
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.onload = function () {
request.open("POST", gameConfig.basedir + "api/leaderboard/submit")
pageEvents.load(request).then(() => {
if (request.status === 200) {
try {
var response = JSON.parse(request.responseText)
if (response.status === "ok") {
self.leaderboardSubmitted = true
self.showLeaderboardNotification(response.message)
// Update button to show success
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "✅ 已提交<br><small>Submitted!</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #4CAF50, #45a049)"
}
} else {
// Show error
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
var data = JSON.parse(request.response)
if (data.status === "ok" && data.new_record && data.rank) {
this.leaderboardResult = {
rank: data.rank
}
assets.sounds["se_results_crown"].play()
}
} catch (e) {
console.error("Failed to parse leaderboard response:", e)
self.showLeaderboardNotification("error")
}
} else {
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
console.error("Leaderboard response parse error:", e)
}
}
}
request.onerror = function () {
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
request.send(JSON.stringify({
song_id: songId,
difficulty: difficulty,
score: scoreObj
}))
}).catch(() => {
console.log("Leaderboard submission failed")
this.showLeaderboardNotification("error")
if (this.leaderboardBtn) {
this.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
this.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}).catch(e => {
console.error("Leaderboard submit failed:", e)
})
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8")
request.setRequestHeader("X-CSRFToken", token)
request.send(JSON.stringify(body))
}).catch(e => {
console.error("Failed to get CSRF token:", e)
})
}
showLeaderboardNotification(message) {
var notification = document.createElement("div")
notification.className = "leaderboard-notification"
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
background: linear-gradient(135deg, #4a90e2, #7b68ee);
color: white;
border-radius: 10px;
font-size: 16px;
font-family: ${strings.font || "sans-serif"};
z-index: 10000;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
opacity: 0;
transform: translateX(100px);
transition: all 0.3s ease-out;
`
var text = ""
switch (message) {
case "score_submitted": text = "🏆 成绩已提交到排行榜!"; break
case "score_updated": text = "🎉 排行榜成绩已更新!"; break
case "score_not_higher": text = "📊 已有更高成绩"; break
case "score_too_low": text = "未进入排行榜前50"; break
case "error": text = "❌ 提交失败,请重试"; break
case "no_song_id": text = "❌ 无法提交歌曲ID缺失"; break
default: text = "排行榜已更新"
}
notification.innerText = text
document.body.appendChild(notification)
// Trigger animation
setTimeout(() => {
notification.style.opacity = "1"
notification.style.transform = "translateX(0)"
}, 10)
// Remove after 3 seconds
setTimeout(() => {
notification.style.opacity = "0"
notification.style.transform = "translateX(100px)"
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 300)
}, 3000)
}
clean() {
this.keyboard.clean()
this.gamepad.clean()
@@ -1193,17 +1041,10 @@ class Scoresheet {
if (!this.multiplayer) {
delete this.tetsuoHana
}
// Clean up leaderboard button
if (this.leaderboardBtn && this.leaderboardBtn.parentNode) {
this.leaderboardBtn.parentNode.removeChild(this.leaderboardBtn)
}
delete this.leaderboardBtn
delete this.leaderboardData
delete this.ctx
delete this.canvas
delete this.fadeScreen
delete this.results
delete this.rules
}
}

View File

@@ -1,19 +1,19 @@
class Search {
constructor(...args) {
class Search{
constructor(...args){
this.init(...args)
}
init(songSelect) {
init(songSelect){
this.songSelect = songSelect
this.opened = false
this.enabled = true
this.style = document.createElement("style")
var css = []
for (var i in this.songSelect.songSkin) {
for(var i in this.songSelect.songSkin){
var skin = this.songSelect.songSkin[i]
if ("id" in skin || i === "default") {
if("id" in skin || i === "default"){
var id = "id" in skin ? ("cat" + skin.id) : i
css.push(loader.cssRuleset({
[".song-search-" + id]: {
"background-color": skin.background
@@ -33,7 +33,7 @@ class Search {
loader.screen.appendChild(this.style)
}
normalizeString(string) {
normalizeString(string){
string = string
.replace('', '\'').replace('“', '"').replace('”', '"')
.replace('。', '.').replace('', ',').replace('、', ',')
@@ -44,29 +44,29 @@ class Search {
return string.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
}
perform(query) {
perform(query){
var results = []
var filters = {}
var querySplit = query.split(" ").filter(word => {
if (word.length > 0) {
if(word.length > 0){
var parts = word.toLowerCase().split(":")
if (parts.length > 1) {
switch (parts[0]) {
if(parts.length > 1){
switch(parts[0]){
case "easy":
case "normal":
case "hard":
case "oni":
case "ura":
var range = this.parseRange(parts[1])
if (range) {
if(range){
filters[parts[0]] = range
}
break
case "extreme":
var range = this.parseRange(parts[1])
if (range) {
if(range){
filters.oni = this.parseRange(parts[1])
}
break
@@ -91,175 +91,175 @@ class Search {
}
return true
})
query = this.normalizeString(querySplit.join(" ").trim())
var totalFilters = Object.keys(filters).length
var random = false
var allResults = false
for (var i = 0; i < assets.songs.length; i++) {
for(var i = 0; i < assets.songs.length; i++){
var song = assets.songs[i]
var passedFilters = 0
Object.keys(filters).forEach(filter => {
var value = filters[filter]
switch (filter) {
switch(filter){
case "easy":
case "normal":
case "hard":
case "oni":
case "ura":
if (song.courses[filter] && song.courses[filter].stars >= value.min && song.courses[filter].stars <= value.max) {
if(song.courses[filter] && song.courses[filter].stars >= value.min && song.courses[filter].stars <= value.max){
passedFilters++
}
break
case "clear":
case "silver":
case "gold":
if (value === "any") {
if(value === "any"){
var score = scoreStorage.scores[song.hash]
scoreStorage.difficulty.forEach(difficulty => {
if (score && score[difficulty] && score[difficulty].crown && (filter === "clear" || score[difficulty].crown === filter)) {
if(score && score[difficulty] && score[difficulty].crown && (filter === "clear" || score[difficulty].crown === filter)){
passedFilters++
}
})
} else {
var score = scoreStorage.scores[song.hash]
if (score && score[value] && score[value].crown && (filter === "clear" || score[value].crown === filter)) {
if(score && score[value] && score[value].crown && (filter === "clear" || score[value].crown === filter)){
passedFilters++
}
}
break
case "played":
var score = scoreStorage.scores[song.hash]
if ((value === "yes" && score) || (value === "no" && !score)) {
if((value === "yes" && score) || (value === "no" && !score)){
passedFilters++
}
break
case "lyrics":
if ((value === "yes" && song.lyrics) || (value === "no" && !song.lyrics)) {
if((value === "yes" && song.lyrics) || (value === "no" && !song.lyrics)){
passedFilters++
}
break
case "creative":
if ((value === "yes" && song.maker) || (value === "no" && !song.maker)) {
if((value === "yes" && song.maker) || (value === "no" && !song.maker)){
passedFilters++
}
break
case "maker":
if (song.maker && song.maker.name.toLowerCase().includes(value.toLowerCase())) {
if(song.maker && song.maker.name.toLowerCase().includes(value.toLowerCase())){
passedFilters++
}
break
case "genre":
var cat = assets.categories.find(cat => cat.id === song.category_id)
var aliases = cat.aliases ? cat.aliases.concat([cat.title]) : [cat.title]
if (aliases.find(alias => alias.toLowerCase() === value.toLowerCase())) {
if(aliases.find(alias => alias.toLowerCase() === value.toLowerCase())){
passedFilters++
}
break
case "diverge":
var branch = Object.values(song.courses).find(course => course && course.branch)
if ((value === "yes" && branch) || (value === "no" && !branch)) {
if((value === "yes" && branch) || (value === "no" && !branch)){
passedFilters++
}
break
case "random":
if (value === "yes" || value === "no") {
if(value === "yes" || value === "no"){
random = value === "yes"
passedFilters++
}
break
case "all":
if (value === "yes" || value === "no") {
if(value === "yes" || value === "no"){
allResults = value === "yes"
passedFilters++
}
break
}
})
if (passedFilters === totalFilters) {
if(passedFilters === totalFilters){
results.push(song)
}
}
var maxResults = allResults ? Infinity : (totalFilters > 0 && !query ? 100 : 50)
if (query) {
if(query){
results = fuzzysort.go(query, results, {
keys: ["titlePrepared", "subtitlePrepared"],
allowTypo: true,
limit: maxResults,
scoreFn: a => {
if (a[0]) {
if(a[0]){
var score0 = a[0].score
a[0].ranges = this.indexesToRanges(a[0].indexes)
if (a[0].indexes.length > 1) {
if(a[0].indexes.length > 1){
var rangeAmount = a[0].ranges.length
var lastIdx = -3
a[0].ranges.forEach(range => {
if (range[0] - lastIdx <= 2) {
if(range[0] - lastIdx <= 2){
rangeAmount--
score0 -= 1000
}
lastIdx = range[1]
})
var index = a[0].target.toLowerCase().indexOf(query)
if (index !== -1) {
if(index !== -1){
a[0].ranges = [[index, index + query.length - 1]]
} else if (rangeAmount > a[0].indexes.length / 2) {
}else if(rangeAmount > a[0].indexes.length / 2){
score0 = -Infinity
a[0].ranges = null
} else if (rangeAmount !== 1) {
}else if(rangeAmount !== 1){
score0 -= 9000
}
}
}
if (a[1]) {
if(a[1]){
var score1 = a[1].score - 1000
a[1].ranges = this.indexesToRanges(a[1].indexes)
if (a[1].indexes.length > 1) {
if(a[1].indexes.length > 1){
var rangeAmount = a[1].ranges.length
var lastIdx = -3
a[1].ranges.forEach(range => {
if (range[0] - lastIdx <= 2) {
if(range[0] - lastIdx <= 2){
rangeAmount--
score1 -= 1000
}
lastIdx = range[1]
})
var index = a[1].target.indexOf(query)
if (index !== -1) {
if(index !== -1){
a[1].ranges = [[index, index + query.length - 1]]
} else if (rangeAmount > a[1].indexes.length / 2) {
}else if(rangeAmount > a[1].indexes.length / 2){
score1 = -Infinity
a[1].ranges = null
} else if (rangeAmount !== 1) {
}else if(rangeAmount !== 1){
score1 -= 9000
}
}
}
if (random) {
if(random){
var rand = Math.random() * -9000
if (score0 !== -Infinity) {
if(score0 !== -Infinity){
score0 = rand
}
if (score1 !== -Infinity) {
if(score1 !== -Infinity){
score1 = rand
}
}
if (a[0]) {
if(a[0]){
return a[1] ? Math.max(score0, score1) : score0
} else {
}else{
return a[1] ? score1 : -Infinity
}
}
})
} else {
if (random) {
for (var i = results.length - 1; i > 0; i--) {
}else{
if(random){
for(var i = results.length - 1; i > 0; i--){
var j = Math.floor(Math.random() * (i + 1))
var temp = results[i]
results[i] = results[j]
@@ -267,53 +267,53 @@ class Search {
}
}
results = results.slice(0, maxResults).map(result => {
return { obj: result }
return {obj: result}
})
}
return results
}
createResult(result, resultWidth, fontSize) {
createResult(result, resultWidth, fontSize){
var song = result.obj
var title = this.songSelect.getLocalTitle(song.title, song.title_lang)
var subtitle = this.songSelect.getLocalTitle(title === song.title ? song.subtitle : "", song.subtitle_lang)
var id = "default"
if (song.category_id) {
if(song.category_id){
var cat = assets.categories.find(cat => cat.id === song.category_id)
if (cat && "id" in cat) {
if(cat && "id" in cat){
id = "cat" + cat.id
}
}
var resultDiv = document.createElement("div")
resultDiv.classList.add("song-search-result", "song-search-" + id)
resultDiv.dataset.songId = song.id
var resultInfoDiv = document.createElement("div")
resultInfoDiv.classList.add("song-search-result-info")
var resultInfoTitle = document.createElement("span")
resultInfoTitle.classList.add("song-search-result-title")
resultInfoTitle.appendChild(this.highlightResult(title, result[0]))
resultInfoTitle.setAttribute("alt", title)
resultInfoDiv.appendChild(resultInfoTitle)
if (subtitle) {
if(subtitle){
resultInfoDiv.appendChild(document.createElement("br"))
var resultInfoSubtitle = document.createElement("span")
resultInfoSubtitle.classList.add("song-search-result-subtitle")
resultInfoSubtitle.appendChild(this.highlightResult(subtitle, result[1]))
resultInfoSubtitle.setAttribute("alt", subtitle)
resultInfoDiv.appendChild(resultInfoSubtitle)
}
resultDiv.appendChild(resultInfoDiv)
var courses = ["easy", "normal", "hard", "oni", "ura"]
courses.forEach(course => {
var courseDiv = document.createElement("div")
@@ -330,41 +330,40 @@ class Search {
var courseStars = document.createElement("div")
courseStars.classList.add("song-search-result-stars")
courseStars.innerText = song.courses[course].stars + "\u2605"
courseDiv.appendChild(courseCrown)
courseDiv.appendChild(courseStars)
} else {
courseDiv.classList.add("song-search-result-hidden")
}
resultDiv.appendChild(courseDiv)
})
this.songSelect.ctx.font = (1.2 * fontSize) + "px " + strings.font
var titleWidth = this.songSelect.ctx.measureText(title).width
var titleRatio = resultWidth / titleWidth
if (titleRatio < 1) {
if(titleRatio < 1){
resultInfoTitle.style.transform = "scale(" + titleRatio + ", 1)"
}
if (subtitle) {
this.songSelect.ctx.font = (0.8 * 1.2 * fontSize) + "px " + strings.font
if(subtitle){
this.songSelect.ctx.font = (0.8 * 1.2 * fontSize) + "px " + strings.font
var subtitleWidth = this.songSelect.ctx.measureText(subtitle).width
var subtitleRatio = resultWidth / subtitleWidth
if (subtitleRatio < 1) {
if(subtitleRatio < 1){
resultInfoSubtitle.style.transform = "scale(" + subtitleRatio + ", 1)"
}
}
return resultDiv
}
highlightResult(text, result) {
if (text === null || text === undefined) return document.createDocumentFragment();
highlightResult(text, result){
var fragment = document.createDocumentFragment()
var ranges = (result ? result.ranges : null) || []
var lastIdx = 0
ranges.forEach(range => {
if (lastIdx !== range[0]) {
if(lastIdx !== range[0]){
fragment.appendChild(document.createTextNode(text.slice(lastIdx, range[0])))
}
var span = document.createElement("span")
@@ -373,91 +372,91 @@ class Search {
fragment.appendChild(span)
lastIdx = range[1] + 1
})
if (text.length !== lastIdx) {
if(text.length !== lastIdx){
fragment.appendChild(document.createTextNode(text.slice(lastIdx)))
}
return fragment
}
setActive(idx) {
setActive(idx){
this.songSelect.playSound("se_ka")
var active = this.div.querySelector(":scope .song-search-result-active")
if (active) {
if(active){
active.classList.remove("song-search-result-active")
}
if (idx === null) {
if(idx === null){
this.active = null
return
}
var el = this.results[idx]
this.input.blur()
el.classList.add("song-search-result-active")
this.scrollTo(el)
this.active = idx
}
display(fromButton = false) {
if (!this.enabled) {
display(fromButton=false){
if(!this.enabled){
return
}
if (this.opened) {
if(this.opened){
return this.remove(true)
}
this.opened = true
this.results = []
this.div = document.createElement("div")
this.div.innerHTML = assets.pages["search"]
this.container = this.div.querySelector(":scope #song-search-container")
if (this.touchEnabled) {
if(this.touchEnabled){
this.container.classList.add("touch-enabled")
}
pageEvents.add(this.container, ["mousedown", "touchstart"], this.onClick.bind(this))
this.input = this.div.querySelector(":scope #song-search-input")
this.input.setAttribute("placeholder", strings.search.searchInput)
pageEvents.add(this.input, ["input"], () => this.onInput())
this.songSelect.playSound("se_pause")
loader.screen.appendChild(this.div)
this.setTip()
cancelTouch = false
noResizeRoot = true
if (this.songSelect.songs[this.songSelect.selectedSong].courses) {
if(this.songSelect.songs[this.songSelect.selectedSong].courses){
snd.previewGain.setVolumeMul(0.5)
} else if (this.songSelect.bgmEnabled) {
}else if(this.songSelect.bgmEnabled){
snd.musicGain.setVolumeMul(0.5)
}
setTimeout(() => {
this.input.focus()
this.input.setSelectionRange(0, this.input.value.length)
}, 10)
var lastQuery = localStorage.getItem("lastSearchQuery")
if (lastQuery) {
if(lastQuery){
this.input.value = lastQuery
this.input.dispatchEvent(new Event("input", {
value: lastQuery
}))
}
}
remove(byUser = false) {
if (this.opened) {
remove(byUser=false){
if(this.opened){
this.opened = false
if (byUser) {
if(byUser){
this.songSelect.playSound("se_cancel")
}
pageEvents.remove(this.div.querySelector(":scope #song-search-container"),
["mousedown", "touchstart"])
["mousedown", "touchstart"])
pageEvents.remove(this.input, ["input"])
this.div.remove()
delete this.results
delete this.div
@@ -466,39 +465,39 @@ class Search {
delete this.active
cancelTouch = true
noResizeRoot = false
if (this.songSelect.songs[this.songSelect.selectedSong].courses) {
if(this.songSelect.songs[this.songSelect.selectedSong].courses){
snd.previewGain.setVolumeMul(1)
} else if (this.songSelect.bgmEnabled) {
}else if(this.songSelect.bgmEnabled){
snd.musicGain.setVolumeMul(1)
}
}
}
setTip(tip, error = false) {
if (this.tip) {
setTip(tip, error=false){
if(this.tip){
this.tip.remove()
delete this.tip
}
if (!tip) {
if(!tip){
tip = strings.search.tip + " " + strings.search.tips[Math.floor(Math.random() * strings.search.tips.length)]
}
var resultsDiv = this.div.querySelector(":scope #song-search-results")
resultsDiv.innerHTML = ""
this.results = []
this.tip = document.createElement("div")
this.tip.id = "song-search-tip"
this.tip.innerText = tip
this.div.querySelector(":scope #song-search").appendChild(this.tip)
if (error) {
if(error){
this.tip.classList.add("song-search-tip-error")
}
}
proceed(songId) {
proceed(songId){
if (/^-?\d+$/.test(songId)) {
songId = parseInt(songId)
}
@@ -506,91 +505,91 @@ class Search {
var song = this.songSelect.songs.find(song => song.id === songId)
this.remove()
this.songSelect.playBgm(false)
if (this.songSelect.previewing === "muted") {
if(this.songSelect.previewing === "muted"){
this.songSelect.previewing = null
}
var songIndex = this.songSelect.songs.findIndex(song => song.id === songId)
this.songSelect.setSelectedSong(songIndex)
this.songSelect.toSelectDifficulty()
}
scrollTo(element) {
scrollTo(element){
var parentNode = element.parentNode
var selected = element.getBoundingClientRect()
var parent = parentNode.getBoundingClientRect()
var scrollY = parentNode.scrollTop
var selectedPosTop = selected.top - selected.height / 2
if (Math.floor(selectedPosTop) < Math.floor(parent.top)) {
if(Math.floor(selectedPosTop) < Math.floor(parent.top)){
parentNode.scrollTop += selectedPosTop - parent.top
} else {
}else{
var selectedPosBottom = selected.top + selected.height * 1.5 - parent.top
if (Math.floor(selectedPosBottom) > Math.floor(parent.height)) {
if(Math.floor(selectedPosBottom) > Math.floor(parent.height)){
parentNode.scrollTop += selectedPosBottom - parent.height
}
}
}
parseRange(string) {
parseRange(string){
var range = string.split("-")
if (range.length == 1) {
if(range.length == 1){
var min = parseInt(range[0]) || 0
return min > 0 ? { min: min, max: min } : false
} else if (range.length == 2) {
return min > 0 ? {min: min, max: min} : false
} else if(range.length == 2){
var min = parseInt(range[0]) || 0
var max = parseInt(range[1]) || 0
return min > 0 && max > 0 ? { min: min, max: max } : false
return min > 0 && max > 0 ? {min: min, max: max} : false
}
}
indexesToRanges(indexes) {
indexesToRanges(indexes){
var ranges = []
var range
indexes.forEach(idx => {
if (range && range[1] === idx - 1) {
if(range && range[1] === idx - 1){
range[1] = idx
} else {
}else{
range = [idx, idx]
ranges.push(range)
}
})
return ranges
}
onInput(resize) {
onInput(resize){
var text = this.input.value
localStorage.setItem("lastSearchQuery", text)
text = text.toLowerCase()
if (text.length === 0) {
if (!resize) {
if(text.length === 0){
if(!resize){
this.setTip()
}
return
}
var new_results = this.perform(text)
if (new_results.length === 0) {
if(new_results.length === 0){
this.setTip(strings.search.noResults, true)
return
} else if (this.tip) {
}else if(this.tip){
this.tip.remove()
delete this.tip
}
var resultsDiv = this.div.querySelector(":scope #song-search-results")
resultsDiv.innerHTML = ""
this.results = []
var fontSize = parseFloat(getComputedStyle(this.div.querySelector(":scope #song-search")).fontSize.slice(0, -2))
var resultsWidth = parseFloat(getComputedStyle(resultsDiv).width.slice(0, -2))
var vmin = Math.min(innerWidth, lastHeight) / 100
var courseWidth = Math.min(3 * fontSize * 1.2, 7 * vmin)
var resultWidth = resultsWidth - 1.8 * fontSize - 0.8 * fontSize - (courseWidth + 0.4 * fontSize * 1.2) * 5 - 0.6 * fontSize
this.songSelect.ctx.save()
var fragment = document.createDocumentFragment()
new_results.forEach(result => {
var result = this.createResult(result, resultWidth, fontSize)
@@ -598,78 +597,78 @@ class Search {
this.results.push(result)
})
resultsDiv.appendChild(fragment)
this.songSelect.ctx.restore()
}
onClick(e) {
if ((e.target.id === "song-search-container" || e.target.id === "song-search-close") && e.which === 1) {
onClick(e){
if((e.target.id === "song-search-container" || e.target.id === "song-search-close") && e.which === 1){
this.remove(true)
} else if (e.which === 1) {
}else if(e.which === 1){
var songEl = e.target.closest(".song-search-result")
if (songEl) {
if(songEl){
var songId = songEl.dataset.songId
this.proceed(songId)
}
}
}
keyPress(pressed, name, event, repeat, ctrl) {
if (name === "back" || (event && event.keyCode && event.keyCode === 70 && ctrl)) {
keyPress(pressed, name, event, repeat, ctrl){
if(name === "back" || (event && event.keyCode && event.keyCode === 70 && ctrl)) {
this.remove(true)
if (event) {
if(event){
event.preventDefault()
}
} else if (name === "down" && this.results.length) {
if (this.input == document.activeElement && this.results) {
}else if(name === "down" && this.results.length){
if(this.input == document.activeElement && this.results){
this.setActive(0)
} else if (this.active === this.results.length - 1) {
}else if(this.active === this.results.length - 1){
this.setActive(null)
this.input.focus()
} else if (Number.isInteger(this.active)) {
}else if(Number.isInteger(this.active)){
this.setActive(this.active + 1)
} else {
}else{
this.setActive(0)
}
} else if (name === "up" && this.results.length) {
if (this.input == document.activeElement && this.results) {
}else if(name === "up" && this.results.length){
if(this.input == document.activeElement && this.results){
this.setActive(this.results.length - 1)
} else if (this.active === 0) {
}else if(this.active === 0){
this.setActive(null)
this.input.focus()
setTimeout(() => {
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
}, 0)
} else if (Number.isInteger(this.active)) {
}else if(Number.isInteger(this.active)){
this.setActive(this.active - 1)
} else {
}else{
this.setActive(this.results.length - 1)
}
} else if (name === "confirm") {
if (Number.isInteger(this.active)) {
}
}else if(name === "confirm"){
if(Number.isInteger(this.active)){
this.proceed(this.results[this.active].dataset.songId)
} else {
}else{
this.onInput()
if (event.keyCode === 13 && this.songSelect.touchEnabled) {
if(event.keyCode === 13 && this.songSelect.touchEnabled){
this.input.blur()
}
}
}
}
redraw() {
if (this.opened && this.container) {
redraw(){
if(this.opened && this.container){
var vmin = Math.min(innerWidth, lastHeight) / 100
if (this.vmin !== vmin) {
if(this.vmin !== vmin){
this.container.style.setProperty("--vmin", vmin + "px")
this.vmin = vmin
}
} else {
}else{
this.vmin = null
}
}
clean() {
clean(){
loader.screen.removeChild(this.style)
fuzzysort.cleanup()
delete this.container

View File

@@ -119,6 +119,7 @@ class SongSelect {
this.font = strings.font
this.search = new Search(this)
this.leaderboard = new LeaderboardUI(this)
this.songs = []
for (let song of assets.songs) {
@@ -298,6 +299,12 @@ class SongSelect {
iconName: "back",
iconFill: "#f7d39c",
letterSpacing: 4
}, {
text: strings.leaderboard,
fill: "#42c0d2",
iconName: "crown",
iconFill: "#8ee7f2",
letterSpacing: 0
}, {
text: strings.songOptions,
fill: "#b2e442",
@@ -310,12 +317,6 @@ 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]
@@ -590,15 +591,13 @@ class SongSelect {
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 === 3) {
this.toDownload()
} else if (this.selectedDiff === 4) {
this.toDelete()
} else if (false) {
// Moved above
} else if (this.selectedDiff === 2) {
this.toOptions(1)
} else {
this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl)
}
@@ -651,6 +650,14 @@ class SongSelect {
if (event.target !== this.canvas || !this.redrawRunning) {
return
}
if (this.leaderboard && this.leaderboard.visible) {
if (event.type === "mousedown") {
var x = event.offsetX
var y = event.offsetY
this.leaderboard.mouseDown(x, y)
}
return
}
if (event.type === "mousedown") {
if (event.which !== 1) {
return
@@ -699,13 +706,13 @@ class SongSelect {
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 === 3) {
this.toDownload()
} else if (moveBy === 4) {
this.toDelete()
} else if (moveBy === 2) {
this.toOptions(1)
} else if (moveBy === "maker") {
window.open(this.songs[this.selectedSong].maker.url)
} else if (moveBy === this.diffOptions.length + 4) {
@@ -2714,6 +2721,10 @@ class SongSelect {
if (screen === "titleFadeIn") {
ctx.save()
if (this.leaderboard && this.leaderboard.visible) {
this.leaderboard.draw(ctx, winW, winH, this.pixelRatio)
}
var elapsed = ms - this.state.screenMS
ctx.globalAlpha = Math.max(0, 1 - elapsed / 500)
ctx.fillStyle = "#000"
@@ -3037,6 +3048,99 @@ class SongSelect {
this.search.enabled = true
}
}
toLeaderboard() {
var song = this.songs[this.selectedSong]
if (!song) {
return
}
var songId = song.id
if (song.hash && typeof songId === 'string' && isNaN(parseInt(songId))) {
// If key is string and not numeric, it might be hash, or backend expects numeric ID
// Ideally we pass hash if available, or ID
// Our backend implementation handles both, but prefers ID or hash
}
// Actually leaderboard API expects song_id to be what is stored in DB.
// For custom songs it uses hash? No, custom songs are local.
// Let's pass song.id by default.
// Determine difficulty ID
// difficultyId array: ["easy", "normal", "hard", "oni", "ura"]
// But in menu we select difficulty by pressing right/left which updates selectedDiff
// When we click "Leaderboard" button, we are NOT yet on a specific difficulty?
// Wait, the leaderboard button is in the difficulty selection SCREEN.
// BUT the button itself is an ACTION, like "Download" or "Back".
// It doesn't select a difficulty.
// However, to show a leaderboard, we need a difficulty.
// Should the leaderboard UI allow switching difficulties?
// OR does the leaderboard button show the leaderboard for the CURRENTLY HIGHLIGHTED difficulty?
// In Taiko Web, the difficulty selection screen creates vertical bars for each difficulty.
// User navigates LEFT/RIGHT to select a difficulty or an option (Back, Custom, Download..).
// So if user selects "Leaderboard" option, which difficulty is selected? None!
// "Leaderboard" is a menu item parallel to "Easy", "Normal", "Hard"...
// NO!
// `this.diffOptions` are valid selections in `selectedDiff`.
// `selectedDiff` 0..N are options efficiently.
// The difficulties are further to the right.
// `moveBy = selectedDiff - diffOptions.length`.
// So if I select "Leaderboard" (index 1), I am NOT selecting a song difficulty.
// So `toLeaderboard` needs to know which difficulty to show?
// Maybe default to Oni? Or show a difficulty selector INSIDE the leaderboard UI?
// OR, change the UX: don't make it a separate main menu item.
// Make it a small button accompanying EACH difficulty?
//
// In my plan: "In songselect.js ... add 'Leaderboard' button to diffOptions".
// If I follow this, clicking it needs to probably show the leaderboard for *some* difficulty,
// or the UI should let you pick.
// `LeaderboardUI` supports `show(songId, difficulty)`.
// Let's make it show "Oni" by default if available, or the highest available.
// And add tabs in LeaderboardUI to switch difficulty?
// Or, simpler: Just show Oni for now, or last played.
//
// Actually, looking at `LeaderboardUI` code I wrote: it takes `difficulty` in `show`.
// I did NOT implement tabs in `LeaderboardUI`.
//
// Alternative idea:
// When user hovers/selects a difficulty (e.g. Oni), maybe press a key to see leaderboard?
// But the requirement was "Add a Leaderboard option".
//
// Let's check how "Download" works. It downloads the current song.
// "Song Options" opens a menu.
//
// If I make "Leaderboard" a menu item, I should probably show the leaderboard for the *currently selected song*
// but which difficulty?
// Let's default to Oni.
// Better: The leaderboard UI should have difficulty tabs.
// Since I can't easily redesign `LeaderboardUI` in this tool call (it's already written),
// I will stick to showing *Oni* (or highest available) for now,
// AND maybe I can quickly patch `LeaderboardUI` later to add tabs if needed.
//
// Wait, `songselect.js` logic:
// `this.selectedDiff` tracks what is selected.
// If > `diffOptions.length`, it is a specific difficulty.
// If I am on "Leaderboard" button, I am NOT on a difficulty.
//
// Let's try to pass 'oni' by default.
var diff = "oni"
if (this.state.ura) {
diff = "ura"
}
// Check if song has oni
if (!song.courses[diff]) {
// Find highest
var diffs = ["ura", "oni", "hard", "normal", "easy"]
for (var d of diffs) {
if (song.courses[d]) {
diff = d
break
}
}
}
this.leaderboard.show(song.id, diff)
}
onsongsel(response) {
if (response && response.value) {
var selected = false
@@ -3158,6 +3262,13 @@ class SongSelect {
return Date.now()
}
wheel(event) {
if (this.leaderboard && this.leaderboard.visible) {
this.leaderboard.wheel(event.deltaY)
return
}
}
clean() {
this.keyboard.clean()
this.gamepad.clean()
@@ -3334,25 +3445,4 @@ class SongSelect {
alert(text);
});
}
toLeaderboard() {
var song = this.songs[this.selectedSong]
var songId = song.id || song.hash
// Allow leaderboard for any song with an ID (numeric or hash)
if (!songId) {
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(songId, diffId)
}
}

View File

@@ -28,7 +28,7 @@ var translations = {
tw: "zh-Hant",
ko: "ko"
},
taikoWeb: {
ja: "たいこウェブ",
en: "Taiko Web",
@@ -438,7 +438,78 @@ var translations = {
tw: "連打數",
ko: "연타 횟수"
},
leaderboard: {
ja: "ランキング",
en: "Leaderboard",
cn: "排行榜",
tw: "排行榜",
ko: "순위표"
},
leaderboardTitle: {
ja: "%s の ランキング",
en: "%s Leaderboard",
cn: "%s 排行榜",
tw: "%s 排行榜",
ko: "%s 순위표"
},
rank: {
ja: "順位",
en: "Rank",
cn: "排名",
tw: "排名",
ko: "순위"
},
playerName: {
ja: "プレイヤー",
en: "Player",
cn: "玩家",
tw: "玩家",
ko: "플레이어"
},
score: {
ja: "スコア",
en: "Score",
cn: "分数",
tw: "分數",
ko: "점수"
},
recordBroken: {
ja: "最高記録を更新!第%s位",
en: "New Record! Rank #%s",
cn: "打破最佳纪录 第%s名",
tw: "打破最佳紀錄 第%s名",
ko: "최고 기록 경신! %s위"
},
yourRank: {
ja: "あなたの順位: %s位",
en: "Your Rank: #%s",
cn: "您的排名: 第%s名",
tw: "您的排名: 第%s名",
ko: "순위: %s위"
},
notRanked: {
ja: "ランク外",
en: "Not Ranked",
cn: "未上榜",
tw: "未上榜",
ko: "순위 없음"
},
loadingLeaderboard: {
ja: "ランキング読み込み中...",
en: "Loading Leaderboard...",
cn: "加载排行榜中...",
tw: "讀取排行榜中...",
ko: "순위표 로딩 중..."
},
noScores: {
ja: "記録なし",
en: "No Scores Yet",
cn: "暂无记录",
tw: "暫無紀錄",
ko: "기록 없음"
},
errorOccured: {
ja: "エラーが発生しました。再読み込みしてください。",
en: "An error occurred, please refresh",
@@ -879,7 +950,7 @@ var translations = {
en: "Audio Latency Calibration",
tw: "聲音延遲校正",
ko: "오디오 레이턴시 조절"
},
content: {
ja: "背景で鳴っている音を聴いてみましょう。\n\n音が聞こえたら、太鼓の面%sまたは%sをたたこう",
@@ -1472,26 +1543,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)
}
}

View File

@@ -1,3 +0,0 @@
<div id="leaderboard">
<canvas id="leaderboard-canvas"></canvas>
</div>

View File

@@ -1,133 +0,0 @@
import requests
import json
import base64
BASE_URL = "http://localhost:34801"
def register_user(username, password):
url = f"{BASE_URL}/api/register"
data = {"username": username, "password": password}
try:
response = requests.post(url, json=data)
if response.status_code == 200:
return response.json()
print(f"Register failed: {response.text}")
except Exception as e:
print(f"Register error: {e}")
return None
def login_user(username, password):
url = f"{BASE_URL}/api/login"
data = {"username": username, "password": password}
session = requests.Session()
try:
response = session.post(url, json=data)
if response.status_code == 200:
return session
print(f"Login failed: {response.text}")
except Exception as e:
print(f"Login error: {e}")
return None
def get_csrf_token(session):
url = f"{BASE_URL}/api/csrftoken"
response = session.get(url)
if response.status_code == 200:
return response.json()['token']
return None
def submit_score(session, song_id, difficulty, score_obj, csrf_token):
url = f"{BASE_URL}/api/leaderboard/submit"
headers = {"X-CSRFToken": csrf_token}
data = {
"song_id": song_id,
"difficulty": difficulty,
"score": score_obj
}
response = session.post(url, json=data, headers=headers)
return response.json()
def get_leaderboard(session, song_id, difficulty):
url = f"{BASE_URL}/api/leaderboard/get"
params = {"song_id": song_id, "difficulty": difficulty}
response = session.get(url, params=params)
return response.json()
def main():
username = "debug_user_1"
password = "password123"
print("1. Registering/Logging in...")
# Try login first
session = login_user(username, password)
if not session:
# Register
reg = register_user(username, password)
if reg:
session = login_user(username, password)
else:
print("Could not register or login")
return
print("2. Getting CSRF token...")
csrf_token = get_csrf_token(session)
if not csrf_token:
print("Could not get CSRF token")
return
song_id = 1 # Assuming song 1 exists
difficulty = "oni"
# Simulate scoresheet.js submission (points only)
score_obj = {
"points": 123456,
"good": 100,
"ok": 10,
"bad": 0,
"maxCombo": 110,
"drumroll": 5,
"crown": "gold"
}
print(f"3. Submitting score: {score_obj['points']}")
submit_res = submit_score(session, song_id, difficulty, score_obj, csrf_token)
print(f"Submit response: {submit_res}")
print("4. Fetching leaderboard...")
leaderboard_res = get_leaderboard(session, song_id, difficulty)
if leaderboard_res['status'] == 'ok':
leaderboard = leaderboard_res['leaderboard']
found = False
for entry in leaderboard:
if entry['username'] == username:
found = True
print(f"Found entry: {entry}")
print(f"entry.score_value: {entry.get('score_value')}")
print(f"entry.score: {entry.get('score')}")
score_value = entry.get('score_value')
score_data = entry.get('score')
# Check what leaderboard.js would see
if score_data and 'score' in score_data:
print(f"JS would assume score: {score_data['score']}")
else:
print("JS would assume score: 0 (undefined)")
if score_value == 123456:
print("SUCCESS: score_value is correct.")
else:
print(f"FAILURE: score_value is incorrect (expected 123456, got {score_value})")
if score_data and score_data.get('points') == 123456:
print("SUCCESS: points preserved in score object.")
if not found:
print("Entry not found in leaderboard (maybe not top 50?)")
else:
print(f"Failed to get leaderboard: {leaderboard_res}")
if __name__ == "__main__":
main()

View File

@@ -85,10 +85,24 @@ leaderboard_submit = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': ['number', 'string']},
'difficulty': {'type': 'string'},
'score': {'type': 'object'}
'song_id': {'type': ['integer', 'string']},
'difficulty': {'type': 'string', 'enum': ['easy', 'normal', 'hard', 'oni', 'ura']},
'score': {'type': 'integer'},
'good': {'type': 'integer'},
'ok': {'type': 'integer'},
'bad': {'type': 'integer'},
'maxCombo': {'type': 'integer'},
'hash': {'type': 'string'}
},
'required': ['song_id', 'difficulty', 'score']
'required': ['difficulty', 'score']
}
leaderboard_get = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': ['integer', 'string']},
'difficulty': {'type': 'string'}
},
'required': ['song_id', 'difficulty']
}

View File

@@ -8,7 +8,7 @@ CODENAME=${VERSION_CODENAME:-}
VERSION=${VERSION_ID:-}
echo "更新系统软件源..."
apt-get update -y || true
apt-get update -y
echo "安装基础依赖..."
apt-get install -y python3 python3-venv python3-pip git ffmpeg rsync curl gnupg libcap2-bin

View File

@@ -1,20 +1,17 @@
<!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="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="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="robots" content="notranslate">
<meta name="robots" content="noimageindex">
<meta name="color-scheme" content="only light">
<link rel="canonical" href="https://taikoapp.uk/" />
<link rel="canonical" href="https://taikoapp.uk/" />
<link rel="stylesheet" href="src/css/loader.css?{{version.commit_short}}">
<link rel="manifest" href="manifest.json">
@@ -27,17 +24,14 @@
<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.1.0">vLightNova 1.1.0</a>
<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>
{% else %}
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.1.0">vLightNova
1.1.0</a>
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
{% endif %}
</div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
@@ -49,5 +43,4 @@
</div>
</noscript>
</body>
</html>
</html>

View File

@@ -1,32 +0,0 @@
// Simulation of song objects
const songs = [
{ name: "Server Song", id: 123, hash: "hash123" },
{ name: "Custom Song (No ID)", id: undefined, hash: "hashCustom" },
{ name: "Custom Song (Null ID)", id: null, hash: "hashNull" },
{ name: "Edge Case (ID 0)", id: 0, hash: "hashZero" } // Checking if behavior is acceptable
];
console.log("=== Frontend Logic Test: Song ID Fallback ===");
songs.forEach(song => {
// Logic from scoresheet.js / songselect.js
const songId = song.id || song.hash;
console.log(`Song: ${song.name}`);
console.log(` Raw ID: ${song.id}, Hash: ${song.hash}`);
console.log(` Resolved songId: ${songId}`);
if (song.id && songId === song.id) {
console.log(" ✅ Correctly used ID");
} else if (!song.id && songId === song.hash) {
console.log(" ✅ Correctly fell back to Hash");
} else if (song.id === 0 && songId === song.hash) {
console.log(" ⚠️ ID was 0, used Hash. (Acceptable if ID > 0 always)");
} else {
console.log(" ❌ FAILED logic mismatch");
}
console.log("---");
});
console.log("Tests Completed.");

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import os
import sys
from datetime import datetime
from pymongo import MongoClient
# Add project root to path
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
try:
import config
except ImportError:
# Handle case where config might not be in path or environment variables are used
config = None
def reset_leaderboard():
"""Delete leaderboard entries not from the current month"""
mongo_host = os.environ.get("TAIKO_WEB_MONGO_HOST")
if not mongo_host and config:
mongo_host = config.MONGO['host']
db_name = "taiko"
if config:
db_name = config.MONGO['database']
if not mongo_host:
print("Error: content not found for MONGO_HOST")
return
client = MongoClient(host=mongo_host)
db = client[db_name]
current_month = datetime.now().strftime('%Y-%m')
# Delete old month data
result = db.leaderboard.delete_many({'month': {'$ne': current_month}})
print(f"Deleted {result.deleted_count} old leaderboard entries")
print(f"Current month: {current_month}")
client.close()
if __name__ == '__main__':
reset_leaderboard()