From 80e7539debc3abd9d117822e961f2abd56e84a8d Mon Sep 17 00:00:00 2001
From: George FunBook <gkurelic@gmail.com>
Date: Thu, 18 Feb 2021 13:58:16 -0600
Subject: [PATCH] add prompt, finalize login UI

---
 Project.xml               |   8 +-
 source/MainMenuState.hx   | 166 ++++++++++++++++++++++++++++++++++++--
 source/NGio.hx            | 156 +++++++++++++++++++++++++----------
 source/TitleState.hx      |  42 +++++-----
 source/ui/MenuItemList.hx | 164 +++++++++++++++++++++++++++----------
 source/ui/Prompt.hx       | 120 +++++++++++++++++++++++++++
 6 files changed, 538 insertions(+), 118 deletions(-)
 create mode 100644 source/ui/Prompt.hx

diff --git a/Project.xml b/Project.xml
index f943faede..bb9ef84de 100644
--- a/Project.xml
+++ b/Project.xml
@@ -166,6 +166,12 @@
 
 
 	<!-- <haxedef name="SKIP_TO_PLAYSTATE" if="debug" /> -->
-	<haxedef name="NG_LOGIN" if="newgrounds" />
+	
+	<!-- Enables Ng.core.verbose -->
+	<!-- <haxedef name="NG_VERBOSE" /> -->
+	<!-- Enables a NG debug session, so medals don't permently unlock -->
+	<!-- <haxedef name="NG_DEBUG" /> -->
+	<!-- pretends that the saved session Id was expired, forcing the reconnect prompt -->
+	<!-- <haxedef name="NG_FORCE_EXPIRED_SESSION" if="debug" /> -->
 	
 </project>
diff --git a/source/MainMenuState.hx b/source/MainMenuState.hx
index a2d484650..db00ce94d 100644
--- a/source/MainMenuState.hx
+++ b/source/MainMenuState.hx
@@ -1,5 +1,7 @@
 package;
 
+import NGio;
+import flixel.ui.FlxButton;
 import flixel.FlxG;
 import flixel.FlxObject;
 import flixel.FlxSprite;
@@ -17,12 +19,13 @@ import io.newgrounds.NG;
 import lime.app.Application;
 
 import ui.MenuItemList;
