This commit is contained in:
Victor 2018-11-15 17:49:04 +02:00
commit c49fa2800f
33 changed files with 10770 additions and 0 deletions

53
build.xml Normal file
View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?><!-- You may freely edit this file. See commented blocks below for --><!-- some examples of how to customize the build. --><!-- (If you delete it and reopen the project it will be recreated.) --><!-- By default, only the Clean and Build commands use this build script. --><project name="OsuReplayDiff" default="default" basedir="." xmlns:fx="javafx:com.sun.javafx.tools.ant">
<description>Builds, tests, and runs the project OsuReplayDiff.</description>
<import file="nbproject/build-impl.xml"/>
<!--
There exist several targets which are by default empty and which can be
used for execution of your tasks. These targets are usually executed
before and after some main targets. Those of them relevant for JavaFX project are:
-pre-init: called before initialization of project properties
-post-init: called after initialization of project properties
-pre-compile: called before javac compilation
-post-compile: called after javac compilation
-pre-compile-test: called before javac compilation of JUnit tests
-post-compile-test: called after javac compilation of JUnit tests
-pre-jfx-jar: called before FX SDK specific <fx:jar> task
-post-jfx-jar: called after FX SDK specific <fx:jar> task
-pre-jfx-deploy: called before FX SDK specific <fx:deploy> task
-post-jfx-deploy: called after FX SDK specific <fx:deploy> task
-pre-jfx-native: called just after -pre-jfx-deploy if <fx:deploy> runs in native packaging mode
-post-jfx-native: called just after -post-jfx-deploy if <fx:deploy> runs in native packaging mode
-post-clean: called after cleaning build products
(Targets beginning with '-' are not intended to be called on their own.)
Example of inserting a HTML postprocessor after javaFX SDK deployment:
<target name="-post-jfx-deploy">
<basename property="jfx.deployment.base" file="${jfx.deployment.jar}" suffix=".jar"/>
<property name="jfx.deployment.html" location="${jfx.deployment.dir}${file.separator}${jfx.deployment.base}.html"/>
<custompostprocess>
<fileset dir="${jfx.deployment.html}"/>
</custompostprocess>
</target>
Example of calling an Ant task from JavaFX SDK. Note that access to JavaFX SDK Ant tasks must be
initialized; to ensure this is done add the dependence on -check-jfx-sdk-version target:
<target name="-post-jfx-jar" depends="-check-jfx-sdk-version">
<echo message="Calling jar task from JavaFX SDK"/>
<fx:jar ...>
...
</fx:jar>
</target>
For more details about JavaFX SDK Ant tasks go to
http://docs.oracle.com/javafx/2/deployment/jfxpub-deployment.htm
For list of available properties check the files
nbproject/build-impl.xml and nbproject/jfx-impl.xml.
-->
</project>

1420
nbproject/build-impl.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
# Do not modify this property in this configuration. It can be re-generated.
$label=Run as WebStart

View File

@ -0,0 +1,2 @@
# Do not modify this property in this configuration. It can be re-generated.
$label=Run in Browser

View File

@ -0,0 +1,8 @@
build.xml.data.CRC32=d513bdc6
build.xml.script.CRC32=94a4c251
build.xml.stylesheet.CRC32=8064a381@1.78.0.48
# This file is used by a NetBeans-based IDE to track changes in generated files such as build-impl.xml.
# Do not edit this file. You may delete it but then the IDE will never regenerate such files for you.
nbproject/build-impl.xml.data.CRC32=d513bdc6
nbproject/build-impl.xml.script.CRC32=f86c884f
nbproject/build-impl.xml.stylesheet.CRC32=05530350@1.79.0.48

4007
nbproject/jfx-impl.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
# Do not modify this property in this configuration. It can be re-generated.
javafx.run.as=webstart

View File

@ -0,0 +1,2 @@
# Do not modify this property in this configuration. It can be re-generated.
javafx.run.as=embedded

View File

@ -0,0 +1,11 @@
auxiliary.org-netbeans-modules-projectapi.issue214819_5f_fx_5f_enabled=true
compile.on.save=true
do.depend=false
do.jar=true
# No need to modify this property unless customizing JavaFX Ant task infrastructure
endorsed.javafx.ant.classpath=.
javac.debug=true
javadoc.preview=true
javafx.run.inbrowser=<Default System Browser>
javafx.run.inbrowser.path=C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe
user.properties.file=C:\\Users\\aNNiMON\\AppData\\Roaming\\NetBeans\\dev\\build.properties

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
<editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/2" lastBookmarkId="0"/>
<open-files xmlns="http://www.netbeans.org/ns/projectui-open-files/2">
<group/>
</open-files>
</project-private>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="system">
<system systemId="http://javafx.com/javafx/8" uri="www.oracle.com/technetwork/java/javase/overview/index.html"/>
</catalog>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,118 @@
annotation.processing.enabled=true
annotation.processing.enabled.in.editor=false
annotation.processing.processors.list=
annotation.processing.run.all.processors=true
annotation.processing.source.output=${build.generated.sources.dir}/ap-source-output
application.title=OsuReplayDiff
application.vendor=aNNiMON
build.classes.dir=${build.dir}/classes
build.classes.excludes=**/*.java,**/*.form
# This directory is removed when the project is cleaned:
build.dir=build
build.generated.dir=${build.dir}/generated
build.generated.sources.dir=${build.dir}/generated-sources
# Only compile against the classpath explicitly listed here:
build.sysclasspath=ignore
build.test.classes.dir=${build.dir}/test/classes
build.test.results.dir=${build.dir}/test/results
compile.on.save=true
compile.on.save.unsupported.javafx=true
# Uncomment to specify the preferred debugger connection transport:
#debug.transport=dt_socket
debug.classpath=\
${run.classpath}
debug.test.classpath=\
${run.test.classpath}
# This directory is removed when the project is cleaned:
dist.dir=dist
dist.jar=${dist.dir}/OsuReplayDiff.jar
dist.javadoc.dir=${dist.dir}/javadoc
endorsed.classpath=
excludes=
file.reference.commons-compress-1.9.jar=commons-compress-1.9.jar
file.reference.xz-1.5.jar=xz-1.5.jar
includes=**
# Non-JavaFX jar file creation is deactivated in JavaFX 2.0+ projects
jar.archive.disabled=true
jar.compress=false
javac.classpath=\
${javafx.classpath.extension}:\
${file.reference.commons-compress-1.9.jar}:\
${file.reference.xz-1.5.jar}
# Space-separated list of extra javac options
javac.compilerargs=
javac.deprecation=false
javac.external.vm=false
javac.processorpath=\
${javac.classpath}
javac.source=1.8
javac.target=1.8
javac.test.classpath=\
${javac.classpath}:\
${build.classes.dir}
javac.test.processorpath=\
${javac.test.classpath}
javadoc.additionalparam=
javadoc.author=false
javadoc.encoding=${source.encoding}
javadoc.noindex=false
javadoc.nonavbar=false
javadoc.notree=false
javadoc.private=false
javadoc.splitindex=true
javadoc.use=true
javadoc.version=false
javadoc.windowtitle=
javafx.application.implementation.version=1.0
javafx.binarycss=false
javafx.classpath.extension=\
${java.home}/lib/javaws.jar:\
${java.home}/lib/deploy.jar:\
${java.home}/lib/plugin.jar
javafx.deploy.allowoffline=true
# If true, application update mode is set to 'background', if false, update mode is set to 'eager'
javafx.deploy.backgroundupdate=false
javafx.deploy.embedJNLP=true
javafx.deploy.includeDT=true
# Set true to prevent creation of temporary copy of deployment artifacts before each run (disables concurrent runs)
javafx.disable.concurrent.runs=false
# Set true to enable multiple concurrent runs of the same WebStart or Run-in-Browser project
javafx.enable.concurrent.external.runs=false
# This is a JavaFX project
javafx.enabled=true
javafx.fallback.class=com.javafx.main.NoJavaFXFallback
# Main class for JavaFX
javafx.main.class=com.annimon.osureplaydiff.Main
javafx.preloader.class=
# This project does not use Preloader
javafx.preloader.enabled=false
javafx.preloader.jar.filename=
javafx.preloader.jar.path=
javafx.preloader.project.path=
javafx.preloader.type=none
# Set true for GlassFish only. Rebases manifest classpaths of JARs in lib dir. Not usable with signed JARs.
javafx.rebase.libs=false
javafx.run.height=600
javafx.run.width=800
# Pre-JavaFX 2.0 WebStart is deactivated in JavaFX 2.0+ projects
jnlp.enabled=false
# Main class for Java launcher
main.class=com.javafx.main.Main
# For improved security specify narrower Codebase manifest attribute to prevent RIAs from being repurposed
manifest.custom.codebase=*
# Specify Permissions manifest attribute to override default (choices: sandbox, all-permissions)
manifest.custom.permissions=
manifest.file=manifest.mf
meta.inf.dir=${src.dir}/META-INF
mkdist.disabled=false
platform.active=default_platform
run.classpath=\
${dist.jar}:\
${javac.classpath}:\
${build.classes.dir}
run.test.classpath=\
${javac.test.classpath}:\
${build.test.classes.dir}
source.encoding=UTF-8
src.dir=src
test.src.dir=test

25
nbproject/project.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://www.netbeans.org/ns/project/1">
<type>org.netbeans.modules.java.j2seproject</type>
<configuration>
<buildExtensions xmlns="http://www.netbeans.org/ns/ant-build-extender/1">
<extension file="jfx-impl.xml" id="jfx3">
<dependency dependsOn="-jfx-copylibs" target="-post-jar"/>
<dependency dependsOn="-rebase-libs" target="-post-jar"/>
<dependency dependsOn="jfx-deployment" target="-post-jar"/>
<dependency dependsOn="jar" target="debug"/>
<dependency dependsOn="jar" target="profile"/>
<dependency dependsOn="jar" target="run"/>
</extension>
</buildExtensions>
<data xmlns="http://www.netbeans.org/ns/j2se-project/3">
<name>OsuReplayDiff</name>
<source-roots>
<root id="src.dir"/>
</source-roots>
<test-roots>
<root id="test.src.dir"/>
</test-roots>
</data>
</configuration>
</project>

View File

