diff --git a/CREDITS.md b/CREDITS.md index 9f622802..6cee6517 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -31,5 +31,6 @@ The following projects were referenced in creating opsu!: Theme Song ---------- -The theme song is "On the Bach" by Jingle Punks, from the [YouTube Audio Library] -(https://www.youtube.com/audiolibrary/music). +Rainbows - Kevin MacLeod (incompetech.com) +Licensed under Creative Commons: By Attribution 3.0 License +http://creativecommons.org/licenses/by/3.0/ diff --git a/res/theme.mp3 b/res/theme.mp3 new file mode 100644 index 00000000..b75ad2bf Binary files /dev/null and b/res/theme.mp3 differ diff --git a/res/theme.ogg b/res/theme.ogg deleted file mode 100644 index 8057f70d..00000000 Binary files a/res/theme.ogg and /dev/null differ diff --git a/src/itdelatrisu/opsu/Container.java b/src/itdelatrisu/opsu/Container.java index 00de84dd..56be0ad6 100644 --- a/src/itdelatrisu/opsu/Container.java +++ b/src/itdelatrisu/opsu/Container.java @@ -21,7 +21,9 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapGroup; import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapSortOrder; import itdelatrisu.opsu.beatmap.BeatmapWatchService; import itdelatrisu.opsu.downloads.DownloadList; import itdelatrisu.opsu.downloads.Updater; @@ -38,12 +40,8 @@ import org.newdawn.slick.opengl.InternalTextureLoader; * AppGameContainer extension that sends critical errors to ErrorHandler. */ public class Container extends AppGameContainer { - /** SlickException causing game failure. */ - protected SlickException e = null; - - private Exception anyException = null; - - public static Container instance; + /** Exception causing game failure. */ + protected Exception e = null; /** * Create a new container wrapping a game @@ -53,9 +51,19 @@ public class Container extends AppGameContainer { */ public Container(Game game) throws SlickException { super(game); - instance = this; - width = this.getWidth(); - height = this.getHeight(); + } + + /** + * Create a new container wrapping a game + * + * @param game The game to be wrapped + * @param width The width of the display required + * @param height The height of the display required + * @param fullscreen True if we want fullscreen mode + * @throws SlickException Indicates a failure to initialise the display + */ + public Container(Game game, int width, int height, boolean fullscreen) throws SlickException { + super(game, width, height, fullscreen); } @Override @@ -66,20 +74,24 @@ public class Container extends AppGameContainer { getDelta(); while (running()) gameLoop(); - } catch(Exception e) { - anyException = e; - } finally { - // destroy the game container - close_sub(); - destroy(); + } catch (Exception e) { + this.e = e; + } - if (anyException != null) { - ErrorHandler.error("Something bad happend while playing", anyException, true); - anyException = null; - } else if (e != null) { - ErrorHandler.error(null, e, true); - e = null; - } + // destroy the game container + try { + close_sub(); + } catch (Exception e) { + if (this.e == null) // suppress if caused by a previous exception + this.e = e; + } + destroy(); + + // report any critical errors + if (e != null) { + ErrorHandler.error(null, e, true); + e = null; + forceExit = true; } if (forceExit) { @@ -118,9 +130,7 @@ public class Container extends AppGameContainer { Options.saveOptions(); // reset cursor - if (UI.getCursor() != null) { - UI.getCursor().reset(); - } + UI.getCursor().reset(); // destroy images InternalTextureLoader.get().clear(); @@ -137,6 +147,8 @@ public class Container extends AppGameContainer { SoundController.stopTrack(); // reset BeatmapSetList data + BeatmapGroup.set(BeatmapGroup.ALL); + BeatmapSortOrder.set(BeatmapSortOrder.TITLE); if (BeatmapSetList.get() != null) BeatmapSetList.get().reset(); diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java index 231bdd54..bac8ca8e 100644 --- a/src/itdelatrisu/opsu/GameData.java +++ b/src/itdelatrisu/opsu/GameData.java @@ -141,23 +141,23 @@ public class GameData { /** Hit result types. */ public static final int - HIT_MISS = 0, - HIT_50 = 1, - HIT_100 = 2, - HIT_300 = 3, - HIT_100K = 4, // 100-Katu - HIT_300K = 5, // 300-Katu - HIT_300G = 6, // Geki - HIT_SLIDER10 = 7, - HIT_SLIDER30 = 8, - HIT_MAX = 9, // not a hit result - HIT_SLIDER_INITIAL = 10, // not a hit result - HIT_SLIDER_REPEAT = 11; // not a hit result + HIT_MISS = 0, + HIT_50 = 1, + HIT_100 = 2, + HIT_300 = 3, + HIT_100K = 4, // 100-Katu + HIT_300K = 5, // 300-Katu + HIT_300G = 6, // Geki + HIT_SLIDER10 = 7, + HIT_SLIDER30 = 8, + HIT_MAX = 9, // not a hit result + HIT_SLIDER_REPEAT = 10, // not a hit result + HIT_ANIMATION_RESULT = 11; // not a hit result - /** Hit result-related images (indexed by HIT_* constants). */ + /** Hit result-related images (indexed by HIT_* constants to HIT_MAX). */ private Image[] hitResults; - /** Counts of each hit result so far. */ + /** Counts of each hit result so far (indexed by HIT_* constants to HIT_MAX). */ private int[] hitResultCount; /** Total objects including slider hits/ticks (for determining Full Combo status). */ @@ -193,7 +193,7 @@ public class GameData { /** Current x coordinate of the combo burst image (for sliding animation). */ private float comboBurstX; - /** Time offsets for obtaining each hit result (indexed by HIT_* constants). */ + /** Time offsets for obtaining each hit result (indexed by HIT_* constants to HIT_MAX). */ private int[] hitResultOffset; /** List of hit result objects associated with hit objects. */ @@ -557,10 +557,11 @@ public class GameData { * @param x the starting x coordinate * @param y the y coordinate * @param scale the scale to apply + * @param alpha the alpha level * @param fixedsize the width to use for all symbols * @param rightAlign align right (true) or left (false) */ - public void drawFixedSizeSymbolString(String str, float x, float y, float scale, float fixedsize, boolean rightAlign) { + public void drawFixedSizeSymbolString(String str, float x, float y, float scale, float alpha, float fixedsize, boolean rightAlign) { char[] c = str.toCharArray(); float cx = x; if (rightAlign) { @@ -569,14 +570,18 @@ public class GameData { if (scale != 1.0f) digit = digit.getScaledCopy(scale); cx -= fixedsize; + digit.setAlpha(alpha); digit.draw(cx + (fixedsize - digit.getWidth()) / 2, y); + digit.setAlpha(1f); } } else { for (int i = 0; i < c.length; i++) { Image digit = getScoreSymbolImage(c[i]); if (scale != 1.0f) digit = digit.getScaledCopy(scale); + digit.setAlpha(alpha); digit.draw(cx + (fixedsize - digit.getWidth()) / 2, y); + digit.setAlpha(1f); cx += fixedsize; } } @@ -589,9 +594,10 @@ public class GameData { * @param g the graphics context * @param breakPeriod if true, will not draw scorebar and combo elements, and will draw grade * @param firstObject true if the first hit object's start time has not yet passed + * @param alpha the alpha level at which to render all elements (except the hit error bar) */ @SuppressWarnings("deprecation") - public void drawGameElements(Graphics g, boolean breakPeriod, boolean firstObject) { + public void drawGameElements(Graphics g, boolean breakPeriod, boolean firstObject, float alpha) { boolean relaxAutoPilot = (GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive()); int margin = (int) (width * 0.008f); float uiScale = GameImage.getUIscale(); @@ -599,14 +605,14 @@ public class GameData { // score if (!relaxAutoPilot) drawFixedSizeSymbolString((scoreDisplay < 100000000) ? String.format("%08d", scoreDisplay) : Long.toString(scoreDisplay), - width - margin, 0, 1.0f, getScoreSymbolImage('0').getWidth() - 2, true); + width - margin, 0, 1f, alpha, getScoreSymbolImage('0').getWidth() - 2, true); // score percentage int symbolHeight = getScoreSymbolImage('0').getHeight(); if (!relaxAutoPilot) drawSymbolString( String.format((scorePercentDisplay < 10f) ? "0%.2f%%" : "%.2f%%", scorePercentDisplay), - width - margin, symbolHeight, 0.60f, 1f, true); + width - margin, symbolHeight, 0.60f, alpha, true); // map progress circle Beatmap beatmap = MusicController.getBeatmap(); @@ -620,23 +626,27 @@ public class GameData { getScoreSymbolImage('%').getWidth() ) * 0.60f - circleDiameter); if (!relaxAutoPilot) { + float oldWhiteAlpha = Colors.WHITE_ALPHA.a; + Colors.WHITE_ALPHA.a = alpha; g.setAntiAlias(true); g.setLineWidth(2f); - g.setColor(Color.white); + g.setColor(Colors.WHITE_ALPHA); g.drawOval(circleX, symbolHeight, circleDiameter, circleDiameter); if (trackPosition > firstObjectTime) { // map progress (white) - g.fillArc(circleX, symbolHeight, circleDiameter, circleDiameter, - -90, -90 + (int) (360f * (trackPosition - firstObjectTime) / (beatmap.endTime - firstObjectTime)) - ); + float progress = Math.min((float) (trackPosition - firstObjectTime) / (beatmap.endTime - firstObjectTime), 1f); + g.fillArc(circleX, symbolHeight, circleDiameter, circleDiameter, -90, -90 + (int) (360f * progress)); } else { // lead-in time (yellow) + float progress = (float) trackPosition / firstObjectTime; + float oldYellowAlpha = Colors.YELLOW_ALPHA.a; + Colors.YELLOW_ALPHA.a *= alpha; g.setColor(Colors.YELLOW_ALPHA); - g.fillArc(circleX, symbolHeight, circleDiameter, circleDiameter, - -90 + (int) (360f * trackPosition / firstObjectTime), -90 - ); + g.fillArc(circleX, symbolHeight, circleDiameter, circleDiameter, -90 + (int) (360f * progress), -90); + Colors.YELLOW_ALPHA.a = oldYellowAlpha; } g.setAntiAlias(false); + Colors.WHITE_ALPHA.a = oldWhiteAlpha; } // mod icons @@ -646,10 +656,12 @@ public class GameData { int modCount = 0; for (GameMod mod : GameMod.VALUES_REVERSED) { if (mod.isActive()) { + mod.getImage().setAlpha(alpha); mod.getImage().draw( modX - (modCount * (modWidth / 2f)), symbolHeight + circleDiameter + 10 ); + mod.getImage().setAlpha(1f); modCount++; } } @@ -697,8 +709,8 @@ public class GameData { float tickWidth = 2 * uiScale; for (HitErrorInfo info : hitErrorList) { int time = info.time; - float alpha = 1 - ((float) (trackPosition - time) / HIT_ERROR_FADE_TIME); - white.a = alpha * hitErrorAlpha; + float tickAlpha = 1 - ((float) (trackPosition - time) / HIT_ERROR_FADE_TIME); + white.a = tickAlpha * hitErrorAlpha; g.setColor(white); g.fillRect((hitErrorX + info.timeDiff - 1) * uiScale, tickY, tickWidth, tickHeight); } @@ -721,9 +733,12 @@ public class GameData { float colourX = 4 * uiScale, colourY = 15 * uiScale; Image colourCropped = colour.getSubImage(0, 0, (int) (645 * uiScale * healthRatio), colour.getHeight()); - scorebar.setAlpha(1f); + scorebar.setAlpha(alpha); scorebar.draw(0, 0); + scorebar.setAlpha(1f); + colourCropped.setAlpha(alpha); colourCropped.draw(colourX, colourY); + colourCropped.setAlpha(1f); Image ki = null; if (health >= 50f) @@ -734,7 +749,9 @@ public class GameData { ki = GameImage.SCOREBAR_KI_DANGER2.getImage(); if (comboPopTime < COMBO_POP_TIME) ki = ki.getScaledCopy(1f + (0.45f * (1f - (float) comboPopTime / COMBO_POP_TIME))); + ki.setAlpha(alpha); ki.drawCentered(colourX + colourCropped.getWidth(), colourY); + ki.setAlpha(1f); // combo burst if (comboBurstIndex != -1 && comboBurstAlpha > 0f) { @@ -750,8 +767,8 @@ public class GameData { float comboPopFront = 1 + comboPop * 0.08f; String comboString = String.format("%dx", combo); if (comboPopTime != COMBO_POP_TIME) - drawSymbolString(comboString, margin, height - margin - (symbolHeight * comboPopBack), comboPopBack, 0.5f, false); - drawSymbolString(comboString, margin, height - margin - (symbolHeight * comboPopFront), comboPopFront, 1f, false); + drawSymbolString(comboString, margin, height - margin - (symbolHeight * comboPopBack), comboPopBack, 0.5f * alpha, false); + drawSymbolString(comboString, margin, height - margin - (symbolHeight * comboPopFront), comboPopFront, alpha, false); } } else if (!relaxAutoPilot) { // grade @@ -759,9 +776,9 @@ public class GameData { if (grade != Grade.NULL) { Image gradeImage = grade.getSmallImage(); float gradeScale = symbolHeight * 0.75f / gradeImage.getHeight(); - gradeImage.getScaledCopy(gradeScale).draw( - circleX - gradeImage.getWidth(), symbolHeight - ); + gradeImage = gradeImage.getScaledCopy(gradeScale); + gradeImage.setAlpha(alpha); + gradeImage.draw(circleX - gradeImage.getWidth(), symbolHeight); } } } @@ -786,7 +803,7 @@ public class GameData { drawFixedSizeSymbolString( (score < 100000000) ? String.format("%08d", score) : Long.toString(score), 210 * uiScale, (rankingHeight + 50) * uiScale, - scoreTextScale, getScoreSymbolImage('0').getWidth() * scoreTextScale - 2, false + scoreTextScale, 1f, getScoreSymbolImage('0').getWidth() * scoreTextScale - 2, false ); // result counts @@ -897,63 +914,17 @@ public class GameData { lighting.drawCentered(hitResult.x, hitResult.y, hitResult.color); } - // hit animation - if (Options.isHitAnimationEnabled() && - hitResult.result != HIT_MISS && ( - hitResult.hitResultType == null || // null => initial slider circle - hitResult.hitResultType == HitObjectType.CIRCLE || - hitResult.hitResultType == HitObjectType.SLIDER_FIRST || - hitResult.hitResultType == HitObjectType.SLIDER_LAST)) { - float progress = AnimationEquation.OUT_CUBIC.calc( - (float) Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME) / HITCIRCLE_FADE_TIME); - float scale = (!hitResult.expand) ? 1f : 1f + (HITCIRCLE_ANIM_SCALE - 1f) * progress; - float alpha = 1f - progress; - - if (hitResult.result == HIT_SLIDER_REPEAT) { - // repeats - Image scaledRepeat = GameImage.REVERSEARROW.getImage().getScaledCopy(scale); - scaledRepeat.setAlpha(alpha); - float ang; - if (hitResult.hitResultType == HitObjectType.SLIDER_FIRST) { - ang = hitResult.curve.getStartAngle(); - } else { - ang = hitResult.curve.getEndAngle(); - } - scaledRepeat.rotate(ang); - scaledRepeat.drawCentered(hitResult.x, hitResult.y, hitResult.color); - } - // "hidden" mod: circle and slider animations not drawn - else if (!GameMod.HIDDEN.isActive()) { - // slider curve - if (hitResult.curve != null) { - float oldWhiteAlpha = Colors.WHITE_FADE.a; - float oldColorAlpha = hitResult.color.a; - Colors.WHITE_FADE.a = alpha; - hitResult.color.a = alpha; - if (!Options.isShrinkingSliders()) { - hitResult.curve.draw(hitResult.color); - } - Colors.WHITE_FADE.a = oldWhiteAlpha; - hitResult.color.a = oldColorAlpha; - } - - if (hitResult.hitResultType == null || hitResult.hitResultType == HitObjectType.CIRCLE) { - // hit circles - Image scaledHitCircle = GameImage.HITCIRCLE.getImage().getScaledCopy(scale); - Image scaledHitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(scale); - scaledHitCircle.setAlpha(alpha); - scaledHitCircleOverlay.setAlpha(alpha); - scaledHitCircle.drawCentered(hitResult.x, hitResult.y, hitResult.color); - scaledHitCircleOverlay.drawCentered(hitResult.x, hitResult.y); - } - } + // hit animations (only draw when the "Hidden" mod is not enabled) + if (!GameMod.HIDDEN.isActive()) { + drawHitAnimations(hitResult, trackPosition); } // hit result if (!hitResult.hideResult && ( - hitResult.hitResultType == HitObjectType.CIRCLE || - hitResult.hitResultType == HitObjectType.SPINNER || - hitResult.curve != null)) { + hitResult.hitResultType == HitObjectType.CIRCLE || + hitResult.hitResultType == HitObjectType.SLIDER_FIRST || + hitResult.hitResultType == HitObjectType.SLIDER_LAST || + hitResult.hitResultType == HitObjectType.SPINNER)) { float scaleProgress = AnimationEquation.IN_OUT_BOUNCE.calc( (float) Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_TEXT_BOUNCE_TIME) / HITCIRCLE_TEXT_BOUNCE_TIME); float scale = 1f + (HITCIRCLE_TEXT_ANIM_SCALE - 1f) * scaleProgress; @@ -974,6 +945,64 @@ public class GameData { } } + /** + * Draw the hit animations: + * circles, reverse arrows, slider curves (fading out and/or expanding). + * @param hitResult the hit result + * @param trackPosition the current track position (in ms) + */ + private void drawHitAnimations(HitObjectResult hitResult, int trackPosition) { + // fade out slider curve + if (hitResult.result != HIT_SLIDER_REPEAT && hitResult.curve != null) { + float progress = AnimationEquation.OUT_CUBIC.calc( + (float) Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME) / HITCIRCLE_FADE_TIME); + float alpha = 1f - progress; + float oldWhiteAlpha = Colors.WHITE_FADE.a; + float oldColorAlpha = hitResult.color.a; + Colors.WHITE_FADE.a = hitResult.color.a = alpha; + hitResult.curve.draw(hitResult.color); + Colors.WHITE_FADE.a = oldWhiteAlpha; + hitResult.color.a = oldColorAlpha; + } + + // miss, don't draw an animation + if (hitResult.result == HIT_MISS) { + return; + } + + // not a circle? + if (hitResult.hitResultType != HitObjectType.CIRCLE && + hitResult.hitResultType != HitObjectType.SLIDER_FIRST && + hitResult.hitResultType != HitObjectType.SLIDER_LAST) { + return; + } + + // hit circles + float progress = AnimationEquation.OUT_CUBIC.calc( + (float) Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME) / HITCIRCLE_FADE_TIME); + float scale = (!hitResult.expand) ? 1f : 1f + (HITCIRCLE_ANIM_SCALE - 1f) * progress; + float alpha = 1f - progress; + if (hitResult.result == HIT_SLIDER_REPEAT) { + // repeats + Image scaledRepeat = GameImage.REVERSEARROW.getImage().getScaledCopy(scale); + scaledRepeat.setAlpha(alpha); + float ang; + if (hitResult.hitResultType == HitObjectType.SLIDER_FIRST) { + ang = hitResult.curve.getStartAngle(); + } else { + ang = hitResult.curve.getEndAngle(); + } + scaledRepeat.rotate(ang); + scaledRepeat.drawCentered(hitResult.x, hitResult.y, hitResult.color); + } + Image scaledHitCircle = GameImage.HITCIRCLE.getImage().getScaledCopy(scale); + Image scaledHitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(scale); + scaledHitCircle.setAlpha(alpha); + scaledHitCircleOverlay.setAlpha(alpha); + scaledHitCircle.drawCentered(hitResult.x, hitResult.y, hitResult.color); + scaledHitCircleOverlay.drawCentered(hitResult.x, hitResult.y); + } + /** * Changes health by a given percentage, modified by drainRate. * @param percent the health percentage @@ -1202,16 +1231,16 @@ public class GameData { health = 0f; } - public void sendInitialSliderResult(int time, float x, float y, Color color, Color mirrorcolor) { - hitResultList.add(new HitObjectResult(time, HIT_SLIDER_INITIAL, x, y, color, null, null, true, false)); - if (!Options.isMirror() || !GameMod.AUTO.isActive()) { - return; - } - float[] m = Utils.mirrorPoint(x, y); - hitResultList.add(new HitObjectResult(time, HIT_SLIDER_INITIAL, m[0], m[1], mirrorcolor, null, null, true, false)); - } - - public void sendRepeatSliderResult(int time, float x, float y, Color color, Curve curve, HitObjectType type) { + /** + * Handles a slider repeat result (animation only: arrow). + * @param time the repeat time + * @param x the x coordinate + * @param y the y coordinate + * @param color the arrow color + * @param curve the slider curve + * @param type the hit object type + */ + public void sendSliderRepeatResult(int time, float x, float y, Color color, Curve curve, HitObjectType type) { hitResultList.add(new HitObjectResult(time, HIT_SLIDER_REPEAT, x, y, color, type, curve, true, true)); if (!Options.isMirror()) { return; @@ -1220,6 +1249,18 @@ public class GameData { hitResultList.add(new HitObjectResult(time, HIT_SLIDER_REPEAT, m[0], m[1], color, type, curve, true, true)); } + /** + * Handles a slider start result (animation only: initial circle). + * @param time the hit time + * @param x the x coordinate + * @param y the y coordinate + * @param color the slider color + * @param expand whether or not the hit result animation should expand + */ + public void sendSliderStartResult(int time, float x, float y, Color color, boolean expand) { + hitResultList.add(new HitObjectResult(time, HIT_ANIMATION_RESULT, x, y, color, HitObjectType.CIRCLE, null, expand, true)); + } + /** * Handles a slider tick result. * @param time the tick start time @@ -1229,7 +1270,7 @@ public class GameData { * @param hitObject the hit object * @param repeat the current repeat number */ - public void sliderTickResult(int time, int result, float x, float y, HitObject hitObject, int repeat) { + public void sendSliderTickResult(int time, int result, float x, float y, HitObject hitObject, int repeat) { int hitValue = 0; switch (result) { case HIT_SLIDER30: @@ -1415,6 +1456,23 @@ public class GameData { boolean expand, int repeat, Curve curve, boolean sliderHeldToEnd) { hitResult(time, result, x, y, color, end, hitObject, hitResultType, expand, repeat, curve, sliderHeldToEnd, true); } + + /** + * Handles a hit result. + * @param time the object start time + * @param result the hit result (HIT_* constants) + * @param x the x coordinate + * @param y the y coordinate + * @param color the combo color + * @param end true if this is the last hit object in the combo + * @param hitObject the hit object + * @param hitResultType the type of hit object for the result + * @param expand whether or not the hit result animation should expand (if applicable) + * @param repeat the current repeat number (for sliders, or 0 otherwise) + * @param curve the slider curve (or null if not applicable) + * @param sliderHeldToEnd whether or not the slider was held to the end (if applicable) + * @param handleResult whether or not to send a score result + */ public void hitResult(int time, int result, float x, float y, Color color, boolean end, HitObject hitObject, HitObjectType hitResultType, boolean expand, int repeat, Curve curve, boolean sliderHeldToEnd, boolean handleResult) { @@ -1431,16 +1489,6 @@ public class GameData { boolean hideResult = (hitResult == HIT_300 || hitResult == HIT_300G || hitResult == HIT_300K) && !Options.isPerfectHitBurstEnabled(); hitResultList.add(new HitObjectResult(time, hitResult, x, y, color, hitResultType, curve, expand, hideResult)); - - /* - // sliders: add the other curve endpoint for the hit animation - if (curve != null) { - boolean isFirst = (hitResultType == HitObjectType.SLIDER_FIRST); - Vec2f p = curve.pointAt((isFirst) ? 1f : 0f); - HitObjectType type = (isFirst) ? HitObjectType.SLIDER_LAST : HitObjectType.SLIDER_FIRST; - hitResultList.add(new HitObjectResult(time, hitResult, p.x, p.y, color, type, null, expand, hideResult)); - } - */ } /** diff --git a/src/itdelatrisu/opsu/GameImage.java b/src/itdelatrisu/opsu/GameImage.java index 756c8004..21d10812 100644 --- a/src/itdelatrisu/opsu/GameImage.java +++ b/src/itdelatrisu/opsu/GameImage.java @@ -655,7 +655,7 @@ public enum GameImage { * If the default image has already been loaded, this will do nothing. */ public void setDefaultImage() { - if (defaultImage != null || defaultImages != null) + if (defaultImage != null || defaultImages != null || Options.getSkin() == null) return; // try to load multiple images diff --git a/src/itdelatrisu/opsu/Opsu.java b/src/itdelatrisu/opsu/Opsu.java index 78872003..485210a9 100644 --- a/src/itdelatrisu/opsu/Opsu.java +++ b/src/itdelatrisu/opsu/Opsu.java @@ -249,9 +249,12 @@ public class Opsu extends StateBasedGame { } else songMenu.resetTrackOnLoad(); } + + // reset game data if (UI.getCursor().isBeatmapSkinned()) UI.getCursor().reset(); songMenu.resetGameDataOnLoad(); + this.enterState(Opsu.STATE_SONGMENU, new EasedFadeOutTransition(), new FadeInTransition()); return false; } diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java index e30be1ab..880305d5 100644 --- a/src/itdelatrisu/opsu/Options.java +++ b/src/itdelatrisu/opsu/Options.java @@ -143,6 +143,12 @@ public class Options { private static boolean noSingleInstance; + /** The theme song string: {@code filename,title,artist,length(ms)} */ + private static String themeString = "theme.mp3,Rainbows,Kevin MacLeod,219350"; + + /** The theme song timing point string (for computing beats to pulse the logo) . */ + private static String themeTimingPoint = "1080,545.454545454545,4,1,0,100,0,0"; + /** * Returns whether the XDG flag in the manifest (if any) is set to "true". * @return true if XDG directories are enabled, false otherwise @@ -171,8 +177,11 @@ public class Options { * @return the XDG base directory, or the working directory if unavailable */ private static File getXDGBaseDir(String env, String fallback) { + File workingDir = Utils.isJarRunning() ? + Utils.getRunningDirectory().getParentFile() : Utils.getWorkingDirectory(); + if (!USE_XDG) - return new File("./"); + return workingDir; String OS = System.getProperty("os.name").toLowerCase(); if (OS.indexOf("nix") >= 0 || OS.indexOf("nux") >= 0 || OS.indexOf("aix") > 0) { @@ -188,7 +197,7 @@ public class Options { ErrorHandler.error(String.format("Failed to create configuration folder at '%s/opsu'.", rootPath), null, false); return dir; } else - return new File("./"); + return workingDir; } /** @@ -219,12 +228,6 @@ public class Options { return (dir.isDirectory()) ? dir : null; } - /** - * The theme song string: - * {@code filename,title,artist,length(ms)} - */ - private static String themeString = "theme.ogg,On the Bach,Jingle Punks,66000"; - /** Game options. */ public enum GameOption { // internal options (not displayed in-game) @@ -275,7 +278,32 @@ public class Options { public String write() { return themeString; } @Override - public void read(String s) { themeString = s; } + public void read(String s) { + String oldThemeString = themeString; + themeString = s; + Beatmap beatmap = getThemeBeatmap(); + if (beatmap == null) { + themeString = oldThemeString; + Log.warn(String.format("The theme song string [%s] is malformed.", s)); + } else if (!beatmap.audioFilename.isFile()) { + themeString = oldThemeString; + Log.warn(String.format("Cannot find theme song [%s].", beatmap.audioFilename.getAbsolutePath())); + } + } + }, + THEME_SONG_TIMINGPOINT ("ThemeSongTiming") { + @Override + public String write() { return themeTimingPoint; } + + @Override + public void read(String s) { + try { + new TimingPoint(s); + themeTimingPoint = s; + } catch (Exception e) { + Log.warn(String.format("The theme song timing point [%s] is malformed.", s)); + } + } }, PORT ("Port") { @Override @@ -1876,25 +1904,26 @@ public class Options { /** * Returns a dummy Beatmap containing the theme song. - * @return the theme song beatmap + * @return the theme song beatmap, or {@code null} if the theme string is malformed */ public static Beatmap getThemeBeatmap() { String[] tokens = themeString.split(","); - if (tokens.length != 4) { - ErrorHandler.error("Theme song string is malformed.", null, false); + if (tokens.length != 4) return null; - } Beatmap beatmap = new Beatmap(null); beatmap.audioFilename = new File(tokens[0]); beatmap.title = tokens[1]; beatmap.artist = tokens[2]; - beatmap.timingPoints = new ArrayList<>(1); - beatmap.timingPoints.add(new TimingPoint("-44,631.578947368421,4,1,0,100,1,0")); try { beatmap.endTime = Integer.parseInt(tokens[3]); } catch (NumberFormatException e) { - ErrorHandler.error("Theme song length is not a valid integer", e, false); + return null; + } + try { + beatmap.timingPoints = new ArrayList<>(1); + beatmap.timingPoints.add(new TimingPoint(themeTimingPoint)); + } catch (Exception e) { return null; } diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java index 507818df..5c90b836 100644 --- a/src/itdelatrisu/opsu/Utils.java +++ b/src/itdelatrisu/opsu/Utils.java @@ -41,6 +41,7 @@ import java.net.SocketTimeoutException; import java.net.URISyntaxException; import java.net.URL; import java.nio.ByteBuffer; +import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.cert.X509Certificate; @@ -158,6 +159,14 @@ public class Utils { anim.draw(x - (anim.getWidth() / 2f), y - (anim.getHeight() / 2f)); } + /** + * Returns the luminance of a color. + * @param c the color + */ + public static float getLuminance(Color c) { + return 0.299f*c.r + 0.587f*c.g + 0.114f*c.b; + } + /** * Clamps a value between a lower and upper bound. * @param val the value to clamp @@ -554,6 +563,14 @@ public class Utils { } } + /** + * Returns the current working directory. + * @return the directory + */ + public static File getWorkingDirectory() { + return Paths.get(".").toAbsolutePath().normalize().toFile(); + } + /** * Parses the integer string argument as a boolean: * {@code 1} is {@code true}, and all other values are {@code false}. diff --git a/src/itdelatrisu/opsu/audio/MusicController.java b/src/itdelatrisu/opsu/audio/MusicController.java index 9cda69b1..c5e24e7b 100644 --- a/src/itdelatrisu/opsu/audio/MusicController.java +++ b/src/itdelatrisu/opsu/audio/MusicController.java @@ -78,6 +78,12 @@ public class MusicController { /** The track dim level, if dimmed. */ private static float dimLevel = 1f; + /** Current timing point index in the track, advanced by {@link #getBeatProgress()}. */ + private static int timingPointIndex; + + /** Last non-inherited timing point. */ + private static TimingPoint lastTimingPoint; + // This class should not be instantiated. private MusicController() {} @@ -94,7 +100,6 @@ public class MusicController { final File audioFile = beatmap.audioFilename; if (!audioFile.isFile() && !ResourceLoader.resourceExists(audioFile.getPath())) { UI.sendBarNotification(String.format("Could not find track '%s'.", audioFile.getName())); - System.out.println(beatmap); return; } @@ -136,8 +141,10 @@ public class MusicController { player.addListener(new MusicListener() { @Override public void musicEnded(Music music) { - if (music == player) // don't fire if music swapped + if (music == player) { // don't fire if music swapped trackEnded = true; + resetTimingPoint(); + } } @Override @@ -159,6 +166,7 @@ public class MusicController { setVolume(Options.getMusicVolume() * Options.getMasterVolume()); trackEnded = false; pauseTime = 0f; + resetTimingPoint(); if (loop) player.loop(); else @@ -187,34 +195,81 @@ public class MusicController { /** * Gets the progress of the current beat. - * @return progress as a value in [0, 1], where 0 means a beat just happend and 1 means the next beat is coming now. + * @return a beat progress value [0,1) where 0 marks the current beat and + * 1 marks the next beat, or {@code null} if no timing information + * is available (e.g. music paused, no timing points) */ - public static Double getBeatProgress() { - if (!isPlaying() || getBeatmap() == null) { + public static Float getBeatProgress() { + if (!updateTimingPoint()) return null; - } + + // calculate beat progress + int trackPosition = Math.max(0, getPosition()); + double beatLength = lastTimingPoint.getBeatLength() * 100.0; + int beatTime = lastTimingPoint.getTime(); + if (trackPosition < beatTime) + trackPosition += (beatLength / 100.0) * (beatTime / lastTimingPoint.getBeatLength()); + return (float) ((((trackPosition - beatTime) * 100.0) % beatLength) / beatLength); + } + + /** + * Gets the progress of the current measure. + * @return a measure progress value [0,1) where 0 marks the start of the measure and + * 1 marks the start of the next measure, or {@code null} if no timing information + * is available (e.g. music paused, no timing points) + */ + public static Float getMeasureProgress() { return getMeasureProgress(1); } + + /** + * Gets the progress of the current measure. + * @param k the meter multiplier + * @return a measure progress value [0,1) where 0 marks the start of the measure and + * 1 marks the start of the next measure, or {@code null} if no timing information + * is available (e.g. music paused, no timing points) + */ + public static Float getMeasureProgress(int k) { + if (!updateTimingPoint()) + return null; + + // calculate measure progress + int trackPosition = Math.max(0, getPosition()); + double measureLength = lastTimingPoint.getBeatLength() * lastTimingPoint.getMeter() * k * 100.0; + int beatTime = lastTimingPoint.getTime(); + if (trackPosition < beatTime) + trackPosition += (measureLength / 100.0) * (beatTime / lastTimingPoint.getBeatLength()); + return (float) ((((trackPosition - beatTime) * 100.0) % measureLength) / measureLength); + } + + /** + * Updates the timing point information for the current track position. + * @return {@code false} if timing point information is not available, {@code true} otherwise + */ + private static boolean updateTimingPoint() { Beatmap map = getBeatmap(); - if (map.timingPoints == null) { - return null; + if (!isPlaying() || map == null || map.timingPoints == null || map.timingPoints.isEmpty()) + return false; + + // initialization + if (timingPointIndex == 0 && lastTimingPoint == null && !map.timingPoints.isEmpty()) { + TimingPoint timingPoint = map.timingPoints.get(0); + if (!timingPoint.isInherited()) + lastTimingPoint = timingPoint; } - int trackposition = getPosition(); - TimingPoint p = null; - float beatlen = 0f; - int time = 0; - for (TimingPoint pts : map.timingPoints) { - if (p == null || pts.getTime() < getPosition()) { - p = pts; - if (!p.isInherited() && p.getBeatLength() > 0) { - beatlen = p.getBeatLength(); - time = p.getTime(); - } - } + + // advance timing point index, record last non-inherited timing point + int trackPosition = getPosition(); + for (int i = timingPointIndex + 1; i < map.timingPoints.size(); i++) { + TimingPoint timingPoint = map.timingPoints.get(i); + if (trackPosition < timingPoint.getTime()) + break; + timingPointIndex = i; + if (!timingPoint.isInherited() && timingPoint.getBeatLength() > 0) + lastTimingPoint = timingPoint; } - if (p == null) { - return null; - } - double beatLength = beatlen * 100; - return (((trackposition * 100 - time * 100) % beatLength) / beatLength); + if (lastTimingPoint == null) + return false; // no timing info + + return true; } /** @@ -258,8 +313,10 @@ public class MusicController { public static void stop() { if (isPlaying()) player.stop(); - if (trackExists()) + if (trackExists()) { pauseTime = 0f; + resetTimingPoint(); + } } /** @@ -298,7 +355,11 @@ public class MusicController { * @param position the new track position (in ms) */ public static boolean setPosition(int position) { - return (trackExists() && position >= 0 && player.setPosition(position / 1000f)); + if (!trackExists() || position < 0) + return false; + + resetTimingPoint(); + return (player.setPosition(position / 1000f)); } /** @@ -339,6 +400,7 @@ public class MusicController { public static void play(boolean loop) { if (trackExists()) { trackEnded = false; + resetTimingPoint(); if (loop) player.loop(); else @@ -409,6 +471,14 @@ public class MusicController { setVolume(volume); } + /** + * Resets timing point information. + */ + private static void resetTimingPoint() { + timingPointIndex = 0; + lastTimingPoint = null; + } + /** * Completely resets MusicController state. *

@@ -427,7 +497,7 @@ public class MusicController { try { trackLoader.join(); } catch (InterruptedException e) { - e.printStackTrace(); + ErrorHandler.error(null, e, true); } } trackLoader = null; @@ -439,6 +509,7 @@ public class MusicController { themePlaying = false; pauseTime = 0f; trackDimmed = false; + resetTimingPoint(); // releases all sources from previous tracks destroyOpenAL(); diff --git a/src/itdelatrisu/opsu/beatmap/Beatmap.java b/src/itdelatrisu/opsu/beatmap/Beatmap.java index 922aedde..a87cc236 100644 --- a/src/itdelatrisu/opsu/beatmap/Beatmap.java +++ b/src/itdelatrisu/opsu/beatmap/Beatmap.java @@ -68,6 +68,18 @@ public class Beatmap implements Comparable { /** The star rating. */ public double starRating = -1; + /** The timestamp this beatmap was first loaded. */ + public long dateAdded = 0; + + /** Whether this beatmap is marked as a "favorite". */ + public boolean favorite = false; + + /** Total number of times this beatmap has been played. */ + public int playCount = 0; + + /** The last time this beatmap was played (timestamp). */ + public long lastPlayed = 0; + /** * [General] */ @@ -501,4 +513,12 @@ public class Beatmap implements Comparable { String[] rgb = s.split(","); this.sliderBorder = new Color(new Color(Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2]))); } + + /** + * Increments the play counter and last played time. + */ + public void incrementPlayCounter() { + this.playCount++; + this.lastPlayed = System.currentTimeMillis(); + } } \ No newline at end of file diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java b/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java index 6cf29528..45075b55 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java @@ -81,7 +81,7 @@ public class BeatmapDifficultyCalculator { */ public BeatmapDifficultyCalculator(Beatmap beatmap) { this.beatmap = beatmap; - if (beatmap.timingPoints == null) + if (beatmap.breaks == null) BeatmapDB.load(beatmap, BeatmapDB.LOAD_ARRAY); BeatmapParser.parseHitObjects(beatmap); } diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapGroup.java b/src/itdelatrisu/opsu/beatmap/BeatmapGroup.java new file mode 100644 index 00000000..204f121d --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapGroup.java @@ -0,0 +1,179 @@ +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.PriorityQueue; + +import org.newdawn.slick.Image; + +/** + * Beatmap groups. + */ +public enum BeatmapGroup { + /** All beatmaps (no filter). */ + ALL (0, "All Songs", null), + + /** Most recently played beatmaps. */ + RECENT (1, "Last Played", "Your recently played beatmaps will appear in this list!") { + /** Number of elements to show. */ + private static final int K = 20; + + /** Returns the latest "last played" time in a beatmap set. */ + private long lastPlayed(BeatmapSet set) { + long max = 0; + for (Beatmap beatmap : set) { + if (beatmap.lastPlayed > max) + max = beatmap.lastPlayed; + } + return max; + } + + @Override + public ArrayList filter(ArrayList list) { + // find top K elements + PriorityQueue pq = new PriorityQueue(K, new Comparator() { + @Override + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + return Long.compare(lastPlayed(v.getBeatmapSet()), lastPlayed(w.getBeatmapSet())); + } + }); + for (BeatmapSetNode node : list) { + long timestamp = lastPlayed(node.getBeatmapSet()); + if (timestamp == 0) + continue; // skip unplayed beatmaps + if (pq.size() < K || timestamp > lastPlayed(pq.peek().getBeatmapSet())) { + if (pq.size() == K) + pq.poll(); + pq.add(node); + } + } + + // return as list + ArrayList filteredList = new ArrayList(); + for (BeatmapSetNode node : pq) + filteredList.add(node); + return filteredList; + } + }, + + /** "Favorite" beatmaps. */ + FAVORITE (2, "Favorites", "Right-click a beatmap to add it to your Favorites!") { + @Override + public ArrayList filter(ArrayList list) { + // find "favorite" beatmaps + ArrayList filteredList = new ArrayList(); + for (BeatmapSetNode node : list) { + if (node.getBeatmapSet().isFavorite()) + filteredList.add(node); + } + return filteredList; + } + }; + + /** The ID of the group (used for tab positioning). */ + private final int id; + + /** The name of the group. */ + private final String name; + + /** The message to display if this list is empty. */ + private final String emptyMessage; + + /** The tab associated with the group (displayed in Song Menu screen). */ + private MenuButton tab; + + /** Total number of groups. */ + private static final int SIZE = values().length; + + /** Array of BeatmapGroup objects in reverse order. */ + public static final BeatmapGroup[] VALUES_REVERSED; + static { + VALUES_REVERSED = values(); + Collections.reverse(Arrays.asList(VALUES_REVERSED)); + } + + /** Current group. */ + private static BeatmapGroup currentGroup = ALL; + + /** + * Returns the current group. + * @return the current group + */ + public static BeatmapGroup current() { return currentGroup; } + + /** + * Sets a new group. + * @param group the new group + */ + public static void set(BeatmapGroup group) { currentGroup = group; } + + /** + * Constructor. + * @param id the ID of the group (for tab positioning) + * @param name the group name + * @param emptyMessage the message to display if this list is empty + */ + BeatmapGroup(int id, String name, String emptyMessage) { + this.id = id; + this.name = name; + this.emptyMessage = emptyMessage; + } + + /** + * Returns the message to display if this list is empty. + * @return the message, or null if none + */ + public String getEmptyMessage() { return emptyMessage; } + + /** + * Returns a filtered list of beatmap set nodes. + * @param list the unfiltered list + * @return the filtered list + */ + public ArrayList filter(ArrayList list) { + return list; + } + + /** + * Initializes the tab. + * @param containerWidth the container width + * @param bottomY the bottom y coordinate + */ + public void init(int containerWidth, float bottomY) { + Image tab = GameImage.MENU_TAB.getImage(); + int tabWidth = tab.getWidth(); + float buttonX = containerWidth / 2f; + float tabOffset = (containerWidth - buttonX - tabWidth) / (SIZE - 1); + if (tabOffset > tabWidth) { // prevent tabs from being spaced out + tabOffset = tabWidth; + buttonX = (containerWidth * 0.99f) - (tabWidth * SIZE); + } + this.tab = new MenuButton(tab, + (buttonX + (tabWidth / 2f)) + (id * tabOffset), + bottomY - (tab.getHeight() / 2f) + ); + } + + /** + * Checks if the coordinates are within the image bounds. + * @param x the x coordinate + * @param y the y coordinate + * @return true if within bounds + */ + public boolean contains(float x, float y) { return tab.contains(x, y); } + + /** + * Draws the tab. + * @param selected whether the tab is selected (white) or not (red) + * @param isHover whether to include a hover effect (unselected only) + */ + public void draw(boolean selected, boolean isHover) { + UI.drawTab(tab.getX(), tab.getY(), name, selected, isHover); + } +} diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java index 0f293ead..3e3fa4f1 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java @@ -112,6 +112,7 @@ public class BeatmapParser { // parse directories BeatmapSetNode lastNode = null; + long timestamp = System.currentTimeMillis(); for (File dir : dirs) { currentDirectoryIndex++; if (!dir.isDirectory()) @@ -160,6 +161,7 @@ public class BeatmapParser { // add to parsed beatmap list if (beatmap != null) { + beatmap.dateAdded = timestamp; beatmaps.add(beatmap); parsedBeatmaps.add(beatmap); } @@ -547,21 +549,7 @@ public class BeatmapParser { break; try { - // parse timing point - TimingPoint timingPoint = new TimingPoint(line); - - // calculate BPM - if (!timingPoint.isInherited()) { - int bpm = Math.round(60000 / timingPoint.getBeatLength()); - if (beatmap.bpmMin == 0) - beatmap.bpmMin = beatmap.bpmMax = bpm; - else if (bpm < beatmap.bpmMin) - beatmap.bpmMin = bpm; - else if (bpm > beatmap.bpmMax) - beatmap.bpmMax = bpm; - } - - beatmap.timingPoints.add(timingPoint); + parseTimingPoint(beatmap, line); } catch (Exception e) { Log.warn(String.format("Failed to read timing point '%s' for file '%s'.", line, file.getAbsolutePath()), e); @@ -678,6 +666,71 @@ public class BeatmapParser { return beatmap; } + /** + * Parses a timing point and adds it to the beatmap. + * @param beatmap the beatmap + * @param line the line containing the unparsed timing point + */ + private static void parseTimingPoint(Beatmap beatmap, String line) { + // parse timing point + TimingPoint timingPoint = new TimingPoint(line); + beatmap.timingPoints.add(timingPoint); + + // calculate BPM + if (!timingPoint.isInherited()) { + int bpm = Math.round(60000 / timingPoint.getBeatLength()); + if (beatmap.bpmMin == 0) { + beatmap.bpmMin = beatmap.bpmMax = bpm; + } else if (bpm < beatmap.bpmMin) { + beatmap.bpmMin = bpm; + } else if (bpm > beatmap.bpmMax) { + beatmap.bpmMax = bpm; + } + } + } + + /** + * Parses all timing points in a beatmap. + * @param beatmap the beatmap to parse + */ + public static void parseTimingPoints(Beatmap beatmap) { + if (beatmap.timingPoints != null) // already parsed + return; + + beatmap.timingPoints = new ArrayList(); + + try (BufferedReader in = new BufferedReader(new FileReader(beatmap.getFile()))) { + String line = in.readLine(); + while (line != null) { + line = line.trim(); + if (!line.equals("[TimingPoints]")) + line = in.readLine(); + else + break; + } + if (line == null) // no timing points + return; + + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + + try { + parseTimingPoint(beatmap, line); + } catch (Exception e) { + Log.warn(String.format("Failed to read timing point '%s' for file '%s'.", + line, beatmap.getFile().getAbsolutePath()), e); + } + } + beatmap.timingPoints.trimToSize(); + } catch (IOException e) { + ErrorHandler.error(String.format("Failed to read file '%s'.", beatmap.getFile().getAbsolutePath()), e, false); + } + } + /** * Parses all hit objects in a beatmap. * @param beatmap the beatmap to parse diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSet.java b/src/itdelatrisu/opsu/beatmap/BeatmapSet.java index 62491ae6..3c5e7e6c 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSet.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSet.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.GameMod; +import itdelatrisu.opsu.db.BeatmapDB; import java.text.DecimalFormat; import java.text.NumberFormat; @@ -185,4 +186,37 @@ public class BeatmapSet implements Iterable { return false; } + + /** + * Returns whether this beatmap set is a "favorite". + */ + public boolean isFavorite() { + for (Beatmap map : beatmaps) { + if (map.favorite) + return true; + } + return false; + } + + /** + * Sets the "favorite" status of this beatmap set. + * @param flag whether this beatmap set should have "favorite" status + */ + public void setFavorite(boolean flag) { + for (Beatmap map : beatmaps) { + map.favorite = flag; + BeatmapDB.updateFavoriteStatus(map); + } + } + + /** + * Returns whether any beatmap in this set has been played. + */ + public boolean isPlayed() { + for (Beatmap map : beatmaps) { + if (map.playCount > 0) + return true; + } + return false; + } } diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java b/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java index 7a0efdd9..dc9471ce 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java @@ -54,6 +54,9 @@ public class BeatmapSetList { /** Total number of beatmaps (i.e. Beatmap objects). */ private int mapCount = 0; + /** List containing all nodes in the current group. */ + private ArrayList groupNodes; + /** Current list of nodes (subset of parsedNodes, used for searches). */ private ArrayList nodes; @@ -97,7 +100,7 @@ public class BeatmapSetList { * This does not erase any parsed nodes. */ public void reset() { - nodes = parsedNodes; + nodes = groupNodes = BeatmapGroup.current().filter(parsedNodes); expandedIndex = -1; expandedStartNode = expandedEndNode = null; lastQuery = ""; @@ -168,6 +171,7 @@ public class BeatmapSetList { Beatmap beatmap = beatmapSet.get(0); nodes.remove(index); parsedNodes.remove(eCur); + groupNodes.remove(eCur); mapCount -= beatmapSet.size(); if (beatmap.beatmapSetID > 0) MSIDdb.remove(beatmap.beatmapSetID); @@ -407,7 +411,7 @@ public class BeatmapSetList { return; // sort the list - Collections.sort(nodes, BeatmapSortOrder.getSort().getComparator()); + Collections.sort(nodes, BeatmapSortOrder.current().getComparator()); expandedIndex = -1; expandedStartNode = expandedEndNode = null; @@ -444,7 +448,7 @@ public class BeatmapSetList { // if empty query, reset to original list if (query.isEmpty() || terms.isEmpty()) { - nodes = parsedNodes; + nodes = groupNodes; return true; } @@ -472,14 +476,14 @@ public class BeatmapSetList { String type = condType.remove(); String operator = condOperator.remove(); float value = condValue.remove(); - for (BeatmapSetNode node : parsedNodes) { + for (BeatmapSetNode node : groupNodes) { if (node.getBeatmapSet().matches(type, operator, value)) nodes.add(node); } } else { // normal term String term = terms.remove(); - for (BeatmapSetNode node : parsedNodes) { + for (BeatmapSetNode node : groupNodes) { if (node.getBeatmapSet().matches(term)) nodes.add(node); } diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java b/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java index 947631c5..1430162a 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java @@ -75,7 +75,7 @@ public class BeatmapSetNode { public void draw(float x, float y, Grade grade, boolean focus) { Image bg = GameImage.MENU_BUTTON_BG.getImage(); boolean expanded = (beatmapIndex > -1); - Beatmap beatmap; + Beatmap beatmap = beatmapSet.get(expanded ? beatmapIndex : 0); bg.setAlpha(0.9f); Color bgColor; Color textColor = Options.getSkin().getSongSelectInactiveTextColor(); @@ -88,11 +88,10 @@ public class BeatmapSetNode { textColor = Options.getSkin().getSongSelectActiveTextColor(); } else bgColor = Colors.BLUE_BUTTON; - beatmap = beatmapSet.get(beatmapIndex); - } else { + } else if (beatmapSet.isPlayed()) bgColor = Colors.ORANGE_BUTTON; - beatmap = beatmapSet.get(0); - } + else + bgColor = Colors.PINK_BUTTON; bg.draw(x, y, bgColor); float cx = x + (bg.getWidth() * 0.043f); diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java b/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java index 78ca593c..93f4dca8 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java @@ -18,28 +18,19 @@ package itdelatrisu.opsu.beatmap; -import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.ui.MenuButton; -import itdelatrisu.opsu.ui.UI; - -import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; -import org.newdawn.slick.Image; - /** * Beatmap sorting orders. */ public enum BeatmapSortOrder { - TITLE (0, "Title", new TitleOrder()), - ARTIST (1, "Artist", new ArtistOrder()), - CREATOR (2, "Creator", new CreatorOrder()), - BPM (3, "BPM", new BPMOrder()), - LENGTH (4, "Length", new LengthOrder()); - - /** The ID of the sort (used for tab positioning). */ - private final int id; + TITLE ("Title", new TitleOrder()), + ARTIST ("Artist", new ArtistOrder()), + CREATOR ("Creator", new CreatorOrder()), + BPM ("BPM", new BPMOrder()), + LENGTH ("Length", new LengthOrder()), + DATE ("Date Added", new DateOrder()), + PLAYS ("Most Played", new PlayOrder()); /** The name of the sort. */ private final String name; @@ -47,19 +38,6 @@ public enum BeatmapSortOrder { /** The comparator for the sort. */ private final Comparator comparator; - /** The tab associated with the sort (displayed in Song Menu screen). */ - private MenuButton tab; - - /** Total number of sorts. */ - private static final int SIZE = values().length; - - /** Array of BeatmapSortOrder objects in reverse order. */ - public static final BeatmapSortOrder[] VALUES_REVERSED; - static { - VALUES_REVERSED = values(); - Collections.reverse(Arrays.asList(VALUES_REVERSED)); - } - /** Current sort. */ private static BeatmapSortOrder currentSort = TITLE; @@ -67,13 +45,13 @@ public enum BeatmapSortOrder { * Returns the current sort. * @return the current sort */ - public static BeatmapSortOrder getSort() { return currentSort; } + public static BeatmapSortOrder current() { return currentSort; } /** * Sets a new sort. * @param sort the new sort */ - public static void setSort(BeatmapSortOrder sort) { BeatmapSortOrder.currentSort = sort; } + public static void set(BeatmapSortOrder sort) { currentSort = sort; } /** * Compares two BeatmapSetNode objects by title. @@ -135,37 +113,57 @@ public enum BeatmapSortOrder { } } + /** + * Compares two BeatmapSetNode objects by date added. + * Uses the latest beatmap added in each set for comparison. + */ + private static class DateOrder implements Comparator { + @Override + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + long vMax = 0, wMax = 0; + for (Beatmap beatmap : v.getBeatmapSet()) { + if (beatmap.dateAdded > vMax) + vMax = beatmap.dateAdded; + } + for (Beatmap beatmap : w.getBeatmapSet()) { + if (beatmap.dateAdded > wMax) + wMax = beatmap.dateAdded; + } + return Long.compare(vMax, wMax); + } + } + + /** + * Compares two BeatmapSetNode objects by total plays + * (summed across all beatmaps in each set). + */ + private static class PlayOrder implements Comparator { + @Override + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + int vTotal = 0, wTotal = 0; + for (Beatmap beatmap : v.getBeatmapSet()) + vTotal += beatmap.playCount; + for (Beatmap beatmap : w.getBeatmapSet()) + wTotal += beatmap.playCount; + return Integer.compare(vTotal, wTotal); + } + } + /** * Constructor. - * @param id the ID of the sort (for tab positioning) * @param name the sort name * @param comparator the comparator for the sort */ - BeatmapSortOrder(int id, String name, Comparator comparator) { - this.id = id; + BeatmapSortOrder(String name, Comparator comparator) { this.name = name; this.comparator = comparator; } /** - * Initializes the sort tab. - * @param containerWidth the container width - * @param bottomY the bottom y coordinate + * Returns the sort name. + * @return the name */ - public void init(int containerWidth, float bottomY) { - Image tab = GameImage.MENU_TAB.getImage(); - int tabWidth = tab.getWidth(); - float buttonX = containerWidth / 2f; - float tabOffset = (containerWidth - buttonX - tabWidth) / (SIZE - 1); - if (tabOffset > tabWidth) { // prevent tabs from being spaced out - tabOffset = tabWidth; - buttonX = (containerWidth * 0.99f) - (tabWidth * SIZE); - } - this.tab = new MenuButton(tab, - (buttonX + (tabWidth / 2f)) + (id * tabOffset), - bottomY - (tab.getHeight() / 2f) - ); - } + public String getName() { return name; } /** * Returns the comparator for the sort. @@ -173,20 +171,6 @@ public enum BeatmapSortOrder { */ public Comparator getComparator() { return comparator; } - /** - * Checks if the coordinates are within the image bounds. - * @param x the x coordinate - * @param y the y coordinate - * @return true if within bounds - */ - public boolean contains(float x, float y) { return tab.contains(x, y); } - - /** - * Draws the sort tab. - * @param selected whether the tab is selected (white) or not (red) - * @param isHover whether to include a hover effect (unselected only) - */ - public void draw(boolean selected, boolean isHover) { - UI.drawTab(tab.getX(), tab.getY(), name, selected, isHover); - } + @Override + public String toString() { return name; } } \ No newline at end of file diff --git a/src/itdelatrisu/opsu/beatmap/TimingPoint.java b/src/itdelatrisu/opsu/beatmap/TimingPoint.java index 91597d4a..2fb26f50 100644 --- a/src/itdelatrisu/opsu/beatmap/TimingPoint.java +++ b/src/itdelatrisu/opsu/beatmap/TimingPoint.java @@ -58,6 +58,14 @@ public class TimingPoint { * @param line the line to be parsed */ public TimingPoint(String line) { + /** + * [TIMING POINT FORMATS] + * Non-inherited: + * offset,msPerBeat,meter,sampleType,sampleSet,volume,inherited,kiai + * + * Inherited: + * offset,velocity,meter,sampleType,sampleSet,volume,inherited,kiai + */ // TODO: better support for old formats String[] tokens = line.split(","); try { diff --git a/src/itdelatrisu/opsu/db/BeatmapDB.java b/src/itdelatrisu/opsu/db/BeatmapDB.java index 5d54ab4d..2dbb1e2f 100644 --- a/src/itdelatrisu/opsu/db/BeatmapDB.java +++ b/src/itdelatrisu/opsu/db/BeatmapDB.java @@ -30,6 +30,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -42,8 +43,30 @@ public class BeatmapDB { /** * Current database version. * This value should be changed whenever the database format changes. + * Add any update queries to the {@link #getUpdateQueries(int)} method. */ - private static final String DATABASE_VERSION = "2015-09-02"; + private static final int DATABASE_VERSION = 20161222; + + /** + * Returns a list of SQL queries to apply, in order, to update from + * the given database version to the latest version. + * @param version the current version + * @return a list of SQL queries + */ + private static List getUpdateQueries(int version) { + List list = new LinkedList(); + if (version < 20161222) { + list.add("ALTER TABLE beatmaps ADD COLUMN dateAdded INTEGER"); + list.add("ALTER TABLE beatmaps ADD COLUMN favorite BOOLEAN"); + list.add("ALTER TABLE beatmaps ADD COLUMN playCount INTEGER"); + list.add("ALTER TABLE beatmaps ADD COLUMN lastPlayed INTEGER"); + list.add("UPDATE beatmaps SET dateAdded = 0, favorite = 0, playCount = 0, lastPlayed = 0"); + } + + /* add future updates here */ + + return list; + } /** Minimum batch size ratio ({@code batchSize/cacheSize}) to invoke batch loading. */ private static final float LOAD_BATCH_MIN_RATIO = 0.2f; @@ -58,7 +81,9 @@ public class BeatmapDB { private static Connection connection; /** Query statements. */ - private static PreparedStatement insertStmt, selectStmt, deleteMapStmt, deleteGroupStmt, setStarsStmt, updateSizeStmt; + private static PreparedStatement + insertStmt, selectStmt, deleteMapStmt, deleteGroupStmt, + setStarsStmt, updatePlayStatsStmt, setFavoriteStmt, updateSizeStmt; /** Current size of beatmap cache table. */ private static int cacheSize = -1; @@ -75,6 +100,9 @@ public class BeatmapDB { if (connection == null) return; + // run any database updates + updateDatabase(); + // create the database createDatabase(); @@ -88,20 +116,21 @@ public class BeatmapDB { // retrieve the cache size getCacheSize(); - // check the database version - checkVersion(); - // prepare sql statements (not used here) try { insertStmt = connection.prepareStatement( "INSERT INTO beatmaps VALUES (" + - "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," + - "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," + + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," + + "?, ?, ?, ?, ?, ?" + + ")" ); selectStmt = connection.prepareStatement("SELECT * FROM beatmaps WHERE dir = ? AND file = ?"); deleteMapStmt = connection.prepareStatement("DELETE FROM beatmaps WHERE dir = ? AND file = ?"); deleteGroupStmt = connection.prepareStatement("DELETE FROM beatmaps WHERE dir = ?"); setStarsStmt = connection.prepareStatement("UPDATE beatmaps SET stars = ? WHERE dir = ? AND file = ?"); + updatePlayStatsStmt = connection.prepareStatement("UPDATE beatmaps SET playCount = ?, lastPlayed = ? WHERE dir = ? AND file = ?"); + setFavoriteStmt = connection.prepareStatement("UPDATE beatmaps SET favorite = ? WHERE dir = ? AND file = ?"); } catch (SQLException e) { ErrorHandler.error("Failed to prepare beatmap statements.", e, true); } @@ -124,7 +153,8 @@ public class BeatmapDB { "audioFile TEXT, audioLeadIn INTEGER, previewTime INTEGER, countdown INTEGER, sampleSet TEXT, stackLeniency REAL, " + "mode INTEGER, letterboxInBreaks BOOLEAN, widescreenStoryboard BOOLEAN, epilepsyWarning BOOLEAN, " + "bg TEXT, sliderBorder TEXT, timingPoints TEXT, breaks TEXT, combo TEXT, " + - "md5hash TEXT, stars REAL" + + "md5hash TEXT, stars REAL, " + + "dateAdded INTEGER, favorite BOOLEAN, playCount INTEGER, lastPlayed INTEGER" + "); " + "CREATE TABLE IF NOT EXISTS info (" + "key TEXT NOT NULL UNIQUE, value TEXT" + @@ -145,29 +175,54 @@ public class BeatmapDB { } /** - * Checks the stored table version, clears the beatmap database if different - * from the current version, then updates the version field. + * Applies any database updates by comparing the current version to the + * stored version. Does nothing if tables have not been created. */ - private static void checkVersion() { + private static void updateDatabase() { try (Statement stmt = connection.createStatement()) { - // get the stored version - String sql = "SELECT value FROM info WHERE key = 'version'"; + int version = 0; + + // if 'info' table does not exist, assume version 0 and apply all updates + String sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'info'"; ResultSet rs = stmt.executeQuery(sql); - String version = (rs.next()) ? rs.getString(1) : ""; + boolean infoExists = rs.isBeforeFirst(); rs.close(); + if (!infoExists) { + // if 'beatmaps' table also does not exist, databases not yet created + sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'beatmaps'"; + ResultSet beatmapsRS = stmt.executeQuery(sql); + boolean beatmapsExists = beatmapsRS.isBeforeFirst(); + beatmapsRS.close(); + if (!beatmapsExists) + return; + } else { + // try to retrieve stored version + sql = "SELECT value FROM info WHERE key = 'version'"; + ResultSet versionRS = stmt.executeQuery(sql); + String versionString = (versionRS.next()) ? versionRS.getString(1) : "0"; + versionRS.close(); + try { + version = Integer.parseInt(versionString); + } catch (NumberFormatException e) {} + } - // if different from current version, clear the database - if (!version.equals(DATABASE_VERSION)) { - clearDatabase(); + // database versions match + if (version >= DATABASE_VERSION) + return; - // update version + // apply updates + for (String query : getUpdateQueries(version)) + stmt.executeUpdate(query); + + // update version + if (infoExists) { PreparedStatement ps = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('version', ?)"); - ps.setString(1, DATABASE_VERSION); + ps.setString(1, Integer.toString(DATABASE_VERSION)); ps.executeUpdate(); ps.close(); } } catch (SQLException e) { - ErrorHandler.error("Beatmap database version checks failed.", e, true); + ErrorHandler.error("Failed to update beatmap database.", e, true); } } @@ -344,6 +399,10 @@ public class BeatmapDB { stmt.setString(40, beatmap.comboToString()); stmt.setString(41, beatmap.md5Hash); stmt.setDouble(42, beatmap.starRating); + stmt.setLong(43, beatmap.dateAdded); + stmt.setBoolean(44, beatmap.favorite); + stmt.setInt(45, beatmap.playCount); + stmt.setLong(46, beatmap.lastPlayed); } catch (SQLException e) { throw e; } catch (Exception e) { @@ -487,6 +546,10 @@ public class BeatmapDB { beatmap.sliderBorderFromString(rs.getString(37)); beatmap.md5Hash = rs.getString(41); beatmap.starRating = rs.getDouble(42); + beatmap.dateAdded = rs.getLong(43); + beatmap.favorite = rs.getBoolean(44); + beatmap.playCount = rs.getInt(45); + beatmap.lastPlayed = rs.getLong(46); } catch (SQLException e) { throw e; } catch (Exception e) { @@ -593,6 +656,45 @@ public class BeatmapDB { } } + /** + * Updates the play statistics for a beatmap in the database. + * @param beatmap the beatmap + */ + public static void updatePlayStatistics(Beatmap beatmap) { + if (connection == null) + return; + + try { + updatePlayStatsStmt.setInt(1, beatmap.playCount); + updatePlayStatsStmt.setLong(2, beatmap.lastPlayed); + updatePlayStatsStmt.setString(3, beatmap.getFile().getParentFile().getName()); + updatePlayStatsStmt.setString(4, beatmap.getFile().getName()); + updatePlayStatsStmt.executeUpdate(); + } catch (SQLException e) { + ErrorHandler.error(String.format("Failed to update play statistics for beatmap '%s' in database.", + beatmap.toString()), e, true); + } + } + + /** + * Updates the "favorite" status for a beatmap in the database. + * @param beatmap the beatmap + */ + public static void updateFavoriteStatus(Beatmap beatmap) { + if (connection == null) + return; + + try { + setFavoriteStmt.setBoolean(1, beatmap.favorite); + setFavoriteStmt.setString(2, beatmap.getFile().getParentFile().getName()); + setFavoriteStmt.setString(3, beatmap.getFile().getName()); + setFavoriteStmt.executeUpdate(); + } catch (SQLException e) { + ErrorHandler.error(String.format("Failed to update favorite status for beatmap '%s' in database.", + beatmap.toString()), e, true); + } + } + /** * Closes the connection to the database. */ diff --git a/src/itdelatrisu/opsu/db/ScoreDB.java b/src/itdelatrisu/opsu/db/ScoreDB.java index c6dc4771..babf1841 100644 --- a/src/itdelatrisu/opsu/db/ScoreDB.java +++ b/src/itdelatrisu/opsu/db/ScoreDB.java @@ -360,7 +360,7 @@ public class ScoreDB { ResultSet rs = selectMapSetStmt.executeQuery(); List list = null; - String version = ""; // sorted by version, so pass through and check for differences + String version = null; // sorted by version, so pass through and check for differences while (rs.next()) { ScoreData s = new ScoreData(rs); if (!s.version.equals(version)) { diff --git a/src/itdelatrisu/opsu/objects/Circle.java b/src/itdelatrisu/opsu/objects/Circle.java index 67c00210..7c0d5756 100644 --- a/src/itdelatrisu/opsu/objects/Circle.java +++ b/src/itdelatrisu/opsu/objects/Circle.java @@ -179,7 +179,7 @@ public class Circle extends GameObject { if (result > -1) { data.addHitError(hitObject.getTime(), x, y, timeDiff); - data.hitResult(trackPosition, result, this.x, this.y, color, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); + data.sendHitResult(trackPosition, result, this.x, this.y, color, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); return true; } } @@ -195,25 +195,25 @@ public class Circle extends GameObject { if (trackPosition > time + hitResultOffset[GameData.HIT_50]) { if (isAutoMod) {// "auto" mod: catch any missed notes due to lag - data.hitResult(time, GameData.HIT_300, x, y, color, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); + data.sendHitResult(time, GameData.HIT_300, x, y, color, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); if (Options.isMirror() && GameMod.AUTO.isActive()) { float[] m = Utils.mirrorPoint(x, y); - data.hitResult(time, GameData.HIT_300, m[0], m[1], mirrorColor, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false, false); + data.sendHitResult(time, GameData.HIT_300, m[0], m[1], mirrorColor, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false, false); } } else // no more points can be scored, so send a miss - data.hitResult(trackPosition, GameData.HIT_MISS, x, y, null, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); + data.sendHitResult(trackPosition, GameData.HIT_MISS, x, y, null, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); return true; } // "auto" mod: send a perfect hit result else if (isAutoMod) { if (Math.abs(trackPosition - time) < hitResultOffset[GameData.HIT_300]) { - data.hitResult(time, GameData.HIT_300, x, y, color, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); + data.sendHitResult(time, GameData.HIT_300, x, y, color, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false); if (Options.isMirror() && GameMod.AUTO.isActive()) { float[] m = Utils.mirrorPoint(x, y); - data.hitResult(time, GameData.HIT_300, m[0], m[1], mirrorColor, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false, false); + data.sendHitResult(time, GameData.HIT_300, m[0], m[1], mirrorColor, comboEnd, hitObject, HitObjectType.CIRCLE, true, 0, null, false, false); } return true; } diff --git a/src/itdelatrisu/opsu/objects/Slider.java b/src/itdelatrisu/opsu/objects/Slider.java index 7cfa0181..78870133 100644 --- a/src/itdelatrisu/opsu/objects/Slider.java +++ b/src/itdelatrisu/opsu/objects/Slider.java @@ -205,6 +205,7 @@ public class Slider extends GameObject { } int timeDiff = hitObject.getTime() - trackPosition; + final int repeatCount = hitObject.getRepeatCount(); final int approachTime = game.getApproachTime(); final int fadeInTime = game.getFadeInTime(); float scale = timeDiff / (float) approachTime; @@ -219,9 +220,12 @@ public class Slider extends GameObject { Image hitCircle = GameImage.HITCIRCLE.getImage(); Vec2f endPos = curve.pointAt(1); - float curveAlpha = 1f; - if (GameMod.HIDDEN.isActive() && trackPosition > getTime()) { - curveAlpha = Math.max(0f, 1f - ((float) (trackPosition - getTime()) / (getEndTime() - getTime())) * 1.05f); + float oldWhiteFadeAlpha = Colors.WHITE_FADE.a; + float sliderAlpha = 1f; + if (GameMod.HIDDEN.isActive() && trackPosition > hitObject.getTime()) { + // "Hidden" mod: fade out sliders + Colors.WHITE_FADE.a = color.a = sliderAlpha = + Math.max(0f, 1f - ((float) (trackPosition - hitObject.getTime()) / (getEndTime() - hitObject.getTime())) * 1.05f); } curveColor.a = curveAlpha; @@ -232,19 +236,37 @@ public class Slider extends GameObject { if (mirror) { g.rotate(x, y, -180f); } + // end circle (only draw if ball still has to go there) + if (curveInterval == 1f && currentRepeats < repeatCount - (repeatCount % 2 == 0 ? 1 : 0)) { + Color circleColor = new Color(color); + Color overlayColor = new Color(Colors.WHITE_FADE); + if (currentRepeats == 0) { + if (Options.isSliderSnaking()) { + // fade in end circle using decorationsAlpha when snaking sliders are enabled + circleColor.a = overlayColor.a = sliderAlpha * decorationsAlpha; + } + } else { + // fade in end circle after repeats + circleColor.a = overlayColor.a = sliderAlpha * getCircleAlphaAfterRepeat(trackPosition, true); + } + Vec2f endCircPos = curve.pointAt(curveInterval); + hitCircle.drawCentered(endCircPos.x, endCircPos.y, circleColor); + hitCircleOverlay.drawCentered(endCircPos.x, endCircPos.y, overlayColor); + } - /* - // end circle - Vec2f endCircPos = curve.pointAt(curveInterval); - hitCircle.drawCentered(endCircPos.x, endCircPos.y, color); - hitCircleOverlay.drawCentered(endCircPos.x, endCircPos.y, Colors.WHITE_FADE); - */ + // set first circle colors to fade in after repeats + Color firstCircleColor = new Color(color); + Color startCircleOverlayColor = new Color(Colors.WHITE_FADE); + if (sliderClickedInitial) { + // fade in first circle after repeats + firstCircleColor.a = startCircleOverlayColor.a = sliderAlpha * getCircleAlphaAfterRepeat(trackPosition, false); + } - // start circle, don't draw if already clicked - if (!sliderClickedInitial) { - hitCircle.drawCentered(x, y, color); - if (!overlayAboveNumber) - hitCircleOverlay.drawCentered(x, y, Colors.WHITE_FADE); + // start circle, only draw if ball still has to go there + if (!sliderClickedInitial || currentRepeats < repeatCount - (repeatCount % 2 == 1 ? 1 : 0)) { + hitCircle.drawCentered(x, y, firstCircleColor); + if (!overlayAboveNumber || sliderClickedInitial) + hitCircleOverlay.drawCentered(x, y, startCircleOverlayColor); } g.popTransform(); @@ -252,7 +274,7 @@ public class Slider extends GameObject { // ticks if (ticksT != null) { drawSliderTicks(g, trackPosition, alpha, decorationsAlpha, mirror); - Colors.WHITE_FADE.a = alpha; + Colors.WHITE_FADE.a = oldWhiteFadeAlpha; } g.pushTransform(); @@ -269,38 +291,40 @@ public class Slider extends GameObject { } } + // draw combo number and overlay if not initially clicked if (!sliderClickedInitial) { data.drawSymbolNumber(hitObject.getComboNumber(), x, y, hitCircle.getWidth() * 0.40f / data.getDefaultSymbolImage(0).getHeight(), alpha); - if (overlayAboveNumber) - hitCircleOverlay.drawCentered(x, y, Colors.WHITE_FADE); + + if (overlayAboveNumber) { + startCircleOverlayColor.a = sliderAlpha; + hitCircleOverlay.drawCentered(x, y, startCircleOverlayColor); + } } g.popTransform(); // repeats if (isCurveCompletelyDrawn) { - for (int tcurRepeat = currentRepeats; tcurRepeat <= currentRepeats + 1; tcurRepeat++) { - if (hitObject.getRepeatCount() - 1 > tcurRepeat) { - Image arrow = GameImage.REVERSEARROW.getImage(); - arrow = arrow.getScaledCopy((float) (1 + 0.2d * ((trackPosition + sliderTime * tcurRepeat) % 292) / 292)); - Color arrowColor = Color.white; - if (tcurRepeat != currentRepeats) { - if (sliderTime == 0) - continue; - float t = Math.max(getT(trackPosition, true), 0); - arrow.setAlpha((float) (t - Math.floor(t))); - } else - arrow.setAlpha(Options.isSliderSnaking() ? decorationsAlpha : 1f); - if (tcurRepeat % 2 == 0) { - // last circle - arrow.setRotation(curve.getEndAngle()); - arrow.drawCentered(endPos.x, endPos.y, arrowColor); - } else { - // first circle - arrow.setRotation(curve.getStartAngle()); - arrow.drawCentered(x, y, arrowColor); + for (int tcurRepeat = currentRepeats; tcurRepeat <= currentRepeats + 1 && tcurRepeat < repeatCount - 1; tcurRepeat++) { + Image arrow = GameImage.REVERSEARROW.getImage(); + arrow = arrow.getScaledCopy((float) (1 + 0.2d * ((trackPosition + sliderTime * tcurRepeat) % 292) / 292)); + if (tcurRepeat == 0) { + arrow.setAlpha(Options.isSliderSnaking() ? decorationsAlpha : 1f); + } else { + if (!sliderClickedInitial) { + continue; } + arrow.setAlpha(getCircleAlphaAfterRepeat(trackPosition, tcurRepeat % 2 == 0)); + } + if (tcurRepeat % 2 == 0) { + // last circle + arrow.setRotation(curve.getEndAngle()); + arrow.drawCentered(endPos.x, endPos.y); + } else { + // first circle + arrow.setRotation(curve.getStartAngle()); + arrow.drawCentered(x, y); } } } @@ -447,6 +471,24 @@ public class Slider extends GameObject { return curveIntervalTo == 1d; } + /** + * Get the alpha level used to fade in circles & reversearrows after repeat + * @param trackPosition current trackposition, in ms + * @param endCircle request alpha for end circle (true) or start circle (false)? + * @return alpha level as float in interval [0, 1] + */ + private float getCircleAlphaAfterRepeat(int trackPosition, boolean endCircle) { + int ticksN = ticksT == null ? 0 : ticksT.length; + float t = getT(trackPosition, false); + if (endCircle) { + t = 1f - t; + } + if (currentRepeats % 2 == (endCircle ? 0 : 1)) { + t = 1f; + } + return Utils.clamp(t * (ticksN + 1), 0f, 1f); + } + /** * Calculates the slider hit result. * @return the hit result (GameData.HIT_* constants) @@ -513,17 +555,19 @@ public class Slider extends GameObject { float cx, cy; HitObjectType type; - if (currentRepeats % 2 == 0) { // last circle + if (currentRepeats % 2 == 0) { + // last circle Vec2f lastPos = curve.pointAt(1); cx = lastPos.x; cy = lastPos.y; type = HitObjectType.SLIDER_LAST; - } else { // first circle + } else { + // first circle cx = x; cy = y; type = HitObjectType.SLIDER_FIRST; } - data.hitResult(hitObject.getTime() + (int) sliderTimeTotal, result, + data.sendHitResult(hitObject.getTime() + (int) sliderTimeTotal, result, cx, cy, color, comboEnd, hitObject, type, sliderHeldToEnd, currentRepeats + 1, curve, sliderHeldToEnd); if (Options.isMirror() && GameMod.AUTO.isActive()) { @@ -550,15 +594,18 @@ public class Slider extends GameObject { if (timeDiff < hitResultOffset[GameData.HIT_50]) { result = GameData.HIT_SLIDER30; ticksHit++; - } else if (timeDiff < hitResultOffset[GameData.HIT_MISS]) + data.sendSliderStartResult(trackPosition, this.x, this.y, color, true); + } else if (timeDiff < hitResultOffset[GameData.HIT_MISS]) { result = GameData.HIT_MISS; + data.sendSliderStartResult(trackPosition, this.x, this.y, color, false); + } //else not a hit if (result > -1) { data.sendInitialSliderResult(trackPosition, this.x, this.y, color, mirrorColor); data.addHitError(hitObject.getTime(), x,y,trackPosition - hitObject.getTime()); sliderClickedInitial = true; - data.sliderTickResult(hitObject.getTime(), result, this.x, this.y, hitObject, currentRepeats); + data.sendSliderTickResult(hitObject.getTime(), result, this.x, this.y, hitObject, currentRepeats); return true; } } @@ -579,9 +626,12 @@ public class Slider extends GameObject { sliderClickedInitial = true; if (isAutoMod) { // "auto" mod: catch any missed notes due to lag ticksHit++; - data.sliderTickResult(time, GameData.HIT_SLIDER30, x, y, hitObject, currentRepeats); - } else - data.sliderTickResult(time, GameData.HIT_MISS, x, y, hitObject, currentRepeats); + data.sendSliderTickResult(time, GameData.HIT_SLIDER30, x, y, hitObject, currentRepeats); + data.sendSliderStartResult(time, x, y, color, true); + } else { + data.sendSliderTickResult(time, GameData.HIT_MISS, x, y, hitObject, currentRepeats); + data.sendSliderStartResult(trackPosition, x, y, color, false); + } } // "auto" mod: send a perfect hit result @@ -589,8 +639,8 @@ public class Slider extends GameObject { if (Math.abs(trackPosition - time) < hitResultOffset[GameData.HIT_300]) { ticksHit++; sliderClickedInitial = true; - data.sliderTickResult(time, GameData.HIT_SLIDER30, x, y, hitObject, currentRepeats); - data.sendInitialSliderResult(time, x, y, color, mirrorColor); + data.sendSliderTickResult(time, GameData.HIT_SLIDER30, x, y, hitObject, currentRepeats); + data.sendSliderStartResult(time, x, y, color, true); } } @@ -644,23 +694,6 @@ public class Slider extends GameObject { tickIndex = 0; isNewRepeat = true; tickExpandTime = TICK_EXPAND_TIME; - - if (Options.isReverseArrowAnimationEnabled()) { - // send hit result, to fade out reversearrow - HitObjectType type; - float posX, posY; - if (currentRepeats % 2 == 1) { - type = HitObjectType.SLIDER_LAST; - Vec2f endPos = curve.pointAt(1); - posX = endPos.x; - posY = endPos.y; - } else { - type = HitObjectType.SLIDER_FIRST; - posX = this.x; - posY = this.y; - } - data.sendRepeatSliderResult(trackPosition, posX, posY, Color.white, curve, type); - } } } @@ -688,19 +721,34 @@ public class Slider extends GameObject { // held during new repeat if (isNewRepeat) { ticksHit++; - if (currentRepeats % 2 > 0) { // last circle - int lastIndex = hitObject.getSliderX().length; - data.sliderTickResult(trackPosition, GameData.HIT_SLIDER30, - curve.getX(lastIndex), curve.getY(lastIndex), hitObject, currentRepeats); - } else // first circle - data.sliderTickResult(trackPosition, GameData.HIT_SLIDER30, - c.x, c.y, hitObject, currentRepeats); + + HitObjectType type; + float posX, posY; + if (currentRepeats % 2 > 0) { + // last circle + type = HitObjectType.SLIDER_LAST; + Vec2f endPos = curve.pointAt(1f); + posX = endPos.x; + posY = endPos.y; + } else { + // first circle + type = HitObjectType.SLIDER_FIRST; + posX = this.x; + posY = this.y; + } + data.sendSliderTickResult(trackPosition, GameData.HIT_SLIDER30, + posX, posY, hitObject, currentRepeats); + + // fade out reverse arrow + float colorLuminance = Utils.getLuminance(color); + Color arrowColor = colorLuminance < 0.8f ? Color.white : Color.black; + data.sendSliderRepeatResult(trackPosition, posX, posY, arrowColor, curve, type); } // held during new tick if (isNewTick) { ticksHit++; - data.sliderTickResult(trackPosition, GameData.HIT_SLIDER10, + data.sendSliderTickResult(trackPosition, GameData.HIT_SLIDER10, c.x, c.y, hitObject, currentRepeats); } @@ -711,9 +759,9 @@ public class Slider extends GameObject { followCircleActive = false; if (isNewRepeat) - data.sliderTickResult(trackPosition, GameData.HIT_MISS, 0, 0, hitObject, currentRepeats); + data.sendSliderTickResult(trackPosition, GameData.HIT_MISS, 0, 0, hitObject, currentRepeats); if (isNewTick) - data.sliderTickResult(trackPosition, GameData.HIT_MISS, 0, 0, hitObject, currentRepeats); + data.sendSliderTickResult(trackPosition, GameData.HIT_MISS, 0, 0, hitObject, currentRepeats); } return false; diff --git a/src/itdelatrisu/opsu/objects/Spinner.java b/src/itdelatrisu/opsu/objects/Spinner.java index 251f4e0d..551c80f8 100644 --- a/src/itdelatrisu/opsu/objects/Spinner.java +++ b/src/itdelatrisu/opsu/objects/Spinner.java @@ -255,7 +255,7 @@ public class Spinner extends GameObject { else result = GameData.HIT_MISS; - data.hitResult(hitObject.getEndTime(), result, width / 2, height / 2, + data.sendHitResult(hitObject.getEndTime(), result, width / 2, height / 2, Color.transparent, true, hitObject, HitObjectType.SPINNER, true, 0, null, false); return result; } diff --git a/src/itdelatrisu/opsu/objects/curves/Curve.java b/src/itdelatrisu/opsu/objects/curves/Curve.java index 94b6c91e..abb2519a 100644 --- a/src/itdelatrisu/opsu/objects/curves/Curve.java +++ b/src/itdelatrisu/opsu/objects/curves/Curve.java @@ -101,12 +101,12 @@ public abstract class Curve { Curve.borderColor = borderColor; ContextCapabilities capabilities = GLContext.getCapabilities(); - mmsliderSupported = capabilities.OpenGL20; + mmsliderSupported = capabilities.OpenGL30; if (mmsliderSupported) CurveRenderState.init(width, height, circleDiameter); else { if (Options.getSkin().getSliderStyle() != Skin.STYLE_PEPPYSLIDER) - Log.warn("New slider style requires OpenGL 2.0."); + Log.warn("New slider style requires OpenGL 3.0."); } } diff --git a/src/itdelatrisu/opsu/states/ButtonMenu.java b/src/itdelatrisu/opsu/states/ButtonMenu.java index da13dfbc..969e3e3c 100644 --- a/src/itdelatrisu/opsu/states/ButtonMenu.java +++ b/src/itdelatrisu/opsu/states/ButtonMenu.java @@ -46,8 +46,8 @@ import org.newdawn.slick.Input; import org.newdawn.slick.SlickException; import org.newdawn.slick.state.BasicGameState; import org.newdawn.slick.state.StateBasedGame; -import org.newdawn.slick.state.transition.FadeInTransition; import org.newdawn.slick.state.transition.EmptyTransition; +import org.newdawn.slick.state.transition.FadeInTransition; /** * Generic button menu state. @@ -69,8 +69,8 @@ public class ButtonMenu extends BasicGameState { Button.NO.click(container, game); } }, - /** The initial beatmap management screen. */ - BEATMAP (new Button[] { Button.CLEAR_SCORES, Button.DELETE, Button.CANCEL }) { + /** The initial beatmap management screen (for a non-"favorite" beatmap). */ + BEATMAP (new Button[] { Button.CLEAR_SCORES, Button.FAVORITE_ADD, Button.DELETE, Button.CANCEL }) { @Override public String[] getTitle(GameContainer container, StateBasedGame game) { BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); @@ -90,6 +90,23 @@ public class ButtonMenu extends BasicGameState { super.scroll(container, game, newValue); } }, + /** The initial beatmap management screen (for a "favorite" beatmap). */ + BEATMAP_FAVORITE (new Button[] { Button.CLEAR_SCORES, Button.FAVORITE_REMOVE, Button.DELETE, Button.CANCEL }) { + @Override + public String[] getTitle(GameContainer container, StateBasedGame game) { + return BEATMAP.getTitle(container, game); + } + + @Override + public void leave(GameContainer container, StateBasedGame game) { + BEATMAP.leave(container, game); + } + + @Override + public void scroll(GameContainer container, StateBasedGame game, int newValue) { + BEATMAP.scroll(container, game, newValue); + } + }, /** The beatmap deletion screen for a beatmap set with multiple beatmaps. */ BEATMAP_DELETE_SELECT (new Button[] { Button.DELETE_GROUP, Button.DELETE_SONG, Button.CANCEL_DELETE }) { @Override @@ -468,6 +485,25 @@ public class ButtonMenu extends BasicGameState { game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition()); } }, + FAVORITE_ADD ("Add to Favorites", Color.blue) { + @Override + public void click(GameContainer container, StateBasedGame game) { + SoundController.playSound(SoundEffect.MENUHIT); + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + node.getBeatmapSet().setFavorite(true); + game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition()); + } + }, + FAVORITE_REMOVE ("Remove from Favorites", Color.blue) { + @Override + public void click(GameContainer container, StateBasedGame game) { + SoundController.playSound(SoundEffect.MENUHIT); + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + node.getBeatmapSet().setFavorite(false); + ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).doStateActionOnLoad(MenuState.BEATMAP_FAVORITE); + game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition()); + } + }, DELETE ("Delete...", Color.red) { @Override public void click(GameContainer container, StateBasedGame game) { diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java index 7c249153..65ed00f0 100644 --- a/src/itdelatrisu/opsu/states/Game.java +++ b/src/itdelatrisu/opsu/states/Game.java @@ -49,6 +49,7 @@ import itdelatrisu.opsu.replay.PlaybackSpeed; import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.replay.ReplayFrame; import itdelatrisu.opsu.ui.*; +import itdelatrisu.opsu.ui.animations.AnimatedValue; import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.io.File; @@ -98,6 +99,9 @@ public class Game extends BasicGameState { /** Screen fade-out time, in milliseconds, when health hits zero. */ private static final int LOSE_FADEOUT_TIME = 500; + /** Game element fade-out time, in milliseconds, when the game ends. */ + private static final int FINISHED_FADEOUT_TIME = 400; + /** Maximum rotation, in degrees, over fade out upon death. */ private static final float MAX_ROTATION = 90f; @@ -287,6 +291,15 @@ public class Game extends BasicGameState { /** The current alpha of the scoreboard. */ private float currentScoreboardAlpha; + /** The star stream shown when passing another score. */ + private StarStream scoreboardStarStream; + + /** Whether the game is finished (last hit object passed). */ + private boolean gameFinished = false; + + /** Timer after game has finished, before changing states. */ + private AnimatedValue gameFinishedTimer = new AnimatedValue(2500, 0, 1, AnimationEquation.LINEAR); + /** Music position bar background colors. */ private static final Color MUSICBAR_NORMAL = new Color(12, 9, 10, 0.25f), @@ -369,6 +382,12 @@ public class Game extends BasicGameState { musicBarWidth = Math.max(width * 0.005f, 7); musicBarHeight = height * 0.9f; + // initialize scoreboard star stream + scoreboardStarStream = new StarStream(0, height * 2f / 3f, width / 4, 0, 0); + scoreboardStarStream.setPositionSpread(height / 20f); + scoreboardStarStream.setDirectionSpread(10f); + scoreboardStarStream.setDurationSpread(700, 100); + // create the associated GameData object data = new GameData(width, height); } @@ -513,7 +532,7 @@ public class Game extends BasicGameState { } if (!Options.isHideUI() || !GameMod.AUTO.isActive()) { - data.drawGameElements(g, true, objectIndex == 0); + data.drawGameElements(g, true, objectIndex == 0, 1f); } if (breakLength >= 8000 && @@ -552,7 +571,13 @@ public class Game extends BasicGameState { else { if (!GameMod.AUTO.isActive() || !Options.isHideUI()) { // game elements - data.drawGameElements(g, false, objectIndex == 0); + float gameElementAlpha = 1f; + if (gameFinished) { + // game finished: fade everything out + float t = 1f - Math.min(gameFinishedTimer.getTime() / (float) FINISHED_FADEOUT_TIME, 1f); + gameElementAlpha = AnimationEquation.OUT_CUBIC.calc(t); + } + data.drawGameElements(g, false, objectIndex == 0, gameElementAlpha); // skip beginning if (objectIndex == 0 && @@ -639,6 +664,7 @@ public class Game extends BasicGameState { ScoreData currentScore = data.getCurrentScoreData(beatmap, true); while (currentRank > 0 && previousScores[currentRank - 1].score < currentScore.score) { currentRank--; + scoreboardStarStream.burst(20); lastRankUpdateTime = trackPosition; } @@ -647,6 +673,9 @@ public class Game extends BasicGameState { ); int scoreboardPosition = 2 * container.getHeight() / 3; + // draw star stream behind the scores + scoreboardStarStream.draw(); + if (currentRank < 4) { // draw the (new) top 5 ranks for (int i = 0; i < 4; i++) { @@ -749,6 +778,7 @@ public class Game extends BasicGameState { playbackSpeed.getButton().hoverUpdate(delta, mouseX, mouseY); int trackPosition = MusicController.getPosition(); int firstObjectTime = beatmap.objects[0].getTime(); + scoreboardStarStream.update(delta); // returning from pause screen: must click previous mouse position if (pauseTime > -1) { @@ -803,11 +833,11 @@ public class Game extends BasicGameState { } // normal game update - if (!isReplay) + if (!isReplay && !gameFinished) addReplayFrameAndRun(mouseX, mouseY, lastKeysPressed, trackPosition); // watching replay - else { + else if (!gameFinished) { // out of frames, use previous data if (replayIndex >= replay.frames.length) updateGame(replayX, replayY, delta, MusicController.getPosition(), lastKeysPressed); @@ -857,7 +887,8 @@ public class Game extends BasicGameState { // update in-game scoreboard if (!Options.isHideUI() && previousScores != null && trackPosition > firstObjectTime) { // show scoreboard if selected, and always in break - if (scoreboardVisible || breakTime > 0) { + // hide when game ends + if ((scoreboardVisible || breakTime > 0) && !gameFinished) { currentScoreboardAlpha += 1f / SCOREBOARD_FADE_IN_TIME * delta; if (currentScoreboardAlpha > 1f) currentScoreboardAlpha = 1f; @@ -869,6 +900,14 @@ public class Game extends BasicGameState { } data.updateDisplays(delta); + + // game finished: change state after timer expires + if (gameFinished && !gameFinishedTimer.update(delta)) { + if (checkpointLoaded) // if checkpoint used, skip ranking screen + game.closeRequested(); + else // go to ranking screen + game.enterState(Opsu.STATE_GAMERANKING, new EasedFadeOutTransition(), new FadeInTransition()); + } } /** @@ -892,12 +931,8 @@ public class Game extends BasicGameState { if (MusicController.trackEnded() && objectIndex < gameObjects.length) gameObjects[objectIndex].update(true, delta, mouseX, mouseY, false, trackPosition); - // if checkpoint used, skip ranking screen - if (checkpointLoaded) - game.closeRequested(); - - // go to ranking screen - else { + // save score and replay + if (!checkpointLoaded) { boolean unranked = (GameMod.AUTO.isActive() || GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive()); ((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data); if (isReplay) @@ -918,9 +953,12 @@ public class Game extends BasicGameState { // add score to database if (!unranked && !isReplay) ScoreDB.addScore(score); - - game.enterState(Opsu.STATE_GAMERANKING, new EasedFadeOutTransition(), new FadeInTransition()); } + + // start timer + gameFinished = true; + gameFinishedTimer.setTime(0); + return; } @@ -1050,6 +1088,8 @@ public class Game extends BasicGameState { @Override public void keyPressed(int key, char c) { + if (gameFinished) + return; if (sbOverlay.keyPressed(key, c)) { return; @@ -1199,9 +1239,13 @@ public class Game extends BasicGameState { @Override public void mousePressed(int button, int x, int y) { + if (gameFinished) + return; + if (sbOverlay.mousePressed(button, x, y)) { return; } + // watching replay if (isReplay || GameMod.AUTO.isActive()) { if (button == Input.MOUSE_MIDDLE_BUTTON) @@ -1294,6 +1338,9 @@ public class Game extends BasicGameState { @Override public void mouseReleased(int button, int x, int y) { + if (gameFinished) + return; + if (sbOverlay.mouseReleased(button, x, y)) { return; } @@ -1315,6 +1362,9 @@ public class Game extends BasicGameState { @Override public void keyReleased(int key, char c) { + if (gameFinished) + return; + int keys = ReplayFrame.KEY_NONE; if (key == Options.getGameKeyLeft()) keys = ReplayFrame.KEY_K1; @@ -1343,7 +1393,7 @@ public class Game extends BasicGameState { if (sbOverlay.mouseWheelMoved(newValue)) { return; } - if (Options.isMouseWheelDisabled() || Options.isMouseDisabled()) + if (Options.isMouseWheelDisabled()) return; UI.changeVolume((newValue < 0) ? -1 : 1); @@ -1375,6 +1425,11 @@ public class Game extends BasicGameState { // restart the game if (restart != Restart.FALSE) { + // update play stats + if (restart == Restart.NEW) { + beatmap.incrementPlayCounter(); + BeatmapDB.updatePlayStatistics(beatmap); + } // load epilepsy warning img epiImgTime = Options.getEpilepsyWarningLength(); @@ -1400,11 +1455,13 @@ public class Game extends BasicGameState { loadImages(); setMapModifiers(); retries = 0; - } else if (restart == Restart.MANUAL) { + } else if (restart == Restart.MANUAL && !GameMod.AUTO.isActive()) { // retry retries++; - } else if (restart == Restart.REPLAY) + } else if (restart == Restart.REPLAY || GameMod.AUTO.isActive()) { + // replay retries = 0; + } gameObjects = new GameObject[beatmap.objects.length]; playbackSpeed = PlaybackSpeed.NORMAL; @@ -1613,6 +1670,11 @@ public class Game extends BasicGameState { * @param trackPosition the track position */ private void drawHitObjects(Graphics g, int trackPosition) { + // draw result objects + if (!Options.isHideObjects()) { + data.drawHitResults(trackPosition); + } + if (Options.isMergingSliders() && knorkesliders != null) { knorkesliders.draw(Color.white, this.slidercurveFrom, this.slidercurveTo); if (Options.isMirror()) { @@ -1622,6 +1684,7 @@ public class Game extends BasicGameState { g.popTransform(); } } + // include previous object in follow points int lastObjectIndex = -1; if (objectIndex > 0 && objectIndex < beatmap.objects.length && @@ -1736,18 +1799,13 @@ public class Game extends BasicGameState { // translate and rotate the object g.translate(0, dt * dt * container.getHeight()); - Vec2f rotationCenter = gameObj.getPointAt(beatmap.objects[idx].getTime()); + Vec2f rotationCenter = gameObj.getPointAt((beatmap.objects[idx].getTime() + beatmap.objects[idx].getEndTime()) / 2); g.rotate(rotationCenter.x, rotationCenter.y, rotSpeed * dt); gameObj.draw(g, trackPosition, false); g.popTransform(); } } - - // draw result objects - if (!Options.isHideObjects()) { - data.drawHitResults(trackPosition); - } } /** @@ -1760,7 +1818,7 @@ public class Game extends BasicGameState { } this.beatmap = beatmap; Display.setTitle(String.format("%s - %s", game.getTitle(), beatmap.toString())); - if (beatmap.timingPoints == null) + if (beatmap.breaks == null) BeatmapDB.load(beatmap, BeatmapDB.LOAD_ARRAY); BeatmapParser.parseHitObjects(beatmap); HitSound.setDefaultSampleSet(beatmap.sampleSet); @@ -1792,6 +1850,9 @@ public class Game extends BasicGameState { autoMousePosition = new Vec2f(); autoMousePressed = false; flashlightRadius = container.getHeight() * 2 / 3; + scoreboardStarStream.clear(); + gameFinished = false; + gameFinishedTimer.setTime(0); System.gc(); } diff --git a/src/itdelatrisu/opsu/states/GamePauseMenu.java b/src/itdelatrisu/opsu/states/GamePauseMenu.java index 25447f47..c3c9dec1 100644 --- a/src/itdelatrisu/opsu/states/GamePauseMenu.java +++ b/src/itdelatrisu/opsu/states/GamePauseMenu.java @@ -184,7 +184,7 @@ public class GamePauseMenu extends BasicGameState { @Override public void mouseWheelMoved(int newValue) { - if (Options.isMouseWheelDisabled() || Options.isMouseDisabled()) + if (Options.isMouseWheelDisabled()) return; UI.changeVolume((newValue < 0) ? -1 : 1); diff --git a/src/itdelatrisu/opsu/states/MainMenu.java b/src/itdelatrisu/opsu/states/MainMenu.java index 6d2f8eca..29153854 100644 --- a/src/itdelatrisu/opsu/states/MainMenu.java +++ b/src/itdelatrisu/opsu/states/MainMenu.java @@ -32,8 +32,12 @@ import itdelatrisu.opsu.beatmap.BeatmapSetNode; import itdelatrisu.opsu.beatmap.TimingPoint; import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.states.ButtonMenu.MenuState; -import itdelatrisu.opsu.ui.*; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; +import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.MenuButton.Expand; +import itdelatrisu.opsu.ui.StarFountain; +import itdelatrisu.opsu.ui.UI; import itdelatrisu.opsu.ui.animations.AnimatedValue; import itdelatrisu.opsu.ui.animations.AnimationEquation; @@ -51,8 +55,8 @@ import org.newdawn.slick.Input; import org.newdawn.slick.SlickException; import org.newdawn.slick.state.BasicGameState; import org.newdawn.slick.state.StateBasedGame; -import org.newdawn.slick.state.transition.FadeInTransition; import org.newdawn.slick.state.transition.EasedFadeOutTransition; +import org.newdawn.slick.state.transition.FadeInTransition; /** * "Main Menu" state. @@ -117,15 +121,18 @@ public class MainMenu extends BasicGameState { /** Music position bar coordinates and dimensions. */ private float musicBarX, musicBarY, musicBarWidth, musicBarHeight; + /** Last measure progress value. */ + private float lastMeasureProgress = 0f; + + /** The star fountain. */ + private StarFountain starFountain; + // game-related variables private GameContainer container; private StateBasedGame game; private Input input; private final int state; - private float hue = 0; - private boolean huedone = false; - public MainMenu(int state) { this.state = state; } @@ -203,9 +210,6 @@ public class MainMenu extends BasicGameState { repoButton.setHoverAnimationDuration(350); repoButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_BACK); repoButton.setHoverExpand(); - } - - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { // only if a webpage can be opened Image repoImg = GameImage.REPOSITORY.getImage(); danceRepoButton = new MenuButton(repoImg, startX - repoImg.getWidth(), startY - repoImg.getHeight() @@ -228,6 +232,9 @@ public class MainMenu extends BasicGameState { restartButton.setHoverAnimationEquation(AnimationEquation.LINEAR); restartButton.setHoverRotate(360); + // initialize star fountain + starFountain = new StarFountain(width, height); + // logo animations float centerOffsetX = width / 6.5f; logoOpen = new AnimatedValue(100, 0, centerOffsetX, AnimationEquation.OUT_QUAD); @@ -262,6 +269,9 @@ public class MainMenu extends BasicGameState { g.fillRect(0, height * 8 / 9f, width, height / 9f); Colors.BLACK_ALPHA.a = oldAlpha; + // draw star fountain + starFountain.draw(); + // draw downloads button downloadsButton.draw(); @@ -271,26 +281,15 @@ public class MainMenu extends BasicGameState { exitButton.draw(); } - // logo - Double position = MusicController.getBeatProgress(); - Color color = Options.isColorMainMenuLogo() ? Cursor.lastCursorColor : Color.white; - boolean renderPiece = position != null; - if (position == null) { - position = System.currentTimeMillis() % 1000 / 1000d; - } - double scale = 1 - (0 - position) * 0.05; - logo.draw(color, (float) scale); - if (renderPiece) { - Image piece = GameImage.MENU_LOGO_PIECE.getImage().getScaledCopy(logo.getCurrentScale()); - float scaleposmodx = piece.getWidth() / 2; - float scaleposmody = piece.getHeight() / 2; - piece.rotate((float) (position * 360)); - piece.draw(logo.getX() - scaleposmodx, logo.getY() - scaleposmody, color); - } - Image logoCopy = GameImage.MENU_LOGO.getImage().getScaledCopy(logo.getCurrentScale() / (float) scale * 1.05f); - float scaleposmodx = logoCopy.getWidth() / 2; - float scaleposmody = logoCopy.getHeight() / 2; - logoCopy.draw(logo.getX() - scaleposmodx, logo.getY() - scaleposmody, Colors.GHOST_LOGO); + // draw logo (pulsing) + Float position = MusicController.getBeatProgress(); + if (position == null) // default to 60bpm + position = System.currentTimeMillis() % 1000 / 1000f; + float scale = 1f + position * 0.05f; + logo.draw(Color.white, scale); + float ghostScale = logo.getLastScale() / scale * 1.05f; + Image ghostLogo = GameImage.MENU_LOGO.getImage().getScaledCopy(ghostScale); + ghostLogo.drawCentered(logo.getX(), logo.getY(), Colors.GHOST_LOGO); // draw music buttons if (MusicController.isPlaying()) @@ -310,16 +309,13 @@ public class MainMenu extends BasicGameState { g.fillRoundRect(musicBarX, musicBarY, musicBarWidth * musicBarPosition, musicBarHeight, 4); } - // draw repository button + // draw repository buttons if (repoButton != null) { repoButton.draw(); String text = "opsu!"; int fheight = Fonts.SMALL.getLineHeight(); int fwidth = Fonts.SMALL.getWidth(text); Fonts.SMALL.drawString(repoButton.getX() - fwidth / 2, repoButton.getY() - repoButton.getImage().getHeight() / 2 - fheight, text, Color.white); - } - - if (danceRepoButton != null) { danceRepoButton.draw(); String text = "opsu!dance"; int fheight = Fonts.SMALL.getLineHeight(); @@ -367,15 +363,15 @@ public class MainMenu extends BasicGameState { throws SlickException { UI.update(delta); if (MusicController.trackEnded()) - nextTrack(); // end of track: go to next track + nextTrack(false); // end of track: go to next track int mouseX = input.getMouseX(), mouseY = input.getMouseY(); logo.hoverUpdate(delta, mouseX, mouseY, 0.25f); playButton.hoverUpdate(delta, mouseX, mouseY, 0.25f); exitButton.hoverUpdate(delta, mouseX, mouseY, 0.25f); - if (repoButton != null) + if (repoButton != null) { repoButton.hoverUpdate(delta, mouseX, mouseY); - if (danceRepoButton != null) danceRepoButton.hoverUpdate(delta, mouseX, mouseY); + } if (Updater.get().showButton()) { updateButton.autoHoverUpdate(delta, true); restartButton.autoHoverUpdate(delta, false); @@ -389,6 +385,7 @@ public class MainMenu extends BasicGameState { noHoverUpdate |= contains; musicNext.hoverUpdate(delta, !noHoverUpdate && musicNext.contains(mouseX, mouseY)); musicPrevious.hoverUpdate(delta, !noHoverUpdate && musicPrevious.contains(mouseX, mouseY)); + starFountain.update(delta); // window focus change: increase/decrease theme song volume if (MusicController.isThemePlaying() && @@ -400,6 +397,14 @@ public class MainMenu extends BasicGameState { if (!(Options.isDynamicBackgroundEnabled() && beatmap != null && beatmap.isBackgroundLoading())) bgAlpha.update(delta); + // check measure progress + Float measureProgress = MusicController.getMeasureProgress(2); + if (measureProgress != null) { + if (measureProgress < lastMeasureProgress) + starFountain.burst(true); + lastMeasureProgress = measureProgress; + } + // buttons int centerX = container.getWidth() / 2; float currentLogoButtonAlpha; @@ -472,6 +477,10 @@ public class MainMenu extends BasicGameState { } } + // reset measure info + lastMeasureProgress = 0f; + starFountain.clear(); + // reset button hover states if mouse is not currently hovering over the button int mouseX = input.getMouseX(), mouseY = input.getMouseY(); if (!logo.contains(mouseX, mouseY, 0.25f)) @@ -514,6 +523,7 @@ public class MainMenu extends BasicGameState { // music position bar if (MusicController.isPlaying()) { if (musicPositionBarContains(x, y)) { + lastMeasureProgress = 0f; float pos = (x - musicBarX) / musicBarWidth; MusicController.setPosition((int) (pos * MusicController.getDuration())); return; @@ -531,10 +541,11 @@ public class MainMenu extends BasicGameState { } return; } else if (musicNext.contains(x, y)) { - nextTrack(); + nextTrack(true); UI.sendBarNotification(">> Next"); return; } else if (musicPrevious.contains(x, y)) { + lastMeasureProgress = 0f; if (!previous.isEmpty()) { SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); menu.setFocus(BeatmapSetList.get().getBaseNode(previous.pop()), -1, true, false); @@ -657,7 +668,7 @@ public class MainMenu extends BasicGameState { game.enterState(Opsu.STATE_DOWNLOADSMENU, new EasedFadeOutTransition(), new FadeInTransition()); break; case Input.KEY_R: - nextTrack(); + nextTrack(true); break; case Input.KEY_UP: UI.changeVolume(1); @@ -717,9 +728,17 @@ public class MainMenu extends BasicGameState { /** * Plays the next track, and adds the previous one to the stack. + * @param user {@code true} if this was user-initiated, false otherwise (track end) */ - private void nextTrack() { + private void nextTrack(boolean user) { + lastMeasureProgress = 0f; boolean isTheme = MusicController.isThemePlaying(); + if (isTheme && !user) { + // theme was playing, restart + // NOTE: not looping due to inaccurate track positions after loop + MusicController.playAt(0, false); + return; + } SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); BeatmapSetNode node = menu.setFocus(BeatmapSetList.get().getRandomNode(), -1, true, false); boolean sameAudio = false; diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java index 9d9e8648..a0328350 100644 --- a/src/itdelatrisu/opsu/states/SongMenu.java +++ b/src/itdelatrisu/opsu/states/SongMenu.java @@ -32,6 +32,7 @@ import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.beatmap.BeatmapDifficultyCalculator; +import itdelatrisu.opsu.beatmap.BeatmapGroup; import itdelatrisu.opsu.beatmap.BeatmapParser; import itdelatrisu.opsu.beatmap.BeatmapSet; import itdelatrisu.opsu.beatmap.BeatmapSetList; @@ -45,6 +46,7 @@ import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.db.ScoreDB; import itdelatrisu.opsu.states.ButtonMenu.MenuState; import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.DropdownMenu; import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.KineticScrolling; import itdelatrisu.opsu.ui.MenuButton; @@ -259,8 +261,11 @@ public class SongMenu extends BasicGameState { /** Header and footer end and start y coordinates, respectively. */ private float headerY, footerY; - /** Height of the footer */ - private float footerHeight; + /** Footer pulsing logo button. */ + private MenuButton footerLogoButton; + + /** Size of the pulsing logo in the footer. */ + private float footerLogoSize; /** Time, in milliseconds, for fading the search bar. */ private int searchTransitionTimer = SEARCH_TRANSITION_TIME; @@ -309,9 +314,15 @@ public class SongMenu extends BasicGameState { /** The star stream. */ private StarStream starStream; + /** The maximum number of stars in the star stream. */ + private static final int MAX_STREAM_STARS = 20; + /** Whether the menu is currently scrolling to the focus node (blocks other actions). */ private boolean isScrollingToFocusNode = false; + /** Sort order dropdown menu. */ + private DropdownMenu sortMenu; + // game-related variables private GameContainer container; private StateBasedGame game; @@ -337,11 +348,48 @@ public class SongMenu extends BasicGameState { Fonts.BOLD.getLineHeight() + Fonts.DEFAULT.getLineHeight() + Fonts.SMALL.getLineHeight(); footerY = height - GameImage.SELECTION_MODS.getImage().getHeight(); - footerHeight = height - footerY; + + // footer logo coordinates + float footerHeight = height - footerY; + footerLogoSize = footerHeight * 3.25f; + Image logo = GameImage.MENU_LOGO.getImage(); + logo = logo.getScaledCopy(footerLogoSize / logo.getWidth()); + footerLogoButton = new MenuButton(logo, width - footerHeight * 0.8f, height - footerHeight * 0.65f); + footerLogoButton.setHoverAnimationDuration(1); + footerLogoButton.setHoverExpand(1.2f); // initialize sorts - for (BeatmapSortOrder sort : BeatmapSortOrder.values()) - sort.init(width, headerY - SongMenu.DIVIDER_LINE_WIDTH / 2); + int sortWidth = (int) (width * 0.12f); + sortMenu = new DropdownMenu(container, BeatmapSortOrder.values(), + width * 0.87f, headerY - GameImage.MENU_TAB.getImage().getHeight() * 2.25f, sortWidth) { + @Override + public void itemSelected(int index, BeatmapSortOrder item) { + BeatmapSortOrder.set(item); + if (focusNode == null) + return; + BeatmapSetNode oldFocusBase = BeatmapSetList.get().getBaseNode(focusNode.index); + int oldFocusFileIndex = focusNode.beatmapIndex; + focusNode = null; + BeatmapSetList.get().init(); + SongMenu.this.setFocus(oldFocusBase, oldFocusFileIndex, true, true); + } + + @Override + public boolean menuClicked(int index) { + if (isInputBlocked()) + return false; + + SoundController.playSound(SoundEffect.MENUCLICK); + return true; + } + }; + sortMenu.setBackgroundColor(Colors.BLACK_BG_HOVER); + sortMenu.setBorderColor(Colors.BLUE_DIVIDER); + sortMenu.setChevronRightColor(Color.white); + + // initialize group tabs + for (BeatmapGroup group : BeatmapGroup.values()) + group.init(width, headerY - DIVIDER_LINE_WIDTH / 2); // initialize score data buttons ScoreData.init(width, headerY + height * 0.01f); @@ -414,7 +462,9 @@ public class SongMenu extends BasicGameState { }); // star stream - starStream = new StarStream(width, height); + starStream = new StarStream(width, (height - GameImage.STAR.getImage().getHeight()) / 2, -width, 0, MAX_STREAM_STARS); + starStream.setPositionSpread(height / 20f); + starStream.setDirectionSpread(10f); } @Override @@ -425,6 +475,7 @@ public class SongMenu extends BasicGameState { int width = container.getWidth(); int height = container.getHeight(); int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + boolean inDropdownMenu = sortMenu.contains(mouseX, mouseY); // background if (focusNode != null) { @@ -496,7 +547,7 @@ public class SongMenu extends BasicGameState { g.clearClip(); // scroll bar - if (focusScores.length > MAX_SCORE_BUTTONS && ScoreData.areaContains(mouseX, mouseY)) + if (focusScores.length > MAX_SCORE_BUTTONS && ScoreData.areaContains(mouseX, mouseY) && !inDropdownMenu) ScoreData.drawScrollbar(g, startScorePos.getPosition(), focusScores.length * ScoreData.getButtonOffset()); } @@ -510,25 +561,22 @@ public class SongMenu extends BasicGameState { g.drawLine(0, footerY, width, footerY); g.resetLineWidth(); - // opsu logo in bottom bar - Image logo = GameImage.MENU_LOGO.getImage(); - float logoSize = footerHeight * 2f; - logo = logo.getScaledCopy(logoSize / logo.getWidth()); - Double position = MusicController.getBeatProgress(); - float x = width - footerHeight * 0.61f; - float y = height - footerHeight * 0.40f; - if (position != null) { - Image ghostLogo = logo.getScaledCopy((float) (1 - (0 - position) * 0.15)); - logo = logo.getScaledCopy((float) (1 - (position) * 0.15)); - logoSize = logo.getWidth(); - logo.draw(x - logoSize / 2, y - logoSize / 2); - logoSize = ghostLogo.getWidth(); - float a = Colors.GHOST_LOGO.a; - Colors.GHOST_LOGO.a *= (1d - position); - ghostLogo.draw(x - logoSize / 2, y - logoSize / 2, Colors.GHOST_LOGO); - Colors.GHOST_LOGO.a = a; + // footer logo (pulsing) + Float position = MusicController.getBeatProgress(); + if (position == null) // default to 60bpm + position = System.currentTimeMillis() % 1000 / 1000f; + if (footerLogoButton.contains(mouseX, mouseY, 0.25f) && !inDropdownMenu) { + // hovering over logo: stop pulsing + footerLogoButton.draw(); } else { - logo.draw(x - logoSize / 2, y - logoSize / 2); + float expand = position * 0.15f; + footerLogoButton.draw(Color.white, 1f - expand); + Image ghostLogo = GameImage.MENU_LOGO.getImage(); + ghostLogo = ghostLogo.getScaledCopy((1f + expand) * footerLogoSize / ghostLogo.getWidth()); + float oldGhostAlpha = Colors.GHOST_LOGO.a; + Colors.GHOST_LOGO.a *= (1f - position); + ghostLogo.drawCentered(footerLogoButton.getX(), footerLogoButton.getY(), Colors.GHOST_LOGO); + Colors.GHOST_LOGO.a = oldGhostAlpha; } // header @@ -607,20 +655,22 @@ public class SongMenu extends BasicGameState { GameImage.SELECTION_OTHER_OPTIONS.getImage().drawCentered(selectOptionsButton.getX(), selectOptionsButton.getY()); selectOptionsButton.draw(); - // sorting tabs - BeatmapSortOrder currentSort = BeatmapSortOrder.getSort(); - BeatmapSortOrder hoverSort = null; - for (BeatmapSortOrder sort : BeatmapSortOrder.values()) { - if (sort.contains(mouseX, mouseY)) { - hoverSort = sort; - break; + // group tabs + BeatmapGroup currentGroup = BeatmapGroup.current(); + BeatmapGroup hoverGroup = null; + if (!inDropdownMenu) { + for (BeatmapGroup group : BeatmapGroup.values()) { + if (group.contains(mouseX, mouseY)) { + hoverGroup = group; + break; + } } } - for (BeatmapSortOrder sort : BeatmapSortOrder.VALUES_REVERSED) { - if (sort != currentSort) - sort.draw(false, sort == hoverSort); + for (BeatmapGroup group : BeatmapGroup.VALUES_REVERSED) { + if (group != currentGroup) + group.draw(false, group == hoverGroup); } - currentSort.draw(true, false); + currentGroup.draw(true, false); // search boolean searchEmpty = search.getText().isEmpty(); @@ -655,6 +705,9 @@ public class SongMenu extends BasicGameState { (searchResultString == null) ? "Searching..." : searchResultString, Color.white); } + // sorting options + sortMenu.render(container, g); + // reloading beatmaps if (reloadThread != null) { // darken the screen @@ -678,20 +731,25 @@ public class SongMenu extends BasicGameState { if (reloadThread == null) MusicController.loopTrackIfEnded(true); else if (reloadThread.isFinished()) { + BeatmapGroup.set(BeatmapGroup.ALL); + BeatmapSortOrder.set(BeatmapSortOrder.TITLE); + BeatmapSetList.get().reset(); + BeatmapSetList.get().init(); if (BeatmapSetList.get().size() > 0) { // initialize song list - BeatmapSetList.get().init(); setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); } else MusicController.playThemeSong(); reloadThread = null; } int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + boolean inDropdownMenu = sortMenu.contains(mouseX, mouseY); UI.getBackButton().hoverUpdate(delta, mouseX, mouseY); selectModsButton.hoverUpdate(delta, mouseX, mouseY); selectRandomButton.hoverUpdate(delta, mouseX, mouseY); selectMapOptionsButton.hoverUpdate(delta, mouseX, mouseY); selectOptionsButton.hoverUpdate(delta, mouseX, mouseY); + footerLogoButton.hoverUpdate(delta, mouseX, mouseY, 0.25f); // beatmap menu timer if (beatmapMenuTimer > -1) { @@ -699,7 +757,9 @@ public class SongMenu extends BasicGameState { if (beatmapMenuTimer >= BEATMAP_MENU_DELAY) { beatmapMenuTimer = -1; if (focusNode != null) { - ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(MenuState.BEATMAP, focusNode); + MenuState state = focusNode.getBeatmapSet().isFavorite() ? + MenuState.BEATMAP_FAVORITE : MenuState.BEATMAP; + ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(state, focusNode); game.enterState(Opsu.STATE_BUTTONMENU); } return; @@ -788,7 +848,7 @@ public class SongMenu extends BasicGameState { // mouse hover BeatmapSetNode node = getNodeAtPosition(mouseX, mouseY); - if (node != null) { + if (node != null && !inDropdownMenu) { if (node == hoverIndex) hoverOffset.update(delta); else { @@ -802,7 +862,9 @@ public class SongMenu extends BasicGameState { } // tooltips - if (focusScores != null && ScoreData.areaContains(mouseX, mouseY)) { + if (sortMenu.baseContains(mouseX, mouseY)) + UI.updateTooltip(delta, "Sort by...", false); + else if (focusScores != null && ScoreData.areaContains(mouseX, mouseY)) { int startScore = (int) (startScorePos.getPosition() / ScoreData.getButtonOffset()); int offset = (int) (-startScorePos.getPosition() + startScore * ScoreData.getButtonOffset()); int scoreButtons = Math.min(focusScores.length - startScore, MAX_SCORE_BUTTONS); @@ -880,25 +942,42 @@ public class SongMenu extends BasicGameState { return; } - if (focusNode == null) - return; - - // sorting buttons - for (BeatmapSortOrder sort : BeatmapSortOrder.values()) { - if (sort.contains(x, y)) { - if (sort != BeatmapSortOrder.getSort()) { - BeatmapSortOrder.setSort(sort); + // group tabs + for (BeatmapGroup group : BeatmapGroup.values()) { + if (group.contains(x, y)) { + if (group != BeatmapGroup.current()) { + BeatmapGroup.set(group); SoundController.playSound(SoundEffect.MENUCLICK); - BeatmapSetNode oldFocusBase = BeatmapSetList.get().getBaseNode(focusNode.index); - int oldFocusFileIndex = focusNode.beatmapIndex; - focusNode = null; + startNode = focusNode = null; + oldFocusNode = null; + randomStack = new Stack(); + songInfo = null; + scoreMap = null; + focusScores = null; + search.setText(""); + searchTimer = SEARCH_DELAY; + searchTransitionTimer = SEARCH_TRANSITION_TIME; + searchResultString = null; + BeatmapSetList.get().reset(); BeatmapSetList.get().init(); - setFocus(oldFocusBase, oldFocusFileIndex, true, true); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); + + if (BeatmapSetList.get().size() < 1 && group.getEmptyMessage() != null) + UI.sendBarNotification(group.getEmptyMessage()); } return; } } + if (focusNode == null) + return; + + // logo: start game + if (footerLogoButton.contains(x, y, 0.25f)) { + startGame(); + return; + } + // song buttons BeatmapSetNode node = getNodeAtPosition(x, y); if (node != null) { @@ -978,6 +1057,7 @@ public class SongMenu extends BasicGameState { search.setText(""); searchTimer = SEARCH_DELAY; searchTransitionTimer = 0; + searchResultString = null; } else { // return to main menu SoundController.playSound(SoundEffect.MENUBACK); @@ -999,7 +1079,11 @@ public class SongMenu extends BasicGameState { SongNode prev; if (randomStack.isEmpty() || (prev = randomStack.pop()) == null) break; - setFocus(prev.getNode(), prev.getIndex(), true, true); + BeatmapSetNode node = prev.getNode(); + int expandedIndex = BeatmapSetList.get().getExpandedIndex(); + if (node.index == expandedIndex) + node = node.next; // move past base node + setFocus(node, prev.getIndex(), true, true); } else { // random track, add previous to stack randomStack.push(new SongNode(BeatmapSetList.get().getBaseNode(focusNode.index), focusNode.beatmapIndex)); @@ -1010,7 +1094,9 @@ public class SongMenu extends BasicGameState { if (focusNode == null) break; SoundController.playSound(SoundEffect.MENUHIT); - ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(MenuState.BEATMAP, focusNode); + MenuState state = focusNode.getBeatmapSet().isFavorite() ? + MenuState.BEATMAP_FAVORITE : MenuState.BEATMAP; + ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(state, focusNode); game.enterState(Opsu.STATE_BUTTONMENU); break; case Input.KEY_F5: @@ -1045,11 +1131,6 @@ public class SongMenu extends BasicGameState { case Input.KEY_ENTER: if (focusNode == null) break; - if (input.isKeyDown(Input.KEY_RCONTROL) || input.isKeyDown(Input.KEY_LCONTROL)) { - // turn on "auto" mod - if (!GameMod.AUTO.isActive()) - GameMod.AUTO.toggle(true); - } startGame(); break; case Input.KEY_DOWN: @@ -1194,6 +1275,8 @@ public class SongMenu extends BasicGameState { songChangeTimer.setTime(songChangeTimer.getDuration()); musicIconBounceTimer.setTime(musicIconBounceTimer.getDuration()); starStream.clear(); + sortMenu.activate(); + sortMenu.reset(); // reset song stack randomStack = new Stack(); @@ -1241,6 +1324,15 @@ public class SongMenu extends BasicGameState { focusScores = getScoreDataForNode(focusNode, true); } + // re-sort (in case play count updated) + if (BeatmapSortOrder.current() == BeatmapSortOrder.PLAYS) { + BeatmapSetNode oldFocusBase = BeatmapSetList.get().getBaseNode(focusNode.index); + int oldFocusFileIndex = focusNode.beatmapIndex; + focusNode = null; + BeatmapSetList.get().init(); + setFocus(oldFocusBase, oldFocusFileIndex, true, true); + } + resetGame = false; } @@ -1325,6 +1417,19 @@ public class SongMenu extends BasicGameState { case RELOAD: // reload beatmaps reloadBeatmaps(true); break; + case BEATMAP_FAVORITE: // removed favorite, reset beatmap list + if (BeatmapGroup.current() == BeatmapGroup.FAVORITE) { + startNode = focusNode = null; + oldFocusNode = null; + randomStack = new Stack(); + songInfo = null; + scoreMap = null; + focusScores = null; + BeatmapSetList.get().reset(); + BeatmapSetList.get().init(); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); + } + break; default: break; } @@ -1338,6 +1443,7 @@ public class SongMenu extends BasicGameState { public void leave(GameContainer container, StateBasedGame game) throws SlickException { search.setFocus(false); + sortMenu.deactivate(); } /** @@ -1430,7 +1536,8 @@ public class SongMenu extends BasicGameState { focusNode = BeatmapSetList.get().getNode(node, beatmapIndex); Beatmap beatmap = focusNode.getSelectedBeatmap(); if (beatmap.timingPoints == null) { - BeatmapParser.parseOnlyTimingPoints(beatmap); + // parse timing points so we can pulse the logo + BeatmapParser.parseTimingPoints(beatmap); } MusicController.play(beatmap, false, preview); @@ -1670,12 +1777,19 @@ public class SongMenu extends BasicGameState { if (MusicController.isTrackLoading()) return; - SoundController.playSound(SoundEffect.MENUHIT); Beatmap beatmap = MusicController.getBeatmap(); if (focusNode == null || beatmap != focusNode.getSelectedBeatmap()) { UI.sendBarNotification("Unable to load the beatmap audio."); return; } + + // turn on "auto" mod if holding "ctrl" key + if (input.isKeyDown(Input.KEY_RCONTROL) || input.isKeyDown(Input.KEY_LCONTROL)) { + if (!GameMod.AUTO.isActive()) + GameMod.AUTO.toggle(true); + } + + SoundController.playSound(SoundEffect.MENUHIT); MultiClip.destroyExtraClips(); Game gameState = (Game) game.getState(Opsu.STATE_GAME); gameState.loadBeatmap(beatmap); diff --git a/src/itdelatrisu/opsu/ui/Colors.java b/src/itdelatrisu/opsu/ui/Colors.java index 70a836d7..3b133d9c 100644 --- a/src/itdelatrisu/opsu/ui/Colors.java +++ b/src/itdelatrisu/opsu/ui/Colors.java @@ -33,6 +33,7 @@ public class Colors { BLUE_BACKGROUND = new Color(74, 130, 255), BLUE_BUTTON = new Color(40, 129, 237), ORANGE_BUTTON = new Color(200, 90, 3), + PINK_BUTTON = new Color(223, 71, 147), YELLOW_ALPHA = new Color(255, 255, 0, 0.4f), WHITE_FADE = new Color(255, 255, 255, 1f), RED_HOVER = new Color(255, 112, 112), @@ -48,8 +49,7 @@ public class Colors { BLACK_BG_NORMAL = new Color(0, 0, 0, 0.25f), BLACK_BG_HOVER = new Color(0, 0, 0, 0.5f), BLACK_BG_FOCUS = new Color(0, 0, 0, 0.75f), - GHOST_LOGO = new Color(1.0f, 1.0f, 1.0f, 0.25f); - + GHOST_LOGO = new Color(1.0f, 1.0f, 1.0f, 0.25f); // This class should not be instantiated. private Colors() {} diff --git a/src/itdelatrisu/opsu/ui/Cursor.java b/src/itdelatrisu/opsu/ui/Cursor.java index 98c01e53..7ad8171d 100644 --- a/src/itdelatrisu/opsu/ui/Cursor.java +++ b/src/itdelatrisu/opsu/ui/Cursor.java @@ -320,8 +320,6 @@ public class Cursor { // reset angles cursorAngle = 0f; - GameImage.CURSOR.getImage().setRotation(0f); - GameImage.CURSOR_TRAIL.getImage().setRotation(0f); } /** diff --git a/src/itdelatrisu/opsu/ui/DropdownMenu.java b/src/itdelatrisu/opsu/ui/DropdownMenu.java index 059751a0..5204c257 100644 --- a/src/itdelatrisu/opsu/ui/DropdownMenu.java +++ b/src/itdelatrisu/opsu/ui/DropdownMenu.java @@ -95,6 +95,9 @@ public class DropdownMenu extends AbstractComponent { /** The chevron images. */ private Image chevronDown, chevronRight; + /** Should the next click be blocked? */ + private boolean blockClick = false; + /** * Creates a new dropdown menu. * @param container the container rendering this menu @@ -327,6 +330,7 @@ public class DropdownMenu extends AbstractComponent { this.expanded = false; this.lastUpdateTime = 0; expandProgress.setTime(0); + blockClick = false; } @Override @@ -349,9 +353,21 @@ public class DropdownMenu extends AbstractComponent { this.itemIndex = idx; itemSelected(idx, items[idx]); } + blockClick = true; consumeEvent(); } + @Override + public void mouseClicked(int button, int x, int y, int clickCount) { + if (!active) + return; + + if (blockClick) { + blockClick = false; + consumeEvent(); + } + } + /** * Notification that a new item was selected (via override). * @param index the index of the item selected diff --git a/src/itdelatrisu/opsu/ui/MenuButton.java b/src/itdelatrisu/opsu/ui/MenuButton.java index 1a71c536..a155d270 100644 --- a/src/itdelatrisu/opsu/ui/MenuButton.java +++ b/src/itdelatrisu/opsu/ui/MenuButton.java @@ -98,11 +98,8 @@ public class MenuButton { /** The default max rotation angle of the button. */ private static final float DEFAULT_ANGLE_MAX = 30f; - private float currentScale = 1f; - - public float getCurrentScale() { - return currentScale; - } + /** The last scale at which the button was drawn. */ + private float lastScale = 1f; /** * Creates a new button from an Image. @@ -172,6 +169,11 @@ public class MenuButton { */ public float getY() { return y; } + /** + * Returns the last scale at which the button was drawn. + */ + public float getLastScale() { return lastScale; } + /** * Sets text to draw in the middle of the button. * @param text the text to draw @@ -197,21 +199,21 @@ public class MenuButton { /** * Draws the button. */ - public void draw() { draw(Color.white, 1.0f); } + public void draw() { draw(Color.white, 1f); } /** * Draws the button with a color filter. * @param filter the color to filter with when drawing */ - public void draw(Color filter) { draw(filter, 1.0f); } + public void draw(Color filter) { draw(filter, 1f); } /** - * Draw the button with a color filter and scale. - * @param filter the color to filter with when drawing - * @param scaleoverride the scale to use when drawing + * Draw the button with a color filter at the given scale. + * @param filter the color to filter with when drawing + * @param scaleOverride the scale to use when drawing (only works for normal images) */ @SuppressWarnings("deprecation") - public void draw(Color filter, float scaleoverride) { + public void draw(Color filter, float scaleOverride) { // animations: get current frame Image image = this.img; if (image == null) { @@ -219,20 +221,17 @@ public class MenuButton { image = anim.getCurrentFrame(); } - currentScale = 1f; - // normal images if (imgL == null) { - float scaleposmodx = 0; - float scaleposmody = 0; - if (scaleoverride != 1f) { - image = image.getScaledCopy(scaleoverride); - scaleposmodx = image.getWidth() / 2 - xRadius; - scaleposmody = image.getHeight() / 2 - yRadius; - currentScale = scaleoverride; + float xScaleOffset = 0f, yScaleOffset = 0f; + if (scaleOverride != 1f) { + image = image.getScaledCopy(scaleOverride); + xScaleOffset = image.getWidth() / 2f - xRadius; + yScaleOffset = image.getHeight() / 2f - yRadius; } + lastScale = scaleOverride; if (hoverEffect == 0) - image.draw(x - xRadius - scaleposmodx, y - yRadius - scaleposmody, filter); + image.draw(x - xRadius, y - yRadius, filter); else { float oldAlpha = image.getAlpha(); float oldAngle = image.getRotation(); @@ -240,16 +239,18 @@ public class MenuButton { if (scale.getValue() != 1f) { image = image.getScaledCopy(scale.getValue()); image.setAlpha(oldAlpha); - scaleposmodx = image.getWidth() / 2 - xRadius; - scaleposmody = image.getHeight() / 2 - yRadius; - currentScale *= scale.getValue(); + if (scaleOverride != 1f) { + xScaleOffset = image.getWidth() / 2f - xRadius; + yScaleOffset = image.getHeight() / 2f - yRadius; + } + lastScale *= scale.getValue(); } } if ((hoverEffect & EFFECT_FADE) > 0) image.setAlpha(alpha.getValue()); if ((hoverEffect & EFFECT_ROTATE) > 0) image.setRotation(angle.getValue()); - image.draw(x - xRadius - scaleposmodx, y - yRadius - scaleposmody, filter); + image.draw(x - xRadius - xScaleOffset, y - yRadius - yScaleOffset, filter); if (image == this.img) { image.setAlpha(oldAlpha); image.setRotation(oldAngle); diff --git a/src/itdelatrisu/opsu/ui/StarFountain.java b/src/itdelatrisu/opsu/ui/StarFountain.java new file mode 100644 index 00000000..3686bf6e --- /dev/null +++ b/src/itdelatrisu/opsu/ui/StarFountain.java @@ -0,0 +1,143 @@ +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; + +import org.newdawn.slick.Image; + +/** + * Star fountain consisting of two star streams. + */ +public class StarFountain { + /** The (approximate) number of stars in each burst. */ + private static final int BURST_SIZE = 125; + + /** Star streams. */ + private final StarStream left, right; + + /** Burst progress. */ + private final AnimatedValue burstProgress = new AnimatedValue(1000, 0, 1, AnimationEquation.LINEAR); + + /** The maximum direction offsets. */ + private final float xDirection, yDirection; + + /** Motion types. */ + private enum Motion { + NONE { + @Override + public void init(StarFountain fountain) { + fountain.left.setDirection(0, fountain.yDirection); + fountain.right.setDirection(0, fountain.yDirection); + fountain.left.setDirectionSpread(20f); + fountain.right.setDirectionSpread(20f); + fountain.left.setDurationSpread(1000, 200); + fountain.right.setDurationSpread(1000, 200); + } + }, + OUTWARD_SWEEP { + @Override + public void init(StarFountain fountain) { + fountain.left.setDirectionSpread(0f); + fountain.right.setDirectionSpread(0f); + fountain.left.setDurationSpread(850, 0); + fountain.right.setDurationSpread(850, 0); + } + + @Override + public void update(StarFountain fountain) { + float t = fountain.burstProgress.getValue(); + fountain.left.setDirection(fountain.xDirection - fountain.xDirection * 2f * t, fountain.yDirection); + fountain.right.setDirection(-fountain.xDirection + fountain.xDirection * 2f * t, fountain.yDirection); + } + }, + INWARD_SWEEP { + @Override + public void init(StarFountain fountain) { OUTWARD_SWEEP.init(fountain); } + + @Override + public void update(StarFountain fountain) { + float t = fountain.burstProgress.getValue(); + fountain.left.setDirection(-fountain.xDirection + fountain.xDirection * 2f * t, fountain.yDirection); + fountain.right.setDirection(fountain.xDirection - fountain.xDirection * 2f * t, fountain.yDirection); + } + }; + + /** Initializes the streams in the fountain. */ + public void init(StarFountain fountain) {} + + /** Updates the streams in the fountain. */ + public void update(StarFountain fountain) {} + } + + /** The current motion. */ + private Motion motion = Motion.NONE; + + /** + * Initializes the star fountain. + * @param containerWidth the container width + * @param containerHeight the container height + */ + public StarFountain(int containerWidth, int containerHeight) { + Image img = GameImage.STAR2.getImage(); + float xOffset = containerWidth * 0.125f; + this.xDirection = containerWidth / 2f - xOffset; + this.yDirection = -containerHeight * 0.85f; + this.left = new StarStream(xOffset - img.getWidth() / 2f, containerHeight, 0, yDirection, 0); + this.right = new StarStream(containerWidth - xOffset - img.getWidth() / 2f, containerHeight, 0, yDirection, 0); + left.setScaleSpread(1.1f, 0.2f); + right.setScaleSpread(1.1f, 0.2f); + } + + /** + * Draws the star fountain. + */ + public void draw() { + left.draw(); + right.draw(); + } + + /** + * Updates the stars in the fountain by a delta interval. + * @param delta the delta interval since the last call + */ + public void update(int delta) { + left.update(delta); + right.update(delta); + + if (burstProgress.update(delta)) { + motion.update(this); + int size = Math.round((float) delta / burstProgress.getDuration() * BURST_SIZE); + left.burst(size); + right.burst(size); + } + } + + /** + * Creates a burst of stars to be processed during the next {@link #update(int)} call. + * @param wait if {@code true}, will not burst if a previous burst is in progress + */ + public void burst(boolean wait) { + if (wait && (burstProgress.getTime() < burstProgress.getDuration() || !left.isEmpty() || !right.isEmpty())) + return; // previous burst in progress + + burstProgress.setTime(0); + + Motion lastMotion = motion; + motion = Motion.values()[(int) (Math.random() * Motion.values().length)]; + if (motion == lastMotion) // don't do the same sweep twice + motion = Motion.NONE; + motion.init(this); + } + + /** + * Clears the stars currently in the fountain. + */ + public void clear() { + left.clear(); + right.clear(); + burstProgress.setTime(burstProgress.getDuration()); + motion = Motion.NONE; + motion.init(this); + } +} diff --git a/src/itdelatrisu/opsu/ui/StarStream.java b/src/itdelatrisu/opsu/ui/StarStream.java index 35bb6acd..0bd11480 100644 --- a/src/itdelatrisu/opsu/ui/StarStream.java +++ b/src/itdelatrisu/opsu/ui/StarStream.java @@ -20,6 +20,7 @@ package itdelatrisu.opsu.ui; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.objects.curves.Vec2f; import itdelatrisu.opsu.ui.animations.AnimatedValue; import itdelatrisu.opsu.ui.animations.AnimationEquation; @@ -31,11 +32,35 @@ import java.util.Random; import org.newdawn.slick.Image; /** - * Horizontal star stream. + * Star stream. */ public class StarStream { - /** The container dimensions. */ - private final int containerWidth, containerHeight; + /** The origin of the star stream. */ + private final Vec2f position; + + /** The direction of the star stream. */ + private final Vec2f direction; + + /** The maximum number of stars to draw at once. */ + private final int maxStars; + + /** The spread of the stars' starting position. */ + private float positionSpread = 0f; + + /** The spread of the stars' direction. */ + private float directionSpread = 0f; + + /** The base (mean) duration for which stars are shown, in ms. */ + private int durationBase = 1300; + + /** The spread of the stars' duration, in ms. */ + private int durationSpread = 300; + + /** The base (mean) scale at which stars are drawn. */ + private float scaleBase = 1f; + + /** The spread of the stars' scale.*/ + private float scaleSpread = 0f; /** The star image. */ private final Image starImg; @@ -43,33 +68,41 @@ public class StarStream { /** The current list of stars. */ private final List stars; - /** The maximum number of stars to draw at once. */ - private static final int MAX_STARS = 20; - /** Random number generator instance. */ private final Random random; /** Contains data for a single star. */ private class Star { + /** The star position offset. */ + private final Vec2f offset; + + /** The star direction vector. */ + private final Vec2f dir; + + /** The star image rotation angle. */ + private final int angle; + + /** The star image scale. */ + private final float scale; + /** The star animation progress. */ private final AnimatedValue animatedValue; - /** The star properties. */ - private final int distance, yOffset, angle; - /** * Creates a star with the given properties. + * @param offset the position offset vector + * @param direction the direction vector + * @param angle the image rotation angle + * @param scale the image scale * @param duration the time, in milliseconds, to show the star - * @param distance the distance for the star to travel in {@code duration} - * @param yOffset the vertical offset from the center of the container - * @param angle the rotation angle * @param eqn the animation equation to use */ - public Star(int duration, int distance, int yOffset, int angle, AnimationEquation eqn) { - this.animatedValue = new AnimatedValue(duration, 0f, 1f, eqn); - this.distance = distance; - this.yOffset = yOffset; + public Star(Vec2f offset, Vec2f direction, int angle, float scale, int duration, AnimationEquation eqn) { + this.offset = offset; + this.dir = direction; this.angle = angle; + this.scale = scale; + this.animatedValue = new AnimatedValue(duration, 0f, 1f, eqn); } /** @@ -79,8 +112,9 @@ public class StarStream { float t = animatedValue.getValue(); starImg.setImageColor(1f, 1f, 1f, Math.min((1 - t) * 5f, 1f)); starImg.drawEmbedded( - containerWidth - (distance * t), ((containerHeight - starImg.getHeight()) / 2) + yOffset, - starImg.getWidth(), starImg.getHeight(), angle); + offset.x + t * dir.x, offset.y + t * dir.y, + starImg.getWidth() * scale, starImg.getHeight() * scale, angle + ); } /** @@ -93,17 +127,60 @@ public class StarStream { /** * Initializes the star stream. - * @param width the container width - * @param height the container height + * @param x the x position + * @param y the y position + * @param dirX the x-axis direction + * @param dirY the y-axis direction + * @param k the maximum number of stars to draw at once (excluding bursts) */ - public StarStream(int width, int height) { - this.containerWidth = width; - this.containerHeight = height; + public StarStream(float x, float y, float dirX, float dirY, int k) { + this.position = new Vec2f(x, y); + this.direction = new Vec2f(dirX, dirY); + this.maxStars = k; this.starImg = GameImage.STAR2.getImage().copy(); - this.stars = new ArrayList(); + this.stars = new ArrayList(k); this.random = new Random(); } + /** + * Set the direction spread of this star stream. + * @param spread the spread of the stars' starting position + */ + public void setPositionSpread(float spread) { this.positionSpread = spread; } + + /** + * Sets the direction of this star stream. + * @param dirX the new x-axis direction + * @param dirY the new y-axis direction + */ + public void setDirection(float dirX, float dirY) { direction.set(dirX, dirY); } + + /** + * Set the direction spread of this star stream. + * @param spread the spread of the stars' direction + */ + public void setDirectionSpread(float spread) { this.directionSpread = spread; } + + /** + * Sets the duration base and spread of this star stream. + * @param base the base (mean) duration for which stars are shown, in ms + * @param spread the spread of the stars' duration, in ms + */ + public void setDurationSpread(int base, int spread) { + this.durationBase = base; + this.durationSpread = spread; + } + + /** + * Sets the scale base and spread of this star stream. + * @param base the base (mean) scale at which stars are drawn + * @param spread the spread of the stars' scale + */ + public void setScaleSpread(float base, float spread) { + this.scaleBase = base; + this.scaleSpread = spread; + } + /** * Draws the star stream. */ @@ -131,27 +208,48 @@ public class StarStream { } // create new stars - for (int i = stars.size(); i < MAX_STARS; i++) { - if (Math.random() < ((i < 5) ? 0.25 : 0.66)) - break; + for (int i = stars.size(); i < maxStars; i++) { + if (Math.random() < ((i < maxStars / 4) ? 0.25 : 0.66)) + break; // stagger spawning new stars - // generate star properties - float distanceRatio = Utils.clamp((float) getGaussian(0.65, 0.25), 0.2f, 0.925f); - int distance = (int) (containerWidth * distanceRatio); - int duration = (int) (distanceRatio * getGaussian(1300, 300)); - int yOffset = (int) getGaussian(0, containerHeight / 20); - int angle = (int) getGaussian(0, 22.5); - AnimationEquation eqn = random.nextBoolean() ? AnimationEquation.IN_OUT_QUAD : AnimationEquation.OUT_QUAD; - - stars.add(new Star(duration, distance, yOffset, angle, eqn)); + stars.add(createStar()); } } + /** + * Creates a new star with randomized properties. + */ + private Star createStar() { + float distanceRatio = Utils.clamp((float) getGaussian(0.65, 0.25), 0.2f, 0.925f); + Vec2f offset = position.cpy().add(direction.cpy().nor().normalize().scale((float) getGaussian(0, positionSpread))); + Vec2f dir = direction.cpy().scale(distanceRatio).add((float) getGaussian(0, directionSpread), (float) getGaussian(0, directionSpread)); + int angle = (int) getGaussian(0, 22.5); + float scale = (float) getGaussian(scaleBase, scaleSpread); + int duration = Math.max(0, (int) (distanceRatio * getGaussian(durationBase, durationSpread))); + AnimationEquation eqn = random.nextBoolean() ? AnimationEquation.IN_OUT_QUAD : AnimationEquation.OUT_QUAD; + + return new Star(offset, dir, angle, scale, duration, eqn); + } + + /** + * Creates a burst of stars instantly. + * @param count the number of stars to create + */ + public void burst(int count) { + for (int i = 0; i < count; i++) + stars.add(createStar()); + } + /** * Clears the stars currently in the stream. */ public void clear() { stars.clear(); } + /** + * Returns whether there are any stars currently in this stream. + */ + public boolean isEmpty() { return stars.isEmpty(); } + /** * Returns the next pseudorandom, Gaussian ("normally") distributed {@code double} value * with the given mean and standard deviation.