+import ui.Prompt;
 
 using StringTools;
 
 class MainMenuState extends MusicBeatState
 {
-	var menuItems:MenuItemList;
+	var menuItems:MainMenuItemList;
 
 	var magenta:FlxSprite;
 	var camFollow:FlxObject;
@@ -63,7 +66,7 @@ class MainMenuState extends MusicBeatState
 		add(magenta);
 		// magenta.scrollFactor.set();
 
-		menuItems = new MenuItemList('FNF_main_menu_assets');
+		menuItems = new MainMenuItemList('FNF_main_menu_assets');
 		add(menuItems);
 		menuItems.onChange.add(onMenuItemChange);
 		menuItems.onAcceptPress.add(function(_)
@@ -74,17 +77,18 @@ class MainMenuState extends MusicBeatState
 		
 		var hasPopupBlocker = #if web true #else false #end;
 		
-		menuItems.addItem('story mode', function () startExitState(new StoryMenuState()));
-		menuItems.addItem('freeplay', function () startExitState(new FreeplayState()));
+		menuItems.enabled = false;// disable for intro
+		menuItems.createItem('story mode', function () startExitState(new StoryMenuState()));
+		menuItems.createItem('freeplay', function () startExitState(new FreeplayState()));
 		// addMenuItem('options', function () startExitState(new OptionMenu()));
 		#if (!switch)
-			menuItems.addItem('donate', selectDonate, hasPopupBlocker);
+			menuItems.createItem('donate', selectDonate, hasPopupBlocker);
 		#end
 		#if newgrounds
 			if (NG.core.loggedIn)
-				menuItems.addItem("logout", selectLogout);
+				menuItems.createItem("logout", selectLogout);
 			else
-				menuItems.addItem("login", selectLogin, hasPopupBlocker);
+				menuItems.createItem("login", selectLogin);
 		#end
 		
 		// center vertically
@@ -109,6 +113,16 @@ class MainMenuState extends MusicBeatState
 		super.create();
 	}
 	
+	override function finishTransIn()
+	{
+		super.finishTransIn();
+		
+		menuItems.enabled = true;
+		
+		if (NGio.savedSessionFailed)
+			showSavedSessionFailed();
+	}
+	
 	function onMenuItemChange(selected:MenuItem)
 	{
 		camFollow.setPosition(selected.getGraphicMidpoint().x, selected.getGraphicMidpoint().y);
@@ -123,13 +137,101 @@ class MainMenuState extends MusicBeatState
 		#end
 	}
 	
+	#if newgrounds
 	function selectLogin()
 	{
+		showNgPrompt(true);
+	}
+	
+	function showSavedSessionFailed()
+	{
+		showNgPrompt(false);
+	}
+	
+	function showNgPrompt(fromUi:Bool)
+	{
+		menuItems.enabled = false;
+		
+		var prompt = new Prompt("prompt-ng_login", "Talking to server...", None);
+		prompt.closeCallback = function() menuItems.enabled = true;
+		openSubState(prompt);
+		function onLoginComplete(result:ConnectionResult)
+		{
+			switch (result)
+			{
+				case Success:
+					menuItems.resetItem("login", "logout", selectLogout);
+					prompt.setText("Login Successful");
+					prompt.setButtons(Ok);
+					prompt.onYes = prompt.close;
+				case Fail(msg):
+					trace("Login Error:" + msg);
+					prompt.setText("Login failed");
+					prompt.setButtons(Ok);
+					prompt.onYes = prompt.close;
+				case Cancelled:
+					if (prompt != null)
+					{
+						prompt.setText("Login cancelled by user");
+						prompt.setButtons(Ok);
+						prompt.onYes = prompt.close;
+					}
+			}
+		}
+		
+		NGio.login
+		(
+			function popupLauncher(openPassportUrl)
+			{
+				var choiceMsg = fromUi
+					? #if web "Log in to Newgrounds?" #else null #end // User-input needed to allow popups
+					: "Your session has expired.\n Please login again.";
+				
+				if (choiceMsg != null)
+				{
+					prompt.setText(choiceMsg);
+					prompt.setButtons(Yes_No);
+					#if web
+					prompt.buttons.getItem("yes").fireInstantly = true;
+					#end
+					prompt.onYes = function()
+					{
+						prompt.setText("Connecting...");
+						prompt.setButtons(None);
+						openPassportUrl();
+					};
+					prompt.onNo = function()
+					{
+						prompt.close();
+						prompt = null;
+						NG.core.cancelLoginRequest();
+					};
+				}
+				else
+				{
+					prompt.setText("Connecting...");
+					openPassportUrl();
+				}
+			},
+			onLoginComplete
+		);
 	}
 	
 	function selectLogout()
 	{
+		menuItems.enabled = false;
+		var prompt = new Prompt("prompt-ng_login", "Log out of " + NG.core.user.name + "?", Yes_No);
+		prompt.closeCallback = function () menuItems.enabled = true;
+		prompt.onYes = function()
+		{
+			NGio.logout();
+			prompt.close();
+			menuItems.resetItem("logout", "login", selectLogin);
+		};
+		prompt.onNo = prompt.close;
+		openSubState(prompt);
 	}
+	#end
 	
 	function startExitState(state:FlxState)
 	{
@@ -156,9 +258,57 @@ class MainMenuState extends MusicBeatState
 			FlxG.sound.music.volume += 0.5 * FlxG.elapsed;
 		}
 
-		if (menuItems.active && controls.BACK)
+		if (menuItems.enabled && controls.BACK)
 			FlxG.switchState(new TitleState());
 
 		super.update(elapsed);
 	}
 }
