Initial commit.

This commit is contained in:
Jeffrey Han
2014-06-29 22:17:04 -04:00
commit 9da166f60f
3902 changed files with 974477 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import org.newdawn.slick.Animation;
import org.newdawn.slick.Color;
import org.newdawn.slick.Image;
/**
* A convenience class for menu buttons.
* Consists of an image or animation and coordinates.
*/
public class GUIMenuButton {
/**
* The image associated with the button.
*/
private Image img;
/**
* The left and right parts of the button (optional).
*/
private Image imgL, imgR;
/**
* The animation associated with the button.
*/
private Animation anim;
/**
* The center coordinates.
*/
private float x, y;
/**
* The x and y radius of the button.
*/
private float xRadius, yRadius;
/**
* Creates a new button from an Image.
*/
public GUIMenuButton(Image img, float x, float y) {
this.img = img;
this.x = x;
this.y = y;
xRadius = img.getWidth() / 2f;
yRadius = img.getHeight() / 2f;
}
/**
* Creates a new button from a 3-part Image.
*/
public GUIMenuButton(Image imgCenter, Image imgLeft, Image imgRight,
float x, float y) {
this.img = imgCenter;
this.imgL = imgLeft;
this.imgR = imgRight;
this.x = x;
this.y = y;
xRadius = (img.getWidth() + imgL.getWidth() + imgR.getWidth()) / 2f;
yRadius = img.getHeight() / 2f;
}
/**
* Creates a new button from an Animation.
*/
public GUIMenuButton(Animation anim, float x, float y) {
this.anim = anim;
this.x = x;
this.y = y;
xRadius = anim.getWidth() / 2f;
yRadius = anim.getHeight() / 2f;
}
/**
* Sets/returns new center coordinates.
*/
public void setX(float x) { this.x = x; }
public void setY(float y) { this.y = y; }
public float getX() { return x; }
public float getY() { return y; }
/**
* Returns the associated image or animation.
*/
public Image getImage() { return img; }
public Animation getAnimation() { return anim; }
/**
* Draws the button.
*/
public void draw() {
if (img != null) {
if (imgL == null)
img.draw(x - xRadius, y - yRadius);
else {
img.draw(x - xRadius + imgL.getWidth(), y - yRadius);
imgL.draw(x - xRadius, y - yRadius);
imgR.draw(x + xRadius - imgR.getWidth(), y - yRadius);
}
} else
anim.draw(x - xRadius, y - yRadius);
}
/**
* Draw the button with a color filter.
* @param filter the color to filter with when drawing
*/
public void draw(Color filter) {
if (img != null) {
if (imgL == null)
img.draw(x - xRadius, y - yRadius, filter);
else {
img.draw(x - xRadius + imgL.getWidth(), y - yRadius, filter);
imgL.draw(x - xRadius, y - yRadius, filter);
imgR.draw(x + xRadius - imgR.getWidth(), y - yRadius, filter);
}
} else
anim.draw(x - xRadius, y - yRadius, filter);
}
/**
* Returns true if the coordinates are within the button bounds.
*/
public boolean contains(float cx, float cy) {
return ((cx > x - xRadius && cx < x + xRadius) &&
(cy > y - yRadius && cy < y + yRadius));
}
}

View File

@@ -0,0 +1,844 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.states.Options;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import org.newdawn.slick.Color;
import org.newdawn.slick.Graphics;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.util.Log;
/**
* Holds score data and renders all score-related elements.
*/
public class GameScore {
/**
* Letter grades.
*/
public static final int
GRADE_SS = 0,
GRADE_SSH = 1, // silver
GRADE_S = 2,
GRADE_SH = 3, // silver
GRADE_A = 4,
GRADE_B = 5,
GRADE_C = 6,
GRADE_D = 7,
GRADE_MAX = 8; // not a grade
/**
* Hit result types.
*/
public static final int
HIT_MISS = 0,
HIT_50 = 1,
HIT_100 = 2,
HIT_300 = 3,
HIT_100K = 4, // 100-Katu
HIT_300K = 5, // 300-Katu
HIT_300G = 6, // Geki
HIT_SLIDER10 = 7,
HIT_SLIDER30 = 8,
HIT_MAX = 9; // not a hit result
/**
* Hit result-related images (indexed by HIT_* constants).
*/
private Image[] hitResults;
/**
* Counts of each hit result so far.
*/
private int[] hitResultCount;
/**
* Total number of hit objects so far, not including Katu/Geki (for calculating grade).
*/
private int objectCount;
/**
* Total objects including slider hits/ticks (for determining Full Combo status).
*/
private int fullObjectCount;
/**
* The current combo streak.
*/
private int combo;
/**
* The max combo streak obtained.
*/
private int comboMax;
/**
* Hit result types accumulated this streak (bitmask), for Katu/Geki status.
* <ul>
* <li>&1: 100
* <li>&2: 50/Miss
* </ul>
*/
private byte comboEnd;
/**
* Combo burst images.
*/
private Image[] comboBurstImages;
/**
* Index of the current combo burst image.
*/
private int comboBurstIndex;
/**
* Alpha level of the current combo burst image (for fade out).
*/
private float comboBurstAlpha;
/**
* Current x coordinate of the combo burst image (for sliding animation).
*/
private int comboBurstX;
/**
* List of hit result objects associated with hit objects.
*/
private LinkedList<OsuHitObjectResult> hitResultList;
/**
* Hit result helper class.
*/
private class OsuHitObjectResult {
public int time; // object start time
public int result; // hit result
public float x, y; // object coordinates
public Color color; // combo color
public float alpha = 1f; // alpha level (for fade out)
/**
* Constructor.
* @param time the result's starting track position
* @param result the hit result (HIT_* constants)
* @param x the center x coordinate
* @param y the center y coordinate
* @param color the color of the hit object
*/
public OsuHitObjectResult(int time, int result, float x, float y, Color color) {
this.time = time;
this.result = result;
this.x = x;
this.y = y;
this.color = color;
}
}
/**
* Current game score.
*/
private long score;
/**
* Current health bar percentage.
*/
private float health;
/**
* Beatmap HPDrainRate value. (0:easy ~ 10:hard)
*/
private byte drainRate = 5;
/**
* Beatmap OverallDifficulty value. (0:easy ~ 10:hard)
*/
private byte difficulty = 5;
/**
* Scorebar-related images.
*/
private Image
bgImage, // background (always rendered)
colourImage, // health bar (cropped)
kiImage, // end image (50~100% health)
kiDangerImage, // end image (25~50% health)
kiDanger2Image; // end image (0~25% health)
/**
* Ranking screen images.
*/
private Image
rankingPanel, // panel to display text in
perfectImage, // display if full combo
rankingImage, // styled text "Ranking"
comboImage, // styled text "Combo"
accuracyImage; // styled text "Accuracy"
/**
* Default text symbol images.
*/
private Image[] defaultSymbols;
/**
* Score text symbol images.
*/
private HashMap<Character, Image> scoreSymbols;
/**
* Letter grade images (large and small sizes).
*/
private Image[] gradesLarge, gradesSmall;
/**
* Lighting effects, displayed behind hit object results (optional).
*/
private Image lighting, lighting1;
/**
* Container dimensions.
*/
private int width, height;
/**
* Constructor.
* @param width container width
* @param height container height
*/
public GameScore(int width, int height) {
this.width = width;
this.height = height;
hitResults = new Image[HIT_MAX];
defaultSymbols = new Image[10];
scoreSymbols = new HashMap<Character, Image>(14);
gradesLarge = new Image[GRADE_MAX];
gradesSmall = new Image[GRADE_MAX];
comboBurstImages = new Image[4];
clear();
try {
initializeImages();
} catch (Exception e) {
Log.error("Failed to initialize images.", e);
}
}
/**
* Clears all data and re-initializes object.
*/
public void clear() {
score = 0;
health = 100f;
hitResultCount = new int[HIT_MAX];
hitResultList = new LinkedList<OsuHitObjectResult>();
objectCount = 0;
fullObjectCount = 0;
combo = 0;
comboMax = 0;
comboEnd = 0;
comboBurstIndex = -1;
}
/**
* Initialize all images tied to this object.
* @throws SlickException
*/
private void initializeImages() throws SlickException {
// scorebar
setScorebarImage(
new Image("scorebar-bg.png"),
new Image("scorebar-colour.png"),
new Image("scorebar-ki.png"),
new Image("scorebar-kidanger.png"),
new Image("scorebar-kidanger2.png")
);
// text symbol images
for (int i = 0; i <= 9; i++) {
defaultSymbols[i] = new Image(String.format("default-%d.png", i));
scoreSymbols.put(Character.forDigit(i, 10), new Image(String.format("score-%d.png", i)));
}
scoreSymbols.put(',', new Image("score-comma.png"));
scoreSymbols.put('.', new Image("score-dot.png"));
scoreSymbols.put('%', new Image("score-percent.png"));
scoreSymbols.put('x', new Image("score-x.png"));
// hit result images
hitResults[HIT_MISS] = new Image("hit0.png");
hitResults[HIT_50] = new Image("hit50.png");
hitResults[HIT_100] = new Image("hit100.png");
hitResults[HIT_300] = new Image("hit300.png");
hitResults[HIT_100K] = new Image("hit100k.png");
hitResults[HIT_300K] = new Image("hit300k.png");
hitResults[HIT_300G] = new Image("hit300g.png");
hitResults[HIT_SLIDER10] = new Image("sliderpoint10.png");
hitResults[HIT_SLIDER30] = new Image("sliderpoint30.png");
// combo burst images
for (int i = 0; i <= 3; i++)
comboBurstImages[i] = new Image(String.format("comboburst-%d.png", i));
// lighting image
try {
lighting = new Image("lighting.png");
lighting1 = new Image("lighting1.png");
} catch (Exception e) {
// optional
}
// letter grade images
String[] grades = { "X", "XH", "S", "SH", "A", "B", "C", "D" };
for (int i = 0; i < grades.length; i++) {
gradesLarge[i] = new Image(String.format("ranking-%s.png", grades[i]));
gradesSmall[i] = new Image(String.format("ranking-%s-small.png", grades[i]));
}
// ranking screen elements
setRankingImage(
new Image("ranking-panel.png"),
new Image("ranking-perfect.png"),
new Image("ranking-title.png"),
new Image("ranking-maxcombo.png"),
new Image("ranking-accuracy.png")
);
}
/**
* Sets a background, health bar, and end image.
* @param bgImage background image
* @param colourImage health bar image
* @param kiImage end image
*/
public void setScorebarImage(Image bg, Image colour,
Image ki, Image kiDanger, Image kiDanger2) {
int bgWidth = width / 2;
this.bgImage = bg.getScaledCopy(bgWidth, bg.getHeight());
this.colourImage = colour.getScaledCopy(bgWidth, colour.getHeight());
this.kiImage = ki;
this.kiDangerImage = kiDanger;
this.kiDanger2Image = kiDanger2;
}
/**
* Sets a ranking panel, full combo, and ranking/combo/accuracy text image.
* @param rankingPanel ranking panel image
* @param perfectImage full combo image
* @param rankingImage styled text "Ranking"
* @param comboImage styled text "Combo"
* @param accuracyImage styled text "Accuracy"
*/
public void setRankingImage(Image rankingPanel, Image perfectImage,
Image rankingImage, Image comboImage, Image accuracyImage) {
this.rankingPanel = rankingPanel.getScaledCopy((height * 0.63f) / rankingPanel.getHeight());
this.perfectImage = perfectImage.getScaledCopy((height * 0.16f) / perfectImage.getHeight());
this.rankingImage = rankingImage.getScaledCopy((height * 0.15f) / rankingImage.getHeight());
this.comboImage = comboImage.getScaledCopy((height * 0.05f) / comboImage.getHeight());
this.accuracyImage = accuracyImage.getScaledCopy((height * 0.05f) / accuracyImage.getHeight());
}
/**
* Returns a default/score text symbol image for a character.
*/
public Image getDefaultSymbolImage(int i) { return defaultSymbols[i]; }
public Image getScoreSymbolImage(char c) { return scoreSymbols.get(c); }
/**
* Sets or returns the health drain rate.
*/
public void setDrainRate(byte drainRate) { this.drainRate = drainRate; }
public byte getDrainRate() { return drainRate; }
/**
* Sets or returns the difficulty.
*/
public void setDifficulty(byte difficulty) { this.difficulty = difficulty; }
public byte getDifficulty() { return difficulty; }
/**
* Draws a number with defaultSymbols.
* @param n the number to draw
* @param x the center x coordinate
* @param y the center y coordinate
* @param scale the scale to apply
*/
public void drawSymbolNumber(int n, float x, float y, float scale) {
int length = (int) (Math.log10(n) + 1);
float digitWidth = getDefaultSymbolImage(0).getWidth() * scale;
float cx = x + ((length - 1) * (digitWidth / 2));
for (int i = 0; i < length; i++) {
getDefaultSymbolImage(n % 10).getScaledCopy(scale).drawCentered(cx, y);
cx -= digitWidth;
n /= 10;
}
}
/**
* Draws a string of scoreSymbols.
* @param str the string to draw
* @param x the starting x coordinate
* @param y the y coordinate
* @param scale the scale to apply
* @param rightAlign align right (true) or left (false)
*/
private void drawSymbolString(String str, int x, int y, float scale, boolean rightAlign) {
char[] c = str.toCharArray();
int cx = x;
if (rightAlign) {
for (int i = c.length - 1; i >= 0; i--) {
Image digit = getScoreSymbolImage(c[i]);
if (scale != 1.0f)
digit = digit.getScaledCopy(scale);
cx -= digit.getWidth();
digit.draw(cx, y);
}
} else {
for (int i = 0; i < c.length; i++) {
Image digit = getScoreSymbolImage(c[i]);
if (scale != 1.0f)
digit = digit.getScaledCopy(scale);
digit.draw(cx, y);
cx += digit.getWidth();
}
}
}
/**
* Draws game elements: scorebar, score, score percentage, combo count, and combo burst.
* @param g the graphics context
* @param mapLength the length of the beatmap (in ms)
* @param breakPeriod if true, will not draw scorebar and combo elements, and will draw grade
* @param firstObject true if the first hit object's start time has not yet passed
*/
public void drawGameElements(Graphics g, int mapLength, boolean breakPeriod, boolean firstObject) {
// score
drawSymbolString(String.format("%08d", score),
width - 2, 0, 1.0f, true);
// score percentage
String scorePercentage = String.format("%02.2f%%", getScorePercent());
drawSymbolString(scorePercentage, width - 2, getScoreSymbolImage('0').getHeight(), 0.75f, true);
// map progress circle
g.setAntiAlias(true);
g.setLineWidth(2f);
g.setColor(Color.white);
int circleX = width - (getScoreSymbolImage('0').getWidth() * scorePercentage.length());
int circleY = getScoreSymbolImage('0').getHeight();
float circleDiameter = getScoreSymbolImage('0').getHeight() * 0.75f;
g.drawOval(circleX, circleY, circleDiameter, circleDiameter);
int firstObjectTime = Game.getOsuFile().objects[0].time;
int trackPosition = MusicController.getPosition();
if (trackPosition > firstObjectTime) {
g.fillArc(circleX, circleY, circleDiameter, circleDiameter,
-90, -90 + (int) (360f * (trackPosition - firstObjectTime) / mapLength)
);
}
if (!breakPeriod) {
// scorebar
float healthRatio = health / 100f;
if (firstObject) { // gradually move ki before map begins
if (firstObjectTime >= 1500 && trackPosition < firstObjectTime - 500)
healthRatio = (float) trackPosition / (firstObjectTime - 500);
}
bgImage.draw(0, 0);
Image colourCropped = colourImage.getSubImage(0, 0, (int) (colourImage.getWidth() * healthRatio), colourImage.getHeight());
colourCropped.draw(0, bgImage.getHeight() / 4f);
if (health >= 50f)
kiImage.drawCentered(colourCropped.getWidth(), kiImage.getHeight() / 2f);
else if (health >= 25f)
kiDangerImage.drawCentered(colourCropped.getWidth(), kiDangerImage.getHeight() / 2f);
else
kiDanger2Image.drawCentered(colourCropped.getWidth(), kiDanger2Image.getHeight() / 2f);
// combo burst
if (comboBurstIndex != -1 && comboBurstAlpha > 0f) {
Image comboBurst = comboBurstImages[comboBurstIndex];
comboBurst.setAlpha(comboBurstAlpha);
comboBurstImages[comboBurstIndex].draw(comboBurstX, height - comboBurst.getHeight());
}
// combo count
if (combo > 0) // 0 isn't a combo
drawSymbolString(String.format("%dx", combo), 10, height - 10 - getScoreSymbolImage('0').getHeight(), 1.0f, false);
} else {
// grade
Image grade = gradesSmall[getGrade()];
float gradeScale = circleY * 0.75f / grade.getHeight();
gradesSmall[getGrade()].getScaledCopy(gradeScale).draw(
circleX - grade.getWidth(), circleY
);
}
}
/**
* Draws ranking elements: score, results, ranking.
* @param g the graphics context
* @param width the width of the container
* @param height the height of the container
*/
public void drawRankingElements(Graphics g, int width, int height) {
// grade
Image grade = gradesLarge[getGrade()];
float gradeScale = (height * 0.5f) / grade.getHeight();
grade = grade.getScaledCopy(gradeScale);
grade.draw(width - grade.getWidth(), height * 0.09f);
// header & "Ranking" text
float rankingHeight = (rankingImage.getHeight() * 0.75f) + 3;
g.setColor(Options.COLOR_BLACK_ALPHA);
g.fillRect(0, 0, width, rankingHeight);
rankingImage.draw((width * 0.97f) - rankingImage.getWidth(), 0);
// ranking panel
int rankingPanelWidth = rankingPanel.getWidth();
int rankingPanelHeight = rankingPanel.getHeight();
rankingPanel.draw(0, rankingHeight - (rankingHeight / 10f));
float symbolTextScale = (height / 15f) / getScoreSymbolImage('0').getHeight();
float rankResultScale = (height * 0.03f) / hitResults[HIT_300].getHeight();
// score
drawSymbolString((score / 100000000 == 0) ? String.format("%08d", score) : Long.toString(score),
(int) (width * 0.18f), height / 6, symbolTextScale, false);
// result counts
float resultInitialX = rankingPanelWidth * 0.20f;
float resultInitialY = rankingHeight + (rankingPanelHeight * 0.27f) + (rankingHeight / 10f);
float resultHitInitialX = rankingPanelWidth * 0.05f;
float resultHitInitialY = resultInitialY + (getScoreSymbolImage('0').getHeight() * symbolTextScale);
float resultOffsetX = rankingPanelWidth / 2f;
float resultOffsetY = rankingPanelHeight * 0.2f;
int[] rankDrawOrder = { HIT_300, HIT_300G, HIT_100, HIT_100K, HIT_50, HIT_MISS };
int[] rankResultOrder = {
hitResultCount[HIT_300], hitResultCount[HIT_300G],
hitResultCount[HIT_100], hitResultCount[HIT_100K] + hitResultCount[HIT_300K],
hitResultCount[HIT_50], hitResultCount[HIT_MISS]
};
for (int i = 0; i < rankDrawOrder.length; i += 2) {
hitResults[rankDrawOrder[i]].getScaledCopy(rankResultScale).draw(
resultHitInitialX, resultHitInitialY - (hitResults[rankDrawOrder[i]].getHeight() * rankResultScale) + (resultOffsetY * (i / 2)));
hitResults[rankDrawOrder[i+1]].getScaledCopy(rankResultScale).draw(
resultHitInitialX + resultOffsetX, resultHitInitialY - (hitResults[rankDrawOrder[i]].getHeight() * rankResultScale) + (resultOffsetY * (i / 2)));
drawSymbolString(String.format("%dx", rankResultOrder[i]),
(int) resultInitialX, (int) (resultInitialY + (resultOffsetY * (i / 2))), symbolTextScale, false);
drawSymbolString(String.format("%dx", rankResultOrder[i+1]),
(int) (resultInitialX + resultOffsetX), (int) (resultInitialY + (resultOffsetY * (i / 2))), symbolTextScale, false);
}
// combo and accuracy
float textY = rankingHeight + (rankingPanelHeight * 0.87f) - (rankingHeight / 10f);
float numbersX = comboImage.getWidth() * .07f;
float numbersY = textY + comboImage.getHeight() * 0.7f;
comboImage.draw(width * 0.01f, textY);
accuracyImage.draw(rankingPanelWidth / 2f, textY);
drawSymbolString(String.format("%dx", comboMax),
(int) (width * 0.01f + numbersX), (int) numbersY, symbolTextScale, false);
drawSymbolString(String.format("%02.2f%%", getScorePercent()),
(int) (rankingPanelWidth / 2f + numbersX), (int) numbersY, symbolTextScale, false);
// full combo
if (combo == fullObjectCount)
perfectImage.draw(width * 0.08f, (height * 0.99f) - perfectImage.getHeight());
}
/**
* Draws stored hit results and removes them from the list as necessary.
* @param trackPosition the current track position
*/
public void drawHitResults(int trackPosition) {
int fadeDelay = 500;
Iterator<OsuHitObjectResult> iter = hitResultList.iterator();
while (iter.hasNext()) {
OsuHitObjectResult hitResult = iter.next();
if (hitResult.time + fadeDelay > trackPosition) {
hitResults[hitResult.result].setAlpha(hitResult.alpha);
hitResult.alpha = 1 - ((float) (trackPosition - hitResult.time) / fadeDelay);
hitResults[hitResult.result].drawCentered(hitResult.x, hitResult.y);
// hit lighting
if (Options.isHitLightingEnabled() && lighting != null &&
hitResult.result != HIT_MISS && hitResult.result != HIT_SLIDER30 && hitResult.result != HIT_SLIDER10) {
float scale = 1f + ((trackPosition - hitResult.time) / (float) fadeDelay);
Image scaledLighting = lighting.getScaledCopy(scale);
scaledLighting.draw(hitResult.x - (scaledLighting.getWidth() / 2f),
hitResult.y - (scaledLighting.getHeight() / 2f),
hitResult.color);
if (lighting1 != null) {
Image scaledLighting1 = lighting1.getScaledCopy(scale);
scaledLighting1.draw(hitResult.x - (scaledLighting1.getWidth() / 2f),
hitResult.y - (scaledLighting1.getHeight() / 2f),
hitResult.color);
}
}
} else
iter.remove();
}
}
/**
* Changes health by a given percentage, modified by drainRate.
*/
public void changeHealth(float percent) {
// TODO: drainRate formula
health += percent;
if (health > 100f)
health = 100f;
if (health < 0f)
health = 0f;
}
/**
* Returns health percentage.
*/
public float getHealth() { return health; }
/**
* Returns false if health is zero.
* If "No Fail" or "Auto" mods are active, this will always return true.
*/
public boolean isAlive() {
return (health > 0f ||
Options.isModActive(Options.MOD_NO_FAIL) ||
Options.isModActive(Options.MOD_AUTO));
}
/**
* Changes score by a raw value (not affected by other modifiers).
*/
public void changeScore(int value) { score += value; }
/**
* Returns score percentage (raw score only).
*/
private float getScorePercent() {
float percent = 0;
if (objectCount > 0)
percent = ((hitResultCount[HIT_50] * 50) + (hitResultCount[HIT_100] * 100)
+ (hitResultCount[HIT_300] * 300)) / (objectCount * 300f) * 100f;
return percent;
}
/**
* Returns (current) letter grade.
*/
private int getGrade() {
if (objectCount < 1) // avoid division by zero
return GRADE_D;
// TODO: silvers
float percent = getScorePercent();
float hit300ratio = hitResultCount[HIT_300] * 100f / objectCount;
float hit50ratio = hitResultCount[HIT_50] * 100f / objectCount;
boolean noMiss = (hitResultCount[HIT_MISS] == 0);
if (percent >= 100f)
return GRADE_SS;
else if (hit300ratio >= 90f && hit50ratio < 1.0f && noMiss)
return GRADE_S;
else if ((hit300ratio >= 80f && noMiss) || hit300ratio >= 90f)
return GRADE_A;
else if ((hit300ratio >= 70f && noMiss) || hit300ratio >= 80f)
return GRADE_B;
else if (hit300ratio >= 60f)
return GRADE_C;
else
return GRADE_D;
}
/**
* Updates combo burst data based on a delta value.
*/
public void updateComboBurst(int delta) {
if (comboBurstIndex > -1 && Options.isComboBurstEnabled()) {
int leftX = 0;
int rightX = width - comboBurstImages[comboBurstIndex].getWidth();
if (comboBurstX < leftX) {
comboBurstX += (delta / 2f);
if (comboBurstX > leftX)
comboBurstX = leftX;
} else if (comboBurstX > rightX) {
comboBurstX -= (delta / 2f);
if (comboBurstX < rightX)
comboBurstX = rightX;
} else if (comboBurstAlpha > 0f) {
comboBurstAlpha -= (delta / 1200f);
if (comboBurstAlpha < 0f)
comboBurstAlpha = 0f;
}
}
}
/**
* Increases the combo streak by one.
*/
private void incrementComboStreak() {
combo++;
if (combo > comboMax)
comboMax = combo;
// combo bursts (at 30, 60, 100+50x)
if (Options.isComboBurstEnabled() &&
(combo == 30 || combo == 60 || (combo >= 100 && combo % 50 == 0))) {
if (combo == 30)
comboBurstIndex = 0;
else
comboBurstIndex = (comboBurstIndex + 1) % comboBurstImages.length;
comboBurstAlpha = 0.8f;
if ((comboBurstIndex % 2) == 0)
comboBurstX = width;
else
comboBurstX = comboBurstImages[0].getWidth() * -1;
}
}
/**
* Handles a slider tick result.
* @param time the tick start time
* @param result the hit result (HIT_* constants)
* @param x the x coordinate
* @param y the y coordinate
*/
public void sliderTickResult(int time, int result, float x, float y) {
int hitValue = 0;
switch (result) {
case HIT_SLIDER30:
hitValue = 30;
incrementComboStreak();
changeHealth(1f);
break;
case HIT_SLIDER10:
hitValue = 10;
incrementComboStreak();
break;
case HIT_MISS:
combo = 0;
if (Options.isModActive(Options.MOD_SUDDEN_DEATH))
health = 0f;
break;
default:
return;
}
fullObjectCount++;
if (hitValue > 0) {
score += hitValue;
hitResultList.add(new OsuHitObjectResult(time, result, x, y, null));
}
}
/**
* Handles a hit result.
* @param time the object start time
* @param result the hit result (HIT_* constants)
* @param x the x coordinate
* @param y the y coordinate
* @param color the combo color
* @param end true if this is the last hit object in the combo
*/
public void hitResult(int time, int result, float x, float y, Color color, boolean end) {
int hitValue = 0;
switch (result) {
case HIT_300:
hitValue = 300;
changeHealth(5f);
objectCount++;
break;
case HIT_100:
hitValue = 100;
changeHealth(2f);
comboEnd |= 1;
objectCount++;
break;
case HIT_50:
hitValue = 50;
comboEnd |= 2;
objectCount++;
break;
case HIT_MISS:
hitValue = 0;
changeHealth(-10f);
comboEnd |= 2;
combo = 0;
if (Options.isModActive(Options.MOD_SUDDEN_DEATH))
health = 0f;
objectCount++;
break;
default:
return;
}
if (hitValue > 0) {
// game mod score multipliers
float modMultiplier = 1f;
if (Options.isModActive(Options.MOD_NO_FAIL))
modMultiplier *= 0.5f;
if (Options.isModActive(Options.MOD_HARD_ROCK))
modMultiplier *= 1.06f;
if (Options.isModActive(Options.MOD_SPUN_OUT))
modMultiplier *= 0.9f;
// not implemented:
// EASY (0.5x), HALF_TIME (0.3x),
// DOUBLE_TIME (1.12x), HIDDEN (1.06x), FLASHLIGHT (1.12x)
/**
* [SCORE FORMULA]
* Score = Hit Value + Hit Value * (Combo * Difficulty * Mod) / 25
* - Hit Value: hit result (50, 100, 300), slider ticks, spinner bonus
* - Combo: combo before this hit - 1 (minimum 0)
* - Difficulty: the beatmap difficulty
* - Mod: mod multipliers
*/
score += (hitValue + (hitValue * (Math.max(combo - 1, 0) * difficulty * modMultiplier) / 25));
incrementComboStreak();
}
hitResultCount[result]++;
fullObjectCount++;
// last element in combo: check for Geki/Katu
if (end) {
if (comboEnd == 0) {
result = HIT_300G;
changeHealth(15f);
hitResultCount[result]++;
} else if ((comboEnd & 2) == 0) {
if (result == HIT_100) {
result = HIT_100K;
changeHealth(10f);
hitResultCount[result]++;
} else if (result == HIT_300) {
result = HIT_300K;
changeHealth(10f);
hitResultCount[result]++;
}
}
comboEnd = 0;
}
hitResultList.add(new OsuHitObjectResult(time, result, x, y, color));
}
}

