diff --git a/.gitignore b/.gitignore index 629e6a34..4ba1441d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,11 +15,17 @@ .metadata .classpath .project +.externalToolBuilders/ # IntelliJ .idea/ *.iml *.iws +*.ipr + +# Gradle +.gradle +build/ Thumbs.db -/target +/target \ No newline at end of file diff --git a/README.md b/README.md index 56d7f4b0..cc1455a2 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # [opsu!](http://itdelatrisu.github.io/opsu/) -**opsu!** is an unofficial open-source client for [osu!](https://osu.ppy.sh/), -a rhythm game based on popular commercial games such as *Ouendan* and -*Elite Beat Agents*. It is written in Java using [Slick2D](http://slick.ninjacave.com/) -and [LWJGL](http://lwjgl.org/), wrappers around the OpenGL and OpenAL libraries. +**opsu!** is an unofficial open-source client for the rhythm game +[osu!](https://osu.ppy.sh/). It is written in Java using +[Slick2D](http://slick.ninjacave.com/) and [LWJGL](http://lwjgl.org/), +wrappers around the OpenGL and OpenAL libraries. -opsu! runs on Windows, OS X, and Linux platforms. A [libGDX port](https://github.com/fluddokt/opsu) -additionally supports Android devices. +opsu! runs on Windows, OS X, and Linux platforms. +A [libGDX port](https://github.com/fluddokt/opsu) additionally supports Android +devices. ## Getting Started Precompiled binaries for opsu! can be found on the [releases](https://github.com/itdelatrisu/opsu/releases) page, with the latest -builds at the top. APK releases can be found [here](https://github.com/fluddokt/opsu/releases). +builds at the top. APK releases can be found +[here](https://github.com/fluddokt/opsu/releases). ### Java Setup The Java Runtime Environment (JRE) must be installed in order to run opsu!. @@ -19,34 +21,70 @@ The download page is located [here](https://www.java.com/en/download/). ### Beatmaps opsu! requires beatmaps to run, which are available for download on the [osu! website](https://osu.ppy.sh/p/beatmaplist) and mirror sites such as -[osu!Mirror](https://osu.yas-online.net/) or [Bloodcat](http://bloodcat.com/osu/). +[osu!Mirror](https://osu.yas-online.net/) and [Bloodcat](http://bloodcat.com/osu/). Beatmaps can also be downloaded directly through opsu! in the downloads menu. -If osu! is already installed, this application will attempt to load songs -directly from the osu! program folder. Otherwise, place songs in the generated -`Songs` folder or set the `BeatmapDirectory` value in the generated -configuration file to the path of the root song directory. +If osu! is already installed, this application will attempt to load beatmaps +directly from the osu! program folder. Otherwise, place beatmaps in the +generated `Songs` folder or set the "BeatmapDirectory" value in the generated +configuration file to the path of the root beatmap directory. Note that beatmaps are typically delivered as OSZ files. These can be extracted -with any ZIP tool, and opsu! will automatically extract them into the songs +with any ZIP tool, and opsu! will automatically extract them into the beatmap folder if placed in the `SongPacks` directory. ### First Run -The `Music Offset` value will likely need to be adjusted when playing for the +The "Music Offset" value will likely need to be adjusted when playing for the first time, or whenever hit objects are out of sync with the music. This and other game options can be accessed by clicking the "Other Options" button in the song menu. -## Building -opsu! is distributed as a Maven project. +### Directory Structure +The following files and folders will be created by opsu! as needed: +* `.opsu.cfg`: The configuration file. Most (but not all) of the settings can + be changed through the options menu. +* `.opsu.db`: The beatmap cache database. +* `.opsu_scores.db`: The scores database. +* `.opsu.log`: The error log. All critical errors displayed in-game are also + logged to this file, and other warnings not shown are logged as well. +* `Songs/`: The beatmap directory (not used if an osu! installation is detected). + The parser searches all of its subdirectories for .osu files to load. +* `SongPacks/`: The beatmap pack directory. The unpacker extracts all .osz + files within this directory to the beatmap directory. +* `Skins/`: The skins directory. Each skin must be placed in a folder within + this directory. Any game resource (in `res/`) can be skinned by placing a + file with the same name in a skin folder. Skins can be selected in the + options menu. +* `Screenshots/`: The screenshot directory. Screenshots can be taken by + pressing the F12 key. +* `Replays/`: The replay directory. Replays of each completed game are saved + as .osr files, and can be viewed at a later time or shared with others. +* `ReplayImport/`: The replay import directory. The importer moves all .osr + files within this directory to the replay directory and saves the scores in + the scores database. Replays can be imported from osu! as well as opsu!. +* `Natives/`: The native libraries directory. -* To run the project, execute the Maven goal `compile exec:exec`. -* To create a single executable JAR file, execute the Maven goal - `install -Djar`. This will link the LWJGL native libraries using a - [modified version](https://github.com/itdelatrisu/JarSplicePlus) of - [JarSplice](http://ninjacave.com/jarsplice), which is included in the - `tools` directory in both its original and modified forms. The resulting - file will be located in `target/opsu-${version}-runnable.jar`. +## Building +opsu! is distributed as both a [Maven](https://maven.apache.org/) and +[Gradle](https://gradle.org/) project. + +### Maven +Maven builds are built to the `target` directory. +* To run the project, execute the Maven goal `compile`. +* To create a single executable jar, execute the Maven goal `package -Djar`. + This will compile a jar to `target/opsu-${version}.jar` with the libraries, + resources and natives packed inside the jar. Setting the "XDG" property + (`-DXDG=true`) will make the application use XDG folders under Unix-like + operating systems. + +### Gradle +Gradle builds are built to the `build` directory. +* To run the project, execute the Gradle task `run`. +* To create a single executable jar, execute the Gradle task `jar`. + This will compile a jar to `build/libs/opsu-${version}.jar` with the libraries, + resources and natives packed inside the jar. Setting the "XDG" property + (`-PXDG=true`) will make the application use XDG folders under Unix-like + operating systems. ## Credits This software was created by Jeffrey Han diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..6c3e15e0 --- /dev/null +++ b/build.gradle @@ -0,0 +1,109 @@ +apply plugin: 'java' +apply plugin: 'maven' +apply plugin: 'eclipse' +apply plugin: 'idea' +apply plugin: 'application' + +import org.apache.tools.ant.filters.* + +group = 'itdelatrisu' +version = '0.11.0' + +mainClassName = 'itdelatrisu.opsu.Opsu' +buildDir = new File(rootProject.projectDir, "build/") + +def useXDG = 'false' +if (hasProperty('XDG')) { + useXDG = XDG +} + +sourceCompatibility = 1.7 +targetCompatibility = 1.7 + +sourceSets { + main { + java { + srcDir 'src' + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + compile('org.lwjgl.lwjgl:lwjgl:2.9.3') { + exclude group: 'net.java.jinput', module: 'jinput' + } + compile('org.slick2d:slick2d-core:1.0.1') { + exclude group: 'org.lwjgl.lwjgl', module: 'lwjgl' + } + compile 'org.jcraft:jorbis:0.0.17' + compile 'net.lingala.zip4j:zip4j:1.3.2' + compile 'com.googlecode.soundlibs:jlayer:1.0.1-1' + compile 'com.googlecode.soundlibs:mp3spi:1.9.5-1' + compile 'com.googlecode.soundlibs:tritonus-share:0.3.7-2' + compile 'org.xerial:sqlite-jdbc:3.8.6' + compile 'org.json:json:20140107' + compile 'net.java.dev.jna:jna:4.1.0' + compile 'net.java.dev.jna:jna-platform:4.1.0' + compile 'org.apache.maven:maven-artifact:3.3.3' + compile 'org.apache.commons:commons-compress:1.9' + compile 'org.tukaani:xz:1.5' + compile 'com.github.jponge:lzma-java:1.3' +} + +def nativePlatforms = ['windows', 'linux', 'osx'] +nativePlatforms.each { platform -> //noinspection GroovyAssignabilityCheck + task "${platform}Natives" { + def outputDir = "${buildDir}/natives/" + inputs.files(configurations.compile) + outputs.dir(outputDir) + doLast { + copy { + def artifacts = configurations.compile.resolvedConfiguration.resolvedArtifacts + .findAll { it.classifier == "natives-$platform" } + artifacts.each { + from zipTree(it.file) + } + into outputDir + } + } + } +} + +processResources { + from 'res' + exclude '**/Thumbs.db' + + filesMatching('version') { + expand(version: project.version, timestamp: new Date().format("yyyy-MM-dd HH:mm")) + } +} + +task unpackNatives { + description "Copies native libraries to the build directory." + dependsOn nativePlatforms.collect { "${it}Natives" }.findAll { tasks[it] } +} + +jar { + manifest { + attributes 'Implementation-Title': 'opsu!', + 'Implementation-Version': version, + 'Main-Class': mainClassName, + 'Use-XDG': useXDG + } + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + baseName = "opsu" + + from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } + exclude '**/Thumbs.db' + + outputs.upToDateWhen { false } +} + +run { + dependsOn 'unpackNatives' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..085a1cdc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..184888ae --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Aug 25 19:45:21 BST 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.3-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..91a7e269 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..8a0b282a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pom.xml b/pom.xml index 4eb303ab..f440c568 100644 --- a/pom.xml +++ b/pom.xml @@ -1,12 +1,15 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 itdelatrisu opsu - 0.9.0 + 0.11.0 + ${project.version} ${maven.build.timestamp} yyyy-MM-dd HH:mm + itdelatrisu.opsu.Opsu + false src @@ -67,30 +70,9 @@ ${jar} java - -Djava.library.path=${project.build.directory}/natives -cp - itdelatrisu.opsu.Opsu - - - - - jarsplice - install - - exec - - - java - ${basedir}/target - - -jar - -Dinput=opsu-${project.version}.jar - -Dmain=itdelatrisu.opsu.Opsu - -Doutput=opsu-${project.version}-runnable.jar - - - ${basedir}/tools/JarSplicePlus.jar + ${mainClassName} @@ -98,15 +80,8 @@ maven-shade-plugin - 2.3 + 2.4.1 - @@ -124,6 +99,14 @@ + + + + ${mainClassName} + ${XDG} + + + false @@ -142,12 +125,24 @@ org.lwjgl.lwjgl lwjgl - 2.9.1 + 2.9.3 + + + net.java.jinput + jinput + + org.slick2d slick2d-core - 1.0.0 + 1.0.1 + + + org.lwjgl.lwjgl + lwjgl + + org.jcraft @@ -197,17 +192,22 @@ org.apache.maven maven-artifact - 3.0.3 + 3.3.3 org.apache.commons commons-compress - 1.8 + 1.9 + + + org.tukaani + xz + 1.5 com.github.jponge lzma-java - 1.2 + 1.3 - + \ No newline at end of file diff --git a/res/bang.png b/res/bang.png deleted file mode 100644 index 6f1e3274..00000000 Binary files a/res/bang.png and /dev/null differ diff --git a/res/chevron-down.png b/res/chevron-down.png new file mode 100644 index 00000000..670b237f Binary files /dev/null and b/res/chevron-down.png differ diff --git a/res/chevron-right.png b/res/chevron-right.png new file mode 100644 index 00000000..76f6c651 Binary files /dev/null and b/res/chevron-right.png differ diff --git a/res/download.png b/res/download.png new file mode 100644 index 00000000..86d75d82 Binary files /dev/null and b/res/download.png differ diff --git a/res/options-background.jpg b/res/options-background.jpg deleted file mode 100644 index d590ad6b..00000000 Binary files a/res/options-background.jpg and /dev/null differ diff --git a/res/options-background.png b/res/options-background.png new file mode 100644 index 00000000..10612e32 Binary files /dev/null and b/res/options-background.png differ diff --git a/res/star.png b/res/star.png new file mode 100644 index 00000000..f6b06d99 Binary files /dev/null and b/res/star.png differ diff --git a/res/star2.png b/res/star2.png new file mode 100644 index 00000000..c2160edc Binary files /dev/null and b/res/star2.png differ diff --git a/res/update.png b/res/update.png new file mode 100644 index 00000000..e1db162f Binary files /dev/null and b/res/update.png differ diff --git a/res/version b/res/version index 27f750ad..399a7f01 100644 --- a/res/version +++ b/res/version @@ -1,3 +1,3 @@ -version=${pom.version} -file=https://github.com/itdelatrisu/opsu/releases/download/${pom.version}/opsu-${pom.version}.jar +version=${version} +file=https://github.com/itdelatrisu/opsu/releases/download/${version}/opsu-${version}.jar build.date=${timestamp} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..0dcd0cb3 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'opsu' diff --git a/src/itdelatrisu/opsu/Container.java b/src/itdelatrisu/opsu/Container.java index 6fa3fc7e..5fbb774b 100644 --- a/src/itdelatrisu/opsu/Container.java +++ b/src/itdelatrisu/opsu/Container.java @@ -21,6 +21,7 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapWatchService; import itdelatrisu.opsu.downloads.DownloadList; import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.render.CurveRenderState; @@ -125,7 +126,7 @@ public class Container extends AppGameContainer { // reset image references GameImage.clearReferences(); GameData.Grade.clearReferences(); - Beatmap.getBackgroundImageCache().clear(); + Beatmap.clearBackgroundImageCache(); // prevent loading tracks from re-initializing OpenAL MusicController.reset(); @@ -136,6 +137,11 @@ public class Container extends AppGameContainer { // delete OpenGL objects involved in the Curve rendering CurveRenderState.shutdown(); + + // destroy watch service + if (!Options.isWatchServiceEnabled()) + BeatmapWatchService.destroy(); + BeatmapWatchService.removeListeners(); } @Override diff --git a/src/itdelatrisu/opsu/ErrorHandler.java b/src/itdelatrisu/opsu/ErrorHandler.java index 31798388..1dc4d227 100644 --- a/src/itdelatrisu/opsu/ErrorHandler.java +++ b/src/itdelatrisu/opsu/ErrorHandler.java @@ -20,8 +20,10 @@ package itdelatrisu.opsu; import java.awt.Cursor; import java.awt.Desktop; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLEncoder; import java.util.Properties; @@ -44,12 +46,13 @@ public class ErrorHandler { /** Error popup description text. */ private static final String desc = "opsu! has encountered an error.", - descR = "opsu! has encountered an error. Please report this!"; + descReport = "opsu! has encountered an error. Please report this!"; /** Error popup button options. */ private static final String[] - options = {"View Error Log", "Close"}, - optionsR = {"Send Report", "View Error Log", "Close"}; + optionsLog = {"View Error Log", "Close"}, + optionsReport = {"Send Report", "Close"}, + optionsLogReport = {"Send Report", "View Error Log", "Close"}; /** Text area for Exception. */ private static final JTextArea textArea = new JTextArea(7, 30); @@ -59,6 +62,7 @@ public class ErrorHandler { textArea.setCursor(Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR)); textArea.setTabSize(2); textArea.setLineWrap(true); + textArea.setWrapStyleWord(true); } /** Scroll pane holding JTextArea. */ @@ -67,7 +71,7 @@ public class ErrorHandler { /** Error popup objects. */ private static final Object[] message = { desc, scroll }, - messageR = { descR, scroll }; + messageReport = { descReport, scroll }; // This class should not be instantiated. private ErrorHandler() {} @@ -107,72 +111,112 @@ public class ErrorHandler { // display popup try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + Desktop desktop = null; + boolean isBrowseSupported = false, isOpenSupported = false; if (Desktop.isDesktopSupported()) { - // try to open the log file and/or issues webpage - if (report) { - // ask to report the error - int n = JOptionPane.showOptionDialog(null, messageR, title, - JOptionPane.DEFAULT_OPTION, JOptionPane.ERROR_MESSAGE, - null, optionsR, optionsR[2]); - if (n == 0) { - // auto-fill debug information - String issueTitle = (error != null) ? error : e.getMessage(); - StringBuilder sb = new StringBuilder(); - Properties props = new Properties(); - props.load(ResourceLoader.getResourceAsStream(Options.VERSION_FILE)); - String version = props.getProperty("version"); - if (version != null && !version.equals("${pom.version}")) { - sb.append("**Version:** "); - sb.append(version); - sb.append('\n'); - } - String timestamp = props.getProperty("build.date"); - if (timestamp != null && - !timestamp.equals("${maven.build.timestamp}") && !timestamp.equals("${timestamp}")) { - sb.append("**Build date:** "); - sb.append(timestamp); - sb.append('\n'); - } - sb.append("**OS:** "); - sb.append(System.getProperty("os.name")); - sb.append(" ("); - sb.append(System.getProperty("os.arch")); - sb.append(")\n"); - sb.append("**JRE:** "); - sb.append(System.getProperty("java.version")); - sb.append('\n'); - if (error != null) { - sb.append("**Error:** `"); - sb.append(error); - sb.append("`\n"); - } - if (trace != null) { - sb.append("**Stack trace:**"); - sb.append("\n```\n"); - sb.append(trace); - sb.append("```"); - } - URI uri = URI.create(String.format(Options.ISSUES_URL, - URLEncoder.encode(issueTitle, "UTF-8"), - URLEncoder.encode(sb.toString(), "UTF-8"))); - Desktop.getDesktop().browse(uri); - } else if (n == 1) - Desktop.getDesktop().open(Options.LOG_FILE); - } else { - // don't report the error + desktop = Desktop.getDesktop(); + isBrowseSupported = desktop.isSupported(Desktop.Action.BROWSE); + isOpenSupported = desktop.isSupported(Desktop.Action.OPEN); + } + if (desktop != null && (isOpenSupported || (report && isBrowseSupported))) { // try to open the log file and/or issues webpage + if (report && isBrowseSupported) { // ask to report the error + if (isOpenSupported) { // also ask to open the log + int n = JOptionPane.showOptionDialog(null, messageReport, title, + JOptionPane.DEFAULT_OPTION, JOptionPane.ERROR_MESSAGE, + null, optionsLogReport, optionsLogReport[2]); + if (n == 0) + desktop.browse(getIssueURI(error, e, trace)); + else if (n == 1) + desktop.open(Options.LOG_FILE); + } else { // only ask to report the error + int n = JOptionPane.showOptionDialog(null, message, title, + JOptionPane.DEFAULT_OPTION, JOptionPane.ERROR_MESSAGE, + null, optionsReport, optionsReport[1]); + if (n == 0) + desktop.browse(getIssueURI(error, e, trace)); + } + } else { // don't report the error int n = JOptionPane.showOptionDialog(null, message, title, JOptionPane.DEFAULT_OPTION, JOptionPane.ERROR_MESSAGE, - null, options, options[1]); + null, optionsLog, optionsLog[1]); if (n == 0) - Desktop.getDesktop().open(Options.LOG_FILE); + desktop.open(Options.LOG_FILE); } - } else { - // display error only - JOptionPane.showMessageDialog(null, report ? messageR : message, + } else { // display error only + JOptionPane.showMessageDialog(null, report ? messageReport : message, title, JOptionPane.ERROR_MESSAGE); } } catch (Exception e1) { - Log.error("Error opening crash popup.", e1); + Log.error("An error occurred in the crash popup.", e1); + } + } + + /** + * Returns the issue reporting URI. + * This will auto-fill the report with the relevant information if possible. + * @param error a description of the error + * @param e the exception causing the error + * @param trace the stack trace + * @return the created URI + */ + private static URI getIssueURI(String error, Throwable e, String trace) { + // generate report information + String issueTitle = (error != null) ? error : e.getMessage(); + StringBuilder sb = new StringBuilder(); + try { + // read version and build date from version file, if possible + Properties props = new Properties(); + props.load(ResourceLoader.getResourceAsStream(Options.VERSION_FILE)); + String version = props.getProperty("version"); + if (version != null && !version.equals("${pom.version}")) { + sb.append("**Version:** "); + sb.append(version); + String hash = Utils.getGitHash(); + if (hash != null) { + sb.append(" ("); + sb.append(hash.substring(0, 12)); + sb.append(')'); + } + sb.append('\n'); + } + String timestamp = props.getProperty("build.date"); + if (timestamp != null && + !timestamp.equals("${maven.build.timestamp}") && !timestamp.equals("${timestamp}")) { + sb.append("**Build date:** "); + sb.append(timestamp); + sb.append('\n'); + } + } catch (IOException e1) { + Log.warn("Could not read version file.", e1); + } + sb.append("**OS:** "); + sb.append(System.getProperty("os.name")); + sb.append(" ("); + sb.append(System.getProperty("os.arch")); + sb.append(")\n"); + sb.append("**JRE:** "); + sb.append(System.getProperty("java.version")); + sb.append('\n'); + if (error != null) { + sb.append("**Error:** `"); + sb.append(error); + sb.append("`\n"); + } + if (trace != null) { + sb.append("**Stack trace:**"); + sb.append("\n```\n"); + sb.append(trace); + sb.append("```"); + } + + // return auto-filled URI + try { + return URI.create(String.format(Options.ISSUES_URL, + URLEncoder.encode(issueTitle, "UTF-8"), + URLEncoder.encode(sb.toString(), "UTF-8"))); + } catch (UnsupportedEncodingException e1) { + Log.warn("URLEncoder failed to encode the auto-filled issue report URL."); + return URI.create(String.format(Options.ISSUES_URL, "", "")); } } } diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java index b3d29e12..1dd3de06 100644 --- a/src/itdelatrisu/opsu/GameData.java +++ b/src/itdelatrisu/opsu/GameData.java @@ -26,8 +26,12 @@ import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.objects.curves.Curve; +import itdelatrisu.opsu.objects.curves.Vec2f; import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.replay.ReplayFrame; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.io.File; import java.util.Date; @@ -87,7 +91,7 @@ public class GameData { D (GameImage.RANKING_D, GameImage.RANKING_D_SMALL); /** GameImages associated with this grade (large and small sizes). */ - private GameImage large, small; + private final GameImage large, small; /** Large-size image scaled for use in song menu. */ private Image menuImage; @@ -129,7 +133,7 @@ public class GameData { return menuImage; Image img = getSmallImage(); - if (!small.hasSkinImage()) // save default image only + if (!small.hasBeatmapSkinImage()) // save default image only this.menuImage = img; return img; } @@ -199,14 +203,14 @@ public class GameData { */ private class HitErrorInfo { /** The correct hit time. */ - private int time; + private final int time; /** The coordinates of the hit. */ @SuppressWarnings("unused") - private int x, y; + private final int x, y; /** The difference between the correct and actual hit times. */ - private int timeDiff; + private final int timeDiff; /** * Constructor. @@ -232,29 +236,32 @@ public class GameData { /** Hit result helper class. */ private class HitObjectResult { /** Object start time. */ - public int time; + public final int time; /** Hit result. */ - public int result; + public final int result; /** Object coordinates. */ - public float x, y; + public final float x, y; /** Combo color. */ - public Color color; + public final Color color; /** The type of the hit object. */ - public HitObjectType hitResultType; + public final HitObjectType hitResultType; + + /** Slider curve. */ + public final Curve curve; + + /** Whether or not to expand when animating. */ + public final boolean expand; + + /** Whether or not to hide the hit result. */ + public final boolean hideResult; /** Alpha level (for fading out). */ public float alpha = 1f; - /** Slider curve. */ - public Curve curve; - - /** Whether or not to expand when animating. */ - public boolean expand; - /** * Constructor. * @param time the result's starting track position @@ -262,11 +269,13 @@ public class GameData { * @param x the center x coordinate * @param y the center y coordinate * @param color the color of the hit object + * @param hitResultType the hit object type * @param curve the slider curve (or null if not applicable) * @param expand whether or not the hit result animation should expand (if applicable) + * @param hideResult whether or not to hide the hit result (but still show the other animations) */ public HitObjectResult(int time, int result, float x, float y, Color color, - HitObjectType hitResultType, Curve curve, boolean expand) { + HitObjectType hitResultType, Curve curve, boolean expand, boolean hideResult) { this.time = time; this.result = result; this.x = x; @@ -275,6 +284,7 @@ public class GameData { this.hitResultType = hitResultType; this.curve = curve; this.expand = expand; + this.hideResult = hideResult; } } @@ -316,7 +326,7 @@ public class GameData { private Replay replay; /** Whether this object is used for gameplay (true) or score viewing (false). */ - private boolean gameplay; + private boolean isGameplay; /** Container dimensions. */ private int width, height; @@ -329,7 +339,7 @@ public class GameData { public GameData(int width, int height) { this.width = width; this.height = height; - this.gameplay = true; + this.isGameplay = true; clear(); } @@ -345,7 +355,7 @@ public class GameData { public GameData(ScoreData s, int width, int height) { this.width = width; this.height = height; - this.gameplay = false; + this.isGameplay = false; this.scoreData = s; this.score = s.score; @@ -400,8 +410,8 @@ public class GameData { // gameplay-specific images if (isGameplay()) { // combo burst images - if (GameImage.COMBO_BURST.hasSkinImages() || - (!GameImage.COMBO_BURST.hasSkinImage() && GameImage.COMBO_BURST.getImages() != null)) + if (GameImage.COMBO_BURST.hasBeatmapSkinImages() || + (!GameImage.COMBO_BURST.hasBeatmapSkinImage() && GameImage.COMBO_BURST.getImages() != null)) comboBurstImages = GameImage.COMBO_BURST.getImages(); else comboBurstImages = new Image[]{ GameImage.COMBO_BURST.getImage() }; @@ -474,6 +484,7 @@ public class GameData { /** * Sets the array of hit result offsets. + * @param hitResultOffset the time offset array (of size {@link #HIT_MAX}) */ public void setHitResultOffset(int[] hitResultOffset) { this.hitResultOffset = hitResultOffset; } @@ -614,7 +625,7 @@ public class GameData { ); } else { // lead-in time (yellow) - g.setColor(Utils.COLOR_YELLOW_ALPHA); + g.setColor(Colors.YELLOW_ALPHA); g.fillArc(circleX, symbolHeight, circleDiameter, circleDiameter, -90 + (int) (360f * trackPosition / firstObjectTime), -90 ); @@ -651,27 +662,27 @@ public class GameData { float hitErrorY = height / uiScale - margin - 10; float barY = (hitErrorY - 3) * uiScale, barHeight = 6 * uiScale; float tickY = (hitErrorY - 10) * uiScale, tickHeight = 20 * uiScale; - float oldAlphaBlack = Utils.COLOR_BLACK_ALPHA.a; - Utils.COLOR_BLACK_ALPHA.a = hitErrorAlpha; - g.setColor(Utils.COLOR_BLACK_ALPHA); + float oldAlphaBlack = Colors.BLACK_ALPHA.a; + Colors.BLACK_ALPHA.a = hitErrorAlpha; + g.setColor(Colors.BLACK_ALPHA); g.fillRect((hitErrorX - 3 - hitResultOffset[HIT_50]) * uiScale, tickY, (hitResultOffset[HIT_50] * 2) * uiScale, tickHeight); - Utils.COLOR_BLACK_ALPHA.a = oldAlphaBlack; - Utils.COLOR_LIGHT_ORANGE.a = hitErrorAlpha; - g.setColor(Utils.COLOR_LIGHT_ORANGE); + Colors.BLACK_ALPHA.a = oldAlphaBlack; + Colors.LIGHT_ORANGE.a = hitErrorAlpha; + g.setColor(Colors.LIGHT_ORANGE); g.fillRect((hitErrorX - 3 - hitResultOffset[HIT_50]) * uiScale, barY, (hitResultOffset[HIT_50] * 2) * uiScale, barHeight); - Utils.COLOR_LIGHT_ORANGE.a = 1f; - Utils.COLOR_LIGHT_GREEN.a = hitErrorAlpha; - g.setColor(Utils.COLOR_LIGHT_GREEN); + Colors.LIGHT_ORANGE.a = 1f; + Colors.LIGHT_GREEN.a = hitErrorAlpha; + g.setColor(Colors.LIGHT_GREEN); g.fillRect((hitErrorX - 3 - hitResultOffset[HIT_100]) * uiScale, barY, (hitResultOffset[HIT_100] * 2) * uiScale, barHeight); - Utils.COLOR_LIGHT_GREEN.a = 1f; - Utils.COLOR_LIGHT_BLUE.a = hitErrorAlpha; - g.setColor(Utils.COLOR_LIGHT_BLUE); + Colors.LIGHT_GREEN.a = 1f; + Colors.LIGHT_BLUE.a = hitErrorAlpha; + g.setColor(Colors.LIGHT_BLUE); g.fillRect((hitErrorX - 3 - hitResultOffset[HIT_300]) * uiScale, barY, (hitResultOffset[HIT_300] * 2) * uiScale, barHeight); - Utils.COLOR_LIGHT_BLUE.a = 1f; + Colors.LIGHT_BLUE.a = 1f; white.a = hitErrorAlpha; g.setColor(white); g.fillRect((hitErrorX - 1.5f) * uiScale, tickY, 3 * uiScale, tickHeight); @@ -830,16 +841,16 @@ public class GameData { // header Image rankingTitle = GameImage.RANKING_TITLE.getImage(); - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, 100 * uiScale); rankingTitle.draw((width * 0.97f) - rankingTitle.getWidth(), 0); float marginX = width * 0.01f, marginY = height * 0.002f; - Utils.FONT_LARGE.drawString(marginX, marginY, + Fonts.LARGE.drawString(marginX, marginY, String.format("%s - %s [%s]", beatmap.getArtist(), beatmap.getTitle(), beatmap.version), Color.white); - Utils.FONT_MEDIUM.drawString(marginX, marginY + Utils.FONT_LARGE.getLineHeight() - 6, + Fonts.MEDIUM.drawString(marginX, marginY + Fonts.LARGE.getLineHeight() - 6, String.format("Beatmap by %s", beatmap.creator), Color.white); String player = (scoreData.playerName == null) ? "" : String.format(" by %s", scoreData.playerName); - Utils.FONT_MEDIUM.drawString(marginX, marginY + Utils.FONT_LARGE.getLineHeight() + Utils.FONT_MEDIUM.getLineHeight() - 10, + Fonts.MEDIUM.drawString(marginX, marginY + Fonts.LARGE.getLineHeight() + Fonts.MEDIUM.getLineHeight() - 10, String.format("Played%s on %s.", player, scoreData.getTimeString()), Color.white); // mod icons @@ -872,7 +883,7 @@ public class GameData { } // hit lighting - else if (Options.isHitLightingEnabled() && hitResult.result != HIT_MISS && + else if (Options.isHitLightingEnabled() && !hitResult.hideResult && hitResult.result != HIT_MISS && hitResult.result != HIT_SLIDER30 && hitResult.result != HIT_SLIDER10) { // TODO: add particle system Image lighting = GameImage.LIGHTING.getImage(); @@ -885,47 +896,45 @@ public class GameData { hitResult.hitResultType == HitObjectType.CIRCLE || hitResult.hitResultType == HitObjectType.SLIDER_FIRST || hitResult.hitResultType == HitObjectType.SLIDER_LAST)) { - float scale = (!hitResult.expand) ? 1f : Utils.easeOut( - Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME), - 1f, HITCIRCLE_ANIM_SCALE - 1f, HITCIRCLE_FADE_TIME - ); - float alpha = Utils.easeOut( - Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME), - 1f, -1f, HITCIRCLE_FADE_TIME - ); + float progress = AnimationEquation.OUT_CUBIC.calc( + (float) Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_FADE_TIME) / HITCIRCLE_FADE_TIME); + float scale = (!hitResult.expand) ? 1f : 1f + (HITCIRCLE_ANIM_SCALE - 1f) * progress; + float alpha = 1f - progress; // slider curve if (hitResult.curve != null) { - float oldWhiteAlpha = Utils.COLOR_WHITE_FADE.a; + float oldWhiteAlpha = Colors.WHITE_FADE.a; float oldColorAlpha = hitResult.color.a; - Utils.COLOR_WHITE_FADE.a = alpha; + Colors.WHITE_FADE.a = alpha; hitResult.color.a = alpha; hitResult.curve.draw(hitResult.color); - Utils.COLOR_WHITE_FADE.a = oldWhiteAlpha; + Colors.WHITE_FADE.a = oldWhiteAlpha; hitResult.color.a = oldColorAlpha; } // hit circles - Image scaledHitCircle = GameImage.HITCIRCLE.getImage().getScaledCopy(scale); - Image scaledHitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(scale); - scaledHitCircle.setAlpha(alpha); - scaledHitCircleOverlay.setAlpha(alpha); - scaledHitCircle.drawCentered(hitResult.x, hitResult.y, hitResult.color); - scaledHitCircleOverlay.drawCentered(hitResult.x, hitResult.y); + if (!(hitResult.hitResultType == HitObjectType.CIRCLE && GameMod.HIDDEN.isActive())) { + // "hidden" mod: expanding animation for only circles not drawn + Image scaledHitCircle = GameImage.HITCIRCLE.getImage().getScaledCopy(scale); + Image scaledHitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(scale); + scaledHitCircle.setAlpha(alpha); + scaledHitCircleOverlay.setAlpha(alpha); + scaledHitCircle.drawCentered(hitResult.x, hitResult.y, hitResult.color); + scaledHitCircleOverlay.drawCentered(hitResult.x, hitResult.y); + } } // hit result - if (hitResult.hitResultType == HitObjectType.CIRCLE || + if (!hitResult.hideResult && ( + hitResult.hitResultType == HitObjectType.CIRCLE || hitResult.hitResultType == HitObjectType.SPINNER || - hitResult.curve != null) { - float scale = Utils.easeBounce( - Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_TEXT_BOUNCE_TIME), - 1f, HITCIRCLE_TEXT_ANIM_SCALE - 1f, HITCIRCLE_TEXT_BOUNCE_TIME - ); - float alpha = Utils.easeOut( - Utils.clamp((trackPosition - hitResult.time) - HITCIRCLE_FADE_TIME, 0, HITCIRCLE_TEXT_FADE_TIME), - 1f, -1f, HITCIRCLE_TEXT_FADE_TIME - ); + hitResult.curve != null)) { + float scaleProgress = AnimationEquation.IN_OUT_BOUNCE.calc( + (float) Utils.clamp(trackPosition - hitResult.time, 0, HITCIRCLE_TEXT_BOUNCE_TIME) / HITCIRCLE_TEXT_BOUNCE_TIME); + float scale = 1f + (HITCIRCLE_TEXT_ANIM_SCALE - 1f) * scaleProgress; + float fadeProgress = AnimationEquation.OUT_CUBIC.calc( + (float) Utils.clamp((trackPosition - hitResult.time) - HITCIRCLE_FADE_TIME, 0, HITCIRCLE_TEXT_FADE_TIME) / HITCIRCLE_TEXT_FADE_TIME); + float alpha = 1f - fadeProgress; Image scaledHitResult = hitResults[hitResult.result].getScaledCopy(scale); scaledHitResult.setAlpha(alpha); scaledHitResult.drawCentered(hitResult.x, hitResult.y); @@ -1037,10 +1046,13 @@ public class GameData { * or {@code Grade.NULL} if no objects have been processed. */ private Grade getGrade() { + boolean silver = (scoreData == null) ? + (GameMod.HIDDEN.isActive() || GameMod.FLASHLIGHT.isActive()) : + (scoreData.mods & (GameMod.HIDDEN.getBit() | GameMod.FLASHLIGHT.getBit())) != 0; return getGrade( hitResultCount[HIT_300], hitResultCount[HIT_100], hitResultCount[HIT_50], hitResultCount[HIT_MISS], - (GameMod.HIDDEN.isActive() || GameMod.FLASHLIGHT.isActive()) + silver ); } @@ -1179,7 +1191,7 @@ public class GameData { switch (result) { case HIT_SLIDER30: hitValue = 30; - changeHealth(1f); + changeHealth(2f); SoundController.playHitSound( hitObject.getEdgeHitSoundType(repeat), hitObject.getSampleSet(repeat), @@ -1187,6 +1199,7 @@ public class GameData { break; case HIT_SLIDER10: hitValue = 10; + changeHealth(1f); SoundController.playHitSound(HitSound.SLIDERTICK); break; case HIT_MISS: @@ -1204,7 +1217,7 @@ public class GameData { if (!Options.isPerfectHitBurstEnabled()) ; // hide perfect hit results else - hitResultList.add(new HitObjectResult(time, result, x, y, null, HitObjectType.SLIDERTICK, null, false)); + hitResultList.add(new HitObjectResult(time, result, x, y, null, HitObjectType.SLIDERTICK, null, false, false)); } fullObjectCount++; } @@ -1360,20 +1373,18 @@ public class GameData { int hitResult = handleHitResult(time, result, x, y, color, end, hitObject, hitResultType, repeat, (curve != null && !sliderHeldToEnd)); - if ((hitResult == HIT_300 || hitResult == HIT_300G || hitResult == HIT_300K) && !Options.isPerfectHitBurstEnabled()) - ; // hide perfect hit results - else if (hitResult == HIT_MISS && (GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive())) - ; // "relax" and "autopilot" mods: hide misses - else { - hitResultList.add(new HitObjectResult(time, hitResult, x, y, color, hitResultType, curve, expand)); + if (hitResult == HIT_MISS && (GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive())) + return; // "relax" and "autopilot" mods: hide misses - // sliders: add the other curve endpoint for the hit animation - if (curve != null) { - boolean isFirst = (hitResultType == HitObjectType.SLIDER_FIRST); - float[] p = curve.pointAt((isFirst) ? 1f : 0f); - HitObjectType type = (isFirst) ? HitObjectType.SLIDER_LAST : HitObjectType.SLIDER_FIRST; - hitResultList.add(new HitObjectResult(time, hitResult, p[0], p[1], color, type, null, expand)); - } + boolean hideResult = (hitResult == HIT_300 || hitResult == HIT_300G || hitResult == HIT_300K) && !Options.isPerfectHitBurstEnabled(); + hitResultList.add(new HitObjectResult(time, hitResult, x, y, color, hitResultType, curve, expand, hideResult)); + + // sliders: add the other curve endpoint for the hit animation + if (curve != null) { + boolean isFirst = (hitResultType == HitObjectType.SLIDER_FIRST); + Vec2f p = curve.pointAt((isFirst) ? 1f : 0f); + HitObjectType type = (isFirst) ? HitObjectType.SLIDER_LAST : HitObjectType.SLIDER_FIRST; + hitResultList.add(new HitObjectResult(time, hitResult, p.x, p.y, color, type, null, expand, hideResult)); } } @@ -1460,7 +1471,13 @@ public class GameData { * Returns whether or not this object is used for gameplay. * @return true if gameplay, false if score viewing */ - public boolean isGameplay() { return gameplay; } + public boolean isGameplay() { return isGameplay; } + + /** + * Sets whether or not this object is used for gameplay. + * @param gameplay true if gameplay, false if score viewing + */ + public void setGameplay(boolean gameplay) { this.isGameplay = gameplay; } /** * Adds the hit into the list of hit error information. diff --git a/src/itdelatrisu/opsu/GameImage.java b/src/itdelatrisu/opsu/GameImage.java index c8cb3bfb..51b93ab7 100644 --- a/src/itdelatrisu/opsu/GameImage.java +++ b/src/itdelatrisu/opsu/GameImage.java @@ -18,6 +18,8 @@ package itdelatrisu.opsu; +import itdelatrisu.opsu.ui.Fonts; + import java.io.File; import java.util.ArrayList; import java.util.List; @@ -35,8 +37,8 @@ public enum GameImage { CURSOR ("cursor", "png"), CURSOR_MIDDLE ("cursormiddle", "png"), CURSOR_TRAIL ("cursortrail", "png"), - CURSOR_OLD ("cursor2", "png", false, false), - CURSOR_TRAIL_OLD ("cursortrail2", "png", false, false), + CURSOR_OLD ("cursor2", "png", false, false), // custom + CURSOR_TRAIL_OLD ("cursortrail2", "png", false, false), // custom // Game SECTION_PASS ("section-pass", "png"), @@ -251,14 +253,14 @@ public enum GameImage { MENU_MUSICNOTE ("music-note", "png", false, false) { @Override protected Image process_sub(Image img, int w, int h) { - int r = (int) ((Utils.FONT_LARGE.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() - 8) / getUIscale()); + int r = (int) ((Fonts.LARGE.getLineHeight() + Fonts.DEFAULT.getLineHeight() - 8) / getUIscale()); return img.getScaledCopy(r, r); } }, MENU_LOADER ("loader", "png", false, false) { @Override protected Image process_sub(Image img, int w, int h) { - int r = (int) ((Utils.FONT_LARGE.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() - 8) / getUIscale()); + int r = (int) ((Fonts.LARGE.getLineHeight() + Fonts.DEFAULT.getLineHeight() - 8) / getUIscale()); return img.getScaledCopy(r / 48f); } }, @@ -290,12 +292,25 @@ public enum GameImage { MENU_BUTTON_MID ("button-middle", "png", false, false), MENU_BUTTON_LEFT ("button-left", "png", false, false), MENU_BUTTON_RIGHT ("button-right", "png", false, false), + STAR ("star", "png", false, false) { + @Override + protected Image process_sub(Image img, int w, int h) { + return img.getScaledCopy((MENU_BUTTON_BG.getImage().getHeight() * 0.16f) / img.getHeight()); + } + }, + STAR2 ("star2", "png", false, false) { + @Override + protected Image process_sub(Image img, int w, int h) { + return img.getScaledCopy((MENU_BUTTON_BG.getImage().getHeight() * 0.33f) / img.getHeight()); + } + }, // Music Player Buttons MUSIC_PLAY ("music-play", "png", false, false), MUSIC_PAUSE ("music-pause", "png", false, false), MUSIC_NEXT ("music-next", "png", false, false), MUSIC_PREVIOUS ("music-previous", "png", false, false), + DOWNLOADS ("downloads", "png", false, false) { @Override protected Image process_sub(Image img, int w, int h) { @@ -312,7 +327,7 @@ public enum GameImage { DELETE ("delete", "png", false, false) { @Override protected Image process_sub(Image img, int w, int h) { - int lineHeight = Utils.FONT_DEFAULT.getLineHeight(); + int lineHeight = Fonts.DEFAULT.getLineHeight(); return img.getScaledCopy(lineHeight, lineHeight); } }, @@ -328,10 +343,16 @@ public enum GameImage { return img.getScaledCopy((h / 17f) / img.getHeight()); } }, - BANG ("bang", "png", false, false) { + DOWNLOAD ("download", "png", false, false) { @Override protected Image process_sub(Image img, int w, int h) { - return REPOSITORY.process_sub(img, w, h); + return img.getScaledCopy((h / 14f) / img.getHeight()); + } + }, + UPDATE ("update", "png", false, false) { + @Override + protected Image process_sub(Image img, int w, int h) { + return img.getScaledCopy((h / 14f) / img.getHeight()); } }, OPTIONS_BG ("options-background", "png|jpg", false, true) { @@ -341,6 +362,8 @@ public enum GameImage { return img.getScaledCopy(w, h); } }, + CHEVRON_DOWN ("chevron-down", "png", false, false), + CHEVRON_RIGHT ("chevron-right", "png", false, false), // TODO: ensure this image hasn't been modified (checksum?) ALPHA_MAP ("alpha", "png", false, false); @@ -351,22 +374,22 @@ public enum GameImage { IMG_JPG = 2; /** The file name. */ - private String filename; + private final String filename; /** The formatted file name string (for loading multiple images). */ private String filenameFormat; /** Image file type. */ - private byte type; + private final byte type; /** * Whether or not the image is skinnable by a beatmap. * These images are typically related to gameplay. */ - private boolean skinnable; + private final boolean beatmapSkinnable; /** Whether or not to preload the image when the program starts. */ - private boolean preload; + private final boolean preload; /** The default image. */ private Image defaultImage; @@ -374,6 +397,9 @@ public enum GameImage { /** The default image array. */ private Image[] defaultImages; + /** Whether the image is currently skinned by a game skin. */ + private boolean isSkinned = false; + /** The beatmap skin image (optional, temporary). */ private Image skinImage; @@ -421,6 +447,7 @@ public enum GameImage { for (GameImage img : GameImage.values()) { img.defaultImage = img.skinImage = null; img.defaultImages = img.skinImages = null; + img.isSkinned = false; } } @@ -488,7 +515,7 @@ public enum GameImage { } /** - * Constructor for game-related images (skinnable and preloaded). + * Constructor for game-related images (beatmap-skinnable and preloaded). * @param filename the image file name * @param type the file types (separated by '|') */ @@ -497,7 +524,7 @@ public enum GameImage { } /** - * Constructor for an array of game-related images (skinnable and preloaded). + * Constructor for an array of game-related images (beatmap-skinnable and preloaded). * @param filename the image file name * @param filenameFormat the formatted file name string (for loading multiple images) * @param type the file types (separated by '|') @@ -511,21 +538,21 @@ public enum GameImage { * Constructor for general images. * @param filename the image file name * @param type the file types (separated by '|') - * @param skinnable whether or not the image is skinnable + * @param beatmapSkinnable whether or not the image is beatmap-skinnable * @param preload whether or not to preload the image */ - GameImage(String filename, String type, boolean skinnable, boolean preload) { + GameImage(String filename, String type, boolean beatmapSkinnable, boolean preload) { this.filename = filename; this.type = getType(type); - this.skinnable = skinnable; + this.beatmapSkinnable = beatmapSkinnable; this.preload = preload; } /** - * Returns whether or not the image is skinnable. - * @return true if skinnable + * Returns whether or not the image is beatmap-skinnable. + * @return true if beatmap-skinnable */ - public boolean isSkinnable() { return skinnable; } + public boolean isBeatmapSkinnable() { return beatmapSkinnable; } /** * Returns whether or not to preload the image when the program starts. @@ -535,7 +562,7 @@ public enum GameImage { /** * Returns the image associated with this resource. - * The skin image takes priority over the default image. + * The beatmap skin image takes priority over the default image. */ public Image getImage() { setDefaultImage(); @@ -556,7 +583,7 @@ public enum GameImage { /** * Returns the image array associated with this resource. - * The skin images takes priority over the default images. + * The beatmap skin images takes priority over the default images. */ public Image[] getImages() { setDefaultImage(); @@ -565,7 +592,7 @@ public enum GameImage { /** * Sets the image associated with this resource to another image. - * The skin image takes priority over the default image. + * The beatmap skin image takes priority over the default image. * @param img the image to set */ public void setImage(Image img) { @@ -577,7 +604,7 @@ public enum GameImage { /** * Sets an image associated with this resource to another image. - * The skin image takes priority over the default image. + * The beatmap skin image takes priority over the default image. * @param img the image to set * @param index the index in the image array */ @@ -602,16 +629,26 @@ public enum GameImage { // try to load multiple images File skinDir = Options.getSkin().getDirectory(); if (filenameFormat != null) { - if ((skinDir != null && ((defaultImages = loadImageArray(skinDir)) != null)) || - ((defaultImages = loadImageArray(null)) != null)) { + if (skinDir != null && ((defaultImages = loadImageArray(skinDir)) != null)) { + isSkinned = true; + process(); + return; + } + if ((defaultImages = loadImageArray(null)) != null) { + isSkinned = false; process(); return; } } // try to load a single image - if ((skinDir != null && ((defaultImage = loadImageSingle(skinDir)) != null)) || - ((defaultImage = loadImageSingle(null)) != null)) { + if (skinDir != null && ((defaultImage = loadImageSingle(skinDir)) != null)) { + isSkinned = true; + process(); + return; + } + if ((defaultImage = loadImageSingle(null)) != null) { + isSkinned = false; process(); return; } @@ -620,17 +657,17 @@ public enum GameImage { } /** - * Sets the associated skin image. + * Sets the associated beatmap skin image. * If the path does not contain the image, the default image is used. * @param dir the image directory to search * @return true if a new skin image is loaded, false otherwise */ - public boolean setSkinImage(File dir) { + public boolean setBeatmapSkinImage(File dir) { if (dir == null) return false; // destroy the existing images, if any - destroySkinImage(); + destroyBeatmapSkinImage(); // beatmap skins disabled if (Options.isBeatmapSkinIgnored()) @@ -709,21 +746,27 @@ public enum GameImage { } /** - * Returns whether a skin image is currently loaded. - * @return true if skin image exists + * Returns whether the default image loaded is part of a game skin. + * @return true if a game skin image is loaded, false if the default image is loaded */ - public boolean hasSkinImage() { return (skinImage != null && !skinImage.isDestroyed()); } + public boolean hasGameSkinImage() { return isSkinned; } /** - * Returns whether skin images are currently loaded. - * @return true if any skin image exists + * Returns whether a beatmap skin image is currently loaded. + * @return true if a beatmap skin image exists */ - public boolean hasSkinImages() { return (skinImages != null); } + public boolean hasBeatmapSkinImage() { return (skinImage != null && !skinImage.isDestroyed()); } /** - * Destroys the associated skin image(s), if any. + * Returns whether beatmap skin images are currently loaded. + * @return true if any beatmap skin image exists */ - public void destroySkinImage() { + public boolean hasBeatmapSkinImages() { return (skinImages != null); } + + /** + * Destroys the associated beatmap skin image(s), if any. + */ + public void destroyBeatmapSkinImage() { if (skinImage == null && skinImages == null) return; try { @@ -740,7 +783,7 @@ public enum GameImage { skinImages = null; } } catch (SlickException e) { - ErrorHandler.error(String.format("Failed to destroy skin images for '%s'.", this.name()), e, true); + ErrorHandler.error(String.format("Failed to destroy beatmap skin images for '%s'.", this.name()), e, true); } } diff --git a/src/itdelatrisu/opsu/GameMod.java b/src/itdelatrisu/opsu/GameMod.java index ff7c86a0..f59f9bc0 100644 --- a/src/itdelatrisu/opsu/GameMod.java +++ b/src/itdelatrisu/opsu/GameMod.java @@ -18,7 +18,9 @@ package itdelatrisu.opsu; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.util.Arrays; import java.util.Collections; @@ -47,7 +49,7 @@ public enum GameMod { "DoubleTime", "Zoooooooooom."), // NIGHTCORE (Category.HARD, 2, GameImage.MOD_NIGHTCORE, "NT", 64, Input.KEY_D, 1.12f, // "Nightcore", "uguuuuuuuu"), - HIDDEN (Category.HARD, 3, GameImage.MOD_HIDDEN, "HD", 8, Input.KEY_F, 1.06f, false, + HIDDEN (Category.HARD, 3, GameImage.MOD_HIDDEN, "HD", 8, Input.KEY_F, 1.06f, "Hidden", "Play with no approach circles and fading notes for a slight score advantage."), FLASHLIGHT (Category.HARD, 4, GameImage.MOD_FLASHLIGHT, "FL", 1024, Input.KEY_G, 1.12f, "Flashlight", "Restricted view area."), @@ -55,9 +57,9 @@ public enum GameMod { "Relax", "You don't need to click.\nGive your clicking/tapping finger a break from the heat of things.\n**UNRANKED**"), AUTOPILOT (Category.SPECIAL, 1, GameImage.MOD_AUTOPILOT, "AP", 8192, Input.KEY_X, 0f, "Relax2", "Automatic cursor movement - just follow the rhythm.\n**UNRANKED**"), - SPUN_OUT (Category.SPECIAL, 2, GameImage.MOD_SPUN_OUT, "SO", 4096, Input.KEY_V, 0.9f, + SPUN_OUT (Category.SPECIAL, 2, GameImage.MOD_SPUN_OUT, "SO", 4096, Input.KEY_C, 0.9f, "SpunOut", "Spinners will be automatically completed."), - AUTO (Category.SPECIAL, 3, GameImage.MOD_AUTO, "", 2048, Input.KEY_B, 1f, + AUTO (Category.SPECIAL, 3, GameImage.MOD_AUTO, "", 2048, Input.KEY_V, 1f, "Autoplay", "Watch a perfect automated play through the song."); /** Mod categories. */ @@ -67,13 +69,13 @@ public enum GameMod { SPECIAL (2, "Special", Color.white); /** Drawing index. */ - private int index; + private final int index; /** Category name. */ - private String name; + private final String name; /** Text color. */ - private Color color; + private final Color color; /** The coordinates of the category. */ private float x, y; @@ -96,10 +98,10 @@ public enum GameMod { * @param height the container height */ public void init(int width, int height) { - float multY = Utils.FONT_LARGE.getLineHeight() * 2 + height * 0.06f; + float multY = Fonts.LARGE.getLineHeight() * 2 + height * 0.06f; float offsetY = GameImage.MOD_EASY.getImage().getHeight() * 1.5f; this.x = width / 30f; - this.y = multY + Utils.FONT_LARGE.getLineHeight() * 3f + offsetY * index; + this.y = multY + Fonts.LARGE.getLineHeight() * 3f + offsetY * index; } /** @@ -124,37 +126,34 @@ public enum GameMod { } /** The category for the mod. */ - private Category category; + private final Category category; /** The index in the category (for positioning). */ - private int categoryIndex; + private final int categoryIndex; /** The file name of the mod image. */ - private GameImage image; + private final GameImage image; /** The abbreviation for the mod. */ - private String abbrev; + private final String abbrev; /** * Bit value associated with the mod. * See the osu! API: https://github.com/peppy/osu-api/wiki#mods */ - private int bit; + private final int bit; /** The shortcut key associated with the mod. */ - private int key; + private final int key; /** The score multiplier. */ - private float multiplier; - - /** Whether or not the mod is implemented. */ - private boolean implemented; + private final float multiplier; /** The name of the mod. */ - private String name; + private final String name; /** The description of the mod. */ - private String description; + private final String description; /** Whether or not this mod is active. */ private boolean active = false; @@ -192,13 +191,15 @@ public enum GameMod { c.init(width, height); // create buttons - float baseX = Category.EASY.getX() + Utils.FONT_LARGE.getWidth(Category.EASY.getName()) * 1.25f; + float baseX = Category.EASY.getX() + Fonts.LARGE.getWidth(Category.EASY.getName()) * 1.25f; float offsetX = GameImage.MOD_EASY.getImage().getWidth() * 2.1f; for (GameMod mod : GameMod.values()) { Image img = mod.image.getImage(); mod.button = new MenuButton(img, baseX + (offsetX * mod.categoryIndex) + img.getWidth() / 2f, mod.category.getY()); + mod.button.setHoverAnimationDuration(300); + mod.button.setHoverAnimationEquation(AnimationEquation.IN_OUT_BACK); mod.button.setHoverExpand(1.2f); mod.button.setHoverRotate(10f); @@ -309,24 +310,6 @@ public enum GameMod { */ GameMod(Category category, int categoryIndex, GameImage image, String abbrev, int bit, int key, float multiplier, String name, String description) { - this(category, categoryIndex, image, abbrev, bit, key, multiplier, true, name, description); - } - - /** - * Constructor. - * @param category the category for the mod - * @param categoryIndex the index in the category - * @param image the GameImage - * @param abbrev the two-letter abbreviation - * @param bit the bit - * @param key the shortcut key - * @param multiplier the score multiplier - * @param implemented whether the mod is implemented - * @param name the name - * @param description the description - */ - GameMod(Category category, int categoryIndex, GameImage image, String abbrev, - int bit, int key, float multiplier, boolean implemented, String name, String description) { this.category = category; this.categoryIndex = categoryIndex; this.image = image; @@ -334,7 +317,6 @@ public enum GameMod { this.bit = bit; this.key = key; this.multiplier = multiplier; - this.implemented = implemented; this.name = name; this.description = description; } @@ -376,20 +358,11 @@ public enum GameMod { */ public String getDescription() { return description; } - /** - * Returns whether or not the mod is implemented. - * @return true if implemented - */ - public boolean isImplemented() { return implemented; } - /** * Toggles the active status of the mod. * @param checkInverse if true, perform checks for mutual exclusivity */ public void toggle(boolean checkInverse) { - if (!implemented) - return; - active = !active; scoreMultiplier = speedMultiplier = difficultyMultiplier = -1f; @@ -446,14 +419,7 @@ public enum GameMod { /** * Draws the game mod. */ - public void draw() { - if (!implemented) { - button.getImage().setAlpha(0.2f); - button.draw(); - button.getImage().setAlpha(1f); - } else - button.draw(); - } + public void draw() { button.draw(); } /** * Checks if the coordinates are within the image bounds. diff --git a/src/itdelatrisu/opsu/NativeLoader.java b/src/itdelatrisu/opsu/NativeLoader.java new file mode 100644 index 00000000..4f6853ca --- /dev/null +++ b/src/itdelatrisu/opsu/NativeLoader.java @@ -0,0 +1,104 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Native loader, based on the JarSplice launcher. + * + * @author http://ninjacave.com + */ +public class NativeLoader { + /** The directory to unpack natives to. */ + private final File nativeDir; + + /** + * Constructor. + * @param dir the directory to unpack natives to + */ + public NativeLoader(File dir) { + nativeDir = dir; + } + + /** + * Unpacks natives for the current operating system to the natives directory. + * @throws IOException if an I/O exception occurs + */ + public void loadNatives() throws IOException { + if (!nativeDir.exists()) + nativeDir.mkdir(); + + JarFile jarFile = Utils.getJarFile(); + if (jarFile == null) + return; + + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry e = entries.nextElement(); + if (e == null) + break; + + File f = new File(nativeDir, e.getName()); + if (isNativeFile(e.getName()) && !e.isDirectory() && e.getName().indexOf('/') == -1 && !f.exists()) { + InputStream in = jarFile.getInputStream(jarFile.getEntry(e.getName())); + OutputStream out = new FileOutputStream(f); + + byte[] buffer = new byte[65536]; + int bufferSize; + while ((bufferSize = in.read(buffer, 0, buffer.length)) != -1) + out.write(buffer, 0, bufferSize); + + in.close(); + out.close(); + } + } + + jarFile.close(); + } + + /** + * Returns whether the given file name is a native file for the current operating system. + * @param entryName the file name + * @return true if the file is a native that should be loaded, false otherwise + */ + private boolean isNativeFile(String entryName) { + String osName = System.getProperty("os.name"); + String name = entryName.toLowerCase(); + + if (osName.startsWith("Win")) { + if (name.endsWith(".dll")) + return true; + } else if (osName.startsWith("Linux")) { + if (name.endsWith(".so")) + return true; + } else if (osName.startsWith("Mac") || osName.startsWith("Darwin")) { + if (name.endsWith(".dylib") || name.endsWith(".jnilib")) + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/itdelatrisu/opsu/Opsu.java b/src/itdelatrisu/opsu/Opsu.java index 1f89dd5b..7cabc6dd 100644 --- a/src/itdelatrisu/opsu/Opsu.java +++ b/src/itdelatrisu/opsu/Opsu.java @@ -38,10 +38,14 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintStream; +import java.lang.reflect.Field; +import java.net.InetAddress; import java.net.ServerSocket; +import java.net.UnknownHostException; import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; +import org.newdawn.slick.Input; import org.newdawn.slick.SlickException; import org.newdawn.slick.state.StateBasedGame; import org.newdawn.slick.state.transition.FadeInTransition; @@ -116,16 +120,43 @@ public class Opsu extends StateBasedGame { // only allow a single instance try { - SERVER_SOCKET = new ServerSocket(Options.getPort()); + SERVER_SOCKET = new ServerSocket(Options.getPort(), 1, InetAddress.getLocalHost()); + } catch (UnknownHostException e) { + // shouldn't happen } catch (IOException e) { - ErrorHandler.error(String.format("Another program is already running on port %d.", Options.getPort()), e, false); + ErrorHandler.error(String.format( + "opsu! could not be launched for one of these reasons:\n" + + "- An instance of opsu! is already running.\n" + + "- Another program is bound to port %d. " + + "You can change the port opsu! uses by editing the \"Port\" field in the configuration file.", + Options.getPort()), null, false); System.exit(1); } - // set path for lwjgl natives - NOT NEEDED if using JarSplice - File nativeDir = new File("./target/natives/"); - if (nativeDir.isDirectory()) - System.setProperty("org.lwjgl.librarypath", nativeDir.getAbsolutePath()); + File nativeDir; + if (!Utils.isJarRunning() && ( + (nativeDir = new File("./target/natives/")).isDirectory() || + (nativeDir = new File("./build/natives/")).isDirectory())) + ; + else { + nativeDir = Options.NATIVE_DIR; + try { + new NativeLoader(nativeDir).loadNatives(); + } catch (IOException e) { + Log.error("Error loading natives.", e); + } + } + System.setProperty("org.lwjgl.librarypath", nativeDir.getAbsolutePath()); + System.setProperty("java.library.path", nativeDir.getAbsolutePath()); + try { + // Workaround for "java.library.path" property being read-only. + // http://stackoverflow.com/a/24988095 + Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths"); + fieldSysPath.setAccessible(true); + fieldSysPath.set(null, null); + } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) { + Log.warn("Failed to set 'sys_paths' field.", e); + } // set the resource paths ResourceLoader.addResourceLocation(new FileSystemLocation(new File("./res/"))); @@ -142,16 +173,21 @@ public class Opsu extends StateBasedGame { Updater.get().setUpdateInfo(args[0], args[1]); // check for updates - new Thread() { - @Override - public void run() { - try { - Updater.get().checkForUpdates(); - } catch (IOException e) { - Log.warn("Check for updates failed.", e); + if (!Options.isUpdaterDisabled()) { + new Thread() { + @Override + public void run() { + try { + Updater.get().checkForUpdates(); + } catch (IOException e) { + Log.warn("Check for updates failed.", e); + } } - } - }.start(); + }.start(); + } + + // disable jinput + Input.disableControllers(); // start the game try { @@ -190,30 +226,28 @@ public class Opsu extends StateBasedGame { SongMenu songMenu = (SongMenu) this.getState(Opsu.STATE_SONGMENU); if (id == STATE_GAMERANKING) { GameData data = ((GameRanking) this.getState(Opsu.STATE_GAMERANKING)).getGameData(); - if (data != null && data.isGameplay()) { - songMenu.resetGameDataOnLoad(); + if (data != null && data.isGameplay()) songMenu.resetTrackOnLoad(); - } } else { - songMenu.resetGameDataOnLoad(); if (id == STATE_GAME) { MusicController.pause(); MusicController.resume(); } else songMenu.resetTrackOnLoad(); } - if (UI.getCursor().isSkinned()) + if (UI.getCursor().isBeatmapSkinned()) UI.getCursor().reset(); + songMenu.resetGameDataOnLoad(); this.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); return false; } // show confirmation dialog if any downloads are active if (DownloadList.get().hasActiveDownloads() && - UI.showExitConfirmation(DownloadList.EXIT_CONFIRMATION)) + UI.showExitConfirmation(DownloadList.EXIT_CONFIRMATION)) return false; if (Updater.get().getStatus() == Updater.Status.UPDATE_DOWNLOADING && - UI.showExitConfirmation(Updater.EXIT_CONFIRMATION)) + UI.showExitConfirmation(Updater.EXIT_CONFIRMATION)) return false; return true; @@ -248,7 +282,7 @@ public class Opsu extends StateBasedGame { // JARs will not run properly inside directories containing '!' // http://bugs.java.com/view_bug.do?bug_id=4523159 if (Utils.isJarRunning() && Utils.getRunningDirectory() != null && - Utils.getRunningDirectory().getAbsolutePath().indexOf('!') != -1) + Utils.getRunningDirectory().getAbsolutePath().indexOf('!') != -1) ErrorHandler.error("JARs cannot be run from some paths containing '!'. Please move or rename the file and try again.", null, false); else ErrorHandler.error(message, e, true); diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java index 0f3f48d6..18d701bd 100644 --- a/src/itdelatrisu/opsu/Options.java +++ b/src/itdelatrisu/opsu/Options.java @@ -22,6 +22,7 @@ import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.skins.Skin; import itdelatrisu.opsu.skins.SkinLoader; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.UI; import java.io.BufferedReader; @@ -37,6 +38,9 @@ import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.concurrent.TimeUnit; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; import org.lwjgl.input.Keyboard; import org.newdawn.slick.GameContainer; @@ -51,12 +55,18 @@ import org.newdawn.slick.util.ResourceLoader; * Handles all user options. */ public class Options { + /** Whether to use XDG directories. */ + public static final boolean USE_XDG = checkXDGFlag(); + /** The config directory. */ private static final File CONFIG_DIR = getXDGBaseDir("XDG_CONFIG_HOME", ".config"); /** The data directory. */ private static final File DATA_DIR = getXDGBaseDir("XDG_DATA_HOME", ".local/share"); + /** The cache directory. */ + private static final File CACHE_DIR = getXDGBaseDir("XDG_CACHE_HOME", ".cache"); + /** File for logging errors. */ public static final File LOG_FILE = new File(CONFIG_DIR, ".opsu.log"); @@ -83,6 +93,9 @@ public class Options { /** Score database name. */ public static final File SCORE_DB = new File(DATA_DIR, ".opsu_scores.db"); + /** Directory where natives are unpacked. */ + public static final File NATIVE_DIR = new File(CACHE_DIR, "Natives/"); + /** Font file name. */ public static final String FONT_NAME = "DroidSansFallback.ttf"; @@ -119,15 +132,35 @@ public class Options { /** Port binding. */ private static int port = 49250; + /** + * Returns whether the XDG flag in the manifest (if any) is set to "true". + * @return true if XDG directories are enabled, false otherwise + */ + private static boolean checkXDGFlag() { + JarFile jarFile = Utils.getJarFile(); + if (jarFile == null) + return false; + try { + Manifest manifest = jarFile.getManifest(); + if (manifest == null) + return false; + Attributes attributes = manifest.getMainAttributes(); + String value = attributes.getValue("Use-XDG"); + return (value != null && value.equalsIgnoreCase("true")); + } catch (IOException e) { + return false; + } + } + /** * Returns the directory based on the XDG base directory specification for - * Unix-like operating systems, only if the system property "XDG" has been defined. + * Unix-like operating systems, only if the "XDG" flag is enabled. * @param env the environment variable to check (XDG_*_*) * @param fallback the fallback directory relative to ~home * @return the XDG base directory, or the working directory if unavailable */ private static File getXDGBaseDir(String env, String fallback) { - if (System.getProperty("XDG") == null) + if (!USE_XDG) return new File("./"); String OS = System.getProperty("os.name").toLowerCase(); @@ -140,8 +173,8 @@ public class Options { rootPath = String.format("%s/%s", home, fallback); } File dir = new File(rootPath, "opsu"); - if (!dir.isDirectory()) - dir.mkdir(); + if (!dir.isDirectory() && !dir.mkdir()) + ErrorHandler.error(String.format("Failed to create configuration folder at '%s/opsu'.", rootPath), null, false); return dir; } else return new File("./"); @@ -212,7 +245,7 @@ public class Options { @Override public void read(String s) { int i = Integer.parseInt(s); - if (i > 0 && i < 65535) + if (i > 0 && i <= 65535) port = i; } }, @@ -287,9 +320,9 @@ public class Options { super.click(container); if (bool) { try { - Utils.FONT_LARGE.loadGlyphs(); - Utils.FONT_MEDIUM.loadGlyphs(); - Utils.FONT_DEFAULT.loadGlyphs(); + Fonts.LARGE.loadGlyphs(); + Fonts.MEDIUM.loadGlyphs(); + Fonts.DEFAULT.loadGlyphs(); } catch (SlickException e) { Log.warn("Failed to load glyphs.", e); } @@ -357,7 +390,7 @@ public class Options { public String getValueString() { return String.format("%dms", val); } }, DISABLE_SOUNDS ("Disable All Sound Effects", "DisableSound", "May resolve Linux sound driver issues. Requires a restart.", - (System.getProperty("os.name").toLowerCase().indexOf("linux") > -1)), + (System.getProperty("os.name").toLowerCase().contains("linux"))), KEY_LEFT ("Left Game Key", "keyOsuLeft", "Select this option to input a key.") { @Override public String getValueString() { return Keyboard.getKeyName(getGameKeyLeft()); } @@ -380,6 +413,7 @@ public class Options { }, DISABLE_MOUSE_WHEEL ("Disable mouse wheel in play mode", "MouseDisableWheel", "During play, you can use the mouse wheel to adjust the volume and pause the game.\nThis will disable that functionality.", false), DISABLE_MOUSE_BUTTONS ("Disable mouse buttons in play mode", "MouseDisableButtons", "This option will disable all mouse buttons.\nSpecifically for people who use their keyboard to click.", false), + DISABLE_CURSOR ("Disable Cursor", "DisableCursor", "Hide the cursor sprite.", false), BACKGROUND_DIM ("Background Dim", "DimLevel", "Percentage to dim the background image during gameplay.", 50, 0, 100), FORCE_DEFAULT_PLAYFIELD ("Force Default Playfield", "ForceDefaultPlayfield", "Override the song background with the default playfield background.", false), IGNORE_BEATMAP_SKINS ("Ignore All Beatmap Skins", "IgnoreBeatmapSkins", "Never use skin element overrides provided by beatmaps.", false), @@ -453,16 +487,19 @@ public class Options { val - TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(val))); } }, - ENABLE_THEME_SONG ("Enable Theme Song", "MenuMusic", "Whether to play the theme song upon starting opsu!", true); + ENABLE_THEME_SONG ("Enable Theme Song", "MenuMusic", "Whether to play the theme song upon starting opsu!", true), + REPLAY_SEEKING ("Replay Seeking", "ReplaySeeking", "Enable a seeking bar on the left side of the screen during replays.", false), + DISABLE_UPDATER ("Disable Automatic Updates", "DisableUpdater", "Disable automatic checking for updates upon starting opsu!.", false), + ENABLE_WATCH_SERVICE ("Enable Watch Service", "WatchService", "Watch the beatmap directory for changes. Requires a restart.", false); /** Option name. */ - private String name; + private final String name; /** Option name, as displayed in the configuration file. */ - private String displayName; + private final String displayName; /** Option description. */ - private String description; + private final String description; /** The boolean value for the option (if applicable). */ protected boolean bool; @@ -484,7 +521,7 @@ public class Options { * @param displayName the option name, as displayed in the configuration file */ GameOption(String displayName) { - this.displayName = displayName; + this(null, displayName, null); } /** @@ -604,7 +641,7 @@ public class Options { */ public void drag(GameContainer container, int d) { if (type == OptionType.NUMERIC) - val = Utils.getBoundedValue(val, d, min, max); + val = Utils.clamp(val + d, min, max); } /** @@ -958,6 +995,24 @@ public class Options { */ public static boolean isThemeSongEnabled() { return GameOption.ENABLE_THEME_SONG.getBooleanValue(); } + /** + * Returns whether or not replay seeking is enabled. + * @return true if enabled + */ + public static boolean isReplaySeekingEnabled() { return GameOption.REPLAY_SEEKING.getBooleanValue(); } + + /** + * Returns whether or not automatic checking for updates is disabled. + * @return true if disabled + */ + public static boolean isUpdaterDisabled() { return GameOption.DISABLE_UPDATER.getBooleanValue(); } + + /** + * Returns whether or not the beatmap watch service is enabled. + * @return true if enabled + */ + public static boolean isWatchServiceEnabled() { return GameOption.ENABLE_WATCH_SERVICE.getBooleanValue(); } + /** * Sets the track checkpoint time, if within bounds. * @param time the track position (in ms) @@ -1005,6 +1060,12 @@ public class Options { "Mouse buttons are disabled." : "Mouse buttons are enabled."); } + /** + * Returns whether or not the cursor sprite should be hidden. + * @return true if disabled + */ + public static boolean isCursorDisabled() { return GameOption.DISABLE_CURSOR.getBooleanValue(); } + /** * Returns the left game key. * @return the left key code @@ -1080,7 +1141,10 @@ public class Options { if (beatmapDir.isDirectory()) return beatmapDir; } - beatmapDir.mkdir(); // none found, create new directory + + // none found, create new directory + if (!beatmapDir.mkdir()) + ErrorHandler.error(String.format("Failed to create beatmap directory at '%s'.", beatmapDir.getAbsolutePath()), null, false); return beatmapDir; } @@ -1094,7 +1158,8 @@ public class Options { return oszDir; oszDir = new File(DATA_DIR, "SongPacks/"); - oszDir.mkdir(); + if (!oszDir.isDirectory() && !oszDir.mkdir()) + ErrorHandler.error(String.format("Failed to create song packs directory at '%s'.", oszDir.getAbsolutePath()), null, false); return oszDir; } @@ -1108,7 +1173,8 @@ public class Options { return replayImportDir; replayImportDir = new File(DATA_DIR, "ReplayImport/"); - replayImportDir.mkdir(); + if (!replayImportDir.isDirectory() && !replayImportDir.mkdir()) + ErrorHandler.error(String.format("Failed to create replay import directory at '%s'.", replayImportDir.getAbsolutePath()), null, false); return replayImportDir; } @@ -1153,7 +1219,10 @@ public class Options { if (skinRootDir.isDirectory()) return skinRootDir; } - skinRootDir.mkdir(); // none found, create new directory + + // none found, create new directory + if (!skinRootDir.mkdir()) + ErrorHandler.error(String.format("Failed to create skins directory at '%s'.", skinRootDir.getAbsolutePath()), null, false); return skinRootDir; } diff --git a/src/itdelatrisu/opsu/ScoreData.java b/src/itdelatrisu/opsu/ScoreData.java index d89eb40a..bf547bd3 100644 --- a/src/itdelatrisu/opsu/ScoreData.java +++ b/src/itdelatrisu/opsu/ScoreData.java @@ -20,7 +20,10 @@ package itdelatrisu.opsu; import itdelatrisu.opsu.GameData.Grade; import itdelatrisu.opsu.states.SongMenu; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.sql.ResultSet; import java.sql.SQLException; @@ -84,11 +87,6 @@ public class ScoreData implements Comparable { /** Drawing values. */ private static float baseX, baseY, buttonWidth, buttonHeight, buttonOffset, buttonAreaHeight; - /** Button background colors. */ - private static final Color - BG_NORMAL = new Color(0, 0, 0, 0.25f), - BG_FOCUS = new Color(0, 0, 0, 0.75f); - /** * Initializes the base coordinates for drawing. * @param containerWidth the container width @@ -99,7 +97,7 @@ public class ScoreData implements Comparable { baseY = topY; buttonWidth = containerWidth * 0.4f; float gradeHeight = GameImage.MENU_BUTTON_BG.getImage().getHeight() * 0.45f; - buttonHeight = Math.max(gradeHeight, Utils.FONT_DEFAULT.getLineHeight() * 3.03f); + buttonHeight = Math.max(gradeHeight, Fonts.DEFAULT.getLineHeight() * 3.03f); buttonOffset = buttonHeight + gradeHeight / 10f; buttonAreaHeight = (SongMenu.MAX_SCORE_BUTTONS - 1) * buttonOffset + buttonHeight; } @@ -155,14 +153,14 @@ public class ScoreData implements Comparable { } /** - * Empty constructor. + * Creates an empty score data object. */ public ScoreData() {} /** - * Constructor. - * @param rs the ResultSet to read from (at the current cursor position) - * @throws SQLException + * Builds a score data object from a database result set. + * @param rs the {@link ResultSet} to read from (at the current cursor position) + * @throws SQLException if a database access error occurs or the result set is closed */ public ScoreData(ResultSet rs) throws SQLException { this.timestamp = rs.getLong(1); @@ -244,46 +242,52 @@ public class ScoreData implements Comparable { * @param rank the score rank * @param prevScore the previous (lower) score, or -1 if none * @param focus whether the button is focused + * @param t the animation progress [0,1] */ - public void draw(Graphics g, float position, int rank, long prevScore, boolean focus) { - Image img = getGrade().getMenuImage(); - float textX = baseX + buttonWidth * 0.24f; - float edgeX = baseX + buttonWidth * 0.98f; + public void draw(Graphics g, float position, int rank, long prevScore, boolean focus, float t) { + float x = baseX - buttonWidth * (1 - AnimationEquation.OUT_BACK.calc(t)) / 2.5f; + float textX = x + buttonWidth * 0.24f; + float edgeX = x + buttonWidth * 0.98f; float y = baseY + position; float midY = y + buttonHeight / 2f; - float marginY = Utils.FONT_DEFAULT.getLineHeight() * 0.01f; + float marginY = Fonts.DEFAULT.getLineHeight() * 0.01f; + Color c = Colors.WHITE_FADE; + float alpha = t; + float oldAlpha = c.a; + c.a = alpha; // rectangle outline - g.setColor((focus) ? BG_FOCUS : BG_NORMAL); - g.fillRect(baseX, y, buttonWidth, buttonHeight); + Color rectColor = (focus) ? Colors.BLACK_BG_FOCUS : Colors.BLACK_BG_NORMAL; + float oldRectAlpha = rectColor.a; + rectColor.a *= AnimationEquation.IN_QUAD.calc(alpha); + g.setColor(rectColor); + g.fillRect(x, y, buttonWidth, buttonHeight); + rectColor.a = oldRectAlpha; // rank if (focus) { - Utils.FONT_LARGE.drawString( - baseX + buttonWidth * 0.04f, - y + (buttonHeight - Utils.FONT_LARGE.getLineHeight()) / 2f, - Integer.toString(rank + 1), Color.white + Fonts.LARGE.drawString( + x + buttonWidth * 0.04f, + y + (buttonHeight - Fonts.LARGE.getLineHeight()) / 2f, + Integer.toString(rank + 1), c ); } // grade image - img.drawCentered(baseX + buttonWidth * 0.15f, midY); + Image img = getGrade().getMenuImage(); + img.setAlpha(alpha); + img.drawCentered(x + buttonWidth * 0.15f, midY); + img.setAlpha(1f); // score - float textOffset = (buttonHeight - Utils.FONT_MEDIUM.getLineHeight() - Utils.FONT_SMALL.getLineHeight()) / 2f; - Utils.FONT_MEDIUM.drawString( - textX, y + textOffset, - String.format("Score: %s (%dx)", NumberFormat.getNumberInstance().format(score), combo), - Color.white - ); + float textOffset = (buttonHeight - Fonts.MEDIUM.getLineHeight() - Fonts.SMALL.getLineHeight()) / 2f; + Fonts.MEDIUM.drawString(textX, y + textOffset, + String.format("Score: %s (%dx)", NumberFormat.getNumberInstance().format(score), combo), c); // hit counts (custom: osu! shows user instead, above score) String player = (playerName == null) ? "" : String.format(" (%s)", playerName); - Utils.FONT_SMALL.drawString( - textX, y + textOffset + Utils.FONT_MEDIUM.getLineHeight(), - String.format("300:%d 100:%d 50:%d Miss:%d%s", hit300, hit100, hit50, miss, player), - Color.white - ); + Fonts.SMALL.drawString(textX, y + textOffset + Fonts.MEDIUM.getLineHeight(), + String.format("300:%d 100:%d 50:%d Miss:%d%s", hit300, hit100, hit50, miss, player), c); // mods StringBuilder sb = new StringBuilder(); @@ -296,39 +300,30 @@ public class ScoreData implements Comparable { if (sb.length() > 0) { sb.setLength(sb.length() - 1); String modString = sb.toString(); - Utils.FONT_DEFAULT.drawString( - edgeX - Utils.FONT_DEFAULT.getWidth(modString), - y + marginY, modString, Color.white - ); + Fonts.DEFAULT.drawString(edgeX - Fonts.DEFAULT.getWidth(modString), y + marginY, modString, c); } // accuracy String accuracy = String.format("%.2f%%", getScorePercent()); - Utils.FONT_DEFAULT.drawString( - edgeX - Utils.FONT_DEFAULT.getWidth(accuracy), - y + marginY + Utils.FONT_DEFAULT.getLineHeight(), - accuracy, Color.white - ); + Fonts.DEFAULT.drawString(edgeX - Fonts.DEFAULT.getWidth(accuracy), y + marginY + Fonts.DEFAULT.getLineHeight(), accuracy, c); // score difference String diff = (prevScore < 0 || score < prevScore) ? "-" : String.format("+%s", NumberFormat.getNumberInstance().format(score - prevScore)); - Utils.FONT_DEFAULT.drawString( - edgeX - Utils.FONT_DEFAULT.getWidth(diff), - y + marginY + Utils.FONT_DEFAULT.getLineHeight() * 2, - diff, Color.white - ); + Fonts.DEFAULT.drawString(edgeX - Fonts.DEFAULT.getWidth(diff), y + marginY + Fonts.DEFAULT.getLineHeight() * 2, diff, c); // time since if (getTimeSince() != null) { Image clock = GameImage.HISTORY.getImage(); - clock.drawCentered(baseX + buttonWidth * 1.02f + clock.getWidth() / 2f, midY); - Utils.FONT_DEFAULT.drawString( - baseX + buttonWidth * 1.03f + clock.getWidth(), - midY - Utils.FONT_DEFAULT.getLineHeight() / 2f, - getTimeSince(), Color.white + clock.drawCentered(x + buttonWidth * 1.02f + clock.getWidth() / 2f, midY); + Fonts.DEFAULT.drawString( + x + buttonWidth * 1.03f + clock.getWidth(), + midY - Fonts.DEFAULT.getLineHeight() / 2f, + getTimeSince(), c ); } + + c.a = oldAlpha; } /** diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java index feb24d44..6b8b6829 100644 --- a/src/itdelatrisu/opsu/Utils.java +++ b/src/itdelatrisu/opsu/Utils.java @@ -24,14 +24,15 @@ import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.downloads.Download; import itdelatrisu.opsu.downloads.DownloadNode; import itdelatrisu.opsu.replay.PlaybackSpeed; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.UI; -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.FileReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -43,13 +44,10 @@ 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 java.util.jar.JarFile; import javax.imageio.ImageIO; @@ -60,16 +58,10 @@ import org.lwjgl.BufferUtils; import org.lwjgl.opengl.Display; import org.lwjgl.opengl.GL11; import org.newdawn.slick.Animation; -import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; import org.newdawn.slick.Input; -import org.newdawn.slick.SlickException; -import org.newdawn.slick.UnicodeFont; -import org.newdawn.slick.font.effects.ColorEffect; -import org.newdawn.slick.font.effects.Effect; import org.newdawn.slick.state.StateBasedGame; import org.newdawn.slick.util.Log; -import org.newdawn.slick.util.ResourceLoader; import com.sun.jna.platform.FileUtils; @@ -77,34 +69,6 @@ import com.sun.jna.platform.FileUtils; * Contains miscellaneous utilities. */ public class Utils { - /** Game colors. */ - public static final Color - COLOR_BLACK_ALPHA = new Color(0, 0, 0, 0.5f), - COLOR_WHITE_ALPHA = new Color(255, 255, 255, 0.5f), - COLOR_BLUE_DIVIDER = new Color(49, 94, 237), - COLOR_BLUE_BACKGROUND = new Color(74, 130, 255), - COLOR_BLUE_BUTTON = new Color(40, 129, 237), - COLOR_ORANGE_BUTTON = new Color(200, 90, 3), - COLOR_YELLOW_ALPHA = new Color(255, 255, 0, 0.4f), - COLOR_WHITE_FADE = new Color(255, 255, 255, 1f), - COLOR_RED_HOVER = new Color(255, 112, 112), - COLOR_GREEN = new Color(137, 201, 79), - COLOR_LIGHT_ORANGE = new Color(255,192,128), - COLOR_LIGHT_GREEN = new Color(128,255,128), - COLOR_LIGHT_BLUE = new Color(128,128,255), - COLOR_GREEN_SEARCH = new Color(173, 255, 47), - COLOR_DARK_GRAY = new Color(0.3f, 0.3f, 0.3f, 1f), - COLOR_RED_HIGHLIGHT = new Color(246, 154, 161), - COLOR_BLUE_HIGHLIGHT = new Color(173, 216, 230); - - /** Game fonts. */ - public static UnicodeFont - FONT_DEFAULT, FONT_BOLD, - FONT_XLARGE, FONT_LARGE, FONT_MEDIUM, FONT_SMALL; - - /** Set of all Unicode strings already loaded per font. */ - private static HashMap> loadedGlyphs = new HashMap>(); - /** * List of illegal filename characters. * @see #cleanFileName(String, char) @@ -115,7 +79,7 @@ public class Utils { 24, 25, 26, 27, 28, 29, 30, 31, 58, 42, 63, 92, 47 }; static { - Arrays.sort(illegalChars); + Arrays.sort(illegalChars); } // game-related variables @@ -128,10 +92,8 @@ public class Utils { * Initializes game settings and class data. * @param container the game container * @param game the game object - * @throws SlickException */ - public static void init(GameContainer container, StateBasedGame game) - throws SlickException { + public static void init(GameContainer container, StateBasedGame game) { input = container.getInput(); int width = container.getWidth(); int height = container.getHeight(); @@ -149,23 +111,8 @@ public class Utils { GameImage.init(width, height); // create fonts - float fontBase = 12f * GameImage.getUIscale(); try { - Font javaFont = Font.createFont(Font.TRUETYPE_FONT, ResourceLoader.getResourceAsStream(Options.FONT_NAME)); - Font font = javaFont.deriveFont(Font.PLAIN, (int) (fontBase * 4 / 3)); - FONT_DEFAULT = new UnicodeFont(font); - FONT_BOLD = new UnicodeFont(font.deriveFont(Font.BOLD)); - FONT_XLARGE = new UnicodeFont(font.deriveFont(fontBase * 3)); - FONT_LARGE = new UnicodeFont(font.deriveFont(fontBase * 2)); - FONT_MEDIUM = new UnicodeFont(font.deriveFont(fontBase * 3 / 2)); - FONT_SMALL = new UnicodeFont(font.deriveFont(fontBase)); - ColorEffect colorEffect = new ColorEffect(); - loadFont(FONT_DEFAULT, colorEffect); - loadFont(FONT_BOLD, colorEffect); - loadFont(FONT_XLARGE, colorEffect); - loadFont(FONT_LARGE, colorEffect); - loadFont(FONT_MEDIUM, colorEffect); - loadFont(FONT_SMALL, colorEffect); + Fonts.init(); } catch (Exception e) { ErrorHandler.error("Failed to load fonts.", e, true); } @@ -206,36 +153,18 @@ public class 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 + * 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 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; + public static int clamp(int val, int low, int high) { + if (val < low) + return low; + if (val > high) + return high; return val; } @@ -269,6 +198,13 @@ public class Utils { return (float) Math.sqrt((v1 * v1) + (v2 * v2)); } + /** + * Linear interpolation of a and b at t. + */ + public static float lerp(float a, float b, float t) { + return a * (1 - t) + b * t; + } + /** * Returns true if a game input key is pressed (mouse/keyboard left/right). * @return true if pressed @@ -289,11 +225,9 @@ public class Utils { public static void takeScreenShot() { // create the screenshot directory File dir = Options.getScreenshotDir(); - if (!dir.isDirectory()) { - if (!dir.mkdir()) { - ErrorHandler.error("Failed to create screenshot directory.", null, false); - return; - } + if (!dir.isDirectory() && !dir.mkdir()) { + ErrorHandler.error(String.format("Failed to create screenshot directory at '%s'.", dir.getAbsolutePath()), null, false); + return; } // create file name @@ -333,54 +267,6 @@ public class Utils { }.start(); } - /** - * Loads a Unicode font. - * @param font the font to load - * @param effect the font effect - * @throws SlickException - */ - @SuppressWarnings("unchecked") - private static void loadFont(UnicodeFont font, Effect effect) throws SlickException { - font.addAsciiGlyphs(); - font.getEffects().add(effect); - font.loadGlyphs(); - } - - /** - * Adds and loads glyphs for a beatmap's Unicode title and artist strings. - * @param font the font to add the glyphs to - * @param title the title string - * @param artist the artist string - */ - public static void loadGlyphs(UnicodeFont font, String title, String artist) { - // get set of added strings - HashSet set = loadedGlyphs.get(font); - if (set == null) { - set = new HashSet(); - loadedGlyphs.put(font, set); - } - - // add glyphs if not in set - boolean glyphsAdded = false; - if (title != null && !title.isEmpty() && !set.contains(title)) { - font.addGlyphs(title); - set.add(title); - glyphsAdded = true; - } - if (artist != null && !artist.isEmpty() && !set.contains(artist)) { - font.addGlyphs(artist); - set.add(artist); - glyphsAdded = true; - } - if (glyphsAdded) { - try { - font.loadGlyphs(); - } catch (SlickException e) { - Log.warn("Failed to load glyphs.", e); - } - } - } - /** * Returns a human-readable representation of a given number of bytes. * @param bytes the number of bytes @@ -407,15 +293,15 @@ public class Utils { return null; boolean doReplace = (replace > 0 && Arrays.binarySearch(illegalChars, replace) < 0); - StringBuilder cleanName = new StringBuilder(); - for (int i = 0, n = badFileName.length(); i < n; i++) { - int c = badFileName.charAt(i); - if (Arrays.binarySearch(illegalChars, c) < 0) - cleanName.append((char) c); - else if (doReplace) - cleanName.append(replace); - } - return cleanName.toString(); + StringBuilder cleanName = new StringBuilder(); + for (int i = 0, n = badFileName.length(); i < n; i++) { + int c = badFileName.charAt(i); + if (Arrays.binarySearch(illegalChars, c) < 0) + cleanName.append((char) c); + else if (doReplace) + cleanName.append(replace); + } + return cleanName.toString(); } /** @@ -474,50 +360,12 @@ public class Utils { dir.delete(); } - /** - * Wraps the given string into a list of split lines based on the width. - * @param text the text to split - * @param font the font used to draw the string - * @param width the maximum width of a line - * @return the list of split strings - * @author davedes (http://slick.ninjacave.com/forum/viewtopic.php?t=3778) - */ - public static List wrap(String text, org.newdawn.slick.Font font, int width) { - List list = new ArrayList(); - String str = text; - String line = ""; - int i = 0; - int lastSpace = -1; - while (i < str.length()) { - char c = str.charAt(i); - if (Character.isWhitespace(c)) - lastSpace = i; - String append = line + c; - if (font.getWidth(append) > width) { - int split = (lastSpace != -1) ? lastSpace : i; - int splitTrimmed = split; - if (lastSpace != -1 && split < str.length() - 1) - splitTrimmed++; - list.add(str.substring(0, split)); - str = str.substring(splitTrimmed); - line = ""; - i = 0; - lastSpace = -1; - } else { - line = append; - i++; - } - } - if (str.length() != 0) - list.add(str); - return list; - } - /** * Returns a the contents of a URL as a string. * @param url the remote URL * @return the contents as a string, or null if any error occurred * @author Roland Illig (http://stackoverflow.com/a/4308662) + * @throws IOException if an I/O exception occurs */ public static String readDataFromUrl(URL url) throws IOException { // open connection @@ -553,6 +401,7 @@ public class Utils { * Returns a JSON object from a URL. * @param url the remote URL * @return the JSON object, or null if an error occurred + * @throws IOException if an I/O exception occurs */ public static JSONObject readJsonObjectFromUrl(URL url) throws IOException { String s = Utils.readDataFromUrl(url); @@ -571,6 +420,7 @@ public class Utils { * Returns a JSON array from a URL. * @param url the remote URL * @return the JSON array, or null if an error occurred + * @throws IOException if an I/O exception occurs */ public static JSONArray readJsonArrayFromUrl(URL url) throws IOException { String s = Utils.readDataFromUrl(url); @@ -640,32 +490,6 @@ public class Utils { 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); - } - /** * Returns whether or not the application is running within a JAR. * @return true if JAR, false if file @@ -674,8 +498,25 @@ public class Utils { return Opsu.class.getResource(String.format("%s.class", Opsu.class.getSimpleName())).toString().startsWith("jar:"); } + /** + * Returns the JarFile for the application. + * @return the JAR file, or null if it could not be determined + */ + public static JarFile getJarFile() { + if (!isJarRunning()) + return null; + + try { + return new JarFile(new File(Opsu.class.getProtectionDomain().getCodeSource().getLocation().toURI()), false); + } catch (URISyntaxException | IOException e) { + Log.error("Could not determine the JAR file.", e); + return null; + } + } + /** * Returns the directory where the application is being run. + * @return the directory, or null if it could not be determined */ public static File getRunningDirectory() { try { @@ -695,4 +536,29 @@ public class Utils { public static boolean parseBoolean(String s) { return (Integer.parseInt(s) == 1); } + + /** + * Returns the git hash of the remote-tracking branch 'origin/master' from the + * most recent update to the working directory (e.g. fetch or successful push). + * @return the 40-character SHA-1 hash, or null if it could not be determined + */ + public static String getGitHash() { + if (isJarRunning()) + return null; + File f = new File(".git/refs/remotes/origin/master"); + if (!f.isFile()) + return null; + try (BufferedReader in = new BufferedReader(new FileReader(f))) { + char[] sha = new char[40]; + if (in.read(sha, 0, sha.length) < sha.length) + return null; + for (int i = 0; i < sha.length; i++) { + if (Character.digit(sha[i], 16) == -1) + return null; + } + return String.valueOf(sha); + } catch (IOException e) { + return null; + } + } } diff --git a/src/itdelatrisu/opsu/audio/HitSound.java b/src/itdelatrisu/opsu/audio/HitSound.java index 590f3ddb..6d0058dd 100644 --- a/src/itdelatrisu/opsu/audio/HitSound.java +++ b/src/itdelatrisu/opsu/audio/HitSound.java @@ -40,10 +40,10 @@ public enum HitSound implements SoundController.SoundComponent { // TAIKO ("taiko", 4); /** The sample set name. */ - private String name; + private final String name; /** The sample set index. */ - private int index; + private final int index; /** Total number of sample sets. */ public static final int SIZE = values().length; @@ -77,7 +77,7 @@ public enum HitSound implements SoundController.SoundComponent { private static SampleSet currentDefaultSampleSet = SampleSet.NORMAL; /** The file name. */ - private String filename; + private final String filename; /** The Clip associated with the hit sound. */ private HashMap clips; diff --git a/src/itdelatrisu/opsu/audio/MultiClip.java b/src/itdelatrisu/opsu/audio/MultiClip.java index 0d4da1c6..f4f18ac9 100644 --- a/src/itdelatrisu/opsu/audio/MultiClip.java +++ b/src/itdelatrisu/opsu/audio/MultiClip.java @@ -49,12 +49,15 @@ public class MultiClip { private byte[] audioData; /** The name given to this clip. */ - private String name; + private final String name; /** * Constructor. * @param name the clip name * @param audioIn the associated AudioInputStream + * @throws IOException if an input or output error occurs + * @throws LineUnavailableException if a clip object is not available or + * if the line cannot be opened due to resource restrictions */ public MultiClip(String name, AudioInputStream audioIn) throws IOException, LineUnavailableException { this.name = name; @@ -105,6 +108,8 @@ public class MultiClip { * Plays the clip with the specified volume. * @param volume the volume the play at * @param listener the line listener + * @throws LineUnavailableException if a clip object is not available or + * if the line cannot be opened due to resource restrictions */ public void start(float volume, LineListener listener) throws LineUnavailableException { Clip clip = getClip(); @@ -130,6 +135,8 @@ public class MultiClip { * If no clip is available, then a new one is created if under MAX_CLIPS. * Otherwise, an existing clip will be returned. * @return the Clip to play + * @throws LineUnavailableException if a clip object is not available or + * if the line cannot be opened due to resource restrictions */ private Clip getClip() throws LineUnavailableException { // TODO: diff --git a/src/itdelatrisu/opsu/audio/MusicController.java b/src/itdelatrisu/opsu/audio/MusicController.java index 695dbe28..c5aac333 100644 --- a/src/itdelatrisu/opsu/audio/MusicController.java +++ b/src/itdelatrisu/opsu/audio/MusicController.java @@ -22,6 +22,7 @@ import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.beatmap.BeatmapParser; +import itdelatrisu.opsu.ui.UI; import java.io.File; import java.io.IOException; @@ -41,6 +42,7 @@ import org.newdawn.slick.MusicListener; import org.newdawn.slick.SlickException; import org.newdawn.slick.openal.Audio; import org.newdawn.slick.openal.SoundStore; +import org.newdawn.slick.util.ResourceLoader; import org.tritonus.share.sampled.file.TAudioFileFormat; /** @@ -87,6 +89,13 @@ public class MusicController { public static void play(final Beatmap beatmap, final boolean loop, final boolean preview) { // new track: load and play if (lastBeatmap == null || !beatmap.audioFilename.equals(lastBeatmap.audioFilename)) { + final File audioFile = beatmap.audioFilename; + if (!audioFile.isFile() && !ResourceLoader.resourceExists(audioFile.getPath())) { + UI.sendBarNotification(String.format("Could not find track '%s'.", audioFile.getName())); + System.out.println(beatmap); + return; + } + reset(); System.gc(); @@ -96,7 +105,7 @@ public class MusicController { trackLoader = new Thread() { @Override public void run() { - loadTrack(beatmap.audioFilename, (preview) ? beatmap.previewTime : 0, loop); + loadTrack(audioFile, (preview) ? beatmap.previewTime : 0, loop); } }; trackLoader.start(); @@ -221,6 +230,7 @@ public class MusicController { /** * Fades out the track. + * @param duration the fade time (in ms) */ public static void fadeOut(int duration) { if (isPlaying()) diff --git a/src/itdelatrisu/opsu/audio/SoundController.java b/src/itdelatrisu/opsu/audio/SoundController.java index b91db34e..7f42f144 100644 --- a/src/itdelatrisu/opsu/audio/SoundController.java +++ b/src/itdelatrisu/opsu/audio/SoundController.java @@ -59,6 +59,9 @@ public class SoundController { /** Sample volume multiplier, from timing points [0, 1]. */ private static float sampleVolumeMultiplier = 1f; + /** Whether all sounds are muted. */ + private static boolean isMuted; + /** The name of the current sound file being loaded. */ private static String currentFileName; @@ -261,7 +264,7 @@ public class SoundController { if (clip == null) // clip failed to load properly return; - if (volume > 0f) { + if (volume > 0f && !isMuted) { try { clip.start(volume, listener); } catch (LineUnavailableException e) { @@ -317,6 +320,12 @@ public class SoundController { playClip(s.getClip(), Options.getHitSoundVolume() * sampleVolumeMultiplier * Options.getMasterVolume(), null); } + /** + * Mutes or unmutes all sounds (hit sounds and sound effects). + * @param mute true to mute, false to unmute + */ + public static void mute(boolean mute) { isMuted = mute; } + /** * Returns the name of the current file being loaded, or null if none. */ diff --git a/src/itdelatrisu/opsu/audio/SoundEffect.java b/src/itdelatrisu/opsu/audio/SoundEffect.java index 03cabe3d..85aa64a9 100644 --- a/src/itdelatrisu/opsu/audio/SoundEffect.java +++ b/src/itdelatrisu/opsu/audio/SoundEffect.java @@ -42,7 +42,7 @@ public enum SoundEffect implements SoundController.SoundComponent { SPINNERSPIN ("spinnerspin"); /** The file name. */ - private String filename; + private final String filename; /** The Clip associated with the sound effect. */ private MultiClip clip; diff --git a/src/itdelatrisu/opsu/beatmap/Beatmap.java b/src/itdelatrisu/opsu/beatmap/Beatmap.java index 8e102e15..922aedde 100644 --- a/src/itdelatrisu/opsu/beatmap/Beatmap.java +++ b/src/itdelatrisu/opsu/beatmap/Beatmap.java @@ -23,6 +23,7 @@ import itdelatrisu.opsu.Options; import java.io.File; import java.util.ArrayList; import java.util.LinkedList; +import java.util.Map; import org.newdawn.slick.Color; import org.newdawn.slick.Image; @@ -36,16 +37,37 @@ public class Beatmap implements Comparable { public static final byte MODE_OSU = 0, MODE_TAIKO = 1, MODE_CTB = 2, MODE_MANIA = 3; /** Background image cache. */ - private static final BeatmapImageCache bgImageCache = new BeatmapImageCache(); + @SuppressWarnings("serial") + private static final LRUCache bgImageCache = new LRUCache(10) { + @Override + public void eldestRemoved(Map.Entry eldest) { + if (eldest.getKey() == lastBG) + lastBG = null; + ImageLoader imageLoader = eldest.getValue(); + imageLoader.destroy(); + } + }; + + /** The last background image loaded. */ + private static File lastBG; /** - * Returns the background image cache. + * Clears the background image cache. + *

+ * NOTE: This does NOT destroy the images in the cache, and will cause + * memory leaks if all images have not been destroyed. */ - public static BeatmapImageCache getBackgroundImageCache() { return bgImageCache; } + public static void clearBackgroundImageCache() { bgImageCache.clear(); } /** The OSU File object associated with this beatmap. */ private File file; + /** MD5 hash of this file. */ + public String md5Hash; + + /** The star rating. */ + public double starRating = -1; + /** * [General] */ @@ -138,7 +160,7 @@ public class Beatmap implements Comparable { public float HPDrainRate = 5f; /** CS: Size of circles and sliders (0:large ~ 10:small). */ - public float circleSize = 4f; + public float circleSize = 5f; /** OD: Affects timing window, spinners, and approach speed (0:easy ~ 10:hard). */ public float overallDifficulty = 5f; @@ -147,7 +169,7 @@ public class Beatmap implements Comparable { public float approachRate = -1f; /** Slider movement speed multiplier. */ - public float sliderMultiplier = 1f; + public float sliderMultiplier = 1.4f; /** Rate at which slider ticks are placed (x per beat). */ public float sliderTickRate = 1f; @@ -185,9 +207,6 @@ public class Beatmap implements Comparable { /** Slider border color. If null, the skin value is used. */ public Color sliderBorder; - /** MD5 hash of this file. */ - public String md5Hash; - /** * [HitObjects] */ @@ -255,47 +274,74 @@ public class Beatmap implements Comparable { } /** - * Draws the beatmap background. + * Loads the beatmap background image. + */ + public void loadBackground() { + if (bg == null || bgImageCache.containsKey(bg) || !bg.isFile()) + return; + + if (lastBG != null) { + ImageLoader lastImageLoader = bgImageCache.get(lastBG); + if (lastImageLoader != null && lastImageLoader.isLoading()) { + lastImageLoader.interrupt(); // only allow loading one image at a time + bgImageCache.remove(lastBG); + } + } + ImageLoader imageLoader = new ImageLoader(bg); + bgImageCache.put(bg, imageLoader); + imageLoader.load(true); + lastBG = bg; + } + + /** + * Returns whether the beatmap background image is currently loading. + * @return true if loading + */ + public boolean isBackgroundLoading() { + if (bg == null) + return false; + ImageLoader imageLoader = bgImageCache.get(bg); + return (imageLoader != null && imageLoader.isLoading()); + } + + /** + * Draws the beatmap background image. * @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) { + public boolean drawBackground(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 + ImageLoader imageLoader = bgImageCache.get(bg); + if (imageLoader == null) return false; + + Image bgImage = imageLoader.getImage(); + if (bgImage == null) + return true; + + 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); return true; } diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java b/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java new file mode 100644 index 00000000..6cf29528 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapDifficultyCalculator.java @@ -0,0 +1,544 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.db.BeatmapDB; +import itdelatrisu.opsu.objects.curves.Curve; +import itdelatrisu.opsu.objects.curves.Vec2f; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.newdawn.slick.util.Log; + +/** + * osu!tp's beatmap difficulty algorithm. + * + * @author Tom94 (https://github.com/Tom94/AiModtpDifficultyCalculator) + */ +public class BeatmapDifficultyCalculator { + /** Difficulty types. */ + public static final int DIFFICULTY_SPEED = 0, DIFFICULTY_AIM = 1; + + /** The star scaling factor. */ + private static final double STAR_SCALING_FACTOR = 0.045; + + /** The scaling factor that favors extremes. */ + private static final double EXTREME_SCALING_FACTOR = 0.5; + + /** The playfield width. */ + private static final float PLAYFIELD_WIDTH = 512f; + + /** + * In milliseconds. For difficulty calculation we will only look at the highest strain value in each + * time interval of size STRAIN_STEP.This is to eliminate higher influence of stream over aim by simply + * having more HitObjects with high strain. The higher this value, the less strains there will be, + * indirectly giving long beatmaps an advantage. + */ + private static final double STRAIN_STEP = 400; + + /** The weighting of each strain value decays to 0.9 * its previous value. */ + private static final double DECAY_WEIGHT = 0.9; + + /** The beatmap. */ + private final Beatmap beatmap; + + /** The beatmap's hit objects. */ + private tpHitObject[] tpHitObjects; + + /** The computed star rating. */ + private double starRating = -1; + + /** The computed difficulties, indexed by the {@code DIFFICULTY_*} constants. */ + private double[] difficulties = { -1, -1 }; + + /** The computed stars, indexed by the {@code DIFFICULTY_*} constants. */ + private double[] stars = { -1, -1 }; + + /** + * Constructor. Call {@link #calculate()} to run all computations. + *

+ * If any parts of the beatmap have not yet been loaded (e.g. timing points, + * hit objects), they will be loaded here. + * @param beatmap the beatmap + */ + public BeatmapDifficultyCalculator(Beatmap beatmap) { + this.beatmap = beatmap; + if (beatmap.timingPoints == null) + BeatmapDB.load(beatmap, BeatmapDB.LOAD_ARRAY); + BeatmapParser.parseHitObjects(beatmap); + } + + /** + * Returns the beatmap's star rating. + */ + public double getStarRating() { return starRating; } + + /** + * Returns the difficulty value for a difficulty type. + * @param type the difficulty type ({@code DIFFICULTY_* constant}) + */ + public double getDifficulty(int type) { return difficulties[type]; } + + /** + * Returns the star value for a difficulty type. + * @param type the difficulty type ({@code DIFFICULTY_* constant}) + */ + public double getStars(int type) { return stars[type]; } + + /** + * Calculates the difficulty values and star ratings for the beatmap. + */ + public void calculate() { + if (beatmap.objects == null || beatmap.timingPoints == null) { + Log.error(String.format("Trying to calculate difficulty values for beatmap '%s' with %s not yet loaded.", + beatmap.toString(), (beatmap.objects == null) ? "hit objects" : "timing points")); + return; + } + + // Fill our custom tpHitObject class, that carries additional information + // TODO: apply hit object stacking algorithm? + HitObject[] hitObjects = beatmap.objects; + this.tpHitObjects = new tpHitObject[hitObjects.length]; + float circleRadius = (PLAYFIELD_WIDTH / 16.0f) * (1.0f - 0.7f * (beatmap.circleSize - 5.0f) / 5.0f); + int timingPointIndex = 0; + float beatLengthBase = 1, beatLength = 1; + if (!beatmap.timingPoints.isEmpty()) { + TimingPoint timingPoint = beatmap.timingPoints.get(0); + if (!timingPoint.isInherited()) { + beatLengthBase = beatLength = timingPoint.getBeatLength(); + timingPointIndex++; + } + } + for (int i = 0; i < hitObjects.length; i++) { + HitObject hitObject = hitObjects[i]; + + // pass beatLength to hit objects + int hitObjectTime = hitObject.getTime(); + while (timingPointIndex < beatmap.timingPoints.size()) { + TimingPoint timingPoint = beatmap.timingPoints.get(timingPointIndex); + if (timingPoint.getTime() > hitObjectTime) + break; + if (!timingPoint.isInherited()) + beatLengthBase = beatLength = timingPoint.getBeatLength(); + else + beatLength = beatLengthBase * timingPoint.getSliderMultiplier(); + timingPointIndex++; + } + + tpHitObjects[i] = new tpHitObject(hitObject, circleRadius, beatmap, beatLength); + } + + if (!calculateStrainValues()) { + Log.error("Could not compute strain values. Aborting difficulty calculation."); + return; + } + + // OverallDifficulty is not considered in this algorithm and neither is HpDrainRate. + // That means, that in this form the algorithm determines how hard it physically is + // to play the map, assuming, that too much of an error will not lead to a death. + // It might be desirable to include OverallDifficulty into map difficulty, but in my + // personal opinion it belongs more to the weighting of the actual performance + // and is superfluous in the beatmap difficulty rating. + // If it were to be considered, then I would look at the hit window of normal HitCircles only, + // since Sliders and Spinners are (almost) "free" 300s and take map length into account as well. + difficulties[DIFFICULTY_SPEED] = calculateDifficulty(DIFFICULTY_SPEED); + difficulties[DIFFICULTY_AIM] = calculateDifficulty(DIFFICULTY_AIM); + + // The difficulty can be scaled by any desired metric. + // In osu!tp it gets squared to account for the rapid increase in difficulty as the + // limit of a human is approached. (Of course it also gets scaled afterwards.) + // It would not be suitable for a star rating, therefore: + + // The following is a proposal to forge a star rating from 0 to 5. It consists of taking + // the square root of the difficulty, since by simply scaling the easier + // 5-star maps would end up with one star. + stars[DIFFICULTY_SPEED] = Math.sqrt(difficulties[DIFFICULTY_SPEED]) * STAR_SCALING_FACTOR; + stars[DIFFICULTY_AIM] = Math.sqrt(difficulties[DIFFICULTY_AIM]) * STAR_SCALING_FACTOR; + + // Again, from own observations and from the general opinion of the community + // a map with high speed and low aim (or vice versa) difficulty is harder, + // than a map with mediocre difficulty in both. Therefore we can not just add + // both difficulties together, but will introduce a scaling that favors extremes. + + // Another approach to this would be taking Speed and Aim separately to a chosen + // power, which again would be equivalent. This would be more convenient if + // the hit window size is to be considered as well. + + // Note: The star rating is tuned extremely tight! Airman (/b/104229) and + // Freedom Dive (/b/126645), two of the hardest ranked maps, both score ~4.66 stars. + // Expect the easier kind of maps that officially get 5 stars to obtain around 2 by + // this metric. The tutorial still scores about half a star. + // Tune by yourself as you please. ;) + this.starRating = stars[DIFFICULTY_SPEED] + stars[DIFFICULTY_AIM] + + Math.abs(stars[DIFFICULTY_SPEED] - stars[DIFFICULTY_AIM]) * EXTREME_SCALING_FACTOR; + } + + /** + * Computes the strain values for the beatmap. + * @return true if successful, false otherwise + */ + private boolean calculateStrainValues() { + // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from + // the strain value of CurrentHitObject and environment. + if (tpHitObjects.length == 0) { + Log.warn("Can not compute difficulty of empty beatmap."); + return false; + } + + tpHitObject currentHitObject = tpHitObjects[0]; + tpHitObject nextHitObject; + int index = 0; + + // First hitObject starts at strain 1. 1 is the default for strain values, + // so we don't need to set it here. See tpHitObject. + while (++index < tpHitObjects.length) { + nextHitObject = tpHitObjects[index]; + nextHitObject.calculateStrains(currentHitObject); + currentHitObject = nextHitObject; + } + + return true; + } + + /** + * Calculates the difficulty value for a difficulty type. + * @param type the difficulty type ({@code DIFFICULTY_* constant}) + * @return the difficulty value + */ + private double calculateDifficulty(int type) { + // Find the highest strain value within each strain step + List highestStrains = new ArrayList(); + double intervalEndTime = STRAIN_STEP; + double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval + + tpHitObject previousHitObject = null; + for (int i = 0; i < tpHitObjects.length; i++) { + tpHitObject hitObject = tpHitObjects[i]; + + // While we are beyond the current interval push the currently available maximum to our strain list + while (hitObject.baseHitObject.getTime() > intervalEndTime) { + highestStrains.add(maximumStrain); + + // The maximum strain of the next interval is not zero by default! We need to take the last + // hitObject we encountered, take its strain and apply the decay until the beginning of the next interval. + if (previousHitObject == null) + maximumStrain = 0; + else { + double decay = Math.pow(tpHitObject.DECAY_BASE[type], (intervalEndTime - previousHitObject.baseHitObject.getTime()) / 1000); + maximumStrain = previousHitObject.getStrain(type) * decay; + } + + // Go to the next time interval + intervalEndTime += STRAIN_STEP; + } + + // Obtain maximum strain + if (hitObject.getStrain(type) > maximumStrain) + maximumStrain = hitObject.getStrain(type); + + previousHitObject = hitObject; + } + + // Build the weighted sum over the highest strains for each interval + double difficulty = 0; + double weight = 1; + Collections.sort(highestStrains, Collections.reverseOrder()); // Sort from highest to lowest strain. + for (double strain : highestStrains) { + difficulty += weight * strain; + weight *= DECAY_WEIGHT; + } + + return difficulty; + } +} + +/** + * Hit object helper class for calculating strains. + */ +class tpHitObject { + /** + * Factor by how much speed / aim strain decays per second. Those values are results + * of tweaking a lot and taking into account general feedback. + * Opinionated observation: Speed is easier to maintain than accurate jumps. + */ + public static final double[] DECAY_BASE = { 0.3, 0.15 }; + + /** Almost the normed diameter of a circle (104 osu pixel). That is -after- position transforming. */ + private static final double ALMOST_DIAMETER = 90; + + /** + * Pseudo threshold values to distinguish between "singles" and "streams". + * Of course the border can not be defined clearly, therefore the algorithm + * has a smooth transition between those values. They also are based on tweaking + * and general feedback. + */ + private static final double STREAM_SPACING_TRESHOLD = 110, SINGLE_SPACING_TRESHOLD = 125; + + /** + * Scaling values for weightings to keep aim and speed difficulty in balance. + * Found from testing a very large map pool (containing all ranked maps) and + * keeping the average values the same. + */ + private static final double[] SPACING_WEIGHT_SCALING = { 1400, 26.25 }; + + /** + * In milliseconds. The smaller the value, the more accurate sliders are approximated. + * 0 leads to an infinite loop, so use something bigger. + */ + private static final int LAZY_SLIDER_STEP_LENGTH = 1; + + /** The base hit object. */ + public final HitObject baseHitObject; + + /** The strain values, indexed by the {@code DIFFICULTY_*} constants. */ + private double[] strains = { 1, 1 }; + + /** The normalized start and end positions. */ + private Vec2f normalizedStartPosition, normalizedEndPosition; + + /** The slider lengths. */ + private float lazySliderLengthFirst = 0, lazySliderLengthSubsequent = 0; + + /** + * Constructor. + * @param baseHitObject the base hit object + * @param circleRadius the circle radius + * @param beatmap the beatmap that contains the hit object + * @param beatLength the current beat length + */ + public tpHitObject(HitObject baseHitObject, float circleRadius, Beatmap beatmap, float beatLength) { + this.baseHitObject = baseHitObject; + + // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. + float scalingFactor = (52.0f / circleRadius); + normalizedStartPosition = new Vec2f(baseHitObject.getX(), baseHitObject.getY()).scale(scalingFactor); + + // Calculate approximation of lazy movement on the slider + if (baseHitObject.isSlider()) { + tpSlider slider = new tpSlider(baseHitObject, beatmap.sliderMultiplier, beatLength); + + // Not sure if this is correct, but here we do not need 100% exact values. This comes pretty darn close in my tests. + float sliderFollowCircleRadius = circleRadius * 3; + + int segmentLength = slider.getSegmentLength(); // baseHitObject.Length / baseHitObject.SegmentCount; + int segmentEndTime = baseHitObject.getTime() + segmentLength; + + // For simplifying this step we use actual osu! coordinates and simply scale the length, + // that we obtain by the ScalingFactor later + Vec2f cursorPos = new Vec2f(baseHitObject.getX(), baseHitObject.getY()); + + // Actual computation of the first lazy curve + for (int time = baseHitObject.getTime() + LAZY_SLIDER_STEP_LENGTH; time < segmentEndTime; time += LAZY_SLIDER_STEP_LENGTH) { + Vec2f difference = slider.getPositionAtTime(time).sub(cursorPos); + float distance = difference.len(); + + // Did we move away too far? + if (distance > sliderFollowCircleRadius) { + // Yep, we need to move the cursor + difference.normalize(); // Obtain the direction of difference. We do no longer need the actual difference + distance -= sliderFollowCircleRadius; + cursorPos.add(difference.cpy().scale(distance)); // We move the cursor just as far as needed to stay in the follow circle + lazySliderLengthFirst += distance; + } + } + + lazySliderLengthFirst *= scalingFactor; + + // If we have an odd amount of repetitions the current position will be the end of the slider. + // Note that this will -always- be triggered if baseHitObject.SegmentCount <= 1, because + // baseHitObject.SegmentCount can not be smaller than 1. Therefore normalizedEndPosition will + // always be initialized + if (baseHitObject.getRepeatCount() % 2 == 1) + normalizedEndPosition = cursorPos.cpy().scale(scalingFactor); + + // If we have more than one segment, then we also need to compute the length of subsequent + // lazy curves. They are different from the first one, since the first one starts right + // at the beginning of the slider. + if (baseHitObject.getRepeatCount() > 1) { + // Use the next segment + segmentEndTime += segmentLength; + + for (int time = segmentEndTime - segmentLength + LAZY_SLIDER_STEP_LENGTH; time < segmentEndTime; time += LAZY_SLIDER_STEP_LENGTH) { + Vec2f difference = slider.getPositionAtTime(time).sub(cursorPos); + float distance = difference.len(); + + // Did we move away too far? + if (distance > sliderFollowCircleRadius) { + // Yep, we need to move the cursor + difference.normalize(); // Obtain the direction of difference. We do no longer need the actual difference + distance -= sliderFollowCircleRadius; + cursorPos.add(difference.cpy().scale(distance)); // We move the cursor just as far as needed to stay in the follow circle + lazySliderLengthSubsequent += distance; + } + } + + lazySliderLengthSubsequent *= scalingFactor; + + // If we have an even amount of repetitions the current position will be the end of the slider + if (baseHitObject.getRepeatCount() % 2 == 0) // == 1) + normalizedEndPosition = cursorPos.cpy().scale(scalingFactor); + } + } else { + // We have a normal HitCircle or a spinner + normalizedEndPosition = normalizedStartPosition.cpy(); //baseHitObject.EndPosition * ScalingFactor; + } + } + + /** + * Returns the strain value for a difficulty type. + * @param type the difficulty type ({@code DIFFICULTY_* constant}) + */ + public double getStrain(int type) { return strains[type]; } + + /** + * Calculates the strain values given the previous hit object. + * @param previousHitObject the previous hit object + */ + public void calculateStrains(tpHitObject previousHitObject) { + calculateSpecificStrain(previousHitObject, BeatmapDifficultyCalculator.DIFFICULTY_SPEED); + calculateSpecificStrain(previousHitObject, BeatmapDifficultyCalculator.DIFFICULTY_AIM); + } + + /** + * Returns the spacing weight for a distance. + * @param distance the distance + * @param type the difficulty type ({@code DIFFICULTY_* constant}) + */ + private static double spacingWeight(double distance, int type) { + // Caution: The subjective values are strong with this one + switch (type) { + case BeatmapDifficultyCalculator.DIFFICULTY_SPEED: + double weight; + if (distance > SINGLE_SPACING_TRESHOLD) + weight = 2.5; + else if (distance > STREAM_SPACING_TRESHOLD) + weight = 1.6 + 0.9 * (distance - STREAM_SPACING_TRESHOLD) / (SINGLE_SPACING_TRESHOLD - STREAM_SPACING_TRESHOLD); + else if (distance > ALMOST_DIAMETER) + weight = 1.2 + 0.4 * (distance - ALMOST_DIAMETER) / (STREAM_SPACING_TRESHOLD - ALMOST_DIAMETER); + else if (distance > ALMOST_DIAMETER / 2) + weight = 0.95 + 0.25 * (distance - (ALMOST_DIAMETER / 2)) / (ALMOST_DIAMETER / 2); + else + weight = 0.95; + return weight; + case BeatmapDifficultyCalculator.DIFFICULTY_AIM: + return Math.pow(distance, 0.99); + default: + // Should never happen. + return 0; + } + } + + /** + * Calculates the strain value for a difficulty type given the previous hit object. + * @param previousHitObject the previous hit object + * @param type the difficulty type ({@code DIFFICULTY_* constant}) + */ + private void calculateSpecificStrain(tpHitObject previousHitObject, int type) { + double addition = 0; + double timeElapsed = baseHitObject.getTime() - previousHitObject.baseHitObject.getTime(); + double decay = Math.pow(DECAY_BASE[type], timeElapsed / 1000); + + if (baseHitObject.isSpinner()) { + // Do nothing for spinners + } else if (baseHitObject.isSlider()) { + switch (type) { + case BeatmapDifficultyCalculator.DIFFICULTY_SPEED: + // For speed strain we treat the whole slider as a single spacing entity, + // since "Speed" is about how hard it is to click buttons fast. + // The spacing weight exists to differentiate between being able to easily + // alternate or having to single. + addition = spacingWeight(previousHitObject.lazySliderLengthFirst + + previousHitObject.lazySliderLengthSubsequent * (Math.max(previousHitObject.baseHitObject.getRepeatCount(), 1) - 1) + + distanceTo(previousHitObject), type) * SPACING_WEIGHT_SCALING[type]; + break; + + case BeatmapDifficultyCalculator.DIFFICULTY_AIM: + // For Aim strain we treat each slider segment and the jump after the end of + // the slider as separate jumps, since movement-wise there is no difference + // to multiple jumps. + addition = (spacingWeight(previousHitObject.lazySliderLengthFirst, type) + + spacingWeight(previousHitObject.lazySliderLengthSubsequent, type) * (Math.max(previousHitObject.baseHitObject.getRepeatCount(), 1) - 1) + + spacingWeight(distanceTo(previousHitObject), type)) * SPACING_WEIGHT_SCALING[type]; + break; + } + } else if (baseHitObject.isCircle()) { + addition = spacingWeight(distanceTo(previousHitObject), type) * SPACING_WEIGHT_SCALING[type]; + } + + // Scale addition by the time, that elapsed. Filter out HitObjects that are too + // close to be played anyway to avoid crazy values by division through close to zero. + // You will never find maps that require this amongst ranked maps. + addition /= Math.max(timeElapsed, 50); + + strains[type] = previousHitObject.strains[type] * decay + addition; + } + + /** + * Returns the distance to another hit object. + * @param other the other hit object + */ + public double distanceTo(tpHitObject other) { + // Scale the distance by circle size. + return (normalizedStartPosition.cpy().sub(other.normalizedEndPosition)).len(); + } +} + +/** + * Slider helper class to fill in some missing pieces needed in the strain calculations. + */ +class tpSlider { + /** The slider start time. */ + private final int startTime; + + /** The time duration of the slider, in milliseconds. */ + private final int sliderTime; + + /** The slider Curve. */ + private final Curve curve; + + /** + * Constructor. + * @param hitObject the hit object + * @param sliderMultiplier the slider movement speed multiplier + * @param beatLength the beat length + */ + public tpSlider(HitObject hitObject, float sliderMultiplier, float beatLength) { + this.startTime = hitObject.getTime(); + this.sliderTime = (int) hitObject.getSliderTime(sliderMultiplier, beatLength); + this.curve = hitObject.getSliderCurve(false); + } + + /** + * Returns the time duration of a slider segment, in milliseconds. + */ + public int getSegmentLength() { return sliderTime; } + + /** + * Returns the coordinates of the slider at a given track position. + * @param time the track position + */ + public Vec2f getPositionAtTime(int time) { + float t = (time - startTime) / sliderTime; + float floor = (float) Math.floor(t); + t = (floor % 2 == 0) ? t - floor : floor + 1 - t; + return curve.pointAt(t); + } +} diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapImageCache.java b/src/itdelatrisu/opsu/beatmap/BeatmapImageCache.java deleted file mode 100644 index fdf502f9..00000000 --- a/src/itdelatrisu/opsu/beatmap/BeatmapImageCache.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * opsu! - an open-source osu! client - * Copyright (C) 2014, 2015 Jeffrey Han - * - * opsu! is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * opsu! is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with opsu!. If not, see . - */ - -package itdelatrisu.opsu.beatmap; - -import java.io.File; -import java.util.LinkedHashMap; -import java.util.Map; - -import org.newdawn.slick.Image; -import org.newdawn.slick.SlickException; -import org.newdawn.slick.util.Log; - -/** - * LRU cache for beatmap background images. - */ -public class BeatmapImageCache { - /** Maximum number of cached images. */ - private static final int MAX_CACHE_SIZE = 10; - - /** Map of all loaded background images. */ - private LinkedHashMap cache; - - /** - * Constructor. - */ - @SuppressWarnings("serial") - public BeatmapImageCache() { - this.cache = new LinkedHashMap(MAX_CACHE_SIZE + 1, 1.1f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - if (size() > MAX_CACHE_SIZE) { - // destroy the eldest image - Image img = eldest.getValue(); - if (img != null && !img.isDestroyed()) { - try { - img.destroy(); - } catch (SlickException e) { - Log.warn(String.format("Failed to destroy image '%s'.", img.getResourceReference()), e); - } - } - return true; - } - return false; - } - }; - } - - /** - * Returns the image mapped to the specified beatmap. - * @param beatmap the Beatmap - * @return the Image, or {@code null} if no such mapping exists - */ - public Image get(Beatmap beatmap) { return cache.get(beatmap.bg); } - - /** - * Creates a mapping from the specified beatmap to the given image. - * @param beatmap the Beatmap - * @param image the Image - * @return the previously mapped Image, or {@code null} if no such mapping existed - */ - public Image put(Beatmap beatmap, Image image) { return cache.put(beatmap.bg, image); } - - /** - * Removes all entries from the cache. - *

- * NOTE: This does NOT destroy the images in the cache, and will cause - * memory leaks if all images have not been destroyed. - */ - public void clear() { cache.clear(); } -} diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java index ef403b03..7a56a1b2 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapParser.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapParser.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.io.MD5InputStreamWrapper; @@ -83,6 +84,10 @@ public class BeatmapParser { // create a new BeatmapSetList BeatmapSetList.create(); + // create a new watch service + if (Options.isWatchServiceEnabled()) + BeatmapWatchService.create(); + // parse all directories parseDirectories(root.listFiles()); } @@ -110,6 +115,9 @@ public class BeatmapParser { List cachedBeatmaps = new LinkedList(); // loaded from database List parsedBeatmaps = new LinkedList(); // loaded from parser + // watch service + BeatmapWatchService ws = (Options.isWatchServiceEnabled()) ? BeatmapWatchService.get() : null; + // parse directories BeatmapSetNode lastNode = null; for (File dir : dirs) { @@ -134,17 +142,19 @@ public class BeatmapParser { // check if beatmap is cached String path = String.format("%s/%s", dir.getName(), file.getName()); - if (map.containsKey(path)) { - // check last modified times - long lastModified = map.get(path); - if (lastModified == file.lastModified()) { - // add to cached beatmap list - Beatmap beatmap = new Beatmap(file); - beatmaps.add(beatmap); - cachedBeatmaps.add(beatmap); - continue; - } else - BeatmapDB.delete(dir.getName(), file.getName()); + if (map != null) { + Long lastModified = map.get(path); + if (lastModified != null) { + // check last modified times + if (lastModified == file.lastModified()) { + // add to cached beatmap list + Beatmap beatmap = new Beatmap(file); + beatmaps.add(beatmap); + cachedBeatmaps.add(beatmap); + continue; + } else + BeatmapDB.delete(dir.getName(), file.getName()); + } } // Parse hit objects only when needed to save time/memory. @@ -162,6 +172,8 @@ public class BeatmapParser { if (!beatmaps.isEmpty()) { beatmaps.trimToSize(); allBeatmaps.add(beatmaps); + if (ws != null) + ws.registerAll(dir.toPath()); } // stop parsing files (interrupted) @@ -676,10 +688,15 @@ public class BeatmapParser { beatmap.objects[objectIndex++] = hitObject; } catch (Exception e) { - Log.warn(String.format("Failed to read hit object '%s' for Beatmap '%s'.", + Log.warn(String.format("Failed to read hit object '%s' for beatmap '%s'.", line, beatmap.toString()), e); } } + + // check that all objects were parsed + if (objectIndex != beatmap.objects.length) + ErrorHandler.error(String.format("Parsed %d objects for beatmap '%s', %d objects expected.", + objectIndex, beatmap.toString(), beatmap.objects.length), null, true); } catch (IOException e) { ErrorHandler.error(String.format("Failed to read file '%s'.", beatmap.getFile().getAbsolutePath()), e, false); } @@ -711,6 +728,7 @@ public class BeatmapParser { /** * Returns the file extension of a file. + * @param file the file name */ public static String getExtension(String file) { int i = file.lastIndexOf('.'); diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSet.java b/src/itdelatrisu/opsu/beatmap/BeatmapSet.java index 8ba0693b..2cffa823 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSet.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSet.java @@ -21,14 +21,15 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.GameMod; import java.util.ArrayList; +import java.util.Iterator; import java.util.concurrent.TimeUnit; /** * Data type containing all beatmaps in a beatmap set. */ -public class BeatmapSet { +public class BeatmapSet implements Iterable { /** List of associated beatmaps. */ - private ArrayList beatmaps; + private final ArrayList beatmaps; /** * Constructor. @@ -46,7 +47,7 @@ public class BeatmapSet { /** * Returns the beatmap at the given index. * @param index the beatmap index - * @throws IndexOutOfBoundsException + * @throws IndexOutOfBoundsException if the index is out of range */ public Beatmap get(int index) { return beatmaps.get(index); } @@ -54,10 +55,13 @@ public class BeatmapSet { * Removes the beatmap at the given index. * @param index the beatmap index * @return the removed beatmap - * @throws IndexOutOfBoundsException + * @throws IndexOutOfBoundsException if the index is out of range */ public Beatmap remove(int index) { return beatmaps.remove(index); } + @Override + public Iterator iterator() { return beatmaps.iterator(); } + /** * Returns an array of strings containing beatmap information. *

    @@ -65,10 +69,10 @@ public class BeatmapSet { *
  • 1: Mapped by {Creator} *
  • 2: Length: {} BPM: {} Objects: {} *
  • 3: Circles: {} Sliders: {} Spinners: {} - *
  • 4: CS:{} HP:{} AR:{} OD:{} + *
  • 4: CS:{} HP:{} AR:{} OD:{} Stars:{} *
* @param index the beatmap index - * @throws IndexOutOfBoundsException + * @throws IndexOutOfBoundsException if the index is out of range */ public String[] getInfo(int index) { Beatmap beatmap = beatmaps.get(index); @@ -88,11 +92,12 @@ public class BeatmapSet { (beatmap.hitObjectCircle + beatmap.hitObjectSlider + beatmap.hitObjectSpinner)); info[3] = String.format("Circles: %d Sliders: %d Spinners: %d", beatmap.hitObjectCircle, beatmap.hitObjectSlider, beatmap.hitObjectSpinner); - info[4] = String.format("CS:%.1f HP:%.1f AR:%.1f OD:%.1f", + info[4] = String.format("CS:%.1f HP:%.1f AR:%.1f OD:%.1f%s", Math.min(beatmap.circleSize * multiplier, 10f), Math.min(beatmap.HPDrainRate * multiplier, 10f), Math.min(beatmap.approachRate * multiplier, 10f), - Math.min(beatmap.overallDifficulty * multiplier, 10f)); + Math.min(beatmap.overallDifficulty * multiplier, 10f), + (beatmap.starRating >= 0) ? String.format(" Stars:%.2f", beatmap.starRating) : ""); return info; } @@ -126,7 +131,7 @@ public class BeatmapSet { return true; // search: version, tags (remaining beatmaps) - for (int i = 1; i < beatmaps.size(); i++) { + for (int i = 1, n = beatmaps.size(); i < n; i++) { beatmap = beatmaps.get(i); if (beatmap.version.toLowerCase().contains(query) || beatmap.tags.contains(query)) @@ -138,7 +143,7 @@ public class BeatmapSet { /** * Checks whether the beatmap set matches a given condition. - * @param type the condition type (ar, cs, od, hp, bpm, length) + * @param type the condition type (ar, cs, od, hp, bpm, length, star/stars) * @param operator the operator {@literal (=/==, >, >=, <, <=)} * @param value the value * @return true if the condition is met @@ -154,6 +159,8 @@ public class BeatmapSet { case "hp": v = beatmap.HPDrainRate; break; case "bpm": v = beatmap.bpmMax; break; case "length": v = beatmap.endTime / 1000; break; + case "star": + case "stars": v = Math.round(beatmap.starRating * 100) / 100f; break; default: return false; } diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java b/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java index c146ee67..7a0efdd9 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSetList.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.db.BeatmapDB; @@ -44,7 +45,7 @@ public class BeatmapSetList { /** Search pattern for conditional expressions. */ private static final Pattern SEARCH_CONDITION_PATTERN = Pattern.compile( - "(ar|cs|od|hp|bpm|length)(=|==|>|>=|<|<=)((\\d*\\.)?\\d+)" + "(ar|cs|od|hp|bpm|length|stars?)(==?|>=?|<=?)((\\d*\\.)?\\d+)" ); /** List containing all parsed nodes. */ @@ -163,12 +164,17 @@ public class BeatmapSetList { } // remove all node references - Beatmap beatmap = node.getBeatmapSet().get(0); + BeatmapSet beatmapSet = node.getBeatmapSet(); + Beatmap beatmap = beatmapSet.get(0); nodes.remove(index); parsedNodes.remove(eCur); - mapCount -= node.getBeatmapSet().size(); + mapCount -= beatmapSet.size(); if (beatmap.beatmapSetID > 0) MSIDdb.remove(beatmap.beatmapSetID); + for (Beatmap bm : beatmapSet) { + if (bm.md5Hash != null) + this.beatmapHashDB.remove(bm.md5Hash); + } // reset indices for (int i = index, size = size(); i < size; i++) @@ -199,11 +205,16 @@ public class BeatmapSetList { BeatmapDB.delete(dir.getName()); // delete the associated directory + BeatmapWatchService ws = (Options.isWatchServiceEnabled()) ? BeatmapWatchService.get() : null; + if (ws != null) + ws.pause(); try { Utils.deleteToTrash(dir); } catch (IOException e) { ErrorHandler.error("Could not delete song group.", e, true); } + if (ws != null) + ws.resume(); return true; } @@ -235,6 +246,8 @@ public class BeatmapSetList { // remove song reference Beatmap beatmap = node.getBeatmapSet().remove(node.beatmapIndex); mapCount--; + if (beatmap.md5Hash != null) + beatmapHashDB.remove(beatmap.md5Hash); // re-link nodes if (node.prev != null) @@ -247,11 +260,16 @@ public class BeatmapSetList { BeatmapDB.delete(file.getParentFile().getName(), file.getName()); // delete the associated file + BeatmapWatchService ws = (Options.isWatchServiceEnabled()) ? BeatmapWatchService.get() : null; + if (ws != null) + ws.pause(); try { Utils.deleteToTrash(file); } catch (IOException e) { ErrorHandler.error("Could not delete song.", e, true); } + if (ws != null) + ws.resume(); return true; } @@ -312,6 +330,7 @@ public class BeatmapSetList { /** * Expands the node at an index by inserting a new node for each Beatmap * in that node and hiding the group node. + * @param index the node index * @return the first of the newly-inserted nodes */ public BeatmapSetNode expand(int index) { diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java b/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java index 6324d1c4..947631c5 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSetNode.java @@ -21,7 +21,8 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.GameData.Grade; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import org.newdawn.slick.Color; import org.newdawn.slick.Image; @@ -31,7 +32,7 @@ import org.newdawn.slick.Image; */ public class BeatmapSetNode { /** The associated beatmap set. */ - private BeatmapSet beatmapSet; + private final BeatmapSet beatmapSet; /** Index of the selected beatmap (-1 if not focused). */ public int beatmapIndex = -1; @@ -56,6 +57,14 @@ public class BeatmapSetNode { */ public BeatmapSet getBeatmapSet() { return beatmapSet; } + /** + * Returns the selected beatmap (based on {@link #beatmapIndex}). + * @return the beatmap, or null if the index is invalid + */ + public Beatmap getSelectedBeatmap() { + return (beatmapIndex < 0 || beatmapIndex >= beatmapSet.size()) ? null : beatmapSet.get(beatmapIndex); + } + /** * Draws the button. * @param x the x coordinate @@ -78,16 +87,16 @@ public class BeatmapSetNode { bgColor = Color.white; textColor = Options.getSkin().getSongSelectActiveTextColor(); } else - bgColor = Utils.COLOR_BLUE_BUTTON; + bgColor = Colors.BLUE_BUTTON; beatmap = beatmapSet.get(beatmapIndex); } else { - bgColor = Utils.COLOR_ORANGE_BUTTON; + bgColor = Colors.ORANGE_BUTTON; beatmap = beatmapSet.get(0); } bg.draw(x, y, bgColor); float cx = x + (bg.getWidth() * 0.043f); - float cy = y + (bg.getHeight() * 0.2f) - 3; + float cy = y + (bg.getHeight() * 0.18f) - 3; // draw grade if (grade != Grade.NULL) { @@ -98,15 +107,58 @@ public class BeatmapSetNode { // draw text if (Options.useUnicodeMetadata()) { // load glyphs - Utils.loadGlyphs(Utils.FONT_MEDIUM, beatmap.titleUnicode, null); - Utils.loadGlyphs(Utils.FONT_DEFAULT, null, beatmap.artistUnicode); + Fonts.loadGlyphs(Fonts.MEDIUM, beatmap.titleUnicode); + Fonts.loadGlyphs(Fonts.DEFAULT, beatmap.artistUnicode); } - Utils.FONT_MEDIUM.drawString(cx, cy, beatmap.getTitle(), textColor); - Utils.FONT_DEFAULT.drawString(cx, cy + Utils.FONT_MEDIUM.getLineHeight() - 2, + Fonts.MEDIUM.drawString(cx, cy, beatmap.getTitle(), textColor); + Fonts.DEFAULT.drawString(cx, cy + Fonts.MEDIUM.getLineHeight() - 3, String.format("%s // %s", beatmap.getArtist(), beatmap.creator), textColor); if (expanded || beatmapSet.size() == 1) - Utils.FONT_BOLD.drawString(cx, cy + Utils.FONT_MEDIUM.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() - 4, + Fonts.BOLD.drawString(cx, cy + Fonts.MEDIUM.getLineHeight() + Fonts.DEFAULT.getLineHeight() - 6, beatmap.version, textColor); + + // draw stars + // (note: in osu!, stars are also drawn for beatmap sets of size 1) + if (expanded) { + if (beatmap.starRating >= 0) { + Image star = GameImage.STAR.getImage(); + float starOffset = star.getWidth() * 1.7f; + float starX = cx + starOffset * 0.04f; + float starY = cy + Fonts.MEDIUM.getLineHeight() + Fonts.DEFAULT.getLineHeight() * 2 - 8f * GameImage.getUIscale(); + float starCenterY = starY + star.getHeight() / 2f; + final float baseAlpha = focus ? 1f : 0.8f; + final float smallStarScale = 0.4f; + star.setAlpha(baseAlpha); + int i = 1; + for (; i < beatmap.starRating && i <= 5; i++) { + if (focus) + star.drawFlash(starX + (i - 1) * starOffset, starY, star.getWidth(), star.getHeight(), textColor); + else + star.draw(starX + (i - 1) * starOffset, starY); + } + if (i <= 5) { + float partialStarScale = smallStarScale + (float) (beatmap.starRating - i + 1) * (1f - smallStarScale); + Image partialStar = star.getScaledCopy(partialStarScale); + partialStar.setAlpha(baseAlpha); + float partialStarY = starCenterY - partialStar.getHeight() / 2f; + if (focus) + partialStar.drawFlash(starX + (i - 1) * starOffset, partialStarY, partialStar.getWidth(), partialStar.getHeight(), textColor); + else + partialStar.draw(starX + (i - 1) * starOffset, partialStarY); + } + if (++i <= 5) { + Image smallStar = star.getScaledCopy(smallStarScale); + smallStar.setAlpha(0.5f); + float smallStarY = starCenterY - smallStar.getHeight() / 2f; + for (; i <= 5; i++) { + if (focus) + smallStar.drawFlash(starX + (i - 1) * starOffset, smallStarY, smallStar.getWidth(), smallStar.getHeight(), textColor); + else + smallStar.draw(starX + (i - 1) * starOffset, smallStarY); + } + } + } + } } /** diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java b/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java index a9c3c545..78ca593c 100644 --- a/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java +++ b/src/itdelatrisu/opsu/beatmap/BeatmapSortOrder.java @@ -39,13 +39,13 @@ public enum BeatmapSortOrder { LENGTH (4, "Length", new LengthOrder()); /** The ID of the sort (used for tab positioning). */ - private int id; + private final int id; /** The name of the sort. */ - private String name; + private final String name; /** The comparator for the sort. */ - private Comparator comparator; + private final Comparator comparator; /** The tab associated with the sort (displayed in Song Menu screen). */ private MenuButton tab; @@ -123,13 +123,11 @@ public enum BeatmapSortOrder { @Override public int compare(BeatmapSetNode v, BeatmapSetNode w) { int vMax = 0, wMax = 0; - for (int i = 0, size = v.getBeatmapSet().size(); i < size; i++) { - Beatmap beatmap = v.getBeatmapSet().get(i); + for (Beatmap beatmap : v.getBeatmapSet()) { if (beatmap.endTime > vMax) vMax = beatmap.endTime; } - for (int i = 0, size = w.getBeatmapSet().size(); i < size; i++) { - Beatmap beatmap = w.getBeatmapSet().get(i); + for (Beatmap beatmap : w.getBeatmapSet()) { if (beatmap.endTime > wMax) wMax = beatmap.endTime; } diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapWatchService.java b/src/itdelatrisu/opsu/beatmap/BeatmapWatchService.java new file mode 100644 index 00000000..8399cc22 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/BeatmapWatchService.java @@ -0,0 +1,295 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; + +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.newdawn.slick.util.Log; + +/* + * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of Oracle nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Watches the beatmap directory tree for changes. + * + * @author The Java Tutorials (http://docs.oracle.com/javase/tutorial/essential/io/examples/WatchDir.java) (base) + */ +public class BeatmapWatchService { + /** Beatmap watcher service instance. */ + private static BeatmapWatchService ws; + + /** + * Creates a new watch service instance (overwriting any previous instance), + * registers the beatmap directory, and starts processing events. + */ + public static void create() { + // close the existing watch service + destroy(); + + // create a new watch service + try { + ws = new BeatmapWatchService(); + ws.register(Options.getBeatmapDir().toPath()); + } catch (IOException e) { + ErrorHandler.error("An I/O exception occurred while creating the watch service.", e, true); + return; + } + + // start processing events + ws.start(); + } + + /** + * Destroys the watch service instance, if any. + * Subsequent calls to {@link #get()} will return {@code null}. + */ + public static void destroy() { + if (ws == null) + return; + + try { + ws.watcher.close(); + ws.service.shutdownNow(); + ws = null; + } catch (IOException e) { + ws = null; + ErrorHandler.error("An I/O exception occurred while closing the previous watch service.", e, true); + } + } + + /** + * Returns the single instance of this class. + */ + public static BeatmapWatchService get() { return ws; } + + /** Watch service listener interface. */ + public interface BeatmapWatchServiceListener { + /** + * Indication that an event was received. + * @param kind the event kind + * @param child the child directory + */ + public void eventReceived(WatchEvent.Kind kind, Path child); + } + + /** The list of listeners. */ + private static final List listeners = new ArrayList(); + + /** + * Adds a listener. + * @param listener the listener to add + */ + public static void addListener(BeatmapWatchServiceListener listener) { listeners.add(listener); } + + /** + * Removes a listener. + * @param listener the listener to remove + */ + public static void removeListener(BeatmapWatchServiceListener listener) { listeners.remove(listener); } + + /** + * Removes all listeners. + */ + public static void removeListeners() { listeners.clear(); } + + /** The watch service. */ + private final WatchService watcher; + + /** The WatchKey -> Path mapping for registered directories. */ + private final Map keys; + + /** The Executor. */ + private ExecutorService service; + + /** Whether the watch service is paused (i.e. does not fire events). */ + private boolean paused = false; + + /** + * Creates the WatchService. + * @throws IOException if an I/O error occurs + */ + private BeatmapWatchService() throws IOException { + this.watcher = FileSystems.getDefault().newWatchService(); + this.keys = new ConcurrentHashMap(); + } + + /** + * Register the given directory with the WatchService. + * @param dir the directory to register + * @throws IOException if an I/O error occurs + */ + private void register(Path dir) throws IOException { + WatchKey key = dir.register(watcher, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + keys.put(key, dir); + } + + /** + * Register the given directory, and all its sub-directories, with the WatchService. + * @param start the root directory to register + */ + public void registerAll(final Path start) { + try { + Files.walkFileTree(start, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + try { + register(dir); + } catch (IOException e) { + Log.warn(String.format("Failed to register path '%s' with the watch service.", dir.toString()), e); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + Log.warn(String.format("Failed to register paths from root directory '%s' with the watch service.", start.toString()), e); + } + } + + @SuppressWarnings("unchecked") + private static WatchEvent cast(WatchEvent event) { + return (WatchEvent) event; + } + + /** + * Start processing events in a new thread. + */ + private void start() { + if (service != null) + return; + + this.service = Executors.newCachedThreadPool(); + service.submit(new Runnable() { + @Override + public void run() { ws.processEvents(); } + }); + } + + /** + * Process all events for keys queued to the watcher + */ + private void processEvents() { + while (true) { + // wait for key to be signaled + WatchKey key; + try { + key = watcher.take(); + } catch (InterruptedException | ClosedWatchServiceException e) { + return; + } + + Path dir = keys.get(key); + if (dir == null) + continue; + + boolean isPaused = paused; + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + if (kind == StandardWatchEventKinds.OVERFLOW) + continue; + + // context for directory entry event is the file name of entry + WatchEvent ev = cast(event); + Path name = ev.context(); + Path child = dir.resolve(name); + //System.out.printf("%s: %s\n", kind.name(), child); + + // fire listeners + if (!isPaused) { + for (BeatmapWatchServiceListener listener : listeners) + listener.eventReceived(kind, child); + } + + // if directory is created, then register it and its sub-directories + if (kind == StandardWatchEventKinds.ENTRY_CREATE) { + if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) + registerAll(child); + } + } + + // reset key and remove from set if directory no longer accessible + if (!key.reset()) { + keys.remove(key); + if (keys.isEmpty()) + break; // all directories are inaccessible + } + } + } + + /** + * Stops listener events from being fired. + */ + public void pause() { paused = true; } + + /** + * Resumes firing listener events. + */ + public void resume() { paused = false; } +} diff --git a/src/itdelatrisu/opsu/beatmap/HitObject.java b/src/itdelatrisu/opsu/beatmap/HitObject.java index f32997ce..522cf296 100644 --- a/src/itdelatrisu/opsu/beatmap/HitObject.java +++ b/src/itdelatrisu/opsu/beatmap/HitObject.java @@ -19,6 +19,11 @@ package itdelatrisu.opsu.beatmap; import itdelatrisu.opsu.GameMod; +import itdelatrisu.opsu.objects.curves.CatmullCurve; +import itdelatrisu.opsu.objects.curves.CircumscribedCircle; +import itdelatrisu.opsu.objects.curves.Curve; +import itdelatrisu.opsu.objects.curves.LinearBezier; +import itdelatrisu.opsu.objects.curves.Vec2f; import java.text.DecimalFormat; import java.text.NumberFormat; @@ -34,13 +39,6 @@ public class HitObject { 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, @@ -101,9 +99,18 @@ public class HitObject { /** Hit sound type (SOUND_* bitmask). */ private byte hitSound; - /** Hit sound addition (sampleSet, AdditionSampleSet, ?, ...). */ + /** Hit sound addition (sampleSet, AdditionSampleSet). */ private byte[] addition; + /** Addition custom sample index. */ + private byte additionCustomSampleIndex; + + /** Addition hit sound volume. */ + private int additionHitSoundVolume; + + /** Addition hit sound file. */ + private String additionHitSound; + /** Slider curve type (SLIDER_* constant). */ private char sliderType; @@ -250,9 +257,17 @@ public class HitObject { // 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]); + if (additionTokens.length > 1) { + this.addition = new byte[2]; + addition[0] = Byte.parseByte(additionTokens[0]); + addition[1] = Byte.parseByte(additionTokens[1]); + } + if (additionTokens.length > 2) + this.additionCustomSampleIndex = Byte.parseByte(additionTokens[2]); + if (additionTokens.length > 3) + this.additionHitSoundVolume = Integer.parseInt(additionTokens[3]); + if (additionTokens.length > 4) + this.additionHitSound = additionTokens[4]; } } @@ -298,13 +313,13 @@ public class HitObject { */ public String getTypeName() { if (isCircle()) - return CIRCLE; + return "circle"; else if (isSlider()) - return SLIDER; + return "slider"; else if (isSpinner()) - return SPINNER; + return "spinner"; else - return UNKNOWN; + return "unknown object type"; } /** @@ -386,6 +401,35 @@ public class HitObject { */ public float getPixelLength() { return pixelLength; } + /** + * Returns the time duration of the slider (excluding repeats), in milliseconds. + * @param sliderMultiplier the beatmap's slider movement speed multiplier + * @param beatLength the beat length + * @return the slider segment length + */ + public float getSliderTime(float sliderMultiplier, float beatLength) { + return beatLength * (pixelLength / sliderMultiplier) / 100f; + } + + /** + * Returns the slider curve. + * @param scaled whether to use scaled coordinates + * @return a new Curve instance + */ + public Curve getSliderCurve(boolean scaled) { + if (sliderType == SLIDER_PASSTHROUGH && sliderX.length == 2) { + Vec2f nora = new Vec2f(sliderX[0] - x, sliderY[0] - y).nor(); + Vec2f norb = new Vec2f(sliderX[0] - sliderX[1], sliderY[0] - sliderY[1]).nor(); + if (Math.abs(norb.x * nora.y - norb.y * nora.x) < 0.00001f) + return new LinearBezier(this, false, scaled); // vectors parallel, use linear bezier instead + else + return new CircumscribedCircle(this, scaled); + } else if (sliderType == SLIDER_CATMULL) + return new CatmullCurve(this, scaled); + else + return new LinearBezier(this, sliderType == SLIDER_LINEAR, scaled); + } + /** * Returns the spinner end time. * @return the end time (in ms) @@ -471,6 +515,21 @@ public class HitObject { return 0; } + /** + * Returns the custom sample index (addition). + */ + public byte getCustomSampleIndex() { return additionCustomSampleIndex; } + + /** + * Returns the hit sound volume (addition). + */ + public int getHitSoundVolume() { return additionHitSoundVolume; } + + /** + * Returns the hit sound file (addition). + */ + public String getHitSoundFile() { return additionHitSound; } + /** * Sets the hit object index in the current stack. * @param stack index in the stack @@ -529,9 +588,12 @@ public class HitObject { // addition if (addition != null) { for (int i = 0; i < addition.length; i++) { - sb.append(addition[i]); - sb.append(':'); + sb.append(addition[i]); sb.append(':'); } + sb.append(additionCustomSampleIndex); sb.append(':'); + sb.append(additionHitSoundVolume); sb.append(':'); + if (additionHitSound != null) + sb.append(additionHitSound); } else sb.setLength(sb.length() - 1); diff --git a/src/itdelatrisu/opsu/beatmap/ImageLoader.java b/src/itdelatrisu/opsu/beatmap/ImageLoader.java new file mode 100644 index 00000000..a6c3dcf6 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/ImageLoader.java @@ -0,0 +1,179 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.newdawn.slick.Image; +import org.newdawn.slick.SlickException; +import org.newdawn.slick.opengl.ImageData; +import org.newdawn.slick.opengl.ImageDataFactory; +import org.newdawn.slick.opengl.LoadableImageData; +import org.newdawn.slick.util.Log; + +/** + * Simple threaded image loader for a single image file. + */ +public class ImageLoader { + /** The image file. */ + private final File file; + + /** The loaded image. */ + private Image image; + + /** The image data. */ + private LoadedImageData data; + + /** The image loader thread. */ + private Thread loaderThread; + + /** ImageData wrapper, needed because {@code ImageIOImageData} doesn't implement {@code getImageBufferData()}. */ + private class LoadedImageData implements ImageData { + /** The image data implementation. */ + private final ImageData imageData; + + /** The stored image. */ + private final ByteBuffer buffer; + + /** + * Constructor. + * @param imageData the class holding the image properties + * @param buffer the stored image + */ + public LoadedImageData(ImageData imageData, ByteBuffer buffer) { + this.imageData = imageData; + this.buffer = buffer; + } + + @Override public int getDepth() { return imageData.getDepth(); } + @Override public int getWidth() { return imageData.getWidth(); } + @Override public int getHeight() { return imageData.getHeight();} + @Override public int getTexWidth() { return imageData.getTexWidth(); } + @Override public int getTexHeight() { return imageData.getTexHeight(); } + @Override public ByteBuffer getImageBufferData() { return buffer; } + } + + /** Image loading thread. */ + private class ImageLoaderThread extends Thread { + /** The image file input stream. */ + private BufferedInputStream in; + + @Override + public void interrupt() { + super.interrupt(); + if (in != null) { + try { + in.close(); // interrupt I/O + } catch (IOException e) {} + } + } + + @Override + public void run() { + // load image data into a ByteBuffer to use constructor Image(ImageData) + LoadableImageData imageData = ImageDataFactory.getImageDataFor(file.getAbsolutePath()); + try (BufferedInputStream in = this.in = new BufferedInputStream(new FileInputStream(file))) { + ByteBuffer textureBuffer = imageData.loadImage(in, false, null); + if (!isInterrupted()) + data = new LoadedImageData(imageData, textureBuffer); + } catch (IOException e) { + if (!isInterrupted()) + Log.warn(String.format("Failed to load background image '%s'.", file), e); + } + this.in = null; + } + } + + /** + * Constructor. Call {@link ImageLoader#load(boolean)} to load the image. + * @param file the image file + */ + public ImageLoader(File file) { + this.file = file; + } + + /** + * Loads the image. + * @param threaded true to load the image data in a new thread + */ + public void load(boolean threaded) { + if (!file.isFile()) + return; + + if (threaded) { + if (loaderThread != null && loaderThread.isAlive()) + loaderThread.interrupt(); + loaderThread = new ImageLoaderThread(); + loaderThread.start(); + } else { + try { + image = new Image(file.getAbsolutePath()); + } catch (SlickException e) { + Log.warn(String.format("Failed to load background image '%s'.", file), e); + } + } + } + + /** + * Returns the image. + * @return the loaded image, or null if not loaded + */ + public Image getImage() { + if (image == null && data != null) { + image = new Image(data); + data = null; + } + return image; + } + + /** + * Returns whether an image is currently loading in another thread. + * @return true if loading, false otherwise + */ + public boolean isLoading() { return (loaderThread != null && loaderThread.isAlive()); } + + /** + * Interrupts the image loader, if running. + */ + public void interrupt() { + if (isLoading()) + loaderThread.interrupt(); + } + + /** + * Releases all resources. + */ + public void destroy() { + interrupt(); + loaderThread = null; + if (image != null && !image.isDestroyed()) { + try { + image.destroy(); + } catch (SlickException e) { + Log.warn(String.format("Failed to destroy image '%s'.", image.getResourceReference()), e); + } + image = null; + } + data = null; + } +} diff --git a/src/itdelatrisu/opsu/beatmap/LRUCache.java b/src/itdelatrisu/opsu/beatmap/LRUCache.java new file mode 100644 index 00000000..abd2b4b8 --- /dev/null +++ b/src/itdelatrisu/opsu/beatmap/LRUCache.java @@ -0,0 +1,59 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.beatmap; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Least recently used cache. + * + * @param the type of keys maintained by this map + * @param the type of mapped values + */ +@SuppressWarnings("serial") +public class LRUCache extends LinkedHashMap { + /** The cache capacity. */ + private final int capacity; + + /** + * Creates a least recently used cache with the given capacity. + * @param capacity the capacity + */ + public LRUCache(int capacity) { + super(capacity + 1, 1.1f, true); + this.capacity = capacity; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() > capacity) { + eldestRemoved(eldest); + return true; + } + return false; + } + + /** + * Notification that the eldest entry was removed. + * Can be used to clean up any resources when this happens (via override). + * @param eldest the removed entry + */ + public void eldestRemoved(Map.Entry eldest) {} +} diff --git a/src/itdelatrisu/opsu/OszUnpacker.java b/src/itdelatrisu/opsu/beatmap/OszUnpacker.java similarity index 92% rename from src/itdelatrisu/opsu/OszUnpacker.java rename to src/itdelatrisu/opsu/beatmap/OszUnpacker.java index b8bf1460..5b9c1071 100644 --- a/src/itdelatrisu/opsu/OszUnpacker.java +++ b/src/itdelatrisu/opsu/beatmap/OszUnpacker.java @@ -16,7 +16,10 @@ * along with opsu!. If not, see . */ -package itdelatrisu.opsu; +package itdelatrisu.opsu.beatmap; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Options; import java.io.File; import java.io.FilenameFilter; @@ -62,6 +65,9 @@ public class OszUnpacker { } // unpack OSZs + BeatmapWatchService ws = (Options.isWatchServiceEnabled()) ? BeatmapWatchService.get() : null; + if (ws != null) + ws.pause(); for (File file : files) { fileIndex++; String dirName = file.getName().substring(0, file.getName().lastIndexOf('.')); @@ -73,6 +79,8 @@ public class OszUnpacker { dirs.add(songDir); } } + if (ws != null) + ws.resume(); fileIndex = -1; files = null; diff --git a/src/itdelatrisu/opsu/db/BeatmapDB.java b/src/itdelatrisu/opsu/db/BeatmapDB.java index 8d47669c..5d54ab4d 100644 --- a/src/itdelatrisu/opsu/db/BeatmapDB.java +++ b/src/itdelatrisu/opsu/db/BeatmapDB.java @@ -43,7 +43,7 @@ public class BeatmapDB { * Current database version. * This value should be changed whenever the database format changes. */ - private static final String DATABASE_VERSION = "2015-06-11"; + private static final String DATABASE_VERSION = "2015-09-02"; /** Minimum batch size ratio ({@code batchSize/cacheSize}) to invoke batch loading. */ private static final float LOAD_BATCH_MIN_RATIO = 0.2f; @@ -58,7 +58,7 @@ public class BeatmapDB { private static Connection connection; /** Query statements. */ - private static PreparedStatement insertStmt, selectStmt, deleteMapStmt, deleteGroupStmt, updateSizeStmt; + private static PreparedStatement insertStmt, selectStmt, deleteMapStmt, deleteGroupStmt, setStarsStmt, updateSizeStmt; /** Current size of beatmap cache table. */ private static int cacheSize = -1; @@ -95,12 +95,13 @@ public class BeatmapDB { try { insertStmt = connection.prepareStatement( "INSERT INTO beatmaps VALUES (" + - "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ); selectStmt = connection.prepareStatement("SELECT * FROM beatmaps WHERE dir = ? AND file = ?"); deleteMapStmt = connection.prepareStatement("DELETE FROM beatmaps WHERE dir = ? AND file = ?"); deleteGroupStmt = connection.prepareStatement("DELETE FROM beatmaps WHERE dir = ?"); + setStarsStmt = connection.prepareStatement("UPDATE beatmaps SET stars = ? WHERE dir = ? AND file = ?"); } catch (SQLException e) { ErrorHandler.error("Failed to prepare beatmap statements.", e, true); } @@ -123,7 +124,7 @@ public class BeatmapDB { "audioFile TEXT, audioLeadIn INTEGER, previewTime INTEGER, countdown INTEGER, sampleSet TEXT, stackLeniency REAL, " + "mode INTEGER, letterboxInBreaks BOOLEAN, widescreenStoryboard BOOLEAN, epilepsyWarning BOOLEAN, " + "bg TEXT, sliderBorder TEXT, timingPoints TEXT, breaks TEXT, combo TEXT, " + - "md5hash TEXT" + + "md5hash TEXT, stars REAL" + "); " + "CREATE TABLE IF NOT EXISTS info (" + "key TEXT NOT NULL UNIQUE, value TEXT" + @@ -342,6 +343,7 @@ public class BeatmapDB { stmt.setString(39, beatmap.breaksToString()); stmt.setString(40, beatmap.comboToString()); stmt.setString(41, beatmap.md5Hash); + stmt.setDouble(42, beatmap.starRating); } catch (SQLException e) { throw e; } catch (Exception e) { @@ -484,6 +486,7 @@ public class BeatmapDB { beatmap.bg = new File(dir, BeatmapParser.getDBString(bg)); beatmap.sliderBorderFromString(rs.getString(37)); beatmap.md5Hash = rs.getString(41); + beatmap.starRating = rs.getDouble(42); } catch (SQLException e) { throw e; } catch (Exception e) { @@ -571,6 +574,25 @@ public class BeatmapDB { } } + /** + * Sets the star rating for a beatmap in the database. + * @param beatmap the beatmap + */ + public static void setStars(Beatmap beatmap) { + if (connection == null) + return; + + try { + setStarsStmt.setDouble(1, beatmap.starRating); + setStarsStmt.setString(2, beatmap.getFile().getParentFile().getName()); + setStarsStmt.setString(3, beatmap.getFile().getName()); + setStarsStmt.executeUpdate(); + } catch (SQLException e) { + ErrorHandler.error(String.format("Failed to save star rating '%.4f' for beatmap '%s' in database.", + beatmap.starRating, beatmap.toString()), e, true); + } + } + /** * Closes the connection to the database. */ diff --git a/src/itdelatrisu/opsu/db/ScoreDB.java b/src/itdelatrisu/opsu/db/ScoreDB.java index 42ffe50d..34891987 100644 --- a/src/itdelatrisu/opsu/db/ScoreDB.java +++ b/src/itdelatrisu/opsu/db/ScoreDB.java @@ -119,7 +119,9 @@ public class ScoreDB { "timestamp = ? AND MID = ? AND MSID = ? AND title = ? AND artist = ? AND " + "creator = ? AND version = ? AND hit300 = ? AND hit100 = ? AND hit50 = ? AND " + "geki = ? AND katu = ? AND miss = ? AND score = ? AND combo = ? AND perfect = ? AND mods = ? AND " + - "replay = ? AND playerName = ?" + "(replay = ? OR (replay IS NULL AND ? IS NULL)) AND " + + "(playerName = ? OR (playerName IS NULL AND ? IS NULL))" + // TODO: extra playerName checks not needed if name is guaranteed not null ); } catch (SQLException e) { ErrorHandler.error("Failed to prepare score statements.", e, true); @@ -222,6 +224,7 @@ public class ScoreDB { try { setStatementFields(insertStmt, data); insertStmt.setString(18, data.replayString); + insertStmt.setString(19, data.playerName); insertStmt.executeUpdate(); } catch (SQLException e) { ErrorHandler.error("Failed to save score to database.", e, true); @@ -238,6 +241,10 @@ public class ScoreDB { try { setStatementFields(deleteScoreStmt, data); + deleteScoreStmt.setString(18, data.replayString); + deleteScoreStmt.setString(19, data.replayString); + deleteScoreStmt.setString(20, data.playerName); + deleteScoreStmt.setString(21, data.playerName); deleteScoreStmt.executeUpdate(); } catch (SQLException e) { ErrorHandler.error("Failed to delete score from database.", e, true); @@ -289,8 +296,6 @@ public class ScoreDB { stmt.setInt(15, data.combo); stmt.setBoolean(16, data.perfect); stmt.setInt(17, data.mods); - stmt.setString(18, data.replayString); - stmt.setString(19, data.playerName); } /** diff --git a/src/itdelatrisu/opsu/downloads/Download.java b/src/itdelatrisu/opsu/downloads/Download.java index 6e1d0973..09ebe292 100644 --- a/src/itdelatrisu/opsu/downloads/Download.java +++ b/src/itdelatrisu/opsu/downloads/Download.java @@ -46,6 +46,9 @@ public class Download { /** Read timeout, in ms. */ public static final int READ_TIMEOUT = 10000; + /** Maximum number of HTTP/HTTPS redirects to follow. */ + public static final int MAX_REDIRECTS = 3; + /** Time between download speed and ETA updates, in ms. */ private static final int UPDATE_INTERVAL = 1000; @@ -58,7 +61,7 @@ public class Download { ERROR ("Error"); /** The status name. */ - private String name; + private final String name; /** * Constructor. @@ -172,13 +175,57 @@ public class Download { new Thread() { @Override public void run() { - // open connection, get content length + // open connection HttpURLConnection conn = null; try { - conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(CONNECTION_TIMEOUT); - conn.setReadTimeout(READ_TIMEOUT); - conn.setUseCaches(false); + URL downloadURL = url; + int redirectCount = 0; + boolean isRedirect = false; + do { + isRedirect = false; + + conn = (HttpURLConnection) downloadURL.openConnection(); + conn.setConnectTimeout(CONNECTION_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setUseCaches(false); + + // allow HTTP <--> HTTPS redirects + // http://download.java.net/jdk7u2/docs/technotes/guides/deployment/deployment-guide/upgrade-guide/article-17.html + conn.setInstanceFollowRedirects(false); + conn.setRequestProperty("User-Agent", "Mozilla/5.0..."); + + // check for redirect + int status = conn.getResponseCode(); + if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || + status == HttpURLConnection.HTTP_SEE_OTHER || status == HttpURLConnection.HTTP_USE_PROXY) { + URL base = conn.getURL(); + String location = conn.getHeaderField("Location"); + URL target = null; + if (location != null) + target = new URL(base, location); + conn.disconnect(); + + // check for problems + String error = null; + if (location == null) + error = String.format("Download for URL '%s' is attempting to redirect without a 'location' header.", base.toString()); + else if (!target.getProtocol().equals("http") && !target.getProtocol().equals("https")) + error = String.format("Download for URL '%s' is attempting to redirect to a non-HTTP/HTTPS protocol '%s'.", base.toString(), target.getProtocol()); + else if (redirectCount > MAX_REDIRECTS) + error = String.format("Download for URL '%s' is attempting too many redirects (over %d).", base.toString(), MAX_REDIRECTS); + if (error != null) { + ErrorHandler.error(error, null, false); + throw new IOException(); + } + + // follow redirect + downloadURL = target; + redirectCount++; + isRedirect = true; + } + } while (isRedirect); + + // store content length contentLength = conn.getContentLength(); } catch (IOException e) { status = Status.ERROR; @@ -198,9 +245,18 @@ public class Download { fos = fileOutputStream; status = Status.DOWNLOADING; updateReadSoFar(); - fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + long bytesRead = fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); if (status == Status.DOWNLOADING) { // not interrupted - // TODO: if connection is lost before a download finishes, it's still marked as "complete" + // check if the entire file was received + if (bytesRead < contentLength) { + status = Status.ERROR; + Log.warn(String.format("Download '%s' failed: %d bytes expected, %d bytes received.", url.toString(), contentLength, bytesRead)); + if (listener != null) + listener.error(); + return; + } + + // mark download as complete status = Status.COMPLETE; rbc.close(); fos.close(); @@ -273,7 +329,7 @@ public class Download { public long readSoFar() { switch (status) { case COMPLETE: - return contentLength; + return (rbc != null) ? rbc.getReadSoFar() : contentLength; case DOWNLOADING: if (rbc != null) return rbc.getReadSoFar(); diff --git a/src/itdelatrisu/opsu/downloads/DownloadList.java b/src/itdelatrisu/opsu/downloads/DownloadList.java index 2c396c9b..dfeac328 100644 --- a/src/itdelatrisu/opsu/downloads/DownloadList.java +++ b/src/itdelatrisu/opsu/downloads/DownloadList.java @@ -75,7 +75,7 @@ public class DownloadList { } /** - * Returns the size of the doownloads list. + * Returns the size of the downloads list. */ public int size() { return nodes.size(); } diff --git a/src/itdelatrisu/opsu/downloads/DownloadNode.java b/src/itdelatrisu/opsu/downloads/DownloadNode.java index 9632462a..70369696 100644 --- a/src/itdelatrisu/opsu/downloads/DownloadNode.java +++ b/src/itdelatrisu/opsu/downloads/DownloadNode.java @@ -26,6 +26,8 @@ import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.downloads.Download.DownloadListener; import itdelatrisu.opsu.downloads.Download.Status; import itdelatrisu.opsu.downloads.servers.DownloadServer; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.UI; import java.io.File; @@ -42,19 +44,19 @@ public class DownloadNode { private Download download; /** Beatmap set ID. */ - private int beatmapSetID; + private final int beatmapSetID; /** Last updated date string. */ - private String date; + private final String date; /** Song title. */ - private String title, titleUnicode; + private final String title, titleUnicode; /** Song artist. */ - private String artist, artistUnicode; + private final String artist, artistUnicode; /** Beatmap creator. */ - private String creator; + private final String creator; /** Button drawing values. */ private static float buttonBaseX, buttonBaseY, buttonWidth, buttonHeight, buttonOffset; @@ -68,12 +70,6 @@ public class DownloadNode { /** Container width. */ private static int containerWidth; - /** Button background colors. */ - public static final Color - BG_NORMAL = new Color(0, 0, 0, 0.25f), - BG_HOVER = new Color(0, 0, 0, 0.5f), - BG_FOCUS = new Color(0, 0, 0, 0.75f); - /** * Initializes the base coordinates for drawing. * @param width the container width @@ -86,16 +82,16 @@ public class DownloadNode { buttonBaseX = width * 0.024f; buttonBaseY = height * 0.2f; buttonWidth = width * 0.7f; - buttonHeight = Utils.FONT_MEDIUM.getLineHeight() * 2.1f; + buttonHeight = Fonts.MEDIUM.getLineHeight() * 2.1f; buttonOffset = buttonHeight * 1.1f; // download info infoBaseX = width * 0.75f; - infoBaseY = height * 0.07f + Utils.FONT_LARGE.getLineHeight() * 2f; + infoBaseY = height * 0.07f + Fonts.LARGE.getLineHeight() * 2f; infoWidth = width * 0.25f; - infoHeight = Utils.FONT_DEFAULT.getLineHeight() * 2.4f; + infoHeight = Fonts.DEFAULT.getLineHeight() * 2.4f; - float searchY = (height * 0.05f) + Utils.FONT_LARGE.getLineHeight(); + float searchY = (height * 0.05f) + Fonts.LARGE.getLineHeight(); float buttonHeight = height * 0.038f; maxResultsShown = (int) ((height - buttonBaseY - searchY) / buttonOffset); maxDownloadsShown = (int) ((height - infoBaseY - searchY - buttonHeight) / infoHeight); @@ -228,10 +224,9 @@ public class DownloadNode { * @param total the total number of buttons */ public static void drawResultScrollbar(Graphics g, float position, float total) { - UI.drawScrollbar(g, position, total, maxResultsShown * buttonOffset, - buttonBaseX, buttonBaseY, + UI.drawScrollbar(g, position, total, maxResultsShown * buttonOffset, buttonBaseX, buttonBaseY, buttonWidth * 1.01f, (maxResultsShown-1) * buttonOffset + buttonHeight, - BG_NORMAL, Color.white, true); + Colors.BLACK_BG_NORMAL, Color.white, true); } /** @@ -242,11 +237,18 @@ public class DownloadNode { */ public static void drawDownloadScrollbar(Graphics g, float index, float total) { UI.drawScrollbar(g, index, total, maxDownloadsShown * infoHeight, infoBaseX, infoBaseY, - infoWidth, maxDownloadsShown * infoHeight, BG_NORMAL, Color.white, true); + infoWidth, maxDownloadsShown * infoHeight, Colors.BLACK_BG_NORMAL, Color.white, true); } /** * Constructor. + * @param beatmapSetID the beatmap set ID + * @param date the last modified date string + * @param title the song title + * @param titleUnicode the Unicode song title (or {@code null} if none) + * @param artist the song artist + * @param artistUnicode the Unicode song artist (or {@code null} if none) + * @param creator the beatmap creator */ public DownloadNode(int beatmapSetID, String date, String title, String titleUnicode, String artist, String artistUnicode, String creator) { @@ -273,7 +275,7 @@ public class DownloadNode { return; String path = String.format("%s%c%d", Options.getOSZDir(), File.separatorChar, beatmapSetID); String rename = String.format("%d %s - %s.osz", beatmapSetID, artist, title); - this.download = new Download(url, path, rename); + Download download = new Download(url, path, rename); download.setListener(new DownloadListener() { @Override public void completed() { @@ -285,8 +287,9 @@ public class DownloadNode { UI.sendBarNotification("Download failed due to a connection error."); } }); + this.download = download; if (Options.useUnicodeMetadata()) // load glyphs - Utils.loadGlyphs(Utils.FONT_LARGE, getTitle(), null); + Fonts.loadGlyphs(Fonts.LARGE, getTitle()); } /** @@ -348,12 +351,12 @@ public class DownloadNode { Download dl = DownloadList.get().getDownload(beatmapSetID); // rectangle outline - g.setColor((focus) ? BG_FOCUS : (hover) ? BG_HOVER : BG_NORMAL); + g.setColor((focus) ? Colors.BLACK_BG_FOCUS : (hover) ? Colors.BLACK_BG_HOVER : Colors.BLACK_BG_NORMAL); g.fillRect(buttonBaseX, y, buttonWidth, buttonHeight); // map is already loaded if (BeatmapSetList.get().containsBeatmapSetID(beatmapSetID)) { - g.setColor(Utils.COLOR_BLUE_BUTTON); + g.setColor(Colors.BLUE_BUTTON); g.fillRect(buttonBaseX, y, buttonWidth, buttonHeight); } @@ -361,7 +364,7 @@ public class DownloadNode { if (dl != null) { float progress = dl.getProgress(); if (progress > 0f) { - g.setColor(Utils.COLOR_GREEN); + g.setColor(Colors.GREEN); g.fillRect(buttonBaseX, y, buttonWidth * progress / 100f, buttonHeight); } } @@ -373,21 +376,22 @@ public class DownloadNode { // text // TODO: if the title/artist line is too long, shorten it (e.g. add "...") instead of just clipping - if (Options.useUnicodeMetadata()) // load glyphs - Utils.loadGlyphs(Utils.FONT_BOLD, getTitle(), getArtist()); - + if (Options.useUnicodeMetadata()) { // load glyphs + Fonts.loadGlyphs(Fonts.BOLD, getTitle()); + Fonts.loadGlyphs(Fonts.BOLD, getArtist()); + } // TODO can't set clip again or else old clip will be cleared - //g.setClip((int) textX, (int) (y + marginY), (int) (edgeX - textX - Utils.FONT_DEFAULT.getWidth(creator)), Utils.FONT_BOLD.getLineHeight()); - Utils.FONT_BOLD.drawString( + //g.setClip((int) textX, (int) (y + marginY), (int) (edgeX - textX - Fonts.DEFAULT.getWidth(creator)), Fonts.BOLD.getLineHeight()); + Fonts.BOLD.drawString( textX, y + marginY, String.format("%s - %s%s", getArtist(), getTitle(), (dl != null) ? String.format(" [%s]", dl.getStatus().getName()) : ""), Color.white); //g.clearClip(); - Utils.FONT_DEFAULT.drawString( - textX, y + marginY + Utils.FONT_BOLD.getLineHeight(), + Fonts.DEFAULT.drawString( + textX, y + marginY + Fonts.BOLD.getLineHeight(), String.format("Last updated: %s", date), Color.white); - Utils.FONT_DEFAULT.drawString( - edgeX - Utils.FONT_DEFAULT.getWidth(creator), y + marginY, + Fonts.DEFAULT.drawString( + edgeX - Fonts.DEFAULT.getWidth(creator), y + marginY, creator, Color.white); } @@ -399,6 +403,7 @@ public class DownloadNode { * @param hover true if the mouse is hovering over this button */ public void drawDownload(Graphics g, float position, int id, boolean hover) { + Download download = this.download; // in case clearDownload() is called asynchronously if (download == null) { ErrorHandler.error("Trying to draw download information for button without Download object.", null, false); return; @@ -410,7 +415,7 @@ public class DownloadNode { float marginY = infoHeight * 0.04f; // rectangle outline - g.setColor((id % 2 == 0) ? BG_HOVER : BG_NORMAL); + g.setColor((id % 2 == 0) ? Colors.BLACK_BG_HOVER : Colors.BLACK_BG_NORMAL); g.fillRect(infoBaseX, y, infoWidth, infoHeight); // text @@ -428,8 +433,8 @@ public class DownloadNode { info = String.format("%s: %.1f%% (%s/%s)", status.getName(), progress, Utils.bytesToString(download.readSoFar()), Utils.bytesToString(download.contentLength())); } - Utils.FONT_BOLD.drawString(textX, y + marginY, getTitle(), Color.white); - Utils.FONT_DEFAULT.drawString(textX, y + marginY + Utils.FONT_BOLD.getLineHeight(), info, Color.white); + Fonts.BOLD.drawString(textX, y + marginY, getTitle(), Color.white); + Fonts.DEFAULT.drawString(textX, y + marginY + Fonts.BOLD.getLineHeight(), info, Color.white); // 'x' button if (hover) { diff --git a/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java b/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java index 786b2c5d..cb1a86d1 100644 --- a/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java +++ b/src/itdelatrisu/opsu/downloads/ReadableByteChannelWrapper.java @@ -28,7 +28,7 @@ import java.nio.channels.ReadableByteChannel; */ public class ReadableByteChannelWrapper implements ReadableByteChannel { /** The wrapped ReadableByteChannel. */ - private ReadableByteChannel rbc; + private final ReadableByteChannel rbc; /** The number of bytes read. */ private long bytesRead; diff --git a/src/itdelatrisu/opsu/downloads/Updater.java b/src/itdelatrisu/opsu/downloads/Updater.java index 366fb623..165d810a 100644 --- a/src/itdelatrisu/opsu/downloads/Updater.java +++ b/src/itdelatrisu/opsu/downloads/Updater.java @@ -77,7 +77,7 @@ public class Updater { UPDATE_FINAL ("Update queued."); /** The status description. */ - private String description; + private final String description; /** * Constructor. @@ -194,9 +194,10 @@ public class Updater { /** * Checks the program version against the version file on the update server. + * @throws IOException if an I/O exception occurs */ public void checkForUpdates() throws IOException { - if (status != Status.INITIAL || System.getProperty("XDG") != null) + if (status != Status.INITIAL || Options.USE_XDG) return; status = Status.CHECKING; diff --git a/src/itdelatrisu/opsu/downloads/servers/DownloadServer.java b/src/itdelatrisu/opsu/downloads/servers/DownloadServer.java index 24cca1f5..2e6834a8 100644 --- a/src/itdelatrisu/opsu/downloads/servers/DownloadServer.java +++ b/src/itdelatrisu/opsu/downloads/servers/DownloadServer.java @@ -75,4 +75,7 @@ public abstract class DownloadServer { public String getPreviewURL(int beatmapSetID) { return String.format(PREVIEW_URL, beatmapSetID); } + + @Override + public String toString() { return getName(); } } diff --git a/src/itdelatrisu/opsu/downloads/servers/MengSkyServer.java b/src/itdelatrisu/opsu/downloads/servers/MengSkyServer.java new file mode 100644 index 00000000..ce4647be --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/servers/MengSkyServer.java @@ -0,0 +1,202 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads.servers; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.DownloadNode; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/** + * Download server: http://osu.mengsky.net/ + */ +public class MengSkyServer extends DownloadServer { + /** Server name. */ + private static final String SERVER_NAME = "MengSky"; + + /** Formatted download URL: {@code beatmapSetID} */ + private static final String DOWNLOAD_URL = "http://osu.mengsky.net/d.php?id=%d"; + + /** Formatted search URL: {@code query} */ + private static final String SEARCH_URL = "http://osu.mengsky.net/index.php?search_keywords=%s"; + + /** Formatted home URL: {@code page} */ + private static final String HOME_URL = "http://osu.mengsky.net/index.php?next=1&page=%d"; + + /** Maximum beatmaps displayed per page. */ + private static final int PAGE_LIMIT = 20; + + /** Total result count from the last query. */ + private int totalResults = -1; + + /** Constructor. */ + public MengSkyServer() {} + + @Override + public String getName() { return SERVER_NAME; } + + @Override + public String getDownloadURL(int beatmapSetID) { + return String.format(DOWNLOAD_URL, beatmapSetID); + } + + @Override + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException { + DownloadNode[] nodes = null; + try { + // read HTML + String search; + boolean isSearch; + if (query.isEmpty()) { + isSearch = false; + search = String.format(HOME_URL, page - 1); + } else { + isSearch = true; + search = String.format(SEARCH_URL, URLEncoder.encode(query, "UTF-8")); + } + String html = Utils.readDataFromUrl(new URL(search)); + if (html == null) { + this.totalResults = -1; + return null; + } + + // parse results + // NOTE: Maybe an HTML parser would be better for this... + // FORMAT: + //
+ //
+ // + //
+ // Creator: {{creator}}
+ // MaxBpm: {{bpm}}
+ // Title: {{titleUnicode}}
+ // Artist: {{artistUnicode}}
+ // Status: {{"Ranked?" || "Unranked"}}
+ //
+ //

+ // Fork: bloodcat
+ // UpdateTime: {{yyyy}}/{{mm}}/{{dd}} {{hh}}:{{mm}}:{{ss}}
+ // Mode: {{...}} + //
+ //
+ // Osu.ppy + //
+ //
+ // DownLoad + //
+ //
+ List nodeList = new ArrayList(); + final String + START_TAG = "
", + CREATOR_TAG = "Creator: ", TITLE_TAG = "Title: ", ARTIST_TAG = "Artist: ", + TIMESTAMP_TAG = "UpdateTime: ", DOWNLOAD_TAG = "
", + BR_TAG = "
", HREF_TAG = "
n) continue; + j = html.indexOf(HREF_TAG_END, i + 1); + if (j == -1 || j > n) continue; + String beatmap = html.substring(i + NAME_TAG.length(), j); + String[] beatmapTokens = beatmap.split(" - ", 2); + if (beatmapTokens.length < 2) + continue; + String artist = beatmapTokens[0]; + String title = beatmapTokens[1]; + + // find other beatmap details + i = html.indexOf(CREATOR_TAG, j + HREF_TAG_END.length()); + if (i == -1 || i > n) continue; + j = html.indexOf(BR_TAG, i + CREATOR_TAG.length()); + if (j == -1 || j > n) continue; + String creator = html.substring(i + CREATOR_TAG.length(), j); + i = html.indexOf(TITLE_TAG, j + BR_TAG.length()); + if (i == -1 || i > n) continue; + j = html.indexOf(BR_TAG, i + TITLE_TAG.length()); + if (j == -1 || j > n) continue; + String titleUnicode = html.substring(i + TITLE_TAG.length(), j); + i = html.indexOf(ARTIST_TAG, j + BR_TAG.length()); + if (i == -1 || i > n) continue; + j = html.indexOf(BR_TAG, i + ARTIST_TAG.length()); + if (j == -1 || j > n) continue; + String artistUnicode = html.substring(i + ARTIST_TAG.length(), j); + i = html.indexOf(TIMESTAMP_TAG, j + BR_TAG.length()); + if (i == -1 || i >= n) continue; + j = html.indexOf(BR_TAG, i + TIMESTAMP_TAG.length()); + if (j == -1 || j > n) continue; + String date = html.substring(i + TIMESTAMP_TAG.length(), j); + + // find beatmap ID + i = html.indexOf(DOWNLOAD_TAG, j + BR_TAG.length()); + if (i == -1 || i >= n) continue; + i = html.indexOf(HREF_TAG, i + DOWNLOAD_TAG.length()); + if (i == -1 || i > n) continue; + j = html.indexOf('"', i + HREF_TAG.length()); + if (j == -1 || j > n) continue; + String downloadURL = html.substring(i + HREF_TAG.length(), j); + String[] downloadTokens = downloadURL.split("(?=\\d*$)", 2); + if (downloadTokens[1].isEmpty()) continue; + int id; + try { + id = Integer.parseInt(downloadTokens[1]); + } catch (NumberFormatException e) { + continue; + } + + nodeList.add(new DownloadNode(id, date, title, titleUnicode, artist, artistUnicode, creator)); + } + + nodes = nodeList.toArray(new DownloadNode[nodeList.size()]); + + // store total result count + if (isSearch) + this.totalResults = nodes.length; + else { + int resultCount = nodes.length + (page - 1) * PAGE_LIMIT; + if (divCount == PAGE_LIMIT) + resultCount++; + this.totalResults = resultCount; + } + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem loading result list for query '%s'.", query), e, true); + } + return nodes; + } + + @Override + public int minQueryLength() { return 2; } + + @Override + public int totalResults() { return totalResults; } +} diff --git a/src/itdelatrisu/opsu/downloads/servers/MnetworkServer.java b/src/itdelatrisu/opsu/downloads/servers/MnetworkServer.java new file mode 100644 index 00000000..4341512a --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/servers/MnetworkServer.java @@ -0,0 +1,133 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.downloads.servers; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.DownloadNode; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Download server: http://osu.uu.gl/ + */ +public class MnetworkServer extends DownloadServer { + /** Server name. */ + private static final String SERVER_NAME = "Mnetwork"; + + /** Formatted download URL: {@code beatmapSetID} */ + private static final String DOWNLOAD_URL = "http://osu.uu.gl/s/%d"; + + /** Formatted search URL: {@code query} */ + private static final String SEARCH_URL = "http://osu.uu.gl/d/%s"; + + /** Total result count from the last query. */ + private int totalResults = -1; + + /** Beatmap pattern. */ + private Pattern BEATMAP_PATTERN = Pattern.compile("^(\\d+) ([^-]+) - (.+)\\.osz$"); + + /** Constructor. */ + public MnetworkServer() {} + + @Override + public String getName() { return SERVER_NAME; } + + @Override + public String getDownloadURL(int beatmapSetID) { + return String.format(DOWNLOAD_URL, beatmapSetID); + } + + @Override + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException { + DownloadNode[] nodes = null; + try { + // read HTML + String queryString = (query.isEmpty()) ? "-" : query; + String search = String.format(SEARCH_URL, URLEncoder.encode(queryString, "UTF-8")); + String html = Utils.readDataFromUrl(new URL(search)); + if (html == null) { + this.totalResults = -1; + return null; + } + + // parse results + // NOTE: Not using a full HTML parser because this is a relatively simple operation. + // FORMAT: + //
+ // {{id}} {{artist}} - {{title}}.osz
+ // BPM: {{bpm}} | Total Time: {{m}}:{{s}}
+ // Genre: {{genre}} | Updated: {{MMM}} {{d}}, {{yyyy}}
+ List nodeList = new ArrayList(); + final String START_TAG = "
", HREF_TAG = "", UPDATED = "Updated: "; + int index = -1; + int nextIndex = html.indexOf(START_TAG, index + 1); + while ((index = nextIndex) != -1) { + nextIndex = html.indexOf(START_TAG, index + 1); + int n = (nextIndex == -1) ? html.length() : nextIndex; + int i, j; + + // find beatmap + i = html.indexOf(HREF_TAG, index + START_TAG.length()); + if (i == -1 || i > n) continue; + i = html.indexOf('>', i + HREF_TAG.length()); + if (i == -1 || i >= n) continue; + j = html.indexOf(HREF_TAG_END, i + 1); + if (j == -1 || j > n) continue; + String beatmap = html.substring(i + 1, j).trim(); + + // find date + i = html.indexOf(UPDATED, j); + if (i == -1 || i >= n) continue; + j = html.indexOf('<', i + UPDATED.length()); + if (j == -1 || j > n) continue; + String date = html.substring(i + UPDATED.length(), j).trim(); + + // parse id, title, and artist + Matcher m = BEATMAP_PATTERN.matcher(beatmap); + if (!m.matches()) + continue; + + nodeList.add(new DownloadNode(Integer.parseInt(m.group(1)), date, m.group(3), null, m.group(2), null, "")); + } + + nodes = nodeList.toArray(new DownloadNode[nodeList.size()]); + + // store total result count + this.totalResults = nodes.length; + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem loading result list for query '%s'.", query), e, true); + } + return nodes; + } + + @Override + public int minQueryLength() { return 0; } + + @Override + public int totalResults() { return totalResults; } +} diff --git a/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java b/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java index d7179664..834f2b9f 100644 --- a/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java +++ b/src/itdelatrisu/opsu/downloads/servers/OsuMirrorServer.java @@ -39,6 +39,8 @@ import org.json.JSONObject; /** * Download server: http://loli.al/ + *

+ * This server went offline in August 2015. */ public class OsuMirrorServer extends DownloadServer { /** Server name. */ diff --git a/src/itdelatrisu/opsu/downloads/servers/YaSOnlineServer.java b/src/itdelatrisu/opsu/downloads/servers/YaSOnlineServer.java new file mode 100644 index 00000000..95846bfd --- /dev/null +++ b/src/itdelatrisu/opsu/downloads/servers/YaSOnlineServer.java @@ -0,0 +1,204 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ +package itdelatrisu.opsu.downloads.servers; + +import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.downloads.DownloadNode; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; +import java.util.List; + +import org.json.JSONObject; + +/** + * Download server: http://osu.yas-online.net/ + */ +public class YaSOnlineServer extends DownloadServer { + /** Server name. */ + private static final String SERVER_NAME = "YaS Online"; + + /** Formatted download URL (returns JSON): {@code beatmapSetID} */ + private static final String DOWNLOAD_URL = "https://osu.yas-online.net/json.mapdata.php?mapId=%d"; + + /** + * Formatted download fetch URL: {@code downloadLink} + * (e.g. {@code /fetch/49125122158ef360a66a07bce2d0483596913843-m-10418.osz}) + */ + private static final String DOWNLOAD_FETCH_URL = "https://osu.yas-online.net%s"; + + /** Maximum beatmaps displayed per page. */ + private static final int PAGE_LIMIT = 25; + + /** Formatted home URL: {@code page} */ + private static final String HOME_URL = "https://osu.yas-online.net/json.maplist.php?o=%d"; + + /** Formatted search URL: {@code query} */ + private static final String SEARCH_URL = "https://osu.yas-online.net/json.search.php?searchQuery=%s"; + + /** Total result count from the last query. */ + private int totalResults = -1; + + /** Max server download ID seen (for approximating total pages). */ + private int maxServerID = 0; + + /** Constructor. */ + public YaSOnlineServer() {} + + @Override + public String getName() { return SERVER_NAME; } + + @Override + public String getDownloadURL(int beatmapSetID) { + try { + // TODO: do this asynchronously (will require lots of changes...) + return getDownloadURLFromMapData(beatmapSetID); + } catch (IOException e) { + return null; + } + } + + /** + * Returns the beatmap download URL by downloading its map data. + *

+ * This is needed because there is no other way to find a beatmap's direct + * download URL. + * @param beatmapSetID the beatmap set ID + * @return the URL string, or null if the address could not be determined + * @throws IOException if any connection error occurred + */ + private String getDownloadURLFromMapData(int beatmapSetID) throws IOException { + try { + // read JSON + String search = String.format(DOWNLOAD_URL, beatmapSetID); + JSONObject json = Utils.readJsonObjectFromUrl(new URL(search)); + JSONObject results; + if (json == null || + !json.getString("result").equals("success") || + (results = json.getJSONObject("success")).length() == 0) { + return null; + } + + // parse result + Iterator keys = results.keys(); + if (!keys.hasNext()) + return null; + String key = (String) keys.next(); + JSONObject item = results.getJSONObject(key); + String downloadLink = item.getString("downloadLink"); + return String.format(DOWNLOAD_FETCH_URL, downloadLink); + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem retrieving download URL for beatmap '%d'.", beatmapSetID), e, true); + return null; + } + } + + @Override + public DownloadNode[] resultList(String query, int page, boolean rankedOnly) throws IOException { + DownloadNode[] nodes = null; + try { + // read JSON + String search; + boolean isSearch; + if (query.isEmpty()) { + isSearch = false; + search = String.format(HOME_URL, (page - 1) * PAGE_LIMIT); + } else { + isSearch = true; + search = String.format(SEARCH_URL, URLEncoder.encode(query, "UTF-8")); + } + JSONObject json = Utils.readJsonObjectFromUrl(new URL(search)); + if (json == null) { + this.totalResults = -1; + return null; + } + JSONObject results; + if (!json.getString("result").equals("success") || + (results = json.getJSONObject("success")).length() == 0) { + this.totalResults = 0; + return new DownloadNode[0]; + } + + // parse result list + List nodeList = new ArrayList(); + for (Object obj : results.keySet()) { + String key = (String) obj; + JSONObject item = results.getJSONObject(key); + + // parse title and artist + String title, artist; + String str = item.getString("map"); + int index = str.indexOf(" - "); + if (index > -1) { + title = str.substring(0, index); + artist = str.substring(index + 3); + } else { // should never happen... + title = str; + artist = "?"; + } + + // only contains date added if part of a beatmap pack + int added = item.getInt("added"); + String date = (added == 0) ? "?" : formatDate(added); + + // approximate page count + int serverID = item.getInt("id"); + if (serverID > maxServerID) + maxServerID = serverID; + + nodeList.add(new DownloadNode(item.getInt("mapid"), date, title, null, artist, null, "")); + } + nodes = nodeList.toArray(new DownloadNode[nodeList.size()]); + + // store total result count + if (isSearch) + this.totalResults = nodes.length; + else + this.totalResults = maxServerID; + } catch (MalformedURLException | UnsupportedEncodingException e) { + ErrorHandler.error(String.format("Problem loading result list for query '%s'.", query), e, true); + } + return nodes; + } + + @Override + public int minQueryLength() { return 3; } + + @Override + public int totalResults() { return totalResults; } + + /** + * Returns a formatted date string from a raw date. + * @param timestamp the UTC timestamp, in seconds + * @return the formatted date + */ + private String formatDate(int timestamp) { + Date d = new Date(timestamp * 1000L); + DateFormat fmt = new SimpleDateFormat("d MMM yyyy HH:mm:ss"); + return fmt.format(d); + } +} diff --git a/src/itdelatrisu/opsu/io/OsuReader.java b/src/itdelatrisu/opsu/io/OsuReader.java index 0cf84dcd..8af11c72 100644 --- a/src/itdelatrisu/opsu/io/OsuReader.java +++ b/src/itdelatrisu/opsu/io/OsuReader.java @@ -61,6 +61,7 @@ public class OsuReader { /** * Closes the input stream. + * @throws IOException if an I/O error occurs */ public void close() throws IOException { reader.close(); } diff --git a/src/itdelatrisu/opsu/io/OsuWriter.java b/src/itdelatrisu/opsu/io/OsuWriter.java index 91b2efce..92d32d54 100644 --- a/src/itdelatrisu/opsu/io/OsuWriter.java +++ b/src/itdelatrisu/opsu/io/OsuWriter.java @@ -62,7 +62,7 @@ public class OsuWriter { /** * Closes the output stream. - * @throws IOException + * @throws IOException if an I/O error occurs */ public void close() throws IOException { writer.close(); } diff --git a/src/itdelatrisu/opsu/objects/Circle.java b/src/itdelatrisu/opsu/objects/Circle.java index 5bc1681c..15e94524 100644 --- a/src/itdelatrisu/opsu/objects/Circle.java +++ b/src/itdelatrisu/opsu/objects/Circle.java @@ -25,7 +25,9 @@ import itdelatrisu.opsu.GameMod; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.objects.curves.Vec2f; import itdelatrisu.opsu.states.Game; +import itdelatrisu.opsu.ui.Colors; import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; @@ -35,9 +37,6 @@ import org.newdawn.slick.Graphics; * Data type representing a circle object. */ public class Circle implements GameObject { - /** The amount of time, in milliseconds, to fade in the circle. */ - private static final int FADE_IN_TIME = 375; - /** The diameter of hit circles. */ private static float diameter; @@ -62,11 +61,10 @@ public class Circle implements GameObject { /** * Initializes the Circle data type with map modifiers, images, and dimensions. * @param container the game container - * @param circleSize the map's circleSize value + * @param circleDiameter the circle diameter */ - public static void init(GameContainer container, float circleSize) { - diameter = (104 - (circleSize * 8)); - diameter = (diameter * HitObject.getXMultiplier()); // convert from Osupixels (640x480) + public static void init(GameContainer container, float circleDiameter) { + diameter = circleDiameter * HitObject.getXMultiplier(); // convert from Osupixels (640x480) int diameterInt = (int) diameter; GameImage.HITCIRCLE.setImage(GameImage.HITCIRCLE.getImage().getScaledCopy(diameterInt, diameterInt)); GameImage.HITCIRCLE_OVERLAY.setImage(GameImage.HITCIRCLE_OVERLAY.getImage().getScaledCopy(diameterInt, diameterInt)); @@ -93,26 +91,37 @@ public class Circle implements GameObject { @Override public void draw(Graphics g, int trackPosition) { int timeDiff = hitObject.getTime() - trackPosition; - float scale = timeDiff / (float) game.getApproachTime(); - float fadeinScale = (timeDiff - game.getApproachTime() + FADE_IN_TIME) / (float) FADE_IN_TIME; + final int approachTime = game.getApproachTime(); + final int fadeInTime = game.getFadeInTime(); + float scale = timeDiff / (float) approachTime; float approachScale = 1 + scale * 3; + float fadeinScale = (timeDiff - approachTime + fadeInTime) / (float) fadeInTime; float alpha = Utils.clamp(1 - fadeinScale, 0, 1); - float oldAlpha = Utils.COLOR_WHITE_FADE.a; - Utils.COLOR_WHITE_FADE.a = color.a = alpha; + if (GameMod.HIDDEN.isActive()) { + final int hiddenDecayTime = game.getHiddenDecayTime(); + final int hiddenTimeDiff = game.getHiddenTimeDiff(); + if (fadeinScale <= 0f && timeDiff < hiddenTimeDiff + hiddenDecayTime) { + float hiddenAlpha = (timeDiff < hiddenTimeDiff) ? 0f : (timeDiff - hiddenTimeDiff) / (float) hiddenDecayTime; + alpha = Math.min(alpha, hiddenAlpha); + } + } - if (timeDiff >= 0) + float oldAlpha = Colors.WHITE_FADE.a; + Colors.WHITE_FADE.a = color.a = alpha; + + if (timeDiff >= 0 && !GameMod.HIDDEN.isActive()) GameImage.APPROACHCIRCLE.getImage().getScaledCopy(approachScale).drawCentered(x, y, color); GameImage.HITCIRCLE.getImage().drawCentered(x, y, color); boolean overlayAboveNumber = Options.getSkin().isHitCircleOverlayAboveNumber(); if (!overlayAboveNumber) - GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Utils.COLOR_WHITE_FADE); + GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Colors.WHITE_FADE); data.drawSymbolNumber(hitObject.getComboNumber(), x, y, GameImage.HITCIRCLE.getImage().getWidth() * 0.40f / data.getDefaultSymbolImage(0).getHeight(), alpha); if (overlayAboveNumber) - GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Utils.COLOR_WHITE_FADE); + GameImage.HITCIRCLE_OVERLAY.getImage().drawCentered(x, y, Colors.WHITE_FADE); - Utils.COLOR_WHITE_FADE.a = oldAlpha; + Colors.WHITE_FADE.a = oldAlpha; } /** @@ -186,7 +195,7 @@ public class Circle implements GameObject { } @Override - public float[] getPointAt(int trackPosition) { return new float[] { x, y }; } + public Vec2f getPointAt(int trackPosition) { return new Vec2f(x, y); } @Override public int getEndTime() { return hitObject.getTime(); } diff --git a/src/itdelatrisu/opsu/objects/DummyObject.java b/src/itdelatrisu/opsu/objects/DummyObject.java index 45c9c1ed..a5426ba6 100644 --- a/src/itdelatrisu/opsu/objects/DummyObject.java +++ b/src/itdelatrisu/opsu/objects/DummyObject.java @@ -19,6 +19,7 @@ package itdelatrisu.opsu.objects; import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.objects.curves.Vec2f; import org.newdawn.slick.Graphics; @@ -53,7 +54,7 @@ public class DummyObject implements GameObject { public boolean mousePressed(int x, int y, int trackPosition) { return false; } @Override - public float[] getPointAt(int trackPosition) { return new float[] { x, y }; } + public Vec2f getPointAt(int trackPosition) { return new Vec2f(x, y); } @Override public int getEndTime() { return hitObject.getTime(); } diff --git a/src/itdelatrisu/opsu/objects/GameObject.java b/src/itdelatrisu/opsu/objects/GameObject.java index 2a40ca47..f1f788d2 100644 --- a/src/itdelatrisu/opsu/objects/GameObject.java +++ b/src/itdelatrisu/opsu/objects/GameObject.java @@ -18,6 +18,8 @@ package itdelatrisu.opsu.objects; +import itdelatrisu.opsu.objects.curves.Vec2f; + import org.newdawn.slick.Graphics; /** @@ -55,9 +57,9 @@ public interface GameObject { /** * Returns the coordinates of the hit object at a given track position. * @param trackPosition the track position - * @return the [x,y] coordinates + * @return the position vector */ - public float[] getPointAt(int trackPosition); + public Vec2f getPointAt(int trackPosition); /** * Returns the end time of the hit object. diff --git a/src/itdelatrisu/opsu/objects/Slider.java b/src/itdelatrisu/opsu/objects/Slider.java index 2ba83c71..2d2476b4 100644 --- a/src/itdelatrisu/opsu/objects/Slider.java +++ b/src/itdelatrisu/opsu/objects/Slider.java @@ -26,11 +26,10 @@ import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.beatmap.Beatmap; import itdelatrisu.opsu.beatmap.HitObject; -import itdelatrisu.opsu.objects.curves.CatmullCurve; -import itdelatrisu.opsu.objects.curves.CircumscribedCircle; import itdelatrisu.opsu.objects.curves.Curve; -import itdelatrisu.opsu.objects.curves.LinearBezier; +import itdelatrisu.opsu.objects.curves.Vec2f; import itdelatrisu.opsu.states.Game; +import itdelatrisu.opsu.ui.Colors; import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; @@ -56,9 +55,6 @@ public class Slider implements GameObject { /** The diameter of hit circles. */ private static float diameter; - /** The amount of time, in milliseconds, to fade in the slider. */ - private static final int FADE_IN_TIME = 375; - /** The associated HitObject. */ private HitObject hitObject; @@ -113,22 +109,21 @@ public class Slider implements GameObject { /** * Initializes the Slider data type with images and dimensions. * @param container the game container - * @param circleSize the map's circleSize value + * @param circleDiameter the circle diameter * @param beatmap the associated beatmap */ - public static void init(GameContainer container, float circleSize, Beatmap beatmap) { + public static void init(GameContainer container, float circleDiameter, Beatmap beatmap) { containerWidth = container.getWidth(); containerHeight = container.getHeight(); - diameter = (104 - (circleSize * 8)); - diameter = (diameter * HitObject.getXMultiplier()); // convert from Osupixels (640x480) + diameter = circleDiameter * HitObject.getXMultiplier(); // convert from Osupixels (640x480) int diameterInt = (int) diameter; followRadius = diameter / 2 * 3f; // slider ball - if (GameImage.SLIDER_BALL.hasSkinImages() || - (!GameImage.SLIDER_BALL.hasSkinImage() && GameImage.SLIDER_BALL.getImages() != null)) + if (GameImage.SLIDER_BALL.hasBeatmapSkinImages() || + (!GameImage.SLIDER_BALL.hasBeatmapSkinImage() && GameImage.SLIDER_BALL.getImages() != null)) sliderBallImages = GameImage.SLIDER_BALL.getImages(); else sliderBallImages = new Image[]{ GameImage.SLIDER_BALL.getImage() }; @@ -160,7 +155,7 @@ public class Slider implements GameObject { updatePosition(); // slider time calculations - this.sliderTime = game.getBeatLength() * (hitObject.getPixelLength() / sliderMultiplier) / 100f; + this.sliderTime = hitObject.getSliderTime(sliderMultiplier, game.getBeatLength()); this.sliderTimeTotal = sliderTime * hitObject.getRepeatCount(); // ticks @@ -178,36 +173,46 @@ public class Slider implements GameObject { @Override public void draw(Graphics g, int trackPosition) { int timeDiff = hitObject.getTime() - trackPosition; - float scale = timeDiff / (float) game.getApproachTime(); - float fadeinScale = (timeDiff - game.getApproachTime() + FADE_IN_TIME) / (float) FADE_IN_TIME; + final int approachTime = game.getApproachTime(); + final int fadeInTime = game.getFadeInTime(); + float scale = timeDiff / (float) approachTime; float approachScale = 1 + scale * 3; + float fadeinScale = (timeDiff - approachTime + fadeInTime) / (float) fadeInTime; float alpha = Utils.clamp(1 - fadeinScale, 0, 1); boolean overlayAboveNumber = Options.getSkin().isHitCircleOverlayAboveNumber(); - float oldAlpha = Utils.COLOR_WHITE_FADE.a; - Utils.COLOR_WHITE_FADE.a = color.a = alpha; + float oldAlpha = Colors.WHITE_FADE.a; + Colors.WHITE_FADE.a = color.a = alpha; Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); Image hitCircle = GameImage.HITCIRCLE.getImage(); - float[] endPos = curve.pointAt(1); + Vec2f endPos = curve.pointAt(1); curve.draw(color); color.a = alpha; // end circle - hitCircle.drawCentered(endPos[0], endPos[1], color); - hitCircleOverlay.drawCentered(endPos[0], endPos[1], Utils.COLOR_WHITE_FADE); + hitCircle.drawCentered(endPos.x, endPos.y, color); + hitCircleOverlay.drawCentered(endPos.x, endPos.y, Colors.WHITE_FADE); // start circle hitCircle.drawCentered(x, y, color); if (!overlayAboveNumber) - hitCircleOverlay.drawCentered(x, y, Utils.COLOR_WHITE_FADE); + hitCircleOverlay.drawCentered(x, y, Colors.WHITE_FADE); // ticks if (ticksT != null) { Image tick = GameImage.SLIDER_TICK.getImage(); for (int i = 0; i < ticksT.length; i++) { - float[] c = curve.pointAt(ticksT[i]); - tick.drawCentered(c[0], c[1], Utils.COLOR_WHITE_FADE); + Vec2f c = curve.pointAt(ticksT[i]); + tick.drawCentered(c.x, c.y, Colors.WHITE_FADE); + } + } + if (GameMod.HIDDEN.isActive()) { + final int hiddenDecayTime = game.getHiddenDecayTime(); + final int hiddenTimeDiff = game.getHiddenTimeDiff(); + if (fadeinScale <= 0f && timeDiff < hiddenTimeDiff + hiddenDecayTime) { + float hiddenAlpha = (timeDiff < hiddenTimeDiff) ? 0f : (timeDiff - hiddenTimeDiff) / (float) hiddenDecayTime; + alpha = Math.min(alpha, hiddenAlpha); } } if (sliderClickedInitial) @@ -216,7 +221,7 @@ public class Slider implements GameObject { data.drawSymbolNumber(hitObject.getComboNumber(), x, y, hitCircle.getWidth() * 0.40f / data.getDefaultSymbolImage(0).getHeight(), alpha); if (overlayAboveNumber) - hitCircleOverlay.drawCentered(x, y, Utils.COLOR_WHITE_FADE); + hitCircleOverlay.drawCentered(x, y, Colors.WHITE_FADE); // repeats for (int tcurRepeat = currentRepeats; tcurRepeat <= currentRepeats + 1; tcurRepeat++) { @@ -232,7 +237,7 @@ public class Slider implements GameObject { if (tcurRepeat % 2 == 0) { // last circle arrow.setRotation(curve.getEndAngle()); - arrow.drawCentered(endPos[0], endPos[1]); + arrow.drawCentered(endPos.x, endPos.y); } else { // first circle arrow.setRotation(curve.getStartAngle()); @@ -243,40 +248,41 @@ public class Slider implements GameObject { if (timeDiff >= 0) { // approach circle - GameImage.APPROACHCIRCLE.getImage().getScaledCopy(approachScale).drawCentered(x, y, color); + if (!GameMod.HIDDEN.isActive()) + GameImage.APPROACHCIRCLE.getImage().getScaledCopy(approachScale).drawCentered(x, y, color); } else { // Since update() might not have run before drawing during a replay, the // slider time may not have been calculated, which causes NAN numbers and flicker. if (sliderTime == 0) return; - float[] c = curve.pointAt(getT(trackPosition, false)); - float[] c2 = curve.pointAt(getT(trackPosition, false) + 0.01f); + Vec2f c = curve.pointAt(getT(trackPosition, false)); + Vec2f c2 = curve.pointAt(getT(trackPosition, false) + 0.01f); float t = getT(trackPosition, false); // float dis = hitObject.getPixelLength() * HitObject.getXMultiplier() * (t - (int) t); // Image sliderBallFrame = sliderBallImages[(int) (dis / (diameter * Math.PI) * 30) % sliderBallImages.length]; Image sliderBallFrame = sliderBallImages[(int) (t * sliderTime * 60 / 1000) % sliderBallImages.length]; - float angle = (float) (Math.atan2(c2[1] - c[1], c2[0] - c[0]) * 180 / Math.PI); + float angle = (float) (Math.atan2(c2.y - c.y, c2.x - c.x) * 180 / Math.PI); sliderBallFrame.setRotation(angle); - sliderBallFrame.drawCentered(c[0], c[1]); + sliderBallFrame.drawCentered(c.x, c.y); // follow circle if (followCircleActive) { - GameImage.SLIDER_FOLLOWCIRCLE.getImage().drawCentered(c[0], c[1]); + GameImage.SLIDER_FOLLOWCIRCLE.getImage().drawCentered(c.x, c.y); // "flashlight" mod: dim the screen if (GameMod.FLASHLIGHT.isActive()) { - float oldAlphaBlack = Utils.COLOR_BLACK_ALPHA.a; - Utils.COLOR_BLACK_ALPHA.a = 0.75f; - g.setColor(Utils.COLOR_BLACK_ALPHA); + float oldAlphaBlack = Colors.BLACK_ALPHA.a; + Colors.BLACK_ALPHA.a = 0.75f; + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, containerWidth, containerHeight); - Utils.COLOR_BLACK_ALPHA.a = oldAlphaBlack; + Colors.BLACK_ALPHA.a = oldAlphaBlack; } } } - Utils.COLOR_WHITE_FADE.a = oldAlpha; + Colors.WHITE_FADE.a = oldAlpha; } /** @@ -346,9 +352,9 @@ public class Slider implements GameObject { float cx, cy; HitObjectType type; if (currentRepeats % 2 == 0) { // last circle - float[] lastPos = curve.pointAt(1); - cx = lastPos[0]; - cy = lastPos[1]; + Vec2f lastPos = curve.pointAt(1); + cx = lastPos.x; + cy = lastPos.y; type = HitObjectType.SLIDER_LAST; } else { // first circle cx = x; @@ -429,8 +435,8 @@ public class Slider implements GameObject { // check if cursor pressed and within end circle if (keyPressed || GameMod.RELAX.isActive()) { - float[] c = curve.pointAt(getT(trackPosition, false)); - double distance = Math.hypot(c[0] - mouseX, c[1] - mouseY); + Vec2f c = curve.pointAt(getT(trackPosition, false)); + double distance = Math.hypot(c.x - mouseX, c.y - mouseY); if (distance < followRadius) sliderHeldToEnd = true; } @@ -473,12 +479,11 @@ public class Slider implements GameObject { } // holding slider... - float[] c = curve.pointAt(getT(trackPosition, false)); - double distance = Math.hypot(c[0] - mouseX, c[1] - mouseY); + Vec2f c = curve.pointAt(getT(trackPosition, false)); + double distance = Math.hypot(c.x - mouseX, c.y - mouseY); if (((keyPressed || GameMod.RELAX.isActive()) && distance < followRadius) || isAutoMod) { // mouse pressed and within follow circle followCircleActive = true; - data.changeHealth(delta * GameData.HP_DRAIN_MULTIPLIER); // held during new repeat if (isNewRepeat) { @@ -489,14 +494,14 @@ public class Slider implements GameObject { curve.getX(lastIndex), curve.getY(lastIndex), hitObject, currentRepeats); } else // first circle data.sliderTickResult(trackPosition, GameData.HIT_SLIDER30, - c[0], c[1], hitObject, currentRepeats); + c.x, c.y, hitObject, currentRepeats); } // held during new tick if (isNewTick) { ticksHit++; data.sliderTickResult(trackPosition, GameData.HIT_SLIDER10, - c[0], c[1], hitObject, currentRepeats); + c.x, c.y, hitObject, currentRepeats); } // held near end of slider @@ -518,22 +523,16 @@ public class Slider implements GameObject { public void updatePosition() { this.x = hitObject.getScaledX(); this.y = hitObject.getScaledY(); - - if (hitObject.getSliderType() == HitObject.SLIDER_PASSTHROUGH && hitObject.getSliderX().length == 2) - this.curve = new CircumscribedCircle(hitObject, color); - else if (hitObject.getSliderType() == HitObject.SLIDER_CATMULL) - this.curve = new CatmullCurve(hitObject, color); - else - this.curve = new LinearBezier(hitObject, color, hitObject.getSliderType() == HitObject.SLIDER_LINEAR); + this.curve = hitObject.getSliderCurve(true); } @Override - public float[] getPointAt(int trackPosition) { + public Vec2f getPointAt(int trackPosition) { if (trackPosition <= hitObject.getTime()) - return new float[] { x, y }; + return new Vec2f(x, y); else if (trackPosition >= hitObject.getTime() + sliderTimeTotal) { if (hitObject.getRepeatCount() % 2 == 0) - return new float[] { x, y }; + return new Vec2f(x, y); else return curve.pointAt(1); } else diff --git a/src/itdelatrisu/opsu/objects/Spinner.java b/src/itdelatrisu/opsu/objects/Spinner.java index 3f9cdcab..e82fa6a3 100644 --- a/src/itdelatrisu/opsu/objects/Spinner.java +++ b/src/itdelatrisu/opsu/objects/Spinner.java @@ -27,7 +27,9 @@ import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.beatmap.HitObject; +import itdelatrisu.opsu.objects.curves.Vec2f; import itdelatrisu.opsu.states.Game; +import itdelatrisu.opsu.ui.Colors; import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; @@ -50,9 +52,6 @@ public class Spinner implements GameObject { /** The amount of time, in milliseconds, before another velocity is stored. */ private static final float DELTA_UPDATE_TIME = 1000 / 60f; - /** The amount of time, in milliseconds, to fade in the spinner. */ - private static final int FADE_IN_TIME = 500; - /** Angle mod multipliers: "auto" (477rpm), "spun out" (287rpm) */ private static final float AUTO_MULTIPLIER = 1 / 20f, // angle = 477/60f * delta/1000f * TWO_PI; @@ -69,6 +68,9 @@ public class Spinner implements GameObject { /** The associated HitObject. */ private HitObject hitObject; + /** The associated Game object. */ + private Game game; + /** The associated GameData object. */ private GameData data; @@ -124,6 +126,7 @@ public class Spinner implements GameObject { */ public Spinner(HitObject hitObject, Game game, GameData data) { this.hitObject = hitObject; + this.game = game; this.data = data; /* @@ -162,7 +165,7 @@ public class Spinner implements GameObject { final int maxVel = 48; final int minTime = 2000; final int maxTime = 5000; - maxStoredDeltaAngles = (int) Utils.clamp((hitObject.getEndTime() - hitObject.getTime() - minTime) + maxStoredDeltaAngles = Utils.clamp((hitObject.getEndTime() - hitObject.getTime() - minTime) * (maxVel - minVel) / (maxTime - minTime) + minVel, minVel, maxVel); storedDeltaAngle = new float[maxStoredDeltaAngles]; @@ -175,20 +178,21 @@ public class Spinner implements GameObject { public void draw(Graphics g, int trackPosition) { // only draw spinners shortly before start time int timeDiff = hitObject.getTime() - trackPosition; - if (timeDiff - FADE_IN_TIME > 0) + final int fadeInTime = game.getFadeInTime(); + if (timeDiff - fadeInTime > 0) return; boolean spinnerComplete = (rotations >= rotationsNeeded); - float alpha = Utils.clamp(1 - (float) timeDiff / FADE_IN_TIME, 0f, 1f); + float alpha = Utils.clamp(1 - (float) timeDiff / fadeInTime, 0f, 1f); // darken screen if (Options.getSkin().isSpinnerFadePlayfield()) { - float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; + float oldAlpha = Colors.BLACK_ALPHA.a; if (timeDiff > 0) - Utils.COLOR_BLACK_ALPHA.a *= alpha; - g.setColor(Utils.COLOR_BLACK_ALPHA); + Colors.BLACK_ALPHA.a *= alpha; + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, height); - Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + Colors.BLACK_ALPHA.a = oldAlpha; } // rpm @@ -210,13 +214,15 @@ public class Spinner implements GameObject { spinnerMetreSub.draw(0, height - spinnerMetreSub.getHeight()); // main spinner elements - float approachScale = 1 - Utils.clamp(((float) timeDiff / (hitObject.getTime() - hitObject.getEndTime())), 0f, 1f); GameImage.SPINNER_CIRCLE.getImage().setAlpha(alpha); GameImage.SPINNER_CIRCLE.getImage().setRotation(drawRotation * 360f); GameImage.SPINNER_CIRCLE.getImage().drawCentered(width / 2, height / 2); - Image approachCircleScaled = GameImage.SPINNER_APPROACHCIRCLE.getImage().getScaledCopy(approachScale); - approachCircleScaled.setAlpha(alpha); - approachCircleScaled.drawCentered(width / 2, height / 2); + if (!GameMod.HIDDEN.isActive()) { + float approachScale = 1 - Utils.clamp(((float) timeDiff / (hitObject.getTime() - hitObject.getEndTime())), 0f, 1f); + Image approachCircleScaled = GameImage.SPINNER_APPROACHCIRCLE.getImage().getScaledCopy(approachScale); + approachCircleScaled.setAlpha(alpha); + approachCircleScaled.drawCentered(width / 2, height / 2); + } GameImage.SPINNER_SPIN.getImage().setAlpha(alpha); GameImage.SPINNER_SPIN.getImage().drawCentered(width / 2, height * 3 / 4); @@ -342,7 +348,7 @@ public class Spinner implements GameObject { public void updatePosition() {} @Override - public float[] getPointAt(int trackPosition) { + public Vec2f getPointAt(int trackPosition) { // get spinner time int timeDiff; float x = hitObject.getScaledX(), y = hitObject.getScaledY(); @@ -357,10 +363,7 @@ public class Spinner implements GameObject { float multiplier = (GameMod.AUTO.isActive()) ? AUTO_MULTIPLIER : SPUN_OUT_MULTIPLIER; float angle = (timeDiff * multiplier) - HALF_PI; final float r = height / 10f; - return new float[] { - (float) (x + r * Math.cos(angle)), - (float) (y + r * Math.sin(angle)) - }; + return new Vec2f((float) (x + r * Math.cos(angle)), (float) (y + r * Math.sin(angle))); } @Override diff --git a/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java b/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java index 95842e77..0d8c61f5 100644 --- a/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java +++ b/src/itdelatrisu/opsu/objects/curves/CatmullCurve.java @@ -18,14 +18,10 @@ package itdelatrisu.opsu.objects.curves; -import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.beatmap.HitObject; import java.util.LinkedList; -import org.newdawn.slick.Color; -import org.newdawn.slick.SlickException; - /** * Representation of Catmull Curve with equidistant points. * @@ -35,10 +31,18 @@ public class CatmullCurve extends EqualDistanceMultiCurve { /** * Constructor. * @param hitObject the associated HitObject - * @param color the color of this curve */ - public CatmullCurve(HitObject hitObject, Color color) { - super(hitObject, color); + public CatmullCurve(HitObject hitObject) { + this(hitObject, true); + } + + /** + * Constructor. + * @param hitObject the associated HitObject + * @param scaled whether to use scaled coordinates + */ + public CatmullCurve(HitObject hitObject, boolean scaled) { + super(hitObject, scaled); LinkedList catmulls = new LinkedList(); int ncontrolPoints = hitObject.getSliderX().length + 1; LinkedList points = new LinkedList(); // temporary list of points to separate different curves @@ -53,24 +57,15 @@ public class CatmullCurve extends EqualDistanceMultiCurve { for (int i = 0; i < ncontrolPoints; i++) { points.addLast(new Vec2f(getX(i), getY(i))); if (points.size() >= 4) { - try { - catmulls.add(new CentripetalCatmullRom(points.toArray(new Vec2f[0]))); - } catch (SlickException e) { - ErrorHandler.error(null, e, true); - } + catmulls.add(new CentripetalCatmullRom(points.toArray(new Vec2f[0]))); points.removeFirst(); } } - if (getX(ncontrolPoints - 1) != getX(ncontrolPoints - 2) - ||getY(ncontrolPoints - 1) != getY(ncontrolPoints - 2)) - points.addLast(new Vec2f(getX(ncontrolPoints - 1), getY(ncontrolPoints - 1))); - if (points.size() >= 4) { - try { - catmulls.add(new CentripetalCatmullRom(points.toArray(new Vec2f[0]))); - } catch (SlickException e) { - ErrorHandler.error(null, e, true); - } - } + if (getX(ncontrolPoints - 1) != getX(ncontrolPoints - 2) || + getY(ncontrolPoints - 1) != getY(ncontrolPoints - 2)) + points.addLast(new Vec2f(getX(ncontrolPoints - 1), getY(ncontrolPoints - 1))); + if (points.size() >= 4) + catmulls.add(new CentripetalCatmullRom(points.toArray(new Vec2f[0]))); init(catmulls); } diff --git a/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java b/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java index 5a13d7ce..c45726dc 100644 --- a/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java +++ b/src/itdelatrisu/opsu/objects/curves/CentripetalCatmullRom.java @@ -18,8 +18,6 @@ package itdelatrisu.opsu.objects.curves; -import org.newdawn.slick.SlickException; - /** * Representation of a Centripetal Catmull–Rom spline. * (Currently not technically Centripetal Catmull–Rom.) @@ -37,11 +35,10 @@ public class CentripetalCatmullRom extends CurveType { /** * Constructor. * @param points the control points of the curve - * @throws SlickException */ - protected CentripetalCatmullRom(Vec2f[] points) throws SlickException { + protected CentripetalCatmullRom(Vec2f[] points) { if (points.length != 4) - throw new SlickException(String.format("Need exactly 4 points to initialize CentripetalCatmullRom, %d provided.", points.length)); + throw new RuntimeException(String.format("Need exactly 4 points to initialize CentripetalCatmullRom, %d provided.", points.length)); this.points = points; time = new float[4]; diff --git a/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java b/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java index b62c17af..866ade2e 100644 --- a/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java +++ b/src/itdelatrisu/opsu/objects/curves/CircumscribedCircle.java @@ -18,11 +18,9 @@ package itdelatrisu.opsu.objects.curves; -import itdelatrisu.opsu.ErrorHandler; +import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.beatmap.HitObject; -import org.newdawn.slick.Color; - /** * Representation of a curve along a Circumscribed Circle of three points. * http://en.wikipedia.org/wiki/Circumscribed_circle @@ -53,10 +51,18 @@ public class CircumscribedCircle extends Curve { /** * Constructor. * @param hitObject the associated HitObject - * @param color the color of this curve */ - public CircumscribedCircle(HitObject hitObject, Color color) { - super(hitObject, color); + public CircumscribedCircle(HitObject hitObject) { + this(hitObject, true); + } + + /** + * Constructor. + * @param hitObject the associated HitObject + * @param scaled whether to use scaled coordinates + */ + public CircumscribedCircle(HitObject hitObject, boolean scaled) { + super(hitObject, scaled); // construct the three points this.start = new Vec2f(getX(0), getY(0)); @@ -70,8 +76,6 @@ public class CircumscribedCircle extends Curve { Vec2f norb = mid.cpy().sub(end).nor(); this.circleCenter = intersect(mida, nora, midb, norb); - if (circleCenter == null) - return; // find the angles relative to the circle center Vec2f startAngPoint = start.cpy().sub(circleCenter); @@ -92,13 +96,8 @@ public class CircumscribedCircle extends Curve { startAng -= TWO_PI; else if (Math.abs(startAng - (endAng - TWO_PI)) < TWO_PI && isIn(startAng, midAng, endAng - (TWO_PI))) endAng -= TWO_PI; - else { - ErrorHandler.error( - String.format("Cannot find angles between midAng (%.3f %.3f %.3f).", - startAng, midAng, endAng), null, true - ); - return; - } + else + throw new RuntimeException(String.format("Cannot find angles between midAng (%.3f %.3f %.3f).", startAng, midAng, endAng)); } // find an angle with an arc length of pixelLength along this circle @@ -116,10 +115,8 @@ public class CircumscribedCircle extends Curve { // calculate points float step = hitObject.getPixelLength() / CURVE_POINTS_SEPERATION; curve = new Vec2f[(int) step + 1]; - for (int i = 0; i < curve.length; i++) { - float[] xy = pointAt(i / step); - curve[i] = new Vec2f(xy[0], xy[1]); - } + for (int i = 0; i < curve.length; i++) + curve[i] = pointAt(i / step); } /** @@ -151,21 +148,19 @@ public class CircumscribedCircle extends Curve { //u = ((b.y-a.y)ta.x +(a.x-b.x)ta.y) / (tb.x*ta.y - tb.y*ta.x); float des = tb.x * ta.y - tb.y * ta.x; - if (Math.abs(des) < 0.00001f) { - ErrorHandler.error("Vectors are parallel.", null, true); - return null; - } + if (Math.abs(des) < 0.00001f) + throw new RuntimeException("Vectors are parallel."); float u = ((b.y - a.y) * ta.x + (a.x - b.x) * ta.y) / des; return b.cpy().add(tb.x * u, tb.y * u); } @Override - public float[] pointAt(float t) { - float ang = lerp(startAng, endAng, t); - return new float[] { + public Vec2f pointAt(float t) { + float ang = Utils.lerp(startAng, endAng, t); + return new Vec2f( (float) (Math.cos(ang) * radius + circleCenter.x), (float) (Math.sin(ang) * radius + circleCenter.y) - }; + ); } @Override diff --git a/src/itdelatrisu/opsu/objects/curves/Curve.java b/src/itdelatrisu/opsu/objects/curves/Curve.java index 0d2f4609..313013db 100644 --- a/src/itdelatrisu/opsu/objects/curves/Curve.java +++ b/src/itdelatrisu/opsu/objects/curves/Curve.java @@ -20,10 +20,10 @@ package itdelatrisu.opsu.objects.curves; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.render.CurveRenderState; import itdelatrisu.opsu.skins.Skin; +import itdelatrisu.opsu.ui.Colors; import org.lwjgl.opengl.ContextCapabilities; import org.lwjgl.opengl.GLContext; @@ -64,14 +64,21 @@ public abstract class Curve { /** * Constructor. * @param hitObject the associated HitObject - * @param color the color of this curve + * @param scaled whether to use scaled coordinates */ - protected Curve(HitObject hitObject, Color color) { + protected Curve(HitObject hitObject, boolean scaled) { this.hitObject = hitObject; - this.x = hitObject.getScaledX(); - this.y = hitObject.getScaledY(); - this.sliderX = hitObject.getScaledSliderX(); - this.sliderY = hitObject.getScaledSliderY(); + if (scaled) { + this.x = hitObject.getScaledX(); + this.y = hitObject.getScaledY(); + this.sliderX = hitObject.getScaledSliderX(); + this.sliderY = hitObject.getScaledSliderY(); + } else { + this.x = hitObject.getX(); + this.y = hitObject.getY(); + this.sliderX = hitObject.getSliderX(); + this.sliderY = hitObject.getSliderY(); + } this.renderState = null; } @@ -80,28 +87,28 @@ public abstract class Curve { * Should be called before any curves are drawn. * @param width the container width * @param height the container height - * @param circleSize the circle size + * @param circleDiameter the circle diameter * @param borderColor the curve border color */ - public static void init(int width, int height, float circleSize, Color borderColor) { + public static void init(int width, int height, float circleDiameter, Color borderColor) { Curve.borderColor = borderColor; ContextCapabilities capabilities = GLContext.getCapabilities(); - mmsliderSupported = capabilities.GL_EXT_framebuffer_object && capabilities.OpenGL32; + mmsliderSupported = capabilities.GL_EXT_framebuffer_object; if (mmsliderSupported) - CurveRenderState.init(width, height, circleSize); + CurveRenderState.init(width, height, circleDiameter); else { if (Options.getSkin().getSliderStyle() != Skin.STYLE_PEPPYSLIDER) - Log.warn("New slider style requires FBO support and OpenGL 3.2."); + Log.warn("New slider style requires FBO support."); } } /** * Returns the point on the curve at a value t. * @param t the t value [0, 1] - * @return the point [x, y] + * @return the position vector */ - public abstract float[] pointAt(float t); + public abstract Vec2f pointAt(float t); /** * Draws the full curve to the graphics context. @@ -116,7 +123,7 @@ public abstract class Curve { Image hitCircle = GameImage.HITCIRCLE.getImage(); Image hitCircleOverlay = GameImage.HITCIRCLE_OVERLAY.getImage(); for (int i = 0; i < curve.length; i++) - hitCircleOverlay.drawCentered(curve[i].x, curve[i].y, Utils.COLOR_WHITE_FADE); + hitCircleOverlay.drawCentered(curve[i].x, curve[i].y, Colors.WHITE_FADE); for (int i = 0; i < curve.length; i++) hitCircle.drawCentered(curve[i].x, curve[i].y, color); } @@ -151,13 +158,6 @@ public abstract class Curve { */ public float getY(int i) { return (i == 0) ? y : sliderY[i - 1]; } - /** - * Linear interpolation of a and b at t. - */ - protected float lerp(float a, float b, float t) { - return a * (1 - t) + b * t; - } - /** * Discards the slider cache (only used for mmsliders). */ diff --git a/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java b/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java index a15a1657..4393d1aa 100644 --- a/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java +++ b/src/itdelatrisu/opsu/objects/curves/EqualDistanceMultiCurve.java @@ -18,13 +18,12 @@ package itdelatrisu.opsu.objects.curves; +import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.beatmap.HitObject; import java.util.Iterator; import java.util.LinkedList; -import org.newdawn.slick.Color; - /** * Representation of multiple curve with equidistant points. * http://pomax.github.io/bezierinfo/#tracing @@ -41,10 +40,18 @@ public abstract class EqualDistanceMultiCurve extends Curve { /** * Constructor. * @param hitObject the associated HitObject - * @param color the color of this curve */ - public EqualDistanceMultiCurve(HitObject hitObject, Color color) { - super(hitObject, color); + public EqualDistanceMultiCurve(HitObject hitObject) { + this(hitObject, true); + } + + /** + * Constructor. + * @param hitObject the associated HitObject + * @param scaled whether to use scaled coordinates + */ + public EqualDistanceMultiCurve(HitObject hitObject, boolean scaled) { + super(hitObject, scaled); } /** @@ -94,7 +101,7 @@ public abstract class EqualDistanceMultiCurve extends Curve { // interpolate the point between the two closest distances if (distanceAt - lastDistanceAt > 1) { float t = (prefDistance - lastDistanceAt) / (distanceAt - lastDistanceAt); - curve[i] = new Vec2f(lerp(lastCurve.x, thisCurve.x, t), lerp(lastCurve.y, thisCurve.y, t)); + curve[i] = new Vec2f(Utils.lerp(lastCurve.x, thisCurve.x, t), Utils.lerp(lastCurve.y, thisCurve.y, t)); } else curve[i] = thisCurve; } @@ -117,20 +124,19 @@ public abstract class EqualDistanceMultiCurve extends Curve { } @Override - public float[] pointAt(float t) { + public Vec2f pointAt(float t) { float indexF = t * ncurve; int index = (int) indexF; - if (index >= ncurve) { - Vec2f poi = curve[ncurve]; - return new float[] { poi.x, poi.y }; - } else { + if (index >= ncurve) + return curve[ncurve].cpy(); + else { Vec2f poi = curve[index]; Vec2f poi2 = curve[index + 1]; float t2 = indexF - index; - return new float[] { - lerp(poi.x, poi2.x, t2), - lerp(poi.y, poi2.y, t2) - }; + return new Vec2f( + Utils.lerp(poi.x, poi2.x, t2), + Utils.lerp(poi.y, poi2.y, t2) + ); } } diff --git a/src/itdelatrisu/opsu/objects/curves/LinearBezier.java b/src/itdelatrisu/opsu/objects/curves/LinearBezier.java index 3ec6717a..4defb192 100644 --- a/src/itdelatrisu/opsu/objects/curves/LinearBezier.java +++ b/src/itdelatrisu/opsu/objects/curves/LinearBezier.java @@ -22,8 +22,6 @@ import itdelatrisu.opsu.beatmap.HitObject; import java.util.LinkedList; -import org.newdawn.slick.Color; - /** * Representation of Bezier curve with equidistant points. * http://pomax.github.io/bezierinfo/#tracing @@ -34,11 +32,20 @@ public class LinearBezier extends EqualDistanceMultiCurve { /** * Constructor. * @param hitObject the associated HitObject - * @param color the color of this curve * @param line whether a new curve should be generated for each sequential pair */ - public LinearBezier(HitObject hitObject, Color color, boolean line) { - super(hitObject, color); + public LinearBezier(HitObject hitObject, boolean line) { + this(hitObject, line, true); + } + + /** + * Constructor. + * @param hitObject the associated HitObject + * @param line whether a new curve should be generated for each sequential pair + * @param scaled whether to use scaled coordinates + */ + public LinearBezier(HitObject hitObject, boolean line, boolean scaled) { + super(hitObject, scaled); LinkedList beziers = new LinkedList(); diff --git a/src/itdelatrisu/opsu/objects/curves/Vec2f.java b/src/itdelatrisu/opsu/objects/curves/Vec2f.java index 1005cd1d..3858823d 100644 --- a/src/itdelatrisu/opsu/objects/curves/Vec2f.java +++ b/src/itdelatrisu/opsu/objects/curves/Vec2f.java @@ -40,6 +40,16 @@ public class Vec2f { */ public Vec2f() {} + /** + * Sets the x and y components of this vector. + * @return itself (for chaining) + */ + public Vec2f set(float nx, float ny) { + x = nx; + y = ny; + return this; + } + /** * Finds the midpoint between this vector and another vector. * @param o the other vector @@ -93,6 +103,17 @@ public class Vec2f { return this; } + /** + * Turns this vector into a unit vector. + * @return itself (for chaining) + */ + public Vec2f normalize() { + float len = len(); + x /= len; + y /= len; + return this; + } + /** * Returns a copy of this vector. */ diff --git a/src/itdelatrisu/opsu/render/CurveRenderState.java b/src/itdelatrisu/opsu/render/CurveRenderState.java index bd7a0914..2d1ac1ad 100644 --- a/src/itdelatrisu/opsu/render/CurveRenderState.java +++ b/src/itdelatrisu/opsu/render/CurveRenderState.java @@ -18,20 +18,21 @@ package itdelatrisu.opsu.render; import itdelatrisu.opsu.GameImage; -import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.beatmap.HitObject; import itdelatrisu.opsu.objects.curves.Vec2f; +import itdelatrisu.opsu.ui.Colors; import java.nio.ByteBuffer; import java.nio.FloatBuffer; +import java.nio.IntBuffer; import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.EXTFramebufferObject; import org.lwjgl.opengl.GL11; import org.lwjgl.opengl.GL13; import org.lwjgl.opengl.GL14; import org.lwjgl.opengl.GL15; import org.lwjgl.opengl.GL20; -import org.lwjgl.opengl.GL30; import org.newdawn.slick.Color; import org.newdawn.slick.Image; import org.newdawn.slick.util.Log; @@ -63,15 +64,14 @@ public class CurveRenderState { * Should be called before any curves are drawn. * @param width the container width * @param height the container height - * @param circleSize the circle size + * @param circleDiameter the circle diameter */ - public static void init(int width, int height, float circleSize) { + public static void init(int width, int height, float circleDiameter) { containerWidth = width; containerHeight = height; // equivalent to what happens in Slider.init() - scale = (int) (104 - (circleSize * 8)); - scale = (int) (scale * HitObject.getXMultiplier()); // convert from Osupixels (640x480) + scale = (int) (circleDiameter * HitObject.getXMultiplier()); // convert from Osupixels (640x480) //scale = scale * 118 / 128; //for curves exactly as big as the sliderball FrameBufferCache.init(width, height); } @@ -114,20 +114,25 @@ public class CurveRenderState { mapping = cache.insert(hitObject); fbo = mapping; - int old_fb = GL11.glGetInteger(GL30.GL_FRAMEBUFFER_BINDING); - int old_tex = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D); + int oldFb = GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT); + int oldTex = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D); - GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, fbo.getID()); + //glGetInteger requires a buffer of size 16, even though just 4 + //values are returned in this specific case + IntBuffer oldViewport = BufferUtils.createIntBuffer(16); + GL11.glGetInteger(GL11.GL_VIEWPORT, oldViewport); + EXTFramebufferObject.glBindFramebufferEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, fbo.getID()); GL11.glViewport(0, 0, fbo.width, fbo.height); GL11.glClearColor(0.0f, 0.0f, 0.0f, 0.0f); GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); - Utils.COLOR_WHITE_FADE.a = 1.0f; + Colors.WHITE_FADE.a = 1.0f; this.draw_curve(color, borderColor, curve); color.a = 1f; - GL11.glBindTexture(GL11.GL_TEXTURE_2D, old_tex); - GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, old_fb); - Utils.COLOR_WHITE_FADE.a = alpha; + GL11.glBindTexture(GL11.GL_TEXTURE_2D, oldTex); + EXTFramebufferObject.glBindFramebufferEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, oldFb); + GL11.glViewport(oldViewport.get(0), oldViewport.get(1), oldViewport.get(2), oldViewport.get(3)); + Colors.WHITE_FADE.a = alpha; } // draw a fullscreen quad with the texture that contains the curve @@ -389,7 +394,7 @@ public class CurveRenderState { buff.flip(); GL11.glBindTexture(GL11.GL_TEXTURE_1D, gradientTexture); GL11.glTexImage1D(GL11.GL_TEXTURE_1D, 0, GL11.GL_RGBA, slider.getWidth(), 0, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buff); - GL30.glGenerateMipmap(GL11.GL_TEXTURE_1D); + EXTFramebufferObject.glGenerateMipmapEXT(GL11.GL_TEXTURE_1D); } } @@ -402,12 +407,12 @@ public class CurveRenderState { program = GL20.glCreateProgram(); int vtxShdr = GL20.glCreateShader(GL20.GL_VERTEX_SHADER); int frgShdr = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER); - GL20.glShaderSource(vtxShdr, "#version 330\n" + GL20.glShaderSource(vtxShdr, "#version 110\n" + "\n" - + "layout(location = 0) in vec4 in_position;\n" - + "layout(location = 1) in vec2 in_tex_coord;\n" + + "attribute vec4 in_position;\n" + + "attribute vec2 in_tex_coord;\n" + "\n" - + "out vec2 tex_coord;\n" + + "varying vec2 tex_coord;\n" + "void main()\n" + "{\n" + " gl_Position = in_position;\n" @@ -419,22 +424,21 @@ public class CurveRenderState { String error = GL20.glGetShaderInfoLog(vtxShdr, 1024); Log.error("Vertex Shader compilation failed.", new Exception(error)); } - GL20.glShaderSource(frgShdr, "#version 330\n" + GL20.glShaderSource(frgShdr, "#version 110\n" + "\n" + "uniform sampler1D tex;\n" + "uniform vec2 tex_size;\n" + "uniform vec3 col_tint;\n" + "uniform vec4 col_border;\n" + "\n" - + "in vec2 tex_coord;\n" - + "layout(location = 0) out vec4 out_colour;\n" + + "varying vec2 tex_coord;\n" + "\n" + "void main()\n" + "{\n" - + " vec4 in_color = texture(tex, tex_coord.x);\n" + + " vec4 in_color = texture1D(tex, tex_coord.x);\n" + " float blend_factor = in_color.r-in_color.b;\n" + " vec4 new_color = vec4(mix(in_color.xyz*col_border.xyz,col_tint,blend_factor),in_color.w);\n" - + " out_colour = new_color;\n" + + " gl_FragColor = new_color;\n" + "}"); GL20.glCompileShader(frgShdr); res = GL20.glGetShaderi(frgShdr, GL20.GL_COMPILE_STATUS); diff --git a/src/itdelatrisu/opsu/render/Rendertarget.java b/src/itdelatrisu/opsu/render/Rendertarget.java index c6f19387..abd16b9d 100644 --- a/src/itdelatrisu/opsu/render/Rendertarget.java +++ b/src/itdelatrisu/opsu/render/Rendertarget.java @@ -19,10 +19,8 @@ package itdelatrisu.opsu.render; import java.nio.ByteBuffer; +import org.lwjgl.opengl.EXTFramebufferObject; import org.lwjgl.opengl.GL11; -import org.lwjgl.opengl.GL20; -import org.lwjgl.opengl.GL30; -import org.lwjgl.opengl.GL32; /** * Represents a rendertarget. For now this maps to an OpenGL FBO via LWJGL. @@ -50,16 +48,16 @@ public class Rendertarget { private Rendertarget(int width, int height) { this.width = width; this.height = height; - fboID = GL30.glGenFramebuffers(); + fboID = EXTFramebufferObject.glGenFramebuffersEXT(); textureID = GL11.glGenTextures(); - depthBufferID = GL30.glGenRenderbuffers(); + depthBufferID = EXTFramebufferObject.glGenRenderbuffersEXT(); } /** * Bind this rendertarget as the primary framebuffer. */ public void bind() { - GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, fboID); + EXTFramebufferObject.glBindFramebufferEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, fboID); } /** @@ -83,7 +81,7 @@ public class Rendertarget { * Bind the default framebuffer. */ public static void unbind() { - GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, 0); + EXTFramebufferObject.glBindFramebufferEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, 0); } /** @@ -93,7 +91,7 @@ public class Rendertarget { * @param height the height */ public static Rendertarget createRTTFramebuffer(int width, int height) { - int old_framebuffer = GL11.glGetInteger(GL30.GL_READ_FRAMEBUFFER_BINDING); + int old_framebuffer = GL11.glGetInteger(EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT); int old_texture = GL11.glGetInteger(GL11.GL_TEXTURE_BINDING_2D); Rendertarget buffer = new Rendertarget(width,height); buffer.bind(); @@ -104,15 +102,14 @@ public class Rendertarget { GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST); - GL30.glBindRenderbuffer(GL30.GL_RENDERBUFFER, buffer.depthBufferID); - GL30.glRenderbufferStorage(GL30.GL_RENDERBUFFER, GL11.GL_DEPTH_COMPONENT, width, height); - GL30.glFramebufferRenderbuffer(GL30.GL_FRAMEBUFFER, GL30.GL_DEPTH_ATTACHMENT, GL30.GL_RENDERBUFFER, buffer.depthBufferID); + EXTFramebufferObject.glBindRenderbufferEXT(EXTFramebufferObject.GL_RENDERBUFFER_EXT, buffer.depthBufferID); + EXTFramebufferObject.glRenderbufferStorageEXT(EXTFramebufferObject.GL_RENDERBUFFER_EXT, GL11.GL_DEPTH_COMPONENT, width, height); + EXTFramebufferObject.glFramebufferRenderbufferEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, EXTFramebufferObject.GL_DEPTH_ATTACHMENT_EXT, EXTFramebufferObject.GL_RENDERBUFFER_EXT, buffer.depthBufferID); - GL32.glFramebufferTexture(GL30.GL_FRAMEBUFFER, GL30.GL_COLOR_ATTACHMENT0, fboTexture, 0); - GL20.glDrawBuffers(GL30.GL_COLOR_ATTACHMENT0); + EXTFramebufferObject.glFramebufferTexture2DEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT, GL11.GL_TEXTURE_2D, fboTexture, 0); GL11.glBindTexture(GL11.GL_TEXTURE_2D, old_texture); - GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, old_framebuffer); + EXTFramebufferObject.glBindFramebufferEXT(EXTFramebufferObject.GL_FRAMEBUFFER_EXT, old_framebuffer); return buffer; } @@ -122,8 +119,8 @@ public class Rendertarget { * to use this rendertarget with OpenGL after calling this method. */ public void destroyRTT() { - GL30.glDeleteFramebuffers(fboID); - GL30.glDeleteRenderbuffers(depthBufferID); + EXTFramebufferObject.glDeleteFramebuffersEXT(fboID); + EXTFramebufferObject.glDeleteRenderbuffersEXT(depthBufferID); GL11.glDeleteTextures(textureID); } } diff --git a/src/itdelatrisu/opsu/replay/LifeFrame.java b/src/itdelatrisu/opsu/replay/LifeFrame.java index 045075f9..de7daea4 100644 --- a/src/itdelatrisu/opsu/replay/LifeFrame.java +++ b/src/itdelatrisu/opsu/replay/LifeFrame.java @@ -25,10 +25,10 @@ package itdelatrisu.opsu.replay; */ public class LifeFrame { /** Time. */ - private int time; + private final int time; /** Percentage. */ - private float percentage; + private final float percentage; /** * Constructor. diff --git a/src/itdelatrisu/opsu/replay/PlaybackSpeed.java b/src/itdelatrisu/opsu/replay/PlaybackSpeed.java index ec2fad6d..4b17adac 100644 --- a/src/itdelatrisu/opsu/replay/PlaybackSpeed.java +++ b/src/itdelatrisu/opsu/replay/PlaybackSpeed.java @@ -35,14 +35,14 @@ public enum PlaybackSpeed { HALF (GameImage.REPLAY_PLAYBACK_HALF, 0.5f); /** The button image. */ - private GameImage gameImage; + private final GameImage gameImage; + + /** The playback speed modifier. */ + private final float modifier; /** The button. */ private MenuButton button; - /** The playback speed modifier. */ - private float modifier; - /** Enum values. */ private static PlaybackSpeed[] values = PlaybackSpeed.values(); diff --git a/src/itdelatrisu/opsu/replay/Replay.java b/src/itdelatrisu/opsu/replay/Replay.java index 505ba128..6f2d9fb4 100644 --- a/src/itdelatrisu/opsu/replay/Replay.java +++ b/src/itdelatrisu/opsu/replay/Replay.java @@ -41,11 +41,11 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import lzma.streams.LzmaOutputStream; - import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream; import org.newdawn.slick.util.Log; +import lzma.streams.LzmaOutputStream; + /** * Captures osu! replay data. * https://osu.ppy.sh/wiki/Osr_%28file_format%29 diff --git a/src/itdelatrisu/opsu/replay/ReplayFrame.java b/src/itdelatrisu/opsu/replay/ReplayFrame.java index 9f8e9f98..28233a7f 100644 --- a/src/itdelatrisu/opsu/replay/ReplayFrame.java +++ b/src/itdelatrisu/opsu/replay/ReplayFrame.java @@ -38,13 +38,13 @@ public class ReplayFrame { private int timeDiff; /** Time, in milliseconds. */ - private int time; + private final int time; /** Cursor coordinates (in OsuPixels). */ - private float x, y; + private final float x, y; /** Keys pressed (bitmask). */ - private int keys; + private final int keys; /** * Returns the start frame. @@ -81,7 +81,8 @@ public class ReplayFrame { public int getTimeDiff() { return timeDiff; } /** - * Sets the time since the previous action, in milliseconds. + * Sets the time since the previous action. + * @param diff the time difference, in milliseconds */ public void setTimeDiff(int diff) { this.timeDiff = diff; } diff --git a/src/itdelatrisu/opsu/states/ButtonMenu.java b/src/itdelatrisu/opsu/states/ButtonMenu.java index c27f0b67..7f31b3cb 100644 --- a/src/itdelatrisu/opsu/states/ButtonMenu.java +++ b/src/itdelatrisu/opsu/states/ButtonMenu.java @@ -29,8 +29,11 @@ import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.beatmap.BeatmapSetNode; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.util.ArrayList; import java.util.List; @@ -187,15 +190,15 @@ public class ButtonMenu extends BasicGameState { float mult = GameMod.getScoreMultiplier(); String multString = String.format("Score Multiplier: %.2fx", mult); Color multColor = (mult == 1f) ? Color.white : (mult > 1f) ? Color.green : Color.red; - float multY = Utils.FONT_LARGE.getLineHeight() * 2 + height * 0.06f; - Utils.FONT_LARGE.drawString( - (width - Utils.FONT_LARGE.getWidth(multString)) / 2f, + float multY = Fonts.LARGE.getLineHeight() * 2 + height * 0.06f; + Fonts.LARGE.drawString( + (width - Fonts.LARGE.getWidth(multString)) / 2f, multY, multString, multColor); // category text for (GameMod.Category category : GameMod.Category.values()) { - Utils.FONT_LARGE.drawString(category.getX(), - category.getY() - Utils.FONT_LARGE.getLineHeight() / 2f, + Fonts.LARGE.drawString(category.getX(), + category.getY() - Fonts.LARGE.getLineHeight() / 2f, category.getName(), category.getColor()); } @@ -217,7 +220,7 @@ public class ButtonMenu extends BasicGameState { } // tooltips - if (hoverMod != null && hoverMod.isImplemented()) + if (hoverMod != null) UI.updateTooltip(delta, hoverMod.getDescription(), true); } @@ -253,7 +256,7 @@ public class ButtonMenu extends BasicGameState { }; /** The buttons in the state. */ - private Button[] buttons; + private final Button[] buttons; /** The associated MenuButton objects. */ private MenuButton[] menuButtons; @@ -261,8 +264,11 @@ public class ButtonMenu extends BasicGameState { /** The actual title string list, generated upon entering the state. */ private List actualTitle; + /** The horizontal center offset, used for the initial button animation. */ + private AnimatedValue centerOffset; + /** Initial x coordinate offsets left/right of center (for shifting animation), times width. (TODO) */ - private static final float OFFSET_WIDTH_RATIO = 1 / 18f; + private static final float OFFSET_WIDTH_RATIO = 1 / 25f; /** * Constructor. @@ -288,7 +294,7 @@ public class ButtonMenu extends BasicGameState { menuButtons = new MenuButton[buttons.length]; for (int i = 0; i < buttons.length; i++) { MenuButton b = new MenuButton(button, buttonL, buttonR, center, baseY + (i * offsetY)); - b.setText(String.format("%d. %s", i + 1, buttons[i].getText()), Utils.FONT_XLARGE, Color.white); + b.setText(String.format("%d. %s", i + 1, buttons[i].getText()), Fonts.XLARGE, Color.white); b.setHoverFade(); menuButtons[i] = b; } @@ -301,7 +307,7 @@ public class ButtonMenu extends BasicGameState { */ protected float getBaseY(GameContainer container, StateBasedGame game) { float baseY = container.getHeight() * 0.2f; - baseY += ((getTitle(container, game).length - 1) * Utils.FONT_LARGE.getLineHeight()); + baseY += ((getTitle(container, game).length - 1) * Fonts.LARGE.getLineHeight()); return baseY; } @@ -315,9 +321,9 @@ public class ButtonMenu extends BasicGameState { // draw title if (actualTitle != null) { float marginX = container.getWidth() * 0.015f, marginY = container.getHeight() * 0.01f; - int lineHeight = Utils.FONT_LARGE.getLineHeight(); + int lineHeight = Fonts.LARGE.getLineHeight(); for (int i = 0, size = actualTitle.size(); i < size; i++) - Utils.FONT_LARGE.drawString(marginX, marginY + (i * lineHeight), actualTitle.get(i), Color.white); + Fonts.LARGE.drawString(marginX, marginY + (i * lineHeight), actualTitle.get(i), Color.white); } // draw buttons @@ -336,18 +342,14 @@ public class ButtonMenu extends BasicGameState { */ public void update(GameContainer container, int delta, int mouseX, int mouseY) { float center = container.getWidth() / 2f; + boolean centerOffsetUpdated = centerOffset.update(delta); + float centerOffsetX = centerOffset.getValue(); for (int i = 0; i < buttons.length; i++) { menuButtons[i].hoverUpdate(delta, mouseX, mouseY); // move button to center - float x = menuButtons[i].getX(); - if (i % 2 == 0) { - if (x < center) - menuButtons[i].setX(Math.min(x + (delta / 5f), center)); - } else { - if (x > center) - menuButtons[i].setX(Math.max(x - (delta / 5f), center)); - } + if (centerOffsetUpdated) + menuButtons[i].setX((i % 2 == 0) ? center + centerOffsetX : center - centerOffsetX); } } @@ -404,9 +406,10 @@ public class ButtonMenu extends BasicGameState { */ public void enter(GameContainer container, StateBasedGame game) { float center = container.getWidth() / 2f; - float centerOffset = container.getWidth() * OFFSET_WIDTH_RATIO; + float centerOffsetX = container.getWidth() * OFFSET_WIDTH_RATIO; + centerOffset = new AnimatedValue(700, centerOffsetX, 0, AnimationEquation.OUT_BOUNCE); for (int i = 0; i < buttons.length; i++) { - menuButtons[i].setX(center + ((i % 2 == 0) ? centerOffset * -1 : centerOffset)); + menuButtons[i].setX(center + ((i % 2 == 0) ? centerOffsetX : centerOffsetX * -1)); menuButtons[i].resetHover(); } @@ -416,8 +419,8 @@ public class ButtonMenu extends BasicGameState { int maxLineWidth = (int) (container.getWidth() * 0.96f); for (int i = 0; i < title.length; i++) { // wrap text if too long - if (Utils.FONT_LARGE.getWidth(title[i]) > maxLineWidth) { - List list = Utils.wrap(title[i], Utils.FONT_LARGE, maxLineWidth); + if (Fonts.LARGE.getWidth(title[i]) > maxLineWidth) { + List list = Fonts.wrap(Fonts.LARGE, title[i], maxLineWidth); actualTitle.addAll(list); } else actualTitle.add(title[i]); @@ -545,10 +548,10 @@ public class ButtonMenu extends BasicGameState { }; /** The text to show on the button. */ - private String text; + private final String text; /** The button color. */ - private Color color; + private final Color color; /** * Constructor. @@ -591,7 +594,7 @@ public class ButtonMenu extends BasicGameState { private GameContainer container; private StateBasedGame game; private Input input; - private int state; + private final int state; public ButtonMenu(int state) { this.state = state; diff --git a/src/itdelatrisu/opsu/states/DownloadsMenu.java b/src/itdelatrisu/opsu/states/DownloadsMenu.java index 7b80bf4e..b6d43c65 100644 --- a/src/itdelatrisu/opsu/states/DownloadsMenu.java +++ b/src/itdelatrisu/opsu/states/DownloadsMenu.java @@ -21,7 +21,6 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OszUnpacker; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; @@ -29,13 +28,19 @@ import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.beatmap.BeatmapParser; import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.beatmap.BeatmapSetNode; +import itdelatrisu.opsu.beatmap.OszUnpacker; import itdelatrisu.opsu.downloads.Download; import itdelatrisu.opsu.downloads.DownloadList; import itdelatrisu.opsu.downloads.DownloadNode; import itdelatrisu.opsu.downloads.servers.BloodcatServer; import itdelatrisu.opsu.downloads.servers.DownloadServer; import itdelatrisu.opsu.downloads.servers.HexideServer; -import itdelatrisu.opsu.downloads.servers.OsuMirrorServer; +import itdelatrisu.opsu.downloads.servers.MengSkyServer; +import itdelatrisu.opsu.downloads.servers.MnetworkServer; +import itdelatrisu.opsu.downloads.servers.YaSOnlineServer; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.DropdownMenu; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.KinecticScrolling; import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.UI; @@ -75,10 +80,10 @@ public class DownloadsMenu extends BasicGameState { private static final int MIN_REQUEST_INTERVAL = 300; /** Available beatmap download servers. */ - private static final DownloadServer[] SERVERS = { new BloodcatServer(), new OsuMirrorServer(), new HexideServer() }; - - /** The beatmap download server index. */ - private int serverIndex = 0; + private static final DownloadServer[] SERVERS = { + new BloodcatServer(), new HexideServer(), new YaSOnlineServer(), + new MnetworkServer(), new MengSkyServer() + }; /** The current list of search results. */ private DownloadNode[] resultList; @@ -137,14 +142,14 @@ public class DownloadsMenu extends BasicGameState { /** Page direction for last query. */ private Page lastQueryDir = Page.RESET; - /** Number of active requests. */ - private int activeRequests = 0; - /** Previous and next page buttons. */ private MenuButton prevPage, nextPage; /** Buttons. */ - private MenuButton clearButton, importButton, resetButton, rankedButton, serverButton; + private MenuButton clearButton, importButton, resetButton, rankedButton; + + /** Dropdown menu. */ + private DropdownMenu serverMenu; /** Beatmap importing thread. */ private Thread importThread; @@ -155,11 +160,97 @@ public class DownloadsMenu extends BasicGameState { /** The bar notification to send upon entering the state. */ private String barNotificationOnLoad; + /** Search query, executed in {@code queryThread}. */ + private SearchQuery searchQuery; + + /** Search query helper class. */ + private class SearchQuery implements Runnable { + /** The search query. */ + private final String query; + + /** The download server. */ + private final DownloadServer server; + + /** Whether the query was interrupted. */ + private boolean interrupted = false; + + /** Whether the query has completed execution. */ + private boolean complete = false; + + /** + * Constructor. + * @param query the search query + * @param server the download server + */ + public SearchQuery(String query, DownloadServer server) { + this.query = query; + this.server = server; + } + + /** Interrupt the query and prevent the results from being processed, if not already complete. */ + public void interrupt() { interrupted = true; } + + /** Returns whether the query has completed execution. */ + public boolean isComplete() { return complete; } + + @Override + public void run() { + // check page direction + Page lastPageDir = pageDir; + pageDir = Page.RESET; + int lastPageSize = (resultList != null) ? resultList.length : 0; + int newPage = page; + if (lastPageDir == Page.RESET) + newPage = 1; + else if (lastPageDir == Page.NEXT) + newPage++; + else if (lastPageDir == Page.PREVIOUS) + newPage--; + try { + DownloadNode[] nodes = server.resultList(query, newPage, rankedOnly); + if (!interrupted) { + // update page total + page = newPage; + if (nodes != null) { + if (lastPageDir == Page.NEXT) + pageResultTotal += nodes.length; + else if (lastPageDir == Page.PREVIOUS) + pageResultTotal -= lastPageSize; + else if (lastPageDir == Page.RESET) + pageResultTotal = nodes.length; + } else + pageResultTotal = 0; + + resultList = nodes; + totalResults = server.totalResults(); + focusResult = -1; + startResultPos.setPosition(0); + if (nodes == null) + searchResultString = "An error has occurred."; + else { + if (query.isEmpty()) + searchResultString = "Type to search!"; + else if (totalResults == 0 || resultList.length == 0) + searchResultString = "No results found."; + else + searchResultString = String.format("%d result%s found!", + totalResults, (totalResults == 1) ? "" : "s"); + } + } + } catch (IOException e) { + if (!interrupted) + searchResultString = "Could not establish connection to server."; + } finally { + complete = true; + } + } + } + // game-related variables private GameContainer container; private StateBasedGame game; private Input input; - private int state; + private final int state; public DownloadsMenu(int state) { this.state = state; @@ -175,17 +266,17 @@ public class DownloadsMenu extends BasicGameState { int width = container.getWidth(); int height = container.getHeight(); float baseX = width * 0.024f; - float searchY = (height * 0.04f) + Utils.FONT_LARGE.getLineHeight(); + float searchY = (height * 0.04f) + Fonts.LARGE.getLineHeight(); float searchWidth = width * 0.3f; // search searchTimer = SEARCH_DELAY; searchResultString = "Loading data from server..."; search = new TextField( - container, Utils.FONT_DEFAULT, (int) baseX, (int) searchY, - (int) searchWidth, Utils.FONT_MEDIUM.getLineHeight() + container, Fonts.DEFAULT, (int) baseX, (int) searchY, + (int) searchWidth, Fonts.MEDIUM.getLineHeight() ); - search.setBackgroundColor(DownloadNode.BG_NORMAL); + search.setBackgroundColor(Colors.BLACK_BG_NORMAL); search.setBorderColor(Color.white); search.setTextColor(Color.white); search.setConsumeEvents(false); @@ -208,9 +299,8 @@ public class DownloadsMenu extends BasicGameState { float buttonHeight = height * 0.038f; float resetWidth = width * 0.085f; float rankedWidth = width * 0.15f; - float serverWidth = width * 0.12f; float lowerWidth = width * 0.12f; - float topButtonY = searchY + Utils.FONT_MEDIUM.getLineHeight() / 2f; + float topButtonY = searchY + Fonts.MEDIUM.getLineHeight() / 2f; float lowerButtonY = height * 0.995f - searchY - buttonHeight / 2f; Image button = GameImage.MENU_BUTTON_MID.getImage(); Image buttonL = GameImage.MENU_BUTTON_LEFT.getImage(); @@ -220,11 +310,9 @@ public class DownloadsMenu extends BasicGameState { int lrButtonWidth = buttonL.getWidth() + buttonR.getWidth(); Image resetButtonImage = button.getScaledCopy((int) resetWidth - lrButtonWidth, (int) buttonHeight); Image rankedButtonImage = button.getScaledCopy((int) rankedWidth - lrButtonWidth, (int) buttonHeight); - Image serverButtonImage = button.getScaledCopy((int) serverWidth - lrButtonWidth, (int) buttonHeight); Image lowerButtonImage = button.getScaledCopy((int) lowerWidth - lrButtonWidth, (int) buttonHeight); float resetButtonWidth = resetButtonImage.getWidth() + lrButtonWidth; float rankedButtonWidth = rankedButtonImage.getWidth() + lrButtonWidth; - float serverButtonWidth = serverButtonImage.getWidth() + lrButtonWidth; float lowerButtonWidth = lowerButtonImage.getWidth() + lrButtonWidth; clearButton = new MenuButton(lowerButtonImage, buttonL, buttonR, width * 0.75f + buttonMarginX + lowerButtonWidth / 2f, lowerButtonY); @@ -234,16 +322,48 @@ public class DownloadsMenu extends BasicGameState { baseX + searchWidth + buttonMarginX + resetButtonWidth / 2f, topButtonY); rankedButton = new MenuButton(rankedButtonImage, buttonL, buttonR, baseX + searchWidth + buttonMarginX * 2f + resetButtonWidth + rankedButtonWidth / 2f, topButtonY); - serverButton = new MenuButton(serverButtonImage, buttonL, buttonR, - baseX + searchWidth + buttonMarginX * 3f + resetButtonWidth + rankedButtonWidth + serverButtonWidth / 2f, topButtonY); - clearButton.setText("Clear", Utils.FONT_MEDIUM, Color.white); - importButton.setText("Import All", Utils.FONT_MEDIUM, Color.white); - resetButton.setText("Reset", Utils.FONT_MEDIUM, Color.white); + clearButton.setText("Clear", Fonts.MEDIUM, Color.white); + importButton.setText("Import All", Fonts.MEDIUM, Color.white); + resetButton.setText("Reset", Fonts.MEDIUM, Color.white); clearButton.setHoverFade(); importButton.setHoverFade(); resetButton.setHoverFade(); rankedButton.setHoverFade(); - serverButton.setHoverFade(); + + // dropdown menu + int serverWidth = (int) (width * 0.12f); + serverMenu = new DropdownMenu(container, SERVERS, + baseX + searchWidth + buttonMarginX * 3f + resetButtonWidth + rankedButtonWidth, searchY, serverWidth) { + @Override + public void itemSelected(int index, DownloadServer item) { + resultList = null; + startResultPos.setPosition(0); + focusResult = -1; + totalResults = 0; + page = 0; + pageResultTotal = 1; + pageDir = Page.RESET; + searchResultString = "Loading data from server..."; + lastQuery = null; + pageDir = Page.RESET; + if (searchQuery != null) + searchQuery.interrupt(); + resetSearchTimer(); + } + + @Override + public boolean menuClicked(int index) { + // block input during beatmap importing + if (importThread != null) + return false; + + SoundController.playSound(SoundEffect.MENUCLICK); + return true; + } + }; + serverMenu.setBackgroundColor(Colors.BLACK_BG_HOVER); + serverMenu.setBorderColor(Color.black); + serverMenu.setChevronRightColor(Color.white); } @Override @@ -252,18 +372,19 @@ public class DownloadsMenu extends BasicGameState { int width = container.getWidth(); int height = container.getHeight(); int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + boolean inDropdownMenu = serverMenu.contains(mouseX, mouseY); // background GameImage.SEARCH_BG.getImage().draw(); // title - Utils.FONT_LARGE.drawString(width * 0.024f, height * 0.03f, "Download Beatmaps!", Color.white); + Fonts.LARGE.drawString(width * 0.024f, height * 0.03f, "Download Beatmaps!", Color.white); // search g.setColor(Color.white); g.setLineWidth(2f); search.render(container, g); - Utils.FONT_BOLD.drawString( + Fonts.BOLD.drawString( search.getX() + search.getWidth() * 0.01f, search.getY() + search.getHeight() * 1.3f, searchResultString, Color.white ); @@ -283,7 +404,7 @@ public class DownloadsMenu extends BasicGameState { if (index >= nodes.length) break; nodes[index].drawResult(g, offset + i * DownloadNode.getButtonOffset(), - DownloadNode.resultContains(mouseX, mouseY - offset, i), + DownloadNode.resultContains(mouseX, mouseY - offset, i) && !inDropdownMenu, (index == focusResult), (previewID == nodes[index].getID())); } g.clearClip(); @@ -297,9 +418,9 @@ public class DownloadsMenu extends BasicGameState { float baseX = width * 0.024f; float buttonY = height * 0.2f; float buttonWidth = width * 0.7f; - Utils.FONT_BOLD.drawString( - baseX + (buttonWidth - Utils.FONT_BOLD.getWidth("Page 1")) / 2f, - buttonY - Utils.FONT_BOLD.getLineHeight() * 1.3f, + Fonts.BOLD.drawString( + baseX + (buttonWidth - Fonts.BOLD.getWidth("Page 1")) / 2f, + buttonY - Fonts.BOLD.getLineHeight() * 1.3f, String.format("Page %d", page), Color.white ); if (page > 1) @@ -311,10 +432,10 @@ public class DownloadsMenu extends BasicGameState { // downloads float downloadsX = width * 0.75f, downloadsY = search.getY(); - g.setColor(DownloadNode.BG_NORMAL); + g.setColor(Colors.BLACK_BG_NORMAL); g.fillRect(downloadsX, downloadsY, width * 0.25f, height - downloadsY * 2f); - Utils.FONT_LARGE.drawString(downloadsX + width * 0.015f, downloadsY + height * 0.015f, "Downloads", Color.white); + Fonts.LARGE.drawString(downloadsX + width * 0.015f, downloadsY + height * 0.015f, "Downloads", Color.white); int downloadsSize = DownloadList.get().size(); if (downloadsSize > 0) { int maxDownloadsShown = DownloadNode.maxDownloadsShown(); @@ -344,15 +465,16 @@ public class DownloadsMenu extends BasicGameState { clearButton.draw(Color.gray); importButton.draw(Color.orange); resetButton.draw(Color.red); - rankedButton.setText((rankedOnly) ? "Show Unranked" : "Hide Unranked", Utils.FONT_MEDIUM, Color.white); + rankedButton.setText((rankedOnly) ? "Show Unranked" : "Hide Unranked", Fonts.MEDIUM, Color.white); rankedButton.draw(Color.magenta); - serverButton.setText(SERVERS[serverIndex].getName(), Utils.FONT_MEDIUM, Color.white); - serverButton.draw(Color.blue); + + // dropdown menu + serverMenu.render(container, g); // importing beatmaps if (importThread != null) { // darken the screen - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, height); UI.drawLoadingProgress(g); @@ -379,7 +501,6 @@ public class DownloadsMenu extends BasicGameState { importButton.hoverUpdate(delta, mouseX, mouseY); resetButton.hoverUpdate(delta, mouseX, mouseY); rankedButton.hoverUpdate(delta, mouseX, mouseY); - serverButton.hoverUpdate(delta, mouseX, mouseY); if (DownloadList.get() != null) startDownloadIndexPos.setMinMax(0, DownloadNode.getInfoHeight() * (DownloadList.get().size() - DownloadNode.maxDownloadsShown())); @@ -399,72 +520,22 @@ public class DownloadsMenu extends BasicGameState { searchTimer = 0; searchTimerReset = false; - final String query = search.getText().trim().toLowerCase(); - final DownloadServer server = SERVERS[serverIndex]; + String query = search.getText().trim().toLowerCase(); + DownloadServer server = serverMenu.getSelectedItem(); if ((lastQuery == null || !query.equals(lastQuery)) && (query.length() == 0 || query.length() >= server.minQueryLength())) { lastQuery = query; lastQueryDir = pageDir; - if (queryThread != null && queryThread.isAlive()) + if (queryThread != null && queryThread.isAlive()) { queryThread.interrupt(); + if (searchQuery != null) + searchQuery.interrupt(); + } // execute query - queryThread = new Thread() { - @Override - public void run() { - activeRequests++; - - // check page direction - Page lastPageDir = pageDir; - pageDir = Page.RESET; - int lastPageSize = (resultList != null) ? resultList.length : 0; - int newPage = page; - if (lastPageDir == Page.RESET) - newPage = 1; - else if (lastPageDir == Page.NEXT) - newPage++; - else if (lastPageDir == Page.PREVIOUS) - newPage--; - try { - DownloadNode[] nodes = server.resultList(query, newPage, rankedOnly); - if (activeRequests - 1 == 0) { - // update page total - page = newPage; - if (nodes != null) { - if (lastPageDir == Page.NEXT) - pageResultTotal += nodes.length; - else if (lastPageDir == Page.PREVIOUS) - pageResultTotal -= lastPageSize; - else if (lastPageDir == Page.RESET) - pageResultTotal = nodes.length; - } else - pageResultTotal = 0; - - resultList = nodes; - totalResults = server.totalResults(); - focusResult = -1; - startResultPos.setPosition(0); - if (nodes == null) - searchResultString = "An error has occurred."; - else { - if (query.isEmpty()) - searchResultString = "Type to search!"; - else if (totalResults == 0 || resultList.length == 0) - searchResultString = "No results found."; - else - searchResultString = String.format("%d result%s found!", - totalResults, (totalResults == 1) ? "" : "s"); - } - } - } catch (IOException e) { - searchResultString = "Could not establish connection to server."; - } finally { - activeRequests--; - queryThread = null; - } - } - }; + searchQuery = new SearchQuery(query, server); + queryThread = new Thread(searchQuery); queryThread.start(); } } @@ -474,7 +545,7 @@ public class DownloadsMenu extends BasicGameState { UI.updateTooltip(delta, "Reset the current search.", false); else if (rankedButton.contains(mouseX, mouseY)) UI.updateTooltip(delta, "Toggle the display of unranked maps.\nSome download servers may not support this option.", true); - else if (serverButton.contains(mouseX, mouseY)) + else if (serverMenu.baseContains(mouseX, mouseY)) UI.updateTooltip(delta, "Select a download server.", false); } @@ -534,7 +605,7 @@ public class DownloadsMenu extends BasicGameState { } else { // play preview try { - final URL url = new URL(SERVERS[serverIndex].getPreviewURL(node.getID())); + final URL url = new URL(serverMenu.getSelectedItem().getPreviewURL(node.getID())); MusicController.pause(); new Thread() { @Override @@ -578,7 +649,7 @@ public class DownloadsMenu extends BasicGameState { } else { // start download if (!DownloadList.get().contains(node.getID())) { - node.createDownload(SERVERS[serverIndex]); + node.createDownload(serverMenu.getSelectedItem()); if (node.getDownload() == null) UI.sendBarNotification("The download could not be started."); else { @@ -601,23 +672,27 @@ public class DownloadsMenu extends BasicGameState { // pages if (nodes.length > 0) { if (page > 1 && prevPage.contains(x, y)) { - if (lastQueryDir == Page.PREVIOUS && queryThread != null && queryThread.isAlive()) + if (lastQueryDir == Page.PREVIOUS && searchQuery != null && !searchQuery.isComplete()) ; // don't send consecutive requests else { SoundController.playSound(SoundEffect.MENUCLICK); pageDir = Page.PREVIOUS; lastQuery = null; + if (searchQuery != null) + searchQuery.interrupt(); resetSearchTimer(); } return; } if (pageResultTotal < totalResults && nextPage.contains(x, y)) { - if (lastQueryDir == Page.NEXT && queryThread != null && queryThread.isAlive()) + if (lastQueryDir == Page.NEXT && searchQuery != null && !searchQuery.isComplete()) ; // don't send consecutive requests else { SoundController.playSound(SoundEffect.MENUCLICK); pageDir = Page.NEXT; lastQuery = null; + if (searchQuery != null) + searchQuery.interrupt(); resetSearchTimer(); return; } @@ -670,6 +745,8 @@ public class DownloadsMenu extends BasicGameState { search.setText(""); lastQuery = null; pageDir = Page.RESET; + if (searchQuery != null) + searchQuery.interrupt(); resetSearchTimer(); return; } @@ -678,22 +755,8 @@ public class DownloadsMenu extends BasicGameState { rankedOnly = !rankedOnly; lastQuery = null; pageDir = Page.RESET; - resetSearchTimer(); - return; - } - if (serverButton.contains(x, y)) { - SoundController.playSound(SoundEffect.MENUCLICK); - resultList = null; - startResultPos.setPosition(0); - focusResult = -1; - totalResults = 0; - page = 0; - pageResultTotal = 1; - pageDir = Page.RESET; - searchResultString = "Loading data from server..."; - serverIndex = (serverIndex + 1) % SERVERS.length; - lastQuery = null; - pageDir = Page.RESET; + if (searchQuery != null) + searchQuery.interrupt(); resetSearchTimer(); return; } @@ -806,6 +869,8 @@ public class DownloadsMenu extends BasicGameState { SoundController.playSound(SoundEffect.MENUCLICK); lastQuery = null; pageDir = Page.CURRENT; + if (searchQuery != null) + searchQuery.interrupt(); resetSearchTimer(); break; case Input.KEY_F7: @@ -837,7 +902,8 @@ public class DownloadsMenu extends BasicGameState { importButton.resetHover(); resetButton.resetHover(); rankedButton.resetHover(); - serverButton.resetHover(); + serverMenu.activate(); + serverMenu.reset(); focusResult = -1; startResultPos.setPosition(0); startDownloadIndexPos.setPosition(0); @@ -853,6 +919,7 @@ public class DownloadsMenu extends BasicGameState { public void leave(GameContainer container, StateBasedGame game) throws SlickException { search.setFocus(false); + serverMenu.deactivate(); SoundController.stopTrack(); MusicController.resume(); } diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java index 2c46797f..0bfa1d54 100644 --- a/src/itdelatrisu/opsu/states/Game.java +++ b/src/itdelatrisu/opsu/states/Game.java @@ -42,12 +42,16 @@ import itdelatrisu.opsu.objects.GameObject; import itdelatrisu.opsu.objects.Slider; import itdelatrisu.opsu.objects.Spinner; import itdelatrisu.opsu.objects.curves.Curve; +import itdelatrisu.opsu.objects.curves.Vec2f; import itdelatrisu.opsu.render.FrameBufferCache; import itdelatrisu.opsu.replay.PlaybackSpeed; import itdelatrisu.opsu.replay.Replay; import itdelatrisu.opsu.replay.ReplayFrame; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.io.File; import java.util.LinkedList; @@ -116,6 +120,15 @@ public class Game extends BasicGameState { /** Hit object approach time, in milliseconds. */ private int approachTime; + /** The amount of time for hit objects to fade in, in milliseconds. */ + private int fadeInTime; + + /** Decay time for hit objects in the "Hidden" mod, in milliseconds. */ + private int hiddenDecayTime; + + /** Time before the hit object time by which the objects have completely faded in the "Hidden" mod, in milliseconds. */ + private int hiddenTimeDiff; + /** Time offsets for obtaining each hit result (indexed by HIT_* constants). */ private int[] hitResultOffset; @@ -146,7 +159,7 @@ public class Game extends BasicGameState { countdown2Sound, countdownGoSound; /** Mouse coordinates before game paused. */ - private int pausedMouseX = -1, pausedMouseY = -1; + private Vec2f pausedMousePosition; /** Track position when game paused. */ private int pauseTime = -1; @@ -209,7 +222,7 @@ public class Game extends BasicGameState { private int flashlightRadius; /** The cursor coordinates using the "auto" or "relax" mods. */ - private int autoMouseX = 0, autoMouseY = 0; + private Vec2f autoMousePosition; /** Whether or not the cursor should be pressed using the "auto" mod. */ private boolean autoMousePressed; @@ -220,11 +233,20 @@ public class Game extends BasicGameState { /** Whether the game is currently seeking to a replay position. */ private boolean isSeeking; + /** Music position bar coordinates and dimensions (for replay seeking). */ + private float musicBarX, musicBarY, musicBarWidth, musicBarHeight; + + /** Music position bar background colors. */ + private static final Color + MUSICBAR_NORMAL = new Color(12, 9, 10, 0.25f), + MUSICBAR_HOVER = new Color(12, 9, 10, 0.35f), + MUSICBAR_FILL = new Color(255, 255, 255, 0.75f); + // game-related variables private GameContainer container; private StateBasedGame game; private Input input; - private int state; + private final int state; public Game(int state) { this.state = state; @@ -245,6 +267,12 @@ public class Game extends BasicGameState { gOffscreen = offscreen.getGraphics(); gOffscreen.setBackground(Color.black); + // initialize music position bar location + musicBarX = width * 0.01f; + musicBarY = height * 0.05f; + musicBarWidth = Math.max(width * 0.005f, 7); + musicBarHeight = height * 0.9f; + // create the associated GameData object data = new GameData(width, height); } @@ -278,7 +306,7 @@ public class Game extends BasicGameState { else dimLevel = 1f; } - if (Options.isDefaultPlayfieldForced() || !beatmap.drawBG(width, height, dimLevel, false)) { + if (Options.isDefaultPlayfieldForced() || !beatmap.drawBackground(width, height, dimLevel, false)) { Image playfield = GameImage.PLAYFIELD.getImage(); playfield.setAlpha(dimLevel); playfield.draw(); @@ -290,32 +318,31 @@ public class Game extends BasicGameState { // "auto" and "autopilot" mods: move cursor automatically // TODO: this should really be in update(), not render() - autoMouseX = width / 2; - autoMouseY = height / 2; + autoMousePosition.set(width / 2, height / 2); autoMousePressed = false; if (GameMod.AUTO.isActive() || GameMod.AUTOPILOT.isActive()) { - float[] autoXY = null; + Vec2f autoPoint = null; if (isLeadIn()) { // lead-in float progress = Math.max((float) (leadInTime - beatmap.audioLeadIn) / approachTime, 0f); - autoMouseY = (int) (height / (2f - progress)); + autoMousePosition.y = height / (2f - progress); } else if (objectIndex == 0 && trackPosition < firstObjectTime) { // before first object timeDiff = firstObjectTime - trackPosition; if (timeDiff < approachTime) { - float[] xy = gameObjects[0].getPointAt(trackPosition); - autoXY = getPointAt(autoMouseX, autoMouseY, xy[0], xy[1], 1f - ((float) timeDiff / approachTime)); + Vec2f point = gameObjects[0].getPointAt(trackPosition); + autoPoint = getPointAt(autoMousePosition.x, autoMousePosition.y, point.x, point.y, 1f - ((float) timeDiff / approachTime)); } } else if (objectIndex < beatmap.objects.length) { // normal object int objectTime = beatmap.objects[objectIndex].getTime(); if (trackPosition < objectTime) { - float[] xyStart = gameObjects[objectIndex - 1].getPointAt(trackPosition); + Vec2f startPoint = gameObjects[objectIndex - 1].getPointAt(trackPosition); int startTime = gameObjects[objectIndex - 1].getEndTime(); if (beatmap.breaks != null && breakIndex < beatmap.breaks.size()) { // starting a break: keep cursor at previous hit object position if (breakTime > 0 || objectTime > beatmap.breaks.get(breakIndex)) - autoXY = xyStart; + autoPoint = startPoint; // after a break ends: move startTime to break end time else if (breakIndex > 1) { @@ -324,10 +351,10 @@ public class Game extends BasicGameState { startTime = lastBreakEndTime; } } - if (autoXY == null) { - float[] xyEnd = gameObjects[objectIndex].getPointAt(trackPosition); + if (autoPoint == null) { + Vec2f endPoint = gameObjects[objectIndex].getPointAt(trackPosition); int totalTime = objectTime - startTime; - autoXY = getPointAt(xyStart[0], xyStart[1], xyEnd[0], xyEnd[1], (float) (trackPosition - startTime) / totalTime); + autoPoint = getPointAt(startPoint.x, startPoint.y, endPoint.x, endPoint.y, (float) (trackPosition - startTime) / totalTime); // hit circles: show a mouse press int offset300 = hitResultOffset[GameData.HIT_300]; @@ -336,19 +363,17 @@ public class Game extends BasicGameState { autoMousePressed = true; } } else { - autoXY = gameObjects[objectIndex].getPointAt(trackPosition); + autoPoint = gameObjects[objectIndex].getPointAt(trackPosition); autoMousePressed = true; } } else { // last object - autoXY = gameObjects[objectIndex - 1].getPointAt(trackPosition); + autoPoint = gameObjects[objectIndex - 1].getPointAt(trackPosition); } // set mouse coordinates - if (autoXY != null) { - autoMouseX = (int) autoXY[0]; - autoMouseY = (int) autoXY[1]; - } + if (autoPoint != null) + autoMousePosition.set(autoPoint.x, autoPoint.y); } // "flashlight" mod: restricted view of hit objects around cursor @@ -366,12 +391,12 @@ public class Game extends BasicGameState { g.setDrawMode(Graphics.MODE_ALPHA_MAP); g.clearAlphaMap(); int mouseX, mouseY; - if (pauseTime > -1 && pausedMouseX > -1 && pausedMouseY > -1) { - mouseX = pausedMouseX; - mouseY = pausedMouseY; + if (pauseTime > -1 && pausedMousePosition != null) { + mouseX = (int) pausedMousePosition.x; + mouseY = (int) pausedMousePosition.y; } else if (GameMod.AUTO.isActive() || GameMod.AUTOPILOT.isActive()) { - mouseX = autoMouseX; - mouseY = autoMouseY; + mouseX = (int) autoMousePosition.x; + mouseY = (int) autoMousePosition.y; } else if (isReplay) { mouseX = replayX; mouseY = replayY; @@ -455,15 +480,15 @@ public class Game extends BasicGameState { GameImage.SCOREBAR_BG.getImage().getHeight(), GameImage.SCOREBAR_KI.getImage().getHeight() ); - float oldAlpha = Utils.COLOR_WHITE_FADE.a; + float oldAlpha = Colors.WHITE_FADE.a; if (timeDiff < -500) - Utils.COLOR_WHITE_FADE.a = (1000 + timeDiff) / 500f; - Utils.FONT_MEDIUM.drawString( + Colors.WHITE_FADE.a = (1000 + timeDiff) / 500f; + Fonts.MEDIUM.drawString( 2 + (width / 100), retryHeight, String.format("%d retries and counting...", retries), - Utils.COLOR_WHITE_FADE + Colors.WHITE_FADE ); - Utils.COLOR_WHITE_FADE.a = oldAlpha; + Colors.WHITE_FADE.a = oldAlpha; } if (isLeadIn()) @@ -525,28 +550,40 @@ public class Game extends BasicGameState { if (isReplay || GameMod.AUTO.isActive()) playbackSpeed.getButton().draw(); + // draw music position bar (for replay seeking) + if (isReplay && Options.isReplaySeekingEnabled()) { + int mouseX = input.getMouseX(), mouseY = input.getMouseY(); + g.setColor((musicPositionBarContains(mouseX, mouseY)) ? MUSICBAR_HOVER : MUSICBAR_NORMAL); + g.fillRoundRect(musicBarX, musicBarY, musicBarWidth, musicBarHeight, 4); + if (!isLeadIn()) { + g.setColor(MUSICBAR_FILL); + float musicBarPosition = Math.min((float) trackPosition / beatmap.endTime, 1f); + g.fillRoundRect(musicBarX, musicBarY, musicBarWidth, musicBarHeight * musicBarPosition, 4); + } + } + // returning from pause screen - if (pauseTime > -1 && pausedMouseX > -1 && pausedMouseY > -1) { + if (pauseTime > -1 && pausedMousePosition != null) { // darken the screen - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, height); // draw glowing hit select circle and pulse effect - int circleRadius = GameImage.HITCIRCLE.getImage().getWidth(); - Image cursorCircle = GameImage.HITCIRCLE_SELECT.getImage().getScaledCopy(circleRadius, circleRadius); + int circleDiameter = GameImage.HITCIRCLE.getImage().getWidth(); + Image cursorCircle = GameImage.HITCIRCLE_SELECT.getImage().getScaledCopy(circleDiameter, circleDiameter); cursorCircle.setAlpha(1.0f); - cursorCircle.drawCentered(pausedMouseX, pausedMouseY); + cursorCircle.drawCentered(pausedMousePosition.x, pausedMousePosition.y); Image cursorCirclePulse = cursorCircle.getScaledCopy(1f + pausePulse); cursorCirclePulse.setAlpha(1f - pausePulse); - cursorCirclePulse.drawCentered(pausedMouseX, pausedMouseY); + cursorCirclePulse.drawCentered(pausedMousePosition.x, pausedMousePosition.y); } if (isReplay) UI.draw(g, replayX, replayY, replayKeyPressed); else if (GameMod.AUTO.isActive()) - UI.draw(g, autoMouseX, autoMouseY, autoMousePressed); + UI.draw(g, (int) autoMousePosition.x, (int) autoMousePosition.y, autoMousePressed); else if (GameMod.AUTOPILOT.isActive()) - UI.draw(g, autoMouseX, autoMouseY, Utils.isGameKeyPressed()); + UI.draw(g, (int) autoMousePosition.x, (int) autoMousePosition.y, Utils.isGameKeyPressed()); else UI.draw(g); } @@ -564,8 +601,7 @@ public class Game extends BasicGameState { // returning from pause screen: must click previous mouse position if (pauseTime > -1) { // paused during lead-in or break, or "relax" or "autopilot": continue immediately - if ((pausedMouseX < 0 && pausedMouseY < 0) || - (GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive())) { + if (pausedMousePosition == null || (GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive())) { pauseTime = -1; if (!isLeadIn()) MusicController.resume(); @@ -603,6 +639,17 @@ public class Game extends BasicGameState { return; } + // "Easy" mod: multiple "lives" + if (GameMod.EASY.isActive() && deathTime > -1) { + if (data.getHealth() < 99f) { + data.changeHealth(delta / 10f); + data.updateDisplays(delta); + return; + } + MusicController.resume(); + deathTime = -1; + } + // normal game update if (!isReplay) addReplayFrameAndRun(mouseX, mouseY, lastKeysPressed, trackPosition); @@ -613,7 +660,7 @@ public class Game extends BasicGameState { if (replayIndex >= replay.frames.length) updateGame(replayX, replayY, delta, MusicController.getPosition(), lastKeysPressed); - //TODO probably should to disable sounds then reseek to the new position + // seeking to a position earlier than original track position if (isSeeking && replayIndex - 1 >= 1 && replayIndex < replay.frames.length && trackPosition < replay.frames[replayIndex - 1].getTime()) { replayIndex = 0; @@ -633,7 +680,6 @@ public class Game extends BasicGameState { timingPointIndex++; } } - isSeeking = false; } // update and run replay frames @@ -648,6 +694,12 @@ public class Game extends BasicGameState { } mouseX = replayX; mouseY = replayY; + + // unmute sounds + if (isSeeking) { + isSeeking = false; + SoundController.mute(false); + } } data.updateDisplays(delta); @@ -662,16 +714,6 @@ public class Game extends BasicGameState { * @param keys the keys that are pressed */ private void updateGame(int mouseX, int mouseY, int delta, int trackPosition, int keys) { - // "Easy" mod: multiple "lives" - if (GameMod.EASY.isActive() && deathTime > -1) { - if (data.getHealth() < 99f) - data.changeHealth(delta / 10f); - else { - MusicController.resume(); - deathTime = -1; - } - } - // map complete! if (objectIndex >= gameObjects.length || (MusicController.trackEnded() && objectIndex > 0)) { // track ended before last object was processed: force a hit result @@ -699,6 +741,7 @@ public class Game extends BasicGameState { r.save(); } ScoreData score = data.getScoreData(beatmap); + data.setGameplay(!isReplay); // add score to database if (!unranked && !isReplay) @@ -745,8 +788,7 @@ public class Game extends BasicGameState { // pause game if focus lost if (!container.hasFocus() && !GameMod.AUTO.isActive() && !isReplay) { if (pauseTime < 0) { - pausedMouseX = mouseX; - pausedMouseY = mouseY; + pausedMousePosition = new Vec2f(mouseX, mouseY); pausePulse = 0f; } if (MusicController.isPlaying() || isLeadIn()) @@ -819,8 +861,7 @@ public class Game extends BasicGameState { // pause game if (pauseTime < 0 && breakTime <= 0 && trackPosition >= beatmap.objects[0].getTime()) { - pausedMouseX = mouseX; - pausedMouseY = mouseY; + pausedMousePosition = new Vec2f(mouseX, mouseY); pausePulse = 0f; } if (MusicController.isPlaying() || isLeadIn()) @@ -888,6 +929,13 @@ public class Game extends BasicGameState { } } break; + case Input.KEY_F: + // change playback speed + if (isReplay || GameMod.AUTO.isActive()) { + playbackSpeed = playbackSpeed.next(); + MusicController.setPitch(GameMod.getSpeedMultiplier() * playbackSpeed.getModifier()); + } + break; case Input.KEY_UP: UI.changeVolume(1); break; @@ -923,9 +971,10 @@ public class Game extends BasicGameState { MusicController.setPitch(GameMod.getSpeedMultiplier() * playbackSpeed.getModifier()); } - // TODO - else if (!GameMod.AUTO.isActive() && y < 50) { - float pos = (float) x / container.getWidth() * beatmap.endTime; + // replay seeking + else if (Options.isReplaySeekingEnabled() && !GameMod.AUTO.isActive() && musicPositionBarContains(x, y)) { + SoundController.mute(true); // mute sounds while seeking + float pos = (y - musicBarY) / musicBarHeight * beatmap.endTime; MusicController.setPosition((int) pos); isSeeking = true; } @@ -939,8 +988,7 @@ public class Game extends BasicGameState { if (button == Input.MOUSE_MIDDLE_BUTTON && !Options.isMouseWheelDisabled()) { int trackPosition = MusicController.getPosition(); if (pauseTime < 0 && breakTime <= 0 && trackPosition >= beatmap.objects[0].getTime()) { - pausedMouseX = x; - pausedMouseY = y; + pausedMousePosition = new Vec2f(x, y); pausePulse = 0f; } if (MusicController.isPlaying() || isLeadIn()) @@ -969,13 +1017,12 @@ public class Game extends BasicGameState { private void gameKeyPressed(int keys, int x, int y, int trackPosition) { // returning from pause screen if (pauseTime > -1) { - double distance = Math.hypot(pausedMouseX - x, pausedMouseY - y); + double distance = Math.hypot(pausedMousePosition.x - x, pausedMousePosition.y - y); int circleRadius = GameImage.HITCIRCLE.getImage().getWidth() / 2; if (distance < circleRadius) { // unpause the game pauseTime = -1; - pausedMouseX = -1; - pausedMouseY = -1; + pausedMousePosition = null; if (!isLeadIn()) MusicController.resume(); } @@ -1065,6 +1112,15 @@ public class Game extends BasicGameState { // restart the game if (restart != Restart.FALSE) { + // load mods + if (isReplay) { + previousMods = GameMod.getModState(); + GameMod.loadModState(replay.mods); + } + + data.setGameplay(true); + + // check restart state if (restart == Restart.NEW) { // new game loadImages(); @@ -1149,10 +1205,6 @@ public class Game extends BasicGameState { // load replay frames if (isReplay) { - // load mods - previousMods = GameMod.getModState(); - GameMod.loadModState(replay.mods); - // load initial data replayX = container.getWidth() / 2; replayY = container.getHeight() / 2; @@ -1188,6 +1240,8 @@ public class Game extends BasicGameState { MusicController.setPosition(0); MusicController.setPitch(GameMod.getSpeedMultiplier()); MusicController.pause(); + + SoundController.mute(false); } skipButton.resetHover(); @@ -1242,10 +1296,10 @@ public class Game extends BasicGameState { final int followPointInterval = container.getHeight() / 14; int lastObjectEndTime = gameObjects[lastObjectIndex].getEndTime() + 1; int objectStartTime = beatmap.objects[index].getTime(); - float[] startXY = gameObjects[lastObjectIndex].getPointAt(lastObjectEndTime); - float[] endXY = gameObjects[index].getPointAt(objectStartTime); - float xDiff = endXY[0] - startXY[0]; - float yDiff = endXY[1] - startXY[1]; + Vec2f startPoint = gameObjects[lastObjectIndex].getPointAt(lastObjectEndTime); + Vec2f endPoint = gameObjects[index].getPointAt(objectStartTime); + float xDiff = endPoint.x - startPoint.x; + float yDiff = endPoint.y - startPoint.y; float dist = (float) Math.hypot(xDiff, yDiff); int numPoints = (int) ((dist - GameImage.HITCIRCLE.getImage().getWidth()) / followPointInterval); if (numPoints > 0) { @@ -1266,8 +1320,8 @@ public class Game extends BasicGameState { float step = 1f / (numPoints + 1); float t = step; for (int i = 0; i < numPoints; i++) { - float x = startXY[0] + xDiff * t; - float y = startXY[1] + yDiff * t; + float x = startPoint.x + xDiff * t; + float y = startPoint.y + yDiff * t; float nextT = t + step; if (lastObjectIndex < objectIndex) { // fade the previous trail if (progress < nextT) { @@ -1321,8 +1375,7 @@ public class Game extends BasicGameState { timingPointIndex = 0; beatLengthBase = beatLength = 1; pauseTime = -1; - pausedMouseX = -1; - pausedMouseY = -1; + pausedMousePosition = null; countdownReadySound = false; countdown3Sound = false; countdown1Sound = false; @@ -1333,8 +1386,7 @@ public class Game extends BasicGameState { deathTime = -1; replayFrames = null; lastReplayTime = 0; - autoMouseX = 0; - autoMouseY = 0; + autoMousePosition = new Vec2f(); autoMousePressed = false; flashlightRadius = container.getHeight() * 2 / 3; @@ -1376,9 +1428,9 @@ public class Game extends BasicGameState { // set images File parent = beatmap.getFile().getParentFile(); for (GameImage img : GameImage.values()) { - if (img.isSkinnable()) { + if (img.isBeatmapSkinnable()) { img.setDefaultImage(); - img.setSkinImage(parent); + img.setBeatmapSkinImage(parent); } } @@ -1390,6 +1442,8 @@ public class Game extends BasicGameState { Image skip = GameImage.SKIP.getImage(); skipButton = new MenuButton(skip, width - skip.getWidth() / 2f, height - (skip.getHeight() / 2f)); } + skipButton.setHoverAnimationDuration(350); + skipButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_BACK); skipButton.setHoverExpand(1.1f, MenuButton.Expand.UP_LEFT); // load other images... @@ -1420,14 +1474,15 @@ public class Game extends BasicGameState { // Stack modifier scales with hit object size // StackOffset = HitObjectRadius / 10 - int diameter = (int) (104 - (circleSize * 8)); + //int diameter = (int) (104 - (circleSize * 8)); + float diameter = 108.848f - (circleSize * 8.9646f); HitObject.setStackOffset(diameter * STACK_OFFSET_MODIFIER); // initialize objects - Circle.init(container, circleSize); - Slider.init(container, circleSize, beatmap); + Circle.init(container, diameter); + Slider.init(container, diameter, beatmap); Spinner.init(container, overallDifficulty); - Curve.init(container.getWidth(), container.getHeight(), circleSize, (Options.isBeatmapSkinIgnored()) ? + Curve.init(container.getWidth(), container.getHeight(), diameter, (Options.isBeatmapSkinIgnored()) ? Options.getSkin().getSliderBorderColor() : beatmap.getSliderBorderColor()); // approachRate (hit object approach time) @@ -1438,9 +1493,9 @@ public class Game extends BasicGameState { // overallDifficulty (hit result time offsets) hitResultOffset = new int[GameData.HIT_MAX]; - hitResultOffset[GameData.HIT_300] = (int) (78 - (overallDifficulty * 6)); - hitResultOffset[GameData.HIT_100] = (int) (138 - (overallDifficulty * 8)); - hitResultOffset[GameData.HIT_50] = (int) (198 - (overallDifficulty * 10)); + hitResultOffset[GameData.HIT_300] = (int) (79.5f - (overallDifficulty * 6)); + hitResultOffset[GameData.HIT_100] = (int) (139.5f - (overallDifficulty * 8)); + hitResultOffset[GameData.HIT_50] = (int) (199.5f - (overallDifficulty * 10)); hitResultOffset[GameData.HIT_MISS] = (int) (500 - (overallDifficulty * 10)); //final float mult = 0.608f; //hitResultOffset[GameData.HIT_300] = (int) ((128 - (overallDifficulty * 9.6)) * mult); @@ -1454,6 +1509,14 @@ public class Game extends BasicGameState { // difficulty multiplier (scoring) data.calculateDifficultyMultiplier(beatmap.HPDrainRate, beatmap.circleSize, beatmap.overallDifficulty); + + // hit object fade-in time (TODO: formula) + fadeInTime = Math.min(375, (int) (approachTime / 2.5f)); + + // fade times ("Hidden" mod) + // TODO: find the actual formulas for this + hiddenDecayTime = (int) (approachTime / 3.6f); + hiddenTimeDiff = (int) (approachTime / 3.3f); } /** @@ -1477,6 +1540,22 @@ public class Game extends BasicGameState { */ public int getApproachTime() { return approachTime; } + /** + * Returns the amount of time for hit objects to fade in, in milliseconds. + */ + public int getFadeInTime() { return fadeInTime; } + + /** + * Returns the object decay time in the "Hidden" mod, in milliseconds. + */ + public int getHiddenDecayTime() { return hiddenDecayTime; } + + /** + * Returns the time before the hit object time by which the objects have + * completely faded in the "Hidden" mod, in milliseconds. + */ + public int getHiddenTimeDiff() { return hiddenTimeDiff; } + /** * Returns an array of hit result offset times, in milliseconds (indexed by GameData.HIT_* constants). */ @@ -1536,8 +1615,8 @@ public class Game extends BasicGameState { public synchronized void addReplayFrameAndRun(int x, int y, int keys, int time){ // "auto" and "autopilot" mods: use automatic cursor coordinates if (GameMod.AUTO.isActive() || GameMod.AUTOPILOT.isActive()) { - x = autoMouseX; - y = autoMouseY; + x = (int) autoMousePosition.x; + y = (int) autoMousePosition.y; } ReplayFrame frame = addReplayFrame(x, y, keys, time); @@ -1611,17 +1690,13 @@ public class Game extends BasicGameState { * @param endX the ending x coordinate * @param endY the ending y coordinate * @param t the t value [0, 1] - * @return the [x,y] coordinates + * @return the position vector */ - private float[] getPointAt(float startX, float startY, float endX, float endY, float t) { + private Vec2f getPointAt(float startX, float startY, float endX, float endY, float t) { // "autopilot" mod: move quicker between objects if (GameMod.AUTOPILOT.isActive()) t = Utils.clamp(t * 2f, 0f, 1f); - - float[] xy = new float[2]; - xy[0] = startX + (endX - startX) * t; - xy[1] = startY + (endY - startY) * t; - return xy; + return new Vec2f(startX + (endX - startX) * t, startY + (endY - startY) * t); } /** @@ -1715,9 +1790,9 @@ public class Game extends BasicGameState { // possible special case: if slider end in the stack, // all next hit objects in stack move right down if (hitObjectN.isSlider()) { - float[] p1 = gameObjects[i].getPointAt(hitObjectI.getTime()); - float[] p2 = gameObjects[n].getPointAt(gameObjects[n].getEndTime()); - float distance = Utils.distance(p1[0], p1[1], p2[0], p2[1]); + Vec2f p1 = gameObjects[i].getPointAt(hitObjectI.getTime()); + Vec2f p2 = gameObjects[n].getPointAt(gameObjects[n].getEndTime()); + float distance = Utils.distance(p1.x, p1.y, p2.x, p2.y); // check if hit object part of this stack if (distance < STACK_LENIENCE * HitObject.getXMultiplier()) { @@ -1725,7 +1800,7 @@ public class Game extends BasicGameState { for (int j = n + 1; j <= i; j++) { HitObject hitObjectJ = beatmap.objects[j]; p1 = gameObjects[j].getPointAt(hitObjectJ.getTime()); - distance = Utils.distance(p1[0], p1[1], p2[0], p2[1]); + distance = Utils.distance(p1.x, p1.y, p2.x, p2.y); // hit object below slider end if (distance < STACK_LENIENCE * HitObject.getXMultiplier()) @@ -1753,4 +1828,14 @@ public class Game extends BasicGameState { gameObjects[i].updatePosition(); } } + + /** + * Returns true if the coordinates are within the music position bar bounds. + * @param cx the x coordinate + * @param cy the y coordinate + */ + private boolean musicPositionBarContains(float cx, float cy) { + return ((cx > musicBarX && cx < musicBarX + musicBarWidth) && + (cy > musicBarY && cy < musicBarY + musicBarHeight)); + } } diff --git a/src/itdelatrisu/opsu/states/GamePauseMenu.java b/src/itdelatrisu/opsu/states/GamePauseMenu.java index e73e5a2a..35f943f8 100644 --- a/src/itdelatrisu/opsu/states/GamePauseMenu.java +++ b/src/itdelatrisu/opsu/states/GamePauseMenu.java @@ -27,6 +27,7 @@ import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import org.lwjgl.input.Keyboard; import org.newdawn.slick.Color; @@ -61,7 +62,7 @@ public class GamePauseMenu extends BasicGameState { private GameContainer container; private StateBasedGame game; private Input input; - private int state; + private final int state; private Game gameState; public GamePauseMenu(int state) { @@ -86,10 +87,10 @@ public class GamePauseMenu extends BasicGameState { // don't draw default background if button skinned and background unskinned boolean buttonsSkinned = - GameImage.PAUSE_CONTINUE.hasSkinImage() || - GameImage.PAUSE_RETRY.hasSkinImage() || - GameImage.PAUSE_BACK.hasSkinImage(); - if (!buttonsSkinned || bg.hasSkinImage()) + GameImage.PAUSE_CONTINUE.hasBeatmapSkinImage() || + GameImage.PAUSE_RETRY.hasBeatmapSkinImage() || + GameImage.PAUSE_BACK.hasBeatmapSkinImage(); + if (!buttonsSkinned || bg.hasBeatmapSkinImage()) bg.getImage().draw(); else g.setBackground(Color.black); @@ -133,7 +134,7 @@ public class GamePauseMenu extends BasicGameState { SoundController.playSound(SoundEffect.MENUBACK); ((SongMenu) game.getState(Opsu.STATE_SONGMENU)).resetGameDataOnLoad(); MusicController.playAt(MusicController.getBeatmap().previewTime, true); - if (UI.getCursor().isSkinned()) + if (UI.getCursor().isBeatmapSkinned()) UI.getCursor().reset(); game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); } else { @@ -187,7 +188,7 @@ public class GamePauseMenu extends BasicGameState { MusicController.playAt(MusicController.getBeatmap().previewTime, true); else MusicController.resume(); - if (UI.getCursor().isSkinned()) + if (UI.getCursor().isBeatmapSkinned()) UI.getCursor().reset(); game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); } @@ -227,6 +228,14 @@ public class GamePauseMenu extends BasicGameState { continueButton = new MenuButton(GameImage.PAUSE_CONTINUE.getImage(), width / 2f, height * 0.25f); retryButton = new MenuButton(GameImage.PAUSE_RETRY.getImage(), width / 2f, height * 0.5f); backButton = new MenuButton(GameImage.PAUSE_BACK.getImage(), width / 2f, height * 0.75f); + final int buttonAnimationDuration = 300; + continueButton.setHoverAnimationDuration(buttonAnimationDuration); + retryButton.setHoverAnimationDuration(buttonAnimationDuration); + backButton.setHoverAnimationDuration(buttonAnimationDuration); + final AnimationEquation buttonAnimationEquation = AnimationEquation.IN_OUT_BACK; + continueButton.setHoverAnimationEquation(buttonAnimationEquation); + retryButton.setHoverAnimationEquation(buttonAnimationEquation); + backButton.setHoverAnimationEquation(buttonAnimationEquation); continueButton.setHoverExpand(); retryButton.setHoverExpand(); backButton.setHoverExpand(); diff --git a/src/itdelatrisu/opsu/states/GameRanking.java b/src/itdelatrisu/opsu/states/GameRanking.java index 4fcc18ec..925f7f48 100644 --- a/src/itdelatrisu/opsu/states/GameRanking.java +++ b/src/itdelatrisu/opsu/states/GameRanking.java @@ -20,6 +20,7 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameData; import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.GameMod; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; @@ -68,7 +69,7 @@ public class GameRanking extends BasicGameState { // game-related variables private GameContainer container; private StateBasedGame game; - private int state; + private final int state; private Input input; public GameRanking(int state) { @@ -105,7 +106,7 @@ public class GameRanking extends BasicGameState { Beatmap beatmap = MusicController.getBeatmap(); // background - if (!beatmap.drawBG(width, height, 0.7f, true)) + if (!beatmap.drawBackground(width, height, 0.7f, true)) GameImage.PLAYFIELD.getImage().draw(0,0); // ranking screen elements @@ -113,7 +114,7 @@ public class GameRanking extends BasicGameState { // buttons replayButton.draw(); - if (data.isGameplay()) + if (data.isGameplay() && !GameMod.AUTO.isActive()) retryButton.draw(); UI.getBackButton().draw(); @@ -175,7 +176,8 @@ public class GameRanking extends BasicGameState { // replay Game gameState = (Game) game.getState(Opsu.STATE_GAME); boolean returnToGame = false; - if (replayButton.contains(x, y)) { + boolean replayButtonPressed = replayButton.contains(x, y); + if (replayButtonPressed && !(data.isGameplay() && GameMod.AUTO.isActive())) { Replay r = data.getReplay(null, null); if (r != null) { try { @@ -194,7 +196,9 @@ public class GameRanking extends BasicGameState { } // retry - else if (data.isGameplay() && retryButton.contains(x, y)) { + else if (data.isGameplay() && + (!GameMod.AUTO.isActive() && retryButton.contains(x, y)) || + (GameMod.AUTO.isActive() && replayButtonPressed)) { gameState.setReplay(null); gameState.setRestart(Game.Restart.MANUAL); returnToGame = true; @@ -221,7 +225,7 @@ public class GameRanking extends BasicGameState { } else { SoundController.playSound(SoundEffect.APPLAUSE); retryButton.resetHover(); - replayButton.setY(replayY); + replayButton.setY(!GameMod.AUTO.isActive() ? replayY : retryY); } replayButton.resetHover(); } @@ -239,12 +243,11 @@ public class GameRanking extends BasicGameState { */ private void returnToSongMenu() { SoundController.playSound(SoundEffect.MENUBACK); - if (data.isGameplay()) { - SongMenu songMenu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); - songMenu.resetGameDataOnLoad(); + SongMenu songMenu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); + if (data.isGameplay()) songMenu.resetTrackOnLoad(); - } - if (UI.getCursor().isSkinned()) + songMenu.resetGameDataOnLoad(); + if (UI.getCursor().isBeatmapSkinned()) UI.getCursor().reset(); game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); } diff --git a/src/itdelatrisu/opsu/states/MainMenu.java b/src/itdelatrisu/opsu/states/MainMenu.java index be484887..f0901df8 100644 --- a/src/itdelatrisu/opsu/states/MainMenu.java +++ b/src/itdelatrisu/opsu/states/MainMenu.java @@ -31,9 +31,13 @@ import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.beatmap.BeatmapSetNode; import itdelatrisu.opsu.downloads.Updater; import itdelatrisu.opsu.states.ButtonMenu.MenuState; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.MenuButton.Expand; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.awt.Desktop; import java.io.IOException; @@ -61,7 +65,7 @@ import org.newdawn.slick.state.transition.FadeOutTransition; */ public class MainMenu extends BasicGameState { /** Idle time, in milliseconds, before returning the logo to its original position. */ - private static final short MOVE_DELAY = 5000; + private static final short LOGO_IDLE_DELAY = 10000; /** Max alpha level of the menu background. */ private static final float BG_MAX_ALPHA = 0.9f; @@ -69,12 +73,21 @@ public class MainMenu extends BasicGameState { /** Logo button that reveals other buttons on click. */ private MenuButton logo; - /** Whether or not the logo has been clicked. */ - private boolean logoClicked = false; + /** Logo states. */ + private enum LogoState { DEFAULT, OPENING, OPEN, CLOSING } + + /** Current logo state. */ + private LogoState logoState = LogoState.DEFAULT; /** Delay timer, in milliseconds, before starting to move the logo back to the center. */ private int logoTimer = 0; + /** Logo horizontal offset for opening and closing actions. */ + private AnimatedValue logoOpen, logoClose; + + /** Logo button alpha levels. */ + private AnimatedValue logoButtonAlpha; + /** Main "Play" and "Exit" buttons. */ private MenuButton playButton, exitButton; @@ -87,8 +100,8 @@ public class MainMenu extends BasicGameState { /** Button linking to repository. */ private MenuButton repoButton; - /** Button for installing updates. */ - private MenuButton updateButton; + /** Buttons for installing updates. */ + private MenuButton updateButton, restartButton; /** Application start time, for drawing the total running time. */ private long programStartTime; @@ -97,7 +110,7 @@ public class MainMenu extends BasicGameState { private Stack previous; /** Background alpha level (for fade-in effect). */ - private float bgAlpha = 0f; + private AnimatedValue bgAlpha = new AnimatedValue(1100, 0f, BG_MAX_ALPHA, AnimationEquation.LINEAR); /** Whether or not a notification was already sent upon entering. */ private boolean enterNotification = false; @@ -105,16 +118,11 @@ public class MainMenu extends BasicGameState { /** Music position bar coordinates and dimensions. */ private float musicBarX, musicBarY, musicBarWidth, musicBarHeight; - /** Music position bar background colors. */ - private static final Color - BG_NORMAL = new Color(0, 0, 0, 0.25f), - BG_HOVER = new Color(0, 0, 0, 0.5f); - // game-related variables private GameContainer container; private StateBasedGame game; private Input input; - private int state; + private final int state; public MainMenu(int state) { this.state = state; @@ -145,9 +153,18 @@ public class MainMenu extends BasicGameState { exitButton = new MenuButton(exitImg, width * 0.75f - exitOffset, (height / 2) + (exitImg.getHeight() / 2f) ); - logo.setHoverExpand(1.05f); - playButton.setHoverExpand(1.05f); - exitButton.setHoverExpand(1.05f); + final int logoAnimationDuration = 350; + logo.setHoverAnimationDuration(logoAnimationDuration); + playButton.setHoverAnimationDuration(logoAnimationDuration); + exitButton.setHoverAnimationDuration(logoAnimationDuration); + final AnimationEquation logoAnimationEquation = AnimationEquation.IN_OUT_BACK; + logo.setHoverAnimationEquation(logoAnimationEquation); + playButton.setHoverAnimationEquation(logoAnimationEquation); + exitButton.setHoverAnimationEquation(logoAnimationEquation); + final float logoHoverScale = 1.08f; + logo.setHoverExpand(logoHoverScale); + playButton.setHoverExpand(logoHoverScale); + exitButton.setHoverExpand(logoHoverScale); // initialize music buttons int musicWidth = GameImage.MUSIC_PLAY.getImage().getWidth(); @@ -170,24 +187,40 @@ public class MainMenu extends BasicGameState { // initialize downloads button Image dlImg = GameImage.DOWNLOADS.getImage(); downloadsButton = new MenuButton(dlImg, width - dlImg.getWidth() / 2f, height / 2f); + downloadsButton.setHoverAnimationDuration(350); + downloadsButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_BACK); downloadsButton.setHoverExpand(1.03f, Expand.LEFT); // initialize repository button float startX = width * 0.997f, startY = height * 0.997f; - if (Desktop.isDesktopSupported()) { // only if a webpage can be opened + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { // only if a webpage can be opened Image repoImg = GameImage.REPOSITORY.getImage(); repoButton = new MenuButton(repoImg, startX - repoImg.getWidth(), startY - repoImg.getHeight() ); + repoButton.setHoverAnimationDuration(350); + repoButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_BACK); repoButton.setHoverExpand(); - startX -= repoImg.getWidth() * 1.75f; - } else - startX -= width * 0.005f; + } - // initialize update button - Image bangImg = GameImage.BANG.getImage(); - updateButton = new MenuButton(bangImg, startX - bangImg.getWidth(), startY - bangImg.getHeight()); - updateButton.setHoverExpand(1.15f); + // initialize update buttons + float updateX = width / 2f, updateY = height * 17 / 18f; + Image downloadImg = GameImage.DOWNLOAD.getImage(); + updateButton = new MenuButton(downloadImg, updateX, updateY); + updateButton.setHoverAnimationDuration(400); + updateButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_QUAD); + updateButton.setHoverExpand(1.1f); + Image updateImg = GameImage.UPDATE.getImage(); + restartButton = new MenuButton(updateImg, updateX, updateY); + restartButton.setHoverAnimationDuration(2000); + restartButton.setHoverAnimationEquation(AnimationEquation.LINEAR); + restartButton.setHoverRotate(360); + + // logo animations + float centerOffsetX = width / 5f; + logoOpen = new AnimatedValue(400, 0, centerOffsetX, AnimationEquation.OUT_QUAD); + logoClose = new AnimatedValue(2200, centerOffsetX, 0, AnimationEquation.OUT_QUAD); + logoButtonAlpha = new AnimatedValue(200, 0f, 1f, AnimationEquation.LINEAR); reset(); } @@ -201,27 +234,27 @@ public class MainMenu extends BasicGameState { // draw background Beatmap beatmap = MusicController.getBeatmap(); if (Options.isDynamicBackgroundEnabled() && - beatmap != null && beatmap.drawBG(width, height, bgAlpha, true)) + beatmap != null && beatmap.drawBackground(width, height, bgAlpha.getValue(), true)) ; else { Image bg = GameImage.MENU_BG.getImage(); - bg.setAlpha(bgAlpha); + bg.setAlpha(bgAlpha.getValue()); bg.draw(); } // top/bottom horizontal bars - float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; - Utils.COLOR_BLACK_ALPHA.a = 0.2f; - g.setColor(Utils.COLOR_BLACK_ALPHA); + float oldAlpha = Colors.BLACK_ALPHA.a; + Colors.BLACK_ALPHA.a = 0.2f; + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, height / 9f); g.fillRect(0, height * 8 / 9f, width, height / 9f); - Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + Colors.BLACK_ALPHA.a = oldAlpha; // draw downloads button downloadsButton.draw(); // draw buttons - if (logoTimer > 0) { + if (logoState == LogoState.OPEN || logoState == LogoState.CLOSING) { playButton.draw(); exitButton.draw(); } @@ -237,7 +270,7 @@ public class MainMenu extends BasicGameState { // draw music position bar int mouseX = input.getMouseX(), mouseY = input.getMouseY(); - g.setColor((musicPositionBarContains(mouseX, mouseY)) ? BG_HOVER : BG_NORMAL); + g.setColor((musicPositionBarContains(mouseX, mouseY)) ? Colors.BLACK_BG_HOVER : Colors.BLACK_BG_NORMAL); g.fillRoundRect(musicBarX, musicBarY, musicBarWidth, musicBarHeight, 4); g.setColor(Color.white); if (!MusicController.isTrackLoading() && beatmap != null) { @@ -251,35 +284,26 @@ public class MainMenu extends BasicGameState { // draw update button if (Updater.get().showButton()) { - Color updateColor = null; - switch (Updater.get().getStatus()) { - case UPDATE_AVAILABLE: - updateColor = Color.red; - break; - case UPDATE_DOWNLOADED: - updateColor = Color.green; - break; - case UPDATE_DOWNLOADING: - updateColor = Color.yellow; - break; - default: - updateColor = Color.white; - break; - } - updateButton.draw(updateColor); + Updater.Status status = Updater.get().getStatus(); + if (status == Updater.Status.UPDATE_AVAILABLE || status == Updater.Status.UPDATE_DOWNLOADING) + updateButton.draw(); + else if (status == Updater.Status.UPDATE_DOWNLOADED) + restartButton.draw(); } // draw text float marginX = width * 0.015f, topMarginY = height * 0.01f, bottomMarginY = height * 0.015f; - g.setFont(Utils.FONT_MEDIUM); - float lineHeight = Utils.FONT_MEDIUM.getLineHeight() * 0.925f; + g.setFont(Fonts.MEDIUM); + float lineHeight = Fonts.MEDIUM.getLineHeight() * 0.925f; g.drawString(String.format("Loaded %d songs and %d beatmaps.", BeatmapSetList.get().getMapSetCount(), BeatmapSetList.get().getMapCount()), marginX, topMarginY); if (MusicController.isTrackLoading()) g.drawString("Track loading...", marginX, topMarginY + lineHeight); else if (MusicController.trackExists()) { - if (Options.useUnicodeMetadata()) // load glyphs - Utils.loadGlyphs(Utils.FONT_MEDIUM, beatmap.titleUnicode, beatmap.artistUnicode); + if (Options.useUnicodeMetadata()) { // load glyphs + Fonts.loadGlyphs(Fonts.MEDIUM, beatmap.titleUnicode); + Fonts.loadGlyphs(Fonts.MEDIUM, beatmap.artistUnicode); + } g.drawString((MusicController.isPlaying()) ? "Now Playing:" : "Paused:", marginX, topMarginY + lineHeight); g.drawString(String.format("%s: %s", beatmap.getArtist(), beatmap.getTitle()), marginX + 25, topMarginY + (lineHeight * 2)); } @@ -305,7 +329,10 @@ public class MainMenu extends BasicGameState { exitButton.hoverUpdate(delta, mouseX, mouseY, 0.25f); if (repoButton != null) repoButton.hoverUpdate(delta, mouseX, mouseY); - updateButton.hoverUpdate(delta, mouseX, mouseY); + if (Updater.get().showButton()) { + updateButton.autoHoverUpdate(delta, true); + restartButton.autoHoverUpdate(delta, false); + } downloadsButton.hoverUpdate(delta, mouseX, mouseY); // ensure only one button is in hover state at once boolean noHoverUpdate = musicPositionBarContains(mouseX, mouseY); @@ -322,46 +349,46 @@ public class MainMenu extends BasicGameState { MusicController.toggleTrackDimmed(0.33f); // fade in background - if (bgAlpha < BG_MAX_ALPHA) { - bgAlpha += delta / 1000f; - if (bgAlpha > BG_MAX_ALPHA) - bgAlpha = BG_MAX_ALPHA; - } + Beatmap beatmap = MusicController.getBeatmap(); + if (!(Options.isDynamicBackgroundEnabled() && beatmap != null && beatmap.isBackgroundLoading())) + bgAlpha.update(delta); // buttons - if (logoClicked) { - if (logoTimer == 0) { // shifting to left - if (logo.getX() > container.getWidth() / 3.3f) - logo.setX(logo.getX() - delta); - else - logoTimer = 1; - } else if (logoTimer >= MOVE_DELAY) // timer over: shift back to center - logoClicked = false; - else { // increment timer + int centerX = container.getWidth() / 2; + float currentLogoButtonAlpha; + switch (logoState) { + case DEFAULT: + break; + case OPENING: + if (logoOpen.update(delta)) // shifting to left + logo.setX(centerX - logoOpen.getValue()); + else { + logoState = LogoState.OPEN; + logoTimer = 0; + logoButtonAlpha.setTime(0); + } + break; + case OPEN: + if (logoButtonAlpha.update(delta)) { // fade in buttons + currentLogoButtonAlpha = logoButtonAlpha.getValue(); + playButton.getImage().setAlpha(currentLogoButtonAlpha); + exitButton.getImage().setAlpha(currentLogoButtonAlpha); + } else if (logoTimer >= LOGO_IDLE_DELAY) { // timer over: shift back to center + logoState = LogoState.CLOSING; + logoClose.setTime(0); + logoTimer = 0; + } else // increment timer logoTimer += delta; - if (logoTimer <= 500) { - // fade in buttons - playButton.getImage().setAlpha(logoTimer / 400f); - exitButton.getImage().setAlpha(logoTimer / 400f); - } - } - } else { - // fade out buttons - if (logoTimer > 0) { - float alpha = playButton.getImage().getAlpha(); - if (alpha > 0f) { - playButton.getImage().setAlpha(alpha - (delta / 200f)); - exitButton.getImage().setAlpha(alpha - (delta / 200f)); - } else - logoTimer = 0; - } - - // move back to original location - if (logo.getX() < container.getWidth() / 2) { - logo.setX(logo.getX() + (delta / 3f)); - if (logo.getX() > container.getWidth() / 2) - logo.setX(container.getWidth() / 2); + break; + case CLOSING: + if (logoButtonAlpha.update(-delta)) { // fade out buttons + currentLogoButtonAlpha = logoButtonAlpha.getValue(); + playButton.getImage().setAlpha(currentLogoButtonAlpha); + exitButton.getImage().setAlpha(currentLogoButtonAlpha); } + if (logoClose.update(delta)) // shifting to right + logo.setX(centerX - logoClose.getValue()); + break; } // tooltips @@ -373,8 +400,12 @@ public class MainMenu extends BasicGameState { UI.updateTooltip(delta, "Next track", false); else if (musicPrevious.contains(mouseX, mouseY)) UI.updateTooltip(delta, "Previous track", false); - else if (Updater.get().showButton() && updateButton.contains(mouseX, mouseY)) - UI.updateTooltip(delta, Updater.get().getStatus().getDescription(), true); + else if (Updater.get().showButton()) { + Updater.Status status = Updater.get().getStatus(); + if (((status == Updater.Status.UPDATE_AVAILABLE || status == Updater.Status.UPDATE_DOWNLOADING) && updateButton.contains(mouseX, mouseY)) || + (status == Updater.Status.UPDATE_DOWNLOADED && restartButton.contains(mouseX, mouseY))) + UI.updateTooltip(delta, status.getDescription(), true); + } } @Override @@ -412,8 +443,8 @@ public class MainMenu extends BasicGameState { musicPrevious.resetHover(); if (repoButton != null && !repoButton.contains(mouseX, mouseY)) repoButton.resetHover(); - if (!updateButton.contains(mouseX, mouseY)) - updateButton.resetHover(); + updateButton.resetHover(); + restartButton.resetHover(); if (!downloadsButton.contains(mouseX, mouseY)) downloadsButton.resetHover(); } @@ -449,71 +480,85 @@ public class MainMenu extends BasicGameState { MusicController.resume(); UI.sendBarNotification("Play"); } + return; } else if (musicNext.contains(x, y)) { nextTrack(); UI.sendBarNotification(">> Next"); + return; } else if (musicPrevious.contains(x, y)) { if (!previous.isEmpty()) { SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU); menu.setFocus(BeatmapSetList.get().getBaseNode(previous.pop()), -1, true, false); if (Options.isDynamicBackgroundEnabled()) - bgAlpha = 0f; + bgAlpha.setTime(0); } else MusicController.setPosition(0); UI.sendBarNotification("<< Previous"); + return; } // downloads button actions - else if (downloadsButton.contains(x, y)) { + if (downloadsButton.contains(x, y)) { SoundController.playSound(SoundEffect.MENUHIT); game.enterState(Opsu.STATE_DOWNLOADSMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black)); + return; } // repository button actions - else if (repoButton != null && repoButton.contains(x, y)) { + if (repoButton != null && repoButton.contains(x, y)) { try { Desktop.getDesktop().browse(Options.REPOSITORY_URI); + } catch (UnsupportedOperationException e) { + UI.sendBarNotification("The repository web page could not be opened."); } catch (IOException e) { ErrorHandler.error("Could not browse to repository URI.", e, false); } + return; } // update button actions - else if (Updater.get().showButton() && updateButton.contains(x, y)) { - switch (Updater.get().getStatus()) { - case UPDATE_AVAILABLE: + if (Updater.get().showButton()) { + Updater.Status status = Updater.get().getStatus(); + if (updateButton.contains(x, y) && status == Updater.Status.UPDATE_AVAILABLE) { SoundController.playSound(SoundEffect.MENUHIT); Updater.get().startDownload(); - break; - case UPDATE_DOWNLOADED: + updateButton.removeHoverEffects(); + updateButton.setHoverAnimationDuration(800); + updateButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_QUAD); + updateButton.setHoverFade(0.6f); + return; + } else if (restartButton.contains(x, y) && status == Updater.Status.UPDATE_DOWNLOADED) { SoundController.playSound(SoundEffect.MENUHIT); Updater.get().prepareUpdate(); container.setForceExit(false); container.exit(); - break; - default: - break; + return; } } // start moving logo (if clicked) - else if (!logoClicked) { + if (logoState == LogoState.DEFAULT || logoState == LogoState.CLOSING) { if (logo.contains(x, y, 0.25f)) { - logoClicked = true; + logoState = LogoState.OPENING; + logoOpen.setTime(0); logoTimer = 0; playButton.getImage().setAlpha(0f); exitButton.getImage().setAlpha(0f); SoundController.playSound(SoundEffect.MENUHIT); + return; } } // other button actions (if visible) - else if (logoClicked) { + else if (logoState == LogoState.OPEN || logoState == LogoState.OPENING) { if (logo.contains(x, y, 0.25f) || playButton.contains(x, y, 0.25f)) { SoundController.playSound(SoundEffect.MENUHIT); enterSongMenu(); - } else if (exitButton.contains(x, y, 0.25f)) + return; + } else if (exitButton.contains(x, y, 0.25f)) { container.exit(); + return; + } } } @@ -532,8 +577,9 @@ public class MainMenu extends BasicGameState { break; case Input.KEY_P: SoundController.playSound(SoundEffect.MENUHIT); - if (!logoClicked) { - logoClicked = true; + if (logoState == LogoState.DEFAULT || logoState == LogoState.CLOSING) { + logoState = LogoState.OPENING; + logoOpen.setTime(0); logoTimer = 0; playButton.getImage().setAlpha(0f); exitButton.getImage().setAlpha(0f); @@ -581,8 +627,11 @@ public class MainMenu extends BasicGameState { public void reset() { // reset logo logo.setX(container.getWidth() / 2); - logoClicked = false; + logoOpen.setTime(0); + logoClose.setTime(0); + logoButtonAlpha.setTime(0); logoTimer = 0; + logoState = LogoState.DEFAULT; logo.resetHover(); playButton.resetHover(); @@ -594,6 +643,7 @@ public class MainMenu extends BasicGameState { if (repoButton != null) repoButton.resetHover(); updateButton.resetHover(); + restartButton.resetHover(); downloadsButton.resetHover(); } @@ -611,7 +661,7 @@ public class MainMenu extends BasicGameState { previous.add(node.index); } if (Options.isDynamicBackgroundEnabled() && !sameAudio && !MusicController.isThemePlaying()) - bgAlpha = 0f; + bgAlpha.setTime(0); } /** diff --git a/src/itdelatrisu/opsu/states/OptionsMenu.java b/src/itdelatrisu/opsu/states/OptionsMenu.java index 6f303701..6cdee288 100644 --- a/src/itdelatrisu/opsu/states/OptionsMenu.java +++ b/src/itdelatrisu/opsu/states/OptionsMenu.java @@ -26,6 +26,8 @@ import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.MenuButton; import itdelatrisu.opsu.ui.UI; @@ -86,14 +88,18 @@ public class OptionsMenu extends BasicGameState { GameOption.DISABLE_MOUSE_WHEEL, GameOption.DISABLE_MOUSE_BUTTONS, GameOption.CURSOR_SIZE, - GameOption.NEW_CURSOR + GameOption.NEW_CURSOR, + GameOption.DISABLE_CURSOR }), CUSTOM ("Custom", new GameOption[] { GameOption.FIXED_CS, GameOption.FIXED_HP, GameOption.FIXED_AR, GameOption.FIXED_OD, - GameOption.CHECKPOINT + GameOption.CHECKPOINT, + GameOption.REPLAY_SEEKING, + GameOption.DISABLE_UPDATER, + GameOption.ENABLE_WATCH_SERVICE }); /** Total number of tabs. */ @@ -110,10 +116,10 @@ public class OptionsMenu extends BasicGameState { private static OptionTab[] values = values(); /** Tab name. */ - private String name; + private final String name; /** Options array. */ - public GameOption[] options; + public final GameOption[] options; /** Associated tab button. */ public MenuButton button; @@ -163,7 +169,7 @@ public class OptionsMenu extends BasicGameState { private StateBasedGame game; private Input input; private Graphics g; - private int state; + private final int state; public OptionsMenu(int state) { this.state = state; @@ -182,8 +188,8 @@ public class OptionsMenu extends BasicGameState { // option tabs Image tabImage = GameImage.MENU_TAB.getImage(); - float tabX = width * 0.032f + Utils.FONT_DEFAULT.getWidth("Change the way opsu! behaves") + (tabImage.getWidth() / 2); - float tabY = Utils.FONT_XLARGE.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() + + float tabX = width * 0.032f + Fonts.DEFAULT.getWidth("Change the way opsu! behaves") + (tabImage.getWidth() / 2); + float tabY = Fonts.XLARGE.getLineHeight() + Fonts.DEFAULT.getLineHeight() + height * 0.015f - (tabImage.getHeight() / 2f); int tabOffset = Math.min(tabImage.getWidth(), width / OptionTab.SIZE); for (OptionTab tab : OptionTab.values()) @@ -198,22 +204,19 @@ public class OptionsMenu extends BasicGameState { @Override public void render(GameContainer container, StateBasedGame game, Graphics g) throws SlickException { - g.setBackground(Utils.COLOR_BLACK_ALPHA); - int width = container.getWidth(); int height = container.getHeight(); int mouseX = input.getMouseX(), mouseY = input.getMouseY(); - float lineY = OptionTab.DISPLAY.button.getY() + (GameImage.MENU_TAB.getImage().getHeight() / 2f); + + // background + GameImage.OPTIONS_BG.getImage().draw(); // title float marginX = width * 0.015f, marginY = height * 0.01f; - Utils.FONT_XLARGE.drawString(marginX, marginY, "Options", Color.white); - Utils.FONT_DEFAULT.drawString(marginX, marginY + Utils.FONT_XLARGE.getLineHeight() * 0.92f, + Fonts.XLARGE.drawString(marginX, marginY, "Options", Color.white); + Fonts.DEFAULT.drawString(marginX, marginY + Fonts.XLARGE.getLineHeight() * 0.92f, "Change the way opsu! behaves", Color.white); - // background - GameImage.OPTIONS_BG.getImage().draw(0, lineY); - // game options g.setLineWidth(1f); GameOption hoverOption = (keyEntryLeft) ? GameOption.KEY_LEFT : @@ -241,6 +244,7 @@ public class OptionsMenu extends BasicGameState { currentTab.getName(), true, false); g.setColor(Color.white); g.setLineWidth(2f); + float lineY = OptionTab.DISPLAY.button.getY() + (GameImage.MENU_TAB.getImage().getHeight() / 2f); g.drawLine(0, lineY, width, lineY); g.resetLineWidth(); @@ -248,15 +252,15 @@ public class OptionsMenu extends BasicGameState { // key entry state if (keyEntryLeft || keyEntryRight) { - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, height); g.setColor(Color.white); String prompt = (keyEntryLeft) ? "Please press the new left-click key." : "Please press the new right-click key."; - Utils.FONT_LARGE.drawString( - (width / 2) - (Utils.FONT_LARGE.getWidth(prompt) / 2), - (height / 2) - Utils.FONT_LARGE.getLineHeight(), prompt + Fonts.LARGE.drawString( + (width / 2) - (Fonts.LARGE.getWidth(prompt) / 2), + (height / 2) - Fonts.LARGE.getLineHeight(), prompt ); } @@ -413,14 +417,14 @@ public class OptionsMenu extends BasicGameState { */ private void drawOption(GameOption option, int pos, boolean focus) { int width = container.getWidth(); - int textHeight = Utils.FONT_LARGE.getLineHeight(); + int textHeight = Fonts.LARGE.getLineHeight(); float y = textY + (pos * offsetY); Color color = (focus) ? Color.cyan : Color.white; - Utils.FONT_LARGE.drawString(width / 30, y, option.getName(), color); - Utils.FONT_LARGE.drawString(width / 2, y, option.getValueString(), color); - Utils.FONT_SMALL.drawString(width / 30, y + textHeight, option.getDescription(), color); - g.setColor(Utils.COLOR_WHITE_ALPHA); + Fonts.LARGE.drawString(width / 30, y, option.getName(), color); + Fonts.LARGE.drawString(width / 2, y, option.getValueString(), color); + Fonts.SMALL.drawString(width / 30, y + textHeight, option.getDescription(), color); + g.setColor(Colors.WHITE_ALPHA); g.drawLine(0, y + textHeight, width, y + textHeight); } @@ -433,7 +437,7 @@ public class OptionsMenu extends BasicGameState { if (y < textY || y > textY + (offsetY * maxOptionsScreen)) return null; - int index = (y - textY + Utils.FONT_LARGE.getLineHeight()) / offsetY; + int index = (y - textY + Fonts.LARGE.getLineHeight()) / offsetY; if (index >= currentTab.options.length) return null; diff --git a/src/itdelatrisu/opsu/states/SongMenu.java b/src/itdelatrisu/opsu/states/SongMenu.java index 2db90587..c26ff637 100644 --- a/src/itdelatrisu/opsu/states/SongMenu.java +++ b/src/itdelatrisu/opsu/states/SongMenu.java @@ -24,7 +24,6 @@ import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.GameMod; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OszUnpacker; import itdelatrisu.opsu.ScoreData; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MultiClip; @@ -32,18 +31,32 @@ import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.audio.SoundEffect; import itdelatrisu.opsu.beatmap.Beatmap; +import itdelatrisu.opsu.beatmap.BeatmapDifficultyCalculator; import itdelatrisu.opsu.beatmap.BeatmapParser; +import itdelatrisu.opsu.beatmap.BeatmapSet; import itdelatrisu.opsu.beatmap.BeatmapSetList; import itdelatrisu.opsu.beatmap.BeatmapSetNode; import itdelatrisu.opsu.beatmap.BeatmapSortOrder; +import itdelatrisu.opsu.beatmap.BeatmapWatchService; +import itdelatrisu.opsu.beatmap.BeatmapWatchService.BeatmapWatchServiceListener; +import itdelatrisu.opsu.beatmap.LRUCache; +import itdelatrisu.opsu.beatmap.OszUnpacker; import itdelatrisu.opsu.db.BeatmapDB; import itdelatrisu.opsu.db.ScoreDB; import itdelatrisu.opsu.states.ButtonMenu.MenuState; import itdelatrisu.opsu.ui.KinecticScrolling; +import itdelatrisu.opsu.ui.Colors; +import itdelatrisu.opsu.ui.Fonts; import itdelatrisu.opsu.ui.MenuButton; +import itdelatrisu.opsu.ui.StarStream; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.io.File; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent.Kind; import java.util.Map; import java.util.Stack; @@ -145,8 +158,8 @@ public class SongMenu extends BasicGameState { /** Button coordinate values. */ private float buttonX, buttonY, buttonOffset, buttonWidth, buttonHeight; - /** Current x offset of song buttons for mouse hover, in pixels. */ - private float hoverOffset = 0f; + /** Horizontal offset of song buttons for mouse hover, in pixels. */ + private AnimatedValue hoverOffset = new AnimatedValue(250, 0, MAX_HOVER_OFFSET, AnimationEquation.OUT_QUART); /** Current index of hovered song button. */ private BeatmapSetNode hoverIndex = null; @@ -209,11 +222,52 @@ public class SongMenu extends BasicGameState { /** The text length of the last string in the search TextField. */ private int lastSearchTextLength = -1; + /** Whether the song folder changed (notified via the watch service). */ + private boolean songFolderChanged = false; + + /** The last background image. */ + private File lastBackgroundImage; + + /** Background alpha level (for fade-in effect). */ + private AnimatedValue bgAlpha = new AnimatedValue(800, 0f, 1f, AnimationEquation.OUT_QUAD); + + /** Timer for animations when a new song node is selected. */ + private AnimatedValue songChangeTimer = new AnimatedValue(900, 0f, 1f, AnimationEquation.LINEAR); + + /** Timer for the music icon animation when a new song node is selected. */ + private AnimatedValue musicIconBounceTimer = new AnimatedValue(350, 0f, 1f, AnimationEquation.LINEAR); + + /** + * Beatmaps whose difficulties were recently computed (if flag is non-null). + * Unless the Boolean flag is null, then upon removal, the beatmap's objects will + * be cleared (to be garbage collected). If the flag is true, also clear the + * beatmap's array fields (timing points, etc.). + */ + @SuppressWarnings("serial") + private LRUCache beatmapsCalculated = new LRUCache(12) { + @Override + public void eldestRemoved(Map.Entry eldest) { + Boolean b = eldest.getValue(); + if (b != null) { + Beatmap beatmap = eldest.getKey(); + beatmap.objects = null; + if (b) { + beatmap.timingPoints = null; + beatmap.breaks = null; + beatmap.combo = null; + } + } + } + }; + + /** The star stream. */ + private StarStream starStream; + // game-related variables private GameContainer container; private StateBasedGame game; private Input input; - private int state; + private final int state; public SongMenu(int state) { this.state = state; @@ -231,8 +285,8 @@ public class SongMenu extends BasicGameState { // header/footer coordinates headerY = height * 0.0075f + GameImage.MENU_MUSICNOTE.getImage().getHeight() + - Utils.FONT_BOLD.getLineHeight() + Utils.FONT_DEFAULT.getLineHeight() + - Utils.FONT_SMALL.getLineHeight(); + Fonts.BOLD.getLineHeight() + Fonts.DEFAULT.getLineHeight() + + Fonts.SMALL.getLineHeight(); footerY = height - GameImage.SELECTION_MODS.getImage().getHeight(); // initialize sorts @@ -253,11 +307,11 @@ public class SongMenu extends BasicGameState { buttonOffset = (footerY - headerY - DIVIDER_LINE_WIDTH) / MAX_SONG_BUTTONS; // search - int textFieldX = (int) (width * 0.7125f + Utils.FONT_BOLD.getWidth("Search: ")); - int textFieldY = (int) (headerY + Utils.FONT_BOLD.getLineHeight() / 2); + int textFieldX = (int) (width * 0.7125f + Fonts.BOLD.getWidth("Search: ")); + int textFieldY = (int) (headerY + Fonts.BOLD.getLineHeight() / 2); search = new TextField( - container, Utils.FONT_BOLD, textFieldX, textFieldY, - (int) (width * 0.99f) - textFieldX, Utils.FONT_BOLD.getLineHeight() + container, Fonts.BOLD, textFieldX, textFieldY, + (int) (width * 0.99f) - textFieldX, Fonts.BOLD.getLineHeight() ); search.setBackgroundColor(Color.transparent); search.setBorderColor(Color.transparent); @@ -287,6 +341,22 @@ public class SongMenu extends BasicGameState { int loaderDim = GameImage.MENU_MUSICNOTE.getImage().getWidth(); SpriteSheet spr = new SpriteSheet(GameImage.MENU_LOADER.getImage(), loaderDim, loaderDim); loader = new Animation(spr, 50); + + // beatmap watch service listener + final StateBasedGame game_ = game; + BeatmapWatchService.addListener(new BeatmapWatchServiceListener() { + @Override + public void eventReceived(Kind kind, Path child) { + if (!songFolderChanged && kind != StandardWatchEventKinds.ENTRY_MODIFY) { + songFolderChanged = true; + if (game_.getCurrentStateID() == Opsu.STATE_SONGMENU) + UI.sendBarNotification("Changes in Songs folder detected. Hit F5 to refresh."); + } + } + }); + + // star stream + starStream = new StarStream(width, height); } @Override @@ -300,23 +370,30 @@ public class SongMenu extends BasicGameState { // background if (focusNode != null) { - Beatmap focusNodeBeatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex); - if (!focusNodeBeatmap.drawBG(width, height, 1.0f, true)) + Beatmap focusNodeBeatmap = focusNode.getSelectedBeatmap(); + if (!focusNodeBeatmap.drawBackground(width, height, bgAlpha.getValue(), true)) GameImage.PLAYFIELD.getImage().draw(); } + // star stream + starStream.draw(); + // song buttons BeatmapSetNode node = startNode; + int startNodeOffsetoffset = 0; + if (node.prev != null) { + startNodeOffsetoffset = -1; + node = node.prev; + } g.setClip(0, (int) (headerY + DIVIDER_LINE_WIDTH / 2), width, (int) (footerY - headerY)); - for (int i = startNodeOffset; i < MAX_SONG_BUTTONS + 1 && node != null; i++, node = node.next) { + for (int i = startNodeOffset + startNodeOffsetoffset; i < MAX_SONG_BUTTONS + 1 && node != null; i++, node = node.next) { // draw the node - float offset = (node == hoverIndex) ? hoverOffset : 0f; + float offset = (node == hoverIndex) ? hoverOffset.getValue() : 0f; float ypos = buttonY + (i*buttonOffset) ; float mid = height/2 - ypos - buttonOffset/2; - final float circleRadi = 1000 * GameImage.getUIscale(); - //finds points along a very large circle - // x^2 = h^2 - y^2 - float t = circleRadi*circleRadi - (mid*mid); + final float circleRadi = 700 * GameImage.getUIscale(); + //finds points along a very large circle (x^2 = h^2 - y^2) + float t = circleRadi * circleRadi - (mid * mid); float xpos = (float)(t>0?Math.sqrt(t):0) - circleRadi + 50 * GameImage.getUIscale(); ScoreData[] scores = getScoreDataForNode(node, false); node.draw(buttonX - offset - xpos, ypos, @@ -336,7 +413,7 @@ public class SongMenu extends BasicGameState { MAX_SONG_BUTTONS * buttonOffset, width, headerY + DIVIDER_LINE_WIDTH / 2, 0, MAX_SONG_BUTTONS * buttonOffset, - Utils.COLOR_BLACK_ALPHA, Color.white, true); + Colors.BLACK_ALPHA, Color.white, true); } } @@ -345,14 +422,19 @@ public class SongMenu extends BasicGameState { ScoreData.clipToDownloadArea(g); int startScore = (int) (startScorePos.getPosition() / ScoreData.getButtonOffset()); int offset = (int) (-startScorePos.getPosition() + startScore * ScoreData.getButtonOffset()); - for (int i = 0; i < MAX_SCORE_BUTTONS + 1; i++) { - int rank = startScore + i; + + int scoreButtons = Math.min(focusScores.length - startScore, MAX_SCORE_BUTTONS + 1); + float timerScale = 1f - (1 / 3f) * ((MAX_SCORE_BUTTONS - scoreButtons) / (float) (MAX_SCORE_BUTTONS - 1)); + int duration = (int) (songChangeTimer.getDuration() * timerScale); + int segmentDuration = (int) ((2 / 3f) * songChangeTimer.getDuration()); + int time = songChangeTimer.getTime(); + for (int i = 0, rank = startScore; i < scoreButtons; i++, rank++) { if (rank < 0) continue; - if (rank >= focusScores.length) - break; long prevScore = (rank + 1 < focusScores.length) ? focusScores[rank + 1].score : -1; - focusScores[rank].draw(g, offset + i*ScoreData.getButtonOffset(), rank, prevScore, ScoreData.buttonContains(mouseX, mouseY-offset, i)); + float t = Utils.clamp((time - (i * (duration - segmentDuration) / scoreButtons)) / (float) segmentDuration, 0f, 1f); + boolean focus = (t >= 0.9999f && ScoreData.buttonContains(mouseX, mouseY - offset, i)); + focusScores[rank].draw(g, offset + i*ScoreData.getButtonOffset(), rank, prevScore, focus, t); } g.clearClip(); @@ -360,13 +442,12 @@ public class SongMenu extends BasicGameState { if (focusScores.length > MAX_SCORE_BUTTONS && ScoreData.areaContains(mouseX, mouseY)) ScoreData.drawScrollbar(g, startScorePos.getPosition() , focusScores.length * ScoreData.getButtonOffset()); } - - + // top/bottom bars - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, headerY); g.fillRect(0, footerY, width, height - footerY); - g.setColor(Utils.COLOR_BLUE_DIVIDER); + g.setColor(Colors.BLUE_DIVIDER); g.setLineWidth(DIVIDER_LINE_WIDTH); g.drawLine(0, headerY, width, headerY); g.drawLine(0, footerY, width, footerY); @@ -379,8 +460,13 @@ public class SongMenu extends BasicGameState { Image musicNote = GameImage.MENU_MUSICNOTE.getImage(); if (MusicController.isTrackLoading()) loader.draw(marginX, marginY); - else - musicNote.draw(marginX, marginY); + else { + float t = musicIconBounceTimer.getValue() * 2f; + if (t > 1) + t = 2f - t; + float musicNoteScale = 1f + 0.3f * t; + musicNote.getScaledCopy(musicNoteScale).drawCentered(marginX + musicNote.getWidth() / 2f, marginY + musicNote.getHeight() / 2f); + } int iconWidth = musicNote.getWidth(); // song info text @@ -388,26 +474,49 @@ public class SongMenu extends BasicGameState { songInfo = focusNode.getInfo(); if (Options.useUnicodeMetadata()) { // load glyphs Beatmap beatmap = focusNode.getBeatmapSet().get(0); - Utils.loadGlyphs(Utils.FONT_LARGE, beatmap.titleUnicode, beatmap.artistUnicode); + Fonts.loadGlyphs(Fonts.LARGE, beatmap.titleUnicode); + Fonts.loadGlyphs(Fonts.LARGE, beatmap.artistUnicode); } } marginX += 5; + Color c = Colors.WHITE_FADE; + float oldAlpha = c.a; + float t = AnimationEquation.OUT_QUAD.calc(songChangeTimer.getValue()); float headerTextY = marginY * 0.2f; - Utils.FONT_LARGE.drawString(marginX + iconWidth * 1.05f, headerTextY, songInfo[0], Color.white); - headerTextY += Utils.FONT_LARGE.getLineHeight() - 6; - Utils.FONT_DEFAULT.drawString(marginX + iconWidth * 1.05f, headerTextY, songInfo[1], Color.white); - headerTextY += Utils.FONT_DEFAULT.getLineHeight() - 2; - float speedModifier = GameMod.getSpeedMultiplier(); - Color color2 = (speedModifier == 1f) ? Color.white : - (speedModifier > 1f) ? Utils.COLOR_RED_HIGHLIGHT : Utils.COLOR_BLUE_HIGHLIGHT; - Utils.FONT_BOLD.drawString(marginX, headerTextY, songInfo[2], color2); - headerTextY += Utils.FONT_BOLD.getLineHeight() - 4; - Utils.FONT_DEFAULT.drawString(marginX, headerTextY, songInfo[3], Color.white); - headerTextY += Utils.FONT_DEFAULT.getLineHeight() - 4; - float multiplier = GameMod.getDifficultyMultiplier(); - Color color4 = (multiplier == 1f) ? Color.white : - (multiplier > 1f) ? Utils.COLOR_RED_HIGHLIGHT : Utils.COLOR_BLUE_HIGHLIGHT; - Utils.FONT_SMALL.drawString(marginX, headerTextY, songInfo[4], color4); + c.a = Math.min(t * songInfo.length / 1.5f, 1f); + if (c.a > 0) + Fonts.LARGE.drawString(marginX + iconWidth * 1.05f, headerTextY, songInfo[0], c); + headerTextY += Fonts.LARGE.getLineHeight() - 6; + c.a = Math.min((t - 1f / (songInfo.length * 1.5f)) * songInfo.length / 1.5f, 1f); + if (c.a > 0) + Fonts.DEFAULT.drawString(marginX + iconWidth * 1.05f, headerTextY, songInfo[1], c); + headerTextY += Fonts.DEFAULT.getLineHeight() - 2; + c.a = Math.min((t - 2f / (songInfo.length * 1.5f)) * songInfo.length / 1.5f, 1f); + if (c.a > 0) { + float speedModifier = GameMod.getSpeedMultiplier(); + Color color2 = (speedModifier == 1f) ? c : + (speedModifier > 1f) ? Colors.RED_HIGHLIGHT : Colors.BLUE_HIGHLIGHT; + float oldAlpha2 = color2.a; + color2.a = c.a; + Fonts.BOLD.drawString(marginX, headerTextY, songInfo[2], color2); + color2.a = oldAlpha2; + } + headerTextY += Fonts.BOLD.getLineHeight() - 4; + c.a = Math.min((t - 3f / (songInfo.length * 1.5f)) * songInfo.length / 1.5f, 1f); + if (c.a > 0) + Fonts.DEFAULT.drawString(marginX, headerTextY, songInfo[3], c); + headerTextY += Fonts.DEFAULT.getLineHeight() - 4; + c.a = Math.min((t - 4f / (songInfo.length * 1.5f)) * songInfo.length / 1.5f, 1f); + if (c.a > 0) { + float multiplier = GameMod.getDifficultyMultiplier(); + Color color4 = (multiplier == 1f) ? c : + (multiplier > 1f) ? Colors.RED_HIGHLIGHT : Colors.BLUE_HIGHLIGHT; + float oldAlpha4 = color4.a; + color4.a = c.a; + Fonts.SMALL.drawString(marginX, headerTextY, songInfo[4], color4); + color4.a = oldAlpha4; + } + c.a = oldAlpha; } // selection buttons @@ -440,38 +549,41 @@ public class SongMenu extends BasicGameState { int searchX = search.getX(), searchY = search.getY(); float searchBaseX = width * 0.7f; float searchTextX = width * 0.7125f; - float searchRectHeight = Utils.FONT_BOLD.getLineHeight() * 2; - float searchExtraHeight = Utils.FONT_DEFAULT.getLineHeight() * 0.7f; + float searchRectHeight = Fonts.BOLD.getLineHeight() * 2; + float searchExtraHeight = Fonts.DEFAULT.getLineHeight() * 0.7f; float searchProgress = (searchTransitionTimer < SEARCH_TRANSITION_TIME) ? ((float) searchTransitionTimer / SEARCH_TRANSITION_TIME) : 1f; - float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; + float oldAlpha = Colors.BLACK_ALPHA.a; if (searchEmpty) { searchRectHeight += (1f - searchProgress) * searchExtraHeight; - Utils.COLOR_BLACK_ALPHA.a = 0.5f - searchProgress * 0.3f; + Colors.BLACK_ALPHA.a = 0.5f - searchProgress * 0.3f; } else { searchRectHeight += searchProgress * searchExtraHeight; - Utils.COLOR_BLACK_ALPHA.a = 0.2f + searchProgress * 0.3f; + Colors.BLACK_ALPHA.a = 0.2f + searchProgress * 0.3f; } - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(searchBaseX, headerY + DIVIDER_LINE_WIDTH / 2, width - searchBaseX, searchRectHeight); - Utils.COLOR_BLACK_ALPHA.a = oldAlpha; - Utils.FONT_BOLD.drawString(searchTextX, searchY, "Search:", Utils.COLOR_GREEN_SEARCH); + Colors.BLACK_ALPHA.a = oldAlpha; + Fonts.BOLD.drawString(searchTextX, searchY, "Search:", Colors.GREEN_SEARCH); if (searchEmpty) - Utils.FONT_BOLD.drawString(searchX, searchY, "Type to search!", Color.white); + Fonts.BOLD.drawString(searchX, searchY, "Type to search!", Color.white); else { g.setColor(Color.white); // TODO: why is this needed to correctly position the TextField? search.setLocation(searchX - 3, searchY - 1); search.render(container, g); search.setLocation(searchX, searchY); - Utils.FONT_DEFAULT.drawString(searchTextX, searchY + Utils.FONT_BOLD.getLineHeight(), + Fonts.DEFAULT.drawString(searchTextX, searchY + Fonts.BOLD.getLineHeight(), (searchResultString == null) ? "Searching..." : searchResultString, Color.white); } + + + // reloading beatmaps if (reloadThread != null) { // darken the screen - g.setColor(Utils.COLOR_BLACK_ALPHA); + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, 0, width, height); UI.drawLoadingProgress(g); @@ -510,6 +622,21 @@ public class SongMenu extends BasicGameState { } } + if (focusNode != null) { + // fade in background + Beatmap focusNodeBeatmap = focusNode.getSelectedBeatmap(); + if (!focusNodeBeatmap.isBackgroundLoading()) + bgAlpha.update(delta); + + // song change timers + songChangeTimer.update(delta); + if (!MusicController.isTrackLoading()) + musicIconBounceTimer.update(delta); + } + + // star stream + starStream.update(delta); + // search search.setFocus(true); searchTimer += delta; @@ -574,14 +701,10 @@ public class SongMenu extends BasicGameState { if ((mouseX > cx && mouseX < cx + buttonWidth) && (mouseY > buttonY + (i * buttonOffset) && mouseY < buttonY + (i * buttonOffset) + buttonHeight)) { if (node == hoverIndex) { - if (hoverOffset < MAX_HOVER_OFFSET) { - hoverOffset += delta / 3f; - if (hoverOffset > MAX_HOVER_OFFSET) - hoverOffset = MAX_HOVER_OFFSET; - } + hoverOffset.update(delta); } else { hoverIndex = node ; - hoverOffset = 0f; + hoverOffset.setTime(0); } isHover = true; break; @@ -589,21 +712,19 @@ public class SongMenu extends BasicGameState { } } if (!isHover) { - hoverOffset = 0f; + hoverOffset.setTime(0); hoverIndex = null; } else return; // tooltips - if (focusScores != null) { + if (focusScores != null && ScoreData.areaContains(mouseX, mouseY)) { int startScore = (int) (startScorePos.getPosition() / ScoreData.getButtonOffset()); int offset = (int) (-startScorePos.getPosition() + startScore * ScoreData.getButtonOffset()); - for (int i = 0; i < MAX_SCORE_BUTTONS; i++) { - int rank = startScore + i; + int scoreButtons = Math.min(focusScores.length - startScore, MAX_SCORE_BUTTONS); + for (int i = 0, rank = startScore; i < scoreButtons; i++, rank++) { if (rank < 0) continue; - if (rank >= focusScores.length) - break; if (ScoreData.buttonContains(mouseX, mouseY - offset, i)) { UI.updateTooltip(delta, focusScores[rank].getTooltipString(), true); break; @@ -689,7 +810,7 @@ public class SongMenu extends BasicGameState { float cx = (node.index == expandedIndex) ? buttonX * 0.9f : buttonX; if ((x > cx && x < cx + buttonWidth) && (y > buttonY + (i * buttonOffset) && y < buttonY + (i * buttonOffset) + buttonHeight)) { - float oldHoverOffset = hoverOffset; + int oldHoverOffsetTime = hoverOffset.getTime(); BeatmapSetNode oldHoverIndex = hoverIndex; // clicked node is already expanded @@ -714,7 +835,7 @@ public class SongMenu extends BasicGameState { } // restore hover data - hoverOffset = oldHoverOffset; + hoverOffset.setTime(oldHoverOffsetTime); hoverIndex = oldHoverIndex; // open beatmap menu @@ -728,12 +849,10 @@ public class SongMenu extends BasicGameState { // score buttons if (focusScores != null && ScoreData.areaContains(x, y)) { - for (int i = 0; i < MAX_SCORE_BUTTONS + 1; i++) { - int startScore = (int) (startScorePos.getPosition() / ScoreData.getButtonOffset()); - int offset = (int) (-startScorePos.getPosition() + startScore * ScoreData.getButtonOffset()); - int rank = startScore + i; - if (rank >= focusScores.length) - break; + int startScore = (int) (startScorePos.getPosition() / ScoreData.getButtonOffset()); + int offset = (int) (-startScorePos.getPosition() + startScore * ScoreData.getButtonOffset()); + int scoreButtons = Math.min(focusScores.length - startScore, MAX_SCORE_BUTTONS); + for (int i = 0, rank = startScore; i < scoreButtons; i++, rank++) { if (ScoreData.buttonContains(x, y - offset, i)) { SoundController.playSound(SoundEffect.MENUHIT); if (button != Input.MOUSE_RIGHT_BUTTON) { @@ -805,8 +924,12 @@ public class SongMenu extends BasicGameState { break; case Input.KEY_F5: SoundController.playSound(SoundEffect.MENUHIT); - ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(MenuState.RELOAD); - game.enterState(Opsu.STATE_BUTTONMENU); + if (songFolderChanged) + reloadBeatmaps(false); + else { + ((ButtonMenu) game.getState(Opsu.STATE_BUTTONMENU)).setMenuState(MenuState.RELOAD); + game.enterState(Opsu.STATE_BUTTONMENU); + } break; case Input.KEY_DELETE: if (focusNode == null) @@ -851,11 +974,11 @@ public class SongMenu extends BasicGameState { if (next != null) { SoundController.playSound(SoundEffect.MENUCLICK); BeatmapSetNode oldStartNode = startNode; - float oldHoverOffset = hoverOffset; + int oldHoverOffsetTime = hoverOffset.getTime(); BeatmapSetNode oldHoverIndex = hoverIndex; setFocus(next, 0, false, true); if (startNode == oldStartNode) { - hoverOffset = oldHoverOffset; + hoverOffset.setTime(oldHoverOffsetTime); hoverIndex = oldHoverIndex; } } @@ -867,11 +990,11 @@ public class SongMenu extends BasicGameState { if (prev != null) { SoundController.playSound(SoundEffect.MENUCLICK); BeatmapSetNode oldStartNode = startNode; - float oldHoverOffset = hoverOffset; + int oldHoverOffsetTime = hoverOffset.getTime(); BeatmapSetNode oldHoverIndex = hoverIndex; setFocus(prev, (prev.index == focusNode.index) ? 0 : prev.getBeatmapSet().size() - 1, false, true); if (startNode == oldStartNode) { - hoverOffset = oldHoverOffset; + hoverOffset.setTime(oldHoverOffsetTime); hoverIndex = oldHoverIndex; } } @@ -965,18 +1088,26 @@ public class SongMenu extends BasicGameState { selectRandomButton.resetHover(); selectMapOptionsButton.resetHover(); selectOptionsButton.resetHover(); - hoverOffset = 0f; + hoverOffset.setTime(0); hoverIndex = null; startScorePos.setPosition(0); beatmapMenuTimer = -1; searchTransitionTimer = SEARCH_TRANSITION_TIME; songInfo = null; + bgAlpha.setTime(bgAlpha.getDuration()); + songChangeTimer.setTime(songChangeTimer.getDuration()); + musicIconBounceTimer.setTime(musicIconBounceTimer.getDuration()); + starStream.clear(); // reset song stack randomStack = new Stack(); + // reload beatmaps if song folder changed + if (songFolderChanged && stateAction != MenuState.RELOAD) + reloadBeatmaps(false); + // set focus node if not set (e.g. theme song playing) - if (focusNode == null && BeatmapSetList.get().size() > 0) + else if (focusNode == null && BeatmapSetList.get().size() > 0) setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); // reset music track @@ -1003,13 +1134,13 @@ public class SongMenu extends BasicGameState { // destroy skin images, if any for (GameImage img : GameImage.values()) { - if (img.isSkinnable()) - img.destroySkinImage(); + if (img.isBeatmapSkinnable()) + img.destroyBeatmapSkinImage(); } // reload scores if (focusNode != null) { - scoreMap = ScoreDB.getMapSetScores(focusNode.getBeatmapSet().get(focusNode.beatmapIndex)); + scoreMap = ScoreDB.getMapSetScores(focusNode.getSelectedBeatmap()); focusScores = getScoreDataForNode(focusNode, true); } @@ -1022,7 +1153,7 @@ public class SongMenu extends BasicGameState { case BEATMAP: // clear all scores if (stateActionNode == null || stateActionNode.beatmapIndex == -1) break; - Beatmap beatmap = stateActionNode.getBeatmapSet().get(stateActionNode.beatmapIndex); + Beatmap beatmap = stateActionNode.getSelectedBeatmap(); ScoreDB.deleteScore(beatmap); if (stateActionNode == focusNode) { focusScores = null; @@ -1033,7 +1164,7 @@ public class SongMenu extends BasicGameState { if (stateActionScore == null) break; ScoreDB.deleteScore(stateActionScore); - scoreMap = ScoreDB.getMapSetScores(focusNode.getBeatmapSet().get(focusNode.beatmapIndex)); + scoreMap = ScoreDB.getMapSetScores(focusNode.getSelectedBeatmap()); focusScores = getScoreDataForNode(focusNode, true); startScorePos.setPosition(0); break; @@ -1095,44 +1226,7 @@ public class SongMenu extends BasicGameState { } break; case RELOAD: // reload beatmaps - // reset state and node references - MusicController.reset(); - startNode = focusNode = null; - scoreMap = null; - focusScores = null; - oldFocusNode = null; - randomStack = new Stack(); - songInfo = null; - hoverOffset = 0f; - hoverIndex = null; - search.setText(""); - searchTimer = SEARCH_DELAY; - searchTransitionTimer = SEARCH_TRANSITION_TIME; - searchResultString = null; - - // reload songs in new thread - reloadThread = new Thread() { - @Override - public void run() { - // clear the beatmap cache - BeatmapDB.clearDatabase(); - - // invoke unpacker and parser - File beatmapDir = Options.getBeatmapDir(); - OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); - BeatmapParser.parseAllFiles(beatmapDir); - - // initialize song list - if (BeatmapSetList.get().size() > 0) { - BeatmapSetList.get().init(); - setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); - } else - MusicController.playThemeSong(); - - reloadThread = null; - } - }; - reloadThread.start(); + reloadBeatmaps(true); break; default: break; @@ -1210,15 +1304,25 @@ public class SongMenu extends BasicGameState { if (node == null) return null; - hoverOffset = 0f; + hoverOffset.setTime(0); hoverIndex = null; songInfo = null; + songChangeTimer.setTime(0); + musicIconBounceTimer.setTime(0); BeatmapSetNode oldFocus = focusNode; // expand node before focusing it int expandedIndex = BeatmapSetList.get().getExpandedIndex(); if (node.index != expandedIndex) { node = BeatmapSetList.get().expand(node.index); + + + // calculate difficulties + calculateStarRatings(node.getBeatmapSet()); + + // if start node was previously expanded, move it + if (startNode != null && startNode.index == expandedIndex) + startNode = BeatmapSetList.get().getBaseNode(startNode.index); } // check beatmapIndex bounds @@ -1227,7 +1331,7 @@ public class SongMenu extends BasicGameState { beatmapIndex = (int) (Math.random() * length); focusNode = BeatmapSetList.get().getNode(node, beatmapIndex); - Beatmap beatmap = focusNode.getBeatmapSet().get(focusNode.beatmapIndex); + Beatmap beatmap = focusNode.getSelectedBeatmap(); MusicController.play(beatmap, false, preview); // load scores @@ -1286,6 +1390,14 @@ public class SongMenu extends BasicGameState { songScrolling.scrollToPosition((focusNode.index + focusNode.getBeatmapSet().size() ) * buttonOffset - (footerY - headerY)); //*/ + // load background image + beatmap.loadBackground(); + boolean isBgNull = lastBackgroundImage == null || beatmap.bg == null; + if ((isBgNull && lastBackgroundImage != beatmap.bg) || (!isBgNull && !beatmap.bg.equals(lastBackgroundImage))) { + bgAlpha.setTime(0); + lastBackgroundImage = beatmap.bg; + } + return oldFocus; } @@ -1346,7 +1458,7 @@ public class SongMenu extends BasicGameState { if (scoreMap == null || scoreMap.isEmpty() || node.beatmapIndex == -1) // node not expanded return null; - Beatmap beatmap = node.getBeatmapSet().get(node.beatmapIndex); + Beatmap beatmap = node.getSelectedBeatmap(); ScoreData[] scores = scoreMap.get(beatmap.version); if (scores == null || scores.length < 1) // no scores return null; @@ -1364,6 +1476,86 @@ public class SongMenu extends BasicGameState { return null; // incorrect map } + /** + * Reloads all beatmaps. + * @param fullReload if true, also clear the beatmap cache and invoke the unpacker + */ + private void reloadBeatmaps(final boolean fullReload) { + songFolderChanged = false; + + // reset state and node references + MusicController.reset(); + startNode = focusNode = null; + scoreMap = null; + focusScores = null; + oldFocusNode = null; + randomStack = new Stack(); + songInfo = null; + hoverOffset.setTime(0); + hoverIndex = null; + search.setText(""); + searchTimer = SEARCH_DELAY; + searchTransitionTimer = SEARCH_TRANSITION_TIME; + searchResultString = null; + lastBackgroundImage = null; + + // reload songs in new thread + reloadThread = new Thread() { + @Override + public void run() { + File beatmapDir = Options.getBeatmapDir(); + + if (fullReload) { + // clear the beatmap cache + BeatmapDB.clearDatabase(); + + // invoke unpacker + OszUnpacker.unpackAllFiles(Options.getOSZDir(), beatmapDir); + } + + // invoke parser + BeatmapParser.parseAllFiles(beatmapDir); + + // initialize song list + if (BeatmapSetList.get().size() > 0) { + BeatmapSetList.get().init(); + setFocus(BeatmapSetList.get().getRandomNode(), -1, true, true); + } else + MusicController.playThemeSong(); + + reloadThread = null; + } + }; + reloadThread.start(); + } + + /** + * Calculates all star ratings for a beatmap set. + * @param beatmapSet the set of beatmaps + */ + private void calculateStarRatings(BeatmapSet beatmapSet) { + for (Beatmap beatmap : beatmapSet) { + if (beatmap.starRating >= 0) { // already calculated + beatmapsCalculated.put(beatmap, beatmapsCalculated.get(beatmap)); + continue; + } + + // if timing points are already loaded before this (for whatever reason), + // don't clear the array fields to be safe + boolean hasTimingPoints = (beatmap.timingPoints != null); + + BeatmapDifficultyCalculator diffCalc = new BeatmapDifficultyCalculator(beatmap); + diffCalc.calculate(); + if (diffCalc.getStarRating() == -1) + continue; // calculations failed + + // save star rating + beatmap.starRating = diffCalc.getStarRating(); + BeatmapDB.setStars(beatmap); + beatmapsCalculated.put(beatmap, !hasTimingPoints); + } + } + /** * Starts the game. */ @@ -1372,8 +1564,12 @@ public class SongMenu extends BasicGameState { return; SoundController.playSound(SoundEffect.MENUHIT); - MultiClip.destroyExtraClips(); Beatmap beatmap = MusicController.getBeatmap(); + if (focusNode == null || beatmap != focusNode.getSelectedBeatmap()) { + UI.sendBarNotification("Unable to load the beatmap audio."); + return; + } + MultiClip.destroyExtraClips(); Game gameState = (Game) game.getState(Opsu.STATE_GAME); gameState.loadBeatmap(beatmap); gameState.setRestart(Game.Restart.NEW); diff --git a/src/itdelatrisu/opsu/states/Splash.java b/src/itdelatrisu/opsu/states/Splash.java index 14d8245a..a0ea9963 100644 --- a/src/itdelatrisu/opsu/states/Splash.java +++ b/src/itdelatrisu/opsu/states/Splash.java @@ -21,21 +21,23 @@ package itdelatrisu.opsu.states; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OszUnpacker; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.MusicController; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.beatmap.BeatmapParser; import itdelatrisu.opsu.beatmap.BeatmapSetList; +import itdelatrisu.opsu.beatmap.BeatmapWatchService; +import itdelatrisu.opsu.beatmap.OszUnpacker; import itdelatrisu.opsu.replay.ReplayImporter; import itdelatrisu.opsu.ui.UI; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import java.io.File; import org.newdawn.slick.Color; import org.newdawn.slick.GameContainer; import org.newdawn.slick.Graphics; -import org.newdawn.slick.Image; import org.newdawn.slick.Input; import org.newdawn.slick.SlickException; import org.newdawn.slick.state.BasicGameState; @@ -47,6 +49,9 @@ import org.newdawn.slick.state.StateBasedGame; * Loads game resources and enters "Main Menu" state. */ public class Splash extends BasicGameState { + /** Minimum time, in milliseconds, to display the splash screen (and fade in the logo). */ + private static final int MIN_SPLASH_TIME = 400; + /** Whether or not loading has completed. */ private boolean finished = false; @@ -59,8 +64,14 @@ public class Splash extends BasicGameState { /** Whether the skin being loaded is a new skin (for program restarts). */ private boolean newSkin = false; + /** Whether the watch service is newly enabled (for program restarts). */ + private boolean watchServiceChange = false; + + /** Logo alpha level. */ + private AnimatedValue logoAlpha; + // game-related variables - private int state; + private final int state; private GameContainer container; private boolean init = false; @@ -77,9 +88,14 @@ public class Splash extends BasicGameState { if (Options.getSkin() != null) this.newSkin = (Options.getSkin().getDirectory() != Options.getSkinDir()); + // check if watch service newly enabled + this.watchServiceChange = Options.isWatchServiceEnabled() && BeatmapWatchService.get() == null; + // load Utils class first (needed in other 'init' methods) Utils.init(container, game); + // fade in logo + this.logoAlpha = new AnimatedValue(MIN_SPLASH_TIME, 0f, 1f, AnimationEquation.LINEAR); GameImage.MENU_LOGO.getImage().setAlpha(0f); } @@ -99,13 +115,18 @@ public class Splash extends BasicGameState { // resources already loaded (from application restart) if (BeatmapSetList.get() != null) { - // reload sounds if skin changed - if (newSkin) { + if (newSkin || watchServiceChange) { // need to reload resources thread = new Thread() { @Override public void run() { + // reload beatmaps if watch service newly enabled + if (watchServiceChange) + BeatmapParser.parseAllFiles(Options.getBeatmapDir()); + + // reload sounds if skin changed // TODO: only reload each sound if actually needed? - SoundController.init(); + if (newSkin) + SoundController.init(); finished = true; thread = null; @@ -144,13 +165,11 @@ public class Splash extends BasicGameState { } // fade in logo - Image logo = GameImage.MENU_LOGO.getImage(); - float alpha = logo.getAlpha(); - if (alpha < 1f) - logo.setAlpha(alpha + (delta / 500f)); + if (logoAlpha.update(delta)) + GameImage.MENU_LOGO.getImage().setAlpha(logoAlpha.getValue()); // change states when loading complete - if (finished && alpha >= 1f) { + if (finished && logoAlpha.getValue() >= 1f) { // initialize song list if (BeatmapSetList.get().size() > 0) { BeatmapSetList.get().init(); diff --git a/src/itdelatrisu/opsu/ui/Colors.java b/src/itdelatrisu/opsu/ui/Colors.java new file mode 100644 index 00000000..2f79efb9 --- /dev/null +++ b/src/itdelatrisu/opsu/ui/Colors.java @@ -0,0 +1,51 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui; + +import org.newdawn.slick.Color; + +/** + * Colors used for drawing. + */ +public class Colors { + public static final Color + BLACK_ALPHA = new Color(0, 0, 0, 0.5f), + WHITE_ALPHA = new Color(255, 255, 255, 0.5f), + BLUE_DIVIDER = new Color(49, 94, 237), + BLUE_BACKGROUND = new Color(74, 130, 255), + BLUE_BUTTON = new Color(40, 129, 237), + ORANGE_BUTTON = new Color(200, 90, 3), + YELLOW_ALPHA = new Color(255, 255, 0, 0.4f), + WHITE_FADE = new Color(255, 255, 255, 1f), + RED_HOVER = new Color(255, 112, 112), + GREEN = new Color(137, 201, 79), + LIGHT_ORANGE = new Color(255, 192, 128), + LIGHT_GREEN = new Color(128, 255, 128), + LIGHT_BLUE = new Color(128, 128, 255), + GREEN_SEARCH = new Color(173, 255, 47), + DARK_GRAY = new Color(0.3f, 0.3f, 0.3f, 1f), + RED_HIGHLIGHT = new Color(246, 154, 161), + BLUE_HIGHLIGHT = new Color(173, 216, 230), + BLACK_BG_NORMAL = new Color(0, 0, 0, 0.25f), + BLACK_BG_HOVER = new Color(0, 0, 0, 0.5f), + BLACK_BG_FOCUS = new Color(0, 0, 0, 0.75f); + + // This class should not be instantiated. + private Colors() {} +} diff --git a/src/itdelatrisu/opsu/ui/Cursor.java b/src/itdelatrisu/opsu/ui/Cursor.java index 252a9c2c..d0382aae 100644 --- a/src/itdelatrisu/opsu/ui/Cursor.java +++ b/src/itdelatrisu/opsu/ui/Cursor.java @@ -24,9 +24,10 @@ import itdelatrisu.opsu.Opsu; import itdelatrisu.opsu.Options; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.skins.Skin; +import itdelatrisu.opsu.ui.animations.AnimationEquation; +import java.awt.Point; import java.nio.IntBuffer; -import java.util.Iterator; import java.util.LinkedList; import org.lwjgl.BufferUtils; @@ -45,13 +46,25 @@ public class Cursor { private static org.lwjgl.input.Cursor emptyCursor; /** Last cursor coordinates. */ - private int lastX = -1, lastY = -1; + private Point lastPosition; /** Cursor rotation angle. */ private float cursorAngle = 0f; + /** The time in milliseconds when the cursor was last pressed, used for the scaling animation. */ + private long lastCursorPressTime = 0L; + + /** Whether or not the cursor was pressed in the last frame, used for the scaling animation. */ + private boolean lastCursorPressState = false; + + /** The amount the cursor scale increases, if enabled, when pressed. */ + private static final float CURSOR_SCALE_CHANGE = 0.25f; + + /** The time it takes for the cursor to scale, in milliseconds. */ + private static final float CURSOR_SCALE_TIME = 125; + /** Stores all previous cursor locations to display a trail. */ - private LinkedList cursorX, cursorY; + private LinkedList trail = new LinkedList(); // game-related variables private static GameContainer container; @@ -81,10 +94,7 @@ public class Cursor { /** * Constructor. */ - public Cursor() { - cursorX = new LinkedList(); - cursorY = new LinkedList(); - } + public Cursor() {} /** * Draws the cursor. @@ -105,85 +115,90 @@ public class Cursor { * @param mousePressed whether or not the mouse button is pressed */ public void draw(int mouseX, int mouseY, boolean mousePressed) { + if (Options.isCursorDisabled()) + return; + // determine correct cursor image Image cursor = null, cursorMiddle = null, cursorTrail = null; - boolean skinned = GameImage.CURSOR.hasSkinImage(); + boolean beatmapSkinned = GameImage.CURSOR.hasBeatmapSkinImage(); boolean newStyle, hasMiddle; - if (skinned) { + Skin skin = Options.getSkin(); + if (beatmapSkinned) { newStyle = true; // osu! currently treats all beatmap cursors as new-style cursors - hasMiddle = GameImage.CURSOR_MIDDLE.hasSkinImage(); + hasMiddle = GameImage.CURSOR_MIDDLE.hasBeatmapSkinImage(); } else newStyle = hasMiddle = Options.isNewCursorEnabled(); - if (skinned || newStyle) { + if (newStyle || beatmapSkinned) { cursor = GameImage.CURSOR.getImage(); cursorTrail = GameImage.CURSOR_TRAIL.getImage(); } else { - cursor = GameImage.CURSOR_OLD.getImage(); - cursorTrail = GameImage.CURSOR_TRAIL_OLD.getImage(); + cursor = GameImage.CURSOR.hasGameSkinImage() ? GameImage.CURSOR.getImage() : GameImage.CURSOR_OLD.getImage(); + cursorTrail = GameImage.CURSOR_TRAIL.hasGameSkinImage() ? GameImage.CURSOR_TRAIL.getImage() : GameImage.CURSOR_TRAIL_OLD.getImage(); } if (hasMiddle) cursorMiddle = GameImage.CURSOR_MIDDLE.getImage(); - int removeCount = 0; - int FPSmod = (Options.getTargetFPS() / 60); - Skin skin = Options.getSkin(); - // scale cursor - float cursorScale = Options.getCursorScale(); - if (mousePressed && skin.isCursorExpanded()) - cursorScale *= 1.25f; // increase the cursor size if pressed + float cursorScaleAnimated = 1f; + if (skin.isCursorExpanded()) { + if (lastCursorPressState != mousePressed) { + lastCursorPressState = mousePressed; + lastCursorPressTime = System.currentTimeMillis(); + } + + float cursorScaleChange = CURSOR_SCALE_CHANGE * AnimationEquation.IN_OUT_CUBIC.calc( + Utils.clamp(System.currentTimeMillis() - lastCursorPressTime, 0, CURSOR_SCALE_TIME) / CURSOR_SCALE_TIME); + cursorScaleAnimated = 1f + ((mousePressed) ? cursorScaleChange : CURSOR_SCALE_CHANGE - cursorScaleChange); + } + float cursorScale = cursorScaleAnimated * Options.getCursorScale(); if (cursorScale != 1f) { cursor = cursor.getScaledCopy(cursorScale); cursorTrail = cursorTrail.getScaledCopy(cursorScale); - if (hasMiddle) - cursorMiddle = cursorMiddle.getScaledCopy(cursorScale); } // TODO: use an image buffer + int removeCount = 0; + float FPSmod = Math.max(container.getFPS(), 1) / 60f; if (newStyle) { // new style: add all points between cursor movements - if (lastX < 0) { - lastX = mouseX; - lastY = mouseY; + if (lastPosition == null) { + lastPosition = new Point(mouseX, mouseY); return; } - addCursorPoints(lastX, lastY, mouseX, mouseY); - lastX = mouseX; - lastY = mouseY; + addCursorPoints(lastPosition.x, lastPosition.y, mouseX, mouseY); + lastPosition.move(mouseX, mouseY); - removeCount = (cursorX.size() / (6 * FPSmod)) + 1; + removeCount = (int) (trail.size() / (6 * FPSmod)) + 1; } else { // old style: sample one point at a time - cursorX.add(mouseX); - cursorY.add(mouseY); + trail.add(new Point(mouseX, mouseY)); - int max = 10 * FPSmod; - if (cursorX.size() > max) - removeCount = cursorX.size() - max; + int max = (int) (10 * FPSmod); + if (trail.size() > max) + removeCount = trail.size() - max; } // remove points from the lists - for (int i = 0; i < removeCount && !cursorX.isEmpty(); i++) { - cursorX.remove(); - cursorY.remove(); - } + for (int i = 0; i < removeCount && !trail.isEmpty(); i++) + trail.remove(); // draw a fading trail float alpha = 0f; - float t = 2f / cursorX.size(); - if (skin.isCursorTrailRotated()) - cursorTrail.setRotation(cursorAngle); - Iterator iterX = cursorX.iterator(); - Iterator iterY = cursorY.iterator(); - while (iterX.hasNext()) { - int cx = iterX.next(); - int cy = iterY.next(); + float t = 2f / trail.size(); + int cursorTrailWidth = cursorTrail.getWidth(), cursorTrailHeight = cursorTrail.getHeight(); + float cursorTrailRotation = (skin.isCursorTrailRotated()) ? cursorAngle : 0; + cursorTrail.startUse(); + for (Point p : trail) { alpha += t; - cursorTrail.setAlpha(alpha); -// if (cx != x || cy != y) - cursorTrail.drawCentered(cx, cy); + cursorTrail.setImageColor(1f, 1f, 1f, alpha); + cursorTrail.drawEmbedded( + p.x - (cursorTrailWidth / 2f), p.y - (cursorTrailHeight / 2f), + cursorTrailWidth, cursorTrailHeight, cursorTrailRotation); } - cursorTrail.drawCentered(mouseX, mouseY); + cursorTrail.drawEmbedded( + mouseX - (cursorTrailWidth / 2f), mouseY - (cursorTrailHeight / 2f), + cursorTrailWidth, cursorTrailHeight, cursorTrailRotation); + cursorTrail.endUse(); // draw the other components if (newStyle && skin.isCursorRotated()) @@ -212,8 +227,7 @@ public class Cursor { if (dy <= dx) { for (int i = 0; ; i++) { if (i == k) { - cursorX.add(x1); - cursorY.add(y1); + trail.add(new Point(x1, y1)); i = 0; } if (x1 == x2) @@ -228,8 +242,7 @@ public class Cursor { } else { for (int i = 0; ; i++) { if (i == k) { - cursorX.add(x1); - cursorY.add(y1); + trail.add(new Point(x1, y1)); i = 0; } if (y1 == y2) @@ -255,13 +268,13 @@ public class Cursor { } /** - * Resets all cursor data and skins. + * Resets all cursor data and beatmap skins. */ public void reset() { // destroy skin images - GameImage.CURSOR.destroySkinImage(); - GameImage.CURSOR_MIDDLE.destroySkinImage(); - GameImage.CURSOR_TRAIL.destroySkinImage(); + GameImage.CURSOR.destroyBeatmapSkinImage(); + GameImage.CURSOR_MIDDLE.destroyBeatmapSkinImage(); + GameImage.CURSOR_TRAIL.destroyBeatmapSkinImage(); // reset locations resetLocations(); @@ -276,18 +289,17 @@ public class Cursor { * Resets all cursor location data. */ public void resetLocations() { - lastX = lastY = -1; - cursorX.clear(); - cursorY.clear(); + lastPosition = null; + trail.clear(); } /** * Returns whether or not the cursor is skinned. */ - public boolean isSkinned() { - return (GameImage.CURSOR.hasSkinImage() || - GameImage.CURSOR_MIDDLE.hasSkinImage() || - GameImage.CURSOR_TRAIL.hasSkinImage()); + public boolean isBeatmapSkinned() { + return (GameImage.CURSOR.hasBeatmapSkinImage() || + GameImage.CURSOR_MIDDLE.hasBeatmapSkinImage() || + GameImage.CURSOR_TRAIL.hasBeatmapSkinImage()); } /** diff --git a/src/itdelatrisu/opsu/ui/DropdownMenu.java b/src/itdelatrisu/opsu/ui/DropdownMenu.java new file mode 100644 index 00000000..059751a0 --- /dev/null +++ b/src/itdelatrisu/opsu/ui/DropdownMenu.java @@ -0,0 +1,424 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; + +import org.newdawn.slick.Color; +import org.newdawn.slick.Font; +import org.newdawn.slick.Graphics; +import org.newdawn.slick.Image; +import org.newdawn.slick.Input; +import org.newdawn.slick.SlickException; +import org.newdawn.slick.UnicodeFont; +import org.newdawn.slick.gui.AbstractComponent; +import org.newdawn.slick.gui.GUIContext; + +/** + * Simple dropdown menu. + *

+ * Basic usage: + *

    + *
  • Override {@link #menuClicked(int)} to perform actions when the menu is clicked + * (e.g. play a sound effect, block input under certain conditions). + *
  • Override {@link #itemSelected(int, Object)} to perform actions when a new item is selected. + *
  • Call {@link #activate()}/{@link #deactivate()} whenever the component is needed + * (e.g. in a state's {@code enter} and {@code leave} events. + *
+ * + * @param the type of the elements in the menu + */ +public class DropdownMenu extends AbstractComponent { + /** Padding ratios for drawing. */ + private static final float PADDING_Y = 0.1f, CHEVRON_X = 0.03f; + + /** Whether this component is active. */ + private boolean active; + + /** The menu items. */ + private E[] items; + + /** The menu item names. */ + private String[] itemNames; + + /** The index of the selected item. */ + private int itemIndex = 0; + + /** Whether the menu is expanded. */ + private boolean expanded = false; + + /** The expanding animation progress. */ + private AnimatedValue expandProgress = new AnimatedValue(300, 0f, 1f, AnimationEquation.LINEAR); + + /** The last update time, in milliseconds. */ + private long lastUpdateTime; + + /** The top-left coordinates. */ + private float x, y; + + /** The width and height of the dropdown menu. */ + private int width, height; + + /** The height of the base item. */ + private int baseHeight; + + /** The vertical offset between items. */ + private float offsetY; + + /** The colors to use. */ + private Color + textColor = Color.white, backgroundColor = Color.black, + highlightColor = Colors.BLUE_DIVIDER, borderColor = Colors.BLUE_DIVIDER, + chevronDownColor = textColor, chevronRightColor = backgroundColor; + + /** The fonts to use. */ + private UnicodeFont fontNormal = Fonts.MEDIUM, fontSelected = Fonts.MEDIUMBOLD; + + /** The chevron images. */ + private Image chevronDown, chevronRight; + + /** + * Creates a new dropdown menu. + * @param container the container rendering this menu + * @param items the list of items (with names given as their {@code toString()} methods) + * @param x the top-left x coordinate + * @param y the top-left y coordinate + */ + public DropdownMenu(GUIContext container, E[] items, float x, float y) { + this(container, items, x, y, 0); + } + + /** + * Creates a new dropdown menu with the given fonts. + * @param container the container rendering this menu + * @param items the list of items (with names given as their {@code toString()} methods) + * @param x the top-left x coordinate + * @param y the top-left y coordinate + * @param normal the normal font + * @param selected the font for the selected item + */ + public DropdownMenu(GUIContext container, E[] items, float x, float y, UnicodeFont normal, UnicodeFont selected) { + this(container, items, x, y, 0, normal, selected); + } + + /** + * Creates a new dropdown menu with the given width. + * @param container the container rendering this menu + * @param items the list of items (with names given as their {@code toString()} methods) + * @param x the top-left x coordinate + * @param y the top-left y coordinate + * @param width the menu width + */ + public DropdownMenu(GUIContext container, E[] items, float x, float y, int width) { + super(container); + init(items, x, y, width); + } + + /** + * Creates a new dropdown menu with the given width and fonts. + * @param container the container rendering this menu + * @param items the list of items (with names given as their {@code toString()} methods) + * @param x the top-left x coordinate + * @param y the top-left y coordinate + * @param width the menu width + * @param normal the normal font + * @param selected the font for the selected item + */ + public DropdownMenu(GUIContext container, E[] items, float x, float y, int width, UnicodeFont normal, UnicodeFont selected) { + super(container); + this.fontNormal = normal; + this.fontSelected = selected; + init(items, x, y, width); + } + + /** + * Returns the maximum item width from the list. + */ + private int getMaxItemWidth() { + int maxWidth = 0; + for (int i = 0; i < itemNames.length; i++) { + int w = fontSelected.getWidth(itemNames[i]); + if (w > maxWidth) + maxWidth = w; + } + return maxWidth; + } + + /** + * Initializes the component. + */ + private void init(E[] items, float x, float y, int width) { + this.items = items; + this.itemNames = new String[items.length]; + for (int i = 0; i < itemNames.length; i++) + itemNames[i] = items[i].toString(); + this.x = x; + this.y = y; + this.baseHeight = fontNormal.getLineHeight(); + this.offsetY = baseHeight + baseHeight * PADDING_Y; + this.height = (int) (offsetY * (items.length + 1)); + int chevronDownSize = baseHeight * 4 / 5; + this.chevronDown = GameImage.CHEVRON_DOWN.getImage().getScaledCopy(chevronDownSize, chevronDownSize); + int chevronRightSize = baseHeight * 2 / 3; + this.chevronRight = GameImage.CHEVRON_RIGHT.getImage().getScaledCopy(chevronRightSize, chevronRightSize); + int maxItemWidth = getMaxItemWidth(); + int minWidth = maxItemWidth + chevronRight.getWidth() * 2; + this.width = Math.max(width, minWidth); + } + + @Override + public void setLocation(int x, int y) { + this.x = x; + this.y = y; + } + + @Override + public int getX() { return (int) x; } + + @Override + public int getY() { return (int) y; } + + @Override + public int getWidth() { return width; } + + @Override + public int getHeight() { return (expanded) ? height : baseHeight; } + + /** Activates the component. */ + public void activate() { this.active = true; } + + /** Deactivates the component. */ + public void deactivate() { this.active = false; } + + /** + * Returns whether the dropdown menu is currently open. + * @return true if open, false otherwise + */ + public boolean isOpen() { return expanded; } + + /** + * Opens or closes the dropdown menu. + * @param flag true to open, false to close + */ + public void open(boolean flag) { this.expanded = flag; } + + /** + * Returns true if the coordinates are within the menu bounds. + * @param cx the x coordinate + * @param cy the y coordinate + */ + public boolean contains(float cx, float cy) { + return (cx > x && cx < x + width && ( + (cy > y && cy < y + baseHeight) || + (expanded && cy > y + offsetY && cy < y + height))); + } + + /** + * Returns true if the coordinates are within the base item bounds. + * @param cx the x coordinate + * @param cy the y coordinate + */ + public boolean baseContains(float cx, float cy) { + return (cx > x && cx < x + width && cy > y && cy < y + baseHeight); + } + + @Override + public void render(GUIContext container, Graphics g) throws SlickException { + // update animation + long time = container.getTime(); + if (lastUpdateTime > 0) { + int delta = (int) (time - lastUpdateTime); + expandProgress.update((expanded) ? delta : -delta * 2); + } + this.lastUpdateTime = time; + + // get parameters + Input input = container.getInput(); + int idx = getIndexAt(input.getMouseX(), input.getMouseY()); + float t = expandProgress.getValue(); + if (expanded) + t = AnimationEquation.OUT_CUBIC.calc(t); + + // background and border + Color oldGColor = g.getColor(); + float oldLineWidth = g.getLineWidth(); + final int cornerRadius = 6; + g.setLineWidth(1f); + g.setColor((idx == -1) ? highlightColor : backgroundColor); + g.fillRoundRect(x, y, width, baseHeight, cornerRadius); + g.setColor(borderColor); + g.drawRoundRect(x, y, width, baseHeight, cornerRadius); + if (expanded || t >= 0.0001) { + float oldBackgroundAlpha = backgroundColor.a; + backgroundColor.a *= t; + g.setColor(backgroundColor); + g.fillRoundRect(x, y + offsetY, width, (height - offsetY) * t, cornerRadius); + backgroundColor.a = oldBackgroundAlpha; + } + if (idx >= 0 && t >= 0.9999) { + g.setColor(highlightColor); + float yPos = y + offsetY + (offsetY * idx); + int yOff = 0, hOff = 0; + if (idx == 0 || idx == items.length - 1) { + g.fillRoundRect(x, yPos, width, offsetY, cornerRadius); + if (idx == 0) + yOff = cornerRadius; + hOff = cornerRadius; + } + g.fillRect(x, yPos + yOff, width, offsetY - hOff); + } + g.setColor(oldGColor); + g.setLineWidth(oldLineWidth); + + // text + chevronDown.draw(x + width - chevronDown.getWidth() - width * CHEVRON_X, y + (baseHeight - chevronDown.getHeight()) / 2f, chevronDownColor); + fontNormal.drawString(x + (width * 0.03f), y + (fontNormal.getPaddingTop() + fontNormal.getPaddingBottom()) / 2f, itemNames[itemIndex], textColor); + float oldTextAlpha = textColor.a; + textColor.a *= t; + if (expanded || t >= 0.0001) { + for (int i = 0; i < itemNames.length; i++) { + Font f = (i == itemIndex) ? fontSelected : fontNormal; + if (i == idx && t >= 0.999) + chevronRight.draw(x, y + offsetY + (offsetY * i) + (offsetY - chevronRight.getHeight()) / 2f, chevronRightColor); + f.drawString(x + chevronRight.getWidth(), y + offsetY + (offsetY * i * t), itemNames[i], textColor); + } + } + textColor.a = oldTextAlpha; + } + + /** + * Returns the index of the item at the given location, -1 for the base item, + * and -2 if there is no item at the location. + * @param cx the x coordinate + * @param cy the y coordinate + */ + private int getIndexAt(float cx, float cy) { + if (!contains(cx, cy)) + return -2; + if (cy <= y + baseHeight) + return -1; + if (!expanded) + return -2; + return (int) ((cy - (y + offsetY)) / offsetY); + } + + /** + * Resets the menu state. + */ + public void reset() { + this.expanded = false; + this.lastUpdateTime = 0; + expandProgress.setTime(0); + } + + @Override + public void mousePressed(int button, int x, int y) { + if (!active) + return; + + if (button == Input.MOUSE_MIDDLE_BUTTON) + return; + + int idx = getIndexAt(x, y); + if (idx == -2) { + this.expanded = false; + return; + } + if (!menuClicked(idx)) + return; + this.expanded = (idx == -1) ? !expanded : false; + if (idx >= 0 && itemIndex != idx) { + this.itemIndex = idx; + itemSelected(idx, items[idx]); + } + consumeEvent(); + } + + /** + * Notification that a new item was selected (via override). + * @param index the index of the item selected + * @param item the item selected + */ + public void itemSelected(int index, E item) {} + + /** + * Notification that the menu was clicked (via override). + * @param index the index of the item clicked, or -1 for the base item + * @return true to process the click, or false to block/intercept it + */ + public boolean menuClicked(int index) { return true; } + + @Override + public void setFocus(boolean focus) { /* does not currently use the "focus" concept */ } + + @Override + public void mouseReleased(int button, int x, int y) { /* does not currently use the "focus" concept */ } + + /** + * Selects the item at the given index. + * @param index the list item index + * @throws IllegalArgumentException if {@code index} is negative or greater than or equal to size + */ + public void setSelectedIndex(int index) { + if (index < 0 || index >= items.length) + throw new IllegalArgumentException(); + this.itemIndex = index; + } + + /** + * Returns the index of the selected item. + */ + public int getSelectedIndex() { return itemIndex; } + + /** + * Returns the selected item. + */ + public E getSelectedItem() { return items[itemIndex]; } + + /** + * Returns the item at the given index. + * @param index the list item index + */ + public E getItemAt(int index) { return items[index]; } + + /** + * Returns the number of items in the list. + */ + public int getItemCount() { return items.length; } + + /** Sets the text color. */ + public void setTextColor(Color c) { this.textColor = c; } + + /** Sets the background color. */ + public void setBackgroundColor(Color c) { this.backgroundColor = c; } + + /** Sets the highlight color. */ + public void setHighlightColor(Color c) { this.highlightColor = c; } + + /** Sets the border color. */ + public void setBorderColor(Color c) { this.borderColor = c; } + + /** Sets the down chevron color. */ + public void setChevronDownColor(Color c) { this.chevronDownColor = c; } + + /** Sets the right chevron color. */ + public void setChevronRightColor(Color c) { this.chevronRightColor = c; } +} diff --git a/src/itdelatrisu/opsu/ui/Fonts.java b/src/itdelatrisu/opsu/ui/Fonts.java new file mode 100644 index 00000000..14ecbdb0 --- /dev/null +++ b/src/itdelatrisu/opsu/ui/Fonts.java @@ -0,0 +1,156 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Options; + +import java.awt.Font; +import java.awt.FontFormatException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +import org.newdawn.slick.SlickException; +import org.newdawn.slick.UnicodeFont; +import org.newdawn.slick.font.effects.ColorEffect; +import org.newdawn.slick.font.effects.Effect; +import org.newdawn.slick.util.Log; +import org.newdawn.slick.util.ResourceLoader; + +/** + * Fonts used for drawing. + */ +public class Fonts { + public static UnicodeFont DEFAULT, BOLD, XLARGE, LARGE, MEDIUM, MEDIUMBOLD, SMALL; + + /** Set of all Unicode strings already loaded per font. */ + private static HashMap> loadedGlyphs = new HashMap>(); + + // This class should not be instantiated. + private Fonts() {} + + /** + * Initializes all fonts. + * @throws SlickException if ASCII glyphs could not be loaded + * @throws FontFormatException if any font stream data does not contain the required font tables + * @throws IOException if a font stream cannot be completely read + */ + public static void init() throws SlickException, FontFormatException, IOException { + float fontBase = 12f * GameImage.getUIscale(); + Font javaFont = Font.createFont(Font.TRUETYPE_FONT, ResourceLoader.getResourceAsStream(Options.FONT_NAME)); + Font font = javaFont.deriveFont(Font.PLAIN, (int) (fontBase * 4 / 3)); + DEFAULT = new UnicodeFont(font); + BOLD = new UnicodeFont(font.deriveFont(Font.BOLD)); + XLARGE = new UnicodeFont(font.deriveFont(fontBase * 3)); + LARGE = new UnicodeFont(font.deriveFont(fontBase * 2)); + MEDIUM = new UnicodeFont(font.deriveFont(fontBase * 3 / 2)); + MEDIUMBOLD = new UnicodeFont(font.deriveFont(Font.BOLD, fontBase * 3 / 2)); + SMALL = new UnicodeFont(font.deriveFont(fontBase)); + ColorEffect colorEffect = new ColorEffect(); + loadFont(DEFAULT, colorEffect); + loadFont(BOLD, colorEffect); + loadFont(XLARGE, colorEffect); + loadFont(LARGE, colorEffect); + loadFont(MEDIUM, colorEffect); + loadFont(MEDIUMBOLD, colorEffect); + loadFont(SMALL, colorEffect); + } + + /** + * Loads a Unicode font and its ASCII glyphs. + * @param font the font to load + * @param effect the font effect + * @throws SlickException if the glyphs could not be loaded + */ + @SuppressWarnings("unchecked") + private static void loadFont(UnicodeFont font, Effect effect) throws SlickException { + font.addAsciiGlyphs(); + font.getEffects().add(effect); + font.loadGlyphs(); + } + + /** + * Adds and loads glyphs for a font. + * @param font the font to add the glyphs to + * @param s the string containing the glyphs to load + */ + public static void loadGlyphs(UnicodeFont font, String s) { + if (s == null || s.isEmpty()) + return; + + // get set of added strings + HashSet set = loadedGlyphs.get(font); + if (set == null) { + set = new HashSet(); + loadedGlyphs.put(font, set); + } else if (set.contains(s)) + return; // string already in set + + // load glyphs + font.addGlyphs(s); + set.add(s); + try { + font.loadGlyphs(); + } catch (SlickException e) { + Log.warn(String.format("Failed to load glyphs for string '%s'.", s), e); + } + } + + /** + * Wraps the given string into a list of split lines based on the width. + * @param font the font used to draw the string + * @param text the text to split + * @param width the maximum width of a line + * @return the list of split strings + * @author davedes (http://slick.ninjacave.com/forum/viewtopic.php?t=3778) + */ + public static List wrap(org.newdawn.slick.Font font, String text, int width) { + List list = new ArrayList(); + String str = text; + String line = ""; + int i = 0; + int lastSpace = -1; + while (i < str.length()) { + char c = str.charAt(i); + if (Character.isWhitespace(c)) + lastSpace = i; + String append = line + c; + if (font.getWidth(append) > width) { + int split = (lastSpace != -1) ? lastSpace : i; + int splitTrimmed = split; + if (lastSpace != -1 && split < str.length() - 1) + splitTrimmed++; + list.add(str.substring(0, split)); + str = str.substring(splitTrimmed); + line = ""; + i = 0; + lastSpace = -1; + } else { + line = append; + i++; + } + } + if (str.length() != 0) + list.add(str); + return list; + } +} diff --git a/src/itdelatrisu/opsu/ui/MenuButton.java b/src/itdelatrisu/opsu/ui/MenuButton.java index 7370d0bc..1aa524dc 100644 --- a/src/itdelatrisu/opsu/ui/MenuButton.java +++ b/src/itdelatrisu/opsu/ui/MenuButton.java @@ -19,6 +19,8 @@ package itdelatrisu.opsu.ui; import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import org.newdawn.slick.Animation; import org.newdawn.slick.Color; @@ -63,11 +65,26 @@ public class MenuButton { /** The hover actions for this button. */ private int hoverEffect = 0; - /** The current and max scale of the button. */ - private float scale = 1f, hoverScale = 1.25f; + /** The hover animation duration, in milliseconds. */ + private int animationDuration = 100; - /** The current and base alpha level of the button. */ - private float alpha = 1f, baseAlpha = 0.75f; + /** The hover animation equation. */ + private AnimationEquation animationEqn = AnimationEquation.LINEAR; + + /** Whether the animation is advancing forwards (if advancing automatically). */ + private boolean autoAnimationForward = true; + + /** The scale of the button. */ + private AnimatedValue scale; + + /** The default max scale of the button. */ + private static final float DEFAULT_SCALE_MAX = 1.25f; + + /** The alpha level of the button. */ + private AnimatedValue alpha; + + /** The default base alpha level of the button. */ + private static final float DEFAULT_ALPHA_BASE = 0.75f; /** The scaled expansion direction for the button. */ private Expand dir = Expand.CENTER; @@ -75,8 +92,11 @@ public class MenuButton { /** Scaled expansion directions. */ public enum Expand { CENTER, UP, RIGHT, LEFT, DOWN, UP_RIGHT, UP_LEFT, DOWN_RIGHT, DOWN_LEFT; } - /** The current and max rotation angles of the button. */ - private float angle = 0f, maxAngle = 30f; + /** The rotation angle of the button. */ + private AnimatedValue angle; + + /** The default max rotation angle of the button. */ + private static final float DEFAULT_ANGLE_MAX = 30f; /** * Creates a new button from an Image. @@ -126,11 +146,13 @@ public class MenuButton { /** * Sets a new center x coordinate. + * @param x the x coordinate */ public void setX(float x) { this.x = x; } /** * Sets a new center y coordinate. + * @param y the y coordinate */ public void setY(float y) { this.y = y; } @@ -192,15 +214,15 @@ public class MenuButton { float oldAlpha = image.getAlpha(); float oldAngle = image.getRotation(); if ((hoverEffect & EFFECT_EXPAND) > 0) { - if (scale != 1f) { - image = image.getScaledCopy(scale); + if (scale.getValue() != 1f) { + image = image.getScaledCopy(scale.getValue()); image.setAlpha(oldAlpha); } } if ((hoverEffect & EFFECT_FADE) > 0) - image.setAlpha(alpha); + image.setAlpha(alpha.getValue()); if ((hoverEffect & EFFECT_ROTATE) > 0) - image.setRotation(angle); + image.setRotation(angle.getValue()); image.draw(x - xRadius, y - yRadius, filter); if (image == this.img) { image.setAlpha(oldAlpha); @@ -217,9 +239,10 @@ public class MenuButton { imgR.draw(x + xRadius - imgR.getWidth(), y - yRadius, filter); } else if ((hoverEffect & EFFECT_FADE) > 0) { float a = image.getAlpha(), aL = imgL.getAlpha(), aR = imgR.getAlpha(); - image.setAlpha(alpha); - imgL.setAlpha(alpha); - imgR.setAlpha(alpha); + float currentAlpha = alpha.getValue(); + image.setAlpha(currentAlpha); + imgL.setAlpha(currentAlpha); + imgR.setAlpha(currentAlpha); image.draw(x - xRadius + imgL.getWidth(), y - yRadius, filter); imgL.draw(x - xRadius, y - yRadius, filter); imgR.draw(x + xRadius - imgR.getWidth(), y - yRadius, filter); @@ -267,28 +290,63 @@ public class MenuButton { */ public void resetHover() { if ((hoverEffect & EFFECT_EXPAND) > 0) { - this.scale = 1f; + scale.setTime(0); setHoverRadius(); } if ((hoverEffect & EFFECT_FADE) > 0) - this.alpha = baseAlpha; + alpha.setTime(0); if ((hoverEffect & EFFECT_ROTATE) > 0) - this.angle = 0f; + angle.setTime(0); + autoAnimationForward = true; } /** * Removes all hover effects that have been set for the button. */ - public void removeHoverEffects() { hoverEffect = 0; } + public void removeHoverEffects() { + this.hoverEffect = 0; + this.scale = null; + this.alpha = null; + this.angle = null; + autoAnimationForward = true; + } + + /** + * Sets the hover animation duration. + * @param duration the duration, in milliseconds + */ + public void setHoverAnimationDuration(int duration) { + this.animationDuration = duration; + if (scale != null) + scale.setDuration(duration); + if (alpha != null) + alpha.setDuration(duration); + if (angle != null) + angle.setDuration(duration); + } + + /** + * Sets the hover animation equation. + * @param eqn the equation to use + */ + public void setHoverAnimationEquation(AnimationEquation eqn) { + this.animationEqn = eqn; + if (scale != null) + scale.setEquation(eqn); + if (alpha != null) + alpha.setEquation(eqn); + if (angle != null) + angle.setEquation(eqn); + } /** * Sets the "expand" hover effect. */ - public void setHoverExpand() { hoverEffect |= EFFECT_EXPAND; } + public void setHoverExpand() { setHoverExpand(DEFAULT_SCALE_MAX, this.dir); } /** * Sets the "expand" hover effect. - * @param scale the maximum scale factor (default 1.25f) + * @param scale the maximum scale factor */ public void setHoverExpand(float scale) { setHoverExpand(scale, this.dir); } @@ -296,45 +354,45 @@ public class MenuButton { * Sets the "expand" hover effect. * @param dir the expansion direction */ - public void setHoverExpand(Expand dir) { setHoverExpand(this.hoverScale, dir); } + public void setHoverExpand(Expand dir) { setHoverExpand(DEFAULT_SCALE_MAX, dir); } /** * Sets the "expand" hover effect. - * @param scale the maximum scale factor (default 1.25f) + * @param scale the maximum scale factor * @param dir the expansion direction */ public void setHoverExpand(float scale, Expand dir) { hoverEffect |= EFFECT_EXPAND; - this.hoverScale = scale; + this.scale = new AnimatedValue(animationDuration, 1f, scale, animationEqn); this.dir = dir; } /** * Sets the "fade" hover effect. */ - public void setHoverFade() { hoverEffect |= EFFECT_FADE; } + public void setHoverFade() { setHoverFade(DEFAULT_ALPHA_BASE); } /** * Sets the "fade" hover effect. - * @param baseAlpha the base alpha level to fade in from (default 0.7f) + * @param baseAlpha the base alpha level to fade in from */ public void setHoverFade(float baseAlpha) { hoverEffect |= EFFECT_FADE; - this.baseAlpha = baseAlpha; + this.alpha = new AnimatedValue(animationDuration, baseAlpha, 1f, animationEqn); } /** * Sets the "rotate" hover effect. */ - public void setHoverRotate() { hoverEffect |= EFFECT_ROTATE; } + public void setHoverRotate() { setHoverRotate(DEFAULT_ANGLE_MAX); } /** * Sets the "rotate" hover effect. - * @param maxAngle the maximum rotation angle, in degrees (default 30f) + * @param maxAngle the maximum rotation angle, in degrees */ public void setHoverRotate(float maxAngle) { hoverEffect |= EFFECT_ROTATE; - this.maxAngle = maxAngle; + this.angle = new AnimatedValue(animationDuration, 0f, maxAngle, animationEqn); } /** @@ -371,45 +429,53 @@ public class MenuButton { if (hoverEffect == 0) return; + int d = delta * (isHover ? 1 : -1); + // scale the button if ((hoverEffect & EFFECT_EXPAND) > 0) { - int sign = 0; - if (isHover && scale < hoverScale) - sign = 1; - else if (!isHover && scale > 1f) - sign = -1; - if (sign != 0) { - scale = Utils.getBoundedValue(scale, sign * (hoverScale - 1f) * delta / 100f, 1, hoverScale); + if (scale.update(d)) setHoverRadius(); - } } // fade the button - if ((hoverEffect & EFFECT_FADE) > 0) { - int sign = 0; - if (isHover && alpha < 1f) - sign = 1; - else if (!isHover && alpha > baseAlpha) - sign = -1; - if (sign != 0) - alpha = Utils.getBoundedValue(alpha, sign * (1f - baseAlpha) * delta / 200f, baseAlpha, 1f); - } + if ((hoverEffect & EFFECT_FADE) > 0) + alpha.update(d); // rotate the button - if ((hoverEffect & EFFECT_ROTATE) > 0) { - int sign = 0; - boolean right = (maxAngle > 0); - if (isHover && angle != maxAngle) - sign = (right) ? 1 : -1; - else if (!isHover && angle != 0) - sign = (right) ? -1 : 1; - if (sign != 0) { - float diff = sign * Math.abs(maxAngle) * delta / 125f; - angle = (right) ? - Utils.getBoundedValue(angle, diff, 0, maxAngle) : - Utils.getBoundedValue(angle, diff, maxAngle, 0); + if ((hoverEffect & EFFECT_ROTATE) > 0) + angle.update(d); + } + + /** + * Automatically advances the hover animation in a loop. + * @param delta the delta interval + * @param reverseAtEnd whether to reverse or restart the animation upon reaching the end + */ + public void autoHoverUpdate(int delta, boolean reverseAtEnd) { + if (hoverEffect == 0) + return; + + int time = ((hoverEffect & EFFECT_EXPAND) > 0) ? scale.getTime() : + ((hoverEffect & EFFECT_FADE) > 0) ? alpha.getTime() : + ((hoverEffect & EFFECT_ROTATE) > 0) ? angle.getTime() : -1; + if (time == -1) + return; + + int d = delta * (autoAnimationForward ? 1 : -1); + if (Utils.clamp(time + d, 0, animationDuration) == time) { + if (reverseAtEnd) + autoAnimationForward = !autoAnimationForward; + else { + if ((hoverEffect & EFFECT_EXPAND) > 0) + scale.setTime(0); + if ((hoverEffect & EFFECT_FADE) > 0) + alpha.setTime(0); + if ((hoverEffect & EFFECT_ROTATE) > 0) + angle.setTime(0); } } + + hoverUpdate(delta, autoAnimationForward); } /** @@ -422,10 +488,11 @@ public class MenuButton { image = anim.getCurrentFrame(); int xOffset = 0, yOffset = 0; + float currentScale = scale.getValue(); if (dir != Expand.CENTER) { // offset by difference between normal/scaled image dimensions - xOffset = (int) ((scale - 1f) * image.getWidth()); - yOffset = (int) ((scale - 1f) * image.getHeight()); + xOffset = (int) ((currentScale - 1f) * image.getWidth()); + yOffset = (int) ((currentScale - 1f) * image.getHeight()); if (dir == Expand.UP || dir == Expand.DOWN) xOffset = 0; // no horizontal offset if (dir == Expand.RIGHT || dir == Expand.LEFT) @@ -435,7 +502,7 @@ public class MenuButton { if (dir == Expand.DOWN || dir == Expand.DOWN_LEFT || dir == Expand.DOWN_RIGHT) yOffset *= -1; // flip y for down } - this.xRadius = ((image.getWidth() * scale) + xOffset) / 2f; - this.yRadius = ((image.getHeight() * scale) + yOffset) / 2f; + this.xRadius = ((image.getWidth() * currentScale) + xOffset) / 2f; + this.yRadius = ((image.getHeight() * currentScale) + yOffset) / 2f; } } diff --git a/src/itdelatrisu/opsu/ui/StarStream.java b/src/itdelatrisu/opsu/ui/StarStream.java new file mode 100644 index 00000000..ec42c2aa --- /dev/null +++ b/src/itdelatrisu/opsu/ui/StarStream.java @@ -0,0 +1,164 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui; + +import itdelatrisu.opsu.GameImage; +import itdelatrisu.opsu.Utils; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Random; + +import org.newdawn.slick.Image; + +/** + * Horizontal star stream. + */ +public class StarStream { + /** The container dimensions. */ + private final int containerWidth, containerHeight; + + /** The star image. */ + private final Image starImg; + + /** The current list of stars. */ + private final List stars; + + /** The maximum number of stars to draw at once. */ + private static final int MAX_STARS = 20; + + /** Random number generator instance. */ + private final Random random; + + /** Contains data for a single star. */ + private class Star { + /** The star animation progress. */ + private final AnimatedValue animatedValue; + + /** The star properties. */ + private final int distance, yOffset, angle; + + /** + * Creates a star with the given properties. + * @param duration the time, in milliseconds, to show the star + * @param distance the distance for the star to travel in {@code duration} + * @param yOffset the vertical offset from the center of the container + * @param angle the rotation angle + * @param eqn the animation equation to use + */ + public Star(int duration, int distance, int yOffset, int angle, AnimationEquation eqn) { + this.animatedValue = new AnimatedValue(duration, 0f, 1f, eqn); + this.distance = distance; + this.yOffset = yOffset; + this.angle = angle; + } + + /** + * Draws the star. + */ + public void draw() { + float t = animatedValue.getValue(); + starImg.setImageColor(1f, 1f, 1f, Math.min((1 - t) * 5f, 1f)); + starImg.drawEmbedded( + containerWidth - (distance * t), ((containerHeight - starImg.getHeight()) / 2) + yOffset, + starImg.getWidth(), starImg.getHeight(), angle); + } + + /** + * Updates the animation by a delta interval. + * @param delta the delta interval since the last call + * @return true if an update was applied, false if the animation was not updated + */ + public boolean update(int delta) { return animatedValue.update(delta); } + } + + /** + * Initializes the star stream. + * @param width the container width + * @param height the container height + */ + public StarStream(int width, int height) { + this.containerWidth = width; + this.containerHeight = height; + this.starImg = GameImage.STAR2.getImage().copy(); + this.stars = new ArrayList(); + this.random = new Random(); + } + + /** + * Draws the star stream. + */ + public void draw() { + if (stars.isEmpty()) + return; + + starImg.startUse(); + for (Star star : stars) + star.draw(); + starImg.endUse(); + } + + /** + * Updates the stars in the stream by a delta interval. + * @param delta the delta interval since the last call + */ + public void update(int delta) { + // update current stars + Iterator iter = stars.iterator(); + while (iter.hasNext()) { + Star star = iter.next(); + if (!star.update(delta)) + iter.remove(); + } + + // create new stars + for (int i = stars.size(); i < MAX_STARS; i++) { + if (Math.random() < ((i < 5) ? 0.25 : 0.66)) + break; + + // generate star properties + float distanceRatio = Utils.clamp((float) getGaussian(0.65, 0.25), 0.2f, 0.925f); + int distance = (int) (containerWidth * distanceRatio); + int duration = (int) (distanceRatio * getGaussian(1300, 300)); + int yOffset = (int) getGaussian(0, containerHeight / 20); + int angle = (int) getGaussian(0, 22.5); + AnimationEquation eqn = random.nextBoolean() ? AnimationEquation.IN_OUT_QUAD : AnimationEquation.OUT_QUAD; + + stars.add(new Star(duration, distance, angle, yOffset, eqn)); + } + } + + /** + * Clears the stars currently in the stream. + */ + public void clear() { stars.clear(); } + + /** + * Returns the next pseudorandom, Gaussian ("normally") distributed {@code double} value + * with the given mean and standard deviation. + * @param mean the mean + * @param stdDev the standard deviation + */ + private double getGaussian(double mean, double stdDev) { + return mean + random.nextGaussian() * stdDev; + } +} diff --git a/src/itdelatrisu/opsu/ui/UI.java b/src/itdelatrisu/opsu/ui/UI.java index 1f132618..b58b7002 100644 --- a/src/itdelatrisu/opsu/ui/UI.java +++ b/src/itdelatrisu/opsu/ui/UI.java @@ -21,11 +21,13 @@ package itdelatrisu.opsu.ui; import itdelatrisu.opsu.ErrorHandler; import itdelatrisu.opsu.GameImage; import itdelatrisu.opsu.Options; -import itdelatrisu.opsu.OszUnpacker; import itdelatrisu.opsu.Utils; import itdelatrisu.opsu.audio.SoundController; import itdelatrisu.opsu.beatmap.BeatmapParser; +import itdelatrisu.opsu.beatmap.OszUnpacker; import itdelatrisu.opsu.replay.ReplayImporter; +import itdelatrisu.opsu.ui.animations.AnimatedValue; +import itdelatrisu.opsu.ui.animations.AnimationEquation; import javax.swing.JOptionPane; import javax.swing.UIManager; @@ -36,7 +38,6 @@ import org.newdawn.slick.GameContainer; import org.newdawn.slick.Graphics; import org.newdawn.slick.Image; import org.newdawn.slick.Input; -import org.newdawn.slick.SlickException; import org.newdawn.slick.state.StateBasedGame; /** @@ -62,7 +63,7 @@ public class UI { private static int barNotifTimer = -1; /** Duration, in milliseconds, to display bar notifications. */ - private static final int BAR_NOTIFICATION_TIME = 1250; + private static final int BAR_NOTIFICATION_TIME = 1500; /** The current tooltip. */ private static String tooltip; @@ -70,11 +71,8 @@ public class UI { /** Whether or not to check the current tooltip for line breaks. */ private static boolean tooltipNewlines; - /** The current tooltip timer. */ - private static int tooltipTimer = -1; - - /** Duration, in milliseconds, to fade tooltips. */ - private static final int TOOLTIP_FADE_TIME = 200; + /** The alpha level of the current tooltip (if any). */ + private static AnimatedValue tooltipAlpha = new AnimatedValue(200, 0f, 1f, AnimationEquation.LINEAR); // game-related variables private static GameContainer container; @@ -87,10 +85,8 @@ public class UI { * Initializes UI data. * @param container the game container * @param game the game object - * @throws SlickException */ - public static void init(GameContainer container, StateBasedGame game) - throws SlickException { + public static void init(GameContainer container, StateBasedGame game) { UI.container = container; UI.input = container.getInput(); @@ -106,6 +102,8 @@ public class UI { Image back = GameImage.MENU_BACK.getImage(); backButton = new MenuButton(back, back.getWidth() / 2f, container.getHeight() - (back.getHeight() / 2f)); } + backButton.setHoverAnimationDuration(350); + backButton.setHoverAnimationEquation(AnimationEquation.IN_OUT_BACK); backButton.setHoverExpand(MenuButton.Expand.UP_RIGHT); } @@ -117,12 +115,11 @@ public class UI { cursor.update(delta); updateVolumeDisplay(delta); updateBarNotification(delta); - if (tooltipTimer > 0) - tooltipTimer -= delta; + tooltipAlpha.update(-delta); } /** - * Draws the global UI components: cursor, FPS, volume bar, bar notifications. + * Draws the global UI components: cursor, FPS, volume bar, tooltips, bar notifications. * @param g the graphics context */ public static void draw(Graphics g) { @@ -134,7 +131,7 @@ public class UI { } /** - * Draws the global UI components: cursor, FPS, volume bar, bar notifications. + * Draws the global UI components: cursor, FPS, volume bar, tooltips, bar notifications. * @param g the graphics context * @param mouseX the mouse x coordinate * @param mouseY the mouse y coordinate @@ -178,18 +175,18 @@ public class UI { */ public static void drawTab(float x, float y, String text, boolean selected, boolean isHover) { Image tabImage = GameImage.MENU_TAB.getImage(); - float tabTextX = x - (Utils.FONT_MEDIUM.getWidth(text) / 2); + float tabTextX = x - (Fonts.MEDIUM.getWidth(text) / 2); float tabTextY = y - (tabImage.getHeight() / 2); Color filter, textColor; if (selected) { filter = Color.white; textColor = Color.black; } else { - filter = (isHover) ? Utils.COLOR_RED_HOVER : Color.red; + filter = (isHover) ? Colors.RED_HOVER : Color.red; textColor = Color.white; } tabImage.drawCentered(x, y, filter); - Utils.FONT_MEDIUM.drawString(tabTextX, tabTextY, text, textColor); + Fonts.MEDIUM.drawString(tabTextX, tabTextY, text, textColor); } /** @@ -201,14 +198,14 @@ public class UI { return; String fps = String.format("%dFPS", container.getFPS()); - Utils.FONT_BOLD.drawString( - container.getWidth() * 0.997f - Utils.FONT_BOLD.getWidth(fps), - container.getHeight() * 0.997f - Utils.FONT_BOLD.getHeight(fps), + Fonts.BOLD.drawString( + container.getWidth() * 0.997f - Fonts.BOLD.getWidth(fps), + container.getHeight() * 0.997f - Fonts.BOLD.getHeight(fps), Integer.toString(container.getFPS()), Color.white ); - Utils.FONT_DEFAULT.drawString( - container.getWidth() * 0.997f - Utils.FONT_BOLD.getWidth("FPS"), - container.getHeight() * 0.997f - Utils.FONT_BOLD.getHeight("FPS"), + Fonts.DEFAULT.drawString( + container.getWidth() * 0.997f - Fonts.BOLD.getWidth("FPS"), + container.getHeight() * 0.997f - Fonts.BOLD.getHeight("FPS"), "FPS", Color.white ); } @@ -263,7 +260,7 @@ public class UI { */ public static void changeVolume(int units) { final float UNIT_OFFSET = 0.05f; - Options.setMasterVolume(container, Utils.getBoundedValue(Options.getMasterVolume(), UNIT_OFFSET * units, 0f, 1f)); + Options.setMasterVolume(container, Utils.clamp(Options.getMasterVolume() + (UNIT_OFFSET * units), 0f, 1f)); if (volumeDisplay == -1) volumeDisplay = 0; else if (volumeDisplay >= VOLUME_DISPLAY_TIME / 10) @@ -271,8 +268,9 @@ public class UI { } /** - * Draws loading progress (OSZ unpacking, beatmap parsing, sound loading) + * Draws loading progress (OSZ unpacking, beatmap parsing, replay importing, sound loading) * at the bottom of the screen. + * @param g the graphics context */ public static void drawLoadingProgress(Graphics g) { String text, file; @@ -298,16 +296,16 @@ public class UI { // draw loading info float marginX = container.getWidth() * 0.02f, marginY = container.getHeight() * 0.02f; float lineY = container.getHeight() - marginY; - int lineOffsetY = Utils.FONT_MEDIUM.getLineHeight(); + int lineOffsetY = Fonts.MEDIUM.getLineHeight(); if (Options.isLoadVerbose()) { // verbose: display percentages and file names - Utils.FONT_MEDIUM.drawString( + Fonts.MEDIUM.drawString( marginX, lineY - (lineOffsetY * 2), String.format("%s (%d%%)", text, progress), Color.white); - Utils.FONT_MEDIUM.drawString(marginX, lineY - lineOffsetY, file, Color.white); + Fonts.MEDIUM.drawString(marginX, lineY - lineOffsetY, file, Color.white); } else { // draw loading bar - Utils.FONT_MEDIUM.drawString(marginX, lineY - (lineOffsetY * 2), text, Color.white); + Fonts.MEDIUM.drawString(marginX, lineY - (lineOffsetY * 2), text, Color.white); g.setColor(Color.white); g.fillRoundRect(marginX, lineY - (lineOffsetY / 2f), (container.getWidth() - (marginX * 2f)) * progress / 100f, lineOffsetY / 4f, 4 @@ -357,12 +355,7 @@ public class UI { if (s != null) { tooltip = s; tooltipNewlines = newlines; - if (tooltipTimer <= 0) - tooltipTimer = delta; - else - tooltipTimer += delta * 2; - if (tooltipTimer > TOOLTIP_FADE_TIME) - tooltipTimer = TOOLTIP_FADE_TIME; + tooltipAlpha.update(delta * 2); } } @@ -372,26 +365,26 @@ public class UI { * @param g the graphics context */ public static void drawTooltip(Graphics g) { - if (tooltipTimer <= 0 || tooltip == null) + if (tooltipAlpha.getTime() == 0 || tooltip == null) return; int containerWidth = container.getWidth(), containerHeight = container.getHeight(); int margin = containerWidth / 100, textMarginX = 2; int offset = GameImage.CURSOR_MIDDLE.getImage().getWidth() / 2; - int lineHeight = Utils.FONT_SMALL.getLineHeight(); + int lineHeight = Fonts.SMALL.getLineHeight(); int textWidth = textMarginX * 2, textHeight = lineHeight; if (tooltipNewlines) { String[] lines = tooltip.split("\\n"); - int maxWidth = Utils.FONT_SMALL.getWidth(lines[0]); + int maxWidth = Fonts.SMALL.getWidth(lines[0]); for (int i = 1; i < lines.length; i++) { - int w = Utils.FONT_SMALL.getWidth(lines[i]); + int w = Fonts.SMALL.getWidth(lines[i]); if (w > maxWidth) maxWidth = w; } textWidth += maxWidth; textHeight += lineHeight * (lines.length - 1); } else - textWidth += Utils.FONT_SMALL.getWidth(tooltip); + textWidth += Fonts.SMALL.getWidth(tooltip); // get drawing coordinates int x = input.getMouseX() + offset, y = input.getMouseY() + offset; @@ -405,29 +398,29 @@ public class UI { y = margin; // draw tooltip text inside a filled rectangle - float alpha = (float) tooltipTimer / TOOLTIP_FADE_TIME; - float oldAlpha = Utils.COLOR_BLACK_ALPHA.a; - Utils.COLOR_BLACK_ALPHA.a = alpha; - g.setColor(Utils.COLOR_BLACK_ALPHA); - Utils.COLOR_BLACK_ALPHA.a = oldAlpha; + float alpha = tooltipAlpha.getValue(); + float oldAlpha = Colors.BLACK_ALPHA.a; + Colors.BLACK_ALPHA.a = alpha; + g.setColor(Colors.BLACK_ALPHA); + Colors.BLACK_ALPHA.a = oldAlpha; g.fillRect(x, y, textWidth, textHeight); - oldAlpha = Utils.COLOR_DARK_GRAY.a; - Utils.COLOR_DARK_GRAY.a = alpha; - g.setColor(Utils.COLOR_DARK_GRAY); + oldAlpha = Colors.DARK_GRAY.a; + Colors.DARK_GRAY.a = alpha; + g.setColor(Colors.DARK_GRAY); g.setLineWidth(1); g.drawRect(x, y, textWidth, textHeight); - Utils.COLOR_DARK_GRAY.a = oldAlpha; - oldAlpha = Utils.COLOR_WHITE_ALPHA.a; - Utils.COLOR_WHITE_ALPHA.a = alpha; - Utils.FONT_SMALL.drawString(x + textMarginX, y, tooltip, Utils.COLOR_WHITE_ALPHA); - Utils.COLOR_WHITE_ALPHA.a = oldAlpha; + Colors.DARK_GRAY.a = oldAlpha; + oldAlpha = Colors.WHITE_ALPHA.a; + Colors.WHITE_ALPHA.a = alpha; + Fonts.SMALL.drawString(x + textMarginX, y, tooltip, Colors.WHITE_ALPHA); + Colors.WHITE_ALPHA.a = oldAlpha; } /** * Resets the tooltip. */ public static void resetTooltip() { - tooltipTimer = -1; + tooltipAlpha.setTime(0); tooltip = null; } @@ -475,18 +468,18 @@ public class UI { if (barNotifTimer >= BAR_NOTIFICATION_TIME * 0.9f) alpha -= 1 - ((BAR_NOTIFICATION_TIME - barNotifTimer) / (BAR_NOTIFICATION_TIME * 0.1f)); int midX = container.getWidth() / 2, midY = container.getHeight() / 2; - float barHeight = Utils.FONT_LARGE.getLineHeight() * (1f + 0.6f * Math.min(barNotifTimer * 15f / BAR_NOTIFICATION_TIME, 1f)); - float oldAlphaB = Utils.COLOR_BLACK_ALPHA.a, oldAlphaW = Utils.COLOR_WHITE_ALPHA.a; - Utils.COLOR_BLACK_ALPHA.a *= alpha; - Utils.COLOR_WHITE_ALPHA.a = alpha; - g.setColor(Utils.COLOR_BLACK_ALPHA); + float barHeight = Fonts.LARGE.getLineHeight() * (1f + 0.6f * Math.min(barNotifTimer * 15f / BAR_NOTIFICATION_TIME, 1f)); + float oldAlphaB = Colors.BLACK_ALPHA.a, oldAlphaW = Colors.WHITE_ALPHA.a; + Colors.BLACK_ALPHA.a *= alpha; + Colors.WHITE_ALPHA.a = alpha; + g.setColor(Colors.BLACK_ALPHA); g.fillRect(0, midY - barHeight / 2f, container.getWidth(), barHeight); - Utils.FONT_LARGE.drawString( - midX - Utils.FONT_LARGE.getWidth(barNotif) / 2f, - midY - Utils.FONT_LARGE.getLineHeight() / 2.2f, - barNotif, Utils.COLOR_WHITE_ALPHA); - Utils.COLOR_BLACK_ALPHA.a = oldAlphaB; - Utils.COLOR_WHITE_ALPHA.a = oldAlphaW; + Fonts.LARGE.drawString( + midX - Fonts.LARGE.getWidth(barNotif) / 2f, + midY - Fonts.LARGE.getLineHeight() / 2.2f, + barNotif, Colors.WHITE_ALPHA); + Colors.BLACK_ALPHA.a = oldAlphaB; + Colors.WHITE_ALPHA.a = oldAlphaW; } /** diff --git a/src/itdelatrisu/opsu/ui/animations/AnimatedValue.java b/src/itdelatrisu/opsu/ui/animations/AnimatedValue.java new file mode 100644 index 00000000..da8bd8d5 --- /dev/null +++ b/src/itdelatrisu/opsu/ui/animations/AnimatedValue.java @@ -0,0 +1,134 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui.animations; + +import itdelatrisu.opsu.Utils; + +/** + * Utility class for updating a value using an animation equation. + */ +public class AnimatedValue { + /** The animation duration, in milliseconds. */ + private int duration; + + /** The current time, in milliseconds. */ + private int time; + + /** The base value. */ + private float base; + + /** The maximum difference from the base value. */ + private float diff; + + /** The current value. */ + private float value; + + /** The animation equation to use. */ + private AnimationEquation eqn; + + /** + * Constructor. + * @param duration the total animation duration, in milliseconds + * @param min the minimum value + * @param max the maximum value + * @param eqn the animation equation to use + */ + public AnimatedValue(int duration, float min, float max, AnimationEquation eqn) { + this.time = 0; + this.duration = duration; + this.value = min; + this.base = min; + this.diff = max - min; + this.eqn = eqn; + } + + /** + * Returns the current value. + */ + public float getValue() { return value; } + + /** + * Returns the current animation time, in milliseconds. + */ + public int getTime() { return time; } + + /** + * Sets the animation time manually. + * @param time the new time, in milliseconds + */ + public void setTime(int time) { + this.time = Utils.clamp(time, 0, duration); + updateValue(); + } + + /** + * Returns the total animation duration, in milliseconds. + */ + public int getDuration() { return duration; } + + /** + * Sets the animation duration. + * @param duration the new duration, in milliseconds + */ + public void setDuration(int duration) { + this.duration = duration; + int newTime = Utils.clamp(time, 0, duration); + if (time != newTime) { + this.time = newTime; + updateValue(); + } + } + + /** + * Returns the animation equation being used. + */ + public AnimationEquation getEquation() { return eqn; } + + /** + * Sets the animation equation to use. + * @param eqn the new equation + */ + public void setEquation(AnimationEquation eqn) { + this.eqn = eqn; + updateValue(); + } + + /** + * Updates the animation by a delta interval. + * @param delta the delta interval since the last call. + * @return true if an update was applied, false if the animation was not updated + */ + public boolean update(int delta) { + int newTime = Utils.clamp(time + delta, 0, duration); + if (time != newTime) { + this.time = newTime; + updateValue(); + return true; + } + return false; + } + + /** + * Recalculates the value by applying the animation equation with the current time. + */ + private void updateValue() { + float t = eqn.calc((float) time / duration); + this.value = base + (t * diff); + } +} diff --git a/src/itdelatrisu/opsu/ui/animations/AnimationEquation.java b/src/itdelatrisu/opsu/ui/animations/AnimationEquation.java new file mode 100644 index 00000000..3953b436 --- /dev/null +++ b/src/itdelatrisu/opsu/ui/animations/AnimationEquation.java @@ -0,0 +1,308 @@ +/* + * opsu! - an open-source osu! client + * Copyright (C) 2014, 2015 Jeffrey Han + * + * opsu! is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * opsu! is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with opsu!. If not, see . + */ + +package itdelatrisu.opsu.ui.animations; + +/* + * These equations are copyright (c) 2001 Robert Penner, all rights reserved, + * and are open source under the BSD License. + * http://www.opensource.org/licenses/bsd-license.php + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the author nor the names of contributors may be used + * to endorse or promote products derived from this software without specific + * prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Easing functions for animations. + * + * @author Robert Penner (
http://robertpenner.com/easing/) + * @author CharlotteGore (https://github.com/CharlotteGore/functional-easing) + */ +public enum AnimationEquation { + /* Linear */ + LINEAR { + @Override + public float calc(float t) { return t; } + }, + + /* Quadratic */ + IN_QUAD { + @Override + public float calc(float t) { return t * t; } + }, + OUT_QUAD { + @Override + public float calc(float t) { return -1 * t * (t - 2); } + }, + IN_OUT_QUAD { + @Override + public float calc(float t) { + t = t * 2; + if (t < 1) + return 0.5f * t * t; + t = t - 1; + return -0.5f * (t * (t - 2) - 1); + } + }, + + /* Cubic */ + IN_CUBIC { + @Override + public float calc(float t) { return t * t * t; } + }, + OUT_CUBIC { + @Override + public float calc(float t) { + t = t - 1; + return t * t * t + 1; + } + }, + IN_OUT_CUBIC { + @Override + public float calc(float t) { + t = t * 2; + if (t < 1) + return 0.5f * t * t * t; + t = t - 2; + return 0.5f * (t * t * t + 2); + } + }, + + /* Quartic */ + IN_QUART { + @Override + public float calc(float t) { return t * t * t * t; } + }, + OUT_QUART { + @Override + public float calc(float t) { + t = t - 1; + return -1 * (t * t * t * t - 1); + } + }, + IN_OUT_QUART { + @Override + public float calc(float t) { + t = t * 2; + if (t < 1) + return 0.5f * t * t * t * t; + t = t - 2; + return -0.5f * (t * t * t * t - 2); + } + }, + + /* Quintic */ + IN_QUINT { + @Override + public float calc(float t) { return t * t * t * t * t; } + }, + OUT_QUINT { + @Override + public float calc(float t) { + t = t - 1; + return (t * t * t * t * t + 1); + } + }, + IN_OUT_QUINT { + @Override + public float calc(float t) { + t = t * 2; + if (t < 1) + return 0.5f * t * t * t * t * t; + t = t - 2; + return 0.5f * (t * t * t * t * t + 2); + } + }, + + /* Sine */ + IN_SINE { + @Override + public float calc(float t) { return -1 * (float) Math.cos(t * (Math.PI / 2)) + 1; } + }, + OUT_SINE { + @Override + public float calc(float t) { return (float) Math.sin(t * (Math.PI / 2)); } + }, + IN_OUT_SINE { + @Override + public float calc(float t) { return (float) (Math.cos(Math.PI * t) - 1) / -2; } + }, + + /* Exponential */ + IN_EXPO { + @Override + public float calc(float t) { return (t == 0) ? 0 : (float) Math.pow(2, 10 * (t - 1)); } + }, + OUT_EXPO { + @Override + public float calc(float t) { return (t == 1) ? 1 : (float) -Math.pow(2, -10 * t) + 1; } + }, + IN_OUT_EXPO { + @Override + public float calc(float t) { + if (t == 0 || t == 1) + return t; + t = t * 2; + if (t < 1) + return 0.5f * (float) Math.pow(2, 10 * (t - 1)); + t = t - 1; + return 0.5f * ((float) -Math.pow(2, -10 * t) + 2); + } + }, + + /* Circular */ + IN_CIRC { + @Override + public float calc(float t) { return -1 * ((float) Math.sqrt(1 - t * t) - 1); } + }, + OUT_CIRC { + @Override + public float calc(float t) { + t = t - 1; + return (float) Math.sqrt(1 - t * t); + } + }, + IN_OUT_CIRC { + @Override + public float calc(float t) { + t = t * 2; + if (t < 1) + return -0.5f * ((float) Math.sqrt(1 - t * t) - 1); + t = t - 2; + return 0.5f * ((float) Math.sqrt(1 - t * t) + 1); + } + }, + + /* Back */ + IN_BACK { + @Override + public float calc(float t) { return t * t * ((OVERSHOOT + 1) * t - OVERSHOOT); } + }, + OUT_BACK { + @Override + public float calc(float t) { + t = t - 1; + return t * t * ((OVERSHOOT + 1) * t + OVERSHOOT) + 1; + } + }, + IN_OUT_BACK { + @Override + public float calc(float t) { + float overshoot = OVERSHOOT * 1.525f; + t = t * 2; + if (t < 1) + return 0.5f * (t * t * ((overshoot + 1) * t - overshoot)); + t = t - 2; + return 0.5f * (t * t * ((overshoot + 1) * t + overshoot) + 2); + } + }, + + /* Bounce */ + IN_BOUNCE { + @Override + public float calc(float t) { return 1 - OUT_BOUNCE.calc(1 - t); } + }, + OUT_BOUNCE { + @Override + public float calc(float t) { + if (t < 0.36363636f) + return 7.5625f * t * t; + else if (t < 0.72727273f) { + t = t - 0.54545454f; + return 7.5625f * t * t + 0.75f; + } else if (t < 0.90909091f) { + t = t - 0.81818182f; + return 7.5625f * t * t + 0.9375f; + } else { + t = t - 0.95454546f; + return 7.5625f * t * t + 0.984375f; + } + } + }, + IN_OUT_BOUNCE { + @Override + public float calc(float t) { + if (t < 0.5f) + return IN_BOUNCE.calc(t * 2) * 0.5f; + return OUT_BOUNCE.calc(t * 2 - 1) * 0.5f + 0.5f; + } + }, + + /* Elastic */ + IN_ELASTIC { + @Override + public float calc(float t) { + if (t == 0 || t == 1) + return t; + float period = 0.3f; + t = t - 1; + return -((float) Math.pow(2, 10 * t) * (float) Math.sin(((t - period / 4) * (Math.PI * 2)) / period)); + } + }, + OUT_ELASTIC { + @Override + public float calc(float t) { + if (t == 0 || t == 1) + return t; + float period = 0.3f; + return (float) Math.pow(2, -10 * t) * (float) Math.sin((t - period / 4) * (Math.PI * 2) / period) + 1; + } + }, + IN_OUT_ELASTIC { + @Override + public float calc(float t) { + if (t == 0 || t == 1) + return t; + float period = 0.44999996f; + t = t * 2 - 1; + if (t < 0) + return -0.5f * ((float) Math.pow(2, 10 * t) * (float) Math.sin((t - period / 4) * (Math.PI * 2) / period)); + return (float) Math.pow(2, -10 * t) * (float) Math.sin((t - period / 4) * (Math.PI * 2) / period) * 0.5f + 1; + } + }; + + /** Overshoot constant for "back" easings. */ + private static final float OVERSHOOT = 1.70158f; + + /** + * Calculates a new {@code t} value using the animation equation. + * @param t the raw {@code t} value [0,1] + * @return the new {@code t} value [0,1] + */ + public abstract float calc(float t); +} diff --git a/src/org/newdawn/slick/Image.java b/src/org/newdawn/slick/Image.java index 1737a808..8989fb92 100644 --- a/src/org/newdawn/slick/Image.java +++ b/src/org/newdawn/slick/Image.java @@ -38,6 +38,7 @@ import org.newdawn.slick.opengl.TextureImpl; import org.newdawn.slick.opengl.pbuffer.GraphicsFactory; import org.newdawn.slick.opengl.renderer.Renderer; import org.newdawn.slick.opengl.renderer.SGL; +import org.newdawn.slick.util.FastTrig; import org.newdawn.slick.util.Log; /** @@ -104,7 +105,7 @@ public class Image implements Renderable { /** The colours for each of the corners */ protected Color[] corners; /** The OpenGL max filter */ - private int filter = SGL.GL_LINEAR; + private int filter = FILTER_LINEAR; /** True if the image should be flipped vertically */ private boolean flipped; @@ -562,7 +563,7 @@ public class Image implements Renderable { * @param y The y coordinate to place the image's center at */ public void drawCentered(float x, float y) { - draw(x-(getWidth()/2),y-(getHeight()/2)); + draw(x - (getWidth() / 2f), y - (getHeight() / 2f)); } /** @@ -595,11 +596,22 @@ public class Image implements Renderable { * @param y The y location to draw the image at * @param filter The color to filter with when drawing */ + @Override public void draw(float x, float y, Color filter) { init(); draw(x,y,width,height, filter); } + /** + * Draw this image as part of a collection of images + * + * @param x The x location to draw the image at + * @param y The y location to draw the image at + */ + public void drawEmbedded(float x,float y) { + drawEmbedded(x, y, getWidth(), getHeight()); + } + /** * Draw this image as part of a collection of images * @@ -719,6 +731,7 @@ public class Image implements Renderable { * @param height * The height to render the image at */ + @Override public void draw(float x,float y,float width,float height) { init(); draw(x,y,width,height,Color.white); @@ -797,6 +810,7 @@ public class Image implements Renderable { * @param height The height to render the image at * @param filter The color to filter with while drawing */ + @Override public void draw(float x,float y,float width,float height,Color filter) { if (alpha != 1) { if (filter == null) { @@ -1159,6 +1173,83 @@ public class Image implements Renderable { newTextureOffsetY); GL.glVertex3f((x + mywidth),y, 0.0f); } + + /** + * Unlike the other drawEmbedded methods, this allows for the embedded image + * to be rotated. This is done by applying a rotation transform to each + * vertex of the image. This ignores getRotation but depends on the + * center x/y (scaled accordingly to the new width/height). + * + * @param x the x to render the image at + * @param y the y to render the image at + * @param width the new width to render the image + * @param height the new height to render the image + * @param rotation the rotation to render the image, using getCenterOfRotationX/Y + * + * @author davedes + */ + public void drawEmbedded(float x, float y, float width, float height, float rotation) { + if (rotation==0) { + drawEmbedded(x, y, width, height); + return; + } + init(); + float scaleX = width/this.width; + float scaleY = height/this.height; + + float cx = getCenterOfRotationX()*scaleX; + float cy = getCenterOfRotationY()*scaleY; + + float p1x = -cx; + float p1y = -cy; + float p2x = width - cx; + float p2y = -cy; + float p3x = width - cx; + float p3y = height - cy; + float p4x = -cx; + float p4y = height - cy; + + double rad = Math.toRadians(rotation); + final float cos = (float) FastTrig.cos(rad); + final float sin = (float) FastTrig.sin(rad); + + float tx = getTextureOffsetX(); + float ty = getTextureOffsetY(); + float tw = getTextureWidth(); + float th = getTextureHeight(); + + float x1 = (cos * p1x - sin * p1y) + cx; // TOP LEFT + float y1 = (sin * p1x + cos * p1y) + cy; + float x2 = (cos * p4x - sin * p4y) + cx; // BOTTOM LEFT + float y2 = (sin * p4x + cos * p4y) + cy; + float x3 = (cos * p3x - sin * p3y) + cx; // BOTTOM RIGHT + float y3 = (sin * p3x + cos * p3y) + cy; + float x4 = (cos * p2x - sin * p2y) + cx; // TOP RIGHT + float y4 = (sin * p2x + cos * p2y) + cy; + if (corners == null) { + GL.glTexCoord2f(tx, ty); + GL.glVertex3f(x+x1, y+y1, 0); + GL.glTexCoord2f(tx, ty + th); + GL.glVertex3f(x+x2, y+y2, 0); + GL.glTexCoord2f(tx + tw, ty + th); + GL.glVertex3f(x+x3, y+y3, 0); + GL.glTexCoord2f(tx + tw, ty); + GL.glVertex3f(x+x4, y+y4, 0); + } else { + corners[TOP_LEFT].bind(); + GL.glTexCoord2f(tx, ty); + GL.glVertex3f(x+x1, y+y1, 0); + corners[BOTTOM_LEFT].bind(); + GL.glTexCoord2f(tx, ty + th); + GL.glVertex3f(x+x2, y+y2, 0); + corners[BOTTOM_RIGHT].bind(); + GL.glTexCoord2f(tx + tw, ty + th); + GL.glVertex3f(x+x3, y+y3, 0); + corners[TOP_RIGHT].bind(); + GL.glTexCoord2f(tx + tw, ty); + GL.glVertex3f(x+x4, y+y4, 0); + } + } /** * Draw the image in a warper rectangle. The effects this can @@ -1457,7 +1548,7 @@ public class Image implements Renderable { if (isDestroyed()) { return; } - + flushPixelData(); destroyed = true; texture.release(); GraphicsFactory.releaseGraphicsForImage(this); diff --git a/src/org/newdawn/slick/Input.java b/src/org/newdawn/slick/Input.java index 1066ca34..696fe1dc 100644 --- a/src/org/newdawn/slick/Input.java +++ b/src/org/newdawn/slick/Input.java @@ -1078,10 +1078,10 @@ public class Input { throw new SlickException("Unable to create controller - no jinput found - add jinput.jar to your classpath"); } throw new SlickException("Unable to create controllers"); - } catch (NoClassDefFoundError e) { + } catch (NoClassDefFoundError | UnsatisfiedLinkError e) { // forget it, no jinput availble } - } + } /** * Notification from an event handle that an event has been consumed @@ -1233,7 +1233,7 @@ public class Input { } while (Mouse.next()) { - if (Mouse.getEventButton() >= 0) { + if (Mouse.getEventButton() >= 0 && Mouse.getEventButton() < mousePressed.length) { if (Mouse.getEventButtonState()) { consumed = false; mousePressed[Mouse.getEventButton()] = true; diff --git a/src/org/newdawn/slick/openal/Mp3InputStream.java b/src/org/newdawn/slick/openal/Mp3InputStream.java index 8d8cb6df..19dd105f 100644 --- a/src/org/newdawn/slick/openal/Mp3InputStream.java +++ b/src/org/newdawn/slick/openal/Mp3InputStream.java @@ -76,8 +76,9 @@ public class Mp3InputStream extends InputStream implements AudioInputStream { /** * Create a new stream to decode MP3 data. * @param input the input stream from which to read the MP3 file + * @throws IOException failure to read the header from the input stream */ - public Mp3InputStream(InputStream input) { + public Mp3InputStream(InputStream input) throws IOException { decoder = new Decoder(); bitstream = new Bitstream(input); try { @@ -85,6 +86,10 @@ public class Mp3InputStream extends InputStream implements AudioInputStream { } catch (BitstreamException e) { Log.error(e); } + if (header == null) { + close(); + throw new IOException("Failed to read header from MP3 input stream."); + } channels = (header.mode() == Header.SINGLE_CHANNEL) ? 1 : 2; sampleRate = header.frequency(); diff --git a/src/org/newdawn/slick/openal/OpenALStreamPlayer.java b/src/org/newdawn/slick/openal/OpenALStreamPlayer.java index a6e6c3f4..cc13a3e5 100644 --- a/src/org/newdawn/slick/openal/OpenALStreamPlayer.java +++ b/src/org/newdawn/slick/openal/OpenALStreamPlayer.java @@ -152,16 +152,30 @@ public class OpenALStreamPlayer { if (url != null) { audio = new OggInputStream(url.openStream()); } else { - if (ref.toLowerCase().endsWith(".mp3")) - audio = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref)); - else - audio = new OggInputStream(ResourceLoader.getResourceAsStream(ref)); - - if (audio.getRate() == 0 && audio.getChannels() == 0) { - if (ref.toLowerCase().endsWith(".mp3")) - audio = new OggInputStream(ResourceLoader.getResourceAsStream(ref)); - else + if (ref.toLowerCase().endsWith(".mp3")) { + try { audio = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref)); + } catch (IOException e) { + // invalid MP3: check if file is actually OGG + try { + audio = new OggInputStream(ResourceLoader.getResourceAsStream(ref)); + } catch (IOException e1) { + throw e; // invalid OGG: re-throw original MP3 exception + } + if (audio.getRate() == 0 && audio.getChannels() == 0) + throw e; // likely not OGG: re-throw original MP3 exception + } + } else { + audio = new OggInputStream(ResourceLoader.getResourceAsStream(ref)); + if (audio.getRate() == 0 && audio.getChannels() == 0) { + // invalid OGG: check if file is actually MP3 + AudioInputStream audioOGG = audio; + try { + audio = new Mp3InputStream(ResourceLoader.getResourceAsStream(ref)); + } catch (IOException e) { + audio = audioOGG; // invalid MP3: keep OGG stream + } + } } } diff --git a/tools/JarSplicePlus.jar b/tools/JarSplicePlus.jar deleted file mode 100644 index b6dacdae..00000000 Binary files a/tools/JarSplicePlus.jar and /dev/null differ