+
+
+private class MainMenuItemList extends MenuTypedItemList<MainMenuItem>
+{
+	public var atlas:FlxAtlasFrames;
+	
+	public function new (atlas)
+	{
+		super(Vertical);
+		
+		if (Std.is(atlas, String))
+			this.atlas = Paths.getSparrowAtlas(cast atlas);
+		else
+			this.atlas = cast atlas;
+	}
+	
+	public function createItem(x = 0.0, y = 0.0, name:String, callback, fireInstantly = false)
+	{
+		var i = length;
+		var item = new MainMenuItem(x, y, name, atlas, callback);
+		item.fireInstantly = fireInstantly;
+		item.ID = i;
+		
+		return addItem(name, item);
+	}
+	
+	override function destroy()
+	{
+		super.destroy();
+		atlas = null;
+	}
+}
+private class MainMenuItem extends MenuItem
+{
+	public function new(x = 0.0, y = 0.0, name, atlas, callback)
+	{
+		super(x, y, name, atlas, callback);
+		scrollFactor.set();
+	}
+	
+	override function changeAnim(anim:String)
+	{
+		super.changeAnim(anim);
+		// position by center
+		centerOrigin();
+		offset.copyFrom(origin);
+	}
+}
\ No newline at end of file
diff --git a/source/NGio.hx b/source/NGio.hx
index ec48ca613..b05b7e8aa 100644
--- a/source/NGio.hx
+++ b/source/NGio.hx
@@ -6,6 +6,7 @@ import flixel.util.FlxTimer;
 import io.newgrounds.NG;
 import io.newgrounds.NGLite;
 import io.newgrounds.components.ScoreBoardComponent.Period;
+import io.newgrounds.objects.Error;
 import io.newgrounds.objects.Medal;
 import io.newgrounds.objects.Score;
 import io.newgrounds.objects.ScoreBoard;
@@ -22,6 +23,11 @@ using StringTools;
  */
 class NGio
 {
+	/**
+	 * True, if the saved sessionId was used in the initial login, and failed to connect.
+	 * Used in MainMenuState to show a popup to establish a new connection
+	 */
+	public static var savedSessionFailed(default, null):Bool = false;
 	public static var isLoggedIn:Bool = false;
 	public static var scoreboardsLoaded:Bool = false;
 
@@ -31,54 +37,68 @@ class NGio
 	public static var ngScoresLoaded(default, null):FlxSignal = new FlxSignal();
 
 	public static var GAME_VER:String = "";
-	public static var GAME_VER_NUMS:String = '';
-	public static var gotOnlineVer:Bool = false;
+	
 
-	public static function noLogin(api:String)
+	static public function checkVersion(callback:String->Void)
 	{
-		trace('INIT NOLOGIN');
+		trace('checking NG.io version');
 		GAME_VER = "v" + Application.current.meta.get('version');
 
-		if (api.length != 0)
-		{
-			NG.create(api);
-
-			new FlxTimer().start(2, function(tmr:FlxTimer)
+		NG.core.calls.app.getCurrentVersion(GAME_VER)
+			.addDataHandler(function(response)
 			{
-				var call = NG.core.calls.app.getCurrentVersion(GAME_VER).addDataHandler(function(response:Response<GetCurrentVersionResult>)
-				{
-					GAME_VER = response.result.data.currentVersion;
-					GAME_VER_NUMS = GAME_VER.split(" ")[0].trim();
-					trace('CURRENT NG VERSION: ' + GAME_VER);
-					gotOnlineVer = true;
-				});
-
-				call.send();
-			});
-		}
+				GAME_VER = response.result.data.currentVersion;
+				trace('CURRENT NG VERSION: ' + GAME_VER);
+				callback(GAME_VER);
+			})
+			.send();
 	}
 
