Files
taiko-web-del/public/src/js/scoresheet.js

1210 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class Scoresheet {
constructor(...args) {
this.init(...args)
}
init(controller, results, multiplayer, touchEnabled) {
this.controller = controller
this.resultsObj = results
this.player = [multiplayer ? (p2.player === 1 ? 0 : 1) : 0]
var player0 = this.player[0]
this.results = []
this.results[player0] = {}
this.rules = []
this.rules[player0] = this.controller.game.rules
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) {
this.results[player0][i] = results[i] === null ? null : results[i].toString()
}
this.multiplayer = multiplayer
this.touchEnabled = touchEnabled
this.canvas = document.getElementById("canvas")
this.ctx = this.canvas.getContext("2d")
var resolution = settings.getItem("resolution")
var noSmoothing = resolution === "low" || resolution === "lowest"
if (noSmoothing) {
this.ctx.imageSmoothingEnabled = false
}
if (resolution === "lowest") {
this.canvas.style.imageRendering = "pixelated"
}
this.game = document.getElementById("game")
this.fadeScreen = document.createElement("div")
this.fadeScreen.id = "fade-screen"
this.game.appendChild(this.fadeScreen)
this.font = strings.font
this.numbersFont = "TnT, Meiryo, sans-serif"
this.state = {
screen: "fadeIn",
screenMS: this.getMS(),
startDelay: 3300,
hasPointer: 0,
scoreNext: false
}
this.frame = 1000 / 60
this.numbers = "001122334455667788900112233445".split("")
this.draw = new CanvasDraw(noSmoothing)
this.canvasCache = new CanvasCache(noSmoothing)
this.nameplateCache = new CanvasCache(noSmoothing)
this.keyboard = new Keyboard({
confirm: ["enter", "space", "esc", "don_l", "don_r"]
}, this.keyDown.bind(this))
this.gamepad = new Gamepad({
confirm: ["a", "b", "start", "ls", "rs"]
}, this.keyDown.bind(this))
this.difficulty = {
"easy": 0,
"normal": 1,
"hard": 2,
"oni": 3,
"ura": 4
}
this.scoreSaved = false
this.redrawRunning = true
this.redrawBind = this.redraw.bind(this)
this.redraw()
assets.sounds["v_results"].play()
assets.sounds["bgm_result"].playLoop(3, false, 0, 0.847, 17.689)
this.session = p2.session
if (this.session) {
if (p2.getMessage("songsel")) {
this.toSongsel(true)
}
pageEvents.add(p2, "message", response => {
if (response.type === "songsel") {
this.toSongsel(true)
}
})
}
pageEvents.send("scoresheet", {
selectedSong: controller.selectedSong,
autoPlayEnabled: controller.autoPlayEnabled,
multiplayer: multiplayer,
touchEnabled: touchEnabled,
results: this.results,
p2results: multiplayer ? p2.results : null,
keyboardEvents: controller.keyboard.keyboardEvents,
gamepadEvents: controller.keyboard.gamepad.gamepadEvents,
touchEvents: controller.view.touchEvents
})
}
keyDown(pressed) {
if (pressed && this.redrawing) {
this.toNext()
}
}
mouseDown(event) {
if (event.type === "touchstart") {
event.preventDefault()
this.canvas.style.cursor = ""
this.state.pointerLocked = true
} else {
this.state.pointerLocked = false
if (event.which !== 1) {
return
}
}
this.toNext()
}
toNext() {
var elapsed = this.getMS() - this.state.screenMS
if (this.state.screen === "fadeIn" && elapsed >= this.state.startDelay) {
this.toScoresShown()
} else if (this.state.screen === "scoresShown" && elapsed >= 1000) {
this.toSongsel()
}
}
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) {
snd.musicGain.fadeOut(0.5)
this.state.screen = "fadeOut"
this.state.screenMS = this.getMS()
if (!fromP2) {
this.controller.playSound("neiro_1_don", 0, true)
}
}
}
startRedraw() {
this.redrawing = true
requestAnimationFrame(this.redrawBind)
this.winW = null
this.winH = null
pageEvents.add(this.canvas, ["mousedown", "touchstart"], this.mouseDown.bind(this))
if (!this.multiplayer) {
this.tetsuoHana = document.createElement("div")
this.tetsuoHana.id = "tetsuohana"
var flowersBg = "url('" + assets.image["results_flowers"].src + "')"
var mikoshiBg = "url('" + assets.image["results_mikoshi"].src + "')"
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") {
var divOut = document.createElement("div")
divOut.id = id[i] + "-out"
this.tetsuoHana.appendChild(divOut)
} else {
var divOut = this.tetsuoHana
}
var div = document.createElement("div")
div.id = id[i]
var divIn = document.createElement("div")
divIn.id = id[i] + "-in"
divIn.style.backgroundImage = bg[i]
div.appendChild(divIn)
divOut.appendChild(div)
}
this.game.appendChild(this.tetsuoHana)
}
// Add leaderboard submit button if user is logged in
if (account.loggedIn && !this.multiplayer) {
this.leaderboardBtn = document.createElement("div")
this.leaderboardBtn.id = "leaderboard-submit-btn"
this.leaderboardBtn.innerHTML = "🏆 提交排行榜<br><small>Submit to Leaderboard</small>"
this.leaderboardBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
background: linear-gradient(135deg, #ff6b9d, #c44db3);
color: white;
border-radius: 15px;
font-size: 18px;
font-family: ${strings.font || "sans-serif"};
text-align: center;
cursor: pointer;
z-index: 1000;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
border: 3px solid #fff;
transition: all 0.2s ease;
user-select: none;
`
this.leaderboardBtn.onmouseover = () => {
if (!this.leaderboardSubmitted) {
this.leaderboardBtn.style.transform = "scale(1.05)"
this.leaderboardBtn.style.boxShadow = "0 6px 20px rgba(0,0,0,0.4)"
}
}
this.leaderboardBtn.onmouseout = () => {
this.leaderboardBtn.style.transform = "scale(1)"
this.leaderboardBtn.style.boxShadow = "0 4px 15px rgba(0,0,0,0.3)"
}
this.leaderboardBtn.onclick = () => this.onLeaderboardBtnClick()
this.game.appendChild(this.leaderboardBtn)
}
}
onLeaderboardBtnClick() {
if (this.leaderboardSubmitted) {
return
}
// Fallback: This is CRITICAL for cases where saveScore() failed or was skipped
if (!this.leaderboardData) {
console.warn("Leaderboard data missing, attempting to reconstruct...")
var song = this.controller.selectedSong
var results = this.resultsObj
if (song && results) {
var songId = song.id || song.hash
if (songId) {
this.leaderboardData = {
songId: songId,
difficulty: results.difficulty,
scoreObj: Object.assign({ score: results.points }, results)
}
// Clean up scoreObj
if (this.leaderboardData.scoreObj) {
delete this.leaderboardData.scoreObj.title
delete this.leaderboardData.scoreObj.difficulty
delete this.leaderboardData.scoreObj.gauge
}
console.log("Leaderboard data reconstructed:", this.leaderboardData)
}
}
}
if (!this.leaderboardData || !this.leaderboardData.songId) {
this.showLeaderboardNotification("no_song_id")
return
}
// Disable button and show submitting state
this.leaderboardBtn.innerHTML = "⏳ 提交中..."
this.leaderboardBtn.style.background = "linear-gradient(135deg, #888, #666)"
this.leaderboardBtn.style.cursor = "default"
this.submitToLeaderboard(
this.leaderboardData.songId,
this.leaderboardData.difficulty,
this.leaderboardData.scoreObj
)
}
redraw() {
if (!this.redrawRunning) {
return
}
if (this.redrawing) {
requestAnimationFrame(this.redrawBind)
}
var ms = this.getMS()
if (!this.redrawRunning) {
return
}
var ctx = this.ctx
ctx.save()
var winW = innerWidth
var winH = lastHeight
this.pixelRatio = window.devicePixelRatio || 1
var resolution = settings.getItem("resolution")
if (resolution === "medium") {
this.pixelRatio *= 0.75
} else if (resolution === "low") {
this.pixelRatio *= 0.5
} else if (resolution === "lowest") {
this.pixelRatio *= 0.25
}
winW *= this.pixelRatio
winH *= this.pixelRatio
var ratioX = winW / 1280
var ratioY = winH / 720
var ratio = (ratioX < ratioY ? ratioX : ratioY)
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)
this.canvas.style.width = (winW / this.pixelRatio) + "px"
this.canvas.style.height = (winH / this.pixelRatio) + "px"
this.canvasCache.resize(winW / ratio, 80 + 1, ratio)
this.nameplateCache.resize(274, 134, ratio + 0.2)
if (!this.multiplayer) {
this.tetsuoHana.style.setProperty("--scale", ratio / this.pixelRatio)
if (this.tetsuoHanaClass === "dance") {
this.tetsuoHana.classList.remove("dance", "dance2")
setTimeout(() => {
this.tetsuoHana.classList.add("dance2")
}, 50)
} else if (this.tetsuoHanaClass === "failed") {
this.tetsuoHana.classList.remove("failed")
setTimeout(() => {
this.tetsuoHana.classList.add("failed")
}, 50)
}
}
} else if (!document.hasFocus() && this.state.screen === "scoresShown") {
if (this.state["countup0"]) {
this.stopSound("se_results_countup", 0)
}
if (this.state["countup1"]) {
this.stopSound("se_results_countup", 1)
}
return
} else {
ctx.clearRect(0, 0, winW / ratio, winH / ratio)
}
} else {
ctx.scale(ratio, ratio)
if (!this.canvasCache.canvas) {
this.canvasCache.resize(winW / ratio, 80 + 1, ratio)
}
if (!this.nameplateCache.canvas) {
this.nameplateCache.resize(274, 67, ratio + 0.2)
}
}
this.winW = winW
this.winH = winH
this.ratio = ratio
winW /= ratio
winH /= ratio
var frameTop = winH / 2 - 720 / 2
var frameLeft = winW / 2 - 1280 / 2
var players = this.multiplayer ? 2 : 1
var p2Offset = 298
var bgOffset = 0
var elapsed = ms - this.state.screenMS
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) {
this.saveScore()
}
if (bgOffset) {
ctx.save()
ctx.translate(0, -bgOffset)
}
this.draw.pattern({
ctx: ctx,
img: assets.image["bg_score_p1"],
x: 0,
y: 0,
w: winW,
h: winH / 2,
dx: frameLeft - 35,
dy: frameTop + 17
})
ctx.fillStyle = "rgba(127, 28, 12, 0.5)"
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) {
ctx.fillStyle = "#000"
ctx.fillRect(0, winH / 2 - 2, winW, 2)
}
ctx.fillStyle = "#fa4529"
ctx.fillRect(0, 0, winW, frameTop + 64)
ctx.fillStyle = "#bf2900"
ctx.fillRect(0, frameTop + 64, winW, 8)
if (bgOffset) {
ctx.restore()
ctx.save()
ctx.translate(0, bgOffset)
}
this.draw.pattern({
ctx: ctx,
img: assets.image[this.multiplayer ? "bg_score_p2" : "bg_score_p1"],
x: 0,
y: winH / 2,
w: winW,
h: winH / 2,
dx: frameLeft - 35,
dy: frameTop - 17
})
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) {
ctx.fillRect(0, winH / 2 - 2, winW, 4)
} else {
ctx.fillRect(0, winH / 2, winW, 2)
}
ctx.fillStyle = this.multiplayer ? "#6bbec0" : "#fa4529"
ctx.fillRect(0, winH - frameTop - 64, winW, frameTop + 64)
ctx.fillStyle = this.multiplayer ? "rgba(160, 228, 229, 0.8)" : "rgba(255, 144, 116, 0.8)"
ctx.fillRect(0, winH - frameTop - 72, winW, 7)
ctx.fillStyle = this.multiplayer ? "#a8e0e0" : "#ff9b7a"
ctx.fillRect(0, winH - frameTop - 66, winW, 2)
if (bgOffset) {
ctx.restore()
}
if (this.state.screen === "scoresShown" || this.state.screen === "fadeOut") {
var elapsed = Infinity
} else if (this.redrawing) {
var elapsed = ms - this.state.screenMS - this.state.startDelay
} 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) {
var p2results = this.results[this.player[1]]
if (p2results && this.controller.syncWith.game.rules.clearReached(p2results.gauge)) {
failedOffset = 0
}
}
if (elapsed >= 3100 + failedOffset) {
for (var p = 0; p < players; p++) {
ctx.save()
var results = this.results[p]
if (!results) {
continue
}
var clear = this.rules[p].clearReached(results.gauge)
if (p === 1 || !this.multiplayer && clear) {
ctx.translate(0, 290)
}
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) {
grd.addColorStop(1, "#ffffba")
} else {
grd.addColorStop(1, "transparent")
}
ctx.fillStyle = grd
ctx.fillRect(0, frameTop + 72, winW, 286)
ctx.restore()
}
}
if (elapsed >= 0) {
if (this.state.hasPointer === 0) {
this.state.hasPointer = 1
if (!this.state.pointerLocked) {
this.canvas.style.cursor = this.session ? "" : "pointer"
}
}
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
this.draw.alpha(Math.min(1, elapsed / 400), ctx, ctx => {
ctx.scale(ratio, ratio)
ctx.translate(frameLeft, frameTop)
this.canvasCache.get({
ctx: ctx,
x: 0,
y: 0,
w: winW,
h: 80,
id: "results"
}, ctx => {
this.draw.layeredText({
ctx: ctx,
text: strings.results,
fontSize: 48,
fontFamily: this.font,
x: 23,
y: 15,
letterSpacing: strings.id === "en" ? 0 : 3,
forceShadow: true
}, [
{ 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] }
])
this.draw.layeredText({
ctx: ctx,
text: this.results[this.player[0]].title,
fontSize: 40,
fontFamily: this.font,
x: 1257,
y: 20,
width: 600,
align: "right",
forceShadow: true
}, [
{ outline: "#000", letterBorder: 10, shadow: [1, 1, 3] },
{ fill: "#fff" }
])
})
ctx.save()
for (var p = 0; p < players; p++) {
var results = this.results[p]
if (!results) {
continue
}
if (p === 1) {
ctx.translate(0, p2Offset)
}
ctx.drawImage(assets.image["difficulty"],
0, 144 * this.difficulty[results.difficulty],
168, 143,
300, 150, 189, 162
)
var diff = results.difficulty
var text = strings[diff === "ura" ? "oni" : diff]
ctx.font = this.draw.bold(this.font) + "28px " + this.font
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.strokeStyle = "#000"
ctx.fillStyle = "#fff"
ctx.lineWidth = 9
ctx.miterLimit = 1
ctx.strokeText(text, 395, 308)
ctx.fillText(text, 395, 308)
ctx.miterLimit = 10
var defaultName = p === 0 ? strings.defaultName : strings.default2PName
if (p === this.player[0]) {
var name = account.loggedIn ? account.displayName : defaultName
} else {
var name = results.name || defaultName
}
this.nameplateCache.get({
ctx: ctx,
x: 259,
y: 92,
w: 273,
h: 66,
id: p.toString() + "p" + name,
}, ctx => {
this.draw.nameplate({
ctx: ctx,
x: 3,
y: 3,
name: name,
font: this.font,
blue: p === 1
})
})
if (this.controller.autoPlayEnabled) {
ctx.drawImage(assets.image["badge_auto"],
431, 311, 34, 34
)
}
this.draw.roundedRect({
ctx: ctx,
x: 532,
y: 98,
w: 728,
h: 232,
radius: 30,
})
ctx.fillStyle = p === 1 ? "rgba(195, 228, 229, 0.8)" : "rgba(255, 224, 216, 0.8)"
ctx.fill()
this.draw.roundedRect({
ctx: ctx,
x: 556,
y: 237,
w: 254,
h: 70,
radius: 15,
})
ctx.fillStyle = "#000"
ctx.fill()
this.draw.roundedRect({
ctx: ctx,
x: 559,
y: 240,
w: 248,
h: 64,
radius: 14,
})
ctx.fillStyle = "#eec954"
ctx.fill()
this.draw.roundedRect({
ctx: ctx,
x: 567,
y: 248,
w: 232,
h: 48,
radius: 6,
})
ctx.fillStyle = "#000"
ctx.fill()
this.draw.layeredText({
ctx: ctx,
text: strings.points,
x: 792,
y: strings.id === "ko" ? 260 : 253,
fontSize: 36,
fontFamily: this.font,
align: "right",
width: 36
}, [
{ fill: "#fff" },
{ outline: "#000", letterBorder: 0.5 }
])
this.draw.score({
ctx: ctx,
score: "good",
x: 823,
y: 192,
results: true
})
this.draw.score({
ctx: ctx,
score: "ok",
x: 823,
y: 233,
results: true
})
this.draw.score({
ctx: ctx,
score: "bad",
x: 823,
y: 273,
results: true
})
ctx.textAlign = "right"
var grd = ctx.createLinearGradient(0, 0, 0, 30)
grd.addColorStop(0.2, "#ff4900")
grd.addColorStop(0.9, "#f7fb00")
this.draw.layeredText({
ctx: ctx,
text: strings.maxCombo,
x: 1149,
y: 193,
fontSize: 29,
fontFamily: this.font,
align: "right",
width: 154,
letterSpacing: strings.id === "ja" ? 1 : 0
}, [
{ outline: "#000", letterBorder: 8 },
{ fill: grd }
])
this.draw.layeredText({
ctx: ctx,
text: strings.drumroll,
x: 1150,
y: 233,
fontSize: 29,
fontFamily: this.font,
align: "right",
width: 154,
letterSpacing: strings.id === "ja" ? 4 : 0
}, [
{ outline: "#000", letterBorder: 8 },
{ fill: "#ffc700" }
])
}
ctx.restore()
})
ctx.restore()
}
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) {
this.tetsuoHana.classList.remove(this.tetsuoHanaClass)
}
this.tetsuoHanaClass = this.rules[this.player[0]].clearReached(this.results[this.player[0]].gauge) ? "dance" : "failed"
this.tetsuoHana.classList.add(this.tetsuoHanaClass)
}
}
}
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++) {
var results = this.results[p]
if (!results) {
continue
}
if (p === 1) {
ctx.translate(0, p2Offset)
}
var w = 712
this.draw.gauge({
ctx: ctx,
x: 558 + w,
y: p === 1 ? 124 : 116,
clear: this.rules[p].gaugeClear,
percentage: this.rules[p].gaugePercent(results.gauge),
font: this.font,
scale: w / 788,
scoresheet: true,
blue: p === 1,
multiplayer: p === 1
})
this.draw.soul({
ctx: ctx,
x: 1215,
y: 144,
scale: 36 / 42,
cleared: this.rules[p].clearReached(results.gauge)
})
}
})
ctx.restore()
}
if (elapsed >= 1200) {
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
var noCrownResultWait = -2000;
for (var p = 0; p < players; p++) {
var results = this.results[p]
if (!results) {
continue
}
var crownType = null
if (this.rules[p].clearReached(results.gauge)) {
crownType = results.bad === "0" ? "gold" : "silver"
}
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) {
ctx.translate(0, p2Offset)
}
var crownScale = 1
var shine = 0
if (amount < 1) {
crownScale = 2.8 * (1 - amount) + 0.9
} else if (elapsed < 1850) {
crownScale = 0.9 + (elapsed - 1650) / 2000
} else if (elapsed < 2200) {
shine = (elapsed - 1850) / 175
if (shine > 1) {
shine = 2 - shine
}
}
if (this.state.screen === "fadeIn" && elapsed >= 1200 && !this.state["fullcomboPlayed" + p]) {
this.state["fullcomboPlayed" + p] = true
if (crownType === "gold") {
this.playSound("v_results_fullcombo" + (p === 1 ? "2" : ""), p)
}
}
if (this.state.screen === "fadeIn" && elapsed >= 1650 && !this.state["crownPlayed" + p]) {
this.state["crownPlayed" + p] = true
this.playSound("se_results_crown", p)
}
this.draw.crown({
ctx: ctx,
type: crownType,
x: 395,
y: 218,
scale: crownScale,
shine: shine,
whiteOutline: true,
ratio: ratio
})
ctx.restore()
})
}
}
ctx.restore()
}
if (elapsed >= 2400 + noCrownResultWait) {
ctx.save()
ctx.translate(frameLeft, frameTop)
var printNumbers = ["good", "ok", "bad", "maxCombo", "drumroll"]
if (!this.state["countupTime0"]) {
var times = {}
var lastTime = 0
for (var p = 0; p < players; p++) {
var results = this.results[p]
if (!results) {
continue
}
var currentTime = 3100 + noCrownResultWait + results.points.length * 30 * this.frame
if (currentTime > lastTime) {
lastTime = currentTime
}
}
for (var i in printNumbers) {
var largestTime = 0
for (var p = 0; p < players; p++) {
var results = this.results[p]
if (!results) {
continue
}
times[printNumbers[i]] = lastTime + 500
var currentTime = lastTime + 500 + results[printNumbers[i]].length * 30 * this.frame
if (currentTime > largestTime) {
largestTime = currentTime
}
}
lastTime = largestTime
}
this.state.fadeInEnd = lastTime
for (var p = 0; p < players; p++) {
this.state["countupTime" + p] = times
}
}
for (var p = 0; p < players; p++) {
var results = this.results[p]
if (!results) {
continue
}
if (p === 1) {
ctx.translate(0, p2Offset)
}
ctx.save()
this.state.countupShown = false
var points = this.getNumber(results.points, 3100 + noCrownResultWait, elapsed)
var scale = 1.3
ctx.font = "35px " + this.numbersFont
ctx.translate(760, 286)
ctx.scale(1 / scale, 1 * 1.1)
ctx.textAlign = "center"
ctx.fillStyle = "#fff"
ctx.strokeStyle = "#fff"
ctx.lineWidth = 0.5
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]) {
var times = {}
var lastTime = 3100 + noCrownResultWait + results.points.length * 30 * this.frame + 1000
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) {
var start = this.state["countupTime" + p][printNumbers[i]]
this.draw.layeredText({
ctx: ctx,
text: this.getNumber(results[printNumbers[i]], start, elapsed),
x: 971 + 270 * Math.floor(i / 3),
y: 196 + (40 * (i % 3)),
fontSize: 26,
fontFamily: this.numbersFont,
letterSpacing: 1,
align: "right"
}, [
{ outline: "#000", letterBorder: 9 },
{ fill: "#fff" }
])
}
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]) {
this.state["countup" + p] = false
this.stopSound("se_results_countup", p)
if (this.state.screen === "fadeIn") {
this.playSound("neiro_1_don", p)
}
}
if (this.state.screen === "fadeIn" && elapsed >= this.state.fadeInEnd) {
this.state.screen = "scoresShown"
this.state.screenMS = this.getMS()
}
}
ctx.restore()
}
if (this.session && !this.state.scoreNext && this.state.screen === "scoresShown" && ms - this.state.screenMS >= 10000) {
this.state.scoreNext = true
if (p2.session) {
p2.send("songsel")
} else {
this.toSongsel(true)
}
}
if (this.state.screen === "fadeOut") {
if (this.state.hasPointer === 1) {
this.state.hasPointer = 2
this.canvas.style.cursor = ""
}
if (!this.fadeScreenBlack) {
this.fadeScreenBlack = true
this.fadeScreen.style.backgroundColor = "#000"
}
var elapsed = ms - this.state.screenMS
if (elapsed >= 1000) {
this.clean()
this.controller.songSelection(true, this.showWarning)
}
}
ctx.restore()
}
getNumber(score, start, elapsed) {
var numberPos = Math.floor((elapsed - start) / this.frame)
if (numberPos < 0) {
return ""
}
var output = ""
for (var i = 0; i < score.length; i++) {
if (numberPos < 30 * (i + 1)) {
this.state.countupShown = true
return this.numbers[numberPos % 30] + output
} else {
output = score[score.length - i - 1] + output
}
}
return output
}
getSound(id, p) {
return assets.sounds[id + (this.multiplayer ? "_p" + (p + 1) : "")]
}
playSound(id, p) {
this.getSound(id, p).play()
}
loopSound(id, p, args) {
this.getSound(id, p).playLoop(...args)
}
stopSound(id, p) {
this.getSound(id, p).stop()
}
mod(length, index) {
return ((index % length) + length) % length
}
getMS() {
return Date.now()
}
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
// Use id if available, otherwise use hash
var songId = this.controller.selectedSong.id || hash
var oldScore = scoreStorage.get(hash, difficulty, true)
var clearReached = this.controller.game.rules.clearReached(this.resultsObj.gauge)
var crown = ""
if (clearReached) {
crown = this.resultsObj.bad === 0 ? "gold" : "silver"
}
// Store data for manual leaderboard submission
this.leaderboardData = {
songId: songId,
difficulty: difficulty,
scoreObj: Object.assign({ score: this.resultsObj.points }, this.resultsObj)
}
this.leaderboardSubmitted = false
if (!oldScore || oldScore.points <= this.resultsObj.points) {
if (oldScore && (oldScore.crown === "gold" || oldScore.crown === "silver" && !crown)) {
crown = oldScore.crown
}
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" }
})
} 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.scoreSaved = true
}
submitToLeaderboard(songId, difficulty, scoreObj) {
// Only submit if user is logged in and song has an ID
if (!account.loggedIn || !songId) {
return
}
var self = this
loader.getCsrfToken().then(token => {
var request = new XMLHttpRequest()
request.open("POST", "api/leaderboard/submit")
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8")
request.setRequestHeader("X-CSRFToken", token)
request.onload = function () {
if (request.status === 200) {
try {
var response = JSON.parse(request.responseText)
if (response.status === "ok") {
self.leaderboardSubmitted = true
self.showLeaderboardNotification(response.message)
// Update button to show success
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "✅ 已提交<br><small>Submitted!</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #4CAF50, #45a049)"
}
} else {
// Show error
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
} catch (e) {
console.error("Failed to parse leaderboard response:", e)
self.showLeaderboardNotification("error")
}
} else {
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
}
request.onerror = function () {
self.showLeaderboardNotification("error")
if (self.leaderboardBtn) {
self.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
}
request.send(JSON.stringify({
song_id: songId,
difficulty: difficulty,
score: scoreObj
}))
}).catch(() => {
console.log("Leaderboard submission failed")
this.showLeaderboardNotification("error")
if (this.leaderboardBtn) {
this.leaderboardBtn.innerHTML = "❌ 失败<br><small>Failed</small>"
this.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)"
}
})
}
showLeaderboardNotification(message) {
var notification = document.createElement("div")
notification.className = "leaderboard-notification"
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
background: linear-gradient(135deg, #4a90e2, #7b68ee);
color: white;
border-radius: 10px;
font-size: 16px;
font-family: ${strings.font || "sans-serif"};
z-index: 10000;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
opacity: 0;
transform: translateX(100px);
transition: all 0.3s ease-out;
`
var text = ""
switch (message) {
case "score_submitted": text = "🏆 成绩已提交到排行榜!"; break
case "score_updated": text = "🎉 排行榜成绩已更新!"; break
case "score_not_higher": text = "📊 已有更高成绩"; break
case "score_too_low": text = "未进入排行榜前50"; break
case "error": text = "❌ 提交失败,请重试"; break
case "no_song_id": text = "❌ 无法提交歌曲ID缺失"; break
default: text = "排行榜已更新"
}
notification.innerText = text
document.body.appendChild(notification)
// Trigger animation
setTimeout(() => {
notification.style.opacity = "1"
notification.style.transform = "translateX(0)"
}, 10)
// Remove after 3 seconds
setTimeout(() => {
notification.style.opacity = "0"
notification.style.transform = "translateX(100px)"
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 300)
}, 3000)
}
clean() {
this.keyboard.clean()
this.gamepad.clean()
this.draw.clean()
this.canvasCache.clean()
assets.sounds["bgm_result"].stop()
snd.buffer.loadSettings()
this.redrawRunning = false
pageEvents.remove(this.canvas, ["mousedown", "touchstart"])
if (this.touchEnabled) {
pageEvents.remove(document.getElementById("touch-full-btn"), "touchend")
}
if (this.session) {
pageEvents.remove(p2, "message")
}
if (!this.multiplayer) {
delete this.tetsuoHana
}
// Clean up leaderboard button
if (this.leaderboardBtn && this.leaderboardBtn.parentNode) {
this.leaderboardBtn.parentNode.removeChild(this.leaderboardBtn)
}
delete this.leaderboardBtn
delete this.leaderboardData
delete this.ctx
delete this.canvas
delete this.fadeScreen
delete this.results
delete this.rules
}
}