Tuesday, February 19, 2013

A Context Menu

As I'm getting more and more things added into the engine, interacting with all the objects in the scene has gotten more complicated.  Also I'd like to start working toward an actual game-like interface as opposed to my cheesy test interface.

Besides all the debug-type side buttons, you could take control of any of the entities in the scene by double-clicking on them.  This allowed you to pilot them with the arrow keys (a bit cumbersome, but fine for testing physics/collisions/rendering).  A single click would select an object, highlighting it.



I wanted something that gave me a bit more options, so I considered a radial-menu style interface.  I remember the old Neverwinter Nights game used one to great effect.  For a while I dorked around with various JQuery radial menu examples and plugins... but they were all very complex, and I wanted something lighter (and ok, I admit that I just didn't get some of the event models they used...)

However, I did spot this, which makes a nice hexagonal menu using pure CSS.  Hexagons are cool.  Hexagons are sort of future-y.  Best of all it was quite simple.  With the basic HTML and CSS it was trivial to simply make it appear on the screen via Awesomium.

But, that is trivial.  Integrating it and getting it to behave took a bit more work.  First:  getting it to appear when and where you want it.

Rather than click (usually used for selecting or targeting things) or a double-click (in most games double-click starts an attack, or 'uses' an object or something), I decided to have the menu appear on a brief mouse-hold.  So you hold down the mouse for some amount (500ms seems like a good value) and the menu pops up.

So I added into my base Scene class:

protected float mouseContinuousDownTime = 0;


and changed the base Update:
public virtual void Update(GameTime gameTime)
			{

			// When the mouse is held down, we add to 'mouseContinuousDownTime'
			// waiting for a certain amount of time before proccessing it as a
			// 'held' button.
			//
			// Once we process it, set mouseContinuousDownTime to -1, which signals
			// us not to process further (so that if you KEEP the button held down
			// for five or six seconds, we don't keep processing a hold every 500 ms).
			// 
			// This -1 flag state is cleared by a new MouseUp or MouseDown.
			if (Mouse.GetState().LeftButton == ButtonState.Pressed)
				{
				if(mouseContinuousDownTime != -1)
					mouseContinuousDownTime += gameTime.ElapsedGameTime.Milliseconds;
				}

			//If the mouse button is held down for 500 ms....
			if (mouseContinuousDownTime > 500.0)
				{
				MouseHeld(Mouse.GetState().X, Mouse.GetState().Y);
				mouseContinuousDownTime = -1;
				}

			

			updateMouseMoveLimiter = false;
			}

  


So:  you start holding down the left mouse button, time accumulates in mouseContinuousDownTime until you reach 500ms or you release it (in the MouseUp function it resets this to zero automatically).

Once we've hit or passed 500ms we do our execute our MouseHeld event, passing the cursor location, then set mouseContinuousDownTime to -1.  Why set it to -1?  Well I didn't have that initially, and what would happen is that you'd hold the mouse down, MouseHeld would fire and the menu would appear... But if you kept the mouse down mouseContinuousDownTime would reset to zero, then 500ms later (as you are still holding the button) MouseHeld fires again....  Basically the -1 flag keeps the event from firing again and again while you hold the mouse down.  Once you get a MouseUp (or a MouseDown, just in case we somehow missed an event, not that it should ever happen), mouseContinuousDownTime  is reset to zero and you are clear to process MouseHeld again.  So for a given mouse press, you are limited to one MouseHeld event.


		public virtual void MouseDown(int X, int Y)
			{
			mouseContinuousDownTime = 0;
			}

		public virtual void MouseUp(int X, int Y)
			{
			mouseContinuousDownTime = 0;
			}

Then:


	public override void MouseHeld(int X, int Y)
			{
			//Ray cast into the scene, get the first entity (if any) we hit.
			Object hitObject = gameEngine.RayCast(CalculateCursorRay(X, Y));

			//By default hide; if we select empty space the menu goes away.
			hexMenu.Hide();

			if (hitObject != null)
				{
				Artemis.Entity e = (Artemis.Entity)hitObject;

				//cheesy way to skip things we don't want to select just yet.
				if (e.GetComponent().name == "Terrain")
					return;

				//If we hit an entity we care about, show the menu 
				//at the appropriate location.
				hexMenu.Show(X, Y, e);
				}

			//This was the release of a hold.
			//We don't want to process the next 
			//mouse-up as any 
			ignoreNextMouseUp = true;

			base.MouseHeld(X, Y);
			}



The next mouse-up is ignored as mouse-up is used for selecting.

Originally I just called some javascript through Awesomium to show and hide the menu (display:none or display:block with positioning) in MouseHeld.  Worked, but I had already decided to abstract the menu into an interface.  That way it would be easy to easily switch menus in and out.  The interface isn't done yet, though that's pretty trivial.  Currently it's simply a class:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;

using Awesomium.Core;
using AwesomiumUiLib;
using Artemis;

namespace RogueMoon.UI.Menus
	{
	class HexMenu
		{
		AwesomiumUI UI;

		private bool isVisible = false;
		public bool IsVisible
			{
			get { return isVisible; }			
			}

		private Artemis.Entity contextEntity = null;
		public Artemis.Entity ContextEntity
			{
			get { return contextEntity; }			
			}

		bool initialized = false;

		public HexMenu(AwesomiumUI ui)
			{
			UI = ui;

			UI.CallJavascript(string.Format("jQuery('#hex').offset({{ left: {0}, top: {1}}})", -1000, -1000));
			}



		public void Initialize()
			{
			string menuHTML = "";
			
			menuHTML = File.ReadAllText(".\\UI\\Menus\\HexMenuHtml.txt");
			
			//For some reason passing to jQuery with \r\n does not work.
			//Double quotes are problematic,too.  Very picky.
			menuHTML = menuHTML.Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace("\"", @"\""");

			UI.CallJavascript(string.Format(@"jQuery(""#contextMenuDiv"").html(""{0}"")", menuHTML));

			initialized = true;
			}


		public void Show(int x, int y, Artemis.Entity _contextEntity)
			{
			if (!initialized)
				return;

			UI.CallJavascript("jQuery('#hex').css('display','block')");

			JSValue width = (JSValue)UI.CallJavascriptWithResult("jQuery('#hex').width()");
			UI.CallJavascript(string.Format("jQuery('#hex').offset({{ left: {0}, top: {1}}})", x - (width.ToInteger() / 2), y - (width.ToInteger() / 2)));

			contextEntity = _contextEntity;

			isVisible = true;
			}

		public void Hide()
			{
			if (!initialized)
				return;

			UI.CallJavascript("jQuery('#hex').offset({ left: -1000, top: -1000})");
			UI.CallJavascript("jQuery('#hex').css('display','none')");
			isVisible = false;
			contextEntity = null;
			}

		}
	}



Pretty straightforward.  The only thing to note is that in Initialize, rather than hard-coding the HTML in here, it reads the HTML from a file then uses jQuery to replace an empty Div with the ID of #contextMenuDiv with our new HTML.

Worth noting is this line:
menuHTML = menuHTML.Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace("\"", @"\""");

Without trimming the various control characters and escaping the double quotes, jQuery appeared to choke on this, which caused Awesomium to choke (there is a known problem in the 1.6x release where bad javascript causes the process to hang for a bit, so be careful with that).

	<ul id="hex" style = "float:left; position:absolute;  display:none; left:-1000; top:-1000;">
	<li class="p1">		<a href="#" onclick='HexMenu.Command("control");'>	<b></b><span>Control</span><em></em></a></li>
	<li >				<a href="#" onclick='HexMenu.Command("lookAt");'>	<b></b><span>Look at</span><em></em></a></li>
	<li class="p2">		<a href="#">	<b></b><span>Select</span><em></em></a></li>
	<li class="p2">		<a class="inner" href="#">	<b></b><em></em></a></li>
	<li class="p2">		<a href="#" onclick='HexMenu.Command("renderBoundingBoxes");'><b></b><span>Debug Boxes</span><em></em></a></li>
	<li class="p1 p2">	<a href="#">	<b></b><span>?</span><em></em></a></li>
	<li class="p2">		<a href="#">	<b></b><span>?</span><em></em></a></li>
	</ul>

One thing I do need to address is that the CSS for this is still in the general UI.css file.  I'll have to find a way to inject it via javascript; I'm sure it can be done.

CSS:

/*
#hex li a.inner b {border-bottom-color:#c60;}
#hex li a.inner span {background:#c60;}
#hex li a.inner em {border-top-color:#c60;}
*/

#hex li a.inner b {border-bottom-color:transparent;z-index:999;}
#hex li a.inner span {background:transparent;z-index:999;}
#hex li a.inner em {border-top-color:transparent;z-index:999;}


#hex li a:hover {white-space:normal; color:#fff;z-index:999; opacity:1;}
#hex li a:hover b {border-bottom-color:DarkOrange;z-index:999; opacity:1;}
#hex li a:hover span {background:DarkOrange;z-index:999; opacity:1;}
#hex li a:hover em {border-top-color:DarkOrange;z-index:999; opacity:1;}

/*
#hex li a.inner:hover b {border-bottom-color:#a40;}
#hex li a.inner:hover span {background:#a40;}
#hex li a.inner:hover em {border-top-color:#a40;}
*/
#hex li a.inner:hover b {border-bottom-color:transparent;z-index:999}
#hex li a.inner:hover em {border-top-color:transparent;z-index:999}


All the z-index business was added to prevent it from being overlapped by the jQuery layout manager that I'm using.  Without that the panes of the layout manager take precedence, and you can't click the menu items.

The events are handled normally through my AwesomiumUI wrapper around Awesomium.Net:

In the scene constructor:

UI.CreateJSObject("HexMenu", "Command", HexMenuCommandClicked);

In the HTML:

onclick='HexMenu.Command("control");'

And the event handler:

	public void HexMenuCommandClicked(object sender, JSCallbackEventArgs e)
			{
			System.Console.WriteLine(e.Arguments[0].ToString());

			String argument = e.Arguments[0].ToString();
			//String text = String.Format("Renderer command: {0}", argument);

			//mainLogger.LogDebug(text);
			//AddToChat(text);


			if (argument == "renderBoundingBoxes")
				renderBoundingBoxes = !renderBoundingBoxes;
			else
				if (argument == "control")
					{
					lookAtEntity = null;

					// Toggles control; sets it if we don't have it.
					// If already controlling this entity, unset it.
					// Returns true if control is taken.
					if (gameEngine.TogglePlayerControlled(hexMenu.ContextEntity, playerId))
						lookAtEntity = hexMenu.ContextEntity;
					}
				else
					if (argument == "lookAt")
						lookAtEntity = hexMenu.ContextEntity;
						

			hexMenu.Hide();
			}


Still, this came together really quickly and works nicely.  Using jQuery to lay out my game UI in XNA!




No comments:

Post a Comment