View File

@@ -0,0 +1,346 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import itdelatrisu.opsu.states.Options;
import java.io.File;
import java.lang.reflect.Field;
import java.nio.IntBuffer;
import java.util.concurrent.TimeUnit;
import javazoom.jl.converter.Converter;
import org.lwjgl.BufferUtils;
import org.lwjgl.openal.AL;
import org.lwjgl.openal.AL10;
import org.newdawn.slick.Music;
import org.newdawn.slick.SlickException;
import org.newdawn.slick.openal.Audio;
import org.newdawn.slick.openal.SoundStore;
import org.newdawn.slick.util.Log;
/**
* Controller for all music.
*/
public class MusicController {
/**
* The current music track.
*/
private static Music player;
/**
* The last OsuFile passed to play().
*/
private static OsuFile lastOsu;
/**
* Temporary WAV file for file conversions (to be deleted).
*/
private static File wavFile;
/**
* Thread for MP3 conversions.
*/
private static Thread conversion;
// This class should not be instantiated.
private MusicController() {}
/**
* Plays an audio file at the preview position.
*/
@SuppressWarnings("deprecation")
public static void play(final OsuFile osu, final boolean loop) {
if (lastOsu == null || !osu.audioFilename.equals(lastOsu.audioFilename)) {
lastOsu = osu;
// TODO: properly interrupt instead of using deprecated conversion.stop();
// interrupt the conversion
if (isConverting())
// conversion.interrupt();
conversion.stop();
if (wavFile != null)
wavFile.delete();
// releases all sources from previous tracks
destroyOpenAL();
switch (OsuParser.getExtension(osu.audioFilename.getName())) {
case "ogg":
loadTrack(osu.audioFilename, osu.previewTime, loop);
break;
case "mp3":
// convert MP3 to WAV in a new conversion
conversion = new Thread() {
@Override
public void run() {
convertMp3(osu.audioFilename);
// if (!Thread.currentThread().isInterrupted())
loadTrack(wavFile, osu.previewTime, loop);
}
};
conversion.start();
break;
default:
break;
}
}
}
/**
* Loads a track and plays it.
*/
private static void loadTrack(File file, int previewTime, boolean loop) {
try { // create a new player
player = new Music(file.getPath());
playAt((previewTime > 0) ? previewTime : 0, loop);
} catch (Exception e) {
Log.error(String.format("Could not play track '%s'.", file.getName()), e);
}
}
/**
* Plays the current track at the given position.
*/
public static void playAt(final int position, final boolean loop) {
if (trackExists()) {
SoundStore.get().setMusicVolume(Options.getMusicVolume());
player.setPosition(position / 1000f);
if (loop)
player.loop();
else
player.play();
}
}
/**
* Converts an MP3 file to a temporary WAV file.
*/
private static File convertMp3(File file) {
try {
wavFile = File.createTempFile(".osu", ".wav", Options.TMP_DIR);
wavFile.deleteOnExit();
Converter converter = new Converter();
converter.convert(file.getPath(), wavFile.getPath());
return wavFile;
} catch (Exception e) {
Log.error(String.format("Failed to play file '%s'.", file.getAbsolutePath()), e);
}
return wavFile;
}
/**
* Returns true if a conversion is running.
*/
public static boolean isConverting() {
return (conversion != null && conversion.isAlive());
}
/**
* Returns true if a track is loaded.
*/
public static boolean trackExists() {
return (player != null);
}
/**
* Returns the name of the current track.
*/
public static String getTrackName() {
if (!trackExists() || lastOsu == null)
return null;
return lastOsu.title;
}
/**
* Returns the artist of the current track.
*/
public static String getArtistName() {
if (!trackExists() || lastOsu == null)
return null;
return lastOsu.artist;
}
/**
* Returns true if the current track is playing.
*/
public static boolean isPlaying() {
return (trackExists() && player.playing());
}
/**
* Pauses the current track.
*/
public static void pause() {
if (isPlaying())
player.pause();
}
/**
* Resumes the current track.
*/
public static void resume() {
if (trackExists()) {
player.resume();
player.setVolume(1.0f);
}
}
/**
* Stops the current track.
*/
public static void stop() {
if (isPlaying())
player.stop();
}
/**
* Fades out the track.
*/
public static void fadeOut(int duration) {
if (isPlaying())
player.fade(duration, 0f, true);
}
/**
* Returns the position in the current track.
* If no track is playing, 0f will be returned.
*/
public static int getPosition() {
if (isPlaying())
return Math.max((int) (player.getPosition() * 1000 + Options.getMusicOffset()), 0);
else
return 0;
}
/**
* Seeks to a position in the current track.
*/
public static boolean setPosition(int position) {
return (trackExists() && player.setPosition(position / 1000f));
}
/**
* Gets the length of the track, in milliseconds.
* Returns 0f if no file is loaded or an MP3 is currently being converted.
* @author bdk (http://slick.ninjacave.com/forum/viewtopic.php?t=2699)
*/
public static int getTrackLength() {
if (!trackExists() || isConverting())
return 0;
float duration = 0f;
try {
// get Music object's (private) Audio object reference
Field sound = player.getClass().getDeclaredField("sound");
sound.setAccessible(true);
Audio audio = (Audio) (sound.get(player));
// access Audio object's (private)'length' field
Field length = audio.getClass().getDeclaredField("length");
length.setAccessible(true);
duration = (float) (length.get(audio));
} catch (Exception e) {
Log.error("Could not get track length.", e);
}
return (int) (duration * 1000);
}
/**
* Gets the length of the track as a formatted string (M:SS).
* Returns "--" if an MP3 is currently being converted.
*/
public static String getTrackLengthString() {
if (isConverting())
return "...";
int duration = getTrackLength();
return String.format("%d:%02d",
TimeUnit.MILLISECONDS.toMinutes(duration),
TimeUnit.MILLISECONDS.toSeconds(duration) -
TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(duration)));
}
/**
* Stops and releases all sources, clears each of the specified Audio
* buffers, destroys the OpenAL context, and resets SoundStore for future use.
*
* Calling SoundStore.get().init() will re-initialize the OpenAL context
* after a call to destroyOpenAL (Note: AudioLoader.getXXX calls init for you).
*
* @author davedes (http://slick.ninjacave.com/forum/viewtopic.php?t=3920)
*/
private static void destroyOpenAL() {
if (!trackExists())
return;
stop();
try {
// get Music object's (private) Audio object reference
Field sound = player.getClass().getDeclaredField("sound");
sound.setAccessible(true);
Audio audio = (Audio) (sound.get(player));
// first clear the sources allocated by SoundStore
int max = SoundStore.get().getSourceCount();
IntBuffer buf = BufferUtils.createIntBuffer(max);
for (int i = 0; i < max; i++) {
int source = SoundStore.get().getSource(i);
buf.put(source);
// stop and detach any buffers at this source
AL10.alSourceStop(source);
AL10.alSourcei(source, AL10.AL_BUFFER, 0);
}
buf.flip();
AL10.alDeleteSources(buf);
int exc = AL10.alGetError();
if (exc != AL10.AL_NO_ERROR) {
throw new SlickException(
"Could not clear SoundStore sources, err: " + exc);
}
// delete any buffer data stored in memory, too...
if (audio != null && audio.getBufferID() != 0) {
buf = BufferUtils.createIntBuffer(1).put(audio.getBufferID());
buf.flip();
AL10.alDeleteBuffers(buf);
exc = AL10.alGetError();
if (exc != AL10.AL_NO_ERROR) {
throw new SlickException("Could not clear buffer "
+ audio.getBufferID()
+ ", err: "+exc);
}
}
// clear OpenAL
AL.destroy();
// reset SoundStore so that next time we create a Sound/Music, it will reinit
SoundStore.get().clear();
player = null;
} catch (Exception e) {
Log.error("Failed to destroy OpenAL.", e);
}
}
}

View File

@@ -0,0 +1,152 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.states.GamePauseMenu;
import itdelatrisu.opsu.states.GameRanking;
import itdelatrisu.opsu.states.MainMenu;
import itdelatrisu.opsu.states.MainMenuExit;
import itdelatrisu.opsu.states.Options;
import itdelatrisu.opsu.states.SongMenu;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.PrintStream;
import org.newdawn.slick.AppGameContainer;
import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
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.DefaultLogSystem;
import org.newdawn.slick.util.FileSystemLocation;
import org.newdawn.slick.util.Log;
import org.newdawn.slick.util.ResourceLoader;
/**
* Main class.
* <p>
* Creates game container, adds all other states, and initializes song data.
*/
public class Opsu extends StateBasedGame {
/**
* Song group structure (each group contains of an ArrayList of OsuFiles).
*/
public static OsuGroupList groups = new OsuGroupList();
/**
* Game states.
*/
public static final int
STATE_OPTIONS = 1,
STATE_MAINMENU = 2,
STATE_MAINMENUEXIT = 3,
STATE_SONGMENU = 4,
STATE_GAME = 5,
STATE_GAMEPAUSEMENU = 6,
STATE_GAMERANKING = 7;
public Opsu(String name) {
super(name);
}
@Override
public void initStatesList(GameContainer container) throws SlickException {
addState(new Options(STATE_OPTIONS));
addState(new MainMenu(STATE_MAINMENU));
addState(new MainMenuExit(STATE_MAINMENUEXIT));
addState(new SongMenu(STATE_SONGMENU));
addState(new Game(STATE_GAME));
addState(new GamePauseMenu(STATE_GAMEPAUSEMENU));
addState(new GameRanking(STATE_GAMERANKING));
}
public static void main(String[] args) {
// set path for lwjgl natives - NOT NEEDED if using JarSplice
// System.setProperty("org.lwjgl.librarypath", new File("native").getAbsolutePath());
// set the resource path
ResourceLoader.addResourceLocation(new FileSystemLocation(new File("./res/")));
// log all errors to a file
Log.setVerbose(false);
try {
DefaultLogSystem.out = new PrintStream(new FileOutputStream(Options.LOG_FILE, true));
} catch (FileNotFoundException e) {
Log.error(e);
}
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
Log.error("** Uncaught Exception! **", e);
}
});
// start the game
Opsu osuGame = new Opsu("opsu!");
try {
AppGameContainer app = new AppGameContainer(osuGame);
// basic game settings
Options.parseOptions();
int[] containerSize = Options.getContainerSize();
app.setDisplayMode(containerSize[0], containerSize[1], false);
String[] icons = { "icon16.png", "icon32.png" };
app.setIcons(icons);
// parse song directory
OsuParser.parseAllFiles(Options.getBeatmapDir(),
app.getWidth(), app.getHeight()
);
// clear the cache
if (!Options.TMP_DIR.mkdir()) {
for (File tmp : Options.TMP_DIR.listFiles())
tmp.delete();
}
Options.TMP_DIR.deleteOnExit();
app.start();
} catch (SlickException e) {
Log.error("Error while creating game container.", e);
}
}
@Override
public boolean closeRequested() {
int id = this.getCurrentStateID();
// intercept close requests in game-related states and return to song menu
if (id == STATE_GAME || id == STATE_GAMEPAUSEMENU || id == STATE_GAMERANKING) {
// start playing track at preview position
MusicController.pause();
MusicController.playAt(Game.getOsuFile().previewTime, true);
this.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
return false;
}
Options.saveOptions();
((AppGameContainer) this.getContainer()).destroy();
return true;
}
}

View File