-	public function new(api:String, encKey:String)
+	static public function init(api:String, encKey:String)
 	{
+		var api = APIStuff.API;
+		if (api == null || api.length == 0)
+		{
+			trace("Missing Newgrounds API key, aborting connection");
+			return;
+		}
 		trace("connecting to newgrounds");
 		
-		var sessionId:String = NGLite.getSessionId();
-		if (sessionId != null)
-			trace("found web session id");
+		#if NG_FORCE_EXPIRED_SESSION
+			var sessionId:String = "fake_session_id";
+			function onSessionFail(error:Error)
+			{
+				trace("Forcing an expired saved session. "
+					+ "To disable, comment out NG_FORCE_EXPIRED_SESSION in Project.xml");
+				savedSessionFailed = true;
+			}
+		#else
+			var sessionId:String = NGLite.getSessionId();
+			if (sessionId != null)
+				trace("found web session id");
+			
+			#if (debug)
+			if (sessionId == null && APIStuff.SESSION != null)
+			{
+				trace("using debug session id");
+				sessionId = APIStuff.SESSION;
+			}
+			#end
 		
-		#if (debug)
-		if (sessionId == null && APIStuff.SESSION != null)
-		{
-			sessionId = APIStuff.SESSION;
-			trace("using debug session id");
-		}
+			var onSessionFail:Error->Void = null;
+			if (sessionId == null && FlxG.save.data.sessionId != null)
+			{
+				trace("using stored session id");
+				sessionId = FlxG.save.data.sessionId;
+				onSessionFail = function (error) savedSessionFailed = true;
+			}
 		#end
 		
+		NG.create(api, sessionId, #if NG_DEBUG true #else false #end, onSessionFail);
 		
-		NG.create(api, sessionId);
-		NG.core.verbose = true;
+		#if NG_VERBOSE NG.core.verbose = true; #end
 		// Set the encryption cipher/format to RC4/Base64. AES128 and Hex are not implemented yet
-		NG.core.initEncryption(encKey); // Found in you NG project view
+		NG.core.initEncryption(APIStuff.EncKey); // Found in you NG project view
 
 		if (NG.core.attemptingLogin)
 		{
@@ -89,21 +109,53 @@ class NGio
 			NG.core.onLogin.add(onNGLogin);
 		}
 		//GK: taking out auto login, adding a login button to the main menu
-		else
+		// else
+		// {
+		// 	/* They are NOT playing on newgrounds.com, no session id was found. We must start one manually, if we want to.
+		// 	 * Note: This will cause a new browser window to pop up where they can log in to newgrounds
+		// 	 */
+		// 	NG.core.requestLogin(onNGLogin);
+		// }
+	}
+	
+	/**
+	 * Attempts to log in to newgrounds by requesting a new session ID, only call if no session ID was found automatically
+	 * @param popupLauncher The function to call to open the login url, must be inside
+	 * a user input event or the popup blocker will block it.
+	 * @param onComplete A callback with the result of the connection.
+	 */
+	static public function login(?popupLauncher:(Void->Void)->Void, onComplete:ConnectionResult->Void)
+	{
+		trace("Logging in manually");
+		var onPending:Void->Void = null;
+		if (popupLauncher != null)
 		{
-			/* They are NOT playing on newgrounds.com, no session id was found. We must start one manually, if we want to.
-			 * Note: This will cause a new browser window to pop up where they can log in to newgrounds
-			 */
-			NG.core.requestLogin(onNGLogin);
+			onPending = function () popupLauncher(NG.core.openPassportUrl);
 		}
+		
+		var onSuccess:Void->Void = onNGLogin;
+		var onFail:Error->Void = null;
+		var onCancel:Void->Void = null;
+		if (onComplete != null)
+		{
+			onSuccess = function ()
+			{
+				onNGLogin();
+				onComplete(Success);
+			}
+			onFail = function (e) onComplete(Fail(e.message));
+			onCancel = function() onComplete(Cancelled);
+		}
+		
+		NG.core.requestLogin(onSuccess, onPending, onFail, onCancel);
 	}
 
-	function onNGLogin():Void
+	static function onNGLogin():Void
 	{
 		trace('logged in! user:${NG.core.user.name}');
 		isLoggedIn = true;
 		FlxG.save.data.sessionId = NG.core.sessionId;
-		// FlxG.save.flush();
+		FlxG.save.flush();
 		// Load medals then call onNGMedalFetch()
 		NG.core.requestMedals(onNGMedalFetch);
 
@@ -112,9 +164,17 @@ class NGio
 
 		ngDataLoaded.dispatch();
 	}
+	
+	static public function logout()
+	{
+		NG.core.logOut();
+		
+		FlxG.save.data.sessionId = null;
+		FlxG.save.flush();
+	}
 
 	// --- MEDALS
-	function onNGMedalFetch():Void
+	static function onNGMedalFetch():Void
 	{
 		/*
 			// Reading medal info
@@ -132,7 +192,7 @@ class NGio
 	}
 
 	// --- SCOREBOARDS
-	function onNGBoardsFetch():Void
+	static function onNGBoardsFetch():Void
 	{
 		/*
 			// Reading medal info
@@ -174,7 +234,7 @@ class NGio
 		}
 	}
 
-	function onNGScoresFetch():Void
+	static function onNGScoresFetch():Void
 	{
 		scoreboardsLoaded = true;
 
@@ -209,3 +269,13 @@ class NGio
 		}
 	}
 }
+
+enum ConnectionResult
+{
+	/** Log in successful */
+	Success;
+	/** Could not login */
+	Fail(msg:String);
+	/** User cancelled the login */
+	Cancelled;
+}
diff --git a/source/TitleState.hx b/source/TitleState.hx
index 8a8aeb42b..ffc691947 100644
--- a/source/TitleState.hx
+++ b/source/TitleState.hx
@@ -54,17 +54,11 @@ class TitleState extends MusicBeatState
 
 		super.create();
 
-		NGio.noLogin(APIStuff.API);
-
-		#if ng
-		var ng:NGio = new NGio(APIStuff.API, APIStuff.EncKey);
-		trace('NEWGROUNDS LOL');
-		#end
-
 		FlxG.save.bind('funkin', 'ninjamuffin99');
-
 		Highscore.load();
 
+		NGio.init(APIStuff.API, APIStuff.EncKey);
+
 		if (FlxG.save.data.weekUnlocked != null)
 		{
 			// FIX LATER!!!
@@ -264,22 +258,26 @@ class TitleState extends MusicBeatState
 			transitioning = true;
 			// FlxG.sound.music.stop();
 
-			new FlxTimer().start(2, function(tmr:FlxTimer)
+			if (!OutdatedSubState.leftState)
 			{
-				// Check if version is outdated
-
-				var version:String = "v" + Application.current.meta.get('version');
-
-				if (version.trim() != NGio.GAME_VER_NUMS && !OutdatedSubState.leftState)
+				NGio.checkVersion(function(version)
 				{
-					trace('OLD VERSION!');
-					FlxG.switchState(new OutdatedSubState());
-				}
-				else
-				{
-					FlxG.switchState(new MainMenuState());
-				}
-			});
+					// Check if version is outdated
+
+					var localVersion:String = "v" + Application.current.meta.get('version');
+					var onlineVersion = version.split(" ")[0].trim();
+
+					if (version.trim() != onlineVersion)
+					{
+						trace('OLD VERSION!');
+						FlxG.switchState(new OutdatedSubState());
+					}
+					else
+					{
+						FlxG.switchState(new MainMenuState());
+					}
+				});
+			}
 			// FlxG.sound.play(Paths.music('titleShoot'), 0.7);
 		}
 
