Merge remote-tracking branch 'ita/master' into omaster

Conflicts:
	src/itdelatrisu/opsu/GameImage.java
This commit is contained in:
fd 2015-03-12 22:51:14 -04:00
commit be4cb6d608
34 changed files with 1757 additions and 188 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/Replays/
/Screenshots/
/Skins/
/SongPacks/

10
pom.xml
View File

@ -197,5 +197,15 @@
<artifactId>maven-artifact</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.8</version>
</dependency>
<dependency>
<groupId>com.github.jponge</groupId>
<artifactId>lzma-java</artifactId>
<version>1.2</version>
</dependency>
</dependencies>
</project>

BIN
res/pause-replay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@ -22,10 +22,15 @@ import itdelatrisu.opsu.audio.HitSound;
import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
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;
import java.util.LinkedList;
import java.util.concurrent.LinkedBlockingDeque;
import org.newdawn.slick.Animation;
import org.newdawn.slick.Color;
@ -165,7 +170,7 @@ public class GameData {
private int[] hitResultOffset;
/** List of hit result objects associated with hit objects. */
private LinkedList<OsuHitObjectResult> hitResultList;
private LinkedBlockingDeque<OsuHitObjectResult> hitResultList;
/**
* Class to store hit error information.
@ -198,7 +203,7 @@ public class GameData {
}
/** List containing recent hit error information. */
private LinkedList<HitErrorInfo> hitErrorList;
private LinkedBlockingDeque<HitErrorInfo> hitErrorList;
/**
* Hit result helper class.
@ -274,6 +279,9 @@ public class GameData {
/** The associated score data. */
private ScoreData scoreData;
/** The associated replay. */
private Replay replay;
/** Whether this object is used for gameplay (true) or score viewing (false). */
private boolean gameplay;
@ -318,6 +326,8 @@ public class GameData {
hitResultCount[HIT_300K] = 0;
hitResultCount[HIT_100K] = s.katu;
hitResultCount[HIT_MISS] = s.miss;
this.replay = (s.replayString == null) ? null :
new Replay(new File(Options.getReplayDir(), String.format("%s.osr", s.replayString)));
loadImages();
}
@ -332,8 +342,8 @@ public class GameData {
health = 100f;
healthDisplay = 100f;
hitResultCount = new int[HIT_MAX];
hitResultList = new LinkedList<OsuHitObjectResult>();
hitErrorList = new LinkedList<HitErrorInfo>();
hitResultList = new LinkedBlockingDeque<OsuHitObjectResult>();
hitErrorList = new LinkedBlockingDeque<HitErrorInfo>();
fullObjectCount = 0;
combo = 0;
comboMax = 0;
@ -1219,15 +1229,50 @@ public class GameData {
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;
scoreData.mods = GameMod.getModState();
scoreData.replayString = (replay == null) ? null : replay.getReplayFilename();
return scoreData;
}
/**
* Returns a Replay object encapsulating all game data.
* If a replay already exists and frames is null, the existing object will
* be returned.
* @param frames the replay frames
* @return the Replay object, or null if none exists and frames is null
*/
public Replay getReplay(ReplayFrame[] frames) {
if (replay != null && frames == null)
return replay;
if (frames == null)
return null;
replay = new Replay();
replay.mode = OsuFile.MODE_OSU;
replay.version = Updater.get().getBuildDate();
replay.beatmapHash = ""; // TODO
replay.playerName = ""; // 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];
replay.geki = (short) hitResultCount[HIT_300G];
replay.katu = (short) (hitResultCount[HIT_300K] + hitResultCount[HIT_100K]);
replay.miss = (short) hitResultCount[HIT_MISS];
replay.score = (int) score;
replay.combo = (short) comboMax;
replay.perfect = (comboMax == fullObjectCount);
replay.mods = GameMod.getModState();
replay.lifeFrames = null; // TODO
replay.timestamp = new Date();
replay.frames = frames;
replay.seed = 0; // TODO
replay.loaded = true;
return replay;
}
/**
* Returns whether or not this object is used for gameplay.
* @return true if gameplay, false if score viewing

View File

@ -87,6 +87,7 @@ public enum GameImage {
PAUSE_CONTINUE ("pause-continue", "png"),
PAUSE_RETRY ("pause-retry", "png"),
PAUSE_BACK ("pause-back", "png"),
PAUSE_REPLAY ("pause-replay", "png"),
PAUSE_OVERLAY ("pause-overlay", "png|jpg") {
@Override
protected Image process_sub(Image img, int w, int h) {
@ -289,8 +290,6 @@ public enum GameImage {
MUSIC_PAUSE ("music-pause", "png", false, false),
MUSIC_NEXT ("music-next", "png", false, false),
MUSIC_PREVIOUS ("music-previous", "png", false, false),
RANKING_RETRY ("ranking-retry", "png", false, false),
RANKING_EXIT ("ranking-back", "png", false, false),
DOWNLOADS ("downloads", "png", false, false) {
@Override
protected Image process_sub(Image img, int w, int h) {

View File

@ -213,6 +213,27 @@ public enum GameMod {
return scoreMultiplier;
}
/**
* Returns the current game mod state (bitwise OR of active mods).
*/
public static int getModState() {
int state = 0;
for (GameMod mod : GameMod.values()) {
if (mod.isActive())
state |= mod.getBit();
}
return state;
}
/**
* Sets the active states of all game mods to the given state.
* @param state the state (bitwise OR of active mods)
*/
public static void loadModState(int state) {
for (GameMod mod : GameMod.values())
mod.active = ((state & mod.getBit()) > 0);
}
/**
* Constructor.
* @param category the category for the mod

View File

@ -137,6 +137,10 @@ public class Opsu extends StateBasedGame {
// initialize databases
DBController.init();
// check if just updated
if (args.length >= 2)
Updater.get().setUpdateInfo(args[0], args[1]);
// check for updates
new Thread() {
@Override

View File

@ -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));

View File

@ -32,6 +32,9 @@ import org.newdawn.slick.util.Log;
* Data type storing parsed data from OSU files.
*/
public class OsuFile implements Comparable<OsuFile> {
/** Game modes. */
public static final byte MODE_OSU = 0, MODE_TAIKO = 1, MODE_CTB = 2, MODE_MANIA = 3;
/** Map of all loaded background images. */
private static HashMap<OsuFile, Image> bgImageMap = new HashMap<OsuFile, Image>();
@ -66,8 +69,8 @@ public class OsuFile implements Comparable<OsuFile> {
/** How often closely placed hit objects will be stacked together. */
public float stackLeniency = 0.7f;
/** Game mode (0:osu!, 1:taiko, 2:catch the beat, 3:osu!mania). */
public byte mode = 0;
/** Game mode (MODE_* constants). */
public byte mode = MODE_OSU;
/** Whether the letterbox (top/bottom black bars) appears during breaks. */
public boolean letterboxInBreaks = false;

View File

@ -59,7 +59,10 @@ public class OsuHitObject {
xOffset, // offset right of border
yOffset; // offset below health bar
/** Starting coordinates (scaled). */
/** The container height. */
private static int containerHeight;
/** Starting coordinates. */
private float x, y;
/** Start time (in ms). */
@ -77,7 +80,7 @@ public class OsuHitObject {
/** Slider curve type (SLIDER_* constant). */
private char sliderType;
/** Slider coordinate lists (scaled). */
/** Slider coordinate lists. */
private float[] sliderX, sliderY;
/** Slider repeat count. */
@ -107,6 +110,7 @@ public class OsuHitObject {
* @param height the container height
*/
public static void init(int width, int height) {
containerHeight = height;
int swidth = width;
int sheight = height;
if (swidth * 3 > sheight * 4)
@ -129,6 +133,16 @@ public class OsuHitObject {
*/
public static float getYMultiplier() { return yMultiplier; }
/**
* Returns the X offset for coordinates.
*/
public static int getXOffset() { return xOffset; }
/**
* Returns the Y offset for coordinates.
*/
public static int getYOffset() { return yOffset; }
/**
* Constructor.
* @param line the line to be parsed
@ -153,8 +167,8 @@ public class OsuHitObject {
String tokens[] = line.split(",");
// common fields
this.x = Float.parseFloat(tokens[0]) * xMultiplier + xOffset;
this.y = Float.parseFloat(tokens[1]) * yMultiplier + yOffset;
this.x = Float.parseFloat(tokens[0]);
this.y = Float.parseFloat(tokens[1]);
this.time = Integer.parseInt(tokens[2]);
this.type = Integer.parseInt(tokens[3]);
this.hitSound = Byte.parseByte(tokens[4]);
@ -175,8 +189,8 @@ public class OsuHitObject {
this.sliderY = new float[sliderTokens.length - 1];
for (int j = 1; j < sliderTokens.length; j++) {
String[] sliderXY = sliderTokens[j].split(":");
this.sliderX[j - 1] = Integer.parseInt(sliderXY[0]) * xMultiplier + xOffset;
this.sliderY[j - 1] = Integer.parseInt(sliderXY[1]) * yMultiplier + yOffset;
this.sliderX[j - 1] = Integer.parseInt(sliderXY[0]);
this.sliderY[j - 1] = Integer.parseInt(sliderXY[1]);
}
this.repeat = Integer.parseInt(tokens[6]);
this.pixelLength = Float.parseFloat(tokens[7]);
@ -211,17 +225,30 @@ public class OsuHitObject {
}
/**
* Returns the starting x coordinate.
* @return the x coordinate
* Returns the raw starting x coordinate.
*/
public float getX() { return x; }
/**
* Returns the starting y coordinate.
* @return the y coordinate
* Returns the raw starting y coordinate.
*/
public float getY() { return y; }
/**
* Returns the scaled starting x coordinate.
*/
public float getScaledX() { return x * xMultiplier + xOffset; }
/**
* Returns the scaled starting y coordinate.
*/
public float getScaledY() {
if (GameMod.HARD_ROCK.isActive())
return containerHeight - (y * yMultiplier + yOffset);
else
return y * yMultiplier + yOffset;
}
/**
* Returns the start time.
* @return the start time (in ms)
@ -259,17 +286,48 @@ public class OsuHitObject {
public char getSliderType() { return sliderType; }
/**
* Returns a list of slider x coordinates.
* @return the slider x coordinates
* Returns a list of raw slider x coordinates.
*/
public float[] getSliderX() { return sliderX; }
/**
* Returns a list of slider y coordinates.
* @return the slider y coordinates
* Returns a list of raw slider y coordinates.
*/
public float[] getSliderY() { return sliderY; }
/**
* Returns a list of scaled slider x coordinates.
* Note that this method will create a new array.
*/
public float[] getScaledSliderX() {
if (sliderX == null)
return null;
float[] x = new float[sliderX.length];
for (int i = 0; i < x.length; i++)
x[i] = sliderX[i] * xMultiplier + xOffset;
return x;
}
/**
* Returns a list of scaled slider y coordinates.
* Note that this method will create a new array.
*/
public float[] getScaledSliderY() {
if (sliderY == null)
return null;
float[] y = new float[sliderY.length];
if (GameMod.HARD_ROCK.isActive()) {
for (int i = 0; i < y.length; i++)
y[i] = containerHeight - (sliderY[i] * yMultiplier + yOffset);
} else {
for (int i = 0; i < y.length; i++)
y[i] = sliderY[i] * yMultiplier + yOffset;
}
return y;
}
/**
* Returns the slider repeat count.
* @return the repeat count

View File

@ -272,7 +272,7 @@ public class OsuParser {
osu.mode = Byte.parseByte(tokens[1]);
/* Non-Opsu! standard files not implemented (obviously). */
if (osu.mode != 0)
if (osu.mode != OsuFile.MODE_OSU)
return null;
break;

View File

@ -0,0 +1,175 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Date;
/**
* Reader for osu! file types.
*
* @author Markus Jarderot (http://stackoverflow.com/questions/28788616)
*/
public class OsuReader {
/** Input stream reader. */
private DataInputStream reader;
/**
* Constructor.
* @param file the file to read from
* @throws IOException
*/
public OsuReader(File file) throws IOException {
this(new FileInputStream(file));
}
/**
* Constructor.
* @param source the input stream to read from
*/
public OsuReader(InputStream source) {
this.reader = new DataInputStream(source);
}
/**
* Returns the input stream in use.
*/
public InputStream getInputStream() { return reader; }
/**
* Closes the input stream.
*/
public void close() throws IOException { reader.close(); }
/**
* Reads a 1-byte value.
*/
public byte readByte() throws IOException {
return this.reader.readByte();
}
/**
* Reads a 2-byte little endian value.
*/
public short readShort() throws IOException {
byte[] bytes = new byte[2];
this.reader.readFully(bytes);
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
return bb.getShort();
}
/**
* Reads a 4-byte little endian value.
*/
public int readInt() throws IOException {
byte[] bytes = new byte[4];
this.reader.readFully(bytes);
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
return bb.getInt();
}
/**
* Reads an 8-byte little endian value.
*/
public long readLong() throws IOException {
byte[] bytes = new byte[8];
this.reader.readFully(bytes);
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
return bb.getLong();
}
/**
* Reads a 4-byte little endian float.
*/
public float readSingle() throws IOException {
byte[] bytes = new byte[4];
this.reader.readFully(bytes);
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
return bb.getFloat();
}
/**
* Reads an 8-byte little endian double.
*/
public double readDouble() throws IOException {
byte[] bytes = new byte[8];
this.reader.readFully(bytes);
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
return bb.getDouble();
}
/**
* Reads a 1-byte value as a boolean.
*/
public boolean readBoolean() throws IOException {
return this.reader.readBoolean();
}
/**
* Reads an unsigned variable length integer (ULEB128).
*/
public int readULEB128() throws IOException {
int value = 0;
for (int shift = 0; shift < 32; shift += 7) {
byte b = this.reader.readByte();
value |= (b & 0x7F) << shift;
if (b >= 0)
return value; // MSB is zero. End of value.
}
throw new IOException("ULEB128 too large");
}
/**
* Reads a variable-length string of 1-byte characters.
*/
public String readString() throws IOException {
// 00 = empty string
// 0B <length> <char>* = normal string
// <length> is encoded as an LEB, and is the byte length of the rest.
// <char>* is encoded as UTF8, and is the string content.
byte kind = this.reader.readByte();
if (kind == 0)
return "";
if (kind != 0x0B)
throw new IOException(String.format("String format error: Expected 0x0B or 0x00, found 0x%02X", kind & 0xFF));
int length = readULEB128();
if (length == 0)
return "";
byte[] utf8bytes = new byte[length];
this.reader.readFully(utf8bytes);
return new String(utf8bytes, "UTF-8");
}
/**
* Reads an 8-byte date in Windows ticks.
*/
public Date readDate() throws IOException {
long ticks = readLong();
final long TICKS_AT_EPOCH = 621355968000000000L;
final long TICKS_PER_MILLISECOND = 10000;
return new Date((ticks - TICKS_AT_EPOCH) / TICKS_PER_MILLISECOND);
}
}

View File

@ -0,0 +1,162 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;
/**
* Writer for osu! file types.
*/
public class OsuWriter {
/** Output stream writer. */
private DataOutputStream writer;
/**
* Constructor.
* @param file the file to write to
* @throws FileNotFoundException
*/
public OsuWriter(File file) throws FileNotFoundException {
this(new FileOutputStream(file));
}
/**
* Constructor.
* @param dest the output stream to write to
*/
public OsuWriter(OutputStream dest) {
this.writer = new DataOutputStream(dest);
}
/**
* Returns the output stream in use.
*/
public OutputStream getOutputStream() { return writer; }
/**
* Closes the output stream.
* @throws IOException
*/
public void close() throws IOException { writer.close(); }
/**
* Writes a 1-byte value.
*/
public void write(byte v) throws IOException { writer.writeByte(v); }
/**
* Writes a 2-byte value.
*/
public void write(short v) throws IOException {
byte[] bytes = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(v).array();
writer.write(bytes);
}
/**
* Writes a 4-byte value.
*/
public void write(int v) throws IOException {
byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array();
writer.write(bytes);
}
/**
* Writes an 8-byte value.
*/
public void write(long v) throws IOException {
byte[] bytes = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array();
writer.write(bytes);
}
/**
* Writes a 4-byte float.
*/
public void write(float v) throws IOException { writer.writeFloat(v); }
/**
* Writes an 8-byte double.
*/
public void write(double v) throws IOException { writer.writeDouble(v); }
/**
* Writes a boolean as a 1-byte value.
*/
public void write(boolean v) throws IOException { writer.writeBoolean(v); }
/**
* Writes an unsigned variable length integer (ULEB128).
*/
public void writeULEB128(int i) throws IOException {
int value = i;
do {
byte b = (byte) (value & 0x7F);
value >>= 7;
if (value != 0)
b |= (1 << 7);
writer.writeByte(b);
} while (value != 0);
}
/**
* Writes a variable-length string of 1-byte characters.
*/
public void write(String s) throws IOException {
// 00 = empty string
// 0B <length> <char>* = normal string
// <length> is encoded as an LEB, and is the byte length of the rest.
// <char>* is encoded as UTF8, and is the string content.
if (s == null || s.length() == 0)
writer.writeByte(0x00);
else {
writer.writeByte(0x0B);
writeULEB128(s.length());
writer.writeBytes(s);
}
}
/**
* Writes a date in Windows ticks (8 bytes).
*/
public void write(Date date) throws IOException {
final long TICKS_AT_EPOCH = 621355968000000000L;
final long TICKS_PER_MILLISECOND = 10000;
Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
calendar.setTime(date);
long ticks = TICKS_AT_EPOCH + calendar.getTimeInMillis() * TICKS_PER_MILLISECOND;
write(ticks);
}
/**
* Writes an array of bytes.
*/
public void write(byte[] b) throws IOException {
writer.write(b);
}
}

View File

@ -59,6 +59,9 @@ public class ScoreData implements Comparable<ScoreData> {
/** Game mod bitmask. */
public int mods;
/** The replay string. */
public String replayString;
/** Time since the score was achieved. */
private String timeSince;
@ -153,6 +156,7 @@ public class ScoreData implements Comparable<ScoreData> {
this.combo = rs.getInt(15);
this.perfect = rs.getBoolean(16);
this.mods = rs.getInt(17);
this.replayString = rs.getString(18);
}
/**

View File

@ -20,12 +20,16 @@ package itdelatrisu.opsu;
import itdelatrisu.opsu.audio.SoundController;
import java.nio.IntBuffer;
import java.util.Iterator;
import java.util.LinkedList;
import javax.swing.JOptionPane;
import javax.swing.UIManager;
import org.lwjgl.BufferUtils;
import org.lwjgl.LWJGLException;
import org.lwjgl.input.Cursor;
import org.newdawn.slick.Animation;
import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
@ -42,6 +46,9 @@ public class UI {
/** Back button. */
private static MenuButton backButton;
/** Empty cursor. */
private static Cursor emptyCursor;
/** Last cursor coordinates. */
private static int lastX = -1, lastY = -1;
@ -88,6 +95,16 @@ public class UI {
UI.game = game;
UI.input = container.getInput();
// hide native cursor
try {
int min = Cursor.getMinCursorSize();
IntBuffer tmp = BufferUtils.createIntBuffer(min * min);
emptyCursor = new Cursor(min, min, min/2, min/2, 1, tmp, null);
hideCursor();
} catch (LWJGLException e) {
ErrorHandler.error("Failed to create hidden cursor.", e, true);
}
// back button
if (GameImage.MENU_BACK.getImages() != null) {
Animation back = GameImage.MENU_BACK.getAnimation(120);
@ -120,6 +137,20 @@ public class UI {
drawCursor();
}
/**
* Draws the global UI components: cursor, FPS, volume bar, bar notifications.
* @param g the graphics context
* @param mouseX the mouse x coordinate
* @param mouseY the mouse y coordinate
* @param mousePressed whether or not the mouse button is pressed
*/
public static void draw(Graphics g, int mouseX, int mouseY, boolean mousePressed) {
drawBarNotification(g);
drawVolume(g);
drawFPS();
drawCursor(mouseX, mouseY, mousePressed);
}
/**
* Resets the necessary UI components upon entering a state.
*/
@ -161,6 +192,21 @@ public class UI {
* Draws the cursor.
*/
public static void drawCursor() {
int state = game.getCurrentStateID();
boolean mousePressed =
(((state == Opsu.STATE_GAME || state == Opsu.STATE_GAMEPAUSEMENU) && Utils.isGameKeyPressed()) ||
((input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON) || input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON)) &&
!(state == Opsu.STATE_GAME && Options.isMouseDisabled())));
drawCursor(input.getMouseX(), input.getMouseY(), mousePressed);
}
/**
* Draws the cursor.
* @param mouseX the mouse x coordinate
* @param mouseY the mouse y coordinate
* @param mousePressed whether or not the mouse button is pressed
*/
public static void drawCursor(int mouseX, int mouseY, boolean mousePressed) {
// determine correct cursor image
// TODO: most beatmaps don't skin CURSOR_MIDDLE, so how to determine style?
Image cursor = null, cursorMiddle = null, cursorTrail = null;
@ -176,7 +222,6 @@ public class UI {
if (newStyle)
cursorMiddle = GameImage.CURSOR_MIDDLE.getImage();
int mouseX = input.getMouseX(), mouseY = input.getMouseY();
int removeCount = 0;
int FPSmod = (Options.getTargetFPS() / 60);
@ -226,10 +271,7 @@ public class UI {
// increase the cursor size if pressed
final float scale = 1.25f;
int state = game.getCurrentStateID();
if (((state == Opsu.STATE_GAME || state == Opsu.STATE_GAMEPAUSEMENU) && Utils.isGameKeyPressed()) ||
((input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON) || input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON)) &&
!(state == Opsu.STATE_GAME && Options.isMouseDisabled()))) {
if (mousePressed) {
cursor = cursor.getScaledCopy(scale);
if (newStyle)
cursorMiddle = cursorMiddle.getScaledCopy(scale);
@ -318,6 +360,26 @@ public class UI {
GameImage.CURSOR.getImage().setRotation(0f);
}
/**
* Hides the cursor, if possible.
*/
public static void hideCursor() {
if (emptyCursor != null) {
try {
container.setMouseCursor(emptyCursor, 0, 0);
} catch (SlickException e) {
ErrorHandler.error("Failed to hide the cursor.", e, true);
}
}
}
/**
* Unhides the cursor.
*/
public static void showCursor() {
container.setDefaultMouseCursor();
}
/**
* Draws the FPS at the bottom-right corner of the game container.
* If the option is not activated, this will do nothing.

View File

@ -34,19 +34,17 @@ import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.IntBuffer;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Scanner;
import javax.imageio.ImageIO;
import org.lwjgl.BufferUtils;
import org.lwjgl.LWJGLException;
import org.lwjgl.input.Cursor;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.GL11;
import org.newdawn.slick.Animation;
@ -130,6 +128,8 @@ public class Utils {
public static void init(GameContainer container, StateBasedGame game)
throws SlickException {
input = container.getInput();
int width = container.getWidth();
int height = container.getHeight();
// game settings
container.setTargetFrameRate(Options.getTargetFPS());
@ -140,20 +140,6 @@ public class Utils {
container.setAlwaysRender(true);
container.setUpdateOnlyWhenVisible(false);
int width = container.getWidth();
int height = container.getHeight();
// set the cursor
try {
// hide the native cursor
int min = Cursor.getMinCursorSize();
IntBuffer tmp = BufferUtils.createIntBuffer(min * min);
Cursor emptyCursor = new Cursor(min, min, min/2, min/2, 1, tmp, null);
container.setMouseCursor(emptyCursor, 0, 0);
} catch (LWJGLException e) {
ErrorHandler.error("Failed to set the cursor.", e, true);
}
GameImage.init(width, height);
// create fonts
@ -539,4 +525,15 @@ public class Utils {
throw e;
}
}
/**
* Converts an input stream to a string.
* @param is the input stream
* @author Pavel Repin, earcam (http://stackoverflow.com/a/5445161)
*/
public static String convertStreamToString(InputStream is) {
try (Scanner s = new Scanner(is)) {
return s.useDelimiter("\\A").hasNext() ? s.next() : "";
}
}
}

View File

@ -127,6 +127,10 @@ public class OsuDB {
"PRAGMA locking_mode = EXCLUSIVE; " +
"PRAGMA journal_mode = WAL;";
stmt.executeUpdate(sql);
// set the version key, if empty
sql = String.format("INSERT OR IGNORE INTO info(key, value) VALUES('version', '%s')", DATABASE_VERSION);
stmt.executeUpdate(sql);
} catch (SQLException e) {
ErrorHandler.error("Could not create beatmap database.", e, true);
}
@ -145,14 +149,15 @@ public class OsuDB {
rs.close();
// if different from current version, clear the database
if (!version.equals(DATABASE_VERSION))
if (!version.equals(DATABASE_VERSION)) {
clearDatabase();
// update version
PreparedStatement ps = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('version', ?)");
ps.setString(1, DATABASE_VERSION);
ps.executeUpdate();
ps.close();
// update version
PreparedStatement ps = connection.prepareStatement("REPLACE INTO info (key, value) VALUES ('version', ?)");
ps.setString(1, DATABASE_VERSION);
ps.executeUpdate();
ps.close();
}
} catch (SQLException e) {
ErrorHandler.error("Beatmap database version checks failed.", e, true);
}

View File

@ -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<String> getUpdateQueries(int version) {
List<String> list = new LinkedList<String>();
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);

View File

@ -28,6 +28,10 @@ import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.net.URL;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Properties;
import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
@ -93,6 +97,12 @@ public class Updater {
/** The current and latest versions. */
private DefaultArtifactVersion currentVersion, latestVersion;
/** The version information if the program was just updated. */
private String updatedFromVersion, updatedToVersion;
/** The build date. */
private int buildDate = -1;
/** The download object. */
private Download download;
@ -115,6 +125,57 @@ public class Updater {
return (status == Status.UPDATE_AVAILABLE || status == Status.UPDATE_DOWNLOADED || status == Status.UPDATE_DOWNLOADING);
}
/**
* Returns the build date, or the current date if not available.
*/
public int getBuildDate() {
if (buildDate == -1) {
Date date = null;
try {
Properties props = new Properties();
props.load(ResourceLoader.getResourceAsStream(Options.VERSION_FILE));
String build = props.getProperty("build.date");
if (build == null || build.equals("${timestamp}") || build.equals("${maven.build.timestamp}"))
date = new Date();
else {
DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ENGLISH);
date = format.parse(build);
}
} catch (Exception e) {
date = new Date();
} finally {
DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
buildDate = Integer.parseInt(dateFormat.format(date));
}
}
return buildDate;
}
/**
* Sets the version information if the program was just updated.
* @param fromVersion the previous version
* @param toVersion the new version
*/
public void setUpdateInfo(String fromVersion, String toVersion) {
this.updatedFromVersion = fromVersion;
this.updatedToVersion = toVersion;
}
/**
* Returns whether or not the program was just updated.
*/
public boolean justUpdated() { return (updatedFromVersion != null && updatedToVersion != null); }
/**
* Returns the version the program was just updated from, or null if not updated.
*/
public String updatedFromVersion() { return (justUpdated()) ? updatedFromVersion : null; }
/**
* Returns the version the program was just updated to, or null if not updated.
*/
public String updatedToVersion() { return (justUpdated()) ? updatedToVersion : null; }
/**
* Returns the version from a set of properties.
* @param props the set of properties
@ -215,7 +276,10 @@ public class Updater {
try {
// TODO: it is better to wait for the process? is this portable?
ProcessBuilder pb = new ProcessBuilder("java", "-jar", download.getLocalPath());
ProcessBuilder pb = new ProcessBuilder(
"java", "-jar", download.getLocalPath(),
currentVersion.toString(), latestVersion.toString()
);
pb.start();
} catch (IOException e) {
status = Status.INTERNAL_ERROR;

View File

@ -37,6 +37,9 @@ public class Circle implements HitObject {
/** The associated OsuHitObject. */
private OsuHitObject hitObject;
/** The scaled starting x, y coordinates. */
private float x, y;
/** The associated Game object. */
private Game game;
@ -72,6 +75,8 @@ public class Circle implements HitObject {
*/
public Circle(OsuHitObject hitObject, Game game, GameData data, Color color, boolean comboEnd) {
this.hitObject = hitObject;
this.x = hitObject.getScaledX();
this.y = hitObject.getScaledY();
this.game = game;
this.data = data;
this.color = color;
@ -84,7 +89,6 @@ public class Circle implements HitObject {
if (timeDiff >= 0) {
float oldAlpha = color.a;
float x = hitObject.getX(), y = hitObject.getY();
float scale = timeDiff / (float)game.getApproachTime();
float approachScale = 1 + scale * 3;
@ -129,7 +133,7 @@ public class Circle implements HitObject {
@Override
public boolean mousePressed(int x, int y) {
double distance = Math.hypot(hitObject.getX() - x, hitObject.getY() - y);
double distance = Math.hypot(this.x - x, this.y - y);
int circleRadius = GameImage.HITCIRCLE.getImage().getWidth() / 2;
if (distance < circleRadius) {
int trackPosition = MusicController.getPosition();
@ -138,11 +142,7 @@ public class Circle implements HitObject {
if (result > -1) {
data.addHitError(hitObject.getTime(), x, y, timeDiff);
data.hitResult(
hitObject.getTime(), result,
hitObject.getX(), hitObject.getY(),
color, comboEnd, hitObject, 0
);
data.hitResult(hitObject.getTime(), result, this.x, this.y, color, comboEnd, hitObject, 0);
return true;
}
}
@ -150,9 +150,8 @@ public class Circle implements HitObject {
}
@Override
public boolean update(boolean overlap, int delta, int mouseX, int mouseY) {
public boolean update(boolean overlap, int delta, int mouseX, int mouseY, boolean keyPressed) {
int time = hitObject.getTime();
float x = hitObject.getX(), y = hitObject.getY();
int trackPosition = MusicController.getPosition();
int[] hitResultOffset = game.getHitResultOffsets();

View File

@ -37,9 +37,10 @@ public interface HitObject {
* @param delta the delta interval since the last call
* @param mouseX the x coordinate of the mouse
* @param mouseY the y coordinate of the mouse
* @param keyPressed whether or not a game key is currently pressed
* @return true if object ended
*/
public boolean update(boolean overlap, int delta, int mouseX, int mouseY);
public boolean update(boolean overlap, int delta, int mouseX, int mouseY, boolean keyPressed);
/**
* Processes a mouse click.

View File

@ -52,6 +52,9 @@ public class Slider implements HitObject {
/** The associated OsuHitObject. */
private OsuHitObject hitObject;
/** The scaled starting x, y coordinates. */
protected float x, y;
/** The associated Game object. */
private Game game;
@ -130,6 +133,8 @@ public class Slider implements HitObject {
*/
public Slider(OsuHitObject hitObject, Game game, GameData data, Color color, boolean comboEnd) {
this.hitObject = hitObject;
this.x = hitObject.getScaledX();
this.y = hitObject.getScaledY();
this.game = game;
this.data = data;
this.color = color;
@ -147,7 +152,6 @@ public class Slider implements HitObject {
int timeDiff = hitObject.getTime() - trackPosition;
float scale = timeDiff / (float) game.getApproachTime();
float approachScale = 1 + scale * 3;
float x = hitObject.getX(), y = hitObject.getY();
float alpha = Utils.clamp(1 - scale, 0, 1);
float oldAlpha = color.a;
@ -256,7 +260,7 @@ public class Slider implements HitObject {
lastPos[0], lastPos[1], color, comboEnd, hitObject, currentRepeats + 1);
} else { // first circle
data.hitResult(hitObject.getTime() + (int) sliderTimeTotal, result,
hitObject.getX(), hitObject.getY(), color, comboEnd, hitObject, currentRepeats + 1);
x, y, color, comboEnd, hitObject, currentRepeats + 1);
}
return result;
@ -267,7 +271,7 @@ public class Slider implements HitObject {
if (sliderClickedInitial) // first circle already processed
return false;
double distance = Math.hypot(hitObject.getX() - x, hitObject.getY() - y);
double distance = Math.hypot(this.x - x, this.y - y);
int circleRadius = GameImage.HITCIRCLE.getImage().getWidth() / 2;
if (distance < circleRadius) {
int trackPosition = MusicController.getPosition();
@ -285,8 +289,7 @@ public class Slider implements HitObject {
if (result > -1) {
data.addHitError(hitObject.getTime(), x,y,trackPosition - hitObject.getTime());
sliderClickedInitial = true;
data.sliderTickResult(hitObject.getTime(), result,
hitObject.getX(), hitObject.getY(), hitObject, currentRepeats);
data.sliderTickResult(hitObject.getTime(), result, this.x, this.y, hitObject, currentRepeats);
return true;
}
}
@ -294,7 +297,7 @@ public class Slider implements HitObject {
}
@Override
public boolean update(boolean overlap, int delta, int mouseX, int mouseY) {
public boolean update(boolean overlap, int delta, int mouseX, int mouseY, boolean keyPressed) {
int repeatCount = hitObject.getRepeatCount();
// slider time and tick calculations
@ -317,7 +320,6 @@ public class Slider implements HitObject {
int trackPosition = MusicController.getPosition();
int[] hitResultOffset = game.getHitResultOffsets();
int lastIndex = hitObject.getSliderX().length - 1;
boolean isAutoMod = GameMod.AUTO.isActive();
if (!sliderClickedInitial) {
@ -328,11 +330,9 @@ public class Slider implements HitObject {
sliderClickedInitial = true;
if (isAutoMod) { // "auto" mod: catch any missed notes due to lag
ticksHit++;
data.sliderTickResult(time, GameData.HIT_SLIDER30,
hitObject.getX(), hitObject.getY(), hitObject, currentRepeats);
data.sliderTickResult(time, GameData.HIT_SLIDER30, x, y, hitObject, currentRepeats);
} else
data.sliderTickResult(time, GameData.HIT_MISS,
hitObject.getX(), hitObject.getY(), hitObject, currentRepeats);
data.sliderTickResult(time, GameData.HIT_MISS, x, y, hitObject, currentRepeats);
}
// "auto" mod: send a perfect hit result
@ -340,8 +340,7 @@ public class Slider implements HitObject {
if (Math.abs(trackPosition - time) < hitResultOffset[GameData.HIT_300]) {
ticksHit++;
sliderClickedInitial = true;
data.sliderTickResult(time, GameData.HIT_SLIDER30,
hitObject.getX(), hitObject.getY(), hitObject, currentRepeats);
data.sliderTickResult(time, GameData.HIT_SLIDER30, x, y, hitObject, currentRepeats);
}
}
@ -355,7 +354,7 @@ public class Slider implements HitObject {
tickIntervals++;
// check if cursor pressed and within end circle
if (Utils.isGameKeyPressed() || GameMod.RELAX.isActive()) {
if (keyPressed || GameMod.RELAX.isActive()) {
float[] c = curve.pointAt(getT(trackPosition, false));
double distance = Math.hypot(c[0] - mouseX, c[1] - mouseY);
int followCircleRadius = GameImage.SLIDER_FOLLOWCIRCLE.getImage().getWidth() / 2;
@ -404,7 +403,7 @@ public class Slider implements HitObject {
float[] c = curve.pointAt(getT(trackPosition, false));
double distance = Math.hypot(c[0] - mouseX, c[1] - mouseY);
int followCircleRadius = GameImage.SLIDER_FOLLOWCIRCLE.getImage().getWidth() / 2;
if (((Utils.isGameKeyPressed() || GameMod.RELAX.isActive()) && distance < followCircleRadius) || isAutoMod) {
if (((keyPressed || GameMod.RELAX.isActive()) && distance < followCircleRadius) || isAutoMod) {
// mouse pressed and within follow circle
followCircleActive = true;
data.changeHealth(delta * GameData.HP_DRAIN_MULTIPLIER);
@ -412,11 +411,11 @@ public class Slider implements HitObject {
// held during new repeat
if (isNewRepeat) {
ticksHit++;
if (currentRepeats % 2 > 0) // last circle
if (currentRepeats % 2 > 0) { // last circle
int lastIndex = hitObject.getSliderX().length;
data.sliderTickResult(trackPosition, GameData.HIT_SLIDER30,
hitObject.getSliderX()[lastIndex], hitObject.getSliderY()[lastIndex],
hitObject, currentRepeats);
else // first circle
curve.getX(lastIndex), curve.getY(lastIndex), hitObject, currentRepeats);
} else // first circle
data.sliderTickResult(trackPosition, GameData.HIT_SLIDER30,
c[0], c[1], hitObject, currentRepeats);
}

View File

@ -194,7 +194,7 @@ public class Spinner implements HitObject {
public boolean mousePressed(int x, int y) { return false; } // not used
@Override
public boolean update(boolean overlap, int delta, int mouseX, int mouseY) {
public boolean update(boolean overlap, int delta, int mouseX, int mouseY, boolean keyPressed) {
int trackPosition = MusicController.getPosition();
// end of spinner
@ -204,7 +204,7 @@ public class Spinner implements HitObject {
}
// game button is released
if (isSpinning && !(Utils.isGameKeyPressed() || GameMod.RELAX.isActive()))
if (isSpinning && !(keyPressed || GameMod.RELAX.isActive()))
isSpinning = false;
// spin automatically
@ -224,7 +224,7 @@ public class Spinner implements HitObject {
angle = (float) Math.atan2(mouseY - (height / 2), mouseX - (width / 2));
// set initial angle to current mouse position to skip first click
if (!isSpinning && (Utils.isGameKeyPressed() || GameMod.RELAX.isActive())) {
if (!isSpinning && (keyPressed || GameMod.RELAX.isActive())) {
lastAngle = angle;
isSpinning = true;
return false;

View File

@ -34,6 +34,12 @@ public abstract class Curve {
/** The color of this curve. */
protected Color color;
/** The scaled starting x, y coordinates. */
protected float x, y;
/** The scaled slider x, y coordinate lists. */
protected float[] sliderX, sliderY;
/**
* Constructor.
* @param hitObject the associated OsuHitObject
@ -41,6 +47,10 @@ public abstract class Curve {
*/
protected Curve(OsuHitObject hitObject, Color color) {
this.hitObject = hitObject;
this.x = hitObject.getScaledX();
this.y = hitObject.getScaledY();
this.sliderX = hitObject.getScaledSliderX();
this.sliderY = hitObject.getScaledSliderY();
this.color = color;
}
@ -67,18 +77,16 @@ public abstract class Curve {
public abstract float getStartAngle();
/**
* Returns the x coordinate of the control point at index i.
* Returns the scaled x coordinate of the control point at index i.
* @param i the control point index
*/
protected float getX(int i) {
return (i == 0) ? hitObject.getX() : hitObject.getSliderX()[i - 1];
}
public float getX(int i) { return (i == 0) ? x : sliderX[i - 1]; }
/**
* Returns the y coordinate of the control point at index i.
* Returns the scaled y coordinate of the control point at index i.
* @param i the control point index
*/
protected float getY(int i) {
return (i == 0) ? hitObject.getY() : hitObject.getSliderY()[i - 1];
}
public float getY(int i) { return (i == 0) ? y : sliderY[i - 1]; }
/**
* Linear interpolation of a and b at t.

View File

@ -0,0 +1,57 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
/**
* Captures a single life frame.
*
* @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/)
*/
package itdelatrisu.opsu.replay;
public class LifeFrame {
/** Time. */
private int time;
/** Percentage. */
private float percentage;
/**
* Constructor.
* @param time the time
* @param percentage the percentage
*/
public LifeFrame(int time, float percentage) {
this.time = time;
this.percentage = percentage;
}
/**
* Returns the frame time.
*/
public int getTime() { return time; }
/**
* Returns the frame percentage.
*/
public float getPercentage() { return percentage; }
@Override
public String toString() {
return String.format("(%d, %.2f)", time, percentage);
}
}

View File

@ -0,0 +1,373 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.replay;
import itdelatrisu.opsu.ErrorHandler;
import itdelatrisu.opsu.Options;
import itdelatrisu.opsu.OsuReader;
import itdelatrisu.opsu.OsuWriter;
import itdelatrisu.opsu.Utils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.CharBuffer;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import lzma.streams.LzmaOutputStream;
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream;
import org.newdawn.slick.util.Log;
/**
* Captures osu! replay data.
* https://osu.ppy.sh/wiki/Osr_%28file_format%29
*
* @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/)
*/
public class Replay {
/** The associated file. */
private File file;
/** Whether or not the replay data has been loaded from the file. */
public boolean loaded = false;
/** The game mode. */
public byte mode;
/** Game version when the replay was created. */
public int version;
/** Beatmap MD5 hash. */
public String beatmapHash;
/** The player's name. */
public String playerName;
/** Replay MD5 hash. */
public String replayHash;
/** Hit result counts. */
public short hit300, hit100, hit50, geki, katu, miss;
/** The score. */
public int score;
/** The max combo. */
public short combo;
/** Whether or not a full combo was achieved. */
public boolean perfect;
/** Game mod bitmask. */
public int mods;
/** Life frames. */
public LifeFrame[] lifeFrames;
/** The time when the replay was created. */
public Date timestamp;
/** Length of the replay data. */
public int replayLength;
/** Replay frames. */
public ReplayFrame[] frames;
/** Seed. (?) */
public int seed;
/** Seed string. */
private static final String SEED_STRING = "-12345";
/**
* Empty constructor.
*/
public Replay() {}
/**
* Constructor.
* @param file the file to load from
*/
public Replay(File file) {
this.file = file;
}
/**
* Loads the replay data.
* @throws IOException failure to load the data
*/
public void load() throws IOException {
if (loaded)
return;
OsuReader reader = new OsuReader(file);
loadHeader(reader);
loadData(reader);
reader.close();
loaded = true;
}
/**
* Loads the replay header data.
* @param reader the associated reader
* @throws IOException
*/
private void loadHeader(OsuReader reader) throws IOException {
this.mode = reader.readByte();
this.version = reader.readInt();
this.beatmapHash = reader.readString();
this.playerName = reader.readString();
this.replayHash = reader.readString();
this.hit300 = reader.readShort();
this.hit100 = reader.readShort();
this.hit50 = reader.readShort();
this.geki = reader.readShort();
this.katu = reader.readShort();
this.miss = reader.readShort();
this.score = reader.readInt();
this.combo = reader.readShort();
this.perfect = reader.readBoolean();
this.mods = reader.readInt();
}
/**
* Loads the replay data.
* @param reader the associated reader
* @throws IOException
*/
private void loadData(OsuReader reader) throws IOException {
// life data
String[] lifeData = reader.readString().split(",");
List<LifeFrame> lifeFrameList = new ArrayList<LifeFrame>(lifeData.length);
for (String frame : lifeData) {
String[] tokens = frame.split("\\|");
if (tokens.length < 2)
continue;
try {
int time = Integer.parseInt(tokens[0]);
float percentage = Float.parseFloat(tokens[1]);
lifeFrameList.add(new LifeFrame(time, percentage));
} catch (NumberFormatException e) {
Log.warn(String.format("Failed to load life frame: '%s'", frame), e);
}
}
this.lifeFrames = lifeFrameList.toArray(new LifeFrame[lifeFrameList.size()]);
// timestamp
this.timestamp = reader.readDate();
// LZMA-encoded replay data
this.replayLength = reader.readInt();
if (replayLength > 0) {
LZMACompressorInputStream lzma = new LZMACompressorInputStream(reader.getInputStream());
String[] replayFrames = Utils.convertStreamToString(lzma).split(",");
lzma.close();
List<ReplayFrame> replayFrameList = new ArrayList<ReplayFrame>(replayFrames.length);
int lastTime = 0;
for (String frame : replayFrames) {
if (frame.isEmpty())
continue;
String[] tokens = frame.split("\\|");
if (tokens.length < 4)
continue;
try {
if (tokens[0].equals(SEED_STRING)) {
seed = Integer.parseInt(tokens[3]);
continue;
}
int timeDiff = Integer.parseInt(tokens[0]);
int time = timeDiff + lastTime;
float x = Float.parseFloat(tokens[1]);
float y = Float.parseFloat(tokens[2]);
int keys = Integer.parseInt(tokens[3]);
replayFrameList.add(new ReplayFrame(timeDiff, time, x, y, keys));
lastTime = time;
} catch (NumberFormatException e) {
Log.warn(String.format("Failed to parse frame: '%s'", frame), e);
}
}
this.frames = replayFrameList.toArray(new ReplayFrame[replayFrameList.size()]);
}
}
/**
* Saves the replay data to a file in the replays directory.
*/
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 in new thread
final File file = new File(dir, String.format("%s.osr", getReplayFilename()));
new Thread() {
@Override
public void run() {
try (FileOutputStream out = new FileOutputStream(file)) {
OsuWriter writer = new OsuWriter(out);
// header
writer.write(mode);
writer.write(version);
writer.write(beatmapHash);
writer.write(playerName);
writer.write(replayHash);
writer.write(hit300);
writer.write(hit100);
writer.write(hit50);
writer.write(geki);
writer.write(katu);
writer.write(miss);
writer.write(score);
writer.write(combo);
writer.write(perfect);
writer.write(mods);
// life data
StringBuilder sb = new StringBuilder();
if (lifeFrames != null) {
NumberFormat nf = new DecimalFormat("##.##");
for (int i = 0; i < lifeFrames.length; i++) {
LifeFrame frame = lifeFrames[i];
sb.append(String.format("%d|%s,",
frame.getTime(), nf.format(frame.getPercentage())));
}
}
writer.write(sb.toString());
// timestamp
writer.write(timestamp);
// LZMA-encoded replay data
if (frames != null && frames.length > 0) {
// build full frame string
NumberFormat nf = new DecimalFormat("###.#####");
sb = new StringBuilder();
for (int i = 0; i < frames.length; i++) {
ReplayFrame frame = frames[i];
sb.append(String.format("%d|%s|%s|%d,",
frame.getTimeDiff(), nf.format(frame.getX()),
nf.format(frame.getY()), frame.getKeys()));
}
sb.append(String.format("%s|0|0|%d", SEED_STRING, seed));
// get bytes from string
CharsetEncoder encoder = StandardCharsets.US_ASCII.newEncoder();
CharBuffer buffer = CharBuffer.wrap(sb);
byte[] bytes = encoder.encode(buffer).array();
// compress data
ByteArrayOutputStream bout = new ByteArrayOutputStream();
LzmaOutputStream compressedOut = new LzmaOutputStream.Builder(bout).useMediumDictionarySize().build();
try {
compressedOut.write(bytes);
} catch (IOException e) {
// possible OOM: https://github.com/jponge/lzma-java/issues/9
ErrorHandler.error("LZMA compression failed (possible out-of-memory error).", e, true);
}
compressedOut.close();
bout.close();
// write to file
byte[] compressed = bout.toByteArray();
writer.write(compressed.length);
writer.write(compressed);
} else
writer.write(0);
writer.close();
} catch (IOException e) {
ErrorHandler.error("Could not save replay data.", e, true);
}
}
}.start();
}
/**
* 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;
final int MAX_LINES = LINE_SPLIT * 10;
StringBuilder sb = new StringBuilder();
sb.append("File: "); sb.append(file.getName()); sb.append('\n');
sb.append("Mode: "); sb.append(mode); sb.append('\n');
sb.append("Version: "); sb.append(version); sb.append('\n');
sb.append("Beatmap hash: "); sb.append(beatmapHash); sb.append('\n');
sb.append("Player name: "); sb.append(playerName); sb.append('\n');
sb.append("Replay hash: "); sb.append(replayHash); sb.append('\n');
sb.append("Hits: ");
sb.append(hit300); sb.append(' ');
sb.append(hit100); sb.append(' ');
sb.append(hit50); sb.append(' ');
sb.append(geki); sb.append(' ');
sb.append(katu); sb.append(' ');
sb.append(miss); sb.append('\n');
sb.append("Score: "); sb.append(score); sb.append('\n');
sb.append("Max combo: "); sb.append(combo); sb.append('\n');
sb.append("Perfect: "); sb.append(perfect); sb.append('\n');
sb.append("Mods: "); sb.append(mods); sb.append('\n');
sb.append("Life data ("); sb.append(lifeFrames.length); sb.append(" total):\n");
for (int i = 0; i < lifeFrames.length && i < MAX_LINES; i++) {
if (i % LINE_SPLIT == 0)
sb.append('\t');
sb.append(lifeFrames[i]);
sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' ');
}
sb.append('\n');
sb.append("Timestamp: "); sb.append(timestamp); sb.append('\n');
sb.append("Replay length: "); sb.append(replayLength); sb.append('\n');
if (frames != null) {
sb.append("Frames ("); sb.append(frames.length); sb.append(" total):\n");
for (int i = 0; i < frames.length && i < MAX_LINES; i++) {
if (i % LINE_SPLIT == 0)
sb.append('\t');
sb.append(frames[i]);
sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' ');
}
sb.append('\n');
}
sb.append("Seed: "); sb.append(seed); sb.append('\n');
return sb.toString();
}
}

View File

@ -0,0 +1,122 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.replay;
import itdelatrisu.opsu.OsuHitObject;
/**
* Captures a single replay frame.
*
* @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/)
*/
public class ReplayFrame {
/** Key bits. */
public static final int
KEY_NONE = 0,
KEY_M1 = (1 << 0),
KEY_M2 = (1 << 1),
KEY_K1 = (1 << 2) | (1 << 0),
KEY_K2 = (1 << 3) | (1 << 1);
/** Time, in milliseconds, since the previous action. */
private int timeDiff;
/** Time, in milliseconds. */
private int time;
/** Cursor coordinates (in OsuPixels). */
private float x, y;
/** Keys pressed (bitmask). */
private int keys;
/**
* Returns the start frame.
* @param t the value for the {@code time} and {@code timeDiff} fields
*/
public static ReplayFrame getStartFrame(int t) {
return new ReplayFrame(t, t, 256, -500, 0);
}
/**
* Constructor.
* @param timeDiff time since the previous action (in ms)
* @param time time (in ms)
* @param x cursor x coordinate [0, 512]
* @param y cursor y coordinate [0, 384]
* @param keys keys pressed (bitmask)
*/
public ReplayFrame(int timeDiff, int time, float x, float y, int keys) {
this.timeDiff = timeDiff;
this.time = time;
this.x = x;
this.y = y;
this.keys = keys;
}
/**
* Returns the frame time, in milliseconds.
*/
public int getTime() { return time; }
/**
* Returns the time since the previous action, in milliseconds.
*/
public int getTimeDiff() { return timeDiff; }
/**
* Sets the time since the previous action, in milliseconds.
*/
public void setTimeDiff(int diff) { this.timeDiff = diff; }
/**
* Returns the raw cursor x coordinate.
*/
public float getX() { return x; }
/**
* Returns the raw cursor y coordinate.
*/
public float getY() { return y; }
/**
* Returns the scaled cursor x coordinate.
*/
public int getScaledX() { return (int) (x * OsuHitObject.getXMultiplier() + OsuHitObject.getXOffset()); }
/**
* Returns the scaled cursor y coordinate.
*/
public int getScaledY() { return (int) (y * OsuHitObject.getYMultiplier() + OsuHitObject.getYOffset()); }
/**
* Returns the keys pressed (KEY_* bitmask).
*/
public int getKeys() { return keys; }
/**
* Returns whether or not a key is pressed.
*/
public boolean isKeyPressed() { return (keys != KEY_NONE); }
@Override
public String toString() {
return String.format("(%d, [%.2f, %.2f], %d)", time, x, y, keys);
}
}

View File

@ -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,17 +36,22 @@ 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;
import itdelatrisu.opsu.objects.Slider;
import itdelatrisu.opsu.objects.Spinner;
import itdelatrisu.opsu.replay.Replay;
import itdelatrisu.opsu.replay.ReplayFrame;
import java.io.File;
import java.util.LinkedList;
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;
@ -71,6 +77,8 @@ public class Game extends BasicGameState {
NEW,
/** Manual retry. */
MANUAL,
/** Replay. */
REPLAY,
/** Health is zero: no-continue/force restart. */
LOSE;
}
@ -146,6 +154,42 @@ public class Game extends BasicGameState {
/** Number of retries. */
private int retries = 0;
/** Whether or not this game is a replay. */
private boolean isReplay = false;
/** The replay, if any. */
private Replay replay;
/** The current replay frame index. */
private int replayIndex = 0;
/** The replay cursor coordinates. */
private int replayX, replayY;
/** Whether a replay key is currently pressed. */
private boolean replayKeyPressed;
/** The replay skip time, or -1 if none. */
private int replaySkipTime = -1;
/** The thread updating the replay frames. */
private Thread replayThread;
/** Whether or not the replay thread should continue running. */
private boolean replayThreadRunning;
/** The last replay frame time. */
private int lastReplayTime = 0;
/** The last game keys pressed. */
private int lastKeysPressed = ReplayFrame.KEY_NONE;
/** The previous game mod state (before the replay). */
private int previousMods = 0;
/** The list of current replay frames (for recording replays). */
private LinkedList<ReplayFrame> replayFrames;
// game-related variables
private GameContainer container;
private StateBasedGame game;
@ -259,7 +303,10 @@ public class Game extends BasicGameState {
if (GameMod.AUTO.isActive())
GameImage.UNRANKED.getImage().drawCentered(width / 2, height * 0.077f);
UI.draw(g);
if (!isReplay)
UI.draw(g);
else
UI.draw(g, replayX, replayY, replayKeyPressed);
return;
}
}
@ -365,14 +412,24 @@ public class Game extends BasicGameState {
cursorCirclePulse.drawCentered(pausedMouseX, pausedMouseY);
}
UI.draw(g);
if (!isReplay)
UI.draw(g);
else
UI.draw(g, replayX, replayY, replayKeyPressed);
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
UI.update(delta);
int mouseX = input.getMouseX(), mouseY = input.getMouseY();
int mouseX, mouseY;
if (!isReplay) {
mouseX = input.getMouseX();
mouseY = input.getMouseY();
} else {
mouseX = replayX;
mouseY = replayY;
}
skipButton.hoverUpdate(delta, mouseX, mouseY);
if (isLeadIn()) { // stop updating during song lead-in
@ -423,15 +480,31 @@ public class Game extends BasicGameState {
if (objectIndex >= hitObjects.length || (MusicController.trackEnded() && objectIndex > 0)) {
// track ended before last object was processed: force a hit result
if (MusicController.trackEnded() && objectIndex < hitObjects.length)
hitObjects[objectIndex].update(true, delta, mouseX, mouseY);
hitObjects[objectIndex].update(true, delta, mouseX, mouseY, false);
if (checkpointLoaded) // if checkpoint used, skip ranking screen
// if checkpoint used, skip ranking screen
if (checkpointLoaded)
game.closeRequested();
else { // go to ranking screen
// go to ranking screen
else {
((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data);
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));
Replay r = data.getReplay(replayFrames.toArray(new ReplayFrame[replayFrames.size()]));
if (r != null)
r.save();
}
ScoreData score = data.getScoreData(osu);
if (!GameMod.AUTO.isActive() && !GameMod.RELAX.isActive() && !GameMod.AUTOPILOT.isActive())
// add score to database
if (!GameMod.AUTO.isActive() && !GameMod.RELAX.isActive() && !GameMod.AUTOPILOT.isActive() && !isReplay)
ScoreDB.addScore(score);
game.enterState(Opsu.STATE_GAMERANKING, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
}
return;
@ -453,6 +526,15 @@ public class Game extends BasicGameState {
}
}
// replays
if (isReplay) {
// skip intro
if (replaySkipTime > 0 && trackPosition > replaySkipTime) {
skipIntro();
replaySkipTime = -1;
}
}
// song beginning
if (objectIndex == 0 && trackPosition < osu.objects[0].getTime())
return; // nothing to do here
@ -478,7 +560,7 @@ public class Game extends BasicGameState {
}
// pause game if focus lost
if (!container.hasFocus() && !GameMod.AUTO.isActive()) {
if (!container.hasFocus() && !GameMod.AUTO.isActive() && !isReplay) {
if (pauseTime < 0) {
pausedMouseX = mouseX;
pausedMouseY = mouseY;
@ -503,18 +585,21 @@ public class Game extends BasicGameState {
}
// game over, force a restart
restart = Restart.LOSE;
game.enterState(Opsu.STATE_GAMEPAUSEMENU);
if (!isReplay) {
restart = Restart.LOSE;
game.enterState(Opsu.STATE_GAMEPAUSEMENU);
}
}
// update objects (loop in unlikely event of any skipped indexes)
boolean keyPressed = ((isReplay && replayKeyPressed) || Utils.isGameKeyPressed());
while (objectIndex < hitObjects.length && trackPosition > osu.objects[objectIndex].getTime()) {
// check if we've already passed the next object's start time
boolean overlap = (objectIndex + 1 < hitObjects.length &&
trackPosition > osu.objects[objectIndex + 1].getTime() - hitResultOffset[GameData.HIT_300]);
// update hit object and check completion status
if (hitObjects[objectIndex].update(overlap, delta, mouseX, mouseY))
if (hitObjects[objectIndex].update(overlap, delta, mouseX, mouseY, keyPressed))
objectIndex++; // done, so increment object index
else
break;
@ -529,17 +614,17 @@ public class Game extends BasicGameState {
int trackPosition = MusicController.getPosition();
// game keys
if (!Keyboard.isRepeatEvent()) {
if (!Keyboard.isRepeatEvent() && !isReplay) {
if (key == Options.getGameKeyLeft())
gameKeyPressed(Input.MOUSE_LEFT_BUTTON, input.getMouseX(), input.getMouseY());
gameKeyPressed(ReplayFrame.KEY_K1, input.getMouseX(), input.getMouseY());
else if (key == Options.getGameKeyRight())
gameKeyPressed(Input.MOUSE_RIGHT_BUTTON, input.getMouseX(), input.getMouseY());
gameKeyPressed(ReplayFrame.KEY_K2, input.getMouseX(), input.getMouseY());
}
switch (key) {
case Input.KEY_ESCAPE:
// "auto" mod: go back to song menu
if (GameMod.AUTO.isActive()) {
// "auto" mod or watching replay: go back to song menu
if (GameMod.AUTO.isActive() || isReplay) {
game.closeRequested();
break;
}
@ -592,6 +677,7 @@ public class Game extends BasicGameState {
if (checkpoint == 0 || checkpoint > osu.endTime)
break; // invalid checkpoint
try {
killReplayThread();
restart = Restart.MANUAL;
enter(container, game);
checkpointLoaded = true;
@ -636,6 +722,14 @@ public class Game extends BasicGameState {
if (Options.isMouseDisabled())
return;
// watching replay
if (isReplay) {
// only allow skip button
if (button != Input.MOUSE_MIDDLE_BUTTON && skipButton.contains(x, y))
skipIntro();
return;
}
// mouse wheel: pause the game
if (button == Input.MOUSE_MIDDLE_BUTTON && !Options.isMouseWheelDisabled()) {
int trackPosition = MusicController.getPosition();
@ -650,16 +744,16 @@ public class Game extends BasicGameState {
return;
}
gameKeyPressed(button, x, y);
gameKeyPressed((button == Input.MOUSE_LEFT_BUTTON) ? ReplayFrame.KEY_M1 : ReplayFrame.KEY_M2, x, y);
}
/**
* Handles a game key pressed event.
* @param button the index of the button pressed
* @param keys the game keys pressed
* @param x the mouse x coordinate
* @param y the mouse y coordinate
*/
private void gameKeyPressed(int button, int x, int y) {
private void gameKeyPressed(int keys, int x, int y) {
// returning from pause screen
if (pauseTime > -1) {
double distance = Math.hypot(pausedMouseX - x, pausedMouseY - y);
@ -690,6 +784,8 @@ public class Game extends BasicGameState {
if (GameMod.AUTO.isActive() || GameMod.RELAX.isActive())
return;
addReplayFrame(x, y, lastKeysPressed | keys);
// circles
if (hitObject.isCircle() && hitObjects[objectIndex].mousePressed(x, y))
objectIndex++; // circle hit
@ -699,6 +795,36 @@ public class Game extends BasicGameState {
hitObjects[objectIndex].mousePressed(x, y);
}
@Override
public void mouseReleased(int button, int x, int y) {
if (Options.isMouseDisabled())
return;
if (button == Input.MOUSE_MIDDLE_BUTTON)
return;
int key = (button == Input.MOUSE_LEFT_BUTTON) ? ReplayFrame.KEY_M1 : ReplayFrame.KEY_M2;
if ((lastKeysPressed & key) > 0)
addReplayFrame(x, y, ReplayFrame.KEY_NONE);
}
@Override
public void keyReleased(int key, char c) {
if ((key == Options.getGameKeyLeft() && (lastKeysPressed & ReplayFrame.KEY_K1) > 0) ||
(key == Options.getGameKeyRight() && (lastKeysPressed & ReplayFrame.KEY_K2) > 0))
addReplayFrame(input.getMouseX(), input.getMouseY(), ReplayFrame.KEY_NONE);
}
@Override
public void mouseMoved(int oldx, int oldy, int newx, int newy) {
addReplayFrame(newx, newy, lastKeysPressed);
}
@Override
public void mouseDragged(int oldx, int oldy, int newx, int newy) {
addReplayFrame(newx, newy, lastKeysPressed);
}
@Override
public void mouseWheelMoved(int newValue) {
if (Options.isMouseWheelDisabled() || Options.isMouseDisabled())
@ -711,8 +837,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.");
@ -727,10 +851,11 @@ public class Game extends BasicGameState {
loadImages();
setMapModifiers();
retries = 0;
} else {
} else if (restart == Restart.MANUAL) {
// retry
retries++;
}
} else if (restart == Restart.REPLAY)
retries = 0;
// reset game data
resetGameData();
@ -769,6 +894,75 @@ public class Game extends BasicGameState {
}
}
// load replay frames
if (isReplay) {
// unhide cursor
UI.showCursor();
// load mods
previousMods = GameMod.getModState();
GameMod.loadModState(replay.mods);
// load initial data
replayX = container.getWidth() / 2;
replayY = container.getHeight() / 2;
replayKeyPressed = false;
replaySkipTime = -1;
for (replayIndex = 0; replayIndex < replay.frames.length; replayIndex++) {
ReplayFrame frame = replay.frames[replayIndex];
if (frame.getY() < 0) { // skip time (?)
if (frame.getTime() > 0)
replaySkipTime = frame.getTime();
} else if (frame.getTime() == 0) {
replayX = frame.getScaledX();
replayY = frame.getScaledY();
replayKeyPressed = frame.isKeyPressed();
} else
break;
}
// run frame updates in another thread
killReplayThread();
replayThread = new Thread() {
@Override
public void run() {
while (replayThreadRunning) {
// update frames
int trackPosition = MusicController.getPosition();
while (replayIndex < replay.frames.length && trackPosition >= replay.frames[replayIndex].getTime()) {
ReplayFrame frame = replay.frames[replayIndex];
replayX = frame.getScaledX();
replayY = frame.getScaledY();
replayKeyPressed = frame.isKeyPressed();
if (replayKeyPressed) // send a key press
gameKeyPressed(frame.getKeys(), replayX, replayY);
replayIndex++;
}
// out of frames
if (replayIndex >= replay.frames.length)
break;
// sleep execution
try {
Thread.sleep(0, 256000);
} catch (InterruptedException e) {}
}
}
};
replayThreadRunning = true;
replayThread.start();
}
// initialize replay-recording structures
else {
lastReplayTime = 0;
lastKeysPressed = ReplayFrame.KEY_NONE;
replaySkipTime = -1;
replayFrames = new LinkedList<ReplayFrame>();
replayFrames.add(new ReplayFrame(0, 0, input.getMouseX(), input.getMouseY(), 0));
}
leadInTime = osu.audioLeadIn + approachTime;
restart = Restart.FALSE;
}
@ -776,11 +970,31 @@ public class Game extends BasicGameState {
skipButton.resetHover();
}
// @Override
// public void leave(GameContainer container, StateBasedGame game)
// throws SlickException {
@Override
public void leave(GameContainer container, StateBasedGame game)
throws SlickException {
// container.setMouseGrabbed(false);
// }
// replays
if (isReplay) {
GameMod.loadModState(previousMods);
UI.hideCursor();
killReplayThread();
}
}
/**
* 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.
@ -805,6 +1019,7 @@ public class Game extends BasicGameState {
checkpointLoaded = false;
deaths = 0;
deathTime = -1;
replayFrames = null;
System.gc();
}
@ -813,15 +1028,17 @@ public class Game extends BasicGameState {
* Skips the beginning of a track.
* @return true if skipped, false otherwise
*/
private boolean skipIntro() {
private synchronized boolean skipIntro() {
int firstObjectTime = osu.objects[0].getTime();
int trackPosition = MusicController.getPosition();
if (objectIndex == 0 &&
trackPosition < firstObjectTime - SKIP_OFFSET) {
if (objectIndex == 0 && trackPosition < firstObjectTime - SKIP_OFFSET) {
if (isLeadIn()) {
leadInTime = 0;
MusicController.resume();
}
replaySkipTime = (isReplay) ? -1 : trackPosition;
if (replayThread != null && replayThread.isAlive())
replayThread.interrupt();
MusicController.setPosition(firstObjectTime - SKIP_OFFSET);
SoundController.playSound(SoundEffect.MENUHIT);
return true;
@ -950,4 +1167,52 @@ public class Game extends BasicGameState {
* Returns the slider multiplier given by the current timing point.
*/
public float getTimingPointMultiplier() { return beatLength / beatLengthBase; }
/**
* Kills the running replay updating thread, if any.
*/
private void killReplayThread() {
if (replayThread != null && replayThread.isAlive()) {
replayThreadRunning = false;
replayThread.interrupt();
}
replayThread = null;
}
/**
* Sets a replay to view, or resets the replay if null.
* @param replay the replay
*/
public void setReplay(Replay replay) {
if (replay == null) {
this.isReplay = false;
this.replay = null;
} else {
if (replay.frames == null) {
ErrorHandler.error("Attempting to set a replay with no frames.", null, false);
return;
}
this.isReplay = true;
this.replay = replay;
}
}
/**
* Adds a replay frame to the list.
* @param x the cursor x coordinate
* @param y the cursor y coordinate
* @param keys the keys pressed
*/
private void addReplayFrame(int x, int y, int keys) {
if (isReplay)
return;
int time = MusicController.getPosition();
int timeDiff = time - lastReplayTime;
lastReplayTime = time;
lastKeysPressed = keys;
int cx = (int) ((x - OsuHitObject.getXOffset()) / OsuHitObject.getXMultiplier());
int cy = (int) ((y - OsuHitObject.getYOffset()) / OsuHitObject.getYMultiplier());
replayFrames.add(new ReplayFrame(timeDiff, time, cx, cy, lastKeysPressed));
}
}

View File

@ -29,6 +29,10 @@ import itdelatrisu.opsu.Utils;
import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
import itdelatrisu.opsu.replay.Replay;
import java.io.FileNotFoundException;
import java.io.IOException;
import org.lwjgl.opengl.Display;
import org.newdawn.slick.Color;
@ -41,12 +45,13 @@ import org.newdawn.slick.state.BasicGameState;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
import org.newdawn.slick.util.Log;
/**
* "Game Ranking" (score card) state.
* <ul>
* <li>[Retry] - restart game (return to game state)
* <li>[Exit] - return to main menu state
* <li>[Replay] - watch replay (return to game state)
* <li>[Back] - return to song menu state
* </ul>
*/
@ -54,8 +59,11 @@ public class GameRanking extends BasicGameState {
/** Associated GameData object. */
private GameData data;
/** "Retry" and "Exit" buttons. */
private MenuButton retryButton, exitButton;
/** "Retry" and "Replay" buttons. */
private MenuButton retryButton, replayButton;
/** Button coordinates. */
private float retryY, replayY;
// game-related variables
private GameContainer container;
@ -78,18 +86,14 @@ public class GameRanking extends BasicGameState {
int height = container.getHeight();
// buttons
Image retry = GameImage.RANKING_RETRY.getImage();
Image exit = GameImage.RANKING_EXIT.getImage();
retryButton = new MenuButton(retry,
width - (retry.getWidth() / 2f),
(height * 0.97f) - (exit.getHeight() * 1.5f)
);
exitButton = new MenuButton(exit,
width - (exit.getWidth() / 2f),
(height * 0.97f) - (exit.getHeight() / 2f)
);
retryButton.setHoverFade(0.6f);
exitButton.setHoverFade(0.6f);
Image retry = GameImage.PAUSE_RETRY.getImage();
Image replay = GameImage.PAUSE_REPLAY.getImage();
replayY = (height * 0.985f) - replay.getHeight() / 2f;
retryY = replayY - (replay.getHeight() / 2f) - (retry.getHeight() / 1.975f);
retryButton = new MenuButton(retry, width - (retry.getWidth() / 2f), retryY);
replayButton = new MenuButton(replay, width - (replay.getWidth() / 2f), replayY);
retryButton.setHoverFade();
replayButton.setHoverFade();
}
@Override
@ -108,10 +112,9 @@ public class GameRanking extends BasicGameState {
data.drawRankingElements(g, osu);
// buttons
if (data.isGameplay()) {
replayButton.draw();
if (data.isGameplay())
retryButton.draw();
exitButton.draw();
}
UI.getBackButton().draw();
UI.draw(g);
@ -122,10 +125,10 @@ public class GameRanking extends BasicGameState {
throws SlickException {
UI.update(delta);
int mouseX = input.getMouseX(), mouseY = input.getMouseY();
if (data.isGameplay()) {
replayButton.hoverUpdate(delta, mouseX, mouseY);
if (data.isGameplay())
retryButton.hoverUpdate(delta, mouseX, mouseY);
exitButton.hoverUpdate(delta, mouseX, mouseY);
} else
else
MusicController.loopTrackIfEnded(true);
UI.getBackButton().hoverUpdate(delta, mouseX, mouseY);
}
@ -157,27 +160,47 @@ public class GameRanking extends BasicGameState {
if (button == Input.MOUSE_MIDDLE_BUTTON)
return;
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();
UI.resetCursor();
game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
return;
}
}
// back to menu
if (UI.getBackButton().contains(x, y)) {
returnToSongMenu();
return;
}
// 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) {
try {
r.load();
gameState.setReplay(r);
gameState.setRestart((data.isGameplay()) ? Game.Restart.REPLAY : Game.Restart.NEW);
returnToGame = true;
} catch (FileNotFoundException e) {
UI.sendBarNotification("Replay file not found.");
} catch (IOException e) {
Log.error("Failed to load replay data.", e);
UI.sendBarNotification("Failed to load replay data. See log for details.");
}
} else
UI.sendBarNotification("Replay file not found.");
}
// retry
else if (data.isGameplay() && retryButton.contains(x, y)) {
gameState.setReplay(null);
gameState.setRestart(Game.Restart.MANUAL);
returnToGame = true;
}
if (returnToGame) {
OsuFile osu = MusicController.getOsuFile();
gameState.loadOsuFile(osu);
SoundController.playSound(SoundEffect.MENUHIT);
game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
return;
}
}
@Override
@ -188,17 +211,21 @@ public class GameRanking extends BasicGameState {
if (!data.isGameplay()) {
if (!MusicController.isTrackDimmed())
MusicController.toggleTrackDimmed(0.5f);
replayButton.setY(retryY);
} else {
SoundController.playSound(SoundEffect.APPLAUSE);
retryButton.resetHover();
exitButton.resetHover();
replayButton.setY(replayY);
}
replayButton.resetHover();
}
@Override
public void leave(GameContainer container, StateBasedGame game)
throws SlickException {
this.data = null;
if (MusicController.isTrackDimmed())
MusicController.toggleTrackDimmed(1f);
}
/**

View File

@ -97,8 +97,8 @@ public class MainMenu extends BasicGameState {
/** Background alpha level (for fade-in effect). */
private float bgAlpha = 0f;
/** Whether or not an update notification was already sent. */
private boolean updateNotification = false;
/** Whether or not a notification was already sent upon entering. */
private boolean enterNotification = false;
/** Music position bar coordinates and dimensions. */
private float musicBarX, musicBarY, musicBarWidth, musicBarHeight;
@ -388,9 +388,14 @@ public class MainMenu extends BasicGameState {
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
UI.enter();
if (!updateNotification && Updater.get().getStatus() == Updater.Status.UPDATE_AVAILABLE) {
UI.sendBarNotification("An opsu! update is available.");
updateNotification = true;
if (!enterNotification) {
if (Updater.get().getStatus() == Updater.Status.UPDATE_AVAILABLE) {
UI.sendBarNotification("An opsu! update is available.");
enterNotification = true;
} else if (Updater.get().justUpdated()) {
UI.sendBarNotification("opsu! is now up to date!");
enterNotification = true;
}
}
// reset button hover states if mouse is not currently hovering over the button

View File

@ -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,12 @@ 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();
((Game) game.getState(Opsu.STATE_GAME)).setRestart(Game.Restart.NEW);
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));
}
}

Binary file not shown.