@@ -0,0 +1,158 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import java.io.File;
import java.util.ArrayList;
import org.newdawn.slick.Color;
import org.newdawn.slick.Image;
import org.newdawn.slick.util.Log;
/**
* Data type storing parsed data from OSU files.
*/
public class OsuFile implements Comparable<OsuFile> {
/**
* The OSU File object associated with this OsuFile.
*/
private File file;
/* [General] */
public File audioFilename; // audio file object
public int audioLeadIn = 0; // delay before music starts (in ms)
// public String audioHash = ""; // audio hash (deprecated)
public int previewTime = -1; // start position of music preview (in ms)
public byte countdown = 0; // countdown type (0:disabled, 1:normal, 2:half, 3:double)
public String sampleSet = ""; // ? ("Normal", "Soft")
public float stackLeniency = 0.7f; // how often closely placed hit objects will be stacked together
public byte mode = 0; // game mode (0:osu!, 1:taiko, 2:catch the beat, 3:osu!mania)
public boolean letterboxInBreaks = false; // whether the letterbox (top/bottom black bars) appears during breaks
public boolean widescreenStoryboard = false;// whether the storyboard should be widescreen
public boolean epilepsyWarning = false; // whether to show an epilepsy warning
/* [Editor] */
/* Not implemented. */
// public int[] bookmarks; // list of editor bookmarks (in ms)
// public float distanceSpacing = 0f; // multiplier for "Distance Snap"
// public byte beatDivisor = 0; // beat division
// public int gridSize = 0; // size of grid for "Grid Snap"
// public int timelineZoom = 0; // zoom in the editor timeline
/* [Metadata] */
public String title = ""; // song title
public String titleUnicode = ""; // song title (unicode)
public String artist = ""; // song artist
public String artistUnicode = ""; // song artist (unicode)
public String creator = ""; // beatmap creator
public String version = ""; // beatmap difficulty
public String source = ""; // song source
// public String[] tags; // song tags, for searching -> different structure
public int beatmapID = 0; // beatmap ID
public int beatmapSetID = 0; // beatmap set ID
/* [Difficulty] */
public byte HPDrainRate = 5; // HP drain (0:easy ~ 10:hard)
public byte circleSize = 4; // size of circles
public byte overallDifficulty = 5; // affects timing window, spinners, and approach speed (0:easy ~ 10:hard)
public byte approachRate = -1; // how long circles stay on the screen (0:long ~ 10:short) **not in old format**
public float sliderMultiplier = 1f; // slider movement speed multiplier
public float sliderTickRate = 1f; // rate at which slider ticks are placed (x per beat)
/* [Events] */
//Background and Video events (0)
public String bg; // background image path
private Image bgImage; // background image (created when needed)
// public Video bgVideo; // background video (not implemented)
//Break Periods (2)
public ArrayList<Integer> breaks; // break periods (start time, end time, ...)
//Storyboard elements (not implemented)
/* [TimingPoints] */
public ArrayList<OsuTimingPoint> timingPoints; // timing points
int bpmMin = 0, bpmMax = 0; // min and max BPM
/* [Colours] */
public Color[] combo; // combo colors (R,G,B), max 5
/* [HitObjects] */
public OsuHitObject[] objects; // hit objects
public int hitObjectCircle = 0; // number of circles
public int hitObjectSlider = 0; // number of sliders
public int hitObjectSpinner = 0; // number of spinners
/**
* Constructor.
* @param file the file associated with this OsuFile
*/
public OsuFile(File file) {
this.file = file;
}
/**
* Returns the associated file object.
*/
public File getFile() { return file; }
/**
* Draws the background associated with the OsuFile.
* @param width the container width
* @param height the container height
* @param alpha the alpha value
* @return true if successful, false if any errors were produced
*/
public boolean drawBG(int width, int height, float alpha) {
if (bg == null)
return false;
try {
if (bgImage == null)
bgImage = new Image(bg).getScaledCopy(width, height);
bgImage.setAlpha(alpha);
bgImage.draw();
} catch (Exception e) {
Log.warn(String.format("Failed to get background image '%s'.", bg), e);
bg = null; // don't try to load the file again until a restart
return false;
}
return true;
}
/**
* Compares two OsuFile objects first by overall difficulty, then by total objects.
*/
@Override
public int compareTo(OsuFile that) {
int cmp = Byte.compare(this.overallDifficulty, that.overallDifficulty);
if (cmp == 0)
cmp = Integer.compare(
this.hitObjectCircle + this.hitObjectSlider + this.hitObjectSpinner,
that.hitObjectCircle + that.hitObjectSlider + that.hitObjectSpinner
);
return cmp;
}
/**
* Returns a formatted string: "Artist - Title [Version]"
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return String.format("%s - %s [%s]", artist, title, version);
}
}

View File

@@ -0,0 +1,313 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
/**
* Indexed, expanding, doubly-linked list data type for song groups.
*/
public class OsuGroupList {
/**
* Sorting orders.
*/
public static final byte
SORT_TITLE = 0,
SORT_ARTIST = 1,
SORT_CREATOR = 2,
SORT_BPM = 3,
SORT_MAX = 4; // not a sort
/**
* Sorting order names (indexed by SORT_* constants).
*/
public static final String[] SORT_NAMES = {
"Title", "Artist", "Creator", "BPM"
};
/**
* List containing all parsed nodes.
*/
private ArrayList<OsuGroupNode> parsedNodes;
/**
* Total number of maps (i.e. OsuFile objects).
*/
private int mapCount = 0;
/**
* Song tags.
* Each tag value is a HashSet which points song group ArrayLists.
*/
private HashMap<String, HashSet<ArrayList<OsuFile>>> tags;
/**
* Current list of nodes.
* (For searches; otherwise, a pointer to parsedNodes.)
*/
private ArrayList<OsuGroupNode> nodes;
/**
* Index of current expanded node.
* If no node is expanded, the value will be -1.
*/
private int expandedIndex = -1;
/**
* The last search query.
*/
private String lastQuery = "";
/**
* Constructor.
*/
public OsuGroupList() {
parsedNodes = new ArrayList<OsuGroupNode>();
nodes = parsedNodes;
tags = new HashMap<String, HashSet<ArrayList<OsuFile>>>();
}
/**
* Returns the number of elements.
*/
public int size() { return nodes.size(); }
/**
* Adds a song group.
* @param osuFiles the list of OsuFile objects in the group
*/
public void addSongGroup(ArrayList<OsuFile> osuFiles) {
parsedNodes.add(new OsuGroupNode(osuFiles));
mapCount += osuFiles.size();
}
/**
* Returns the total number of parsed maps (i.e. OsuFile objects).
*/
public int getMapCount() { return mapCount; }
/**
* Adds a tag.
* @param tag the tag string (key)
* @param osuFiles the song group associated with the tag (value)
*/
public void addTag(String tag, ArrayList<OsuFile> osuFiles) {
tag = tag.toLowerCase();
HashSet<ArrayList<OsuFile>> tagSet = tags.get(tag);
if (tagSet == null) {
tagSet = new HashSet<ArrayList<OsuFile>>();
tags.put(tag, tagSet);
}
tagSet.add(osuFiles);
}
/**
* Returns the OsuGroupNode at an index, disregarding expansions.
*/
public OsuGroupNode getBaseNode(int index) {
if (index < 0 || index >= size())
return null;
return nodes.get(index);
}
/**
* Returns a random base node.
*/
public OsuGroupNode getRandomNode() {
return getBaseNode((int) (Math.random() * size()));
}
/**
* Returns the OsuGroupNode a given number of positions forward or backwards.
* @param node the starting node
* @param shift the number of nodes to shift forward (+) or backward (-).
*/
public OsuGroupNode getNode(OsuGroupNode node, int shift) {
OsuGroupNode startNode = node;
if (shift > 0) {
for (int i = 0; i < shift && startNode != null; i++)
startNode = startNode.next;
} else {
for (int i = 0; i < shift && startNode != null; i++)
startNode = startNode.prev;
}
return startNode;
}
/**
* Returns the index of the expanded node (or -1 if nothing is expanded).
*/
public int getExpandedIndex() { return expandedIndex; }
/**
* Expands the node at an index by inserting a new node for each OsuFile in that node.
*/
public void expand(int index) {
// undo the previous expansion
if (unexpand() == index)
return; // don't re-expand the same node
OsuGroupNode node = getBaseNode(index);
if (node == null)
return;
ArrayList<OsuFile> osuFiles = node.osuFiles;
OsuGroupNode nextNode = node.next;
for (int i = 0; i < node.osuFiles.size(); i++) {
OsuGroupNode newNode = new OsuGroupNode(osuFiles);
newNode.index = index;
newNode.osuFileIndex = i;
newNode.prev = node;
node.next = newNode;
node = node.next;
}
if (nextNode != null) {
node.next = nextNode;
nextNode.prev = node;
}
expandedIndex = index;
}
/**
* Undoes the current expansion, if any.
* @return the index of the previously expanded node, or -1 if no expansions
*/
private int unexpand() {
if (expandedIndex < 0 || expandedIndex >= size())
return -1;
// reset [base].next and [base+1].prev
OsuGroupNode eCur = getBaseNode(expandedIndex);
OsuGroupNode eNext = getBaseNode(expandedIndex + 1);
eCur.next = eNext;
if (eNext != null)
eNext.prev = eCur;
int oldIndex = expandedIndex;
expandedIndex = -1;
return oldIndex;
}
/**
* Initializes the links in the list, given a sorting order (SORT_* constants).
*/
public void init(byte order) {
if (size() < 1)
return;
// sort the list
switch (order) {
case SORT_TITLE:
Collections.sort(nodes);
break;
case SORT_ARTIST:
Collections.sort(nodes, new OsuGroupNode.ArtistOrder());
break;
case SORT_CREATOR:
Collections.sort(nodes, new OsuGroupNode.CreatorOrder());
break;
case SORT_BPM:
Collections.sort(nodes, new OsuGroupNode.BPMOrder());
break;
}
expandedIndex = -1;
// create links
OsuGroupNode lastNode = nodes.get(0);
lastNode.index = 0;
lastNode.prev = null;
for (int i = 1, size = size(); i < size; i++) {
OsuGroupNode node = nodes.get(i);
lastNode.next = node;
node.index = i;
node.prev = lastNode;
lastNode = node;
}
lastNode.next = null;
}
/**
* Creates a new list of song groups in which each group contains a match to a search query.
* @param query the search query (tag terms separated by spaces)
* @return false if query is the same as the previous one, true otherwise
*/
public boolean search(String query) {
if (query == null)
return false;
// don't redo the same search
query = query.trim().toLowerCase();
if (lastQuery != null && query.equals(lastQuery))
return false;
lastQuery = query;
// if empty query, reset to original list
if (query.isEmpty()) {
nodes = parsedNodes;
return true;
}
// tag search: check if each word is contained in global tag structure
HashSet<ArrayList<OsuFile>> taggedGroups = new HashSet<ArrayList<OsuFile>>();
String[] terms = query.split("\\s+");
for (String term : terms) {
if (tags.containsKey(term))
taggedGroups.addAll(tags.get(term)); // add all matches
}
// traverse parsedNodes, comparing each element with the query
nodes = new ArrayList<OsuGroupNode>();
for (OsuGroupNode node : parsedNodes) {
// search: tags
if (taggedGroups.contains(node.osuFiles)) {
nodes.add(node);
continue;
}
OsuFile osu = node.osuFiles.get(0);
// search: title, artist, creator, source, version (first OsuFile)
if (osu.title.toLowerCase().contains(query) ||
osu.artist.toLowerCase().contains(query) ||
osu.creator.toLowerCase().contains(query) ||
osu.source.toLowerCase().contains(query) ||
osu.version.toLowerCase().contains(query)) {
nodes.add(node);
continue;
}
// search: versions (all OsuFiles)
for (int i = 1; i < node.osuFiles.size(); i++) {
if (node.osuFiles.get(i).version.toLowerCase().contains(query)) {
nodes.add(node);
break;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,191 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import itdelatrisu.opsu.states.Options;
import java.util.ArrayList;
import java.util.Comparator;
import org.newdawn.slick.Color;
import org.newdawn.slick.Image;
/**
* Node in an OsuGroupList representing a group of OsuFile objects.
*/
public class OsuGroupNode implements Comparable<OsuGroupNode> {
/**
* Menu background image.
*/
private static Image bg;
/**
* List of associated OsuFile objects.
*/
public ArrayList<OsuFile> osuFiles;
/**
* Index of this OsuGroupNode.
*/
public int index = 0;
/**
* Index of selected osuFile.
* If not focused, the value will be -1.
*/
public int osuFileIndex = -1;
/**
* Links to other OsuGroupNode objects.
*/
public OsuGroupNode prev, next;
/**
* Constructor.
* @param osuFiles the OsuFile objects in this group
*/
public OsuGroupNode(ArrayList<OsuFile> osuFiles) {
this.osuFiles = osuFiles;
}
/**
* Compares two OsuGroupNode objects by title.
*/
@Override
public int compareTo(OsuGroupNode that) {
return this.osuFiles.get(0).title.compareToIgnoreCase(that.osuFiles.get(0).title);
}
/**
* Compares two OsuGroupNode objects by artist.
*/
public static class ArtistOrder implements Comparator<OsuGroupNode> {
@Override
public int compare(OsuGroupNode v, OsuGroupNode w) {
return v.osuFiles.get(0).artist.compareToIgnoreCase(w.osuFiles.get(0).artist);
}
}
/**
* Compares two OsuGroupNode objects by creator.
*/
public static class CreatorOrder implements Comparator<OsuGroupNode> {
@Override
public int compare(OsuGroupNode v, OsuGroupNode w) {
return v.osuFiles.get(0).creator.compareToIgnoreCase(w.osuFiles.get(0).creator);
}
}
/**
* Compares two OsuGroupNode objects by BPM.
*/
public static class BPMOrder implements Comparator<OsuGroupNode> {
@Override
public int compare(OsuGroupNode v, OsuGroupNode w) {
return Integer.compare(v.osuFiles.get(0).bpmMax, w.osuFiles.get(0).bpmMax);
}
}
/**
* Sets a button background image.
*/
public static void setBackground(Image background) {
bg = background;
}
/**
* Draws the button.
* @param x the x coordinate
* @param y the y coordinate
* @param focus true if this is the focused node
*/
public void draw(float x, float y, boolean focus) {
boolean expanded = (osuFileIndex > -1);
float xOffset = 0f;
OsuFile osu;
Color textColor = Color.lightGray;
if (expanded) { // expanded
if (focus) {
xOffset = bg.getWidth() * -0.05f;
bg.draw(x + xOffset, y, Color.white);
textColor = Color.white;
} else {
xOffset = bg.getWidth() * 0.05f;
bg.draw(x + xOffset, y, Options.COLOR_BLUE_BUTTON);
}
osu = osuFiles.get(osuFileIndex);
} else {
bg.draw(x, y, Options.COLOR_ORANGE_BUTTON);
osu = osuFiles.get(0);
}
float cx = x + (bg.getWidth() * 0.05f) + xOffset;
float cy = y + (bg.getHeight() * 0.2f) - 3;
Options.FONT_MEDIUM.drawString(cx, cy, osu.title, textColor);
Options.FONT_DEFAULT.drawString(cx, cy + Options.FONT_MEDIUM.getLineHeight() - 4, String.format("%s // %s", osu.artist, osu.creator), textColor);
if (expanded)
Options.FONT_BOLD.drawString(cx, cy + Options.FONT_MEDIUM.getLineHeight() + Options.FONT_DEFAULT.getLineHeight() - 8, osu.version, textColor);
}
/**
* Returns an array of strings containing song information.
* <ul>
* <li>0: {Artist} - {Title} [{Version}]
* <li>1: Mapped by {Creator}
* <li>2: Length: {} BPM: {} Objects: {}
* <li>3: Circles: {} Sliders: {} Spinners: {}
* <li>4: CS:{} HP:{} AR:{} OD:{}
* </ul>
*/
public String[] getInfo() {
if (osuFileIndex < 0)
return null;
OsuFile osu = osuFiles.get(osuFileIndex);
String[] info = new String[5];
info[0] = osu.toString();
info[1] = String.format("Mapped by %s",
osu.creator);
info[2] = String.format("Length: %s BPM: %s Objects: %d",
MusicController.getTrackLengthString(),
(osu.bpmMax <= 0) ? "--" :
((osu.bpmMin == osu.bpmMax) ? osu.bpmMin : String.format("%d-%d", osu.bpmMin, osu.bpmMax)),
(osu.hitObjectCircle + osu.hitObjectSlider + osu.hitObjectSpinner));
info[3] = String.format("Circles: %d Sliders: %d Spinners: %d",
osu.hitObjectCircle, osu.hitObjectSlider, osu.hitObjectSpinner);
info[4] = String.format("CS:%d HP:%d AR:%d OD:%d",
osu.circleSize, osu.HPDrainRate, osu.approachRate, osu.overallDifficulty);
return info;
}
/**
* Returns a formatted string for the OsuFile at osuFileIndex:
* "Artist - Title [Version]" (version omitted if osuFileIndex is invalid)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
if (osuFileIndex == -1)
return String.format("%s - %s", osuFiles.get(0).artist, osuFiles.get(0).title);
else
return osuFiles.get(osuFileIndex).toString();
}
}

View File

@@ -0,0 +1,92 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
/**
* Data type representing a hit object.
*/
public class OsuHitObject {
/**
* Hit object types (bits).
*/
public static final int
TYPE_CIRCLE = 1,
TYPE_SLIDER = 2,
TYPE_NEWCOMBO = 4, // not an object
TYPE_SPINNER = 8;
/**
* Hit sound types.
*/
public static final byte
SOUND_NORMAL = 0,
SOUND_WHISTLE = 2,
SOUND_FINISH = 4,
SOUND_WHISTLEFINISH = 6,
SOUND_CLAP = 8;
/**
* Slider curve types.
* (Deprecated: only Beziers are currently used.)
*/
public static final char
SLIDER_CATMULL = 'C',
SLIDER_BEZIER = 'B',
SLIDER_LINEAR = 'L',
SLIDER_PASSTHROUGH = 'P';
/**
* Max hit object coordinates.
*/
public static final int
MAX_X = 512,
MAX_Y = 384;
// parsed fields (coordinates are scaled)
public float x, y; // start coordinates
public int time; // start time, in ms
public int type; // hit object type
public byte hitSound; // hit sound type
public char sliderType; // slider curve type (sliders only)
public float[] sliderX; // slider x coordinate list (sliders only)
public float[] sliderY; // slider y coordinate list (sliders only)
public int repeat; // slider repeat count (sliders only)
public float pixelLength; // slider pixel length (sliders only)
public int endTime; // end time, in ms (spinners only)
// additional v10+ parameters not implemented...
// addition -> sampl:add:cust:vol:hitsound
// edge_hitsound, edge_addition (sliders only)
// extra fields
public int comboIndex; // current index in Color array
public int comboNumber; // number to display in hit object
/**
* Constructor with all required fields.
*/
public OsuHitObject(float x, float y, int time, int type, byte hitSound) {
this.x = x;
this.y = y;
this.time = time;
this.type = type;
this.hitSound = hitSound;
}
}

View File

@@ -0,0 +1,553 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
import itdelatrisu.opsu.states.Options;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import org.newdawn.slick.Color;
import org.newdawn.slick.util.Log;
/**
* Parser for OSU files.
*/
public class OsuParser {
/**
* The x and y multipliers for hit object coordinates.
*/
private static float xMultiplier, yMultiplier;
/**
* The x and y offsets for hit object coordinates.
*/
private static int
xOffset, // offset right of border
yOffset; // offset below health bar
// This class should not be instantiated.
private OsuParser() {}
/**
* Invokes parser for each OSU file in a root directory.
* @param root the root directory (search has depth 1)
* @param width the container width
* @param height the container height
*/
public static void parseAllFiles(File root, int width, int height) {
// set coordinate modifiers
xMultiplier = (width * 0.6f) / OsuHitObject.MAX_X;
yMultiplier = (height * 0.6f) / OsuHitObject.MAX_Y;
xOffset = width / 5;
yOffset = height / 5;
for (File folder : root.listFiles()) {
if (!folder.isDirectory())
continue;
File[] files = folder.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.toLowerCase().endsWith(".osu");
}
});
if (files.length < 1)
continue;
// create a new group entry
ArrayList<OsuFile> osuFiles = new ArrayList<OsuFile>();
for (File file : files) {
// Parse hit objects only when needed to save time/memory.
// Change boolean to 'true' to parse them immediately.
parseFile(file, osuFiles, false);
}
if (!osuFiles.isEmpty()) { // add entry if non-empty
Collections.sort(osuFiles);
Opsu.groups.addSongGroup(osuFiles);
}
}
}
/**
* Parses an OSU file.
* @param file the file to parse
* @param osuFiles the song group
* @param parseObjects if true, hit objects will be fully parsed now
* @return the new OsuFile object
*/
private static OsuFile parseFile(File file, ArrayList<OsuFile> osuFiles, boolean parseObjects) {
OsuFile osu = new OsuFile(file);
String tags = ""; // parse at end, only if valid OsuFile
try (BufferedReader in = new BufferedReader(new FileReader(file))) {
// copy the default combo colors
osu.combo = Arrays.copyOf(Options.DEFAULT_COMBO, Options.DEFAULT_COMBO.length);
// initialize timing point list
osu.timingPoints = new ArrayList<OsuTimingPoint>();
String line = in.readLine();
String tokens[];
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;
tokens = tokenize(line);
switch (tokens[0]) {
case "AudioFilename":
osu.audioFilename = new File(file.getParent() + File.separator + tokens[1]);
break;
case "AudioLeadIn":
osu.audioLeadIn = Integer.parseInt(tokens[1]);
break;
// case "AudioHash": // deprecated
// osu.audioHash = tokens[1];
// break;
case "PreviewTime":
osu.previewTime = Integer.parseInt(tokens[1]);
break;
case "Countdown":
osu.countdown = Byte.parseByte(tokens[1]);
break;
case "SampleSet":
osu.sampleSet = tokens[1];
break;
case "StackLeniency":
osu.stackLeniency = Float.parseFloat(tokens[1]);
break;
case "Mode":
osu.mode = Byte.parseByte(tokens[1]);
/* Non-Opsu! standard files not implemented (obviously). */
if (osu.mode != 0)
return null;
break;
case "LetterboxInBreaks":
osu.letterboxInBreaks = (Integer.parseInt(tokens[1]) == 1);
break;
case "WidescreenStoryboard":
osu.widescreenStoryboard = (Integer.parseInt(tokens[1]) == 1);
break;
case "EpilepsyWarning":
osu.epilepsyWarning = (Integer.parseInt(tokens[1]) == 1);
default:
break;
}
}
break;
case "[Editor]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
/* Not implemented. */
// tokens = tokenize(line);
// switch (tokens[0]) {
// case "Bookmarks":
// String[] bookmarks = tokens[1].split(",");
// osu.bookmarks = new int[bookmarks.length];
// for (int i = 0; i < bookmarks.length; i++)
// osu.bookmarks[i] = Integer.parseInt(bookmarks[i]);
// break;
// case "DistanceSpacing":
// osu.distanceSpacing = Float.parseFloat(tokens[1]);
// break;
// case "BeatDivisor":
// osu.beatDivisor = Byte.parseByte(tokens[1]);
// break;
// case "GridSize":
// osu.gridSize = Integer.parseInt(tokens[1]);
// break;
// case "TimelineZoom":
// osu.timelineZoom = Integer.parseInt(tokens[1]);
// break;
// default:
// break;
// }
}
break;
case "[Metadata]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
tokens = tokenize(line);
switch (tokens[0]) {
case "Title":
osu.title = tokens[1];
break;
case "TitleUnicode":
osu.titleUnicode = tokens[1];
break;
case "Artist":
osu.artist = tokens[1];
break;
case "ArtistUnicode":
osu.artistUnicode = tokens[1];
break;
case "Creator":
osu.creator = tokens[1];
break;
case "Version":
osu.version = tokens[1];
break;
case "Source":
osu.source = tokens[1];
break;
case "Tags":
tags = tokens[1];
break;
case "BeatmapID":
osu.beatmapID = Integer.parseInt(tokens[1]);
break;
case "BeatmapSetID":
osu.beatmapSetID = Integer.parseInt(tokens[1]);
break;
}
}
break;
case "[Difficulty]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
tokens = tokenize(line);
switch (tokens[0]) {
case "HPDrainRate":
osu.HPDrainRate = Byte.parseByte(tokens[1]);
break;
case "CircleSize":
osu.circleSize = Byte.parseByte(tokens[1]);
break;
case "OverallDifficulty":
osu.overallDifficulty = Byte.parseByte(tokens[1]);
break;
case "ApproachRate":
osu.approachRate = Byte.parseByte(tokens[1]);
break;
case "SliderMultiplier":
osu.sliderMultiplier = Float.parseFloat(tokens[1]);
break;
case "SliderTickRate":
osu.sliderTickRate = Float.parseFloat(tokens[1]);
break;
}
}
if (osu.approachRate == -1) // not in old format
osu.approachRate = osu.overallDifficulty;
break;
case "[Events]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
tokens = line.split(",");
switch (tokens[0]) {
case "0": // background
tokens[2] = tokens[2].replaceAll("^\"|\"$", "");
String ext = OsuParser.getExtension(tokens[2]);
if (ext.equals("jpg") || ext.equals("png"))
osu.bg = file.getParent() + File.separator + tokens[2];
break;
case "2": // break periods
if (osu.breaks == null) // optional, create if needed
osu.breaks = new ArrayList<Integer>();
osu.breaks.add(Integer.parseInt(tokens[1]));
osu.breaks.add(Integer.parseInt(tokens[2]));
break;
default:
/* Not implemented. */
break;
}
}
break;
case "[TimingPoints]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
tokens = line.split(",");
OsuTimingPoint timingPoint = new OsuTimingPoint();
try { // newer file versions have many new fields
timingPoint.time = (int) Float.parseFloat(tokens[0]); //rare float
timingPoint.meter = Integer.parseInt(tokens[2]);
timingPoint.sampleType = Byte.parseByte(tokens[3]);
timingPoint.sampleTypeCustom = Byte.parseByte(tokens[4]);
timingPoint.sampleVolume = Integer.parseInt(tokens[5]);
timingPoint.inherited = (Integer.parseInt(tokens[6]) == 1);
timingPoint.kiai = (Integer.parseInt(tokens[7]) == 1);
} catch (ArrayIndexOutOfBoundsException e) {
// TODO: better support for old formats
// Log.error(String.format("Error while parsing TimingPoints, line: '%s'.", line), e);
}
// tokens[1] is either beatLength (positive) or velocity (negative)
float beatLength = Float.parseFloat(tokens[1]);
if (beatLength > 0) {
timingPoint.beatLength = beatLength;
int bpm = Math.round(60000 / beatLength);
if (osu.bpmMin == 0)
osu.bpmMin = osu.bpmMax = bpm;
else if (bpm < osu.bpmMin)
osu.bpmMin = bpm;
else if (bpm > osu.bpmMax)
osu.bpmMax = bpm;
} else
timingPoint.velocity = (int) beatLength;
osu.timingPoints.add(timingPoint);
}
break;
case "[Colours]":
LinkedList<Color> colors = new LinkedList<Color>();
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
tokens = tokenize(line);
switch (tokens[0]) {
case "Combo1":
case "Combo2":
case "Combo3":
case "Combo4":
case "Combo5":
case "Combo6":
case "Combo7":
case "Combo8":
String[] rgb = tokens[1].split(",");
colors.add(new Color(
Integer.parseInt(rgb[0]),
Integer.parseInt(rgb[1]),
Integer.parseInt(rgb[2])
));
default:
break;
}
}
if (!colors.isEmpty())
osu.combo = colors.toArray(new Color[colors.size()]);
break;
case "[HitObjects]":
while ((line = in.readLine()) != null) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
/* Only type counts parsed at this time. */
tokens = line.split(",");
int type = Integer.parseInt(tokens[3]);
if ((type & OsuHitObject.TYPE_CIRCLE) > 0)
osu.hitObjectCircle++;
else if ((type & OsuHitObject.TYPE_SLIDER) > 0)
osu.hitObjectSlider++;
else //if ((type & OsuHitObject.TYPE_SPINNER) > 0)
osu.hitObjectSpinner++;
}
break;
default:
line = in.readLine().trim();
break;
}
}
} catch (IOException e) {
Log.error(String.format("Failed to read file '%s'.", file.getAbsolutePath()), e);
}
// if no custom colors, use the default color scheme
if (osu.combo == null)
osu.combo = Options.DEFAULT_COMBO;
// add tags
if (!tags.isEmpty()) {
for (String tag : tags.split(" "))
Opsu.groups.addTag(tag, osuFiles);
}
// parse hit objects now?
if (parseObjects)
parseHitObjects(osu);
// add OsuFile to song group
osuFiles.add(osu);
return osu;
}
/**
* Parses all hit objects in an OSU file.
*
* Object formats:
* - Circles [1]:
* x,y,time,type,hitSound,addition
* 256,148,9466,1,2,0:0:0:0:
*
* - Sliders [2]:
* x,y,time,type,hitSound,sliderType|curveX:curveY|...,repeat,pixelLength,edgeHitsound,edgeAddition,addition
* 300,68,4591,2,0,B|372:100|332:172|420:192,2,180,2|2|2,0:0|0:0|0:0,0:0:0:0:
*
* - Spinners [8]:
* x,y,time,type,hitSound,endTime,addition
* 256,192,654,12,0,4029,0:0:0:0:
*
* Notes:
* - 'addition' is optional, and defaults to "0:0:0:0:".
* - Field descriptions are located in OsuHitObject.java.
*/
public static void parseHitObjects(OsuFile osu) {
if (osu.objects != null) // already parsed
return;
osu.objects = new OsuHitObject[(osu.hitObjectCircle
+ osu.hitObjectSlider + osu.hitObjectSpinner)];
try (BufferedReader in = new BufferedReader(new FileReader(osu.getFile()))) {
String line = in.readLine();
while (line != null) {
line = line.trim();
if (!line.equals("[HitObjects]")) {
line = in.readLine();
continue;
}
int i = 0; // object index
int comboIndex = 0; // color index
int comboNumber = 1; // combo number
String tokens[], sliderTokens[], sliderXY[];
while ((line = in.readLine()) != null && i < osu.objects.length) {
line = line.trim();
if (!isValidLine(line))
continue;
if (line.charAt(0) == '[')
break;
// create a new OsuHitObject for each line
tokens = line.split(",");
float scaledX = Integer.parseInt(tokens[0]) * xMultiplier + xOffset;
float scaledY = Integer.parseInt(tokens[1]) * yMultiplier + yOffset;
int type = Integer.parseInt(tokens[3]);
osu.objects[i] = new OsuHitObject(
scaledX, scaledY, Integer.parseInt(tokens[2]),
type, Byte.parseByte(tokens[4])
);
if ((type & OsuHitObject.TYPE_CIRCLE) > 0) {
/* 'addition' not implemented. */
} else if ((type & OsuHitObject.TYPE_SLIDER) > 0) {
// slider curve type and coordinates
sliderTokens = tokens[5].split("\\|");
osu.objects[i].sliderType = sliderTokens[0].charAt(0);
osu.objects[i].sliderX = new float[sliderTokens.length-1];
osu.objects[i].sliderY = new float[sliderTokens.length-1];
for (int j = 1; j < sliderTokens.length; j++) {
sliderXY = sliderTokens[j].split(":");
osu.objects[i].sliderX[j-1] = Integer.parseInt(sliderXY[0]) * xMultiplier + xOffset;
osu.objects[i].sliderY[j-1] = Integer.parseInt(sliderXY[1]) * yMultiplier + yOffset;
}
osu.objects[i].repeat = Integer.parseInt(tokens[6]);
osu.objects[i].pixelLength = Float.parseFloat(tokens[7]);
/* edge fields and 'addition' not implemented. */
} else { //if ((type & OsuHitObject.TYPE_SPINNER) > 0) {
// some 'endTime' fields contain a ':' character (?)
int index = tokens[5].indexOf(':');
if (index != -1)
tokens[5] = tokens[5].substring(0, index);
osu.objects[i].endTime = Integer.parseInt(tokens[5]);
/* 'addition' not implemented. */
}
// set combo info
// - new combo: get next combo index, reset combo number
// - else: maintain combo index, increase combo number
if ((osu.objects[i].type & OsuHitObject.TYPE_NEWCOMBO) > 0) {
comboIndex = (comboIndex + 1) % osu.combo.length;
comboNumber = 1;
}
osu.objects[i].comboIndex = comboIndex;
osu.objects[i].comboNumber = comboNumber++;
i++;
}
break;
}
} catch (IOException e) {
Log.error(String.format("Failed to read file '%s'.", osu.getFile().getAbsolutePath()), e);
}
}
/**
* 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.
*/
private static String[] tokenize(String line) {
String[] tokens = new String[2];
try {
int index = line.indexOf(':');
tokens[0] = line.substring(0, index).trim();
tokens[1] = line.substring(index + 1).trim();
} catch (java.lang.StringIndexOutOfBoundsException e) {
Log.error(String.format("Failed to tokenize line: '%s'.", line), e);
}
return tokens;
}
/**
* Returns the file extension of a file.
*/
public static String getExtension(String file) {
int i = file.lastIndexOf('.');
return (i > -1) ? file.substring(i + 1).toLowerCase() : "";
}
}

