/* * object layering: * assuming number of possible hits doesn't exceed 9998 */ define(["osu", "playerActions", "SliderMesh", "overlay/score", "overlay/volume", "overlay/loading", "overlay/break", "overlay/progress", "overlay/hiterrormeter"], function (Osu, setPlayerActions, SliderMesh, ScoreOverlay, VolumeMenu, LoadingMenu, BreakOverlay, ProgressOverlay, ErrorMeterOverlay) { function clamp01(a) { return Math.min(1, Math.max(0, a)); } function colorLerp(rgb1, rgb2, t) { let r = (1 - t) * ((rgb1 >> 16) / 255) + t * ((rgb2 >> 16) / 255); let g = (1 - t) * (((rgb1 >> 8) & 255) / 255) + t * (((rgb2 >> 8) & 255) / 255); let b = (1 - t) * ((rgb1 & 255) / 255) + t * ((rgb2 & 255) / 255); return Math.round(r * 255) << 16 | Math.round(g * 255) << 8 | Math.round(b * 255); } function repeatclamp(a) { a %= 2; return a > 1 ? 2 - a : a; } function Playback(game, osu, track) { var self = this; window.playback = this; self.game = game; self.osu = osu; self.track = track; self.background = null; self.started = false; self.upcomingHits = []; // creating a copy of hitobjects self.hits = []; _.each(self.track.hitObjects, function (o) { self.hits.push(Object.assign({}, o)); }); self.offset = 0; self.currentHitIndex = 0; // index for all hit objects self.ended = false; // mods self.autoplay = game.autoplay; self.modhidden = game.hidden; self.playbackRate = 1.0; if (self.game.nightcore) self.playbackRate *= 1.5; if (self.game.daycore) self.playbackRate *= 0.75; self.hideNumbers = game.hideNumbers; self.hideGreat = game.hideGreat; self.hideFollowPoints = game.hideFollowPoints; self.approachScale = 3; self.audioReady = false; self.endTime = self.hits[self.hits.length - 1].endTime + 1500; this.wait = Math.max(0, 1500 - this.hits[0].time); self.skipTime = this.hits[0].time / 1000 - 3; self.skipped = false; self.osu.onready = function () { self.loadingMenu.hide(); self.audioReady = true; if (self.onload) self.onload(); self.start(); } self.load = function () { self.osu.load_mp3(self.track); } var gfx = window.gfx = {}; // game field area self.gamefield = new PIXI.Container(); self.calcSize = function () { gfx.width = game.window.innerWidth; gfx.height = game.window.innerHeight; if (gfx.width / 512 > gfx.height / 384) gfx.width = gfx.height / 384 * 512; else gfx.height = gfx.width / 512 * 384; gfx.width *= 0.8; gfx.height *= 0.8; gfx.xoffset = (game.window.innerWidth - gfx.width) / 2; gfx.yoffset = (game.window.innerHeight - gfx.height) / 2; self.gamefield.x = gfx.xoffset; self.gamefield.y = gfx.yoffset; self.gamefield.scale.set(gfx.width / 512); }; self.calcSize(); game.mouseX = 512 / 2; game.mouseY = 384 / 2; self.loadingMenu = new LoadingMenu({ width: game.window.innerWidth, height: game.window.innerHeight }, track); self.volumeMenu = new VolumeMenu({ width: game.window.innerWidth, height: game.window.innerHeight }); self.breakOverlay = new BreakOverlay({ width: game.window.innerWidth, height: game.window.innerHeight }); self.progressOverlay = new ProgressOverlay({ width: game.window.innerWidth, height: game.window.innerHeight }, this.hits[0].time - 1500, this.hits[this.hits.length - 1].endTime); window.onresize = function () { window.app.renderer.resize(window.innerWidth, window.innerHeight); if (self.audioReady) self.pause(); self.calcSize(); self.scoreOverlay.resize({ width: window.innerWidth, height: window.innerHeight }); self.errorMeter.resize({ width: window.innerWidth, height: window.innerHeight }); self.loadingMenu.resize({ width: window.innerWidth, height: window.innerHeight }); self.volumeMenu.resize({ width: window.innerWidth, height: window.innerHeight }); self.breakOverlay.resize({ width: window.innerWidth, height: window.innerHeight }); self.progressOverlay.resize({ width: window.innerWidth, height: window.innerHeight }); if (self.background && self.background.texture) { self.background.x = window.innerWidth / 2; self.background.y = window.innerHeight / 2; self.background.scale.set(Math.max(window.innerWidth / self.background.texture.width, window.innerHeight / self.background.texture.height)); } SliderMesh.prototype.resetTransform({ dx: 2 * gfx.width / window.innerWidth / 512, ox: -1 + 2 * gfx.xoffset / window.innerWidth, dy: -2 * gfx.height / window.innerHeight / 384, oy: 1 - 2 * gfx.yoffset / window.innerHeight, }); } var blurCallback = function (e) { if (self.audioReady) self.pause(); }; window.addEventListener("blur", blurCallback); // deal with difficulties this.OD = track.difficulty.OverallDifficulty; this.CS = track.difficulty.CircleSize; this.AR = track.difficulty.ApproachRate; this.HP = track.difficulty.HPDrainRate; if (game.hardrock) { this.OD = Math.min(this.OD * 1.4, 10); this.CS = Math.min(this.CS * 1.3, 10); this.AR = Math.min(this.AR * 1.4, 10); this.HP = Math.min(this.HP * 1.4, 10); } if (game.easy) { this.OD = this.OD * 0.5; this.CS = this.CS * 0.5; this.AR = this.AR * 0.5; this.HP = this.HP * 0.5; } let scoreModMultiplier = 1.0; if (game.easy) scoreModMultiplier *= 0.50; if (game.daycore) scoreModMultiplier *= 0.30; if (game.hardrock) scoreModMultiplier *= 1.06; if (game.nightcore) scoreModMultiplier *= 1.12; if (game.hidden) scoreModMultiplier *= 1.06; self.scoreOverlay = new ScoreOverlay({ width: game.window.innerWidth, height: game.window.innerHeight }, this.HP, scoreModMultiplier); self.circleRadius = (109 - 9 * this.CS) / 2; // unit: osu! pixel self.hitSpriteScale = self.circleRadius / 60; self.MehTime = 200 - 10 * this.OD; self.GoodTime = 140 - 8 * this.OD; self.GreatTime = 80 - 6 * this.OD; self.errorMeter = new ErrorMeterOverlay({ width: game.window.innerWidth, height: game.window.innerHeight }, this.GreatTime, this.GoodTime, this.MehTime); self.approachTime = this.AR < 5 ? 1800 - 120 * this.AR : 1950 - 150 * this.AR; // time of sliders/hitcircles and approach circles approaching self.approachFadeInTime = Math.min(800, self.approachTime); // duration of approach circles fading in, at beginning of approaching for (let i = 0; i < self.hits.length; ++i) { let hit = self.hits[i]; if (self.modhidden && (i > 0 && self.hits[i - 1].type != "spinner")) { // don't hide the first one hit.objectFadeInTime = 0.4 * self.approachTime; hit.objectFadeOutOffset = -0.6 * self.approachTime; hit.circleFadeOutTime = 0.3 * self.approachTime; } else { hit.enableflash = true; hit.objectFadeInTime = Math.min(400, self.approachTime); // duration of sliders/hitcircles fading in, at beginning of approaching hit.circleFadeOutTime = 100; hit.objectFadeOutOffset = self.MehTime; } } for (let i = 0; i < self.hits.length; ++i) { if (self.hits[i].type == "slider") { if (self.modhidden && (i > 0 && self.hits[i - 1].type != "spinner")) { self.hits[i].fadeOutOffset = -0.6 * self.approachTime; self.hits[i].fadeOutDuration = self.hits[i].sliderTimeTotal - self.hits[i].fadeOutOffset; } else { self.hits[i].fadeOutOffset = self.hits[i].sliderTimeTotal; self.hits[i].fadeOutDuration = 300; } } } self.glowFadeOutTime = 350; self.glowMaxOpacity = 0.5; self.flashFadeInTime = 40; self.flashFadeOutTime = 120; self.flashMaxOpacity = 0.8; self.scoreFadeOutTime = 500; self.followZoomInTime = 100; self.followFadeOutTime = 100; self.ballFadeOutTime = 100; self.objectDespawnTime = 1500; self.backgroundFadeTime = 800; self.spinnerAppearTime = self.approachTime; self.spinnerZoomInTime = 300; self.spinnerFadeOutTime = 150; setPlayerActions(self); self.game.paused = false; this.pause = function () { if (this.osu.audio.pause()) { // pause music success this.game.paused = true; let menu = document.getElementById("pause-menu"); menu.removeAttribute("hidden"); btn_continue = document.getElementById("pausebtn-continue"); btn_retry = document.getElementById("pausebtn-retry"); btn_quit = document.getElementById("pausebtn-quit"); btn_continue.onclick = function () { self.resume(); btn_continue.onclick = null; btn_retry.onclick = null; btn_quit.onclick = null; } btn_retry.onclick = function () { self.game.paused = false; menu.setAttribute("hidden", ""); self.retry(); } btn_quit.onclick = function () { self.game.paused = false; menu.setAttribute("hidden", ""); self.quit(); } } }; this.resume = function () { this.osu.audio.play(); this.game.paused = false; document.getElementById("pause-menu").setAttribute("hidden", ""); }; // adjust volume var wheelCallback; if (game.allowMouseScroll) { wheelCallback = function (e) { self.game.masterVolume -= e.deltaY * 0.002; if (self.game.masterVolume < 0) { self.game.masterVolume = 0; } if (self.game.masterVolume > 1) { self.game.masterVolume = 1; } self.osu.audio.gain.gain.value = self.game.musicVolume * self.game.masterVolume; self.volumeMenu.setVolume(self.game.masterVolume * 100); }; window.addEventListener('wheel', wheelCallback); } var pauseKeyCallback = function (e) { // press esc to pause if ((e.keyCode === game.ESCkeycode || e.keyCode == game.ESC2keycode) && !self.game.paused) { self.pause(); self.pausing = true; // to prevent resuming at end of first key press } }; var resumeKeyCallback = function (e) { // press and release esc to pause if ((e.keyCode === game.ESCkeycode || e.keyCode == game.ESC2keycode) && self.game.paused) { if (self.pausing) self.pausing = false; else self.resume(); } }; var skipKeyCallback = function (e) { if (e.keyCode === game.CTRLkeycode && !self.game.paused) { if (!self.skipped && !self.pausing) self.skip(); } } window.addEventListener("keydown", pauseKeyCallback); window.addEventListener("keyup", resumeKeyCallback); window.addEventListener("keydown", skipKeyCallback); this.fadeOutEasing = function (t) { // [0..1] -> [1..0] if (t <= 0) return 1; if (t > 1) return 0; return 1 - Math.sin(t * Math.PI / 2); } function judgementText(points) { switch (points) { case 0: return "miss"; case 50: return "meh"; case 100: return "good"; case 300: return "great"; default: throw "No judgement"; } } function judgementColor(points) { switch (points) { case 0: return 0xed1121; case 50: return 0xffcc22; case 100: return 0x88b300; case 300: return 0x66ccff; default: throw "No judgement"; } } this.createJudgement = function (x, y, depth, finalTime) { let judge = new PIXI.Text('',{fontFamily: 'Comfortaa', fontSize: 20, fill: ['#ffffff']}); judge.anchor.set(0.5); judge.scale.set(0.85 * this.hitSpriteScale, 1 * this.hitSpriteScale); judge.visible = false; judge.basex = judge.x = x; judge.basey = judge.y = y; judge.depth = depth; judge.points = -1; judge.finalTime = finalTime; judge.defaultScore = 0; return judge; } this.invokeJudgement = function (judge, points, time) { judge.visible = true; judge.points = points; judge.t0 = time; if (!this.hideGreat || points != 300) judge.text = judgementText(points); judge.tint = judgementColor(points); this.updateJudgement(judge, time); } this.updateJudgement = function (judge, time) // set transform of judgement text { if (judge.points < 0 && time >= judge.finalTime) // miss { this.scoreOverlay.hit(judge.defaultScore, 300, time); this.invokeJudgement(judge, judge.defaultScore, time); return; } if (!judge.visible) return; let t = time - judge.t0; if (judge.points == 0) // miss { if (t > 800) { judge.visible = false; return; } judge.alpha = (t < 100) ? t / 100 : (t < 600) ? 1 : 1 - (t - 600) / 200; judge.y = judge.basey + 100 * Math.pow(t / 800, 5) * this.hitSpriteScale; judge.rotation = 0.7 * Math.pow(t / 800, 5); } else // meh, good, great { if (t > 500) { judge.visible = false; return; } judge.alpha = (t < 100) ? t / 100 : 1 - (t - 100) / 400; judge.letterSpacing = 70 * (Math.pow(t / 1800 - 1, 5) + 1); } } this.createBackground = function () { // Load background if possible function loadBackground(uri) { var loader = new PIXI.Loader(); loader.add("bg", uri, { loadType: PIXI.LoaderResource.LOAD_TYPE.IMAGE }).load(function (loader, resources) { let sprite = new PIXI.Sprite(resources.bg.texture); // apply gaussian blur if enabled if (self.game.backgroundBlurRate > 0.0001) { let width = resources.bg.texture.width; let height = resources.bg.texture.height; sprite.anchor.set(0.5); sprite.x = width / 2; sprite.y = height / 2; let blurstrength = self.game.backgroundBlurRate * Math.min(width, height); t = Math.max(Math.min(width, height), Math.max(10, blurstrength) * 3); // zoom in the image a little bit to hide the dark edges // (since filter clamping somehow doesn't work here) sprite.scale.set(t / (t - 2 * Math.max(10, blurstrength))); let blurFilter = new PIXI.filters.BlurFilter(blurstrength, 14); blurFilter.autoFit = false; sprite.filters = [blurFilter]; } let texture = PIXI.RenderTexture.create(resources.bg.texture.width, resources.bg.texture.height); window.app.renderer.render(sprite, texture); self.background = new PIXI.Sprite(texture); self.background.anchor.set(0.5); self.background.x = window.innerWidth / 2; self.background.y = window.innerHeight / 2; // fit the background (preserve aspect) self.background.scale.set(Math.max(window.innerWidth / self.background.texture.width, window.innerHeight / self.background.texture.height)); self.game.stage.addChildAt(self.background, 0); }); } if (self.track.events.length != 0) { var file = self.track.events[0][2]; if (track.events[0][0] === "Video") { file = self.track.events[1][2]; } file = file.substr(1, file.length - 2); entry = osu.zip.getChildByName(file); if (entry) { entry.getBlob("image/jpeg", function (blob) { var uri = URL.createObjectURL(blob); loadBackground(uri); }); } else { loadBackground("img/defaultbg.jpg"); } } else { loadBackground("img/defaultbg.jpg"); } }; self.createBackground(); // load combo colors function convertcolor(color) { return ((+color[0]) << 16) | ((+color[1]) << 8) | ((+color[2]) << 0); } var combos = []; for (var i = 0; i < track.colors.length; i++) { combos.push(convertcolor(track.colors[i])); } var SliderTrackOverride; var SliderBorder; // leave them undefined if they're undefined in the beatmap if (track.colors.SliderTrackOverride) SliderTrackOverride = convertcolor(track.colors.SliderTrackOverride); if (track.colors.SliderBorder) SliderBorder = convertcolor(track.colors.SliderBorder); self.game.stage.addChild(this.gamefield); self.game.stage.addChild(this.scoreOverlay); self.game.stage.addChild(this.errorMeter); self.game.stage.addChild(this.progressOverlay); self.game.stage.addChild(this.breakOverlay); self.game.stage.addChild(this.volumeMenu); self.game.stage.addChild(this.loadingMenu); // creating hit objects this.createHitCircle = function (hit) { function newHitSprite(spritename, depth, scalemul = 1, anchorx = 0.5, anchory = 0.5) { let sprite = new PIXI.Sprite(Skin[spritename]); sprite.initialscale = self.hitSpriteScale * scalemul; sprite.scale.x = sprite.scale.y = sprite.initialscale; sprite.anchor.x = anchorx; sprite.anchor.y = anchory; sprite.x = hit.x; sprite.y = hit.y; sprite.depth = depth; sprite.alpha = 0; hit.objects.push(sprite); return sprite; } let index = hit.index + 1; let basedep = 4.9999 - 0.0001 * hit.hitIndex; hit.base = newHitSprite("disc.png", basedep, 0.5); hit.base.tint = combos[hit.combo % combos.length]; hit.circle = newHitSprite("hitcircleoverlay.png", basedep, 0.5); hit.glow = newHitSprite("ring-glow.png", basedep + 2, 0.46); hit.glow.tint = combos[hit.combo % combos.length]; hit.glow.blendMode = PIXI.BLEND_MODES.ADD; hit.burst = newHitSprite("hitburst.png", 8.00005 + 0.0001 * hit.hitIndex); hit.burst.visible = false; hit.approach = newHitSprite("approachcircle.png", 8 + 0.0001 * hit.hitIndex); hit.approach.tint = combos[hit.combo % combos.length]; hit.judgements.push(this.createJudgement(hit.x, hit.y, 4, hit.time + this.MehTime)); // create combo number hit.numbers = []; if (index <= 9) { hit.numbers.push(newHitSprite("score-" + index + ".png", basedep, 0.4, 0.5, 0.47)); } else if (index <= 99) { hit.numbers.push(newHitSprite("score-" + (index % 10) + ".png", basedep, 0.35, 0, 0.47)); hit.numbers.push(newHitSprite("score-" + ((index - (index % 10)) / 10) + ".png", basedep, 0.35, 1, 0.47)); } // Note: combos > 99 hits are unsupported } this.createSlider = function (hit) { hit.lastrep = 0; // for current-repeat counting hit.nexttick = 0; // for tick hit counting // create slider body // manually set transform osupixel -> gl coordinate var body = hit.body = new SliderMesh(hit.curve, this.circleRadius, hit.combo % combos.length); body.alpha = 0; body.depth = 4.9999 - 0.0001 * hit.hitIndex; hit.objects.push(body); function newSprite(spritename, x, y, scalemul = 1) { let sprite = new PIXI.Sprite(Skin[spritename]); sprite.scale.set(self.hitSpriteScale * scalemul); sprite.anchor.set(0.5); sprite.x = x; sprite.y = y; sprite.depth = 4.9999 - 0.0001 * hit.hitIndex; sprite.alpha = 0; hit.objects.push(sprite); return sprite; } // add slider ticks hit.ticks = []; let tickDuration = hit.timing.trueMillisecondsPerBeat / this.track.difficulty.SliderTickRate; let nticks = Math.floor(hit.sliderTimeTotal / tickDuration) + 1; for (let i = 0; i < nticks; ++i) { let t = hit.time + i * tickDuration; // Question: are ticks offset to the slider start or its timing point? let pos = repeatclamp(i * tickDuration / hit.sliderTime); if (Math.min(pos, 1 - pos) * hit.sliderTime <= 10) // omit ticks near slider end (within 10ms) continue; let at = hit.curve.pointAt(pos); hit.ticks.push(newSprite("sliderscorepoint.png", at.x, at.y)); hit.ticks[hit.ticks.length - 1].appeartime = t - 2 * tickDuration; hit.ticks[hit.ticks.length - 1].time = t; hit.ticks[hit.ticks.length - 1].result = false; } // add reverse symbol if (hit.repeat > 1) { // curve points are of about-same distance, so these 2 points should be different let p = hit.curve.curve[hit.curve.curve.length - 1]; let p2 = hit.curve.curve[hit.curve.curve.length - 2]; hit.reverse = newSprite("reversearrow.png", p.x, p.y, 0.36); hit.reverse.rotation = Math.atan2(p2.y - p.y, p2.x - p.x); } if (hit.repeat > 2) { // curve points are of about-same distance, so these 2 points should be different let p = hit.curve.curve[0]; let p2 = hit.curve.curve[1]; hit.reverse_b = newSprite("reversearrow.png", p.x, p.y, 0.36); hit.reverse_b.rotation = Math.atan2(p2.y - p.y, p2.x - p.x); hit.reverse_b.visible = false; // Only visible when it's the next end to hit } // Add follow circle (above slider body) hit.follow = newSprite("sliderfollowcircle.png", hit.x, hit.y); hit.follow.visible = false; hit.follow.blendMode = PIXI.BLEND_MODES.ADD; hit.followSize = 1; // [1,2] current follow circle size relative to hitcircle // Add slider ball (above follow circle) hit.ball = newSprite("sliderb.png", hit.x, hit.y, 0.5); hit.ball.visible = false; // A slider contains a complete hit circle at its start, so we just make use of this self.createHitCircle(hit); // add judgement objects at edge let endPoint = hit.curve.curve[hit.curve.curve.length - 1]; for (let i = 1; i <= hit.repeat; ++i) { let x = (i % 2 == 1) ? endPoint.x : hit.x; let y = (i % 2 == 1) ? endPoint.y : hit.y; hit.judgements.push(this.createJudgement(x, y, 4, hit.time + i * hit.sliderTime)); } } this.createSpinner = function (hit) { hit.approachTime = self.spinnerAppearTime + self.spinnerZoomInTime; hit.x = 512 / 2; hit.y = 384 / 2; // absolute position hit.rotation = 0; hit.rotationProgress = 0; hit.clicked = false; let spinRequiredPerSec = this.OD < 5 ? 3 + 0.4 * this.OD : 2.5 + 0.5 * this.OD; spinRequiredPerSec *= 0.7; // make it easier hit.rotationRequired = 2 * Math.PI * spinRequiredPerSec * (hit.endTime - hit.time) / 1000; function newsprite(spritename) { var sprite = new PIXI.Sprite(Skin[spritename]); sprite.anchor.set(0.5); sprite.x = hit.x; sprite.y = hit.y; sprite.depth = 4.9999 - 0.0001 * (hit.hitIndex || 1); sprite.alpha = 0; hit.objects.push(sprite); return sprite; } hit.base = newsprite("spinnerbase.png"); hit.progress = newsprite("spinnerprogress.png"); hit.top = newsprite("spinnertop.png"); if (this.modhidden) { hit.progress.visible = false; hit.base.visible = false; } hit.judgements.push(this.createJudgement(hit.x, hit.y, 4, hit.endTime + 233)); } // create a follow point connection between two hit objects & store it in the latter object // this should be called after these hit objects be initialized, but before they're added to the stage this.createFollowPoint = function (hitBefore, hit) { var x1 = hitBefore.x; var y1 = hitBefore.y; var t1 = hitBefore.time; if (hitBefore.type == "slider") { t1 += hitBefore.sliderTimeTotal; if (hitBefore.repeat % 2 == 1) { x1 = hitBefore.curve.curve[hitBefore.curve.curve.length - 1].x; y1 = hitBefore.curve.curve[hitBefore.curve.curve.length - 1].y; } } var container = new PIXI.Container(); container.depth = 3; container.x1 = x1; container.y1 = y1; container.t1 = t1; container.dx = hit.x - x1; container.dy = hit.y - y1; container.dt = hit.time - t1; container.preempt = this.approachTime; container.hit = hit; hit.objects.push(container); hit.followPoints = container; const spacing = this.circleRadius * 0.7; const rotation = Math.atan2(container.dy, container.dx); const distance = Math.hypot(container.dx, container.dy); for (let d = spacing * 2; d < distance - 1.5 * spacing; d += spacing) { let p = new PIXI.Sprite(Skin["followpoint.png"]); p.scale.set(this.hitSpriteScale * 0.3); p.x = x1 + container.dx * d / distance; p.y = y1 + container.dy * d / distance; p.blendMode = PIXI.BLEND_MODES.ADD; p.rotation = rotation; p.anchor.set(0.5); p.alpha = 0; p.fraction = d / distance; // store for convenience container.addChild(p); } } this.populateHit = function (hit) { // Creates PIXI objects for a given hit this.currentHitIndex += 1; hit.hitIndex = this.currentHitIndex; hit.objects = []; hit.judgements = []; hit.score = -1; switch (hit.type) { case "circle": self.createHitCircle(hit); break; case "slider": self.createSlider(hit); break; case "spinner": self.createSpinner(hit); break; } } this.updateCursorPredictVisualizer = function () { if (!this.predictVisualizer && game.mouse) { // create visualizer let o = this.predictVisualizer = new PIXI.Sprite(Skin["sliderb.png"]); o.anchor.set(0.5); o.tint = 0x00ff00; this.gamefield.addChild(o); } if (this.predictVisualizer) { let res = game.mouse(new Date().getTime()); // prediction result this.predictVisualizer.x = res.x; this.predictVisualizer.y = res.y; this.predictVisualizer.scale.set(res.r / 120); this.predictVisualizer.bringToFront(); } } SliderMesh.prototype.initialize(combos, this.circleRadius, { dx: 2 * gfx.width / window.innerWidth / 512, ox: -1 + 2 * gfx.xoffset / window.innerWidth, dy: -2 * gfx.height / window.innerHeight / 384, oy: 1 - 2 * gfx.yoffset / window.innerHeight, }, SliderTrackOverride, SliderBorder); // prepare sliders for (let i = 0; i < this.hits.length; i++) { this.populateHit(this.hits[i]); // Prepare sprites and such } if (this.modhidden) { for (let i = 0; i < this.hits.length; i++) { if (this.hits[i].approach && (i > 0 && this.hits[i - 1].type != "spinner")) this.hits[i].approach.visible = false; } } if (this.hideNumbers) { for (let i = 0; i < this.hits.length; i++) { if (this.hits[i].numbers) { for (let j = 0; j < this.hits[i].numbers.length; ++j) this.hits[i].numbers[j].visible = false; } } } for (let i = 0; i < this.hits.length - 1; i++) { if (this.hits[i].type != "spinner" && this.hits[i + 1].type != "spinner" && this.hits[i + 1].combo == this.hits[i].combo) this.createFollowPoint(this.hits[i], this.hits[i + 1]); } if (this.hideFollowPoints) { for (let i = 0; i < this.hits.length; i++) { if (this.hits[i].followPoints) { this.hits[i].followPoints.visible = false; } } } // hit result handling // use separate timing for sounds, since volume may change inside a slider or spinner // note: time is expected time of object hit, not real time this.curtimingid = 0; this.playTicksound = function playTicksound(hit, time) { while (this.curtimingid + 1 < this.track.timingPoints.length && this.track.timingPoints[this.curtimingid + 1].offset <= time) this.curtimingid++; while (this.curtimingid > 0 && this.track.timingPoints[this.curtimingid].offset > time) this.curtimingid--; let timing = this.track.timingPoints[this.curtimingid]; let volume = self.game.masterVolume * self.game.effectVolume * (hit.hitSample.volume || timing.volume) / 100; let defaultSet = timing.sampleSet || self.game.sampleSet; self.game.sample[defaultSet].slidertick.volume = volume; self.game.sample[defaultSet].slidertick.play(); }; this.playHitsound = function playHitsound(hit, id, time) { while (this.curtimingid + 1 < this.track.timingPoints.length && this.track.timingPoints[this.curtimingid + 1].offset <= time) this.curtimingid++; while (this.curtimingid > 0 && this.track.timingPoints[this.curtimingid].offset > time) this.curtimingid--; let timing = this.track.timingPoints[this.curtimingid]; let volume = self.game.masterVolume * self.game.effectVolume * (hit.hitSample.volume || timing.volume) / 100; let defaultSet = timing.sampleSet || self.game.sampleSet; function playHit(bitmask, normalSet, additionSet) { // The normal sound is always played self.game.sample[normalSet].hitnormal.volume = volume; self.game.sample[normalSet].hitnormal.play(); if (bitmask & 2) { self.game.sample[additionSet].hitwhistle.volume = volume; self.game.sample[additionSet].hitwhistle.play(); } if (bitmask & 4) { self.game.sample[additionSet].hitfinish.volume = volume; self.game.sample[additionSet].hitfinish.play(); } if (bitmask & 8) { self.game.sample[additionSet].hitclap.volume = volume; self.game.sample[additionSet].hitclap.play(); } } if (hit.type == 'circle' || hit.type == 'spinner') { let toplay = hit.hitSound; let normalSet = hit.hitSample.normalSet || defaultSet; let additionSet = hit.hitSample.additionSet || normalSet; playHit(toplay, normalSet, additionSet); } if (hit.type == 'slider') { let toplay = hit.edgeHitsounds[id]; let normalSet = hit.edgeSets[id].normalSet || defaultSet; let additionSet = hit.edgeSets[id].additionSet || normalSet; playHit(toplay, normalSet, additionSet); } }; this.hitSuccess = function hitSuccess(hit, points, time) { this.scoreOverlay.hit(points, 300, time); if (points > 0) { if (hit.type == "spinner") self.playHitsound(hit, 0, hit.endTime); // hit happen at end of spinner else { self.playHitsound(hit, 0, hit.time); self.errorMeter.hit(time - hit.time, time); } if (hit.type == "slider") { // special rule: only missing slider end will not result in a miss hit.judgements[hit.judgements.length - 1].defaultScore = 50; } } hit.score = points; hit.clickTime = time; self.invokeJudgement(hit.judgements[0], points, time); }; // hit object updating var futuremost = 0, current = 0; if (self.track.hitObjects.length > 0) { futuremost = self.track.hitObjects[0].time; } var waitinghitid = 0; // the first object that's not ended this.updateUpcoming = function (time) { while (waitinghitid < self.hits.length && self.hits[waitinghitid].endTime < time) waitinghitid++; function findindex(i) { // returning smallest j satisfying (self.gamefield.children[j].depth || 0)>=i let l = 0, r = self.gamefield.children.length; while (l + 1 < r) { let m = Math.floor((l + r) / 2) - 1; if ((self.gamefield.children[m].depth || 0) < i) l = m + 1; else r = m + 1; } return l; } // Cache hit objects in the next 3 seconds while (current < self.hits.length && futuremost < time + 3000) { var hit = self.hits[current++]; for (let i = hit.judgements.length - 1; i >= 0; i--) { self.gamefield.addChildAt(hit.judgements[i], findindex(hit.judgements[i].depth || 0.0001)); } for (let i = hit.objects.length - 1; i >= 0; i--) { self.gamefield.addChildAt(hit.objects[i], findindex(hit.objects[i].depth || 0.0001)); } self.upcomingHits.push(hit); if (hit.time > futuremost) { futuremost = hit.time; } } for (var i = 0; i < self.upcomingHits.length; i++) { var hit = self.upcomingHits[i]; var diff = hit.time - time; var despawn = -this.objectDespawnTime; if (hit.type === "slider") { despawn -= hit.sliderTimeTotal; } if (hit.type === "spinner") { despawn -= hit.endTime - hit.time; } if (diff < despawn) { self.upcomingHits.splice(i, 1); i--; _.each(hit.objects, function (o) { self.gamefield.removeChild(o); o.destroy(); }); _.each(hit.judgements, function (o) { self.gamefield.removeChild(o); o.destroy(); }); hit.destroyed = true; } } } // this should be called on a follow point connection every frame when it's valid this.updateFollowPoints = function (f, time) { for (let i = 0; i < f.children.length; ++i) { let o = f.children[i]; let startx = f.x1 + (o.fraction - 0.1) * f.dx; let starty = f.y1 + (o.fraction - 0.1) * f.dy; let endx = f.x1 + o.fraction * f.dx; let endy = f.y1 + o.fraction * f.dy; let fadeOutTime = f.t1 + o.fraction * f.dt; let fadeInTime = fadeOutTime - f.preempt; let relpos = clamp01((time - fadeInTime) / f.hit.objectFadeInTime); relpos *= 2 - relpos; // ease out o.x = startx + (endx - startx) * relpos; o.y = starty + (endy - starty) * relpos; o.alpha = 0.5 * ((time < fadeOutTime) ? clamp01((time - fadeInTime) / f.hit.objectFadeInTime) : 1 - clamp01((time - fadeOutTime) / f.hit.objectFadeInTime)); } } this.updateHitCircle = function (hit, time) { if (hit.followPoints) this.updateFollowPoints(hit.followPoints, time); let diff = hit.time - time; // milliseconds before time of circle // update approach circle let approachFullAppear = this.approachTime - this.approachFadeInTime; // duration of opaque approach circle when approaching if (diff <= this.approachTime && diff > 0) { // approaching let scalemul = diff / this.approachTime * this.approachScale + 1; hit.approach.scale.set(0.5 * this.hitSpriteScale * scalemul); } else { hit.approach.scale.set(0.5 * this.hitSpriteScale); } if (diff <= this.approachTime && diff > approachFullAppear) { // approach circle fading in hit.approach.alpha = (this.approachTime - diff) / this.approachFadeInTime; } else if (diff <= approachFullAppear && hit.score < 0) { // approach circle opaque, just shrinking hit.approach.alpha = 1; } // calculate opacity of circle let noteFullAppear = this.approachTime - hit.objectFadeInTime; // duration of opaque hit circle when approaching function setcircleAlpha(alpha) { hit.base.alpha = alpha; hit.circle.alpha = alpha; for (let i = 0; i < hit.numbers.length; ++i) hit.numbers[i].alpha = alpha; hit.glow.alpha = alpha * self.glowMaxOpacity; } if (diff <= this.approachTime && diff > noteFullAppear) { // fading in let alpha = (this.approachTime - diff) / hit.objectFadeInTime; setcircleAlpha(alpha); } else if (diff <= noteFullAppear) { if (-diff > hit.objectFadeOutOffset) { // fading out let timeAfter = -diff - hit.objectFadeOutOffset; setcircleAlpha(clamp01(1 - timeAfter / hit.circleFadeOutTime)); hit.approach.alpha = clamp01(1 - timeAfter / 50); } else { setcircleAlpha(1); } } // flash out if clicked if (hit.score > 0 && hit.enableflash) { hit.burst.visible = true; let timeAfter = time - hit.clickTime; let t = timeAfter / this.glowFadeOutTime; let newscale = 1 + 0.5 * t * (2 - t); hit.burst.scale.set(newscale * hit.burst.initialscale); hit.glow.scale.set(newscale * hit.glow.initialscale); hit.burst.alpha = this.flashMaxOpacity * clamp01((timeAfter < this.flashFadeInTime) ? (timeAfter / this.flashFadeInTime) : (1 - (timeAfter - this.flashFadeInTime) / this.flashFadeOutTime)); hit.glow.alpha = clamp01(1 - timeAfter / this.glowFadeOutTime) * this.glowMaxOpacity; if (hit.base.visible) { if (timeAfter < this.flashFadeInTime) { hit.base.scale.set(newscale * hit.base.initialscale); hit.circle.scale.set(newscale * hit.circle.initialscale); for (let i = 0; i < hit.numbers.length; ++i) hit.numbers[i].scale.set(newscale * hit.numbers[i].initialscale); } else { // hide circle hit.base.visible = false; hit.circle.visible = false; for (let i = 0; i < hit.numbers.length; ++i) hit.numbers[i].visible = false; hit.approach.visible = false; } } } this.updateJudgement(hit.judgements[0], time); } this.updateSlider = function (hit, time) { // just make use of the duplicate part this.updateHitCircle(hit, time); let noteFullAppear = this.approachTime - hit.objectFadeInTime; // duration of opaque hit circle when approaching hit.body.startt = 0.0; hit.body.endt = 1.0; // set opacity of slider body function setbodyAlpha(alpha) { hit.body.alpha = alpha; for (let i = 0; i < hit.ticks.length; ++i) hit.ticks[i].alpha = alpha; } let diff = hit.time - time; // milliseconds before hit.time if (diff <= this.approachTime && diff > noteFullAppear) { // Fade in (before hit) setbodyAlpha((this.approachTime - diff) / hit.objectFadeInTime); if (hit.reverse) hit.reverse.alpha = hit.body.alpha; if (hit.reverse_b) hit.reverse_b.alpha = hit.body.alpha; } else if (diff <= noteFullAppear) { if (-diff > hit.fadeOutOffset) { let t = clamp01((-diff - hit.fadeOutOffset) / hit.fadeOutDuration); setbodyAlpha(1 - t * (2 - t)); } else { setbodyAlpha(1); if (hit.reverse) hit.reverse.alpha = 1; if (hit.reverse_b) hit.reverse_b.alpha = 1; } } if (this.game.snakein) { if (diff > 0) { let t = clamp01((time - (hit.time - this.approachTime)) / (this.approachTime / 3)); hit.body.endt = t; if (hit.reverse) { let p = hit.curve.pointAt(t); hit.reverse.x = p.x; hit.reverse.y = p.y; let p2; if (t < 0.5) { let p2 = hit.curve.pointAt(t + 0.005); hit.reverse.rotation = Math.atan2(p.y - p2.y, p.x - p2.x); } else { let p2 = hit.curve.pointAt(t - 0.005); hit.reverse.rotation = Math.atan2(p2.y - p.y, p2.x - p.x); } } } } // set position of slider ball & follow circle // approach circle & hit circle moves along fading function resizeFollow(hit, time, dir) { if (!hit.followLasttime) hit.followLasttime = time; if (!hit.followLinearSize) hit.followLinearSize = 1; let dt = time - hit.followLasttime; hit.followLinearSize = Math.max(1, Math.min(2, hit.followLinearSize + dt * dir)); hit.followSize = hit.followLinearSize; // easing can happen here hit.followLasttime = time; } if (-diff >= 0 && -diff <= hit.fadeOutDuration + hit.sliderTimeTotal) { // after hit.time & before slider disappears // t: position relative to slider duration let t = -diff / hit.sliderTime; hit.currentRepeat = Math.min(Math.ceil(t), hit.repeat); // check for slider edge hit let atEnd = false; if (Math.floor(t) > hit.lastrep) { hit.lastrep = Math.floor(t); if (hit.lastrep > 0 && hit.lastrep <= hit.repeat) atEnd = true; } // clamp t t = repeatclamp(Math.min(t, hit.repeat)); // Update ball and follow circle position let at = hit.curve.pointAt(t); hit.follow.x = at.x; hit.follow.y = at.y; hit.ball.x = at.x; hit.ball.y = at.y; if (hit.base.visible && hit.score <= 0) { // the hit circle at start of slider will move if not hit hit.base.x = at.x; hit.base.y = at.y; hit.circle.x = at.x; hit.circle.y = at.y; for (let i = 0; i < hit.numbers.length; ++i) { hit.numbers[i].x = at.x; hit.numbers[i].y = at.y; } hit.glow.x = at.x; hit.glow.y = at.y; hit.burst.x = at.x; hit.burst.y = at.y; hit.approach.x = at.x; hit.approach.y = at.y; } let dx = game.mouseX - at.x; let dy = game.mouseY - at.y; let followPixelSize = hit.followSize * this.circleRadius; let isfollowing = dx * dx + dy * dy <= followPixelSize * followPixelSize; let predict = game.mouse(this.realtime); let dx1 = predict.x - at.x; let dy1 = predict.y - at.y; isfollowing |= dx1 * dx1 + dy1 * dy1 <= (followPixelSize + predict.r) * (followPixelSize + predict.r); let activated = this.game.down && isfollowing || hit.followSize > 1.01; // slider tick judgement if (hit.nexttick < hit.ticks.length && time >= hit.ticks[hit.nexttick].time) { if (activated) { hit.ticks[hit.nexttick].result = true; self.playTicksound(hit, hit.ticks[hit.nexttick].time); // special rule: only missing slider end will not result in a miss hit.judgements[hit.judgements.length - 1].defaultScore = 50; } self.scoreOverlay.hit(activated ? 10 : 0, 10, time); hit.nexttick++; } // slider edge judgement // Note: being tolerant if follow circle hasn't shrinked to minimum if (atEnd && activated) { self.invokeJudgement(hit.judgements[hit.lastrep], 300, time); self.scoreOverlay.hit(300, 300, time); self.playHitsound(hit, hit.lastrep, hit.time + hit.lastrep * hit.sliderTime); } // sliderball & follow circle Animation if (-diff >= 0 && -diff <= hit.sliderTimeTotal) { // slider ball immediately emerges hit.ball.visible = true; hit.ball.alpha = 1; // follow circie immediately emerges and gradually enlarges hit.follow.visible = true; if (this.game.down && isfollowing) resizeFollow(hit, time, 1 / this.followZoomInTime); // expand else resizeFollow(hit, time, -1 / this.followZoomInTime); // shrink let followscale = hit.followSize * 0.45 * this.hitSpriteScale; hit.follow.scale.x = hit.follow.scale.y = followscale; hit.follow.alpha = hit.followSize - 1; } let timeAfter = -diff - hit.sliderTimeTotal; if (timeAfter > 0) { resizeFollow(hit, time, -1 / this.followZoomInTime); // shrink let followscale = hit.followSize * 0.45 * this.hitSpriteScale; hit.follow.scale.x = hit.follow.scale.y = followscale; hit.follow.alpha = hit.followSize - 1; hit.ball.alpha = this.fadeOutEasing(timeAfter / this.ballFadeOutTime); let ballscale = (1 + 0.15 * timeAfter / this.ballFadeOutTime) * 0.5 * this.hitSpriteScale; hit.ball.scale.x = hit.ball.scale.y = ballscale; } // reverse arrow if (hit.repeat > 1) { let finalrepfromA = hit.repeat - hit.repeat % 2; // even let finalrepfromB = hit.repeat - 1 + hit.repeat % 2; // odd hit.reverse.visible = (hit.currentRepeat < finalrepfromA); if (hit.reverse_b) hit.reverse_b.visible = (hit.currentRepeat < finalrepfromB); // TODO reverse arrow fade out animation } // update snaking out portion if (this.game.snakeout) { if (hit.currentRepeat == hit.repeat) { if (hit.repeat % 2 == 1) { hit.body.startt = t; hit.body.endt = 1.0; } else { hit.body.startt = 0.0; hit.body.endt = t; } } } } // calculate ticks fade in/out for (let i = 0; i < hit.ticks.length; ++i) { if (time < hit.ticks[i].appeartime) { // fade in let dt = hit.ticks[i].appeartime - time; hit.ticks[i].alpha *= clamp01(1 - dt / 500); hit.ticks[i].scale.set(0.5 * this.hitSpriteScale * (0.5 + 0.5 * clamp01((1 - dt / 500) * (1 + dt / 500)))); } else { hit.ticks[i].scale.set(0.5 * this.hitSpriteScale); } if (time >= hit.ticks[i].time) { let dt = time - hit.ticks[i].time; if (hit.ticks[i].result) { // hit hit.ticks[i].alpha *= clamp01(-Math.pow(dt / 150 - 1, 5)); hit.ticks[i].scale.set(0.5 * this.hitSpriteScale * (1 + 0.5 * (dt / 150) * (2 - dt / 150))); } else { // missed hit.ticks[i].alpha *= clamp01(1 - dt / 150); hit.ticks[i].tint = colorLerp(0xffffff, 0xff0000, clamp01(dt / 75)); } } } // display hit score for (let i = 0; i < hit.judgements.length; ++i) this.updateJudgement(hit.judgements[i], time); } this.updateSpinner = function (hit, time) { // update rotation if (time >= hit.time && time <= hit.endTime) { if (this.game.down) { let Xr = this.game.mouseX - hit.x; let Yr = this.game.mouseY - hit.y; let mouseAngle = Math.atan2(Yr, Xr); if (!hit.clicked) { hit.clicked = true; } else { let delta = mouseAngle - hit.lastAngle; if (delta > Math.PI) delta -= Math.PI * 2; if (delta < -Math.PI) delta += Math.PI * 2; hit.rotation += delta; hit.rotationProgress += Math.abs(delta); } hit.lastAngle = mouseAngle; } else { hit.clicked = false; } } // calculate opacity of spinner let alpha = 0; if (time >= hit.time - self.spinnerZoomInTime - self.spinnerAppearTime) { if (time <= hit.endTime) alpha = 1; else alpha = clamp01(1 - (time - hit.endTime) / self.spinnerFadeOutTime); } hit.top.alpha = alpha; hit.progress.alpha = alpha; hit.base.alpha = alpha; // calculate scales of components if (time < hit.endTime) { // top zoom in first hit.top.scale.set(0.3 * clamp01((time - (hit.time - self.spinnerZoomInTime - self.spinnerAppearTime)) / self.spinnerZoomInTime)); hit.base.scale.set(0.6 * clamp01((time - (hit.time - self.spinnerZoomInTime)) / self.spinnerZoomInTime)); } if (time < hit.time) { let t = (hit.time - time) / (self.spinnerZoomInTime + self.spinnerAppearTime); if (t <= 1) hit.top.rotation = -t * t * 10; } let progress = hit.rotationProgress / hit.rotationRequired; if (time > hit.time) { hit.base.rotation = hit.rotation / 2; hit.top.rotation = hit.rotation / 2; hit.progress.scale.set(0.6 * (0.13 + 0.87 * clamp01(progress))); } else { hit.progress.scale.set(0); } if (time >= hit.endTime) { if (hit.score < 0) { let points = 0; if (progress >= 1) points = 300; else if (progress >= 0.9) points = 100; else if (progress >= 0.75) points = 50; this.hitSuccess(hit, points, hit.endTime); } } this.updateJudgement(hit.judgements[0], time); } this.updateHitObjects = function (time) { self.updateUpcoming(time); for (var i = self.upcomingHits.length - 1; i >= 0; i--) { var hit = self.upcomingHits[i]; switch (hit.type) { case "circle": self.updateHitCircle(hit, time); break; case "slider": self.updateSlider(hit, time); break; case "spinner": self.updateSpinner(hit, time); break; } } } this.updateBackground = function (time) { if (!self.background) return; let fade = self.game.backgroundDimRate; if (time < -self.wait) fade *= Math.max(0, 1 - (-self.wait - time) / self.backgroundFadeTime); self.background.tint = colorLerp(0xffffff, 0, fade); } this.render = function (timestamp) { this.realtime = new Date().getTime(); if (window.lastPlaybackRenderTime) { window.currentFrameInterval = this.realtime - window.lastPlaybackRenderTime; } window.lastPlaybackRenderTime = this.realtime; var time; if (this.audioReady) { time = osu.audio.getPosition() * 1000 + self.offset; } if (typeof time !== 'undefined') { let nextapproachtime = (waitinghitid < this.hits.length && this.hits[waitinghitid].time - (this.hits[waitinghitid].approachTime || this.approachTime) > time) ? this.hits[waitinghitid].time - (this.hits[waitinghitid].approachTime || this.approachTime) : -1; this.breakOverlay.countdown(nextapproachtime, time); this.updateBackground(time); this.updateHitObjects(time); this.scoreOverlay.update(time); this.game.updatePlayerActions(time); this.progressOverlay.update(time); this.errorMeter.update(time); } else { this.updateBackground(-100000); } this.volumeMenu.update(timestamp); this.loadingMenu.update(timestamp); // this.updateCursorPredictVisualizer(); if (time > this.endTime) { // game ends if (!this.ended) { this.ended = true; this.pause = function () {}; this.scoreOverlay.visible = false; this.scoreOverlay.showSummary(this.track.metadata, this.errorMeter.record, this.retry, this.quit); } self.background.tint = 0xffffff; } } this.destroy = function () { // clean up console.log("Destroy gamefield"); _.each(self.hits, function (hit) { if (!hit.destroyed) { _.each(hit.objects, function (o) { self.gamefield.removeChild(o); o.destroy(); }); _.each(hit.judgements, function (o) { self.gamefield.removeChild(o); o.destroy(); }); hit.destroyed = true; } }); let opt = { children: true, texture: false } self.scoreOverlay.destroy(opt); self.errorMeter.destroy(opt); self.loadingMenu.destroy(opt); self.volumeMenu.destroy(opt); self.breakOverlay.destroy(opt); self.progressOverlay.destroy(opt); self.gamefield.destroy(opt); self.background.destroy(); // clean up event listeners window.onresize = null; window.removeEventListener("blur", blurCallback); window.removeEventListener('wheel', wheelCallback); window.removeEventListener('keydown', pauseKeyCallback); window.removeEventListener('keyup', resumeKeyCallback); window.removeEventListener('keydown', skipKeyCallback) self.game.cleanupPlayerActions(); self.render = function () {}; }; this.start = function () { console.log("Start playback") self.started = true; self.skipped = false; self.osu.audio.gain.gain.value = self.game.musicVolume * self.game.masterVolume; self.osu.audio.playbackRate = self.playbackRate; self.osu.audio.play(self.backgroundFadeTime + self.wait); }; this.retry = function () { if (!self.game.paused) { self.osu.audio.pause(); self.game.paused = true; } console.log("Re-trying playback..."); self.destroy(); self.constructor(self.game, self.osu, self.track); self.loadingMenu.hide(); self.audioReady = true; self.start(); } this.quit = function () { if (!self.game.paused) { self.osu.audio.pause(); self.game.paused = true; } console.log("Exiting gamefield..."); self.destroy(); if (window.quitGame) window.quitGame(); } this.skip = function () { if (self.osu.audio && self.osu.audio.seekforward(self.skipTime)) { self.skipped = true; } } } return Playback; });