4 Commits

8 changed files with 1714 additions and 1082 deletions

155
app.py
View File

@@ -105,6 +105,9 @@ db.users.create_index('username', unique=True)
db.songs.create_index('id', unique=True)
db.songs.create_index('song_type')
db.scores.create_index('username')
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('month', 1), ('score_value', -1)])
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('username', 1), ('month', 1)], unique=True)
db.leaderboards.create_index('month')
class HashException(Exception):
@@ -746,6 +749,158 @@ def route_api_scores_get():
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
@app.route(basedir + 'api/leaderboard/submit', methods=['POST'])
@login_required
def route_api_leaderboard_submit():
data = request.get_json()
if not schema.validate(data, schema.leaderboard_submit):
return abort(400)
username = session.get('username')
user = db.users.find_one({'username': username})
if not user:
return api_error('user_not_found')
song_id = data.get('song_id')
difficulty = data.get('difficulty')
score_data = data.get('score')
# Validate difficulty
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
if difficulty not in valid_difficulties:
return api_error('invalid_difficulty')
# Get current month (YYYY-MM format)
current_month = time.strftime('%Y-%m', time.gmtime())
# Check if user already has a record for this song/difficulty/month
existing = db.leaderboards.find_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'month': current_month
})
# Parse score (assuming it's in the same format as the scores collection)
try:
if isinstance(score_data, str):
import json as json_module
score_obj = json_module.loads(score_data)
else:
score_obj = score_data
score_value = int(score_obj.get('score', 0))
except:
return api_error('invalid_score_format')
if existing:
# Update only if new score is higher
existing_score = existing.get('score_value', 0)
if score_value > existing_score:
db.leaderboards.update_one(
{'_id': existing['_id']},
{'$set': {
'score': score_data,
'score_value': score_value,
'display_name': user['display_name'],
'submitted_at': time.time()
}}
)
return jsonify({'status': 'ok', 'message': 'score_updated'})
else:
return jsonify({'status': 'ok', 'message': 'score_not_higher'})
else:
# Check if this score would be in top 50
count = db.leaderboards.count_documents({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
})
if count >= 50:
# Find the 50th score
leaderboard = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}).sort('score_value', -1).limit(50))
if len(leaderboard) >= 50:
last_score = leaderboard[49].get('score_value', 0)
if score_value <= last_score:
return jsonify({'status': 'ok', 'message': 'score_too_low'})
# Insert new record
db.leaderboards.insert_one({
'song_id': song_id,
'difficulty': difficulty,
'username': username,
'display_name': user['display_name'],
'score': score_data,
'score_value': score_value,
'submitted_at': time.time(),
'month': current_month
})
# Remove entries beyond 50th place
if count >= 50:
# Get all entries sorted by score
all_entries = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}).sort('score_value', -1))
# Delete entries beyond 50
if len(all_entries) > 50:
for entry in all_entries[50:]:
db.leaderboards.delete_one({'_id': entry['_id']})
return jsonify({'status': 'ok', 'message': 'score_submitted'})
@app.route(basedir + 'api/leaderboard/get')
def route_api_leaderboard_get():
song_id = request.args.get('song_id', None)
difficulty = request.args.get('difficulty', None)
if not song_id or not difficulty:
return abort(400)
try:
song_id = int(song_id)
except:
return abort(400)
# Validate difficulty
valid_difficulties = ['easy', 'normal', 'hard', 'oni', 'ura']
if difficulty not in valid_difficulties:
return abort(400)
# Get current month
current_month = time.strftime('%Y-%m', time.gmtime())
# Get top 50 scores
leaderboard = list(db.leaderboards.find({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}, {
'_id': False,
'username': True,
'display_name': True,
'score': True,
'score_value': True,
'submitted_at': True
}).sort('score_value', -1).limit(50))
# Add rank to each entry
for i, entry in enumerate(leaderboard):
entry['rank'] = i + 1
return jsonify({'status': 'ok', 'leaderboard': leaderboard, 'month': current_month})
@app.route(basedir + 'privacy')
def route_api_privacy():
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))

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

@@ -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"])
}
}

View File