View File

@@ -0,0 +1,39 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu;
/**
* Data type representing a timing point.
*/
public class OsuTimingPoint {
public int time; // start time/offset (in ms)
public float beatLength; // (non-inherited) ms per beat
public int velocity = 0; // (inherited) slider multiplier = -100 / value
public int meter; // beats per measure
public byte sampleType; // sound samples (0:none, 1:normal, 2:soft)
public byte sampleTypeCustom; // custom samples (0:default, 1:custom1, 2:custom2
public int sampleVolume; // volume of samples (0~100)
public boolean inherited; // is this timing point inherited?
public boolean kiai; // is Kiai Mode active?
/**
* Constructor.
*/
public OsuTimingPoint() {}
}

View File

@@ -0,0 +1,214 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.objects;
import itdelatrisu.opsu.GameScore;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.OsuHitObject;
import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.states.Options;
import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
/**
* Data type representing a circle object.
*/
public class Circle {
/**
* Images related to hit circles.
*/
private static Image
hitCircle, // hit circle
hitCircleOverlay, // hit circle overlay
approachCircle; // approach circle
/**
* The associated OsuHitObject.
*/
private OsuHitObject hitObject;
/**
* The associated Game object.
*/
private Game game;
/**
* The associated GameScore object.
*/
private GameScore score;
/**
* The color of this circle.
*/
private Color color;
/**
* Whether or not the circle result ends the combo streak.
*/
private boolean comboEnd;
/**
* Initializes the Circle data type with map modifiers, images, and dimensions.
* @param container the game container
* @param circleSize the map's circleSize value
* @throws SlickException
*/
public static void init(GameContainer container, byte circleSize) throws SlickException {
int diameter = 96 - (circleSize * 8);
diameter = diameter * container.getWidth() / 640; // convert from Osupixels (640x480)
hitCircle = new Image("hitcircle.png").getScaledCopy(diameter, diameter);
hitCircleOverlay = new Image("hitcircleoverlay.png").getScaledCopy(diameter, diameter);
approachCircle = new Image("approachcircle.png").getScaledCopy(diameter, diameter);
}
/**
* Returns the hit circle image.
*/
public static Image getHitCircle() { return hitCircle; }
/**
* Returns the hit circle overlay image.
*/
public static Image getHitCircleOverlay() { return hitCircleOverlay; }
/**
* Returns the approach circle image.
*/
public static Image getApproachCircle() { return approachCircle; }
/**
* Constructor.
* @param hitObject the associated OsuHitObject
* @param game the associated Game object
* @param score the associated GameScore object
* @param color the color of this circle
* @param comboEnd true if this is the last hit object in the combo
*/
public Circle(OsuHitObject hitObject, Game game, GameScore score, Color color, boolean comboEnd) {
this.hitObject = hitObject;
this.game = game;
this.score = score;
this.color = color;
this.comboEnd = comboEnd;
}
/**
* Draws the circle to the graphics context.
* @param trackPosition the current track position
*/
public void draw(int trackPosition) {
int timeDiff = hitObject.time - trackPosition;
if (timeDiff >= 0) {
float approachScale = 1 + (timeDiff * 2f / game.getApproachTime());
drawCentered(approachCircle.getScaledCopy(approachScale), hitObject.x, hitObject.y, color);
drawCentered(hitCircleOverlay, hitObject.x, hitObject.y, Color.white);
drawCentered(hitCircle, hitObject.x, hitObject.y, color);
score.drawSymbolNumber(hitObject.comboNumber, hitObject.x, hitObject.y,
hitCircle.getWidth() * 0.40f / score.getDefaultSymbolImage(0).getHeight());
}
}
/**
* Draws an image based on its center with a color filter.
*/
private void drawCentered(Image img, float x, float y, Color color) {
img.draw(x - (img.getWidth() / 2f), y - (img.getHeight() / 2f), color);
}
/**
* Calculates the circle hit result.
* @param time the hit object time (difference between track time)
* @return the hit result (GameScore.HIT_* constants)
*/
public int hitResult(int time) {
int trackPosition = MusicController.getPosition();
int timeDiff = Math.abs(trackPosition - time);
int[] hitResultOffset = game.getHitResultOffsets();
int result = -1;
if (timeDiff < hitResultOffset[GameScore.HIT_300])
result = GameScore.HIT_300;
else if (timeDiff < hitResultOffset[GameScore.HIT_100])
result = GameScore.HIT_100;
else if (timeDiff < hitResultOffset[GameScore.HIT_50])
result = GameScore.HIT_50;
else if (timeDiff < hitResultOffset[GameScore.HIT_MISS])
result = GameScore.HIT_MISS;
//else not a hit
return result;
}
/**
* Processes a mouse click.
* @param x the x coordinate of the mouse
* @param y the y coordinate of the mouse
* @param comboEnd if this is the last object in the combo
* @return true if a hit result was processed
*/
public boolean mousePressed(int x, int y) {
double distance = Math.hypot(hitObject.x - x, hitObject.y - y);
int circleRadius = hitCircle.getWidth() / 2;
if (distance < circleRadius) {
int result = hitResult(hitObject.time);
if (result > -1) {
score.hitResult(hitObject.time, result, hitObject.x, hitObject.y, color, comboEnd);
return true;
}
}
return false;
}
/**
* Updates the circle object.
* @param overlap true if the next object's start time has already passed
* @return true if a hit result (miss) was processed
*/
public boolean update(boolean overlap) {
int trackPosition = MusicController.getPosition();
int[] hitResultOffset = game.getHitResultOffsets();
boolean isAutoMod = Options.isModActive(Options.MOD_AUTO);
if (overlap || trackPosition > hitObject.time + hitResultOffset[GameScore.HIT_50]) {
if (isAutoMod) // "auto" mod: catch any missed notes due to lag
score.hitResult(hitObject.time, GameScore.HIT_300,
hitObject.x, hitObject.y, color, comboEnd);
else // no more points can be scored, so send a miss
score.hitResult(hitObject.time, GameScore.HIT_MISS,
hitObject.x, hitObject.y, null, comboEnd);
return true;
}
// "auto" mod: send a perfect hit result
else if (isAutoMod) {
if (Math.abs(trackPosition - hitObject.time) < hitResultOffset[GameScore.HIT_300]) {
score.hitResult(hitObject.time, GameScore.HIT_300,
hitObject.x, hitObject.y, color, comboEnd);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,581 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.objects;
import itdelatrisu.opsu.GameScore;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.OsuFile;
import itdelatrisu.opsu.OsuHitObject;
import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.states.Options;
import org.newdawn.slick.Animation;
import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
/**
* Data type representing a slider object.
*/
public class Slider {
/**
* Images related to sliders.
*/
private static Image
sliderFollowCircle, // slider follow circle
reverseArrow, // reverse arrow (for repeats)
sliderTick; // slider tick
/**
* Slider ball animation.
*/
private static Animation sliderBall;
/**
* Slider movement speed multiplier.
*/
private static float sliderMultiplier = 1.0f;
/**
* Rate at which slider ticks are placed.
*/
private static float sliderTickRate = 1.0f;
/**
* The associated OsuHitObject.
*/
private OsuHitObject hitObject;
/**
* The associated Game object.
*/
private Game game;
/**
* The associated GameScore object.
*/
private GameScore score;
/**
* The color of this slider.
*/
private Color color;
/**
* The underlying Bezier object.
*/
private Bezier bezier;
/**
* The time duration of the slider, in milliseconds.
*/
private float sliderTime = 0f;
/**
* The time duration of the slider including repeats, in milliseconds.
*/
private float sliderTimeTotal = 0f;
/**
* Whether or not the result of the initial hit circle has been processed.
*/
private boolean sliderClicked = false;
/**
* Whether or not to show the follow circle.
*/
private boolean followCircleActive = false;
/**
* Whether or not the slider result ends the combo streak.
*/
private boolean comboEnd;
/**
* The number of repeats that have passed so far.
*/
private int currentRepeats = 0;
/**
* The t values of the slider ticks.
*/
private float[] ticksT;
/**
* The tick index in the ticksT[] array.
*/
private int tickIndex = 0;
/**
* Number of ticks hit and tick intervals so far.
*/
private int ticksHit = 0, tickIntervals = 1;
/**
* Representation of a Bezier curve, the main component of a slider.
*
* @author Alex Gheorghiu (http://html5tutorial.com/how-to-draw-n-grade-bezier-curve-with-canvas-api/)
* @author pictuga (https://github.com/pictuga/osu-web)
*/
private class Bezier {
/**
* The order of the Bezier curve.
*/
private int order;
/**
* The step size (used for drawing),
*/
private float step;
/**
* The curve points for drawing with step size given by 'step'.
*/
private float[] curveX, curveY;
/**
* Constructor.
*/
public Bezier(float length) {
this.order = hitObject.sliderX.length + 1;
this.step = 5 / length;
// calculate curve points for drawing
int N = (int) (1 / step);
this.curveX = new float[N];
this.curveY = new float[N];
float t = 0f;
for (int i = 0; i < N; i++, t += step) {
float[] c = pointAt(t);
curveX[i] = c[0];
curveY[i] = c[1];
}
}
/**
* Returns the x coordinate of the control point at index i.
*/
private float getX(int i) {
return (i == 0) ? hitObject.x : hitObject.sliderX[i - 1];
}
/**
* Returns the y coordinate of the control point at index i.
*/
private float getY(int i) {
return (i == 0) ? hitObject.y : hitObject.sliderY[i - 1];
}
/**
* Calculates the factorial of a number.
*/
private long factorial(int n) {
return (n <= 1 || n > 20) ? 1 : n * factorial(n - 1);
}
/**
* Calculates the Bernstein polynomial.
* @param i the index
* @param n the degree of the polynomial (i.e. number of points)
* @param t the t value [0, 1]
*/
private double bernstein(int i, int n, float t) {
return factorial(n) / (factorial(i) * factorial(n-i)) *
Math.pow(t, i) * Math.pow(1-t, n-i);
}
/**
* Returns the point on the Bezier curve at a value t.
* For curves of order greater than 4, points will be generated along
* a path of overlapping cubic (at most) Beziers.
* @param t the t value [0, 1]
* @return the point [x, y]
*/
public float[] pointAt(float t) {
float[] c = { 0f, 0f };
int n = order - 1;
if (n < 4) { // normal curve
for (int i = 0; i <= n; i++) {
c[0] += getX(i) * bernstein(i, n, t);
c[1] += getY(i) * bernstein(i, n, t);
}
} else { // split curve into path
// TODO: this is probably wrong...
int segmentCount = (n / 3) + 1;
int segment = (int) Math.floor(t * segmentCount);
int startIndex = 3 * segment;
int segmentOrder = Math.min(startIndex + 3, n) - startIndex;
float segmentT = (t * segmentCount) - segment;
for (int i = 0; i <= segmentOrder; i++) {
c[0] += getX(i + startIndex) * bernstein(i, segmentOrder, segmentT);
c[1] += getY(i + startIndex) * bernstein(i, segmentOrder, segmentT);
}
}
return c;
}
/**
* Draws the full Bezier curve to the graphics context.
*/
public void draw() {
Image hitCircle = Circle.getHitCircle();
Image hitCircleOverlay = Circle.getHitCircleOverlay();
// draw overlay and hit circle
for (int i = curveX.length - 1; i >= 0; i--)
drawCentered(hitCircleOverlay, curveX[i], curveY[i], Color.white);
for (int i = curveX.length - 1; i >= 0; i--)
drawCentered(hitCircle, curveX[i], curveY[i], color);
}
}
/**
* Initializes the Slider data type with images and dimensions.
* @param container the game container
* @param circleSize the map's circleSize value
* @param osu the associated OsuFile object
* @throws SlickException
*/
public static void init(GameContainer container, byte circleSize, OsuFile osu) throws SlickException {
int diameter = 96 - (circleSize * 8);
diameter = diameter * container.getWidth() / 640; // convert from Osupixels (640x480)
sliderBall = new Animation();
for (int i = 0; i <= 9; i++)
sliderBall.addFrame(new Image(String.format("sliderb%d.png", i)).getScaledCopy(diameter * 118 / 128, diameter * 118 / 128), 60);
sliderFollowCircle = new Image("sliderfollowcircle.png").getScaledCopy(diameter * 259 / 128, diameter * 259 / 128);
reverseArrow = new Image("reversearrow.png").getScaledCopy(diameter, diameter);
sliderTick = new Image("sliderscorepoint.png").getScaledCopy(diameter / 4, diameter / 4);
sliderMultiplier = osu.sliderMultiplier;
sliderTickRate = osu.sliderTickRate;
}
/**
* Constructor.
* @param hitObject the associated OsuHitObject
* @param game the associated Game object
* @param score the associated GameScore object
* @param color the color of this circle
* @param comboEnd true if this is the last hit object in the combo
*/
public Slider(OsuHitObject hitObject, Game game, GameScore score, Color color, boolean comboEnd) {
this.hitObject = hitObject;
this.game = game;
this.score = score;
this.color = color;
this.comboEnd = comboEnd;
this.bezier = new Bezier(hitObject.pixelLength);
// calculate slider time and ticks upon first update call
}
/**
* Draws the slider to the graphics context.
* @param trackPosition the current track position
* @param currentObject true if this is the current hit object
*/
public void draw(int trackPosition, boolean currentObject) {
int timeDiff = hitObject.time - trackPosition;
Image hitCircleOverlay = Circle.getHitCircleOverlay();
Image hitCircle = Circle.getHitCircle();
// bezier
bezier.draw();
// ticks
if (currentObject && ticksT != null) {
for (int i = 0; i < ticksT.length; i++) {
float[] c = bezier.pointAt(ticksT[i]);
sliderTick.drawCentered(c[0], c[1]);
}
}
// end circle
int lastIndex = hitObject.sliderX.length - 1;
drawCentered(hitCircleOverlay, hitObject.sliderX[lastIndex], hitObject.sliderY[lastIndex], Color.white);
drawCentered(hitCircle, hitObject.sliderX[lastIndex], hitObject.sliderY[lastIndex], color);
// start circle
drawCentered(hitCircleOverlay, hitObject.x, hitObject.y, Color.white);
drawCentered(hitCircle, hitObject.x, hitObject.y, color);
if (sliderClicked)
; // don't draw current combo number if already clicked
else
score.drawSymbolNumber(hitObject.comboNumber, hitObject.x, hitObject.y,
hitCircle.getWidth() * 0.40f / score.getDefaultSymbolImage(0).getHeight());
// repeats
if (hitObject.repeat - 1 > currentRepeats) {
if (currentRepeats % 2 == 0) // last circle
reverseArrow.drawCentered(hitObject.sliderX[lastIndex], hitObject.sliderY[lastIndex]);
else // first circle
reverseArrow.drawCentered(hitObject.x, hitObject.y);
}
if (timeDiff >= 0) {
// approach circle
float approachScale = 1 + (timeDiff * 2f / game.getApproachTime());
drawCentered(Circle.getApproachCircle().getScaledCopy(approachScale), hitObject.x, hitObject.y, color);
} else {
float[] c = bezier.pointAt(getT(trackPosition, false));
// slider ball
drawCentered(sliderBall, c[0], c[1]);
// follow circle
if (followCircleActive)
sliderFollowCircle.drawCentered(c[0], c[1]);
}
}
/**
* Draws an image based on its center with a color filter.
*/
private void drawCentered(Image img, float x, float y, Color color) {
img.draw(x - (img.getWidth() / 2f), y - (img.getHeight() / 2f), color);
}
/**
* Draws an animation based on its center with a color filter.
*/
private static void drawCentered(Animation anim, float x, float y) {
anim.draw(x - (anim.getWidth() / 2f), y - (anim.getHeight() / 2f));
}
/**
* Calculates the slider hit result.
* @param time the hit object time (difference between track time)
* @param lastCircleHit true if the cursor was held within the last circle
* @return the hit result (GameScore.HIT_* constants)
*/
public int hitResult() {
int lastIndex = hitObject.sliderX.length - 1;
float tickRatio = (float) ticksHit / tickIntervals;
int result;
if (tickRatio >= 1.0f)
result = GameScore.HIT_300;
else if (tickRatio >= 0.5f)
result = GameScore.HIT_100;
else if (tickRatio > 0f)
result = GameScore.HIT_50;
else
result = GameScore.HIT_MISS;
if (currentRepeats % 2 == 0) // last circle
score.hitResult(hitObject.time + (int) sliderTimeTotal, result,
hitObject.sliderX[lastIndex], hitObject.sliderY[lastIndex], color, comboEnd);
else // first circle
score.hitResult(hitObject.time + (int) sliderTimeTotal, result,
hitObject.x, hitObject.y, color, comboEnd);
return result;
}
/**
* Processes a mouse click.
* @param x the x coordinate of the mouse
* @param y the y coordinate of the mouse
* @param comboEnd if this is the last object in the combo
* @return true if a hit result was processed
*/
public boolean mousePressed(int x, int y) {
if (sliderClicked) // first circle already processed
return false;
double distance = Math.hypot(hitObject.x - x, hitObject.y - y);
int circleRadius = Circle.getHitCircle().getWidth() / 2;
if (distance < circleRadius) {
int trackPosition = MusicController.getPosition();
int timeDiff = Math.abs(trackPosition - hitObject.time);
int[] hitResultOffset = game.getHitResultOffsets();
int result = -1;
if (timeDiff < hitResultOffset[GameScore.HIT_50]) {
result = GameScore.HIT_SLIDER30;
ticksHit++;
} else if (timeDiff < hitResultOffset[GameScore.HIT_MISS])
result = GameScore.HIT_MISS;
//else not a hit
if (result > -1) {
sliderClicked = true;
score.sliderTickResult(hitObject.time, result, hitObject.x, hitObject.y);
return true;
}
}
return false;
}
/**
* Updates the slider object.
* @param overlap true if the next object's start time has already passed
* @param delta the delta interval since the last call
* @param mouseX the x coordinate of the mouse
* @param mouseY the y coordinate of the mouse
* @return true if slider ended
*/
public boolean update(boolean overlap, int delta, int mouseX, int mouseY) {
// slider time and tick calculations
if (sliderTimeTotal == 0f) {
// slider time
this.sliderTime = game.getBeatLength() * (hitObject.pixelLength / sliderMultiplier) / 100f;
this.sliderTimeTotal = sliderTime * hitObject.repeat;
// ticks
float tickLengthDiv = 100f * sliderMultiplier / sliderTickRate / game.getTimingPointMultiplier();
int tickCount = (int) Math.ceil(hitObject.pixelLength / tickLengthDiv) - 1;
if (tickCount > 0) {
this.ticksT = new float[tickCount];
float tickTOffset = 1f / (tickCount + 1);
float t = tickTOffset;
for (int i = 0; i < tickCount; i++, t += tickTOffset)
ticksT[i] = t;
}
}
int trackPosition = MusicController.getPosition();
int[] hitResultOffset = game.getHitResultOffsets();
int lastIndex = hitObject.sliderX.length - 1;
boolean isAutoMod = Options.isModActive(Options.MOD_AUTO);
if (!sliderClicked) {
// start circle time passed
if (trackPosition > hitObject.time + hitResultOffset[GameScore.HIT_50]) {
sliderClicked = true;
if (isAutoMod) { // "auto" mod: catch any missed notes due to lag
ticksHit++;
score.sliderTickResult(hitObject.time, GameScore.HIT_SLIDER30, hitObject.x, hitObject.y);
} else
score.sliderTickResult(hitObject.time, GameScore.HIT_MISS, hitObject.x, hitObject.y);
}
// "auto" mod: send a perfect hit result
else if (isAutoMod) {
if (Math.abs(trackPosition - hitObject.time) < hitResultOffset[GameScore.HIT_300]) {
ticksHit++;
sliderClicked = true;
score.sliderTickResult(hitObject.time, GameScore.HIT_SLIDER30, hitObject.x, hitObject.y);
}
}
}
// end of slider
if (overlap || trackPosition > hitObject.time + sliderTimeTotal) {
tickIntervals++;
// "auto" mod: send a perfect hit result
if (isAutoMod)
ticksHit++;
// check if cursor pressed and within end circle
else if (game.isInputKeyPressed()) {
double distance = Math.hypot(hitObject.sliderX[lastIndex] - mouseX, hitObject.sliderY[lastIndex] - mouseY);
int followCircleRadius = sliderFollowCircle.getWidth() / 2;
if (distance < followCircleRadius)
ticksHit++;
}
// calculate and send slider result
hitResult();
return true;
}
// repeats
boolean isNewRepeat = false;
if (hitObject.repeat - 1 > currentRepeats) {
float t = getT(trackPosition, true);
if (Math.floor(t) > currentRepeats) {
currentRepeats++;
tickIntervals++;
isNewRepeat = true;
}
}
// ticks
boolean isNewTick = false;
if (ticksT != null &&
tickIntervals < (ticksT.length * (currentRepeats + 1)) + hitObject.repeat &&
tickIntervals < (ticksT.length * hitObject.repeat) + hitObject.repeat) {
float t = getT(trackPosition, true);
if (t - Math.floor(t) >= ticksT[tickIndex]) {
tickIntervals++;
tickIndex = (tickIndex + 1) % ticksT.length;
isNewTick = true;
}
}
// holding slider...
float[] c = bezier.pointAt(getT(trackPosition, false));
double distance = Math.hypot(c[0] - mouseX, c[1] - mouseY);
int followCircleRadius = sliderFollowCircle.getWidth() / 2;
if ((game.isInputKeyPressed() && distance < followCircleRadius) || isAutoMod) {
// mouse pressed and within follow circle
followCircleActive = true;
score.changeHealth(delta / 200f);
// held during new repeat
if (isNewRepeat) {
ticksHit++;
if (currentRepeats % 2 > 0) // last circle
score.sliderTickResult(trackPosition, GameScore.HIT_SLIDER30,
hitObject.sliderX[lastIndex], hitObject.sliderY[lastIndex]);
else // first circle
score.sliderTickResult(trackPosition, GameScore.HIT_SLIDER30,
c[0], c[1]);
}
// held during new tick
if (isNewTick) {
ticksHit++;
score.sliderTickResult(trackPosition, GameScore.HIT_SLIDER10, c[0], c[1]);
}
} else {
followCircleActive = false;
if (isNewRepeat)
score.sliderTickResult(trackPosition, GameScore.HIT_MISS, 0, 0);
if (isNewTick)
score.sliderTickResult(trackPosition, GameScore.HIT_MISS, 0, 0);
}
return false;
}
/**
* Returns the t value based on the given track position.
* @param trackPosition the current track position
* @param raw if false, ensures that the value lies within [0, 1] by looping repeats
* @return the t value: raw [0, repeats] or looped [0, 1]
*/
public float getT(int trackPosition, boolean raw) {
float t = (trackPosition - hitObject.time) / sliderTime;
if (raw)
return t;
else {
float floor = (float) Math.floor(t);
return (floor % 2 == 0) ? t - floor : floor + 1 - t;
}
}
}

View File

@@ -0,0 +1,251 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.objects;
import itdelatrisu.opsu.GameScore;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.OsuHitObject;
import itdelatrisu.opsu.states.Game;
import itdelatrisu.opsu.states.Options;
import org.newdawn.slick.Color;
import org.newdawn.slick.GameContainer;
import org.newdawn.slick.Graphics;
import org.newdawn.slick.Image;
import org.newdawn.slick.SlickException;
/**
* Data type representing a spinner object.
*/
public class Spinner {
/**
* Images related to spinners.
*/
private static Image
spinnerCircle, // spinner
spinnerApproachCircle, // spinner approach circle (for end time)
spinnerMetre, // spinner meter (subimage based on completion ratio)
// spinnerOsuImage, // spinner "OSU!" text (complete)
spinnerSpinImage, // spinner "SPIN!" text (start)
spinnerClearImage; // spinner "CLEAR" text (passed)
/**
* Container dimensions.
*/
private static int width, height;
/**
* The associated OsuHitObject.
*/
private OsuHitObject hitObject;
/**
* The associated Game object.
*/
private Game game;
/**
* The associated GameScore object.
*/
private GameScore score;
/**
* The last rotation angle.
*/
private float lastAngle = -1f;
/**
* The current number of rotations.
*/
private float rotations = 0f;
/**
* The total number of rotations needed to clear the spinner.
*/
private float rotationsNeeded;
/**
* Initializes the Spinner data type with images and dimensions.
* @param container the game container
* @throws SlickException
*/
public static void init(GameContainer container) throws SlickException {
width = container.getWidth();
height = container.getHeight();
spinnerCircle = new Image("spinner-circle.png").getScaledCopy(height * 9 / 10, height * 9 / 10);
spinnerApproachCircle = new Image("spinner-approachcircle.png").getScaledCopy(spinnerCircle.getWidth(), spinnerCircle.getHeight());
spinnerMetre = new Image("spinner-metre.png").getScaledCopy(width, height);
spinnerSpinImage = new Image("spinner-spin.png");
spinnerClearImage = new Image("spinner-clear.png");
// spinnerOsuImage = new Image("spinner-osu.png");
}
/**
* Constructor.
* @param hitObject the associated OsuHitObject
* @param game the associated Game object
* @param score the associated GameScore object
*/
public Spinner(OsuHitObject hitObject, Game game, GameScore score) {
this.hitObject = hitObject;
this.game = game;
this.score = score;
// calculate rotations needed
int spinsPerMinute = 100 + (score.getDifficulty() * 15);
rotationsNeeded = (float) spinsPerMinute * (hitObject.endTime - hitObject.time) / 60000f;
}
/**
* Draws the spinner to the graphics context.
* @param trackPosition the current track position
* @param g the graphics context
*/
public void draw(int trackPosition, Graphics g) {
int timeDiff = hitObject.time - trackPosition;
boolean spinnerComplete = (rotations >= rotationsNeeded);
// TODO: draw "OSU!" image after spinner ends
//spinnerOsuImage.drawCentered(width / 2, height / 4);
// darken screen
g.setColor(Options.COLOR_BLACK_ALPHA);
g.fillRect(0, 0, width, height);
if (timeDiff > 0)
return;
// spinner meter (subimage)
int spinnerMetreY = (spinnerComplete) ? 0 : (int) (spinnerMetre.getHeight() * (1 - (rotations / rotationsNeeded)));
Image spinnerMetreSub = spinnerMetre.getSubImage(
0, spinnerMetreY,
spinnerMetre.getWidth(), spinnerMetre.getHeight() - spinnerMetreY
);
spinnerMetreSub.draw(0, height - spinnerMetreSub.getHeight());
// main spinner elements
spinnerCircle.drawCentered(width / 2, height / 2);
spinnerApproachCircle.getScaledCopy(1 - ((float) timeDiff / (hitObject.time - hitObject.endTime))).drawCentered(width / 2, height / 2);
spinnerSpinImage.drawCentered(width / 2, height * 3 / 4);
if (spinnerComplete) {
spinnerClearImage.drawCentered(width / 2, height / 4);
int extraRotations = (int) (rotations - rotationsNeeded);
if (extraRotations > 0)
score.drawSymbolNumber(extraRotations * 1000, width / 2, height * 2 / 3, 1.0f);
}
}
/**
* Calculates and sends the spinner hit result.
* @return the hit result (GameScore.HIT_* constants)
*/
public int hitResult() {
// TODO: verify ratios
int result;
float ratio = rotations / rotationsNeeded;
if (ratio >= 1.0f ||
Options.isModActive(Options.MOD_AUTO) || Options.isModActive(Options.MOD_SPUN_OUT))
result = GameScore.HIT_300;
else if (ratio >= 0.8f)
result = GameScore.HIT_100;
else if (ratio >= 0.5f)
result = GameScore.HIT_50;
else
result = GameScore.HIT_MISS;
score.hitResult(hitObject.endTime, result, width / 2, height / 2, Color.transparent, true);
return result;
}
/**
* Updates the spinner by a delta interval.
* @param overlap true if the next object's start time has already passed
* @param delta the delta interval since the last call
* @param mouseX the x coordinate of the mouse
* @param mouseY the y coordinate of the mouse
* @return true if spinner ended
*/
public boolean update(boolean overlap, int delta, int mouseX, int mouseY) {
int trackPosition = MusicController.getPosition();
if (overlap)
return true;
// end of spinner
if (trackPosition > hitObject.endTime) {
hitResult();
return true;
}
// spin automatically (TODO: correct rotation angles)
if (Options.isModActive(Options.MOD_AUTO)) {
// "auto" mod (fast)
score.changeHealth(delta / 200f); // maintain health (TODO)
rotate(delta / 20f);
return false;
} else if (Options.isModActive(Options.MOD_SPUN_OUT)) {
// "spun out" mod (slow)
score.changeHealth(delta / 200f); // maintain health (TODO)
rotate(delta / 32f);
return false;
}
// not spinning: nothing to do
if (!game.isInputKeyPressed()) {
lastAngle = -1f;
return false;
}
// scale angle from [-pi, +pi] to [0, +pi]
float angle = (float) Math.atan2(mouseY - (height / 2), mouseX - (width / 2));
if (angle < 0f)
angle += Math.PI;
if (lastAngle >= 0f) { // skip initial clicks
float angleDiff = Math.abs(lastAngle - angle);
if (angleDiff < Math.PI / 2) { // skip huge angle changes...
score.changeHealth(delta / 200f); // maintain health (TODO)
rotate(angleDiff);
}
}
lastAngle = angle;
return false;
}
/**
* Rotates the spinner by a number of degrees.
* @param degrees the angle to rotate (in radians)
*/
private void rotate(float degrees) {
float newRotations = rotations + (degrees / (float) (2 * Math.PI));
// added one whole rotation...
if (Math.floor(newRotations) > rotations) {
if (newRotations > rotationsNeeded) // extra rotations
score.changeScore(1000);
else
score.changeScore(100);
}
rotations = newRotations;
}
}

View File

@@ -0,0 +1,774 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.GameScore;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.Opsu;
import itdelatrisu.opsu.OsuFile;
import itdelatrisu.opsu.OsuHitObject;
import itdelatrisu.opsu.OsuTimingPoint;
import itdelatrisu.opsu.objects.Circle;
import itdelatrisu.opsu.objects.Slider;
import itdelatrisu.opsu.objects.Spinner;
import java.util.HashMap;
import java.util.Stack;
import org.lwjgl.input.Keyboard;
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;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.EmptyTransition;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
import org.newdawn.slick.util.Log;
/**
* "Game" state.
*/
public class Game extends BasicGameState {
/**
* Game restart states.
*/
public static final byte
RESTART_FALSE = 0,
RESTART_NEW = 1, // first time loading song
RESTART_MANUAL = 2, // retry
RESTART_LOSE = 3; // health is zero: no-continue/force restart
/**
* Current restart state.
*/
private static byte restart;
/**
* The associated OsuFile object.
*/
private static OsuFile osu;
/**
* The associated GameScore object (holds all score data).
*/
private static GameScore score;
/**
* Current hit object index in OsuHitObject[] array.
*/
private int objectIndex = 0;
/**
* This map's hit circles objects, keyed by objectIndex.
*/
private HashMap<Integer, Circle> circles;
/**
* This map's slider objects, keyed by objectIndex.
*/
private HashMap<Integer, Slider> sliders;
/**
* This map's spinner objects, keyed by objectIndex.
*/
private HashMap<Integer, Spinner> spinners;
/**
* Delay time, in milliseconds, before song starts.
*/
private static int leadInTime;
/**
* Hit object approach time, in milliseconds.
*/
private int approachTime;
/**
* Time offsets for obtaining each hit result (indexed by HIT_* constants).
*/
private int[] hitResultOffset;
/**
* Time, in milliseconds, between the first and last hit object.
*/
private int mapLength;
/**
* Current break index in breaks ArrayList.
*/
private int breakIndex;
/**
* Warning arrows, pointing right and left.
*/
private Image warningArrowR, warningArrowL;
/**
* Section pass and fail images (displayed at start of break, when necessary).
*/
private Image breakStartPass, breakStartFail;
/**
* Break start time (0 if not in break).
*/
private int breakTime = 0;
/**
* Skip button (displayed at song start, when necessary).
*/
private GUIMenuButton skipButton;
/**
* Minimum time before start of song, in milliseconds, to process skip-related actions.
*/
private final int skipOffsetTime = 2000;
/**
* Current timing point index in timingPoints ArrayList.
*/
private int timingPointIndex;
/**
* Current beat lengths (base value and inherited value).
*/
private float beatLengthBase, beatLength;
/**
* Countdown-related images.
*/
private Image
countdownReady, // "READY?" text
countdown3, // "3" text
countdown1, // "2" text
countdown2, // "1" text
countdownGo; // "GO!" text
/**
* Glowing hit circle outline which must be clicked when returning from pause menu.
*/
private Image hitCircleSelect;
/**
* Mouse coordinates before game paused.
*/
private int pausedMouseX = -1, pausedMouseY = -1;
/**
* Track position when game paused.
*/
private int pauseTime = -1;
/**
* Value for handling hitCircleSelect pulse effect (expanding, alpha level).
*/
private float pausePulse;
// game-related variables
private GameContainer container;
private StateBasedGame game;
private Input input;
private int state;
public Game(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
this.container = container;
this.game = game;
input = container.getInput();
int width = container.getWidth();
int height = container.getHeight();
// spinners have fixed properties, and only need to be initialized once
Spinner.init(container);
// breaks
breakStartPass = new Image("section-pass.png");
breakStartFail = new Image("section-fail.png");
warningArrowR = new Image("play-warningarrow.png");
warningArrowL = warningArrowR.getFlippedCopy(true, false);
// skip button
Image skip = new Image("play-skip.png");
float skipScale = (height * 0.1f) / skip.getHeight();
skip = skip.getScaledCopy(skipScale);
skipButton = new GUIMenuButton(skip,
width - (skip.getWidth() / 2f),
height - (skip.getHeight() / 2f));
// countdown
float countdownHeight = height / 3f;
countdownReady = new Image("ready.png");
countdownReady = countdownReady.getScaledCopy(countdownHeight / countdownReady.getHeight());
countdown3 = new Image("count3.png");
countdown3 = countdown3.getScaledCopy(countdownHeight / countdown3.getHeight());
countdown2 = new Image("count2.png");
countdown2 = countdown2.getScaledCopy(countdownHeight / countdown2.getHeight());
countdown1 = new Image("count1.png");
countdown1 = countdown1.getScaledCopy(countdownHeight / countdown1.getHeight());
countdownGo = new Image("go.png");
countdownGo = countdownGo.getScaledCopy(countdownHeight / countdownGo.getHeight());
// hit circle select
hitCircleSelect = new Image("hitcircleselect.png");
// create the associated GameScore object
score = new GameScore(width, height);
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
int width = container.getWidth();
int height = container.getHeight();
// background
if (!osu.drawBG(width, height, 0.7f))
g.setBackground(Color.black);
Options.drawFPS();
int trackPosition = MusicController.getPosition();
if (pauseTime > -1) // returning from pause screen
trackPosition = pauseTime;
// break periods
if (osu.breaks != null && breakIndex < osu.breaks.size()) {
if (breakTime > 0) {
int endTime = osu.breaks.get(breakIndex);
int breakLength = endTime - breakTime;
// letterbox effect (black bars on top/bottom)
if (osu.letterboxInBreaks && breakLength >= 4000) {
g.setColor(Color.black);
g.fillRect(0, 0, width, height * 0.125f);
g.fillRect(0, height * 0.875f, width, height * 0.125f);
}
score.drawGameElements(g, mapLength, true, objectIndex == 0);
if (breakLength >= 8000 &&
trackPosition - breakTime > 2000 &&
trackPosition - breakTime < 5000) {
// show break start
if (score.getHealth() >= 50)
breakStartPass.drawCentered(width / 2f, height / 2f);
else
breakStartFail.drawCentered(width / 2f, height / 2f);
} else if (breakLength >= 4000) {
// show break end (flash twice for 500ms)
int endTimeDiff = endTime - trackPosition;
if ((endTimeDiff > 1500 && endTimeDiff < 2000) ||
(endTimeDiff > 500 && endTimeDiff < 1000)) {
warningArrowR.draw(width * 0.15f, height * 0.15f);
warningArrowR.draw(width * 0.15f, height * 0.75f);
warningArrowL.draw(width * 0.75f, height * 0.15f);
warningArrowL.draw(width * 0.75f, height * 0.75f);
}
}
return;
}
}
// game elements
score.drawGameElements(g, mapLength, false, objectIndex == 0);
// first object...
if (objectIndex == 0) {
// skip beginning
if (osu.objects[objectIndex].time - skipOffsetTime > 5000 &&
trackPosition < osu.objects[objectIndex].time - skipOffsetTime)
skipButton.draw();
// mod icons
if (trackPosition < osu.objects[objectIndex].time) {
for (int i = Options.MOD_MAX - 1; i >= 0; i--) {
if (Options.isModActive(i)) {
Image modImage = Options.getModImage(i);
modImage.draw(
(width * 0.85f) + ((i - (Options.MOD_MAX / 2)) * modImage.getWidth() / 3f),
height / 10f
);
}
}
}
}
if (isLeadIn())
trackPosition = leadInTime * -1; // render approach circles during song lead-in
// countdown
if (osu.countdown > 0) { // TODO: implement half/double rate settings
int timeDiff = osu.objects[0].time - trackPosition;
if (timeDiff >= 500 && timeDiff < 3000) {
if (timeDiff >= 1500)
countdownReady.drawCentered(width / 2, height / 2);
if (timeDiff < 2000)
countdown3.draw(0, 0);
if (timeDiff < 1500)
countdown2.draw(width - countdown2.getWidth(), 0);
if (timeDiff < 1000)
countdown1.drawCentered(width / 2, height / 2);
} else if (timeDiff >= -500 && timeDiff < 500) {
countdownGo.setAlpha((timeDiff < 0) ? 1 - (timeDiff / -1000f) : 1);
countdownGo.drawCentered(width / 2, height / 2);
}
}
// draw hit objects in reverse order, or else overlapping objects are unreadable
Stack<Integer> stack = new Stack<Integer>();
for (int i = objectIndex; i < osu.objects.length && osu.objects[i].time < trackPosition + approachTime; i++)
stack.add(i);
while (!stack.isEmpty()) {
int i = stack.pop();
OsuHitObject hitObject = osu.objects[i];
if ((hitObject.type & OsuHitObject.TYPE_CIRCLE) > 0)
circles.get(i).draw(trackPosition);
else if ((hitObject.type & OsuHitObject.TYPE_SLIDER) > 0)
sliders.get(i).draw(trackPosition, stack.isEmpty());
else if ((hitObject.type & OsuHitObject.TYPE_SPINNER) > 0) {
if (stack.isEmpty()) // only draw spinner at objectIndex
spinners.get(i).draw(trackPosition, g);
else
continue;
}
}
// draw OsuHitObjectResult objects
score.drawHitResults(trackPosition);
// returning from pause screen
if (pauseTime > -1 && pausedMouseX > -1 && pausedMouseY > -1) {
// darken the screen
g.setColor(Options.COLOR_BLACK_ALPHA);
g.fillRect(0, 0, width, height);
// draw glowing hit select circle and pulse effect
int circleRadius = Circle.getHitCircle().getWidth();
Image cursorCircle = hitCircleSelect.getScaledCopy(circleRadius, circleRadius);
cursorCircle.setAlpha(1.0f);
cursorCircle.drawCentered(pausedMouseX, pausedMouseY);
Image cursorCirclePulse = cursorCircle.getScaledCopy(1f + pausePulse);
cursorCirclePulse.setAlpha(1f - pausePulse);
cursorCirclePulse.drawCentered(pausedMouseX, pausedMouseY);
}
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
if (isLeadIn()) { // stop updating during song lead-in
leadInTime -= delta;
if (!isLeadIn())
MusicController.playAt(0, false);
return;
}
// returning from pause screen: must click previous mouse position
if (pauseTime > -1) {
// paused during lead-in or break: continue immediately
if (pausedMouseX < 0 && pausedMouseY < 0) {
pauseTime = -1;
if (!isLeadIn())
MusicController.resume();
}
// focus lost: go back to pause screen
else if (!container.hasFocus()) {
game.enterState(Opsu.STATE_GAMEPAUSEMENU);
pausePulse = 0f;
}
// advance pulse animation
else {
pausePulse += delta / 750f;
if (pausePulse > 1f)
pausePulse = 0f;
}
return;
}
// map complete!
if (objectIndex >= osu.objects.length) {
game.enterState(Opsu.STATE_GAMERANKING, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
return;
}
int trackPosition = MusicController.getPosition();
// timing points
if (timingPointIndex < osu.timingPoints.size()) {
OsuTimingPoint timingPoint = osu.timingPoints.get(timingPointIndex);
if (trackPosition >= timingPoint.time) {
if (timingPoint.velocity >= 0)
beatLengthBase = beatLength = timingPoint.beatLength;
else
beatLength = beatLengthBase * (timingPoint.velocity / -100f);
timingPointIndex++;
}
}
// song beginning
if (objectIndex == 0) {
if (trackPosition < osu.objects[0].time)
return; // nothing to do here
}
// break periods
if (osu.breaks != null && breakIndex < osu.breaks.size()) {
int breakValue = osu.breaks.get(breakIndex);
if (breakTime > 0) { // in a break period
if (trackPosition < breakValue)
return;
else {
// break is over
breakTime = 0;
breakIndex++;
}
} else if (trackPosition >= breakValue) {
// start a break
breakTime = breakValue;
breakIndex++;
return;
}
}
// pause game if focus lost
if (!container.hasFocus() && !Options.isModActive(Options.MOD_AUTO)) {
if (pauseTime < 0) {
pausedMouseX = input.getMouseX();
pausedMouseY = input.getMouseY();
pausePulse = 0f;
}
if (MusicController.isPlaying() || isLeadIn())
pauseTime = trackPosition;
game.enterState(Opsu.STATE_GAMEPAUSEMENU);
}
// drain health
score.changeHealth(delta / -200f);
if (!score.isAlive()) {
// game over, force a restart
restart = RESTART_LOSE;
game.enterState(Opsu.STATE_GAMEPAUSEMENU);
}
score.updateComboBurst(delta);
// update objects (loop in unlikely event of any skipped indexes)
while (objectIndex < osu.objects.length && trackPosition > osu.objects[objectIndex].time) {
OsuHitObject hitObject = osu.objects[objectIndex];
// check if we've already passed the next object's start time
boolean overlap = (objectIndex + 1 < osu.objects.length &&
trackPosition > osu.objects[objectIndex + 1].time - hitResultOffset[GameScore.HIT_300]);
// check completion status of the hit object
boolean done = false;
if ((hitObject.type & OsuHitObject.TYPE_CIRCLE) > 0)
done = circles.get(objectIndex).update(overlap);
else if ((hitObject.type & OsuHitObject.TYPE_SLIDER) > 0)
done = sliders.get(objectIndex).update(overlap, delta, input.getMouseX(), input.getMouseY());
else if ((hitObject.type & OsuHitObject.TYPE_SPINNER) > 0)
done = spinners.get(objectIndex).update(overlap, delta, input.getMouseX(), input.getMouseY());
// increment object index?
if (done)
objectIndex++;
else
break;
}
}
@Override
public int getID() { return state; }
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
// pause game
int trackPosition = MusicController.getPosition();
if (pauseTime < 0 && breakTime <= 0 && trackPosition >= osu.objects[0].time) {
pausedMouseX = input.getMouseX();
pausedMouseY = input.getMouseY();
pausePulse = 0f;
}
if (MusicController.isPlaying() || isLeadIn())
pauseTime = trackPosition;
game.enterState(Opsu.STATE_GAMEPAUSEMENU, new EmptyTransition(), new FadeInTransition(Color.black));
break;
case Input.KEY_SPACE:
// skip
skipIntro();
break;
case Input.KEY_Z:
// left-click
if (!Keyboard.isRepeatEvent())
mousePressed(Input.MOUSE_LEFT_BUTTON, input.getMouseX(), input.getMouseY());
break;
case Input.KEY_X:
// right-click
if (!Keyboard.isRepeatEvent())
mousePressed(Input.MOUSE_RIGHT_BUTTON, input.getMouseX(), input.getMouseY());
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
}
}
@Override
public void mousePressed(int button, int x, int y) {
if (button == Input.MOUSE_MIDDLE_BUTTON)
return;
// returning from pause screen
if (pauseTime > -1) {
double distance = Math.hypot(pausedMouseX - x, pausedMouseY - y);
int circleRadius = Circle.getHitCircle().getWidth() / 2;
if (distance < circleRadius) {
// unpause the game
pauseTime = -1;
pausedMouseX = -1;
pausedMouseY = -1;
if (!Game.isLeadIn())
MusicController.resume();
}
return;
}
if (objectIndex >= osu.objects.length) // nothing left to do here
return;
OsuHitObject hitObject = osu.objects[objectIndex];
// skip beginning
if (skipButton.contains(x, y)) {
if (skipIntro())
return; // successfully skipped
}
// "auto" mod: ignore user actions
if (Options.isModActive(Options.MOD_AUTO))
return;
// circles
if ((hitObject.type & OsuHitObject.TYPE_CIRCLE) > 0) {
boolean hit = circles.get(objectIndex).mousePressed(x, y);
if (hit)
objectIndex++;
}
// sliders
else if ((hitObject.type & OsuHitObject.TYPE_SLIDER) > 0)
sliders.get(objectIndex).mousePressed(x, y);
}
@Override
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
if (osu == null || osu.objects == null)
throw new RuntimeException("Running game with no OsuFile loaded.");
// restart the game
if (restart != RESTART_FALSE) {
// new game
if (restart == RESTART_NEW) {
setMapModifiers();
// calculate map length (TODO: end on slider?)
OsuHitObject lastObject = osu.objects[osu.objects.length - 1];
int endTime;
if ((lastObject.type & OsuHitObject.TYPE_SPINNER) > 0)
endTime = lastObject.endTime;
else
endTime = lastObject.time;
mapLength = endTime - osu.objects[0].time;
}
// initialize object maps
circles = new HashMap<Integer, Circle>();
sliders = new HashMap<Integer, Slider>();
spinners = new HashMap<Integer, Spinner>();
for (int i = 0; i < osu.objects.length; i++) {
OsuHitObject hitObject = osu.objects[i];
// is this the last note in the combo?
boolean comboEnd = false;
if (i + 1 < osu.objects.length &&
(osu.objects[i + 1].type & OsuHitObject.TYPE_NEWCOMBO) > 0)
comboEnd = true;
if ((hitObject.type & OsuHitObject.TYPE_CIRCLE) > 0) {
circles.put(i, new Circle(hitObject, this, score, osu.combo[hitObject.comboIndex], comboEnd));
} else if ((hitObject.type & OsuHitObject.TYPE_SLIDER) > 0) {
sliders.put(i, new Slider(hitObject, this, score, osu.combo[hitObject.comboIndex], comboEnd));
} else if ((hitObject.type & OsuHitObject.TYPE_SPINNER) > 0) {
spinners.put(i, new Spinner(hitObject, this, score));
}
}
// reset indexes
MusicController.setPosition(0);
MusicController.pause();
score.clear();
objectIndex = 0;
breakIndex = 0;
breakTime = 0;
timingPointIndex = 0;
pauseTime = -1;
pausedMouseX = -1;
pausedMouseY = -1;
// load the first timingPoint
if (!osu.timingPoints.isEmpty() && osu.timingPoints.get(0).velocity >= 0) {
beatLengthBase = beatLength = osu.timingPoints.get(0).beatLength;
timingPointIndex++;
}
leadInTime = osu.audioLeadIn + approachTime;
restart = RESTART_FALSE;
}
}
/**
* Skips the beginning of a track.
* @return true if skipped, false otherwise
*/
private boolean skipIntro() {
int trackPosition = MusicController.getPosition();
if (objectIndex == 0 &&
osu.objects[0].time - skipOffsetTime > 4000 &&
trackPosition < osu.objects[0].time - skipOffsetTime) {
if (isLeadIn()) {
leadInTime = 0;
MusicController.resume();
}
MusicController.setPosition(osu.objects[0].time - skipOffsetTime);
return true;
}
return false;
}
/**
* Returns true if an input key is pressed (mouse left/right, keyboard Z/X).
*/
public boolean isInputKeyPressed() {
return (input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON) ||
input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON) ||
input.isKeyDown(Input.KEY_Z) || input.isKeyDown(Input.KEY_X));
}
/**
* Set map modifiers.
*/
private void setMapModifiers() {
try {
// map-based properties, so re-initialize each game
byte circleSize = osu.circleSize;
byte approachRate = osu.approachRate;
byte overallDifficulty = osu.overallDifficulty;
byte HPDrainRate = osu.HPDrainRate;
if (Options.isModActive(Options.MOD_HARD_ROCK)) { // hard rock modifiers
circleSize = (byte) Math.max(circleSize - 1, 0);
approachRate = (byte) Math.min(approachRate + 3, 10);
overallDifficulty = (byte) Math.min(overallDifficulty + 3, 10);
HPDrainRate = (byte) Math.min(HPDrainRate + 3, 10);
}
Circle.init(container, circleSize);
Slider.init(container, circleSize, osu);
// approachRate (hit object approach time)
if (approachRate < 5)
approachTime = 1800 - (approachRate * 120);
else
approachTime = 1200 - ((approachRate - 5) * 150);
// overallDifficulty (hit result time offsets)
hitResultOffset = new int[GameScore.HIT_MAX];
hitResultOffset[GameScore.HIT_300] = 78 - (overallDifficulty * 6);
hitResultOffset[GameScore.HIT_100] = 138 - (overallDifficulty * 8);
hitResultOffset[GameScore.HIT_50] = 198 - (overallDifficulty * 10);
hitResultOffset[GameScore.HIT_MISS] = 500 - (overallDifficulty * 10);
// HPDrainRate (health change), overallDifficulty (scoring)
score.setDrainRate(HPDrainRate);
score.setDifficulty(overallDifficulty);
} catch (SlickException e) {
Log.error("Error while setting map modifiers.", e);
}
}
/**
* Sets/returns whether entering the state will restart it.
*/
public static void setRestart(byte restart) { Game.restart = restart; }
public static byte getRestart() { return Game.restart; }
/**
* Sets or returns the associated OsuFile.
*/
public static void setOsuFile(OsuFile osu) { Game.osu = osu; }
public static OsuFile getOsuFile() { return osu; }
/**
* Returns the associated GameScore object.
*/
public static GameScore getGameScore() { return score; }
/**
* Returns whether or not the track is in the lead-in time state.
*/
public static boolean isLeadIn() { return leadInTime > 0; }
/**
* Returns the object approach time, in milliseconds.
*/
public int getApproachTime() { return approachTime; }
/**
* Returns an array of hit result offset times, in milliseconds (indexed by GameScore.HIT_* constants).
*/
public int[] getHitResultOffsets() { return hitResultOffset; }
/**
* Returns the beat length.
*/
public float getBeatLength() { return beatLength; }
/**
* Returns the slider multiplier given by the current timing point.
*/
public float getTimingPointMultiplier() { return beatLength / beatLengthBase; }
}