@ -0,0 +1,237 @@
/*
*/
package com.annimon.osureplaydiff;
import itdelatrisu.opsu.Options;
import itdelatrisu.opsu.beatmap.Beatmap;
import itdelatrisu.opsu.beatmap.BeatmapParser;
import itdelatrisu.opsu.beatmap.HitObject;
import itdelatrisu.opsu.replay.Replay;
import itdelatrisu.opsu.replay.ReplayFrame;
import itdelatrisu.opsu.skins.Skin;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Arrays;
import java.util.Map;
import java.util.ResourceBundle;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.geometry.Point2D;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.paint.Color;
/**
*
* @author aNNiMON
*/
public class FXMLDocumentController implements Initializable {
private static final int WIDTH = 512, HEIGHT = 384;
@FXML
private Slider slider;
@FXML
private Canvas canvas;
private GraphicsContext gc;
private Beatmap beatmap;
private Skin skin;
private int[][] indices;
private HitObject[] hits;
private ReplayFrame[] framesA, framesB;
@FXML
private void handleButtonAction(ActionEvent event) {
final String songDir = "D:\\GAMES\\osu\\Songs\\332340 Loz Contreras - Sarajevo (Blackmill Remix)";
beatmap = BeatmapParser.parseFile(
new File(songDir + "\\Loz Contreras - Sarajevo (Blackmill Remix) (aNNiMON) [Hard].osu"),
new File(songDir), true);
skin = Options.getSkin();
Replay replayA = new Replay(new File("E://MierivaL - Loz Contreras - Sarajevo (Blackmill Remix) [Hard] (2015-07-09) Osu.osr"));
Replay replayB = new Replay(new File("E://aNNiMON - Loz Contreras - Sarajevo (Blackmill Remix) [Hard] (2015-07-10) Osu.osr"));
try {
replayA.load();
replayB.load();
//System.out.println(replay.toString());
} catch (IOException ex) { }
final HitObject[] hits = beatmap.objects;
final ReplayFrame[] framesA = replayA.frames;
final ReplayFrame[] framesB = replayB.frames;
Arrays.sort(framesA, (a, b) -> Integer.compare(a.getTime(), b.getTime()));
Arrays.sort(framesB, (a, b) -> Integer.compare(a.getTime(), b.getTime()));
indices = new int[3][beatmap.endTime];
int hitIndex = 0;
int frameIndexA1 = 0;
int frameIndexB1 = 0;
final int HIT_TIME = 200;
while (hits[hitIndex].getTime() < HIT_TIME) frameIndexA1++;
while (framesA[frameIndexA1].getTime() < 0) frameIndexA1++;
while (framesB[frameIndexB1].getTime() < 0) frameIndexB1++;
for (int time = 0; time < beatmap.endTime; time++) {
if ( (hitIndex < hits.length) && (time == hits[hitIndex].getTime() - HIT_TIME) ) hitIndex++;
if (frameIndexA1 < framesA.length - 1) {
if (time == framesA[frameIndexA1].getTime()) {
frameIndexA1++;
while ( (frameIndexA1 < framesA.length) &&
(framesA[frameIndexA1 - 1].getTime() == framesA[frameIndexA1].getTime()) ) frameIndexA1++;
}
}
if (frameIndexB1 < framesB.length - 1) {
if (time == framesB[frameIndexB1].getTime()) {
frameIndexB1++;
while ( (frameIndexB1 < framesB.length - 1) &&
(framesB[frameIndexB1 - 1].getTime() == framesB[frameIndexB1].getTime()) ) frameIndexB1++;
}
}
indices[0][time] = hitIndex - 1;
indices[1][time] = frameIndexA1;
indices[2][time] = frameIndexB1;
}
this.hits = hits;
this.framesA = framesA;
this.framesB = framesB;
final int DIV = 1;
slider.setMax(beatmap.endTime / DIV);
// slider.setMax(frames[frames.length - 1].getTime());
slider.valueProperty().addListener((ov, oldValue, newValue) -> {
final int time = newValue.intValue();
draw(time * DIV);
});
draw(0);
}
@Override
public void initialize(URL url, ResourceBundle rb) {
gc = canvas.getGraphicsContext2D();
}
private void draw(int time) {
final int hitIndex = indices[0][time], indexA = indices[1][time], indexB = indices[2][time];
final HitObject hit = (hitIndex == -1) ? null : hits[hitIndex];
final ReplayFrame frameA1 = framesA[indexA];
final ReplayFrame frameA2 = (indexA == framesA.length - 1) ? framesA[indexA] : framesA[indexA + 1];
final ReplayFrame frameB1 = framesB[indexB];
final ReplayFrame frameB2 = (indexB == framesB.length - 1) ? framesB[indexB] : framesB[indexB + 1];
final int playerSize = 10, hitSize = 25;
gc.setFill(Color.WHITE);
gc.fillRect(0, 0, WIDTH, HEIGHT);
if (hit != null) {
gc.setStroke(skin.getComboColors()[hit.getComboIndex()]);
gc.setFill(skin.getComboColors()[hit.getComboIndex()]);
if (hit.isCircle()) {
circle(hit.getX(), hit.getY(), hitSize);
} else if (hit.isSpinner()) {
circle(WIDTH / 2, HEIGHT / 2, HEIGHT / 2);
} else if (hit.isSlider()) {
float[] x = hit.getSliderX();
float[] y = hit.getSliderY();
final int length = x.length;
gc.setLineWidth(hitSize);
gc.beginPath();
gc.moveTo(hit.getX(), hit.getY());
for (int i = 1; i < length - 1; i+=2) {
gc.bezierCurveTo(x[i-1], y[i-1], x[i], y[i], x[i+1], y[i+1]);
}
gc.stroke();
}
}
final float ax = (frameA1.getX() + frameA2.getX()) / 2;
final float ay = (frameA1.getY() + frameA2.getY()) / 2;
final float bx = (frameB1.getX() + frameB2.getX()) / 2;
final float by = (frameB1.getY() + frameB2.getY()) / 2;
gc.setFill(Color.BLUE);
circle(ax, ay, playerSize);
gc.setFill(Color.GREEN);
circle(bx, by, playerSize);
gc.setStroke(Color.BLACK);
gc.setLineWidth(1);
if (frameA1.isKeyPressed()) {
gc.strokeText(frameA1.keyAsString(), frameA1.getX(), frameA1.getY());
}
if (frameB1.isKeyPressed()) {
gc.strokeText(frameB1.keyAsString(), frameB1.getX(), frameB1.getY());
}
}
private void circle(double cx, double cy, double size) {
final double rad = size / 2;
gc.fillOval(cx - rad, cy - rad, size, size);
}
/*private ReplayFrame findByTime(int time, ReplayFrame[] frames) {
final int length = frames.length;
int indexA = 0, indexB = length - 1;
while (indexA < indexB) {
final int midIndex = indexA + (indexB - indexA) / 2;
final ReplayFrame midFrame = frames[midIndex];
final int midTime = midFrame.getTime();
if (time <= midTime) indexB = midIndex;
else indexA = midIndex + 1;
}
if (indexB >= length - 1) return frames[indexB];
final ReplayFrame frameA = frames[indexB];
final ReplayFrame frameB = frames[indexB + 1];
final int timeDiff = (frameA.getTimeDiff() + frameB.getTimeDiff()) / 2;
final float x = (frameA.getX() + frameB.getX()) / 2;
final float y = (frameA.getY() + frameB.getY()) / 2;
final int keys = frameA.getKeys();
return new ReplayFrame(timeDiff, time, x, y, keys);
}
private HitObject findHitObjectByTime(int time) {
final int length = beatmap.objects.length;
int indexA = 0, indexB = length - 1;
while (indexA < indexB) {
final int midIndex = indexA + (indexB - indexA) / 2;
final HitObject midObject = beatmap.objects[midIndex];
if (midObject == null) {
int index = midIndex;
while (index > 0 && beatmap.objects[index] == null) {
index--;
}
return beatmap.objects[index];
}
if (midObject.isCircle()) {
if (time <= midObject.getTime()) indexB = midIndex;
else indexA = midIndex + 1;
} else {
if (time < midObject.getTime()) indexB = midIndex;
else if (time > midObject.getEndTime()) indexA = midIndex + 1;
else {
indexB = midIndex;
break;
}
}
}
return beatmap.objects[indexB];
}*/
}

View File

@ -0,0 +1,32 @@
package com.annimon.osureplaydiff;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
/**
*
* @author aNNiMON
*/
public class Main extends Application {
@Override
public void start(Stage stage) throws Exception {
Parent root = FXMLLoader.load(getClass().getResource("main.fxml"));
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();
}
/**
* @param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.*?>
<?import javafx.scene.canvas.*?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane prefHeight="243.0" prefWidth="423.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.annimon.osureplaydiff.FXMLDocumentController">
<top>
<Button fx:id="button" onAction="#handleButtonAction" text="Click Me!" BorderPane.alignment="CENTER" />
</top>
<bottom>
<Slider fx:id="slider" BorderPane.alignment="CENTER">
<BorderPane.margin>
<Insets bottom="2.0" left="2.0" right="2.0" top="2.0" />
</BorderPane.margin>
</Slider>
</bottom>
<center>
<Canvas fx:id="canvas" width="512" height="384" BorderPane.alignment="CENTER" />
</center>
</BorderPane>

View File

@ -0,0 +1,28 @@
package itdelatrisu.opsu;
/**
*
* @author aNNiMON
*/
public final class Log {
public static void error(String text, Exception e, boolean b) {
System.err.println(text + " " + e.toString());
}
public static void warn(String text, Exception e) {
warn(text + " " + e.toString());
}
public static void debug(String text) {
System.err.println(text);
}
public static void warn(String text) {
System.err.println(text);
}
public static void error(String text) {
System.err.println(text);
}
}

View File

@ -0,0 +1,17 @@
package itdelatrisu.opsu;
import itdelatrisu.opsu.skins.Skin;
/**
*
* @author aNNiMON
*/
public final class Options {
private static final Skin DEFAULT_SKIN = new Skin(null);
public static Skin getSkin() {
return DEFAULT_SKIN;
}
}

View File

@ -0,0 +1,225 @@
/*
* 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.awt.Font;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Scanner;
import javax.imageio.ImageIO;
/**
* Contains miscellaneous utilities.
*/
public class Utils {
// This class should not be instantiated.
private Utils() {}
/**
* Returns a bounded value for a base value and displacement.
* @param base the initial value
* @param diff the value change
* @param min the minimum value
* @param max the maximum value
* @return the bounded value
*/
public static int getBoundedValue(int base, int diff, int min, int max) {
int val = base + diff;
if (val < min)
val = min;
else if (val > max)
val = max;
return val;
}
/**
* Returns a bounded value for a base value and displacement.
* @param base the initial value
* @param diff the value change
* @param min the minimum value
* @param max the maximum value
* @return the bounded value
*/
public static float getBoundedValue(float base, float diff, float min, float max) {
float val = base + diff;
if (val < min)
val = min;
else if (val > max)
val = max;
return val;
}
/**
* Clamps a value between a lower and upper bound.
* @param val the value to clamp
* @param low the lower bound
* @param high the upper bound
* @return the clamped value
* @author fluddokt
*/
public static float clamp(float val, float low, float high) {
if (val < low)
return low;
if (val > high)
return high;
return val;
}
/**
* Returns the distance between two points.
* @param x1 the x-component of the first point
* @param y1 the y-component of the first point
* @param x2 the x-component of the second point
* @param y2 the y-component of the second point
* @return the Euclidean distance between points (x1,y1) and (x2,y2)
*/
public static float distance(float x1, float y1, float x2, float y2) {
float v1 = Math.abs(x1 - x2);
float v2 = Math.abs(y1 - y2);
return (float) Math.sqrt((v1 * v1) + (v2 * v2));
}
/**
* Returns a human-readable representation of a given number of bytes.
* @param bytes the number of bytes
* @return the string representation
* @author aioobe (http://stackoverflow.com/a/3758880)
*/
public static String bytesToString(long bytes) {
if (bytes < 1024)
return bytes + " B";
int exp = (int) (Math.log(bytes) / Math.log(1024));
char pre = "KMGTPE".charAt(exp - 1);
return String.format("%.1f %cB", bytes / Math.pow(1024, exp), pre);
}
/**
* 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() : "";
}
}
/**
* Returns the md5 hash of a file in hex form.
* @param file the file to hash
* @return the md5 hash
*/
public static String getMD5(File file) {
try {
InputStream in = new BufferedInputStream(new FileInputStream(file));
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] buf = new byte[4096];
while (true) {
int len = in.read(buf);
if (len < 0)
break;
md.update(buf, 0, len);
}
in.close();
byte[] md5byte = md.digest();
StringBuilder result = new StringBuilder();
for (byte b : md5byte)
result.append(String.format("%02x", b));
return result.toString();
} catch (NoSuchAlgorithmException | IOException e) {
//ErrorHandler.error("Failed to calculate MD5 hash.", e, true);
}
return null;
}
/**
* Returns a formatted time string for a given number of seconds.
* @param seconds the number of seconds
* @return the time as a readable string
*/
public static String getTimeString(int seconds) {
if (seconds < 60)
return (seconds == 1) ? "1 second" : String.format("%d seconds", seconds);
else if (seconds < 3600)
return String.format("%02d:%02d", seconds / 60, seconds % 60);
else
return String.format("%02d:%02d:%02d", seconds / 3600, (seconds / 60) % 60, seconds % 60);
}
/**
* Cubic ease out function.
* @param t the current time
* @param a the starting position
* @param b the finishing position
* @param d the duration
* @return the eased float
*/
public static float easeOut(float t, float a, float b, float d) {
return b * ((t = t / d - 1f) * t * t + 1f) + a;
}
/**
* Fake bounce ease function.
* @param t the current time
* @param a the starting position
* @param b the finishing position
* @param d the duration
* @return the eased float
*/
public static float easeBounce(float t, float a, float b, float d) {
if (t < d / 2)
return easeOut(t, a, b, d);
return easeOut(d - t, a, b, d);
}
/**
* Parses the integer string argument as a boolean:
* {@code 1} is {@code true}, and all other values are {@code false}.
* @param s the {@code String} containing the boolean representation to be parsed
* @return the boolean represented by the string argument
*/
public static boolean parseBoolean(String s) {
return (Integer.parseInt(s) == 1);
}
}

