From e62822ab5fd2b413943e290c5d7c574abbb112c3 Mon Sep 17 00:00:00 2001
From: Jeffrey Han <itdelatrisu@gmail.com>
Date: Sat, 7 Jan 2017 04:40:17 -0500
Subject: [PATCH] Implemented osu! HP algorithms. (base:
 ppy/osu-iPhone@e72787f)

Signed-off-by: Jeffrey Han <itdelatrisu@gmail.com>
---
 src/itdelatrisu/opsu/GameData.java            | 119 +++++------
 src/itdelatrisu/opsu/Utils.java               |  17 ++
 .../beatmap/BeatmapHPDropRateCalculator.java  | 199 ++++++++++++++++++
 src/itdelatrisu/opsu/beatmap/Health.java      | 167 +++++++++++++++
 src/itdelatrisu/opsu/objects/Spinner.java     |  19 +-
 src/itdelatrisu/opsu/states/Game.java         |  38 ++--
 6 files changed, 470 insertions(+), 89 deletions(-)
 create mode 100644 src/itdelatrisu/opsu/beatmap/BeatmapHPDropRateCalculator.java
 create mode 100644 src/itdelatrisu/opsu/beatmap/Health.java

diff --git a/src/itdelatrisu/opsu/GameData.java b/src/itdelatrisu/opsu/GameData.java
index db6af0cc..74a8e125 100644
--- a/src/itdelatrisu/opsu/GameData.java
+++ b/src/itdelatrisu/opsu/GameData.java
@@ -23,6 +23,7 @@
 import itdelatrisu.opsu.audio.SoundController;
 import itdelatrisu.opsu.audio.SoundEffect;
 import itdelatrisu.opsu.beatmap.Beatmap;
+import itdelatrisu.opsu.beatmap.Health;
 import itdelatrisu.opsu.beatmap.HitObject;
 import itdelatrisu.opsu.downloads.Updater;
 import itdelatrisu.opsu.objects.curves.Curve;