View File

@@ -0,0 +1,195 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.Opsu;
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;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
/**
* "Game Paused" state.
* <ul>
* <li>[Continue] - unpause game (return to game state)
* <li>[Retry] - restart game (return to game state)
* <li>[Back] - return to song menu state
* </ul>
*/
public class GamePauseMenu extends BasicGameState {
/**
* Music fade-out time, in milliseconds.
*/
private static final int FADEOUT_TIME = 1000;
/**
* Track position when the pause menu was loaded (for FADEOUT_TIME).
*/
private long pauseStartTime;
/**
* "Continue", "Retry", and "Back" buttons.
*/
private GUIMenuButton continueButton, retryButton, backButton;
/**
* Background image for pause menu (optional).
*/
private Image backgroundImage;
/**
* Background image for fail menu (optional).
*/
private Image failImage;
// game-related variables
private StateBasedGame game;
private int state;
public GamePauseMenu(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
this.game = game;
int width = container.getWidth();
int height = container.getHeight();
// initialize buttons
continueButton = new GUIMenuButton(new Image("pause-continue.png"), width / 2f, height * 0.25f);
retryButton = new GUIMenuButton(new Image("pause-retry.png"), width / 2f, height * 0.5f);
backButton = new GUIMenuButton(new Image("pause-back.png"), width / 2f, height * 0.75f);
// pause background image
try {
backgroundImage = new Image("pause-overlay.png").getScaledCopy(width, height);
backgroundImage.setAlpha(0.7f);
} catch (Exception e) {
// optional
}
// fail image
try {
failImage = new Image("fail-background.png").getScaledCopy(width, height);
failImage.setAlpha(0.7f);
} catch (Exception e) {
// optional
}
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
// background
if (backgroundImage != null && Game.getRestart() != Game.RESTART_LOSE)
backgroundImage.draw();
else if (failImage != null && Game.getRestart() == Game.RESTART_LOSE)
failImage.draw();
else
g.setBackground(Color.black);
Options.drawFPS();
// draw buttons
if (Game.getRestart() != Game.RESTART_LOSE)
continueButton.draw();
retryButton.draw();
backButton.draw();
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
// empty
}
@Override
public int getID() { return state; }
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
// 'esc' will normally unpause, but will return to song menu if health is zero
if (Game.getRestart() == Game.RESTART_LOSE) {
MusicController.stop();
MusicController.playAt(Game.getOsuFile().previewTime, true);
game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
} else
unPause(Game.RESTART_FALSE);
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
}
}
@Override
public void mousePressed(int button, int x, int y) {
// check mouse button
if (button != Input.MOUSE_LEFT_BUTTON)
return;
boolean loseState = (Game.getRestart() == Game.RESTART_LOSE);
// if music faded out (i.e. health is zero), don't process any actions before FADEOUT_TIME
if (loseState && System.currentTimeMillis() - pauseStartTime < FADEOUT_TIME)
return;
if (continueButton.contains(x, y) && !loseState)
unPause(Game.RESTART_FALSE);
else if (retryButton.contains(x, y)) {
unPause(Game.RESTART_MANUAL);
} else if (backButton.contains(x, y)) {
MusicController.pause(); // lose state
MusicController.playAt(Game.getOsuFile().previewTime, true);
game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
}
}
@Override
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
pauseStartTime = System.currentTimeMillis();
if (Game.getRestart() == Game.RESTART_LOSE)
MusicController.fadeOut(FADEOUT_TIME);
else
MusicController.pause();
}
/**
* Unpause and return to the Game state.
*/
private void unPause(byte restart) {
Game.setRestart(restart);
game.enterState(Opsu.STATE_GAME);
}
}

