diff --git a/src/itdelatrisu/opsu/GameImage.java b/src/itdelatrisu/opsu/GameImage.java
index a804cb31..b569f776 100644
--- a/src/itdelatrisu/opsu/GameImage.java
+++ b/src/itdelatrisu/opsu/GameImage.java
@@ -593,8 +593,9 @@ public enum GameImage {
return;
// try to load multiple images
+ File skinDir = Options.getSkin().getDirectory();
if (filenameFormat != null) {
- if (((defaultImages = loadImageArray(Options.getSkinDir())) != null) ||
+ if ((skinDir != null && ((defaultImages = loadImageArray(skinDir)) != null)) ||
((defaultImages = loadImageArray(null)) != null)) {
process();
return;
@@ -602,7 +603,7 @@ public enum GameImage {
}
// try to load a single image
- if (((defaultImage = loadImageSingle(Options.getSkinDir())) != null) ||
+ if ((skinDir != null && ((defaultImage = loadImageSingle(skinDir)) != null)) ||
((defaultImage = loadImageSingle(null)) != null)) {
process();
return;
diff --git a/src/itdelatrisu/opsu/Opsu.java b/src/itdelatrisu/opsu/Opsu.java
index 3524863b..baa98a50 100644
--- a/src/itdelatrisu/opsu/Opsu.java
+++ b/src/itdelatrisu/opsu/Opsu.java
@@ -45,7 +45,6 @@ import org.newdawn.slick.SlickException;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
-import org.newdawn.slick.util.ClasspathLocation;
import org.newdawn.slick.util.DefaultLogSystem;
import org.newdawn.slick.util.FileSystemLocation;
import org.newdawn.slick.util.Log;
@@ -128,10 +127,6 @@ public class Opsu extends StateBasedGame {
System.setProperty("org.lwjgl.librarypath", nativeDir.getAbsolutePath());
// set the resource paths
- ResourceLoader.removeAllResourceLocations();
- ResourceLoader.addResourceLocation(new FileSystemLocation(Options.getSkinDir()));
- ResourceLoader.addResourceLocation(new ClasspathLocation());
- ResourceLoader.addResourceLocation(new FileSystemLocation(new File(".")));
ResourceLoader.addResourceLocation(new FileSystemLocation(new File("./res/")));
// initialize databases
diff --git a/src/itdelatrisu/opsu/Options.java b/src/itdelatrisu/opsu/Options.java
index ef5b620e..de0f0291 100644
--- a/src/itdelatrisu/opsu/Options.java
+++ b/src/itdelatrisu/opsu/Options.java
@@ -20,6 +20,8 @@ package itdelatrisu.opsu;
import itdelatrisu.opsu.audio.MusicController;
import itdelatrisu.opsu.beatmap.Beatmap;
+import itdelatrisu.opsu.skins.Skin;
+import itdelatrisu.opsu.skins.SkinLoader;
import java.io.BufferedReader;
import java.io.BufferedWriter;
@@ -38,7 +40,10 @@ import org.lwjgl.input.Keyboard;
import org.newdawn.slick.GameContainer;
import org.newdawn.slick.Input;
import org.newdawn.slick.SlickException;
+import org.newdawn.slick.util.ClasspathLocation;
+import org.newdawn.slick.util.FileSystemLocation;
import org.newdawn.slick.util.Log;
+import org.newdawn.slick.util.ResourceLoader;
/**
* Handles all user options.
@@ -56,13 +61,20 @@ public class Options {
/** File for storing user options. */
private static final File OPTIONS_FILE = new File(CONFIG_DIR, ".opsu.cfg");
- /** Beatmap directories (where to search for files). */
+ /** Beatmap directories (where to search for files). */
private static final String[] BEATMAP_DIRS = {
"C:/Program Files (x86)/osu!/Songs/",
"C:/Program Files/osu!/Songs/",
new File(DATA_DIR, "Songs/").getPath()
};
+ /** Skin directories (where to search for skins). */
+ private static final String[] SKIN_ROOT_DIRS = {
+ "C:/Program Files (x86)/osu!/Skins/",
+ "C:/Program Files/osu!/Skins/",
+ new File(DATA_DIR, "Skins/").getPath()
+ };
+
/** Cached beatmap database name. */
public static final File BEATMAP_DB = new File(DATA_DIR, ".opsu.db");
@@ -96,8 +108,8 @@ public class Options {
/** The replay directory (created when needed). */
private static File replayDir;
- /** The current skin directory (for user skins). */
- private static File skinDir;
+ /** The root skin directory. */
+ private static File skinRootDir;
/** Port binding. */
private static int port = 49250;
@@ -153,6 +165,16 @@ public class Options {
}
},
// FULLSCREEN ("Fullscreen Mode", "Restart to apply changes.", false),
+ SKIN ("Skin", "Restart (Ctrl+Shift+F5) to apply skin changes.") {
+ @Override
+ public String getValueString() { return skinName; }
+
+ @Override
+ public void click(GameContainer container) {
+ skinDirIndex = (skinDirIndex + 1) % skinDirs.length;
+ skinName = skinDirs[skinDirIndex];
+ }
+ },
TARGET_FPS ("Frame Limiter", "Higher values may cause high CPU usage.") {
@Override
public String getValueString() {
@@ -449,6 +471,18 @@ public class Options {
/** Current screen resolution. */
private static Resolution resolution = Resolution.RES_1024_768;
+ /** The available skin directories. */
+ private static String[] skinDirs;
+
+ /** The index in the skinDirs array. */
+ private static int skinDirIndex = 0;
+
+ /** The name of the skin. */
+ private static String skinName = "Default";
+
+ /** The current skin. */
+ private static Skin skin;
+
/** Frame limiters. */
private static final int[] targetFPS = { 60, 120, 240 };
@@ -857,15 +891,67 @@ public class Options {
* If invalid, this will create a "Skins" folder in the root directory.
* @return the skin directory
*/
- public static File getSkinDir() {
- if (skinDir != null && skinDir.isDirectory())
- return skinDir;
+ public static File getSkinRootDir() {
+ if (skinRootDir != null && skinRootDir.isDirectory())
+ return skinRootDir;
- skinDir = new File(DATA_DIR, "Skins/");
- skinDir.mkdir();
- return skinDir;
+ // search for directory
+ for (int i = 0; i < SKIN_ROOT_DIRS.length; i++) {
+ skinRootDir = new File(SKIN_ROOT_DIRS[i]);
+ if (skinRootDir.isDirectory())
+ return skinRootDir;
+ }
+ skinRootDir.mkdir(); // none found, create new directory
+ return skinRootDir;
}
+ /**
+ * Loads the skin given by the current skin directory.
+ * If the directory is invalid, the default skin will be loaded.
+ */
+ public static void loadSkin() {
+ File root = getSkinRootDir();
+ File skinDir = new File(root, skinName);
+ if (!skinDir.isDirectory()) { // invalid skin name
+ skinName = Skin.DEFAULT_SKIN_NAME;
+ skinDir = null;
+ }
+
+ // create available skins list
+ File[] dirs = SkinLoader.getSkinDirectories(root);
+ skinDirs = new String[dirs.length + 1];
+ skinDirs[0] = Skin.DEFAULT_SKIN_NAME;
+ for (int i = 0; i < dirs.length; i++)
+ skinDirs[i + 1] = dirs[i].getName();
+
+ // set skin and modify resource locations
+ ResourceLoader.removeAllResourceLocations();
+ if (skinDir == null)
+ skin = new Skin(null);
+ else {
+ // set skin index
+ for (int i = 1; i < skinDirs.length; i++) {
+ if (skinDirs[i].equals(skinName)) {
+ skinDirIndex = i;
+ break;
+ }
+ }
+
+ // load the skin
+ skin = SkinLoader.loadSkin(skinDir);
+ ResourceLoader.addResourceLocation(new FileSystemLocation(skinDir));
+ }
+ ResourceLoader.addResourceLocation(new ClasspathLocation());
+ ResourceLoader.addResourceLocation(new FileSystemLocation(new File(".")));
+ ResourceLoader.addResourceLocation(new FileSystemLocation(new File("./res/")));
+ }
+
+ /**
+ * Returns the current skin.
+ * @return the skin, or null if no skin is loaded (see {@link #loadSkin()})
+ */
+ public static Skin getSkin() { return skin; }
+
/**
* Returns a dummy Beatmap containing the theme song.
* @return the theme song beatmap
@@ -928,8 +1014,8 @@ public class Options {
case "ReplayDirectory":
replayDir = new File(value);
break;
- case "Skin":
- skinDir = new File(value);
+ case "SkinDirectory":
+ skinRootDir = new File(value);
break;
case "ThemeSong":
themeString = value;
@@ -948,6 +1034,9 @@ public class Options {
// case "Fullscreen":
// GameOption.FULLSCREEN.setValue(Boolean.parseBoolean(value));
// break;
+ case "Skin":
+ skinName = value;
+ break;
case "FrameSync":
i = Integer.parseInt(value);
for (int j = 0; j < targetFPS.length; j++) {
@@ -1098,7 +1187,7 @@ public class Options {
writer.newLine();
writer.write(String.format("ReplayDirectory = %s", getReplayDir().getAbsolutePath()));
writer.newLine();
- writer.write(String.format("Skin = %s", getSkinDir().getAbsolutePath()));
+ writer.write(String.format("SkinDirectory = %s", getSkinRootDir().getAbsolutePath()));
writer.newLine();
writer.write(String.format("ThemeSong = %s", themeString));
writer.newLine();
@@ -1108,6 +1197,8 @@ public class Options {
writer.newLine();
// writer.write(String.format("Fullscreen = %b", isFullscreen()));
// writer.newLine();
+ writer.write(String.format("Skin = %s", skinName));
+ writer.newLine();
writer.write(String.format("FrameSync = %d", targetFPS[targetFPSindex]));
writer.newLine();
writer.write(String.format("FpsCounter = %b", isFPSCounterEnabled()));
diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java
index 71e395dd..80ee74b0 100644
--- a/src/itdelatrisu/opsu/Utils.java
+++ b/src/itdelatrisu/opsu/Utils.java
@@ -179,6 +179,9 @@ public class Utils {
ErrorHandler.error("Failed to load fonts.", e, true);
}
+ // load skin
+ Options.loadSkin();
+
// initialize game images
for (GameImage img : GameImage.values()) {
if (img.isPreload())
diff --git a/src/itdelatrisu/opsu/audio/SoundController.java b/src/itdelatrisu/opsu/audio/SoundController.java
index c6022879..a16a46fc 100644
--- a/src/itdelatrisu/opsu/audio/SoundController.java
+++ b/src/itdelatrisu/opsu/audio/SoundController.java
@@ -177,11 +177,14 @@ public class SoundController {
*/
private static String getSoundFileName(String filename) {
String wav = String.format("%s.wav", filename), mp3 = String.format("%s.mp3", filename);
- File skinWAV = new File(Options.getSkinDir(), wav), skinMP3 = new File(Options.getSkinDir(), mp3);
- if (skinWAV.isFile())
- return skinWAV.getAbsolutePath();
- if (skinMP3.isFile())
- return skinMP3.getAbsolutePath();
+ File skinDir = Options.getSkin().getDirectory();
+ if (skinDir != null) {
+ File skinWAV = new File(skinDir, wav), skinMP3 = new File(skinDir, mp3);
+ if (skinWAV.isFile())
+ return skinWAV.getAbsolutePath();
+ if (skinMP3.isFile())
+ return skinMP3.getAbsolutePath();
+ }
if (ResourceLoader.resourceExists(wav))
return wav;
if (ResourceLoader.resourceExists(mp3))
diff --git a/src/itdelatrisu/opsu/skins/Skin.java b/src/itdelatrisu/opsu/skins/Skin.java
new file mode 100644
index 00000000..d1e70e2b
--- /dev/null
+++ b/src/itdelatrisu/opsu/skins/Skin.java
@@ -0,0 +1,374 @@
+/*
+ * 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.skins;
+
+import java.io.File;
+
+import org.newdawn.slick.Color;
+
+/**
+ * Skin configuration (skin.ini).
+ */
+public class Skin {
+ /** The default skin name. */
+ public static final String DEFAULT_SKIN_NAME = "Default";
+
+ /** Slider styles. */
+ public static final byte
+ STYLE_PEPPYSLIDER = 1,
+ STYLE_MMSLIDER = 2,
+ STYLE_TOONSLIDER = 3,
+ STYLE_OPENGLSLIDER = 4;
+
+ /** The latest skin version. */
+ protected static final int LATEST_VERSION = 2;
+
+ /** The default list of combos with combo sounds. */
+ private static final int[] DEFAULT_CUSTOM_COMBO_BURST_SOUNDS = { 50, 75, 100, 200, 300 };
+
+ /** The default combo colors (used when a beatmap does not provide custom colors). */
+ private static final Color[] DEFAULT_COMBO = {
+ new Color(255, 192, 0),
+ new Color(0, 202, 0),
+ new Color(18, 124, 255),
+ new Color(242, 24, 57)
+ };
+
+ /** The default menu visualization bar color. */
+ private static final Color DEFAULT_MENU_GLOW = new Color(0, 78, 155);
+
+ /** The default slider border color. */
+ private static final Color DEFAULT_SLIDER_BORDER = new Color(38, 38, 38);
+
+ /** The default slider ball color. */
+ private static final Color DEFAULT_SLIDER_BALL = new Color(2, 170, 255);
+
+ /** The default spinner approach circle color. */
+ private static final Color DEFAULT_SPINNER_APPROACH_CIRCLE = new Color(77, 139, 217);
+
+ /** The default color of the active text in the song selection menu. */
+ private static final Color DEFAULT_SONG_SELECT_ACTIVE_TEXT = new Color(255, 255, 255);
+
+ /** The default color of the inactive text in the song selection menu. */
+ private static final Color DEFAULT_SONG_SELECT_INACTIVE_TEXT = new Color(178, 178, 178);
+
+ /** The default color of the stars that fall from the cursor during breaks. */
+ private static final Color DEFAULT_STAR_BREAK_ADDITIVE = new Color(255, 182, 193);
+
+ /** The skin directory. */
+ private File dir;
+
+ /**
+ * [General]
+ */
+
+ /** The name of the skin. */
+ protected String name = "opsu! Default Skin";
+
+ /** The skin author. */
+ protected String author = "[various authors]";
+
+ /** The skin version. */
+ protected int version = LATEST_VERSION;
+
+ /** When a slider has a reverse, should the ball sprite flip horizontally? */
+ protected boolean sliderBallFlip = false;
+
+ /** Should the cursor sprite rotate constantly? */
+ protected boolean cursorRotate = true;
+
+ /** Should the cursor expand when clicked? */
+ protected boolean cursorExpand = true;
+
+ /** Should the cursor have an origin at the center of the image? (if not, the top-left corner is used) */
+ protected boolean cursorCentre = true;
+
+ /** The number of frames in the slider ball animation. */
+ protected int sliderBallFrames = 10;
+
+ /** Should the hitcircleoverlay sprite be drawn above the hircircle combo number? */
+ protected boolean hitCircleOverlayAboveNumber = true;
+
+ /** Should the sound frequency be modulated depending on the spinner score? */
+ protected boolean spinnerFrequencyModulate = false;
+
+ /** Should the normal hitsound always be played? */
+ protected boolean layeredHitSounds = true;
+
+ /** Should the spinner fade the playfield? */
+ protected boolean spinnerFadePlayfield = true;
+
+ /** Should the last spinner bar blink? */
+ protected boolean spinnerNoBlink = false;
+
+ /** Should the slider combo color tint the slider ball? */
+ protected boolean allowSliderBallTint = false;
+
+ /** The FPS of animations. */
+ protected int animationFramerate = -1;
+
+ /** Should the cursor trail sprite rotate constantly? */
+ protected boolean cursorTrailRotate = false;
+
+ /** List of combos with combo sounds. */
+ protected int[] customComboBurstSounds = DEFAULT_CUSTOM_COMBO_BURST_SOUNDS;
+
+ /** Should the combo burst sprites appear in random order? */
+ protected boolean comboBurstRandom = false;
+
+ /** The slider style to use (see STYLE_* constants). */
+ protected byte sliderStyle = 2;
+
+ /**
+ * [Colours]
+ */
+
+ /** Combo colors (max 8). */
+ protected Color[] combo = DEFAULT_COMBO;
+
+ /** The menu visualization bar color. */
+ protected Color menuGlow = DEFAULT_MENU_GLOW;
+
+ /** The color for the slider border. */
+ protected Color sliderBorder = DEFAULT_SLIDER_BORDER;
+
+ /** The slider ball color. */
+ protected Color sliderBall = DEFAULT_SLIDER_BALL;
+
+ /** The spinner approach circle color. */
+ protected Color spinnerApproachCircle = DEFAULT_SPINNER_APPROACH_CIRCLE;
+
+ /** The color of text in the currently active group in song selection. */
+ protected Color songSelectActiveText = DEFAULT_SONG_SELECT_ACTIVE_TEXT;
+
+ /** The color of text in the inactive groups in song selection. */
+ protected Color songSelectInactiveText = DEFAULT_SONG_SELECT_INACTIVE_TEXT;
+
+ /** The color of the stars that fall from the cursor (star2 sprite) in breaks. */
+ protected Color starBreakAdditive = DEFAULT_STAR_BREAK_ADDITIVE;
+
+ /**
+ * [Fonts]
+ */
+
+ /** The prefix for the hitcircle font sprites. */
+ protected String hitCirclePrefix = "default";
+
+ /** How much should the hitcircle font sprites overlap? */
+ protected int hitCircleOverlap = -2;
+
+ /** The prefix for the score font sprites. */
+ protected String scorePrefix = "score";
+
+ /** How much should the score font sprites overlap? */
+ protected int scoreOverlap = 0;
+
+ /** The prefix for the combo font sprites. */
+ protected String comboPrefix = "score";
+
+ /** How much should the combo font sprites overlap? */
+ protected int comboOverlap = 0;
+
+ /**
+ * Constructor.
+ * @param dir the skin directory
+ */
+ public Skin(File dir) {
+ this.dir = dir;
+ }
+
+ /**
+ * Returns the skin directory.
+ */
+ public File getDirectory() { return dir; }
+
+ /**
+ * Returns the name of the skin.
+ */
+ public String getName() { return name; }
+
+ /**
+ * Returns the skin author.
+ */
+ public String getAuthor() { return author; }
+
+ /**
+ * Returns the skin version.
+ */
+ public int getVersion() { return version; }
+
+ /**
+ * Returns whether the slider ball should be flipped horizontally during a reverse.
+ */
+ public boolean isSliderBallFlipped() { return sliderBallFlip; }
+
+ /**
+ * Returns whether the cursor should rotate.
+ */
+ public boolean isCursorRotated() { return cursorRotate; }
+
+ /**
+ * Returns whether the cursor should expand when clicked.
+ */
+ public boolean isCursorExpanded() { return cursorExpand; }
+
+ /**
+ * Returns whether the cursor should have an origin in the center.
+ * @return {@code true} if center, {@code false} if top-left corner
+ */
+ public boolean isCursorCentered() { return cursorCentre; }
+
+ /**
+ * Returns the number of frames in the slider ball animation.
+ */
+ public int getSliderBallFrames() { return sliderBallFrames; }
+
+ /**
+ * Returns whether the hit circle overlay should be drawn above the combo number.
+ */
+ public boolean isHitCircleOverlayAboveNumber() { return hitCircleOverlayAboveNumber; }
+
+ /**
+ * Returns whether the sound frequency should be modulated depending on the spinner score.
+ */
+ public boolean isSpinnerFrequencyModulated() { return spinnerFrequencyModulate; }
+
+ /**
+ * Returns whether the normal hitsound should always be played (and layered on other sounds).
+ */
+ public boolean isLayeredHitSounds() { return layeredHitSounds; }
+
+ /**
+ * Returns whether the playfield should fade for spinners.
+ */
+ public boolean isSpinnerFadePlayfield() { return spinnerFadePlayfield; }
+
+ /**
+ * Returns whether the last spinner bar should blink.
+ */
+ public boolean isSpinnerNoBlink() { return spinnerNoBlink; }
+
+ /**
+ * Returns whether the slider ball should be tinted with the slider combo color.
+ */
+ public boolean isAllowSliderBallTint() { return allowSliderBallTint; }
+
+ /**
+ * Returns the frame rate of animations.
+ * @return the FPS, or {@code -1} (TODO)
+ */
+ public int getAnimationFramerate() { return animationFramerate; }
+
+ /**
+ * Returns whether the cursor trail should rotate.
+ */
+ public boolean isCursorTrailRotated() { return cursorTrailRotate; }
+
+ /**
+ * Returns a list of combos with combo sounds.
+ */
+ public int[] getCustomComboBurstSounds() { return customComboBurstSounds; }
+
+ /**
+ * Returns whether combo bursts should appear in random order.
+ */
+ public boolean isComboBurstRandom() { return comboBurstRandom; }
+
+ /**
+ * Returns the slider style.
+ *
+ * - 1: peppysliders (segmented)
+ *
- 2: mmsliders (smooth)
+ *
- 3: toonsliders (smooth, with steps instead of gradient)
+ *
- 4: legacy OpenGL-only sliders
+ *
+ * @return the style (see STYLE_* constants)
+ */
+ public byte getSliderStyle() { return sliderStyle; }
+
+ /**
+ * Returns the list of combo colors (max 8).
+ */
+ public Color[] getComboColors() { return combo; }
+
+ /**
+ * Returns the menu visualization bar color.
+ */
+ public Color getMenuGlowColor() { return menuGlow; }
+
+ /**
+ * Returns the slider border color.
+ */
+ public Color getSliderBorderColor() { return sliderBorder; }
+
+ /**
+ * Returns the slider ball color.
+ */
+ public Color getSliderBallColor() { return sliderBall; }
+
+ /**
+ * Returns the spinner approach circle color.
+ */
+ public Color getSpinnerApproachCircleColor() { return spinnerApproachCircle; }
+
+ /**
+ * Returns the color of the active text in the song selection menu.
+ */
+ public Color getSongSelectActiveTextColor() { return songSelectActiveText; }
+
+ /**
+ * Returns the color of the inactive text in the song selection menu.
+ */
+ public Color getSongSelectInactiveTextColor() { return songSelectInactiveText; }
+
+ /**
+ * Returns the color of the stars that fall from the cursor during breaks.
+ */
+ public Color getStarBreakAdditiveColor() { return starBreakAdditive; }
+
+ /**
+ * Returns the prefix for the hit circle font sprites.
+ */
+ public String getHitCircleFontPrefix() { return hitCirclePrefix; }
+
+ /**
+ * Returns the amount of overlap between the hit circle font sprites.
+ */
+ public int getHitCircleFontOverlap() { return hitCircleOverlap; }
+
+ /**
+ * Returns the prefix for the score font sprites.
+ */
+ public String getScoreFontPrefix() { return scorePrefix; }
+
+ /**
+ * Returns the amount of overlap between the score font sprites.
+ */
+ public int getScoreFontOverlap() { return scoreOverlap; }
+
+ /**
+ * Returns the prefix for the combo font sprites.
+ */
+ public String getComboFontPrefix() { return comboPrefix; }
+
+ /**
+ * Returns the amount of overlap between the combo font sprites.
+ */
+ public int getComboFontOverlap() { return comboOverlap; }
+}
diff --git a/src/itdelatrisu/opsu/skins/SkinLoader.java b/src/itdelatrisu/opsu/skins/SkinLoader.java
new file mode 100644
index 00000000..a0a7362e
--- /dev/null
+++ b/src/itdelatrisu/opsu/skins/SkinLoader.java
@@ -0,0 +1,299 @@
+/*
+ * 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.skins;
+
+import itdelatrisu.opsu.ErrorHandler;
+import itdelatrisu.opsu.Utils;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.LinkedList;
+
+import org.newdawn.slick.Color;
+import org.newdawn.slick.util.Log;
+
+/**
+ * Loads skin configuration files.
+ */
+public class SkinLoader {
+ /** Name of the skin configuration file. */
+ private static final String CONFIG_FILENAME = "skin.ini";
+
+ // This class should not be instantiated.
+ private SkinLoader() {}
+
+ /**
+ * Returns a list of all subdirectories in the Skins directory.
+ * @param root the root directory (search has depth 1)
+ * @return an array of skin directories
+ */
+ public static File[] getSkinDirectories(File root) {
+ ArrayList dirs = new ArrayList();
+ for (File dir : root.listFiles()) {
+ if (dir.isDirectory())
+ dirs.add(dir);
+ }
+ return dirs.toArray(new File[dirs.size()]);
+ }
+
+ /**
+ * Loads a skin configuration file.
+ * If 'skin.ini' is not found, or if any fields are not specified, the
+ * default values will be used.
+ * @param dir the skin directory
+ * @return the loaded skin
+ */
+ public static Skin loadSkin(File dir) {
+ File skinFile = new File(dir, CONFIG_FILENAME);
+ Skin skin = new Skin(dir);
+ if (!skinFile.isFile()) // missing skin.ini
+ return skin;
+
+ try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream(skinFile), "UTF-8"))) {
+ String line = in.readLine();
+ String tokens[] = null;
+ while (line != null) {
+ line = line.trim();
+ if (!isValidLine(line)) {
+ line = in.readLine();
+ continue;
+ }
+ switch (line) {
+ case "[General]":
+ while ((line = in.readLine()) != null) {
+ line = line.trim();
+ if (!isValidLine(line))
+ continue;
+ if (line.charAt(0) == '[')
+ break;
+ if ((tokens = tokenize(line)) == null)
+ continue;
+ try {
+ switch (tokens[0]) {
+ case "Name":
+ skin.name = tokens[1];
+ break;
+ case "Author":
+ skin.author = tokens[1];
+ break;
+ case "Version":
+ if (tokens[1].equalsIgnoreCase("latest"))
+ skin.version = Skin.LATEST_VERSION;
+ else
+ skin.version = Integer.parseInt(tokens[1]);
+ break;
+ case "SliderBallFlip":
+ skin.sliderBallFlip = Utils.parseBoolean(tokens[1]);
+ break;
+ case "CursorRotate":
+ skin.cursorRotate = Utils.parseBoolean(tokens[1]);
+ break;
+ case "CursorExpand":
+ skin.cursorExpand = Utils.parseBoolean(tokens[1]);
+ break;
+ case "CursorCentre":
+ skin.cursorCentre = Utils.parseBoolean(tokens[1]);
+ break;
+ case "SliderBallFrames":
+ skin.sliderBallFrames = Integer.parseInt(tokens[1]);
+ break;
+ case "HitCircleOverlayAboveNumber":
+ skin.hitCircleOverlayAboveNumber = Utils.parseBoolean(tokens[1]);
+ break;
+ case "spinnerFrequencyModulate":
+ skin.spinnerFrequencyModulate = Utils.parseBoolean(tokens[1]);
+ break;
+ case "LayeredHitSounds":
+ skin.layeredHitSounds = Utils.parseBoolean(tokens[1]);
+ break;
+ case "SpinnerFadePlayfield":
+ skin.spinnerFadePlayfield = Utils.parseBoolean(tokens[1]);
+ break;
+ case "SpinnerNoBlink":
+ skin.spinnerNoBlink = Utils.parseBoolean(tokens[1]);
+ break;
+ case "AllowSliderBallTint":
+ skin.allowSliderBallTint = Utils.parseBoolean(tokens[1]);
+ break;
+ case "AnimationFramerate":
+ skin.animationFramerate = Integer.parseInt(tokens[1]);
+ break;
+ case "CursorTrailRotate":
+ skin.cursorTrailRotate = Utils.parseBoolean(tokens[1]);
+ break;
+ case "CustomComboBurstSounds":
+ String[] split = tokens[1].split(",");
+ int[] customComboBurstSounds = new int[split.length];
+ for (int i = 0; i < split.length; i++)
+ customComboBurstSounds[i] = Integer.parseInt(split[i]);
+ skin.customComboBurstSounds = customComboBurstSounds;
+ break;
+ case "ComboBurstRandom":
+ skin.comboBurstRandom = Utils.parseBoolean(tokens[1]);
+ break;
+ case "SliderStyle":
+ skin.sliderStyle = Byte.parseByte(tokens[1]);
+ break;
+ default:
+ break;
+ }
+ } catch (Exception e) {
+ Log.warn(String.format("Failed to read line '%s' for file '%s'.",
+ line, skinFile.getAbsolutePath()), e);
+ }
+ }
+ break;
+ case "[Colours]":
+ LinkedList colors = new LinkedList();
+ while ((line = in.readLine()) != null) {
+ line = line.trim();
+ if (!isValidLine(line))
+ continue;
+ if (line.charAt(0) == '[')
+ break;
+ if ((tokens = tokenize(line)) == null)
+ continue;
+ try {
+ String[] rgb = tokens[1].split(",");
+ Color color = new Color(
+ Integer.parseInt(rgb[0]),
+ Integer.parseInt(rgb[1]),
+ Integer.parseInt(rgb[2])
+ );
+ switch (tokens[0]) {
+ case "Combo1":
+ case "Combo2":
+ case "Combo3":
+ case "Combo4":
+ case "Combo5":
+ case "Combo6":
+ case "Combo7":
+ case "Combo8":
+ colors.add(color);
+ break;
+ case "MenuGlow":
+ skin.menuGlow = color;
+ break;
+ case "SliderBorder":
+ skin.sliderBorder = color;
+ break;
+ case "SliderBall":
+ skin.sliderBall = color;
+ break;
+ case "SpinnerApproachCircle":
+ skin.spinnerApproachCircle = color;
+ break;
+ case "SongSelectActiveText":
+ skin.songSelectActiveText = color;
+ break;
+ case "SongSelectInactiveText":
+ skin.songSelectInactiveText = color;
+ break;
+ case "StarBreakAdditive":
+ skin.starBreakAdditive = color;
+ break;
+ default:
+ break;
+ }
+ } catch (Exception e) {
+ Log.warn(String.format("Failed to read color '%s' for file '%s'.",
+ line, skinFile.getAbsolutePath()), e);
+ }
+ }
+ if (!colors.isEmpty())
+ skin.combo = colors.toArray(new Color[colors.size()]);
+ break;
+ case "[Fonts]":
+ while ((line = in.readLine()) != null) {
+ line = line.trim();
+ if (!isValidLine(line))
+ continue;
+ if (line.charAt(0) == '[')
+ break;
+ if ((tokens = tokenize(line)) == null)
+ continue;
+ try {
+ switch (tokens[0]) {
+ case "HitCirclePrefix":
+ skin.hitCirclePrefix = tokens[1];
+ break;
+ case "HitCircleOverlap":
+ skin.hitCircleOverlap = Integer.parseInt(tokens[1]);
+ break;
+ case "ScorePrefix":
+ skin.scorePrefix = tokens[1];
+ break;
+ case "ScoreOverlap":
+ skin.scoreOverlap = Integer.parseInt(tokens[1]);
+ break;
+ case "ComboPrefix":
+ skin.comboPrefix = tokens[1];
+ break;
+ case "ComboOverlap":
+ skin.comboOverlap = Integer.parseInt(tokens[1]);
+ break;
+ default:
+ break;
+ }
+ } catch (Exception e) {
+ Log.warn(String.format("Failed to read color '%s' for file '%s'.",
+ line, skinFile.getAbsolutePath()), e);
+ }
+ }
+ break;
+ default:
+ line = in.readLine();
+ break;
+ }
+ }
+ } catch (IOException e) {
+ ErrorHandler.error(String.format("Failed to read file '%s'.", skinFile.getAbsolutePath()), e, false);
+ }
+
+ return skin;
+ }
+
+ /**
+ * Returns false if the line is too short or commented.
+ */
+ private static boolean isValidLine(String line) {
+ return (line.length() > 1 && !line.startsWith("//"));
+ }
+
+ /**
+ * Splits line into two strings: tag, value.
+ * If no ':' character is present, null will be returned.
+ */
+ private static String[] tokenize(String line) {
+ int index = line.indexOf(':');
+ if (index == -1) {
+ Log.debug(String.format("Failed to tokenize line: '%s'.", line));
+ return null;
+ }
+
+ String[] tokens = new String[2];
+ tokens[0] = line.substring(0, index).trim();
+ tokens[1] = line.substring(index + 1).trim();
+ return tokens;
+ }
+}
diff --git a/src/itdelatrisu/opsu/states/OptionsMenu.java b/src/itdelatrisu/opsu/states/OptionsMenu.java
index ba6ff7f1..29cd42ee 100644
--- a/src/itdelatrisu/opsu/states/OptionsMenu.java
+++ b/src/itdelatrisu/opsu/states/OptionsMenu.java
@@ -52,6 +52,7 @@ public class OptionsMenu extends BasicGameState {
DISPLAY ("Display", new GameOption[] {
GameOption.SCREEN_RESOLUTION,
// GameOption.FULLSCREEN,
+ GameOption.SKIN,
GameOption.TARGET_FPS,
GameOption.SHOW_FPS,
GameOption.SHOW_UNICODE,