View File

@ -0,0 +1,446 @@
/*
* 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.beatmap;
import itdelatrisu.opsu.Options;
import java.io.File;
import java.util.ArrayList;
import java.util.LinkedList;
import javafx.scene.paint.Color;
/**
* Beatmap structure storing data parsed from OSU files.
*/
public class Beatmap implements Comparable<Beatmap> {
/** Game modes. */
public static final byte MODE_OSU = 0, MODE_TAIKO = 1, MODE_CTB = 2, MODE_MANIA = 3;
/** The OSU File object associated with this beatmap. */
private File file;
/**
* [General]
*/
/** Audio file object. */
public File audioFilename;
/** Delay time before music starts (in ms). */
public int audioLeadIn = 0;
/** Audio hash (deprecated). */
// public String audioHash = "";
/** Start position of music preview (in ms). */
public int previewTime = -1;
/** Countdown type (0:disabled, 1:normal, 2:half, 3:double). */
public byte countdown = 0;
/** Sound samples ("None", "Normal", "Soft"). */
public String sampleSet = "";
/** How often closely placed hit objects will be stacked together. */
public float stackLeniency = 0.7f;
/** Game mode (MODE_* constants). */
public byte mode = MODE_OSU;
/** Whether the letterbox (top/bottom black bars) appears during breaks. */
public boolean letterboxInBreaks = false;
/** Whether the storyboard should be widescreen. */
public boolean widescreenStoryboard = false;
/** Whether to show an epilepsy warning. */
public boolean epilepsyWarning = false;
/**
* [Editor]
*/
/** List of editor bookmarks (in ms). */
// public int[] bookmarks;
/** Multiplier for "Distance Snap". */
// public float distanceSpacing = 0f;
/** Beat division. */
// public byte beatDivisor = 0;
/** Size of grid for "Grid Snap". */
// public int gridSize = 0;
/** Zoom in the editor timeline. */
// public int timelineZoom = 0;
/**
* [Metadata]
*/
/** Song title. */
public String title = "", titleUnicode = "";
/** Song artist. */
public String artist = "", artistUnicode = "";
/** Beatmap creator. */
public String creator = "";
/** Beatmap difficulty. */
public String version = "";
/** Song source. */
public String source = "";
/** Song tags (for searching). */
public String tags = "";
/** Beatmap ID. */
public int beatmapID = 0;
/** Beatmap set ID. */
public int beatmapSetID = 0;
/**
* [Difficulty]
*/
/** HP: Health drain rate (0:easy ~ 10:hard) */
public float HPDrainRate = 5f;
/** CS: Size of circles and sliders (0:large ~ 10:small). */
public float circleSize = 4f;
/** OD: Affects timing window, spinners, and approach speed (0:easy ~ 10:hard). */
public float overallDifficulty = 5f;
/** AR: How long circles stay on the screen (0:long ~ 10:short). */
public float approachRate = -1f;
/** Slider movement speed multiplier. */
public float sliderMultiplier = 1f;
/** Rate at which slider ticks are placed (x per beat). */
public float sliderTickRate = 1f;
/**
* [Events]
*/
/** Background image file. */
public File bg;
/** Background video file. */
// public File video;
/** All break periods (start time, end time, ...). */
public ArrayList<Integer> breaks;
/**
* [TimingPoints]
*/
/** All timing points. */
public ArrayList<TimingPoint> timingPoints;
/** Song BPM range. */
public int bpmMin = 0, bpmMax = 0;
/**
* [Colours]
*/
/** Combo colors (max 8). If null, the skin value is used. */
public Color[] combo;
/** Slider border color. If null, the skin value is used. */
public Color sliderBorder;
/** MD5 hash of this file. */
public String md5Hash;
/**
* [HitObjects]
*/
/** All hit objects. */
public HitObject[] objects;
/** Number of individual objects. */
public int
hitObjectCircle = 0,
hitObjectSlider = 0,
hitObjectSpinner = 0;
/** Last object end time (in ms). */
public int endTime = -1;
/**
* Constructor.
* @param file the file associated with this beatmap
*/
public Beatmap(File file) {
this.file = file;
}
/**
* Returns the associated file object.
* @return the File object
*/
public File getFile() { return file; }
/**
* Returns the song title.
* If configured, the Unicode string will be returned instead.
* @return the song title
*/
public String getTitle() {
return (/*Options.useUnicodeMetadata() && */!titleUnicode.isEmpty()) ? titleUnicode : title;
}
/**
* Returns the song artist.
* If configured, the Unicode string will be returned instead.
* @return the song artist
*/
public String getArtist() {
return (/*Options.useUnicodeMetadata() && */!artistUnicode.isEmpty()) ? artistUnicode : artist;
}
/**
* Returns the list of combo colors (max 8).
* If the beatmap does not provide colors, the skin colors will be returned instead.
* @return the combo colors
*/
public Color[] getComboColors() {
return (combo != null) ? combo : Options.getSkin().getComboColors();
}
/**
* Returns the slider border color.
* If the beatmap does not provide a color, the skin color will be returned instead.
* @return the slider border color
*/
public Color getSliderBorderColor() {
return (sliderBorder != null) ? sliderBorder : Options.getSkin().getSliderBorderColor();
}
/**
* Draws the beatmap background.
* @param width the container width
* @param height the container height
* @param alpha the alpha value
* @param stretch if true, stretch to screen dimensions; otherwise, maintain aspect ratio
* @return true if successful, false if any errors were produced
*/
/*public boolean drawBG(int width, int height, float alpha, boolean stretch) {
if (bg == null)
return false;
try {
Image bgImage = bgImageCache.get(this);
if (bgImage == null) {
bgImage = new Image(bg.getAbsolutePath());
bgImageCache.put(this, bgImage);
}
int swidth = width;
int sheight = height;
if (!stretch) {
// fit image to screen
if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
else
swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
} else {
// fill screen while maintaining aspect ratio
if (bgImage.getWidth() / (float) bgImage.getHeight() > width / (float) height) // x > y
swidth = (int) (height * bgImage.getWidth() / (float) bgImage.getHeight());
else
sheight = (int) (width * bgImage.getHeight() / (float) bgImage.getWidth());
}
bgImage = bgImage.getScaledCopy(swidth, sheight);
bgImage.setAlpha(alpha);
bgImage.drawCentered(width / 2, height / 2);
} catch (Exception e) {
Log.warn(String.format("Failed to get background image '%s'.", bg), e);
bg = null; // don't try to load the file again until a restart
return false;
}
return true;
}*/
/**
* Compares two Beatmap objects first by overall difficulty, then by total objects.
*/
@Override
public int compareTo(Beatmap that) {
int cmp = Float.compare(this.overallDifficulty, that.overallDifficulty);
if (cmp == 0)
cmp = Integer.compare(
this.hitObjectCircle + this.hitObjectSlider + this.hitObjectSpinner,
that.hitObjectCircle + that.hitObjectSlider + that.hitObjectSpinner
);
return cmp;
}
/**
* Returns a formatted string: "Artist - Title [Version]"
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return String.format("%s - %s [%s]", getArtist(), getTitle(), version);
}
/**
* Returns the {@link #breaks} field formatted as a string,
* or null if the field is null.
*/
public String breaksToString() {
if (breaks == null)
return null;
StringBuilder sb = new StringBuilder();
for (int i : breaks) {
sb.append(i);
sb.append(',');
}
if (sb.length() > 0)
sb.setLength(sb.length() - 1);
return sb.toString();
}
/**
* Sets the {@link #breaks} field from a string.
* @param s the string
*/
public void breaksFromString(String s) {
if (s == null)
return;
this.breaks = new ArrayList<Integer>();
String[] tokens = s.split(",");
for (int i = 0; i < tokens.length; i++)
breaks.add(Integer.parseInt(tokens[i]));
}
/**
* Returns the {@link #timingPoints} field formatted as a string,
* or null if the field is null.
*/
public String timingPointsToString() {
if (timingPoints == null)
return null;
StringBuilder sb = new StringBuilder();
for (TimingPoint p : timingPoints) {
sb.append(p.toString());
sb.append('|');
}
if (sb.length() > 0)
sb.setLength(sb.length() - 1);
return sb.toString();
}
/**
* Sets the {@link #timingPoints} field from a string.
* @param s the string
*/
public void timingPointsFromString(String s) {
this.timingPoints = new ArrayList<TimingPoint>();
if (s == null)
return;
String[] tokens = s.split("\\|");
for (int i = 0; i < tokens.length; i++) {
try {
timingPoints.add(new TimingPoint(tokens[i]));
} catch (Exception e) {
// Log.warn(String.format("Failed to read timing point '%s'.", tokens[i]), e);
}
}
timingPoints.trimToSize();
}
/**
* Returns the {@link #combo} field formatted as a string,
* or null if the field is null or the default combo.
*/
public String comboToString() {
if (combo == null)
return null;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < combo.length; i++) {
Color c = combo[i];
sb.append(c.getRed());
sb.append(',');
sb.append(c.getGreen());
sb.append(',');
sb.append(c.getBlue());
sb.append('|');
}
if (sb.length() > 0)
sb.setLength(sb.length() - 1);
return sb.toString();
}
/**
* Sets the {@link #combo} field from a string.
* @param s the string
*/
public void comboFromString(String s) {
if (s == null)
return;
LinkedList<Color> colors = new LinkedList<Color>();
String[] tokens = s.split("\\|");
for (int i = 0; i < tokens.length; i++) {
String[] rgb = tokens[i].split(",");
colors.add(Color.rgb(Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2])));
}
if (!colors.isEmpty())
this.combo = colors.toArray(new Color[colors.size()]);
}
/**
* Returns the {@link #sliderBorder} field formatted as a string,
* or null if the field is null.
*/
public String sliderBorderToString() {
if (sliderBorder == null)
return null;
return String.format("%d,%d,%d", sliderBorder.getRed(), sliderBorder.getGreen(), sliderBorder.getBlue());
}
/**
* Sets the {@link #sliderBorder} field from a string.
* @param s the string
*/
public void sliderBorderFromString(String s) {
if (s == null)
return;
String[] rgb = s.split(",");
this.sliderBorder = Color.rgb(Integer.parseInt(rgb[0]), Integer.parseInt(rgb[1]), Integer.parseInt(rgb[2]));
}
}

