25 Commits

Author SHA1 Message Date
e52baf2555 Fix leaderboard submission display issue: update frontend to correctly handle score vs points 2026-01-17 21:22:34 +08:00
45d6b1d9de Fix: Backend score parsing (points vs score) and Frontend songselect ID fallback 2026-01-17 21:03:09 +08:00
bb3ad834b2 Fix: Add robust leaderboard submission fallback and fix search.js crash 2026-01-17 20:45:13 +08:00
addd9d90f0 Fix: Use hash as fallback when song ID is not available 2026-01-17 20:30:03 +08:00
69b92b34d8 Feature: Manual leaderboard submission button on result screen with notification 2026-01-17 20:14:11 +08:00
9935d70e31 Fix: Support both numeric and hash song IDs for leaderboard 2026-01-17 19:56:41 +08:00
3f7ff13ef7 Fix: Only allow leaderboard for server songs with numeric IDs 2026-01-17 19:46:24 +08:00
0706f99427 Fix: Validate songId as number and add user notification for leaderboard submission 2026-01-17 19:29:36 +08:00
9bd2b21d44 Fix: Ignore apt-get update failure in setup script 2026-01-17 19:17:13 +08:00
b15752e051 Fix: Initialize leaderboard canvas after page is loaded 2026-01-17 19:07:52 +08:00
84d15b70c6 Fix: Add leaderboard.js and leaderboard.html to assets loading list 2026-01-17 18:57:02 +08:00
271fc52e82 Enhanced leaderboard UI with gradients, glows, medals and modern design 2026-01-15 23:54:29 +08:00
1038fc85b9 Add auto-submit to leaderboard and argparse support for app.py 2026-01-15 23:51:01 +08:00
76a3d52098 Fix critical bugs: touchEnabled undefined, MongoDB score sorting with score_value field 2026-01-15 23:39:12 +08:00
d6a1b6bd41 Add leaderboard feature with monthly reset and top 50 limit, update version to 1.1.0 2026-01-15 23:32:43 +08:00
taiko-web
6d7be5c45c initial import 2026-01-01 21:31:54 +08:00
92c1261f6f feat: 中文曲名优先显示 TITLEZH,其次 TITLEJA;版本号改为 vLightNova 1.0.0 2025-12-14 12:49:20 +08:00
9a2a7dbee6 chore: push pending changes 2025-12-06 20:02:43 +08:00
f91e3c9089 feat: support TITLEJA/TITLE by language 2025-11-30 16:50:45 +08:00
84a0c2b7e0 fix(songselect): category jump fallback to song_type when category missing 2025-11-23 13:07:17 +08:00
9f2d753500 docs: add 全命令部署(Docker MongoDB) 2025-11-23 08:18:00 +08:00
a77534c72b feat(type): 新增歌曲类型模型与校验;/api/songs 支持按类型过滤;上传页增加类型选择;歌曲选择页支持左右切换类型并显示标签;README 补充说明 2025-11-22 23:25:10 +08:00
1ca7a3f610 fix(upload): DuplicateKey 使用 更新,避免替换触发 _id 不可变错误 (code 66) 2025-11-22 22:33:50 +08:00
4da81d16dc fix(upload): 处理重复 ID,出现 DuplicateKey 时替换现有文档并启用 2025-11-22 22:29:50 +08:00
3d611a9c46 fix(upload): 使用绝对路径和父目录创建,避免 public/songs 不存在导致上传失败 2025-11-22 22:27:11 +08:00
21 changed files with 2779 additions and 1300 deletions

View File

@@ -0,0 +1,9 @@
I will implement the requested changes to remove the delete functionality and redirect the upload interface while keeping the upload API intact.
### 1. Frontend: Song Selection Menu (`public/src/js/songselect.js`)
* **Remove Delete Button**: I will remove the "Delete" (削除) button configuration from the `difficultyMenu` buttons array (around lines 313-319). This removes the option from the UI.
* **Redirect Upload Action**: I will modify the handler for the "upload" action (around lines 954-958). Instead of redirecting to the local `/upload/` page, it will redirect to `https://zizhipu.taiko.asia`.
### 2. Backend: API Security (`app.py`)
* **Disable Delete API**: I will modify the `/api/delete` route to return a 403 Forbidden error (or simply pass), ensuring that songs cannot be deleted even if someone calls the API directly.
* **Keep Upload API**: The `/api/upload` route will remain unchanged, preserving the ability to upload songs via API as requested.

View File

