13 Commits

Author SHA1 Message Date
4895990729 fix: Fix leaderboard freeze and AJAX submission issues - Fixed fetchData async/await to use proper Promise pattern - Added basedir prefix to API URLs - Fixed submitToLeaderboard to use XMLHttpRequest with CSRF token 2026-01-18 07:55:52 +08:00
6686f5ae15 feat: Add song difficulty leaderboard feature 2026-01-18 07:42:58 +08:00
8d58bf683f Optimize loading with multi-threaded worker loader 2026-01-03 16:10:27 +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
19 changed files with 2244 additions and 1118 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`.

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+左右或肩键)自动切换类型并刷新列表

194
app.py
View File

@@ -13,6 +13,7 @@ import requests
import schema import schema
import os import os
import time import time
from datetime import datetime
# -- カスタム -- # -- カスタム --
import traceback import traceback
@@ -33,6 +34,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 +46,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 +104,15 @@ 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.leaderboard.create_index([
('song_id', 1),
('difficulty', 1),
('month', 1),
('score', -1)
])
db.leaderboard.create_index('username')
class HashException(Exception): class HashException(Exception):
@@ -479,7 +502,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 +754,133 @@ 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})
current_month = datetime.now().strftime('%Y-%m')
# Handle song_id type
song_id = data['song_id']
query = {
'song_id': song_id,
'difficulty': data['difficulty'],
'month': current_month,
'username': username
}
existing = db.leaderboard.find_one(query)
new_record = False
rank_info = None
if not existing or data['score'] > existing['score']:
entry = {
'song_id': song_id,
'difficulty': data['difficulty'],
'month': current_month,
'username': username,
'display_name': user['display_name'],
'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']}
})
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')
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')
query = {
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}
# Get top 50
leaderboard = list(db.leaderboard.find(query, {'_id': False}).sort('score', -1).limit(50))
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') @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,16 +1008,31 @@ 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:
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) target_dir.mkdir(parents=True, exist_ok=True)
# TJAを保存 # TJAを保存
@@ -875,19 +1046,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.

View File

@@ -6,6 +6,7 @@ var assets = {
"parseosu.js", "parseosu.js",
"titlescreen.js", "titlescreen.js",
"scoresheet.js", "scoresheet.js",
"leaderboard_ui.js",
"songselect.js", "songselect.js",
"keyboard.js", "keyboard.js",
"gameinput.js", "gameinput.js",

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,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.errorMessages = []
this.songSearchGradient = "linear-gradient(to top, rgba(245, 246, 252, 0.08), #ff5963), " this.songSearchGradient = "linear-gradient(to top, rgba(245, 246, 252, 0.08), #ff5963), "
this.initWorkers()
var promises = [] var promises = []
promises.push(this.ajax("src/views/loader.html").then(page => { promises.push(this.ajax("src/views/loader.html").then(page => {
@@ -23,6 +25,78 @@ class Loader{
Promise.all(promises).then(this.run.bind(this)) 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(){ run(){
this.promises = [] this.promises = []
this.loaderDiv = document.querySelector("#loader") this.loaderDiv = document.querySelector("#loader")
@@ -538,6 +612,17 @@ class Loader{
return css.join("\n") return css.join("\n")
} }
ajax(url, customRequest, customResponse){ 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() var request = new XMLHttpRequest()
request.open("GET", url) request.open("GET", url)
var promise = pageEvents.load(request) var promise = pageEvents.load(request)
@@ -557,12 +642,13 @@ class Loader{
return promise return promise
} }
loadScript(url){ loadScript(url){
var script = document.createElement("script")
var url = url + this.queryString var url = url + this.queryString
var promise = pageEvents.load(script) return this.workerFetch(url, "text").then(code => {
script.src = url var script = document.createElement("script")
code += "\n//# sourceURL=" + url
script.text = code
document.head.appendChild(script) document.head.appendChild(script)
return promise })
} }
getCsrfToken(){ getCsrfToken(){
return this.ajax("api/csrftoken").then(response => { return this.ajax("api/csrftoken").then(response => {

View File

@@ -849,6 +849,29 @@ class Scoresheet{
} }
ctx.restore() 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) { if (this.session && !this.state.scoreNext && this.state.screen === "scoresShown" && ms - this.state.screenMS >= 10000) {
this.state.scoreNext = true this.state.scoreNext = true
@@ -950,9 +973,56 @@ class Scoresheet{
}) })
} }
} }
if (!this.controller.autoPlayEnabled && account.loggedIn && !this.multiplayer) {
this.submitToLeaderboard()
}
this.scoreSaved = true this.scoreSaved = true
} }
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
}
loader.getCsrfToken().then(token => {
var request = new XMLHttpRequest()
request.open("POST", gameConfig.basedir + "api/leaderboard/submit")
pageEvents.load(request).then(() => {
if (request.status === 200) {
try {
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("Leaderboard response parse error:", e)
}
}
}).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)
})
}
clean() { clean() {
this.keyboard.clean() this.keyboard.clean()
this.gamepad.clean() this.gamepad.clean()

View File

@@ -119,6 +119,7 @@ class SongSelect{
this.font = strings.font this.font = strings.font
this.search = new Search(this) this.search = new Search(this)
this.leaderboard = new LeaderboardUI(this)
this.songs = [] this.songs = []
for (let song of assets.songs) { for (let song of assets.songs) {
@@ -298,6 +299,12 @@ class SongSelect{
iconName: "back", iconName: "back",
iconFill: "#f7d39c", iconFill: "#f7d39c",
letterSpacing: 4 letterSpacing: 4
}, {
text: strings.leaderboard,
fill: "#42c0d2",
iconName: "crown",
iconFill: "#8ee7f2",
letterSpacing: 0
}, { }, {
text: strings.songOptions, text: strings.songOptions,
fill: "#b2e442", fill: "#b2e442",
@@ -310,12 +317,6 @@ class SongSelect{
iconName: "download", iconName: "download",
iconFill: "#e7cbe1", iconFill: "#e7cbe1",
letterSpacing: 4 letterSpacing: 4
}, {
text: "削除",
fill: "silver",
iconName: "trash",
iconFill: "#111111",
letterSpacing: 4
}] }]
this.optionsList = [strings.none, strings.auto, strings.netplay] this.optionsList = [strings.none, strings.auto, strings.netplay]
@@ -386,6 +387,32 @@ class SongSelect{
} }
this.songSelect = document.getElementById("song-select") this.songSelect = document.getElementById("song-select")
this.songTypes = [
"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",
]
this.songTypeIndex = Math.max(0, Math.min(this.songTypes.length - 1, +(localStorage.getItem("songTypeIndex") || 0)))
this.typeLabel = document.createElement("div")
this.typeLabel.style.position = "absolute"
this.typeLabel.style.top = "8px"
this.typeLabel.style.left = "12px"
this.typeLabel.style.padding = "4px 8px"
this.typeLabel.style.background = "rgba(0,0,0,0.5)"
this.typeLabel.style.color = "#fff"
this.typeLabel.style.borderRadius = "6px"
this.typeLabel.style.fontSize = "14px"
this.typeLabel.style.zIndex = "10"
this.songSelect.appendChild(this.typeLabel)
this.updateTypeLabel()
var cat = this.songs[this.selectedSong].originalCategory var cat = this.songs[this.selectedSong].originalCategory
this.drawBackground(cat) this.drawBackground(cat)
@@ -536,24 +563,20 @@ class SongSelect{
this.toSession() this.toSession()
} else if (name === "left") { } else if (name === "left") {
if (shift) { if (shift) {
if(!repeat){ if (!repeat) { this.changeType(-1) }
this.categoryJump(-1)
}
} else { } else {
this.moveToSong(-1) this.moveToSong(-1)
} }
} else if (name === "right") { } else if (name === "right") {
if (shift) { if (shift) {
if(!repeat){ if (!repeat) { this.changeType(1) }
this.categoryJump(1)
}
} else { } else {
this.moveToSong(1) this.moveToSong(1)
} }
} else if (name === "jump_left" && !repeat) { } else if (name === "jump_left" && !repeat) {
this.categoryJump(-1) this.changeType(-1)
} else if (name === "jump_right" && !repeat) { } else if (name === "jump_right" && !repeat) {
this.categoryJump(1) this.changeType(1)
} else if (name === "mute" || name === "ctrlGamepad") { } else if (name === "mute" || name === "ctrlGamepad") {
this.endPreview(true) this.endPreview(true)
this.playBgm(false) this.playBgm(false)
@@ -567,11 +590,13 @@ class SongSelect{
} else if (name === "confirm") { } else if (name === "confirm") {
if (this.selectedDiff === 0) { if (this.selectedDiff === 0) {
this.toSongSelect() this.toSongSelect()
}else if(this.selectedDiff === 2){
this.toDownload()
}else if(this.selectedDiff === 3){
this.toDelete()
} else if (this.selectedDiff === 1) { } else if (this.selectedDiff === 1) {
this.toLeaderboard()
} else if (this.selectedDiff === 3) {
this.toDownload()
} else if (this.selectedDiff === 4) {
this.toDelete()
} else if (this.selectedDiff === 2) {
this.toOptions(1) this.toOptions(1)
} else { } else {
this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl) this.toLoadSong(this.selectedDiff - this.diffOptions.length, shift, ctrl)
@@ -598,6 +623,23 @@ class SongSelect{
} }
} }
updateTypeLabel() {
this.setAltText(this.typeLabel, this.songTypes[this.songTypeIndex])
}
changeType(delta) {
this.songTypeIndex = (this.songTypeIndex + delta + this.songTypes.length) % this.songTypes.length
localStorage.setItem("songTypeIndex", this.songTypeIndex)
this.updateTypeLabel()
var type = encodeURIComponent(this.songTypes[this.songTypeIndex])
loader.ajax("api/songs?type=" + type).then(resp => {
var songs = JSON.parse(resp)
assets.songsDefault = songs
assets.songs = assets.songsDefault
new SongSelect(false, false, this.touchEnabled)
}).catch(() => { })
}
mouseDown(event) { mouseDown(event) {
if (event.target === this.selectable || event.target.parentNode === this.selectable) { if (event.target === this.selectable || event.target.parentNode === this.selectable) {
this.selectable.focus() this.selectable.focus()
@@ -608,6 +650,14 @@ class SongSelect{
if (event.target !== this.canvas || !this.redrawRunning) { if (event.target !== this.canvas || !this.redrawRunning) {
return 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.type === "mousedown") {
if (event.which !== 1) { if (event.which !== 1) {
return return
@@ -655,11 +705,13 @@ class SongSelect{
} else if (moveBy === 0) { } else if (moveBy === 0) {
this.selectedDiff = 0 this.selectedDiff = 0
this.toSongSelect() this.toSongSelect()
}else if(moveBy === 2){
this.toDownload()
}else if(moveBy === 3){
this.toDelete()
} else if (moveBy === 1) { } else if (moveBy === 1) {
this.toLeaderboard()
} else if (moveBy === 3) {
this.toDownload()
} else if (moveBy === 4) {
this.toDelete()
} else if (moveBy === 2) {
this.toOptions(1) this.toOptions(1)
} else if (moveBy === "maker") { } else if (moveBy === "maker") {
window.open(this.songs[this.selectedSong].maker.url) window.open(this.songs[this.selectedSong].maker.url)
@@ -915,7 +967,7 @@ class SongSelect{
} else if (currentSong.action === "upload") { } else if (currentSong.action === "upload") {
this.playSound("se_don"); this.playSound("se_don");
setTimeout(() => { setTimeout(() => {
window.location.href = "/upload/"; window.location.href = "https://zizhipu.taiko.asia";
}, 100); }, 100);
} else if (currentSong.action === "keijiban") { } else if (currentSong.action === "keijiban") {
this.playSound("se_don"); this.playSound("se_don");
@@ -2669,6 +2721,10 @@ class SongSelect{
if (screen === "titleFadeIn") { if (screen === "titleFadeIn") {
ctx.save() ctx.save()
if (this.leaderboard && this.leaderboard.visible) {
this.leaderboard.draw(ctx, winW, winH, this.pixelRatio)
}
var elapsed = ms - this.state.screenMS var elapsed = ms - this.state.screenMS
ctx.globalAlpha = Math.max(0, 1 - elapsed / 500) ctx.globalAlpha = Math.max(0, 1 - elapsed / 500)
ctx.fillStyle = "#000" ctx.fillStyle = "#000"
@@ -2926,6 +2982,12 @@ class SongSelect{
var categoryName = song.category var categoryName = song.category
var originalCategory = song.category var originalCategory = song.category
} }
if (!categoryName) {
if (song.song_type) {
categoryName = song.song_type
originalCategory = song.song_type
}
}
var addedSong = { var addedSong = {
title: title, title: title,
originalTitle: song.title, originalTitle: song.title,
@@ -2986,6 +3048,99 @@ class SongSelect{
this.search.enabled = true 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) { onsongsel(response) {
if (response && response.value) { if (response && response.value) {
var selected = false var selected = false
@@ -3068,6 +3223,15 @@ class SongSelect{
getLocalTitle(title, titleLang) { getLocalTitle(title, titleLang) {
if (titleLang) { if (titleLang) {
if (strings.id === "cn") {
if (titleLang.cn) {
return titleLang.cn
}
if (titleLang.ja) {
return titleLang.ja
}
return title
}
for (var id in titleLang) { for (var id in titleLang) {
if (id === "en" && strings.preferEn && !(strings.id in titleLang) && titleLang.en || id === strings.id && titleLang[id]) { if (id === "en" && strings.preferEn && !(strings.id in titleLang) && titleLang.en || id === strings.id && titleLang[id]) {
return titleLang[id] return titleLang[id]
@@ -3098,6 +3262,13 @@ class SongSelect{
return Date.now() return Date.now()
} }
wheel(event) {
if (this.leaderboard && this.leaderboard.visible) {
this.leaderboard.wheel(event.deltaY)
return
}
}
clean() { clean() {
this.keyboard.clean() this.keyboard.clean()
this.gamepad.clean() this.gamepad.clean()

View File

@@ -439,6 +439,77 @@ var translations = {
ko: "연타 횟수" 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: { errorOccured: {
ja: "エラーが発生しました。再読み込みしてください。", ja: "エラーが発生しました。再読み込みしてください。",
en: "An error occurred, please refresh", en: "An error occurred, please refresh",

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>

View File

@@ -80,3 +80,29 @@ scores_save = {
} }
} }
} }
leaderboard_submit = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'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': ['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

@@ -29,9 +29,9 @@
<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.0.0">vLightNova 1.0.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.0.0">vLightNova 1.0.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>

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,

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()

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