View File

@ -0,0 +1,624 @@
/*
* 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.beatmap;
import itdelatrisu.opsu.Log;
import itdelatrisu.opsu.Utils;
import itdelatrisu.opsu.io.MD5InputStreamWrapper;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import javafx.scene.paint.Color;
/**
* Parser for beatmaps.
*/
public class BeatmapParser {
/** The string lookup database. */
private static HashMap<String, String> stringdb = new HashMap<>();
/** The expected pattern for beatmap directories, used to find beatmap set IDs. */
private static final String DIR_MSID_PATTERN = "^\\d+ .*";
/** The current file being parsed. */
private static File currentFile;
/** The current directory number while parsing. */
private static int currentDirectoryIndex = -1;
/** The total number of directories to parse. */
private static int totalDirectories = -1;
/** Parser statuses. */
public enum Status { NONE, PARSING, CACHE, INSERTING };
/** The current status. */
private static Status status = Status.NONE;
/** If no Provider supports a MessageDigestSpi implementation for the MD5 algorithm. */
private static boolean hasNoMD5Algorithm = false;
// This class should not be instantiated.
private BeatmapParser() {}
/**
* Parses a beatmap.
* @param file the file to parse
* @param dir the directory containing the beatmap
* @param beatmaps the song group
* @param parseObjects if true, hit objects will be fully parsed now
* @return the new beatmap
*/
public static Beatmap parseFile(File file, File dir, boolean parseObjects) {
Beatmap beatmap = new Beatmap(file);
beatmap.timingPoints = new ArrayList<TimingPoint>();
try (
InputStream bis = new BufferedInputStream(new FileInputStream(file));
MD5InputStreamWrapper md5stream = (!hasNoMD5Algorithm) ? new MD5InputStreamWrapper(bis) : null;
BufferedReader in = new BufferedReader(new InputStreamReader((md5stream != null) ? md5stream : bis, "UTF-8"));
) {
String line = in.readLine();
String tokens[] = null;
while (line != null) {
line = line.trim();
if (!isValidLine(line)) {
line = in.readLine();
continue;
}
switch (line) {
case "[General]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
switch (tokens[0]) {
case "AudioFilename":
File audioFileName = new File(dir, tokens[1]);
if (!audioFileName.isFile()) {
// try to find the file with a case-insensitive match
boolean match = false;
for (String s : dir.list()) {
if (s.equalsIgnoreCase(tokens[1])) {
audioFileName = new File(dir, s);
match = true;
break;
}
}
if (!match) {
Log.error(String.format("Audio file '%s' not found in directory '%s'.", tokens[1], dir.getName()));
return null;
}
}
beatmap.audioFilename = audioFileName;
break;
case "AudioLeadIn":
beatmap.audioLeadIn = Integer.parseInt(tokens[1]);
break;
// case "AudioHash": // deprecated
// beatmap.audioHash = tokens[1];
// break;
case "PreviewTime":
beatmap.previewTime = Integer.parseInt(tokens[1]);
break;
case "Countdown":
beatmap.countdown = Byte.parseByte(tokens[1]);
break;
case "SampleSet":
beatmap.sampleSet = getDBString(tokens[1]);
break;
case "StackLeniency":
beatmap.stackLeniency = Float.parseFloat(tokens[1]);
break;
case "Mode":
beatmap.mode = Byte.parseByte(tokens[1]);
/* Non-Opsu! standard files not implemented (obviously). */
if (beatmap.mode != Beatmap.MODE_OSU)
return null;
break;
case "LetterboxInBreaks":
beatmap.letterboxInBreaks = Utils.parseBoolean(tokens[1]);
break;
case "WidescreenStoryboard":
beatmap.widescreenStoryboard = Utils.parseBoolean(tokens[1]);
break;
case "EpilepsyWarning":
beatmap.epilepsyWarning = Utils.parseBoolean(tokens[1]);
default:
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read line '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
}
break;
case "[Editor]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
/* Not implemented. */
// if ((tokens = tokenize(line)) == null)
// continue;
// try {
// switch (tokens[0]) {
// case "Bookmarks":
// String[] bookmarks = tokens[1].split(",");
// beatmap.bookmarks = new int[bookmarks.length];
// for (int i = 0; i < bookmarks.length; i++)
// osu.bookmarks[i] = Integer.parseInt(bookmarks[i]);
// break;
// case "DistanceSpacing":
// beatmap.distanceSpacing = Float.parseFloat(tokens[1]);
// break;
// case "BeatDivisor":
// beatmap.beatDivisor = Byte.parseByte(tokens[1]);
// break;
// case "GridSize":
// beatmap.gridSize = Integer.parseInt(tokens[1]);
// break;
// case "TimelineZoom":
// beatmap.timelineZoom = Integer.parseInt(tokens[1]);
// break;
// default:
// break;
// }
// } catch (Exception e) {
// Log.warn(String.format("Failed to read editor line '%s' for file '%s'.",
// line, file.getAbsolutePath()), e);
// }
}
break;
case "[Metadata]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
switch (tokens[0]) {
case "Title":
beatmap.title = getDBString(tokens[1]);
break;
case "TitleUnicode":
beatmap.titleUnicode = getDBString(tokens[1]);
break;
case "Artist":
beatmap.artist = getDBString(tokens[1]);
break;
case "ArtistUnicode":
beatmap.artistUnicode = getDBString(tokens[1]);
break;
case "Creator":
beatmap.creator = getDBString(tokens[1]);
break;
case "Version":
beatmap.version = getDBString(tokens[1]);
break;
case "Source":
beatmap.source = getDBString(tokens[1]);
break;
case "Tags":
beatmap.tags = getDBString(tokens[1].toLowerCase());
break;
case "BeatmapID":
beatmap.beatmapID = Integer.parseInt(tokens[1]);
break;
case "BeatmapSetID":
beatmap.beatmapSetID = Integer.parseInt(tokens[1]);
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read metadata '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
if (beatmap.beatmapSetID <= 0) { // try to determine MSID from directory name
if (dir != null && dir.isDirectory()) {
String dirName = dir.getName();
if (!dirName.isEmpty() && dirName.matches(DIR_MSID_PATTERN))
beatmap.beatmapSetID = Integer.parseInt(dirName.substring(0, dirName.indexOf(' ')));
}
}
}
break;
case "[Difficulty]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
switch (tokens[0]) {
case "HPDrainRate":
beatmap.HPDrainRate = Float.parseFloat(tokens[1]);
break;
case "CircleSize":
beatmap.circleSize = Float.parseFloat(tokens[1]);
break;
case "OverallDifficulty":
beatmap.overallDifficulty = Float.parseFloat(tokens[1]);
break;
case "ApproachRate":
beatmap.approachRate = Float.parseFloat(tokens[1]);
break;
case "SliderMultiplier":
beatmap.sliderMultiplier = Float.parseFloat(tokens[1]);
break;
case "SliderTickRate":
beatmap.sliderTickRate = Float.parseFloat(tokens[1]);
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read difficulty '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
}
if (beatmap.approachRate == -1f) // not in old format
beatmap.approachRate = beatmap.overallDifficulty;
break;
case "[Events]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
tokens = line.split(",");
switch (tokens[0]) {
case "0": // background
tokens[2] = tokens[2].replaceAll("^\"|\"$", "");
String ext = BeatmapParser.getExtension(tokens[2]);
if (ext.equals("jpg") || ext.equals("png"))
beatmap.bg = new File(dir, getDBString(tokens[2]));
break;
case "2": // break periods
try {
if (beatmap.breaks == null) // optional, create if needed
beatmap.breaks = new ArrayList<Integer>();
beatmap.breaks.add(Integer.parseInt(tokens[1]));
beatmap.breaks.add(Integer.parseInt(tokens[2]));
} catch (Exception e) {
Log.warn(String.format("Failed to read break period '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
break;
default:
/* Not implemented. */
break;
}
}
if (beatmap.breaks != null)
beatmap.breaks.trimToSize();
break;
case "[TimingPoints]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
try {
// parse timing point
TimingPoint timingPoint = new TimingPoint(line);
// calculate BPM
if (!timingPoint.isInherited()) {
int bpm = Math.round(60000 / timingPoint.getBeatLength());
if (beatmap.bpmMin == 0)
beatmap.bpmMin = beatmap.bpmMax = bpm;
else if (bpm < beatmap.bpmMin)
beatmap.bpmMin = bpm;
else if (bpm > beatmap.bpmMax)
beatmap.bpmMax = bpm;
}
beatmap.timingPoints.add(timingPoint);
} catch (Exception e) {
Log.warn(String.format("Failed to read timing point '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
}
beatmap.timingPoints.trimToSize();
break;
case "[Colours]":
LinkedList<Color> colors = new LinkedList<Color>();
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
String[] rgb = tokens[1].split(",");
Color color = Color.rgb(
Integer.parseInt(rgb[0]),
Integer.parseInt(rgb[1]),
Integer.parseInt(rgb[2])
);
switch (tokens[0]) {
case "Combo1":
case "Combo2":
case "Combo3":
case "Combo4":
case "Combo5":
case "Combo6":
case "Combo7":
case "Combo8":
colors.add(color);
break;
case "SliderBorder":
beatmap.sliderBorder = color;
break;
default:
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read color '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
}
if (!colors.isEmpty())
beatmap.combo = colors.toArray(new Color[colors.size()]);
break;
case "[HitObjects]":
int type = 0;
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
/* Only type counts parsed at this time. */
tokens = line.split(",");
try {
type = Integer.parseInt(tokens[3]);
if ((type & HitObject.TYPE_CIRCLE) > 0)
beatmap.hitObjectCircle++;
else if ((type & HitObject.TYPE_SLIDER) > 0)
beatmap.hitObjectSlider++;
else //if ((type & HitObject.TYPE_SPINNER) > 0)
beatmap.hitObjectSpinner++;
} catch (Exception e) {
Log.warn(String.format("Failed to read hit object '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
}
try {
// map length = last object end time (TODO: end on slider?)
if ((type & HitObject.TYPE_SPINNER) > 0) {
// some 'endTime' fields contain a ':' character (?)
int index = tokens[5].indexOf(':');
if (index != -1)
tokens[5] = tokens[5].substring(0, index);
beatmap.endTime = Integer.parseInt(tokens[5]);
} else if (type != 0)
beatmap.endTime = Integer.parseInt(tokens[2]);
} catch (Exception e) {
Log.warn(String.format("Failed to read hit object end time '%s' for file '%s'.",
line, file.getAbsolutePath()), e);
}
break;
default:
line = in.readLine();
break;
}
}
if (md5stream != null)
beatmap.md5Hash = md5stream.getMD5();
} catch (IOException e) {
Log.error(String.format("Failed to read file '%s'.", file.getAbsolutePath()), e, false);
} catch (NoSuchAlgorithmException e) {
Log.error("Failed to get MD5 hash stream.", e, true);
// retry without MD5
hasNoMD5Algorithm = true;
return parseFile(file, dir, parseObjects);
}
// no associated audio file?
if (beatmap.audioFilename == null)
return null;
// parse hit objects now?
if (parseObjects)
parseHitObjects(beatmap);
return beatmap;
}
/**
* Parses all hit objects in a beatmap.
* @param beatmap the beatmap to parse
*/
public static void parseHitObjects(Beatmap beatmap) {
if (beatmap.objects != null) // already parsed
return;
beatmap.objects = new HitObject[(beatmap.hitObjectCircle + beatmap.hitObjectSlider + beatmap.hitObjectSpinner)];
try (BufferedReader in = new BufferedReader(new FileReader(beatmap.getFile()))) {
String line = in.readLine();
while (line != null) {
line = line.trim();
if (!line.equals("[HitObjects]"))
line = in.readLine();
else
break;
}
if (line == null) {
Log.warn(String.format("No hit objects found in Beatmap '%s'.", beatmap.toString()));
return;
}
// combo info
Color[] combo = beatmap.getComboColors();
int comboIndex = 0; // color index
int comboNumber = 1; // combo number
int objectIndex = 0;
boolean first = true;
while ((line = in.readLine()) != null && objectIndex < beatmap.objects.length) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
// lines must have at minimum 5 parameters
int tokenCount = line.length() - line.replace(",", "").length();
if (tokenCount < 4)
continue;
try {
// create a new HitObject for each line
HitObject hitObject = new HitObject(line);
// set combo info
// - new combo: get next combo index, reset combo number
// - else: maintain combo index, increase combo number
if (hitObject.isNewCombo() || first) {
int skip = (hitObject.isSpinner() ? 0 : 1) + hitObject.getComboSkip();
for (int i = 0; i < skip; i++) {
comboIndex = (comboIndex + 1) % combo.length;
comboNumber = 1;
}
first = false;
}
hitObject.setComboIndex(comboIndex);
hitObject.setComboNumber(comboNumber++);
beatmap.objects[objectIndex++] = hitObject;
} catch (Exception e) {
Log.warn(String.format("Failed to read hit object '%s' for Beatmap '%s'.",
line, beatmap.toString()), e);
}
}
} catch (IOException e) {
Log.error(String.format("Failed to read file '%s'.", beatmap.getFile().getAbsolutePath()), e, false);
}
}
/**
* Returns false if the line is too short or commented.
*/
private static boolean isValidLine(String line) {
return (line.length() > 1 && !line.startsWith("//"));
}
/**
* Splits line into two strings: tag, value.
* If no ':' character is present, null will be returned.
*/
private static String[] tokenize(String line) {
int index = line.indexOf(':');
if (index == -1) {
// Log.debug(String.format("Failed to tokenize line: '%s'.", line));
return null;
}
String[] tokens = new String[2];
tokens[0] = line.substring(0, index).trim();
tokens[1] = line.substring(index + 1).trim();
return tokens;
}
/**
* Returns the file extension of a file.
*/
public static String getExtension(String file) {
int i = file.lastIndexOf('.');
return (i != -1) ? file.substring(i + 1).toLowerCase() : "";
}
/**
* Returns the name of the current file being parsed, or null if none.
*/
public static String getCurrentFileName() {
if (status == Status.PARSING)
return (currentFile != null) ? currentFile.getName() : null;
else
return (status == Status.NONE) ? null : "";
}
/**
* Returns the progress of file parsing, or -1 if not parsing.
* @return the completion percent [0, 100] or -1
*/
public static int getParserProgress() {
if (currentDirectoryIndex == -1 || totalDirectories == -1)
return -1;
return currentDirectoryIndex * 100 / totalDirectories;
}
/**
* Returns the current parser status.
*/
public static Status getStatus() { return status; }
/**
* Returns the String object in the database for the given String.
* If none, insert the String into the database and return the original String.
* @param s the string to retrieve
* @return the string object
*/
public static String getDBString(String s) {
String DBString = stringdb.get(s);
if (DBString == null) {
stringdb.put(s, s);
return s;
} else
return DBString;
}
}

View File

@ -0,0 +1,545 @@
/*
* 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.beatmap;
import java.text.DecimalFormat;
import java.text.NumberFormat;
/**
* Data type representing a parsed hit object.
*/
public class HitObject {
/** Hit object types (bits). */
public static final int
TYPE_CIRCLE = 1,
TYPE_SLIDER = 2,
TYPE_NEWCOMBO = 4, // not an object
TYPE_SPINNER = 8;
/** Hit object type names. */
private static final String
CIRCLE = "circle",
SLIDER = "slider",
SPINNER = "spinner",
UNKNOWN = "unknown object";
/** Hit sound types (bits). */
public static final byte
SOUND_NORMAL = 0,
SOUND_WHISTLE = 2,
SOUND_FINISH = 4,
SOUND_CLAP = 8;
/**
* Slider curve types.
* (Deprecated: only Beziers are currently used.)
*/
public static final char
SLIDER_CATMULL = 'C',
SLIDER_BEZIER = 'B',
SLIDER_LINEAR = 'L',
SLIDER_PASSTHROUGH = 'P';
/** Max hit object coordinates. */
private static final int
MAX_X = 512,
MAX_Y = 384;
/** The x and y multipliers for hit object coordinates. */
private static float xMultiplier, yMultiplier;
/** The x and y offsets for hit object coordinates. */
private static int
xOffset, // offset right of border
yOffset; // offset below health bar
/** The container height. */
private static int containerHeight;
/** The offset per stack. */
private static float stackOffset;
/**
* Returns the stack position modifier, in pixels.
* @return stack position modifier
*/
public static float getStackOffset() { return stackOffset; }
/**
* Sets the stack position modifier.
* @param offset stack position modifier, in pixels
*/
public static void setStackOffset(float offset) { stackOffset = offset; }
/** Starting coordinates. */
private float x, y;
/** Start time (in ms). */
private int time;
/** Hit object type (TYPE_* bitmask). */
private int type;
/** Hit sound type (SOUND_* bitmask). */
private byte hitSound;
/** Hit sound addition (sampleSet, AdditionSampleSet, ?, ...). */
private byte[] addition;
/** Slider curve type (SLIDER_* constant). */
private char sliderType;
/** Slider coordinate lists. */
private float[] sliderX, sliderY;
/** Slider repeat count. */
private int repeat;
/** Slider pixel length. */
private float pixelLength;
/** Spinner end time (in ms). */
private int endTime;
/** Slider edge hit sound type (SOUND_* bitmask). */
private byte[] edgeHitSound;
/** Slider edge hit sound addition (sampleSet, AdditionSampleSet). */
private byte[][] edgeAddition;
/** Current index in combo color array. */
private int comboIndex;
/** Number to display in hit object. */
private int comboNumber;
/** Hit object index in the current stack. */
private int stack;
private String sliderPath;
/**
* Initializes the HitObject data type with container dimensions.
* @param width the container width
* @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)
swidth = sheight * 4 / 3;
else
sheight = swidth * 3 / 4;
xMultiplier = swidth / 640f;
yMultiplier = sheight / 480f;
xOffset = (int) (width - MAX_X * xMultiplier) / 2;
yOffset = (int) (height - MAX_Y * yMultiplier) / 2;
}
/**
* Returns the X multiplier for coordinates.
*/
public static float getXMultiplier() { return xMultiplier; }
/**
* Returns the Y multiplier for coordinates.
*/
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
*/
public HitObject(String line) {
/**
* [OBJECT FORMATS]
* Circles:
* x,y,time,type,hitSound,addition
* 256,148,9466,1,2,0:0:0:0:
*
* Sliders:
* x,y,time,type,hitSound,sliderType|curveX:curveY|...,repeat,pixelLength,edgeHitsound,edgeAddition,addition
* 300,68,4591,2,0,B|372:100|332:172|420:192,2,180,2|2|2,0:0|0:0|0:0,0:0:0:0:
*
* Spinners:
* x,y,time,type,hitSound,endTime,addition
* 256,192,654,12,0,4029,0:0:0:0:
*
* NOTE: 'addition' -> sampl:add:cust:vol:hitsound (optional, defaults to "0:0:0:0:")
*/
String tokens[] = line.split(",");
// common fields
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]);
// type-specific fields
int additionIndex;
if ((type & HitObject.TYPE_CIRCLE) > 0)
additionIndex = 5;
else if ((type & HitObject.TYPE_SLIDER) > 0) {
additionIndex = 10;
// slider curve type and coordinates
sliderPath = tokens[5];
String[] sliderTokens = tokens[5].split("\\|");
this.sliderType = sliderTokens[0].charAt(0);
this.sliderX = new float[sliderTokens.length - 1];
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]);
this.sliderY[j - 1] = Integer.parseInt(sliderXY[1]);
}
this.repeat = Integer.parseInt(tokens[6]);
this.pixelLength = Float.parseFloat(tokens[7]);
if (tokens.length > 8) {
String[] edgeHitSoundTokens = tokens[8].split("\\|");
this.edgeHitSound = new byte[edgeHitSoundTokens.length];
for (int j = 0; j < edgeHitSoundTokens.length; j++)
edgeHitSound[j] = Byte.parseByte(edgeHitSoundTokens[j]);
}
if (tokens.length > 9) {
String[] edgeAdditionTokens = tokens[9].split("\\|");
this.edgeAddition = new byte[edgeAdditionTokens.length][2];
for (int j = 0; j < edgeAdditionTokens.length; j++) {
String[] tedgeAddition = edgeAdditionTokens[j].split(":");
edgeAddition[j][0] = Byte.parseByte(tedgeAddition[0]);
edgeAddition[j][1] = Byte.parseByte(tedgeAddition[1]);
}
}
} else { //if ((type & HitObject.TYPE_SPINNER) > 0) {
additionIndex = 6;
// some 'endTime' fields contain a ':' character (?)
int index = tokens[5].indexOf(':');
if (index != -1)
tokens[5] = tokens[5].substring(0, index);
this.endTime = Integer.parseInt(tokens[5]);
}
// addition
if (tokens.length > additionIndex) {
String[] additionTokens = tokens[additionIndex].split(":");
this.addition = new byte[additionTokens.length];
for (int j = 0; j < additionTokens.length; j++)
this.addition[j] = Byte.parseByte(additionTokens[j]);
}
}
public String getSliderPath() {
return sliderPath;
}
/**
* Returns the raw starting x coordinate.
*/
public float getX() { return x; }
/**
* Returns the raw starting y coordinate.
*/
public float getY() { return y; }
/**
* Returns the scaled starting x coordinate.
*/
public float getScaledX() { return (x - stack * stackOffset) * xMultiplier + xOffset; }
/**
* Returns the scaled starting y coordinate.
*/
public float getScaledY() {
/*if (GameMod.HARD_ROCK.isActive())
return containerHeight - ((y + stack * stackOffset) * yMultiplier + yOffset);
else*/
return (y - stack * stackOffset) * yMultiplier + yOffset;
}
/**
* Returns the start time.
* @return the start time (in ms)
*/
public int getTime() { return time; }
/**
* Returns the hit object type.
* @return the object type (TYPE_* bitmask)
*/
public int getType() { return type; }
/**
* Returns the name of the hit object type.
*/
public String getTypeName() {
if (isCircle())
return CIRCLE;
else if (isSlider())
return SLIDER;
else if (isSpinner())
return SPINNER;
else
return UNKNOWN;
}
/**
* Returns the hit sound type.
* @return the sound type (SOUND_* bitmask)
*/
public byte getHitSoundType() { return hitSound; }
/**
* Returns the edge hit sound type.
* @param index the slider edge index (ignored for non-sliders)
* @return the sound type (SOUND_* bitmask)
*/
public byte getEdgeHitSoundType(int index) {
if (edgeHitSound != null)
return edgeHitSound[index];
else
return hitSound;
}
/**
* Returns the slider type.
* @return the slider type (SLIDER_* constant)
*/
public char getSliderType() { return sliderType; }
/**
* Returns a list of raw slider x coordinates.
*/
public float[] getSliderX() { return sliderX; }
/**
* 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] - stack * stackOffset) * 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] + stack * stackOffset) * yMultiplier + yOffset);
} else*/ {
for (int i = 0; i < y.length; i++)
y[i] = (sliderY[i] - stack * stackOffset) * yMultiplier + yOffset;
}
return y;
}
/**
* Returns the slider repeat count.
* @return the repeat count
*/
public int getRepeatCount() { return repeat; }
/**
* Returns the slider pixel length.
* @return the pixel length
*/
public float getPixelLength() { return pixelLength; }
/**
* Returns the spinner end time.
* @return the end time (in ms)
*/
public int getEndTime() { return endTime; }
/**
* Sets the current index in the combo color array.
* @param comboIndex the combo index
*/
public void setComboIndex(int comboIndex) { this.comboIndex = comboIndex; }
/**
* Returns the current index in the combo color array.
* @return the combo index
*/
public int getComboIndex() { return comboIndex; }
/**
* Sets the number to display in the hit object.
* @param comboNumber the combo number
*/
public void setComboNumber(int comboNumber) { this.comboNumber = comboNumber; }
/**
* Returns the number to display in the hit object.
* @return the combo number
*/
public int getComboNumber() { return comboNumber; }
/**
* Returns whether or not the hit object is a circle.
* @return true if circle
*/
public boolean isCircle() { return (type & TYPE_CIRCLE) > 0; }
/**
* Returns whether or not the hit object is a slider.
* @return true if slider
*/
public boolean isSlider() { return (type & TYPE_SLIDER) > 0; }
/**
* Returns whether or not the hit object is a spinner.
* @return true if spinner
*/
public boolean isSpinner() { return (type & TYPE_SPINNER) > 0; }
/**
* Returns whether or not the hit object starts a new combo.
* @return true if new combo
*/
public boolean isNewCombo() { return (type & TYPE_NEWCOMBO) > 0; }
/**
* Returns the number of extra skips on the combo colors.
*/
public int getComboSkip() { return (type >> TYPE_NEWCOMBO); }
/**
* Returns the sample set at the given index.
* @param index the index (for sliders, ignored otherwise)
* @return the sample set, or 0 if none available
*/
public byte getSampleSet(int index) {
if (edgeAddition != null)
return edgeAddition[index][0];
if (addition != null)
return addition[0];
return 0;
}
/**
* Returns the 'addition' sample set at the given index.
* @param index the index (for sliders, ignored otherwise)
* @return the sample set, or 0 if none available
*/
public byte getAdditionSampleSet(int index) {
if (edgeAddition != null)
return edgeAddition[index][1];
if (addition != null)
return addition[1];
return 0;
}
/**
* Sets the hit object index in the current stack.
* @param stack index in the stack
*/
public void setStack(int stack) { this.stack = stack; }
/**
* Returns the hit object index in the current stack.
* @return index in the stack
*/
public int getStack() { return stack; }
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
NumberFormat nf = new DecimalFormat("###.#####");
// common fields
sb.append(nf.format(x)); sb.append(',');
sb.append(nf.format(y)); sb.append(',');
sb.append(time); sb.append(',');
sb.append(type); sb.append(',');
sb.append(hitSound); sb.append(',');
// type-specific fields
if (isCircle())
;
else if (isSlider()) {
sb.append(getSliderType());
sb.append('|');
for (int i = 0; i < sliderX.length; i++) {
sb.append(nf.format(sliderX[i])); sb.append(':');
sb.append(nf.format(sliderY[i])); sb.append('|');
}
sb.setCharAt(sb.length() - 1, ',');
sb.append(repeat); sb.append(',');
sb.append(pixelLength); sb.append(',');
if (edgeHitSound != null) {
for (int i = 0; i < edgeHitSound.length; i++) {
sb.append(edgeHitSound[i]); sb.append('|');
}
sb.setCharAt(sb.length() - 1, ',');
}
if (edgeAddition != null) {
for (int i = 0; i < edgeAddition.length; i++) {
sb.append(edgeAddition[i][0]); sb.append(':');
sb.append(edgeAddition[i][1]); sb.append('|');
}
sb.setCharAt(sb.length() - 1, ',');
}
} else if (isSpinner()) {
sb.append(endTime);
sb.append(',');
}
// addition
if (addition != null) {
for (int i = 0; i < addition.length; i++) {
sb.append(addition[i]);
sb.append(':');
}
} else
sb.setLength(sb.length() - 1);
return sb.toString();
}
}

