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 = "🏆 提交排行榜
Submit to Leaderboard" 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 = "✅ 已提交
Submitted!" self.leaderboardBtn.style.background = "linear-gradient(135deg, #4CAF50, #45a049)" } } else { // Show error self.showLeaderboardNotification("error") if (self.leaderboardBtn) { self.leaderboardBtn.innerHTML = "❌ 失败
Failed" 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 = "❌ 失败
Failed" self.leaderboardBtn.style.background = "linear-gradient(135deg, #f44336, #d32f2f)" } } } request.onerror = function () { self.showLeaderboardNotification("error") if (self.leaderboardBtn) { self.leaderboardBtn.innerHTML = "❌ 失败
Failed" 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 = "❌ 失败
Failed" 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 } }