Prototyping a conversation system: Twine meet Unity

edited in Projects
image

Hi,

I present to you... Celestial, a prototype. [Update: To be clear, this is in no way affiliated with the awesome games company of the same name]

Download playable PC demo (30MB) - UPDATED: v0.002
(Created with Unity Beta 4.6... Sorry, web build therefore not available)

What is this?

An elusive pursuit of creating a fun and interesting game by means of prototyping.

I have taken a slight detour away from building gameplay mechanics and have ventured into a realm
that is new to me... Interactive conversations and story-telling.

Choice in game conversations apparently improves player engagement and buy-in.
It also allows for complex branching stories to be told, which adds depth, improves character development and
facilitates game replayability. So they say.

As you can tell, I've done a little research and also discovered the wonderful interactive story creation tool called Twine. (Check it out if you haven't already, twine is very cool!!!)

I've also received excellent advice from local expert @rustybroomhandle who implemented a similar mechanic in their awesome game The Makers Eden by screwylightbulb.

So without further ado I present a short prototype which is inspired by one of my all-time favorite games, where player
choice in conversations had a fun and real impact on events... Star Control II.

Technical goals of prototype:

1) Build interactive branching conversation mechanics that supports external story creation via Twine.
2) MUST HAVE Star Control-ish vibe.
3) Create a rotating universe (star with gravity, rotating planet and moon, alien presence)
4) Use this opportunity to explore the new Unity UI in Beta 4.6
5) Implement an off-screen target indicator mechanic to assist navigation. (Yep, its a bit buggy... Aargh, the maths of it all!)
6) Build a complex, branching interactive story (complex? haha! done).

Your feedback, as always is appreciated, thanks. (BTW: I believe that writers block is a real phenomenon and I might have suffered from it during the making of this...)

PS: The Twine stories that I wrote(?) and implemented in game can be downloaded in Twine format here.

PPS: I don't hate PE... :)
Thanked by 1Tuism