View File

@ -0,0 +1,157 @@
/*
* 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.beatmap;
import itdelatrisu.opsu.Utils;
/**
* Data type representing a timing point.
*/
public class TimingPoint {
/** Timing point start time/offset (in ms). */
private int time = 0;
/** Time per beat (in ms). [NON-INHERITED] */
private float beatLength = 0f;
/** Slider multiplier. [INHERITED] */
private int velocity = 0;
/** Beats per measure. */
private int meter = 4;
/** Sound sample type. */
private byte sampleType = 1;
/** Custom sound sample type. */
private byte sampleTypeCustom = 0;
/** Volume of samples. [0, 100] */
private int sampleVolume = 100;
/** Whether or not this timing point is inherited. */
private boolean inherited = false;
/** Whether or not Kiai Mode is active. */
private boolean kiai = false;
/**
* Constructor.
* @param line the line to be parsed
*/
public TimingPoint(String line) {
// TODO: better support for old formats
String[] tokens = line.split(",");
try {
this.time = (int) Float.parseFloat(tokens[0]); // rare float
this.meter = Integer.parseInt(tokens[2]);
this.sampleType = Byte.parseByte(tokens[3]);
this.sampleTypeCustom = Byte.parseByte(tokens[4]);
this.sampleVolume = Integer.parseInt(tokens[5]);
// this.inherited = Utils.parseBoolean(tokens[6]);
if (tokens.length > 7)
this.kiai = Utils.parseBoolean(tokens[7]);
} catch (ArrayIndexOutOfBoundsException e) {
// Log.debug(String.format("Error parsing timing point: '%s'", line));
}
// tokens[1] is either beatLength (positive) or velocity (negative)
float beatLength = Float.parseFloat(tokens[1]);
if (beatLength > 0)
this.beatLength = beatLength;
else {
this.velocity = (int) beatLength;
this.inherited = true;
}
}
/**
* Returns the timing point start time/offset.
* @return the start time (in ms)
*/
public int getTime() { return time; }
/**
* Returns the beat length. [NON-INHERITED]
* @return the time per beat (in ms)
*/
public float getBeatLength() { return beatLength; }
/**
* Returns the slider multiplier. [INHERITED]
*/
public float getSliderMultiplier() { return velocity / -100f; }
/**
* Returns the meter.
* @return the number of beats per measure
*/
public int getMeter() { return meter; }
/**
* Returns the sample type.
* <ul>
* <li>0: none
* <li>1: normal
* <li>2: soft
* <li>3: drum
* </ul>
*/
public byte getSampleType() { return sampleType; }
/**
* Returns the custom sample type.
* <ul>
* <li>0: default
* <li>1: custom 1
* <li>2: custom 2
* </ul>
*/
public byte getSampleTypeCustom() { return sampleTypeCustom; }
/**
* Returns the sample volume.
* @return the sample volume [0, 1]
*/
public float getSampleVolume() { return sampleVolume / 100f; }
/**
* Returns whether or not this timing point is inherited.
* @return the inherited
*/
public boolean isInherited() { return inherited; }
/**
* Returns whether or not Kiai Time is active.
* @return true if active
*/
public boolean isKiaiTimeActive() { return kiai; }
@Override
public String toString() {
if (inherited)
return String.format("%d,%d,%d,%d,%d,%d,%d,%d",
time, velocity, meter, (int) sampleType,
(int) sampleTypeCustom, sampleVolume, 1, (kiai) ? 1: 0);
else
return String.format("%d,%g,%d,%d,%d,%d,%d,%d",
time, beatLength, meter, (int) sampleType,
(int) sampleTypeCustom, sampleVolume, 0, (kiai) ? 1: 0);
}
}

