diff --git a/pom.xml b/pom.xml
index 33147f42..887bced8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -197,5 +197,10 @@
maven-artifact
3.0.3
+
+ org.apache.commons
+ commons-compress
+ 1.8
+
diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java
index a0a16908..a476e78d 100644
--- a/src/itdelatrisu/opsu/GameData.java
+++ b/src/itdelatrisu/opsu/GameData.java
@@ -1212,12 +1212,7 @@ 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();
return scoreData;
}
diff --git a/src/itdelatrisu/opsu/GameMod.java b/src/itdelatrisu/opsu/GameMod.java
index da5633f4..473366f7 100644
--- a/src/itdelatrisu/opsu/GameMod.java
+++ b/src/itdelatrisu/opsu/GameMod.java
@@ -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
diff --git a/src/itdelatrisu/opsu/OsuHitObject.java b/src/itdelatrisu/opsu/OsuHitObject.java
index 6d0a0072..39d61929 100644
--- a/src/itdelatrisu/opsu/OsuHitObject.java
+++ b/src/itdelatrisu/opsu/OsuHitObject.java
@@ -129,6 +129,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
diff --git a/src/itdelatrisu/opsu/OsuReader.java b/src/itdelatrisu/opsu/OsuReader.java
new file mode 100644
index 00000000..e5ebd50c
--- /dev/null
+++ b/src/itdelatrisu/opsu/OsuReader.java
@@ -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 .
+ */
+
+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 * = normal string
+ // is encoded as an LEB, and is the byte length of the rest.
+ // * 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);
+ }
+}
diff --git a/src/itdelatrisu/opsu/ScoreData.java b/src/itdelatrisu/opsu/ScoreData.java
index 37e25518..9152429e 100644
--- a/src/itdelatrisu/opsu/ScoreData.java
+++ b/src/itdelatrisu/opsu/ScoreData.java
@@ -19,6 +19,7 @@
package itdelatrisu.opsu;
import itdelatrisu.opsu.GameData.Grade;
+import itdelatrisu.opsu.replay.Replay;
import itdelatrisu.opsu.states.SongMenu;
import java.sql.ResultSet;
@@ -59,6 +60,9 @@ public class ScoreData implements Comparable {
/** Game mod bitmask. */
public int mods;
+ /** The replay. */
+ public Replay replay;
+
/** Time since the score was achieved. */
private String timeSince;
@@ -153,6 +157,7 @@ public class ScoreData implements Comparable {
this.combo = rs.getInt(15);
this.perfect = rs.getBoolean(16);
this.mods = rs.getInt(17);
+// this.replay = ; // TODO
}
/**
diff --git a/src/itdelatrisu/opsu/UI.java b/src/itdelatrisu/opsu/UI.java
index 4ef0ec3d..3aa7f1d9 100644
--- a/src/itdelatrisu/opsu/UI.java
+++ b/src/itdelatrisu/opsu/UI.java
@@ -120,6 +120,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 +175,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 +205,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 +254,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);
diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java
index 0b4d631e..fc459b46 100644
--- a/src/itdelatrisu/opsu/Utils.java
+++ b/src/itdelatrisu/opsu/Utils.java
@@ -41,6 +41,7 @@ import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
+import java.util.Scanner;
import javax.imageio.ImageIO;
@@ -546,4 +547,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() : "";
+ }
+ }
}
diff --git a/src/itdelatrisu/opsu/objects/Circle.java b/src/itdelatrisu/opsu/objects/Circle.java
index ba09ed0c..2c7e2547 100644
--- a/src/itdelatrisu/opsu/objects/Circle.java
+++ b/src/itdelatrisu/opsu/objects/Circle.java
@@ -150,7 +150,7 @@ 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();
diff --git a/src/itdelatrisu/opsu/objects/HitObject.java b/src/itdelatrisu/opsu/objects/HitObject.java
index d68e23b0..1f4f64b6 100644
--- a/src/itdelatrisu/opsu/objects/HitObject.java
+++ b/src/itdelatrisu/opsu/objects/HitObject.java
@@ -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.
diff --git a/src/itdelatrisu/opsu/objects/Slider.java b/src/itdelatrisu/opsu/objects/Slider.java
index 4211ae5a..652c5e0b 100644
--- a/src/itdelatrisu/opsu/objects/Slider.java
+++ b/src/itdelatrisu/opsu/objects/Slider.java
@@ -294,7 +294,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
@@ -355,7 +355,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 +404,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);
diff --git a/src/itdelatrisu/opsu/objects/Spinner.java b/src/itdelatrisu/opsu/objects/Spinner.java
index 558cb38c..cbe6038d 100644
--- a/src/itdelatrisu/opsu/objects/Spinner.java
+++ b/src/itdelatrisu/opsu/objects/Spinner.java
@@ -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;
diff --git a/src/itdelatrisu/opsu/replay/LifeFrame.java b/src/itdelatrisu/opsu/replay/LifeFrame.java
new file mode 100644
index 00000000..f53e8745
--- /dev/null
+++ b/src/itdelatrisu/opsu/replay/LifeFrame.java
@@ -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 .
+ */
+
+/**
+ * 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);
+ }
+}
diff --git a/src/itdelatrisu/opsu/replay/Replay.java b/src/itdelatrisu/opsu/replay/Replay.java
new file mode 100644
index 00000000..086fa9c8
--- /dev/null
+++ b/src/itdelatrisu/opsu/replay/Replay.java
@@ -0,0 +1,221 @@
+/*
+ * opsu! - an open-source osu! client
+ * Copyright (C) 2014, 2015 Jeffrey Han
+ *
+ * opsu! is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * opsu! is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with opsu!. If not, see .
+ */
+
+package itdelatrisu.opsu.replay;
+
+import itdelatrisu.opsu.OsuReader;
+import itdelatrisu.opsu.Utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+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;
+
+ /** 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 long 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;
+
+ /**
+ * Constructor.
+ * @param file the file to load from
+ */
+ public Replay(File file) {
+ this.file = file;
+ try {
+ OsuReader reader = new OsuReader(file);
+ loadHeader(reader);
+ loadData(reader);
+ reader.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * 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 lifeFrameList = new ArrayList(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 | NullPointerException e) {
+ Log.warn(String.format("Failed to 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(",");
+ List replayFrameList = new ArrayList(replayFrames.length);
+ int lastTime = 0;
+ for (String frame : replayFrames) {
+ if (frame.isEmpty())
+ continue;
+ String[] tokens = frame.split("\\|");
+ if (tokens.length < 4)
+ continue;
+ try {
+ 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 | NullPointerException e) {
+ Log.warn(String.format("Failed to parse frame: '%s'", frame), e);
+ }
+ }
+ this.frames = replayFrameList.toArray(new ReplayFrame[replayFrameList.size()]);
+ }
+ }
+
+ @Override
+ public String toString() {
+ final int 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:\n");
+ for (int i = 0; i < lifeFrames.length; 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:\n");
+ for (int i = 0; i < frames.length; i++) {
+ if (i % LINE_SPLIT == 0)
+ sb.append('\t');
+ sb.append(frames[i]);
+ sb.append((i % LINE_SPLIT == LINE_SPLIT - 1) ? '\n' : ' ');
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/itdelatrisu/opsu/replay/ReplayFrame.java b/src/itdelatrisu/opsu/replay/ReplayFrame.java
new file mode 100644
index 00000000..e3217578
--- /dev/null
+++ b/src/itdelatrisu/opsu/replay/ReplayFrame.java
@@ -0,0 +1,99 @@
+/*
+ * opsu! - an open-source osu! client
+ * Copyright (C) 2014, 2015 Jeffrey Han
+ *
+ * opsu! is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * opsu! is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with opsu!. If not, see .
+ */
+
+package itdelatrisu.opsu.replay;
+
+import itdelatrisu.opsu.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;
+
+ /**
+ * 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; }
+
+ /**
+ * Returns the scaled cursor x coordinate.
+ */
+ public int getX() { return (int) (x * OsuHitObject.getXMultiplier() + OsuHitObject.getXOffset()); }
+
+ /**
+ * Returns the scaled cursor Y coordinate.
+ */
+ public int getY() { 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);
+ }
+}
diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java
index 84656a00..2c4e4597 100644
--- a/src/itdelatrisu/opsu/states/Game.java
+++ b/src/itdelatrisu/opsu/states/Game.java
@@ -40,6 +40,8 @@ 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.Stack;
@@ -146,6 +148,24 @@ public class Game extends BasicGameState {
/** Number of retries. */
private int retries = 0;
+ /** 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 previous game mod state (before the replay). */
+ private int previousMods = 0;
+
// game-related variables
private GameContainer container;
private StateBasedGame game;
@@ -365,14 +385,24 @@ public class Game extends BasicGameState {
cursorCirclePulse.drawCentered(pausedMouseX, pausedMouseY);
}
- UI.draw(g);
+ if (replay == null)
+ 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 (replay == null) {
+ 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,14 +453,14 @@ 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
game.closeRequested();
else { // go to ranking screen
((GameRanking) game.getState(Opsu.STATE_GAMERANKING)).setGameData(data);
ScoreData score = data.getScoreData(osu);
- if (!GameMod.AUTO.isActive() && !GameMod.RELAX.isActive() && !GameMod.AUTOPILOT.isActive())
+ if (!GameMod.AUTO.isActive() && !GameMod.RELAX.isActive() && !GameMod.AUTOPILOT.isActive() && replay == null)
ScoreDB.addScore(score);
game.enterState(Opsu.STATE_GAMERANKING, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
}
@@ -453,6 +483,32 @@ public class Game extends BasicGameState {
}
}
+ // replays
+ if (replay != null) {
+ // skip intro
+ if (replaySkipTime > 0 && trackPosition > replaySkipTime) {
+ skipIntro();
+ replaySkipTime = -1;
+ }
+
+ // load next frame(s)
+ int replayKey = ReplayFrame.KEY_NONE;
+ while (replayIndex < replay.frames.length && trackPosition >= replay.frames[replayIndex].getTime()) {
+ ReplayFrame frame = replay.frames[replayIndex];
+ replayX = frame.getX();
+ replayY = frame.getY();
+ replayKeyPressed = frame.isKeyPressed();
+ if (replayKeyPressed)
+ replayKey = frame.getKeys();
+ replayIndex++;
+ }
+
+ // send a key press
+ if (replayKey != ReplayFrame.KEY_NONE)
+ gameKeyPressed(((replayKey & ReplayFrame.KEY_M1) > 0) ?
+ Input.MOUSE_LEFT_BUTTON : Input.MOUSE_RIGHT_BUTTON, replayX, replayY);
+ }
+
// song beginning
if (objectIndex == 0 && trackPosition < osu.objects[0].getTime())
return; // nothing to do here
@@ -478,7 +534,7 @@ public class Game extends BasicGameState {
}
// pause game if focus lost
- if (!container.hasFocus() && !GameMod.AUTO.isActive()) {
+ if (!container.hasFocus() && !GameMod.AUTO.isActive() && replay == null) {
if (pauseTime < 0) {
pausedMouseX = mouseX;
pausedMouseY = mouseY;
@@ -503,18 +559,21 @@ public class Game extends BasicGameState {
}
// game over, force a restart
- restart = Restart.LOSE;
- game.enterState(Opsu.STATE_GAMEPAUSEMENU);
+ if (replay == null) {
+ restart = Restart.LOSE;
+ game.enterState(Opsu.STATE_GAMEPAUSEMENU);
+ }
}
// update objects (loop in unlikely event of any skipped indexes)
+ boolean keyPressed = ((replay != null && 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,7 +588,7 @@ public class Game extends BasicGameState {
int trackPosition = MusicController.getPosition();
// game keys
- if (!Keyboard.isRepeatEvent()) {
+ if (!Keyboard.isRepeatEvent() && replay == null) {
if (key == Options.getGameKeyLeft())
gameKeyPressed(Input.MOUSE_LEFT_BUTTON, input.getMouseX(), input.getMouseY());
else if (key == Options.getGameKeyRight())
@@ -538,8 +597,8 @@ public class Game extends BasicGameState {
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() || replay != null) {
game.closeRequested();
break;
}
@@ -636,6 +695,10 @@ public class Game extends BasicGameState {
if (Options.isMouseDisabled())
return;
+ // watching replay
+ if (replay != null)
+ return;
+
// mouse wheel: pause the game
if (button == Input.MOUSE_MIDDLE_BUTTON && !Options.isMouseWheelDisabled()) {
int trackPosition = MusicController.getPosition();
@@ -769,6 +832,31 @@ public class Game extends BasicGameState {
}
}
+ // load replay frames
+ if (replay != null) {
+ // 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.getX();
+ replayY = frame.getY();
+ replayKeyPressed = frame.isKeyPressed();
+ } else
+ break;
+ }
+ }
+
leadInTime = osu.audioLeadIn + approachTime;
restart = Restart.FALSE;
}
@@ -776,11 +864,15 @@ 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);
-// }
+
+ // reset previous mod state
+ if (replay != null)
+ GameMod.loadModState(previousMods);
+ }
/**
* Resets all game data and structures.
@@ -816,12 +908,12 @@ public class Game extends BasicGameState {
private 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 = -1;
MusicController.setPosition(firstObjectTime - SKIP_OFFSET);
SoundController.playSound(SoundEffect.MENUHIT);
return true;
@@ -950,4 +1042,10 @@ public class Game extends BasicGameState {
* Returns the slider multiplier given by the current timing point.
*/
public float getTimingPointMultiplier() { return beatLength / beatLengthBase; }
+
+ /**
+ * Sets a replay to view.
+ * @param replay the replay
+ */
+ public void setReplay(Replay replay) { this.replay = replay; }
}
diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java
index c83c25c3..67caa65e 100644
--- a/src/itdelatrisu/opsu/states/SongMenu.java
+++ b/src/itdelatrisu/opsu/states/SongMenu.java
@@ -41,6 +41,7 @@ import itdelatrisu.opsu.audio.SoundController;
import itdelatrisu.opsu.audio.SoundEffect;
import itdelatrisu.opsu.db.OsuDB;
import itdelatrisu.opsu.db.ScoreDB;
+import itdelatrisu.opsu.replay.Replay;
import itdelatrisu.opsu.states.ButtonMenu.MenuState;
import java.io.File;