View File

@@ -0,0 +1,180 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.GameScore;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.Opsu;
import itdelatrisu.opsu.OsuFile;
import org.lwjgl.opengl.Display;
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;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
/**
* "Game Ranking" (score card) state.
* <ul>
* <li>[Retry] - restart game (return to game state)
* <li>[Exit] - return to main menu state
* <li>[Back] - return to song menu state
* </ul>
*/
public class GameRanking extends BasicGameState {
/**
* Associated GameScore object.
*/
private static GameScore score;
/**
* "Retry" and "Exit" buttons.
*/
private GUIMenuButton retryButton, exitButton;
// game-related variables
private StateBasedGame game;
private int state;
public GameRanking(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
this.game = game;
score = Game.getGameScore();
int width = container.getWidth();
int height = container.getHeight();
// buttons
Image retry = new Image("ranking-retry.png");
Image exit = new Image("ranking-back.png");
float scale = (height * 0.15f) / retry.getHeight();
retry = retry.getScaledCopy(scale);
exit = exit.getScaledCopy(scale);
retryButton = new GUIMenuButton(retry,
width - (retry.getWidth() / 2f),
(height * 0.97f) - (exit.getHeight() * 1.5f)
);
exitButton = new GUIMenuButton(exit,
width - (exit.getWidth() / 2f),
(height * 0.97f) - (exit.getHeight() / 2f)
);
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
int width = container.getWidth();
int height = container.getHeight();
OsuFile osu = Game.getOsuFile();
// background
if (!osu.drawBG(width, height, 0.7f))
g.setBackground(Options.COLOR_BLACK_ALPHA);
// ranking screen elements
score.drawRankingElements(g, width, height);
// game mods
for (int i = Options.MOD_MAX - 1; i >= 0; i--) {
if (Options.isModActive(i)) {
Image modImage = Options.getModImage(i);
modImage.draw(
(width * 0.75f) + ((i - (Options.MOD_MAX / 2)) * modImage.getWidth() / 3f),
height / 2f
);
}
}
// header text
g.setColor(Color.white);
Options.FONT_LARGE.drawString(10, 0,
String.format("%s - %s [%s]", osu.artist, osu.title, osu.version));
Options.FONT_MEDIUM.drawString(10, Options.FONT_LARGE.getLineHeight() - 6,
String.format("Beatmap by %s", osu.creator));
// buttons
retryButton.draw();
exitButton.draw();
Options.getBackButton().draw();
Options.drawFPS();
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
// empty
}
@Override
public int getID() { return state; }
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
MusicController.playAt(Game.getOsuFile().previewTime, true);
game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
}
}
@Override
public void mousePressed(int button, int x, int y) {
// check mouse button
if (button != Input.MOUSE_LEFT_BUTTON)
return;
if (retryButton.contains(x, y)) {
OsuFile osu = Game.getOsuFile();
Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString()));
Game.setRestart(Game.RESTART_MANUAL);
game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
} else if (exitButton.contains(x, y))
game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
else if (Options.getBackButton().contains(x, y)) {
MusicController.stop();
MusicController.playAt(Game.getOsuFile().previewTime, true);
game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
}
}
@Override
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
Display.setTitle(game.getTitle());
}
}

View File

@@ -0,0 +1,319 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.Opsu;
import itdelatrisu.opsu.OsuGroupNode;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Stack;
import java.util.concurrent.TimeUnit;
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;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
/**
* "Main Menu" state.
* <ul>
* <li>[Play] - move to song selection menu
* <li>[Exit] - move to confirm exit menu
* </ul>
*/
public class MainMenu extends BasicGameState {
/**
* Idle time, in milliseconds, before returning the logo to its original position.
*/
private static final short MOVE_DELAY = 5000;
/**
* Logo button that reveals other buttons on click.
*/
private GUIMenuButton logo;
/**
* Whether or not the logo has been clicked.
*/
private boolean logoClicked = false;
/**
* Delay timer, in milliseconds, before starting to move the logo back to the center.
*/
private int logoTimer = 0;
/**
* Main "Play" and "Exit" buttons.
*/
private GUIMenuButton playButton, exitButton;
/**
* Music control buttons.
*/
private GUIMenuButton musicPlay, musicPause, musicNext, musicPrevious;
/**
* Application start time, for drawing the total running time.
*/
private long osuStartTime;
/**
* Indexes of previous songs.
*/
private static Stack<Integer> previous;
/**
* Main menu background image (optional).
*/
private Image backgroundImage;
// game-related variables
private StateBasedGame game;
private int state;
public MainMenu(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
this.game = game;
osuStartTime = System.currentTimeMillis();
previous = new Stack<Integer>();
int width = container.getWidth();
int height = container.getHeight();
// initialize buttons
Image logoImg = new Image("logo.png");
float buttonScale = (height / 1.2f) / logoImg.getHeight();
Image logoImgScaled = logoImg.getScaledCopy(buttonScale);
logo = new GUIMenuButton(logoImgScaled, width / 2f, height / 2f);
Image playImg = new Image("menu-play.png");
Image exitImg = new Image("menu-exit.png");
playImg = playImg.getScaledCopy((logoImg.getWidth() * 0.83f) / playImg.getWidth());
exitImg = exitImg.getScaledCopy((logoImg.getWidth() * 0.66f) / exitImg.getWidth());
float exitOffset = (playImg.getWidth() - exitImg.getWidth()) / 3f;
playButton = new GUIMenuButton(playImg.getScaledCopy(buttonScale),
width * 0.75f, (height / 2) - (logoImgScaled.getHeight() / 5f)
);
exitButton = new GUIMenuButton(exitImg.getScaledCopy(buttonScale),
width * 0.75f - exitOffset, (height / 2) + (exitImg.getHeight() / 2f)
);
// initialize music buttons
int musicWidth = 48;
int musicHeight = 30;
musicPlay = new GUIMenuButton(new Image("music-play.png"), width - (2 * musicWidth), musicHeight);
musicPause = new GUIMenuButton(new Image("music-pause.png"), width - (2 * musicWidth), musicHeight);
musicNext = new GUIMenuButton(new Image("music-next.png"), width - musicWidth, musicHeight);
musicPrevious = new GUIMenuButton(new Image("music-previous.png"), width - (3 * musicWidth), musicHeight);
// menu background
try {
backgroundImage = new Image("menu-background.jpg").getScaledCopy(width, height);
backgroundImage.setAlpha(0.9f);
} catch (Exception e) {
// optional
}
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
if (backgroundImage != null)
backgroundImage.draw();
else
g.setBackground(Options.COLOR_BLUE_BACKGROUND);
g.setFont(Options.FONT_MEDIUM);
int width = container.getWidth();
int height = container.getHeight();
// draw buttons
if (logoTimer > 0) {
playButton.draw();
exitButton.draw();
}
logo.draw();
// draw music buttons
if (MusicController.isPlaying())
musicPause.draw();
else
musicPlay.draw();
musicNext.draw();
musicPrevious.draw();
g.setColor(Options.COLOR_BLACK_ALPHA);
g.fillRoundRect(width - 168, 54, 148, 5, 4);
g.setColor(Color.white);
if (!MusicController.isConverting())
g.fillRoundRect(width - 168, 54,
148f * MusicController.getPosition() / MusicController.getTrackLength(), 5, 4);
// draw text
int lineHeight = Options.FONT_MEDIUM.getLineHeight();
g.drawString(String.format("Loaded %d songs and %d beatmaps.",
Opsu.groups.size(), Opsu.groups.getMapCount()), 25, 25);
if (MusicController.isConverting())
g.drawString("Track loading...", 25, 25 + lineHeight);
else if (MusicController.trackExists()) {
g.drawString((MusicController.isPlaying()) ? "Now Playing:" : "Paused:", 25, 25 + lineHeight);
g.drawString(String.format("%s: %s",
MusicController.getArtistName(),
MusicController.getTrackName()),
50, 25 + (lineHeight * 2));
}
long time = System.currentTimeMillis() - osuStartTime;
g.drawString(String.format("opsu! has been running for %d minutes, %d seconds.",
TimeUnit.MILLISECONDS.toMinutes(time),
TimeUnit.MILLISECONDS.toSeconds(time) -
TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(time))),
25, height - 25 - (lineHeight * 2));
g.drawString(String.format("The current time is %s.",
new SimpleDateFormat("h:mm a").format(new Date())),
25, height - 25 - lineHeight);
Options.drawFPS();
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
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
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 / 2f));
if (logo.getX() > container.getWidth() / 2)
logo.setX(container.getWidth() / 2);
}
}
}
@Override
public int getID() { return state; }
@Override
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
logoClicked = false;
logoTimer = 0;
logo.setX(container.getWidth() / 2);
}
@Override
public void mousePressed(int button, int x, int y) {
// check mouse button
if (button != Input.MOUSE_LEFT_BUTTON)
return;
// music button actions
if (musicPlay.contains(x, y)) {
if (MusicController.isPlaying())
MusicController.pause();
else if (!MusicController.isConverting())
MusicController.resume();
} else if (musicNext.contains(x, y)) {
SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU);
OsuGroupNode node = menu.setFocus(Opsu.groups.getRandomNode(), -1, true);
if (node != null)
previous.add(node.index);
} else if (musicPrevious.contains(x, y)) {
if (!previous.isEmpty()) {
SongMenu menu = (SongMenu) game.getState(Opsu.STATE_SONGMENU);
menu.setFocus(Opsu.groups.getBaseNode(previous.pop()), -1, true);
} else
MusicController.setPosition(0);
}
// start moving logo (if clicked)
else if (!logoClicked) {
if (logo.contains(x, y)) {
logoClicked = true;
logoTimer = 0;
playButton.getImage().setAlpha(0f);
exitButton.getImage().setAlpha(0f);
}
}
// other button actions (if visible)
else if (logoClicked) {
if (logo.contains(x, y))
logoTimer = MOVE_DELAY;
else if (playButton.contains(x, y))
game.enterState(Opsu.STATE_SONGMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
else if (exitButton.contains(x, y))
game.enterState(Opsu.STATE_MAINMENUEXIT);
}
}
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
if (logoClicked)
logoTimer = MOVE_DELAY;
else
game.enterState(Opsu.STATE_MAINMENUEXIT);
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
}
}
}