@@ -33,6 +33,55 @@ http://<服务器IP>/
- 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80` - 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80`
- 开放防火墙 `80/tcp`(如系统启用了 `ufw` - 开放防火墙 `80/tcp`(如系统启用了 `ufw`
## 全命令部署(使用 Docker 部署 MongoDB
适用系统Ubuntu 20.04+/22.04+/24.04(需 rootMongoDB 通过 Docker 启动,其余步骤照常。
1. 安装 Docker 并启动:
```bash
sudo apt update
sudo apt install -y docker.io
sudo systemctl enable --now docker
```
2. 启动 MongoDB 容器(持久化到 `/srv/taiko-web-mongo`,监听 `27017`
```bash
sudo mkdir -p /srv/taiko-web-mongo
sudo docker run -d \
--name taiko-web-mongo \
--restart unless-stopped \
-v /srv/taiko-web-mongo:/data/db \
-p 27017:27017 \
mongo:6
```
如需开启认证,可加上:
```bash
-e MONGO_INITDB_ROOT_USERNAME=<用户名> -e MONGO_INITDB_ROOT_PASSWORD=<强密码>
```
并在应用侧通过环境变量指定 Host
```bash
export TAIKO_WEB_MONGO_HOST=127.0.0.1:27017
```
3. 安装并启动 Redis照常
```bash
sudo apt install -y redis-server
sudo systemctl enable --now redis-server
```
4. 准备项目与虚拟环境(照常):
```bash
sudo mkdir -p /srv/taiko-web
sudo rsync -a --delete --exclude '.git' --exclude '.venv' . /srv/taiko-web/
sudo python3 -m venv /srv/taiko-web/.venv
sudo /srv/taiko-web/.venv/bin/pip install -U pip
sudo /srv/taiko-web/.venv/bin/pip install -r /srv/taiko-web/requirements.txt
sudo cp /srv/taiko-web/config.example.py /srv/taiko-web/config.py
```
5. 赋予 80 端口绑定权限并启动:
```bash
sudo setcap 'cap_net_bind_service=+ep' /srv/taiko-web/.venv/bin/python3
export TAIKO_WEB_MONGO_HOST=${TAIKO_WEB_MONGO_HOST:-127.0.0.1:27017}
sudo /srv/taiko-web/.venv/bin/gunicorn -b 0.0.0.0:80 app:app
```
## 手动部署(可选) ## 手动部署(可选)
1. 安装依赖: 1. 安装依赖:
@@ -94,3 +143,30 @@ docker run --detach \
--- ---
如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。 如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。
## 歌曲类型Type
- 可选枚举:
- 01 Pop
- 02 Anime
- 03 Vocaloid
- 04 Children and Folk
- 05 Variety
- 06 Classical
- 07 Game Music
- 08 Live Festival Mode
- 09 Namco Original
- 10 Taiko Towers
- 11 Dan Dojo
### 上传要求
- 上传表单新增必填字段 `song_type`,取值为上述枚举之一
- 成功后将写入 MongoDB `songs.song_type`
### API 扩展
- `GET /api/songs?type=<歌曲类型>` 按类型过滤返回启用歌曲
- 示例:`/api/songs?type=02%20Anime`
- 返回项包含 `song_type` 字段
### 前端切换
- 在歌曲选择页顶部显示当前歌曲类型标签
- 使用左右跳转Shift+左右或肩键)自动切换类型并刷新列表

225
app.py
View File

@@ -33,6 +33,7 @@ from flask_session import Session
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
from ffmpy import FFmpeg from ffmpy import FFmpeg
from pymongo import MongoClient from pymongo import MongoClient
from pymongo.errors import DuplicateKeyError
from redis import Redis from redis import Redis
def take_config(name, required=False): def take_config(name, required=False):
@@ -44,6 +45,19 @@ def take_config(name, required=False):
return None return None
app = Flask(__name__) app = Flask(__name__)
SONG_TYPES = [
"01 Pop",
"02 Anime",
"03 Vocaloid",
"04 Children and Folk",
"05 Variety",
"06 Classical",
"07 Game Music",
"08 Live Festival Mode",
"09 Namco Original",
"10 Taiko Towers",
"11 Dan Dojo",
]
def get_remote_address() -> str: def get_remote_address() -> str:
return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1" return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1"
@@ -89,7 +103,11 @@ sess.init_app(app)
db = client[take_config('MONGO', required=True)['database']] db = client[take_config('MONGO', required=True)['database']]
db.users.create_index('username', unique=True) db.users.create_index('username', unique=True)
db.songs.create_index('id', unique=True) db.songs.create_index('id', unique=True)
db.songs.create_index('song_type')
db.scores.create_index('username') 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): class HashException(Exception):
@@ -479,7 +497,13 @@ def route_api_preview():
@app.route(basedir + 'api/songs') @app.route(basedir + 'api/songs')
@app.cache.cached(timeout=15) @app.cache.cached(timeout=15)
def route_api_songs(): def route_api_songs():
songs = list(db.songs.find({'enabled': True}, {'_id': False, 'enabled': False})) type_q = flask.request.args.get('type')
query = {'enabled': True}
if type_q:
if type_q not in SONG_TYPES:
return abort(400)
query['song_type'] = type_q
songs = list(db.songs.find(query, {'_id': False, 'enabled': False}))
for song in songs: for song in songs:
if song['maker_id']: if song['maker_id']:
if song['maker_id'] == 0: if song['maker_id'] == 0:
@@ -725,6 +749,167 @@ def route_api_scores_get():
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don}) 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
# 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')
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)
# 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({
'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') @app.route(basedir + 'privacy')
def route_api_privacy(): def route_api_privacy():
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt'))) last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
@@ -852,17 +1037,32 @@ def upload_file():
db_entry['enabled'] = True db_entry['enabled'] = True
pprint.pprint(db_entry) pprint.pprint(db_entry)
# mongoDBにデータをぶち込む # 必要な歌曲类型
client['taiko']["songs"].insert_one(db_entry) song_type = flask.request.form.get('song_type')
if not song_type or song_type not in SONG_TYPES:
return flask.jsonify({'error': 'invalid_song_type'})
db_entry['song_type'] = song_type
# mongoDBにデータをぶち込む重複IDは部分更新で上書きし、_id を不変に保つ)
coll = client['taiko']["songs"]
try:
coll.insert_one(db_entry)
except DuplicateKeyError:
coll.update_one({"id": db_entry["id"]}, {"$set": db_entry}, upsert=True)
# キャッシュ削除(/api/songs # キャッシュ削除(/api/songs
try: try:
app.cache.delete_memoized(route_api_songs) app.cache.delete_memoized(route_api_songs)
except Exception: except Exception:
pass pass
# ディレクトリを作成 base_env = os.getenv("TAIKO_WEB_SONGS_DIR")
target_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs")) / generated_id if base_env:
target_dir.mkdir(parents=True,exist_ok=True) base_dir = pathlib.Path(base_env)
else:
base_dir = pathlib.Path(__file__).resolve().parent / "public" / "songs"
base_dir.mkdir(parents=True, exist_ok=True)
target_dir = base_dir / generated_id
target_dir.mkdir(parents=True, exist_ok=True)
# TJAを保存 # TJAを保存
(target_dir / "main.tja").write_bytes(tja_data) (target_dir / "main.tja").write_bytes(tja_data)
@@ -875,19 +1075,8 @@ def upload_file():
return flask.jsonify({'success': True}) return flask.jsonify({'success': True})
@app.route("/api/delete", methods=["POST"]) @app.route("/api/delete", methods=["POST"])
@limiter.limit("1 per day")
def delete(): def delete():
id = flask.request.get_json().get('id') return flask.jsonify({ "success": False, "reason": "Deletion is disabled" }), 403
client["taiko"]["songs"].delete_one({ "id": id })
parent_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs"))
target_dir = parent_dir / id
if not (target_dir.resolve().parents and parent_dir.resolve() in target_dir.resolve().parents):
return flask.jsonify({ "success": False, "reason": "PARENT IS NOT ALLOWED" })
shutil.rmtree(target_dir)
return "成功しました。"
if __name__ == '__main__': if __name__ == '__main__':
import argparse import argparse

