mirror of
https://gitlab.com/skysthelimit.dev/selenite.git
synced 2025-06-16 10:32:08 -05:00
1409 lines
66 KiB
JavaScript
1409 lines
66 KiB
JavaScript
/*
|
|
* 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;
|
|
}); |