View File

@@ -0,0 +1,163 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.Opsu;
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;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.EmptyTransition;
import org.newdawn.slick.state.transition.FadeInTransition;
/**
* "Confirm Exit" state.
* <ul>
* <li>[Yes] - quit game
* <li>[No] - return to main menu
* </ul>
*/
public class MainMenuExit extends BasicGameState {
/**
* "Yes" and "No" buttons.
*/
private GUIMenuButton yesButton, noButton;
/**
* Initial x coordinate offsets left/right of center (for shifting animation).
*/
private float centerOffset;
private GameContainer container;
private StateBasedGame game;
private int state;
public MainMenuExit(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
this.container = container;
this.game = game;
int width = container.getWidth();
int height = container.getHeight();
centerOffset = width / 8f;
// initialize buttons
Image button = new Image("button-middle.png");
Image buttonL = new Image("button-left.png");
Image buttonR = new Image("button-right.png");
button = button.getScaledCopy(width / 2, button.getHeight());
yesButton = new GUIMenuButton(button, buttonL, buttonR,
width / 2f - centerOffset, height * 0.2f
);
noButton = new GUIMenuButton(button, buttonL, buttonR,
width / 2f + centerOffset, height * 0.2f + (button.getHeight() * 1.25f)
);
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
g.setBackground(Color.black);
g.setColor(Color.white);
// draw text
float c = container.getWidth() * 0.02f;
Options.FONT_LARGE.drawString(c, c, "Are you sure you want to exit opsu!?");
// draw buttons
yesButton.draw(Color.green);
noButton.draw(Color.red);
g.setFont(Options.FONT_XLARGE);
g.drawString("1. Yes",
yesButton.getX() - (Options.FONT_XLARGE.getWidth("1. Yes") / 2f),
yesButton.getY() - (Options.FONT_XLARGE.getHeight() / 2f)
);
g.drawString("2. No",
noButton.getX() - (Options.FONT_XLARGE.getWidth("2. No") / 2f),
noButton.getY() - (Options.FONT_XLARGE.getHeight() / 2f)
);
Options.drawFPS();
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
// move buttons to center
float yesX = yesButton.getX(), noX = noButton.getX();
float center = container.getWidth() / 2f;
if (yesX < center)
yesButton.setX(Math.min(yesX + (delta / 2f), center));
if (noX > center)
noButton.setX(Math.max(noX - (delta / 2f), center));
}
@Override
public int getID() { return state; }
@Override
public void mousePressed(int button, int x, int y) {
// check mouse button
if (button != Input.MOUSE_LEFT_BUTTON)
return;
if (yesButton.contains(x, y)) {
Options.saveOptions();
container.exit();
} else if (noButton.contains(x, y))
game.enterState(Opsu.STATE_MAINMENU, new EmptyTransition(), new FadeInTransition(Color.black));
}
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_1:
Options.saveOptions();
container.exit();
break;
case Input.KEY_2:
case Input.KEY_ESCAPE:
game.enterState(Opsu.STATE_MAINMENU, new EmptyTransition(), new FadeInTransition(Color.black));
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
}
}
@Override
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
float center = container.getWidth() / 2f;
yesButton.setX(center - centerOffset);
noButton.setX(center + centerOffset);
}
}

View File

@@ -0,0 +1,822 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.Opsu;
import java.awt.Font;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
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.TrueTypeFont;
import org.newdawn.slick.imageout.ImageOut;
import org.newdawn.slick.state.BasicGameState;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.EmptyTransition;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.util.Log;
/**
* "Game Options" state.
*/
public class Options extends BasicGameState {
/**
* Temporary folder for file conversions, auto-deleted upon successful exit.
*/
public static final File TMP_DIR = new File(".osu_tmp/");
/**
* Directory for screenshots (created when needed).
*/
public static final File SCREENSHOT_DIR = new File("screenshot/");
/**
* File for logging errors.
*/
public static final File LOG_FILE = new File(".opsu.log");
/**
* Beatmap directories (where to search for files).
*/
private static final String[] BEATMAP_DIRS = {
"C:/Program Files (x86)/osu!/Songs/",
"C:/Program Files/osu!/Songs/",
"songs/"
};
/**
* The current beatmap directory.
*/
private static File beatmapDir;
/**
* File for storing user options.
*/
private static final String OPTIONS_FILE = ".opsu.cfg";
/**
* Whether or not any changes were made to options.
* If false, the options file will not be modified.
*/
private static boolean optionsChanged = false;
/**
* Game colors.
*/
public static final Color
COLOR_BLACK_ALPHA = new Color(0, 0, 0, 0.5f),
COLOR_BLUE_DIVIDER = new Color(49, 94, 237),
COLOR_BLUE_BACKGROUND = new Color(74, 130, 255),
COLOR_BLUE_BUTTON = new Color(50, 189, 237),
COLOR_ORANGE_BUTTON = new Color(230, 151, 87),
COLOR_GREEN_OBJECT = new Color(26, 207, 26),
COLOR_BLUE_OBJECT = new Color(46, 136, 248),
COLOR_RED_OBJECT = new Color(243, 48, 77),
COLOR_ORANGE_OBJECT = new Color(255, 200, 32);
/**
* The default map colors, used when a map does not provide custom colors.
*/
public static final Color[] DEFAULT_COMBO = {
COLOR_GREEN_OBJECT, COLOR_BLUE_OBJECT,
COLOR_RED_OBJECT, COLOR_ORANGE_OBJECT
};
/**
* Game fonts.
*/
public static TrueTypeFont
FONT_DEFAULT, FONT_BOLD,
FONT_XLARGE, FONT_LARGE, FONT_MEDIUM, FONT_SMALL;
/**
* Game mods.
*/
public static final int
MOD_NO_FAIL = 0,
MOD_HARD_ROCK = 1,
MOD_SUDDEN_DEATH = 2,
MOD_SPUN_OUT = 3,
MOD_AUTO = 4,
MOD_MAX = 5; // not a mod
/**
* Whether a mod is active (indexed by MOD_* constants).
*/
private static boolean[] modsActive;
/**
* Mod buttons.
*/
private static GUIMenuButton[] modButtons;
/**
* Game option constants.
*/
private static final int
OPTIONS_SCREEN_RESOLUTION = 0,
// OPTIONS_FULLSCREEN = ,
OPTIONS_TARGET_FPS = 1,
OPTIONS_MUSIC_VOLUME = 2,
OPTIONS_MUSIC_OFFSET = 3,
OPTIONS_SCREENSHOT_FORMAT = 4,
OPTIONS_DISPLAY_FPS = 5,
OPTIONS_HIT_LIGHTING = 6,
OPTIONS_COMBO_BURSTS = 7,
OPTIONS_MAX = 8; // not an option
/**
* Screen resolutions.
*/
private static final int[][] resolutions = {
{ 800, 600 },
{ 1024, 600 },
{ 1024, 768 },
{ 1280, 800 },
{ 1280, 960 },
{ 1366, 768 },
{ 1440, 900 },
{ 1680, 1050 },
{ 1920, 1080 }
};
/**
* Index (row) in resolutions[][] array.
*/
private static int resolutionIndex = 3;
// /**
// * Whether or not the game should run in fullscreen mode.
// */
// private static boolean fullscreen = false;
/**
* Frame limiters.
*/
private static final int[] targetFPS = { 60, 120, 240 };
/**
* Index in targetFPS[] array.
*/
private static int targetFPSindex = 0;
/**
* Whether or not to show the FPS.
*/
private static boolean showFPS = false;
/**
* Whether or not to show hit lighting effects.
*/
private static boolean showHitLighting = true;
/**
* Whether or not to show combo burst images.
*/
private static boolean showComboBursts = true;
/**
* Default music volume.
*/
private static int musicVolume = 20;
/**
* Offset time, in milliseconds, for music position-related elements.
*/
private static int musicOffset = -150;
/**
* Screenshot file format.
*/
private static String[] screenshotFormat = { "png", "jpg", "bmp" };
/**
* Index in screenshotFormat[] array.
*/
private static int screenshotFormatIndex = 0;
/**
* Back button (shared by other states).
*/
private static GUIMenuButton backButton;
/**
* Game option coordinate modifiers (for drawing).
*/
private int textY, offsetY;
// game-related variables
private static GameContainer container;
private static StateBasedGame game;
private Input input;
private int state;
private boolean init = false;
public Options(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
Options.container = container;
Options.game = game;
this.input = container.getInput();
// game settings;
container.setTargetFrameRate(60);
container.setMouseCursor("cursor.png", 16, 16);
container.setMusicVolume(getMusicVolume());
container.setShowFPS(false);
container.getInput().enableKeyRepeat();
container.setAlwaysRender(true);
// create fonts
float fontBase;
if (container.getHeight() <= 600)
fontBase = 9f;
else if (container.getHeight() < 800)
fontBase = 10f;
else if (container.getHeight() <= 900)
fontBase = 12f;
else
fontBase = 14f;
Font font = new Font("Lucida Sans Unicode", Font.PLAIN, (int) (fontBase * 4 / 3));
FONT_DEFAULT = new TrueTypeFont(font, false);
FONT_BOLD = new TrueTypeFont(font.deriveFont(Font.BOLD), false);
FONT_XLARGE = new TrueTypeFont(font.deriveFont(fontBase * 4), false);
FONT_LARGE = new TrueTypeFont(font.deriveFont(fontBase * 2), false);
FONT_MEDIUM = new TrueTypeFont(font.deriveFont(fontBase * 3 / 2), false);
FONT_SMALL = new TrueTypeFont(font.deriveFont(fontBase), false);
int width = container.getWidth();
int height = container.getHeight();
// game option coordinate modifiers
textY = 10 + (FONT_XLARGE.getLineHeight() * 3 / 2);
offsetY = (int) (((height * 0.8f) - textY) / OPTIONS_MAX);
// game mods
modsActive = new boolean[MOD_MAX];
modButtons = new GUIMenuButton[MOD_MAX];
Image noFailImage = new Image("selection-mod-nofail.png");
float modScale = (height * 0.12f) / noFailImage.getHeight();
noFailImage = noFailImage.getScaledCopy(modScale);
float modButtonOffsetX = noFailImage.getWidth() * 1.5f;
float modButtonX = (width / 2f) - (modButtonOffsetX * modButtons.length / 2.75f);
float modButtonY = (height * 0.8f) + (noFailImage.getHeight() / 2);
modButtons[MOD_NO_FAIL] = new GUIMenuButton(
noFailImage, modButtonX, modButtonY
);
modButtons[MOD_HARD_ROCK] = new GUIMenuButton(
new Image("selection-mod-hardrock.png").getScaledCopy(modScale),
modButtonX + modButtonOffsetX, modButtonY
);
modButtons[MOD_SUDDEN_DEATH] = new GUIMenuButton(
new Image("selection-mod-suddendeath.png").getScaledCopy(modScale),
modButtonX + (modButtonOffsetX * 2), modButtonY
);
modButtons[MOD_SPUN_OUT] = new GUIMenuButton(
new Image("selection-mod-spunout.png").getScaledCopy(modScale),
modButtonX + (modButtonOffsetX * 3), modButtonY
);
modButtons[MOD_AUTO] = new GUIMenuButton(
new Image("selection-mod-autoplay.png").getScaledCopy(modScale),
modButtonX + (modButtonOffsetX * 4), modButtonY
);
for (int i = 0; i < modButtons.length; i++)
modButtons[i].getImage().setAlpha(0.5f);
// back button
Image back = new Image("menu-back.png");
float scale = (height * 0.1f) / back.getHeight();
back = back.getScaledCopy(scale);
backButton = new GUIMenuButton(back,
back.getWidth() / 2f,
height - (back.getHeight() / 2f));
game.enterState(Opsu.STATE_MAINMENU, new EmptyTransition(), new FadeInTransition(Color.black));
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
if (!init)
return;
g.setBackground(COLOR_BLACK_ALPHA);
g.setColor(Color.white);
int width = container.getWidth();
int height = container.getHeight();
// title
FONT_XLARGE.drawString(
(width / 2) - (FONT_XLARGE.getWidth("GAME OPTIONS") / 2),
10, "GAME OPTIONS"
);
FONT_DEFAULT.drawString(
(width / 2) - (FONT_DEFAULT.getWidth("Click or drag an option to change it.") / 2),
10 + FONT_XLARGE.getHeight(), "Click or drag an option to change it."
);
// game options
g.setLineWidth(1f);
g.setFont(FONT_LARGE);
this.drawOption(g, OPTIONS_SCREEN_RESOLUTION, "Screen Resolution",
String.format("%dx%d", resolutions[resolutionIndex][0], resolutions[resolutionIndex][1]),
"Restart to apply resolution changes."
);
// this.drawOption(g, OPTIONS_FULLSCREEN, "Fullscreen Mode",
// fullscreen ? "Yes" : "No",
// "Restart to apply changes."
// );
this.drawOption(g, OPTIONS_TARGET_FPS, "Frame Limiter",
String.format("%dfps", targetFPS[targetFPSindex]),
"Higher values may cause high CPU usage."
);
this.drawOption(g, OPTIONS_MUSIC_VOLUME, "Music Volume",
String.format("%d%%", musicVolume),
"Global music volume."
);
this.drawOption(g, OPTIONS_MUSIC_OFFSET, "Music Offset",
String.format("%dms", musicOffset),
"Adjust this value if hit objects are out of sync."
);
this.drawOption(g, OPTIONS_SCREENSHOT_FORMAT, "Screenshot Format",
screenshotFormat[screenshotFormatIndex].toUpperCase(),
"Press F12 to take a screenshot."
);
this.drawOption(g, OPTIONS_DISPLAY_FPS, "Show FPS Counter",
showFPS ? "Yes" : "No",
null
);
this.drawOption(g, OPTIONS_HIT_LIGHTING, "Show Hit Lighting",
showHitLighting ? "Yes" : "No",
null
);
this.drawOption(g, OPTIONS_COMBO_BURSTS, "Show Combo Bursts",
showComboBursts ? "Yes" : "No",
null
);
// game mods
FONT_LARGE.drawString(width * 0.02f, height * 0.8f, "Game Mods:", Color.white);
for (int i = 0; i < modButtons.length; i++)
modButtons[i].draw();
backButton.draw();
drawFPS();
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
init = true;
}
@Override
public int getID() { return state; }
@Override
public void mousePressed(int button, int x, int y) {
// check mouse button
if (button == Input.MOUSE_MIDDLE_BUTTON)
return;
// back
if (backButton.contains(x, y)) {
game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition(Color.black));
return;
}
// game mods
for (int i = 0; i < modButtons.length; i++) {
if (modButtons[i].contains(x, y)) {
toggleMod(i);
// mutually exclusive mods
if (modsActive[MOD_AUTO]) {
if (i == MOD_AUTO) {
if (modsActive[MOD_SPUN_OUT])
toggleMod(MOD_SPUN_OUT);
if (modsActive[MOD_SUDDEN_DEATH])
toggleMod(MOD_SUDDEN_DEATH);
} else if (i == MOD_SPUN_OUT || i == MOD_SUDDEN_DEATH) {
if (modsActive[i])
toggleMod(i);
}
} else if (modsActive[MOD_SUDDEN_DEATH] && modsActive[MOD_NO_FAIL])
toggleMod((i == MOD_SUDDEN_DEATH) ? MOD_NO_FAIL : MOD_SUDDEN_DEATH);
return;
}
}
// options (click only)
if (isOptionClicked(OPTIONS_SCREEN_RESOLUTION, y)) {
resolutionIndex = (resolutionIndex + 1) % resolutions.length;
return;
}
// if (isOptionClicked(OPTIONS_FULLSCREEN, y)) {
// fullscreen = !fullscreen;
// return;
// }
if (isOptionClicked(OPTIONS_TARGET_FPS, y)) {
targetFPSindex = (targetFPSindex + 1) % targetFPS.length;
container.setTargetFrameRate(targetFPS[targetFPSindex]);
return;
}
if (isOptionClicked(OPTIONS_SCREENSHOT_FORMAT, y)) {
screenshotFormatIndex = (screenshotFormatIndex + 1) % screenshotFormat.length;
return;
}
if (isOptionClicked(OPTIONS_DISPLAY_FPS, y)) {
showFPS = !showFPS;
return;
}
if (isOptionClicked(OPTIONS_HIT_LIGHTING, y)) {
showHitLighting = !showHitLighting;
return;
}
if (isOptionClicked(OPTIONS_COMBO_BURSTS, y)) {
showComboBursts = !showComboBursts;
return;
}
}
@Override
public void mouseDragged(int oldx, int oldy, int newx, int newy) {
// check mouse button (right click scrolls faster)
int multiplier;
if (input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON))
multiplier = 4;
else if (input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON))
multiplier = 1;
else
return;
// get direction
int diff = newx - oldx;
if (diff == 0)
return;
diff = ((diff > 0) ? 1 : -1) * multiplier;
// options (drag only)
if (isOptionClicked(OPTIONS_MUSIC_VOLUME, oldy)) {
musicVolume += diff;
if (musicVolume < 0)
musicVolume = 0;
else if (musicVolume > 100)
musicVolume = 100;
container.setMusicVolume(getMusicVolume());
return;
}
if (isOptionClicked(OPTIONS_MUSIC_OFFSET, oldy)) {
musicOffset += diff;
if (musicOffset < -500)
musicOffset = -500;
else if (musicOffset > 500)
musicOffset = 500;
return;
}
}
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
game.enterState(Opsu.STATE_SONGMENU, new EmptyTransition(), new FadeInTransition(Color.black));
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
}
}
/**
* Draws a game option.
* @param g the graphics context
* @param pos the element position (OPTIONS_* constants)
* @param label the option name
* @param value the option value
* @param notes additional notes (optional)
*/
private void drawOption(Graphics g, int pos, String label, String value, String notes) {
int width = container.getWidth();
int textHeight = FONT_LARGE.getHeight();
float y = textY + (pos * offsetY);
g.drawString(label, width / 50, y);
g.drawString(value, width / 2, y);
g.drawLine(0, y + textHeight, width, y + textHeight);
if (notes != null)
FONT_SMALL.drawString(width / 50, y + textHeight, notes);
}
/**
* Returns whether or not an option was clicked.
* @param pos the element position (OPTIONS_* constants)
* @param y the y coordinate of the click
* @return true if clicked
*/
private boolean isOptionClicked(int pos, int y) {
if (y > textY + (offsetY * pos) - FONT_LARGE.getHeight() &&
y < textY + (offsetY * pos) + FONT_LARGE.getHeight()) {
optionsChanged = true;
return true;
}
return false;
}
/**
* Toggles the active status of a game mod.
* Note that this does not perform checks for mutual exclusivity.
* @param mod the game mod (MOD_* constants)
*/
private static void toggleMod(int mod) {
modButtons[mod].getImage().setAlpha(modsActive[mod] ? 0.5f : 1.0f);
modsActive[mod] = !modsActive[mod];
}
/**
* Returns whether or not a game mod is active.
* @param mod the game mod (MOD_* constants)
* @return true if the mod is active
*/
public static boolean isModActive(int mod) { return modsActive[mod]; }
/**
* Returns the image associated with a game mod.
* @param mod the game mod (MOD_* constants)
* @return the associated image
*/
public static Image getModImage(int mod) { return modButtons[mod].getImage(); }
/**
* Returns the 'back' GUIMenuButton.
*/
public static GUIMenuButton getBackButton() { return backButton; }
/**
* Returns the default music volume.
* @return the volume [0, 1]
*/
public static float getMusicVolume() { return musicVolume / 100f; }
/**
* Returns the music offset time.
* @return the offset (in milliseconds)
*/
public static int getMusicOffset() { return musicOffset; }
/**
* Draws the FPS at the bottom-right corner of the game container.
* If the option is not activated, this will do nothing.
*/
public static void drawFPS() {
if (showFPS) {
String fps = String.format("FPS: %d", container.getFPS());
FONT_DEFAULT.drawString(
container.getWidth() - 15 - FONT_DEFAULT.getWidth(fps),
container.getHeight() - 15 - FONT_DEFAULT.getHeight(fps),
fps, Color.white
);
}
}
/**
* Takes a screenshot.
* @return true if successful
*/
public static boolean takeScreenShot() {
// TODO: should this be threaded?
try {
// create the screenshot directory
if (!SCREENSHOT_DIR.isDirectory()) {
if (!SCREENSHOT_DIR.mkdir())
return false;
}
// create file name
SimpleDateFormat date = new SimpleDateFormat("yyyyMMdd_HHmmss");
String file = date.format(new Date());
// copy the screen
Image screen = new Image(container.getWidth(), container.getHeight());
container.getGraphics().copyArea(screen, 0, 0);
ImageOut.write(screen, String.format("%s%sscreenshot_%s.%s",
SCREENSHOT_DIR.getName(), File.separator,
file, screenshotFormat[screenshotFormatIndex]), false
);
screen.destroy();
} catch (SlickException e) {
Log.warn("Failed to take a screenshot.", e);
return false;
}
return true;
}
/**
* Returns the screen resolution.
* @return an array containing the resolution [width, height]
*/
public static int[] getContainerSize() { return resolutions[resolutionIndex]; }
// /**
// * Returns whether or not fullscreen mode is enabled.
// * @return true if enabled
// */
// public static boolean isFullscreen() { return fullscreen; }
/**
* Returns whether or not hit lighting effects are enabled.
* @return true if enabled
*/
public static boolean isHitLightingEnabled() { return showHitLighting; }
/**
* Returns whether or not combo burst effects are enabled.
* @return true if enabled
*/
public static boolean isComboBurstEnabled() { return showComboBursts; }
/**
* Returns the current beatmap directory.
* If invalid, this will attempt to search for the directory,
* and if nothing found, will create one.
*/
public static File getBeatmapDir() {
if (beatmapDir != null && beatmapDir.isDirectory())
return beatmapDir;
// search for directory
for (int i = 0; i < BEATMAP_DIRS.length; i++) {
beatmapDir = new File(BEATMAP_DIRS[i]);
if (beatmapDir.isDirectory()) {
optionsChanged = true; // force config file creation
return beatmapDir;
}
}
beatmapDir.mkdir(); // none found, create new directory
return beatmapDir;
}
/**
* Reads user options from the options file, if it exists.
*/
public static void parseOptions() {
// if no config file, use default settings
File file = new File(OPTIONS_FILE);
if (!file.isFile()) {
optionsChanged = true; // force file creation
saveOptions();
optionsChanged = false;
return;
}
try (BufferedReader in = new BufferedReader(new FileReader(file))) {
String line;
String name, value;
int i;
while ((line = in.readLine()) != null) {
line = line.trim();
if (line.length() < 2 || line.charAt(0) == '#')
continue;
int index = line.indexOf('=');
if (index == -1)
continue;
name = line.substring(0, index).trim();
value = line.substring(index + 1).trim();
switch (name) {
case "BeatmapDirectory":
beatmapDir = new File(value);
break;
case "ScreenResolution":
i = Integer.parseInt(value);
if (i >= 0 && i < resolutions.length)
resolutionIndex = i;
break;
// case "Fullscreen":
// fullscreen = Boolean.parseBoolean(value);
// break;
case "FrameSync":
i = Integer.parseInt(value);
if (i >= 0 && i <= targetFPS.length)
targetFPSindex = i;
break;
case "VolumeMusic":
i = Integer.parseInt(value);
if (i >= 0 && i <= 100)
musicVolume = i;
break;
case "Offset":
i = Integer.parseInt(value);
if (i >= -500 && i <= 500)
musicOffset = i;
break;
case "ScreenshotFormat":
i = Integer.parseInt(value);
if (i >= 0 && i < screenshotFormat.length)
screenshotFormatIndex = i;
break;
case "FpsCounter":
showFPS = Boolean.parseBoolean(value);
break;
case "HitLighting":
showHitLighting = Boolean.parseBoolean(value);
break;
case "ComboBurst":
showComboBursts = Boolean.parseBoolean(value);
break;
}
}
} catch (IOException e) {
Log.error(String.format("Failed to read file '%s'.", OPTIONS_FILE), e);
} catch (NumberFormatException e) {
Log.warn("Format error in options file.", e);
return;
}
}
/**
* (Over)writes user options to a file.
*/
public static void saveOptions() {
// only overwrite when needed
if (!optionsChanged)
return;
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(OPTIONS_FILE), "utf-8"))) {
// header
SimpleDateFormat dateFormat = new SimpleDateFormat("EEEE, MMMM dd, yyyy");
String date = dateFormat.format(new Date());
writer.write("# opsu! configuration");
writer.newLine();
writer.write("# last updated on ");
writer.write(date);
writer.newLine();
writer.newLine();
// options
if (beatmapDir != null) {
writer.write(String.format("BeatmapDirectory = %s", beatmapDir.getAbsolutePath()));
writer.newLine();
}
writer.write(String.format("ScreenResolution = %d", resolutionIndex));
writer.newLine();
// writer.write(String.format("Fullscreen = %b", fullscreen));
// writer.newLine();
writer.write(String.format("FrameSync = %d", targetFPSindex));
writer.newLine();
writer.write(String.format("VolumeMusic = %d", musicVolume));
writer.newLine();
writer.write(String.format("Offset = %d", musicOffset));
writer.newLine();
writer.write(String.format("ScreenshotFormat = %d", screenshotFormatIndex));
writer.newLine();
writer.write(String.format("FpsCounter = %b", showFPS));
writer.newLine();
writer.write(String.format("HitLighting = %b", showHitLighting));
writer.newLine();
writer.write(String.format("ComboBurst = %b", showComboBursts));
writer.newLine();
writer.close();
} catch (IOException e) {
Log.error(String.format("Failed to write to file '%s'.", OPTIONS_FILE), e);
}
}
}