diff --git a/source/ui/MenuItemList.hx b/source/ui/MenuItemList.hx
index 241aac43d..a79ca1f2b 100644
--- a/source/ui/MenuItemList.hx
+++ b/source/ui/MenuItemList.hx
@@ -10,53 +10,110 @@ import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxSignal;
 
-typedef ItemAsset = OneOfTwo<String, FlxAtlasFrames>
+typedef AtlasAsset = OneOfTwo<String, FlxAtlasFrames>;
 
 class MenuItemList extends MenuTypedItemList<MenuItem>
 {
-	public function addItem(x = 0.0, y = 0.0, name, callback, fireInstantly = false)
+	public var atlas:FlxAtlasFrames;
+	
+	public function new (atlas, navControls:NavControls = Vertical)
 	{
-		var i = length;
-		var menuItem = new MenuItem(name, tex, callback, x, y);
-		menuItem.fireInstantly = fireInstantly;
-		menuItem.ID = i;
-		add(menuItem);
+		super(navControls);
 		
-		if (i == selectedIndex)
-			menuItem.select();
-		
-		return menuItem;
+		if (Std.is(atlas, String))
+			this.atlas = Paths.getSparrowAtlas(cast atlas);
+		else
+			this.atlas = cast atlas;
+	}
+	
+	public function createItem(x = 0.0, y = 0.0, name, callback, fireInstantly = false)
+	{
+		var item = new MenuItem(x, y, name, atlas, callback);
+		item.fireInstantly = fireInstantly;
+		return addItem(name, item);
+	}
+	
+	override function destroy()
+	{
+		super.destroy();
+		atlas = null;
 	}
 }
 
 class MenuTypedItemList<T:MenuItem> extends FlxTypedGroup<T>
 {
-	public var tex:FlxAtlasFrames;
-	public var selectedIndex = 0;
+	public var selectedIndex(default, null) = 0;
+	/** Called when a new item is highlighted */
 	public var onChange(default, null) = new FlxTypedSignal<T->Void>();
+	/** Called when an item is accepted */
 	public var onAcceptPress(default, null) = new FlxTypedSignal<T->Void>();
+	/** The navigation control scheme to use */
+	public var navControls:NavControls;
+	/** Set to false to disable nav control */
+	public var enabled:Bool = true;
 	
-	public function new (asset:ItemAsset)
+	var byName = new Map<String, T>();
+	/** Set to true, internally to disable controls, without affecting vars like `enabled` */
+	var busy:Bool = false;
+	
+	public function new (navControls:NavControls = Vertical)
 	{
+		this.navControls = navControls;
 		super();
+	}
+	
+	function addItem(name:String, item:T):T
+	{
+		if (length == selectedIndex)
+			item.select();
 		
-		if (Std.is(asset, String))
-			tex = Paths.getSparrowAtlas(cast asset);
-		else
-			tex = cast asset;
+		byName[name] = item;
+		return add(item);
+	}
+	
+	public function resetItem(oldName:String, newName:String, ?callback:Void->Void):T
+	{
+		if (!byName.exists(oldName))
+			throw "No item named:" + oldName;
+		
+		var item = byName[oldName];
+		byName.remove(oldName);
+		byName[newName] = item;
+		item.setItem(newName, callback);
+		
+		return item;
 	}
 	
 	override function update(elapsed:Float)
 	{
 		super.update(elapsed);
 		
+		if (enabled && !busy)
+			updateControls();
+	}
+	
+	inline function updateControls()
+	{
 		var controls = PlayerSettings.player1.controls;
 		
-		if (controls.UP_P)
-			prev();
-
-		if (controls.DOWN_P)
-			next();
+		switch(navControls)
+		{
+			case Vertical:
+			{
+				if (controls.UP_P  ) prev();
+				if (controls.DOWN_P) next();
+			}
+			case Horizontal:
+			{
+				if (controls.LEFT_P ) prev();
+				if (controls.RIGHT_P) next();
+			}
+			case Both:
+			{
+				if (controls.LEFT_P  || controls.UP_P  ) prev();
+				if (controls.RIGHT_P || controls.DOWN_P) next();
+			}
+		}
 
 		if (controls.ACCEPT)
 			accept();
@@ -71,12 +128,12 @@ class MenuTypedItemList<T:MenuItem> extends FlxTypedGroup<T>
 			selected.callback();
 		else
 		{
-			active = false;
+			busy = true;
 			FlxG.sound.play(Paths.sound('confirmMenu'));
 			FlxFlicker.flicker(selected, 1, 0.06, true, false, function(_)
 			{
+				busy = false;
 				selected.callback();
-				active = true;
 			});
 		}
 	}
@@ -87,24 +144,35 @@ class MenuTypedItemList<T:MenuItem> extends FlxTypedGroup<T>
 	function changeItem(amount:Int)
 	{
 		FlxG.sound.play(Paths.sound('scrollMenu'));
+		var index = selectedIndex + amount;
+		if (index >= length)
+			index = 0;
+		else if (index < 0)
+			index = length - 1;
+		
+		selectItem(index);
+	}
+	
+	public function selectItem(index:Int)
+	{
 		members[selectedIndex].idle();
 		
-		selectedIndex += amount;
-
-		if (selectedIndex >= length)
-			selectedIndex = 0;
-		else if (selectedIndex < 0)
-			selectedIndex = length - 1;
+		selectedIndex = index;
 		
 		var selected = members[selectedIndex];
 		selected.select();
 		onChange.dispatch(selected);
 	}
 	
+	public function getItem(name:String)
+	{
+		return byName[name];
+	}
+	
 	override function destroy()
 	{
 		super.destroy();
-		tex = null;
+		byName.clear();
 	}
 }
 