Comments

  • @konman, I had a quick play session and it's cool :) Im a Starcontrol 1 fan, never got around to 2:(. What I like about Celestial so far is one gets to fly over a star or planet and then communicate with it. In SC1 one merely crashed into planets. I liked using the sling-shot method to evade my enemies if I couldn't blip.
    Here I can do that as well I see. I like the dialogue part of it too, very easy to access. It pauses the screen, so one could make strategic decisions then too.
    Will the ship be upgradeable in future? As in Xenon 2 for example?

    I'm really curious to see where Celestial goes.
  • edited
    @Jurgen Thanks for the feedback. Yeah Star Control 2 had the Star Control 1 gameplay, but a whole different dimension to it as there are exploration/resource gathering/adventure game elements as well. It was a very deep game for its time.

    I also did like what Star Rangers 2 and the more recent Space Pirates and Zombies did with their game mechanics, so there's plenty of ideas out there that are great.

    Not sure yet exactly where Celestial wants to go... It would be cool to board other ships, land on planet surfaces, ride around in a buggy and enter bases (with a smaller ship and/or squad)? I guess the right approach would be to go back to pen and paper and design it before touching any code.

    I already have done some procedural level generation stuff before which I may incorporate in a bigger prototype in the future, will see.

    For now I am just very happy with getting a basic choice driven story-telling mechanic into a game for the first time using Twine :)
  • I thought this was by Travis' company Celestial and had to do with their conversation engine Parley :P
  • Tuism said:
    I thought this was by Travis' company Celestial and had to do with their conversation engine Parley :P
    Nope :p

  • I loved Star Control 2! Keen to test out your prototype once it gets that far.
  • Tuism said:
    I thought this was by Travis' company Celestial and had to do with their conversation engine Parley :P
    So did I
  • edited
    @Tuism Haha. Mmmh, but I can now see the connection... sorry, it was honestly not intended to deceive. Maybe I should have called it Celestial Bodies? Or something further removed from any local SA context... It's just a working title anyhow and will most likely now change if I take the prototype any further in the future. *If*, because the corporate is hijacking my time a lot lately.

    Oh and I've never heard of Parley before, going to go and investigate...
  • edited
    There, to avoid any further confusion I have updated the title of this post :) Oh and if anyone can think of a good name for this type of game, your input is appreciated.
  • Nah I doubt outside of this context anyone else would make that connection, and I'm especially prone to connections anyway :P

    Gave the prototype a spin and I must say I really like the controls there, pausing the game and going into the menu with the wheel is awesome :)

    The conversation engine is also pretty cool, I'll have to look into how it's done, I wanna get conversations into my games too and having an engine behind it would be ideal :)
  • @Tuism Thanks, I'm glad the menu/pause system works. As you know a bad interface can ruin the game experience.

    The whole conversation mechanic is simply a text parser of the Twine "twee" format. So pattern matching. I am loving Twine more and more. Its a huge productivity tool for creating content.

    In the prototype if you opt to go aggressive against the UFO, it will fire an in game event, calling a method that changes the colour of the UFO to red. In the future it might attack the player. :)
  • konman said:

    Oh and I've never heard of Parley before, going to go and investigate...
    Send me an email address and I can arrange a free copy for you. All our tools are free for South African teams. Don't know if it will suit your project. We working on a new version now with Unity 4.6
  • @tbulford That is very kind of you :) I saw your Snap UI plugin project too and that looks very very cool!
  • @konman could you perhaps give a little more detail on how to "parse" the Twee format to an absolute layperson? Or is it something readily available through google?

    @tbulford please keep me in the loop on Parley! Last we spoke about it we were talking about a Gamemaker version, now I can work Unity :D
  • edited
    @Tuism I have one big God class that handles the loading of the text file, parsing into memory and dealing with prepping the buttons and their click events using the new Unity UI (has event thingy) ...

    The code is not optimised and very rough as it is for a prototype. This class is added to some GameObject (GameController)
    and I set the public variables by dragging the UI button objects to this script variables via inspector...

    In the game, you can only chat with one object... the one that is in proximity. I use the twine TAG section to set the image resource per passage. See my twine file linked above for details.

    The actual parse method is right at the bottom.

    Sorry for the wall of text... here's the code, would be happy to discuss.

    using UnityEngine;
    using System.Collections;
    using System.Collections.Generic;
    using System.Text;
    //NOTE: reference to new UI stuff
    using UnityEngine.UI;
    using UnityEngine.EventSystems;
    using System.Linq;
    
    public class ChatTwineScript : MonoBehaviour 
    {
    	[System.Serializable]
    	public class Passage 
    	{
    		public string title;
    		public Dictionary<string,string> tags; //like image etc
    		public string me;
    		public string other;
    		public Dictionary<string, string> responses;
    	}
    	
    	public TextAsset tweeSourceAsset; // The TextAsset resource we'll be parsing
    	protected string tweeSource; // The one big string that will hold the twee source file
    	public Dictionary<string, Passage> passages = new Dictionary<string, Passage>(); // Dictionary to hold the passages, keyed by their titles
    
    //Manage conversations
    	private GameControllerScript game;
    	public Image Image;
    	public Text Me;
    	public Text Other;
    	public GameObject Button1;
    	public string Button1Command;
    	public GameObject Button2;
    	public string Button2Command;
    	public GameObject Button3;
    	public string Button3Command;
    	private string lastChatWith; 
    	private string lastChatPassage; 
    
    	void Awake() 
    	{
    		tweeSource = tweeSourceAsset.text; // Load the twee source from the asset
    
    		Parse(); //magic is here
    	}
    
    	void Start () 
    	{
    		game = this.GetComponentInParent<GameControllerScript> ();
    
    		//ButtonClick Eventhandlers
    		//Button1.GetComponent<Button>().onClick.AddListener(() => { Execute(Button1param); MyOtherFunction(Button1.name); });
    		Button1.GetComponent<Button>().onClick.AddListener(() => { Execute(Button1Command);});
    		Button2.GetComponent<Button>().onClick.AddListener(() => { Execute(Button2Command);});
    		Button3.GetComponent<Button>().onClick.AddListener(() => { Execute(Button3Command);});
    	}
    
    	void Update () 
    	{
    
    		if (!string.IsNullOrEmpty(game.Proximity)) //cannot chat if no-one near
    		{
    			if(lastChatWith != game.Proximity) // new person to chat with that is nearby
    			{
    				lastChatWith = game.Proximity;
    				lastChatPassage = game.Proximity; //first passage for this person
    			}
    
    			if(!passages.ContainsKey(lastChatPassage))
    			{
    				Debug.Log("Unknown chat/story: "+lastChatPassage);
    				return;
    			}
    
    // hailing selected and a valid conversation exists
    			if (game.SelectedOption == 2 && passages.ContainsKey(lastChatPassage)) 
    			{
    //reset
    				Me.text = "";
    				Other.text = "";
    				Globals.GetChildGameObject (Button1, "Text").GetComponent<Text> ().text = "";
    				Button1Command = "";
    				Globals.GetChildGameObject (Button2, "Text").GetComponent<Text> ().text = "";
    				Button2Command = "";
    				Globals.GetChildGameObject (Button3, "Text").GetComponent<Text> ().text = "";
    				Button3Command = "";
    
    //image
    				if(passages[lastChatPassage].tags!= null)
    					Image.sprite = Resources.Load<Sprite>(string.Format("Textures/{0}",passages[lastChatPassage].tags["image"]));
    
    //update chat window
    				if(passages[lastChatPassage].me.Trim() != string.Empty)
    					Me.text =  string.Format("Me...\n{0}", passages[lastChatPassage].me); 
    
    				if(passages[lastChatPassage].other.Trim() != string.Empty)
    					Other.text = string.Format("{0}...\n{1}",lastChatWith,passages [lastChatPassage].other); 
    
    				//response 1 : Text
    				string text1 = passages[lastChatPassage].responses.Keys.ElementAt(0);
    				if(text1.Contains("{")) text1 = text1.Remove(text1.IndexOf ("{"),text1.IndexOf("}")-text1.IndexOf ("{")+1); //strip out {} bit
    				Globals.GetChildGameObject (Button1, "Text").GetComponent<Text> ().text = string.Format("1. {0}",text1); 
    				//Response 1 : The button click eventhandler parameter...
    				Button1Command = string.Format("{0}|{1}", passages[lastChatPassage].responses.Keys.ElementAt(0), passages[lastChatPassage].responses.Values.ElementAt(0));
    
    
    				//response 2 : Text
    				if(passages[lastChatPassage].responses.Keys.Count>=2)
    				{
    					string text2 = passages[lastChatPassage].responses.Keys.ElementAt(1);
    					if(text2.Contains("{")) text2 = text2.Remove(text2.IndexOf ("{"),text2.IndexOf("}") - text2.IndexOf ("{") + 1); //strip out {} bit
    					Globals.GetChildGameObject (Button2, "Text").GetComponent<Text> ().text = string.Format("2. {0}",text2); 
    					//Response 1 : The button click eventhandler parameter...
    					Button2Command = string.Format("{0}|{1}", passages[lastChatPassage].responses.Keys.ElementAt(1), passages[lastChatPassage].responses.Values.ElementAt(1));
    
    				}
    
    				//response 3 : Text
    				if(passages[lastChatPassage].responses.Keys.Count==3)
    				{
    					string text3 = passages[lastChatPassage].responses.Keys.ElementAt(2);
    					if(text3.Contains("{")) text3 = text3.Remove(text3.IndexOf ("{"),text3.IndexOf("}") - text3.IndexOf ("{") + 1); //strip out {} bit
    					Globals.GetChildGameObject (Button3, "Text").GetComponent<Text> ().text = string.Format("3. {0}",text3); 
    					//Response 3 : The button click eventhandler parameter...
    					Button3Command = string.Format("{0}|{1}", passages[lastChatPassage].responses.Keys.ElementAt(2), passages[lastChatPassage].responses.Values.ElementAt(2));
    				}
    			} 
    		}
    		else if(game.SelectedOption == 2) //else buttons disabled...null
    		{
    //reset
    			Me.text = "";
    			Other.text = "";
    			Globals.GetChildGameObject (Button1, "Text").GetComponent<Text> ().text = "";
    			Button1Command = "";
    			Globals.GetChildGameObject (Button2, "Text").GetComponent<Text> ().text = "";
    			Button2Command = "";
    			Globals.GetChildGameObject (Button3, "Text").GetComponent<Text> ().text = "";
    			Button3Command = "";
    		}
    		else
    		{
    			lastChatWith = string.Empty;
    			lastChatPassage = string.Empty;
    			Image.sprite = null;
    		}
    
    		//Hide empty buttons?
    		//Button1.renderer.enabled = !string.IsNullOrEmpty(Button1Command);
    		//Button2.renderer.enabled = !string.IsNullOrEmpty(Button2Command);
    		//Button3.renderer.enabled = !string.IsNullOrEmpty(Button3Command);
    	}
    
    	private void Execute(string command) 
    	{
    		if (string.IsNullOrEmpty (command)) 
    		{
    			//Debug.Log ("ChatTwineScript.Execute() - parameter is empty?");
    			return;
    		}
    
    		//Debug.Log ("Execute: "+command);
    
    		Dictionary<string,string> actions = new Dictionary<string, string>();
    
    		string[] responseKeyVal = command.Split('|');
    		string next = responseKeyVal[1]; //next passage... [[some text|NEXT]]
    		string responseWithActionsInBrackets = responseKeyVal[0]; //response with actions... [[some response{exec=ACTION;}|next]]
    
    		//Debug.Log ("What actions? "+responseWithActionsInBrackets);
    
    		//find everything between { and }
    		if (responseWithActionsInBrackets.Contains ("{")) 
    		{
    			int indexStart = responseWithActionsInBrackets.IndexOf ("{")+1;
    			int indexEnd = responseWithActionsInBrackets.IndexOf ("}");
    			int length = indexEnd - indexStart;
    			string actionString = responseWithActionsInBrackets.Substring(indexStart, length);
    			
    			//Debug.Log (string.Format("actionString [{0}]",actionString));
    			
    			//split actions on ; and add to dictionary
    			actions = actionString
    				.Split(new[] {';'}, System.StringSplitOptions.RemoveEmptyEntries)
    					.Select(part => part.Split('='))
    					.ToDictionary(split => split[1], split => split[0]);
    		}
    
    		//last action is always: go to next passage
    		actions.Add (next, "next");
    
    		//Debug.Log (string.Format("Action count=[{0}]",actions.Count));
    
    		//preform actions and go to next passage
    		foreach(KeyValuePair<string, string> action in actions)
    		{
    			//Debug.Log (string.Format("Loop actions: [{0}] [{1}]",action.Key, action.Value));
    			switch (action.Value)
    			{
    				case "next": // show next passage in conversation
    					if(action.Key == "End")
    						game.CloseChat ();
    					else
    						lastChatPassage = action.Key;
    					break;
    				case "exec": //call a local method to do some action
    					CallSomeMethod(action.Key);
    					break;
    			}
    		}
    	}
    
    	private void CallSomeMethod(string methodName) 
    	{
    		//Debug.Log ("ChatTwineScript.CallSomeMethod() - " + methodName);
    		switch (methodName)
    		{
    			case "CloseChat": 
    				game.CloseChat (); 
    			break;
    			case "AttackPlayer": 
    				game.AttackPlayer (); 
    			break;
    			case "ForgivePlayer": 
    				game.ForgivePlayer (); 
    			break;
    			default:
    				Debug.Log ("METHOD NOT FOUND. ChatTwineScript.CallSomeMethod() - " + methodName);
    			break;
    		}
    	}
    
    	private void Parse() 
    	{
    		Passage currentPassage = null; // A reference to the passage we're currently building from the source
    		string[] lines; // Array that will hold all of the individual lines in the twee source
    		string[] chunks; // Utility array used in various instances where a string needs to be split up
    		lines = tweeSource.Split(new string[] {"\n"}, System.StringSplitOptions.None); // Split the twee source into lines so we can make sense of it while parsing
    		
    		for (long i = 0; i < lines.LongLength; i++) // Just iterating through the whole file here
    		{
    			if (lines[i].StartsWith("::")) //new passage
    			{
    				// If we were already building a passage, that one is done. Wrap it up and add it to the dictionary of passages. 
    				if (currentPassage != null)
    					passages.Add(currentPassage.title, currentPassage);                 
    				
    				currentPassage = new Passage();
    				currentPassage.responses = new Dictionary<string, string>();
    
    				//split title from tagsignore the :: prefix, strip off the ] at the end of the tags, and split the line on [ into two strings, one of which will be the passage title while the other has all of the passage's tags
    				chunks = lines[i].Substring(2).Replace ("]", "").Split('[');
    				currentPassage.title = chunks[0].Trim();
    
    				//tags to dictionary
    				if (chunks.Length > 1)
    				{
    					//currentPassage.tags = chunks[1].Trim();
    					//add tags to dictionary
    					currentPassage.tags = new Dictionary<string, string>();
    					currentPassage.tags = chunks[1].Trim()
    						.Split(new[] {' '}, System.StringSplitOptions.RemoveEmptyEntries)
    							.Select(part => part.Split('='))
    							.ToDictionary(split => split[0], split => split[1]);
    				}
    
    				// If there was anything after the [, the passage has tags, so just split them up and attach them to the passage.
    				//if (chunks[1].Length > 1) 
    					///currentPassage.tags = chunks[1].Trim().Replace(" ", ",");  
    			} 
    			else if (lines[i].StartsWith("me|")) 
    			{
    				currentPassage.me = lines[i].Substring(3);
    			}
    			else if (lines[i].StartsWith("other|")) 
    			{
    				currentPassage.other = lines[i].Substring(6);
    			}
    			else if (lines[i].StartsWith("[[")) 
    			{
    				chunks = lines[i].Substring(2).Replace("[[", "").Replace("]]", "").Split('|');
    				currentPassage.responses.Add(chunks[0].Trim(),chunks[1].Trim());
    			}
    
    		}
    
    		// When we hit the end of the file, we should still have the last passage to add
    		if (currentPassage != null)            
    			passages.Add(currentPassage.title, currentPassage);
    	}
    }
    Thanked by 1Tuism
  • edited
    Minor update: (PC demo v0.002, see link above)

    -added meteors (destructible)
    -added enemy ship with very basic AI
    -particle trails (woot!)
    -radar distance indicators
    - a shield thing (hold down right mouse)

    Coming soon: health, weapon damage, explosions. A betterer story. (You can currently have a chat with the sun, the planet, the moon and the ufo when in proximity - see comms).
Sign In or Register to comment.