@@ -47,9 +48,6 @@
  * Holds game data and renders all related elements.
  */
 public class GameData {
-	/** Delta multiplier for steady HP drain. */
-	public static final float HP_DRAIN_MULTIPLIER = 1 / 200f;
-
 	/** Time, in milliseconds, for a hit result to remain existent. */
 	public static final int HITRESULT_TIME = 833;
 
@@ -149,9 +147,11 @@ public Image getMenuImage() {
 		HIT_300G             = 6,   // Geki
 		HIT_SLIDER10         = 7,
 		HIT_SLIDER30         = 8,
-		HIT_MAX              = 9,   // not a hit result
-		HIT_SLIDER_REPEAT    = 10,  // not a hit result
-		HIT_ANIMATION_RESULT = 11;  // not a hit result
+		HIT_MAX              = 9,
+		HIT_SLIDER_REPEAT    = 10,
+		HIT_ANIMATION_RESULT = 11,
+		HIT_SPINNERSPIN      = 12,
+		HIT_SPINNERBONUS     = 13;
 
 	/** Hit result-related images (indexed by HIT_* constants to HIT_MAX). */
 	private Image[] hitResults;
@@ -298,19 +298,12 @@ public HitObjectResult(int time, int result, float x, float y, Color color,
 	/** Displayed game score percent (for animation, slightly behind score percent). */
 	private float scorePercentDisplay;
 
-	/** Current health bar percentage. */
-	private float health;
-
-	/** Displayed health (for animation, slightly behind health). */
-	private float healthDisplay;
+	/** Health. */
+	private Health health = new Health();
 
 	/** The difficulty multiplier used in the score formula. */
 	private int difficultyMultiplier = 2;
 
-	/** Beatmap HPDrainRate value. (0:easy ~ 10:hard) */
-	@SuppressWarnings("unused")
-	private float drainRate = 5f;
-
 	/** Default text symbol images. */
 	private Image[] defaultSymbols;
 
@@ -383,10 +376,8 @@ public void clear() {
 		score = 0;
 		scoreDisplay = 0;
 		scorePercentDisplay = 0f;
-		health = 100f;
-		healthDisplay = 100f;
+		health.reset();
 		hitResultCount = new int[HIT_MAX];
-		drainRate = 5f;
 		if (hitResultList != null) {
 			for (HitObjectResult hitResult : hitResultList) {
 				if (hitResult.curve != null)
@@ -477,12 +468,6 @@ public void loadImages() {
 	 */
 	public Image getScoreSymbolImage(char c) { return scoreSymbols.get(c); }
 
-	/**
-	 * Sets the health drain rate.
-	 * @param drainRate the new drain rate [0-10]
-	 */
-	public void setDrainRate(float drainRate) { this.drainRate = drainRate; }
-
 	/**
 	 * Sets the array of hit result offsets.
 	 * @param hitResultOffset the time offset array (of size {@link #HIT_MAX})
@@ -713,7 +698,7 @@ public void drawGameElements(Graphics g, boolean breakPeriod, boolean firstObjec
 
 		if (!breakPeriod && !relaxAutoPilot) {
 			// scorebar
-			float healthRatio = healthDisplay / 100f;
+			float healthRatio = health.getHealthDisplay() / 100f;
 			if (firstObject) {  // gradually move ki before map begins
 				if (firstObjectTime >= 1500 && trackPosition < firstObjectTime - 500)
 					healthRatio = (float) trackPosition / (firstObjectTime - 500);
@@ -736,9 +721,9 @@ public void drawGameElements(Graphics g, boolean breakPeriod, boolean firstObjec
 			colourCropped.setAlpha(1f);
 
 			Image ki = null;
-			if (health >= 50f)
+			if (health.getHealth() >= 50f)
 				ki = GameImage.SCOREBAR_KI.getImage();
-			else if (health >= 25f)
+			else if (health.getHealth() >= 25f)
 				ki = GameImage.SCOREBAR_KI_DANGER.getImage();
 			else
 				ki = GameImage.SCOREBAR_KI_DANGER2.getImage();
@@ -1104,32 +1089,35 @@ private void drawHitAnimations(HitObjectResult hitResult, int trackPosition) {
 	}
 
 	/**
-	 * Changes health by a given percentage, modified by drainRate.
-	 * @param percent the health percentage
+	 * Returns the current health percentage.
 	 */
-	public void changeHealth(float percent) {
-		// TODO: drainRate formula
-		health += percent;
-		if (health > 100f)
-			health = 100f;
-		else if (health < 0f)
-			health = 0f;
-	}
+	public float getHealthPercent() { return health.getHealth(); }
 
 	/**
-	 * Returns the current health percentage.
+	 * Sets the health modifiers.
+	 * @param hpDrainRate the HP drain rate
+	 * @param hpMultiplierNormal the normal HP multiplier
+	 * @param hpMultiplierComboEnd the combo-end HP multiplier
 	 */
-	public float getHealth() { return health; }
+	public void setHealthModifiers(float hpDrainRate, float hpMultiplierNormal, float hpMultiplierComboEnd) {
+		health.setModifiers(hpDrainRate, hpMultiplierNormal, hpMultiplierComboEnd);
+	}
 
 	/**
 	 * 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 || GameMod.NO_FAIL.isActive() || GameMod.AUTO.isActive() ||
+		return (health.getHealth() > 0f || GameMod.NO_FAIL.isActive() || GameMod.AUTO.isActive() ||
 		        GameMod.RELAX.isActive() || GameMod.AUTOPILOT.isActive());
 	}
 
+	/**
+	 * Changes health by a raw value.
+	 * @param value the health value
+	 */
+	public void changeHealth(float value) { health.changeHealth(value); }
+
 	/**
 	 * Changes score by a raw value (not affected by other modifiers).
 	 * @param value the score value
@@ -1237,18 +1225,7 @@ public void updateDisplays(int delta) {
 		}
 
 		// health display
-		if (healthDisplay != health) {
-			float shift = delta / 15f;
-			if (healthDisplay < health) {
-				healthDisplay += shift;
-				if (healthDisplay > health)
-					healthDisplay = health;
-			} else {
-				healthDisplay -= shift;
-				if (healthDisplay < health)
-					healthDisplay = health;
-			}
-		}
+		health.update(delta);
 
 		// combo burst
 		if (comboBurstIndex > -1 && Options.isComboBurstEnabled()) {
@@ -1328,7 +1305,7 @@ private void resetComboStreak() {
 			SoundController.playSound(SoundEffect.COMBOBREAK);
 		combo = 0;
 		if (GameMod.SUDDEN_DEATH.isActive())
-			health = 0f;
+			health.setHealth(0f);
 	}
 
 	/**
@@ -1370,7 +1347,6 @@ public void sendSliderTickResult(int time, int result, float x, float y, HitObje
 		switch (result) {
 		case HIT_SLIDER30:
 			hitValue = 30;
-			changeHealth(2f);
 			SoundController.playHitSound(
 					hitObject.getEdgeHitSoundType(repeat),
 					hitObject.getSampleSet(repeat),
@@ -1378,7 +1354,6 @@ public void sendSliderTickResult(int time, int result, float x, float y, HitObje
 			break;
 		case HIT_SLIDER10:
 			hitValue = 10;
-			changeHealth(1f);
 			SoundController.playHitSound(HitSound.SLIDERTICK);
 			break;
 		case HIT_MISS:
@@ -1392,6 +1367,7 @@ public void sendSliderTickResult(int time, int result, float x, float y, HitObje
 			// calculate score and increment combo streak
 			score += hitValue;
 			incrementComboStreak();
+			health.changeHealthForHit(result);
 
 			if (!Options.isPerfectHitBurstEnabled())
 				;  // hide perfect hit results
@@ -1401,6 +1377,29 @@ public void sendSliderTickResult(int time, int result, float x, float y, HitObje
 		fullObjectCount++;
 	}
 
+	/**
+	 * Handles a spinner spin result.
+	 * @param result the hit result (HIT_* constants)
+	 */
+	public void sendSpinnerSpinResult(int result) {
+		int hitValue = 0;
+		switch (result) {
+		case HIT_SPINNERSPIN:
+			hitValue = 100;
+			SoundController.playSound(SoundEffect.SPINNERSPIN);
+			break;
+		case HIT_SPINNERBONUS:
+			hitValue = 1100;
+			SoundController.playSound(SoundEffect.SPINNERBONUS);
+			break;
+		default:
+			return;
+		}
+
+		score += hitValue;
+		health.changeHealthForHit(result);
+	}
+
 	/**
 	 * Returns the score for a hit based on the following score formula:
 	 * <p>
@@ -1474,11 +1473,9 @@ private int handleHitResult(int time, int result, float x, float y, Color color,
 		switch (result) {
 		case HIT_300:
 			hitValue = 300;
-			changeHealth(5f);
 			break;
 		case HIT_100:
 			hitValue = 100;
-			changeHealth(2f);
 			comboEnd |= 1;
 			break;
 		case HIT_50:
@@ -1487,7 +1484,6 @@ private int handleHitResult(int time, int result, float x, float y, Color color,
 			break;
 		case HIT_MISS:
 			hitValue = 0;
-			changeHealth(-10f);
 			comboEnd |= 2;
 			resetComboStreak();
 			break;
@@ -1505,6 +1501,7 @@ private int handleHitResult(int time, int result, float x, float y, Color color,
 			if (!noIncrementCombo)
 				incrementComboStreak();
 		}
+		health.changeHealthForHit(result);
 		hitResultCount[result]++;
 		fullObjectCount++;
 
@@ -1512,16 +1509,16 @@ private int handleHitResult(int time, int result, float x, float y, Color color,
 		if (end) {
 			if (comboEnd == 0) {
 				result = HIT_300G;
-				changeHealth(15f);
+				health.changeHealthForHit(HIT_300G);
 				hitResultCount[result]++;
 			} else if ((comboEnd & 2) == 0) {
 				if (result == HIT_100) {
 					result = HIT_100K;
-					changeHealth(10f);
+					health.changeHealthForHit(HIT_100K);
 					hitResultCount[result]++;
 				} else if (result == HIT_300) {
 					result = HIT_300K;
-					changeHealth(10f);
+					health.changeHealthForHit(HIT_300K);
 					hitResultCount[result]++;
 				}
 			}
diff --git a/src/itdelatrisu/opsu/Utils.java b/src/itdelatrisu/opsu/Utils.java
index 9e85899d..90c5253f 100644
--- a/src/itdelatrisu/opsu/Utils.java
+++ b/src/itdelatrisu/opsu/Utils.java
@@ -226,6 +226,23 @@ public static float lerp(float a, float b, float t) {
 		return a * (1 - t) + b * t;
 	}
 
+	/**
+	 * Maps a difficulty value to the given range.
+	 * @param difficulty the difficulty value
+	 * @param min the min
+	 * @param mid the mid
+	 * @param max the max
+	 * @author peppy (ppy/osu-iPhone:OsuFunctions.m)
+	 */
+	public static float mapDifficultyRange(float difficulty, float min, float mid, float max) {
+		if (difficulty > 5f)
+			return mid + (max - mid) * (difficulty - 5f) / 5f;
+		else if (difficulty < 5f)
+			return mid - (mid - min) * (5f - difficulty) / 5f;
+		else
+			return mid;
+	}
+
 	/**
 	 * Returns true if a game input key is pressed (mouse/keyboard left/right).
 	 * @return true if pressed
diff --git a/src/itdelatrisu/opsu/beatmap/BeatmapHPDropRateCalculator.java b/src/itdelatrisu/opsu/beatmap/BeatmapHPDropRateCalculator.java
new file mode 100644
index 00000000..9e2033b3
--- /dev/null
+++ b/src/itdelatrisu/opsu/beatmap/BeatmapHPDropRateCalculator.java
@@ -0,0 +1,199 @@
+/*
+ * opsu! - an open-source osu! client
+ * Copyright (C) 2014, 2015, 2016 Jeffrey Han
+ *
+ * opsu! is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * opsu! is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with opsu!.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package itdelatrisu.opsu.beatmap;
+
+import itdelatrisu.opsu.GameData;
+import itdelatrisu.opsu.Utils;
+import itdelatrisu.opsu.db.BeatmapDB;
+
+/**
+ * osu's HP drop rate algorithm.
+ *
+ * @author peppy (ppy/osu-iPhone:OsuFiletype.m)
+ */
+public class BeatmapHPDropRateCalculator {
+	/** The beatmap. */
+	private final Beatmap beatmap;
+
+	/** The HP drain rate. */
+	private final float hpDrainRate;
+
+	/** The overall difficulty. */
+	private final float overallDifficulty;
+
+	/** The HP drop rate. */
+	private float hpDropRate;
+
+	/** The normal HP multiplier. */
+	private float hpMultiplierNormal;
+
+	/** The combo-end HP multiplier. */
+	private float hpMultiplierComboEnd;
+
+	/**
+	 * Constructor. Call {@link #calculate()} to run all computations.
+	 * <p>
+	 * If any parts of the beatmap have not yet been loaded (e.g. timing points,
+	 * hit objects), they will be loaded here.
+	 * @param beatmap the beatmap
+	 * @param hpDrainRate the HP drain rate
+	 */
+	public BeatmapHPDropRateCalculator(Beatmap beatmap, float hpDrainRate, float overallDifficulty) {
+		this.beatmap = beatmap;
+		this.hpDrainRate = hpDrainRate;
+		this.overallDifficulty = overallDifficulty;
+		if (beatmap.timingPoints == null)
+			BeatmapDB.load(beatmap, BeatmapDB.LOAD_ARRAY);
+		BeatmapParser.parseHitObjects(beatmap);
+	}
+
+	/** Returns the HP drop rate. */
+	public float getHpDropRate() { return hpDropRate; }
+
+	/** Returns the normal HP multiplier. */
+	public float getHpMultiplierNormal() { return hpMultiplierNormal; }
+
+	/** Returns the combo-end HP multiplier. */
+	public float getHpMultiplierComboEnd() { return hpMultiplierComboEnd; }
+
+	/** Calculates the HP drop rate for the beatmap. */
+	public void calculate() {
+		float lowestHpEver = Utils.mapDifficultyRange(hpDrainRate, 195, 160, 60);
+		float lowestHpComboEnd = Utils.mapDifficultyRange(hpDrainRate, 198, 170, 80);
+		float lowestHpEnd = Utils.mapDifficultyRange(hpDrainRate, 198, 180, 80);
+		float hpRecoveryAvailable = Utils.mapDifficultyRange(hpDrainRate, 8, 4, 0);
+		int difficultyPreEmpt = (int) Utils.mapDifficultyRange(overallDifficulty, 1800, 1200, 450);
+
+		float testDrop = 0.05f;
+		Health health = new Health();
+		hpMultiplierNormal = hpMultiplierComboEnd = 1.0f;
+
+		while (true) {
+			health.reset();
+			health.setModifiers(hpDrainRate, hpMultiplierNormal, hpMultiplierComboEnd);
+			double lowestHp = health.getRawHealth();
+			int lastTime = beatmap.objects[0].getTime() - difficultyPreEmpt;
+			int comboTooLowCount = 0;
+			boolean fail = false;
+			int timingPointIndex = 0;
+			float beatLengthBase = 1f, beatLength = 1f;
+
+			for (int i = 0; i < beatmap.objects.length; i++) {
+				HitObject hitObject = beatmap.objects[i];
+
+				// breaks
+				int breakTime = 0;
+				if (beatmap.breaks != null) {
+					for (int j = 0; j < beatmap.breaks.size(); j += 2) {
+						int breakStart = beatmap.breaks.get(j), breakEnd = beatmap.breaks.get(j+1);
+						if (breakStart >= lastTime && breakEnd <= hitObject.getTime()) {
+							breakTime = breakEnd - breakStart;
+							break;
+						}
+					}
+				}
+				health.changeHealth(-testDrop * (hitObject.getTime() - lastTime - breakTime));
+
+				// pass beatLength to hit objects
+				int hitObjectTime = hitObject.getTime();
+				while (timingPointIndex < beatmap.timingPoints.size()) {
+					TimingPoint timingPoint = beatmap.timingPoints.get(timingPointIndex);
+					if (timingPoint.getTime() > hitObjectTime)
+						break;
+					if (!timingPoint.isInherited())
+						beatLengthBase = beatLength = timingPoint.getBeatLength();
+					else
+						beatLength = beatLengthBase * timingPoint.getSliderMultiplier();
+					timingPointIndex++;
+				}
+
+				// compute end time
+				int endTime;
+				if (hitObject.isCircle())
+					endTime = hitObject.getTime();
+				else if (hitObject.isSlider()) {
+					float sliderTime = hitObject.getSliderTime(beatmap.sliderMultiplier, beatLength);
+					float sliderTimeTotal = sliderTime * hitObject.getRepeatCount();
+					endTime = hitObject.getTime() + (int) sliderTimeTotal;
+				} else
+					endTime = hitObject.getEndTime();
+				lastTime = endTime;
+
+				if (health.getRawHealth() < lowestHp)
+					lowestHp = health.getRawHealth();
+
+				if (health.getRawHealth() <= lowestHpEver) {
+					fail = true;
+					testDrop *= 0.96f;
+					break;
+				}
+
+				health.changeHealth(-testDrop * (endTime - hitObject.getTime()));
+
+				// hit objects
+				if (hitObject.isSlider()) {
+					float tickLengthDiv = 100f * beatmap.sliderMultiplier / beatmap.sliderTickRate / (beatLength / beatLengthBase);
+					int tickCount = (int) Math.ceil(hitObject.getPixelLength() / tickLengthDiv) - 1;
+					for (int j = 0; j < hitObject.getRepeatCount(); j++)
+						health.changeHealthForHit(GameData.HIT_SLIDER30);
+					for (int j = 0; j < tickCount * hitObject.getRepeatCount(); j++)
+						health.changeHealthForHit(GameData.HIT_SLIDER10);
+				} else if (hitObject.isSpinner()) {
+					float spinsPerMinute = 100 + (beatmap.overallDifficulty * 15);
+					int rotationsNeeded = (int) (spinsPerMinute * (hitObject.getEndTime() - hitObject.getTime()) / 60000f);
+					for (int j = 0; j < rotationsNeeded; j++)
+						health.changeHealthForHit(GameData.HIT_SPINNERSPIN);
+				}
+				health.changeHealthForHit(GameData.HIT_300);
+				if (i == beatmap.objects.length - 1 || beatmap.objects[i + 1].isNewCombo()) {
+					health.changeHealthForHit(GameData.HIT_300G);
+					if (health.getRawHealth() < lowestHpComboEnd) {
+						if (++comboTooLowCount > 2) {
+							fail = true;
+							hpMultiplierNormal *= 1.03;
+							hpMultiplierComboEnd *= 1.07;
+							break;
+						}
+					}
+				}
+			}
+
+			if (!fail && health.getRawHealth() < lowestHpEnd) {
+				fail = true;
+				testDrop *= 0.94f;
+				hpMultiplierNormal *= 1.01;
+				hpMultiplierComboEnd *= 1.01;
+			}
+
+			double recovery = (health.getUncappedRawHealth() - Health.HP_MAX) / beatmap.objects.length;
+			if (!fail && recovery < hpRecoveryAvailable) {
+				fail = true;
+				testDrop *= 0.96;
+				hpMultiplierNormal *= 1.01;
+				hpMultiplierComboEnd *= 1.02;
+			}
+
+			if (fail)
+				continue;
+
+			hpDropRate = testDrop;
+			break;
+		}
+	}
+}
diff --git a/src/itdelatrisu/opsu/beatmap/Health.java b/src/itdelatrisu/opsu/beatmap/Health.java
new file mode 100644
index 00000000..28f93869
--- /dev/null
+++ b/src/itdelatrisu/opsu/beatmap/Health.java
@@ -0,0 +1,167 @@
+/*
+ * opsu! - an open-source osu! client
+ * Copyright (C) 2014, 2015, 2016 Jeffrey Han
+ *
+ * opsu! is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * opsu! is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with opsu!.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package itdelatrisu.opsu.beatmap;
+
+import itdelatrisu.opsu.GameData;
+import itdelatrisu.opsu.Utils;
+
+/**
+ * Health bar.
+ *
+ * @author peppy (ppy/osu-iPhone:OsuAppDelegate.m,OsuFunctions.h)
+ */
+public class Health {
+	/** Maximum HP. */
+	public static final float HP_MAX = 200f;
+
+	/** HP increase values. */
+	private static final float
+		HP_50           = 0.4f,
+		HP_100          = 2.2f,
+		HP_300          = 6f,
+		HP_100K         = 10f,   // 100-Katu
+		HP_300K         = 10f,   // 300-Katu
+		HP_300G         = 14f,   // Geki
+		HP_SLIDER10     = 3f,
+		HP_SLIDER30     = 4f,
+		HP_SPINNERSPIN  = 1.7f,
+		HP_SPINNERBONUS = 2f;
+
+	/** Current health. */
+	private float health;
+
+	/** Displayed health (for animation, slightly behind health). */
+	private float healthDisplay;
+
+	/** Uncapped health. */
+	private float healthUncapped;
+
+	/** The HP drain rate (0:easy ~ 10:hard) */
+	private float hpDrainRate;
+
+	/** The normal HP multiplier. */
+	private float hpMultiplierNormal;
+
+	/** The combo-end HP multiplier. */
+	private float hpMultiplierComboEnd;
+
+	/** Constructor. */
+	public Health() {
+		reset();
+	}
+
+	/**
+	 * Sets the health modifiers.
+	 * @param hpDrainRate the HP drain rate
+	 * @param hpMultiplierNormal the normal HP multiplier
+	 * @param hpMultiplierComboEnd the combo-end HP multiplier
+	 */
+	public void setModifiers(float hpDrainRate, float hpMultiplierNormal, float hpMultiplierComboEnd) {
+		this.hpDrainRate = hpDrainRate;
+		this.hpMultiplierNormal = hpMultiplierNormal;
+		this.hpMultiplierComboEnd = hpMultiplierComboEnd;
+	}
+
+	/**
+	 * Updates displayed health based on a delta value.
+	 * @param delta the delta interval since the last call
+	 */
+	public void update(int delta) {
+		float multiplier = delta / 32f;
+		if (healthDisplay < health)
+			healthDisplay = Utils.clamp(healthDisplay + (health - healthDisplay) * multiplier, 0f, health);
+		else if (healthDisplay > health) {
+			if (health < 10f)
+				multiplier *= 10f;
+			healthDisplay = Utils.clamp(healthDisplay - (healthDisplay - health) * multiplier, health, HP_MAX);
+		}
+	}
+
+	/** Returns the current health percentage. */
+	public float getHealth() { return health / HP_MAX * 100f; }
+
+	/** Returns the displayed health percentage. */
+	public float getHealthDisplay() { return healthDisplay / HP_MAX * 100f; }
+
+	/** Returns the current health value. */
+	public float getRawHealth() { return health; }
+
+	/** Returns the current uncapped health value. */
+	public float getUncappedRawHealth() { return healthUncapped; }
+
+	/** Sets the current health value. */
+	public void setHealth(float value) { health = healthUncapped = value; }
+
+	/** Changes the current health by the given value. */
+	public void changeHealth(float value) {
+		health = Utils.clamp(health + value, 0f, HP_MAX);
+		healthUncapped += value;
+	}
+
+	/**
+	 * Changes the current health value based on a hit.
+	 * @param type the hit type (HIT_* constants)
+	 */
+	public void changeHealthForHit(int type) {
+		switch (type) {
+		case GameData.HIT_MISS:
+			changeHealth(Utils.mapDifficultyRange(hpDrainRate, -6, -25, -40));
+			break;
+		case GameData.HIT_50:
+			changeHealth(hpMultiplierNormal * Utils.mapDifficultyRange(hpDrainRate, HP_50 * 8, HP_50, HP_50));
+			break;
+		case GameData.HIT_100:
+			changeHealth(hpMultiplierNormal * Utils.mapDifficultyRange(hpDrainRate, HP_100 * 8, HP_100, HP_100));
+			break;
+		case GameData.HIT_300:
+			changeHealth(hpMultiplierNormal * HP_300);
+			break;
+		case GameData.HIT_100K:
+			changeHealth(hpMultiplierComboEnd * HP_100K);
+			break;
+		case GameData.HIT_300K:
+			changeHealth(hpMultiplierComboEnd * HP_300K);
+			break;
+		case GameData.HIT_300G:
+			changeHealth(hpMultiplierComboEnd * HP_300G);
+			break;
+		case GameData.HIT_SLIDER10:
+			changeHealth(hpMultiplierNormal * HP_SLIDER10);
+			break;
+		case GameData.HIT_SLIDER30:
+			changeHealth(hpMultiplierNormal * HP_SLIDER30);
+			break;
+		case GameData.HIT_SPINNERSPIN:
+			changeHealth(hpMultiplierNormal * HP_SPINNERSPIN);
+			break;
+		case GameData.HIT_SPINNERBONUS:
+			changeHealth(hpMultiplierNormal * HP_SPINNERBONUS);
+			break;
+		default:
+			break;
+		}
+	}
+
+	/** Resets health. */
+	public void reset() {
+		health = healthDisplay = healthUncapped = HP_MAX;
+		hpDrainRate = 5f;
+		hpMultiplierNormal = hpMultiplierComboEnd = 1f;
+	}
+}
diff --git a/src/itdelatrisu/opsu/objects/Spinner.java b/src/itdelatrisu/opsu/objects/Spinner.java
index 9eb12d62..c625b5d7 100644
--- a/src/itdelatrisu/opsu/objects/Spinner.java
+++ b/src/itdelatrisu/opsu/objects/Spinner.java
@@ -336,8 +336,6 @@ else if (angleDiff > Math.PI)
 			drawnRPM = (int) (Math.abs(rotationPerSec * 60));
 
 			rotate(rotationAngle);
-			if (Math.abs(rotationAngle) > 0.00001f)
-				data.changeHealth(DELTA_UPDATE_TIME * GameData.HP_DRAIN_MULTIPLIER);
 		}
 
 		//TODO may need to update 1 more time when the spinner ends?
@@ -380,21 +378,12 @@ private void rotate(float angle) {
 
 		// added one whole rotation...
 		if (Math.floor(newRotations) > rotations) {
-			//TODO seems to give 1100 points per spin but also an extra 100 for some spinners
-			if (newRotations > rotationsNeeded) {  // extra rotations
-				data.changeScore(1000);
-				SoundController.playSound(SoundEffect.SPINNERBONUS);
-			}
-			data.changeScore(100);
-			SoundController.playSound(SoundEffect.SPINNERSPIN);
+			if (newRotations > rotationsNeeded)  // extra rotations
+				data.sendSpinnerSpinResult(GameData.HIT_SPINNERBONUS);
+			else
+				data.sendSpinnerSpinResult(GameData.HIT_SPINNERSPIN);
 		}
 
-		// extra 100 for some spinners (mostly wrong)
-//		if (Math.floor(newRotations + 0.5f) > rotations + 0.5f) {
-//			if (newRotations + 0.5f > rotationsNeeded)  // extra rotations
-//				data.changeScore(100);
-//		}
-
 		rotations = newRotations;
 	}
 
diff --git a/src/itdelatrisu/opsu/states/Game.java b/src/itdelatrisu/opsu/states/Game.java
index 21ff371f..04981adb 100644
--- a/src/itdelatrisu/opsu/states/Game.java
+++ b/src/itdelatrisu/opsu/states/Game.java
@@ -31,6 +31,7 @@
 import itdelatrisu.opsu.audio.SoundController;
 import itdelatrisu.opsu.audio.SoundEffect;
 import itdelatrisu.opsu.beatmap.Beatmap;
+import itdelatrisu.opsu.beatmap.BeatmapHPDropRateCalculator;
 import itdelatrisu.opsu.beatmap.BeatmapParser;
 import itdelatrisu.opsu.beatmap.HitObject;
 import itdelatrisu.opsu.beatmap.TimingPoint;
@@ -291,6 +292,12 @@ public enum Restart {
 	/** Timer after game has finished, before changing states. */
 	private AnimatedValue gameFinishedTimer = new AnimatedValue(2500, 0, 1, AnimationEquation.LINEAR);
 
+	/** The HP drop rate. */
+	private float hpDropRate = 0.05f;
+
+	/** The last track position. */
+	private int lastTrackPosition = 0;
+
 	/** Music position bar background colors. */
 	private static final Color
 		MUSICBAR_NORMAL = new Color(12, 9, 10, 0.25f),
@@ -506,7 +513,7 @@ else if (breakIndex > 1) {
 				trackPosition - breakTime > 2000 &&
 				trackPosition - breakTime < 5000) {
 				// show break start
-				if (data.getHealth() >= 50) {
+				if (data.getHealthPercent() >= 50) {
 					GameImage.SECTION_PASS.getImage().drawCentered(width / 2f, height / 2f);
 					if (!breakSound) {
 						SoundController.playSound(SoundEffect.SECTIONPASS);
@@ -765,8 +772,8 @@ else if (!container.hasFocus()) {
 
 		// "Easy" mod: multiple "lives"
 		if (GameMod.EASY.isActive() && deathTime > -1) {
-			if (data.getHealth() < 99f) {
-				data.changeHealth(delta / 10f);
+			if (data.getHealthPercent() < 99f) {
+				data.changeHealth(delta / 5f);
 				data.updateDisplays(delta);
 				return;
 			}
@@ -826,6 +833,8 @@ else if (!gameFinished) {
 			}
 		}
 
+		lastTrackPosition = trackPosition;
+
 		// update in-game scoreboard
 		if (previousScores != null && trackPosition > firstObjectTime) {
 			// show scoreboard if selected, and always in break
@@ -944,7 +953,9 @@ else if (replayFrames != null) {
 		}
 
 		// drain health
-		data.changeHealth(delta * -1 * GameData.HP_DRAIN_MULTIPLIER);
+		if (lastTrackPosition > 0)
+			data.changeHealth((trackPosition - lastTrackPosition) * -1 * hpDropRate);
+
 		if (!data.isAlive()) {
 			// "Easy" mod
 			if (GameMod.EASY.isActive() && !GameMod.SUDDEN_DEATH.isActive()) {
@@ -1099,6 +1110,7 @@ else if (key == Options.getGameKeyRight())
 						;
 					objectIndex--;
 					lastReplayTime = beatmap.objects[objectIndex].getTime();
+					lastTrackPosition = checkpoint;
 				} catch (SlickException e) {
 					ErrorHandler.error("Failed to load checkpoint.", e, false);
 				}
@@ -1170,6 +1182,7 @@ else if (Options.isReplaySeekingEnabled() && !GameMod.AUTO.isActive() && musicPo
 				SoundController.mute(true);  // mute sounds while seeking
 				float pos = (y - musicBarY) / musicBarHeight * beatmap.endTime;
 				MusicController.setPosition((int) pos);
+				lastTrackPosition = (int) pos;
 				isSeeking = true;
 			}
 			return;
@@ -1665,6 +1678,7 @@ public void resetGameData() {
 		scoreboardStarStream.clear();
 		gameFinished = false;
 		gameFinishedTimer.setTime(0);
+		lastTrackPosition = 0;
 
 		System.gc();
 	}
@@ -1769,19 +1783,17 @@ private void setMapModifiers() {
 
 		// overallDifficulty (hit result time offsets)
 		hitResultOffset = new int[GameData.HIT_MAX];
-		hitResultOffset[GameData.HIT_300]  = (int) (79.5f - (overallDifficulty * 6));
-		hitResultOffset[GameData.HIT_100]  = (int) (139.5f - (overallDifficulty * 8));
-		hitResultOffset[GameData.HIT_50]   = (int) (199.5f - (overallDifficulty * 10));
+		hitResultOffset[GameData.HIT_300]  = (int) Utils.mapDifficultyRange(overallDifficulty, 80, 50, 20);
+		hitResultOffset[GameData.HIT_100]  = (int) Utils.mapDifficultyRange(overallDifficulty, 140, 100, 60);
+		hitResultOffset[GameData.HIT_50]   = (int) Utils.mapDifficultyRange(overallDifficulty, 200, 150, 100);
 		hitResultOffset[GameData.HIT_MISS] = (int) (500 - (overallDifficulty * 10));
-		//final float mult = 0.608f;
-		//hitResultOffset[GameData.HIT_300]  = (int) ((128 - (overallDifficulty * 9.6)) * mult);
-		//hitResultOffset[GameData.HIT_100]  = (int) ((224 - (overallDifficulty * 12.8)) * mult);
-		//hitResultOffset[GameData.HIT_50]   = (int) ((320 - (overallDifficulty * 16)) * mult);
-		//hitResultOffset[GameData.HIT_MISS] = (int) ((1000 - (overallDifficulty * 10)) * mult);
 		data.setHitResultOffset(hitResultOffset);
 
 		// HPDrainRate (health change)
-		data.setDrainRate(HPDrainRate);
+		BeatmapHPDropRateCalculator hpCalc = new BeatmapHPDropRateCalculator(beatmap, HPDrainRate, overallDifficulty);
+		hpCalc.calculate();
+		hpDropRate = hpCalc.getHpDropRate();
+		data.setHealthModifiers(HPDrainRate, hpCalc.getHpMultiplierNormal(), hpCalc.getHpMultiplierComboEnd());
 
 		// difficulty multiplier (scoring)
 		data.calculateDifficultyMultiplier(beatmap.HPDrainRate, beatmap.circleSize, beatmap.overallDifficulty);