@@ -116,41 +184,49 @@ class MenuItem extends flixel.FlxSprite
 	 */
 	public var fireInstantly = false;
 	
-	public function new (name, tex, callback, x = 0.0, y = 0.0)
+	public function new (x = 0.0, y = 0.0, name, tex, callback)
 	{
 		super(x, y);
 		
 		frames = tex;
 		setItem(name, callback);
+		antialiasing = true;
 	}
 	
-	public function setItem(name:String, callback:Void->Void)
+	public function setItem(name:String, ?callback:Void->Void)
 	{
-		this.callback = callback;
+		if (callback != null)
+			this.callback = callback;
+		
+		var selected = animation.curAnim != null && animation.curAnim.name == "selected";
 		
 		animation.addByPrefix('idle', '$name basic', 24);
 		animation.addByPrefix('selected', '$name white', 24);
 		idle();
-		scrollFactor.set();
-		antialiasing = true;
+		if (selected)
+			select();
 	}
 	
-	function updateSize()
+	function changeAnim(anim:String)
 	{
+		animation.play(anim);
 		updateHitbox();
-		centerOrigin();
-		offset.copyFrom(origin);
 	}
 	
 	public function idle()
 	{
-		animation.play('idle');
-		updateSize();
+		changeAnim('idle');
 	}
 	
 	public function select()
 	{
-		animation.play('selected');
-		updateSize();
+		changeAnim('selected');
 	}