View File

@ -0,0 +1,118 @@
/*
* 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.io;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* Wrapper for an InputStream that computes the MD5 hash while reading the stream.
*/
public class MD5InputStreamWrapper extends InputStream {
/** The input stream. */
private InputStream in;
/** Whether the end of stream has been reached. */
private boolean eof = false;
/** A MessageDigest object that implements the MD5 digest algorithm. */
private MessageDigest md;
/** The computed MD5 hash. */
private String md5;
/**
* Constructor.
* @param in the input stream
* @throws NoSuchAlgorithmException if no Provider supports a MessageDigestSpi implementation for the MD5 algorithm
*/
public MD5InputStreamWrapper(InputStream in) throws NoSuchAlgorithmException {
this.in = in;
this.md = MessageDigest.getInstance("MD5");
}
@Override
public int read() throws IOException {
int bytesRead = in.read();
if (bytesRead >= 0)
md.update((byte) bytesRead);
else
eof = true;
return bytesRead;
}
@Override
public int available() throws IOException { return in.available(); }
@Override
public void close() throws IOException { in.close(); }
@Override
public synchronized void mark(int readlimit) { in.mark(readlimit); }
@Override
public boolean markSupported() { return in.markSupported(); }
@Override
public int read(byte[] b, int off, int len) throws IOException {
int bytesRead = in.read(b, off, len);
if (bytesRead >= 0)
md.update(b, off, bytesRead);
else
eof = true;
return bytesRead;
}
@Override
public int read(byte[] b) throws IOException { return read(b, 0, b.length); }
@Override
public synchronized void reset() throws IOException {
throw new RuntimeException("The reset() method is not implemented.");
}
@Override
public long skip(long n) throws IOException {
throw new RuntimeException("The skip() method is not implemented.");
}
/**
* Returns the MD5 hash of the input stream.
* @throws IOException if the end of stream has not yet been reached and a call to {@link #read(byte[])} fails
*/
public String getMD5() throws IOException {
if (md5 != null)
return md5;
if (!eof) { // read the rest of the stream
byte[] buf = new byte[0x1000];
while (!eof)
read(buf);
}
byte[] md5byte = md.digest();
StringBuilder result = new StringBuilder();
for (byte b : md5byte)
result.append(String.format("%02x", b));
md5 = result.toString();
return md5;
}
}

