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;