diff --git a/.gitignore b/.gitignore index 172616ad..1773d9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,10 @@ .classpath .project +# IntelliJ +.idea/ +*.iml +*.iws + Thumbs.db /target diff --git a/pom.xml b/pom.xml index 796fce56..4eb303ab 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 itdelatrisu opsu - 0.8.0 + 0.9.0 ${maven.build.timestamp} yyyy-MM-dd HH:mm @@ -115,6 +115,8 @@ org/newdawn/slick/GameContainer.* org/newdawn/slick/Image.* org/newdawn/slick/Music.* + org/newdawn/slick/Input.* + org/newdawn/slick/Input$NullOutputStream.* org/newdawn/slick/gui/TextField.* org/newdawn/slick/openal/AudioInputStream* org/newdawn/slick/openal/OpenALStreamPlayer* diff --git a/res/DroidSansFallback.ttf b/res/DroidSansFallback.ttf new file mode 100644 index 00000000..2b751139 Binary files /dev/null and b/res/DroidSansFallback.ttf differ diff --git a/res/kochi-gothic.ttf b/res/kochi-gothic.ttf deleted file mode 100644 index 68c9ac85..00000000 Binary files a/res/kochi-gothic.ttf and /dev/null differ diff --git a/res/lighting.png b/res/lighting.png index 85e22fdc..01d9794c 100644 Binary files a/res/lighting.png and b/res/lighting.png differ diff --git a/res/lighting1.png b/res/lighting1.png deleted file mode 100644 index e83fdad6..00000000 Binary files a/res/lighting1.png and /dev/null differ diff --git a/res/options-background.jpg b/res/options-background.jpg new file mode 100644 index 00000000..d590ad6b Binary files /dev/null and b/res/options-background.jpg differ diff --git a/res/playback-double.png b/res/playback-double.png new file mode 100644 index 00000000..ba43cdfb Binary files /dev/null and b/res/playback-double.png differ diff --git a/res/playback-half.png b/res/playback-half.png new file mode 100644 index 00000000..358d7e0c Binary files /dev/null and b/res/playback-half.png differ diff --git a/res/playback-normal.png b/res/playback-normal.png new file mode 100644 index 00000000..a5949368 Binary files /dev/null and b/res/playback-normal.png differ diff --git a/res/slidergradient.png b/res/slidergradient.png new file mode 100644 index 00000000..6939923f Binary files /dev/null and b/res/slidergradient.png differ diff --git a/src/itdelatrisu/opsu/Container.java b/src/itdelatrisu/opsu/Container.java index e4678f9e..7e789160 100644 --- a/src/itdelatrisu/opsu/Container.java +++ b/src/itdelatrisu/opsu/Container.java @@ -19,8 +19,11 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.downloads.DownloadList; import itdelatrisu.opsu.downloads.Updater; +import itdelatrisu.opsu.ui.UI; import org.lwjgl.opengl.Display; import org.newdawn.slick.AppGameContainer; @@ -112,20 +115,23 @@ public class Container extends AppGameContainer { // save user options Options.saveOptions(); + // reset cursor + UI.getCursor().reset(); + // destroy images InternalTextureLoader.get().clear(); // reset image references GameImage.clearReferences(); GameData.Grade.clearReferences(); - OsuFile.resetImageCache(); + Beatmap.getBackgroundImageCache().clear(); // prevent loading tracks from re-initializing OpenAL MusicController.reset(); - // reset OsuGroupList data - if (OsuGroupList.get() != null) - OsuGroupList.get().reset(); + // reset BeatmapSetList data + if (BeatmapSetList.get() != null) + BeatmapSetList.get().reset(); } @Override diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java index 666b15ed..63f0bd2f 100644 --- a/src/itdelatrisu/opsu/GameData.java +++ b/src/itdelatrisu/opsu/GameData.java @@ -22,7 +22,10 @@ import itdelatrisu.opsu.audio.HitSound; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.downloads.Updater; +import itdelatrisu.opsu.objects.curves.Curve; import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.replay.ReplayFrame; @@ -44,15 +47,33 @@ public class GameData { /** Delta multiplier for steady HP drain. */ public static final float HP_DRAIN_MULTIPLIER = 1 / 200f; + /** Time, in milliseconds, for a hit result to remain existent. */ + public static final int HITRESULT_TIME = 833; + /** Time, in milliseconds, for a hit result to fade. */ public static final int HITRESULT_FADE_TIME = 500; + /** Time, in milliseconds, for a hit circle to fade. */ + public static final int HITCIRCLE_FADE_TIME = 300; + /** Duration, in milliseconds, of a combo pop effect. */ private static final int COMBO_POP_TIME = 250; /** Time, in milliseconds, for a hit error tick to fade. */ private static final int HIT_ERROR_FADE_TIME = 5000; + /** Size of a hit circle at the end of the hit animation. */ + private static final float HITCIRCLE_ANIM_SCALE = 1.38f; + + /** Size of the hit result text at the end of its animation. */ + private static final float HITCIRCLE_TEXT_ANIM_SCALE = 1.28f; + + /** Time, in milliseconds, for the hit result text to bounce. */ + private static final int HITCIRCLE_TEXT_BOUNCE_TIME = 100; + + /** Time, in milliseconds, for the hit result text to fade. */ + private static final int HITCIRCLE_TEXT_FADE_TIME = 833; + /** Letter grades. */ public enum Grade { NULL (null, null), @@ -170,7 +191,7 @@ public class GameData { private int[] hitResultOffset; /** List of hit result objects associated with hit objects. */ - private LinkedBlockingDeque hitResultList; + private LinkedBlockingDeque hitResultList; /** * Class to store hit error information. @@ -205,10 +226,11 @@ public class GameData { /** List containing recent hit error information. */ private LinkedBlockingDeque hitErrorList; - /** - * Hit result helper class. - */ - private class OsuHitObjectResult { + /** Hit object types, used for drawing results. */ + public enum HitObjectType { CIRCLE, SLIDERTICK, SLIDER_FIRST, SLIDER_LAST, SPINNER } + + /** Hit result helper class. */ + private class HitObjectResult { /** Object start time. */ public int time; @@ -221,12 +243,18 @@ public class GameData { /** Combo color. */ public Color color; - /** Whether the hit object was a spinner. */ - public boolean isSpinner; + /** The type of the hit object. */ + public HitObjectType hitResultType; /** Alpha level (for fading out). */ public float alpha = 1f; + /** Slider curve. */ + public Curve curve; + + /** Whether or not to expand when animating. */ + public boolean expand; + /** * Constructor. * @param time the result's starting track position @@ -234,15 +262,19 @@ public class GameData { * @param x the center x coordinate * @param y the center y coordinate * @param color the color of the hit object - * @param isSpinner whether the hit object was a spinner + * @param curve the slider curve (or null if not applicable) + * @param expand whether or not the hit result animation should expand (if applicable) */ - public OsuHitObjectResult(int time, int result, float x, float y, Color color, boolean isSpinner) { + public HitObjectResult(int time, int result, float x, float y, Color color, + HitObjectType hitResultType, Curve curve, boolean expand) { this.time = time; this.result = result; this.x = x; this.y = y; this.color = color; - this.isSpinner = isSpinner; + this.hitResultType = hitResultType; + this.curve = curve; + this.expand = expand; } } @@ -312,7 +344,7 @@ public class GameData { /** * Constructor for score viewing. * This will initialize all parameters and images needed for the - * {@link #drawRankingElements(Graphics, OsuFile)} method. + * {@link #drawRankingElements(Graphics, Beatmap)} method. * @param s the ScoreData object * @param width container width * @param height container height @@ -350,7 +382,13 @@ public class GameData { health = 100f; healthDisplay = 100f; hitResultCount = new int[HIT_MAX]; - hitResultList = new LinkedBlockingDeque(); + if (hitResultList != null) { + for (HitObjectResult hitResult : hitResultList) { + if (hitResult.curve != null) + hitResult.curve.discardCache(); + } + } + hitResultList = new LinkedBlockingDeque(); hitErrorList = new LinkedBlockingDeque(); fullObjectCount = 0; combo = 0; @@ -423,21 +461,37 @@ public class GameData { } /** - * Returns a default/score text symbol image for a character. + * Returns a default text symbol image for a digit. + * @param i the digit [0-9] */ public Image getDefaultSymbolImage(int i) { return defaultSymbols[i]; } + + /** + * Returns a score text symbol image for a character. + * @param c the character [0-9,.%x] + */ public Image getScoreSymbolImage(char c) { return scoreSymbols.get(c); } /** - * Sets or returns the health drain rate. + * Sets the health drain rate. + * @param drainRate the new drain rate [0-10] */ public void setDrainRate(float drainRate) { this.drainRate = drainRate; } + + /** + * Returns the health drain rate. + */ public float getDrainRate() { return drainRate; } /** - * Sets or returns the difficulty. + * Sets the overall difficulty level. + * @param difficulty the new difficulty [0-10] */ public void setDifficulty(float difficulty) { this.difficulty = difficulty; } + + /** + * Returns the overall difficulty level. + */ public float getDifficulty() { return difficulty; } /** @@ -572,8 +626,8 @@ public class GameData { width - margin, symbolHeight, 0.60f, 1f, true); // map progress circle - OsuFile osu = MusicController.getOsuFile(); - int firstObjectTime = osu.objects[0].getTime(); + Beatmap beatmap = MusicController.getBeatmap(); + int firstObjectTime = beatmap.objects[0].getTime(); int trackPosition = MusicController.getPosition(); float circleDiameter = symbolHeight * 0.60f; int circleX = (int) (width - margin - ( // max width: "100.00%" @@ -590,7 +644,7 @@ public class GameData { if (trackPosition > firstObjectTime) { // map progress (white) g.fillArc(circleX, symbolHeight, circleDiameter, circleDiameter, - -90, -90 + (int) (360f * (trackPosition - firstObjectTime) / (osu.endTime - firstObjectTime)) + -90, -90 + (int) (360f * (trackPosition - firstObjectTime) / (beatmap.endTime - firstObjectTime)) ); } else { // lead-in time (yellow) @@ -732,9 +786,9 @@ public class GameData { /** * Draws ranking elements: score, results, ranking, game mods. * @param g the graphics context - * @param osu the OsuFile + * @param beatmap the beatmap */ - public void drawRankingElements(Graphics g, OsuFile osu) { + public void drawRankingElements(Graphics g, Beatmap beatmap) { // TODO Version 2 skins float rankingHeight = 75; float scoreTextScale = 1.0f; @@ -813,13 +867,12 @@ public class GameData { g.setColor(Utils.COLOR_BLACK_ALPHA); g.fillRect(0, 0, width, 100 * uiScale); rankingTitle.draw((width * 0.97f) - rankingTitle.getWidth(), 0); - float c = width * 0.01f; - Utils.FONT_LARGE.drawString(c, c, - String.format("%s - %s [%s]", osu.getArtist(), osu.getTitle(), osu.version), Color.white); - Utils.FONT_MEDIUM.drawString(c, c + Utils.FONT_LARGE.getLineHeight() - 6, - String.format("Beatmap by %s", osu.creator), Color.white); - Utils.FONT_MEDIUM.drawString( - c, c + Utils.FONT_LARGE.getLineHeight() + Utils.FONT_MEDIUM.getLineHeight() - 10, + float marginX = width * 0.01f, marginY = height * 0.002f; + Utils.FONT_LARGE.drawString(marginX, marginY, + String.format("%s - %s [%s]", beatmap.getArtist(), beatmap.getTitle(), beatmap.version), Color.white); + Utils.FONT_MEDIUM.drawString(marginX, marginY + Utils.FONT_LARGE.getLineHeight() - 6, + String.format("Beatmap by %s", beatmap.creator), Color.white); + Utils.FONT_MEDIUM.drawString(marginX, marginY + Utils.FONT_LARGE.getLineHeight() + Utils.FONT_MEDIUM.getLineHeight() - 10, String.format("Played on %s.", scoreData.getTimeString()), Color.white); // mod icons @@ -836,20 +889,15 @@ public class GameData { /** * Draws stored hit results and removes them from the list as necessary. - * @param trackPosition the current track position + * @param trackPosition the current track position (in ms) */ public void drawHitResults(int trackPosition) { - Iterator iter = hitResultList.iterator(); + Iterator iter = hitResultList.iterator(); while (iter.hasNext()) { - OsuHitObjectResult hitResult = iter.next(); - if (hitResult.time + HITRESULT_FADE_TIME > trackPosition) { - // hit result - hitResults[hitResult.result].setAlpha(hitResult.alpha); - hitResults[hitResult.result].drawCentered(hitResult.x, hitResult.y); - hitResults[hitResult.result].setAlpha(1f); - + HitObjectResult hitResult = iter.next(); + if (hitResult.time + HITRESULT_TIME > trackPosition) { // spinner - if (hitResult.isSpinner && hitResult.result != HIT_MISS) { + if (hitResult.hitResultType == HitObjectType.SPINNER && hitResult.result != HIT_MISS) { Image spinnerOsu = GameImage.SPINNER_OSU.getImage(); spinnerOsu.setAlpha(hitResult.alpha); spinnerOsu.drawCentered(width / 2, height / 4); @@ -859,26 +907,75 @@ public class GameData { // hit lighting else if (Options.isHitLightingEnabled() && hitResult.result != HIT_MISS && hitResult.result != HIT_SLIDER30 && hitResult.result != HIT_SLIDER10) { - float scale = 1f + ((trackPosition - hitResult.time) / (float) HITRESULT_FADE_TIME); - Image scaledLighting = GameImage.LIGHTING.getImage().getScaledCopy(scale); - Image scaledLighting1 = GameImage.LIGHTING1.getImage().getScaledCopy(scale); - scaledLighting.setAlpha(hitResult.alpha); - scaledLighting1.setAlpha(hitResult.alpha); + // TODO: add particle system + Image lighting = GameImage.LIGHTING.getImage(); + lighting.setAlpha(hitResult.alpha); + lighting.drawCentered(hitResult.x, hitResult.y, hitResult.color); + } - scaledLighting.draw(hitResult.x - (scaledLighting.getWidth() / 2f), - hitResult.y - (scaledLighting.getHeight() / 2f), hitResult.color); - scaledLighting1.draw(hitResult.x - (scaledLighting1.getWidth() / 2f), - hitResult.y - (scaledLighting1.getHeight() / 2f), hitResult.color); + // hit animation + if (hitResult.result != HIT_MISS && ( + hitResult.hitResultType == HitObjectType.CIRCLE || + hitResult.hitResultType == HitObjectType.SLIDER_FIRST || + hitResult.hitResultType == HitObjectType.SLIDER_LAST)) { + float scale = (!hitResult.expand) ? 1f : Utils.easeOut( + Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME), + 1f, HITCIRCLE_ANIM_SCALE - 1f, HITCIRCLE_FADE_TIME + ); + float alpha = Utils.easeOut( + Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME), + 1f, -1f, HITCIRCLE_FADE_TIME + ); + + // slider curve + if (hitResult.curve != null) { + float oldWhiteAlpha = Utils.COLOR_WHITE_FADE.a; + float oldColorAlpha = hitResult.color.a; + Utils.COLOR_WHITE_FADE.a = alpha; + hitResult.color.a = alpha; + hitResult.curve.draw(hitResult.color); + Utils.COLOR_WHITE_FADE.a = oldWhiteAlpha; + hitResult.color.a = oldColorAlpha; + } + + // 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 result + if (hitResult.hitResultType == HitObjectType.CIRCLE || + hitResult.hitResultType == HitObjectType.SPINNER || + hitResult.curve != null) { + float scale = Utils.easeBounce( + Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_TEXT_BOUNCE_TIME), + 1f, HITCIRCLE_TEXT_ANIM_SCALE - 1f, HITCIRCLE_TEXT_BOUNCE_TIME + ); + float alpha = Utils.easeOut( + Utils.clamp((trackPosition - hitResult.time) - HITCIRCLE_FADE_TIME, 0, HITCIRCLE_TEXT_FADE_TIME), + 1f, -1f, HITCIRCLE_TEXT_FADE_TIME + ); + Image scaledHitResult = hitResults[hitResult.result].getScaledCopy(scale); + scaledHitResult.setAlpha(alpha); + scaledHitResult.drawCentered(hitResult.x, hitResult.y); } hitResult.alpha = 1 - ((float) (trackPosition - hitResult.time) / HITRESULT_FADE_TIME); - } else + } else { + if (hitResult.curve != null) + hitResult.curve.discardCache(); iter.remove(); + } } } /** * Changes health by a given percentage, modified by drainRate. + * @param percent the health percentage */ public void changeHealth(float percent) { // TODO: drainRate formula @@ -890,7 +987,7 @@ public class GameData { } /** - * Returns health percentage. + * Returns the current health percentage. */ public float getHealth() { return health; } @@ -905,6 +1002,7 @@ public class GameData { /** * Changes score by a raw value (not affected by other modifiers). + * @param value the score value */ public void changeScore(int value) { score += value; } @@ -914,7 +1012,7 @@ public class GameData { * @param hit100 the number of 100s * @param hit50 the number of 50s * @param miss the number of misses - * @return the percentage + * @return the score percentage */ public static float getScorePercent(int hit300, int hit100, int hit50, int miss) { float percent = 0; @@ -969,7 +1067,7 @@ public class GameData { /** * Returns letter grade based on score data, - * or Grade.NULL if no objects have been processed. + * or {@code Grade.NULL} if no objects have been processed. */ private Grade getGrade() { return getGrade( @@ -1073,10 +1171,14 @@ public class GameData { // combo bursts (at 30, 60, 100+50x) if (Options.isComboBurstEnabled() && (combo == 30 || combo == 60 || (combo >= 100 && combo % 50 == 0))) { - if (combo == 30) - comboBurstIndex = 0; - else - comboBurstIndex = (comboBurstIndex + 1) % comboBurstImages.length; + if (Options.getSkin().isComboBurstRandom()) + comboBurstIndex = (int) (Math.random() * comboBurstImages.length); + else { + if (combo == 30) + comboBurstIndex = 0; + else + comboBurstIndex = (comboBurstIndex + 1) % comboBurstImages.length; + } comboBurstAlpha = 0.8f; if ((comboBurstIndex % 2) == 0) comboBurstX = width; @@ -1105,7 +1207,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, OsuHitObject hitObject, int repeat) { + public void sliderTickResult(int time, int result, float x, float y, HitObject hitObject, int repeat) { int hitValue = 0; switch (result) { case HIT_SLIDER30: @@ -1135,7 +1237,7 @@ public class GameData { if (!Options.isPerfectHitBurstEnabled()) ; // hide perfect hit results else - hitResultList.add(new OsuHitObjectResult(time, result, x, y, null, false)); + hitResultList.add(new HitObjectResult(time, result, x, y, null, HitObjectType.SLIDERTICK, null, false)); } } public void sliderFinalResult(int time, int hitSlider30, float x, float y, @@ -1144,23 +1246,41 @@ public class GameData { } /** - * Handles a hit result. + * Returns the score for a hit based on the following score formula: + *

+ * Score = Hit Value + Hit Value * (Combo * Difficulty * Mod) / 25 + *

    + *
  • Hit Value: hit result (50, 100, 300), slider ticks, spinner bonus + *
  • Combo: combo before this hit - 1 (minimum 0) + *
  • Difficulty: the beatmap difficulty + *
  • Mod: mod multipliers + *
+ * @param hitValue the hit value + * @return the score value + */ + private int getScoreForHit(int hitValue) { + return hitValue + (int) (hitValue * (Math.max(combo - 1, 0) * difficulty * GameMod.getScoreMultiplier()) / 25); + } + + /** + * Handles a hit result and performs all associated calculations. * @param time the object start time - * @param result the hit result (HIT_* constants) + * @param result the base 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 repeat the current repeat number (for sliders, or 0 otherwise) + * @param hitResultType the type of hit object for the result + * @return the actual hit result (HIT_* constants) */ - public void hitResult(int time, int result, float x, float y, Color color, - boolean end, OsuHitObject hitObject, int repeat) { + private int handleHitResult(int time, int result, float x, float y, Color color, + boolean end, HitObject hitObject, int repeat, HitObjectType hitResultType) { + // update health, score, and combo streak based on hit result int hitValue = 0; - boolean perfectHit = false; switch (result) { case HIT_300: - perfectHit = true; hitValue = 300; changeHealth(5f); break; @@ -1183,13 +1303,15 @@ public class GameData { resetComboStreak(); break; default: - return; + return HIT_MISS; } if (hitValue > 0) { SoundController.playHitSound( hitObject.getEdgeHitSoundType(repeat), hitObject.getSampleSet(repeat), hitObject.getAdditionSampleSet(repeat)); + //TODO merge conflict + /** * https://osu.ppy.sh/wiki/Score * [SCORE FORMULA] @@ -1204,7 +1326,11 @@ public class GameData { comboMulti += 1; } score += (hitValue + (hitValue * (comboMulti * getDifficultyMultiplier() * GameMod.getScoreMultiplier()) / 25)); + + // calculate score and increment combo streak + changeScore(getScoreForHit(hitValue)); incrementComboStreak(); + //merge conflict end } hitResultCount[result]++; fullObjectCount++; @@ -1229,12 +1355,43 @@ public class GameData { comboEnd = 0; } - if (perfectHit && !Options.isPerfectHitBurstEnabled()) + return result; + } + + /** + * Handles a slider 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 repeat the current repeat number (for sliders, or 0 otherwise) + * @param hitResultType the type of hit object for the result + * @param curve the slider curve (or null if not applicable) + * @param expand whether or not the hit result animation should expand (if applicable) + */ + public void hitResult(int time, int result, float x, float y, Color color, + boolean end, HitObject hitObject, int repeat, + HitObjectType hitResultType, Curve curve, boolean expand) { + result = handleHitResult(time, result, x, y, color, end, hitObject, repeat, hitResultType); + + if ((result == HIT_300 || result == HIT_300G || result == HIT_300K) && !Options.isPerfectHitBurstEnabled()) ; // hide perfect hit results else if (result == HIT_MISS && (GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive())) ; // "relax" and "autopilot" mods: hide misses - else - hitResultList.add(new OsuHitObjectResult(time, result, x, y, color, hitObject.isSpinner())); + else { + hitResultList.add(new HitObjectResult(time, result, x, y, color, hitResultType, curve, expand)); + + // sliders: add the other curve endpoint for the hit animation + if (curve != null) { + boolean isFirst = (hitResultType == HitObjectType.SLIDER_FIRST); + float[] p = curve.pointAt((isFirst) ? 1f : 0f); + HitObjectType type = (isFirst) ? HitObjectType.SLIDER_LAST : HitObjectType.SLIDER_FIRST; + hitResultList.add(new HitObjectResult(time, result, p[0], p[1], color, type, null, expand)); + } + } } private int getDifficultyMultiplier() { @@ -1258,21 +1415,21 @@ public class GameData { * Returns a ScoreData object encapsulating all game data. * If score data already exists, the existing object will be returned * (i.e. this will not overwrite existing data). - * @param osu the OsuFile + * @param beatmap the beatmap * @return the ScoreData object */ - public ScoreData getScoreData(OsuFile osu) { + public ScoreData getScoreData(Beatmap beatmap) { if (scoreData != null) return scoreData; scoreData = new ScoreData(); scoreData.timestamp = System.currentTimeMillis() / 1000L; - scoreData.MID = osu.beatmapID; - scoreData.MSID = osu.beatmapSetID; - scoreData.title = osu.title; - scoreData.artist = osu.artist; - scoreData.creator = osu.creator; - scoreData.version = osu.version; + scoreData.MID = beatmap.beatmapID; + scoreData.MSID = beatmap.beatmapSetID; + scoreData.title = beatmap.title; + scoreData.artist = beatmap.artist; + scoreData.creator = beatmap.creator; + scoreData.version = beatmap.version; scoreData.hit300 = hitResultCount[HIT_300]; scoreData.hit100 = hitResultCount[HIT_100]; scoreData.hit50 = hitResultCount[HIT_50]; @@ -1292,10 +1449,10 @@ public class GameData { * Returns a Replay object encapsulating all game data. * If a replay already exists and frames is null, the existing object will be returned. * @param frames the replay frames - * @param osu the associated OsuFile + * @param beatmap the associated beatmap * @return the Replay object, or null if none exists and frames is null */ - public Replay getReplay(ReplayFrame[] frames, OsuFile osu) { + public Replay getReplay(ReplayFrame[] frames, Beatmap beatmap) { if (replay != null && frames == null) return replay; @@ -1303,9 +1460,9 @@ public class GameData { return null; replay = new Replay(); - replay.mode = OsuFile.MODE_OSU; + replay.mode = Beatmap.MODE_OSU; replay.version = Updater.get().getBuildDate(); - replay.beatmapHash = (osu == null) ? "" : Utils.getMD5(osu.getFile()); + replay.beatmapHash = (beatmap == null) ? "" : Utils.getMD5(beatmap.getFile()); replay.playerName = ""; // TODO replay.replayHash = Long.toString(System.currentTimeMillis()); // TODO replay.hit300 = (short) hitResultCount[HIT_300]; @@ -1329,6 +1486,7 @@ public class GameData { /** * Sets the replay object. + * @param replay the replay */ public void setReplay(Replay replay) { this.replay = replay; } diff --git a/src/itdelatrisu/opsu/GameImage.java b/src/itdelatrisu/opsu/GameImage.java index 12f9a814..c8cb3bfb 100644 --- a/src/itdelatrisu/opsu/GameImage.java +++ b/src/itdelatrisu/opsu/GameImage.java @@ -110,6 +110,7 @@ public enum GameImage { APPROACHCIRCLE ("approachcircle", "png"), // Slider + SLIDER_GRADIENT ("slidergradient", "png"), SLIDER_BALL ("sliderb", "sliderb%d", "png"), SLIDER_FOLLOWCIRCLE ("sliderfollowcircle", "png"), REVERSEARROW ("reversearrow", "png"), @@ -202,7 +203,6 @@ public enum GameImage { SCORE_PERCENT ("score-percent", "png"), SCORE_X ("score-x", "png"), LIGHTING ("lighting", "png"), - LIGHTING1 ("lighting1", "png"), // Game Mods MOD_EASY ("selection-mod-easy", "png", false, false), @@ -228,6 +228,11 @@ public enum GameImage { SELECTION_OTHER_OPTIONS ("selection-selectoptions", "png", false, false), SELECTION_OTHER_OPTIONS_OVERLAY ("selection-selectoptions-over", "png", false, false), + // Replay Speed Buttons + REPLAY_PLAYBACK_NORMAL ("playback-normal", "png", false, false), + REPLAY_PLAYBACK_DOUBLE ("playback-double", "png", false, false), + REPLAY_PLAYBACK_HALF ("playback-half", "png", false, false), + // Non-Game Components VOLUME ("volume-bg", "png", false, false) { @Override @@ -329,6 +334,14 @@ public enum GameImage { return REPOSITORY.process_sub(img, w, h); } }, + OPTIONS_BG ("options-background", "png|jpg", false, true) { + @Override + protected Image process_sub(Image img, int w, int h) { + img.setAlpha(0.7f); + return img.getScaledCopy(w, h); + } + }, + // TODO: ensure this image hasn't been modified (checksum?) ALPHA_MAP ("alpha", "png", false, false); @@ -376,10 +389,13 @@ public enum GameImage { /** The unscaled container height that uiscale is based on. */ private static final int UNSCALED_HEIGHT = 768; + /** Filename suffix for HD images. */ + public static final String HD_SUFFIX = "@2x"; + /** Image HD/SD suffixes. */ private static final String[] - SUFFIXES_HD = new String[] { "@2x", "" }, - SUFFIXES_SD = new String[] { "" }; + SUFFIXES_HD = new String[] { HD_SUFFIX, "" }, + SUFFIXES_SD = new String[] { "" }; /** * Initializes the GameImage class with container dimensions. @@ -550,6 +566,7 @@ public enum GameImage { /** * Sets the image associated with this resource to another image. * The skin image takes priority over the default image. + * @param img the image to set */ public void setImage(Image img) { if (skinImage != null) @@ -561,6 +578,8 @@ public enum GameImage { /** * Sets an image associated with this resource to another image. * The skin image takes priority over the default image. + * @param img the image to set + * @param index the index in the image array */ public void setImage(Image img, int index) { if (skinImages != null) { @@ -581,8 +600,9 @@ public enum GameImage { return; // try to load multiple images + File skinDir = Options.getSkin().getDirectory(); if (filenameFormat != null) { - if (((defaultImages = loadImageArray(Options.getSkinDir())) != null) || + if ((skinDir != null && ((defaultImages = loadImageArray(skinDir)) != null)) || ((defaultImages = loadImageArray(null)) != null)) { process(); return; @@ -590,7 +610,7 @@ public enum GameImage { } // try to load a single image - if (((defaultImage = loadImageSingle(Options.getSkinDir())) != null) || + if ((skinDir != null && ((defaultImage = loadImageSingle(skinDir)) != null)) || ((defaultImage = loadImageSingle(null)) != null)) { process(); return; @@ -602,6 +622,7 @@ public enum GameImage { /** * Sets the associated skin image. * If the path does not contain the image, the default image is used. + * @param dir the image directory to search * @return true if a new skin image is loaded, false otherwise */ public boolean setSkinImage(File dir) { @@ -632,6 +653,7 @@ public enum GameImage { /** * Attempts to load multiple Images from the GameImage. + * @param dir the image directory to search, or null to use the default resource locations * @return an array of the loaded images, or null if not found */ private Image[] loadImageArray(File dir) { @@ -649,7 +671,7 @@ public enum GameImage { // add image to list try { Image img = new Image(name); - if (suffix.equals("@2x")) + if (suffix.equals(HD_SUFFIX)) img = img.getScaledCopy(0.5f); list.add(img); } catch (SlickException e) { @@ -666,6 +688,7 @@ public enum GameImage { /** * Attempts to load a single Image from the GameImage. + * @param dir the image directory to search, or null to use the default resource locations * @return the loaded image, or null if not found */ private Image loadImageSingle(File dir) { @@ -674,7 +697,7 @@ public enum GameImage { if (name != null) { try { Image img = new Image(name); - if (suffix.equals("@2x")) + if (suffix.equals(HD_SUFFIX)) img = img.getScaledCopy(0.5f); return img; } catch (SlickException e) { diff --git a/src/itdelatrisu/opsu/GameMod.java b/src/itdelatrisu/opsu/GameMod.java index a7bd3e97..ff7c86a0 100644 --- a/src/itdelatrisu/opsu/GameMod.java +++ b/src/itdelatrisu/opsu/GameMod.java @@ -18,6 +18,8 @@ package itdelatrisu.opsu; +import itdelatrisu.opsu.ui.MenuButton; + import java.util.Arrays; import java.util.Collections; @@ -33,7 +35,7 @@ public enum GameMod { "Easy", "Reduces overall difficulty - larger circles, more forgiving HP drain, less accuracy required."), NO_FAIL (Category.EASY, 1, GameImage.MOD_NO_FAIL, "NF", 1, Input.KEY_W, 0.5f, "NoFail", "You can't fail. No matter what."), - HALF_TIME (Category.EASY, 2, GameImage.MOD_HALF_TIME, "HT", 256, Input.KEY_E, 0.3f, false, + HALF_TIME (Category.EASY, 2, GameImage.MOD_HALF_TIME, "HT", 256, Input.KEY_E, 0.3f, "HalfTime", "Less zoom."), HARD_ROCK (Category.HARD, 0, GameImage.MOD_HARD_ROCK, "HR", 16, Input.KEY_A, 1.06f, "HardRock", "Everything just got a bit harder..."), @@ -41,7 +43,7 @@ public enum GameMod { "SuddenDeath", "Miss a note and fail."), // PERFECT (Category.HARD, 1, GameImage.MOD_PERFECT, "PF", 64, Input.KEY_S, 1f, // "Perfect", "SS or quit."), - DOUBLE_TIME (Category.HARD, 2, GameImage.MOD_DOUBLE_TIME, "DT", 64, Input.KEY_D, 1.12f, false, + DOUBLE_TIME (Category.HARD, 2, GameImage.MOD_DOUBLE_TIME, "DT", 64, Input.KEY_D, 1.12f, "DoubleTime", "Zoooooooooom."), // NIGHTCORE (Category.HARD, 2, GameImage.MOD_NIGHTCORE, "NT", 64, Input.KEY_D, 1.12f, // "Nightcore", "uguuuuuuuu"), @@ -173,6 +175,12 @@ public enum GameMod { /** The last calculated score multiplier, or -1f if it must be recalculated. */ private static float scoreMultiplier = -1f; + /** The last calculated track speed multiplier, or -1f if it must be recalculated. */ + private static float speedMultiplier = -1f; + + /** The last calculated difficulty multiplier, or -1f if it must be recalculated. */ + private static float difficultyMultiplier = -1f; + /** * Initializes the game mods. * @param width the container width @@ -198,7 +206,7 @@ public enum GameMod { mod.active = false; } - scoreMultiplier = -1f; + scoreMultiplier = speedMultiplier = difficultyMultiplier = -1f; } /** @@ -216,6 +224,36 @@ public enum GameMod { return scoreMultiplier; } + /** + * Returns the current track speed multiplier from all active mods. + */ + public static float getSpeedMultiplier() { + if (speedMultiplier < 0f) { + if (DOUBLE_TIME.isActive()) + speedMultiplier = 1.5f; + else if (HALF_TIME.isActive()) + speedMultiplier = 0.75f; + else + speedMultiplier = 1f; + } + return speedMultiplier; + } + + /** + * Returns the current difficulty multiplier from all active mods. + */ + public static float getDifficultyMultiplier() { + if (difficultyMultiplier < 0f) { + if (HARD_ROCK.isActive()) + difficultyMultiplier = 1.4f; + else if (EASY.isActive()) + difficultyMultiplier = 0.5f; + else + difficultyMultiplier = 1f; + } + return difficultyMultiplier; + } + /** * Returns the current game mod state (bitwise OR of active mods). */ @@ -233,6 +271,7 @@ public enum GameMod { * @param state the state (bitwise OR of active mods) */ public static void loadModState(int state) { + scoreMultiplier = speedMultiplier = difficultyMultiplier = -1f; for (GameMod mod : GameMod.values()) mod.active = ((state & mod.getBit()) > 0); } @@ -352,7 +391,7 @@ public enum GameMod { return; active = !active; - scoreMultiplier = -1f; + scoreMultiplier = speedMultiplier = difficultyMultiplier = -1f; if (checkInverse) { if (AUTO.isActive()) { diff --git a/src/itdelatrisu/opsu/Opsu.java b/src/itdelatrisu/opsu/Opsu.java index 078e791d..1f89dd5b 100644 --- a/src/itdelatrisu/opsu/Opsu.java +++ b/src/itdelatrisu/opsu/Opsu.java @@ -31,6 +31,7 @@ import itdelatrisu.opsu.states.MainMenu; import itdelatrisu.opsu.states.OptionsMenu; import itdelatrisu.opsu.states.SongMenu; import itdelatrisu.opsu.states.Splash; +import itdelatrisu.opsu.ui.UI; import java.io.File; import java.io.FileNotFoundException; @@ -45,7 +46,6 @@ import org.newdawn.slick.SlickException; import org.newdawn.slick.state.StateBasedGame; import org.newdawn.slick.state.transition.FadeInTransition; import org.newdawn.slick.state.transition.FadeOutTransition; -import org.newdawn.slick.util.ClasspathLocation; import org.newdawn.slick.util.DefaultLogSystem; import org.newdawn.slick.util.FileSystemLocation; import org.newdawn.slick.util.Log; @@ -128,14 +128,14 @@ public class Opsu extends StateBasedGame { System.setProperty("org.lwjgl.librarypath", nativeDir.getAbsolutePath()); // set the resource paths - ResourceLoader.removeAllResourceLocations(); - ResourceLoader.addResourceLocation(new FileSystemLocation(Options.getSkinDir())); - ResourceLoader.addResourceLocation(new ClasspathLocation()); - ResourceLoader.addResourceLocation(new FileSystemLocation(new File("."))); ResourceLoader.addResourceLocation(new FileSystemLocation(new File("./res/"))); // initialize databases - DBController.init(); + try { + DBController.init(); + } catch (UnsatisfiedLinkError e) { + errorAndExit(e, "The databases could not be initialized."); + } // check if just updated if (args.length >= 2) @@ -176,12 +176,7 @@ public class Opsu extends StateBasedGame { } } } catch (SlickException e) { - // JARs will not run properly inside directories containing '!' - // http://bugs.java.com/view_bug.do?bug_id=4523159 - if (new File("").getAbsolutePath().indexOf('!') != -1) - ErrorHandler.error("Cannot run JAR from path containing '!'.", null, false); - else - ErrorHandler.error("Error while creating game container.", e, true); + errorAndExit(e, "An error occurred while creating the game container."); } } @@ -207,7 +202,8 @@ public class Opsu extends StateBasedGame { } else songMenu.resetTrackOnLoad(); } - UI.resetCursor(); + if (UI.getCursor().isSkinned()) + UI.getCursor().reset(); this.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); return false; } @@ -242,4 +238,20 @@ public class Opsu extends StateBasedGame { } } } + + /** + * Throws an error and exits the application with the given message. + * @param e the exception that caused the crash + * @param message the message to display + */ + private static void errorAndExit(Throwable e, String message) { + // JARs will not run properly inside directories containing '!' + // http://bugs.java.com/view_bug.do?bug_id=4523159 + if (Utils.isJarRunning() && Utils.getRunningDirectory() != null && + Utils.getRunningDirectory().getAbsolutePath().indexOf('!') != -1) + ErrorHandler.error("JARs cannot be run from some paths containing '!'. Please move or rename the file and try again.", null, false); + else + ErrorHandler.error(message, e, true); + System.exit(1); + } } diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java index 00a0f901..ecfe30b9 100644 --- a/src/itdelatrisu/opsu/Options.java +++ b/src/itdelatrisu/opsu/Options.java @@ -19,6 +19,10 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.skins.Skin; +import itdelatrisu.opsu.skins.SkinLoader; +import itdelatrisu.opsu.ui.UI; import java.io.BufferedReader; import java.io.BufferedWriter; @@ -30,6 +34,7 @@ import java.io.OutputStreamWriter; import java.net.URI; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.HashMap; import java.util.Locale; import java.util.concurrent.TimeUnit; @@ -37,7 +42,10 @@ import org.lwjgl.input.Keyboard; import org.newdawn.slick.GameContainer; import org.newdawn.slick.Input; import org.newdawn.slick.SlickException; +import org.newdawn.slick.util.ClasspathLocation; +import org.newdawn.slick.util.FileSystemLocation; import org.newdawn.slick.util.Log; +import org.newdawn.slick.util.ResourceLoader; /** * Handles all user options. @@ -55,21 +63,28 @@ public class Options { /** File for storing user options. */ private static final File OPTIONS_FILE = new File(CONFIG_DIR, ".opsu.cfg"); - /** Beatmap directories (where to search for files). */ + /** Beatmap directories (where to search for files). */ private static final String[] BEATMAP_DIRS = { "C:/Program Files (x86)/osu!/Songs/", "C:/Program Files/osu!/Songs/", new File(DATA_DIR, "Songs/").getPath() }; + /** Skin directories (where to search for skins). */ + private static final String[] SKIN_ROOT_DIRS = { + "C:/Program Files (x86)/osu!/Skins/", + "C:/Program Files/osu!/Skins/", + new File(DATA_DIR, "Skins/").getPath() + }; + /** Cached beatmap database name. */ - public static final File OSU_DB = new File(DATA_DIR, ".opsu.db"); + public static final File BEATMAP_DB = new File(DATA_DIR, ".opsu.db"); /** Score database name. */ public static final File SCORE_DB = new File(DATA_DIR, ".opsu_scores.db"); /** Font file name. */ - public static final String FONT_NAME = "kochi-gothic.ttf"; + public static final String FONT_NAME = "DroidSansFallback.ttf"; /** Version file name. */ public static final String VERSION_FILE = "version"; @@ -98,8 +113,8 @@ public class Options { /** The replay import directory. */ private static File replayImportDir; - /** The current skin directory (for user skins). */ - private static File skinDir; + /** The root skin directory. */ + private static File skinRootDir; /** Port binding. */ private static int port = 49250; @@ -140,8 +155,63 @@ public class Options { /** Game options. */ public enum GameOption { - NULL (null, null), - SCREEN_RESOLUTION ("Screen Resolution", "Restart (Ctrl+Shift+F5) to apply resolution changes.") { + // internal options (not displayed in-game) + BEATMAP_DIRECTORY ("BeatmapDirectory") { + @Override + public String write() { return getBeatmapDir().getAbsolutePath(); } + + @Override + public void read(String s) { beatmapDir = new File(s); } + }, + OSZ_DIRECTORY ("OSZDirectory") { + @Override + public String write() { return getOSZDir().getAbsolutePath(); } + + @Override + public void read(String s) { oszDir = new File(s); } + }, + SCREENSHOT_DIRECTORY ("ScreenshotDirectory") { + @Override + public String write() { return getScreenshotDir().getAbsolutePath(); } + + @Override + public void read(String s) { screenshotDir = new File(s); } + }, + REPLAY_DIRECTORY ("ReplayDirectory") { + @Override + public String write() { return getReplayDir().getAbsolutePath(); } + + @Override + public void read(String s) { replayDir = new File(s); } + }, + SKIN_DIRECTORY ("SkinDirectory") { + @Override + public String write() { return getSkinRootDir().getAbsolutePath(); } + + @Override + public void read(String s) { skinRootDir = new File(s); } + }, + THEME_SONG ("ThemeSong") { + @Override + public String write() { return themeString; } + + @Override + public void read(String s) { themeString = s; } + }, + PORT ("Port") { + @Override + public String write() { return Integer.toString(port); } + + @Override + public void read(String s) { + int i = Integer.parseInt(s); + if (i > 0 && i < 65535) + port = i; + } + }, + + // in-game options + SCREEN_RESOLUTION ("Screen Resolution", "ScreenResolution", "Restart (Ctrl+Shift+F5) to apply resolution changes.") { @Override public String getValueString() { return resolution.toString(); } @@ -149,13 +219,34 @@ public class Options { public void click(GameContainer container) { do { resolution = resolution.next(); - } while (resolution != Resolution.RES_800_600 && - (container.getScreenWidth() < resolution.getWidth() || - container.getScreenHeight() < resolution.getHeight())); + } while (resolution != Resolution.RES_800_600 && ( + container.getScreenWidth() < resolution.getWidth() || + container.getScreenHeight() < resolution.getHeight())); + } + + @Override + public void read(String s) { + try { + Resolution res = Resolution.valueOf(String.format("RES_%s", s.replace('x', '_'))); + resolution = res; + } catch (IllegalArgumentException e) {} } }, -// FULLSCREEN ("Fullscreen Mode", "Restart to apply changes.", false), - TARGET_FPS ("Frame Limiter", "Higher values may cause high CPU usage.") { +// FULLSCREEN ("Fullscreen Mode", "Fullscreen", "Restart to apply changes.", false), + SKIN ("Skin", "Skin", "Restart (Ctrl+Shift+F5) to apply skin changes.") { + @Override + public String getValueString() { return skinName; } + + @Override + public void click(GameContainer container) { + skinDirIndex = (skinDirIndex + 1) % skinDirs.length; + skinName = skinDirs[skinDirIndex]; + } + + @Override + public void read(String s) { skinName = s; } + }, + TARGET_FPS ("Frame Limiter", "FrameSync", "Higher values may cause high CPU usage.") { @Override public String getValueString() { return String.format((getTargetFPS() == 60) ? "%dfps (vsync)" : "%dfps", getTargetFPS()); @@ -167,86 +258,23 @@ public class Options { container.setTargetFrameRate(getTargetFPS()); container.setVSync(getTargetFPS() == 60); } - }, - MASTER_VOLUME ("Master Volume", "Global volume level.", 35, 0, 100) { - @Override - public void drag(GameContainer container, int d) { - super.drag(container, d); - container.setMusicVolume(getMasterVolume() * getMusicVolume()); - } - }, - MUSIC_VOLUME ("Music Volume", "Volume of music.", 80, 0, 100) { - @Override - public void drag(GameContainer container, int d) { - super.drag(container, d); - container.setMusicVolume(getMasterVolume() * getMusicVolume()); - } - }, - EFFECT_VOLUME ("Effect Volume", "Volume of menu and game sounds.", 70, 0, 100), - HITSOUND_VOLUME ("Hit Sound Volume", "Volume of hit sounds.", 30, 0, 100), - MUSIC_OFFSET ("Music Offset", "Adjust this value if hit objects are out of sync.", -75, -500, 500) { - @Override - public String getValueString() { return String.format("%dms", val); } - }, - SCREENSHOT_FORMAT ("Screenshot Format", "Press F12 to take a screenshot.") { - @Override - public String getValueString() { return screenshotFormat[screenshotFormatIndex].toUpperCase(); } @Override - public void click(GameContainer container) { screenshotFormatIndex = (screenshotFormatIndex + 1) % screenshotFormat.length; } - }, - SHOW_FPS ("Show FPS Counter", "Show an FPS counter in the bottom-right hand corner.", true), - SHOW_HIT_LIGHTING ("Show Hit Lighting", "Adds an effect behind hit explosions.", true), - SHOW_COMBO_BURSTS ("Show Combo Bursts", "A character image is displayed at combo milestones.", true), - SHOW_PERFECT_HIT ("Show Perfect Hits", "Whether to show perfect hit result bursts (300s, slider ticks).", true), - SHOW_FOLLOW_POINTS ("Show Follow Points", "Whether to show follow points between hit objects.", true), - NEW_CURSOR ("Enable New Cursor", "Use the new cursor style (may cause higher CPU usage).", true) { + public String write() { return Integer.toString(targetFPS[targetFPSindex]); } + @Override - public void click(GameContainer container) { - super.click(container); - UI.resetCursor(); + public void read(String s) { + int i = Integer.parseInt(s); + for (int j = 0; j < targetFPS.length; j++) { + if (i == targetFPS[j]) { + targetFPSindex = j; + break; + } + } } }, - DYNAMIC_BACKGROUND ("Enable Dynamic Backgrounds", "The song background will be used as the main menu background.", true), - BACKGROUND_DIM ("Background Dim", "Percentage to dim the background image during gameplay.", 50, 0, 100), - FORCE_DEFAULT_PLAYFIELD ("Force Default Playfield", "Override the song background with the default playfield background.", false), - IGNORE_BEATMAP_SKINS ("Ignore All Beatmap Skins", "Never use skin element overrides provided by beatmaps.", false), - FIXED_CS ("Fixed Circle Size (CS)", "Determines the size of circles and sliders.", 0, 0, 100) { - @Override - public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } - }, - FIXED_HP ("Fixed HP Drain Rate (HP)", "Determines the rate at which health decreases.", 0, 0, 100) { - @Override - public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } - }, - FIXED_AR ("Fixed Approach Rate (AR)", "Determines how long hit circles stay on the screen.", 0, 0, 100) { - @Override - public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } - }, - FIXED_OD ("Fixed Overall Difficulty (OD)", "Determines the time window for hit results.", 0, 0, 100) { - @Override - public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } - }, - LOAD_VERBOSE ("Show Detailed Loading Progress", "Display more specific loading information in the splash screen.", false), - CHECKPOINT ("Track Checkpoint", "Press Ctrl+L while playing to load a checkpoint, and Ctrl+S to set one.", 0, 0, 3599) { - @Override - public String getValueString() { - return (val == 0) ? "Disabled" : String.format("%02d:%02d", - TimeUnit.SECONDS.toMinutes(val), - val - TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(val))); - } - }, - DISABLE_SOUNDS ("Disable All Sound Effects", "May resolve Linux sound driver issues. Requires a restart.", - (System.getProperty("os.name").toLowerCase().indexOf("linux") > -1)), - KEY_LEFT ("Left Game Key", "Select this option to input a key.") { - @Override - public String getValueString() { return Keyboard.getKeyName(getGameKeyLeft()); } - }, - KEY_RIGHT ("Right Game Key", "Select this option to input a key.") { - @Override - public String getValueString() { return Keyboard.getKeyName(getGameKeyRight()); } - }, - SHOW_UNICODE ("Prefer Non-English Metadata", "Where available, song titles will be shown in their native language.", false) { + SHOW_FPS ("Show FPS Counter", "FpsCounter", "Show an FPS counter in the bottom-right hand corner.", true), + SHOW_UNICODE ("Prefer Non-English Metadata", "ShowUnicode", "Where available, song titles will be shown in their native language.", false) { @Override public void click(GameContainer container) { super.click(container); @@ -261,15 +289,157 @@ public class Options { } } }, - ENABLE_THEME_SONG ("Enable Theme Song", "Whether to play the theme song upon starting opsu!", true), - SHOW_HIT_ERROR_BAR ("Show Hit Error Bar", "Shows precisely how accurate you were with each hit.", false), - LOAD_HD_IMAGES ("Load HD Images", "Loads HD (@2x) images when available. Increases memory usage and loading times.", true), - DISABLE_MOUSE_WHEEL ("Disable mouse wheel in play mode", "During play, you can use the mouse wheel to adjust the volume and pause the game.\nThis will disable that functionality.", false), - DISABLE_MOUSE_BUTTONS ("Disable mouse buttons in play mode", "This option will disable all mouse buttons.\nSpecifically for people who use their keyboard to click.", false); + SCREENSHOT_FORMAT ("Screenshot Format", "ScreenshotFormat", "Press F12 to take a screenshot.") { + @Override + public String getValueString() { return screenshotFormat[screenshotFormatIndex].toUpperCase(); } + + @Override + public void click(GameContainer container) { screenshotFormatIndex = (screenshotFormatIndex + 1) % screenshotFormat.length; } + + @Override + public String write() { return Integer.toString(screenshotFormatIndex); } + + @Override + public void read(String s) { + int i = Integer.parseInt(s); + if (i >= 0 && i < screenshotFormat.length) + screenshotFormatIndex = i; + } + }, + NEW_CURSOR ("Enable New Cursor", "NewCursor", "Use the new cursor style (may cause higher CPU usage).", true) { + @Override + public void click(GameContainer container) { + super.click(container); + UI.getCursor().reset(); + } + }, + DYNAMIC_BACKGROUND ("Enable Dynamic Backgrounds", "DynamicBackground", "The song background will be used as the main menu background.", true), + LOAD_VERBOSE ("Show Detailed Loading Progress", "LoadVerbose", "Display more specific loading information in the splash screen.", false), + MASTER_VOLUME ("Master Volume", "VolumeUniversal", "Global volume level.", 35, 0, 100) { + @Override + public void drag(GameContainer container, int d) { + super.drag(container, d); + container.setMusicVolume(getMasterVolume() * getMusicVolume()); + } + }, + MUSIC_VOLUME ("Music Volume", "VolumeMusic", "Volume of music.", 80, 0, 100) { + @Override + public void drag(GameContainer container, int d) { + super.drag(container, d); + container.setMusicVolume(getMasterVolume() * getMusicVolume()); + } + }, + EFFECT_VOLUME ("Effect Volume", "VolumeEffect", "Volume of menu and game sounds.", 70, 0, 100), + HITSOUND_VOLUME ("Hit Sound Volume", "VolumeHitSound", "Volume of hit sounds.", 30, 0, 100), + MUSIC_OFFSET ("Music Offset", "Offset", "Adjust this value if hit objects are out of sync.", -75, -500, 500) { + @Override + public String getValueString() { return String.format("%dms", val); } + }, + DISABLE_SOUNDS ("Disable All Sound Effects", "DisableSound", "May resolve Linux sound driver issues. Requires a restart.", + (System.getProperty("os.name").toLowerCase().indexOf("linux") > -1)), + KEY_LEFT ("Left Game Key", "keyOsuLeft", "Select this option to input a key.") { + @Override + public String getValueString() { return Keyboard.getKeyName(getGameKeyLeft()); } + + @Override + public String write() { return Keyboard.getKeyName(getGameKeyLeft()); } + + @Override + public void read(String s) { setGameKeyLeft(Keyboard.getKeyIndex(s)); } + }, + KEY_RIGHT ("Right Game Key", "keyOsuRight", "Select this option to input a key.") { + @Override + public String getValueString() { return Keyboard.getKeyName(getGameKeyRight()); } + + @Override + public String write() { return Keyboard.getKeyName(getGameKeyRight()); } + + @Override + public void read(String s) { setGameKeyRight(Keyboard.getKeyIndex(s)); } + }, + DISABLE_MOUSE_WHEEL ("Disable mouse wheel in play mode", "MouseDisableWheel", "During play, you can use the mouse wheel to adjust the volume and pause the game.\nThis will disable that functionality.", false), + DISABLE_MOUSE_BUTTONS ("Disable mouse buttons in play mode", "MouseDisableButtons", "This option will disable all mouse buttons.\nSpecifically for people who use their keyboard to click.", false), + BACKGROUND_DIM ("Background Dim", "DimLevel", "Percentage to dim the background image during gameplay.", 50, 0, 100), + FORCE_DEFAULT_PLAYFIELD ("Force Default Playfield", "ForceDefaultPlayfield", "Override the song background with the default playfield background.", false), + IGNORE_BEATMAP_SKINS ("Ignore All Beatmap Skins", "IgnoreBeatmapSkins", "Never use skin element overrides provided by beatmaps.", false), + SHOW_HIT_LIGHTING ("Show Hit Lighting", "HitLighting", "Adds an effect behind hit explosions.", true), + SHOW_COMBO_BURSTS ("Show Combo Bursts", "ComboBurst", "A character image is displayed at combo milestones.", true), + SHOW_PERFECT_HIT ("Show Perfect Hits", "PerfectHit", "Whether to show perfect hit result bursts (300s, slider ticks).", true), + SHOW_FOLLOW_POINTS ("Show Follow Points", "FollowPoints", "Whether to show follow points between hit objects.", true), + SHOW_HIT_ERROR_BAR ("Show Hit Error Bar", "ScoreMeter", "Shows precisely how accurate you were with each hit.", false), + LOAD_HD_IMAGES ("Load HD Images", "LoadHDImages", String.format("Loads HD (%s) images when available. Increases memory usage and loading times.", GameImage.HD_SUFFIX), true), + FIXED_CS ("Fixed Circle Size (CS)", "FixedCS", "Determines the size of circles and sliders.", 0, 0, 100) { + @Override + public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } + + @Override + public String write() { return String.format(Locale.US, "%.1f", val / 10f); } + + @Override + public void read(String s) { + int i = (int) (Float.parseFloat(s) * 10f); + if (i >= 0 && i <= 100) + val = i; + } + }, + FIXED_HP ("Fixed HP Drain Rate (HP)", "FixedHP", "Determines the rate at which health decreases.", 0, 0, 100) { + @Override + public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } + + @Override + public String write() { return String.format(Locale.US, "%.1f", val / 10f); } + + @Override + public void read(String s) { + int i = (int) (Float.parseFloat(s) * 10f); + if (i >= 0 && i <= 100) + val = i; + } + }, + FIXED_AR ("Fixed Approach Rate (AR)", "FixedAR", "Determines how long hit circles stay on the screen.", 0, 0, 100) { + @Override + public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } + + @Override + public String write() { return String.format(Locale.US, "%.1f", val / 10f); } + + @Override + public void read(String s) { + int i = (int) (Float.parseFloat(s) * 10f); + if (i >= 0 && i <= 100) + val = i; + } + }, + FIXED_OD ("Fixed Overall Difficulty (OD)", "FixedOD", "Determines the time window for hit results.", 0, 0, 100) { + @Override + public String getValueString() { return (val == 0) ? "Disabled" : String.format("%.1f", val / 10f); } + + @Override + public String write() { return String.format(Locale.US, "%.1f", val / 10f); } + + @Override + public void read(String s) { + int i = (int) (Float.parseFloat(s) * 10f); + if (i >= 0 && i <= 100) + val = i; + } + }, + CHECKPOINT ("Track Checkpoint", "Checkpoint", "Press Ctrl+L while playing to load a checkpoint, and Ctrl+S to set one.", 0, 0, 3599) { + @Override + public String getValueString() { + return (val == 0) ? "Disabled" : String.format("%02d:%02d", + TimeUnit.SECONDS.toMinutes(val), + val - TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(val))); + } + }, + ENABLE_THEME_SONG ("Enable Theme Song", "MenuMusic", "Whether to play the theme song upon starting opsu!", true); /** Option name. */ private String name; + /** Option name, as displayed in the configuration file. */ + private String displayName; + /** Option description. */ private String description; @@ -282,43 +452,58 @@ public class Options { /** The upper and lower bounds on the integer value (if applicable). */ private int max, min; + /** Option types. */ + private enum OptionType { BOOLEAN, NUMERIC, OTHER }; + /** Whether or not this is a numeric option. */ - private boolean isNumeric; + private OptionType type = OptionType.OTHER; /** - * Constructor. + * Constructor for internal options (not displayed in-game). + * @param displayName the option name, as displayed in the configuration file + */ + GameOption(String displayName) { + this.displayName = displayName; + } + + /** + * Constructor for other option types. * @param name the option name + * @param displayName the option name, as displayed in the configuration file * @param description the option description */ - GameOption(String name, String description) { + GameOption(String name, String displayName, String description) { this.name = name; + this.displayName = displayName; this.description = description; } /** - * Constructor. + * Constructor for boolean options. * @param name the option name + * @param displayName the option name, as displayed in the configuration file * @param description the option description * @param value the default boolean value */ - GameOption(String name, String description, boolean value) { - this(name, description); + GameOption(String name, String displayName, String description, boolean value) { + this(name, displayName, description); this.bool = value; - this.isNumeric = false; + this.type = OptionType.BOOLEAN; } /** - * Constructor. + * Constructor for numeric options. * @param name the option name + * @param displayName the option name, as displayed in the configuration file * @param description the option description * @param value the default integer value */ - GameOption(String name, String description, int value, int min, int max) { - this(name, description); + GameOption(String name, String displayName, String description, int value, int min, int max) { + this(name, displayName, description); this.val = value; this.min = min; this.max = max; - this.isNumeric = true; + this.type = OptionType.NUMERIC; } /** @@ -327,6 +512,12 @@ public class Options { */ public String getName() { return name; } + /** + * Returns the option name, as displayed in the configuration file. + * @return the display name string + */ + public String getDisplayName() { return displayName; } + /** * Returns the option description. * @return the description string @@ -360,15 +551,18 @@ public class Options { /** * Returns the value of the option as a string (via override). *

- * By default, this returns "{@code val}%" if this is an numeric option, - * or "Yes" or "No" based on the {@code bool} field otherwise. + * By default, this returns "{@code val}%" for numeric options, + * "Yes" or "No" based on the {@code bool} field for boolean options, + * and an empty string otherwise. * @return the value string */ public String getValueString() { - if (isNumeric) + if (type == OptionType.NUMERIC) return String.format("%d%%", val); - else + else if (type == OptionType.BOOLEAN) return (bool) ? "Yes" : "No"; + else + return ""; } /** @@ -382,17 +576,54 @@ public class Options { /** * Processes a mouse drag action (via override). *

- * By default, if this is a numeric option, the {@code val} field will - * be shifted by {@code d} within the given bounds. + * By default, only if this is a numeric option, the {@code val} field + * will be shifted by {@code d} within the given bounds. * @param container the game container * @param d the dragged distance (modified by multiplier) */ public void drag(GameContainer container, int d) { - if (isNumeric) + if (type == OptionType.NUMERIC) val = Utils.getBoundedValue(val, d, min, max); } + + /** + * Returns the string to write to the configuration file (via override). + *

+ * By default, this returns "{@code val}" for numeric options, + * "true" or "false" based on the {@code bool} field for boolean options, + * and {@link #getValueString()} otherwise. + * @return the string to write + */ + public String write() { + if (type == OptionType.NUMERIC) + return Integer.toString(val); + else if (type == OptionType.BOOLEAN) + return Boolean.toString(bool); + else + return getValueString(); + } + + /** + * Reads the value of the option from the configuration file (via override). + *

+ * By default, this sets {@code val} for numeric options only if the + * value is between the min and max bounds, sets {@code bool} for + * boolean options, and does nothing otherwise. + * @param s the value string read from the configuration file + */ + public void read(String s) { + if (type == OptionType.NUMERIC) { + int i = Integer.parseInt(s); + if (i >= min && i <= max) + val = i; + } else if (type == OptionType.BOOLEAN) + bool = Boolean.parseBoolean(s); + } }; + /** Map of option display names to GameOptions. */ + private static HashMap optionMap; + /** Screen resolutions. */ private enum Resolution { RES_800_600 (800, 600), @@ -451,6 +682,18 @@ public class Options { /** Current screen resolution. */ private static Resolution resolution = Resolution.RES_1024_768; + /** The available skin directories. */ + private static String[] skinDirs; + + /** The index in the skinDirs array. */ + private static int skinDirIndex = 0; + + /** The name of the skin. */ + private static String skinName = "Default"; + + /** The current skin. */ + private static Skin skin; + /** Frame limiters. */ private static final int[] targetFPS = { 60, 120, 240, 30, 20, 15, 12 }; @@ -872,38 +1115,102 @@ public class Options { * If invalid, this will create a "Skins" folder in the root directory. * @return the skin directory */ - public static File getSkinDir() { - if (skinDir != null && skinDir.isDirectory()) - return skinDir; + public static File getSkinRootDir() { + if (skinRootDir != null && skinRootDir.isDirectory()) + return skinRootDir; - skinDir = new File(DATA_DIR, "Skins/"); - skinDir.mkdir(); - return skinDir; + // search for directory + for (int i = 0; i < SKIN_ROOT_DIRS.length; i++) { + skinRootDir = new File(SKIN_ROOT_DIRS[i]); + if (skinRootDir.isDirectory()) + return skinRootDir; + } + skinRootDir.mkdir(); // none found, create new directory + return skinRootDir; } /** - * Returns a dummy OsuFile containing the theme song. - * @return the theme song OsuFile + * Loads the skin given by the current skin directory. + * If the directory is invalid, the default skin will be loaded. */ - public static OsuFile getOsuTheme() { + public static void loadSkin() { + File skinDir = getSkinDir(); + if (skinDir == null) // invalid skin name + skinName = Skin.DEFAULT_SKIN_NAME; + + // create available skins list + File[] dirs = SkinLoader.getSkinDirectories(getSkinRootDir()); + skinDirs = new String[dirs.length + 1]; + skinDirs[0] = Skin.DEFAULT_SKIN_NAME; + for (int i = 0; i < dirs.length; i++) + skinDirs[i + 1] = dirs[i].getName(); + + // set skin and modify resource locations + ResourceLoader.removeAllResourceLocations(); + if (skinDir == null) + skin = new Skin(null); + else { + // set skin index + for (int i = 1; i < skinDirs.length; i++) { + if (skinDirs[i].equals(skinName)) { + skinDirIndex = i; + break; + } + } + + // load the skin + skin = SkinLoader.loadSkin(skinDir); + ResourceLoader.addResourceLocation(new FileSystemLocation(skinDir)); + } + ResourceLoader.addResourceLocation(new ClasspathLocation()); + ResourceLoader.addResourceLocation(new FileSystemLocation(new File("."))); + ResourceLoader.addResourceLocation(new FileSystemLocation(new File("./res/"))); + } + + /** + * Returns the current skin. + * @return the skin, or null if no skin is loaded (see {@link #loadSkin()}) + */ + public static Skin getSkin() { return skin; } + + /** + * Returns the current skin directory. + *

+ * NOTE: This directory will differ from that of the currently loaded skin + * if {@link #loadSkin()} has not been called after a directory change. + * Use {@link Skin#getDirectory()} to get the directory of the currently + * loaded skin. + * @return the skin directory, or null for the default skin + */ + public static File getSkinDir() { + File root = getSkinRootDir(); + File dir = new File(root, skinName); + return (dir.isDirectory()) ? dir : null; + } + + /** + * Returns a dummy Beatmap containing the theme song. + * @return the theme song beatmap + */ + public static Beatmap getThemeBeatmap() { String[] tokens = themeString.split(","); if (tokens.length != 4) { ErrorHandler.error("Theme song string is malformed.", null, false); return null; } - OsuFile osu = new OsuFile(null); - osu.audioFilename = new File(tokens[0]); - osu.title = tokens[1]; - osu.artist = tokens[2]; + Beatmap beatmap = new Beatmap(null); + beatmap.audioFilename = new File(tokens[0]); + beatmap.title = tokens[1]; + beatmap.artist = tokens[2]; try { - osu.endTime = Integer.parseInt(tokens[3]); + beatmap.endTime = Integer.parseInt(tokens[3]); } catch (NumberFormatException e) { ErrorHandler.error("Theme song length is not a valid integer", e, false); return null; } - return osu; + return beatmap; } /** @@ -916,10 +1223,16 @@ public class Options { return; } + // create option map + if (optionMap == null) { + optionMap = new HashMap(); + for (GameOption option : GameOption.values()) + optionMap.put(option.getDisplayName(), option); + } + + // read file try (BufferedReader in = new BufferedReader(new FileReader(OPTIONS_FILE))) { String line; - String name, value; - int i; while ((line = in.readLine()) != null) { line = line.trim(); if (line.length() < 2 || line.charAt(0) == '#') @@ -927,160 +1240,17 @@ public class Options { int index = line.indexOf('='); if (index == -1) continue; - name = line.substring(0, index).trim(); - value = line.substring(index + 1).trim(); - try { - switch (name) { - case "BeatmapDirectory": - beatmapDir = new File(value); - break; - case "OSZDirectory": - oszDir = new File(value); - break; - case "ScreenshotDirectory": - screenshotDir = new File(value); - break; - case "ReplayDirectory": - replayDir = new File(value); - break; - case "Skin": - skinDir = new File(value); - break; - case "ThemeSong": - themeString = value; - break; - case "Port": - i = Integer.parseInt(value); - if (i > 0 && i <= 65535) - port = i; - break; - case "ScreenResolution": - try { - Resolution res = Resolution.valueOf(String.format("RES_%s", value.replace('x', '_'))); - resolution = res; - } catch (IllegalArgumentException e) {} - break; -// case "Fullscreen": -// GameOption.FULLSCREEN.setValue(Boolean.parseBoolean(value)); -// break; - case "FrameSync": - i = Integer.parseInt(value); - for (int j = 0; j < targetFPS.length; j++) { - if (i == targetFPS[j]) - targetFPSindex = j; - } - break; - case "ScreenshotFormat": - i = Integer.parseInt(value); - if (i >= 0 && i < screenshotFormat.length) - screenshotFormatIndex = i; - break; - case "FpsCounter": - GameOption.SHOW_FPS.setValue(Boolean.parseBoolean(value)); - break; - case "ShowUnicode": - GameOption.SHOW_UNICODE.setValue(Boolean.parseBoolean(value)); - break; - case "NewCursor": - GameOption.NEW_CURSOR.setValue(Boolean.parseBoolean(value)); - break; - case "DynamicBackground": - GameOption.DYNAMIC_BACKGROUND.setValue(Boolean.parseBoolean(value)); - break; - case "LoadVerbose": - GameOption.LOAD_VERBOSE.setValue(Boolean.parseBoolean(value)); - break; - case "VolumeUniversal": - i = Integer.parseInt(value); - if (i >= 0 && i <= 100) - GameOption.MASTER_VOLUME.setValue(i); - break; - case "VolumeMusic": - i = Integer.parseInt(value); - if (i >= 0 && i <= 100) - GameOption.MUSIC_VOLUME.setValue(i); - break; - case "VolumeEffect": - i = Integer.parseInt(value); - if (i >= 0 && i <= 100) - GameOption.EFFECT_VOLUME.setValue(i); - break; - case "VolumeHitSound": - i = Integer.parseInt(value); - if (i >= 0 && i <= 100) - GameOption.HITSOUND_VOLUME.setValue(i); - break; - case "Offset": - i = Integer.parseInt(value); - if (i >= -500 && i <= 500) - GameOption.MUSIC_OFFSET.setValue(i); - break; - case "DisableSound": - GameOption.DISABLE_SOUNDS.setValue(Boolean.parseBoolean(value)); - break; - case "keyOsuLeft": - setGameKeyLeft(Keyboard.getKeyIndex(value)); - break; - case "keyOsuRight": - setGameKeyRight(Keyboard.getKeyIndex(value)); - break; - case "MouseDisableWheel": - GameOption.DISABLE_MOUSE_WHEEL.setValue(Boolean.parseBoolean(value)); - break; - case "MouseDisableButtons": - GameOption.DISABLE_MOUSE_BUTTONS.setValue(Boolean.parseBoolean(value)); - break; - case "DimLevel": - i = Integer.parseInt(value); - if (i >= 0 && i <= 100) - GameOption.BACKGROUND_DIM.setValue(i); - break; - case "ForceDefaultPlayfield": - GameOption.FORCE_DEFAULT_PLAYFIELD.setValue(Boolean.parseBoolean(value)); - break; - case "IgnoreBeatmapSkins": - GameOption.IGNORE_BEATMAP_SKINS.setValue(Boolean.parseBoolean(value)); - break; - case "HitLighting": - GameOption.SHOW_HIT_LIGHTING.setValue(Boolean.parseBoolean(value)); - break; - case "ComboBurst": - GameOption.SHOW_COMBO_BURSTS.setValue(Boolean.parseBoolean(value)); - break; - case "PerfectHit": - GameOption.SHOW_PERFECT_HIT.setValue(Boolean.parseBoolean(value)); - break; - case "FollowPoints": - GameOption.SHOW_FOLLOW_POINTS.setValue(Boolean.parseBoolean(value)); - break; - case "ScoreMeter": - GameOption.SHOW_HIT_ERROR_BAR.setValue(Boolean.parseBoolean(value)); - break; - case "LoadHDImages": - GameOption.LOAD_HD_IMAGES.setValue(Boolean.parseBoolean(value)); - break; - case "FixedCS": - GameOption.FIXED_CS.setValue((int) (Float.parseFloat(value) * 10f)); - break; - case "FixedHP": - GameOption.FIXED_HP.setValue((int) (Float.parseFloat(value) * 10f)); - break; - case "FixedAR": - GameOption.FIXED_AR.setValue((int) (Float.parseFloat(value) * 10f)); - break; - case "FixedOD": - GameOption.FIXED_OD.setValue((int) (Float.parseFloat(value) * 10f)); - break; - case "Checkpoint": - setCheckpoint(Integer.parseInt(value)); - break; - case "MenuMusic": - GameOption.ENABLE_THEME_SONG.setValue(Boolean.parseBoolean(value)); - break; + + // read option + String name = line.substring(0, index).trim(); + GameOption option = optionMap.get(name); + if (option != null) { + try { + String value = line.substring(index + 1).trim(); + option.read(value); + } catch (NumberFormatException e) { + Log.warn(String.format("Format error in options file for line: '%s'.", line), e); } - } catch (NumberFormatException e) { - Log.warn(String.format("Format error in options file for line: '%s'.", line), e); - continue; } } } catch (IOException e) { @@ -1105,88 +1275,12 @@ public class Options { writer.newLine(); // options - writer.write(String.format("BeatmapDirectory = %s", getBeatmapDir().getAbsolutePath())); - writer.newLine(); - writer.write(String.format("OSZDirectory = %s", getOSZDir().getAbsolutePath())); - writer.newLine(); - writer.write(String.format("ScreenshotDirectory = %s", getScreenshotDir().getAbsolutePath())); - writer.newLine(); - writer.write(String.format("ReplayDirectory = %s", getReplayDir().getAbsolutePath())); - writer.newLine(); - writer.write(String.format("Skin = %s", getSkinDir().getAbsolutePath())); - writer.newLine(); - writer.write(String.format("ThemeSong = %s", themeString)); - writer.newLine(); - writer.write(String.format("Port = %d", port)); - writer.newLine(); - writer.write(String.format("ScreenResolution = %s", resolution.toString())); - writer.newLine(); -// writer.write(String.format("Fullscreen = %b", isFullscreen())); -// writer.newLine(); - writer.write(String.format("FrameSync = %d", targetFPS[targetFPSindex])); - writer.newLine(); - writer.write(String.format("FpsCounter = %b", isFPSCounterEnabled())); - writer.newLine(); - writer.write(String.format("ShowUnicode = %b", useUnicodeMetadata())); - writer.newLine(); - writer.write(String.format("ScreenshotFormat = %d", screenshotFormatIndex)); - writer.newLine(); - writer.write(String.format("NewCursor = %b", isNewCursorEnabled())); - writer.newLine(); - writer.write(String.format("DynamicBackground = %b", isDynamicBackgroundEnabled())); - writer.newLine(); - writer.write(String.format("LoadVerbose = %b", isLoadVerbose())); - writer.newLine(); - writer.write(String.format("VolumeUniversal = %d", GameOption.MASTER_VOLUME.getIntegerValue())); - writer.newLine(); - writer.write(String.format("VolumeMusic = %d", GameOption.MUSIC_VOLUME.getIntegerValue())); - writer.newLine(); - writer.write(String.format("VolumeEffect = %d", GameOption.EFFECT_VOLUME.getIntegerValue())); - writer.newLine(); - writer.write(String.format("VolumeHitSound = %d", GameOption.HITSOUND_VOLUME.getIntegerValue())); - writer.newLine(); - writer.write(String.format("Offset = %d", getMusicOffset())); - writer.newLine(); - writer.write(String.format("DisableSound = %b", isSoundDisabled())); - writer.newLine(); - writer.write(String.format("keyOsuLeft = %s", Keyboard.getKeyName(getGameKeyLeft()))); - writer.newLine(); - writer.write(String.format("keyOsuRight = %s", Keyboard.getKeyName(getGameKeyRight()))); - writer.newLine(); - writer.write(String.format("MouseDisableWheel = %b", isMouseWheelDisabled())); - writer.newLine(); - writer.write(String.format("MouseDisableButtons = %b", isMouseDisabled())); - writer.newLine(); - writer.write(String.format("DimLevel = %d", GameOption.BACKGROUND_DIM.getIntegerValue())); - writer.newLine(); - writer.write(String.format("ForceDefaultPlayfield = %b", isDefaultPlayfieldForced())); - writer.newLine(); - writer.write(String.format("IgnoreBeatmapSkins = %b", isBeatmapSkinIgnored())); - writer.newLine(); - writer.write(String.format("HitLighting = %b", isHitLightingEnabled())); - writer.newLine(); - writer.write(String.format("ComboBurst = %b", isComboBurstEnabled())); - writer.newLine(); - writer.write(String.format("PerfectHit = %b", isPerfectHitBurstEnabled())); - writer.newLine(); - writer.write(String.format("FollowPoints = %b", isFollowPointEnabled())); - writer.newLine(); - writer.write(String.format("ScoreMeter = %b", isHitErrorBarEnabled())); - writer.newLine(); - writer.write(String.format("LoadHDImages = %b", loadHDImages())); - writer.newLine(); - writer.write(String.format(Locale.US, "FixedCS = %.1f", getFixedCS())); - writer.newLine(); - writer.write(String.format(Locale.US, "FixedHP = %.1f", getFixedHP())); - writer.newLine(); - writer.write(String.format(Locale.US, "FixedAR = %.1f", getFixedAR())); - writer.newLine(); - writer.write(String.format(Locale.US, "FixedOD = %.1f", getFixedOD())); - writer.newLine(); - writer.write(String.format("Checkpoint = %d", GameOption.CHECKPOINT.getIntegerValue())); - writer.newLine(); - writer.write(String.format("MenuMusic = %b", isThemeSongEnabled())); - writer.newLine(); + for (GameOption option : GameOption.values()) { + writer.write(option.getDisplayName()); + writer.write(" = "); + writer.write(option.write()); + writer.newLine(); + } writer.close(); } catch (IOException e) { ErrorHandler.error(String.format("Failed to write to file '%s'.", OPTIONS_FILE.getAbsolutePath()), e, false); diff --git a/src/itdelatrisu/opsu/OsuFile.java b/src/itdelatrisu/opsu/OsuFile.java index 9e01abc3..dd7c9436 100644 --- a/src/itdelatrisu/opsu/OsuFile.java +++ b/src/itdelatrisu/opsu/OsuFile.java @@ -1,3 +1,4 @@ +//TODO rename /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/OsuGroupList.java b/src/itdelatrisu/opsu/OsuGroupList.java index b7bbbe50..54aa09ba 100644 --- a/src/itdelatrisu/opsu/OsuGroupList.java +++ b/src/itdelatrisu/opsu/OsuGroupList.java @@ -1,3 +1,5 @@ +//TODO rename + /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/OsuGroupNode.java b/src/itdelatrisu/opsu/OsuGroupNode.java deleted file mode 100644 index 8c8e15d2..00000000 --- a/src/itdelatrisu/opsu/OsuGroupNode.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * opsu! - an open-source osu! client - * Copyright (C) 2014, 2015 Jeffrey Han - * - * opsu! is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * opsu! is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with opsu!. If not, see . - */ - -package itdelatrisu.opsu; - -import itdelatrisu.opsu.GameData.Grade; - -import java.util.ArrayList; -import java.util.concurrent.TimeUnit; - -import org.newdawn.slick.Color; -import org.newdawn.slick.Image; - -/** - * Node in an OsuGroupList representing a group of OsuFile objects. - */ -public class OsuGroupNode { - /** List of associated OsuFile objects. */ - public ArrayList osuFiles; - - /** Index of this OsuGroupNode. */ - public int index = 0; - - /** Index of selected osuFile (-1 if not focused). */ - public int osuFileIndex = -1; - - /** Links to other OsuGroupNode objects. */ - public OsuGroupNode prev, next; - - /** - * Constructor. - * @param osuFiles the OsuFile objects in this group - */ - public OsuGroupNode(ArrayList osuFiles) { - this.osuFiles = osuFiles; - } - - /** - * Draws the button. - * @param x the x coordinate - * @param y the y coordinate - * @param grade the highest grade, if any - * @param focus true if this is the focused node - */ - public void draw(float x, float y, Grade grade, boolean focus) { - Image bg = GameImage.MENU_BUTTON_BG.getImage(); - boolean expanded = (osuFileIndex > -1); - OsuFile osu; - bg.setAlpha(0.9f); - Color bgColor; - Color textColor = Color.lightGray; - - // get drawing parameters - if (expanded) { - x -= bg.getWidth() / 10f; - if (focus) { - bgColor = Color.white; - textColor = Color.white; - } else - bgColor = Utils.COLOR_BLUE_BUTTON; - osu = osuFiles.get(osuFileIndex); - } else { - bgColor = Utils.COLOR_ORANGE_BUTTON; - osu = osuFiles.get(0); - } - bg.draw(x, y, bgColor); - - float cx = x + (bg.getWidth() * 0.05f); - float cy = y + (bg.getHeight() * 0.2f) - 3; - - // draw grade - if (grade != Grade.NULL) { - Image gradeImg = grade.getMenuImage(); - gradeImg.drawCentered(cx - bg.getWidth() * 0.01f + gradeImg.getWidth() / 2f, y + bg.getHeight() / 2.2f); - cx += gradeImg.getWidth(); - } - - // draw text - if (Options.useUnicodeMetadata()) { // load glyphs - Utils.loadGlyphs(Utils.FONT_MEDIUM, osu.titleUnicode, null); - Utils.loadGlyphs(Utils.FONT_DEFAULT, null, osu.artistUnicode); - } - Utils.FONT_MEDIUM.drawString(cx, cy, osu.getTitle(), textColor); - Utils.FONT_DEFAULT.drawString(cx, cy + Utils.FONT_MEDIUM.getLineHeight() - 4, - String.format("%s // %s", osu.getArtist(), osu.creator), textColor); - if (expanded || osuFiles.size() == 1) - Utils.FONT_BOLD.drawString(cx, cy + Utils.FONT_MEDIUM.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() - 8, - osu.version, textColor); - } - - /** - * Returns an array of strings containing song information. - *

    - *
  • 0: {Artist} - {Title} [{Version}] - *
  • 1: Mapped by {Creator} - *
  • 2: Length: {} BPM: {} Objects: {} - *
  • 3: Circles: {} Sliders: {} Spinners: {} - *
  • 4: CS:{} HP:{} AR:{} OD:{} - *
- */ - public String[] getInfo() { - if (osuFileIndex < 0) - return null; - - OsuFile osu = osuFiles.get(osuFileIndex); - String[] info = new String[5]; - info[0] = osu.toString(); - info[1] = String.format("Mapped by %s", - osu.creator); - info[2] = String.format("Length: %d:%02d BPM: %s Objects: %d", - TimeUnit.MILLISECONDS.toMinutes(osu.endTime), - TimeUnit.MILLISECONDS.toSeconds(osu.endTime) - - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(osu.endTime)), - (osu.bpmMax <= 0) ? "--" : - ((osu.bpmMin == osu.bpmMax) ? osu.bpmMin : String.format("%d-%d", osu.bpmMin, osu.bpmMax)), - (osu.hitObjectCircle + osu.hitObjectSlider + osu.hitObjectSpinner)); - info[3] = String.format("Circles: %d Sliders: %d Spinners: %d", - osu.hitObjectCircle, osu.hitObjectSlider, osu.hitObjectSpinner); - info[4] = String.format("CS:%.1f HP:%.1f AR:%.1f OD:%.1f", - osu.circleSize, osu.HPDrainRate, osu.approachRate, osu.overallDifficulty); - return info; - } - - /** - * Returns a formatted string for the OsuFile at osuFileIndex: - * "Artist - Title [Version]" (version omitted if osuFileIndex is invalid) - * @see java.lang.Object#toString() - */ - @Override - public String toString() { - if (osuFileIndex == -1) - return String.format("%s - %s", osuFiles.get(0).getArtist(), osuFiles.get(0).getTitle()); - else - return osuFiles.get(osuFileIndex).toString(); - } - - /** - * Checks whether the node matches a given search query. - * @param query the search term - * @return true if title, artist, creator, source, version, or tag matches query - */ - public boolean matches(String query) { - OsuFile osu = osuFiles.get(0); - - // search: title, artist, creator, source, version, tags (first OsuFile) - if (osu.title.toLowerCase().contains(query) || - osu.titleUnicode.toLowerCase().contains(query) || - osu.artist.toLowerCase().contains(query) || - osu.artistUnicode.toLowerCase().contains(query) || - osu.creator.toLowerCase().contains(query) || - osu.source.toLowerCase().contains(query) || - osu.version.toLowerCase().contains(query) || - osu.tags.contains(query)) - return true; - - // search: version, tags (remaining OsuFiles) - for (int i = 1; i < osuFiles.size(); i++) { - osu = osuFiles.get(i); - if (osu.version.toLowerCase().contains(query) || - osu.tags.contains(query)) - return true; - } - - return false; - } - - /** - * Checks whether the node matches a given condition. - * @param type the condition type (ar, cs, od, hp, bpm, length) - * @param operator the operator (=/==, >, >=, <, <=) - * @param value the value - * @return true if the condition is met - */ - public boolean matches(String type, String operator, float value) { - for (OsuFile osu : osuFiles) { - // get value - float osuValue; - switch (type) { - case "ar": osuValue = osu.approachRate; break; - case "cs": osuValue = osu.circleSize; break; - case "od": osuValue = osu.overallDifficulty; break; - case "hp": osuValue = osu.HPDrainRate; break; - case "bpm": osuValue = osu.bpmMax; break; - case "length": osuValue = osu.endTime / 1000; break; - default: return false; - } - - // get operator - boolean met; - switch (operator) { - case "=": - case "==": met = (osuValue == value); break; - case ">": met = (osuValue > value); break; - case ">=": met = (osuValue >= value); break; - case "<": met = (osuValue < value); break; - case "<=": met = (osuValue <= value); break; - default: return false; - } - - if (met) - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/src/itdelatrisu/opsu/OsuHitObject.java b/src/itdelatrisu/opsu/OsuHitObject.java index ac641a7a..4b62bdce 100644 --- a/src/itdelatrisu/opsu/OsuHitObject.java +++ b/src/itdelatrisu/opsu/OsuHitObject.java @@ -1,3 +1,5 @@ +//TODO rename + /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/OsuParser.java b/src/itdelatrisu/opsu/OsuParser.java index a3b2c0ab..715e90d4 100644 --- a/src/itdelatrisu/opsu/OsuParser.java +++ b/src/itdelatrisu/opsu/OsuParser.java @@ -1,3 +1,5 @@ +//TODO rename + /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/ScoreData.java b/src/itdelatrisu/opsu/ScoreData.java index 7abb055d..b6307031 100644 --- a/src/itdelatrisu/opsu/ScoreData.java +++ b/src/itdelatrisu/opsu/ScoreData.java @@ -20,6 +20,7 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.GameData.Grade; import itdelatrisu.opsu.states.SongMenu; +import itdelatrisu.opsu.ui.UI; import java.sql.ResultSet; import java.sql.SQLException; diff --git a/src/itdelatrisu/opsu/UI.java b/src/itdelatrisu/opsu/UI.java index bbffec46..1f7aa07d 100644 --- a/src/itdelatrisu/opsu/UI.java +++ b/src/itdelatrisu/opsu/UI.java @@ -1,3 +1,5 @@ +//TODO rename? + /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java index 61baf19a..feb24d44 100644 --- a/src/itdelatrisu/opsu/Utils.java +++ b/src/itdelatrisu/opsu/Utils.java @@ -20,8 +20,11 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.downloads.Download; import itdelatrisu.opsu.downloads.DownloadNode; +import itdelatrisu.opsu.replay.PlaybackSpeed; +import itdelatrisu.opsu.ui.UI; import java.awt.Font; import java.awt.image.BufferedImage; @@ -34,6 +37,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.SocketTimeoutException; +import java.net.URISyntaxException; import java.net.URL; import java.nio.ByteBuffer; import java.security.MessageDigest; @@ -49,6 +53,9 @@ import java.util.Scanner; import javax.imageio.ImageIO; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; import org.lwjgl.BufferUtils; import org.lwjgl.opengl.Display; import org.lwjgl.opengl.GL11; @@ -78,10 +85,6 @@ public class Utils { COLOR_BLUE_BACKGROUND = new Color(74, 130, 255), COLOR_BLUE_BUTTON = new Color(40, 129, 237), COLOR_ORANGE_BUTTON = new Color(200, 90, 3), - COLOR_GREEN_OBJECT = new Color(26, 207, 26), - COLOR_BLUE_OBJECT = new Color(46, 136, 248), - COLOR_RED_OBJECT = new Color(243, 48, 77), - COLOR_ORANGE_OBJECT = new Color(255, 200, 32), COLOR_YELLOW_ALPHA = new Color(255, 255, 0, 0.4f), COLOR_WHITE_FADE = new Color(255, 255, 255, 1f), COLOR_RED_HOVER = new Color(255, 112, 112), @@ -90,13 +93,9 @@ public class Utils { COLOR_LIGHT_GREEN = new Color(128,255,128), COLOR_LIGHT_BLUE = new Color(128,128,255), COLOR_GREEN_SEARCH = new Color(173, 255, 47), - COLOR_DARK_GRAY = new Color(0.3f, 0.3f, 0.3f, 1f); - - /** The default map colors, used when a map does not provide custom colors. */ - public static final Color[] DEFAULT_COMBO = { - COLOR_ORANGE_OBJECT, COLOR_GREEN_OBJECT, - COLOR_BLUE_OBJECT, COLOR_RED_OBJECT, - }; + COLOR_DARK_GRAY = new Color(0.3f, 0.3f, 0.3f, 1f), + COLOR_RED_HIGHLIGHT = new Color(246, 154, 161), + COLOR_BLUE_HIGHLIGHT = new Color(173, 216, 230); /** Game fonts. */ public static UnicodeFont @@ -161,16 +160,19 @@ public class Utils { FONT_MEDIUM = new UnicodeFont(font.deriveFont(fontBase * 3 / 2)); FONT_SMALL = new UnicodeFont(font.deriveFont(fontBase)); ColorEffect colorEffect = new ColorEffect(); - loadFont(FONT_DEFAULT, 2, colorEffect); - loadFont(FONT_BOLD, 2, colorEffect); - loadFont(FONT_XLARGE, 4, colorEffect); - loadFont(FONT_LARGE, 4, colorEffect); - loadFont(FONT_MEDIUM, 3, colorEffect); - loadFont(FONT_SMALL, 1, colorEffect); + loadFont(FONT_DEFAULT, colorEffect); + loadFont(FONT_BOLD, colorEffect); + loadFont(FONT_XLARGE, colorEffect); + loadFont(FONT_LARGE, colorEffect); + loadFont(FONT_MEDIUM, colorEffect); + loadFont(FONT_SMALL, colorEffect); } catch (Exception e) { ErrorHandler.error("Failed to load fonts.", e, true); } + // load skin + Options.loadSkin(); + // initialize game images for (GameImage img : GameImage.values()) { if (img.isPreload()) @@ -180,8 +182,11 @@ public class Utils { // initialize game mods GameMod.init(width, height); + // initialize playback buttons + PlaybackSpeed.init(width, height); + // initialize hit objects - OsuHitObject.init(width, height); + HitObject.init(width, height); // initialize download nodes DownloadNode.init(width, height); @@ -331,15 +336,11 @@ public class Utils { /** * Loads a Unicode font. * @param font the font to load - * @param padding the top and bottom padding * @param effect the font effect * @throws SlickException */ @SuppressWarnings("unchecked") - private static void loadFont(UnicodeFont font, int padding, - Effect effect) throws SlickException { - font.setPaddingTop(padding); - font.setPaddingBottom(padding); + private static void loadFont(UnicodeFont font, Effect effect) throws SlickException { font.addAsciiGlyphs(); font.getEffects().add(effect); font.loadGlyphs(); @@ -516,6 +517,7 @@ public class Utils { * Returns a the contents of a URL as a string. * @param url the remote URL * @return the contents as a string, or null if any error occurred + * @author Roland Illig (http://stackoverflow.com/a/4308662) */ public static String readDataFromUrl(URL url) throws IOException { // open connection @@ -547,6 +549,42 @@ public class Utils { } } + /** + * Returns a JSON object from a URL. + * @param url the remote URL + * @return the JSON object, or null if an error occurred + */ + public static JSONObject readJsonObjectFromUrl(URL url) throws IOException { + String s = Utils.readDataFromUrl(url); + JSONObject json = null; + if (s != null) { + try { + json = new JSONObject(s); + } catch (JSONException e) { + ErrorHandler.error("Failed to create JSON object.", e, true); + } + } + return json; + } + + /** + * Returns a JSON array from a URL. + * @param url the remote URL + * @return the JSON array, or null if an error occurred + */ + public static JSONArray readJsonArrayFromUrl(URL url) throws IOException { + String s = Utils.readDataFromUrl(url); + JSONArray json = null; + if (s != null) { + try { + json = new JSONArray(s); + } catch (JSONException e) { + ErrorHandler.error("Failed to create JSON array.", e, true); + } + } + return json; + } + /** * Converts an input stream to a string. * @param is the input stream @@ -601,4 +639,60 @@ public class Utils { else return String.format("%02d:%02d:%02d", seconds / 3600, (seconds / 60) % 60, seconds % 60); } + + /** + * Cubic ease out function. + * @param t the current time + * @param a the starting position + * @param b the finishing position + * @param d the duration + * @return the eased float + */ + public static float easeOut(float t, float a, float b, float d) { + return b * ((t = t / d - 1f) * t * t + 1f) + a; + } + + /** + * Fake bounce ease function. + * @param t the current time + * @param a the starting position + * @param b the finishing position + * @param d the duration + * @return the eased float + */ + public static float easeBounce(float t, float a, float b, float d) { + if (t < d / 2) + return easeOut(t, a, b, d); + return easeOut(d - t, a, b, d); + } + + /** + * Returns whether or not the application is running within a JAR. + * @return true if JAR, false if file + */ + public static boolean isJarRunning() { + return Opsu.class.getResource(String.format("%s.class", Opsu.class.getSimpleName())).toString().startsWith("jar:"); + } + + /** + * Returns the directory where the application is being run. + */ + public static File getRunningDirectory() { + try { + return new File(Opsu.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath()); + } catch (URISyntaxException e) { + Log.error("Could not get the running directory.", e); + return null; + } + } + + /** + * Parses the integer string argument as a boolean: + * {@code 1} is {@code true}, and all other values are {@code false}. + * @param s the {@code String} containing the boolean representation to be parsed + * @return the boolean represented by the string argument + */ + public static boolean parseBoolean(String s) { + return (Integer.parseInt(s) == 1); + } } diff --git a/src/itdelatrisu/opsu/audio/HitSound.java b/src/itdelatrisu/opsu/audio/HitSound.java index ab2457d5..590f3ddb 100644 --- a/src/itdelatrisu/opsu/audio/HitSound.java +++ b/src/itdelatrisu/opsu/audio/HitSound.java @@ -105,6 +105,12 @@ public enum HitSound implements SoundController.SoundComponent { return (currentSampleSet != null) ? clips.get(currentSampleSet) : null; } + /** + * Returns the Clip associated with the given sample set. + * @param s the sample set + */ + public MultiClip getClip(SampleSet s) { return clips.get(s); } + /** * Sets the hit sound Clip for the sample type. * @param s the sample set diff --git a/src/itdelatrisu/opsu/audio/MusicController.java b/src/itdelatrisu/opsu/audio/MusicController.java index 7c93c15d..695dbe28 100644 --- a/src/itdelatrisu/opsu/audio/MusicController.java +++ b/src/itdelatrisu/opsu/audio/MusicController.java @@ -20,8 +20,8 @@ package itdelatrisu.opsu.audio; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuFile; -import itdelatrisu.opsu.OsuParser; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapParser; import java.io.File; import java.io.IOException; @@ -50,8 +50,8 @@ public class MusicController { /** The current music track. */ private static Music player; - /** The last OsuFile passed to play(). */ - private static OsuFile lastOsu; + /** The last beatmap passed to play(). */ + private static Beatmap lastBeatmap; /** The track duration. */ private static int duration = 0; @@ -80,23 +80,23 @@ public class MusicController { /** * Plays an audio file at the preview position. * If the audio file is already playing, then nothing will happen. - * @param osu the OsuFile to play + * @param beatmap the beatmap to play * @param loop whether or not to loop the track * @param preview whether to start at the preview time (true) or beginning (false) */ - public static void play(final OsuFile osu, final boolean loop, final boolean preview) { + public static void play(final Beatmap beatmap, final boolean loop, final boolean preview) { // new track: load and play - if (lastOsu == null || !osu.audioFilename.equals(lastOsu.audioFilename)) { + if (lastBeatmap == null || !beatmap.audioFilename.equals(lastBeatmap.audioFilename)) { reset(); System.gc(); - switch (OsuParser.getExtension(osu.audioFilename.getName())) { + switch (BeatmapParser.getExtension(beatmap.audioFilename.getName())) { case "ogg": case "mp3": trackLoader = new Thread() { @Override public void run() { - loadTrack(osu.audioFilename, (preview) ? osu.previewTime : 0, loop); + loadTrack(beatmap.audioFilename, (preview) ? beatmap.previewTime : 0, loop); } }; trackLoader.start(); @@ -107,10 +107,10 @@ public class MusicController { } // new track position: play at position - else if (osu.previewTime != lastOsu.previewTime) - playAt(osu.previewTime, loop); + else if (beatmap.previewTime != lastBeatmap.previewTime) + playAt(beatmap.previewTime, loop); - lastOsu = osu; + lastBeatmap = beatmap; } /** @@ -170,9 +170,9 @@ public class MusicController { public static boolean trackExists() { return (player != null); } /** - * Returns the OsuFile associated with the current track. + * Returns the beatmap associated with the current track. */ - public static OsuFile getOsuFile() { return lastOsu; } + public static Beatmap getBeatmap() { return lastBeatmap; } /** * Returns true if the current track is playing. @@ -228,7 +228,7 @@ public class MusicController { } /** - * Returns the position in the current track, in ms. + * Returns the position in the current track, in milliseconds. * If no track is loaded, 0 will be returned. */ public static int getPosition() { @@ -242,6 +242,7 @@ public class MusicController { /** * Seeks to a position in the current track. + * @param position the new track position (in ms) */ public static boolean setPosition(int position) { return (trackExists() && position >= 0 && player.setPosition(position / 1000f)); @@ -251,17 +252,18 @@ public class MusicController { * Returns the duration of the current track, in milliseconds. * Currently only works for MP3s. * @return the duration, or -1 if no track exists, else the {@code endTime} - * field of the OsuFile loaded + * field of the beatmap loaded * @author Tom Brito (http://stackoverflow.com/a/3056161) */ public static int getDuration() { - if (!trackExists() || lastOsu == null) + if (!trackExists() || lastBeatmap == null) return -1; if (duration == 0) { - if (lastOsu.audioFilename.getName().endsWith(".mp3")) { + // TAudioFileFormat method only works for MP3s + if (lastBeatmap.audioFilename.getName().endsWith(".mp3")) { try { - AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(lastOsu.audioFilename); + AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(lastBeatmap.audioFilename); if (fileFormat instanceof TAudioFileFormat) { Map properties = ((TAudioFileFormat) fileFormat).properties(); Long microseconds = (Long) properties.get("duration"); @@ -270,7 +272,9 @@ public class MusicController { } } catch (UnsupportedAudioFileException | IOException e) {} } - duration = lastOsu.endTime; + + // fallback: use beatmap end time (often not the track duration) + duration = lastBeatmap.endTime; } return duration; } @@ -291,12 +295,20 @@ public class MusicController { /** * Sets the music volume. - * @param volume [0, 1] + * @param volume the new volume [0, 1] */ public static void setVolume(float volume) { SoundStore.get().setMusicVolume((isTrackDimmed()) ? volume * dimLevel : volume); } + /** + * Sets the music pitch (and speed). + * @param pitch the new pitch + */ + public static void setPitch(float pitch) { + SoundStore.get().setMusicPitch(pitch); + } + /** * Returns whether or not the current track has ended. */ @@ -308,16 +320,16 @@ public class MusicController { */ public static void loopTrackIfEnded(boolean preview) { if (trackEnded && trackExists()) - playAt((preview) ? lastOsu.previewTime : 0, false); + playAt((preview) ? lastBeatmap.previewTime : 0, false); } /** * Plays the theme song. */ public static void playThemeSong() { - OsuFile osu = Options.getOsuTheme(); - if (osu != null) { - play(osu, true, false); + Beatmap beatmap = Options.getThemeBeatmap(); + if (beatmap != null) { + play(beatmap, true, false); themePlaying = true; } } @@ -368,7 +380,7 @@ public class MusicController { trackLoader = null; // reset state - lastOsu = null; + lastBeatmap = null; duration = 0; trackEnded = false; themePlaying = false; diff --git a/src/itdelatrisu/opsu/audio/SoundController.java b/src/itdelatrisu/opsu/audio/SoundController.java index 96c5df26..b91db34e 100644 --- a/src/itdelatrisu/opsu/audio/SoundController.java +++ b/src/itdelatrisu/opsu/audio/SoundController.java @@ -20,8 +20,8 @@ package itdelatrisu.opsu.audio; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuHitObject; import itdelatrisu.opsu.audio.HitSound.SampleSet; +import itdelatrisu.opsu.beatmap.HitObject; import java.io.File; import java.io.IOException; @@ -164,7 +164,7 @@ public class SoundController { } if (bestIndex >= 0) return new MultiClip(ref, AudioSystem.getAudioInputStream(formats[bestIndex], audioIn)); - + // still couldn't find anything, try the default clip format return new MultiClip(ref, AudioSystem.getAudioInputStream(clip.getFormat(), audioIn)); } @@ -177,11 +177,14 @@ public class SoundController { */ private static String getSoundFileName(String filename) { String wav = String.format("%s.wav", filename), mp3 = String.format("%s.mp3", filename); - File skinWAV = new File(Options.getSkinDir(), wav), skinMP3 = new File(Options.getSkinDir(), mp3); - if (skinWAV.isFile()) - return skinWAV.getAbsolutePath(); - if (skinMP3.isFile()) - return skinMP3.getAbsolutePath(); + File skinDir = Options.getSkin().getDirectory(); + if (skinDir != null) { + File skinWAV = new File(skinDir, wav), skinMP3 = new File(skinDir, mp3); + if (skinWAV.isFile()) + return skinWAV.getAbsolutePath(); + if (skinMP3.isFile()) + return skinMP3.getAbsolutePath(); + } if (ResourceLoader.resourceExists(wav)) return wav; if (ResourceLoader.resourceExists(mp3)) @@ -204,7 +207,14 @@ public class SoundController { ErrorHandler.error(String.format("Could not find sound file '%s'.", s.getFileName()), null, false); continue; } - s.setClip(loadClip(currentFileName, currentFileName.endsWith(".mp3"))); + MultiClip newClip = loadClip(currentFileName, currentFileName.endsWith(".mp3")); + if (s.getClip() != null) { // clip previously loaded (e.g. program restart) + if (newClip != null) { + s.getClip().destroy(); // destroy previous clip + s.setClip(newClip); + } + } else + s.setClip(newClip); currentFileIndex++; } @@ -216,7 +226,14 @@ public class SoundController { ErrorHandler.error(String.format("Could not find hit sound file '%s'.", filename), null, false); continue; } - s.setClip(ss, loadClip(currentFileName, false)); + MultiClip newClip = loadClip(currentFileName, false); + if (s.getClip(ss) != null) { // clip previously loaded (e.g. program restart) + if (newClip != null) { + s.getClip(ss).destroy(); // destroy previous clip + s.setClip(ss, newClip); + } + } else + s.setClip(ss, newClip); currentFileIndex++; } } @@ -262,7 +279,7 @@ public class SoundController { } /** - * Plays hit sound(s) using an OsuHitObject bitmask. + * Plays hit sound(s) using a HitObject bitmask. * @param hitSound the hit sound (bitmask) * @param sampleSet the sample set * @param additionSampleSet the 'addition' sample set @@ -276,16 +293,20 @@ public class SoundController { return; // play all sounds - HitSound.setSampleSet(sampleSet); - playClip(HitSound.NORMAL.getClip(), volume, null); + if (hitSound == HitObject.SOUND_NORMAL || Options.getSkin().isLayeredHitSounds()) { + HitSound.setSampleSet(sampleSet); + playClip(HitSound.NORMAL.getClip(), volume, null); + } - HitSound.setSampleSet(additionSampleSet); - if ((hitSound & OsuHitObject.SOUND_WHISTLE) > 0) - playClip(HitSound.WHISTLE.getClip(), volume, null); - if ((hitSound & OsuHitObject.SOUND_FINISH) > 0) - playClip(HitSound.FINISH.getClip(), volume, null); - if ((hitSound & OsuHitObject.SOUND_CLAP) > 0) - playClip(HitSound.CLAP.getClip(), volume, null); + if (hitSound != HitObject.SOUND_NORMAL) { + HitSound.setSampleSet(additionSampleSet); + if ((hitSound & HitObject.SOUND_WHISTLE) > 0) + playClip(HitSound.WHISTLE.getClip(), volume, null); + if ((hitSound & HitObject.SOUND_FINISH) > 0) + playClip(HitSound.FINISH.getClip(), volume, null); + if ((hitSound & HitObject.SOUND_CLAP) > 0) + playClip(HitSound.CLAP.getClip(), volume, null); + } } /** diff --git a/src/itdelatrisu/opsu/beatmap/Beatmap.java b/src/itdelatrisu/opsu/beatmap/Beatmap.java new file mode 100644 index 00000000..ad8b5476 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/Beatmap.java @@ -0,0 +1,455 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.Options; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedList; + +import org.newdawn.slick.Color; +import org.newdawn.slick.Image; +import org.newdawn.slick.util.Log; + +/** + * Beatmap structure storing data parsed from OSU files. + */ +public class Beatmap implements Comparable { + /** Game modes. */ + public static final byte MODE_OSU = 0, MODE_TAIKO = 1, MODE_CTB = 2, MODE_MANIA = 3; + + /** Background image cache. */ + private static final BeatmapImageCache bgImageCache = new BeatmapImageCache(); + + /** + * Returns the background image cache. + */ + public static BeatmapImageCache getBackgroundImageCache() { return bgImageCache; } + + /** The OSU File object associated with this beatmap. */ + private File file; + + /** + * [General] + */ + + /** Audio file object. */ + public File audioFilename; + + /** Delay time before music starts (in ms). */ + public int audioLeadIn = 0; + + /** Audio hash (deprecated). */ +// public String audioHash = ""; + + /** Start position of music preview (in ms). */ + public int previewTime = -1; + + /** Countdown type (0:disabled, 1:normal, 2:half, 3:double). */ + public byte countdown = 0; + + /** Sound samples ("None", "Normal", "Soft"). */ + public String sampleSet = ""; + + /** How often closely placed hit objects will be stacked together. */ + public float stackLeniency = 0.7f; + + /** Game mode (MODE_* constants). */ + public byte mode = MODE_OSU; + + /** Whether the letterbox (top/bottom black bars) appears during breaks. */ + public boolean letterboxInBreaks = false; + + /** Whether the storyboard should be widescreen. */ + public boolean widescreenStoryboard = false; + + /** Whether to show an epilepsy warning. */ + public boolean epilepsyWarning = false; + + /** + * [Editor] + */ + + /** List of editor bookmarks (in ms). */ +// public int[] bookmarks; + + /** Multiplier for "Distance Snap". */ +// public float distanceSpacing = 0f; + + /** Beat division. */ +// public byte beatDivisor = 0; + + /** Size of grid for "Grid Snap". */ +// public int gridSize = 0; + + /** Zoom in the editor timeline. */ +// public int timelineZoom = 0; + + /** + * [Metadata] + */ + + /** Song title. */ + public String title = "", titleUnicode = ""; + + /** Song artist. */ + public String artist = "", artistUnicode = ""; + + /** Beatmap creator. */ + public String creator = ""; + + /** Beatmap difficulty. */ + public String version = ""; + + /** Song source. */ + public String source = ""; + + /** Song tags (for searching). */ + public String tags = ""; + + /** Beatmap ID. */ + public int beatmapID = 0; + + /** Beatmap set ID. */ + public int beatmapSetID = 0; + + /** + * [Difficulty] + */ + + /** HP: Health drain rate (0:easy ~ 10:hard) */ + public float HPDrainRate = 5f; + + /** CS: Size of circles and sliders (0:large ~ 10:small). */ + public float circleSize = 4f; + + /** OD: Affects timing window, spinners, and approach speed (0:easy ~ 10:hard). */ + public float overallDifficulty = 5f; + + /** AR: How long circles stay on the screen (0:long ~ 10:short). */ + public float approachRate = -1f; + + /** Slider movement speed multiplier. */ + public float sliderMultiplier = 1f; + + /** Rate at which slider ticks are placed (x per beat). */ + public float sliderTickRate = 1f; + + /** + * [Events] + */ + + /** Background image file. */ + public File bg; + + /** Background video file. */ +// public File video; + + /** All break periods (start time, end time, ...). */ + public ArrayList breaks; + + /** + * [TimingPoints] + */ + + /** All timing points. */ + public ArrayList timingPoints; + + /** Song BPM range. */ + public int bpmMin = 0, bpmMax = 0; + + /** + * [Colours] + */ + + /** Combo colors (max 8). If null, the skin value is used. */ + public Color[] combo; + + /** Slider border color. If null, the skin value is used. */ + public Color sliderBorder; + + /** + * [HitObjects] + */ + + /** All hit objects. */ + public HitObject[] objects; + + /** Number of individual objects. */ + public int + hitObjectCircle = 0, + hitObjectSlider = 0, + hitObjectSpinner = 0; + + /** Last object end time (in ms). */ + public int endTime = -1; + + /** + * Constructor. + * @param file the file associated with this beatmap + */ + public Beatmap(File file) { + this.file = file; + } + + /** + * Returns the associated file object. + * @return the File object + */ + public File getFile() { return file; } + + /** + * Returns the song title. + * If configured, the Unicode string will be returned instead. + * @return the song title + */ + public String getTitle() { + return (Options.useUnicodeMetadata() && !titleUnicode.isEmpty()) ? titleUnicode : title; + } + + /** + * Returns the song artist. + * If configured, the Unicode string will be returned instead. + * @return the song artist + */ + public String getArtist() { + return (Options.useUnicodeMetadata() && !artistUnicode.isEmpty()) ? artistUnicode : artist; + } + + /** + * Returns the list of combo colors (max 8). + * If the beatmap does not provide colors, the skin colors will be returned instead. + * @return the combo colors + */ + public Color[] getComboColors() { + return (combo != null) ? combo : Options.getSkin().getComboColors(); + } + + /** + * Returns the slider border color. + * If the beatmap does not provide a color, the skin color will be returned instead. + * @return the slider border color + */ + public Color getSliderBorderColor() { + return (sliderBorder != null) ? sliderBorder : Options.getSkin().getSliderBorderColor(); + } + + /** + * Draws the beatmap background. + * @param width the container width + * @param height the container height + * @param alpha the alpha value + * @param stretch if true, stretch to screen dimensions; otherwise, maintain aspect ratio + * @return true if successful, false if any errors were produced + */ + public boolean drawBG(int width, int height, float alpha, boolean stretch) { + if (bg == null) + return false; + try { + Image bgImage = bgImageCache.get(this); + if (bgImage == null) { + bgImage = new Image(bg.getAbsolutePath()); + bgImageCache.put(this, bgImage); + } + + int swidth = width; + int sheight = height; + if (!stretch) { + // fit image to screen + if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y + sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth()); + else + swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight()); + } else { + // fill screen while maintaining aspect ratio + if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y + swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight()); + else + sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth()); + } + bgImage = bgImage.getScaledCopy(swidth, sheight); + + bgImage.setAlpha(alpha); + bgImage.drawCentered(width / 2, height / 2); + } catch (Exception e) { + Log.warn(String.format("Failed to get background image '%s'.", bg), e); + bg = null; // don't try to load the file again until a restart + return false; + } + return true; + } + + /** + * Compares two Beatmap objects first by overall difficulty, then by total objects. + */ + @Override + public int compareTo(Beatmap that) { + int cmp = Float.compare(this.overallDifficulty, that.overallDifficulty); + if (cmp == 0) + cmp = Integer.compare( + this.hitObjectCircle + this.hitObjectSlider + this.hitObjectSpinner, + that.hitObjectCircle + that.hitObjectSlider + that.hitObjectSpinner + ); + return cmp; + } + + /** + * Returns a formatted string: "Artist - Title [Version]" + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return String.format("%s - %s [%s]", getArtist(), getTitle(), version); + } + + /** + * Returns the {@link #breaks} field formatted as a string, + * or null if the field is null. + */ + public String breaksToString() { + if (breaks == null) + return null; + + StringBuilder sb = new StringBuilder(); + for (int i : breaks) { + sb.append(i); + sb.append(','); + } + if (sb.length() > 0) + sb.setLength(sb.length() - 1); + return sb.toString(); + } + + /** + * Sets the {@link #breaks} field from a string. + * @param s the string + */ + public void breaksFromString(String s) { + if (s == null) + return; + + this.breaks = new ArrayList(); + String[] tokens = s.split(","); + for (int i = 0; i < tokens.length; i++) + breaks.add(Integer.parseInt(tokens[i])); + } + + /** + * Returns the {@link #timingPoints} field formatted as a string, + * or null if the field is null. + */ + public String timingPointsToString() { + if (timingPoints == null) + return null; + + StringBuilder sb = new StringBuilder(); + for (TimingPoint p : timingPoints) { + sb.append(p.toString()); + sb.append('|'); + } + if (sb.length() > 0) + sb.setLength(sb.length() - 1); + return sb.toString(); + } + + /** + * Sets the {@link #timingPoints} field from a string. + * @param s the string + */ + public void timingPointsFromString(String s) { + this.timingPoints = new ArrayList(); + if (s == null) + return; + + String[] tokens = s.split("\\|"); + for (int i = 0; i < tokens.length; i++) { + try { + timingPoints.add(new TimingPoint(tokens[i])); + } catch (Exception e) { + Log.warn(String.format("Failed to read timing point '%s'.", tokens[i]), e); + } + } + timingPoints.trimToSize(); + } + + /** + * Returns the {@link #combo} field formatted as a string, + * or null if the field is null or the default combo. + */ + public String comboToString() { + if (combo == null) + return null; + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < combo.length; i++) { + Color c = combo[i]; + sb.append(c.getRed()); + sb.append(','); + sb.append(c.getGreen()); + sb.append(','); + sb.append(c.getBlue()); + sb.append('|'); + } + if (sb.length() > 0) + sb.setLength(sb.length() - 1); + return sb.toString(); + } + + /** + * Sets the {@link #combo} field from a string. + * @param s the string + */ + public void comboFromString(String s) { + if (s == null) + return; + + LinkedList colors = new LinkedList(); + String[] tokens = s.split("\\|"); + for (int i = 0; i < tokens.length; i++) { + String[] rgb = tokens[i].split(","); + colors.add(new Color(Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2]))); + } + if (!colors.isEmpty()) + this.combo = colors.toArray(new Color[colors.size()]); + } + + /** + * Returns the {@link #sliderBorder} field formatted as a string, + * or null if the field is null. + */ + public String sliderBorderToString() { + if (sliderBorder == null) + return null; + + return String.format("%d,%d,%d", sliderBorder.getRed(), sliderBorder.getGreen(), sliderBorder.getBlue()); + } + + /** + * Sets the {@link #sliderBorder} field from a string. + * @param s the string + */ + public void sliderBorderFromString(String s) { + if (s == null) + return; + + String[] rgb = s.split(","); + this.sliderBorder = new Color(new Color(Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2]))); + } +} \ No newline at end of file diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapImageCache.java b/src/itdelatrisu/opsu/beatmap/BeatmapImageCache.java new file mode 100644 index 00000000..fdf502f9 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapImageCache.java @@ -0,0 +1,86 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.newdawn.slick.Image; +import org.newdawn.slick.SlickException; +import org.newdawn.slick.util.Log; + +/** + * LRU cache for beatmap background images. + */ +public class BeatmapImageCache { + /** Maximum number of cached images. */ + private static final int MAX_CACHE_SIZE = 10; + + /** Map of all loaded background images. */ + private LinkedHashMap cache; + + /** + * Constructor. + */ + @SuppressWarnings("serial") + public BeatmapImageCache() { + this.cache = new LinkedHashMap(MAX_CACHE_SIZE + 1, 1.1f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() > MAX_CACHE_SIZE) { + // destroy the eldest image + Image img = eldest.getValue(); + if (img != null && !img.isDestroyed()) { + try { + img.destroy(); + } catch (SlickException e) { + Log.warn(String.format("Failed to destroy image '%s'.", img.getResourceReference()), e); + } + } + return true; + } + return false; + } + }; + } + + /** + * Returns the image mapped to the specified beatmap. + * @param beatmap the Beatmap + * @return the Image, or {@code null} if no such mapping exists + */ + public Image get(Beatmap beatmap) { return cache.get(beatmap.bg); } + + /** + * Creates a mapping from the specified beatmap to the given image. + * @param beatmap the Beatmap + * @param image the Image + * @return the previously mapped Image, or {@code null} if no such mapping existed + */ + public Image put(Beatmap beatmap, Image image) { return cache.put(beatmap.bg, image); } + + /** + * Removes all entries from the cache. + *

+ * NOTE: This does NOT destroy the images in the cache, and will cause + * memory leaks if all images have not been destroyed. + */ + public void clear() { cache.clear(); } +} diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java new file mode 100644 index 00000000..369ea19e --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java @@ -0,0 +1,741 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.db.BeatmapDB; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.FilenameFilter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.newdawn.slick.Color; +import org.newdawn.slick.util.Log; + +/** + * Parser for beatmaps. + */ +public class BeatmapParser { + /** The string lookup database. */ + private static HashMap stringdb = new HashMap(); + + /** The expected pattern for beatmap directories, used to find beatmap set IDs. */ + private static final String DIR_MSID_PATTERN = "^\\d+ .*"; + + /** The current file being parsed. */ + private static File currentFile; + + /** The current directory number while parsing. */ + private static int currentDirectoryIndex = -1; + + /** The total number of directories to parse. */ + private static int totalDirectories = -1; + + /** Parser statuses. */ + public enum Status { NONE, PARSING, CACHE, INSERTING }; + + /** The current status. */ + private static Status status = Status.NONE; + + // This class should not be instantiated. + private BeatmapParser() {} + + /** + * Invokes parser for each OSU file in a root directory and + * adds the beatmaps to a new BeatmapSetList. + * @param root the root directory (search has depth 1) + */ + public static void parseAllFiles(File root) { + // create a new BeatmapSetList + BeatmapSetList.create(); + + // parse all directories + parseDirectories(root.listFiles()); + } + + /** + * Invokes parser for each directory in the given array and + * adds the beatmaps to the existing BeatmapSetList. + * @param dirs the array of directories to parse + * @return the last BeatmapSetNode parsed, or null if none + */ + public static BeatmapSetNode parseDirectories(File[] dirs) { + if (dirs == null) + return null; + + // progress tracking + status = Status.PARSING; + currentDirectoryIndex = 0; + totalDirectories = dirs.length; + + // get last modified map from database + Map map = BeatmapDB.getLastModifiedMap(); + + // beatmap lists + List> allBeatmaps = new LinkedList>(); + List cachedBeatmaps = new LinkedList(); // loaded from database + List parsedBeatmaps = new LinkedList(); // loaded from parser + + // parse directories + BeatmapSetNode lastNode = null; + for (File dir : dirs) { + currentDirectoryIndex++; + if (!dir.isDirectory()) + continue; + + // find all OSU files + File[] files = dir.listFiles(new FilenameFilter() { + @Override + public boolean accept(File dir, String name) { + return name.toLowerCase().endsWith(".osu"); + } + }); + if (files.length < 1) + continue; + + // create a new group entry + ArrayList beatmaps = new ArrayList(); + for (File file : files) { + currentFile = file; + + // check if beatmap is cached + String path = String.format("%s/%s", dir.getName(), file.getName()); + if (map.containsKey(path)) { + // check last modified times + long lastModified = map.get(path); + if (lastModified == file.lastModified()) { + // add to cached beatmap list + Beatmap beatmap = new Beatmap(file); + beatmaps.add(beatmap); + cachedBeatmaps.add(beatmap); + continue; + } else + BeatmapDB.delete(dir.getName(), file.getName()); + } + + // Parse hit objects only when needed to save time/memory. + // Change boolean to 'true' to parse them immediately. + Beatmap beatmap = parseFile(file, dir, beatmaps, false); + + // add to parsed beatmap list + if (beatmap != null) { + beatmaps.add(beatmap); + parsedBeatmaps.add(beatmap); + } + } + + // add group entry if non-empty + if (!beatmaps.isEmpty()) { + beatmaps.trimToSize(); + allBeatmaps.add(beatmaps); + } + + // stop parsing files (interrupted) + if (Thread.interrupted()) + break; + } + + // load cached entries from database + if (!cachedBeatmaps.isEmpty()) { + status = Status.CACHE; + + // Load array fields only when needed to save time/memory. + // Change flag to 'LOAD_ALL' to load them immediately. + BeatmapDB.load(cachedBeatmaps, BeatmapDB.LOAD_NONARRAY); + } + + // add group entries to BeatmapSetList + for (ArrayList beatmaps : allBeatmaps) { + Collections.sort(beatmaps); + lastNode = BeatmapSetList.get().addSongGroup(beatmaps); + } + + // clear string DB + stringdb = new HashMap(); + + // add beatmap entries to database + if (!parsedBeatmaps.isEmpty()) { + status = Status.INSERTING; + BeatmapDB.insert(parsedBeatmaps); + } + + status = Status.NONE; + currentFile = null; + currentDirectoryIndex = -1; + totalDirectories = -1; + return lastNode; + } + + /** + * Parses a beatmap. + * @param file the file to parse + * @param dir the directory containing the beatmap + * @param beatmaps the song group + * @param parseObjects if true, hit objects will be fully parsed now + * @return the new beatmap + */ + private static Beatmap parseFile(File file, File dir, ArrayList beatmaps, boolean parseObjects) { + Beatmap beatmap = new Beatmap(file); + beatmap.timingPoints = new ArrayList(); + + try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + String line = in.readLine(); + String tokens[] = null; + while (line != null) { + line = line.trim(); + if (!isValidLine(line)) { + line = in.readLine(); + continue; + } + switch (line) { + case "[General]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + switch (tokens[0]) { + case "AudioFilename": + File audioFileName = new File(dir, tokens[1]); + if (!beatmaps.isEmpty()) { + // if possible, reuse the same File object from another Beatmap in the group + File groupAudioFileName = beatmaps.get(0).audioFilename; + if (groupAudioFileName != null && + tokens[1].equalsIgnoreCase(groupAudioFileName.getName())) + audioFileName = groupAudioFileName; + } + if (!audioFileName.isFile()) { + // try to find the file with a case-insensitive match + boolean match = false; + for (String s : dir.list()) { + if (s.equalsIgnoreCase(tokens[1])) { + audioFileName = new File(dir, s); + match = true; + break; + } + } + if (!match) { + Log.error(String.format("Audio file '%s' not found in directory '%s'.", tokens[1], dir.getName())); + return null; + } + } + beatmap.audioFilename = audioFileName; + break; + case "AudioLeadIn": + beatmap.audioLeadIn = Integer.parseInt(tokens[1]); + break; +// case "AudioHash": // deprecated +// beatmap.audioHash = tokens[1]; +// break; + case "PreviewTime": + beatmap.previewTime = Integer.parseInt(tokens[1]); + break; + case "Countdown": + beatmap.countdown = Byte.parseByte(tokens[1]); + break; + case "SampleSet": + beatmap.sampleSet = getDBString(tokens[1]); + break; + case "StackLeniency": + beatmap.stackLeniency = Float.parseFloat(tokens[1]); + break; + case "Mode": + beatmap.mode = Byte.parseByte(tokens[1]); + + /* Non-Opsu! standard files not implemented (obviously). */ + if (beatmap.mode != Beatmap.MODE_OSU) + return null; + + break; + case "LetterboxInBreaks": + beatmap.letterboxInBreaks = Utils.parseBoolean(tokens[1]); + break; + case "WidescreenStoryboard": + beatmap.widescreenStoryboard = Utils.parseBoolean(tokens[1]); + break; + case "EpilepsyWarning": + beatmap.epilepsyWarning = Utils.parseBoolean(tokens[1]); + default: + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read line '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + } + break; + case "[Editor]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + /* Not implemented. */ +// if ((tokens = tokenize(line)) == null) +// continue; +// try { +// switch (tokens[0]) { +// case "Bookmarks": +// String[] bookmarks = tokens[1].split(","); +// beatmap.bookmarks = new int[bookmarks.length]; +// for (int i = 0; i < bookmarks.length; i++) +// osu.bookmarks[i] = Integer.parseInt(bookmarks[i]); +// break; +// case "DistanceSpacing": +// beatmap.distanceSpacing = Float.parseFloat(tokens[1]); +// break; +// case "BeatDivisor": +// beatmap.beatDivisor = Byte.parseByte(tokens[1]); +// break; +// case "GridSize": +// beatmap.gridSize = Integer.parseInt(tokens[1]); +// break; +// case "TimelineZoom": +// beatmap.timelineZoom = Integer.parseInt(tokens[1]); +// break; +// default: +// break; +// } +// } catch (Exception e) { +// Log.warn(String.format("Failed to read editor line '%s' for file '%s'.", +// line, file.getAbsolutePath()), e); +// } + } + break; + case "[Metadata]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + switch (tokens[0]) { + case "Title": + beatmap.title = getDBString(tokens[1]); + break; + case "TitleUnicode": + beatmap.titleUnicode = getDBString(tokens[1]); + break; + case "Artist": + beatmap.artist = getDBString(tokens[1]); + break; + case "ArtistUnicode": + beatmap.artistUnicode = getDBString(tokens[1]); + break; + case "Creator": + beatmap.creator = getDBString(tokens[1]); + break; + case "Version": + beatmap.version = getDBString(tokens[1]); + break; + case "Source": + beatmap.source = getDBString(tokens[1]); + break; + case "Tags": + beatmap.tags = getDBString(tokens[1].toLowerCase()); + break; + case "BeatmapID": + beatmap.beatmapID = Integer.parseInt(tokens[1]); + break; + case "BeatmapSetID": + beatmap.beatmapSetID = Integer.parseInt(tokens[1]); + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read metadata '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + if (beatmap.beatmapSetID <= 0) { // try to determine MSID from directory name + if (dir != null && dir.isDirectory()) { + String dirName = dir.getName(); + if (!dirName.isEmpty() && dirName.matches(DIR_MSID_PATTERN)) + beatmap.beatmapSetID = Integer.parseInt(dirName.substring(0, dirName.indexOf(' '))); + } + } + } + break; + case "[Difficulty]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + switch (tokens[0]) { + case "HPDrainRate": + beatmap.HPDrainRate = Float.parseFloat(tokens[1]); + break; + case "CircleSize": + beatmap.circleSize = Float.parseFloat(tokens[1]); + break; + case "OverallDifficulty": + beatmap.overallDifficulty = Float.parseFloat(tokens[1]); + break; + case "ApproachRate": + beatmap.approachRate = Float.parseFloat(tokens[1]); + break; + case "SliderMultiplier": + beatmap.sliderMultiplier = Float.parseFloat(tokens[1]); + break; + case "SliderTickRate": + beatmap.sliderTickRate = Float.parseFloat(tokens[1]); + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read difficulty '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + } + if (beatmap.approachRate == -1f) // not in old format + beatmap.approachRate = beatmap.overallDifficulty; + break; + case "[Events]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + tokens = line.split(","); + switch (tokens[0]) { + case "0": // background + tokens[2] = tokens[2].replaceAll("^\"|\"$", ""); + String ext = BeatmapParser.getExtension(tokens[2]); + if (ext.equals("jpg") || ext.equals("png")) + beatmap.bg = new File(dir, getDBString(tokens[2])); + break; + case "2": // break periods + try { + if (beatmap.breaks == null) // optional, create if needed + beatmap.breaks = new ArrayList(); + beatmap.breaks.add(Integer.parseInt(tokens[1])); + beatmap.breaks.add(Integer.parseInt(tokens[2])); + } catch (Exception e) { + Log.warn(String.format("Failed to read break period '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + break; + default: + /* Not implemented. */ + break; + } + } + if (beatmap.breaks != null) + beatmap.breaks.trimToSize(); + break; + case "[TimingPoints]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + 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); + } catch (Exception e) { + Log.warn(String.format("Failed to read timing point '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + } + beatmap.timingPoints.trimToSize(); + break; + case "[Colours]": + LinkedList colors = new LinkedList(); + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + String[] rgb = tokens[1].split(","); + Color color = new Color( + Integer.parseInt(rgb[0]), + Integer.parseInt(rgb[1]), + Integer.parseInt(rgb[2]) + ); + switch (tokens[0]) { + case "Combo1": + case "Combo2": + case "Combo3": + case "Combo4": + case "Combo5": + case "Combo6": + case "Combo7": + case "Combo8": + colors.add(color); + break; + case "SliderBorder": + beatmap.sliderBorder = color; + break; + default: + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read color '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + } + if (!colors.isEmpty()) + beatmap.combo = colors.toArray(new Color[colors.size()]); + break; + case "[HitObjects]": + int type = 0; + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + /* Only type counts parsed at this time. */ + tokens = line.split(","); + try { + type = Integer.parseInt(tokens[3]); + if ((type & HitObject.TYPE_CIRCLE) > 0) + beatmap.hitObjectCircle++; + else if ((type & HitObject.TYPE_SLIDER) > 0) + beatmap.hitObjectSlider++; + else //if ((type & HitObject.TYPE_SPINNER) > 0) + beatmap.hitObjectSpinner++; + } catch (Exception e) { + Log.warn(String.format("Failed to read hit object '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + } + + try { + // map length = last object end time (TODO: end on slider?) + if ((type & HitObject.TYPE_SPINNER) > 0) { + // some 'endTime' fields contain a ':' character (?) + int index = tokens[5].indexOf(':'); + if (index != -1) + tokens[5] = tokens[5].substring(0, index); + beatmap.endTime = Integer.parseInt(tokens[5]); + } else if (type != 0) + beatmap.endTime = Integer.parseInt(tokens[2]); + } catch (Exception e) { + Log.warn(String.format("Failed to read hit object end time '%s' for file '%s'.", + line, file.getAbsolutePath()), e); + } + break; + default: + line = in.readLine(); + break; + } + } + } catch (IOException e) { + ErrorHandler.error(String.format("Failed to read file '%s'.", file.getAbsolutePath()), e, false); + } + + // no associated audio file? + if (beatmap.audioFilename == null) + return null; + + // parse hit objects now? + if (parseObjects) + parseHitObjects(beatmap); + + return beatmap; + } + + /** + * Parses all hit objects in a beatmap. + * @param beatmap the beatmap to parse + */ + public static void parseHitObjects(Beatmap beatmap) { + if (beatmap.objects != null) // already parsed + return; + + beatmap.objects = new HitObject[(beatmap.hitObjectCircle + beatmap.hitObjectSlider + beatmap.hitObjectSpinner)]; + + try (BufferedReader in = new BufferedReader(new FileReader(beatmap.getFile()))) { + String line = in.readLine(); + while (line != null) { + line = line.trim(); + if (!line.equals("[HitObjects]")) + line = in.readLine(); + else + break; + } + if (line == null) { + Log.warn(String.format("No hit objects found in Beatmap '%s'.", beatmap.toString())); + return; + } + + // combo info + Color[] combo = beatmap.getComboColors(); + int comboIndex = 0; // color index + int comboNumber = 1; // combo number + + int objectIndex = 0; + boolean first = true; + while ((line = in.readLine()) != null && objectIndex < beatmap.objects.length) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + + // lines must have at minimum 5 parameters + int tokenCount = line.length() - line.replace(",", "").length(); + if (tokenCount < 4) + continue; + + try { + // create a new HitObject for each line + HitObject hitObject = new HitObject(line); + + // set combo info + // - new combo: get next combo index, reset combo number + // - else: maintain combo index, increase combo number + if (hitObject.isNewCombo() || first) { + int skip = (hitObject.isSpinner() ? 0 : 1) + hitObject.getComboSkip(); + for (int i = 0; i < skip; i++) { + comboIndex = (comboIndex + 1) % combo.length; + comboNumber = 1; + } + first = false; + } + + hitObject.setComboIndex(comboIndex); + hitObject.setComboNumber(comboNumber++); + + beatmap.objects[objectIndex++] = hitObject; + } catch (Exception e) { + Log.warn(String.format("Failed to read hit object '%s' for Beatmap '%s'.", + line, beatmap.toString()), e); + } + } + } catch (IOException e) { + ErrorHandler.error(String.format("Failed to read file '%s'.", beatmap.getFile().getAbsolutePath()), e, false); + } + } + + /** + * Returns false if the line is too short or commented. + */ + private static boolean isValidLine(String line) { + return (line.length() > 1 && !line.startsWith("//")); + } + + /** + * Splits line into two strings: tag, value. + * If no ':' character is present, null will be returned. + */ + private static String[] tokenize(String line) { + int index = line.indexOf(':'); + if (index == -1) { + Log.debug(String.format("Failed to tokenize line: '%s'.", line)); + return null; + } + + String[] tokens = new String[2]; + tokens[0] = line.substring(0, index).trim(); + tokens[1] = line.substring(index + 1).trim(); + return tokens; + } + + /** + * Returns the file extension of a file. + */ + public static String getExtension(String file) { + int i = file.lastIndexOf('.'); + return (i != -1) ? file.substring(i + 1).toLowerCase() : ""; + } + + /** + * Returns the name of the current file being parsed, or null if none. + */ + public static String getCurrentFileName() { + if (status == Status.PARSING) + return (currentFile != null) ? currentFile.getName() : null; + else + return (status == Status.NONE) ? null : ""; + } + + /** + * Returns the progress of file parsing, or -1 if not parsing. + * @return the completion percent [0, 100] or -1 + */ + public static int getParserProgress() { + if (currentDirectoryIndex == -1 || totalDirectories == -1) + return -1; + + return currentDirectoryIndex * 100 / totalDirectories; + } + + /** + * Returns the current parser status. + */ + public static Status getStatus() { return status; } + + /** + * Returns the String object in the database for the given String. + * If none, insert the String into the database and return the original String. + * @param s the string to retrieve + * @return the string object + */ + public static String getDBString(String s) { + String DBString = stringdb.get(s); + if (DBString == null) { + stringdb.put(s, s); + return s; + } else + return DBString; + } +} \ No newline at end of file diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSet.java b/src/itdelatrisu/opsu/beatmap/BeatmapSet.java new file mode 100644 index 00000000..8ba0693b --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSet.java @@ -0,0 +1,178 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.GameMod; + +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/** + * Data type containing all beatmaps in a beatmap set. + */ +public class BeatmapSet { + /** List of associated beatmaps. */ + private ArrayList beatmaps; + + /** + * Constructor. + * @param beatmaps the beatmaps in this set + */ + public BeatmapSet(ArrayList beatmaps) { + this.beatmaps = beatmaps; + } + + /** + * Returns the number of elements. + */ + public int size() { return beatmaps.size(); } + + /** + * Returns the beatmap at the given index. + * @param index the beatmap index + * @throws IndexOutOfBoundsException + */ + public Beatmap get(int index) { return beatmaps.get(index); } + + /** + * Removes the beatmap at the given index. + * @param index the beatmap index + * @return the removed beatmap + * @throws IndexOutOfBoundsException + */ + public Beatmap remove(int index) { return beatmaps.remove(index); } + + /** + * Returns an array of strings containing beatmap information. + *

    + *
  • 0: {Artist} - {Title} [{Version}] + *
  • 1: Mapped by {Creator} + *
  • 2: Length: {} BPM: {} Objects: {} + *
  • 3: Circles: {} Sliders: {} Spinners: {} + *
  • 4: CS:{} HP:{} AR:{} OD:{} + *
+ * @param index the beatmap index + * @throws IndexOutOfBoundsException + */ + public String[] getInfo(int index) { + Beatmap beatmap = beatmaps.get(index); + float speedModifier = GameMod.getSpeedMultiplier(); + long endTime = (long) (beatmap.endTime / speedModifier); + int bpmMin = (int) (beatmap.bpmMin * speedModifier); + int bpmMax = (int) (beatmap.bpmMax * speedModifier); + float multiplier = GameMod.getDifficultyMultiplier(); + String[] info = new String[5]; + info[0] = beatmap.toString(); + info[1] = String.format("Mapped by %s", beatmap.creator); + info[2] = String.format("Length: %d:%02d BPM: %s Objects: %d", + TimeUnit.MILLISECONDS.toMinutes(endTime), + TimeUnit.MILLISECONDS.toSeconds(endTime) - + TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(endTime)), + (bpmMax <= 0) ? "--" : ((bpmMin == bpmMax) ? bpmMin : String.format("%d-%d", bpmMin, bpmMax)), + (beatmap.hitObjectCircle + beatmap.hitObjectSlider + beatmap.hitObjectSpinner)); + info[3] = String.format("Circles: %d Sliders: %d Spinners: %d", + beatmap.hitObjectCircle, beatmap.hitObjectSlider, beatmap.hitObjectSpinner); + info[4] = String.format("CS:%.1f HP:%.1f AR:%.1f OD:%.1f", + Math.min(beatmap.circleSize * multiplier, 10f), + Math.min(beatmap.HPDrainRate * multiplier, 10f), + Math.min(beatmap.approachRate * multiplier, 10f), + Math.min(beatmap.overallDifficulty * multiplier, 10f)); + return info; + } + + /** + * Returns a formatted string for the beatmap set: + * "Artist - Title" + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + Beatmap beatmap = beatmaps.get(0); + return String.format("%s - %s", beatmap.getArtist(), beatmap.getTitle()); + } + + /** + * Checks whether the beatmap set matches a given search query. + * @param query the search term + * @return true if title, artist, creator, source, version, or tag matches query + */ + public boolean matches(String query) { + // search: title, artist, creator, source, version, tags (first beatmap) + Beatmap beatmap = beatmaps.get(0); + if (beatmap.title.toLowerCase().contains(query) || + beatmap.titleUnicode.toLowerCase().contains(query) || + beatmap.artist.toLowerCase().contains(query) || + beatmap.artistUnicode.toLowerCase().contains(query) || + beatmap.creator.toLowerCase().contains(query) || + beatmap.source.toLowerCase().contains(query) || + beatmap.version.toLowerCase().contains(query) || + beatmap.tags.contains(query)) + return true; + + // search: version, tags (remaining beatmaps) + for (int i = 1; i < beatmaps.size(); i++) { + beatmap = beatmaps.get(i); + if (beatmap.version.toLowerCase().contains(query) || + beatmap.tags.contains(query)) + return true; + } + + return false; + } + + /** + * Checks whether the beatmap set matches a given condition. + * @param type the condition type (ar, cs, od, hp, bpm, length) + * @param operator the operator {@literal (=/==, >, >=, <, <=)} + * @param value the value + * @return true if the condition is met + */ + public boolean matches(String type, String operator, float value) { + for (Beatmap beatmap : beatmaps) { + // get value + float v; + switch (type) { + case "ar": v = beatmap.approachRate; break; + case "cs": v = beatmap.circleSize; break; + case "od": v = beatmap.overallDifficulty; break; + case "hp": v = beatmap.HPDrainRate; break; + case "bpm": v = beatmap.bpmMax; break; + case "length": v = beatmap.endTime / 1000; break; + default: return false; + } + + // get operator + boolean met; + switch (operator) { + case "=": + case "==": met = (v == value); break; + case ">": met = (v > value); break; + case ">=": met = (v >= value); break; + case "<": met = (v < value); break; + case "<=": met = (v <= value); break; + default: return false; + } + + if (met) + return true; + } + + return false; + } +} diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java b/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java new file mode 100644 index 00000000..7c5aab81 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java @@ -0,0 +1,504 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.audio.MusicController; +import itdelatrisu.opsu.db.BeatmapDB; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Indexed, expanding, doubly-linked list data type for song groups. + */ +public class BeatmapSetList { + /** Song group structure (each group contains a list of beatmaps). */ + private static BeatmapSetList list; + + /** Search pattern for conditional expressions. */ + private static final Pattern SEARCH_CONDITION_PATTERN = Pattern.compile( + "(ar|cs|od|hp|bpm|length)(=|==|>|>=|<|<=)((\\d*\\.)?\\d+)" + ); + + /** List containing all parsed nodes. */ + private ArrayList parsedNodes; + + /** Total number of beatmaps (i.e. Beatmap objects). */ + private int mapCount = 0; + + /** Current list of nodes (subset of parsedNodes, used for searches). */ + private ArrayList nodes; + + /** Set of all beatmap set IDs for the parsed beatmaps. */ + private HashSet MSIDdb; + + /** Index of current expanded node (-1 if no node is expanded). */ + private int expandedIndex; + + /** Start and end nodes of expanded group. */ + private BeatmapSetNode expandedStartNode, expandedEndNode; + + /** The last search query. */ + private String lastQuery; + + /** + * Creates a new instance of this class (overwriting any previous instance). + */ + public static void create() { list = new BeatmapSetList(); } + + /** + * Returns the single instance of this class. + */ + public static BeatmapSetList get() { return list; } + + /** + * Constructor. + */ + private BeatmapSetList() { + parsedNodes = new ArrayList(); + MSIDdb = new HashSet(); + reset(); + } + + /** + * Resets the list's fields. + * This does not erase any parsed nodes. + */ + public void reset() { + nodes = parsedNodes; + expandedIndex = -1; + expandedStartNode = expandedEndNode = null; + lastQuery = ""; + } + + /** + * Returns the number of elements. + */ + public int size() { return nodes.size(); } + + /** + * Adds a song group. + * @param beatmaps the list of beatmaps in the group + * @return the new BeatmapSetNode + */ + public BeatmapSetNode addSongGroup(ArrayList beatmaps) { + BeatmapSet beatmapSet = new BeatmapSet(beatmaps); + BeatmapSetNode node = new BeatmapSetNode(beatmapSet); + parsedNodes.add(node); + mapCount += beatmaps.size(); + + // add beatmap set ID to set + int msid = beatmaps.get(0).beatmapSetID; + if (msid > 0) + MSIDdb.add(msid); + + return node; + } + + /** + * Deletes a song group from the list, and also deletes the beatmap + * directory associated with the node. + * @param node the node containing the song group to delete + * @return true if the song group was deleted, false otherwise + */ + public boolean deleteSongGroup(BeatmapSetNode node) { + if (node == null) + return false; + + // re-link base nodes + int index = node.index; + BeatmapSetNode ePrev = getBaseNode(index - 1), eCur = getBaseNode(index), eNext = getBaseNode(index + 1); + if (ePrev != null) { + if (ePrev.index == expandedIndex) + expandedEndNode.next = eNext; + else if (eNext != null && eNext.index == expandedIndex) + ePrev.next = expandedStartNode; + else + ePrev.next = eNext; + } + if (eNext != null) { + if (eNext.index == expandedIndex) + expandedStartNode.prev = ePrev; + else if (ePrev != null && ePrev.index == expandedIndex) + eNext.prev = expandedEndNode; + else + eNext.prev = ePrev; + } + + // remove all node references + Beatmap beatmap = node.getBeatmapSet().get(0); + nodes.remove(index); + parsedNodes.remove(eCur); + mapCount -= node.getBeatmapSet().size(); + if (beatmap.beatmapSetID > 0) + MSIDdb.remove(beatmap.beatmapSetID); + + // reset indices + for (int i = index, size = size(); i < size; i++) + nodes.get(i).index = i; + if (index == expandedIndex) { + expandedIndex = -1; + expandedStartNode = expandedEndNode = null; + } else if (expandedIndex > index) { + expandedIndex--; + BeatmapSetNode expandedNode = expandedStartNode; + for (int i = 0, size = expandedNode.getBeatmapSet().size(); + i < size && expandedNode != null; + i++, expandedNode = expandedNode.next) + expandedNode.index = expandedIndex; + } + + // stop playing the track + File dir = beatmap.getFile().getParentFile(); + if (MusicController.trackExists() || MusicController.isTrackLoading()) { + File audioFile = MusicController.getBeatmap().audioFilename; + if (audioFile != null && audioFile.equals(beatmap.audioFilename)) { + MusicController.reset(); + System.gc(); // TODO: why can't files be deleted without calling this? + } + } + + // remove entry from cache + BeatmapDB.delete(dir.getName()); + + // delete the associated directory + try { + Utils.deleteToTrash(dir); + } catch (IOException e) { + ErrorHandler.error("Could not delete song group.", e, true); + } + + return true; + } + + /** + * Deletes a song from a song group, and also deletes the beatmap file. + * If this causes the song group to be empty, then the song group and + * beatmap directory will be deleted altogether. + * @param node the node containing the song group to delete (expanded only) + * @return true if the song or song group was deleted, false otherwise + * @see #deleteSongGroup(BeatmapSetNode) + */ + public boolean deleteSong(BeatmapSetNode node) { + if (node == null || node.beatmapIndex == -1 || node.index != expandedIndex) + return false; + + // last song in group? + int size = node.getBeatmapSet().size(); + if (size == 1) + return deleteSongGroup(node); + + // reset indices + BeatmapSetNode expandedNode = node.next; + for (int i = node.beatmapIndex + 1; + i < size && expandedNode != null && expandedNode.index == node.index; + i++, expandedNode = expandedNode.next) + expandedNode.beatmapIndex--; + + // remove song reference + Beatmap beatmap = node.getBeatmapSet().remove(node.beatmapIndex); + mapCount--; + + // re-link nodes + if (node.prev != null) + node.prev.next = node.next; + if (node.next != null) + node.next.prev = node.prev; + + // remove entry from cache + File file = beatmap.getFile(); + BeatmapDB.delete(file.getParentFile().getName(), file.getName()); + + // delete the associated file + try { + Utils.deleteToTrash(file); + } catch (IOException e) { + ErrorHandler.error("Could not delete song.", e, true); + } + + return true; + } + + /** + * Returns the total number of parsed maps (i.e. Beatmap objects). + */ + public int getMapCount() { return mapCount; } + + /** + * Returns the total number of parsed maps sets. + */ + public int getMapSetCount() { return parsedNodes.size(); } + + /** + * Returns the BeatmapSetNode at an index, disregarding expansions. + * @param index the node index + */ + public BeatmapSetNode getBaseNode(int index) { + if (index < 0 || index >= size()) + return null; + + return nodes.get(index); + } + + /** + * Returns a random base node. + */ + public BeatmapSetNode getRandomNode() { + BeatmapSetNode node = getBaseNode((int) (Math.random() * size())); + if (node != null && node.index == expandedIndex) // don't choose an expanded group node + node = node.next; + return node; + } + + /** + * Returns the BeatmapSetNode a given number of positions forward or backwards. + * @param node the starting node + * @param shift the number of nodes to shift forward (+) or backward (-). + */ + public BeatmapSetNode getNode(BeatmapSetNode node, int shift) { + BeatmapSetNode startNode = node; + if (shift > 0) { + for (int i = 0; i < shift && startNode != null; i++) + startNode = startNode.next; + } else { + for (int i = 0; i < shift && startNode != null; i++) + startNode = startNode.prev; + } + return startNode; + } + + /** + * Returns the index of the expanded node (or -1 if nothing is expanded). + */ + public int getExpandedIndex() { return expandedIndex; } + + /** + * Expands the node at an index by inserting a new node for each Beatmap + * in that node and hiding the group node. + * @return the first of the newly-inserted nodes + */ + public BeatmapSetNode expand(int index) { + // undo the previous expansion + unexpand(); + + BeatmapSetNode node = getBaseNode(index); + if (node == null) + return null; + + expandedStartNode = expandedEndNode = null; + + // create new nodes + BeatmapSet beatmapSet = node.getBeatmapSet(); + BeatmapSetNode prevNode = node.prev; + BeatmapSetNode nextNode = node.next; + for (int i = 0, size = beatmapSet.size(); i < size; i++) { + BeatmapSetNode newNode = new BeatmapSetNode(beatmapSet); + newNode.index = index; + newNode.beatmapIndex = i; + newNode.prev = node; + + // unlink the group node + if (i == 0) { + expandedStartNode = newNode; + newNode.prev = prevNode; + if (prevNode != null) + prevNode.next = newNode; + } + + node.next = newNode; + node = node.next; + } + if (nextNode != null) { + node.next = nextNode; + nextNode.prev = node; + } + expandedEndNode = node; + + expandedIndex = index; + return expandedStartNode; + } + + /** + * Undoes the current expansion, if any. + */ + private void unexpand() { + if (expandedIndex < 0 || expandedIndex >= size()) + return; + + // recreate surrounding links + BeatmapSetNode + ePrev = getBaseNode(expandedIndex - 1), + eCur = getBaseNode(expandedIndex), + eNext = getBaseNode(expandedIndex + 1); + if (ePrev != null) + ePrev.next = eCur; + eCur.prev = ePrev; + eCur.index = expandedIndex; + eCur.next = eNext; + if (eNext != null) + eNext.prev = eCur; + + expandedIndex = -1; + expandedStartNode = expandedEndNode = null; + return; + } + + /** + * Initializes the links in the list. + */ + public void init() { + if (size() < 1) + return; + + // sort the list + Collections.sort(nodes, BeatmapSortOrder.getSort().getComparator()); + expandedIndex = -1; + expandedStartNode = expandedEndNode = null; + + // create links + BeatmapSetNode lastNode = nodes.get(0); + lastNode.index = 0; + lastNode.prev = null; + for (int i = 1, size = size(); i < size; i++) { + BeatmapSetNode node = nodes.get(i); + lastNode.next = node; + node.index = i; + node.prev = lastNode; + + lastNode = node; + } + lastNode.next = null; + } + + /** + * Creates a new list of song groups in which each group contains a match to a search query. + * @param query the search query (terms separated by spaces) + * @return false if query is the same as the previous one, true otherwise + */ + public boolean search(String query) { + if (query == null) + return false; + + // don't redo the same search + query = query.trim().toLowerCase(); + if (lastQuery != null && query.equals(lastQuery)) + return false; + lastQuery = query; + LinkedList terms = new LinkedList(Arrays.asList(query.split("\\s+"))); + + // if empty query, reset to original list + if (query.isEmpty() || terms.isEmpty()) { + nodes = parsedNodes; + return true; + } + + // find and remove any conditional search terms + LinkedList condType = new LinkedList(); + LinkedList condOperator = new LinkedList(); + LinkedList condValue = new LinkedList(); + + Iterator termIter = terms.iterator(); + while (termIter.hasNext()) { + String term = termIter.next(); + Matcher m = SEARCH_CONDITION_PATTERN.matcher(term); + if (m.find()) { + condType.add(m.group(1)); + condOperator.add(m.group(2)); + condValue.add(Float.parseFloat(m.group(3))); + termIter.remove(); + } + } + + // build an initial list from first search term + nodes = new ArrayList(); + if (terms.isEmpty()) { + // conditional term + String type = condType.remove(); + String operator = condOperator.remove(); + float value = condValue.remove(); + for (BeatmapSetNode node : parsedNodes) { + if (node.getBeatmapSet().matches(type, operator, value)) + nodes.add(node); + } + } else { + // normal term + String term = terms.remove(); + for (BeatmapSetNode node : parsedNodes) { + if (node.getBeatmapSet().matches(term)) + nodes.add(node); + } + } + + // iterate through remaining normal search terms + while (!terms.isEmpty()) { + if (nodes.isEmpty()) + return true; + + String term = terms.remove(); + + // remove nodes from list if they don't match all terms + Iterator nodeIter = nodes.iterator(); + while (nodeIter.hasNext()) { + BeatmapSetNode node = nodeIter.next(); + if (!node.getBeatmapSet().matches(term)) + nodeIter.remove(); + } + } + + // iterate through remaining conditional terms + while (!condType.isEmpty()) { + if (nodes.isEmpty()) + return true; + + String type = condType.remove(); + String operator = condOperator.remove(); + float value = condValue.remove(); + + // remove nodes from list if they don't match all terms + Iterator nodeIter = nodes.iterator(); + while (nodeIter.hasNext()) { + BeatmapSetNode node = nodeIter.next(); + if (!node.getBeatmapSet().matches(type, operator, value)) + nodeIter.remove(); + } + } + + return true; + } + + /** + * Returns whether or not the list contains the given beatmap set ID. + *

+ * Note that IDs for older maps might have been improperly parsed, so + * there is no guarantee that this method will return an accurate value. + * @param id the beatmap set ID to check + * @return true if id is in the list + */ + public boolean containsBeatmapSetID(int id) { return MSIDdb.contains(id); } +} \ No newline at end of file diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java b/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java new file mode 100644 index 00000000..6324d1c4 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java @@ -0,0 +1,131 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.GameData.Grade; +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Options; +import itdelatrisu.opsu.Utils; + +import org.newdawn.slick.Color; +import org.newdawn.slick.Image; + +/** + * Node in an BeatmapSetList representing a beatmap set. + */ +public class BeatmapSetNode { + /** The associated beatmap set. */ + private BeatmapSet beatmapSet; + + /** Index of the selected beatmap (-1 if not focused). */ + public int beatmapIndex = -1; + + /** Index of this node. */ + public int index = 0; + + /** Links to other nodes. */ + public BeatmapSetNode prev, next; + + /** + * Constructor. + * @param beatmapSet the beatmap set + */ + public BeatmapSetNode(BeatmapSet beatmapSet) { + this.beatmapSet = beatmapSet; + } + + /** + * Returns the associated beatmap set. + * @return the beatmap set + */ + public BeatmapSet getBeatmapSet() { return beatmapSet; } + + /** + * Draws the button. + * @param x the x coordinate + * @param y the y coordinate + * @param grade the highest grade, if any + * @param focus true if this is the focused node + */ + public void draw(float x, float y, Grade grade, boolean focus) { + Image bg = GameImage.MENU_BUTTON_BG.getImage(); + boolean expanded = (beatmapIndex > -1); + Beatmap beatmap; + bg.setAlpha(0.9f); + Color bgColor; + Color textColor = Options.getSkin().getSongSelectInactiveTextColor(); + + // get drawing parameters + if (expanded) { + x -= bg.getWidth() / 10f; + if (focus) { + bgColor = Color.white; + textColor = Options.getSkin().getSongSelectActiveTextColor(); + } else + bgColor = Utils.COLOR_BLUE_BUTTON; + beatmap = beatmapSet.get(beatmapIndex); + } else { + bgColor = Utils.COLOR_ORANGE_BUTTON; + beatmap = beatmapSet.get(0); + } + bg.draw(x, y, bgColor); + + float cx = x + (bg.getWidth() * 0.043f); + float cy = y + (bg.getHeight() * 0.2f) - 3; + + // draw grade + if (grade != Grade.NULL) { + Image gradeImg = grade.getMenuImage(); + gradeImg.drawCentered(cx - bg.getWidth() * 0.01f + gradeImg.getWidth() / 2f, y + bg.getHeight() / 2.2f); + cx += gradeImg.getWidth(); + } + + // draw text + if (Options.useUnicodeMetadata()) { // load glyphs + Utils.loadGlyphs(Utils.FONT_MEDIUM, beatmap.titleUnicode, null); + Utils.loadGlyphs(Utils.FONT_DEFAULT, null, beatmap.artistUnicode); + } + Utils.FONT_MEDIUM.drawString(cx, cy, beatmap.getTitle(), textColor); + Utils.FONT_DEFAULT.drawString(cx, cy + Utils.FONT_MEDIUM.getLineHeight() - 2, + String.format("%s // %s", beatmap.getArtist(), beatmap.creator), textColor); + if (expanded || beatmapSet.size() == 1) + Utils.FONT_BOLD.drawString(cx, cy + Utils.FONT_MEDIUM.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() - 4, + beatmap.version, textColor); + } + + /** + * Returns an array of strings containing beatmap information for the + * selected beatmap, or null if none selected. + * @see BeatmapSet#getInfo(int) + */ + public String[] getInfo() { return (beatmapIndex < 0) ? null : beatmapSet.getInfo(beatmapIndex); } + + /** + * Returns a formatted string for the beatmap at {@code beatmapIndex}: + * "Artist - Title [Version]" (version omitted if {@code beatmapIndex} is invalid) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + if (beatmapIndex == -1) + return beatmapSet.toString(); + else + return beatmapSet.get(beatmapIndex).toString(); + } +} \ No newline at end of file diff --git a/src/itdelatrisu/opsu/SongSort.java b/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java similarity index 59% rename from src/itdelatrisu/opsu/SongSort.java rename to src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java index db0c69a2..a9c3c545 100644 --- a/src/itdelatrisu/opsu/SongSort.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java @@ -16,7 +16,11 @@ * along with opsu!. If not, see . */ -package itdelatrisu.opsu; +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; @@ -25,9 +29,9 @@ import java.util.Comparator; import org.newdawn.slick.Image; /** - * OsuGroupNode sorts. + * Beatmap sorting orders. */ -public enum SongSort { +public enum BeatmapSortOrder { TITLE (0, "Title", new TitleOrder()), ARTIST (1, "Artist", new ArtistOrder()), CREATOR (2, "Creator", new CreatorOrder()), @@ -41,7 +45,7 @@ public enum SongSort { private String name; /** The comparator for the sort. */ - private Comparator comparator; + private Comparator comparator; /** The tab associated with the sort (displayed in Song Menu screen). */ private MenuButton tab; @@ -49,83 +53,85 @@ public enum SongSort { /** Total number of sorts. */ private static final int SIZE = values().length; - /** Array of SongSort objects in reverse order. */ - public static final SongSort[] VALUES_REVERSED; + /** 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 SongSort currentSort = TITLE; + private static BeatmapSortOrder currentSort = TITLE; /** * Returns the current sort. * @return the current sort */ - public static SongSort getSort() { return currentSort; } + public static BeatmapSortOrder getSort() { return currentSort; } /** * Sets a new sort. * @param sort the new sort */ - public static void setSort(SongSort sort) { SongSort.currentSort = sort; } + public static void setSort(BeatmapSortOrder sort) { BeatmapSortOrder.currentSort = sort; } /** - * Compares two OsuGroupNode objects by title. + * Compares two BeatmapSetNode objects by title. */ - private static class TitleOrder implements Comparator { + private static class TitleOrder implements Comparator { @Override - public int compare(OsuGroupNode v, OsuGroupNode w) { - return v.osuFiles.get(0).title.compareToIgnoreCase(w.osuFiles.get(0).title); + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + return v.getBeatmapSet().get(0).title.compareToIgnoreCase(w.getBeatmapSet().get(0).title); } } /** - * Compares two OsuGroupNode objects by artist. + * Compares two BeatmapSetNode objects by artist. */ - private static class ArtistOrder implements Comparator { + private static class ArtistOrder implements Comparator { @Override - public int compare(OsuGroupNode v, OsuGroupNode w) { - return v.osuFiles.get(0).artist.compareToIgnoreCase(w.osuFiles.get(0).artist); + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + return v.getBeatmapSet().get(0).artist.compareToIgnoreCase(w.getBeatmapSet().get(0).artist); } } /** - * Compares two OsuGroupNode objects by creator. + * Compares two BeatmapSetNode objects by creator. */ - private static class CreatorOrder implements Comparator { + private static class CreatorOrder implements Comparator { @Override - public int compare(OsuGroupNode v, OsuGroupNode w) { - return v.osuFiles.get(0).creator.compareToIgnoreCase(w.osuFiles.get(0).creator); + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + return v.getBeatmapSet().get(0).creator.compareToIgnoreCase(w.getBeatmapSet().get(0).creator); } } /** - * Compares two OsuGroupNode objects by BPM. + * Compares two BeatmapSetNode objects by BPM. */ - private static class BPMOrder implements Comparator { + private static class BPMOrder implements Comparator { @Override - public int compare(OsuGroupNode v, OsuGroupNode w) { - return Integer.compare(v.osuFiles.get(0).bpmMax, w.osuFiles.get(0).bpmMax); + public int compare(BeatmapSetNode v, BeatmapSetNode w) { + return Integer.compare(v.getBeatmapSet().get(0).bpmMax, w.getBeatmapSet().get(0).bpmMax); } } /** - * Compares two OsuGroupNode objects by length. + * Compares two BeatmapSetNode objects by length. * Uses the longest beatmap in each set for comparison. */ - private static class LengthOrder implements Comparator { + private static class LengthOrder implements Comparator { @Override - public int compare(OsuGroupNode v, OsuGroupNode w) { + public int compare(BeatmapSetNode v, BeatmapSetNode w) { int vMax = 0, wMax = 0; - for (OsuFile osu : v.osuFiles) { - if (osu.endTime > vMax) - vMax = osu.endTime; + for (int i = 0, size = v.getBeatmapSet().size(); i < size; i++) { + Beatmap beatmap = v.getBeatmapSet().get(i); + if (beatmap.endTime > vMax) + vMax = beatmap.endTime; } - for (OsuFile osu : w.osuFiles) { - if (osu.endTime > wMax) - wMax = osu.endTime; + for (int i = 0, size = w.getBeatmapSet().size(); i < size; i++) { + Beatmap beatmap = w.getBeatmapSet().get(i); + if (beatmap.endTime > wMax) + wMax = beatmap.endTime; } return Integer.compare(vMax, wMax); } @@ -137,7 +143,7 @@ public enum SongSort { * @param name the sort name * @param comparator the comparator for the sort */ - SongSort(int id, String name, Comparator comparator) { + BeatmapSortOrder(int id, String name, Comparator comparator) { this.id = id; this.name = name; this.comparator = comparator; @@ -167,7 +173,7 @@ public enum SongSort { * Returns the comparator for the sort. * @return the comparator */ - public Comparator getComparator() { return comparator; } + public Comparator getComparator() { return comparator; } /** * Checks if the coordinates are within the image bounds. diff --git a/src/itdelatrisu/opsu/beatmap/HitObject.java b/src/itdelatrisu/opsu/beatmap/HitObject.java new file mode 100644 index 00000000..f32997ce --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/HitObject.java @@ -0,0 +1,540 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.GameMod; + +import java.text.DecimalFormat; +import java.text.NumberFormat; + +/** + * Data type representing a parsed hit object. + */ +public class HitObject { + /** Hit object types (bits). */ + public static final int + TYPE_CIRCLE = 1, + TYPE_SLIDER = 2, + TYPE_NEWCOMBO = 4, // not an object + TYPE_SPINNER = 8; + + /** Hit object type names. */ + private static final String + CIRCLE = "circle", + SLIDER = "slider", + SPINNER = "spinner", + UNKNOWN = "unknown object"; + + /** Hit sound types (bits). */ + public static final byte + SOUND_NORMAL = 0, + SOUND_WHISTLE = 2, + SOUND_FINISH = 4, + SOUND_CLAP = 8; + + /** + * Slider curve types. + * (Deprecated: only Beziers are currently used.) + */ + public static final char + SLIDER_CATMULL = 'C', + SLIDER_BEZIER = 'B', + SLIDER_LINEAR = 'L', + SLIDER_PASSTHROUGH = 'P'; + + /** Max hit object coordinates. */ + private static final int + MAX_X = 512, + MAX_Y = 384; + + /** The x and y multipliers for hit object coordinates. */ + private static float xMultiplier, yMultiplier; + + /** The x and y offsets for hit object coordinates. */ + private static int + xOffset, // offset right of border + yOffset; // offset below health bar + + /** The container height. */ + private static int containerHeight; + + /** The offset per stack. */ + private static float stackOffset; + + /** + * Returns the stack position modifier, in pixels. + * @return stack position modifier + */ + public static float getStackOffset() { return stackOffset; } + + /** + * Sets the stack position modifier. + * @param offset stack position modifier, in pixels + */ + public static void setStackOffset(float offset) { stackOffset = offset; } + + /** Starting coordinates. */ + private float x, y; + + /** Start time (in ms). */ + private int time; + + /** Hit object type (TYPE_* bitmask). */ + private int type; + + /** Hit sound type (SOUND_* bitmask). */ + private byte hitSound; + + /** Hit sound addition (sampleSet, AdditionSampleSet, ?, ...). */ + private byte[] addition; + + /** Slider curve type (SLIDER_* constant). */ + private char sliderType; + + /** Slider coordinate lists. */ + private float[] sliderX, sliderY; + + /** Slider repeat count. */ + private int repeat; + + /** Slider pixel length. */ + private float pixelLength; + + /** Spinner end time (in ms). */ + private int endTime; + + /** Slider edge hit sound type (SOUND_* bitmask). */ + private byte[] edgeHitSound; + + /** Slider edge hit sound addition (sampleSet, AdditionSampleSet). */ + private byte[][] edgeAddition; + + /** Current index in combo color array. */ + private int comboIndex; + + /** Number to display in hit object. */ + private int comboNumber; + + /** Hit object index in the current stack. */ + private int stack; + + /** + * Initializes the HitObject data type with container dimensions. + * @param width the container width + * @param height the container height + */ + public static void init(int width, int height) { + containerHeight = height; + int swidth = width; + int sheight = height; + if (swidth * 3 > sheight * 4) + swidth = sheight * 4 / 3; + else + sheight = swidth * 3 / 4; + xMultiplier = swidth / 640f; + yMultiplier = sheight / 480f; + xOffset = (int) (width - MAX_X * xMultiplier) / 2; + yOffset = (int) (height - MAX_Y * yMultiplier) / 2; + } + + /** + * Returns the X multiplier for coordinates. + */ + public static float getXMultiplier() { return xMultiplier; } + + /** + * Returns the Y multiplier for coordinates. + */ + public static float getYMultiplier() { return yMultiplier; } + + /** + * Returns the X offset for coordinates. + */ + public static int getXOffset() { return xOffset; } + + /** + * Returns the Y offset for coordinates. + */ + public static int getYOffset() { return yOffset; } + + /** + * Constructor. + * @param line the line to be parsed + */ + public HitObject(String line) { + /** + * [OBJECT FORMATS] + * Circles: + * x,y,time,type,hitSound,addition + * 256,148,9466,1,2,0:0:0:0: + * + * Sliders: + * x,y,time,type,hitSound,sliderType|curveX:curveY|...,repeat,pixelLength,edgeHitsound,edgeAddition,addition + * 300,68,4591,2,0,B|372:100|332:172|420:192,2,180,2|2|2,0:0|0:0|0:0,0:0:0:0: + * + * Spinners: + * x,y,time,type,hitSound,endTime,addition + * 256,192,654,12,0,4029,0:0:0:0: + * + * NOTE: 'addition' -> sampl:add:cust:vol:hitsound (optional, defaults to "0:0:0:0:") + */ + String tokens[] = line.split(","); + + // common fields + this.x = Float.parseFloat(tokens[0]); + this.y = Float.parseFloat(tokens[1]); + this.time = Integer.parseInt(tokens[2]); + this.type = Integer.parseInt(tokens[3]); + this.hitSound = Byte.parseByte(tokens[4]); + + // type-specific fields + int additionIndex; + if ((type & HitObject.TYPE_CIRCLE) > 0) + additionIndex = 5; + else if ((type & HitObject.TYPE_SLIDER) > 0) { + additionIndex = 10; + + // slider curve type and coordinates + String[] sliderTokens = tokens[5].split("\\|"); + this.sliderType = sliderTokens[0].charAt(0); + this.sliderX = new float[sliderTokens.length - 1]; + this.sliderY = new float[sliderTokens.length - 1]; + for (int j = 1; j < sliderTokens.length; j++) { + String[] sliderXY = sliderTokens[j].split(":"); + this.sliderX[j - 1] = Integer.parseInt(sliderXY[0]); + this.sliderY[j - 1] = Integer.parseInt(sliderXY[1]); + } + this.repeat = Integer.parseInt(tokens[6]); + this.pixelLength = Float.parseFloat(tokens[7]); + if (tokens.length > 8) { + String[] edgeHitSoundTokens = tokens[8].split("\\|"); + this.edgeHitSound = new byte[edgeHitSoundTokens.length]; + for (int j = 0; j < edgeHitSoundTokens.length; j++) + edgeHitSound[j] = Byte.parseByte(edgeHitSoundTokens[j]); + } + if (tokens.length > 9) { + String[] edgeAdditionTokens = tokens[9].split("\\|"); + this.edgeAddition = new byte[edgeAdditionTokens.length][2]; + for (int j = 0; j < edgeAdditionTokens.length; j++) { + String[] tedgeAddition = edgeAdditionTokens[j].split(":"); + edgeAddition[j][0] = Byte.parseByte(tedgeAddition[0]); + edgeAddition[j][1] = Byte.parseByte(tedgeAddition[1]); + } + } + } else { //if ((type & HitObject.TYPE_SPINNER) > 0) { + additionIndex = 6; + + // some 'endTime' fields contain a ':' character (?) + int index = tokens[5].indexOf(':'); + if (index != -1) + tokens[5] = tokens[5].substring(0, index); + this.endTime = Integer.parseInt(tokens[5]); + } + + // addition + if (tokens.length > additionIndex) { + String[] additionTokens = tokens[additionIndex].split(":"); + this.addition = new byte[additionTokens.length]; + for (int j = 0; j < additionTokens.length; j++) + this.addition[j] = Byte.parseByte(additionTokens[j]); + } + } + + /** + * Returns the raw starting x coordinate. + */ + public float getX() { return x; } + + /** + * Returns the raw starting y coordinate. + */ + public float getY() { return y; } + + /** + * Returns the scaled starting x coordinate. + */ + public float getScaledX() { return (x - stack * stackOffset) * xMultiplier + xOffset; } + + /** + * Returns the scaled starting y coordinate. + */ + public float getScaledY() { + if (GameMod.HARD_ROCK.isActive()) + return containerHeight - ((y + stack * stackOffset) * yMultiplier + yOffset); + else + return (y - stack * stackOffset) * yMultiplier + yOffset; + } + + /** + * Returns the start time. + * @return the start time (in ms) + */ + public int getTime() { return time; } + + /** + * Returns the hit object type. + * @return the object type (TYPE_* bitmask) + */ + public int getType() { return type; } + + /** + * Returns the name of the hit object type. + */ + public String getTypeName() { + if (isCircle()) + return CIRCLE; + else if (isSlider()) + return SLIDER; + else if (isSpinner()) + return SPINNER; + else + return UNKNOWN; + } + + /** + * Returns the hit sound type. + * @return the sound type (SOUND_* bitmask) + */ + public byte getHitSoundType() { return hitSound; } + + /** + * Returns the edge hit sound type. + * @param index the slider edge index (ignored for non-sliders) + * @return the sound type (SOUND_* bitmask) + */ + public byte getEdgeHitSoundType(int index) { + if (edgeHitSound != null) + return edgeHitSound[index]; + else + return hitSound; + } + + /** + * Returns the slider type. + * @return the slider type (SLIDER_* constant) + */ + public char getSliderType() { return sliderType; } + + /** + * Returns a list of raw slider x coordinates. + */ + public float[] getSliderX() { return sliderX; } + + /** + * Returns a list of raw slider y coordinates. + */ + public float[] getSliderY() { return sliderY; } + + /** + * Returns a list of scaled slider x coordinates. + * Note that this method will create a new array. + */ + public float[] getScaledSliderX() { + if (sliderX == null) + return null; + + float[] x = new float[sliderX.length]; + for (int i = 0; i < x.length; i++) + x[i] = (sliderX[i] - stack * stackOffset) * xMultiplier + xOffset; + return x; + } + + /** + * Returns a list of scaled slider y coordinates. + * Note that this method will create a new array. + */ + public float[] getScaledSliderY() { + if (sliderY == null) + return null; + + float[] y = new float[sliderY.length]; + if (GameMod.HARD_ROCK.isActive()) { + for (int i = 0; i < y.length; i++) + y[i] = containerHeight - ((sliderY[i] + stack * stackOffset) * yMultiplier + yOffset); + } else { + for (int i = 0; i < y.length; i++) + y[i] = (sliderY[i] - stack * stackOffset) * yMultiplier + yOffset; + } + return y; + } + + /** + * Returns the slider repeat count. + * @return the repeat count + */ + public int getRepeatCount() { return repeat; } + + /** + * Returns the slider pixel length. + * @return the pixel length + */ + public float getPixelLength() { return pixelLength; } + + /** + * Returns the spinner end time. + * @return the end time (in ms) + */ + public int getEndTime() { return endTime; } + + /** + * Sets the current index in the combo color array. + * @param comboIndex the combo index + */ + public void setComboIndex(int comboIndex) { this.comboIndex = comboIndex; } + + /** + * Returns the current index in the combo color array. + * @return the combo index + */ + public int getComboIndex() { return comboIndex; } + + /** + * Sets the number to display in the hit object. + * @param comboNumber the combo number + */ + public void setComboNumber(int comboNumber) { this.comboNumber = comboNumber; } + + /** + * Returns the number to display in the hit object. + * @return the combo number + */ + public int getComboNumber() { return comboNumber; } + + /** + * Returns whether or not the hit object is a circle. + * @return true if circle + */ + public boolean isCircle() { return (type & TYPE_CIRCLE) > 0; } + + /** + * Returns whether or not the hit object is a slider. + * @return true if slider + */ + public boolean isSlider() { return (type & TYPE_SLIDER) > 0; } + + /** + * Returns whether or not the hit object is a spinner. + * @return true if spinner + */ + public boolean isSpinner() { return (type & TYPE_SPINNER) > 0; } + + /** + * Returns whether or not the hit object starts a new combo. + * @return true if new combo + */ + public boolean isNewCombo() { return (type & TYPE_NEWCOMBO) > 0; } + + /** + * Returns the number of extra skips on the combo colors. + */ + public int getComboSkip() { return (type >> TYPE_NEWCOMBO); } + + /** + * Returns the sample set at the given index. + * @param index the index (for sliders, ignored otherwise) + * @return the sample set, or 0 if none available + */ + public byte getSampleSet(int index) { + if (edgeAddition != null) + return edgeAddition[index][0]; + if (addition != null) + return addition[0]; + return 0; + } + + /** + * Returns the 'addition' sample set at the given index. + * @param index the index (for sliders, ignored otherwise) + * @return the sample set, or 0 if none available + */ + public byte getAdditionSampleSet(int index) { + if (edgeAddition != null) + return edgeAddition[index][1]; + if (addition != null) + return addition[1]; + return 0; + } + + /** + * Sets the hit object index in the current stack. + * @param stack index in the stack + */ + public void setStack(int stack) { this.stack = stack; } + + /** + * Returns the hit object index in the current stack. + * @return index in the stack + */ + public int getStack() { return stack; } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + NumberFormat nf = new DecimalFormat("###.#####"); + + // common fields + sb.append(nf.format(x)); sb.append(','); + sb.append(nf.format(y)); sb.append(','); + sb.append(time); sb.append(','); + sb.append(type); sb.append(','); + sb.append(hitSound); sb.append(','); + + // type-specific fields + if (isCircle()) + ; + else if (isSlider()) { + sb.append(getSliderType()); + sb.append('|'); + for (int i = 0; i < sliderX.length; i++) { + sb.append(nf.format(sliderX[i])); sb.append(':'); + sb.append(nf.format(sliderY[i])); sb.append('|'); + } + sb.setCharAt(sb.length() - 1, ','); + sb.append(repeat); sb.append(','); + sb.append(pixelLength); sb.append(','); + if (edgeHitSound != null) { + for (int i = 0; i < edgeHitSound.length; i++) { + sb.append(edgeHitSound[i]); sb.append('|'); + } + sb.setCharAt(sb.length() - 1, ','); + } + if (edgeAddition != null) { + for (int i = 0; i < edgeAddition.length; i++) { + sb.append(edgeAddition[i][0]); sb.append(':'); + sb.append(edgeAddition[i][1]); sb.append('|'); + } + sb.setCharAt(sb.length() - 1, ','); + } + } else if (isSpinner()) { + sb.append(endTime); + sb.append(','); + } + + // addition + if (addition != null) { + for (int i = 0; i < addition.length; i++) { + sb.append(addition[i]); + sb.append(':'); + } + } else + sb.setLength(sb.length() - 1); + + return sb.toString(); + } +} diff --git a/src/itdelatrisu/opsu/OsuTimingPoint.java b/src/itdelatrisu/opsu/beatmap/TimingPoint.java similarity index 94% rename from src/itdelatrisu/opsu/OsuTimingPoint.java rename to src/itdelatrisu/opsu/beatmap/TimingPoint.java index be99b486..d508d270 100644 --- a/src/itdelatrisu/opsu/OsuTimingPoint.java +++ b/src/itdelatrisu/opsu/beatmap/TimingPoint.java @@ -16,14 +16,16 @@ * along with opsu!. If not, see . */ -package itdelatrisu.opsu; +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.Utils; import org.newdawn.slick.util.Log; /** * Data type representing a timing point. */ -public class OsuTimingPoint { +public class TimingPoint { /** Timing point start time/offset (in ms). */ private int time = 0; @@ -55,7 +57,7 @@ public class OsuTimingPoint { * Constructor. * @param line the line to be parsed */ - public OsuTimingPoint(String line) { + public TimingPoint(String line) { // TODO: better support for old formats String[] tokens = line.split(","); try { @@ -64,9 +66,9 @@ public class OsuTimingPoint { this.sampleType = Byte.parseByte(tokens[3]); this.sampleTypeCustom = Byte.parseByte(tokens[4]); this.sampleVolume = Integer.parseInt(tokens[5]); -// this.inherited = (Integer.parseInt(tokens[6]) == 1); +// this.inherited = Utils.parseBoolean(tokens[6]); if (tokens.length > 7) - this.kiai = (Integer.parseInt(tokens[7]) == 1); + this.kiai = Utils.parseBoolean(tokens[7]); } catch (ArrayIndexOutOfBoundsException e) { Log.debug(String.format("Error parsing timing point: '%s'", line)); } diff --git a/src/itdelatrisu/opsu/db/BeatmapDB.java b/src/itdelatrisu/opsu/db/BeatmapDB.java new file mode 100644 index 00000000..3f85373e --- /dev/null +++ b/src/itdelatrisu/opsu/db/BeatmapDB.java @@ -0,0 +1,590 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.db; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapParser; + +import java.io.File; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.newdawn.slick.util.Log; + +/** + * Handles connections and queries with the cached beatmap database. + */ +public class BeatmapDB { + /** + * Current database version. + * This value should be changed whenever the database format changes. + */ + private static final String DATABASE_VERSION = "2014-06-08"; + + /** Minimum batch size ratio ({@code batchSize/cacheSize}) to invoke batch loading. */ + private static final float LOAD_BATCH_MIN_RATIO = 0.2f; + + /** Minimum batch size to invoke batch insertion. */ + private static final int INSERT_BATCH_MIN = 100; + + /** Beatmap loading flags. */ + public static final int LOAD_NONARRAY = 1, LOAD_ARRAY = 2, LOAD_ALL = 3; + + /** Database connection. */ + private static Connection connection; + + /** Query statements. */ + private static PreparedStatement insertStmt, selectStmt, deleteMapStmt, deleteGroupStmt, updateSizeStmt; + + /** Current size of beatmap cache table. */ + private static int cacheSize = -1; + + // This class should not be instantiated. + private BeatmapDB() {} + + /** + * Initializes the database connection. + */ + public static void init() { + // create a database connection + connection = DBController.createConnection(Options.BEATMAP_DB.getPath()); + if (connection == null) + return; + + // create the database + createDatabase(); + + // prepare sql statements (used below) + try { + updateSizeStmt = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('size', ?)"); + } catch (SQLException e) { + ErrorHandler.error("Failed to prepare beatmap statements.", e, true); + } + + // 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 = ?"); + } catch (SQLException e) { + ErrorHandler.error("Failed to prepare beatmap statements.", e, true); + } + } + + /** + * Creates the database, if it does not exist. + */ + private static void createDatabase() { + try (Statement stmt = connection.createStatement()) { + String sql = + "CREATE TABLE IF NOT EXISTS beatmaps (" + + "dir TEXT, file TEXT, lastModified INTEGER, " + + "MID INTEGER, MSID INTEGER, " + + "title TEXT, titleUnicode TEXT, artist TEXT, artistUnicode TEXT, " + + "creator TEXT, version TEXT, source TEXT, tags TEXT, " + + "circles INTEGER, sliders INTEGER, spinners INTEGER, " + + "hp REAL, cs REAL, od REAL, ar REAL, sliderMultiplier REAL, sliderTickRate REAL, " + + "bpmMin INTEGER, bpmMax INTEGER, endTime INTEGER, " + + "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" + + "); " + + "CREATE TABLE IF NOT EXISTS info (" + + "key TEXT NOT NULL UNIQUE, value TEXT" + + "); " + + "CREATE INDEX IF NOT EXISTS idx ON beatmaps (dir, file); " + + + // extra optimizations + "PRAGMA locking_mode = EXCLUSIVE; " + + "PRAGMA journal_mode = WAL;"; + stmt.executeUpdate(sql); + + // set the version key, if empty + sql = String.format("INSERT OR IGNORE INTO info(key, value) VALUES('version', '%s')", DATABASE_VERSION); + stmt.executeUpdate(sql); + } catch (SQLException e) { + ErrorHandler.error("Could not create beatmap database.", e, true); + } + } + + /** + * Checks the stored table version, clears the beatmap database if different + * from the current version, then updates the version field. + */ + private static void checkVersion() { + try (Statement stmt = connection.createStatement()) { + // get the stored version + String sql = "SELECT value FROM info WHERE key = 'version'"; + ResultSet rs = stmt.executeQuery(sql); + String version = (rs.next()) ? rs.getString(1) : ""; + rs.close(); + + // if different from current version, clear the database + if (!version.equals(DATABASE_VERSION)) { + clearDatabase(); + + // update version + PreparedStatement ps = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('version', ?)"); + ps.setString(1, DATABASE_VERSION); + ps.executeUpdate(); + ps.close(); + } + } catch (SQLException e) { + ErrorHandler.error("Beatmap database version checks failed.", e, true); + } + } + + /** + * Retrieves the size of the beatmap cache from the 'info' table. + */ + private static void getCacheSize() { + try (Statement stmt = connection.createStatement()) { + String sql = "SELECT value FROM info WHERE key = 'size'"; + ResultSet rs = stmt.executeQuery(sql); + try { + cacheSize = (rs.next()) ? Integer.parseInt(rs.getString(1)) : 0; + } catch (NumberFormatException e) { + cacheSize = 0; + } + rs.close(); + } catch (SQLException e) { + ErrorHandler.error("Could not get beatmap cache size.", e, true); + } + } + + /** + * Updates the size of the beatmap cache in the 'info' table. + */ + private static void updateCacheSize() { + if (connection == null) + return; + + try { + updateSizeStmt.setString(1, Integer.toString(Math.max(cacheSize, 0))); + updateSizeStmt.executeUpdate(); + } catch (SQLException e) { + ErrorHandler.error("Could not update beatmap cache size.", e, true); + } + } + + /** + * Clears the database. + */ + public static void clearDatabase() { + if (connection == null) + return; + + // drop the table, then recreate it + try (Statement stmt = connection.createStatement()) { + String sql = "DROP TABLE beatmaps"; + stmt.executeUpdate(sql); + cacheSize = 0; + updateCacheSize(); + } catch (SQLException e) { + ErrorHandler.error("Could not drop beatmap database.", e, true); + } + createDatabase(); + } + + /** + * Adds the beatmap to the database. + * @param beatmap the beatmap + */ + public static void insert(Beatmap beatmap) { + if (connection == null) + return; + + try { + setStatementFields(insertStmt, beatmap); + cacheSize += insertStmt.executeUpdate(); + updateCacheSize(); + } catch (SQLException e) { + ErrorHandler.error("Failed to add beatmap to database.", e, true); + } + } + + /** + * Adds the beatmaps to the database in a batch. + * @param batch a list of beatmaps + */ + public static void insert(List batch) { + if (connection == null) + return; + + try (Statement stmt = connection.createStatement()) { + // turn off auto-commit mode + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + + // drop indexes + boolean recreateIndexes = (batch.size() >= INSERT_BATCH_MIN); + if (recreateIndexes) { + String sql = "DROP INDEX IF EXISTS idx"; + stmt.executeUpdate(sql); + } + + // batch insert + for (Beatmap beatmap : batch) { + try { + setStatementFields(insertStmt, beatmap); + } catch (SQLException e) { + Log.error(String.format("Failed to insert map '%s' into database.", beatmap.getFile().getPath()), e); + continue; + } + insertStmt.addBatch(); + } + int[] results = insertStmt.executeBatch(); + for (int i = 0; i < results.length; i++) { + if (results[i] > 0) + cacheSize += results[i]; + } + + // re-create indexes + if (recreateIndexes) { + String sql = "CREATE INDEX idx ON beatmaps (dir, file)"; + stmt.executeUpdate(sql); + } + + // restore previous auto-commit mode + connection.commit(); + connection.setAutoCommit(autoCommit); + + // update cache size + updateCacheSize(); + } catch (SQLException e) { + ErrorHandler.error("Failed to add beatmaps to database.", e, true); + } + } + + /** + * Sets all statement fields using a given beatmap. + * @param stmt the statement to set fields for + * @param beatmap the beatmap + * @throws SQLException + */ + private static void setStatementFields(PreparedStatement stmt, Beatmap beatmap) + throws SQLException { + try { + stmt.setString(1, beatmap.getFile().getParentFile().getName()); + stmt.setString(2, beatmap.getFile().getName()); + stmt.setLong(3, beatmap.getFile().lastModified()); + stmt.setInt(4, beatmap.beatmapID); + stmt.setInt(5, beatmap.beatmapSetID); + stmt.setString(6, beatmap.title); + stmt.setString(7, beatmap.titleUnicode); + stmt.setString(8, beatmap.artist); + stmt.setString(9, beatmap.artistUnicode); + stmt.setString(10, beatmap.creator); + stmt.setString(11, beatmap.version); + stmt.setString(12, beatmap.source); + stmt.setString(13, beatmap.tags); + stmt.setInt(14, beatmap.hitObjectCircle); + stmt.setInt(15, beatmap.hitObjectSlider); + stmt.setInt(16, beatmap.hitObjectSpinner); + stmt.setFloat(17, beatmap.HPDrainRate); + stmt.setFloat(18, beatmap.circleSize); + stmt.setFloat(19, beatmap.overallDifficulty); + stmt.setFloat(20, beatmap.approachRate); + stmt.setFloat(21, beatmap.sliderMultiplier); + stmt.setFloat(22, beatmap.sliderTickRate); + stmt.setInt(23, beatmap.bpmMin); + stmt.setInt(24, beatmap.bpmMax); + stmt.setInt(25, beatmap.endTime); + stmt.setString(26, beatmap.audioFilename.getName()); + stmt.setInt(27, beatmap.audioLeadIn); + stmt.setInt(28, beatmap.previewTime); + stmt.setByte(29, beatmap.countdown); + stmt.setString(30, beatmap.sampleSet); + stmt.setFloat(31, beatmap.stackLeniency); + stmt.setByte(32, beatmap.mode); + stmt.setBoolean(33, beatmap.letterboxInBreaks); + stmt.setBoolean(34, beatmap.widescreenStoryboard); + stmt.setBoolean(35, beatmap.epilepsyWarning); + stmt.setString(36, (beatmap.bg == null) ? null : beatmap.bg.getName()); + stmt.setString(37, beatmap.sliderBorderToString()); + stmt.setString(38, beatmap.timingPointsToString()); + stmt.setString(39, beatmap.breaksToString()); + stmt.setString(40, beatmap.comboToString()); + } catch (SQLException e) { + throw e; + } catch (Exception e) { + throw new SQLException(e); + } + } + + /** + * Loads beatmap fields from the database. + * @param beatmap the beatmap + * @param flag whether to load all fields (LOAD_ALL), non-array + * fields (LOAD_NONARRAY), or array fields (LOAD_ARRAY) + */ + public static void load(Beatmap beatmap, int flag) { + if (connection == null) + return; + + try { + selectStmt.setString(1, beatmap.getFile().getParentFile().getName()); + selectStmt.setString(2, beatmap.getFile().getName()); + ResultSet rs = selectStmt.executeQuery(); + if (rs.next()) { + if ((flag & LOAD_NONARRAY) > 0) + setBeatmapFields(rs, beatmap); + if ((flag & LOAD_ARRAY) > 0) + setBeatmapArrayFields(rs, beatmap); + } + rs.close(); + } catch (SQLException e) { + ErrorHandler.error("Failed to load Beatmap from database.", e, true); + } + } + + /** + * Loads Beatmap fields from the database in a batch. + * @param batch a list of beatmaps + * @param flag whether to load all fields (LOAD_ALL), non-array + * fields (LOAD_NONARRAY), or array fields (LOAD_ARRAY) + */ + public static void load(List batch, int flag) { + if (connection == null) + return; + + // batch size too small + int size = batch.size(); + if (size < cacheSize * LOAD_BATCH_MIN_RATIO) { + for (Beatmap beatmap : batch) + load(beatmap, flag); + return; + } + + try (Statement stmt = connection.createStatement()) { + // create map + HashMap> map = new HashMap>(); + for (Beatmap beatmap : batch) { + String parent = beatmap.getFile().getParentFile().getName(); + String name = beatmap.getFile().getName(); + HashMap m = map.get(parent); + if (m == null) { + m = new HashMap(); + map.put(parent, m); + } + m.put(name, beatmap); + } + + // iterate through database to load beatmaps + int count = 0; + stmt.setFetchSize(100); + String sql = "SELECT * FROM beatmaps"; + ResultSet rs = stmt.executeQuery(sql); + while (rs.next()) { + String parent = rs.getString(1); + HashMap m = map.get(parent); + if (m != null) { + String name = rs.getString(2); + Beatmap beatmap = m.get(name); + if (beatmap != null) { + try { + if ((flag & LOAD_NONARRAY) > 0) + setBeatmapFields(rs, beatmap); + if ((flag & LOAD_ARRAY) > 0) + setBeatmapArrayFields(rs, beatmap); + } catch (SQLException e) { + Log.error(String.format("Failed to load map '%s/%s' from database.", parent, name), e); + } + if (++count >= size) + break; + } + } + } + rs.close(); + } catch (SQLException e) { + ErrorHandler.error("Failed to load beatmaps from database.", e, true); + } + } + + /** + * Sets all beatmap non-array fields using a given result set. + * @param rs the result set containing the fields + * @param beatmap the beatmap + * @throws SQLException + */ + private static void setBeatmapFields(ResultSet rs, Beatmap beatmap) throws SQLException { + try { + File dir = beatmap.getFile().getParentFile(); + beatmap.beatmapID = rs.getInt(4); + beatmap.beatmapSetID = rs.getInt(5); + beatmap.title = BeatmapParser.getDBString(rs.getString(6)); + beatmap.titleUnicode = BeatmapParser.getDBString(rs.getString(7)); + beatmap.artist = BeatmapParser.getDBString(rs.getString(8)); + beatmap.artistUnicode = BeatmapParser.getDBString(rs.getString(9)); + beatmap.creator = BeatmapParser.getDBString(rs.getString(10)); + beatmap.version = BeatmapParser.getDBString(rs.getString(11)); + beatmap.source = BeatmapParser.getDBString(rs.getString(12)); + beatmap.tags = BeatmapParser.getDBString(rs.getString(13)); + beatmap.hitObjectCircle = rs.getInt(14); + beatmap.hitObjectSlider = rs.getInt(15); + beatmap.hitObjectSpinner = rs.getInt(16); + beatmap.HPDrainRate = rs.getFloat(17); + beatmap.circleSize = rs.getFloat(18); + beatmap.overallDifficulty = rs.getFloat(19); + beatmap.approachRate = rs.getFloat(20); + beatmap.sliderMultiplier = rs.getFloat(21); + beatmap.sliderTickRate = rs.getFloat(22); + beatmap.bpmMin = rs.getInt(23); + beatmap.bpmMax = rs.getInt(24); + beatmap.endTime = rs.getInt(25); + beatmap.audioFilename = new File(dir, BeatmapParser.getDBString(rs.getString(26))); + beatmap.audioLeadIn = rs.getInt(27); + beatmap.previewTime = rs.getInt(28); + beatmap.countdown = rs.getByte(29); + beatmap.sampleSet = BeatmapParser.getDBString(rs.getString(30)); + beatmap.stackLeniency = rs.getFloat(31); + beatmap.mode = rs.getByte(32); + beatmap.letterboxInBreaks = rs.getBoolean(33); + beatmap.widescreenStoryboard = rs.getBoolean(34); + beatmap.epilepsyWarning = rs.getBoolean(35); + String bg = rs.getString(36); + if (bg != null) + beatmap.bg = new File(dir, BeatmapParser.getDBString(bg)); + beatmap.sliderBorderFromString(rs.getString(37)); + } catch (SQLException e) { + throw e; + } catch (Exception e) { + throw new SQLException(e); + } + } + + /** + * Sets all Beatmap array fields using a given result set. + * @param rs the result set containing the fields + * @param beatmap the beatmap + * @throws SQLException + */ + private static void setBeatmapArrayFields(ResultSet rs, Beatmap beatmap) throws SQLException { + try { + beatmap.timingPointsFromString(rs.getString(38)); + beatmap.breaksFromString(rs.getString(39)); + beatmap.comboFromString(rs.getString(40)); + } catch (SQLException e) { + throw e; + } catch (Exception e) { + throw new SQLException(e); + } + } + + /** + * Returns a map of file paths ({dir}/{file}) to last modified times, or + * null if any error occurred. + */ + public static Map getLastModifiedMap() { + if (connection == null) + return null; + + try (Statement stmt = connection.createStatement()) { + Map map = new HashMap(); + String sql = "SELECT dir, file, lastModified FROM beatmaps"; + ResultSet rs = stmt.executeQuery(sql); + stmt.setFetchSize(100); + while (rs.next()) { + String path = String.format("%s/%s", rs.getString(1), rs.getString(2)); + long lastModified = rs.getLong(3); + map.put(path, lastModified); + } + rs.close(); + return map; + } catch (SQLException e) { + ErrorHandler.error("Failed to get last modified map from database.", e, true); + return null; + } + } + + /** + * Deletes the beatmap entry from the database. + * @param dir the directory + * @param file the file + */ + public static void delete(String dir, String file) { + if (connection == null) + return; + + try { + deleteMapStmt.setString(1, dir); + deleteMapStmt.setString(2, file); + cacheSize -= deleteMapStmt.executeUpdate(); + updateCacheSize(); + } catch (SQLException e) { + ErrorHandler.error("Failed to delete beatmap entry from database.", e, true); + } + } + + /** + * Deletes the beatmap group entry from the database. + * @param dir the directory + */ + public static void delete(String dir) { + if (connection == null) + return; + + try { + deleteGroupStmt.setString(1, dir); + cacheSize -= deleteGroupStmt.executeUpdate(); + updateCacheSize(); + } catch (SQLException e) { + ErrorHandler.error("Failed to delete beatmap group entry from database.", e, true); + } + } + + /** + * Closes the connection to the database. + */ + public static void closeConnection() { + if (connection == null) + return; + + try { + insertStmt.close(); + selectStmt.close(); + deleteMapStmt.close(); + deleteGroupStmt.close(); + updateSizeStmt.close(); + connection.close(); + connection = null; + } catch (SQLException e) { + ErrorHandler.error("Failed to close beatmap database.", e, true); + } + } +} diff --git a/src/itdelatrisu/opsu/db/DBController.java b/src/itdelatrisu/opsu/db/DBController.java index 05841b6c..aebd414b 100644 --- a/src/itdelatrisu/opsu/db/DBController.java +++ b/src/itdelatrisu/opsu/db/DBController.java @@ -43,7 +43,7 @@ public class DBController { } // initialize the databases - OsuDB.init(); + BeatmapDB.init(); ScoreDB.init(); } @@ -51,7 +51,7 @@ public class DBController { * Closes all database connections. */ public static void closeConnections() { - OsuDB.closeConnection(); + BeatmapDB.closeConnection(); ScoreDB.closeConnection(); } diff --git a/src/itdelatrisu/opsu/db/OsuDB.java b/src/itdelatrisu/opsu/db/OsuDB.java index f72bf4c5..d632daa3 100644 --- a/src/itdelatrisu/opsu/db/OsuDB.java +++ b/src/itdelatrisu/opsu/db/OsuDB.java @@ -1,3 +1,5 @@ +//todo rename + /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/db/ScoreDB.java b/src/itdelatrisu/opsu/db/ScoreDB.java index 73869809..4feee2da 100644 --- a/src/itdelatrisu/opsu/db/ScoreDB.java +++ b/src/itdelatrisu/opsu/db/ScoreDB.java @@ -20,8 +20,8 @@ package itdelatrisu.opsu.db; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuFile; import itdelatrisu.opsu.ScoreData; +import itdelatrisu.opsu.beatmap.Beatmap; import java.sql.Connection; import java.sql.PreparedStatement; @@ -249,18 +249,18 @@ public class ScoreDB { /** * Deletes all the scores for the given beatmap from the database. - * @param osu the OsuFile object + * @param beatmap the beatmap */ - public static void deleteScore(OsuFile osu) { + public static void deleteScore(Beatmap beatmap) { if (connection == null) return; try { - deleteSongStmt.setInt(1, osu.beatmapID); - deleteSongStmt.setString(2, osu.title); - deleteSongStmt.setString(3, osu.artist); - deleteSongStmt.setString(4, osu.creator); - deleteSongStmt.setString(5, osu.version); + deleteSongStmt.setInt(1, beatmap.beatmapID); + deleteSongStmt.setString(2, beatmap.title); + deleteSongStmt.setString(3, beatmap.artist); + deleteSongStmt.setString(4, beatmap.creator); + deleteSongStmt.setString(5, beatmap.version); deleteSongStmt.executeUpdate(); } catch (SQLException e) { ErrorHandler.error("Failed to delete scores from database.", e, true); @@ -298,21 +298,21 @@ public class ScoreDB { } /** - * Retrieves the game scores for an OsuFile map. - * @param osu the OsuFile + * Retrieves the game scores for a beatmap. + * @param beatmap the beatmap * @return all scores for the beatmap, or null if any error occurred */ - public static ScoreData[] getMapScores(OsuFile osu) { + public static ScoreData[] getMapScores(Beatmap beatmap) { if (connection == null) return null; List list = new ArrayList(); try { - selectMapStmt.setInt(1, osu.beatmapID); - selectMapStmt.setString(2, osu.title); - selectMapStmt.setString(3, osu.artist); - selectMapStmt.setString(4, osu.creator); - selectMapStmt.setString(5, osu.version); + selectMapStmt.setInt(1, beatmap.beatmapID); + selectMapStmt.setString(2, beatmap.title); + selectMapStmt.setString(3, beatmap.artist); + selectMapStmt.setString(4, beatmap.creator); + selectMapStmt.setString(5, beatmap.version); ResultSet rs = selectMapStmt.executeQuery(); while (rs.next()) { ScoreData s = new ScoreData(rs); @@ -327,21 +327,21 @@ public class ScoreDB { } /** - * Retrieves the game scores for an OsuFile map set. - * @param osu the OsuFile + * Retrieves the game scores for a beatmap set. + * @param beatmap the beatmap * @return all scores for the beatmap set (Version, ScoreData[]), * or null if any error occurred */ - public static Map getMapSetScores(OsuFile osu) { + public static Map getMapSetScores(Beatmap beatmap) { if (connection == null) return null; Map map = new HashMap(); try { - selectMapSetStmt.setInt(1, osu.beatmapSetID); - selectMapSetStmt.setString(2, osu.title); - selectMapSetStmt.setString(3, osu.artist); - selectMapSetStmt.setString(4, osu.creator); + selectMapSetStmt.setInt(1, beatmap.beatmapSetID); + selectMapSetStmt.setString(2, beatmap.title); + selectMapSetStmt.setString(3, beatmap.artist); + selectMapSetStmt.setString(4, beatmap.creator); ResultSet rs = selectMapSetStmt.executeQuery(); List list = null; diff --git a/src/itdelatrisu/opsu/downloads/DownloadNode.java b/src/itdelatrisu/opsu/downloads/DownloadNode.java index 0040aa2e..8ffa7dba 100644 --- a/src/itdelatrisu/opsu/downloads/DownloadNode.java +++ b/src/itdelatrisu/opsu/downloads/DownloadNode.java @@ -21,11 +21,12 @@ package itdelatrisu.opsu.downloads; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuGroupList; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.downloads.Download.DownloadListener; import itdelatrisu.opsu.downloads.Download.Status; +import itdelatrisu.opsu.downloads.servers.DownloadServer; +import itdelatrisu.opsu.ui.UI; import java.io.File; @@ -85,7 +86,7 @@ public class DownloadNode { buttonBaseX = width * 0.024f; buttonBaseY = height * 0.2f; buttonWidth = width * 0.7f; - buttonHeight = Utils.FONT_MEDIUM.getLineHeight() * 2f; + buttonHeight = Utils.FONT_MEDIUM.getLineHeight() * 2.1f; buttonOffset = buttonHeight * 1.1f; // download info @@ -239,22 +240,26 @@ public class DownloadNode { * @see #getDownload() */ public void createDownload(DownloadServer server) { - if (download == null) { - String path = String.format("%s%c%d", Options.getOSZDir(), File.separatorChar, beatmapSetID); - String rename = String.format("%d %s - %s.osz", beatmapSetID, artist, title); - this.download = new Download(server.getURL(beatmapSetID), path, rename); - download.setListener(new DownloadListener() { - @Override - public void completed() { - UI.sendBarNotification(String.format("Download complete: %s", getTitle())); - } + if (download != null) + return; - @Override - public void error() { - UI.sendBarNotification("Download failed due to a connection error."); - } - }); - } + String url = server.getDownloadURL(beatmapSetID); + if (url == null) + return; + String path = String.format("%s%c%d", Options.getOSZDir(), File.separatorChar, beatmapSetID); + String rename = String.format("%d %s - %s.osz", beatmapSetID, artist, title); + this.download = new Download(url, path, rename); + download.setListener(new DownloadListener() { + @Override + public void completed() { + UI.sendBarNotification(String.format("Download complete: %s", getTitle())); + } + + @Override + public void error() { + UI.sendBarNotification("Download failed due to a connection error."); + } + }); } /** @@ -320,7 +325,7 @@ public class DownloadNode { g.fillRect(buttonBaseX, y, buttonWidth, buttonHeight); // map is already loaded - if (OsuGroupList.get().containsBeatmapSetID(beatmapSetID)) { + if (BeatmapSetList.get().containsBeatmapSetID(beatmapSetID)) { g.setColor(Utils.COLOR_BLUE_BUTTON); g.fillRect(buttonBaseX, y, buttonWidth, buttonHeight); } @@ -340,13 +345,15 @@ public class DownloadNode { textX += img.getWidth() + buttonWidth * 0.001f; // text - // TODO: if the title/artist line is too long, shorten it (e.g. add "...") + // TODO: if the title/artist line is too long, shorten it (e.g. add "...") instead of just clipping if (Options.useUnicodeMetadata()) // load glyphs Utils.loadGlyphs(Utils.FONT_BOLD, getTitle(), getArtist()); + g.setClip((int) textX, (int) (y + marginY), (int) (edgeX - textX - Utils.FONT_DEFAULT.getWidth(creator)), Utils.FONT_BOLD.getLineHeight()); Utils.FONT_BOLD.drawString( textX, y + marginY, String.format("%s - %s%s", getArtist(), getTitle(), (dl != null) ? String.format(" [%s]", dl.getStatus().getName()) : ""), Color.white); + g.clearClip(); Utils.FONT_DEFAULT.drawString( textX, y + marginY + Utils.FONT_BOLD.getLineHeight(), String.format("Last updated: %s", date), Color.white); diff --git a/src/itdelatrisu/opsu/downloads/Updater.java b/src/itdelatrisu/opsu/downloads/Updater.java index 5612e381..366fb623 100644 --- a/src/itdelatrisu/opsu/downloads/Updater.java +++ b/src/itdelatrisu/opsu/downloads/Updater.java @@ -20,14 +20,15 @@ package itdelatrisu.opsu.downloads; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.downloads.Download.DownloadListener; +import itdelatrisu.opsu.ui.UI; import java.io.File; import java.io.IOException; import java.io.StringReader; import java.net.URL; +import java.net.UnknownHostException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; @@ -35,6 +36,7 @@ import java.util.Locale; import java.util.Properties; import org.apache.maven.artifact.versioning.DefaultArtifactVersion; +import org.newdawn.slick.util.Log; import org.newdawn.slick.util.ResourceLoader; /** @@ -206,7 +208,12 @@ public class Updater { return; // get latest version - String s = Utils.readDataFromUrl(new URL(Options.VERSION_REMOTE)); + String s = null; + try { + s = Utils.readDataFromUrl(new URL(Options.VERSION_REMOTE)); + } catch (UnknownHostException e) { + Log.warn(String.format("Check for updates failed. Please check your internet connection, or your connection to %s.", Options.VERSION_REMOTE)); + } if (s == null) { status = Status.CONNECTION_ERROR; return; diff --git a/src/itdelatrisu/opsu/downloads/BloodcatServer.java b/src/itdelatrisu/opsu/downloads/servers/BloodcatServer.java similarity index 68% rename from src/itdelatrisu/opsu/downloads/BloodcatServer.java rename to src/itdelatrisu/opsu/downloads/servers/BloodcatServer.java index 111cc77a..c696e800 100644 --- a/src/itdelatrisu/opsu/downloads/BloodcatServer.java +++ b/src/itdelatrisu/opsu/downloads/servers/BloodcatServer.java @@ -16,25 +16,32 @@ * along with opsu!. If not, see . */ -package itdelatrisu.opsu.downloads; +package itdelatrisu.opsu.downloads.servers; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.DownloadNode; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; import org.json.JSONArray; -import org.json.JSONException; import org.json.JSONObject; /** * Download server: http://bloodcat.com/osu/ */ public class BloodcatServer extends DownloadServer { + /** Server name. */ + private static final String SERVER_NAME = "Bloodcat"; + /** Formatted download URL: {@code beatmapSetID} */ private static final String DOWNLOAD_URL = "http://bloodcat.com/osu/s/%d"; @@ -48,7 +55,10 @@ public class BloodcatServer extends DownloadServer { public BloodcatServer() {} @Override - public String getURL(int beatmapSetID) { + public String getName() { return SERVER_NAME; } + + @Override + public String getDownloadURL(int beatmapSetID) { return String.format(DOWNLOAD_URL, beatmapSetID); } @@ -58,7 +68,7 @@ public class BloodcatServer extends DownloadServer { try { // read JSON String search = String.format(SEARCH_URL, URLEncoder.encode(query, "UTF-8"), rankedOnly ? "0" : "", page); - JSONObject json = readJsonFromUrl(new URL(search)); + JSONObject json = Utils.readJsonObjectFromUrl(new URL(search)); if (json == null) { this.totalResults = -1; return null; @@ -70,7 +80,7 @@ public class BloodcatServer extends DownloadServer { for (int i = 0; i < nodes.length; i++) { JSONObject item = arr.getJSONObject(i); nodes[i] = new DownloadNode( - item.getInt("id"), item.getString("date"), + item.getInt("id"), formatDate(item.getString("date")), item.getString("title"), item.isNull("titleUnicode") ? null : item.getString("titleUnicode"), item.getString("artist"), item.isNull("artistUnicode") ? null : item.getString("artistUnicode"), item.getString("creator") @@ -85,25 +95,31 @@ public class BloodcatServer extends DownloadServer { return nodes; } + @Override + public int minQueryLength() { return 0; } + @Override public int totalResults() { return totalResults; } /** - * Returns a JSON object from a URL. - * @param url the remote URL - * @return the JSON object - * @author Roland Illig (http://stackoverflow.com/a/4308662) + * Returns a formatted date string from a raw date. + * @param s the raw date string (e.g. "2015-05-14T23:38:47+09:00") + * @return the formatted date, or the raw string if it could not be parsed */ - private static JSONObject readJsonFromUrl(URL url) throws IOException { - String s = Utils.readDataFromUrl(url); - JSONObject json = null; - if (s != null) { - try { - json = new JSONObject(s); - } catch (JSONException e) { - ErrorHandler.error("Failed to create JSON object.", e, true); - } + private String formatDate(String s) { + try { + // make string parseable by SimpleDateFormat + int index = s.lastIndexOf(':'); + if (index == -1) + return s; + String str = new StringBuilder(s).deleteCharAt(index).toString(); + + DateFormat f = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); + Date d = f.parse(str); + DateFormat fmt = new SimpleDateFormat("d MMM yyyy HH:mm:ss"); + return fmt.format(d); + } catch (StringIndexOutOfBoundsException | ParseException e) { + return s; } - return json; } } diff --git a/src/itdelatrisu/opsu/downloads/DownloadServer.java b/src/itdelatrisu/opsu/downloads/servers/DownloadServer.java similarity index 80% rename from src/itdelatrisu/opsu/downloads/DownloadServer.java rename to src/itdelatrisu/opsu/downloads/servers/DownloadServer.java index b3840364..24cca1f5 100644 --- a/src/itdelatrisu/opsu/downloads/DownloadServer.java +++ b/src/itdelatrisu/opsu/downloads/servers/DownloadServer.java @@ -16,7 +16,9 @@ * along with opsu!. If not, see . */ -package itdelatrisu.opsu.downloads; +package itdelatrisu.opsu.downloads.servers; + +import itdelatrisu.opsu.downloads.DownloadNode; import java.io.IOException; @@ -27,12 +29,18 @@ public abstract class DownloadServer { /** Track preview URL. */ private static final String PREVIEW_URL = "http://b.ppy.sh/preview/%d.mp3"; + /** + * Returns the name of the download server. + * @return the server name + */ + public abstract String getName(); + /** * Returns a web address to download the given beatmap. * @param beatmapSetID the beatmap set ID - * @return the URL string + * @return the URL string, or null if the address could not be determined */ - public abstract String getURL(int beatmapSetID); + public abstract String getDownloadURL(int beatmapSetID); /** * Returns a list of results for a given search query, or null if the @@ -45,6 +53,12 @@ public abstract class DownloadServer { */ public abstract DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException; + /** + * Returns the minimum allowable length of a search query. + * @return the minimum length, or 0 if none + */ + public abstract int minQueryLength(); + /** * Returns the total number of results for the last search query. * This will differ from the the size of the array returned by diff --git a/src/itdelatrisu/opsu/downloads/servers/HexideServer.java b/src/itdelatrisu/opsu/downloads/servers/HexideServer.java new file mode 100644 index 00000000..1d31289f --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/servers/HexideServer.java @@ -0,0 +1,139 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads.servers; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.DownloadNode; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Download server: https://osu.hexide.com/ + */ +public class HexideServer extends DownloadServer { + /** Server name. */ + private static final String SERVER_NAME = "Hexide"; + + /** Formatted download URL: {@code beatmapSetID,beatmapSetID} */ + private static final String DOWNLOAD_URL = "https://osu.hexide.com/beatmaps/%d/download/%d.osz"; + + /** API fields. */ + private static final String API_FIELDS = "maps.ranked_id;maps.title;maps.date;metadata.m_title;metadata.m_artist;metadata.m_creator"; + + /** Maximum beatmaps displayed per page. */ + private static final int PAGE_LIMIT = 20; + + /** Formatted home URL: {@code page} */ + private static final String HOME_URL = "https://osu.hexide.com/search/" + API_FIELDS + "/maps.size.gt.0/order.date.desc/limit.%d." + (PAGE_LIMIT + 1); + + /** Formatted search URL: {@code query,page} */ + private static final String SEARCH_URL = "https://osu.hexide.com/search/" + API_FIELDS + "/maps.title.like.%s/order.date.desc/limit.%d." + (PAGE_LIMIT + 1); + + /** Total result count from the last query. */ + private int totalResults = -1; + + /** Constructor. */ + public HexideServer() {} + + @Override + public String getName() { return SERVER_NAME; } + + @Override + public String getDownloadURL(int beatmapSetID) { + return String.format(DOWNLOAD_URL, beatmapSetID, beatmapSetID); + } + + @Override + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException { + DownloadNode[] nodes = null; + try { + // read JSON + int resultIndex = (page - 1) * PAGE_LIMIT; + String search; + if (query.isEmpty()) + search = String.format(HOME_URL, resultIndex); + else + search = String.format(SEARCH_URL, URLEncoder.encode(query, "UTF-8"), resultIndex); + URL searchURL = new URL(search); + JSONArray arr = null; + try { + arr = Utils.readJsonArrayFromUrl(searchURL); + } catch (IOException e1) { + // a valid search with no results still throws an exception (?) + this.totalResults = 0; + return new DownloadNode[0]; + } + if (arr == null) { + this.totalResults = -1; + return null; + } + + // parse result list + nodes = new DownloadNode[Math.min(arr.length(), PAGE_LIMIT)]; + for (int i = 0; i < nodes.length && i < PAGE_LIMIT; i++) { + JSONObject item = arr.getJSONObject(i); + String title, artist, creator; + if (item.has("versions")) { + JSONArray versions = item.getJSONArray("versions"); + JSONObject version = versions.getJSONObject(0); + title = version.getString("m_title"); + artist = version.getString("m_artist"); + creator = version.getString("m_creator"); + } else { // "versions" is sometimes missing (?) + String str = item.getString("title"); + int index = str.indexOf(" - "); + if (index > -1) { + title = str.substring(0, index); + artist = str.substring(index + 3); + creator = "?"; + } else { // should never happen... + title = str; + artist = creator = "?"; + } + } + nodes[i] = new DownloadNode( + item.getInt("ranked_id"), item.getString("date"), + title, null, artist, null, creator + ); + } + + // store total result count + // NOTE: The API doesn't provide a result count without retrieving + // all results at once; this approach just gets pagination correct. + this.totalResults = arr.length() + resultIndex; + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem loading result list for query '%s'.", query), e, true); + } + return nodes; + } + + @Override + public int minQueryLength() { return 0; } + + @Override + public int totalResults() { return totalResults; } +} diff --git a/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java b/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java new file mode 100644 index 00000000..d7179664 --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java @@ -0,0 +1,151 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads.servers; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.DownloadNode; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.TimeZone; + +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Download server: http://loli.al/ + */ +public class OsuMirrorServer extends DownloadServer { + /** Server name. */ + private static final String SERVER_NAME = "osu!Mirror"; + + /** Formatted download URL: {@code beatmapSetID} */ + private static final String DOWNLOAD_URL = "http://loli.al/d/%d/"; + + /** Formatted search URL: {@code page,query} */ + private static final String SEARCH_URL = "http://loli.al/mirror/search/%d.json?keyword=%s"; + + /** Formatted home URL: {@code page} */ + private static final String HOME_URL = "http://loli.al/mirror/home/%d.json"; + + /** Minimum allowable length of a search query. */ + private static final int MIN_QUERY_LENGTH = 3; + + /** Total result count from the last query. */ + private int totalResults = -1; + + /** Max server download ID seen (for approximating total pages). */ + private int maxServerID = 0; + + /** Lookup table from beatmap set ID -> server download ID. */ + private HashMap idTable = new HashMap(); + + /** Constructor. */ + public OsuMirrorServer() {} + + @Override + public String getName() { return SERVER_NAME; } + + @Override + public String getDownloadURL(int beatmapSetID) { + return (idTable.containsKey(beatmapSetID)) ? String.format(DOWNLOAD_URL, idTable.get(beatmapSetID)) : null; + } + + @Override + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException { + // NOTE: ignores 'rankedOnly' flag. + DownloadNode[] nodes = null; + try { + // read JSON + String search; + boolean isSearch; + if (query.isEmpty()) { + isSearch = false; + search = String.format(HOME_URL, page); + } else { + isSearch = true; + search = String.format(SEARCH_URL, page, URLEncoder.encode(query, "UTF-8")); + } + JSONObject json = Utils.readJsonObjectFromUrl(new URL(search)); + if (json == null || json.getInt("code") != 0) { + this.totalResults = -1; + return null; + } + + // parse result list + JSONArray arr = json.getJSONArray("maplist"); + nodes = new DownloadNode[arr.length()]; + for (int i = 0; i < nodes.length; i++) { + JSONObject item = arr.getJSONObject(i); + int beatmapSetID = item.getInt("OSUSetid"); + int serverID = item.getInt("id"); + nodes[i] = new DownloadNode( + beatmapSetID, formatDate(item.getString("ModifyDate")), + item.getString("Title"), null, + item.getString("Artist"), null, + item.getString("Mapper") + ); + idTable.put(beatmapSetID, serverID); + if (serverID > maxServerID) + maxServerID = serverID; + } + + // store total result count + if (isSearch) + this.totalResults = json.getInt("totalRows"); + else + this.totalResults = maxServerID; + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem loading result list for query '%s'.", query), e, true); + } + return nodes; + } + + @Override + public int minQueryLength() { return MIN_QUERY_LENGTH; } + + @Override + public int totalResults() { return totalResults; } + + /** + * Returns a formatted date string from a raw date. + * @param s the raw date string (e.g. "2015-05-14T23:38:47Z") + * @return the formatted date, or the raw string if it could not be parsed + */ + private String formatDate(String s) { + try { + DateFormat f = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + f.setTimeZone(TimeZone.getTimeZone("UTC")); + Date d = f.parse(s); + DateFormat fmt = new SimpleDateFormat("d MMM yyyy HH:mm:ss"); + return fmt.format(d); + } catch (ParseException e) { + return s; + } + } +} diff --git a/src/itdelatrisu/opsu/objects/Circle.java b/src/itdelatrisu/opsu/objects/Circle.java index 85f367da..cb9bada1 100644 --- a/src/itdelatrisu/opsu/objects/Circle.java +++ b/src/itdelatrisu/opsu/objects/Circle.java @@ -19,10 +19,12 @@ package itdelatrisu.opsu.objects; import itdelatrisu.opsu.GameData; +import itdelatrisu.opsu.GameData.HitObjectType; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; -import itdelatrisu.opsu.OsuHitObject; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.states.Game; import org.newdawn.slick.Color; @@ -32,14 +34,14 @@ import org.newdawn.slick.Graphics; /** * Data type representing a circle object. */ -public class Circle implements HitObject { +public class Circle implements GameObject { /** The amount of time, in milliseconds, to fade in the circle. */ private static final int FADE_IN_TIME = 375; private static float diameter; - /** The associated OsuHitObject. */ - private OsuHitObject hitObject; + /** The associated HitObject. */ + private HitObject hitObject; /** The scaled starting x, y coordinates. */ private float x, y; @@ -69,17 +71,22 @@ public class Circle implements HitObject { GameImage.HITCIRCLE.setImage(GameImage.HITCIRCLE.getImage().getScaledCopy(diameterInt, diameterInt)); GameImage.HITCIRCLE_OVERLAY.setImage(GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(diameterInt, diameterInt)); GameImage.APPROACHCIRCLE.setImage(GameImage.APPROACHCIRCLE.getImage().getScaledCopy(diameterInt, diameterInt)); + /*int diameter = (int) (104 - (circleSize * 8)); + diameter = (int) (diameter * HitObject.getXMultiplier()); // convert from Osupixels (640x480) + GameImage.HITCIRCLE.setImage(GameImage.HITCIRCLE.getImage().getScaledCopy(diameter, diameter)); + GameImage.HITCIRCLE_OVERLAY.setImage(GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(diameter, diameter)); + GameImage.APPROACHCIRCLE.setImage(GameImage.APPROACHCIRCLE.getImage().getScaledCopy(diameter, diameter));*/ } /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject * @param game the associated Game object * @param data the associated GameData object * @param color the color of this circle * @param comboEnd true if this is the last hit object in the combo */ - public Circle(OsuHitObject hitObject, Game game, GameData data, Color color, boolean comboEnd) { + public Circle(HitObject hitObject, Game game, GameData data, Color color, boolean comboEnd) { this.hitObject = hitObject; this.game = game; this.data = data; @@ -102,9 +109,13 @@ public class Circle implements HitObject { if (timeDiff >= 0) GameImage.APPROACHCIRCLE.getImage().getScaledCopy(approachScale).drawCentered(x, y, color); GameImage.HITCIRCLE.getImage().drawCentered(x, y, color); - GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Utils.COLOR_WHITE_FADE); + boolean overlayAboveNumber = Options.getSkin().isHitCircleOverlayAboveNumber(); + if (!overlayAboveNumber) + GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Utils.COLOR_WHITE_FADE); data.drawSymbolNumber(hitObject.getComboNumber(), x, y, GameImage.HITCIRCLE.getImage().getWidth() * 0.40f / data.getDefaultSymbolImage(0).getHeight(), alpha); + if (overlayAboveNumber) + GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Utils.COLOR_WHITE_FADE); Utils.COLOR_WHITE_FADE.a = oldAlpha; } @@ -144,7 +155,7 @@ public class Circle implements HitObject { if (result > -1) { data.addHitError(hitObject.getTime(), x, y, timeDiff); - data.hitResult(hitObject.getTime(), result, this.x, this.y, color, comboEnd, hitObject, 0); + data.hitResult(trackPosition, result, this.x, this.y, color, comboEnd, hitObject, 0, HitObjectType.CIRCLE, null, true); return true; } } @@ -160,17 +171,17 @@ public class Circle implements HitObject { 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, 0); + data.hitResult(time, GameData.HIT_300, x, y, color, comboEnd, hitObject, 0, HitObjectType.CIRCLE, null, true); else // no more points can be scored, so send a miss - data.hitResult(time, GameData.HIT_MISS, x, y, null, comboEnd, hitObject, 0); + data.hitResult(trackPosition, GameData.HIT_MISS, x, y, null, comboEnd, hitObject, 0, HitObjectType.CIRCLE, null, true); 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, 0); + data.hitResult(time, GameData.HIT_300, x, y, color, comboEnd, hitObject, 0, HitObjectType.CIRCLE, null, true); return true; } } diff --git a/src/itdelatrisu/opsu/objects/DummyObject.java b/src/itdelatrisu/opsu/objects/DummyObject.java index 4f727345..80f920a7 100644 --- a/src/itdelatrisu/opsu/objects/DummyObject.java +++ b/src/itdelatrisu/opsu/objects/DummyObject.java @@ -18,25 +18,25 @@ package itdelatrisu.opsu.objects; -import itdelatrisu.opsu.OsuHitObject; +import itdelatrisu.opsu.beatmap.HitObject; import org.newdawn.slick.Graphics; /** - * Dummy hit object, used when another HitObject class cannot be created. + * Dummy hit object, used when another GameObject class cannot be created. */ -public class DummyObject implements HitObject { - /** The associated OsuHitObject. */ - private OsuHitObject hitObject; +public class DummyObject implements GameObject { + /** The associated HitObject. */ + private HitObject hitObject; /** The scaled starting x, y coordinates. */ private float x, y; /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject */ - public DummyObject(OsuHitObject hitObject) { + public DummyObject(HitObject hitObject) { this.hitObject = hitObject; updatePosition(); } diff --git a/src/itdelatrisu/opsu/objects/GameObject.java b/src/itdelatrisu/opsu/objects/GameObject.java new file mode 100644 index 00000000..ca1ab8e5 --- /dev/null +++ b/src/itdelatrisu/opsu/objects/GameObject.java @@ -0,0 +1,72 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.objects; + +import org.newdawn.slick.Graphics; + +/** + * Interface for hit object types used during gameplay. + */ +public interface GameObject { + /** + * Draws the hit object to the graphics context. + * @param g the graphics context + * @param trackPosition the current track position + */ + public void draw(Graphics g, int trackPosition); + + /** + * Updates the hit object. + * @param overlap true if the next object's start time has already passed + * @param delta the delta interval since the last call + * @param mouseX the x coordinate of the mouse + * @param mouseY the y coordinate of the mouse + * @param keyPressed whether or not a game key is currently pressed + * @param trackPosition the track position + * @return true if object ended + */ + public boolean update(boolean overlap, int delta, int mouseX, int mouseY, boolean keyPressed, int trackPosition); + + /** + * Processes a mouse click. + * @param x the x coordinate of the mouse + * @param y the y coordinate of the mouse + * @param trackPosition the track position + * @return true if a hit result was processed + */ + public boolean mousePressed(int x, int y, int trackPosition); + + /** + * Returns the coordinates of the hit object at a given track position. + * @param trackPosition the track position + * @return the [x,y] coordinates + */ + public float[] getPointAt(int trackPosition); + + /** + * Returns the end time of the hit object. + * @return the end time, in milliseconds + */ + public int getEndTime(); + + /** + * Updates the position of the hit object. + */ + public void updatePosition(); +} diff --git a/src/itdelatrisu/opsu/objects/HitObject.java b/src/itdelatrisu/opsu/objects/HitObject.java index 0be6c3e0..d4602e2d 100644 --- a/src/itdelatrisu/opsu/objects/HitObject.java +++ b/src/itdelatrisu/opsu/objects/HitObject.java @@ -1,3 +1,5 @@ +//TODO rename + /* * opsu! - an open-source osu! client * Copyright (C) 2014, 2015 Jeffrey Han diff --git a/src/itdelatrisu/opsu/objects/Slider.java b/src/itdelatrisu/opsu/objects/Slider.java index fb88aa11..ac50e429 100644 --- a/src/itdelatrisu/opsu/objects/Slider.java +++ b/src/itdelatrisu/opsu/objects/Slider.java @@ -19,11 +19,14 @@ package itdelatrisu.opsu.objects; import itdelatrisu.opsu.GameData; +import itdelatrisu.opsu.GameData.HitObjectType; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; -import itdelatrisu.opsu.OsuFile; -import itdelatrisu.opsu.OsuHitObject; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.objects.curves.CatmullCurve; import itdelatrisu.opsu.objects.curves.CircumscribedCircle; import itdelatrisu.opsu.objects.curves.Curve; import itdelatrisu.opsu.objects.curves.LinearBezier; @@ -37,7 +40,7 @@ import org.newdawn.slick.Image; /** * Data type representing a slider object. */ -public class Slider implements HitObject { +public class Slider implements GameObject { /** Slider ball frames. */ private static Image[] sliderBallImages; @@ -54,8 +57,8 @@ public class Slider implements HitObject { /** The amount of time, in milliseconds, to fade in the slider. */ private static final int FADE_IN_TIME = 375; - /** The associated OsuHitObject. */ - private OsuHitObject hitObject; + /** The associated HitObject. */ + private HitObject hitObject; /** The scaled starting x, y coordinates. */ protected float x, y; @@ -108,9 +111,9 @@ public class Slider implements HitObject { * Initializes the Slider data type with images and dimensions. * @param container the game container * @param circleSize the map's circleSize value - * @param osu the associated OsuFile object + * @param beatmap the associated beatmap */ - public static void init(GameContainer container, float circleSize, OsuFile osu) { + public static void init(GameContainer container, float circleSize, Beatmap beatmap) { containerWidth = container.getWidth(); containerHeight = container.getHeight(); @@ -119,7 +122,11 @@ public class Slider implements HitObject { int diameterInt = (int)diameter; followRadius = diameter / 2 * 3f; - + //TODO conflict + /* + int diameter = (int) (104 - (circleSize * 8)); + diameter = (int) (diameter * HitObject.getXMultiplier()); // convert from Osupixels (640x480) + */ // slider ball if (GameImage.SLIDER_BALL.hasSkinImages() || (!GameImage.SLIDER_BALL.hasSkinImage() && GameImage.SLIDER_BALL.getImages() != null)) @@ -133,19 +140,19 @@ public class Slider implements HitObject { GameImage.REVERSEARROW.setImage(GameImage.REVERSEARROW.getImage().getScaledCopy(diameterInt, diameterInt)); GameImage.SLIDER_TICK.setImage(GameImage.SLIDER_TICK.getImage().getScaledCopy(diameterInt / 4, diameterInt / 4)); - sliderMultiplier = osu.sliderMultiplier; - sliderTickRate = osu.sliderTickRate; + sliderMultiplier = beatmap.sliderMultiplier; + sliderTickRate = beatmap.sliderTickRate; } /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject * @param game the associated Game object * @param data the associated GameData object * @param color the color of this slider * @param comboEnd true if this is the last hit object in the combo */ - public Slider(OsuHitObject hitObject, Game game, GameData data, Color color, boolean comboEnd) { + public Slider(HitObject hitObject, Game game, GameData data, Color color, boolean comboEnd) { this.hitObject = hitObject; this.game = game; this.data = data; @@ -176,12 +183,25 @@ public class Slider implements HitObject { float fadeinScale = (timeDiff - game.getApproachTime() + FADE_IN_TIME) / (float) FADE_IN_TIME; float approachScale = 1 + scale * 3; float alpha = Utils.clamp(1 - fadeinScale, 0, 1); + boolean overlayAboveNumber = Options.getSkin().isHitCircleOverlayAboveNumber(); float oldAlpha = Utils.COLOR_WHITE_FADE.a; Utils.COLOR_WHITE_FADE.a = color.a = alpha; + Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); + Image hitCircle = GameImage.HITCIRCLE.getImage(); + float[] endPos = curve.pointAt(1); - // curve - curve.draw(); + curve.draw(color); + color.a = alpha; + + // end circle + hitCircle.drawCentered(endPos[0], endPos[1], color); + hitCircleOverlay.drawCentered(endPos[0], endPos[1], Utils.COLOR_WHITE_FADE); + + // start circle + hitCircle.drawCentered(x, y, color); + if (!overlayAboveNumber) + hitCircleOverlay.drawCentered(x, y, Utils.COLOR_WHITE_FADE); // ticks if (ticksT != null) { @@ -191,23 +211,13 @@ public class Slider implements HitObject { tick.drawCentered(c[0], c[1], Utils.COLOR_WHITE_FADE); } } - - Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); - Image hitCircle = GameImage.HITCIRCLE.getImage(); - - // end circle - float[] endPos = curve.pointAt(1); - hitCircle.drawCentered(endPos[0], endPos[1], color); - hitCircleOverlay.drawCentered(endPos[0], endPos[1], Utils.COLOR_WHITE_FADE); - - // start circle - hitCircle.drawCentered(x, y, color); - hitCircleOverlay.drawCentered(x, y, Utils.COLOR_WHITE_FADE); if (sliderClickedInitial) ; // don't draw current combo number if already clicked else data.drawSymbolNumber(hitObject.getComboNumber(), x, y, - hitCircle.getWidth() * 0.40f / data.getDefaultSymbolImage(0).getHeight(), alpha); + hitCircle.getWidth() * 0.40f / data.getDefaultSymbolImage(0).getHeight(), alpha); + if (overlayAboveNumber) + hitCircleOverlay.drawCentered(x, y, Utils.COLOR_WHITE_FADE); // repeats for (int tcurRepeat = currentRepeats; tcurRepeat <= currentRepeats + 1; tcurRepeat++) { @@ -245,7 +255,7 @@ public class Slider implements HitObject { float[] c2 = curve.pointAt(getT(trackPosition, false) + 0.01f); float t = getT(trackPosition, false); -// float dis = hitObject.getPixelLength() * OsuHitObject.getXMultiplier() * (t - (int) t); +// float dis = hitObject.getPixelLength() * HitObject.getXMultiplier() * (t - (int) t); // Image sliderBallFrame = sliderBallImages[(int) (dis / (diameter * Math.PI) * 30) % sliderBallImages.length]; Image sliderBallFrame = sliderBallImages[(int) (t * sliderTime * 60 / 1000) % sliderBallImages.length]; float angle = (float) (Math.atan2(c2[1] - c[1], c2[0] - c[0]) * 180 / Math.PI); @@ -336,14 +346,20 @@ public class Slider implements HitObject { else result = GameData.HIT_MISS; + float cx, cy; + HitObjectType type; if (currentRepeats % 2 == 0) { // last circle float[] lastPos = curve.pointAt(1); - data.hitResult(hitObject.getTime() + (int) sliderTimeTotal, result, - lastPos[0], lastPos[1], color, comboEnd, hitObject, currentRepeats + 1); + cx = lastPos[0]; + cy = lastPos[1]; + type = HitObjectType.SLIDER_LAST; } else { // first circle - data.hitResult(hitObject.getTime() + (int) sliderTimeTotal, result, - x, y, color, comboEnd, hitObject, currentRepeats + 1); + cx = x; + cy = y; + type = HitObjectType.SLIDER_FIRST; } + data.hitResult(hitObject.getTime() + (int) sliderTimeTotal, result, + cx, cy, color, comboEnd, hitObject, currentRepeats + 1, type, curve, sliderClickedFinal); return result; } @@ -509,10 +525,12 @@ public class Slider implements HitObject { this.x = hitObject.getScaledX(); this.y = hitObject.getScaledY(); - if (hitObject.getSliderType() == OsuHitObject.SLIDER_PASSTHROUGH && hitObject.getSliderX().length == 2) + if (hitObject.getSliderType() == HitObject.SLIDER_PASSTHROUGH && hitObject.getSliderX().length == 2) this.curve = new CircumscribedCircle(hitObject, color); + else if (hitObject.getSliderType() == HitObject.SLIDER_CATMULL) + this.curve = new CatmullCurve(hitObject, color); else - this.curve = new LinearBezier(hitObject, color); + this.curve = new LinearBezier(hitObject, color, hitObject.getSliderType() == HitObject.SLIDER_LINEAR); } @Override diff --git a/src/itdelatrisu/opsu/objects/Spinner.java b/src/itdelatrisu/opsu/objects/Spinner.java index acc8c28d..c8298ff8 100644 --- a/src/itdelatrisu/opsu/objects/Spinner.java +++ b/src/itdelatrisu/opsu/objects/Spinner.java @@ -19,12 +19,14 @@ package itdelatrisu.opsu.objects; import itdelatrisu.opsu.GameData; +import itdelatrisu.opsu.GameData.HitObjectType; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; -import itdelatrisu.opsu.OsuHitObject; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.states.Game; import org.newdawn.slick.Color; @@ -35,7 +37,7 @@ import org.newdawn.slick.Image; /** * Data type representing a spinner object. */ -public class Spinner implements HitObject { +public class Spinner implements GameObject { /** Container dimensions. */ private static int width, height; @@ -62,8 +64,8 @@ public class Spinner implements HitObject { private static final float MAX_ANG_DIFF = DELTA_UPDATE_TIME * 477 / 60 / 1000 * TWO_PI; // ~95.3 - /** The associated OsuHitObject. */ - private OsuHitObject hitObject; + /** The associated HitObject. */ + private HitObject hitObject; /** The associated GameData object. */ private GameData data; @@ -110,11 +112,11 @@ public class Spinner implements HitObject { /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject * @param game the associated Game object * @param data the associated GameData object */ - public Spinner(OsuHitObject hitObject, Game game, GameData data) { + public Spinner(HitObject hitObject, Game game, GameData data) { this.hitObject = hitObject; this.data = data; @@ -132,15 +134,17 @@ public class Spinner implements HitObject { return; boolean spinnerComplete = (rotations >= rotationsNeeded); + float alpha = Utils.clamp(1 - (float) timeDiff / FADE_IN_TIME, 0f, 1f); // darken screen - float alpha = Utils.clamp(1 - (float) timeDiff / FADE_IN_TIME, 0f, 1f); - float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; - if (timeDiff > 0) - Utils.COLOR_BLACK_ALPHA.a *= alpha; - g.setColor(Utils.COLOR_BLACK_ALPHA); - g.fillRect(0, 0, width, height); - Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + if (Options.getSkin().isSpinnerFadePlayfield()) { + float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; + if (timeDiff > 0) + Utils.COLOR_BLACK_ALPHA.a *= alpha; + g.setColor(Utils.COLOR_BLACK_ALPHA); + g.fillRect(0, 0, width, height); + Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + } // rpm Image rpmImg = GameImage.SPINNER_RPM.getImage(); @@ -198,7 +202,7 @@ public class Spinner implements HitObject { result = GameData.HIT_MISS; data.hitResult(hitObject.getEndTime(), result, width / 2, height / 2, - Color.transparent, true, hitObject, 0); + Color.transparent, true, hitObject, 0, HitObjectType.SPINNER, null, true); return result; } diff --git a/src/itdelatrisu/opsu/objects/curves/Bezier2.java b/src/itdelatrisu/opsu/objects/curves/Bezier2.java index 35552b4a..df0a5e09 100644 --- a/src/itdelatrisu/opsu/objects/curves/Bezier2.java +++ b/src/itdelatrisu/opsu/objects/curves/Bezier2.java @@ -23,22 +23,10 @@ package itdelatrisu.opsu.objects.curves; * * @author fluddokt (https://github.com/fluddokt) */ -public class Bezier2 { +public class Bezier2 extends CurveType { /** The control points of the Bezier curve. */ private Vec2f[] points; - /** Points along the curve of the Bezier curve. */ - private Vec2f[] curve; - - /** Distances between a point of the curve and the last point. */ - private float[] curveDis; - - /** The number of points along the curve. */ - private int ncurve; - - /** The total distances of this Bezier. */ - private float totalDistance; - /** * Constructor. * @param points the control points @@ -52,27 +40,10 @@ public class Bezier2 { for (int i = 0; i < points.length - 1; i++) approxlength += points[i].cpy().sub(points[i + 1]).len(); - // subdivide the curve - this.ncurve = (int) (approxlength / 4); - this.curve = new Vec2f[ncurve]; - for (int i = 0; i < ncurve; i++) - curve[i] = pointAt(i / (float) ncurve); - - // find the distance of each point from the previous point - this.curveDis = new float[ncurve]; - this.totalDistance = 0; - for (int i = 0; i < ncurve; i++) { - curveDis[i] = (i == 0) ? 0 : curve[i].cpy().sub(curve[i - 1]).len(); - totalDistance += curveDis[i]; - } -// System.out.println("New Bezier2 "+points.length+" "+approxlength+" "+totalDistance()); + init(approxlength); } - /** - * Returns the point on the Bezier curve at a value t. - * @param t the t value [0, 1] - * @return the point [x, y] - */ + @Override public Vec2f pointAt(float t) { Vec2f c = new Vec2f(); int n = points.length - 1; @@ -84,31 +55,11 @@ public class Bezier2 { return c; } - /** - * Returns the points along the curve of the Bezier curve. - */ - public Vec2f[] getCurve() { return curve; } - - /** - * Returns the distances between a point of the curve and the last point. - */ - public float[] getCurveDistances() { return curveDis; } - - /** - * Returns the number of points along the curve. - */ - public int points() { return ncurve; } - - /** - * Returns the total distances of this Bezier curve. - */ - public float totalDistance() { return totalDistance; } - /** * Calculates the binomial coefficient. * http://en.wikipedia.org/wiki/Binomial_coefficient#Binomial_coefficient_in_programming_languages */ - public static long binomialCoefficient(int n, int k) { + private static long binomialCoefficient(int n, int k) { if (k < 0 || k > n) return 0; if (k == 0 || k == n) diff --git a/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java b/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java new file mode 100644 index 00000000..95842e77 --- /dev/null +++ b/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java @@ -0,0 +1,77 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.objects.curves; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.beatmap.HitObject; + +import java.util.LinkedList; + +import org.newdawn.slick.Color; +import org.newdawn.slick.SlickException; + +/** + * Representation of Catmull Curve with equidistant points. + * + * @author fluddokt (https://github.com/fluddokt) + */ +public class CatmullCurve extends EqualDistanceMultiCurve { + /** + * Constructor. + * @param hitObject the associated HitObject + * @param color the color of this curve + */ + public CatmullCurve(HitObject hitObject, Color color) { + super(hitObject, color); + LinkedList catmulls = new LinkedList(); + int ncontrolPoints = hitObject.getSliderX().length + 1; + LinkedList points = new LinkedList(); // temporary list of points to separate different curves + + // repeat the first and last points as controls points + // only if the first/last two points are different + // aabb + // aabc abcc + // aabc abcd bcdd + if (getX(0) != getX(1) || getY(0) != getY(1)) + points.addLast(new Vec2f(getX(0), getY(0))); + for (int i = 0; i < ncontrolPoints; i++) { + points.addLast(new Vec2f(getX(i), getY(i))); + if (points.size() >= 4) { + try { + catmulls.add(new CentripetalCatmullRom(points.toArray(new Vec2f[0]))); + } catch (SlickException e) { + ErrorHandler.error(null, e, true); + } + points.removeFirst(); + } + } + if (getX(ncontrolPoints - 1) != getX(ncontrolPoints - 2) + ||getY(ncontrolPoints - 1) != getY(ncontrolPoints - 2)) + points.addLast(new Vec2f(getX(ncontrolPoints - 1), getY(ncontrolPoints - 1))); + if (points.size() >= 4) { + try { + catmulls.add(new CentripetalCatmullRom(points.toArray(new Vec2f[0]))); + } catch (SlickException e) { + ErrorHandler.error(null, e, true); + } + } + + init(catmulls); + } +} diff --git a/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java b/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java new file mode 100644 index 00000000..5a13d7ce --- /dev/null +++ b/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java @@ -0,0 +1,85 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.objects.curves; + +import org.newdawn.slick.SlickException; + +/** + * Representation of a Centripetal Catmull–Rom spline. + * (Currently not technically Centripetal Catmull–Rom.) + * http://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline + * + * @author fluddokt (https://github.com/fluddokt) + */ +public class CentripetalCatmullRom extends CurveType { + /** The time values of the Catmull curve. */ + private float [] time; + + /** The control points of the Catmull curve. */ + private Vec2f[] points; + + /** + * Constructor. + * @param points the control points of the curve + * @throws SlickException + */ + protected CentripetalCatmullRom(Vec2f[] points) throws SlickException { + if (points.length != 4) + throw new SlickException(String.format("Need exactly 4 points to initialize CentripetalCatmullRom, %d provided.", points.length)); + + this.points = points; + time = new float[4]; + time[0] = 0; + float approxLength = 0; + for (int i = 1; i < 4; i++) { + float len = 0; + if (i > 0) + len = points[i].cpy().sub(points[i - 1]).len(); + if (len <= 0) + len += 0.0001f; + approxLength += len; + // time[i] = (float) Math.sqrt(len) + time[i-1];// ^(0.5) + time[i] = i; + } + + init(approxLength / 2); + } + + @Override + public Vec2f pointAt(float t) { + t = t * (time[2] - time[1]) + time[1]; + + Vec2f A1 = points[0].cpy().scale((time[1] - t) / (time[1] - time[0])) + .add(points[1].cpy().scale((t - time[0]) / (time[1] - time[0]))); + Vec2f A2 = points[1].cpy().scale((time[2] - t) / (time[2] - time[1])) + .add(points[2].cpy().scale((t - time[1]) / (time[2] - time[1]))); + Vec2f A3 = points[2].cpy().scale((time[3] - t) / (time[3] - time[2])) + .add(points[3].cpy().scale((t - time[2]) / (time[3] - time[2]))); + + Vec2f B1 = A1.cpy().scale((time[2] - t) / (time[2] - time[0])) + .add(A2.cpy().scale((t - time[0]) / (time[2] - time[0]))); + Vec2f B2 = A2.cpy().scale((time[3] - t) / (time[3] - time[1])) + .add(A3.cpy().scale((t - time[1]) / (time[3] - time[1]))); + + Vec2f C = B1.cpy().scale((time[2] - t) / (time[2] - time[1])) + .add(B2.cpy().scale((t - time[1]) / (time[2] - time[1]))); + + return C; + } +} diff --git a/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java b/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java index 74ac354d..b62c17af 100644 --- a/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java +++ b/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java @@ -19,12 +19,9 @@ package itdelatrisu.opsu.objects.curves; import itdelatrisu.opsu.ErrorHandler; -import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.OsuHitObject; -import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.HitObject; import org.newdawn.slick.Color; -import org.newdawn.slick.Image; /** * Representation of a curve along a Circumscribed Circle of three points. @@ -53,22 +50,14 @@ public class CircumscribedCircle extends Curve { /** The start and end angles for drawing. */ private float drawStartAngle, drawEndAngle; - /** The number of steps in the curve to draw. */ - private float step; - - /** Points along the curve. */ - private Vec2f[] curve; - /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject * @param color the color of this curve */ - public CircumscribedCircle(OsuHitObject hitObject, Color color) { + public CircumscribedCircle(HitObject hitObject, Color color) { super(hitObject, color); - this.step = hitObject.getPixelLength() / 5f; - // construct the three points this.start = new Vec2f(getX(0), getY(0)); this.mid = new Vec2f(getX(1), getY(1)); @@ -114,7 +103,7 @@ public class CircumscribedCircle extends Curve { // find an angle with an arc length of pixelLength along this circle this.radius = startAngPoint.len(); - float pixelLength = hitObject.getPixelLength() * OsuHitObject.getXMultiplier(); + float pixelLength = hitObject.getPixelLength() * HitObject.getXMultiplier(); float arcAng = pixelLength / radius; // len = theta * r / theta = len / r // now use it for our new end angle @@ -125,6 +114,7 @@ public class CircumscribedCircle extends Curve { this.drawStartAngle = (float) ((startAng + (startAng > endAng ? -HALF_PI : HALF_PI)) * 180 / Math.PI); // calculate points + float step = hitObject.getPixelLength() / CURVE_POINTS_SEPERATION; curve = new Vec2f[(int) step + 1]; for (int i = 0; i < curve.length; i++) { float[] xy = pointAt(i / step); @@ -178,16 +168,6 @@ public class CircumscribedCircle extends Curve { }; } - @Override - public void draw() { - Image hitCircle = GameImage.HITCIRCLE.getImage(); - Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); - for (int i = 0; i < step; i++) - hitCircleOverlay.drawCentered(curve[i].x, curve[i].y, Utils.COLOR_WHITE_FADE); - for (int i = 0; i < step; i++) - hitCircle.drawCentered(curve[i].x, curve[i].y, color); - } - @Override public float getEndAngle() { return drawEndAngle; } diff --git a/src/itdelatrisu/opsu/objects/curves/Curve.java b/src/itdelatrisu/opsu/objects/curves/Curve.java index 028923be..1ae526ab 100644 --- a/src/itdelatrisu/opsu/objects/curves/Curve.java +++ b/src/itdelatrisu/opsu/objects/curves/Curve.java @@ -18,9 +18,17 @@ package itdelatrisu.opsu.objects.curves; -import itdelatrisu.opsu.OsuHitObject; +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Options; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.render.CurveRenderState; +import itdelatrisu.opsu.skins.Skin; +import org.lwjgl.opengl.GLContext; import org.newdawn.slick.Color; +import org.newdawn.slick.Image; +import org.newdawn.slick.util.Log; /** * Representation of a curve. @@ -28,11 +36,17 @@ import org.newdawn.slick.Color; * @author fluddokt (https://github.com/fluddokt) */ public abstract class Curve { - /** The associated OsuHitObject. */ - protected OsuHitObject hitObject; + /** Points generated along the curve should be spaced this far apart. */ + protected static float CURVE_POINTS_SEPERATION = 5; - /** The color of this curve. */ - protected Color color; + /** The curve border color. */ + private static Color borderColor; + + /** Whether OpenGL 3.0 is supported. */ + private static boolean openGL30 = false; + + /** The associated HitObject. */ + protected HitObject hitObject; /** The scaled starting x, y coordinates. */ protected float x, y; @@ -40,18 +54,43 @@ public abstract class Curve { /** The scaled slider x, y coordinate lists. */ protected float[] sliderX, sliderY; + /** Per-curve render-state used for the new style curve renders. */ + private CurveRenderState renderState; + + /** Points along the curve (set by inherited classes). */ + protected Vec2f[] curve; + /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject * @param color the color of this curve */ - protected Curve(OsuHitObject hitObject, Color color) { + protected Curve(HitObject hitObject, Color color) { this.hitObject = hitObject; this.x = hitObject.getScaledX(); this.y = hitObject.getScaledY(); this.sliderX = hitObject.getScaledSliderX(); this.sliderY = hitObject.getScaledSliderY(); - this.color = color; + this.renderState = null; + } + + /** + * Set the width and height of the container that Curves get drawn into. + * Should be called before any curves are drawn. + * @param width the container width + * @param height the container height + * @param circleSize the circle size + * @param borderColor the curve border color + */ + public static void init(int width, int height, float circleSize, Color borderColor) { + Curve.borderColor = borderColor; + openGL30 = GLContext.getCapabilities().OpenGL30; + if (openGL30) + CurveRenderState.init(width, height, circleSize); + else { + if (Options.getSkin().getSliderStyle() != Skin.STYLE_PEPPYSLIDER) + Log.warn("New slider style requires OpenGL 3.0, which is not supported."); + } } /** @@ -63,8 +102,29 @@ public abstract class Curve { /** * Draws the full curve to the graphics context. + * @param color the color filter */ - public abstract void draw(); + public void draw(Color color) { + if (curve == null) + return; + + // peppysliders + if (Options.getSkin().getSliderStyle() == Skin.STYLE_PEPPYSLIDER || !openGL30) { + Image hitCircle = GameImage.HITCIRCLE.getImage(); + Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); + for (int i = 0; i < curve.length; i++) + hitCircleOverlay.drawCentered(curve[i].x, curve[i].y, Utils.COLOR_WHITE_FADE); + for (int i = 0; i < curve.length; i++) + hitCircle.drawCentered(curve[i].x, curve[i].y, color); + } + + // mmsliders + else { + if (renderState == null) + renderState = new CurveRenderState(hitObject); + renderState.draw(color, borderColor, curve); + } + } /** * Returns the angle of the first control point. @@ -94,4 +154,12 @@ public abstract class Curve { protected float lerp(float a, float b, float t) { return a * (1 - t) + b * t; } + + /** + * Discards the slider cache (only used for mmsliders). + */ + public void discardCache() { + if (renderState != null) + renderState.discardCache(); + } } diff --git a/src/itdelatrisu/opsu/objects/curves/CurveType.java b/src/itdelatrisu/opsu/objects/curves/CurveType.java new file mode 100644 index 00000000..4af02a62 --- /dev/null +++ b/src/itdelatrisu/opsu/objects/curves/CurveType.java @@ -0,0 +1,86 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.objects.curves; + +/** + * Representation of a curve with the distance between each point calculated. + * + * @author fluddokt (https://github.com/fluddokt) + */ +public abstract class CurveType { + /** Points along the curve of the Bezier curve. */ + private Vec2f[] curve; + + /** Distances between a point of the curve and the last point. */ + private float[] curveDis; + + /** The number of points along the curve. */ + private int ncurve; + + /** The total distances of this Bezier. */ + private float totalDistance; + + /** + * Returns the point on the curve at a value t. + * @param t the t value [0, 1] + * @return the point [x, y] + */ + public abstract Vec2f pointAt(float t); + + /** + * Initialize the curve points and distance. + * Must be called by inherited classes. + * @param approxlength an approximate length of the curve + */ + public void init(float approxlength) { + // subdivide the curve + this.ncurve = (int) (approxlength / 4) + 2; + this.curve = new Vec2f[ncurve]; + for (int i = 0; i < ncurve; i++) + curve[i] = pointAt(i / (float) (ncurve - 1)); + + // find the distance of each point from the previous point + this.curveDis = new float[ncurve]; + this.totalDistance = 0; + for (int i = 0; i < ncurve; i++) { + curveDis[i] = (i == 0) ? 0 : curve[i].cpy().sub(curve[i - 1]).len(); + totalDistance += curveDis[i]; + } + } + + /** + * Returns the points along the curve of the Bezier curve. + */ + public Vec2f[] getCurvePoint() { return curve; } + + /** + * Returns the distances between a point of the curve and the last point. + */ + public float[] getCurveDistances() { return curveDis; } + + /** + * Returns the number of points along the curve. + */ + public int getCurvesCount() { return ncurve; } + + /** + * Returns the total distances of this Bezier curve. + */ + public float totalDistance() { return totalDistance; } +} diff --git a/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java b/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java new file mode 100644 index 00000000..a15a1657 --- /dev/null +++ b/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java @@ -0,0 +1,142 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.objects.curves; + +import itdelatrisu.opsu.beatmap.HitObject; + +import java.util.Iterator; +import java.util.LinkedList; + +import org.newdawn.slick.Color; + +/** + * Representation of multiple curve with equidistant points. + * http://pomax.github.io/bezierinfo/#tracing + * + * @author fluddokt (https://github.com/fluddokt) + */ +public abstract class EqualDistanceMultiCurve extends Curve { + /** The angles of the first and last control points for drawing. */ + private float startAngle, endAngle; + + /** The number of points along the curve. */ + private int ncurve; + + /** + * Constructor. + * @param hitObject the associated HitObject + * @param color the color of this curve + */ + public EqualDistanceMultiCurve(HitObject hitObject, Color color) { + super(hitObject, color); + } + + /** + * Initialize the curve points with equal distance. + * Must be called by inherited classes. + * @param curvesList a list of curves to join + */ + public void init(LinkedList curvesList){ + // now try to creates points the are equidistant to each other + this.ncurve = (int) (hitObject.getPixelLength() / CURVE_POINTS_SEPERATION); + this.curve = new Vec2f[ncurve + 1]; + + float distanceAt = 0; + Iterator iter = curvesList.iterator(); + int curPoint = 0; + CurveType curCurve = iter.next(); + Vec2f lastCurve = curCurve.getCurvePoint()[0]; + float lastDistanceAt = 0; + + // length of Curve should equal pixel length (in 640x480) + float pixelLength = hitObject.getPixelLength() * HitObject.getXMultiplier(); + + // for each distance, try to get in between the two points that are between it + for (int i = 0; i < ncurve + 1; i++) { + int prefDistance = (int) (i * pixelLength / ncurve); + while (distanceAt < prefDistance) { + lastDistanceAt = distanceAt; + lastCurve = curCurve.getCurvePoint()[curPoint]; + curPoint++; + + if (curPoint >= curCurve.getCurvesCount()) { + if (iter.hasNext()) { + curCurve = iter.next(); + curPoint = 0; + } else { + curPoint = curCurve.getCurvesCount() - 1; + if (lastDistanceAt == distanceAt) { + // out of points even though the preferred distance hasn't been reached + break; + } + } + } + distanceAt += curCurve.getCurveDistances()[curPoint]; + } + Vec2f thisCurve = curCurve.getCurvePoint()[curPoint]; + + // interpolate the point between the two closest distances + if (distanceAt - lastDistanceAt > 1) { + float t = (prefDistance - lastDistanceAt) / (distanceAt - lastDistanceAt); + curve[i] = new Vec2f(lerp(lastCurve.x, thisCurve.x, t), lerp(lastCurve.y, thisCurve.y, t)); + } else + curve[i] = thisCurve; + } + +// if (hitObject.getRepeatCount() > 1) { + Vec2f c1 = curve[0]; + int cnt = 1; + Vec2f c2 = curve[cnt++]; + while (cnt <= ncurve && c2.cpy().sub(c1).len() < 1) + c2 = curve[cnt++]; + this.startAngle = (float) (Math.atan2(c2.y - c1.y, c2.x - c1.x) * 180 / Math.PI); + + c1 = curve[ncurve]; + cnt = ncurve - 1; + c2 = curve[cnt--]; + while (cnt >= 0 && c2.cpy().sub(c1).len() < 1) + c2 = curve[cnt--]; + this.endAngle = (float) (Math.atan2(c2.y - c1.y, c2.x - c1.x) * 180 / Math.PI); +// } + } + + @Override + public float[] pointAt(float t) { + float indexF = t * ncurve; + int index = (int) indexF; + if (index >= ncurve) { + Vec2f poi = curve[ncurve]; + return new float[] { poi.x, poi.y }; + } else { + Vec2f poi = curve[index]; + Vec2f poi2 = curve[index + 1]; + float t2 = indexF - index; + return new float[] { + lerp(poi.x, poi2.x, t2), + lerp(poi.y, poi2.y, t2) + }; + } + } + + @Override + public float getEndAngle() { return endAngle; } + + @Override + public float getStartAngle() { return startAngle; } +} diff --git a/src/itdelatrisu/opsu/objects/curves/LinearBezier.java b/src/itdelatrisu/opsu/objects/curves/LinearBezier.java index 21e3b82c..3ec6717a 100644 --- a/src/itdelatrisu/opsu/objects/curves/LinearBezier.java +++ b/src/itdelatrisu/opsu/objects/curves/LinearBezier.java @@ -18,50 +18,46 @@ package itdelatrisu.opsu.objects.curves; -import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.OsuHitObject; -import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.HitObject; -import java.util.Iterator; import java.util.LinkedList; import org.newdawn.slick.Color; -import org.newdawn.slick.Image; /** - * Representation of a Bezier curve with equidistant points. + * Representation of Bezier curve with equidistant points. * http://pomax.github.io/bezierinfo/#tracing * * @author fluddokt (https://github.com/fluddokt) */ -public class LinearBezier extends Curve { - /** The angles of the first and last control points for drawing. */ - private float startAngle, endAngle; - - /** List of Bezier curves in the set of points. */ - private LinkedList beziers = new LinkedList(); - - /** Points along the curve at equal distance. */ - private Vec2f[] curve; - - /** The number of points along the curve. */ - private int ncurve; - +public class LinearBezier extends EqualDistanceMultiCurve { /** * Constructor. - * @param hitObject the associated OsuHitObject + * @param hitObject the associated HitObject * @param color the color of this curve + * @param line whether a new curve should be generated for each sequential pair */ - public LinearBezier(OsuHitObject hitObject, Color color) { + public LinearBezier(HitObject hitObject, Color color, boolean line) { super(hitObject, color); - // splits points into different Beziers if has the same points (red points) + LinkedList beziers = new LinkedList(); + + // Beziers: splits points into different Beziers if has the same points (red points) + // a b c - c d - d e f g + // Lines: generate a new curve for each sequential pair + // ab bc cd de ef fg int controlPoints = hitObject.getSliderX().length + 1; LinkedList points = new LinkedList(); // temporary list of points to separate different Bezier curves Vec2f lastPoi = null; for (int i = 0; i < controlPoints; i++) { Vec2f tpoi = new Vec2f(getX(i), getY(i)); - if (lastPoi != null && tpoi.equals(lastPoi)) { + if (line) { + if (lastPoi != null) { + points.add(tpoi); + beziers.add(new Bezier2(points.toArray(new Vec2f[0]))); + points.clear(); + } + } else if (lastPoi != null && tpoi.equals(lastPoi)) { if (points.size() >= 2) beziers.add(new Bezier2(points.toArray(new Vec2f[0]))); points.clear(); @@ -69,7 +65,7 @@ public class LinearBezier extends Curve { points.add(tpoi); lastPoi = tpoi; } - if (points.size() < 2) { + if (line || points.size() < 2) { // trying to continue Bezier with less than 2 points // probably ending on a red point, just ignore it } else { @@ -77,105 +73,6 @@ public class LinearBezier extends Curve { points.clear(); } - // find the length of all beziers -// int totalDistance = 0; -// for (Bezier2 bez : beziers) { -// totalDistance += bez.totalDistance(); -// } - - // now try to creates points the are equidistant to each other - this.ncurve = (int) (hitObject.getPixelLength() / 5f); - this.curve = new Vec2f[ncurve + 1]; - - float distanceAt = 0; - Iterator iter = beziers.iterator(); - int curPoint = 0; - Bezier2 curBezier = iter.next(); - Vec2f lastCurve = curBezier.getCurve()[0]; - float lastDistanceAt = 0; - - // length of Bezier should equal pixel length (in 640x480) - float pixelLength = hitObject.getPixelLength() * OsuHitObject.getXMultiplier(); - - // for each distance, try to get in between the two points that are between it - for (int i = 0; i < ncurve + 1; i++) { - int prefDistance = (int) (i * pixelLength / ncurve); - while (distanceAt < prefDistance) { - lastDistanceAt = distanceAt; - lastCurve = curBezier.getCurve()[curPoint]; - distanceAt += curBezier.getCurveDistances()[curPoint++]; - - if (curPoint >= curBezier.points()) { - if (iter.hasNext()) { - curBezier = iter.next(); - curPoint = 0; - } else { - curPoint = curBezier.points() - 1; - if (lastDistanceAt == distanceAt) { - // out of points even though the preferred distance hasn't been reached - break; - } - } - } - } - Vec2f thisCurve = curBezier.getCurve()[curPoint]; - - // interpolate the point between the two closest distances - if (distanceAt - lastDistanceAt > 1) { - float t = (prefDistance - lastDistanceAt) / (distanceAt - lastDistanceAt); - curve[i] = new Vec2f(lerp(lastCurve.x, thisCurve.x, t), lerp(lastCurve.y, thisCurve.y, t)); - } else - curve[i] = thisCurve; - } - -// if (hitObject.getRepeatCount() > 1) { - Vec2f c1 = curve[0]; - int cnt = 1; - Vec2f c2 = curve[cnt++]; - while (cnt <= ncurve && c2.cpy().sub(c1).len() < 1) - c2 = curve[cnt++]; - this.startAngle = (float) (Math.atan2(c2.y - c1.y, c2.x - c1.x) * 180 / Math.PI); - - c1 = curve[ncurve]; - cnt = ncurve - 1; - c2 = curve[cnt--]; - while (cnt >= 0 && c2.cpy().sub(c1).len() < 1) - c2 = curve[cnt--]; - this.endAngle = (float) (Math.atan2(c2.y - c1.y, c2.x - c1.x) * 180 / Math.PI); -// } + init(beziers); } - - @Override - public float[] pointAt(float t) { - float indexF = t * ncurve; - int index = (int) indexF; - if (index >= ncurve) { - Vec2f poi = curve[ncurve]; - return new float[] { poi.x, poi.y }; - } else { - Vec2f poi = curve[index]; - Vec2f poi2 = curve[index + 1]; - float t2 = indexF - index; - return new float[] { - lerp(poi.x, poi2.x, t2), - lerp(poi.y, poi2.y, t2) - }; - } - } - - @Override - public void draw() { - Image hitCircle = GameImage.HITCIRCLE.getImage(); - Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); - for (int i = curve.length - 2; i >= 0; i--) - hitCircleOverlay.drawCentered(curve[i].x, curve[i].y, Utils.COLOR_WHITE_FADE); - for (int i = curve.length - 2; i >= 0; i--) - hitCircle.drawCentered(curve[i].x, curve[i].y, color); - } - - @Override - public float getEndAngle() { return endAngle; } - - @Override - public float getStartAngle() { return startAngle; } } diff --git a/src/itdelatrisu/opsu/objects/curves/Vec2f.java b/src/itdelatrisu/opsu/objects/curves/Vec2f.java index b49bac2d..1005cd1d 100644 --- a/src/itdelatrisu/opsu/objects/curves/Vec2f.java +++ b/src/itdelatrisu/opsu/objects/curves/Vec2f.java @@ -49,6 +49,28 @@ public class Vec2f { return new Vec2f((x + o.x) / 2, (y + o.y) / 2); } + /** + * Scales the vector. + * @param s scaler to scale by + * @return itself (for chaining) + */ + public Vec2f scale(float s) { + x *= s; + y *= s; + return this; + } + + /** + * Adds a vector to this vector. + * @param o the other vector + * @return itself (for chaining) + */ + public Vec2f add(Vec2f o) { + x += o.x; + y += o.y; + return this; + } + /** * Subtracts a vector from this vector. * @param o the other vector @@ -97,4 +119,7 @@ public class Vec2f { * @return true if the two vectors are numerically equal */ public boolean equals(Vec2f o) { return (x == o.x && y == o.y); } + + @Override + public String toString() { return String.format("(%.3f, %.3f)", x, y); } } diff --git a/src/itdelatrisu/opsu/render/CurveRenderState.java b/src/itdelatrisu/opsu/render/CurveRenderState.java new file mode 100644 index 00000000..287ebd63 --- /dev/null +++ b/src/itdelatrisu/opsu/render/CurveRenderState.java @@ -0,0 +1,451 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ +package itdelatrisu.opsu.render; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.objects.curves.Vec2f; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; + +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL13; +import org.lwjgl.opengl.GL14; +import org.lwjgl.opengl.GL15; +import org.lwjgl.opengl.GL20; +import org.lwjgl.opengl.GL30; +import org.newdawn.slick.Color; +import org.newdawn.slick.Image; +import org.newdawn.slick.util.Log; + +/** + * Hold the temporary render state that needs to be restored again after the new + * style curves are drawn. + * + * @author Bigpet {@literal } + */ +public class CurveRenderState { + /** The width and height of the display container this curve gets drawn into. */ + protected static int containerWidth, containerHeight; + + /** Thickness of the curve. */ + protected static int scale; + + /** Static state that's needed to draw the new style curves. */ + private static final NewCurveStyleState staticState = new NewCurveStyleState(); + + /** Cached drawn slider, only used if new style sliders are activated. */ + public Rendertarget fbo; + + /** The HitObject associated with the curve to be drawn. */ + protected HitObject hitObject; + + /** + * Set the width and height of the container that Curves get drawn into. + * Should be called before any curves are drawn. + * @param width the container width + * @param height the container height + * @param circleSize the circle size + */ + public static void init(int width, int height, float circleSize) { + containerWidth = width; + containerHeight = height; + + // equivalent to what happens in Slider.init() + scale = (int) (104 - (circleSize * 8)); + scale = (int) (scale * HitObject.getXMultiplier()); // convert from Osupixels (640x480) + //scale = scale * 118 / 128; //for curves exactly as big as the sliderball + FrameBufferCache.init(width, height); + } + + /** + * Creates an object to hold the render state that's necessary to draw a curve. + * @param hitObject the HitObject that represents this curve, just used as a unique ID + */ + public CurveRenderState(HitObject hitObject) { + fbo = null; + this.hitObject = hitObject; + } + + /** + * Draw a curve to the screen that's tinted with `color`. The first time + * this is called this caches the image result of the curve and on subsequent + * runs it just draws the cached copy to the screen. + * @param color tint of the curve + * @param borderColor the curve border color + * @param curve the points along the curve to be drawn + */ + public void draw(Color color, Color borderColor, Vec2f[] curve) { + float alpha = color.a; + + // if this curve hasn't been drawn, draw it and cache the result + if (fbo == null) { + FrameBufferCache cache = FrameBufferCache.getInstance(); + Rendertarget mapping = cache.get(hitObject); + if (mapping == null) + mapping = cache.insert(hitObject); + fbo = mapping; + + int old_fb = GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING); + int old_tex = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D); + + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, fbo.getID()); + GL11.glViewport(0, 0, fbo.width, fbo.height); + GL11.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + Utils.COLOR_WHITE_FADE.a = 1.0f; + this.draw_curve(color, borderColor, curve); + color.a = 1f; + + GL11.glBindTexture(GL11.GL_TEXTURE_2D, old_tex); + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, old_fb); + Utils.COLOR_WHITE_FADE.a = alpha; + } + + // draw a fullscreen quad with the texture that contains the curve + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glDisable(GL11.GL_TEXTURE_1D); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, fbo.getTextureID()); + GL11.glBegin(GL11.GL_QUADS); + GL11.glColor4f(1.0f, 1.0f, 1.0f, alpha); + GL11.glTexCoord2f(1.0f, 1.0f); + GL11.glVertex2i(fbo.width, 0); + GL11.glTexCoord2f(0.0f, 1.0f); + GL11.glVertex2i(0, 0); + GL11.glTexCoord2f(0.0f, 0.0f); + GL11.glVertex2i(0, fbo.height); + GL11.glTexCoord2f(1.0f, 0.0f); + GL11.glVertex2i(fbo.width, fbo.height); + GL11.glEnd(); + } + + /** + * Discard the cache. + */ + public void discardCache() { + fbo = null; + FrameBufferCache.getInstance().freeMappingFor(hitObject); + } + + /** + * A structure to hold all the important OpenGL state that needs to be + * changed to draw the curve. This is used to backup and restore the state + * so that the code outside of this (mainly Slick2D) doesn't break. + */ + private class RenderState { + boolean smoothedPoly; + boolean blendEnabled; + boolean depthEnabled; + boolean depthWriteEnabled; + boolean texEnabled; + int texUnit; + int oldProgram; + int oldArrayBuffer; + } + + /** + * Backup the current state of the relevant OpenGL state and change it to + * what's needed to draw the curve. + */ + private RenderState startRender() { + RenderState state = new RenderState(); + state.smoothedPoly = GL11.glGetBoolean(GL11.GL_POLYGON_SMOOTH); + state.blendEnabled = GL11.glGetBoolean(GL11.GL_BLEND); + state.depthEnabled = GL11.glGetBoolean(GL11.GL_DEPTH_TEST); + state.depthWriteEnabled = GL11.glGetBoolean(GL11.GL_DEPTH_WRITEMASK); + state.texEnabled = GL11.glGetBoolean(GL11.GL_TEXTURE_2D); + state.texUnit = GL11.glGetInteger(GL13.GL_ACTIVE_TEXTURE); + state.oldProgram = GL11.glGetInteger(GL20.GL_CURRENT_PROGRAM); + state.oldArrayBuffer = GL11.glGetInteger(GL15.GL_ARRAY_BUFFER_BINDING); + GL11.glDisable(GL11.GL_POLYGON_SMOOTH); + GL11.glEnable(GL11.GL_BLEND); + GL14.glBlendEquation(GL14.GL_FUNC_ADD); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glEnable(GL11.GL_DEPTH_TEST); + GL11.glDepthMask(true); + GL11.glDisable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_TEXTURE_1D); + GL11.glBindTexture(GL11.GL_TEXTURE_1D, staticState.gradientTexture); + GL11.glTexParameteri(GL11.GL_TEXTURE_1D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR_MIPMAP_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_1D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); + GL11.glTexParameteri(GL11.GL_TEXTURE_1D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP); + + GL20.glUseProgram(0); + + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPushMatrix(); + GL11.glLoadIdentity(); + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPushMatrix(); + GL11.glLoadIdentity(); + + return state; + } + + /** + * Restore the old OpenGL state that's backed up in {@code state}. + * @param state the old state to restore + */ + private void endRender(RenderState state) { + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPopMatrix(); + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPopMatrix(); + GL11.glEnable(GL11.GL_BLEND); + GL20.glUseProgram(state.oldProgram); + GL13.glActiveTexture(state.texUnit); + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, state.oldArrayBuffer); + if (!state.depthWriteEnabled) + GL11.glDepthMask(false); + if (!state.depthEnabled) + GL11.glDisable(GL11.GL_DEPTH_TEST); + if (state.texEnabled) + GL11.glEnable(GL11.GL_TEXTURE_2D); + if (state.smoothedPoly) + GL11.glEnable(GL11.GL_POLYGON_SMOOTH); + if (!state.blendEnabled) + GL11.glDisable(GL11.GL_BLEND); + } + + /** + * Do the actual drawing of the curve into the currently bound framebuffer. + * @param color the color of the curve + * @param borderColor the curve border color + * @param curve the points along the curve + */ + private void draw_curve(Color color, Color borderColor, Vec2f[] curve) { + staticState.initGradient(); + RenderState state = startRender(); + int vtx_buf; + // the size is: floatsize * (position + texture coordinates) * (number of cones) * (vertices in a cone) + FloatBuffer buff = BufferUtils.createByteBuffer(4 * (4 + 2) * (2 * curve.length - 1) * (NewCurveStyleState.DIVIDES + 2)).asFloatBuffer(); + staticState.initShaderProgram(); + vtx_buf = GL15.glGenBuffers(); + for (int i = 0; i < curve.length; ++i) { + float x = curve[i].x; + float y = curve[i].y; + //if (i == 0 || i == curve.length - 1){ + fillCone(buff, x, y, NewCurveStyleState.DIVIDES); + if (i != 0) { + float last_x = curve[i - 1].x; + float last_y = curve[i - 1].y; + double diff_x = x - last_x; + double diff_y = y - last_y; + x = (float) (x - diff_x / 2); + y = (float) (y - diff_y / 2); + fillCone(buff, x, y, NewCurveStyleState.DIVIDES); + } + } + buff.flip(); + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, vtx_buf); + GL15.glBufferData(GL15.GL_ARRAY_BUFFER, buff, GL15.GL_STATIC_DRAW); + GL20.glUseProgram(staticState.program); + GL20.glEnableVertexAttribArray(staticState.attribLoc); + GL20.glEnableVertexAttribArray(staticState.texCoordLoc); + GL20.glUniform1i(staticState.texLoc, 0); + GL20.glUniform3f(staticState.colLoc, color.r, color.g, color.b); + GL20.glUniform4f(staticState.colBorderLoc, borderColor.r, borderColor.g, borderColor.b, borderColor.a); + //stride is 6*4 for the floats (4 bytes) (u,v)(x,y,z,w) + //2*4 is for skipping the first 2 floats (u,v) + GL20.glVertexAttribPointer(staticState.attribLoc, 4, GL11.GL_FLOAT, false, 6 * 4, 2 * 4); + GL20.glVertexAttribPointer(staticState.texCoordLoc, 2, GL11.GL_FLOAT, false, 6 * 4, 0); + for (int i = 0; i < curve.length * 2 - 1; ++i) + GL11.glDrawArrays(GL11.GL_TRIANGLE_FAN, i * (NewCurveStyleState.DIVIDES + 2), NewCurveStyleState.DIVIDES + 2); + GL20.glDisableVertexAttribArray(staticState.texCoordLoc); + GL20.glDisableVertexAttribArray(staticState.attribLoc); + GL15.glDeleteBuffers(vtx_buf); + endRender(state); + } + + /** + * Fill {@code buff} with the texture coordinates and positions for a cone + * with {@code DIVIDES} ground corners that has its center at the coordinates + * {@code (x1,y1)}. + * @param buff the buffer to be filled + * @param x1 x-coordinate of the cone + * @param y1 y-coordinate of the cone + * @param DIVIDES the base of the cone is a regular polygon with this many sides + */ + protected void fillCone(FloatBuffer buff, float x1, float y1, final int DIVIDES) { + float divx = containerWidth / 2.0f; + float divy = containerHeight / 2.0f; + float offx = -1.0f; + float offy = 1.0f; + float x, y; + float radius = scale / 2; + buff.put(1.0f); + buff.put(0.5f); + //GL11.glTexCoord2d(1.0, 0.5); + x = offx + x1 / divx; + y = offy - y1 / divy; + buff.put(x); + buff.put(y); + buff.put(0f); + buff.put(1f); + //GL11.glVertex4f(x, y, 0.0f, 1.0f); + for (int j = 0; j < DIVIDES; ++j) { + double phase = j * (float) Math.PI * 2 / DIVIDES; + buff.put(0.0f); + buff.put(0.5f); + //GL11.glTexCoord2d(0.0, 0.5); + x = (x1 + radius * (float) Math.sin(phase)) / divx; + y = (y1 + radius * (float) Math.cos(phase)) / divy; + buff.put((offx + x)); + buff.put((offy - y)); + buff.put(1f); + buff.put(1f); + //GL11.glVertex4f(x + 90 * (float) Math.sin(phase), y + 90 * (float) Math.cos(phase), 1.0f, 1.0f); + } + buff.put(0.0f); + buff.put(0.5f); + //GL11.glTexCoord2d(0.0, 0.5); + x = (x1 + radius * (float) Math.sin(0.0)) / divx; + y = (y1 + radius * (float) Math.cos(0.0)) / divy; + buff.put((offx + x)); + buff.put((offy - y)); + buff.put(1f); + buff.put(1f); + //GL11.glVertex4f(x + 90 * (float) Math.sin(0.0), y + 90 * (float) Math.cos(0.0), 1.0f, 1.0f); + } + + /** + * Contains all the necessary state that needs to be tracked to draw curves + * in the new style and not re-create the shader each time. + * + * @author Bigpet {@literal } + */ + private static class NewCurveStyleState { + /** + * Used for new style Slider rendering, defines how many vertices the + * base of the cone has that is used to draw the curve. + */ + protected static final int DIVIDES = 30; + + /** OpenGL shader program ID used to draw and recolor the curve. */ + protected int program = 0; + + /** OpenGL shader attribute location of the vertex position attribute. */ + protected int attribLoc = 0; + + /** OpenGL shader attribute location of the texture coordinate attribute. */ + protected int texCoordLoc = 0; + + /** OpenGL shader uniform location of the color attribute. */ + protected int colLoc = 0; + + /** OpenGL shader uniform location of the border color attribute. */ + protected int colBorderLoc = 0; + + /** OpenGL shader uniform location of the texture sampler attribute. */ + protected int texLoc = 0; + + /** OpenGL texture id for the gradient texture for the curve. */ + protected int gradientTexture = 0; + + /** + * Reads the first row of the slider gradient texture and upload it as + * a 1D texture to OpenGL if it hasn't already been done. + */ + public void initGradient() { + if (gradientTexture == 0) { + Image slider = GameImage.SLIDER_GRADIENT.getImage().getScaledCopy(1.0f / GameImage.getUIscale()); + staticState.gradientTexture = GL11.glGenTextures(); + ByteBuffer buff = BufferUtils.createByteBuffer(slider.getWidth() * 4); + for (int i = 0; i < slider.getWidth(); ++i) { + Color col = slider.getColor(i, 0); + buff.put((byte) (255 * col.r)); + buff.put((byte) (255 * col.g)); + buff.put((byte) (255 * col.b)); + buff.put((byte) (255 * col.a)); + } + buff.flip(); + GL11.glBindTexture(GL11.GL_TEXTURE_1D, gradientTexture); + GL11.glTexImage1D(GL11.GL_TEXTURE_1D, 0, GL11.GL_RGBA, slider.getWidth(), 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buff); + GL30.glGenerateMipmap(GL11.GL_TEXTURE_1D); + } + } + + /** + * Compiles and links the shader program for the new style curve objects + * if it hasn't already been compiled and linked. + */ + public void initShaderProgram() { + if (program == 0) { + program = GL20.glCreateProgram(); + int vtxShdr = GL20.glCreateShader(GL20.GL_VERTEX_SHADER); + int frgShdr = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER); + GL20.glShaderSource(vtxShdr, "#version 330\n" + + "\n" + + "layout(location = 0) in vec4 in_position;\n" + + "layout(location = 1) in vec2 in_tex_coord;\n" + + "\n" + + "out vec2 tex_coord;\n" + + "void main()\n" + + "{\n" + + " gl_Position = in_position;\n" + + " tex_coord = in_tex_coord;\n" + + "}"); + GL20.glCompileShader(vtxShdr); + int res = GL20.glGetShaderi(vtxShdr, GL20.GL_COMPILE_STATUS); + if (res != GL11.GL_TRUE) { + String error = GL20.glGetShaderInfoLog(vtxShdr, 1024); + Log.error("Vertex Shader compilation failed.", new Exception(error)); + } + GL20.glShaderSource(frgShdr, "#version 330\n" + + "\n" + + "uniform sampler1D tex;\n" + + "uniform vec2 tex_size;\n" + + "uniform vec3 col_tint;\n" + + "uniform vec4 col_border;\n" + + "\n" + + "in vec2 tex_coord;\n" + + "layout(location = 0) out vec4 out_colour;\n" + + "\n" + + "void main()\n" + + "{\n" + + " vec4 in_color = texture(tex, tex_coord.x);\n" + + " float blend_factor = in_color.r-in_color.b;\n" + + " vec4 new_color = vec4(mix(in_color.xyz*col_border.xyz,col_tint,blend_factor),in_color.w);\n" + + " out_colour = new_color;\n" + + "}"); + GL20.glCompileShader(frgShdr); + res = GL20.glGetShaderi(frgShdr, GL20.GL_COMPILE_STATUS); + if (res != GL11.GL_TRUE) { + String error = GL20.glGetShaderInfoLog(frgShdr, 1024); + Log.error("Fragment Shader compilation failed.", new Exception(error)); + } + GL20.glAttachShader(program, vtxShdr); + GL20.glAttachShader(program, frgShdr); + GL20.glLinkProgram(program); + res = GL20.glGetProgrami(program, GL20.GL_LINK_STATUS); + if (res != GL11.GL_TRUE) { + String error = GL20.glGetProgramInfoLog(program, 1024); + Log.error("Program linking failed.", new Exception(error)); + } + attribLoc = GL20.glGetAttribLocation(program, "in_position"); + texCoordLoc = GL20.glGetAttribLocation(program, "in_tex_coord"); + texLoc = GL20.glGetUniformLocation(program, "tex"); + colLoc = GL20.glGetUniformLocation(program, "col_tint"); + colBorderLoc = GL20.glGetUniformLocation(program, "col_border"); + } + } + } +} diff --git a/src/itdelatrisu/opsu/render/FrameBufferCache.java b/src/itdelatrisu/opsu/render/FrameBufferCache.java new file mode 100644 index 00000000..2558dd15 --- /dev/null +++ b/src/itdelatrisu/opsu/render/FrameBufferCache.java @@ -0,0 +1,136 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ +package itdelatrisu.opsu.render; + +import itdelatrisu.opsu.beatmap.HitObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +/** + * This is cache for OpenGL FrameBufferObjects. This is currently only used + * to draw curve objects of the new slider style. Does currently not integrate + * well and requires some manual OpenGL state manipulation to use it. + * + * @author Bigpet {@literal } + */ +public class FrameBufferCache { + /** The single framebuffer cache instance. */ + private static FrameBufferCache instance = null; + + /** The mapping from hit objects to framebuffers. */ + private Map cacheMap; + + /** */ + private ArrayList cache; + + /** Container dimensions. */ + public static int width, height; + + /** + * Set the width and height of the framebuffers in this cache. + * Should be called before anything is inserted into the map. + * @param width the container width + * @param height the container height + */ + public static void init(int width, int height) { + FrameBufferCache.width = width; + FrameBufferCache.height = height; + } + + /** + * Constructor. + */ + private FrameBufferCache() { + cache = new ArrayList(); + cacheMap = new HashMap(); + } + + /** + * Check if there is a framebuffer object mapped to {@code obj}. + * @param obj the hit object + * @return true if there is a framebuffer mapped for this {@code HitObject}, else false + */ + public boolean contains(HitObject obj) { + return cacheMap.containsKey(obj); + } + + /** + * Get the {@code Rendertarget} mapped to {@code obj}. + * @param obj the hit object + * @return the {@code Rendertarget} if there's one mapped to {@code obj}, otherwise null + */ + public Rendertarget get(HitObject obj) { + return cacheMap.get(obj); + } + + /** + * Clear the mapping for {@code obj} to free it up to get used by another {@code HitObject}. + * @param obj the hit object + * @return true if there was a mapping for {@code obj} and false if there was no mapping for it. + */ + public boolean freeMappingFor(HitObject obj) { + return cacheMap.remove(obj) != null; + } + + /** + * Clear the cache of all the mappings. This does not actually delete the + * cached framebuffers, it merely frees them all up to get mapped anew. + */ + public void freeMap() { + cacheMap.clear(); + } + + /** + * Create a mapping from {@code obj} to a framebuffer. If there was already + * a mapping from {@code obj} this will associate another framebuffer with it + * (thereby freeing up the previously mapped framebuffer). + * @param obj the hit object + * @return the {@code Rendertarget} newly mapped to {@code obj} + */ + public Rendertarget insert(HitObject obj) { + // find first RTTFramebuffer that's not mapped to anything and return it + Rendertarget buffer; + for (int i = 0; i < cache.size(); ++i) { + buffer = cache.get(i); + if (!cacheMap.containsValue(buffer)) { + cacheMap.put(obj, buffer); + return buffer; + } + } + + // no unmapped RTTFramebuffer found, create a new one + buffer = Rendertarget.createRTTFramebuffer(width, height); + cache.add(buffer); + cacheMap.put(obj, buffer); + return buffer; + } + + /** + * There should only ever be one framebuffer cache, this function returns + * that one framebuffer cache instance. + * If there was no instance created already then this function creates it. + * @return the instance of FrameBufferCache + */ + public static FrameBufferCache getInstance() { + if (instance == null) + instance = new FrameBufferCache(); + return instance; + } +} diff --git a/src/itdelatrisu/opsu/render/Rendertarget.java b/src/itdelatrisu/opsu/render/Rendertarget.java new file mode 100644 index 00000000..00599f5f --- /dev/null +++ b/src/itdelatrisu/opsu/render/Rendertarget.java @@ -0,0 +1,115 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ +package itdelatrisu.opsu.render; + +import java.nio.ByteBuffer; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL20; +import org.lwjgl.opengl.GL30; +import org.lwjgl.opengl.GL32; + +/** + * Represents a rendertarget. For now this maps to an OpenGL FBO via LWJGL. + * + * @author Bigpet {@literal } + */ +public class Rendertarget { + /** The dimensions. */ + public final int width, height; + + /** The FBO ID. */ + private final int fboID; + + /** The texture ID. */ + private final int textureID; + + /** + * Create a new FBO. + * @param width the width + * @param height the height + */ + private Rendertarget(int width, int height) { + this.width = width; + this.height = height; + fboID = GL30.glGenFramebuffers(); + textureID = GL11.glGenTextures(); + } + + /** + * Bind this rendertarget as the primary framebuffer. + */ + public void bind() { + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, fboID); + } + + /** + * Returns the FBO ID. + */ + // NOTE: use judiciously, try to avoid if possible and consider adding a + // method to this class if you find yourself calling this repeatedly. + public int getID() { + return fboID; + } + + /** + * Returns the texture ID. + */ + // NOTE: try not to use, could be moved into separate class. + public int getTextureID() { + return textureID; + } + + /** + * Bind the default framebuffer. + */ + public static void unbind() { + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, 0); + } + + /** + * Creates a Rendertarget with a Texture that it renders the color buffer in + * and a renderbuffer that it renders the depth to. + * @param width the width + * @param height the height + */ + public static Rendertarget createRTTFramebuffer(int width, int height) { + int old_framebuffer = GL11.glGetInteger(GL30.GL_READ_FRAMEBUFFER_BINDING); + int old_texture = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D); + Rendertarget buffer = new Rendertarget(width,height); + buffer.bind(); + + int fboTexture = buffer.textureID; + GL11.glBindTexture(GL11.GL_TEXTURE_2D, fboTexture); + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, 4, width, height, 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_INT, (ByteBuffer) null); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST); + + int fboDepth = GL30.glGenRenderbuffers(); + GL30.glBindRenderbuffer(GL30.GL_RENDERBUFFER, fboDepth); + GL30.glRenderbufferStorage(GL30.GL_RENDERBUFFER, GL11.GL_DEPTH_COMPONENT, width, height); + GL30.glFramebufferRenderbuffer(GL30.GL_FRAMEBUFFER, GL30.GL_DEPTH_ATTACHMENT, GL30.GL_RENDERBUFFER, fboDepth); + + GL32.glFramebufferTexture(GL30.GL_FRAMEBUFFER, GL30.GL_COLOR_ATTACHMENT0, fboTexture, 0); + GL20.glDrawBuffers(GL30.GL_COLOR_ATTACHMENT0); + + GL11.glBindTexture(GL11.GL_TEXTURE_2D, old_texture); + GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, old_framebuffer); + + return buffer; + } +} diff --git a/src/itdelatrisu/opsu/replay/LifeFrame.java b/src/itdelatrisu/opsu/replay/LifeFrame.java index f53e8745..045075f9 100644 --- a/src/itdelatrisu/opsu/replay/LifeFrame.java +++ b/src/itdelatrisu/opsu/replay/LifeFrame.java @@ -16,13 +16,13 @@ * along with opsu!. If not, see . */ +package itdelatrisu.opsu.replay; + /** * Captures a single life frame. * * @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/) */ -package itdelatrisu.opsu.replay; - public class LifeFrame { /** Time. */ private int time; diff --git a/src/itdelatrisu/opsu/replay/PlaybackSpeed.java b/src/itdelatrisu/opsu/replay/PlaybackSpeed.java new file mode 100644 index 00000000..ec2fad6d --- /dev/null +++ b/src/itdelatrisu/opsu/replay/PlaybackSpeed.java @@ -0,0 +1,93 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.replay; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.GameMod; +import itdelatrisu.opsu.ui.MenuButton; + +import org.newdawn.slick.Image; + +/** + * Playback speeds for replays. + * + * @author DarkTigrus (https://github.com/DarkTigrus) + */ +public enum PlaybackSpeed { + NORMAL (GameImage.REPLAY_PLAYBACK_NORMAL, 1f), + DOUBLE (GameImage.REPLAY_PLAYBACK_DOUBLE, 2f), + HALF (GameImage.REPLAY_PLAYBACK_HALF, 0.5f); + + /** The button image. */ + private GameImage gameImage; + + /** The button. */ + private MenuButton button; + + /** The playback speed modifier. */ + private float modifier; + + /** Enum values. */ + private static PlaybackSpeed[] values = PlaybackSpeed.values(); + + /** + * Initializes the playback buttons. + * @param width the container width + * @param height the container height + */ + public static void init(int width, int height) { + for (PlaybackSpeed playback : PlaybackSpeed.values()) { + Image img = playback.gameImage.getImage(); + playback.button = new MenuButton(img, width * 0.98f - (img.getWidth() / 2f), height * 0.25f); + playback.button.setHoverFade(); + } + } + + /** + * Constructor. + * @param gameImage the button image + * @param modifier the speed modifier + */ + PlaybackSpeed(GameImage gameImage, float modifier) { + this.gameImage = gameImage; + this.modifier = modifier; + } + + /** + * Returns the button. + * @return the associated button + */ + public MenuButton getButton() { return button; } + + /** + * Returns the speed modifier. + * @return the speed + */ + public float getModifier() { return modifier; } + + /** + * Returns the next playback speed. + */ + public PlaybackSpeed next() { + PlaybackSpeed next = values[(this.ordinal() + 1) % values.length]; + if ((GameMod.DOUBLE_TIME.isActive() && next == PlaybackSpeed.DOUBLE)) + next = next.next(); + return next; + } +} diff --git a/src/itdelatrisu/opsu/replay/ReplayFrame.java b/src/itdelatrisu/opsu/replay/ReplayFrame.java index 393f1494..9f8e9f98 100644 --- a/src/itdelatrisu/opsu/replay/ReplayFrame.java +++ b/src/itdelatrisu/opsu/replay/ReplayFrame.java @@ -18,7 +18,7 @@ package itdelatrisu.opsu.replay; -import itdelatrisu.opsu.OsuHitObject; +import itdelatrisu.opsu.beatmap.HitObject; /** * Captures a single replay frame. @@ -98,12 +98,12 @@ public class ReplayFrame { /** * Returns the scaled cursor x coordinate. */ - public int getScaledX() { return (int) (x * OsuHitObject.getXMultiplier() + OsuHitObject.getXOffset()); } + public int getScaledX() { return (int) (x * HitObject.getXMultiplier() + HitObject.getXOffset()); } /** * Returns the scaled cursor y coordinate. */ - public int getScaledY() { return (int) (y * OsuHitObject.getYMultiplier() + OsuHitObject.getYOffset()); } + public int getScaledY() { return (int) (y * HitObject.getYMultiplier() + HitObject.getYOffset()); } /** * Returns the keys pressed (KEY_* bitmask). diff --git a/src/itdelatrisu/opsu/skins/Skin.java b/src/itdelatrisu/opsu/skins/Skin.java new file mode 100644 index 00000000..28c40b0b --- /dev/null +++ b/src/itdelatrisu/opsu/skins/Skin.java @@ -0,0 +1,374 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.skins; + +import java.io.File; + +import org.newdawn.slick.Color; + +/** + * Skin configuration (skin.ini). + */ +public class Skin { + /** The default skin name. */ + public static final String DEFAULT_SKIN_NAME = "Default"; + + /** Slider styles. */ + public static final byte + STYLE_PEPPYSLIDER = 1, // fallback + STYLE_MMSLIDER = 2, // default (requires OpenGL 3.0) + STYLE_TOONSLIDER = 3, // not implemented + STYLE_OPENGLSLIDER = 4; // not implemented + + /** The latest skin version. */ + protected static final int LATEST_VERSION = 2; + + /** The default list of combos with combo sounds. */ + private static final int[] DEFAULT_CUSTOM_COMBO_BURST_SOUNDS = { 50, 75, 100, 200, 300 }; + + /** The default combo colors (used when a beatmap does not provide custom colors). */ + private static final Color[] DEFAULT_COMBO = { + new Color(255, 192, 0), + new Color(0, 202, 0), + new Color(18, 124, 255), + new Color(242, 24, 57) + }; + + /** The default menu visualization bar color. */ + private static final Color DEFAULT_MENU_GLOW = new Color(0, 78, 155); + + /** The default slider border color. */ + private static final Color DEFAULT_SLIDER_BORDER = new Color(255, 255, 255); + + /** The default slider ball color. */ + private static final Color DEFAULT_SLIDER_BALL = new Color(2, 170, 255); + + /** The default spinner approach circle color. */ + private static final Color DEFAULT_SPINNER_APPROACH_CIRCLE = new Color(77, 139, 217); + + /** The default color of the active text in the song selection menu. */ + private static final Color DEFAULT_SONG_SELECT_ACTIVE_TEXT = new Color(255, 255, 255); + + /** The default color of the inactive text in the song selection menu. */ + private static final Color DEFAULT_SONG_SELECT_INACTIVE_TEXT = new Color(178, 178, 178); + + /** The default color of the stars that fall from the cursor during breaks. */ + private static final Color DEFAULT_STAR_BREAK_ADDITIVE = new Color(255, 182, 193); + + /** The skin directory. */ + private File dir; + + /** + * [General] + */ + + /** The name of the skin. */ + protected String name = "opsu! Default Skin"; + + /** The skin author. */ + protected String author = "[various authors]"; + + /** The skin version. */ + protected int version = LATEST_VERSION; + + /** When a slider has a reverse, should the ball sprite flip horizontally? */ + protected boolean sliderBallFlip = false; + + /** Should the cursor sprite rotate constantly? */ + protected boolean cursorRotate = true; + + /** Should the cursor expand when clicked? */ + protected boolean cursorExpand = true; + + /** Should the cursor have an origin at the center of the image? (if not, the top-left corner is used) */ + protected boolean cursorCentre = true; + + /** The number of frames in the slider ball animation. */ + protected int sliderBallFrames = 10; + + /** Should the hitcircleoverlay sprite be drawn above the hircircle combo number? */ + protected boolean hitCircleOverlayAboveNumber = true; + + /** Should the sound frequency be modulated depending on the spinner score? */ + protected boolean spinnerFrequencyModulate = false; + + /** Should the normal hitsound always be played? */ + protected boolean layeredHitSounds = true; + + /** Should the spinner fade the playfield? */ + protected boolean spinnerFadePlayfield = true; + + /** Should the last spinner bar blink? */ + protected boolean spinnerNoBlink = false; + + /** Should the slider combo color tint the slider ball? */ + protected boolean allowSliderBallTint = false; + + /** The FPS of animations. */ + protected int animationFramerate = -1; + + /** Should the cursor trail sprite rotate constantly? */ + protected boolean cursorTrailRotate = false; + + /** List of combos with combo sounds. */ + protected int[] customComboBurstSounds = DEFAULT_CUSTOM_COMBO_BURST_SOUNDS; + + /** Should the combo burst sprites appear in random order? */ + protected boolean comboBurstRandom = false; + + /** The slider style to use (see STYLE_* constants). */ + protected byte sliderStyle = STYLE_MMSLIDER; + + /** + * [Colours] + */ + + /** Combo colors (max 8). */ + protected Color[] combo = DEFAULT_COMBO; + + /** The menu visualization bar color. */ + protected Color menuGlow = DEFAULT_MENU_GLOW; + + /** The color for the slider border. */ + protected Color sliderBorder = DEFAULT_SLIDER_BORDER; + + /** The slider ball color. */ + protected Color sliderBall = DEFAULT_SLIDER_BALL; + + /** The spinner approach circle color. */ + protected Color spinnerApproachCircle = DEFAULT_SPINNER_APPROACH_CIRCLE; + + /** The color of text in the currently active group in song selection. */ + protected Color songSelectActiveText = DEFAULT_SONG_SELECT_ACTIVE_TEXT; + + /** The color of text in the inactive groups in song selection. */ + protected Color songSelectInactiveText = DEFAULT_SONG_SELECT_INACTIVE_TEXT; + + /** The color of the stars that fall from the cursor (star2 sprite) in breaks. */ + protected Color starBreakAdditive = DEFAULT_STAR_BREAK_ADDITIVE; + + /** + * [Fonts] + */ + + /** The prefix for the hitcircle font sprites. */ + protected String hitCirclePrefix = "default"; + + /** How much should the hitcircle font sprites overlap? */ + protected int hitCircleOverlap = -2; + + /** The prefix for the score font sprites. */ + protected String scorePrefix = "score"; + + /** How much should the score font sprites overlap? */ + protected int scoreOverlap = 0; + + /** The prefix for the combo font sprites. */ + protected String comboPrefix = "score"; + + /** How much should the combo font sprites overlap? */ + protected int comboOverlap = 0; + + /** + * Constructor. + * @param dir the skin directory + */ + public Skin(File dir) { + this.dir = dir; + } + + /** + * Returns the skin directory. + */ + public File getDirectory() { return dir; } + + /** + * Returns the name of the skin. + */ + public String getName() { return name; } + + /** + * Returns the skin author. + */ + public String getAuthor() { return author; } + + /** + * Returns the skin version. + */ + public int getVersion() { return version; } + + /** + * Returns whether the slider ball should be flipped horizontally during a reverse. + */ + public boolean isSliderBallFlipped() { return sliderBallFlip; } + + /** + * Returns whether the cursor should rotate. + */ + public boolean isCursorRotated() { return cursorRotate; } + + /** + * Returns whether the cursor should expand when clicked. + */ + public boolean isCursorExpanded() { return cursorExpand; } + + /** + * Returns whether the cursor should have an origin in the center. + * @return {@code true} if center, {@code false} if top-left corner + */ + public boolean isCursorCentered() { return cursorCentre; } + + /** + * Returns the number of frames in the slider ball animation. + */ + public int getSliderBallFrames() { return sliderBallFrames; } + + /** + * Returns whether the hit circle overlay should be drawn above the combo number. + */ + public boolean isHitCircleOverlayAboveNumber() { return hitCircleOverlayAboveNumber; } + + /** + * Returns whether the sound frequency should be modulated depending on the spinner score. + */ + public boolean isSpinnerFrequencyModulated() { return spinnerFrequencyModulate; } + + /** + * Returns whether the normal hitsound should always be played (and layered on other sounds). + */ + public boolean isLayeredHitSounds() { return layeredHitSounds; } + + /** + * Returns whether the playfield should fade for spinners. + */ + public boolean isSpinnerFadePlayfield() { return spinnerFadePlayfield; } + + /** + * Returns whether the last spinner bar should blink. + */ + public boolean isSpinnerNoBlink() { return spinnerNoBlink; } + + /** + * Returns whether the slider ball should be tinted with the slider combo color. + */ + public boolean isAllowSliderBallTint() { return allowSliderBallTint; } + + /** + * Returns the frame rate of animations. + * @return the FPS, or {@code -1} (TODO) + */ + public int getAnimationFramerate() { return animationFramerate; } + + /** + * Returns whether the cursor trail should rotate. + */ + public boolean isCursorTrailRotated() { return cursorTrailRotate; } + + /** + * Returns a list of combos with combo sounds. + */ + public int[] getCustomComboBurstSounds() { return customComboBurstSounds; } + + /** + * Returns whether combo bursts should appear in random order. + */ + public boolean isComboBurstRandom() { return comboBurstRandom; } + + /** + * Returns the slider style. + *

    + *
  • 1: peppysliders (segmented) + *
  • 2: mmsliders (smooth) + *
  • 3: toonsliders (smooth, with steps instead of gradient) + *
  • 4: legacy OpenGL-only sliders + *
+ * @return the style (see STYLE_* constants) + */ + public byte getSliderStyle() { return sliderStyle; } + + /** + * Returns the list of combo colors (max 8). + */ + public Color[] getComboColors() { return combo; } + + /** + * Returns the menu visualization bar color. + */ + public Color getMenuGlowColor() { return menuGlow; } + + /** + * Returns the slider border color. + */ + public Color getSliderBorderColor() { return sliderBorder; } + + /** + * Returns the slider ball color. + */ + public Color getSliderBallColor() { return sliderBall; } + + /** + * Returns the spinner approach circle color. + */ + public Color getSpinnerApproachCircleColor() { return spinnerApproachCircle; } + + /** + * Returns the color of the active text in the song selection menu. + */ + public Color getSongSelectActiveTextColor() { return songSelectActiveText; } + + /** + * Returns the color of the inactive text in the song selection menu. + */ + public Color getSongSelectInactiveTextColor() { return songSelectInactiveText; } + + /** + * Returns the color of the stars that fall from the cursor during breaks. + */ + public Color getStarBreakAdditiveColor() { return starBreakAdditive; } + + /** + * Returns the prefix for the hit circle font sprites. + */ + public String getHitCircleFontPrefix() { return hitCirclePrefix; } + + /** + * Returns the amount of overlap between the hit circle font sprites. + */ + public int getHitCircleFontOverlap() { return hitCircleOverlap; } + + /** + * Returns the prefix for the score font sprites. + */ + public String getScoreFontPrefix() { return scorePrefix; } + + /** + * Returns the amount of overlap between the score font sprites. + */ + public int getScoreFontOverlap() { return scoreOverlap; } + + /** + * Returns the prefix for the combo font sprites. + */ + public String getComboFontPrefix() { return comboPrefix; } + + /** + * Returns the amount of overlap between the combo font sprites. + */ + public int getComboFontOverlap() { return comboOverlap; } +} diff --git a/src/itdelatrisu/opsu/skins/SkinLoader.java b/src/itdelatrisu/opsu/skins/SkinLoader.java new file mode 100644 index 00000000..a0a7362e --- /dev/null +++ b/src/itdelatrisu/opsu/skins/SkinLoader.java @@ -0,0 +1,299 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.skins; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.LinkedList; + +import org.newdawn.slick.Color; +import org.newdawn.slick.util.Log; + +/** + * Loads skin configuration files. + */ +public class SkinLoader { + /** Name of the skin configuration file. */ + private static final String CONFIG_FILENAME = "skin.ini"; + + // This class should not be instantiated. + private SkinLoader() {} + + /** + * Returns a list of all subdirectories in the Skins directory. + * @param root the root directory (search has depth 1) + * @return an array of skin directories + */ + public static File[] getSkinDirectories(File root) { + ArrayList dirs = new ArrayList(); + for (File dir : root.listFiles()) { + if (dir.isDirectory()) + dirs.add(dir); + } + return dirs.toArray(new File[dirs.size()]); + } + + /** + * Loads a skin configuration file. + * If 'skin.ini' is not found, or if any fields are not specified, the + * default values will be used. + * @param dir the skin directory + * @return the loaded skin + */ + public static Skin loadSkin(File dir) { + File skinFile = new File(dir, CONFIG_FILENAME); + Skin skin = new Skin(dir); + if (!skinFile.isFile()) // missing skin.ini + return skin; + + try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(skinFile), "UTF-8"))) { + String line = in.readLine(); + String tokens[] = null; + while (line != null) { + line = line.trim(); + if (!isValidLine(line)) { + line = in.readLine(); + continue; + } + switch (line) { + case "[General]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + switch (tokens[0]) { + case "Name": + skin.name = tokens[1]; + break; + case "Author": + skin.author = tokens[1]; + break; + case "Version": + if (tokens[1].equalsIgnoreCase("latest")) + skin.version = Skin.LATEST_VERSION; + else + skin.version = Integer.parseInt(tokens[1]); + break; + case "SliderBallFlip": + skin.sliderBallFlip = Utils.parseBoolean(tokens[1]); + break; + case "CursorRotate": + skin.cursorRotate = Utils.parseBoolean(tokens[1]); + break; + case "CursorExpand": + skin.cursorExpand = Utils.parseBoolean(tokens[1]); + break; + case "CursorCentre": + skin.cursorCentre = Utils.parseBoolean(tokens[1]); + break; + case "SliderBallFrames": + skin.sliderBallFrames = Integer.parseInt(tokens[1]); + break; + case "HitCircleOverlayAboveNumber": + skin.hitCircleOverlayAboveNumber = Utils.parseBoolean(tokens[1]); + break; + case "spinnerFrequencyModulate": + skin.spinnerFrequencyModulate = Utils.parseBoolean(tokens[1]); + break; + case "LayeredHitSounds": + skin.layeredHitSounds = Utils.parseBoolean(tokens[1]); + break; + case "SpinnerFadePlayfield": + skin.spinnerFadePlayfield = Utils.parseBoolean(tokens[1]); + break; + case "SpinnerNoBlink": + skin.spinnerNoBlink = Utils.parseBoolean(tokens[1]); + break; + case "AllowSliderBallTint": + skin.allowSliderBallTint = Utils.parseBoolean(tokens[1]); + break; + case "AnimationFramerate": + skin.animationFramerate = Integer.parseInt(tokens[1]); + break; + case "CursorTrailRotate": + skin.cursorTrailRotate = Utils.parseBoolean(tokens[1]); + break; + case "CustomComboBurstSounds": + String[] split = tokens[1].split(","); + int[] customComboBurstSounds = new int[split.length]; + for (int i = 0; i < split.length; i++) + customComboBurstSounds[i] = Integer.parseInt(split[i]); + skin.customComboBurstSounds = customComboBurstSounds; + break; + case "ComboBurstRandom": + skin.comboBurstRandom = Utils.parseBoolean(tokens[1]); + break; + case "SliderStyle": + skin.sliderStyle = Byte.parseByte(tokens[1]); + break; + default: + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read line '%s' for file '%s'.", + line, skinFile.getAbsolutePath()), e); + } + } + break; + case "[Colours]": + LinkedList colors = new LinkedList(); + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + String[] rgb = tokens[1].split(","); + Color color = new Color( + Integer.parseInt(rgb[0]), + Integer.parseInt(rgb[1]), + Integer.parseInt(rgb[2]) + ); + switch (tokens[0]) { + case "Combo1": + case "Combo2": + case "Combo3": + case "Combo4": + case "Combo5": + case "Combo6": + case "Combo7": + case "Combo8": + colors.add(color); + break; + case "MenuGlow": + skin.menuGlow = color; + break; + case "SliderBorder": + skin.sliderBorder = color; + break; + case "SliderBall": + skin.sliderBall = color; + break; + case "SpinnerApproachCircle": + skin.spinnerApproachCircle = color; + break; + case "SongSelectActiveText": + skin.songSelectActiveText = color; + break; + case "SongSelectInactiveText": + skin.songSelectInactiveText = color; + break; + case "StarBreakAdditive": + skin.starBreakAdditive = color; + break; + default: + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read color '%s' for file '%s'.", + line, skinFile.getAbsolutePath()), e); + } + } + if (!colors.isEmpty()) + skin.combo = colors.toArray(new Color[colors.size()]); + break; + case "[Fonts]": + while ((line = in.readLine()) != null) { + line = line.trim(); + if (!isValidLine(line)) + continue; + if (line.charAt(0) == '[') + break; + if ((tokens = tokenize(line)) == null) + continue; + try { + switch (tokens[0]) { + case "HitCirclePrefix": + skin.hitCirclePrefix = tokens[1]; + break; + case "HitCircleOverlap": + skin.hitCircleOverlap = Integer.parseInt(tokens[1]); + break; + case "ScorePrefix": + skin.scorePrefix = tokens[1]; + break; + case "ScoreOverlap": + skin.scoreOverlap = Integer.parseInt(tokens[1]); + break; + case "ComboPrefix": + skin.comboPrefix = tokens[1]; + break; + case "ComboOverlap": + skin.comboOverlap = Integer.parseInt(tokens[1]); + break; + default: + break; + } + } catch (Exception e) { + Log.warn(String.format("Failed to read color '%s' for file '%s'.", + line, skinFile.getAbsolutePath()), e); + } + } + break; + default: + line = in.readLine(); + break; + } + } + } catch (IOException e) { + ErrorHandler.error(String.format("Failed to read file '%s'.", skinFile.getAbsolutePath()), e, false); + } + + return skin; + } + + /** + * Returns false if the line is too short or commented. + */ + private static boolean isValidLine(String line) { + return (line.length() > 1 && !line.startsWith("//")); + } + + /** + * Splits line into two strings: tag, value. + * If no ':' character is present, null will be returned. + */ + private static String[] tokenize(String line) { + int index = line.indexOf(':'); + if (index == -1) { + Log.debug(String.format("Failed to tokenize line: '%s'.", line)); + return null; + } + + String[] tokens = new String[2]; + tokens[0] = line.substring(0, index).trim(); + tokens[1] = line.substring(index + 1).trim(); + return tokens; + } +} diff --git a/src/itdelatrisu/opsu/states/ButtonMenu.java b/src/itdelatrisu/opsu/states/ButtonMenu.java index dde1172b..c27f0b67 100644 --- a/src/itdelatrisu/opsu/states/ButtonMenu.java +++ b/src/itdelatrisu/opsu/states/ButtonMenu.java @@ -20,17 +20,17 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuGroupList; -import itdelatrisu.opsu.OsuGroupNode; import itdelatrisu.opsu.ScoreData; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapSetNode; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import java.util.ArrayList; import java.util.List; @@ -66,9 +66,9 @@ public class ButtonMenu extends BasicGameState { BEATMAP (new Button[] { Button.CLEAR_SCORES, Button.DELETE, Button.CANCEL }) { @Override public String[] getTitle(GameContainer container, StateBasedGame game) { - OsuGroupNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); - String osuString = (node != null) ? OsuGroupList.get().getBaseNode(node.index).toString() : ""; - return new String[] { osuString, "What do you want to do with this beatmap?" }; + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + String beatmapString = (node != null) ? BeatmapSetList.get().getBaseNode(node.index).toString() : ""; + return new String[] { beatmapString, "What do you want to do with this beatmap?" }; } @Override @@ -86,9 +86,9 @@ public class ButtonMenu extends BasicGameState { BEATMAP_DELETE_SELECT (new Button[] { Button.DELETE_GROUP, Button.DELETE_SONG, Button.CANCEL_DELETE }) { @Override public String[] getTitle(GameContainer container, StateBasedGame game) { - OsuGroupNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); - String osuString = (node != null) ? node.toString() : ""; - return new String[] { String.format("Are you sure you wish to delete '%s' from disk?", osuString) }; + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + String beatmapString = (node != null) ? node.toString() : ""; + return new String[] { String.format("Are you sure you wish to delete '%s' from disk?", beatmapString) }; } @Override @@ -314,10 +314,10 @@ public class ButtonMenu extends BasicGameState { public void draw(GameContainer container, StateBasedGame game, Graphics g) { // draw title if (actualTitle != null) { - float c = container.getWidth() * 0.02f; + float marginX = container.getWidth() * 0.015f, marginY = container.getHeight() * 0.01f; int lineHeight = Utils.FONT_LARGE.getLineHeight(); for (int i = 0, size = actualTitle.size(); i < size; i++) - Utils.FONT_LARGE.drawString(c, c + (i * lineHeight), actualTitle.get(i), Color.white); + Utils.FONT_LARGE.drawString(marginX, marginY + (i * lineHeight), actualTitle.get(i), Color.white); } // draw buttons @@ -451,7 +451,7 @@ public class ButtonMenu extends BasicGameState { @Override public void click(GameContainer container, StateBasedGame game) { SoundController.playSound(SoundEffect.MENUHIT); - OsuGroupNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).doStateActionOnLoad(MenuState.BEATMAP, node); game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition(Color.black)); } @@ -460,8 +460,8 @@ public class ButtonMenu extends BasicGameState { @Override public void click(GameContainer container, StateBasedGame game) { SoundController.playSound(SoundEffect.MENUHIT); - OsuGroupNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); - MenuState ms = (node.osuFileIndex == -1 || node.osuFiles.size() == 1) ? + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + MenuState ms = (node.beatmapIndex == -1 || node.getBeatmapSet().size() == 1) ? MenuState.BEATMAP_DELETE_CONFIRM : MenuState.BEATMAP_DELETE_SELECT; ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(ms, node); game.enterState(Opsu.STATE_BUTTONMENU); @@ -478,7 +478,7 @@ public class ButtonMenu extends BasicGameState { @Override public void click(GameContainer container, StateBasedGame game) { SoundController.playSound(SoundEffect.MENUHIT); - OsuGroupNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).doStateActionOnLoad(MenuState.BEATMAP_DELETE_CONFIRM, node); game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition(Color.black)); } @@ -493,7 +493,7 @@ public class ButtonMenu extends BasicGameState { @Override public void click(GameContainer container, StateBasedGame game) { SoundController.playSound(SoundEffect.MENUHIT); - OsuGroupNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); + BeatmapSetNode node = ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).getNode(); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).doStateActionOnLoad(MenuState.BEATMAP_DELETE_SELECT, node); game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition(Color.black)); } @@ -582,7 +582,7 @@ public class ButtonMenu extends BasicGameState { private MenuState menuState; /** The song node to process in the state. */ - private OsuGroupNode node; + private BeatmapSetNode node; /** The score data to process in the state. */ private ScoreData scoreData; @@ -691,7 +691,7 @@ public class ButtonMenu extends BasicGameState { * @param menuState the new menu state * @param node the song node to process in the state */ - public void setMenuState(MenuState menuState, OsuGroupNode node) { setMenuState(menuState, node, null); } + public void setMenuState(MenuState menuState, BeatmapSetNode node) { setMenuState(menuState, node, null); } /** * Changes the menu state. @@ -706,7 +706,7 @@ public class ButtonMenu extends BasicGameState { * @param node the song node to process in the state * @param scoreData the score scoreData */ - private void setMenuState(MenuState menuState, OsuGroupNode node, ScoreData scoreData) { + private void setMenuState(MenuState menuState, BeatmapSetNode node, ScoreData scoreData) { this.menuState = menuState; this.node = node; this.scoreData = scoreData; @@ -715,7 +715,7 @@ public class ButtonMenu extends BasicGameState { /** * Returns the song node being processed, or null if none. */ - private OsuGroupNode getNode() { return node; } + private BeatmapSetNode getNode() { return node; } /** * Returns the score data being processed, or null if none. diff --git a/src/itdelatrisu/opsu/states/DownloadsMenu.java b/src/itdelatrisu/opsu/states/DownloadsMenu.java index 52d46087..98c08d63 100644 --- a/src/itdelatrisu/opsu/states/DownloadsMenu.java +++ b/src/itdelatrisu/opsu/states/DownloadsMenu.java @@ -19,23 +19,25 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuGroupList; -import itdelatrisu.opsu.OsuGroupNode; -import itdelatrisu.opsu.OsuParser; import itdelatrisu.opsu.OszUnpacker; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; -import itdelatrisu.opsu.downloads.BloodcatServer; +import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapSetNode; +import itdelatrisu.opsu.beatmap.BeatmapParser; import itdelatrisu.opsu.downloads.Download; import itdelatrisu.opsu.downloads.DownloadList; import itdelatrisu.opsu.downloads.DownloadNode; -import itdelatrisu.opsu.downloads.DownloadServer; +import itdelatrisu.opsu.downloads.servers.BloodcatServer; +import itdelatrisu.opsu.downloads.servers.DownloadServer; +import itdelatrisu.opsu.downloads.servers.HexideServer; +import itdelatrisu.opsu.downloads.servers.OsuMirrorServer; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import java.io.File; import java.io.IOException; @@ -71,8 +73,11 @@ public class DownloadsMenu extends BasicGameState { /** Minimum time, in milliseconds, that must elapse between queries. */ private static final int MIN_REQUEST_INTERVAL = 300; - /** The beatmap download server. */ - private DownloadServer server = new BloodcatServer(); + /** Available beatmap download servers. */ + private static final DownloadServer[] SERVERS = { new BloodcatServer(), new OsuMirrorServer(), new HexideServer() }; + + /** The beatmap download server index. */ + private int serverIndex = 0; /** The current list of search results. */ private DownloadNode[] resultList; @@ -138,7 +143,7 @@ public class DownloadsMenu extends BasicGameState { private MenuButton prevPage, nextPage; /** Buttons. */ - private MenuButton clearButton, importButton, resetButton, rankedButton; + private MenuButton clearButton, importButton, resetButton, rankedButton, serverButton; /** Beatmap importing thread. */ private Thread importThread; @@ -169,12 +174,12 @@ public class DownloadsMenu extends BasicGameState { int width = container.getWidth(); int height = container.getHeight(); float baseX = width * 0.024f; - float searchY = (height * 0.05f) + Utils.FONT_LARGE.getLineHeight(); - float searchWidth = width * 0.35f; + float searchY = (height * 0.04f) + Utils.FONT_LARGE.getLineHeight(); + float searchWidth = width * 0.3f; // search searchTimer = SEARCH_DELAY; - searchResultString = "Type to search!"; + searchResultString = "Loading data from server..."; search = new TextField( container, Utils.FONT_DEFAULT, (int) baseX, (int) searchY, (int) searchWidth, Utils.FONT_MEDIUM.getLineHeight() @@ -200,8 +205,10 @@ public class DownloadsMenu extends BasicGameState { // buttons float buttonMarginX = width * 0.004f; float buttonHeight = height * 0.038f; - float topButtonWidth = width * 0.14f; - float lowerButtonWidth = width * 0.12f; + float resetWidth = width * 0.085f; + float rankedWidth = width * 0.15f; + float serverWidth = width * 0.12f; + float lowerWidth = width * 0.12f; float topButtonY = searchY + Utils.FONT_MEDIUM.getLineHeight() / 2f; float lowerButtonY = height * 0.995f - searchY - buttonHeight / 2f; Image button = GameImage.MENU_BUTTON_MID.getImage(); @@ -209,25 +216,33 @@ public class DownloadsMenu extends BasicGameState { Image buttonR = GameImage.MENU_BUTTON_RIGHT.getImage(); buttonL = buttonL.getScaledCopy(buttonHeight / buttonL.getHeight()); buttonR = buttonR.getScaledCopy(buttonHeight / buttonR.getHeight()); - Image topButton = button.getScaledCopy((int) topButtonWidth - buttonL.getWidth() - buttonR.getWidth(), (int) buttonHeight); - Image lowerButton = button.getScaledCopy((int) lowerButtonWidth - buttonL.getWidth() - buttonR.getWidth(), (int) buttonHeight); - float fullTopButtonWidth = topButton.getWidth() + buttonL.getWidth() + buttonR.getWidth(); - float fullLowerButtonWidth = lowerButton.getWidth() + buttonL.getWidth() + buttonR.getWidth(); - clearButton = new MenuButton(lowerButton, buttonL, buttonR, - width * 0.75f + buttonMarginX + fullLowerButtonWidth / 2f, lowerButtonY); - importButton = new MenuButton(lowerButton, buttonL, buttonR, - width - buttonMarginX - fullLowerButtonWidth / 2f, lowerButtonY); - resetButton = new MenuButton(topButton, buttonL, buttonR, - baseX + searchWidth + buttonMarginX + fullTopButtonWidth / 2f, topButtonY); - rankedButton = new MenuButton(topButton, buttonL, buttonR, - baseX + searchWidth + buttonMarginX * 2f + fullTopButtonWidth * 3 / 2f, topButtonY); + int lrButtonWidth = buttonL.getWidth() + buttonR.getWidth(); + Image resetButtonImage = button.getScaledCopy((int) resetWidth - lrButtonWidth, (int) buttonHeight); + Image rankedButtonImage = button.getScaledCopy((int) rankedWidth - lrButtonWidth, (int) buttonHeight); + Image serverButtonImage = button.getScaledCopy((int) serverWidth - lrButtonWidth, (int) buttonHeight); + Image lowerButtonImage = button.getScaledCopy((int) lowerWidth - lrButtonWidth, (int) buttonHeight); + float resetButtonWidth = resetButtonImage.getWidth() + lrButtonWidth; + float rankedButtonWidth = rankedButtonImage.getWidth() + lrButtonWidth; + float serverButtonWidth = serverButtonImage.getWidth() + lrButtonWidth; + float lowerButtonWidth = lowerButtonImage.getWidth() + lrButtonWidth; + clearButton = new MenuButton(lowerButtonImage, buttonL, buttonR, + width * 0.75f + buttonMarginX + lowerButtonWidth / 2f, lowerButtonY); + importButton = new MenuButton(lowerButtonImage, buttonL, buttonR, + width - buttonMarginX - lowerButtonWidth / 2f, lowerButtonY); + resetButton = new MenuButton(resetButtonImage, buttonL, buttonR, + baseX + searchWidth + buttonMarginX + resetButtonWidth / 2f, topButtonY); + rankedButton = new MenuButton(rankedButtonImage, buttonL, buttonR, + baseX + searchWidth + buttonMarginX * 2f + resetButtonWidth + rankedButtonWidth / 2f, topButtonY); + serverButton = new MenuButton(serverButtonImage, buttonL, buttonR, + baseX + searchWidth + buttonMarginX * 3f + resetButtonWidth + rankedButtonWidth + serverButtonWidth / 2f, topButtonY); clearButton.setText("Clear", Utils.FONT_MEDIUM, Color.white); importButton.setText("Import All", Utils.FONT_MEDIUM, Color.white); - resetButton.setText("Reset Search", Utils.FONT_MEDIUM, Color.white); + resetButton.setText("Reset", Utils.FONT_MEDIUM, Color.white); clearButton.setHoverFade(); importButton.setHoverFade(); resetButton.setHoverFade(); rankedButton.setHoverFade(); + serverButton.setHoverFade(); } @Override @@ -241,7 +256,7 @@ public class DownloadsMenu extends BasicGameState { GameImage.SEARCH_BG.getImage().draw(); // title - Utils.FONT_LARGE.drawString(width * 0.024f, height * 0.04f, "Download Beatmaps!", Color.white); + Utils.FONT_LARGE.drawString(width * 0.024f, height * 0.03f, "Download Beatmaps!", Color.white); // search g.setColor(Color.white); @@ -317,6 +332,8 @@ public class DownloadsMenu extends BasicGameState { resetButton.draw(Color.red); rankedButton.setText((rankedOnly) ? "Show Unranked" : "Hide Unranked", Utils.FONT_MEDIUM, Color.white); rankedButton.draw(Color.magenta); + serverButton.setText(SERVERS[serverIndex].getName(), Utils.FONT_MEDIUM, Color.white); + serverButton.draw(Color.blue); // importing beatmaps if (importThread != null) { @@ -348,6 +365,7 @@ public class DownloadsMenu extends BasicGameState { importButton.hoverUpdate(delta, mouseX, mouseY); resetButton.hoverUpdate(delta, mouseX, mouseY); rankedButton.hoverUpdate(delta, mouseX, mouseY); + serverButton.hoverUpdate(delta, mouseX, mouseY); // focus timer if (focusResult != -1 && focusTimer < FOCUS_DELAY) @@ -361,7 +379,9 @@ public class DownloadsMenu extends BasicGameState { searchTimerReset = false; final String query = search.getText().trim().toLowerCase(); - if (lastQuery == null || !query.equals(lastQuery)) { + final DownloadServer server = SERVERS[serverIndex]; + if ((lastQuery == null || !query.equals(lastQuery)) && + (query.length() == 0 || query.length() >= server.minQueryLength())) { lastQuery = query; lastQueryDir = pageDir; @@ -409,7 +429,7 @@ public class DownloadsMenu extends BasicGameState { else { if (query.isEmpty()) searchResultString = "Type to search!"; - else if (totalResults == 0) + else if (totalResults == 0 || resultList.length == 0) searchResultString = "No results found."; else searchResultString = String.format("%d result%s found!", @@ -427,6 +447,14 @@ public class DownloadsMenu extends BasicGameState { queryThread.start(); } } + + // tooltips + if (resetButton.contains(mouseX, mouseY)) + UI.updateTooltip(delta, "Reset the current search.", false); + else if (rankedButton.contains(mouseX, mouseY)) + UI.updateTooltip(delta, "Toggle the display of unranked maps.\nSome download servers may not support this option.", true); + else if (serverButton.contains(mouseX, mouseY)) + UI.updateTooltip(delta, "Select a download server.", false); } @Override @@ -463,7 +491,7 @@ public class DownloadsMenu extends BasicGameState { final DownloadNode node = nodes[index]; // check if map is already loaded - boolean isLoaded = OsuGroupList.get().containsBeatmapSetID(node.getID()); + boolean isLoaded = BeatmapSetList.get().containsBeatmapSetID(node.getID()); // track preview if (DownloadNode.resultIconContains(x, y, i)) { @@ -481,7 +509,7 @@ public class DownloadsMenu extends BasicGameState { } else { // play preview try { - final URL url = new URL(server.getPreviewURL(node.getID())); + final URL url = new URL(SERVERS[serverIndex].getPreviewURL(node.getID())); MusicController.pause(); new Thread() { @Override @@ -525,9 +553,13 @@ public class DownloadsMenu extends BasicGameState { } else { // start download if (!DownloadList.get().contains(node.getID())) { - DownloadList.get().addNode(node); - node.createDownload(server); - node.getDownload().start(); + node.createDownload(SERVERS[serverIndex]); + if (node.getDownload() == null) + UI.sendBarNotification("The download could not be started."); + else { + DownloadList.get().addNode(node); + node.getDownload().start(); + } } } } else { @@ -584,15 +616,15 @@ public class DownloadsMenu extends BasicGameState { // invoke unpacker and parser File[] dirs = OszUnpacker.unpackAllFiles(Options.getOSZDir(), Options.getBeatmapDir()); if (dirs != null && dirs.length > 0) { - OsuGroupNode node = OsuParser.parseDirectories(dirs); + BeatmapSetNode node = BeatmapParser.parseDirectories(dirs); if (node != null) { // stop preview previewID = -1; SoundController.stopTrack(); // initialize song list - OsuGroupList.get().reset(); - OsuGroupList.get().init(); + BeatmapSetList.get().reset(); + BeatmapSetList.get().init(); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).setFocus(node, -1, true, true); // send notification @@ -624,6 +656,22 @@ public class DownloadsMenu extends BasicGameState { resetSearchTimer(); return; } + if (serverButton.contains(x, y)) { + SoundController.playSound(SoundEffect.MENUCLICK); + resultList = null; + startResult = 0; + focusResult = -1; + totalResults = 0; + page = 0; + pageResultTotal = 1; + pageDir = Page.RESET; + searchResultString = "Loading data from server..."; + serverIndex = (serverIndex + 1) % SERVERS.length; + lastQuery = null; + pageDir = Page.RESET; + resetSearchTimer(); + return; + } // downloads if (!DownloadList.get().isEmpty() && DownloadNode.downloadAreaContains(x, y)) { @@ -700,7 +748,7 @@ public class DownloadsMenu extends BasicGameState { switch (key) { case Input.KEY_ESCAPE: if (importThread != null) { - // beatmap importing: stop parsing OsuFiles by sending interrupt to OsuParser + // beatmap importing: stop parsing beatmaps by sending interrupt to BeatmapParser importThread.interrupt(); } else if (!search.getText().isEmpty()) { // clear search text @@ -755,6 +803,7 @@ public class DownloadsMenu extends BasicGameState { importButton.resetHover(); resetButton.resetHover(); rankedButton.resetHover(); + serverButton.resetHover(); focusResult = -1; startResult = 0; startDownloadIndex = 0; diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java index 4e6a4479..acb371c4 100644 --- a/src/itdelatrisu/opsu/states/Game.java +++ b/src/itdelatrisu/opsu/states/Game.java @@ -22,29 +22,32 @@ import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.GameData; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuFile; -import itdelatrisu.opsu.OsuHitObject; -import itdelatrisu.opsu.OsuParser; -import itdelatrisu.opsu.OsuTimingPoint; import itdelatrisu.opsu.ScoreData; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.HitSound; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; -import itdelatrisu.opsu.db.OsuDB; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapParser; +import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.beatmap.TimingPoint; +import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.db.ScoreDB; import itdelatrisu.opsu.objects.Circle; import itdelatrisu.opsu.objects.DummyObject; -import itdelatrisu.opsu.objects.HitObject; +import itdelatrisu.opsu.objects.GameObject; import itdelatrisu.opsu.objects.Slider; import itdelatrisu.opsu.objects.Spinner; +import itdelatrisu.opsu.objects.curves.Curve; +import itdelatrisu.opsu.render.FrameBufferCache; +import itdelatrisu.opsu.replay.PlaybackSpeed; import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.replay.ReplayFrame; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import java.io.File; import java.util.LinkedList; @@ -95,17 +98,17 @@ public class Game extends BasicGameState { /** Stack position offset modifier. */ private static final float STACK_OFFSET_MODIFIER = 0.05f; - /** The associated OsuFile object. */ - private OsuFile osu; + /** The associated beatmap. */ + private Beatmap beatmap; /** The associated GameData object. */ private GameData data; - /** Current hit object index in OsuHitObject[] array. */ + /** Current hit object index (in both hit object arrays). */ private int objectIndex = 0; - /** The map's HitObjects, indexed by objectIndex. */ - private HitObject[] hitObjects; + /** The map's game objects, indexed by objectIndex. */ + private GameObject[] gameObjects; /** Delay time, in milliseconds, before song starts. */ private int leadInTime; @@ -211,6 +214,9 @@ public class Game extends BasicGameState { /** Whether or not the cursor should be pressed using the "auto" mod. */ private boolean autoMousePressed; + /** Playback speed (used in replays and "auto" mod). */ + private PlaybackSpeed playbackSpeed; + // game-related variables private GameContainer container; private StateBasedGame game; @@ -254,7 +260,7 @@ public class Game extends BasicGameState { trackPosition = pauseTime; else if (deathTime > -1) // "Easy" mod: health bar increasing trackPosition = deathTime; - int firstObjectTime = osu.objects[0].getTime(); + int firstObjectTime = beatmap.objects[0].getTime(); int timeDiff = firstObjectTime - trackPosition; g.setBackground(Color.black); @@ -269,11 +275,11 @@ public class Game extends BasicGameState { float dimLevel = Options.getBackgroundDim(); if (trackPosition < firstObjectTime) { if (timeDiff < approachTime) - dimLevel += (1f - dimLevel) * ((float) timeDiff / Math.min(approachTime, firstObjectTime)); + dimLevel += (1f - dimLevel) * ((float) timeDiff / approachTime); else dimLevel = 1f; } - if (Options.isDefaultPlayfieldForced() || !osu.drawBG(width, height, dimLevel, false)) { + if (Options.isDefaultPlayfieldForced() || !beatmap.drawBG(width, height, dimLevel, false)) { Image playfield = GameImage.PLAYFIELD.getImage(); playfield.setAlpha(dimLevel); playfield.draw(); @@ -292,51 +298,51 @@ public class Game extends BasicGameState { float[] autoXY = null; if (isLeadIn()) { // lead-in - float progress = Math.max((float) (leadInTime - osu.audioLeadIn) / approachTime, 0f); + float progress = Math.max((float) (leadInTime - beatmap.audioLeadIn) / approachTime, 0f); autoMouseY = (int) (height / (2f - progress)); } else if (objectIndex == 0 && trackPosition < firstObjectTime) { // before first object timeDiff = firstObjectTime - trackPosition; if (timeDiff < approachTime) { - float[] xy = hitObjects[0].getPointAt(trackPosition); - autoXY = getPointAt(autoMouseX, autoMouseY, xy[0], xy[1], 1f - ((float) timeDiff / Math.min(approachTime, firstObjectTime))); + float[] xy = gameObjects[0].getPointAt(trackPosition); + autoXY = getPointAt(autoMouseX, autoMouseY, xy[0], xy[1], 1f - ((float) timeDiff / approachTime)); } - } else if (objectIndex < osu.objects.length) { + } else if (objectIndex < beatmap.objects.length) { // normal object - int objectTime = osu.objects[objectIndex].getTime(); + int objectTime = beatmap.objects[objectIndex].getTime(); if (trackPosition < objectTime) { - float[] xyStart = hitObjects[objectIndex - 1].getPointAt(trackPosition); - int startTime = hitObjects[objectIndex - 1].getEndTime(); - if (osu.breaks != null && breakIndex < osu.breaks.size()) { + float[] xyStart = gameObjects[objectIndex - 1].getPointAt(trackPosition); + int startTime = gameObjects[objectIndex - 1].getEndTime(); + if (beatmap.breaks != null && breakIndex < beatmap.breaks.size()) { // starting a break: keep cursor at previous hit object position - if (breakTime > 0 || objectTime > osu.breaks.get(breakIndex)) + if (breakTime > 0 || objectTime > beatmap.breaks.get(breakIndex)) autoXY = xyStart; // after a break ends: move startTime to break end time else if (breakIndex > 1) { - int lastBreakEndTime = osu.breaks.get(breakIndex - 1); + int lastBreakEndTime = beatmap.breaks.get(breakIndex - 1); if (objectTime > lastBreakEndTime && startTime < lastBreakEndTime) startTime = lastBreakEndTime; } } if (autoXY == null) { - float[] xyEnd = hitObjects[objectIndex].getPointAt(trackPosition); + float[] xyEnd = gameObjects[objectIndex].getPointAt(trackPosition); int totalTime = objectTime - startTime; autoXY = getPointAt(xyStart[0], xyStart[1], xyEnd[0], xyEnd[1], (float) (trackPosition - startTime) / totalTime); // hit circles: show a mouse press int offset300 = hitResultOffset[GameData.HIT_300]; - if ((osu.objects[objectIndex].isCircle() && objectTime - trackPosition < offset300) || - (osu.objects[objectIndex - 1].isCircle() && trackPosition - osu.objects[objectIndex - 1].getTime() < offset300)) + if ((beatmap.objects[objectIndex].isCircle() && objectTime - trackPosition < offset300) || + (beatmap.objects[objectIndex - 1].isCircle() && trackPosition - beatmap.objects[objectIndex - 1].getTime() < offset300)) autoMousePressed = true; } } else { - autoXY = hitObjects[objectIndex].getPointAt(trackPosition); + autoXY = gameObjects[objectIndex].getPointAt(trackPosition); autoMousePressed = true; } } else { // last object - autoXY = hitObjects[objectIndex - 1].getPointAt(trackPosition); + autoXY = gameObjects[objectIndex - 1].getPointAt(trackPosition); } // set mouse coordinates @@ -388,12 +394,12 @@ public class Game extends BasicGameState { } // break periods - if (osu.breaks != null && breakIndex < osu.breaks.size() && breakTime > 0) { - int endTime = osu.breaks.get(breakIndex); + if (beatmap.breaks != null && breakIndex < beatmap.breaks.size() && breakTime > 0) { + int endTime = beatmap.breaks.get(breakIndex); int breakLength = endTime - breakTime; // letterbox effect (black bars on top/bottom) - if (osu.letterboxInBreaks && breakLength >= 4000) { + if (beatmap.letterboxInBreaks && breakLength >= 4000) { g.setColor(Color.black); g.fillRect(0, 0, width, height * 0.125f); g.fillRect(0, height * 0.875f, width, height * 0.125f); @@ -441,7 +447,7 @@ public class Game extends BasicGameState { // skip beginning if (objectIndex == 0 && - trackPosition < osu.objects[0].getTime() - SKIP_OFFSET) + trackPosition < beatmap.objects[0].getTime() - SKIP_OFFSET) skipButton.draw(); // show retries @@ -465,40 +471,41 @@ public class Game extends BasicGameState { trackPosition = (leadInTime - Options.getMusicOffset()) * -1; // render approach circles during song lead-in // countdown - if (osu.countdown > 0) { // TODO: implement half/double rate settings + if (beatmap.countdown > 0) { + float speedModifier = GameMod.getSpeedMultiplier() * playbackSpeed.getModifier(); timeDiff = firstObjectTime - trackPosition; - if (timeDiff >= 500 && timeDiff < 3000) { - if (timeDiff >= 1500) { + if (timeDiff >= 500 * speedModifier && timeDiff < 3000 * speedModifier) { + if (timeDiff >= 1500 * speedModifier) { GameImage.COUNTDOWN_READY.getImage().drawCentered(width / 2, height / 2); if (!countdownReadySound) { SoundController.playSound(SoundEffect.READY); countdownReadySound = true; } } - if (timeDiff < 2000) { + if (timeDiff < 2000 * speedModifier) { GameImage.COUNTDOWN_3.getImage().draw(0, 0); if (!countdown3Sound) { SoundController.playSound(SoundEffect.COUNT3); countdown3Sound = true; } } - if (timeDiff < 1500) { + if (timeDiff < 1500 * speedModifier) { GameImage.COUNTDOWN_2.getImage().draw(width - GameImage.COUNTDOWN_2.getImage().getWidth(), 0); if (!countdown2Sound) { SoundController.playSound(SoundEffect.COUNT2); countdown2Sound = true; } } - if (timeDiff < 1000) { + if (timeDiff < 1000 * speedModifier) { GameImage.COUNTDOWN_1.getImage().drawCentered(width / 2, height / 2); if (!countdown1Sound) { SoundController.playSound(SoundEffect.COUNT1); countdown1Sound = true; } } - } else if (timeDiff >= -500 && timeDiff < 500) { + } else if (timeDiff >= -500 * speedModifier && timeDiff < 500 * speedModifier) { Image go = GameImage.COUNTDOWN_GO.getImage(); - go.setAlpha((timeDiff < 0) ? 1 - (timeDiff / -1000f) : 1); + go.setAlpha((timeDiff < 0) ? 1 - (timeDiff / speedModifier / -500f) : 1); go.drawCentered(width / 2, height / 2); if (!countdownGoSound) { SoundController.playSound(SoundEffect.GO); @@ -515,6 +522,10 @@ public class Game extends BasicGameState { if (GameMod.AUTO.isActive()) GameImage.UNRANKED.getImage().drawCentered(width / 2, height * 0.077f); + // draw replay speed button + if (isReplay || GameMod.AUTO.isActive()) + playbackSpeed.getButton().draw(); + // returning from pause screen if (pauseTime > -1 && pausedMouseX > -1 && pausedMouseY > -1) { // darken the screen @@ -539,7 +550,6 @@ public class Game extends BasicGameState { UI.draw(g, autoMouseX, autoMouseY, Utils.isGameKeyPressed()); else UI.draw(g); - } @Override @@ -548,6 +558,8 @@ public class Game extends BasicGameState { UI.update(delta); int mouseX = input.getMouseX(), mouseY = input.getMouseY(); skipButton.hoverUpdate(delta, mouseX, mouseY); + if (isReplay || GameMod.AUTO.isActive()) + playbackSpeed.getButton().hoverUpdate(delta, mouseX, mouseY); int trackPosition = MusicController.getPosition(); // returning from pause screen: must click previous mouse position @@ -660,10 +672,10 @@ public class Game extends BasicGameState { } // map complete! - if (objectIndex >= hitObjects.length || (MusicController.trackEnded() && objectIndex > 0)) { + if (objectIndex >= gameObjects.length || (MusicController.trackEnded() && objectIndex > 0)) { // track ended before last object was processed: force a hit result - if (MusicController.trackEnded() && objectIndex < hitObjects.length) - hitObjects[objectIndex].update(true, delta, mouseX, mouseY, false, trackPosition); + if (MusicController.trackEnded() && objectIndex < gameObjects.length) + gameObjects[objectIndex].update(true, delta, mouseX, mouseY, false, trackPosition); // if checkpoint used, skip ranking screen if (checkpointLoaded) @@ -681,11 +693,11 @@ public class Game extends BasicGameState { replayFrames.getFirst().setTimeDiff(replaySkipTime * -1); replayFrames.addFirst(ReplayFrame.getStartFrame(replaySkipTime)); replayFrames.addFirst(ReplayFrame.getStartFrame(0)); - Replay r = data.getReplay(replayFrames.toArray(new ReplayFrame[replayFrames.size()]), osu); + Replay r = data.getReplay(replayFrames.toArray(new ReplayFrame[replayFrames.size()]), beatmap); if (r != null && !unranked) r.save(); } - ScoreData score = data.getScoreData(osu); + ScoreData score = data.getScoreData(beatmap); // add score to database if (!unranked && !isReplay) @@ -697,23 +709,21 @@ public class Game extends BasicGameState { } // timing points - if (timingPointIndex < osu.timingPoints.size()) { - OsuTimingPoint timingPoint = osu.timingPoints.get(timingPointIndex); + if (timingPointIndex < beatmap.timingPoints.size()) { + TimingPoint timingPoint = beatmap.timingPoints.get(timingPointIndex); if (trackPosition >= timingPoint.getTime()) { - setBeatLength(timingPoint); - HitSound.setDefaultSampleSet(timingPoint.getSampleType()); - SoundController.setSampleVolume(timingPoint.getSampleVolume()); + setBeatLength(timingPoint, true); timingPointIndex++; } } // song beginning - if (objectIndex == 0 && trackPosition < osu.objects[0].getTime()) + if (objectIndex == 0 && trackPosition < beatmap.objects[0].getTime()) return; // nothing to do here // break periods - if (osu.breaks != null && breakIndex < osu.breaks.size()) { - int breakValue = osu.breaks.get(breakIndex); + if (beatmap.breaks != null && breakIndex < beatmap.breaks.size()) { + int breakValue = beatmap.breaks.get(breakIndex); if (breakTime > 0) { // in a break period if (trackPosition < breakValue) return; @@ -765,13 +775,13 @@ public class Game extends BasicGameState { // update objects (loop in unlikely event of any skipped indexes) boolean keyPressed = keys != ReplayFrame.KEY_NONE; - while (objectIndex < hitObjects.length && trackPosition > osu.objects[objectIndex].getTime()) { + while (objectIndex < gameObjects.length && trackPosition > beatmap.objects[objectIndex].getTime()) { // check if we've already passed the next object's start time boolean overlap = (objectIndex + 1 < hitObjects.length && trackPosition > osu.objects[objectIndex + 1].getTime() - hitResultOffset[GameData.HIT_50]); // update hit object and check completion status - if (hitObjects[objectIndex].update(overlap, delta, mouseX, mouseY, keyPressed, trackPosition)) + if (gameObjects[objectIndex].update(overlap, delta, mouseX, mouseY, keyPressed, trackPosition)) objectIndex++; // done, so increment object index else break; @@ -807,7 +817,7 @@ public class Game extends BasicGameState { } // pause game - if (pauseTime < 0 && breakTime <= 0 && trackPosition >= osu.objects[0].getTime()) { + if (pauseTime < 0 && breakTime <= 0 && trackPosition >= beatmap.objects[0].getTime()) { pausedMouseX = mouseX; pausedMouseY = mouseY; pausePulse = 0f; @@ -824,7 +834,7 @@ public class Game extends BasicGameState { // restart if (input.isKeyDown(Input.KEY_RCONTROL) || input.isKeyDown(Input.KEY_LCONTROL)) { try { - if (trackPosition < osu.objects[0].getTime()) + if (trackPosition < beatmap.objects[0].getTime()) retries--; // don't count this retry (cancel out later increment) restart = Restart.MANUAL; enter(container, game); @@ -851,7 +861,7 @@ public class Game extends BasicGameState { // load checkpoint if (input.isKeyDown(Input.KEY_RCONTROL) || input.isKeyDown(Input.KEY_LCONTROL)) { int checkpoint = Options.getCheckpoint(); - if (checkpoint == 0 || checkpoint > osu.endTime) + if (checkpoint == 0 || checkpoint > beatmap.endTime) break; // invalid checkpoint try { restart = Restart.MANUAL; @@ -866,11 +876,12 @@ public class Game extends BasicGameState { // skip to checkpoint MusicController.setPosition(checkpoint); - while (objectIndex < hitObjects.length && - osu.objects[objectIndex++].getTime() <= checkpoint) + MusicController.setPitch(GameMod.getSpeedMultiplier() * playbackSpeed.getModifier()); + while (objectIndex < gameObjects.length && + beatmap.objects[objectIndex++].getTime() <= checkpoint) ; objectIndex--; - lastReplayTime = osu.objects[objectIndex].getTime(); + lastReplayTime = beatmap.objects[objectIndex].getTime(); } catch (SlickException e) { ErrorHandler.error("Failed to load checkpoint.", e, false); } @@ -896,13 +907,13 @@ public class Game extends BasicGameState { @Override public void mousePressed(int button, int x, int y) { - if (Options.isMouseDisabled()) - return; - // watching replay - if (isReplay) { - // only allow skip button - if (button != Input.MOUSE_MIDDLE_BUTTON && skipButton.contains(x, y)) + if (isReplay || GameMod.AUTO.isActive()) { + if (button == Input.MOUSE_MIDDLE_BUTTON) + return; + + // skip button + if (skipButton.contains(x, y)) skipIntro(); if(y < 50){ float pos = (float)x / width * osu.endTime; @@ -912,10 +923,13 @@ public class Game extends BasicGameState { return; } + if (Options.isMouseDisabled()) + return; + // mouse wheel: pause the game if (button == Input.MOUSE_MIDDLE_BUTTON && !Options.isMouseWheelDisabled()) { int trackPosition = MusicController.getPosition(); - if (pauseTime < 0 && breakTime <= 0 && trackPosition >= osu.objects[0].getTime()) { + if (pauseTime < 0 && breakTime <= 0 && trackPosition >= beatmap.objects[0].getTime()) { pausedMouseX = x; pausedMouseY = y; pausePulse = 0f; @@ -1031,8 +1045,11 @@ public class Game extends BasicGameState { throws SlickException { UI.enter(); - if (osu == null || osu.objects == null) - throw new RuntimeException("Running game with no OsuFile loaded."); + if (beatmap == null || beatmap.objects == null) + throw new RuntimeException("Running game with no beatmap loaded."); + + // free all previously cached hitobject to framebuffer mappings if some still exist + FrameBufferCache.getInstance().freeMap(); // grab the mouse (not working for touchscreen) // container.setMouseGrabbed(true); @@ -1053,10 +1070,14 @@ public class Game extends BasicGameState { // reset game data resetGameData(); - // needs to play before setting position to resume without lag later - MusicController.play(false); - MusicController.setPosition(0); - MusicController.pause(); + // load the first timingPoint for stacking + if (!beatmap.timingPoints.isEmpty()) { + TimingPoint timingPoint = beatmap.timingPoints.get(0); + if (!timingPoint.isInherited()) { + setBeatLength(timingPoint, true); + timingPointIndex++; + } + } if (!osu.timingPoints.isEmpty()) { OsuTimingPoint timingPoint = osu.timingPoints.get(0); @@ -1067,39 +1088,39 @@ public class Game extends BasicGameState { hitObjects = new HitObject[osu.objects.length]; // initialize object maps - for (int i = 0; i < osu.objects.length; i++) { - OsuHitObject hitObject = osu.objects[i]; + Color[] combo = beatmap.getComboColors(); + for (int i = 0; i < beatmap.objects.length; i++) { + HitObject hitObject = beatmap.objects[i]; // is this the last note in the combo? boolean comboEnd = false; if (i + 1 >= osu.objects.length || osu.objects[i + 1].isNewCombo()) comboEnd = true; - Color color = osu.combo[hitObject.getComboIndex()]; + Color color = combo[hitObject.getComboIndex()]; // pass beatLength to hit objects int hitObjectTime = hitObject.getTime(); - int timingPointIndex = 0; - while (timingPointIndex < osu.timingPoints.size()) { - OsuTimingPoint timingPoint = osu.timingPoints.get(timingPointIndex); + while (timingPointIndex < beatmap.timingPoints.size()) { + TimingPoint timingPoint = beatmap.timingPoints.get(timingPointIndex); if (timingPoint.getTime() > hitObjectTime) break; - setBeatLength(timingPoint); + setBeatLength(timingPoint, false); timingPointIndex++; } try { if (hitObject.isCircle()) - hitObjects[i] = new Circle(hitObject, this, data, color, comboEnd); + gameObjects[i] = new Circle(hitObject, this, data, color, comboEnd); else if (hitObject.isSlider()) - hitObjects[i] = new Slider(hitObject, this, data, color, comboEnd); + gameObjects[i] = new Slider(hitObject, this, data, color, comboEnd); else if (hitObject.isSpinner()) - hitObjects[i] = new Spinner(hitObject, this, data); + gameObjects[i] = new Spinner(hitObject, this, data); } catch (Exception e) { - // try to handle the error gracefully: substitute in a dummy HitObject + // try to handle the error gracefully: substitute in a dummy GameObject ErrorHandler.error(String.format("Failed to create %s at index %d:\n%s", hitObject.getTypeName(), i, hitObject.toString()), e, true); - hitObjects[i] = new DummyObject(hitObject); + gameObjects[i] = new DummyObject(hitObject); continue; } } @@ -1108,19 +1129,19 @@ public class Game extends BasicGameState { calculateStacks(); // load the first timingPoint - if (!osu.timingPoints.isEmpty()) { - OsuTimingPoint timingPoint = osu.timingPoints.get(0); + timingPointIndex = 0; + beatLengthBase = beatLength = 1; + if (!beatmap.timingPoints.isEmpty()) { + TimingPoint timingPoint = beatmap.timingPoints.get(0); if (!timingPoint.isInherited()) { - beatLengthBase = beatLength = timingPoint.getBeatLength(); - HitSound.setDefaultSampleSet(timingPoint.getSampleType()); - SoundController.setSampleVolume(timingPoint.getSampleVolume()); + setBeatLength(timingPoint, true); timingPointIndex++; } } // unhide cursor for "auto" mod and replays if (GameMod.AUTO.isActive() || isReplay) - UI.showCursor(); + UI.getCursor().show(); // load replay frames if (isReplay) { @@ -1156,11 +1177,20 @@ public class Game extends BasicGameState { replayFrames.add(new ReplayFrame(0, 0, input.getMouseX(), input.getMouseY(), 0)); } - leadInTime = osu.audioLeadIn + approachTime; + leadInTime = beatmap.audioLeadIn + approachTime; restart = Restart.FALSE; + + // needs to play before setting position to resume without lag later + MusicController.play(false); + MusicController.setPosition(0); + MusicController.setPitch(GameMod.getSpeedMultiplier()); + MusicController.pause(); } skipButton.resetHover(); + if (isReplay || GameMod.AUTO.isActive()) + playbackSpeed.getButton().resetHover(); + MusicController.setPitch(GameMod.getSpeedMultiplier() * playbackSpeed.getModifier()); } @Override @@ -1170,11 +1200,14 @@ public class Game extends BasicGameState { // re-hide cursor if (GameMod.AUTO.isActive() || isReplay) - UI.hideCursor(); + UI.getCursor().hide(); // replays if (isReplay) GameMod.loadModState(previousMods); + + // reset playback speed + MusicController.setPitch(1f); } /** @@ -1185,29 +1218,29 @@ public class Game extends BasicGameState { private void drawHitObjects(Graphics g, int trackPosition) { // include previous object in follow points int lastObjectIndex = -1; - if (objectIndex > 0 && objectIndex < osu.objects.length && - trackPosition < osu.objects[objectIndex].getTime() && !osu.objects[objectIndex - 1].isSpinner()) + if (objectIndex > 0 && objectIndex < beatmap.objects.length && + trackPosition < beatmap.objects[objectIndex].getTime() && !beatmap.objects[objectIndex - 1].isSpinner()) lastObjectIndex = objectIndex - 1; // draw hit objects in reverse order, or else overlapping objects are unreadable Stack stack = new Stack(); - for (int index = objectIndex; index < hitObjects.length && osu.objects[index].getTime() < trackPosition + approachTime; index++) { + for (int index = objectIndex; index < gameObjects.length && beatmap.objects[index].getTime() < trackPosition + approachTime; index++) { stack.add(index); // draw follow points if (!Options.isFollowPointEnabled()) continue; - if (osu.objects[index].isSpinner()) { + if (beatmap.objects[index].isSpinner()) { lastObjectIndex = -1; continue; } - if (lastObjectIndex != -1 && !osu.objects[index].isNewCombo()) { + if (lastObjectIndex != -1 && !beatmap.objects[index].isNewCombo()) { // calculate points final int followPointInterval = container.getHeight() / 14; - int lastObjectEndTime = hitObjects[lastObjectIndex].getEndTime() + 1; - int objectStartTime = osu.objects[index].getTime(); - float[] startXY = hitObjects[lastObjectIndex].getPointAt(lastObjectEndTime); - float[] endXY = hitObjects[index].getPointAt(objectStartTime); + int lastObjectEndTime = gameObjects[lastObjectIndex].getEndTime() + 1; + int objectStartTime = beatmap.objects[index].getTime(); + float[] startXY = gameObjects[lastObjectIndex].getPointAt(lastObjectEndTime); + float[] endXY = gameObjects[index].getPointAt(objectStartTime); float xDiff = endXY[0] - startXY[0]; float yDiff = endXY[1] - startXY[1]; float dist = (float) Math.hypot(xDiff, yDiff); @@ -1254,29 +1287,32 @@ public class Game extends BasicGameState { } while (!stack.isEmpty()) - hitObjects[stack.pop()].draw(g, trackPosition); + gameObjects[stack.pop()].draw(g, trackPosition); // draw OsuHitObjectResult objects data.drawHitResults(trackPosition); } /** - * Loads all required data from an OsuFile. - * @param osu the OsuFile to load + * Loads all required data from a beatmap. + * @param beatmap the beatmap to load */ - public void loadOsuFile(OsuFile osu) { - this.osu = osu; - Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString())); - if (osu.timingPoints == null || osu.combo == null) - OsuDB.load(osu, OsuDB.LOAD_ARRAY); - OsuParser.parseHitObjects(osu); - HitSound.setDefaultSampleSet(osu.sampleSet); + public void loadBeatmap(Beatmap beatmap) { + this.beatmap = beatmap; + Display.setTitle(String.format("%s - %s", game.getTitle(), beatmap.toString())); + if (beatmap.timingPoints == null) + BeatmapDB.load(beatmap, BeatmapDB.LOAD_ARRAY); + BeatmapParser.parseHitObjects(beatmap); + HitSound.setDefaultSampleSet(beatmap.sampleSet); } /** * Resets all game data and structures. */ public void resetGameData() { + //conflict + gameObjects = new GameObject[beatmap.objects.length]; + // data.clear(); objectIndex = 0; breakIndex = 0; @@ -1301,16 +1337,17 @@ public class Game extends BasicGameState { autoMouseY = 0; autoMousePressed = false; flashlightRadius = container.getHeight() * 2 / 3; + playbackSpeed = PlaybackSpeed.NORMAL; System.gc(); } /** * Skips the beginning of a track. - * @return true if skipped, false otherwise + * @return {@code true} if skipped, {@code false} otherwise */ private synchronized boolean skipIntro() { - int firstObjectTime = osu.objects[0].getTime(); + int firstObjectTime = beatmap.objects[0].getTime(); int trackPosition = MusicController.getPosition(); if (objectIndex == 0 && trackPosition < firstObjectTime - SKIP_OFFSET) { if (isLeadIn()) { @@ -1318,6 +1355,7 @@ public class Game extends BasicGameState { MusicController.resume(); } MusicController.setPosition(firstObjectTime - SKIP_OFFSET); + MusicController.setPitch(GameMod.getSpeedMultiplier() * playbackSpeed.getModifier()); replaySkipTime = (isReplay) ? -1 : trackPosition; if (isReplay) { replayX = (int) skipButton.getX(); @@ -1337,7 +1375,7 @@ public class Game extends BasicGameState { int height = container.getHeight(); // set images - File parent = osu.getFile().getParentFile(); + File parent = beatmap.getFile().getParentFile(); for (GameImage img : GameImage.values()) { if (img.isSkinnable()) { img.setDefaultImage(); @@ -1365,26 +1403,11 @@ public class Game extends BasicGameState { */ private void setMapModifiers() { // map-based properties, re-initialized each game - float circleSize = osu.circleSize; - float approachRate = osu.approachRate; - float overallDifficulty = osu.overallDifficulty; - float HPDrainRate = osu.HPDrainRate; - - // "Hard Rock" modifiers - if (GameMod.HARD_ROCK.isActive()) { - circleSize = Math.min(circleSize * 1.4f, 10); - approachRate = Math.min(approachRate * 1.4f, 10); - overallDifficulty = Math.min(overallDifficulty * 1.4f, 10); - HPDrainRate = Math.min(HPDrainRate * 1.4f, 10); - } - - // "Easy" modifiers - else if (GameMod.EASY.isActive()) { - circleSize /= 2f; - approachRate /= 2f; - overallDifficulty /= 2f; - HPDrainRate /= 2f; - } + float multiplier = GameMod.getDifficultyMultiplier(); + float circleSize = Math.min(beatmap.circleSize * multiplier, 10f); + float approachRate = Math.min(beatmap.approachRate * multiplier, 10f); + float overallDifficulty = Math.min(beatmap.overallDifficulty * multiplier, 10f); + float HPDrainRate = Math.min(beatmap.HPDrainRate * multiplier, 10f); // fixed difficulty overrides if (Options.getFixedCS() > 0f) @@ -1399,12 +1422,14 @@ public class Game extends BasicGameState { // Stack modifier scales with hit object size // StackOffset = HitObjectRadius / 10 int diameter = (int) (104 - (circleSize * 8)); - OsuHitObject.setStackOffset(diameter * STACK_OFFSET_MODIFIER); + HitObject.setStackOffset(diameter * STACK_OFFSET_MODIFIER); // initialize objects Circle.init(container, circleSize); - Slider.init(container, circleSize, osu); + Slider.init(container, circleSize, beatmap); Spinner.init(container); + Curve.init(container.getWidth(), container.getHeight(), circleSize, (Options.isBeatmapSkinIgnored()) ? + Options.getSkin().getSliderBorderColor() : beatmap.getSliderBorderColor()); // approachRate (hit object approach time) if (approachRate < 5) @@ -1436,9 +1461,14 @@ public class Game extends BasicGameState { } /** - * Sets/returns whether entering the state will restart it. + * Sets the restart state. + * @param restart the new restart state */ public void setRestart(Restart restart) { this.restart = restart; } + + /** + * Returns the current restart state. + */ public Restart getRestart() { return restart; } /** @@ -1463,12 +1493,18 @@ public class Game extends BasicGameState { /** * Sets the beat length fields based on a given timing point. + * @param timingPoint the timing point + * @param setSampleSet whether to set the hit sample set based on the timing point */ - private void setBeatLength(OsuTimingPoint timingPoint) { + private void setBeatLength(TimingPoint timingPoint, boolean setSampleSet) { if (!timingPoint.isInherited()) beatLengthBase = beatLength = timingPoint.getBeatLength(); else beatLength = beatLengthBase * timingPoint.getSliderMultiplier(); + if (setSampleSet) { + HitSound.setDefaultSampleSet(timingPoint.getSampleType()); + SoundController.setSampleVolume(timingPoint.getSampleVolume()); + } } /** @@ -1540,18 +1576,18 @@ public class Game extends BasicGameState { */ private void sendGameKeyPress(int keys, int x, int y, int trackPosition) { System.out.println("Game Key Pressed"+keys+" "+x+" "+y+" "+objectIndex); - if (objectIndex >= hitObjects.length) // nothing to do here + if (objectIndex >= gameObjects.length) // nothing to do here return; - OsuHitObject hitObject = osu.objects[objectIndex]; + HitObject hitObject = beatmap.objects[objectIndex]; // circles - if (hitObject.isCircle() && hitObjects[objectIndex].mousePressed(x, y, trackPosition)) + if (hitObject.isCircle() && gameObjects[objectIndex].mousePressed(x, y, trackPosition)) objectIndex++; // circle hit // sliders else if (hitObject.isSlider()) - hitObjects[objectIndex].mousePressed(x, y, trackPosition); + gameObjects[objectIndex].mousePressed(x, y, trackPosition); } /** @@ -1565,8 +1601,8 @@ public class Game extends BasicGameState { private ReplayFrame addReplayFrame(int x, int y, int keys, int time) { int timeDiff = time - lastReplayTime; lastReplayTime = time; - int cx = (int) ((x - OsuHitObject.getXOffset()) / OsuHitObject.getXMultiplier()); - int cy = (int) ((y - OsuHitObject.getYOffset()) / OsuHitObject.getYMultiplier()); + int cx = (int) ((x - HitObject.getXOffset()) / HitObject.getXMultiplier()); + int cy = (int) ((y - HitObject.getYOffset()) / HitObject.getYMultiplier()); ReplayFrame frame = new ReplayFrame(timeDiff, time, cx, cy, keys); if (replayFrames != null) replayFrames.add(frame); @@ -1603,14 +1639,14 @@ public class Game extends BasicGameState { return; int width = container.getWidth(), height = container.getHeight(); - boolean firstObject = (objectIndex == 0 && trackPosition < osu.objects[0].getTime()); + boolean firstObject = (objectIndex == 0 && trackPosition < beatmap.objects[0].getTime()); if (isLeadIn()) { // lead-in: expand area - float progress = Math.max((float) (leadInTime - osu.audioLeadIn) / approachTime, 0f); + float progress = Math.max((float) (leadInTime - beatmap.audioLeadIn) / approachTime, 0f); flashlightRadius = width - (int) ((width - (height * 2 / 3)) * progress); } else if (firstObject) { // before first object: shrink area - int timeDiff = osu.objects[0].getTime() - trackPosition; + int timeDiff = beatmap.objects[0].getTime() - trackPosition; flashlightRadius = width; if (timeDiff < approachTime) { float progress = (float) timeDiff / approachTime; @@ -1626,10 +1662,10 @@ public class Game extends BasicGameState { targetRadius = height / 2; else targetRadius = height / 3; - if (osu.breaks != null && breakIndex < osu.breaks.size() && breakTime > 0) { + if (beatmap.breaks != null && breakIndex < beatmap.breaks.size() && breakTime > 0) { // breaks: expand at beginning, shrink at end flashlightRadius = targetRadius; - int endTime = osu.breaks.get(breakIndex); + int endTime = beatmap.breaks.get(breakIndex); int breakLength = endTime - breakTime; if (breakLength > approachTime * 3) { float progress = 1f; @@ -1662,8 +1698,8 @@ public class Game extends BasicGameState { */ private void calculateStacks() { // reverse pass for stack calculation - for (int i = hitObjects.length - 1; i > 0; i--) { - OsuHitObject hitObjectI = osu.objects[i]; + for (int i = gameObjects.length - 1; i > 0; i--) { + HitObject hitObjectI = beatmap.objects[i]; // already calculated if (hitObjectI.getStack() != 0 || hitObjectI.isSpinner()) @@ -1671,33 +1707,33 @@ public class Game extends BasicGameState { // search for hit objects in stack for (int n = i - 1; n >= 0; n--) { - OsuHitObject hitObjectN = osu.objects[n]; + HitObject hitObjectN = beatmap.objects[n]; if (hitObjectN.isSpinner()) continue; // check if in range stack calculation - float timeI = hitObjectI.getTime() - (STACK_TIMEOUT * osu.stackLeniency); - float timeN = hitObjectN.isSlider() ? hitObjects[n].getEndTime() : hitObjectN.getTime(); + float timeI = hitObjectI.getTime() - (STACK_TIMEOUT * beatmap.stackLeniency); + float timeN = hitObjectN.isSlider() ? gameObjects[n].getEndTime() : hitObjectN.getTime(); if (timeI > timeN) break; // possible special case: if slider end in the stack, // all next hit objects in stack move right down if (hitObjectN.isSlider()) { - float[] p1 = hitObjects[i].getPointAt(hitObjectI.getTime()); - float[] p2 = hitObjects[n].getPointAt(hitObjects[n].getEndTime()); + float[] p1 = gameObjects[i].getPointAt(hitObjectI.getTime()); + float[] p2 = gameObjects[n].getPointAt(gameObjects[n].getEndTime()); float distance = Utils.distance(p1[0], p1[1], p2[0], p2[1]); // check if hit object part of this stack - if (distance < STACK_LENIENCE * OsuHitObject.getXMultiplier()) { + if (distance < STACK_LENIENCE * HitObject.getXMultiplier()) { int offset = hitObjectI.getStack() - hitObjectN.getStack() + 1; for (int j = n + 1; j <= i; j++) { - OsuHitObject hitObjectJ = osu.objects[j]; - p1 = hitObjects[j].getPointAt(hitObjectJ.getTime()); + HitObject hitObjectJ = beatmap.objects[j]; + p1 = gameObjects[j].getPointAt(hitObjectJ.getTime()); distance = Utils.distance(p1[0], p1[1], p2[0], p2[1]); // hit object below slider end - if (distance < STACK_LENIENCE * OsuHitObject.getXMultiplier()) + if (distance < STACK_LENIENCE * HitObject.getXMultiplier()) hitObjectJ.setStack(hitObjectJ.getStack() - offset); } break; // slider end always start of the stack: reset calculation @@ -1717,9 +1753,9 @@ public class Game extends BasicGameState { } // update hit object positions - for (int i = 0; i < hitObjects.length; i++) { - if (osu.objects[i].getStack() != 0) - hitObjects[i].updatePosition(); + for (int i = 0; i < gameObjects.length; i++) { + if (beatmap.objects[i].getStack() != 0) + gameObjects[i].updatePosition(); } } } diff --git a/src/itdelatrisu/opsu/states/GamePauseMenu.java b/src/itdelatrisu/opsu/states/GamePauseMenu.java index 4eec52f5..e73e5a2a 100644 --- a/src/itdelatrisu/opsu/states/GamePauseMenu.java +++ b/src/itdelatrisu/opsu/states/GamePauseMenu.java @@ -19,14 +19,14 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import org.lwjgl.input.Keyboard; import org.newdawn.slick.Color; @@ -132,8 +132,9 @@ public class GamePauseMenu extends BasicGameState { if (gameState.getRestart() == Game.Restart.LOSE) { SoundController.playSound(SoundEffect.MENUBACK); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).resetGameDataOnLoad(); - MusicController.playAt(MusicController.getOsuFile().previewTime, true); - UI.resetCursor(); + MusicController.playAt(MusicController.getBeatmap().previewTime, true); + if (UI.getCursor().isSkinned()) + UI.getCursor().reset(); game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); } else { SoundController.playSound(SoundEffect.MENUBACK); @@ -183,10 +184,11 @@ public class GamePauseMenu extends BasicGameState { SoundController.playSound(SoundEffect.MENUBACK); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).resetGameDataOnLoad(); if (loseState) - MusicController.playAt(MusicController.getOsuFile().previewTime, true); + MusicController.playAt(MusicController.getBeatmap().previewTime, true); else MusicController.resume(); - UI.resetCursor(); + if (UI.getCursor().isSkinned()) + UI.getCursor().reset(); game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); } } diff --git a/src/itdelatrisu/opsu/states/GameRanking.java b/src/itdelatrisu/opsu/states/GameRanking.java index 327f9c41..4fcc18ec 100644 --- a/src/itdelatrisu/opsu/states/GameRanking.java +++ b/src/itdelatrisu/opsu/states/GameRanking.java @@ -20,16 +20,16 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameData; import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuFile; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.replay.Replay; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import java.io.FileNotFoundException; import java.io.IOException; @@ -102,14 +102,14 @@ public class GameRanking extends BasicGameState { int width = container.getWidth(); int height = container.getHeight(); - OsuFile osu = MusicController.getOsuFile(); + Beatmap beatmap = MusicController.getBeatmap(); // background - if (!osu.drawBG(width, height, 0.7f, true)) + if (!beatmap.drawBG(width, height, 0.7f, true)) GameImage.PLAYFIELD.getImage().draw(0,0); // ranking screen elements - data.drawRankingElements(g, osu); + data.drawRankingElements(g, beatmap); // buttons replayButton.draw(); @@ -201,8 +201,8 @@ public class GameRanking extends BasicGameState { } if (returnToGame) { - OsuFile osu = MusicController.getOsuFile(); - gameState.loadOsuFile(osu); + Beatmap beatmap = MusicController.getBeatmap(); + gameState.loadBeatmap(beatmap); SoundController.playSound(SoundEffect.MENUHIT); game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); return; @@ -244,7 +244,8 @@ public class GameRanking extends BasicGameState { songMenu.resetGameDataOnLoad(); songMenu.resetTrackOnLoad(); } - UI.resetCursor(); + if (UI.getCursor().isSkinned()) + UI.getCursor().reset(); game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); } diff --git a/src/itdelatrisu/opsu/states/MainMenu.java b/src/itdelatrisu/opsu/states/MainMenu.java index 999b4420..53aa372e 100644 --- a/src/itdelatrisu/opsu/states/MainMenu.java +++ b/src/itdelatrisu/opsu/states/MainMenu.java @@ -20,20 +20,20 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.MenuButton; -import itdelatrisu.opsu.MenuButton.Expand; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuFile; -import itdelatrisu.opsu.OsuGroupList; -import itdelatrisu.opsu.OsuGroupNode; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapSetNode; import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.states.ButtonMenu.MenuState; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.MenuButton.Expand; import java.awt.Desktop; import java.io.IOException; @@ -91,7 +91,7 @@ public class MainMenu extends BasicGameState { private MenuButton updateButton; /** Application start time, for drawing the total running time. */ - private long osuStartTime; + private long programStartTime; /** Indexes of previous songs. */ private Stack previous; @@ -127,7 +127,7 @@ public class MainMenu extends BasicGameState { this.game = game; this.input = container.getInput(); - osuStartTime = System.currentTimeMillis(); + programStartTime = System.currentTimeMillis(); previous = new Stack(); int width = container.getWidth(); @@ -199,9 +199,9 @@ public class MainMenu extends BasicGameState { int height = container.getHeight(); // draw background - OsuFile osu = MusicController.getOsuFile(); + Beatmap beatmap = MusicController.getBeatmap(); if (Options.isDynamicBackgroundEnabled() && - osu != null && osu.drawBG(width, height, bgAlpha, true)) + beatmap != null && beatmap.drawBG(width, height, bgAlpha, true)) ; else { Image bg = GameImage.MENU_BG.getImage(); @@ -240,7 +240,7 @@ public class MainMenu extends BasicGameState { g.setColor((musicPositionBarContains(mouseX, mouseY)) ? BG_HOVER : BG_NORMAL); g.fillRoundRect(musicBarX, musicBarY, musicBarWidth, musicBarHeight, 4); g.setColor(Color.white); - if (!MusicController.isTrackLoading() && osu != null) { + if (!MusicController.isTrackLoading() && beatmap != null) { float musicBarPosition = Math.min((float) MusicController.getPosition() / MusicController.getDuration(), 1f); g.fillRoundRect(musicBarX, musicBarY, musicBarWidth * musicBarPosition, musicBarHeight, 4); } @@ -270,25 +270,25 @@ public class MainMenu extends BasicGameState { } // draw text - float marginX = width * 0.015f, marginY = height * 0.015f; + float marginX = width * 0.015f, topMarginY = height * 0.01f, bottomMarginY = height * 0.015f; g.setFont(Utils.FONT_MEDIUM); - int lineHeight = Utils.FONT_MEDIUM.getLineHeight() * 9 / 10; + float lineHeight = Utils.FONT_MEDIUM.getLineHeight() * 0.925f; g.drawString(String.format("Loaded %d songs and %d beatmaps.", - OsuGroupList.get().getMapSetCount(), OsuGroupList.get().getMapCount()), marginX, marginY); + BeatmapSetList.get().getMapSetCount(), BeatmapSetList.get().getMapCount()), marginX, topMarginY); if (MusicController.isTrackLoading()) - g.drawString("Track loading...", marginX, marginY + lineHeight); + g.drawString("Track loading...", marginX, topMarginY + lineHeight); else if (MusicController.trackExists()) { if (Options.useUnicodeMetadata()) // load glyphs - Utils.loadGlyphs(Utils.FONT_MEDIUM, osu.titleUnicode, osu.artistUnicode); - g.drawString((MusicController.isPlaying()) ? "Now Playing:" : "Paused:", marginX, marginY + lineHeight); - g.drawString(String.format("%s: %s", osu.getArtist(), osu.getTitle()), marginX + 25, marginY + (lineHeight * 2)); + Utils.loadGlyphs(Utils.FONT_MEDIUM, beatmap.titleUnicode, beatmap.artistUnicode); + g.drawString((MusicController.isPlaying()) ? "Now Playing:" : "Paused:", marginX, topMarginY + lineHeight); + g.drawString(String.format("%s: %s", beatmap.getArtist(), beatmap.getTitle()), marginX + 25, topMarginY + (lineHeight * 2)); } g.drawString(String.format("opsu! has been running for %s.", - Utils.getTimeString((int) (System.currentTimeMillis() - osuStartTime) / 1000)), - marginX, height - marginY - (lineHeight * 2)); + Utils.getTimeString((int) (System.currentTimeMillis() - programStartTime) / 1000)), + marginX, height - bottomMarginY - (lineHeight * 2)); g.drawString(String.format("It is currently %s.", new SimpleDateFormat("h:mm a").format(new Date())), - marginX, height - marginY - lineHeight); + marginX, height - bottomMarginY - lineHeight); UI.draw(g); } @@ -455,7 +455,7 @@ public class MainMenu extends BasicGameState { } else if (musicPrevious.contains(x, y)) { if (!previous.isEmpty()) { SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); - menu.setFocus(OsuGroupList.get().getBaseNode(previous.pop()), -1, true, false); + menu.setFocus(BeatmapSetList.get().getBaseNode(previous.pop()), -1, true, false); if (Options.isDynamicBackgroundEnabled()) bgAlpha = 0f; } else @@ -603,10 +603,10 @@ public class MainMenu extends BasicGameState { private void nextTrack() { boolean isTheme = MusicController.isThemePlaying(); SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); - OsuGroupNode node = menu.setFocus(OsuGroupList.get().getRandomNode(), -1, true, false); + BeatmapSetNode node = menu.setFocus(BeatmapSetList.get().getRandomNode(), -1, true, false); boolean sameAudio = false; if (node != null) { - sameAudio = MusicController.getOsuFile().audioFilename.equals(node.osuFiles.get(0).audioFilename); + sameAudio = MusicController.getBeatmap().audioFilename.equals(node.getBeatmapSet().get(0).audioFilename); if (!isTheme && !sameAudio) previous.add(node.index); } @@ -619,7 +619,7 @@ public class MainMenu extends BasicGameState { */ private void enterSongMenu() { int state = Opsu.STATE_SONGMENU; - if (OsuGroupList.get().getMapSetCount() == 0) { + if (BeatmapSetList.get().getMapSetCount() == 0) { ((DownloadsMenu) game.getState(Opsu.STATE_DOWNLOADSMENU)).notifyOnLoad("Download some beatmaps to get started!"); state = Opsu.STATE_DOWNLOADSMENU; } diff --git a/src/itdelatrisu/opsu/states/OptionsMenu.java b/src/itdelatrisu/opsu/states/OptionsMenu.java index ce7953d5..86d5fdb7 100644 --- a/src/itdelatrisu/opsu/states/OptionsMenu.java +++ b/src/itdelatrisu/opsu/states/OptionsMenu.java @@ -19,15 +19,15 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Options.GameOption; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import java.util.Arrays; import java.util.Collections; @@ -52,12 +52,14 @@ public class OptionsMenu extends BasicGameState { DISPLAY ("Display", new GameOption[] { GameOption.SCREEN_RESOLUTION, // GameOption.FULLSCREEN, + GameOption.SKIN, GameOption.TARGET_FPS, GameOption.SHOW_FPS, GameOption.SHOW_UNICODE, GameOption.SCREENSHOT_FORMAT, GameOption.NEW_CURSOR, GameOption.DYNAMIC_BACKGROUND, + GameOption.LOAD_HD_IMAGES, GameOption.LOAD_VERBOSE }), MUSIC ("Music", new GameOption[] { @@ -90,8 +92,7 @@ public class OptionsMenu extends BasicGameState { GameOption.FIXED_HP, GameOption.FIXED_AR, GameOption.FIXED_OD, - GameOption.CHECKPOINT, - GameOption.LOAD_HD_IMAGES + GameOption.CHECKPOINT }); /** Total number of tabs. */ @@ -180,9 +181,9 @@ public class OptionsMenu extends BasicGameState { // option tabs Image tabImage = GameImage.MENU_TAB.getImage(); - float tabX = (width / 50) + (tabImage.getWidth() / 2f); - float tabY = Utils.FONT_LARGE.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() + - height * 0.03f + (tabImage.getHeight() / 2f); + float tabX = width * 0.032f + Utils.FONT_DEFAULT.getWidth("Change the way opsu! behaves") + (tabImage.getWidth() / 2); + float tabY = Utils.FONT_XLARGE.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() + + height * 0.015f - (tabImage.getHeight() / 2f); int tabOffset = Math.min(tabImage.getWidth(), width / OptionTab.SIZE); for (OptionTab tab : OptionTab.values()) tab.button = new MenuButton(tabImage, tabX + (tab.ordinal() * tabOffset), tabY); @@ -201,12 +202,16 @@ public class OptionsMenu extends BasicGameState { int width = container.getWidth(); int height = container.getHeight(); int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + float lineY = OptionTab.DISPLAY.button.getY() + (GameImage.MENU_TAB.getImage().getHeight() / 2f); // title - float c = container.getWidth() * 0.02f; - Utils.FONT_LARGE.drawString(c, c, "Game Options", Color.white); - Utils.FONT_DEFAULT.drawString(c, c + Utils.FONT_LARGE.getLineHeight() * 0.9f, - "Click or drag an option to change it.", Color.white); + float marginX = width * 0.015f, marginY = height * 0.01f; + Utils.FONT_XLARGE.drawString(marginX, marginY, "Options", Color.white); + Utils.FONT_DEFAULT.drawString(marginX, marginY + Utils.FONT_XLARGE.getLineHeight() * 0.92f, + "Change the way opsu! behaves", Color.white); + + // background + GameImage.OPTIONS_BG.getImage().draw(0, lineY); // game options g.setLineWidth(1f); @@ -235,7 +240,6 @@ public class OptionsMenu extends BasicGameState { currentTab.getName(), true, false); g.setColor(Color.white); g.setLineWidth(2f); - float lineY = OptionTab.DISPLAY.button.getY() + (GameImage.MENU_TAB.getImage().getHeight() / 2f); g.drawLine(0, lineY, width, lineY); g.resetLineWidth(); @@ -302,7 +306,7 @@ public class OptionsMenu extends BasicGameState { // options (click only) GameOption option = getOptionAt(y); - if (option != GameOption.NULL) + if (option != null) option.click(container); // special key entry states @@ -338,7 +342,7 @@ public class OptionsMenu extends BasicGameState { // options (drag only) GameOption option = getOptionAt(oldy); - if (option != GameOption.NULL) + if (option != null) option.drag(container, diff); } @@ -425,14 +429,13 @@ public class OptionsMenu extends BasicGameState { * @return the option, or GameOption.NULL if no such option exists */ private GameOption getOptionAt(int y) { - GameOption option = GameOption.NULL; - if (y < textY || y > textY + (offsetY * maxOptionsScreen)) - return option; + return null; int index = (y - textY + Utils.FONT_LARGE.getLineHeight()) / offsetY; - if (index < currentTab.options.length) - option = currentTab.options[index]; - return option; + if (index >= currentTab.options.length) + return null; + + return currentTab.options[index]; } } diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java index 87704504..4e9cc4e6 100644 --- a/src/itdelatrisu/opsu/states/SongMenu.java +++ b/src/itdelatrisu/opsu/states/SongMenu.java @@ -22,25 +22,25 @@ import itdelatrisu.opsu.GameData; import itdelatrisu.opsu.GameData.Grade; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; -import itdelatrisu.opsu.MenuButton; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuFile; -import itdelatrisu.opsu.OsuGroupList; -import itdelatrisu.opsu.OsuGroupNode; -import itdelatrisu.opsu.OsuParser; import itdelatrisu.opsu.OszUnpacker; import itdelatrisu.opsu.ScoreData; -import itdelatrisu.opsu.SongSort; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MultiClip; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; -import itdelatrisu.opsu.db.OsuDB; +import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapSetNode; +import itdelatrisu.opsu.beatmap.BeatmapSortOrder; +import itdelatrisu.opsu.beatmap.BeatmapParser; +import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.db.ScoreDB; import itdelatrisu.opsu.states.ButtonMenu.MenuState; +import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.UI; import java.io.File; import java.util.Map; @@ -91,28 +91,28 @@ public class SongMenu extends BasicGameState { /** Line width of the header/footer divider. */ private static final int DIVIDER_LINE_WIDTH = 4; - /** Song node class representing an OsuGroupNode and file index. */ + /** Song node class representing an BeatmapSetNode and file index. */ private static class SongNode { /** Song node. */ - private OsuGroupNode node; + private BeatmapSetNode node; /** File index. */ private int index; /** * Constructor. - * @param node the OsuGroupNode + * @param node the BeatmapSetNode * @param index the file index */ - public SongNode(OsuGroupNode node, int index) { + public SongNode(BeatmapSetNode node, int index) { this.node = node; this.index = index; } /** - * Returns the associated OsuGroupNode. + * Returns the associated BeatmapSetNode. */ - public OsuGroupNode getNode() { return node; } + public BeatmapSetNode getNode() { return node; } /** * Returns the associated file index. @@ -121,10 +121,10 @@ public class SongMenu extends BasicGameState { } /** Current start node (topmost menu entry). */ - private OsuGroupNode startNode; + private BeatmapSetNode startNode; /** Current focused (selected) node. */ - private OsuGroupNode focusNode; + private BeatmapSetNode focusNode; /** The base node of the previous focus node. */ private SongNode oldFocusNode = null; @@ -172,7 +172,7 @@ public class SongMenu extends BasicGameState { private MenuState stateAction; /** If non-null, the node that stateAction acts upon. */ - private OsuGroupNode stateActionNode; + private BeatmapSetNode stateActionNode; /** If non-null, the score data that stateAction acts upon. */ private ScoreData stateActionScore; @@ -228,7 +228,7 @@ public class SongMenu extends BasicGameState { footerY = height - GameImage.SELECTION_MODS.getImage().getHeight(); // initialize sorts - for (SongSort sort : SongSort.values()) + for (BeatmapSortOrder sort : BeatmapSortOrder.values()) sort.init(width, headerY - SongMenu.DIVIDER_LINE_WIDTH / 2); // initialize score data buttons @@ -292,13 +292,13 @@ public class SongMenu extends BasicGameState { // background if (focusNode != null) { - OsuFile focusNodeOsu = focusNode.osuFiles.get(focusNode.osuFileIndex); - if (!focusNodeOsu.drawBG(width, height, 1.0f, true)) + Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex); + if (!focusNodeBeatmap.drawBG(width, height, 1.0f, true)) GameImage.PLAYFIELD.getImage().draw(); } // song buttons - OsuGroupNode node = startNode; + BeatmapSetNode node = startNode; int songButtonIndex = 0; if (node != null && node.prev != null) { node = node.prev; @@ -339,21 +339,27 @@ public class SongMenu extends BasicGameState { if (songInfo == null) { songInfo = focusNode.getInfo(); if (Options.useUnicodeMetadata()) { // load glyphs - OsuFile osu = focusNode.osuFiles.get(0); - Utils.loadGlyphs(Utils.FONT_LARGE, osu.titleUnicode, osu.artistUnicode); + Beatmap beatmap = focusNode.getBeatmapSet().get(0); + Utils.loadGlyphs(Utils.FONT_LARGE, beatmap.titleUnicode, beatmap.artistUnicode); } } marginX += 5; - float headerTextY = marginY; + float headerTextY = marginY * 0.2f; Utils.FONT_LARGE.drawString(marginX + iconWidth * 1.05f, headerTextY, songInfo[0], Color.white); - headerTextY += Utils.FONT_LARGE.getLineHeight() - 8; + headerTextY += Utils.FONT_LARGE.getLineHeight() - 6; Utils.FONT_DEFAULT.drawString(marginX + iconWidth * 1.05f, headerTextY, songInfo[1], Color.white); headerTextY += Utils.FONT_DEFAULT.getLineHeight() - 2; - Utils.FONT_BOLD.drawString(marginX, headerTextY, songInfo[2], Color.white); + float speedModifier = GameMod.getSpeedMultiplier(); + Color color2 = (speedModifier == 1f) ? Color.white : + (speedModifier > 1f) ? Utils.COLOR_RED_HIGHLIGHT : Utils.COLOR_BLUE_HIGHLIGHT; + Utils.FONT_BOLD.drawString(marginX, headerTextY, songInfo[2], color2); headerTextY += Utils.FONT_BOLD.getLineHeight() - 4; Utils.FONT_DEFAULT.drawString(marginX, headerTextY, songInfo[3], Color.white); headerTextY += Utils.FONT_DEFAULT.getLineHeight() - 4; - Utils.FONT_SMALL.drawString(marginX, headerTextY, songInfo[4], Color.white); + float multiplier = GameMod.getDifficultyMultiplier(); + Color color4 = (multiplier == 1f) ? Color.white : + (multiplier > 1f) ? Utils.COLOR_RED_HIGHLIGHT : Utils.COLOR_BLUE_HIGHLIGHT; + Utils.FONT_SMALL.drawString(marginX, headerTextY, songInfo[4], color4); } // score buttons @@ -382,15 +388,15 @@ public class SongMenu extends BasicGameState { selectOptionsButton.draw(); // sorting tabs - SongSort currentSort = SongSort.getSort(); - SongSort hoverSort = null; - for (SongSort sort : SongSort.values()) { + BeatmapSortOrder currentSort = BeatmapSortOrder.getSort(); + BeatmapSortOrder hoverSort = null; + for (BeatmapSortOrder sort : BeatmapSortOrder.values()) { if (sort.contains(mouseX, mouseY)) { hoverSort = sort; break; } } - for (SongSort sort : SongSort.VALUES_REVERSED) { + for (BeatmapSortOrder sort : BeatmapSortOrder.VALUES_REVERSED) { if (sort != currentSort) sort.draw(false, sort == hoverSort); } @@ -431,14 +437,14 @@ public class SongMenu extends BasicGameState { // scroll bar if (focusNode != null) { - int focusNodes = focusNode.osuFiles.size(); - int totalNodes = OsuGroupList.get().size() + focusNodes - 1; + int focusNodes = focusNode.getBeatmapSet().size(); + int totalNodes = BeatmapSetList.get().size() + focusNodes - 1; if (totalNodes > MAX_SONG_BUTTONS) { int startIndex = startNode.index; if (startNode.index > focusNode.index) startIndex += focusNodes; else if (startNode.index == focusNode.index) - startIndex += startNode.osuFileIndex; + startIndex += startNode.beatmapIndex; UI.drawScrollbar(g, startIndex, totalNodes, MAX_SONG_BUTTONS, width, headerY + DIVIDER_LINE_WIDTH / 2, 0, buttonOffset - DIVIDER_LINE_WIDTH * 1.5f, buttonOffset, Utils.COLOR_BLACK_ALPHA, Color.white, true); @@ -495,9 +501,9 @@ public class SongMenu extends BasicGameState { // store the start/focus nodes if (focusNode != null) - oldFocusNode = new SongNode(OsuGroupList.get().getBaseNode(focusNode.index), focusNode.osuFileIndex); + oldFocusNode = new SongNode(BeatmapSetList.get().getBaseNode(focusNode.index), focusNode.beatmapIndex); - if (OsuGroupList.get().search(search.getText())) { + if (BeatmapSetList.get().search(search.getText())) { // reset song stack randomStack = new Stack(); @@ -509,19 +515,19 @@ public class SongMenu extends BasicGameState { startNode = focusNode = null; scoreMap = null; focusScores = null; - if (OsuGroupList.get().size() > 0) { - OsuGroupList.get().init(); + if (BeatmapSetList.get().size() > 0) { + BeatmapSetList.get().init(); if (search.getText().isEmpty()) { // cleared search // use previous start/focus if possible if (oldFocusNode != null) setFocus(oldFocusNode.getNode(), oldFocusNode.getIndex(), true, true); else - setFocus(OsuGroupList.get().getRandomNode(), -1, true, true); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); } else { - int size = OsuGroupList.get().size(); + int size = BeatmapSetList.get().size(); searchResultString = String.format("%d match%s found!", size, (size == 1) ? "" : "es"); - setFocus(OsuGroupList.get().getRandomNode(), -1, true, true); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); } oldFocusNode = null; } else if (!search.getText().isEmpty()) @@ -549,9 +555,9 @@ public class SongMenu extends BasicGameState { // mouse hover boolean isHover = false; if (mouseY > headerY && mouseY < footerY) { - OsuGroupNode node = startNode; + BeatmapSetNode node = startNode; for (int i = 0; i < MAX_SONG_BUTTONS && node != null; i++, node = node.next) { - float cx = (node.index == OsuGroupList.get().getExpandedIndex()) ? buttonX * 0.9f : buttonX; + float cx = (node.index == BeatmapSetList.get().getExpandedIndex()) ? buttonX * 0.9f : buttonX; if ((mouseX > cx && mouseX < cx + buttonWidth) && (mouseY > buttonY + (i * buttonOffset) && mouseY < buttonY + (i * buttonOffset) + buttonHeight)) { if (i == hoverIndex) { @@ -630,15 +636,15 @@ public class SongMenu extends BasicGameState { return; // sorting buttons - for (SongSort sort : SongSort.values()) { + for (BeatmapSortOrder sort : BeatmapSortOrder.values()) { if (sort.contains(x, y)) { - if (sort != SongSort.getSort()) { - SongSort.setSort(sort); + if (sort != BeatmapSortOrder.getSort()) { + BeatmapSortOrder.setSort(sort); SoundController.playSound(SoundEffect.MENUCLICK); - OsuGroupNode oldFocusBase = OsuGroupList.get().getBaseNode(focusNode.index); - int oldFocusFileIndex = focusNode.osuFileIndex; + BeatmapSetNode oldFocusBase = BeatmapSetList.get().getBaseNode(focusNode.index); + int oldFocusFileIndex = focusNode.beatmapIndex; focusNode = null; - OsuGroupList.get().init(); + BeatmapSetList.get().init(); setFocus(oldFocusBase, oldFocusFileIndex, true, true); } return; @@ -647,8 +653,8 @@ public class SongMenu extends BasicGameState { // song buttons if (y > headerY && y < footerY) { - int expandedIndex = OsuGroupList.get().getExpandedIndex(); - OsuGroupNode node = startNode; + int expandedIndex = BeatmapSetList.get().getExpandedIndex(); + BeatmapSetNode node = startNode; for (int i = 0; i < MAX_SONG_BUTTONS && node != null; i++, node = node.next) { // is button at this index clicked? float cx = (node.index == expandedIndex) ? buttonX * 0.9f : buttonX; @@ -659,7 +665,7 @@ public class SongMenu extends BasicGameState { // clicked node is already expanded if (node.index == expandedIndex) { - if (node.osuFileIndex == focusNode.osuFileIndex) { + if (node.beatmapIndex == focusNode.beatmapIndex) { // if already focused, load the beatmap if (button != Input.MOUSE_RIGHT_BUTTON) startGame(); @@ -724,7 +730,7 @@ public class SongMenu extends BasicGameState { switch (key) { case Input.KEY_ESCAPE: if (reloadThread != null) { - // beatmap reloading: stop parsing OsuFiles by sending interrupt to OsuParser + // beatmap reloading: stop parsing beatmaps by sending interrupt to BeatmapParser reloadThread.interrupt(); } else if (!search.getText().isEmpty()) { // clear search text @@ -755,8 +761,8 @@ public class SongMenu extends BasicGameState { setFocus(prev.getNode(), prev.getIndex(), true, true); } else { // random track, add previous to stack - randomStack.push(new SongNode(OsuGroupList.get().getBaseNode(focusNode.index), focusNode.osuFileIndex)); - setFocus(OsuGroupList.get().getRandomNode(), -1, true, true); + randomStack.push(new SongNode(BeatmapSetList.get().getBaseNode(focusNode.index), focusNode.beatmapIndex)); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); } break; case Input.KEY_F3: @@ -776,7 +782,7 @@ public class SongMenu extends BasicGameState { break; if (input.isKeyDown(Input.KEY_RSHIFT) || input.isKeyDown(Input.KEY_LSHIFT)) { SoundController.playSound(SoundEffect.MENUHIT); - MenuState ms = (focusNode.osuFileIndex == -1 || focusNode.osuFiles.size() == 1) ? + MenuState ms = (focusNode.beatmapIndex == -1 || focusNode.getBeatmapSet().size() == 1) ? MenuState.BEATMAP_DELETE_CONFIRM : MenuState.BEATMAP_DELETE_SELECT; ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(ms, focusNode); game.enterState(Opsu.STATE_BUTTONMENU); @@ -810,10 +816,10 @@ public class SongMenu extends BasicGameState { case Input.KEY_RIGHT: if (focusNode == null) break; - OsuGroupNode next = focusNode.next; + BeatmapSetNode next = focusNode.next; if (next != null) { SoundController.playSound(SoundEffect.MENUCLICK); - OsuGroupNode oldStartNode = startNode; + BeatmapSetNode oldStartNode = startNode; float oldHoverOffset = hoverOffset; int oldHoverIndex = hoverIndex; setFocus(next, 0, false, true); @@ -826,13 +832,13 @@ public class SongMenu extends BasicGameState { case Input.KEY_LEFT: if (focusNode == null) break; - OsuGroupNode prev = focusNode.prev; + BeatmapSetNode prev = focusNode.prev; if (prev != null) { SoundController.playSound(SoundEffect.MENUCLICK); - OsuGroupNode oldStartNode = startNode; + BeatmapSetNode oldStartNode = startNode; float oldHoverOffset = hoverOffset; int oldHoverIndex = hoverIndex; - setFocus(prev, (prev.index == focusNode.index) ? 0 : prev.osuFiles.size() - 1, false, true); + setFocus(prev, (prev.index == focusNode.index) ? 0 : prev.getBeatmapSet().size() - 1, false, true); if (startNode == oldStartNode) { hoverOffset = oldHoverOffset; hoverIndex = oldHoverIndex; @@ -937,18 +943,19 @@ public class SongMenu extends BasicGameState { startScore = 0; beatmapMenuTimer = -1; searchTransitionTimer = SEARCH_TRANSITION_TIME; + songInfo = null; // reset song stack randomStack = new Stack(); // set focus node if not set (e.g. theme song playing) - if (focusNode == null && OsuGroupList.get().size() > 0) - setFocus(OsuGroupList.get().getRandomNode(), -1, true, true); + if (focusNode == null && BeatmapSetList.get().size() > 0) + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); // reset music track else if (resetTrack) { MusicController.pause(); - MusicController.playAt(MusicController.getOsuFile().previewTime, true); + MusicController.playAt(MusicController.getBeatmap().previewTime, true); resetTrack = false; } @@ -975,7 +982,7 @@ public class SongMenu extends BasicGameState { // reload scores if (focusNode != null) { - scoreMap = ScoreDB.getMapSetScores(focusNode.osuFiles.get(focusNode.osuFileIndex)); + scoreMap = ScoreDB.getMapSetScores(focusNode.getBeatmapSet().get(focusNode.beatmapIndex)); focusScores = getScoreDataForNode(focusNode, true); } @@ -986,31 +993,31 @@ public class SongMenu extends BasicGameState { if (stateAction != null) { switch (stateAction) { case BEATMAP: // clear all scores - if (stateActionNode == null || stateActionNode.osuFileIndex == -1) + if (stateActionNode == null || stateActionNode.beatmapIndex == -1) break; - OsuFile osu = stateActionNode.osuFiles.get(stateActionNode.osuFileIndex); - ScoreDB.deleteScore(osu); + Beatmap beatmap = stateActionNode.getBeatmapSet().get(stateActionNode.beatmapIndex); + ScoreDB.deleteScore(beatmap); if (stateActionNode == focusNode) { focusScores = null; - scoreMap.remove(osu.version); + scoreMap.remove(beatmap.version); } break; case SCORE: // clear single score if (stateActionScore == null) break; ScoreDB.deleteScore(stateActionScore); - scoreMap = ScoreDB.getMapSetScores(focusNode.osuFiles.get(focusNode.osuFileIndex)); + scoreMap = ScoreDB.getMapSetScores(focusNode.getBeatmapSet().get(focusNode.beatmapIndex)); focusScores = getScoreDataForNode(focusNode, true); startScore = 0; break; case BEATMAP_DELETE_CONFIRM: // delete song group if (stateActionNode == null) break; - OsuGroupNode - prev = OsuGroupList.get().getBaseNode(stateActionNode.index - 1), - next = OsuGroupList.get().getBaseNode(stateActionNode.index + 1); + BeatmapSetNode + prev = BeatmapSetList.get().getBaseNode(stateActionNode.index - 1), + next = BeatmapSetList.get().getBaseNode(stateActionNode.index + 1); int oldIndex = stateActionNode.index, focusNodeIndex = focusNode.index, startNodeIndex = startNode.index; - OsuGroupList.get().deleteSongGroup(stateActionNode); + BeatmapSetList.get().deleteSongGroup(stateActionNode); if (oldIndex == focusNodeIndex) { if (prev != null) setFocus(prev, -1, true, true); @@ -1039,7 +1046,7 @@ public class SongMenu extends BasicGameState { if (stateActionNode == null) break; int index = stateActionNode.index; - OsuGroupList.get().deleteSong(stateActionNode); + BeatmapSetList.get().deleteSong(stateActionNode); if (stateActionNode == focusNode) { if (stateActionNode.prev != null && !(stateActionNode.next != null && stateActionNode.next.index == index)) { @@ -1081,17 +1088,17 @@ public class SongMenu extends BasicGameState { @Override public void run() { // clear the beatmap cache - OsuDB.clearDatabase(); + BeatmapDB.clearDatabase(); // invoke unpacker and parser File beatmapDir = Options.getBeatmapDir(); OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); - OsuParser.parseAllFiles(beatmapDir); + BeatmapParser.parseAllFiles(beatmapDir); // initialize song list - if (OsuGroupList.get().size() > 0) { - OsuGroupList.get().init(); - setFocus(OsuGroupList.get().getRandomNode(), -1, true, true); + if (BeatmapSetList.get().size() > 0) { + BeatmapSetList.get().init(); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); } else MusicController.playThemeSong(); @@ -1139,7 +1146,7 @@ public class SongMenu extends BasicGameState { n++; shifted = true; } else if (n > 0 && startNode.next != null && - OsuGroupList.get().getNode(startNode, MAX_SONG_BUTTONS) != null) { + BeatmapSetList.get().getNode(startNode, MAX_SONG_BUTTONS) != null) { startNode = startNode.next; buttonY -= buttonOffset / 4; if (buttonY < headerY - height * 0.02f) @@ -1159,69 +1166,69 @@ public class SongMenu extends BasicGameState { /** * Sets a new focus node. * @param node the base node; it will be expanded if it isn't already - * @param osuFileIndex the OsuFile element to focus; if out of bounds, it will be randomly chosen + * @param beatmapIndex the beatmap element to focus; if out of bounds, it will be randomly chosen * @param changeStartNode if true, startNode will be set to the first node in the group * @param preview whether to start at the preview time (true) or beginning (false) * @return the old focus node */ - public OsuGroupNode setFocus(OsuGroupNode node, int osuFileIndex, boolean changeStartNode, boolean preview) { + public BeatmapSetNode setFocus(BeatmapSetNode node, int beatmapIndex, boolean changeStartNode, boolean preview) { if (node == null) return null; hoverOffset = 0f; hoverIndex = -1; songInfo = null; - OsuGroupNode oldFocus = focusNode; + BeatmapSetNode oldFocus = focusNode; // expand node before focusing it - int expandedIndex = OsuGroupList.get().getExpandedIndex(); + int expandedIndex = BeatmapSetList.get().getExpandedIndex(); if (node.index != expandedIndex) { - node = OsuGroupList.get().expand(node.index); + node = BeatmapSetList.get().expand(node.index); // if start node was previously expanded, move it if (startNode != null && startNode.index == expandedIndex) - startNode = OsuGroupList.get().getBaseNode(startNode.index); + startNode = BeatmapSetList.get().getBaseNode(startNode.index); } - // check osuFileIndex bounds - int length = node.osuFiles.size(); - if (osuFileIndex < 0 || osuFileIndex > length - 1) // set a random index - osuFileIndex = (int) (Math.random() * length); + // check beatmapIndex bounds + int length = node.getBeatmapSet().size(); + if (beatmapIndex < 0 || beatmapIndex > length - 1) // set a random index + beatmapIndex = (int) (Math.random() * length); // change the focus node - if (changeStartNode || (startNode.index == 0 && startNode.osuFileIndex == -1 && startNode.prev == null)) + if (changeStartNode || (startNode.index == 0 && startNode.beatmapIndex == -1 && startNode.prev == null)) startNode = node; - focusNode = OsuGroupList.get().getNode(node, osuFileIndex); - OsuFile osu = focusNode.osuFiles.get(focusNode.osuFileIndex); - MusicController.play(osu, false, preview); + focusNode = BeatmapSetList.get().getNode(node, beatmapIndex); + Beatmap beatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex); + MusicController.play(beatmap, false, preview); // load scores - scoreMap = ScoreDB.getMapSetScores(osu); + scoreMap = ScoreDB.getMapSetScores(beatmap); focusScores = getScoreDataForNode(focusNode, true); startScore = 0; // check startNode bounds - while (startNode.index >= OsuGroupList.get().size() + length - MAX_SONG_BUTTONS && startNode.prev != null) + while (startNode.index >= BeatmapSetList.get().size() + length - MAX_SONG_BUTTONS && startNode.prev != null) startNode = startNode.prev; // make sure focusNode is on the screen (TODO: cleanup...) - int val = focusNode.index + focusNode.osuFileIndex - (startNode.index + MAX_SONG_BUTTONS) + 1; + int val = focusNode.index + focusNode.beatmapIndex - (startNode.index + MAX_SONG_BUTTONS) + 1; if (val > 0) // below screen changeIndex(val); else { // above screen if (focusNode.index == startNode.index) { - val = focusNode.index + focusNode.osuFileIndex - (startNode.index + startNode.osuFileIndex); + val = focusNode.index + focusNode.beatmapIndex - (startNode.index + startNode.beatmapIndex); if (val < 0) changeIndex(val); } else if (startNode.index > focusNode.index) { - val = focusNode.index - focusNode.osuFiles.size() + focusNode.osuFileIndex - startNode.index + 1; + val = focusNode.index - focusNode.getBeatmapSet().size() + focusNode.beatmapIndex - startNode.index + 1; if (val < 0) changeIndex(val); } } // if start node is expanded and on group node, move it - if (startNode.index == focusNode.index && startNode.osuFileIndex == -1) + if (startNode.index == focusNode.index && startNode.beatmapIndex == -1) changeIndex(1); return oldFocus; @@ -1248,7 +1255,7 @@ public class SongMenu extends BasicGameState { * @param menuState the menu state determining the action * @param node the song node to perform the action on */ - public void doStateActionOnLoad(MenuState menuState, OsuGroupNode node) { + public void doStateActionOnLoad(MenuState menuState, BeatmapSetNode node) { doStateActionOnLoad(menuState, node, null); } @@ -1267,32 +1274,32 @@ public class SongMenu extends BasicGameState { * @param node the song node to perform the action on * @param scoreData the score data to perform the action on */ - private void doStateActionOnLoad(MenuState menuState, OsuGroupNode node, ScoreData scoreData) { + private void doStateActionOnLoad(MenuState menuState, BeatmapSetNode node, ScoreData scoreData) { stateAction = menuState; stateActionNode = node; stateActionScore = scoreData; } /** - * Returns all the score data for an OsuGroupNode from scoreMap. + * Returns all the score data for an BeatmapSetNode from scoreMap. * If no score data is available for the node, return null. - * @param node the OsuGroupNode + * @param node the BeatmapSetNode * @param setTimeSince whether or not to set the "time since" field for the scores * @return the ScoreData array */ - private ScoreData[] getScoreDataForNode(OsuGroupNode node, boolean setTimeSince) { - if (scoreMap == null || scoreMap.isEmpty() || node.osuFileIndex == -1) // node not expanded + private ScoreData[] getScoreDataForNode(BeatmapSetNode node, boolean setTimeSince) { + if (scoreMap == null || scoreMap.isEmpty() || node.beatmapIndex == -1) // node not expanded return null; - OsuFile osu = node.osuFiles.get(node.osuFileIndex); - ScoreData[] scores = scoreMap.get(osu.version); + Beatmap beatmap = node.getBeatmapSet().get(node.beatmapIndex); + ScoreData[] scores = scoreMap.get(beatmap.version); if (scores == null || scores.length < 1) // no scores return null; ScoreData s = scores[0]; - if (osu.beatmapID == s.MID && osu.beatmapSetID == s.MSID && - osu.title.equals(s.title) && osu.artist.equals(s.artist) && - osu.creator.equals(s.creator)) { + if (beatmap.beatmapID == s.MID && beatmap.beatmapSetID == s.MSID && + beatmap.title.equals(s.title) && beatmap.artist.equals(s.artist) && + beatmap.creator.equals(s.creator)) { if (setTimeSince) { for (int i = 0; i < scores.length; i++) scores[i].getTimeSince(); @@ -1311,9 +1318,9 @@ public class SongMenu extends BasicGameState { SoundController.playSound(SoundEffect.MENUHIT); MultiClip.destroyExtraClips(); - OsuFile osu = MusicController.getOsuFile(); + Beatmap beatmap = MusicController.getBeatmap(); Game gameState = (Game) game.getState(Opsu.STATE_GAME); - gameState.loadOsuFile(osu); + gameState.loadBeatmap(beatmap); gameState.setRestart(Game.Restart.NEW); gameState.setReplay(null); game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); diff --git a/src/itdelatrisu/opsu/states/Splash.java b/src/itdelatrisu/opsu/states/Splash.java index 90ee8fcf..b69c95d3 100644 --- a/src/itdelatrisu/opsu/states/Splash.java +++ b/src/itdelatrisu/opsu/states/Splash.java @@ -21,14 +21,15 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OsuGroupList; -import itdelatrisu.opsu.OsuParser; import itdelatrisu.opsu.OszUnpacker; -import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.replay.ReplayImporter; +//conflict +import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapParser; +import itdelatrisu.opsu.ui.UI; import java.io.File; @@ -56,6 +57,9 @@ public class Splash extends BasicGameState { /** Number of times the 'Esc' key has been pressed. */ private int escapeCount = 0; + /** Whether the skin being loaded is a new skin (for program restarts). */ + private boolean newSkin = false; + // game-related variables private int state; private GameContainer container; @@ -70,6 +74,10 @@ public class Splash extends BasicGameState { throws SlickException { this.container = container; + // check if skin changed + if (Options.getSkin() != null) + this.newSkin = (Options.getSkin().getDirectory() != Options.getSkinDir()); + // load Utils class first (needed in other 'init' methods) Utils.init(container, game); @@ -90,11 +98,27 @@ public class Splash extends BasicGameState { if (!init) { init = true; - if (OsuGroupList.get() != null) { - // resources already loaded (from application restart) - finished = true; - } else { - // load resources in a new thread + // resources already loaded (from application restart) + if (BeatmapSetList.get() != null) { + // reload sounds if skin changed + if (newSkin) { + thread = new Thread() { + @Override + public void run() { + // TODO: only reload each sound if actually needed? + SoundController.init(); + + finished = true; + thread = null; + } + }; + thread.start(); + } else // don't reload anything + finished = true; + } + + // load all resources in a new thread + else { thread = new Thread() { @Override public void run() { @@ -104,7 +128,7 @@ public class Splash extends BasicGameState { OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); // parse song directory - OsuParser.parseAllFiles(beatmapDir); + BeatmapParser.parseAllFiles(beatmapDir); // import replays ReplayImporter.importAllReplaysFromDir(Options.getReplayImportDir()); @@ -129,12 +153,12 @@ public class Splash extends BasicGameState { // change states when loading complete if (finished && alpha >= 1f) { // initialize song list - if (OsuGroupList.get().size() > 0) { - OsuGroupList.get().init(); + if (BeatmapSetList.get().size() > 0) { + BeatmapSetList.get().init(); if (Options.isThemeSongEnabled()) MusicController.playThemeSong(); else - ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).setFocus(OsuGroupList.get().getRandomNode(), -1, true, true); + ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); } // play the theme song @@ -155,7 +179,7 @@ public class Splash extends BasicGameState { if (++escapeCount >= 3) container.exit(); - // stop parsing OsuFiles by sending interrupt to OsuParser + // stop parsing beatmaps by sending interrupt to BeatmapParser else if (thread != null) thread.interrupt(); } diff --git a/src/itdelatrisu/opsu/ui/Cursor.java b/src/itdelatrisu/opsu/ui/Cursor.java new file mode 100644 index 00000000..d990205c --- /dev/null +++ b/src/itdelatrisu/opsu/ui/Cursor.java @@ -0,0 +1,309 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Opsu; +import itdelatrisu.opsu.Options; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.skins.Skin; + +import java.nio.IntBuffer; +import java.util.Iterator; +import java.util.LinkedList; + +import org.lwjgl.BufferUtils; +import org.lwjgl.LWJGLException; +import org.newdawn.slick.GameContainer; +import org.newdawn.slick.Image; +import org.newdawn.slick.Input; +import org.newdawn.slick.SlickException; +import org.newdawn.slick.state.StateBasedGame; + +/** + * Updates and draws the cursor. + */ +public class Cursor { + /** Empty cursor. */ + private static org.lwjgl.input.Cursor emptyCursor; + + /** Last cursor coordinates. */ + private int lastX = -1, lastY = -1; + + /** Cursor rotation angle. */ + private float cursorAngle = 0f; + + /** Stores all previous cursor locations to display a trail. */ + private LinkedList cursorX, cursorY; + + // game-related variables + private static GameContainer container; + private static StateBasedGame game; + private static Input input; + + /** + * Initializes the class. + * @param container the game container + * @param game the game object + */ + public static void init(GameContainer container, StateBasedGame game) { + Cursor.container = container; + Cursor.game = game; + Cursor.input = container.getInput(); + + // create empty cursor to simulate hiding the cursor + try { + int min = org.lwjgl.input.Cursor.getMinCursorSize(); + IntBuffer tmp = BufferUtils.createIntBuffer(min * min); + emptyCursor = new org.lwjgl.input.Cursor(min, min, min/2, min/2, 1, tmp, null); + } catch (LWJGLException e) { + ErrorHandler.error("Failed to create hidden cursor.", e, true); + } + } + + /** + * Constructor. + */ + public Cursor() { + cursorX = new LinkedList(); + cursorY = new LinkedList(); + } + + /** + * Draws the cursor. + */ + public void draw() { + int state = game.getCurrentStateID(); + boolean mousePressed = + (((state == Opsu.STATE_GAME || state == Opsu.STATE_GAMEPAUSEMENU) && Utils.isGameKeyPressed()) || + ((input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON) || input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON)) && + !(state == Opsu.STATE_GAME && Options.isMouseDisabled()))); + draw(input.getMouseX(), input.getMouseY(), mousePressed); + } + + /** + * Draws the cursor. + * @param mouseX the mouse x coordinate + * @param mouseY the mouse y coordinate + * @param mousePressed whether or not the mouse button is pressed + */ + public void draw(int mouseX, int mouseY, boolean mousePressed) { + // determine correct cursor image + Image cursor = null, cursorMiddle = null, cursorTrail = null; + boolean skinned = GameImage.CURSOR.hasSkinImage(); + boolean newStyle, hasMiddle; + if (skinned) { + newStyle = true; // osu! currently treats all beatmap cursors as new-style cursors + hasMiddle = GameImage.CURSOR_MIDDLE.hasSkinImage(); + } else + newStyle = hasMiddle = Options.isNewCursorEnabled(); + if (skinned || newStyle) { + cursor = GameImage.CURSOR.getImage(); + cursorTrail = GameImage.CURSOR_TRAIL.getImage(); + } else { + cursor = GameImage.CURSOR_OLD.getImage(); + cursorTrail = GameImage.CURSOR_TRAIL_OLD.getImage(); + } + if (hasMiddle) + cursorMiddle = GameImage.CURSOR_MIDDLE.getImage(); + + int removeCount = 0; + int FPSmod = (Options.getTargetFPS() / 60); + Skin skin = Options.getSkin(); + + // TODO: use an image buffer + if (newStyle) { + // new style: add all points between cursor movements + if (lastX < 0) { + lastX = mouseX; + lastY = mouseY; + return; + } + addCursorPoints(lastX, lastY, mouseX, mouseY); + lastX = mouseX; + lastY = mouseY; + + removeCount = (cursorX.size() / (6 * FPSmod)) + 1; + } else { + // old style: sample one point at a time + cursorX.add(mouseX); + cursorY.add(mouseY); + + int max = 10 * FPSmod; + if (cursorX.size() > max) + removeCount = cursorX.size() - max; + } + + // remove points from the lists + for (int i = 0; i < removeCount && !cursorX.isEmpty(); i++) { + cursorX.remove(); + cursorY.remove(); + } + + // draw a fading trail + float alpha = 0f; + float t = 2f / cursorX.size(); + if (skin.isCursorTrailRotated()) + cursorTrail.setRotation(cursorAngle); + Iterator iterX = cursorX.iterator(); + Iterator iterY = cursorY.iterator(); + while (iterX.hasNext()) { + int cx = iterX.next(); + int cy = iterY.next(); + alpha += t; + cursorTrail.setAlpha(alpha); +// if (cx != x || cy != y) + cursorTrail.drawCentered(cx, cy); + } + cursorTrail.drawCentered(mouseX, mouseY); + + // increase the cursor size if pressed + if (mousePressed && skin.isCursorExpanded()) { + final float scale = 1.25f; + cursor = cursor.getScaledCopy(scale); + if (hasMiddle) + cursorMiddle = cursorMiddle.getScaledCopy(scale); + } + + // draw the other components + if (newStyle && skin.isCursorRotated()) + cursor.setRotation(cursorAngle); + cursor.drawCentered(mouseX, mouseY); + if (hasMiddle) + cursorMiddle.drawCentered(mouseX, mouseY); + } + + /** + * Adds all points between (x1, y1) and (x2, y2) to the cursor point lists. + * @author http://rosettacode.org/wiki/Bitmap/Bresenham's_line_algorithm#Java + */ + private void addCursorPoints(int x1, int y1, int x2, int y2) { + // delta of exact value and rounded value of the dependent variable + int d = 0; + int dy = Math.abs(y2 - y1); + int dx = Math.abs(x2 - x1); + + int dy2 = (dy << 1); // slope scaling factors to avoid floating + int dx2 = (dx << 1); // point + int ix = x1 < x2 ? 1 : -1; // increment direction + int iy = y1 < y2 ? 1 : -1; + + int k = 5; // sample size + if (dy <= dx) { + for (int i = 0; ; i++) { + if (i == k) { + cursorX.add(x1); + cursorY.add(y1); + i = 0; + } + if (x1 == x2) + break; + x1 += ix; + d += dy2; + if (d > dx) { + y1 += iy; + d -= dx2; + } + } + } else { + for (int i = 0; ; i++) { + if (i == k) { + cursorX.add(x1); + cursorY.add(y1); + i = 0; + } + if (y1 == y2) + break; + y1 += iy; + d += dx2; + if (d > dy) { + x1 += ix; + d -= dy2; + } + } + } + } + + /** + * Rotates the cursor by a degree determined by a delta interval. + * If the old style cursor is being used, this will do nothing. + * @param delta the delta interval since the last call + */ + public void update(int delta) { + cursorAngle += delta / 40f; + cursorAngle %= 360; + } + + /** + * Resets all cursor data and skins. + */ + public void reset() { + // destroy skin images + GameImage.CURSOR.destroySkinImage(); + GameImage.CURSOR_MIDDLE.destroySkinImage(); + GameImage.CURSOR_TRAIL.destroySkinImage(); + + // reset locations + resetLocations(); + + // reset angles + cursorAngle = 0f; + GameImage.CURSOR.getImage().setRotation(0f); + GameImage.CURSOR_TRAIL.getImage().setRotation(0f); + } + + /** + * Resets all cursor location data. + */ + public void resetLocations() { + lastX = lastY = -1; + cursorX.clear(); + cursorY.clear(); + } + + /** + * Returns whether or not the cursor is skinned. + */ + public boolean isSkinned() { + return (GameImage.CURSOR.hasSkinImage() || + GameImage.CURSOR_MIDDLE.hasSkinImage() || + GameImage.CURSOR_TRAIL.hasSkinImage()); + } + + /** + * Hides the cursor, if possible. + */ + public void hide() { + if (emptyCursor != null) { + try { + container.setMouseCursor(emptyCursor, 0, 0); + } catch (SlickException e) { + ErrorHandler.error("Failed to hide the cursor.", e, true); + } + } + } + + /** + * Unhides the cursor. + */ + public void show() { + container.setDefaultMouseCursor(); + } +} diff --git a/src/itdelatrisu/opsu/MenuButton.java b/src/itdelatrisu/opsu/ui/MenuButton.java similarity index 99% rename from src/itdelatrisu/opsu/MenuButton.java rename to src/itdelatrisu/opsu/ui/MenuButton.java index de04a0e3..7370d0bc 100644 --- a/src/itdelatrisu/opsu/MenuButton.java +++ b/src/itdelatrisu/opsu/ui/MenuButton.java @@ -16,7 +16,9 @@ * along with opsu!. If not, see . */ -package itdelatrisu.opsu; +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.Utils; import org.newdawn.slick.Animation; import org.newdawn.slick.Color; @@ -146,7 +148,7 @@ public class MenuButton { * Sets text to draw in the middle of the button. * @param text the text to draw * @param font the font to use when drawing - * @color the color to draw the text + * @param color the color to draw the text */ public void setText(String text, Font font, Color color) { this.text = text; diff --git a/src/itdelatrisu/opsu/ui/UI.java b/src/itdelatrisu/opsu/ui/UI.java new file mode 100644 index 00000000..80e4cd3b --- /dev/null +++ b/src/itdelatrisu/opsu/ui/UI.java @@ -0,0 +1,506 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Options; +import itdelatrisu.opsu.OszUnpacker; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.audio.SoundController; +import itdelatrisu.opsu.beatmap.BeatmapParser; + +import javax.swing.JOptionPane; +import javax.swing.UIManager; + +import org.newdawn.slick.Animation; +import org.newdawn.slick.Color; +import org.newdawn.slick.GameContainer; +import org.newdawn.slick.Graphics; +import org.newdawn.slick.Image; +import org.newdawn.slick.Input; +import org.newdawn.slick.SlickException; +import org.newdawn.slick.state.StateBasedGame; + +/** + * Draws common UI components. + */ +public class UI { + /** Cursor. */ + private static Cursor cursor = new Cursor(); + + /** Back button. */ + private static MenuButton backButton; + + /** Time to show volume image, in milliseconds. */ + private static final int VOLUME_DISPLAY_TIME = 1500; + + /** Volume display elapsed time. */ + private static int volumeDisplay = -1; + + /** The current bar notification string. */ + private static String barNotif; + + /** The current bar notification timer. */ + private static int barNotifTimer = -1; + + /** Duration, in milliseconds, to display bar notifications. */ + private static final int BAR_NOTIFICATION_TIME = 1250; + + /** The current tooltip. */ + private static String tooltip; + + /** Whether or not to check the current tooltip for line breaks. */ + private static boolean tooltipNewlines; + + /** The current tooltip timer. */ + private static int tooltipTimer = -1; + + /** Duration, in milliseconds, to fade tooltips. */ + private static final int TOOLTIP_FADE_TIME = 200; + + // game-related variables + private static GameContainer container; + private static Input input; + + // This class should not be instantiated. + private UI() {} + + /** + * Initializes UI data. + * @param container the game container + * @param game the game object + * @throws SlickException + */ + public static void init(GameContainer container, StateBasedGame game) + throws SlickException { + UI.container = container; + UI.input = container.getInput(); + + // initialize cursor + Cursor.init(container, game); + cursor.hide(); + + // back button + if (GameImage.MENU_BACK.getImages() != null) { + Animation back = GameImage.MENU_BACK.getAnimation(120); + backButton = new MenuButton(back, back.getWidth() / 2f, container.getHeight() - (back.getHeight() / 2f)); + } else { + Image back = GameImage.MENU_BACK.getImage(); + backButton = new MenuButton(back, back.getWidth() / 2f, container.getHeight() - (back.getHeight() / 2f)); + } + backButton.setHoverExpand(MenuButton.Expand.UP_RIGHT); + } + + /** + * Updates all UI components by a delta interval. + * @param delta the delta interval since the last call. + */ + public static void update(int delta) { + cursor.update(delta); + updateVolumeDisplay(delta); + updateBarNotification(delta); + if (tooltipTimer > 0) + tooltipTimer -= delta; + } + + /** + * Draws the global UI components: cursor, FPS, volume bar, bar notifications. + * @param g the graphics context + */ + public static void draw(Graphics g) { + drawBarNotification(g); + drawVolume(g); + drawFPS(); + cursor.draw(); + drawTooltip(g); + } + + /** + * Draws the global UI components: cursor, FPS, volume bar, bar notifications. + * @param g the graphics context + * @param mouseX the mouse x coordinate + * @param mouseY the mouse y coordinate + * @param mousePressed whether or not the mouse button is pressed + */ + public static void draw(Graphics g, int mouseX, int mouseY, boolean mousePressed) { + drawBarNotification(g); + drawVolume(g); + drawFPS(); + cursor.draw(mouseX, mouseY, mousePressed); + drawTooltip(g); + } + + /** + * Resets the necessary UI components upon entering a state. + */ + public static void enter() { + backButton.resetHover(); + cursor.resetLocations(); + resetBarNotification(); + resetTooltip(); + } + + /** + * Returns the game cursor. + */ + public static Cursor getCursor() { return cursor; } + + /** + * Returns the 'menu-back' MenuButton. + */ + public static MenuButton getBackButton() { return backButton; } + + /** + * Draws a tab image and text centered at a location. + * @param x the center x coordinate + * @param y the center y coordinate + * @param text the text to draw inside the tab + * @param selected whether the tab is selected (white) or not (red) + * @param isHover whether to include a hover effect (unselected only) + */ + public static void drawTab(float x, float y, String text, boolean selected, boolean isHover) { + Image tabImage = GameImage.MENU_TAB.getImage(); + float tabTextX = x - (Utils.FONT_MEDIUM.getWidth(text) / 2); + float tabTextY = y - (tabImage.getHeight() / 2); + Color filter, textColor; + if (selected) { + filter = Color.white; + textColor = Color.black; + } else { + filter = (isHover) ? Utils.COLOR_RED_HOVER : Color.red; + textColor = Color.white; + } + tabImage.drawCentered(x, y, filter); + Utils.FONT_MEDIUM.drawString(tabTextX, tabTextY, text, textColor); + } + + /** + * Draws the FPS at the bottom-right corner of the game container. + * If the option is not activated, this will do nothing. + */ + public static void drawFPS() { + if (!Options.isFPSCounterEnabled()) + return; + + String fps = String.format("%dFPS", container.getFPS()); + Utils.FONT_BOLD.drawString( + container.getWidth() * 0.997f - Utils.FONT_BOLD.getWidth(fps), + container.getHeight() * 0.997f - Utils.FONT_BOLD.getHeight(fps), + Integer.toString(container.getFPS()), Color.white + ); + Utils.FONT_DEFAULT.drawString( + container.getWidth() * 0.997f - Utils.FONT_BOLD.getWidth("FPS"), + container.getHeight() * 0.997f - Utils.FONT_BOLD.getHeight("FPS"), + "FPS", Color.white + ); + } + + /** + * Draws the volume bar on the middle right-hand side of the game container. + * Only draws if the volume has recently been changed using with {@link #changeVolume(int)}. + * @param g the graphics context + */ + public static void drawVolume(Graphics g) { + if (volumeDisplay == -1) + return; + + int width = container.getWidth(), height = container.getHeight(); + Image img = GameImage.VOLUME.getImage(); + + // move image in/out + float xOffset = 0; + float ratio = (float) volumeDisplay / VOLUME_DISPLAY_TIME; + if (ratio <= 0.1f) + xOffset = img.getWidth() * (1 - (ratio * 10f)); + else if (ratio >= 0.9f) + xOffset = img.getWidth() * (1 - ((1 - ratio) * 10f)); + + img.drawCentered(width - img.getWidth() / 2f + xOffset, height / 2f); + float barHeight = img.getHeight() * 0.9f; + float volume = Options.getMasterVolume(); + g.setColor(Color.white); + g.fillRoundRect( + width - (img.getWidth() * 0.368f) + xOffset, + (height / 2f) - (img.getHeight() * 0.47f) + (barHeight * (1 - volume)), + img.getWidth() * 0.15f, barHeight * volume, 3 + ); + } + + /** + * Updates volume display by a delta interval. + * @param delta the delta interval since the last call + */ + private static void updateVolumeDisplay(int delta) { + if (volumeDisplay == -1) + return; + + volumeDisplay += delta; + if (volumeDisplay > VOLUME_DISPLAY_TIME) + volumeDisplay = -1; + } + + /** + * Changes the master volume by a unit (positive or negative). + * @param units the number of units + */ + public static void changeVolume(int units) { + final float UNIT_OFFSET = 0.05f; + Options.setMasterVolume(container, Utils.getBoundedValue(Options.getMasterVolume(), UNIT_OFFSET * units, 0f, 1f)); + if (volumeDisplay == -1) + volumeDisplay = 0; + else if (volumeDisplay >= VOLUME_DISPLAY_TIME / 10) + volumeDisplay = VOLUME_DISPLAY_TIME / 10; + } + + /** + * Draws loading progress (OSZ unpacking, beatmap parsing, sound loading) + * at the bottom of the screen. + */ + public static void drawLoadingProgress(Graphics g) { + String text, file; + int progress; + + // determine current action + if ((file = OszUnpacker.getCurrentFileName()) != null) { + text = "Unpacking new beatmaps..."; + progress = OszUnpacker.getUnpackerProgress(); + } else if ((file = BeatmapParser.getCurrentFileName()) != null) { + text = (BeatmapParser.getStatus() == BeatmapParser.Status.INSERTING) ? + "Updating database..." : "Loading beatmaps..."; + progress = BeatmapParser.getParserProgress(); + } else if ((file = SoundController.getCurrentFileName()) != null) { + text = "Loading sounds..."; + progress = SoundController.getLoadingProgress(); + } else + return; + + // draw loading info + float marginX = container.getWidth() * 0.02f, marginY = container.getHeight() * 0.02f; + float lineY = container.getHeight() - marginY; + int lineOffsetY = Utils.FONT_MEDIUM.getLineHeight(); + if (Options.isLoadVerbose()) { + // verbose: display percentages and file names + Utils.FONT_MEDIUM.drawString( + marginX, lineY - (lineOffsetY * 2), + String.format("%s (%d%%)", text, progress), Color.white); + Utils.FONT_MEDIUM.drawString(marginX, lineY - lineOffsetY, file, Color.white); + } else { + // draw loading bar + Utils.FONT_MEDIUM.drawString(marginX, lineY - (lineOffsetY * 2), text, Color.white); + g.setColor(Color.white); + g.fillRoundRect(marginX, lineY - (lineOffsetY / 2f), + (container.getWidth() - (marginX * 2f)) * progress / 100f, lineOffsetY / 4f, 4 + ); + } + } + + /** + * Draws a scroll bar. + * @param g the graphics context + * @param unitIndex the unit index + * @param totalUnits the total number of units + * @param maxShown the maximum number of units shown at one time + * @param unitBaseX the base x coordinate of the units + * @param unitBaseY the base y coordinate of the units + * @param unitWidth the width of a unit + * @param unitHeight the height of a unit + * @param unitOffsetY the y offset between units + * @param bgColor the scroll bar area background color (null if none) + * @param scrollbarColor the scroll bar color + * @param right whether or not to place the scroll bar on the right side of the unit + */ + public static void drawScrollbar( + Graphics g, int unitIndex, int totalUnits, int maxShown, + float unitBaseX, float unitBaseY, float unitWidth, float unitHeight, float unitOffsetY, + Color bgColor, Color scrollbarColor, boolean right + ) { + float scrollbarWidth = container.getWidth() * 0.00347f; + float heightRatio = (float) (2.6701f * Math.exp(-0.81 * Math.log(totalUnits))); + float scrollbarHeight = container.getHeight() * heightRatio; + float scrollAreaHeight = unitHeight + unitOffsetY * (maxShown - 1); + float offsetY = (scrollAreaHeight - scrollbarHeight) * ((float) unitIndex / (totalUnits - maxShown)); + float scrollbarX = unitBaseX + unitWidth - ((right) ? scrollbarWidth : 0); + if (bgColor != null) { + g.setColor(bgColor); + g.fillRect(scrollbarX, unitBaseY, scrollbarWidth, scrollAreaHeight); + } + g.setColor(scrollbarColor); + g.fillRect(scrollbarX, unitBaseY + offsetY, scrollbarWidth, scrollbarHeight); + } + + /** + * Sets or updates a tooltip for drawing. + * Must be called with {@link #drawTooltip(Graphics)}. + * @param delta the delta interval since the last call + * @param s the tooltip text + * @param newlines whether to check for line breaks ('\n') + */ + public static void updateTooltip(int delta, String s, boolean newlines) { + if (s != null) { + tooltip = s; + tooltipNewlines = newlines; + if (tooltipTimer <= 0) + tooltipTimer = delta; + else + tooltipTimer += delta * 2; + if (tooltipTimer > TOOLTIP_FADE_TIME) + tooltipTimer = TOOLTIP_FADE_TIME; + } + } + + /** + * Draws a tooltip, if any, near the current mouse coordinates, + * bounded by the container dimensions. + * @param g the graphics context + */ + public static void drawTooltip(Graphics g) { + if (tooltipTimer <= 0 || tooltip == null) + return; + + int containerWidth = container.getWidth(), containerHeight = container.getHeight(); + int margin = containerWidth / 100, textMarginX = 2; + int offset = GameImage.CURSOR_MIDDLE.getImage().getWidth() / 2; + int lineHeight = Utils.FONT_SMALL.getLineHeight(); + int textWidth = textMarginX * 2, textHeight = lineHeight; + if (tooltipNewlines) { + String[] lines = tooltip.split("\\n"); + int maxWidth = Utils.FONT_SMALL.getWidth(lines[0]); + for (int i = 1; i < lines.length; i++) { + int w = Utils.FONT_SMALL.getWidth(lines[i]); + if (w > maxWidth) + maxWidth = w; + } + textWidth += maxWidth; + textHeight += lineHeight * (lines.length - 1); + } else + textWidth += Utils.FONT_SMALL.getWidth(tooltip); + + // get drawing coordinates + int x = input.getMouseX() + offset, y = input.getMouseY() + offset; + if (x + textWidth > containerWidth - margin) + x = containerWidth - margin - textWidth; + else if (x < margin) + x = margin; + if (y + textHeight > containerHeight - margin) + y = containerHeight - margin - textHeight; + else if (y < margin) + y = margin; + + // draw tooltip text inside a filled rectangle + float alpha = (float) tooltipTimer / TOOLTIP_FADE_TIME; + float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; + Utils.COLOR_BLACK_ALPHA.a = alpha; + g.setColor(Utils.COLOR_BLACK_ALPHA); + Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + g.fillRect(x, y, textWidth, textHeight); + oldAlpha = Utils.COLOR_DARK_GRAY.a; + Utils.COLOR_DARK_GRAY.a = alpha; + g.setColor(Utils.COLOR_DARK_GRAY); + g.setLineWidth(1); + g.drawRect(x, y, textWidth, textHeight); + Utils.COLOR_DARK_GRAY.a = oldAlpha; + oldAlpha = Utils.COLOR_WHITE_ALPHA.a; + Utils.COLOR_WHITE_ALPHA.a = alpha; + Utils.FONT_SMALL.drawString(x + textMarginX, y, tooltip, Utils.COLOR_WHITE_ALPHA); + Utils.COLOR_WHITE_ALPHA.a = oldAlpha; + } + + /** + * Resets the tooltip. + */ + public static void resetTooltip() { + tooltipTimer = -1; + tooltip = null; + } + + /** + * Submits a bar notification for drawing. + * Must be called with {@link #drawBarNotification(Graphics)}. + * @param s the notification string + */ + public static void sendBarNotification(String s) { + if (s != null) { + barNotif = s; + barNotifTimer = 0; + } + } + + /** + * Updates the bar notification by a delta interval. + * @param delta the delta interval since the last call + */ + private static void updateBarNotification(int delta) { + if (barNotifTimer > -1 && barNotifTimer < BAR_NOTIFICATION_TIME) { + barNotifTimer += delta; + if (barNotifTimer > BAR_NOTIFICATION_TIME) + barNotifTimer = BAR_NOTIFICATION_TIME; + } + } + + /** + * Resets the bar notification. + */ + public static void resetBarNotification() { + barNotifTimer = -1; + barNotif = null; + } + + /** + * Draws the notification sent from {@link #sendBarNotification(String)}. + * @param g the graphics context + */ + public static void drawBarNotification(Graphics g) { + if (barNotifTimer <= 0 || barNotifTimer >= BAR_NOTIFICATION_TIME) + return; + + float alpha = 1f; + if (barNotifTimer >= BAR_NOTIFICATION_TIME * 0.9f) + alpha -= 1 - ((BAR_NOTIFICATION_TIME - barNotifTimer) / (BAR_NOTIFICATION_TIME * 0.1f)); + int midX = container.getWidth() / 2, midY = container.getHeight() / 2; + float barHeight = Utils.FONT_LARGE.getLineHeight() * (1f + 0.6f * Math.min(barNotifTimer * 15f / BAR_NOTIFICATION_TIME, 1f)); + float oldAlphaB = Utils.COLOR_BLACK_ALPHA.a, oldAlphaW = Utils.COLOR_WHITE_ALPHA.a; + Utils.COLOR_BLACK_ALPHA.a *= alpha; + Utils.COLOR_WHITE_ALPHA.a = alpha; + g.setColor(Utils.COLOR_BLACK_ALPHA); + g.fillRect(0, midY - barHeight / 2f, container.getWidth(), barHeight); + Utils.FONT_LARGE.drawString( + midX - Utils.FONT_LARGE.getWidth(barNotif) / 2f, + midY - Utils.FONT_LARGE.getLineHeight() / 2.2f, + barNotif, Utils.COLOR_WHITE_ALPHA); + Utils.COLOR_BLACK_ALPHA.a = oldAlphaB; + Utils.COLOR_WHITE_ALPHA.a = oldAlphaW; + } + + /** + * Shows a confirmation dialog (used before exiting the game). + * @param message the message to display + * @return true if user selects "yes", false otherwise + */ + public static boolean showExitConfirmation(String message) { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception e) { + ErrorHandler.error("Could not set system look and feel for exit confirmation.", e, true); + } + int n = JOptionPane.showConfirmDialog(null, message, "Warning", + JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + return (n != JOptionPane.YES_OPTION); + } +} diff --git a/src/org/newdawn/slick/GameContainer.java b/src/org/newdawn/slick/GameContainer.java index 3c6cff2d..01c7604f 100644 --- a/src/org/newdawn/slick/GameContainer.java +++ b/src/org/newdawn/slick/GameContainer.java @@ -555,8 +555,8 @@ public abstract class GameContainer implements GUIContext { * bottom. * * @param ref The reference to the image to be loaded - * @param x The x-coordinate of the cursor hotspot (left -> right) - * @param y The y-coordinate of the cursor hotspot (bottom -> top) + * @param x The x-coordinate of the cursor hotspot (left {@literal ->} right) + * @param y The y-coordinate of the cursor hotspot (bottom {@literal ->} top) * @param width The x width of the cursor * @param height The y height of the cursor * @param cursorDelays image delays between changing frames in animation diff --git a/src/org/newdawn/slick/Image.java b/src/org/newdawn/slick/Image.java index 05027b9b..1737a808 100644 --- a/src/org/newdawn/slick/Image.java +++ b/src/org/newdawn/slick/Image.java @@ -935,7 +935,7 @@ public class Image implements Renderable { /** * Set the angle to rotate this image to. The angle will be normalized to - * be 0 <= angle < 360. The image will be rotated around its center. + * be {@literal 0 <= angle < 360}. The image will be rotated around its center. * * @param angle The angle to be set */ @@ -973,7 +973,7 @@ public class Image implements Renderable { /** * Add the angle provided to the current rotation. The angle will be normalized to - * be 0 <= angle < 360. The image will be rotated around its center. + * be {@literal 0 <= angle < 360}. The image will be rotated around its center. * * @param angle The angle to add. */ diff --git a/src/org/newdawn/slick/Input.java b/src/org/newdawn/slick/Input.java new file mode 100644 index 00000000..1066ca34 --- /dev/null +++ b/src/org/newdawn/slick/Input.java @@ -0,0 +1,1568 @@ +/* + * Copyright (c) 2013, Slick2D + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the Slick2D nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package org.newdawn.slick; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; + +import org.lwjgl.LWJGLException; +import org.lwjgl.input.Controller; +import org.lwjgl.input.Controllers; +import org.lwjgl.input.Keyboard; +import org.lwjgl.input.Mouse; +import org.lwjgl.opengl.Display; +import org.newdawn.slick.util.Log; + +/** + * A wrapped for all keyboard, mouse and controller input + * + * @author kevin + */ +@SuppressWarnings({"rawtypes", "unchecked", "unused"}) +public class Input { + /** The controller index to pass to check all controllers */ + public static final int ANY_CONTROLLER = -1; + + /** The maximum number of buttons on controllers */ + private static final int MAX_BUTTONS = 100; + + /** */ + public static final int KEY_ESCAPE = 0x01; + /** */ + public static final int KEY_1 = 0x02; + /** */ + public static final int KEY_2 = 0x03; + /** */ + public static final int KEY_3 = 0x04; + /** */ + public static final int KEY_4 = 0x05; + /** */ + public static final int KEY_5 = 0x06; + /** */ + public static final int KEY_6 = 0x07; + /** */ + public static final int KEY_7 = 0x08; + /** */ + public static final int KEY_8 = 0x09; + /** */ + public static final int KEY_9 = 0x0A; + /** */ + public static final int KEY_0 = 0x0B; + /** */ + public static final int KEY_MINUS = 0x0C; /* - on main keyboard */ + /** */ + public static final int KEY_EQUALS = 0x0D; + /** */ + public static final int KEY_BACK = 0x0E; /* backspace */ + /** */ + public static final int KEY_TAB = 0x0F; + /** */ + public static final int KEY_Q = 0x10; + /** */ + public static final int KEY_W = 0x11; + /** */ + public static final int KEY_E = 0x12; + /** */ + public static final int KEY_R = 0x13; + /** */ + public static final int KEY_T = 0x14; + /** */ + public static final int KEY_Y = 0x15; + /** */ + public static final int KEY_U = 0x16; + /** */ + public static final int KEY_I = 0x17; + /** */ + public static final int KEY_O = 0x18; + /** */ + public static final int KEY_P = 0x19; + /** */ + public static final int KEY_LBRACKET = 0x1A; + /** */ + public static final int KEY_RBRACKET = 0x1B; + /** */ + public static final int KEY_RETURN = 0x1C; /* Enter on main keyboard */ + /** */ + public static final int KEY_ENTER = 0x1C; /* Enter on main keyboard */ + /** */ + public static final int KEY_LCONTROL = 0x1D; + /** */ + public static final int KEY_A = 0x1E; + /** */ + public static final int KEY_S = 0x1F; + /** */ + public static final int KEY_D = 0x20; + /** */ + public static final int KEY_F = 0x21; + /** */ + public static final int KEY_G = 0x22; + /** */ + public static final int KEY_H = 0x23; + /** */ + public static final int KEY_J = 0x24; + /** */ + public static final int KEY_K = 0x25; + /** */ + public static final int KEY_L = 0x26; + /** */ + public static final int KEY_SEMICOLON = 0x27; + /** */ + public static final int KEY_APOSTROPHE = 0x28; + /** */ + public static final int KEY_GRAVE = 0x29; /* accent grave */ + /** */ + public static final int KEY_LSHIFT = 0x2A; + /** */ + public static final int KEY_BACKSLASH = 0x2B; + /** */ + public static final int KEY_Z = 0x2C; + /** */ + public static final int KEY_X = 0x2D; + /** */ + public static final int KEY_C = 0x2E; + /** */ + public static final int KEY_V = 0x2F; + /** */ + public static final int KEY_B = 0x30; + /** */ + public static final int KEY_N = 0x31; + /** */ + public static final int KEY_M = 0x32; + /** */ + public static final int KEY_COMMA = 0x33; + /** */ + public static final int KEY_PERIOD = 0x34; /* . on main keyboard */ + /** */ + public static final int KEY_SLASH = 0x35; /* / on main keyboard */ + /** */ + public static final int KEY_RSHIFT = 0x36; + /** */ + public static final int KEY_MULTIPLY = 0x37; /* * on numeric keypad */ + /** */ + public static final int KEY_LMENU = 0x38; /* left Alt */ + /** */ + public static final int KEY_SPACE = 0x39; + /** */ + public static final int KEY_CAPITAL = 0x3A; + /** */ + public static final int KEY_F1 = 0x3B; + /** */ + public static final int KEY_F2 = 0x3C; + /** */ + public static final int KEY_F3 = 0x3D; + /** */ + public static final int KEY_F4 = 0x3E; + /** */ + public static final int KEY_F5 = 0x3F; + /** */ + public static final int KEY_F6 = 0x40; + /** */ + public static final int KEY_F7 = 0x41; + /** */ + public static final int KEY_F8 = 0x42; + /** */ + public static final int KEY_F9 = 0x43; + /** */ + public static final int KEY_F10 = 0x44; + /** */ + public static final int KEY_NUMLOCK = 0x45; + /** */ + public static final int KEY_SCROLL = 0x46; /* Scroll Lock */ + /** */ + public static final int KEY_NUMPAD7 = 0x47; + /** */ + public static final int KEY_NUMPAD8 = 0x48; + /** */ + public static final int KEY_NUMPAD9 = 0x49; + /** */ + public static final int KEY_SUBTRACT = 0x4A; /* - on numeric keypad */ + /** */ + public static final int KEY_NUMPAD4 = 0x4B; + /** */ + public static final int KEY_NUMPAD5 = 0x4C; + /** */ + public static final int KEY_NUMPAD6 = 0x4D; + /** */ + public static final int KEY_ADD = 0x4E; /* + on numeric keypad */ + /** */ + public static final int KEY_NUMPAD1 = 0x4F; + /** */ + public static final int KEY_NUMPAD2 = 0x50; + /** */ + public static final int KEY_NUMPAD3 = 0x51; + /** */ + public static final int KEY_NUMPAD0 = 0x52; + /** */ + public static final int KEY_DECIMAL = 0x53; /* . on numeric keypad */ + /** */ + public static final int KEY_F11 = 0x57; + /** */ + public static final int KEY_F12 = 0x58; + /** */ + public static final int KEY_F13 = 0x64; /* (NEC PC98) */ + /** */ + public static final int KEY_F14 = 0x65; /* (NEC PC98) */ + /** */ + public static final int KEY_F15 = 0x66; /* (NEC PC98) */ + /** */ + public static final int KEY_KANA = 0x70; /* (Japanese keyboard) */ + /** */ + public static final int KEY_CONVERT = 0x79; /* (Japanese keyboard) */ + /** */ + public static final int KEY_NOCONVERT = 0x7B; /* (Japanese keyboard) */ + /** */ + public static final int KEY_YEN = 0x7D; /* (Japanese keyboard) */ + /** */ + public static final int KEY_NUMPADEQUALS = 0x8D; /* = on numeric keypad (NEC PC98) */ + /** */ + public static final int KEY_CIRCUMFLEX = 0x90; /* (Japanese keyboard) */ + /** */ + public static final int KEY_AT = 0x91; /* (NEC PC98) */ + /** */ + public static final int KEY_COLON = 0x92; /* (NEC PC98) */ + /** */ + public static final int KEY_UNDERLINE = 0x93; /* (NEC PC98) */ + /** */ + public static final int KEY_KANJI = 0x94; /* (Japanese keyboard) */ + /** */ + public static final int KEY_STOP = 0x95; /* (NEC PC98) */ + /** */ + public static final int KEY_AX = 0x96; /* (Japan AX) */ + /** */ + public static final int KEY_UNLABELED = 0x97; /* (J3100) */ + /** */ + public static final int KEY_NUMPADENTER = 0x9C; /* Enter on numeric keypad */ + /** */ + public static final int KEY_RCONTROL = 0x9D; + /** */ + public static final int KEY_NUMPADCOMMA = 0xB3; /* , on numeric keypad (NEC PC98) */ + /** */ + public static final int KEY_DIVIDE = 0xB5; /* / on numeric keypad */ + /** */ + public static final int KEY_SYSRQ = 0xB7; + /** */ + public static final int KEY_RMENU = 0xB8; /* right Alt */ + /** */ + public static final int KEY_PAUSE = 0xC5; /* Pause */ + /** */ + public static final int KEY_HOME = 0xC7; /* Home on arrow keypad */ + /** */ + public static final int KEY_UP = 0xC8; /* UpArrow on arrow keypad */ + /** */ + public static final int KEY_PRIOR = 0xC9; /* PgUp on arrow keypad */ + /** */ + public static final int KEY_LEFT = 0xCB; /* LeftArrow on arrow keypad */ + /** */ + public static final int KEY_RIGHT = 0xCD; /* RightArrow on arrow keypad */ + /** */ + public static final int KEY_END = 0xCF; /* End on arrow keypad */ + /** */ + public static final int KEY_DOWN = 0xD0; /* DownArrow on arrow keypad */ + /** */ + public static final int KEY_NEXT = 0xD1; /* PgDn on arrow keypad */ + /** */ + public static final int KEY_INSERT = 0xD2; /* Insert on arrow keypad */ + /** */ + public static final int KEY_DELETE = 0xD3; /* Delete on arrow keypad */ + /** */ + public static final int KEY_LWIN = 0xDB; /* Left Windows key */ + /** */ + public static final int KEY_RWIN = 0xDC; /* Right Windows key */ + /** */ + public static final int KEY_APPS = 0xDD; /* AppMenu key */ + /** */ + public static final int KEY_POWER = 0xDE; + /** */ + public static final int KEY_SLEEP = 0xDF; + + /** A helper for left ALT */ + public static final int KEY_LALT = KEY_LMENU; + /** A helper for right ALT */ + public static final int KEY_RALT = KEY_RMENU; + + /** Control index */ + private static final int LEFT = 0; + /** Control index */ + private static final int RIGHT = 1; + /** Control index */ + private static final int UP = 2; + /** Control index */ + private static final int DOWN = 3; + /** Control index */ + private static final int BUTTON1 = 4; + /** Control index */ + private static final int BUTTON2 = 5; + /** Control index */ + private static final int BUTTON3 = 6; + /** Control index */ + private static final int BUTTON4 = 7; + /** Control index */ + private static final int BUTTON5 = 8; + /** Control index */ + private static final int BUTTON6 = 9; + /** Control index */ + private static final int BUTTON7 = 10; + /** Control index */ + private static final int BUTTON8 = 11; + /** Control index */ + private static final int BUTTON9 = 12; + /** Control index */ + private static final int BUTTON10 = 13; + + /** The left mouse button indicator */ + public static final int MOUSE_LEFT_BUTTON = 0; + /** The right mouse button indicator */ + public static final int MOUSE_RIGHT_BUTTON = 1; + /** The middle mouse button indicator */ + public static final int MOUSE_MIDDLE_BUTTON = 2; + + /** True if the controllers system has been initialised */ + private static boolean controllersInited = false; + /** The list of controllers */ + private static ArrayList controllers = new ArrayList(); + + /** The last recorded mouse x position */ + private int lastMouseX; + /** The last recorded mouse y position */ + private int lastMouseY; + /** THe state of the mouse buttons */ + protected boolean[] mousePressed = new boolean[10]; + /** THe state of the controller buttons */ + private boolean[][] controllerPressed = new boolean[100][MAX_BUTTONS]; + + /** The character values representing the pressed keys */ + protected char[] keys = new char[1024]; + /** True if the key has been pressed since last queries */ + protected boolean[] pressed = new boolean[1024]; + /** The time since the next key repeat to be fired for the key */ + protected long[] nextRepeat = new long[1024]; + + /** The control states from the controllers */ + private boolean[][] controls = new boolean[10][MAX_BUTTONS+10]; + /** True if the event has been consumed */ + protected boolean consumed = false; + /** A list of listeners to be notified of input events */ + protected HashSet allListeners = new HashSet(); + /** The listeners to notify of key events */ + protected ArrayList keyListeners = new ArrayList(); + /** The listener to add */ + protected ArrayList keyListenersToAdd = new ArrayList(); + /** The listeners to notify of mouse events */ + protected ArrayList mouseListeners = new ArrayList(); + /** The listener to add */ + protected ArrayList mouseListenersToAdd = new ArrayList(); + /** The listener to nofiy of controller events */ + protected ArrayList controllerListeners = new ArrayList(); + /** The current value of the wheel */ + private int wheel; + /** The height of the display */ + private int height; + + /** True if the display is active */ + private boolean displayActive = true; + + /** True if key repeat is enabled */ + private boolean keyRepeat; + /** The initial delay for key repeat starts */ + private int keyRepeatInitial; + /** The interval of key repeat */ + private int keyRepeatInterval; + + /** True if the input is currently paused */ + private boolean paused; + /** The scale to apply to screen coordinates */ + private float scaleX = 1; + /** The scale to apply to screen coordinates */ + private float scaleY = 1; + /** The offset to apply to screen coordinates */ + private float xoffset = 0; + /** The offset to apply to screen coordinates */ + private float yoffset = 0; + + /** The delay before determining a single or double click */ + private int doubleClickDelay = 250; + /** The timer running out for a single click */ + private long doubleClickTimeout = 0; + + /** The clicked x position */ + private int clickX; + /** The clicked y position */ + private int clickY; + /** The clicked button */ + private int clickButton; + + /** The x position location the mouse was pressed */ + private int pressedX = -1; + + /** The x position location the mouse was pressed */ + private int pressedY = -1; + + /** The pixel distance the mouse can move to accept a mouse click */ + private int mouseClickTolerance = 5; + + /** + * Disables support for controllers. This means the jinput JAR and native libs + * are not required. + */ + public static void disableControllers() { + controllersInited = true; + } + + /** + * Create a new input with the height of the screen + * + * @param height The height of the screen + */ + public Input(int height) { + init(height); + } + + /** + * Set the double click interval, the time between the first + * and second clicks that should be interpreted as a + * double click. + * + * @param delay The delay between clicks + */ + public void setDoubleClickInterval(int delay) { + doubleClickDelay = delay; + } + + /** + * Set the pixel distance the mouse can move to accept a mouse click. + * Default is 5. + * + * @param mouseClickTolerance The number of pixels. + */ + public void setMouseClickTolerance (int mouseClickTolerance) { + this.mouseClickTolerance = mouseClickTolerance; + } + + /** + * Set the scaling to apply to screen coordinates + * + * @param scaleX The scaling to apply to the horizontal axis + * @param scaleY The scaling to apply to the vertical axis + */ + public void setScale(float scaleX, float scaleY) { + this.scaleX = scaleX; + this.scaleY = scaleY; + } + + /** + * Set the offset to apply to the screen coodinates + * + * @param xoffset The offset on the x-axis + * @param yoffset The offset on the y-axis + */ + public void setOffset(float xoffset, float yoffset) { + this.xoffset = xoffset; + this.yoffset = yoffset; + } + + /** + * Reset the transformation being applied to the input to the default + */ + public void resetInputTransform() { + setOffset(0, 0); + setScale(1, 1); + } + + /** + * Add a listener to be notified of input events + * + * @param listener The listener to be notified + */ + public void addListener(InputListener listener) { + addKeyListener(listener); + addMouseListener(listener); + addControllerListener(listener); + } + + /** + * Add a key listener to be notified of key input events + * + * @param listener The listener to be notified + */ + public void addKeyListener(KeyListener listener) { + keyListenersToAdd.add(listener); + } + + /** + * Add a key listener to be notified of key input events + * + * @param listener The listener to be notified + */ + private void addKeyListenerImpl(KeyListener listener) { + if (keyListeners.contains(listener)) { + return; + } + keyListeners.add(listener); + allListeners.add(listener); + } + + /** + * Add a mouse listener to be notified of mouse input events + * + * @param listener The listener to be notified + */ + public void addMouseListener(MouseListener listener) { + mouseListenersToAdd.add(listener); + } + + /** + * Add a mouse listener to be notified of mouse input events + * + * @param listener The listener to be notified + */ + private void addMouseListenerImpl(MouseListener listener) { + if (mouseListeners.contains(listener)) { + return; + } + mouseListeners.add(listener); + allListeners.add(listener); + } + + /** + * Add a controller listener to be notified of controller input events + * + * @param listener The listener to be notified + */ + public void addControllerListener(ControllerListener listener) { + if (controllerListeners.contains(listener)) { + return; + } + controllerListeners.add(listener); + allListeners.add(listener); + } + + /** + * Remove all the listeners from this input + */ + public void removeAllListeners() { + removeAllKeyListeners(); + removeAllMouseListeners(); + removeAllControllerListeners(); + } + + /** + * Remove all the key listeners from this input + */ + public void removeAllKeyListeners() { + allListeners.removeAll(keyListeners); + keyListeners.clear(); + } + + /** + * Remove all the mouse listeners from this input + */ + public void removeAllMouseListeners() { + allListeners.removeAll(mouseListeners); + mouseListeners.clear(); + } + + /** + * Remove all the controller listeners from this input + */ + public void removeAllControllerListeners() { + allListeners.removeAll(controllerListeners); + controllerListeners.clear(); + } + + /** + * Add a listener to be notified of input events. This listener + * will get events before others that are currently registered + * + * @param listener The listener to be notified + */ + public void addPrimaryListener(InputListener listener) { + removeListener(listener); + + keyListeners.add(0, listener); + mouseListeners.add(0, listener); + controllerListeners.add(0, listener); + + allListeners.add(listener); + } + + /** + * Remove a listener that will no longer be notified + * + * @param listener The listen to be removed + */ + public void removeListener(InputListener listener) { + removeKeyListener(listener); + removeMouseListener(listener); + removeControllerListener(listener); + } + + /** + * Remove a key listener that will no longer be notified + * + * @param listener The listen to be removed + */ + public void removeKeyListener(KeyListener listener) { + keyListeners.remove(listener); + + if (!mouseListeners.contains(listener) && !controllerListeners.contains(listener)) { + allListeners.remove(listener); + } + } + + /** + * Remove a controller listener that will no longer be notified + * + * @param listener The listen to be removed + */ + public void removeControllerListener(ControllerListener listener) { + controllerListeners.remove(listener); + + if (!mouseListeners.contains(listener) && !keyListeners.contains(listener)) { + allListeners.remove(listener); + } + } + + /** + * Remove a mouse listener that will no longer be notified + * + * @param listener The listen to be removed + */ + public void removeMouseListener(MouseListener listener) { + mouseListeners.remove(listener); + + if (!controllerListeners.contains(listener) && !keyListeners.contains(listener)) { + allListeners.remove(listener); + } + } + + /** + * Initialise the input system + * + * @param height The height of the window + */ + void init(int height) { + this.height = height; + lastMouseX = getMouseX(); + lastMouseY = getMouseY(); + } + + /** + * Get the character representation of the key identified by the specified code + * + * @param code The key code of the key to retrieve the name of + * @return The name or character representation of the key requested + */ + public static String getKeyName(int code) { + return Keyboard.getKeyName(code); + } + + /** + * Check if a particular key has been pressed since this method + * was last called for the specified key + * + * @param code The key code of the key to check + * @return True if the key has been pressed + */ + public boolean isKeyPressed(int code) { + if (pressed[code]) { + pressed[code] = false; + return true; + } + + return false; + } + + /** + * Check if a mouse button has been pressed since last call + * + * @param button The button to check + * @return True if the button has been pressed since last call + */ + public boolean isMousePressed(int button) { + if (mousePressed[button]) { + mousePressed[button] = false; + return true; + } + + return false; + } + + /** + * Check if a controller button has been pressed since last + * time + * + * @param button The button to check for (note that this includes directional controls first) + * @return True if the button has been pressed since last time + */ + public boolean isControlPressed(int button) { + return isControlPressed(button, 0); + } + + /** + * Check if a controller button has been pressed since last + * time + * + * @param controller The index of the controller to check + * @param button The button to check for (note that this includes directional controls first) + * @return True if the button has been pressed since last time + */ + public boolean isControlPressed(int button, int controller) { + if (controllerPressed[controller][button]) { + controllerPressed[controller][button] = false; + return true; + } + + return false; + } + + /** + * Clear the state for isControlPressed method. This will reset all + * controls to not pressed + */ + public void clearControlPressedRecord() { + for (int i=0;iisKeyPressed method. This will + * resort in all keys returning that they haven't been pressed, until + * they are pressed again + */ + public void clearKeyPressedRecord() { + Arrays.fill(pressed, false); + } + + /** + * Clear the state for the isMousePressed method. This will + * resort in all mouse buttons returning that they haven't been pressed, until + * they are pressed again + */ + public void clearMousePressedRecord() { + Arrays.fill(mousePressed, false); + } + + /** + * Check if a particular key is down + * + * @param code The key code of the key to check + * @return True if the key is down + */ + public boolean isKeyDown(int code) { + return Keyboard.isKeyDown(code); + } + + /** + * Get the absolute x position of the mouse cursor within the container + * + * @return The absolute x position of the mouse cursor + */ + public int getAbsoluteMouseX() { + return Mouse.getX(); + } + + /** + * Get the absolute y position of the mouse cursor within the container + * + * @return The absolute y position of the mouse cursor + */ + public int getAbsoluteMouseY() { + return height - Mouse.getY(); + } + + /** + * Get the x position of the mouse cursor + * + * @return The x position of the mouse cursor + */ + public int getMouseX() { + return (int) ((Mouse.getX() * scaleX)+xoffset); + } + + /** + * Get the y position of the mouse cursor + * + * @return The y position of the mouse cursor + */ + public int getMouseY() { + return (int) (((height-Mouse.getY()) * scaleY)+yoffset); + } + + /** + * Check if a given mouse button is down + * + * @param button The index of the button to check (starting at 0) + * @return True if the mouse button is down + */ + public boolean isMouseButtonDown(int button) { + return Mouse.isButtonDown(button); + } + + /** + * Check if any mouse button is down + * + * @return True if any mouse button is down + */ + private boolean anyMouseDown() { + for (int i=0;i<3;i++) { + if (Mouse.isButtonDown(i)) { + return true; + } + } + + return false; + } + + /** + * Get a count of the number of controlles available + * + * @return The number of controllers available + */ + public int getControllerCount() { + try { + initControllers(); + } catch (SlickException e) { + throw new RuntimeException("Failed to initialise controllers"); + } + + return controllers.size(); + } + + /** + * Get the number of axis that are avaiable on a given controller + * + * @param controller The index of the controller to check + * @return The number of axis available on the controller + */ + public int getAxisCount(int controller) { + return ((Controller) controllers.get(controller)).getAxisCount(); + } + + /** + * Get the value of the axis with the given index + * + * @param controller The index of the controller to check + * @param axis The index of the axis to read + * @return The axis value at time of reading + */ + public float getAxisValue(int controller, int axis) { + return ((Controller) controllers.get(controller)).getAxisValue(axis); + } + + /** + * Get the name of the axis with the given index + * + * @param controller The index of the controller to check + * @param axis The index of the axis to read + * @return The name of the specified axis + */ + public String getAxisName(int controller, int axis) { + return ((Controller) controllers.get(controller)).getAxisName(axis); + } + + /** + * Check if the controller has the left direction pressed + * + * @param controller The index of the controller to check + * @return True if the controller is pressed to the left + */ + public boolean isControllerLeft(int controller) { + if (controller >= getControllerCount()) { + return false; + } + + if (controller == ANY_CONTROLLER) { + for (int i=0;i= getControllerCount()) { + return false; + } + + if (controller == ANY_CONTROLLER) { + for (int i=0;i 0.5f + || ((Controller) controllers.get(controller)).getPovX() > 0.5f; + } + + /** + * Check if the controller has the up direction pressed + * + * @param controller The index of the controller to check + * @return True if the controller is pressed to the up + */ + public boolean isControllerUp(int controller) { + if (controller >= getControllerCount()) { + return false; + } + + if (controller == ANY_CONTROLLER) { + for (int i=0;i= getControllerCount()) { + return false; + } + + if (controller == ANY_CONTROLLER) { + for (int i=0;i 0.5f + || ((Controller) controllers.get(controller)).getPovY() > 0.5f; + + } + + /** + * Check if controller button is pressed + * + * @param controller The index of the controller to check + * @param index The index of the button to check + * @return True if the button is pressed + */ + public boolean isButtonPressed(int index, int controller) { + if (controller >= getControllerCount()) { + return false; + } + + if (controller == ANY_CONTROLLER) { + for (int i=0;i= 3) && (controller.getButtonCount() < MAX_BUTTONS)) { + controllers.add(controller); + } + } + + Log.info("Found "+controllers.size()+" controllers"); + for (int i=0;i doubleClickTimeout) { + doubleClickTimeout = 0; + } + } + + this.height = height; + + Iterator allStarts = allListeners.iterator(); + while (allStarts.hasNext()) { + ControlledInputReciever listener = (ControlledInputReciever) allStarts.next(); + listener.inputStarted(); + } + + while (Keyboard.next()) { + if (Keyboard.getEventKeyState()) { + int eventKey = resolveEventKey(Keyboard.getEventKey(), Keyboard.getEventCharacter()); + + keys[eventKey] = Keyboard.getEventCharacter(); + pressed[eventKey] = true; + nextRepeat[eventKey] = System.currentTimeMillis() + keyRepeatInitial; + + consumed = false; + for (int i=0;i= 0) { + if (Mouse.getEventButtonState()) { + consumed = false; + mousePressed[Mouse.getEventButton()] = true; + + pressedX = (int) (xoffset + (Mouse.getEventX() * scaleX)); + pressedY = (int) (yoffset + ((height-Mouse.getEventY()) * scaleY)); + + for (int i=0;i nextRepeat[i]) { + nextRepeat[i] = System.currentTimeMillis() + keyRepeatInterval; + consumed = false; + for (int j=0;j= BUTTON1) { + return isButtonPressed((index-BUTTON1), controllerIndex); + } + + throw new RuntimeException("Unknown control index"); + } + + + /** + * Pauses the polling and sending of input events. + */ + public void pause() { + paused = true; + + // Reset all polling arrays + clearKeyPressedRecord(); + clearMousePressedRecord(); + clearControlPressedRecord(); + } + + /** + * Resumes the polling and sending of input events. + */ + public void resume() { + paused = false; + } + + /** + * Notify listeners that the mouse button has been clicked + * + * @param button The button that has been clicked + * @param x The location at which the button was clicked + * @param y The location at which the button was clicked + * @param clickCount The number of times the button was clicked (single or double click) + */ + private void fireMouseClicked(int button, int x, int y, int clickCount) { + consumed = false; + for (int i=0;i + * @author Nathan Sweet {@literal } */ @SuppressWarnings({"rawtypes", "unchecked"}) public class Music { diff --git a/src/org/newdawn/slick/openal/OpenALStreamPlayer.java b/src/org/newdawn/slick/openal/OpenALStreamPlayer.java index 8fbe3694..2e737886 100644 --- a/src/org/newdawn/slick/openal/OpenALStreamPlayer.java +++ b/src/org/newdawn/slick/openal/OpenALStreamPlayer.java @@ -46,7 +46,7 @@ import org.newdawn.slick.util.ResourceLoader; * as required. * * @author Kevin Glass - * @author Nathan Sweet + * @author Nathan Sweet {@literal } * @author Rockstar play and setPosition cleanup */ public class OpenALStreamPlayer { diff --git a/tools/jarsplice-0.40.jar b/tools/jarsplice-0.40.jar deleted file mode 100644 index 44fd3a6a..00000000 Binary files a/tools/jarsplice-0.40.jar and /dev/null differ