View File

@ -0,0 +1,176 @@
/*
* 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.io;
import java.io.BufferedInputStream;
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(new BufferedInputStream(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,163 @@
/*
* 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.io;
import java.io.BufferedOutputStream;
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(new BufferedOutputStream(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

@ -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/>.
*/
package itdelatrisu.opsu.replay;
/**
* Captures a single life frame.
*
* @author smoogipooo (https://github.com/smoogipooo/osu-Replay-API/)
*/
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,309 @@
/*
* 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.Utils;
import itdelatrisu.opsu.io.OsuReader;
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;
/**
* 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 associated score data. */
// private ScoreData scoreData;
/** 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 only.
* @throws IOException failure to load the data
*/
public void loadHeader() throws IOException {
OsuReader reader = new OsuReader(file);
loadHeader(reader);
reader.close();
}
/**
* 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<>(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<>(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()]);
}
}
/**
* Returns a ScoreData object encapsulating all replay data.
* If score data already exists, the existing object will be returned
* (i.e. this will not overwrite existing data).
* @param beatmap the beatmap
* @return the ScoreData object
*/
/*public ScoreData getScoreData(Beatmap beatmap) {
if (scoreData != null)
return scoreData;
scoreData = new ScoreData();
scoreData.timestamp = file.lastModified() / 1000L;
scoreData.MID = beatmap.beatmapID;
scoreData.MSID = beatmap.beatmapSetID;
scoreData.title = beatmap.title;
scoreData.artist = beatmap.artist;
scoreData.creator = beatmap.creator;
scoreData.version = beatmap.version;
scoreData.hit300 = hit300;
scoreData.hit100 = hit100;
scoreData.hit50 = hit50;
scoreData.geki = geki;
scoreData.katu = katu;
scoreData.miss = miss;
scoreData.score = score;
scoreData.combo = combo;
scoreData.perfect = perfect;
scoreData.mods = mods;
scoreData.replayString = getReplayFilename();
scoreData.playerName = playerName;
return scoreData;
}*/
/**
* 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,137 @@
/*
* 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.beatmap.HitObject;
/**
* 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 * HitObject.getXMultiplier() + HitObject.getXOffset()); }
/**
* Returns the scaled cursor y coordinate.
*/
public int getScaledY() { return (int) (y * HitObject.getYMultiplier() + HitObject.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); }
public String keyAsString() {
// StringBuilder sb = new StringBuilder(10);
// if ((keys & KEY_K1) != 0) sb.append(" K1");
// if ((keys & KEY_K2) != 0) sb.append(" K2");
// if ((keys & KEY_M1) != 0) sb.append(" M1");
// if ((keys & KEY_M2) != 0) sb.append(" M2");
//
// return sb.toString().trim();
if ((keys & KEY_K1) != 0) return "K1";
if ((keys & KEY_K2) != 0) return "K2";
if ((keys & KEY_M1) != 0) return "M1";
if ((keys & KEY_M2) != 0) return "M2";
return "";
}
@Override
public String toString() {
return String.format("(%d, [%.2f, %.2f], %d)", time, x, y, keys);
}
}

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.skins;
import java.io.File;
import javafx.scene.paint.Color;
/**
* Skin configuration (skin.ini).
*/
public class Skin {
/** The default skin name. */
public static final String DEFAULT_SKIN_NAME = "Default";
/** Slider styles. */
public static final byte
STYLE_PEPPYSLIDER = 1, // fallback
STYLE_MMSLIDER = 2, // default (requires OpenGL 3.0)
STYLE_TOONSLIDER = 3, // not implemented
STYLE_OPENGLSLIDER = 4; // not implemented
/** The latest skin version. */
protected static final int LATEST_VERSION = 2;
/** The default list of combos with combo sounds. */
private static final int[] DEFAULT_CUSTOM_COMBO_BURST_SOUNDS = { 50, 75, 100, 200, 300 };
/** The default combo colors (used when a beatmap does not provide custom colors). */
private static final Color[] DEFAULT_COMBO = {
Color.rgb(255, 192, 0),
Color.rgb(0, 202, 0),
Color.rgb(18, 124, 255),
Color.rgb(242, 24, 57)
};
/** The default menu visualization bar color. */
private static final Color DEFAULT_MENU_GLOW = Color.rgb(0, 78, 155);
/** The default slider border color. */
private static final Color DEFAULT_SLIDER_BORDER = Color.rgb(255, 255, 255);
/** The default slider ball color. */
private static final Color DEFAULT_SLIDER_BALL = Color.rgb(2, 170, 255);
/** The default spinner approach circle color. */
private static final Color DEFAULT_SPINNER_APPROACH_CIRCLE = Color.rgb(77, 139, 217);
/** The default color of the active text in the song selection menu. */
private static final Color DEFAULT_SONG_SELECT_ACTIVE_TEXT = Color.rgb(255, 255, 255);
/** The default color of the inactive text in the song selection menu. */
private static final Color DEFAULT_SONG_SELECT_INACTIVE_TEXT = Color.rgb(178, 178, 178);
/** The default color of the stars that fall from the cursor during breaks. */
private static final Color DEFAULT_STAR_BREAK_ADDITIVE = Color.rgb(255, 182, 193);
/** The skin directory. */
private File dir;
/**
* [General]
*/
/** The name of the skin. */
protected String name = "opsu! Default Skin";
/** The skin author. */
protected String author = "[various authors]";
/** The skin version. */
protected int version = LATEST_VERSION;
/** When a slider has a reverse, should the ball sprite flip horizontally? */
protected boolean sliderBallFlip = false;
/** Should the cursor sprite rotate constantly? */
protected boolean cursorRotate = true;
/** Should the cursor expand when clicked? */
protected boolean cursorExpand = true;
/** Should the cursor have an origin at the center of the image? (if not, the top-left corner is used) */
protected boolean cursorCentre = true;
/** The number of frames in the slider ball animation. */
protected int sliderBallFrames = 10;
/** Should the hitcircleoverlay sprite be drawn above the hircircle combo number? */
protected boolean hitCircleOverlayAboveNumber = true;
/** Should the sound frequency be modulated depending on the spinner score? */
protected boolean spinnerFrequencyModulate = false;
/** Should the normal hitsound always be played? */
protected boolean layeredHitSounds = true;
/** Should the spinner fade the playfield? */
protected boolean spinnerFadePlayfield = true;
/** Should the last spinner bar blink? */
protected boolean spinnerNoBlink = false;
/** Should the slider combo color tint the slider ball? */
protected boolean allowSliderBallTint = false;
/** The FPS of animations. */
protected int animationFramerate = -1;
/** Should the cursor trail sprite rotate constantly? */
protected boolean cursorTrailRotate = false;
/** List of combos with combo sounds. */
protected int[] customComboBurstSounds = DEFAULT_CUSTOM_COMBO_BURST_SOUNDS;
/** Should the combo burst sprites appear in random order? */
protected boolean comboBurstRandom = false;
/** The slider style to use (see STYLE_* constants). */
protected byte sliderStyle = STYLE_MMSLIDER;
/**
* [Colours]
*/
/** Combo colors (max 8). */
protected Color[] combo = DEFAULT_COMBO;
/** The menu visualization bar color. */
protected Color menuGlow = DEFAULT_MENU_GLOW;
/** The color for the slider border. */
protected Color sliderBorder = DEFAULT_SLIDER_BORDER;
/** The slider ball color. */
protected Color sliderBall = DEFAULT_SLIDER_BALL;
/** The spinner approach circle color. */
protected Color spinnerApproachCircle = DEFAULT_SPINNER_APPROACH_CIRCLE;
/** The color of text in the currently active group in song selection. */
protected Color songSelectActiveText = DEFAULT_SONG_SELECT_ACTIVE_TEXT;
/** The color of text in the inactive groups in song selection. */
protected Color songSelectInactiveText = DEFAULT_SONG_SELECT_INACTIVE_TEXT;
/** The color of the stars that fall from the cursor (star2 sprite) in breaks. */
protected Color starBreakAdditive = DEFAULT_STAR_BREAK_ADDITIVE;
/**
* [Fonts]
*/
/** The prefix for the hitcircle font sprites. */
protected String hitCirclePrefix = "default";
/** How much should the hitcircle font sprites overlap? */
protected int hitCircleOverlap = -2;
/** The prefix for the score font sprites. */
protected String scorePrefix = "score";
/** How much should the score font sprites overlap? */
protected int scoreOverlap = 0;
/** The prefix for the combo font sprites. */
protected String comboPrefix = "score";
/** How much should the combo font sprites overlap? */
protected int comboOverlap = 0;
/**
* Constructor.
* @param dir the skin directory
*/
public Skin(File dir) {
this.dir = dir;
}
/**
* Returns the skin directory.
*/
public File getDirectory() { return dir; }
/**
* Returns the name of the skin.
*/
public String getName() { return name; }
/**
* Returns the skin author.
*/
public String getAuthor() { return author; }
/**
* Returns the skin version.
*/
public int getVersion() { return version; }
/**
* Returns whether the slider ball should be flipped horizontally during a reverse.
*/
public boolean isSliderBallFlipped() { return sliderBallFlip; }
/**
* Returns whether the cursor should rotate.
*/
public boolean isCursorRotated() { return cursorRotate; }
/**
* Returns whether the cursor should expand when clicked.
*/
public boolean isCursorExpanded() { return cursorExpand; }
/**
* Returns whether the cursor should have an origin in the center.
* @return {@code true} if center, {@code false} if top-left corner
*/
public boolean isCursorCentered() { return cursorCentre; }
/**
* Returns the number of frames in the slider ball animation.
*/
public int getSliderBallFrames() { return sliderBallFrames; }
/**
* Returns whether the hit circle overlay should be drawn above the combo number.
*/
public boolean isHitCircleOverlayAboveNumber() { return hitCircleOverlayAboveNumber; }
/**
* Returns whether the sound frequency should be modulated depending on the spinner score.
*/
public boolean isSpinnerFrequencyModulated() { return spinnerFrequencyModulate; }
/**
* Returns whether the normal hitsound should always be played (and layered on other sounds).
*/
public boolean isLayeredHitSounds() { return layeredHitSounds; }
/**
* Returns whether the playfield should fade for spinners.
*/
public boolean isSpinnerFadePlayfield() { return spinnerFadePlayfield; }
/**
* Returns whether the last spinner bar should blink.
*/
public boolean isSpinnerNoBlink() { return spinnerNoBlink; }
/**
* Returns whether the slider ball should be tinted with the slider combo color.
*/
public boolean isAllowSliderBallTint() { return allowSliderBallTint; }
/**
* Returns the frame rate of animations.
* @return the FPS, or {@code -1} (TODO)
*/
public int getAnimationFramerate() { return animationFramerate; }
/**
* Returns whether the cursor trail should rotate.
*/
public boolean isCursorTrailRotated() { return cursorTrailRotate; }
/**
* Returns a list of combos with combo sounds.
*/
public int[] getCustomComboBurstSounds() { return customComboBurstSounds; }
/**
* Returns whether combo bursts should appear in random order.
*/
public boolean isComboBurstRandom() { return comboBurstRandom; }
/**
* Returns the slider style.
* <ul>
* <li>1: peppysliders (segmented)
* <li>2: mmsliders (smooth)
* <li>3: toonsliders (smooth, with steps instead of gradient)
* <li>4: legacy OpenGL-only sliders
* </ul>
* @return the style (see STYLE_* constants)
*/
public byte getSliderStyle() { return sliderStyle; }
/**
* Returns the list of combo colors (max 8).
*/
public Color[] getComboColors() { return combo; }
/**
* Returns the menu visualization bar color.
*/
public Color getMenuGlowColor() { return menuGlow; }
/**
* Returns the slider border color.
*/
public Color getSliderBorderColor() { return sliderBorder; }
/**
* Returns the slider ball color.
*/
public Color getSliderBallColor() { return sliderBall; }
/**
* Returns the spinner approach circle color.
*/
public Color getSpinnerApproachCircleColor() { return spinnerApproachCircle; }
/**
* Returns the color of the active text in the song selection menu.
*/
public Color getSongSelectActiveTextColor() { return songSelectActiveText; }
/**
* Returns the color of the inactive text in the song selection menu.
*/
public Color getSongSelectInactiveTextColor() { return songSelectInactiveText; }
/**
* Returns the color of the stars that fall from the cursor during breaks.
*/
public Color getStarBreakAdditiveColor() { return starBreakAdditive; }
/**
* Returns the prefix for the hit circle font sprites.
*/
public String getHitCircleFontPrefix() { return hitCirclePrefix; }
/**
* Returns the amount of overlap between the hit circle font sprites.
*/
public int getHitCircleFontOverlap() { return hitCircleOverlap; }
/**
* Returns the prefix for the score font sprites.
*/
public String getScoreFontPrefix() { return scorePrefix; }
/**
* Returns the amount of overlap between the score font sprites.
*/
public int getScoreFontOverlap() { return scoreOverlap; }
/**
* Returns the prefix for the combo font sprites.
*/
public String getComboFontPrefix() { return comboPrefix; }
/**
* Returns the amount of overlap between the combo font sprites.
*/
public int getComboFontOverlap() { return comboOverlap; }
}