View File

@@ -0,0 +1,601 @@
/*
* opsu! - an open-source osu! client
* Copyright (C) 2014 Jeffrey Han
*
* opsu! is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* opsu! is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with opsu!. If not, see <http://www.gnu.org/licenses/>.
*/
package itdelatrisu.opsu.states;
import itdelatrisu.opsu.GUIMenuButton;
import itdelatrisu.opsu.MusicController;
import itdelatrisu.opsu.Opsu;
import itdelatrisu.opsu.OsuFile;
import itdelatrisu.opsu.OsuGroupList;
import itdelatrisu.opsu.OsuGroupNode;
import itdelatrisu.opsu.OsuParser;
import org.lwjgl.opengl.Display;
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.gui.TextField;
import org.newdawn.slick.state.BasicGameState;
import org.newdawn.slick.state.StateBasedGame;
import org.newdawn.slick.state.transition.EmptyTransition;
import org.newdawn.slick.state.transition.FadeInTransition;
import org.newdawn.slick.state.transition.FadeOutTransition;
/**
* "Song Selection" state.
* <ul>
* <li>[Song] - start game (move to game state)
* <li>[Back] - return to main menu
* </ul>
*/
public class SongMenu extends BasicGameState {
/**
* The number of buttons to be shown on each screen.
*/
private static final int MAX_BUTTONS = 6;
/**
* Delay time, in milliseconds, between each search.
*/
private static final int SEARCH_DELAY = 300;
/**
* Current start node (topmost menu entry).
*/
private OsuGroupNode startNode;
/**
* Current focused (selected) node.
*/
private OsuGroupNode focusNode;
/**
* Button coordinate values.
*/
private float
buttonX, buttonY, buttonOffset,
buttonWidth, buttonHeight;
/**
* Sorting tab buttons (indexed by SORT_* constants).
*/
private GUIMenuButton[] sortTabs;
/**
* The current sort order (SORT_* constant).
*/
private byte currentSort;
/**
* The options button (to enter the "Game Options" menu).
*/
private GUIMenuButton optionsButton;
/**
* The search textfield.
*/
private TextField search;
/**
* Delay timer, in milliseconds, before running another search.
* This is overridden by character entry (reset) and 'esc' (immediate search).
*/
private int searchTimer;
/**
* Information text to display based on the search query.
*/
private String searchResultString;
/**
* Search icon.
*/
private Image searchIcon;
/**
* Music note icon.
*/
private Image musicNote;
// game-related variables
private GameContainer container;
private StateBasedGame game;
private Input input;
private int state;
public SongMenu(int state) {
this.state = state;
}
@Override
public void init(GameContainer container, StateBasedGame game)
throws SlickException {
this.container = container;
this.game = game;
this.input = container.getInput();
// initialize song list
currentSort = OsuGroupList.SORT_TITLE;
Opsu.groups.init(currentSort);
setFocus(Opsu.groups.getRandomNode(), -1, true);
int width = container.getWidth();
int height = container.getHeight();
// song button background & graphics context
Image menuBackground = new Image("menu-button-background.png").getScaledCopy(width / 2, height / 6);
OsuGroupNode.setBackground(menuBackground);
// song button coordinates
buttonX = width * 0.6f;
buttonY = height * 0.16f;
buttonWidth = menuBackground.getWidth();
buttonHeight = menuBackground.getHeight();
buttonOffset = (height * 0.8f) / MAX_BUTTONS;
// sorting tabs
sortTabs = new GUIMenuButton[OsuGroupList.SORT_MAX];
Image tab = new Image("selection-tab.png");
float tabScale = (height * 0.033f) / tab.getHeight();
tab = tab.getScaledCopy(tabScale);
float tabX = buttonX + (tab.getWidth() / 2f);
float tabY = (height * 0.15f) - (tab.getHeight() / 2f) - 2f;
float tabOffset = (width - buttonX) / sortTabs.length;
for (int i = 0; i < sortTabs.length; i++)
sortTabs[i] = new GUIMenuButton(tab, tabX + (i * tabOffset), tabY);
// search
searchTimer = 0;
searchResultString = "Type to search!";
searchIcon = new Image("search.png");
float iconScale = Options.FONT_BOLD.getLineHeight() * 2f / searchIcon.getHeight();
searchIcon = searchIcon.getScaledCopy(iconScale);
search = new TextField(
container, Options.FONT_DEFAULT,
(int) tabX + searchIcon.getWidth(), (int) ((height * 0.15f) - (tab.getHeight() * 5 / 2f)),
(int) (buttonWidth / 2), Options.FONT_DEFAULT.getHeight()
);
search.setBackgroundColor(Color.transparent);
search.setBorderColor(Color.transparent);
search.setTextColor(Color.white);
search.setConsumeEvents(false);
search.setMaxLength(60);
// options button
Image optionsIcon = new Image("options.png").getScaledCopy(iconScale);
optionsButton = new GUIMenuButton(optionsIcon, search.getX() - (optionsIcon.getWidth() * 1.5f), search.getY());
musicNote = new Image("music-note.png");
}
@Override
public void render(GameContainer container, StateBasedGame game, Graphics g)
throws SlickException {
g.setBackground(Color.black);
int width = container.getWidth();
int height = container.getHeight();
// background
if (focusNode != null)
focusNode.osuFiles.get(focusNode.osuFileIndex).drawBG(width, height, 1.0f);
// header setup
float lowerBound = height * 0.15f;
g.setColor(Options.COLOR_BLACK_ALPHA);
g.fillRect(0, 0, width, lowerBound);
g.setColor(Options.COLOR_BLUE_DIVIDER);
g.setLineWidth(2f);
g.drawLine(0, lowerBound, width, lowerBound);
g.resetLineWidth();
// header
if (focusNode != null) {
musicNote.draw();
int musicNoteWidth = musicNote.getWidth();
int musicNoteHeight = musicNote.getHeight();
String[] info = focusNode.getInfo();
g.setColor(Color.white);
Options.FONT_LARGE.drawString(
musicNoteWidth + 5, -3, info[0]);
float y1 = -3 + Options.FONT_LARGE.getHeight() * 0.75f;
Options.FONT_DEFAULT.drawString(
musicNoteWidth + 5, y1, info[1]);
Options.FONT_BOLD.drawString(
5, Math.max(y1 + 4, musicNoteHeight - 3), info[2]);
Options.FONT_DEFAULT.drawString(
5, musicNoteHeight + Options.FONT_BOLD.getLineHeight() - 9, info[3]);
Options.FONT_SMALL.drawString(
5, musicNoteHeight + Options.FONT_BOLD.getLineHeight() + Options.FONT_DEFAULT.getLineHeight() - 13, info[4]);
}
// song buttons
OsuGroupNode node = startNode;
for (int i = 0; i < MAX_BUTTONS && node != null; i++) {
node.draw(buttonX, buttonY + (i*buttonOffset), (node == focusNode));
node = node.next;
}
// options button
optionsButton.draw();
// sorting tabs
float tabTextY = sortTabs[0].getY() - (sortTabs[0].getImage().getHeight() / 2f);
for (int i = sortTabs.length - 1; i >= 0; i--) {
sortTabs[i].getImage().setAlpha((i == currentSort) ? 1.0f : 0.7f);
sortTabs[i].draw();
float tabTextX = sortTabs[i].getX() - (Options.FONT_MEDIUM.getWidth(OsuGroupList.SORT_NAMES[i]) / 2);
Options.FONT_MEDIUM.drawString(tabTextX, tabTextY, OsuGroupList.SORT_NAMES[i], Color.white);
}
// search
Options.FONT_BOLD.drawString(
search.getX(), search.getY() - Options.FONT_BOLD.getLineHeight(),
searchResultString, Color.white
);
searchIcon.draw(search.getX() - searchIcon.getWidth(),
search.getY() - Options.FONT_DEFAULT.getLineHeight());
g.setColor(Color.white);
search.render(container, g);
// scroll bar
if (focusNode != null) {
float scrollStartY = height * 0.16f;
float scrollEndY = height * 0.82f;
g.setColor(Options.COLOR_BLACK_ALPHA);
g.fillRoundRect(width - 10, scrollStartY, 5, scrollEndY, 4);
g.setColor(Color.white);
g.fillRoundRect(width - 10, scrollStartY + (scrollEndY * startNode.index / Opsu.groups.size()), 5, 20, 4);
}
// back button
Options.getBackButton().draw();
Options.drawFPS();
}
@Override
public void update(GameContainer container, StateBasedGame game, int delta)
throws SlickException {
// search
search.setFocus(true);
searchTimer += delta;
if (searchTimer >= SEARCH_DELAY) {
searchTimer = 0;
// store the start/focus nodes
OsuGroupNode oldFocusNode = null;
int oldFileIndex = -1;
if (focusNode != null) {
oldFocusNode = Opsu.groups.getBaseNode(focusNode.index);
oldFileIndex = focusNode.osuFileIndex;
}
if (Opsu.groups.search(search.getText())) {
// empty search
if (search.getText().isEmpty())
searchResultString = "Type to search!";
// search produced new list: re-initialize it
startNode = focusNode = null;
if (Opsu.groups.size() > 0) {
Opsu.groups.init(currentSort);
if (search.getText().isEmpty()) { // cleared search
// use previous start/focus if possible
if (oldFocusNode != null)
setFocus(oldFocusNode, oldFileIndex + 1, true);
else
setFocus(Opsu.groups.getRandomNode(), -1, true);
} else {
searchResultString = String.format("%d matches found!", Opsu.groups.size());
setFocus(Opsu.groups.getRandomNode(), -1, true);
}
} else if (!search.getText().isEmpty())
searchResultString = "No matches found. Hit 'esc' to reset.";
}
}
// slide buttons
int height = container.getHeight();
float targetY = height * 0.16f;
if (buttonY > targetY) {
buttonY -= height * delta / 20000f;
if (buttonY < targetY)
buttonY = targetY;
} else if (buttonY < targetY) {
buttonY += height * delta / 20000f;
if (buttonY > targetY)
buttonY = targetY;
}
}
@Override
public int getID() { return state; }
@Override
public void mousePressed(int button, int x, int y) {
// check mouse button
if (button != Input.MOUSE_LEFT_BUTTON)
return;
// back
if (Options.getBackButton().contains(x, y)) {
game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
return;
}
// options
if (optionsButton.contains(x, y)) {
game.enterState(Opsu.STATE_OPTIONS, new EmptyTransition(), new FadeInTransition(Color.black));
return;
}
if (focusNode == null)
return;
// sorting buttons
for (byte i = 0; i < sortTabs.length; i++) {
if (sortTabs[i].contains(x, y) && i != currentSort) {
currentSort = i;
OsuGroupNode oldFocusBase = Opsu.groups.getBaseNode(focusNode.index);
int oldFocusFileIndex = focusNode.osuFileIndex;
focusNode = null;
Opsu.groups.init(i);
setFocus(oldFocusBase, oldFocusFileIndex + 1, true);
}
}
for (int i = 0; i < MAX_BUTTONS; i++) {
if ((x > buttonX && x < buttonX + buttonWidth) &&
(y > buttonY + (i*buttonOffset) && y < buttonY + (i*buttonOffset) + buttonHeight)) {
OsuGroupNode node = Opsu.groups.getNode(startNode, i);
if (node == null) // out of bounds
break;
int expandedIndex = Opsu.groups.getExpandedIndex();
// clicked node is already expanded
if (node.index == expandedIndex) {
if (node.osuFileIndex == -1) {
// check bounds
int max = Math.max(Opsu.groups.size() - MAX_BUTTONS, 0);
if (startNode.index > max)
startNode = Opsu.groups.getBaseNode(max);
// if group button clicked, undo expansion
Opsu.groups.expand(node.index);
} else if (node.osuFileIndex == focusNode.osuFileIndex) {
// if already focused, load the beatmap
startGame(focusNode.osuFiles.get(focusNode.osuFileIndex));
} else {
// focus the node
setFocus(node, 0, false);
}
break;
}
// if current start node is expanded,
// set it to the base node before undoing the expansion
if (startNode.index == expandedIndex) {
int max = Math.max(Opsu.groups.size() - MAX_BUTTONS, 0);
if (startNode.index > max) // check bounds
startNode = Opsu.groups.getBaseNode(max);
else
startNode = Opsu.groups.getBaseNode(startNode.index);
}
setFocus(node, -1, false);
break;
}
}
}
@Override
public void keyPressed(int key, char c) {
switch (key) {
case Input.KEY_ESCAPE:
if (!search.getText().isEmpty()) {
search.setText("");
searchTimer = SEARCH_DELAY;
} else
game.enterState(Opsu.STATE_MAINMENU, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
break;
case Input.KEY_F1:
game.enterState(Opsu.STATE_OPTIONS, new EmptyTransition(), new FadeInTransition(Color.black));
break;
case Input.KEY_F2:
setFocus(Opsu.groups.getRandomNode(), -1, true);
break;
case Input.KEY_F12:
Options.takeScreenShot();
break;
case Input.KEY_ENTER:
if (focusNode != null)
startGame(focusNode.osuFiles.get(focusNode.osuFileIndex));
break;
case Input.KEY_DOWN:
changeIndex(1);
break;
case Input.KEY_UP:
changeIndex(-1);
break;
case Input.KEY_RIGHT:
if (focusNode == null)
break;
OsuGroupNode next = focusNode.next;
if (next != null) {
setFocus(next, (next.index == focusNode.index) ? 0 : 1, false);
changeIndex(1);
}
break;
case Input.KEY_LEFT:
if (focusNode == null)
break;
OsuGroupNode prev = focusNode.prev;
if (prev != null) {
if (prev.index == focusNode.index && prev.osuFileIndex < 0) {
// skip the group node
prev = prev.prev;
if (prev == null) // this is the first node
break;
setFocus(prev, prev.osuFiles.size(), true);
// move the start node forward if off the screen
int size = prev.osuFiles.size();
while (size-- >= MAX_BUTTONS)
startNode = startNode.next;
} else {
setFocus(prev, 0, false);
changeIndex(-1);
}
}
break;
case Input.KEY_NEXT:
changeIndex(MAX_BUTTONS);
break;
case Input.KEY_PRIOR:
changeIndex(-MAX_BUTTONS);
break;
default:
// wait for user to finish typing
if (Character.isLetterOrDigit(c) || key == Input.KEY_BACK)
searchTimer = 0;
break;
}
}
@Override
public void mouseDragged(int oldx, int oldy, int newx, int newy) {
// check mouse button (right click scrolls faster)
int multiplier;
if (input.isMouseButtonDown(Input.MOUSE_RIGHT_BUTTON))
multiplier = 4;
else if (input.isMouseButtonDown(Input.MOUSE_LEFT_BUTTON))
multiplier = 1;
else
return;
int diff = newy - oldy;
if (diff != 0) {
diff = ((diff < 0) ? 1 : -1) * multiplier;
changeIndex(diff);
}
}
@Override
public void mouseWheelMoved(int newValue) {
changeIndex((newValue < 0) ? 1 : -1);
}
@Override
public void enter(GameContainer container, StateBasedGame game)
throws SlickException {
Display.setTitle(game.getTitle());
}
/**
* Shifts the startNode forward (+) or backwards (-) by a given number of nodes.
* Initiates sliding "animation" by shifting the button Y position.
*/
private void changeIndex(int shift) {
while (shift != 0) {
if (startNode == null)
break;
int height = container.getHeight();
if (shift < 0 && startNode.prev != null) {
startNode = startNode.prev;
buttonY += buttonOffset / 4;
if (buttonY > height * 0.18f)
buttonY = height * 0.18f;
shift++;
} else if (shift > 0 && startNode.next != null &&
Opsu.groups.getNode(startNode, MAX_BUTTONS) != null) {
startNode = startNode.next;
buttonY -= buttonOffset / 4;
if (buttonY < height * 0.14f)
buttonY = height * 0.14f;
shift--;
} else
break;
}
return;
}
/**
* Sets a new focus node.
* @param node the base node; it will be expanded if it isn't already
* @param pos the OsuFile element to focus; if out of bounds, it will be randomly chosen
* @param flag if true, startNode will be set to the song group node
* @return the old focus node
*/
public OsuGroupNode setFocus(OsuGroupNode node, int pos, boolean flag) {
if (node == null)
return null;
OsuGroupNode oldFocus = focusNode;
// expand node before focusing it
if (node.index != Opsu.groups.getExpandedIndex())
Opsu.groups.expand(node.index);
// check pos bounds
int length = node.osuFiles.size();
if (pos < 0 || pos > length) // set a random pos
pos = (int) (Math.random() * length) + 1;
if (flag)
startNode = node;
focusNode = Opsu.groups.getNode(node, pos);
MusicController.play(focusNode.osuFiles.get(focusNode.osuFileIndex), true);
// check startNode bounds
if (focusNode.index - startNode.index == MAX_BUTTONS - 1)
changeIndex(1);
while (startNode.index >= Opsu.groups.size() + length + 1 - MAX_BUTTONS &&
startNode.prev != null)
changeIndex(-1);
return oldFocus;
}
/**
* Starts the game.
* @param osu the OsuFile to send to the game
*/
private void startGame(OsuFile osu) {
if (MusicController.isConverting())
return;
Display.setTitle(String.format("%s - %s", game.getTitle(), osu.toString()));
OsuParser.parseHitObjects(osu);
Game.setOsuFile(osu);
Game.setRestart(Game.RESTART_NEW);
game.enterState(Opsu.STATE_GAME, new FadeOutTransition(Color.black), new FadeInTransition(Color.black));
}
}