Binary file not shown.

201
local_debug.py Normal file
View File

@@ -0,0 +1,201 @@
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

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

View File

@@ -7,6 +7,7 @@ var assets = {
"titlescreen.js", "titlescreen.js",
"scoresheet.js", "scoresheet.js",
"songselect.js", "songselect.js",
"leaderboard.js",
"keyboard.js", "keyboard.js",
"gameinput.js", "gameinput.js",
"game.js", "game.js",
@@ -98,7 +99,7 @@ var assets = {
"audioSfx": [ "audioSfx": [
"se_pause.ogg", "se_pause.ogg",
"se_calibration.ogg", "se_calibration.ogg",
"v_results.ogg", "v_results.ogg",
"v_sanka.ogg", "v_sanka.ogg",
"v_songsel.ogg", "v_songsel.ogg",
@@ -112,14 +113,14 @@ var assets = {
"se_don.ogg", "se_don.ogg",
"se_ka.ogg", "se_ka.ogg",
"se_jump.ogg", "se_jump.ogg",
"se_balloon.ogg", "se_balloon.ogg",
"se_gameclear.ogg", "se_gameclear.ogg",
"se_gamefail.ogg", "se_gamefail.ogg",
"se_gamefullcombo.ogg", "se_gamefullcombo.ogg",
"se_results_countup.ogg", "se_results_countup.ogg",
"se_results_crown.ogg", "se_results_crown.ogg",
"v_fullcombo.ogg", "v_fullcombo.ogg",
"v_renda.ogg", "v_renda.ogg",
"v_results_fullcombo.ogg", "v_results_fullcombo.ogg",
@@ -140,6 +141,7 @@ var assets = {
}, },
"views": [ "views": [
"game.html", "game.html",
"leaderboard.html",
"loadsong.html", "loadsong.html",
"songselect.html", "songselect.html",
"titlescreen.html", "titlescreen.html",
@@ -153,7 +155,7 @@ var assets = {
"customsongs.html", "customsongs.html",
"search.html" "search.html"
], ],
"songs": [], "songs": [],
"sounds": {}, "sounds": {},
"image": {}, "image": {},

View File

@@ -1,4 +1,4 @@
class ImportSongs{ class ImportSongs{
constructor(...args){ constructor(...args){
this.init(...args) this.init(...args)
} }
@@ -322,6 +322,10 @@
songTitle = songTitle.slice(0, uraPos) songTitle = songTitle.slice(0, uraPos)
} }
} }
if(id === "cn" && !meta["titlecn"] && meta.titlezh){
titleLang.cn = meta.titlezh
titleLangAdded = true
}
if(meta["title" + id]){ if(meta["title" + id]){
titleLang[id] = meta["title" + id] titleLang[id] = meta["title" + id]
titleLangAdded = true titleLangAdded = true
@@ -329,6 +333,10 @@
titleLang[id] = this.songTitle[songTitle][id] + ura titleLang[id] = this.songTitle[songTitle][id] + ura
titleLangAdded = true titleLangAdded = true
} }
if(id === "cn" && !meta["subtitlecn"] && meta.subtitlezh){
subtitleLang.cn = meta.subtitlezh
subtitleLangAdded = true
}
if(meta["subtitle" + id]){ if(meta["subtitle" + id]){
subtitleLang[id] = meta["subtitle" + id] subtitleLang[id] = meta["subtitle" + id]
subtitleLangAdded = true subtitleLangAdded = true

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,6 +16,21 @@
<label for="file_music">音楽ファイル:</label> <label for="file_music">音楽ファイル:</label>
<input type="file" name="file_music" accept=".ogg,.mp3,.wav" required> <input type="file" name="file_music" accept=".ogg,.mp3,.wav" required>
<label for="song_type">曲のタイプ:</label>
<select name="song_type" required>
<option value="01 Pop">01 Pop</option>
<option value="02 Anime">02 Anime</option>
<option value="03 Vocaloid">03 Vocaloid</option>
<option value="04 Children and Folk">04 Children and Folk</option>
<option value="05 Variety">05 Variety</option>
<option value="06 Classical">06 Classical</option>
<option value="07 Game Music">07 Game Music</option>
<option value="08 Live Festival Mode">08 Live Festival Mode</option>
<option value="09 Namco Original">09 Namco Original</option>
<option value="10 Taiko Towers">10 Taiko Towers</option>
<option value="11 Dan Dojo">11 Dan Dojo</option>
</select>
</form> </form>
<button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button> <button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button>

133
reproduce_issue.py Normal file
View File

@@ -0,0 +1,133 @@
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

@@ -80,3 +80,15 @@ scores_save = {
} }
} }
} }
leaderboard_submit = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': ['number', 'string']},
'difficulty': {'type': 'string'},
'score': {'type': 'object'}
},
'required': ['song_id', 'difficulty', 'score']
}