@@ -1,8 +1,8 @@
class Scoresheet{
constructor(...args){
class Scoresheet {
constructor(...args) {
this.init(...args)
}
init(controller, results, multiplayer, touchEnabled){
init(controller, results, multiplayer, touchEnabled) {
this.controller = controller
this.resultsObj = results
this.player = [multiplayer ? (p2.player === 1 ? 0 : 1) : 0]
@@ -11,12 +11,12 @@ class Scoresheet{
this.results[player0] = {}
this.rules = []
this.rules[player0] = this.controller.game.rules
if(multiplayer){
if (multiplayer) {
this.player.push(p2.player === 2 ? 0 : 1)
this.results[this.player[1]] = p2.results
this.rules[this.player[1]] = this.controller.syncWith.game.rules
}
for(var i in results){
for (var i in results) {
this.results[player0][i] = results[i] === null ? null : results[i].toString()
}
this.multiplayer = multiplayer
@@ -26,10 +26,10 @@ class Scoresheet{
this.ctx = this.canvas.getContext("2d")
var resolution = settings.getItem("resolution")
var noSmoothing = resolution === "low" || resolution === "lowest"
if(noSmoothing){
if (noSmoothing) {
this.ctx.imageSmoothingEnabled = false
}
if(resolution === "lowest"){
if (resolution === "lowest") {
this.canvas.style.imageRendering = "pixelated"
}
this.game = document.getElementById("game")
@@ -78,12 +78,12 @@ class Scoresheet{
assets.sounds["bgm_result"].playLoop(3, false, 0, 0.847, 17.689)
this.session = p2.session
if(this.session){
if(p2.getMessage("songsel")){
if (this.session) {
if (p2.getMessage("songsel")) {
this.toSongsel(true)
}
pageEvents.add(p2, "message", response => {
if(response.type === "songsel"){
if (response.type === "songsel") {
this.toSongsel(true)
}
})
@@ -100,51 +100,51 @@ class Scoresheet{
touchEvents: controller.view.touchEvents
})
}
keyDown(pressed){
if(pressed && this.redrawing){
keyDown(pressed) {
if (pressed && this.redrawing) {
this.toNext()
}
}
mouseDown(event){
if(event.type === "touchstart"){
mouseDown(event) {
if (event.type === "touchstart") {
event.preventDefault()
this.canvas.style.cursor = ""
this.state.pointerLocked = true
}else{
} else {
this.state.pointerLocked = false
if(event.which !== 1){
if (event.which !== 1) {
return
}
}
this.toNext()
}
toNext(){
toNext() {
var elapsed = this.getMS() - this.state.screenMS
if(this.state.screen === "fadeIn" && elapsed >= this.state.startDelay){
if (this.state.screen === "fadeIn" && elapsed >= this.state.startDelay) {
this.toScoresShown()
}else if(this.state.screen === "scoresShown" && elapsed >= 1000){
} else if (this.state.screen === "scoresShown" && elapsed >= 1000) {
this.toSongsel()
}
}
toScoresShown(){
if(!p2.session){
toScoresShown() {
if (!p2.session) {
this.state.screen = "scoresShown"
this.state.screenMS = this.getMS()
this.controller.playSound("neiro_1_don", 0, true)
}
}
toSongsel(fromP2){
if(!p2.session || fromP2){
toSongsel(fromP2) {
if (!p2.session || fromP2) {
snd.musicGain.fadeOut(0.5)
this.state.screen = "fadeOut"
this.state.screenMS = this.getMS()
if(!fromP2){
if (!fromP2) {
this.controller.playSound("neiro_1_don", 0, true)
}
}
}
startRedraw(){
startRedraw() {
this.redrawing = true
requestAnimationFrame(this.redrawBind)
this.winW = null
@@ -152,7 +152,7 @@ class Scoresheet{
pageEvents.add(this.canvas, ["mousedown", "touchstart"], this.mouseDown.bind(this))
if(!this.multiplayer){
if (!this.multiplayer) {
this.tetsuoHana = document.createElement("div")
this.tetsuoHana.id = "tetsuohana"
var flowersBg = "url('" + assets.image["results_flowers"].src + "')"
@@ -160,12 +160,12 @@ class Scoresheet{
var tetsuoHanaBg = "url('" + assets.image["results_tetsuohana" + (debugObj.state === "closed" ? "" : "2")].src + "')"
var id = ["flowers1", "flowers2", "mikoshi", "tetsuo", "hana"]
var bg = [flowersBg, flowersBg, mikoshiBg, tetsuoHanaBg, tetsuoHanaBg]
for(var i = 0; i < id.length; i++){
if(id[i] === "mikoshi"){
for (var i = 0; i < id.length; i++) {
if (id[i] === "mikoshi") {
var divOut = document.createElement("div")
divOut.id = id[i] + "-out"
this.tetsuoHana.appendChild(divOut)
}else{
} else {
var divOut = this.tetsuoHana
}
var div = document.createElement("div")
@@ -180,16 +180,16 @@ class Scoresheet{
}
}
redraw(){
if(!this.redrawRunning){
redraw() {
if (!this.redrawRunning) {
return
}
if(this.redrawing){
if (this.redrawing) {
requestAnimationFrame(this.redrawBind)
}
var ms = this.getMS()
if(!this.redrawRunning){
if (!this.redrawRunning) {
return
}
@@ -200,11 +200,11 @@ class Scoresheet{
var winH = lastHeight
this.pixelRatio = window.devicePixelRatio || 1
var resolution = settings.getItem("resolution")
if(resolution === "medium"){
if (resolution === "medium") {
this.pixelRatio *= 0.75
}else if(resolution === "low"){
} else if (resolution === "low") {
this.pixelRatio *= 0.5
}else if(resolution === "lowest"){
} else if (resolution === "lowest") {
this.pixelRatio *= 0.25
}
winW *= this.pixelRatio
@@ -213,8 +213,8 @@ class Scoresheet{
var ratioY = winH / 720
var ratio = (ratioX < ratioY ? ratioX : ratioY)
if(this.redrawing){
if(this.winW !== winW || this.winH !== winH){
if (this.redrawing) {
if (this.winW !== winW || this.winH !== winH) {
this.canvas.width = Math.max(1, winW)
this.canvas.height = Math.max(1, winH)
ctx.scale(ratio, ratio)
@@ -224,37 +224,37 @@ class Scoresheet{
this.canvasCache.resize(winW / ratio, 80 + 1, ratio)
this.nameplateCache.resize(274, 134, ratio + 0.2)
if(!this.multiplayer){
if (!this.multiplayer) {
this.tetsuoHana.style.setProperty("--scale", ratio / this.pixelRatio)
if(this.tetsuoHanaClass === "dance"){
if (this.tetsuoHanaClass === "dance") {
this.tetsuoHana.classList.remove("dance", "dance2")
setTimeout(()=>{
setTimeout(() => {
this.tetsuoHana.classList.add("dance2")
},50)
}else if(this.tetsuoHanaClass === "failed"){
}, 50)
} else if (this.tetsuoHanaClass === "failed") {
this.tetsuoHana.classList.remove("failed")
setTimeout(()=>{
setTimeout(() => {
this.tetsuoHana.classList.add("failed")
},50)
}, 50)
}
}
}else if(!document.hasFocus() && this.state.screen === "scoresShown"){
if(this.state["countup0"]){
} else if (!document.hasFocus() && this.state.screen === "scoresShown") {
if (this.state["countup0"]) {
this.stopSound("se_results_countup", 0)
}
if(this.state["countup1"]){
if (this.state["countup1"]) {
this.stopSound("se_results_countup", 1)
}
return
}else{
} else {
ctx.clearRect(0, 0, winW / ratio, winH / ratio)
}
}else{
} else {
ctx.scale(ratio, ratio)
if(!this.canvasCache.canvas){
if (!this.canvasCache.canvas) {
this.canvasCache.resize(winW / ratio, 80 + 1, ratio)
}
if(!this.nameplateCache.canvas){
if (!this.nameplateCache.canvas) {
this.nameplateCache.resize(274, 67, ratio + 0.2)
}
}
@@ -272,14 +272,14 @@ class Scoresheet{
var bgOffset = 0
var elapsed = ms - this.state.screenMS
if(this.state.screen === "fadeIn" && elapsed < 1000){
if (this.state.screen === "fadeIn" && elapsed < 1000) {
bgOffset = Math.min(1, this.draw.easeIn(1 - elapsed / 1000)) * (winH / 2)
}
if((this.state.screen !== "fadeIn" || elapsed >= 1000) && !this.scoreSaved){
if ((this.state.screen !== "fadeIn" || elapsed >= 1000) && !this.scoreSaved) {
this.saveScore()
}
if(bgOffset){
if (bgOffset) {
ctx.save()
ctx.translate(0, -bgOffset)
}
@@ -297,7 +297,7 @@ class Scoresheet{
ctx.fillRect(0, winH / 2 - 12, winW, 12)
ctx.fillStyle = "rgba(0, 0, 0, 0.25)"
ctx.fillRect(0, winH / 2, winW, 20)
if(bgOffset !== 0){
if (bgOffset !== 0) {
ctx.fillStyle = "#000"
ctx.fillRect(0, winH / 2 - 2, winW, 2)
}
@@ -306,7 +306,7 @@ class Scoresheet{
ctx.fillStyle = "#bf2900"
ctx.fillRect(0, frameTop + 64, winW, 8)
if(bgOffset){
if (bgOffset) {
ctx.restore()
ctx.save()
ctx.translate(0, bgOffset)
@@ -325,9 +325,9 @@ class Scoresheet{
ctx.fillStyle = this.multiplayer ? "rgba(138, 245, 247, 0.5)" : "rgba(249, 163, 149, 0.5)"
ctx.fillRect(0, winH / 2, winW, 12)
ctx.fillStyle = "#000"
if(bgOffset === 0){
if (bgOffset === 0) {
ctx.fillRect(0, winH / 2 - 2, winW, 4)
}else{
} else {
ctx.fillRect(0, winH / 2, winW, 2)
}
ctx.fillStyle = this.multiplayer ? "#6bbec0" : "#fa4529"
@@ -337,46 +337,46 @@ class Scoresheet{
ctx.fillStyle = this.multiplayer ? "#a8e0e0" : "#ff9b7a"
ctx.fillRect(0, winH - frameTop - 66, winW, 2)
if(bgOffset){
if (bgOffset) {
ctx.restore()
}
if(this.state.screen === "scoresShown" || this.state.screen === "fadeOut"){
if (this.state.screen === "scoresShown" || this.state.screen === "fadeOut") {
var elapsed = Infinity
}else if(this.redrawing){
} else if (this.redrawing) {
var elapsed = ms - this.state.screenMS - this.state.startDelay
}else{
} else {
var elapsed = 0
}
var rules = this.controller.game.rules
var failedOffset = rules.clearReached(this.results[this.player[0]].gauge) ? 0 : -2000
if(players === 2 && failedOffset !== 0){
if (players === 2 && failedOffset !== 0) {
var p2results = this.results[this.player[1]]
if(p2results && this.controller.syncWith.game.rules.clearReached(p2results.gauge)){
if (p2results && this.controller.syncWith.game.rules.clearReached(p2results.gauge)) {
failedOffset = 0
}
}
if(elapsed >= 3100 + failedOffset){
for(var p = 0; p < players; p++){
if (elapsed >= 3100 + failedOffset) {
for (var p = 0; p < players; p++) {
ctx.save()
var results = this.results[p]
if(!results){
if (!results) {
continue
}
var clear = this.rules[p].clearReached(results.gauge)
if(p === 1 || !this.multiplayer && clear){
if (p === 1 || !this.multiplayer && clear) {
ctx.translate(0, 290)
}
if(clear){
if (clear) {
ctx.globalCompositeOperation = "lighter"
}
ctx.globalAlpha = Math.min(1, Math.max(0, (elapsed - (3100 + failedOffset)) / 500)) * 0.5
var grd = ctx.createLinearGradient(0, frameTop + 72, 0, frameTop + 368)
grd.addColorStop(0, "#000")
if(clear){
if (clear) {
grd.addColorStop(1, "#ffffba")
}else{
} else {
grd.addColorStop(1, "transparent")
}
ctx.fillStyle = grd
@@ -385,10 +385,10 @@ class Scoresheet{
}
}
if(elapsed >= 0){
if(this.state.hasPointer === 0){
if (elapsed >= 0) {
if (this.state.hasPointer === 0) {
this.state.hasPointer = 1
if(!this.state.pointerLocked){
if (!this.state.pointerLocked) {
this.canvas.style.cursor = this.session ? "" : "pointer"
}
}
@@ -416,13 +416,13 @@ class Scoresheet{
letterSpacing: strings.id === "en" ? 0 : 3,
forceShadow: true
}, [
{x: -2, y: -2, outline: "#000", letterBorder: 22},
{ x: -2, y: -2, outline: "#000", letterBorder: 22 },
{},
{x: 2, y: 2, shadow: [2, 2, 7]},
{x: 2, y: 2, outline: "#ad1516", letterBorder: 10},
{x: -2, y: -2, outline: "#ff797b"},
{outline: "#f70808"},
{fill: "#fff", shadow: [-1, 1, 3, 1.5]}
{ x: 2, y: 2, shadow: [2, 2, 7] },
{ x: 2, y: 2, outline: "#ad1516", letterBorder: 10 },
{ x: -2, y: -2, outline: "#ff797b" },
{ outline: "#f70808" },
{ fill: "#fff", shadow: [-1, 1, 3, 1.5] }
])
this.draw.layeredText({
@@ -436,18 +436,18 @@ class Scoresheet{
align: "right",
forceShadow: true
}, [
{outline: "#000", letterBorder: 10, shadow: [1, 1, 3]},
{fill: "#fff"}
{ outline: "#000", letterBorder: 10, shadow: [1, 1, 3] },
{ fill: "#fff" }
])
})
ctx.save()
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
var results = this.results[p]
if(!results){
if (!results) {
continue
}
if(p === 1){
if (p === 1) {
ctx.translate(0, p2Offset)
}
@@ -470,9 +470,9 @@ class Scoresheet{
ctx.miterLimit = 10
var defaultName = p === 0 ? strings.defaultName : strings.default2PName
if(p === this.player[0]){
if (p === this.player[0]) {
var name = account.loggedIn ? account.displayName : defaultName
}else{
} else {
var name = results.name || defaultName
}
this.nameplateCache.get({
@@ -493,7 +493,7 @@ class Scoresheet{
})
})
if(this.controller.autoPlayEnabled){
if (this.controller.autoPlayEnabled) {
ctx.drawImage(assets.image["badge_auto"],
431, 311, 34, 34
)
@@ -549,8 +549,8 @@ class Scoresheet{
align: "right",
width: 36
}, [
{fill: "#fff"},
{outline: "#000", letterBorder: 0.5}
{ fill: "#fff" },
{ outline: "#000", letterBorder: 0.5 }
])
this.draw.score({
@@ -590,8 +590,8 @@ class Scoresheet{
width: 154,
letterSpacing: strings.id === "ja" ? 1 : 0
}, [
{outline: "#000", letterBorder: 8},
{fill: grd}
{ outline: "#000", letterBorder: 8 },
{ fill: grd }
])
this.draw.layeredText({
ctx: ctx,
@@ -604,8 +604,8 @@ class Scoresheet{
width: 154,
letterSpacing: strings.id === "ja" ? 4 : 0
}, [
{outline: "#000", letterBorder: 8},
{fill: "#ffc700"}
{ outline: "#000", letterBorder: 8 },
{ fill: "#ffc700" }
])
}
ctx.restore()
@@ -613,15 +613,15 @@ class Scoresheet{
ctx.restore()
}
if(!this.multiplayer){
if(elapsed >= 400 && elapsed < 3100 + failedOffset){
if(this.tetsuoHanaClass !== "fadein"){
if (!this.multiplayer) {
if (elapsed >= 400 && elapsed < 3100 + failedOffset) {
if (this.tetsuoHanaClass !== "fadein") {
this.tetsuoHana.classList.add("fadein")
this.tetsuoHanaClass = "fadein"
}
}else if(elapsed >= 3100 + failedOffset){
if(this.tetsuoHanaClass !== "dance" && this.tetsuoHanaClass !== "failed"){
if(this.tetsuoHanaClass){
} else if (elapsed >= 3100 + failedOffset) {
if (this.tetsuoHanaClass !== "dance" && this.tetsuoHanaClass !== "failed") {
if (this.tetsuoHanaClass) {
this.tetsuoHana.classList.remove(this.tetsuoHanaClass)
}
this.tetsuoHanaClass = this.rules[this.player[0]].clearReached(this.results[this.player[0]].gauge) ? "dance" : "failed"
@@ -630,19 +630,19 @@ class Scoresheet{
}
}
if(elapsed >= 800){
if (elapsed >= 800) {
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
this.draw.alpha(Math.min(1, (elapsed - 800) / 500), ctx, ctx => {
ctx.scale(ratio, ratio)
ctx.translate(frameLeft, frameTop)
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
var results = this.results[p]
if(!results){
if (!results) {
continue
}
if(p === 1){
if (p === 1) {
ctx.translate(0, p2Offset)
}
var w = 712
@@ -670,50 +670,50 @@ class Scoresheet{
ctx.restore()
}
if(elapsed >= 1200){
if (elapsed >= 1200) {
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
var noCrownResultWait = -2000;
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
var results = this.results[p]
if(!results){
if (!results) {
continue
}
var crownType = null
if(this.rules[p].clearReached(results.gauge)){
if (this.rules[p].clearReached(results.gauge)) {
crownType = results.bad === "0" ? "gold" : "silver"
}
if(crownType !== null){
if (crownType !== null) {
noCrownResultWait = 0;
var amount = Math.min(1, (elapsed - 1200) / 450)
this.draw.alpha(this.draw.easeIn(amount), ctx, ctx => {
ctx.save()
ctx.scale(ratio, ratio)
ctx.translate(frameLeft, frameTop)
if(p === 1){
if (p === 1) {
ctx.translate(0, p2Offset)
}
var crownScale = 1
var shine = 0
if(amount < 1){
if (amount < 1) {
crownScale = 2.8 * (1 - amount) + 0.9
}else if(elapsed < 1850){
} else if (elapsed < 1850) {
crownScale = 0.9 + (elapsed - 1650) / 2000
}else if(elapsed < 2200){
} else if (elapsed < 2200) {
shine = (elapsed - 1850) / 175
if(shine > 1){
if (shine > 1) {
shine = 2 - shine
}
}
if(this.state.screen === "fadeIn" && elapsed >= 1200 && !this.state["fullcomboPlayed" + p]){
if (this.state.screen === "fadeIn" && elapsed >= 1200 && !this.state["fullcomboPlayed" + p]) {
this.state["fullcomboPlayed" + p] = true
if(crownType === "gold"){
if (crownType === "gold") {
this.playSound("v_results_fullcombo" + (p === 1 ? "2" : ""), p)
}
}
if(this.state.screen === "fadeIn" && elapsed >= 1650 && !this.state["crownPlayed" + p]){
if (this.state.screen === "fadeIn" && elapsed >= 1650 && !this.state["crownPlayed" + p]) {
this.state["crownPlayed" + p] = true
this.playSound("se_results_crown", p)
}
@@ -735,51 +735,51 @@ class Scoresheet{
ctx.restore()
}
if(elapsed >= 2400 + noCrownResultWait){
if (elapsed >= 2400 + noCrownResultWait) {
ctx.save()
ctx.translate(frameLeft, frameTop)
var printNumbers = ["good", "ok", "bad", "maxCombo", "drumroll"]
if(!this.state["countupTime0"]){
if (!this.state["countupTime0"]) {
var times = {}
var lastTime = 0
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
var results = this.results[p]
if(!results){
if (!results) {
continue
}
var currentTime = 3100 + noCrownResultWait + results.points.length * 30 * this.frame
if(currentTime > lastTime){
if (currentTime > lastTime) {
lastTime = currentTime
}
}
for(var i in printNumbers){
for (var i in printNumbers) {
var largestTime = 0
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
var results = this.results[p]
if(!results){
if (!results) {
continue
}
times[printNumbers[i]] = lastTime + 500
var currentTime = lastTime + 500 + results[printNumbers[i]].length * 30 * this.frame
if(currentTime > largestTime){
if (currentTime > largestTime) {
largestTime = currentTime
}
}
lastTime = largestTime
}
this.state.fadeInEnd = lastTime
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
this.state["countupTime" + p] = times
}
}
for(var p = 0; p < players; p++){
for (var p = 0; p < players; p++) {
var results = this.results[p]
if(!results){
if (!results) {
continue
}
if(p === 1){
if (p === 1) {
ctx.translate(0, p2Offset)
}
ctx.save()
@@ -795,24 +795,24 @@ class Scoresheet{
ctx.fillStyle = "#fff"
ctx.strokeStyle = "#fff"
ctx.lineWidth = 0.5
for(var i = 0; i < points.length; i++){
for (var i = 0; i < points.length; i++) {
ctx.translate(-23.3 * scale, 0)
ctx.fillText(points[points.length - i - 1], 0, 0)
ctx.strokeText(points[points.length - i - 1], 0, 0)
}
ctx.restore()
if(!this.state["countupTime" + p]){
if (!this.state["countupTime" + p]) {
var times = {}
var lastTime = 3100 + noCrownResultWait + results.points.length * 30 * this.frame + 1000
for(var i in printNumbers){
for (var i in printNumbers) {
times[printNumbers[i]] = lastTime + 500
lastTime = lastTime + 500 + results[printNumbers[i]].length * 30 * this.frame
}
this.state["countupTime" + p] = times
}
for(var i in printNumbers){
for (var i in printNumbers) {
var start = this.state["countupTime" + p][printNumbers[i]]
this.draw.layeredText({
ctx: ctx,
@@ -824,25 +824,25 @@ class Scoresheet{
letterSpacing: 1,
align: "right"
}, [
{outline: "#000", letterBorder: 9},
{fill: "#fff"}
{ outline: "#000", letterBorder: 9 },
{ fill: "#fff" }
])
}
if(this.state.countupShown){
if(!this.state["countup" + p]){
if (this.state.countupShown) {
if (!this.state["countup" + p]) {
this.state["countup" + p] = true
this.loopSound("se_results_countup", p, [0.1, false, 0, 0, 0.07])
}
}else if(this.state["countup" + p]){
} else if (this.state["countup" + p]) {
this.state["countup" + p] = false
this.stopSound("se_results_countup", p)
if(this.state.screen === "fadeIn"){
if (this.state.screen === "fadeIn") {
this.playSound("neiro_1_don", p)
}
}
if(this.state.screen === "fadeIn" && elapsed >= this.state.fadeInEnd){
if (this.state.screen === "fadeIn" && elapsed >= this.state.fadeInEnd) {
this.state.screen = "scoresShown"
this.state.screenMS = this.getMS()
}
@@ -850,28 +850,28 @@ class Scoresheet{
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
if(p2.session){
if (p2.session) {
p2.send("songsel")
}else{
} else {
this.toSongsel(true)
}
}
if(this.state.screen === "fadeOut"){
if(this.state.hasPointer === 1){
if (this.state.screen === "fadeOut") {
if (this.state.hasPointer === 1) {
this.state.hasPointer = 2
this.canvas.style.cursor = ""
}
if(!this.fadeScreenBlack){
if (!this.fadeScreenBlack) {
this.fadeScreenBlack = true
this.fadeScreen.style.backgroundColor = "#000"
}
var elapsed = ms - this.state.screenMS
if(elapsed >= 1000){
if (elapsed >= 1000) {
this.clean()
this.controller.songSelection(true, this.showWarning)
}
@@ -880,80 +880,106 @@ class Scoresheet{
ctx.restore()
}
getNumber(score, start, elapsed){
getNumber(score, start, elapsed) {
var numberPos = Math.floor((elapsed - start) / this.frame)
if(numberPos < 0){
if (numberPos < 0) {
return ""
}
var output = ""
for(var i = 0; i < score.length; i++){
if(numberPos < 30 * (i + 1)){
for (var i = 0; i < score.length; i++) {
if (numberPos < 30 * (i + 1)) {
this.state.countupShown = true
return this.numbers[numberPos % 30] + output
}else{
} else {
output = score[score.length - i - 1] + output
}
}
return output
}
getSound(id, p){
getSound(id, p) {
return assets.sounds[id + (this.multiplayer ? "_p" + (p + 1) : "")]
}
playSound(id, p){
playSound(id, p) {
this.getSound(id, p).play()
}
loopSound(id, p, args){
loopSound(id, p, args) {
this.getSound(id, p).playLoop(...args)
}
stopSound(id, p){
stopSound(id, p) {
this.getSound(id, p).stop()
}
mod(length, index){
mod(length, index) {
return ((index % length) + length) % length
}
getMS(){
getMS() {
return Date.now()
}
saveScore(){
if(this.controller.saveScore){
if(this.resultsObj.points < 0){
saveScore() {
if (this.controller.saveScore) {
if (this.resultsObj.points < 0) {
this.resultsObj.points = 0
}
var title = this.controller.selectedSong.originalTitle
var hash = this.controller.selectedSong.hash
var difficulty = this.resultsObj.difficulty
var songId = this.controller.selectedSong.id
var oldScore = scoreStorage.get(hash, difficulty, true)
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
var crown = ""
if(clearReached){
if (clearReached) {
crown = this.resultsObj.bad === 0 ? "gold" : "silver"
}
if(!oldScore || oldScore.points <= this.resultsObj.points){
if(oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)){
if (!oldScore || oldScore.points <= this.resultsObj.points) {
if (oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)) {
crown = oldScore.crown
}
this.resultsObj.crown = crown
delete this.resultsObj.title
delete this.resultsObj.difficulty
delete this.resultsObj.gauge
scoreStorage.add(hash, difficulty, this.resultsObj, true, title).catch(() => {
this.showWarning = {name: "scoreSaveFailed"}
scoreStorage.add(hash, difficulty, this.resultsObj, true, title).then(() => {
// Auto-submit to leaderboard if logged in and has song ID
this.submitToLeaderboard(songId, difficulty, this.resultsObj)
}).catch(() => {
this.showWarning = { name: "scoreSaveFailed" }
})
}else if(oldScore && (crown === "gold" && oldScore.crown !== "gold" || crown && !oldScore.crown)){
} else if (oldScore && (crown === "gold" && oldScore.crown !== "gold" || crown && !oldScore.crown)) {
oldScore.crown = crown
scoreStorage.add(hash, difficulty, oldScore, true, title).catch(() => {
this.showWarning = {name: "scoreSaveFailed"}
this.showWarning = { name: "scoreSaveFailed" }
})
}
}
this.scoreSaved = true
}
clean(){
submitToLeaderboard(songId, difficulty, scoreObj) {
// Only submit if user is logged in and song has valid ID
if (!account.loggedIn || !songId) {
return
}
loader.getCsrfToken().then(token => {
var request = new XMLHttpRequest()
request.open("POST", "api/leaderboard/submit")
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8")
request.setRequestHeader("X-CSRFToken", token)
request.send(JSON.stringify({
song_id: songId,
difficulty: difficulty,
score: scoreObj
}))
}).catch(() => {
// Silently fail - leaderboard submission is optional
console.log("Leaderboard submission failed")
})
}
clean() {
this.keyboard.clean()
this.gamepad.clean()
this.draw.clean()
@@ -962,13 +988,13 @@ class Scoresheet{
snd.buffer.loadSettings()
this.redrawRunning = false
pageEvents.remove(this.canvas, ["mousedown", "touchstart"])
if(this.touchEnabled){
if (this.touchEnabled) {
pageEvents.remove(document.getElementById("touch-full-btn"), "touchend")
}
if(this.session){
if (this.session) {
pageEvents.remove(p2, "message")
}
if(!this.multiplayer){
if (!this.multiplayer) {
delete this.tetsuoHana
}
delete this.ctx

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

@@ -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']
}

View File

@@ -1,12 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="description" content="2025年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
<meta name="description"
content="2026年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
<meta name="keywords"
content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
<meta name="robots" content="notranslate">
<meta name="robots" content="noimageindex">
<meta name="color-scheme" content="only light">
@@ -24,14 +27,17 @@
<script src="src/js/lib/jszip.js"></script>
</head>
<body>
<div id="assets"></div>
<div id="screen" class="pattern-bg"></div>
<div data-nosnippet id="version">
{% if version.version and version.commit_short and version.commit %}
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link"
class="stroke-sub" alt="vLightNova 1.1.0">vLightNova 1.1.0</a>
{% else %}
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.1.0">vLightNova
1.1.0</a>
{% endif %}
</div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
@@ -43,4 +49,5 @@
</div>
</noscript>
</body>
</html>