1210 lines
32 KiB
JavaScript
1210 lines
32 KiB
JavaScript
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
|
||
}
|
||
|
||
}
|