View File

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

View File

@@ -1,17 +1,20 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title> <title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png"> <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="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="description"
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹"> 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="notranslate">
<meta name="robots" content="noimageindex"> <meta name="robots" content="noimageindex">
<meta name="color-scheme" content="only light"> <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="stylesheet" href="src/css/loader.css?{{version.commit_short}}">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
@@ -24,14 +27,17 @@
<script src="src/js/lib/jszip.js"></script> <script src="src/js/lib/jszip.js"></script>
</head> </head>
<body> <body>
<div id="assets"></div> <div id="assets"></div>
<div id="screen" class="pattern-bg"></div> <div id="screen" class="pattern-bg"></div>
<div data-nosnippet id="version"> <div data-nosnippet id="version">
{% if version.version and version.commit_short and version.commit %} {% 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="taiko-web ver.{{version.version}} ({{version.commit_short}})">taiko-web ver.{{version.version}} ({{version.commit_short}})</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 %} {% else %}
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="taiko-web vRAINBOW-BETA4">taiko-web vRAINBOW-BETA4</a> <a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.1.0">vLightNova
1.1.0</a>
{% endif %} {% endif %}
</div> </div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script> <script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
@@ -43,4 +49,5 @@
</div> </div>
</noscript> </noscript>
</body> </body>
</html>
</html>

