diff --git a/.gitignore b/.gitignore
index 4bf3877b..78139c50 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
/Songs/
/.opsu.log
/.opsu.cfg
+/.opsu_scores.db
# Eclipse
/.settings/
diff --git a/pom.xml b/pom.xml
index dca85e6e..8641dcd4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -133,5 +133,10 @@
jlayer
1.0.1
+
+ org.xerial
+ sqlite-jdbc
+ 3.8.7
+
\ No newline at end of file
diff --git a/src/itdelatrisu/opsu/Container.java b/src/itdelatrisu/opsu/Container.java
index 42cf8571..fe94c166 100644
--- a/src/itdelatrisu/opsu/Container.java
+++ b/src/itdelatrisu/opsu/Container.java
@@ -74,10 +74,8 @@ public class Container extends AppGameContainer {
}
}
- if (forceExit) {
- Opsu.closeSocket();
- System.exit(0);
- }
+ if (forceExit)
+ Opsu.exit();
}
/**
diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java
index 8c628e41..6e58517c 100644
--- a/src/itdelatrisu/opsu/GameData.java
+++ b/src/itdelatrisu/opsu/GameData.java
@@ -18,12 +18,12 @@
package itdelatrisu.opsu;
+import itdelatrisu.opsu.Scores.ScoreData;
import itdelatrisu.opsu.audio.HitSound;
import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
-import java.io.File;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
@@ -41,7 +41,7 @@ public class GameData {
public static final float HP_DRAIN_MULTIPLIER = 1 / 200f;
/** Letter grades. */
- private enum Grade {
+ public enum Grade {
NULL (null, null),
SS (GameImage.RANKING_SS, GameImage.RANKING_SS_SMALL),
SSH (GameImage.RANKING_SSH, GameImage.RANKING_SSH_SMALL), // silver
@@ -95,9 +95,6 @@ public class GameData {
/** Counts of each hit result so far. */
private int[] hitResultCount;
- /** Total number of hit objects so far, not including Katu/Geki (for calculating grade). */
- private int objectCount;
-
/** Total objects including slider hits/ticks (for determining Full Combo status). */
private int fullObjectCount;
@@ -194,21 +191,57 @@ public class GameData {
/** Scorebar animation. */
private Animation scorebarColour;
+ /** The associated score data. */
+ private ScoreData scoreData;
+
+ /** Whether this object is used for gameplay (true) or score viewing (false). */
+ private boolean gameplay;
+
/** Container dimensions. */
private int width, height;
/**
- * Constructor.
+ * Constructor for gameplay.
* @param width container width
* @param height container height
*/
public GameData(int width, int height) {
this.width = width;
this.height = height;
+ this.gameplay = true;
clear();
}
+ /**
+ * Constructor for score viewing.
+ * This will initialize all parameters and images needed for the
+ * {@link #drawRankingElements(Graphics, int, int)} method.
+ * @param s the ScoreData object
+ * @param width container width
+ * @param height container height
+ */
+ public GameData(ScoreData s, int width, int height) {
+ this.width = width;
+ this.height = height;
+ this.gameplay = false;
+
+ this.scoreData = s;
+ this.score = s.score;
+ this.comboMax = s.combo;
+ this.fullObjectCount = (s.perfect) ? s.combo : -1;
+ this.hitResultCount = new int[HIT_MAX];
+ hitResultCount[HIT_300] = s.hit300;
+ hitResultCount[HIT_100] = s.hit100;
+ hitResultCount[HIT_50] = s.hit50;
+ hitResultCount[HIT_300G] = s.geki;
+ hitResultCount[HIT_300K] = 0;
+ hitResultCount[HIT_100K] = s.katu;
+ hitResultCount[HIT_MISS] = s.miss;
+
+ loadImages();
+ }
+
/**
* Clears all data and re-initializes object.
*/
@@ -219,42 +252,44 @@ public class GameData {
healthDisplay = 100f;
hitResultCount = new int[HIT_MAX];
hitResultList = new LinkedList();
- objectCount = 0;
fullObjectCount = 0;
combo = 0;
comboMax = 0;
comboEnd = 0;
comboBurstIndex = -1;
+ scoreData = null;
}
/**
* Loads all game score images.
- * @param dir the image directory
*/
- public void loadImages(File dir) {
- // combo burst images
- if (GameImage.COMBO_BURST.hasSkinImages() ||
- (!GameImage.COMBO_BURST.hasSkinImage() && GameImage.COMBO_BURST.getImages() != null))
- comboBurstImages = GameImage.COMBO_BURST.getImages();
- else
- comboBurstImages = new Image[]{ GameImage.COMBO_BURST.getImage() };
-
- // scorebar-colour animation
- Image[] scorebar = GameImage.SCOREBAR_COLOUR.getImages();
- scorebarColour = (scorebar != null) ? new Animation(scorebar, 60) : null;
-
- // default symbol images
- defaultSymbols = new Image[10];
- defaultSymbols[0] = GameImage.DEFAULT_0.getImage();
- defaultSymbols[1] = GameImage.DEFAULT_1.getImage();
- defaultSymbols[2] = GameImage.DEFAULT_2.getImage();
- defaultSymbols[3] = GameImage.DEFAULT_3.getImage();
- defaultSymbols[4] = GameImage.DEFAULT_4.getImage();
- defaultSymbols[5] = GameImage.DEFAULT_5.getImage();
- defaultSymbols[6] = GameImage.DEFAULT_6.getImage();
- defaultSymbols[7] = GameImage.DEFAULT_7.getImage();
- defaultSymbols[8] = GameImage.DEFAULT_8.getImage();
- defaultSymbols[9] = GameImage.DEFAULT_9.getImage();
+ public void loadImages() {
+ // gameplay-specific images
+ if (isGameplay()) {
+ // combo burst images
+ if (GameImage.COMBO_BURST.hasSkinImages() ||
+ (!GameImage.COMBO_BURST.hasSkinImage() && GameImage.COMBO_BURST.getImages() != null))
+ comboBurstImages = GameImage.COMBO_BURST.getImages();
+ else
+ comboBurstImages = new Image[]{ GameImage.COMBO_BURST.getImage() };
+
+ // scorebar-colour animation
+ Image[] scorebar = GameImage.SCOREBAR_COLOUR.getImages();
+ scorebarColour = (scorebar != null) ? new Animation(scorebar, 60) : null;
+
+ // default symbol images
+ defaultSymbols = new Image[10];
+ defaultSymbols[0] = GameImage.DEFAULT_0.getImage();
+ defaultSymbols[1] = GameImage.DEFAULT_1.getImage();
+ defaultSymbols[2] = GameImage.DEFAULT_2.getImage();
+ defaultSymbols[3] = GameImage.DEFAULT_3.getImage();
+ defaultSymbols[4] = GameImage.DEFAULT_4.getImage();
+ defaultSymbols[5] = GameImage.DEFAULT_5.getImage();
+ defaultSymbols[6] = GameImage.DEFAULT_6.getImage();
+ defaultSymbols[7] = GameImage.DEFAULT_7.getImage();
+ defaultSymbols[8] = GameImage.DEFAULT_8.getImage();
+ defaultSymbols[9] = GameImage.DEFAULT_9.getImage();
+ }
// score symbol images
scoreSymbols = new HashMap(14);
@@ -471,12 +506,11 @@ public class GameData {
}
/**
- * Draws ranking elements: score, results, ranking.
+ * Draws ranking elements: score, results, ranking, game mods.
* @param g the graphics context
- * @param width the width of the container
- * @param height the height of the container
+ * @param osu the OsuFile
*/
- public void drawRankingElements(Graphics g, int width, int height) {
+ public void drawRankingElements(Graphics g, OsuFile osu) {
// grade
Grade grade = getGrade();
if (grade != Grade.NULL) {
@@ -492,6 +526,14 @@ public class GameData {
g.setColor(Utils.COLOR_BLACK_ALPHA);
g.fillRect(0, 0, width, rankingHeight);
rankingTitle.draw((width * 0.97f) - rankingTitle.getWidth(), 0);
+ float marginX = width * 0.01f, marginY = height * 0.01f;
+ Utils.FONT_LARGE.drawString(marginX, marginY,
+ String.format("%s - %s [%s]", osu.getArtist(), osu.getTitle(), osu.version), Color.white);
+ Utils.FONT_MEDIUM.drawString(marginX, marginY + Utils.FONT_LARGE.getLineHeight() - 6,
+ String.format("Beatmap by %s", osu.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);
// ranking panel
Image rankingPanel = GameImage.RANKING_PANEL.getImage();
@@ -546,12 +588,23 @@ public class GameData {
(int) (rankingPanelWidth / 2f + numbersX), (int) numbersY, symbolTextScale, false);
// full combo
- if (combo == fullObjectCount) {
+ if (comboMax == fullObjectCount) {
GameImage.RANKING_PERFECT.getImage().draw(
width * 0.08f,
(height * 0.99f) - GameImage.RANKING_PERFECT.getImage().getHeight()
);
}
+
+ // mod icons
+ int modWidth = GameMod.AUTO.getImage().getWidth();
+ float modX = (width * 0.98f) - modWidth;
+ int modCount = 0;
+ for (GameMod mod : GameMod.VALUES_REVERSED) {
+ if ((mod.getBit() & scoreData.mods) > 0) {
+ mod.getImage().draw(modX - (modCount * (modWidth / 2f)), height / 2f);
+ modCount++;
+ }
+ }
}
/**
@@ -616,30 +669,50 @@ public class GameData {
public void changeScore(int value) { score += value; }
/**
- * Returns score percentage (raw score only).
+ * Returns the raw score percentage.
+ * @param hit300 the number of 300s
+ * @param hit100 the number of 100s
+ * @param hit50 the number of 50s
+ * @param miss the number of misses
+ * @return the percentage
+ */
+ public static float getScorePercent(int hit300, int hit100, int hit50, int miss) {
+ float percent = 0;
+ int objectCount = hit300 + hit100 + hit50 + miss;
+ if (objectCount > 0)
+ percent = (hit300 * 300 + hit100 * 100 + hit50 * 50) / (objectCount * 300f) * 100f;
+ return percent;
+ }
+
+ /**
+ * Returns the raw score percentage.
*/
private float getScorePercent() {
- float percent = 0;
- if (objectCount > 0)
- percent = ((hitResultCount[HIT_50] * 50) + (hitResultCount[HIT_100] * 100)
- + (hitResultCount[HIT_300] * 300)) / (objectCount * 300f) * 100f;
- return percent;
+ return getScorePercent(
+ hitResultCount[HIT_300], hitResultCount[HIT_100],
+ hitResultCount[HIT_50], hitResultCount[HIT_MISS]
+ );
}
/**
* Returns letter grade based on score data,
* or Grade.NULL if no objects have been processed.
+ * @param hit300 the number of 300s
+ * @param hit100 the number of 100s
+ * @param hit50 the number of 50s
+ * @param miss the number of misses
* @return the current Grade
*/
- private Grade getGrade() {
+ public static Grade getGrade(int hit300, int hit100, int hit50, int miss) {
+ int objectCount = hit300 + hit100 + hit50 + miss;
if (objectCount < 1) // avoid division by zero
return Grade.NULL;
// TODO: silvers
- float percent = getScorePercent();
- float hit300ratio = hitResultCount[HIT_300] * 100f / objectCount;
- float hit50ratio = hitResultCount[HIT_50] * 100f / objectCount;
- boolean noMiss = (hitResultCount[HIT_MISS] == 0);
+ float percent = getScorePercent(hit300, hit100, hit50, miss);
+ float hit300ratio = hit300 * 100f / objectCount;
+ float hit50ratio = hit50 * 100f / objectCount;
+ boolean noMiss = (miss == 0);
if (percent >= 100f)
return Grade.SS;
else if (hit300ratio >= 90f && hit50ratio < 1.0f && noMiss)
@@ -654,6 +727,17 @@ public class GameData {
return Grade.D;
}
+ /**
+ * Returns letter grade based on score data,
+ * or Grade.NULL if no objects have been processed.
+ */
+ private Grade getGrade() {
+ return getGrade(
+ hitResultCount[HIT_300], hitResultCount[HIT_100],
+ hitResultCount[HIT_50], hitResultCount[HIT_MISS]
+ );
+ }
+
/**
* Updates the score, health, and combo burst displays based on a delta value.
* @param delta the delta interval since the last call
@@ -792,25 +876,21 @@ public class GameData {
perfectHit = true;
hitValue = 300;
changeHealth(5f);
- objectCount++;
break;
case HIT_100:
hitValue = 100;
changeHealth(2f);
comboEnd |= 1;
- objectCount++;
break;
case HIT_50:
hitValue = 50;
comboEnd |= 2;
- objectCount++;
break;
case HIT_MISS:
hitValue = 0;
changeHealth(-10f);
comboEnd |= 2;
resetComboStreak();
- objectCount++;
break;
default:
return;
@@ -864,4 +944,47 @@ public class GameData {
else
hitResultList.add(new OsuHitObjectResult(time, result, x, y, color));
}
+
+ /**
+ * 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
+ * @return the ScoreData object
+ */
+ public ScoreData getScoreData(OsuFile osu) {
+ 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.hit300 = hitResultCount[HIT_300];
+ scoreData.hit100 = hitResultCount[HIT_100];
+ scoreData.hit50 = hitResultCount[HIT_50];
+ scoreData.geki = hitResultCount[HIT_300G];
+ scoreData.katu = hitResultCount[HIT_300K] + hitResultCount[HIT_100K];
+ scoreData.miss = hitResultCount[HIT_MISS];
+ scoreData.score = score;
+ scoreData.combo = comboMax;
+ scoreData.perfect = (comboMax == fullObjectCount);
+ int mods = 0;
+ for (GameMod mod : GameMod.values()) {
+ if (mod.isActive())
+ mods |= mod.getBit();
+ }
+ scoreData.mods = mods;
+ return scoreData;
+ }
+
+ /**
+ * Returns whether or not this object is used for gameplay.
+ * @return true if gameplay, false if score viewing
+ */
+ public boolean isGameplay() { return gameplay; }
}
\ No newline at end of file
diff --git a/src/itdelatrisu/opsu/GameImage.java b/src/itdelatrisu/opsu/GameImage.java
index db0da82f..d56a732c 100644
--- a/src/itdelatrisu/opsu/GameImage.java
+++ b/src/itdelatrisu/opsu/GameImage.java
@@ -164,7 +164,7 @@ public enum GameImage {
SPINNER_CLEAR ("spinner-clear", "png"),
SPINNER_OSU ("spinner-osu", "png"),
- // Game Score
+ // Score Data
COMBO_BURST ("comboburst", "comboburst-%d", "png"),
SCOREBAR_BG ("scorebar-bg", "png") {
@Override
diff --git a/src/itdelatrisu/opsu/GameMod.java b/src/itdelatrisu/opsu/GameMod.java
index 4b5ff200..5944f56c 100644
--- a/src/itdelatrisu/opsu/GameMod.java
+++ b/src/itdelatrisu/opsu/GameMod.java
@@ -28,16 +28,16 @@ import org.newdawn.slick.Input;
* Game mods.
*/
public enum GameMod {
- EASY (0, GameImage.MOD_EASY, Input.KEY_Q, 0.5f),
- NO_FAIL (1, GameImage.MOD_NO_FAIL, Input.KEY_W, 0.5f),
- HARD_ROCK (2, GameImage.MOD_HARD_ROCK, Input.KEY_A, 1.06f),
- SUDDEN_DEATH (3, GameImage.MOD_SUDDEN_DEATH, Input.KEY_S),
- SPUN_OUT (4, GameImage.MOD_SPUN_OUT, Input.KEY_V, 0.9f),
- AUTO (5, GameImage.MOD_AUTO, Input.KEY_B);
-// HALF_TIME (, GameImage.MOD_HALF_TIME, Input.KEY_E, 0.3f),
-// DOUBLE_TIME (, GameImage.MOD_DOUBLE_TIME, Input.KEY_D, 1.12f),
-// HIDDEN (, GameImage.MOD_HIDDEN, Input.KEY_F, 1.06f),
-// FLASHLIGHT (, GameImage.MOD_FLASHLIGHT, Input.KEY_G, 1.12f);
+ EASY (0, GameImage.MOD_EASY, "EZ", 2, Input.KEY_Q, 0.5f),
+ NO_FAIL (1, GameImage.MOD_NO_FAIL, "NF", 1, Input.KEY_W, 0.5f),
+ HARD_ROCK (2, GameImage.MOD_HARD_ROCK, "HR", 16, Input.KEY_A, 1.06f),
+ SUDDEN_DEATH (3, GameImage.MOD_SUDDEN_DEATH, "SD", 32, Input.KEY_S),
+ SPUN_OUT (4, GameImage.MOD_SPUN_OUT, "SO", 4096, Input.KEY_V, 0.9f),
+ AUTO (5, GameImage.MOD_AUTO, "", 2048, Input.KEY_B);
+// HALF_TIME (, GameImage.MOD_HALF_TIME, "HT", 256, Input.KEY_E, 0.3f),
+// DOUBLE_TIME (, GameImage.MOD_DOUBLE_TIME, "DT", 64, Input.KEY_D, 1.12f),
+// HIDDEN (, GameImage.MOD_HIDDEN, "HD", 8, Input.KEY_F, 1.06f),
+// FLASHLIGHT (, GameImage.MOD_FLASHLIGHT, "FL", 1024, Input.KEY_G, 1.12f);
/** The ID of the mod (used for positioning). */
private int id;
@@ -45,7 +45,16 @@ public enum GameMod {
/** The file name of the mod image. */
private GameImage image;
- /** The shortcut key associated with the mod. */
+ /** The abbreviation for the mod. */
+ private String abbrev;
+
+ /** Bit value associated with the mod. */
+ private int bit;
+
+ /**
+ * The shortcut key associated with the mod.
+ * See the osu! API: https://github.com/peppy/osu-api/wiki#mods
+ */
private int key;
/** The score multiplier. */
@@ -71,11 +80,15 @@ public enum GameMod {
* Constructor.
* @param id the ID of the mod (for positioning).
* @param image the GameImage
+ * @param abbrev the two-letter abbreviation
+ * @param bit the bit
* @param key the shortcut key
*/
- GameMod(int id, GameImage image, int key) {
+ GameMod(int id, GameImage image, String abbrev, int bit, int key) {
this.id = id;
this.image = image;
+ this.abbrev = abbrev;
+ this.bit = bit;
this.key = key;
this.multiplier = 1f;
}
@@ -84,12 +97,16 @@ public enum GameMod {
* Constructor.
* @param id the ID of the mod (for positioning).
* @param image the GameImage
+ * @param abbrev the two-letter abbreviation
+ * @param bit the bit
* @param key the shortcut key
* @param multiplier the score multiplier
*/
- GameMod(int id, GameImage image, int key, float multiplier) {
+ GameMod(int id, GameImage image, String abbrev, int bit, int key, float multiplier) {
this.id = id;
this.image = image;
+ this.abbrev = abbrev;
+ this.bit = bit;
this.key = key;
this.multiplier = multiplier;
}
@@ -113,6 +130,18 @@ public enum GameMod {
this.button.setHoverScale(1.15f);
}
+ /**
+ * Returns the abbreviated name of the mod.
+ * @return the two-letter abbreviation
+ */
+ public String getAbbreviation() { return abbrev; }
+
+ /**
+ * Returns the bit associated with the mod.
+ * @return the bit
+ */
+ public int getBit() { return bit; }
+
/**
* Returns the shortcut key for the mod.
* @return the key
@@ -173,12 +202,6 @@ public enum GameMod {
*/
public Image getImage() { return image.getImage(); }
- /**
- * Returns the mod ID.
- * @return the mod ID
- */
- public int getID() { return id; }
-
/**
* Draws the game mod.
*/
diff --git a/src/itdelatrisu/opsu/Opsu.java b/src/itdelatrisu/opsu/Opsu.java
index dff2ce3b..c040a9a0 100644
--- a/src/itdelatrisu/opsu/Opsu.java
+++ b/src/itdelatrisu/opsu/Opsu.java
@@ -137,6 +137,9 @@ public class Opsu extends StateBasedGame {
}
Options.TMP_DIR.deleteOnExit();
+ // initialize score database
+ Scores.init();
+
// start the game
try {
// loop until force exit
@@ -161,8 +164,7 @@ public class Opsu extends StateBasedGame {
ErrorHandler.error("Error while creating game container.", e, true);
}
- // close server socket
- closeSocket();
+ Opsu.exit();
}
@Override
@@ -188,9 +190,13 @@ public class Opsu extends StateBasedGame {
}
/**
- * Closes the server socket.
+ * Closes all resources and exits the application.
*/
- public static void closeSocket() {
+ public static void exit() {
+ // close scores database
+ Scores.closeConnection();
+
+ // close server socket
if (SERVER_SOCKET != null) {
try {
SERVER_SOCKET.close();
@@ -198,5 +204,7 @@ public class Opsu extends StateBasedGame {
ErrorHandler.error("Failed to close server socket.", e, false);
}
}
+
+ System.exit(0);
}
}
diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java
index 2793a0d3..04c69bd6 100644
--- a/src/itdelatrisu/opsu/Options.java
+++ b/src/itdelatrisu/opsu/Options.java
@@ -58,6 +58,9 @@ public class Options {
"Songs/"
};
+ /** Score database name. */
+ public static final String SCORE_DB = ".opsu_scores.db";
+
/** Font file name. */
public static final String FONT_NAME = "kochi-gothic.ttf";
diff --git a/src/itdelatrisu/opsu/OsuGroupNode.java b/src/itdelatrisu/opsu/OsuGroupNode.java
index fbac9747..7a368dfb 100644
--- a/src/itdelatrisu/opsu/OsuGroupNode.java
+++ b/src/itdelatrisu/opsu/OsuGroupNode.java
@@ -18,6 +18,8 @@
package itdelatrisu.opsu;
+import itdelatrisu.opsu.GameData.Grade;
+
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
@@ -52,9 +54,10 @@ public class OsuGroupNode {
* 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, boolean focus) {
+ public void draw(float x, float y, Grade grade, boolean focus) {
boolean expanded = (osuFileIndex > -1);
float xOffset = 0f;
OsuFile osu;
@@ -77,6 +80,13 @@ public class OsuGroupNode {
float cx = x + (bg.getWidth() * 0.05f) - xOffset;
float cy = y + (bg.getHeight() * 0.2f) - 3;
+ if (grade != Grade.NULL) {
+ Image gradeImg = grade.getSmallImage();
+ gradeImg = gradeImg.getScaledCopy((bg.getHeight() * 0.45f) / gradeImg.getHeight());
+ gradeImg.drawCentered(cx - bg.getWidth() * 0.01f + gradeImg.getWidth() / 2f, y + bg.getHeight() / 2.2f);
+ cx += gradeImg.getWidth();
+ }
+
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);
diff --git a/src/itdelatrisu/opsu/Scores.java b/src/itdelatrisu/opsu/Scores.java
new file mode 100644
index 00000000..5e41c814
--- /dev/null
+++ b/src/itdelatrisu/opsu/Scores.java
@@ -0,0 +1,338 @@
+/*
+ * 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.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Handles game score data.
+ */
+public class Scores {
+ /** Class encapsulating all score data. */
+ public static class ScoreData implements Comparable {
+ /** The time when the score was achieved (Unix time). */
+ public long timestamp;
+
+ /** The beatmap and beatmap set IDs. */
+ public int MID, MSID;
+
+ /** Beatmap metadata. */
+ public String title, artist, creator, version;
+
+ /** Hit counts. */
+ public int hit300, hit100, hit50, geki, katu, miss;
+
+ /** The score. */
+ public long score;
+
+ /** The max combo. */
+ public int combo;
+
+ /** Whether or not a full combo was achieved. */
+ public boolean perfect;
+
+ /** Game mod bitmask. */
+ public int mods;
+
+ /**
+ * Empty constructor.
+ */
+ public ScoreData() {}
+
+ /**
+ * Constructor.
+ * @param rs the ResultSet to read from (at the current cursor position)
+ * @throws SQLException
+ */
+ public ScoreData(ResultSet rs) throws SQLException {
+ this.timestamp = rs.getLong(1);
+ this.MID = rs.getInt(2);
+ this.MSID = rs.getInt(3);
+ this.title = rs.getString(4);
+ this.artist = rs.getString(5);
+ this.creator = rs.getString(6);
+ this.version = rs.getString(7);
+ this.hit300 = rs.getInt(8);
+ this.hit100 = rs.getInt(9);
+ this.hit50 = rs.getInt(10);
+ this.geki = rs.getInt(11);
+ this.katu = rs.getInt(12);
+ this.miss = rs.getInt(13);
+ this.score = rs.getLong(14);
+ this.combo = rs.getInt(15);
+ this.perfect = rs.getBoolean(16);
+ this.mods = rs.getInt(17);
+ }
+
+ /**
+ * Returns the timestamp as a string.
+ */
+ public String getTimeString() {
+ return new SimpleDateFormat("M/d/yyyy h:mm:ss a").format(new Date(timestamp * 1000L));
+ }
+
+ /**
+ * Returns letter grade based on score data,
+ * or Grade.NULL if no objects have been processed.
+ * @see GameData#getGrade(int, int, int, int)
+ */
+ public Grade getGrade() {
+ return GameData.getGrade(hit300, hit100, hit50, miss);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "%s | ID: (%d, %d) | %s - %s [%s] (by %s) | " +
+ "Hits: (%d, %d, %d, %d, %d, %d) | Score: %d (%d combo%s) | Mods: %d",
+ getTimeString(), MID, MSID, artist, title, version, creator,
+ hit300, hit100, hit50, geki, katu, miss, score, combo, perfect ? ", FC" : "", mods
+ );
+ }
+
+ @Override
+ public int compareTo(ScoreData that) {
+ if (this.score != that.score)
+ return Long.compare(this.score, that.score);
+ else
+ return Long.compare(this.timestamp, that.timestamp);
+ }
+ }
+
+ /** Database connection. */
+ private static Connection connection;
+
+ /** Score insertion statement. */
+ private static PreparedStatement insertStmt;
+
+ /** Score select statement. */
+ private static PreparedStatement selectMapStmt, selectMapSetStmt;
+
+ // This class should not be instantiated.
+ private Scores() {}
+
+ /**
+ * Initializes the score database connection.
+ */
+ public static void init() {
+ // load the sqlite-JDBC driver using the current class loader
+ try {
+ Class.forName("org.sqlite.JDBC");
+ } catch (ClassNotFoundException e) {
+ ErrorHandler.error("Could not load sqlite-JDBC driver.", e, true);
+ }
+
+ // create a database connection
+ try {
+ connection = DriverManager.getConnection(String.format("jdbc:sqlite:%s", Options.SCORE_DB));
+ } catch (SQLException e) {
+ // if the error message is "out of memory", it probably means no database file is found
+ ErrorHandler.error("Could not connect to score database.", e, true);
+ }
+
+ // create the database
+ createDatabase();
+
+ // prepare sql statements
+ try {
+ insertStmt = connection.prepareStatement(
+ "INSERT INTO scores VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ );
+ selectMapStmt = connection.prepareStatement(
+ "SELECT * FROM scores WHERE " +
+ "MID = ? AND title = ? AND artist = ? AND creator = ? AND version = ?"
+ );
+ selectMapSetStmt = connection.prepareStatement(
+ "SELECT * FROM scores WHERE " +
+ "MSID = ? AND title = ? AND artist = ? AND creator = ? ORDER BY version DESC"
+ );
+ } catch (SQLException e) {
+ ErrorHandler.error("Failed to prepare score insertion statement.", e, true);
+ }
+ }
+
+ /**
+ * Creates the score database, if it does not exist.
+ */
+ private static void createDatabase() {
+ try (Statement stmt = connection.createStatement()) {
+ String sql =
+ "CREATE TABLE IF NOT EXISTS scores (" +
+ "timestamp INTEGER PRIMARY KEY, " +
+ "MID INTEGER, MSID INTEGER, " +
+ "title TEXT, artist TEXT, creator TEXT, version TEXT, " +
+ "hit300 INTEGER, hit100 INTEGER, hit50 INTEGER, " +
+ "geki INTEGER, katu INTEGER, miss INTEGER, " +
+ "score INTEGER, " +
+ "combo INTEGER, " +
+ "perfect BOOLEAN, " +
+ "mods INTEGER" +
+ ")";
+ stmt.executeUpdate(sql);
+ } catch (SQLException e) {
+ ErrorHandler.error("Could not create score database.", e, true);
+ }
+ }
+
+ /**
+ * Adds the game score to the database.
+ * @param data the GameData object
+ */
+ public static void addScore(ScoreData score) {
+ try {
+ insertStmt.setLong(1, score.timestamp);
+ insertStmt.setInt(2, score.MID);
+ insertStmt.setInt(3, score.MSID);
+ insertStmt.setString(4, score.title);
+ insertStmt.setString(5, score.artist);
+ insertStmt.setString(6, score.creator);
+ insertStmt.setString(7, score.version);
+ insertStmt.setInt(8, score.hit300);
+ insertStmt.setInt(9, score.hit100);
+ insertStmt.setInt(10, score.hit50);
+ insertStmt.setInt(11, score.geki);
+ insertStmt.setInt(12, score.katu);
+ insertStmt.setInt(13, score.miss);
+ insertStmt.setLong(14, score.score);
+ insertStmt.setInt(15, score.combo);
+ insertStmt.setBoolean(16, score.perfect);
+ insertStmt.setInt(17, score.mods);
+ insertStmt.executeUpdate();
+ } catch (SQLException e) {
+ ErrorHandler.error("Failed to save score to database.", e, true);
+ }
+ }
+
+ /**
+ * Retrieves the game scores for an OsuFile map.
+ * @param osu the OsuFile
+ * @return all scores for the beatmap
+ */
+ public static ScoreData[] getMapScores(OsuFile osu) {
+ 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);
+ ResultSet rs = selectMapStmt.executeQuery();
+ while (rs.next()) {
+ ScoreData s = new ScoreData(rs);
+ list.add(s);
+ }
+ rs.close();
+ } catch (SQLException e) {
+ ErrorHandler.error("Failed to read scores from database.", e, true);
+ return null;
+ }
+ return getSortedArray(list);
+ }
+
+ /**
+ * Retrieves the game scores for an OsuFile map set.
+ * @param osu the OsuFile
+ * @return all scores for the beatmap set (Version, ScoreData[])
+ */
+ public static Map getMapSetScores(OsuFile osu) {
+ 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);
+ ResultSet rs = selectMapSetStmt.executeQuery();
+
+ List list = null;
+ String version = ""; // sorted by version, so pass through and check for differences
+ while (rs.next()) {
+ ScoreData s = new ScoreData(rs);
+ if (!s.version.equals(version)) {
+ if (list != null)
+ map.put(version, getSortedArray(list));
+ version = s.version;
+ list = new ArrayList();
+ }
+ list.add(s);
+ }
+ if (list != null)
+ map.put(version, getSortedArray(list));
+ rs.close();
+ } catch (SQLException e) {
+ ErrorHandler.error("Failed to read scores from database.", e, true);
+ return null;
+ }
+ return map;
+ }
+
+ /**
+ * Returns a sorted ScoreData array (in reverse order) from a List.
+ */
+ private static ScoreData[] getSortedArray(List list) {
+ ScoreData[] scores = list.toArray(new ScoreData[list.size()]);
+ Arrays.sort(scores, Collections.reverseOrder());
+ return scores;
+ }
+
+ /**
+ * Closes the connection to the score database.
+ */
+ public static void closeConnection() {
+ if (connection != null) {
+ try {
+ insertStmt.close();
+ selectMapStmt.close();
+ selectMapSetStmt.close();
+ connection.close();
+ } catch (SQLException e) {
+ ErrorHandler.error("Failed to close score database.", e, true);
+ }
+ }
+ }
+
+ /**
+ * Prints the entire database (for debugging purposes).
+ */
+ protected static void printDatabase() {
+ try (
+ Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT * FROM scores ORDER BY timestamp ASC");
+ ) {
+ while (rs.next())
+ System.out.println(new ScoreData(rs));
+ } catch (SQLException e) {
+ ErrorHandler.error(null, e, false);
+ }
+ }
+}
diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java
index 6011946c..b96a4327 100644
--- a/src/itdelatrisu/opsu/states/Game.java
+++ b/src/itdelatrisu/opsu/states/Game.java
@@ -28,6 +28,7 @@ import itdelatrisu.opsu.Options;
import itdelatrisu.opsu.OsuFile;
import itdelatrisu.opsu.OsuHitObject;
import itdelatrisu.opsu.OsuTimingPoint;
+import itdelatrisu.opsu.Scores;
import itdelatrisu.opsu.Utils;
import itdelatrisu.opsu.audio.HitSound;
import itdelatrisu.opsu.audio.MusicController;
@@ -160,7 +161,6 @@ public class Game extends BasicGameState {
// create the associated GameData object
data = new GameData(width, height);
- ((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data);
}
@Override
@@ -419,8 +419,12 @@ public class Game extends BasicGameState {
if (objectIndex >= osu.objects.length) {
if (checkpointLoaded) // if checkpoint used, skip ranking screen
game.closeRequested();
- else // go to ranking screen
+ else { // go to ranking screen
+ ((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data);
+ if (!GameMod.AUTO.isActive())
+ Scores.addScore(data.getScoreData(osu));
game.enterState(Opsu.STATE_GAMERANKING, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
+ }
return;
}
@@ -800,7 +804,7 @@ public class Game extends BasicGameState {
// load other images...
((GamePauseMenu) game.getState(Opsu.STATE_GAMEPAUSEMENU)).loadImages();
- data.loadImages(osu.getFile().getParentFile());
+ data.loadImages();
}
/**
diff --git a/src/itdelatrisu/opsu/states/GameRanking.java b/src/itdelatrisu/opsu/states/GameRanking.java
index 752949b0..2414c14b 100644
--- a/src/itdelatrisu/opsu/states/GameRanking.java
+++ b/src/itdelatrisu/opsu/states/GameRanking.java
@@ -18,9 +18,8 @@
package itdelatrisu.opsu.states;
-import itdelatrisu.opsu.GameImage;
-import itdelatrisu.opsu.GameMod;
import itdelatrisu.opsu.GameData;
+import itdelatrisu.opsu.GameImage;
import itdelatrisu.opsu.MenuButton;
import itdelatrisu.opsu.Opsu;
import itdelatrisu.opsu.OsuFile;
@@ -100,29 +99,13 @@ public class GameRanking extends BasicGameState {
g.setBackground(Utils.COLOR_BLACK_ALPHA);
// ranking screen elements
- data.drawRankingElements(g, width, height);
-
- // game mods
- for (GameMod mod : GameMod.VALUES_REVERSED) {
- if (mod.isActive()) {
- Image modImage = mod.getImage();
- modImage.draw(
- (width * 0.75f) + ((mod.getID() - (GameMod.SIZE / 2)) * modImage.getWidth() / 3f),
- height / 2f
- );
- }
- }
-
- // header text
- float marginX = width * 0.01f, marginY = height * 0.01f;
- Utils.FONT_LARGE.drawString(marginX, marginY,
- String.format("%s - %s [%s]", osu.getArtist(), osu.getTitle(), osu.version), Color.white);
- Utils.FONT_MEDIUM.drawString(marginX, marginY + Utils.FONT_LARGE.getLineHeight() - 6,
- String.format("Beatmap by %s", osu.creator), Color.white);
+ data.drawRankingElements(g, osu);
// buttons
- retryButton.draw();
- exitButton.draw();
+ if (data.isGameplay()) {
+ retryButton.draw();
+ exitButton.draw();
+ }
Utils.getBackButton().draw();
Utils.drawVolume(g);
@@ -146,12 +129,7 @@ public class GameRanking extends BasicGameState {
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
- SoundController.playSound(SoundEffect.MENUBACK);
- SongMenu songMenu = (SongMenu) game.getState(Opsu.STATE_SONGMENU);
- songMenu.resetGameDataOnLoad();
- songMenu.resetTrackOnLoad();
- Utils.resetCursor();
- game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
+ returnToSongMenu();
break;
case Input.KEY_F12:
Utils.takeScreenShot();
@@ -165,25 +143,26 @@ public class GameRanking extends BasicGameState {
if (button != Input.MOUSE_LEFT_BUTTON)
return;
- if (retryButton.contains(x, y)) {
- OsuFile osu = MusicController.getOsuFile();
- Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString()));
- ((Game) game.getState(Opsu.STATE_GAME)).setRestart(Game.Restart.MANUAL);
- SoundController.playSound(SoundEffect.MENUHIT);
- game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
- } else if (exitButton.contains(x, y)) {
- SoundController.playSound(SoundEffect.MENUBACK);
- ((MainMenu) game.getState(Opsu.STATE_MAINMENU)).reset();
- ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).resetGameDataOnLoad();
- Utils.resetCursor();
- game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
- } else if (Utils.getBackButton().contains(x, y)) {
- SoundController.playSound(SoundEffect.MENUBACK);
- SongMenu songMenu = (SongMenu) game.getState(Opsu.STATE_SONGMENU);
- songMenu.resetGameDataOnLoad();
- songMenu.resetTrackOnLoad();
- Utils.resetCursor();
- game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
+ if (data.isGameplay()) {
+ if (retryButton.contains(x, y)) {
+ OsuFile osu = MusicController.getOsuFile();
+ Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString()));
+ ((Game) game.getState(Opsu.STATE_GAME)).setRestart(Game.Restart.MANUAL);
+ SoundController.playSound(SoundEffect.MENUHIT);
+ game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
+ return;
+ } else if (exitButton.contains(x, y)) {
+ SoundController.playSound(SoundEffect.MENUBACK);
+ ((MainMenu) game.getState(Opsu.STATE_MAINMENU)).reset();
+ ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).resetGameDataOnLoad();
+ Utils.resetCursor();
+ game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
+ return;
+ }
+ }
+ if (Utils.getBackButton().contains(x, y)) {
+ returnToSongMenu();
+ return;
}
}
@@ -195,6 +174,26 @@ public class GameRanking extends BasicGameState {
SoundController.playSound(SoundEffect.APPLAUSE);
}
+ @Override
+ public void leave(GameContainer container, StateBasedGame game)
+ throws SlickException {
+ this.data = null;
+ }
+
+ /**
+ * Returns to the song menu.
+ */
+ private void returnToSongMenu() {
+ SoundController.playSound(SoundEffect.MENUBACK);
+ if (data.isGameplay()) {
+ SongMenu songMenu = (SongMenu) game.getState(Opsu.STATE_SONGMENU);
+ songMenu.resetGameDataOnLoad();
+ songMenu.resetTrackOnLoad();
+ }
+ Utils.resetCursor();
+ game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
+ }
+
/**
* Sets the associated GameData object.
* @param data the GameData
diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java
index 621c6645..1afb5803 100644
--- a/src/itdelatrisu/opsu/states/SongMenu.java
+++ b/src/itdelatrisu/opsu/states/SongMenu.java
@@ -28,6 +28,9 @@ import itdelatrisu.opsu.OsuGroupList;
import itdelatrisu.opsu.OsuGroupNode;
import itdelatrisu.opsu.OsuParser;
import itdelatrisu.opsu.OszUnpacker;
+import itdelatrisu.opsu.Scores;
+import itdelatrisu.opsu.GameData.Grade;
+import itdelatrisu.opsu.Scores.ScoreData;
import itdelatrisu.opsu.SongSort;
import itdelatrisu.opsu.Utils;
import itdelatrisu.opsu.audio.HitSound;
@@ -36,6 +39,7 @@ import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
import java.io.File;
+import java.util.Map;
import java.util.Stack;
import org.lwjgl.opengl.Display;
@@ -153,6 +157,9 @@ public class SongMenu extends BasicGameState {
/** Beatmap reloading thread. */
private Thread reloadThread;
+ /** Current map of scores (Version, ScoreData[]). */
+ private Map scoreMap;
+
// game-related variables
private GameContainer container;
private StateBasedGame game;
@@ -261,8 +268,16 @@ public class SongMenu extends BasicGameState {
// song buttons
OsuGroupNode node = startNode;
for (int i = 0; i < MAX_BUTTONS && node != null; i++, node = node.next) {
+ // draw the node
float offset = (i == hoverIndex) ? hoverOffset : 0f;
- node.draw(buttonX - offset, buttonY + (i*buttonOffset), (node == focusNode));
+ ScoreData[] scores = getScoreDataForNode(node);
+ node.draw(
+ buttonX - offset, buttonY + (i*buttonOffset),
+ (scores == null) ? Grade.NULL : scores[0].getGrade(),
+ (node == focusNode)
+ );
+
+ // load glyphs
Utils.loadGlyphs(node.osuFiles.get(0));
}
@@ -703,11 +718,17 @@ public class SongMenu extends BasicGameState {
// reset game data
if (resetGame) {
((Game) game.getState(Opsu.STATE_GAME)).resetGameData();
+
// destroy skin images, if any
for (GameImage img : GameImage.values()) {
if (img.isSkinnable())
img.destroySkinImage();
}
+
+ // reload scores
+ if (focusNode != null)
+ scoreMap = Scores.getMapSetScores(focusNode.osuFiles.get(focusNode.osuFileIndex));
+
resetGame = false;
}
}
@@ -798,6 +819,9 @@ public class SongMenu extends BasicGameState {
MusicController.play(osu, true);
Utils.loadGlyphs(osu);
+ // load scores
+ scoreMap = Scores.getMapSetScores(osu);
+
// check startNode bounds
while (startNode.index >= OsuGroupList.get().size() + length - MAX_BUTTONS && startNode.prev != null)
startNode = startNode.prev;
@@ -835,6 +859,30 @@ public class SongMenu extends BasicGameState {
*/
public void resetTrackOnLoad() { resetTrack = true; }
+ /**
+ * Returns all the score data for an OsuGroupNode from scoreMap.
+ * If no score data is available for the node, return null.
+ * @param node the OsuGroupNode
+ * @return the ScoreData array
+ */
+ private ScoreData[] getScoreDataForNode(OsuGroupNode node) {
+ if (scoreMap == null || node.osuFileIndex == -1) // node not expanded
+ return null;
+
+ OsuFile osu = node.osuFiles.get(node.osuFileIndex);
+ ScoreData[] scores = scoreMap.get(osu.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))
+ return scores;
+ else
+ return null; // incorrect map
+ }
+
/**
* Starts the game.
* @param osu the OsuFile to send to the game