+}
+
+enum NavControls
+{
+	Horizontal;
+	Vertical;
+	Both;
 }
\ No newline at end of file
diff --git a/source/ui/Prompt.hx b/source/ui/Prompt.hx
new file mode 100644
index 000000000..dea595252
--- /dev/null
+++ b/source/ui/Prompt.hx
@@ -0,0 +1,120 @@
+package ui;
+
+import flixel.FlxSprite;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.text.FlxText;
+import flixel.util.FlxColor;
+
+class Prompt extends flixel.FlxSubState
+{
+	inline static var MARGIN = 100;
+	
+	public var onYes:Void->Void;
+	public var onNo:Void->Void;
+	public var buttons:MenuItemList;
+	public var field:FlxText;
+	public var back:FlxSprite;
+	
+	var style:ButtonStyle;
+	
+	public function new (atlas, text:String, style:ButtonStyle = Ok)
+	{
+		this.style = style;
+		super();
+		
+		var texture:FlxAtlasFrames;
+		if (Std.is(atlas, String))
+			texture = Paths.getSparrowAtlas(cast atlas);
+		else
+			texture = cast atlas;
+		
+		back = new FlxSprite();
+		back.frames = texture;
+		back.animation.addByPrefix("idle", "back");
+		back.scrollFactor.set(0, 0);
+		
+		buttons = new MenuItemList(texture, Horizontal);
+		
+		field = new FlxText();
+		field.setFormat(Paths.font("vcr.ttf"), 64, FlxColor.BLACK, CENTER);
+		field.text = text;
+		field.scrollFactor.set(0, 0);
+	}
+	
+	override function create()
+	{
+		super.create();
+		
+		back.animation.play("idle");
+		back.updateHitbox();
+		back.screenCenter(XY);
+		add(back);
+		
+		field.y = back.y + MARGIN;
+		field.screenCenter(X);
+		add(field);
+		
+		createButtons();
+		add(buttons);
+	}
+	
+	public function setButtons(style:ButtonStyle)
+	{
+		if (this.style != style)
+		{
+			this.style = style;
+			createButtons();
+		}
+	}
+	
+	function createButtons()
+	{
+		// destroy previous buttons
+		while(buttons.members.length > 0)
+		{
+			buttons.remove(buttons.members[0], true).destroy();
+		}
+		
+		switch(style)
+		{
+			case Yes_No         : createButtonsHelper("yes", "no");
+			case Ok             : createButtonsHelper("ok");
+			case Custom(yes, no): createButtonsHelper(yes, no);
+			case None           : buttons.exists = false;
+		};
+	}
+	
+	function createButtonsHelper(yes:String, ?no:String)
+	{
+		buttons.exists = true;
+		// pass anonymous functions rather than the current callbacks, in case they change later
+		var yesButton = buttons.createItem(yes, function() onYes());
+		yesButton.screenCenter(X);
+		yesButton.y = back.y + back.height - yesButton.height - MARGIN;
+		yesButton.scrollFactor.set(0, 0);
+		if (no != null)
+		{
+			// place right
+			yesButton.x = back.x + back.width - yesButton.width - MARGIN;
+			
+			var noButton = buttons.createItem(no, function() onNo());
+			noButton.x = back.x + MARGIN;
+			noButton.y = back.y + back.height - noButton.height - MARGIN;
+			noButton.scrollFactor.set(0, 0);
+		}
+	}
+	
+	public function setText(text:String)
+	{
+		field.text = text;
+		field.screenCenter(X);
+	}
+}
+
+enum ButtonStyle
+{
+    Ok;
+    Yes_No;
+    Custom(yes:String, no:Null<String>);//Todo: more than 2
+	None;
+}
\ No newline at end of file