32
test_frontend_logic.js Normal file
View File

@@ -0,0 +1,32 @@
// 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.");

22
tjaf.py
View File

@@ -7,6 +7,8 @@ class Tja:
self.text = text self.text = text
self.title: Optional[str] = None self.title: Optional[str] = None
self.subtitle: Optional[str] = None self.subtitle: Optional[str] = None
self.title_ja: Optional[str] = None
self.subtitle_ja: Optional[str] = None
self.wave: Optional[str] = None self.wave: Optional[str] = None
self.offset: Optional[float] = None self.offset: Optional[float] = None
self.courses: Dict[str, Dict[str, Optional[int]]] = {} self.courses: Dict[str, Dict[str, Optional[int]]] = {}
@@ -25,8 +27,12 @@ class Tja:
val = v.strip() val = v.strip()
if key == "TITLE": if key == "TITLE":
self.title = val or None self.title = val or None
elif key == "TITLEJA":
self.title_ja = val or None
elif key == "SUBTITLE": elif key == "SUBTITLE":
self.subtitle = val or None self.subtitle = val or None
elif key == "SUBTITLEJA":
self.subtitle_ja = val or None
elif key == "WAVE": elif key == "WAVE":
self.wave = val or None self.wave = val or None
elif key == "OFFSET": elif key == "OFFSET":
@@ -73,8 +79,20 @@ class Tja:
"type": "tja", "type": "tja",
"title": self.title, "title": self.title,
"subtitle": self.subtitle, "subtitle": self.subtitle,
"title_lang": {"ja": self.title, "en": None, "cn": None, "tw": None, "ko": None}, "title_lang": {
"subtitle_lang": {"ja": self.subtitle, "en": None, "cn": None, "tw": None, "ko": None}, "ja": self.title_ja or self.title,
"en": None,
"cn": self.title_ja or None,
"tw": None,
"ko": None,
},
"subtitle_lang": {
"ja": self.subtitle_ja or self.subtitle,
"en": None,
"cn": self.subtitle_ja or None,
"tw": None,
"ko": None,
},
"courses": courses_out, "courses": courses_out,
"enabled": False, "enabled": False,
"category_id": None, "category_id": None,

40
update.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "${EUID}" -ne 0 ]; then echo "需要 root 权限"; exit 1; fi
SRC_DIR=$(cd "$(dirname "$0")" && pwd)
DEST_DIR=/srv/taiko-web
SONGS_DIR="$DEST_DIR/public/songs"
BACKUP_DIR="$DEST_DIR/.backup_songs_$(date +%Y%m%d_%H%M%S)"
systemctl stop taiko-web || true
if [ -d "$SONGS_DIR" ]; then
mkdir -p "$BACKUP_DIR"
rsync -a "$SONGS_DIR/" "$BACKUP_DIR/" || cp -a "$SONGS_DIR/." "$BACKUP_DIR/"
fi
mkdir -p "$DEST_DIR"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'public/songs' \
"$SRC_DIR/" "$DEST_DIR/"
if [ -x "$DEST_DIR/.venv/bin/pip" ]; then
"$DEST_DIR/.venv/bin/pip" install -U pip
"$DEST_DIR/.venv/bin/pip" install -r "$DEST_DIR/requirements.txt"
fi
chown -R www-data:www-data "$DEST_DIR"
if [ -d "$BACKUP_DIR" ]; then
mkdir -p "$SONGS_DIR"
rsync -a "$BACKUP_DIR/" "$SONGS_DIR/" || cp -a "$BACKUP_DIR/." "$SONGS_DIR/"
fi
systemctl daemon-reload || true
systemctl restart taiko-web || systemctl start taiko-web || true
systemctl is-active --quiet taiko-web