From 0bd72e731ac7e3b295186cadbfe74577514910b8 Mon Sep 17 00:00:00 2001 From: Jeffrey Han Date: Wed, 28 Jan 2015 03:47:24 -0500 Subject: [PATCH] Save game scores to an SQLite database. [Incomplete!] Implemented basic features (mostly stable). The remaining features are mostly graphical. - Added package org.xerial.sqlite-jdbc. All scores are saved to .opsu_scores.db on table `scores` after a game completes. - Added "Scores" class to handle all game score data (including database connections). The "Score" subclass encapsulates all database fields. - Added "score viewing" constructor to GameData, for use only in the ranking screen. - Draw the grade of the highest score next to expanded song buttons in the song menu. - Added "bit" and "abbrev" fields to GameMod, used in storing/displaying scores. - Hide the retry/exit buttons in the ranking screen when viewing a score. Other changes: - Removed "objectCount" field in GameData (no longer necessary). - Removed "getID()" method in GameMod (no longer used). - Moved most drawing in GameRanking state to GameData. - Removed File parameter of "GameData.loadImages()" (leftover, no longer used). Signed-off-by: Jeffrey Han --- .gitignore | 1 + pom.xml | 5 + src/itdelatrisu/opsu/Container.java | 6 +- src/itdelatrisu/opsu/GameData.java | 227 ++++++++++--- src/itdelatrisu/opsu/GameImage.java | 2 +- src/itdelatrisu/opsu/GameMod.java | 61 ++-- src/itdelatrisu/opsu/Opsu.java | 16 +- src/itdelatrisu/opsu/Options.java | 3 + src/itdelatrisu/opsu/OsuGroupNode.java | 12 +- src/itdelatrisu/opsu/Scores.java | 338 +++++++++++++++++++ src/itdelatrisu/opsu/states/Game.java | 10 +- src/itdelatrisu/opsu/states/GameRanking.java | 95 +++--- src/itdelatrisu/opsu/states/SongMenu.java | 50 ++- 13 files changed, 693 insertions(+), 133 deletions(-) create mode 100644 src/itdelatrisu/opsu/Scores.java 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