From 712cf30e012c7c7ec336f8bf6e15e6f847825d24 Mon Sep 17 00:00:00 2001 From: Jeffrey Han Date: Wed, 11 Mar 2015 20:52:51 -0400 Subject: [PATCH] Added replay saving/loading and score database auto-updater. - Added 'info' table to score database to store the database version. Upon startup, if the stored version is less than the source version, all update queries defined in ScoreDB.getUpdateQueries() will be run. - Created "Replays" directory to store replay files. Replay files are created after gameplay. - Added 'replay' column to the score database to hold replay file names. - Created a Game.loadOsuFile() method to load game data. Signed-off-by: Jeffrey Han --- .gitignore | 1 + src/itdelatrisu/opsu/GameData.java | 12 +-- src/itdelatrisu/opsu/Options.java | 21 +++++ src/itdelatrisu/opsu/ScoreData.java | 7 +- src/itdelatrisu/opsu/db/ScoreDB.java | 92 +++++++++++++++++++- src/itdelatrisu/opsu/replay/Replay.java | 29 +++++- src/itdelatrisu/opsu/states/Game.java | 25 ++++-- src/itdelatrisu/opsu/states/GameRanking.java | 16 ++-- src/itdelatrisu/opsu/states/SongMenu.java | 12 +-- 9 files changed, 180 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 1668a52a..172616ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/Replays/ /Screenshots/ /Skins/ /SongPacks/ diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java index 2d9ba80e..4bb14094 100644 --- a/src/itdelatrisu/opsu/GameData.java +++ b/src/itdelatrisu/opsu/GameData.java @@ -26,6 +26,7 @@ import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.replay.ReplayFrame; +import java.io.File; import java.util.Date; import java.util.HashMap; import java.util.Iterator; @@ -325,7 +326,8 @@ public class GameData { hitResultCount[HIT_300K] = 0; hitResultCount[HIT_100K] = s.katu; hitResultCount[HIT_MISS] = s.miss; - this.replay = s.replay; + this.replay = (s.replayString == null) ? null : + new Replay(new File(Options.getReplayDir(), String.format("%s.osr", s.replayString))); loadImages(); } @@ -1197,10 +1199,9 @@ public class GameData { * If score data already exists, the existing object will be returned * (i.e. this will not overwrite existing data). * @param osu the OsuFile - * @param frames the replay frames * @return the ScoreData object */ - public ScoreData getScoreData(OsuFile osu, ReplayFrame[] frames) { + public ScoreData getScoreData(OsuFile osu) { if (scoreData != null) return scoreData; @@ -1222,7 +1223,7 @@ public class GameData { scoreData.combo = comboMax; scoreData.perfect = (comboMax == fullObjectCount); scoreData.mods = GameMod.getModState(); - scoreData.replay = getReplay(frames); + scoreData.replayString = (replay == null) ? null : replay.getReplayFilename(); return scoreData; } @@ -1245,7 +1246,7 @@ public class GameData { replay.version = Updater.get().getBuildDate(); replay.beatmapHash = ""; // TODO replay.playerName = ""; // TODO - replay.replayHash = ""; // TODO + replay.replayHash = Long.toString(System.currentTimeMillis()); // TODO replay.hit300 = (short) hitResultCount[HIT_300]; replay.hit100 = (short) hitResultCount[HIT_100]; replay.hit50 = (short) hitResultCount[HIT_50]; @@ -1261,6 +1262,7 @@ public class GameData { replay.frames = frames; replay.seed = 0; // TODO replay.loaded = true; + return replay; } diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java index f43e1365..b4ef7add 100644 --- a/src/itdelatrisu/opsu/Options.java +++ b/src/itdelatrisu/opsu/Options.java @@ -90,6 +90,9 @@ public class Options { /** The screenshot directory (created when needed). */ private static File screenshotDir; + /** The replay directory (created when needed). */ + private static File replayDir; + /** The current skin directory (for user skins). */ private static File skinDir; @@ -819,6 +822,19 @@ public class Options { return screenshotDir; } + /** + * Returns the replay directory. + * If invalid, this will return a "Replay" directory. + * @return the replay directory + */ + public static File getReplayDir() { + if (replayDir != null && replayDir.isDirectory()) + return replayDir; + + replayDir = new File(DATA_DIR, "Replays/"); + return replayDir; + } + /** * Returns the current skin directory. * If invalid, this will create a "Skins" folder in the root directory. @@ -892,6 +908,9 @@ public class Options { case "ScreenshotDirectory": screenshotDir = new File(value); break; + case "ReplayDirectory": + replayDir = new File(value); + break; case "Skin": skinDir = new File(value); break; @@ -1054,6 +1073,8 @@ public class Options { 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)); diff --git a/src/itdelatrisu/opsu/ScoreData.java b/src/itdelatrisu/opsu/ScoreData.java index 9152429e..970fa3fe 100644 --- a/src/itdelatrisu/opsu/ScoreData.java +++ b/src/itdelatrisu/opsu/ScoreData.java @@ -19,7 +19,6 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.GameData.Grade; -import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.states.SongMenu; import java.sql.ResultSet; @@ -60,8 +59,8 @@ public class ScoreData implements Comparable { /** Game mod bitmask. */ public int mods; - /** The replay. */ - public Replay replay; + /** The replay string. */ + public String replayString; /** Time since the score was achieved. */ private String timeSince; @@ -157,7 +156,7 @@ public class ScoreData implements Comparable { this.combo = rs.getInt(15); this.perfect = rs.getBoolean(16); this.mods = rs.getInt(17); -// this.replay = ; // TODO + this.replayString = rs.getString(18); } /** diff --git a/src/itdelatrisu/opsu/db/ScoreDB.java b/src/itdelatrisu/opsu/db/ScoreDB.java index 25976bb9..7744992d 100644 --- a/src/itdelatrisu/opsu/db/ScoreDB.java +++ b/src/itdelatrisu/opsu/db/ScoreDB.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -39,6 +40,29 @@ import java.util.Map; * Handles connections and queries with the scores database. */ public class ScoreDB { + /** + * Current database version. + * This value should be changed whenever the database format changes. + * Add any update queries to the {@link #getUpdateQueries(int)} method. + */ + private static final int DATABASE_VERSION = 20140311; + + /** + * Returns a list of SQL queries to apply, in order, to update from + * the given database version to the latest version. + * @param version the current version + * @return a list of SQL queries + */ + private static List getUpdateQueries(int version) { + List list = new LinkedList(); + if (version < 20140311) + list.add("ALTER TABLE scores ADD COLUMN replay TEXT"); + + /* add future updates here */ + + return list; + } + /** Database connection. */ private static Connection connection; @@ -63,13 +87,16 @@ public class ScoreDB { if (connection == null) return; + // run any database updates + updateDatabase(); + // create the database createDatabase(); // prepare sql statements try { insertStmt = connection.prepareStatement( - "INSERT INTO scores VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO scores VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); selectMapStmt = connection.prepareStatement( "SELECT * FROM scores WHERE " + @@ -109,15 +136,75 @@ public class ScoreDB { "score INTEGER, " + "combo INTEGER, " + "perfect BOOLEAN, " + - "mods INTEGER" + + "mods INTEGER, " + + "replay TEXT" + ");" + + "CREATE TABLE IF NOT EXISTS info (" + + "key TEXT NOT NULL UNIQUE, value TEXT" + + "); " + "CREATE INDEX IF NOT EXISTS idx ON scores (MID, MSID, title, artist, creator, version);"; stmt.executeUpdate(sql); + + // set the version key, if empty + sql = String.format("INSERT OR IGNORE INTO info(key, value) VALUES('version', %d)", DATABASE_VERSION); + stmt.executeUpdate(sql); } catch (SQLException e) { ErrorHandler.error("Could not create score database.", e, true); } } + /** + * Applies any database updates by comparing the current version to the + * stored version. Does nothing if tables have not been created. + */ + private static void updateDatabase() { + try (Statement stmt = connection.createStatement()) { + int version = 0; + + // if 'info' table does not exist, assume version 0 and apply all updates + String sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'info'"; + ResultSet rs = stmt.executeQuery(sql); + boolean infoExists = rs.isBeforeFirst(); + rs.close(); + if (!infoExists) { + // if 'scores' table also does not exist, databases not yet created + sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'scores'"; + ResultSet scoresRS = stmt.executeQuery(sql); + boolean scoresExists = scoresRS.isBeforeFirst(); + scoresRS.close(); + if (!scoresExists) + return; + } else { + // try to retrieve stored version + sql = "SELECT value FROM info WHERE key = 'version'"; + ResultSet versionRS = stmt.executeQuery(sql); + String versionString = (versionRS.next()) ? versionRS.getString(1) : "0"; + versionRS.close(); + try { + version = Integer.parseInt(versionString); + } catch (NumberFormatException e) {} + } + + // database versions match + if (version >= DATABASE_VERSION) + return; + + // apply updates + for (String query : getUpdateQueries(version)) + stmt.executeUpdate(query); + + // update version + if (infoExists) { + PreparedStatement ps = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('version', ?)"); + ps.setString(1, Integer.toString(DATABASE_VERSION)); + ps.executeUpdate(); + ps.close(); + } + } catch (SQLException e) { + ErrorHandler.error("Failed to update score database.", e, true); + } + } + /** * Adds the game score to the database. * @param data the GameData object @@ -128,6 +215,7 @@ public class ScoreDB { try { setStatementFields(insertStmt, data); + insertStmt.setString(18, data.replayString); insertStmt.executeUpdate(); } catch (SQLException e) { ErrorHandler.error("Failed to save score to database.", e, true); diff --git a/src/itdelatrisu/opsu/replay/Replay.java b/src/itdelatrisu/opsu/replay/Replay.java index 73d41a73..98273025 100644 --- a/src/itdelatrisu/opsu/replay/Replay.java +++ b/src/itdelatrisu/opsu/replay/Replay.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu.replay; import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.OsuReader; import itdelatrisu.opsu.OsuWriter; import itdelatrisu.opsu.Utils; @@ -217,10 +218,20 @@ public class Replay { } /** - * Saves the replay data to a file. - * @param file the file to write to + * Saves the replay data to a file in the replays directory. */ - public void save(File file) { + public void save() { + // create replay directory + File dir = Options.getReplayDir(); + if (!dir.isDirectory()) { + if (!dir.mkdir()) { + ErrorHandler.error("Failed to create replay directory.", null, false); + return; + } + } + + // write file + File file = new File(dir, String.format("%s.osr", getReplayFilename())); try (FileOutputStream out = new FileOutputStream(file)) { OsuWriter writer = new OsuWriter(out); @@ -299,6 +310,18 @@ public class Replay { } } + /** + * Returns the file name of where the replay should be saved and loaded, + * or null if the required fields are not set. + */ + public String getReplayFilename() { + if (replayHash == null) + return null; + + return String.format("%s-%d%d%d%d%d%d", + replayHash, hit300, hit100, hit50, geki, katu, miss); + } + @Override public String toString() { final int LINE_SPLIT = 5; diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java index a78bf298..613cf610 100644 --- a/src/itdelatrisu/opsu/states/Game.java +++ b/src/itdelatrisu/opsu/states/Game.java @@ -27,6 +27,7 @@ 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; @@ -35,6 +36,7 @@ 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.db.ScoreDB; import itdelatrisu.opsu.objects.Circle; import itdelatrisu.opsu.objects.HitObject; @@ -49,6 +51,7 @@ import java.util.Stack; import java.util.concurrent.TimeUnit; import org.lwjgl.input.Keyboard; +import org.lwjgl.opengl.Display; import org.newdawn.slick.Animation; import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; @@ -483,16 +486,17 @@ public class Game extends BasicGameState { // go to ranking screen else { ((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data); - ReplayFrame[] rf = null; if (!isReplay && replayFrames != null) { // finalize replay frames with start/skip frames if (!replayFrames.isEmpty()) replayFrames.getFirst().setTimeDiff(replaySkipTime * -1); replayFrames.addFirst(ReplayFrame.getStartFrame(replaySkipTime)); replayFrames.addFirst(ReplayFrame.getStartFrame(0)); - rf = replayFrames.toArray(new ReplayFrame[replayFrames.size()]); + Replay r = data.getReplay(replayFrames.toArray(new ReplayFrame[replayFrames.size()])); + if (r != null) + r.save(); } - ScoreData score = data.getScoreData(osu, rf); + ScoreData score = data.getScoreData(osu); // add score to database if (!GameMod.AUTO.isActive() && !GameMod.RELAX.isActive() && !GameMod.AUTOPILOT.isActive() && !isReplay) @@ -830,8 +834,6 @@ public class Game extends BasicGameState { public void enter(GameContainer container, StateBasedGame game) throws SlickException { UI.enter(); - if (restart == Restart.NEW) - osu = MusicController.getOsuFile(); if (osu == null || osu.objects == null) throw new RuntimeException("Running game with no OsuFile loaded."); @@ -978,6 +980,19 @@ public class Game extends BasicGameState { } } + /** + * Loads all required data from an OsuFile. + * @param osu the OsuFile 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); + } + /** * Resets all game data and structures. */ diff --git a/src/itdelatrisu/opsu/states/GameRanking.java b/src/itdelatrisu/opsu/states/GameRanking.java index f7842b76..27bc53b4 100644 --- a/src/itdelatrisu/opsu/states/GameRanking.java +++ b/src/itdelatrisu/opsu/states/GameRanking.java @@ -163,27 +163,29 @@ public class GameRanking extends BasicGameState { } // replay + Game gameState = (Game) game.getState(Opsu.STATE_GAME); boolean returnToGame = false; if (replayButton.contains(x, y)) { Replay r = data.getReplay(null); if (r != null) { r.load(); - ((Game) game.getState(Opsu.STATE_GAME)).setReplay(r); - ((Game) game.getState(Opsu.STATE_GAME)).setRestart(Game.Restart.REPLAY); + gameState.setReplay(r); + gameState.setRestart((data.isGameplay()) ? Game.Restart.REPLAY : Game.Restart.NEW); returnToGame = true; - } + } else + UI.sendBarNotification("Replay file not found."); } // retry else if (data.isGameplay() && retryButton.contains(x, y)) { - ((Game) game.getState(Opsu.STATE_GAME)).setReplay(null); - ((Game) game.getState(Opsu.STATE_GAME)).setRestart(Game.Restart.MANUAL); + gameState.setReplay(null); + gameState.setRestart(Game.Restart.MANUAL); returnToGame = true; } if (returnToGame) { OsuFile osu = MusicController.getOsuFile(); - Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString())); + gameState.loadOsuFile(osu); SoundController.playSound(SoundEffect.MENUHIT); game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); return; @@ -211,6 +213,8 @@ public class GameRanking extends BasicGameState { public void leave(GameContainer container, StateBasedGame game) throws SlickException { this.data = null; + if (MusicController.isTrackDimmed()) + MusicController.toggleTrackDimmed(1f); } /** diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java index bc789141..cdba346d 100644 --- a/src/itdelatrisu/opsu/states/SongMenu.java +++ b/src/itdelatrisu/opsu/states/SongMenu.java @@ -34,7 +34,6 @@ import itdelatrisu.opsu.ScoreData; import itdelatrisu.opsu.SongSort; import itdelatrisu.opsu.UI; import itdelatrisu.opsu.Utils; -import itdelatrisu.opsu.audio.HitSound; import itdelatrisu.opsu.audio.MultiClip; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; @@ -1288,17 +1287,10 @@ public class SongMenu extends BasicGameState { return; SoundController.playSound(SoundEffect.MENUHIT); - OsuFile osu = MusicController.getOsuFile(); - Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString())); - - // load any missing data - if (osu.timingPoints == null || osu.combo == null) - OsuDB.load(osu, OsuDB.LOAD_ARRAY); - OsuParser.parseHitObjects(osu); - HitSound.setDefaultSampleSet(osu.sampleSet); - MultiClip.destroyExtraClips(); + OsuFile osu = MusicController.getOsuFile(); Game gameState = (Game) game.getState(Opsu.STATE_GAME); + gameState.loadOsuFile(osu); gameState.setRestart(Game.Restart.NEW); gameState.setReplay(null); game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));