Compare commits
14 Commits
48e707cd64
...
Bang2
| Author | SHA1 | Date | |
|---|---|---|---|
| 271fc52e82 | |||
| 1038fc85b9 | |||
| 76a3d52098 | |||
| d6a1b6bd41 | |||
|
|
6d7be5c45c | ||
| 92c1261f6f | |||
| 9a2a7dbee6 | |||
| f91e3c9089 | |||
| 84a0c2b7e0 | |||
| 9f2d753500 | |||
| a77534c72b | |||
| 1ca7a3f610 | |||
| 4da81d16dc | |||
| 3d611a9c46 |
@@ -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.
|
||||
76
README.md
76
README.md
@@ -33,6 +33,55 @@ http://<服务器IP>/
|
||||
- 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80`
|
||||
- 开放防火墙 `80/tcp`(如系统启用了 `ufw`)
|
||||
|
||||
## 全命令部署(使用 Docker 部署 MongoDB)
|
||||
|
||||
适用系统:Ubuntu 20.04+/22.04+/24.04(需 root),MongoDB 通过 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. 安装依赖:
|
||||
@@ -94,3 +143,30 @@ docker run --detach \
|
||||
---
|
||||
|
||||
如需将监听接口改为仅内网或增加并发工作数(例如 `--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+左右或肩键)自动切换类型并刷新列表
|
||||
|
||||
216
app.py
216
app.py
@@ -33,6 +33,7 @@ from flask_session import Session
|
||||
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
|
||||
from ffmpy import FFmpeg
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from redis import Redis
|
||||
|
||||
def take_config(name, required=False):
|
||||
@@ -44,6 +45,19 @@ def take_config(name, required=False):
|
||||
return None
|
||||
|
||||
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:
|
||||
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.users.create_index('username', unique=True)
|
||||
db.songs.create_index('id', unique=True)
|
||||
db.songs.create_index('song_type')
|
||||
db.scores.create_index('username')
|
||||
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('month', 1), ('score_value', -1)])
|
||||
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('username', 1), ('month', 1)], unique=True)
|
||||
db.leaderboards.create_index('month')
|
||||
|
||||
|
||||
class HashException(Exception):
|
||||
@@ -479,7 +497,13 @@ def route_api_preview():
|
||||
@app.route(basedir + 'api/songs')
|
||||
@app.cache.cached(timeout=15)
|
||||
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:
|
||||
if song['maker_id']:
|
||||
if song['maker_id'] == 0:
|
||||
@@ -725,6 +749,158 @@ def route_api_scores_get():
|
||||
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
|
||||
|
||||
|
||||
@app.route(basedir + 'api/leaderboard/submit', methods=['POST'])
|
||||
@login_required
|
||||
def route_api_leaderboard_submit():
|
||||
data = request.get_json()
|
||||
if not schema.validate(data, schema.leaderboard_submit):
|
||||
return abort(400)
|
||||
|
||||
username = session.get('username')
|
||||
user = db.users.find_one({'username': username})
|
||||
if not user:
|
||||
return api_error('user_not_found')
|
||||
|
||||
song_id = data.get('song_id')
|
||||
difficulty = data.get('difficulty')
|
||||
score_data = data.get('score')
|
||||
|
||||
# Validate difficulty
|
||||
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
|
||||
if difficulty not in valid_difficulties:
|
||||
return api_error('invalid_difficulty')
|
||||
|
||||
# Get current month (YYYY-MM format)
|
||||
current_month = time.strftime('%Y-%m', time.gmtime())
|
||||
|
||||
# Check if user already has a record for this song/difficulty/month
|
||||
existing = db.leaderboards.find_one({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'username': username,
|
||||
'month': current_month
|
||||
})
|
||||
|
||||
# Parse score (assuming it's in the same format as the scores collection)
|
||||
try:
|
||||
if isinstance(score_data, str):
|
||||
import json as json_module
|
||||
score_obj = json_module.loads(score_data)
|
||||
else:
|
||||
score_obj = score_data
|
||||
|
||||
score_value = int(score_obj.get('score', 0))
|
||||
except:
|
||||
return api_error('invalid_score_format')
|
||||
|
||||
if existing:
|
||||
# Update only if new score is higher
|
||||
existing_score = existing.get('score_value', 0)
|
||||
if score_value > existing_score:
|
||||
db.leaderboards.update_one(
|
||||
{'_id': existing['_id']},
|
||||
{'$set': {
|
||||
'score': score_data,
|
||||
'score_value': score_value,
|
||||
'display_name': user['display_name'],
|
||||
'submitted_at': time.time()
|
||||
}}
|
||||
)
|
||||
return jsonify({'status': 'ok', 'message': 'score_updated'})
|
||||
else:
|
||||
return jsonify({'status': 'ok', 'message': 'score_not_higher'})
|
||||
else:
|
||||
# Check if this score would be in top 50
|
||||
count = db.leaderboards.count_documents({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
})
|
||||
|
||||
if count >= 50:
|
||||
# Find the 50th score
|
||||
leaderboard = list(db.leaderboards.find({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
}).sort('score_value', -1).limit(50))
|
||||
|
||||
if len(leaderboard) >= 50:
|
||||
last_score = leaderboard[49].get('score_value', 0)
|
||||
if score_value <= last_score:
|
||||
return jsonify({'status': 'ok', 'message': 'score_too_low'})
|
||||
|
||||
# Insert new record
|
||||
db.leaderboards.insert_one({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'username': username,
|
||||
'display_name': user['display_name'],
|
||||
'score': score_data,
|
||||
'score_value': score_value,
|
||||
'submitted_at': time.time(),
|
||||
'month': current_month
|
||||
})
|
||||
|
||||
# Remove entries beyond 50th place
|
||||
if count >= 50:
|
||||
# Get all entries sorted by score
|
||||
all_entries = list(db.leaderboards.find({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
}).sort('score_value', -1))
|
||||
|
||||
# Delete entries beyond 50
|
||||
if len(all_entries) > 50:
|
||||
for entry in all_entries[50:]:
|
||||
db.leaderboards.delete_one({'_id': entry['_id']})
|
||||
|
||||
return jsonify({'status': 'ok', 'message': 'score_submitted'})
|
||||
|
||||
|
||||
@app.route(basedir + 'api/leaderboard/get')
|
||||
def route_api_leaderboard_get():
|
||||
song_id = request.args.get('song_id', None)
|
||||
difficulty = request.args.get('difficulty', None)
|
||||
|
||||
if not song_id or not difficulty:
|
||||
return abort(400)
|
||||
|
||||
try:
|
||||
song_id = int(song_id)
|
||||
except:
|
||||
return abort(400)
|
||||
|
||||
# Validate difficulty
|
||||
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
|
||||
if difficulty not in valid_difficulties:
|
||||
return abort(400)
|
||||
|
||||
# Get current month
|
||||
current_month = time.strftime('%Y-%m', time.gmtime())
|
||||
|
||||
# Get top 50 scores
|
||||
leaderboard = list(db.leaderboards.find({
|
||||
'song_id': song_id,
|
||||
'difficulty': difficulty,
|
||||
'month': current_month
|
||||
}, {
|
||||
'_id': False,
|
||||
'username': True,
|
||||
'display_name': True,
|
||||
'score': True,
|
||||
'score_value': True,
|
||||
'submitted_at': True
|
||||
}).sort('score_value', -1).limit(50))
|
||||
|
||||
# Add rank to each entry
|
||||
for i, entry in enumerate(leaderboard):
|
||||
entry['rank'] = i + 1
|
||||
|
||||
return jsonify({'status': 'ok', 'leaderboard': leaderboard, 'month': current_month})
|
||||
|
||||
|
||||
@app.route(basedir + 'privacy')
|
||||
def route_api_privacy():
|
||||
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
|
||||
@@ -852,17 +1028,32 @@ def upload_file():
|
||||
db_entry['enabled'] = True
|
||||
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)
|
||||
try:
|
||||
app.cache.delete_memoized(route_api_songs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ディレクトリを作成
|
||||
target_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs")) / generated_id
|
||||
target_dir.mkdir(parents=True,exist_ok=True)
|
||||
base_env = os.getenv("TAIKO_WEB_SONGS_DIR")
|
||||
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)
|
||||
|
||||
# TJAを保存
|
||||
(target_dir / "main.tja").write_bytes(tja_data)
|
||||
@@ -875,19 +1066,8 @@ def upload_file():
|
||||
return flask.jsonify({'success': True})
|
||||
|
||||
@app.route("/api/delete", methods=["POST"])
|
||||
@limiter.limit("1 per day")
|
||||
def delete():
|
||||
id = flask.request.get_json().get('id')
|
||||
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 "成功しました。"
|
||||
return flask.jsonify({ "success": False, "reason": "Deletion is disabled" }), 403
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
BIN
flask_session/2029240f6d1128be89ddc32729463129
Normal file
BIN
flask_session/2029240f6d1128be89ddc32729463129
Normal file
Binary file not shown.
13
public/src/css/leaderboard.css
Normal file
13
public/src/css/leaderboard.css
Normal file
@@ -0,0 +1,13 @@
|
||||
#leaderboard {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#leaderboard-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
class ImportSongs{
|
||||
class ImportSongs{
|
||||
constructor(...args){
|
||||
this.init(...args)
|
||||
}
|
||||
@@ -322,6 +322,10 @@
|
||||
songTitle = songTitle.slice(0, uraPos)
|
||||
}
|
||||
}
|
||||
if(id === "cn" && !meta["titlecn"] && meta.titlezh){
|
||||
titleLang.cn = meta.titlezh
|
||||
titleLangAdded = true
|
||||
}
|
||||
if(meta["title" + id]){
|
||||
titleLang[id] = meta["title" + id]
|
||||
titleLangAdded = true
|
||||
@@ -329,6 +333,10 @@
|
||||
titleLang[id] = this.songTitle[songTitle][id] + ura
|
||||
titleLangAdded = true
|
||||
}
|
||||
if(id === "cn" && !meta["subtitlecn"] && meta.subtitlezh){
|
||||
subtitleLang.cn = meta.subtitlezh
|
||||
subtitleLangAdded = true
|
||||
}
|
||||
if(meta["subtitle" + id]){
|
||||
subtitleLang[id] = meta["subtitle" + id]
|
||||
subtitleLangAdded = true
|
||||
|
||||
390
public/src/js/leaderboard.js
Normal file
390
public/src/js/leaderboard.js
Normal file
@@ -0,0 +1,390 @@
|
||||
class Leaderboard {
|
||||
constructor() {
|
||||
this.init()
|
||||
}
|
||||
|
||||
init() {
|
||||
this.canvas = document.getElementById("leaderboard-canvas")
|
||||
if (!this.canvas) {
|
||||
return
|
||||
}
|
||||
this.ctx = this.canvas.getContext("2d")
|
||||
|
||||
var resolution = settings.getItem("resolution")
|
||||
var noSmoothing = resolution === "low" || resolution === "lowest"
|
||||
if (noSmoothing) {
|
||||
this.ctx.imageSmoothingEnabled = false
|
||||
}
|
||||
|
||||
this.songId = null
|
||||
this.difficulty = null
|
||||
this.leaderboardData = []
|
||||
this.currentMonth = ""
|
||||
this.visible = false
|
||||
|
||||
this.draw = new CanvasDraw(noSmoothing)
|
||||
|
||||
// Keyboard controls
|
||||
this.keyboard = new Keyboard({
|
||||
confirm: ["enter", "escape", "don_l", "don_r"],
|
||||
back: ["escape"],
|
||||
left: ["left", "ka_l"],
|
||||
right: ["right", "ka_r"]
|
||||
}, this.keyPress.bind(this))
|
||||
|
||||
pageEvents.add(this.canvas, ["mousedown", "touchstart"], this.onClose.bind(this))
|
||||
}
|
||||
|
||||
keyPress(pressed, name) {
|
||||
if (!pressed || !this.visible) {
|
||||
return
|
||||
}
|
||||
if (name === "confirm" || name === "back") {
|
||||
this.close()
|
||||
} else if (name === "left") {
|
||||
this.changeDifficulty(-1)
|
||||
} else if (name === "right") {
|
||||
this.changeDifficulty(1)
|
||||
}
|
||||
}
|
||||
|
||||
async display(songId, difficulty) {
|
||||
this.songId = songId
|
||||
this.difficulty = difficulty
|
||||
this.visible = true
|
||||
|
||||
loader.changePage("leaderboard", false)
|
||||
|
||||
// Fetch leaderboard data
|
||||
await this.fetchLeaderboard()
|
||||
|
||||
// Start rendering
|
||||
this.redrawRunning = true
|
||||
this.redrawBind = this.redraw.bind(this)
|
||||
this.redraw()
|
||||
|
||||
assets.sounds["se_don"].play()
|
||||
}
|
||||
|
||||
async fetchLeaderboard() {
|
||||
try {
|
||||
var response = await loader.ajax(
|
||||
`${gameConfig.basedir || "/"}api/leaderboard/get?song_id=${this.songId}&difficulty=${this.difficulty}`
|
||||
)
|
||||
var data = JSON.parse(response)
|
||||
if (data.status === "ok") {
|
||||
this.leaderboardData = data.leaderboard || []
|
||||
this.currentMonth = data.month || ""
|
||||
} else {
|
||||
this.leaderboardData = []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch leaderboard:", e)
|
||||
this.leaderboardData = []
|
||||
}
|
||||
}
|
||||
|
||||
changeDifficulty(direction) {
|
||||
var difficulties = ["easy", "normal", "hard", "oni", "ura"]
|
||||
var currentIndex = difficulties.indexOf(this.difficulty)
|
||||
if (currentIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
var newIndex = (currentIndex + direction + difficulties.length) % difficulties.length
|
||||
this.difficulty = difficulties[newIndex]
|
||||
|
||||
this.fetchLeaderboard().then(() => {
|
||||
assets.sounds["se_ka"].play()
|
||||
})
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
if (!this.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
var rect = this.canvas.getBoundingClientRect()
|
||||
var x = (event.offsetX || event.touches[0].pageX - rect.left)
|
||||
var y = (event.offsetY || event.touches[0].pageY - rect.top)
|
||||
|
||||
// Check if clicked outside modal - updated dimensions
|
||||
var centerX = this.canvas.width / 2
|
||||
var centerY = this.canvas.height / 2
|
||||
var modalWidth = 880 // Updated from 800
|
||||
var modalHeight = 640 // Updated from 600
|
||||
|
||||
if (x < centerX - modalWidth / 2 || x > centerX + modalWidth / 2 ||
|
||||
y < centerY - modalHeight / 2 || y > centerY + modalHeight / 2) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false
|
||||
this.redrawRunning = false
|
||||
|
||||
if (this.keyboard) {
|
||||
this.keyboard.clean()
|
||||
}
|
||||
|
||||
assets.sounds["se_cancel"].play()
|
||||
|
||||
// Return to song select - get touchEnabled from global or default to false
|
||||
setTimeout(() => {
|
||||
var touchEnabled = typeof window.touchEnabled !== 'undefined' ? window.touchEnabled : false
|
||||
new SongSelect(false, false, touchEnabled)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
redraw() {
|
||||
if (!this.redrawRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(this.redrawBind)
|
||||
|
||||
var winW = innerWidth
|
||||
var winH = innerHeight
|
||||
var ratio = winH / 720
|
||||
|
||||
this.canvas.width = winW
|
||||
this.canvas.height = winH
|
||||
this.ctx.save()
|
||||
|
||||
// Draw semi-transparent background
|
||||
this.ctx.fillStyle = "rgba(0, 0, 0, 0.8)"
|
||||
this.ctx.fillRect(0, 0, winW, winH)
|
||||
|
||||
this.ctx.scale(ratio, ratio)
|
||||
|
||||
// Draw modal background with gradient
|
||||
var modalX = 200
|
||||
var modalY = 40
|
||||
var modalW = 880
|
||||
var modalH = 640
|
||||
|
||||
// Gradient background
|
||||
var bgGradient = this.ctx.createLinearGradient(modalX, modalY, modalX, modalY + modalH)
|
||||
bgGradient.addColorStop(0, "#ffffff")
|
||||
bgGradient.addColorStop(1, "#f0f4f8")
|
||||
this.ctx.fillStyle = bgGradient
|
||||
this.ctx.shadowColor = "rgba(0, 0, 0, 0.3)"
|
||||
this.ctx.shadowBlur = 30
|
||||
this.ctx.shadowOffsetX = 0
|
||||
this.ctx.shadowOffsetY = 10
|
||||
this.ctx.fillRect(modalX, modalY, modalW, modalH)
|
||||
this.ctx.shadowBlur = 0
|
||||
|
||||
// Modal border with gradient
|
||||
var borderGradient = this.ctx.createLinearGradient(modalX, modalY, modalX + modalW, modalY + modalH)
|
||||
borderGradient.addColorStop(0, "#4a90e2")
|
||||
borderGradient.addColorStop(0.5, "#7b68ee")
|
||||
borderGradient.addColorStop(1, "#ff6b9d")
|
||||
this.ctx.strokeStyle = borderGradient
|
||||
this.ctx.lineWidth = 6
|
||||
this.ctx.strokeRect(modalX, modalY, modalW, modalH)
|
||||
|
||||
// Draw title with gradient
|
||||
var titleGradient = this.ctx.createLinearGradient(0, 90, 0, 130)
|
||||
titleGradient.addColorStop(0, "#4a90e2")
|
||||
titleGradient.addColorStop(1, "#7b68ee")
|
||||
this.ctx.fillStyle = titleGradient
|
||||
this.ctx.font = "bold 48px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.shadowColor = "rgba(0, 0, 0, 0.2)"
|
||||
this.ctx.shadowBlur = 5
|
||||
this.ctx.shadowOffsetX = 2
|
||||
this.ctx.shadowOffsetY = 2
|
||||
this.ctx.fillText("🏆 排行榜 Leaderboard", 640, 100)
|
||||
this.ctx.shadowBlur = 0
|
||||
|
||||
// Draw difficulty selector with improved styling
|
||||
var diffX = 640
|
||||
var diffY = 155
|
||||
var difficulties = [
|
||||
{ id: "easy", name: "簡単", color: "#00a0e9", glow: "#00d4ff" },
|
||||
{ id: "normal", name: "普通", color: "#00a040", glow: "#00ff66" },
|
||||
{ id: "hard", name: "難しい", color: "#ff8c00", glow: "#ffb347" },
|
||||
{ id: "oni", name: "鬼", color: "#dc143c", glow: "#ff6b9d" },
|
||||
{ id: "ura", name: "裏", color: "#9400d3", glow: "#da70d6" }
|
||||
]
|
||||
|
||||
this.ctx.font = "bold 22px " + (strings.font || "sans-serif")
|
||||
for (var i = 0; i < difficulties.length; i++) {
|
||||
var diff = difficulties[i]
|
||||
var x = diffX - 220 + i * 110
|
||||
|
||||
if (diff.id === this.difficulty) {
|
||||
// Selected difficulty with glow effect
|
||||
this.ctx.shadowColor = diff.glow
|
||||
this.ctx.shadowBlur = 15
|
||||
var gradient = this.ctx.createLinearGradient(x - 50, diffY - 30, x + 50, diffY + 20)
|
||||
gradient.addColorStop(0, diff.color)
|
||||
gradient.addColorStop(1, diff.glow)
|
||||
this.ctx.fillStyle = gradient
|
||||
this.ctx.fillRect(x - 50, diffY - 30, 100, 50)
|
||||
this.ctx.shadowBlur = 0
|
||||
this.ctx.fillStyle = "#ffffff"
|
||||
this.ctx.shadowColor = "rgba(0, 0, 0, 0.5)"
|
||||
this.ctx.shadowBlur = 3
|
||||
} else {
|
||||
// Unselected difficulty
|
||||
this.ctx.strokeStyle = diff.color
|
||||
this.ctx.lineWidth = 3
|
||||
this.ctx.strokeRect(x - 50, diffY - 30, 100, 50)
|
||||
this.ctx.fillStyle = diff.color
|
||||
}
|
||||
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText(diff.name, x, diffY)
|
||||
this.ctx.shadowBlur = 0
|
||||
}
|
||||
|
||||
// Draw month info with style
|
||||
this.ctx.fillStyle = "#666666"
|
||||
this.ctx.font = "20px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("📅 当月排行 " + this.currentMonth, 640, 215)
|
||||
|
||||
// Header bar
|
||||
var headerY = 250
|
||||
var headerGradient = this.ctx.createLinearGradient(modalX, headerY, modalX, headerY + 35)
|
||||
headerGradient.addColorStop(0, "#e8eef5")
|
||||
headerGradient.addColorStop(1, "#d0dae8")
|
||||
this.ctx.fillStyle = headerGradient
|
||||
this.ctx.fillRect(modalX + 20, headerY, modalW - 40, 35)
|
||||
|
||||
this.ctx.fillStyle = "#333333"
|
||||
this.ctx.font = "bold 18px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("排名", modalX + 80, headerY + 23)
|
||||
this.ctx.textAlign = "left"
|
||||
this.ctx.fillText("玩家", modalX + 140, headerY + 23)
|
||||
this.ctx.textAlign = "right"
|
||||
this.ctx.fillText("分数", modalX + modalW - 60, headerY + 23)
|
||||
|
||||
// Draw leaderboard entries
|
||||
var startY = 295
|
||||
var rowHeight = 38
|
||||
|
||||
this.ctx.font = "22px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "left"
|
||||
|
||||
if (this.leaderboardData.length === 0) {
|
||||
this.ctx.fillStyle = "#999999"
|
||||
this.ctx.font = "24px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("暂无排行数据", 640, startY + 120)
|
||||
} else {
|
||||
for (var i = 0; i < Math.min(this.leaderboardData.length, 15); i++) {
|
||||
var entry = this.leaderboardData[i]
|
||||
var y = startY + i * rowHeight
|
||||
var rank = entry.rank
|
||||
|
||||
// Rank background color with gradient
|
||||
var gradient
|
||||
if (rank === 1) {
|
||||
// Gold gradient for 1st place
|
||||
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
|
||||
gradient.addColorStop(0, "#ffd700")
|
||||
gradient.addColorStop(1, "#ffed4e")
|
||||
this.ctx.fillStyle = gradient
|
||||
this.ctx.shadowColor = "#ffd700"
|
||||
this.ctx.shadowBlur = 20
|
||||
} else if (rank === 2) {
|
||||
// Silver gradient for 2nd place
|
||||
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
|
||||
gradient.addColorStop(0, "#c0c0c0")
|
||||
gradient.addColorStop(1, "#e8e8e8")
|
||||
this.ctx.fillStyle = gradient
|
||||
this.ctx.shadowColor = "#c0c0c0"
|
||||
this.ctx.shadowBlur = 15
|
||||
} else if (rank === 3) {
|
||||
// Bronze gradient for 3rd place
|
||||
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
|
||||
gradient.addColorStop(0, "#cd7f32")
|
||||
gradient.addColorStop(1, "#e9a86a")
|
||||
this.ctx.fillStyle = gradient
|
||||
this.ctx.shadowColor = "#cd7f32"
|
||||
this.ctx.shadowBlur = 12
|
||||
} else {
|
||||
// Regular entries with subtle gradient
|
||||
gradient = this.ctx.createLinearGradient(modalX + 20, y - 25, modalX + 20, y + 10)
|
||||
gradient.addColorStop(0, "#ffffff")
|
||||
gradient.addColorStop(1, "#f8f9fa")
|
||||
this.ctx.fillStyle = gradient
|
||||
}
|
||||
|
||||
// Rounded rectangle effect
|
||||
this.ctx.fillRect(modalX + 20, y - 25, modalW - 40, 35)
|
||||
this.ctx.shadowBlur = 0
|
||||
|
||||
// Border for entries
|
||||
if (rank <= 3) {
|
||||
this.ctx.strokeStyle = rank === 1 ? "#ffd700" : rank === 2 ? "#c0c0c0" : "#cd7f32"
|
||||
this.ctx.lineWidth = 2
|
||||
this.ctx.strokeRect(modalX + 20, y - 25, modalW - 40, 35)
|
||||
}
|
||||
|
||||
// Rank with medal emoji for top 3
|
||||
if (rank === 1) {
|
||||
this.ctx.fillStyle = "#8b6914"
|
||||
this.ctx.font = "bold 24px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("🥇", modalX + 80, y + 2)
|
||||
} else if (rank === 2) {
|
||||
this.ctx.fillStyle = "#5c5c5c"
|
||||
this.ctx.font = "bold 24px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("🥈", modalX + 80, y + 2)
|
||||
} else if (rank === 3) {
|
||||
this.ctx.fillStyle = "#6d4423"
|
||||
this.ctx.font = "bold 24px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("🥉", modalX + 80, y + 2)
|
||||
} else {
|
||||
this.ctx.fillStyle = rank <= 3 ? "#ffffff" : "#555555"
|
||||
this.ctx.font = "bold 22px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("#" + rank, modalX + 80, y + 2)
|
||||
}
|
||||
|
||||
// Display name
|
||||
this.ctx.fillStyle = rank <= 3 ? "#1a1a1a" : "#333333"
|
||||
this.ctx.font = (rank <= 3 ? "bold " : "") + "20px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "left"
|
||||
var displayName = entry.display_name || entry.username
|
||||
if (displayName.length > 18) {
|
||||
displayName = displayName.substring(0, 18) + "..."
|
||||
}
|
||||
this.ctx.fillText(displayName, modalX + 140, y + 2)
|
||||
|
||||
// Score with formatting
|
||||
var score = entry.score && entry.score.score ? entry.score.score : 0
|
||||
this.ctx.fillStyle = rank <= 3 ? "#1a1a1a" : "#555555"
|
||||
this.ctx.font = (rank <= 3 ? "bold " : "") + "20px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "right"
|
||||
this.ctx.fillText(score.toLocaleString(), modalX + modalW - 60, y + 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw close hint with icon
|
||||
this.ctx.fillStyle = "#888888"
|
||||
this.ctx.font = "18px " + (strings.font || "sans-serif")
|
||||
this.ctx.textAlign = "center"
|
||||
this.ctx.fillText("⌨️ 按ESC或点击外部关闭 Press ESC or click outside to close", 640, 665)
|
||||
|
||||
this.ctx.restore()
|
||||
}
|
||||
|
||||
clean() {
|
||||
if (this.keyboard) {
|
||||
this.keyboard.clean()
|
||||
}
|
||||
if (this.redrawRunning) {
|
||||
this.redrawRunning = false
|
||||
}
|
||||
pageEvents.remove(this.canvas, ["mousedown", "touchstart"])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3
public/src/views/leaderboard.html
Normal file
3
public/src/views/leaderboard.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id="leaderboard">
|
||||
<canvas id="leaderboard-canvas"></canvas>
|
||||
</div>
|
||||
@@ -16,6 +16,21 @@
|
||||
|
||||
<label for="file_music">音楽ファイル:</label>
|
||||
<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>
|
||||
|
||||
<button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button>
|
||||
|
||||
11
schema.py
11
schema.py
@@ -80,3 +80,14 @@ scores_save = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leaderboard_submit = {
|
||||
'$schema': 'http://json-schema.org/schema#',
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'song_id': {'type': 'number'},
|
||||
'difficulty': {'type': 'string'},
|
||||
'score': {'type': 'object'}
|
||||
},
|
||||
'required': ['song_id', 'difficulty', 'score']
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
|
||||
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<meta name="description" content="2025年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
|
||||
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
|
||||
<meta name="description"
|
||||
content="2026年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
|
||||
<meta name="keywords"
|
||||
content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
|
||||
<meta name="robots" content="notranslate">
|
||||
<meta name="robots" content="noimageindex">
|
||||
<meta name="color-scheme" content="only light">
|
||||
|
||||
<link rel="canonical" href="https://taikoapp.uk/" />
|
||||
<link rel="canonical" href="https://taikoapp.uk/" />
|
||||
|
||||
<link rel="stylesheet" href="src/css/loader.css?{{version.commit_short}}">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
@@ -24,14 +27,17 @@
|
||||
<script src="src/js/lib/jszip.js"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="assets"></div>
|
||||
<div id="screen" class="pattern-bg"></div>
|
||||
<div data-nosnippet id="version">
|
||||
{% if version.version and version.commit_short and version.commit %}
|
||||
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
|
||||
@@ -43,4 +49,5 @@
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
22
tjaf.py
22
tjaf.py
@@ -7,6 +7,8 @@ class Tja:
|
||||
self.text = text
|
||||
self.title: 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.offset: Optional[float] = None
|
||||
self.courses: Dict[str, Dict[str, Optional[int]]] = {}
|
||||
@@ -25,8 +27,12 @@ class Tja:
|
||||
val = v.strip()
|
||||
if key == "TITLE":
|
||||
self.title = val or None
|
||||
elif key == "TITLEJA":
|
||||
self.title_ja = val or None
|
||||
elif key == "SUBTITLE":
|
||||
self.subtitle = val or None
|
||||
elif key == "SUBTITLEJA":
|
||||
self.subtitle_ja = val or None
|
||||
elif key == "WAVE":
|
||||
self.wave = val or None
|
||||
elif key == "OFFSET":
|
||||
@@ -73,8 +79,20 @@ class Tja:
|
||||
"type": "tja",
|
||||
"title": self.title,
|
||||
"subtitle": self.subtitle,
|
||||
"title_lang": {"ja": self.title, "en": None, "cn": None, "tw": None, "ko": None},
|
||||
"subtitle_lang": {"ja": self.subtitle, "en": None, "cn": None, "tw": None, "ko": None},
|
||||
"title_lang": {
|
||||
"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,
|
||||
"enabled": False,
|
||||
"category_id": None,
|
||||
|
||||
40
update.sh
Normal file
40
update.sh
Normal 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
|
||||
Reference in New Issue
Block a user