View File

@ -0,0 +1,297 @@
/*
* 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.skins;
import itdelatrisu.opsu.Log;
import itdelatrisu.opsu.Utils;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.LinkedList;
import javafx.scene.paint.Color;
/**
* Loads skin configuration files.
*/
public class SkinLoader {
/** Name of the skin configuration file. */
private static final String CONFIG_FILENAME = "skin.ini";
// This class should not be instantiated.
private SkinLoader() {}
/**
* Returns a list of all subdirectories in the Skins directory.
* @param root the root directory (search has depth 1)
* @return an array of skin directories
*/
public static File[] getSkinDirectories(File root) {
ArrayList<File> dirs = new ArrayList<File>();
for (File dir : root.listFiles()) {
if (dir.isDirectory())
dirs.add(dir);
}
return dirs.toArray(new File[dirs.size()]);
}
/**
* Loads a skin configuration file.
* If 'skin.ini' is not found, or if any fields are not specified, the
* default values will be used.
* @param dir the skin directory
* @return the loaded skin
*/
public static Skin loadSkin(File dir) {
File skinFile = new File(dir, CONFIG_FILENAME);
Skin skin = new Skin(dir);
if (!skinFile.isFile()) // missing skin.ini
return skin;
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(skinFile), "UTF-8"))) {
String line = in.readLine();
String tokens[] = null;
while (line != null) {
line = line.trim();
if (!isValidLine(line)) {
line = in.readLine();
continue;
}
switch (line) {
case "[General]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
switch (tokens[0]) {
case "Name":
skin.name = tokens[1];
break;
case "Author":
skin.author = tokens[1];
break;
case "Version":
if (tokens[1].equalsIgnoreCase("latest"))
skin.version = Skin.LATEST_VERSION;
else
skin.version = Integer.parseInt(tokens[1]);
break;
case "SliderBallFlip":
skin.sliderBallFlip = Utils.parseBoolean(tokens[1]);
break;
case "CursorRotate":
skin.cursorRotate = Utils.parseBoolean(tokens[1]);
break;
case "CursorExpand":
skin.cursorExpand = Utils.parseBoolean(tokens[1]);
break;
case "CursorCentre":
skin.cursorCentre = Utils.parseBoolean(tokens[1]);
break;
case "SliderBallFrames":
skin.sliderBallFrames = Integer.parseInt(tokens[1]);
break;
case "HitCircleOverlayAboveNumber":
skin.hitCircleOverlayAboveNumber = Utils.parseBoolean(tokens[1]);
break;
case "spinnerFrequencyModulate":
skin.spinnerFrequencyModulate = Utils.parseBoolean(tokens[1]);
break;
case "LayeredHitSounds":
skin.layeredHitSounds = Utils.parseBoolean(tokens[1]);
break;
case "SpinnerFadePlayfield":
skin.spinnerFadePlayfield = Utils.parseBoolean(tokens[1]);
break;
case "SpinnerNoBlink":
skin.spinnerNoBlink = Utils.parseBoolean(tokens[1]);
break;
case "AllowSliderBallTint":
skin.allowSliderBallTint = Utils.parseBoolean(tokens[1]);
break;
case "AnimationFramerate":
skin.animationFramerate = Integer.parseInt(tokens[1]);
break;
case "CursorTrailRotate":
skin.cursorTrailRotate = Utils.parseBoolean(tokens[1]);
break;
case "CustomComboBurstSounds":
String[] split = tokens[1].split(",");
int[] customComboBurstSounds = new int[split.length];
for (int i = 0; i < split.length; i++)
customComboBurstSounds[i] = Integer.parseInt(split[i]);
skin.customComboBurstSounds = customComboBurstSounds;
break;
case "ComboBurstRandom":
skin.comboBurstRandom = Utils.parseBoolean(tokens[1]);
break;
case "SliderStyle":
skin.sliderStyle = Byte.parseByte(tokens[1]);
break;
default:
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read line '%s' for file '%s'.",
line, skinFile.getAbsolutePath()), e);
}
}
break;
case "[Colours]":
LinkedList<Color> colors = new LinkedList<Color>();
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
String[] rgb = tokens[1].split(",");
Color color = Color.rgb(
Integer.parseInt(rgb[0]),
Integer.parseInt(rgb[1]),
Integer.parseInt(rgb[2])
);
switch (tokens[0]) {
case "Combo1":
case "Combo2":
case "Combo3":
case "Combo4":
case "Combo5":
case "Combo6":
case "Combo7":
case "Combo8":
colors.add(color);
break;
case "MenuGlow":
skin.menuGlow = color;
break;
case "SliderBorder":
skin.sliderBorder = color;
break;
case "SliderBall":
skin.sliderBall = color;
break;
case "SpinnerApproachCircle":
skin.spinnerApproachCircle = color;
break;
case "SongSelectActiveText":
skin.songSelectActiveText = color;
break;
case "SongSelectInactiveText":
skin.songSelectInactiveText = color;
break;
case "StarBreakAdditive":
skin.starBreakAdditive = color;
break;
default:
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read color '%s' for file '%s'.",
line, skinFile.getAbsolutePath()), e);
}
}
if (!colors.isEmpty())
skin.combo = colors.toArray(new Color[colors.size()]);
break;
case "[Fonts]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
if ((tokens = tokenize(line)) == null)
continue;
try {
switch (tokens[0]) {
case "HitCirclePrefix":
skin.hitCirclePrefix = tokens[1];
break;
case "HitCircleOverlap":
skin.hitCircleOverlap = Integer.parseInt(tokens[1]);
break;
case "ScorePrefix":
skin.scorePrefix = tokens[1];
break;
case "ScoreOverlap":
skin.scoreOverlap = Integer.parseInt(tokens[1]);
break;
case "ComboPrefix":
skin.comboPrefix = tokens[1];
break;
case "ComboOverlap":
skin.comboOverlap = Integer.parseInt(tokens[1]);
break;
default:
break;
}
} catch (Exception e) {
Log.warn(String.format("Failed to read color '%s' for file '%s'.",
line, skinFile.getAbsolutePath()), e);
}
}
break;
default:
line = in.readLine();
break;
}
}
} catch (IOException e) {
Log.error(String.format("Failed to read file '%s'.", skinFile.getAbsolutePath()), e, false);
}
return skin;
}
/**
* Returns false if the line is too short or commented.
*/
private static boolean isValidLine(String line) {
return (line.length() > 1 && !line.startsWith("//"));
}
/**
* Splits line into two strings: tag, value.
* If no ':' character is present, null will be returned.
*/
private static String[] tokenize(String line) {
int index = line.indexOf(':');
if (index == -1) {
Log.debug(String.format("Failed to tokenize line: '%s'.", line));
return null;
}
String[] tokens = new String[2];
tokens[0] = line.substring(0, index).trim();
tokens[1] = line.substring(index + 1).trim();
return tokens;
}
}

BIN
xz-1.5.jar Normal file

Binary file not shown.