Well I ended last month with an ominous statement questioning how much I intended to keep using Unity for these 1GAM projects, since one of the big selling points of Unity for something like this was the web player, which is on its slow death march into unsupported oblivion. So of course, my very next project is not only in Unity, it's tied directly into a core Unity system and will be of essentially no interest or use to anyone who doesn't use Unity.
I sometimes puzzle myself.
The upshot is that since I keep finding myself drawn to Unity/Twine integration, I've got a basic working example of adding per-character click events to Unity's UI Text elements, and a simple but extensible Twine parser to support translating those those events into Twine navigation and event firing capabilities, and I'm putting up the source code for anyone who wants it. Hurray!
What I've put together here comes in two parts. The first one, a more general-purpose component that makes the rest of this possible, is TextInteractor. TextInteractor is a MonoBehavior component you can attach directly to a GameObject that also has a Unity UI Text component. When it detects that a user has clicked on a character, it sends an OnInteraction event message, and the event argument is an InteractionResult telling which object was clicked at what world location, whether the hit was in the text bounds, whether it was in a character's bounds, and if so, the index of the character in the displayed text string. There's also a public HitCheck(Vector3) method to allow you to check an arbitrary point in world space for the same information.
There are very few options, and you may not need any of them:
- Dynamic tells TextInteractor that this object may be moving, so always recalculate the bounding boxes of characters when a user clicks. If your object is static, uncheck Dynamic to slightly reduce the overhead of processing a click event.
- Dirty and Gizmo Color were things I used during development that I figured I would leave in place just in case anyone finds them useful. Setting the Dirty flag just triggers a Refresh of the text/character bounds (which you can also do from code by calling Refresh() on the TextInteractor component), and in case you're working with multiple clickable elements, maybe you'd like custom gizmo colors.
- Character Bounds Padding increases the bounding box of each character by the given amount. You may want to experiment with this to find good values for your needs, but you'll probably want some sort of padding, otherwise you can end up with annoyingly precise click detection that ignores clicks in the empty spaces between letters and such, which is typically no good at all.
- The last two woefully-long-named flags enable reporting of click events that don't hit a character. In-bounds misses are misses that are within the text area, but didn't hit any character, and out-of-bounds misses are misses that hit the Text element but weren't even within the text area. Both types of events are ignored by default but you can use these flags to enable them.
The rest of this project lives in the TwineUI namespace. The main class where the work gets done is Twine2Handler, which is an abstract class that handles most of the necessary parsing and event handling. Twine 2 has no standard passage markup format, since that's all handled by the different story formats, so you have to create your own class which inherits from Twine2Handler and implements the CreatePassageFromElement() method to parse a passage into a TwinePassage, including its collection of TwineLinks. I've included as an example BasicSugarcubeHandler, which handles parsing passages with basic Sugarcube-style links. For basic functionality, all you need to do is export a Twine 2 story to a file, add that file to your project as a text asset, and assign that asset to the Twine Data File property.
As you can see in the image on the right, I made everything serializable, so the inspector can be used to view the entire parsed story.
Once all the parsing is done, the Twine2Handler listens for OnInteraction events from a TextInteractor component. Using the information in the collection of TwineLinks for the current TwinePassage, it can determine whether a click was received by a character within a link, and if so, an event message is sent to the current object's components. If the "Auto Navigate" property is checked, the Twine2Handler will automatically update the associated Text object to display the new passage, so if you just want to allow the user to navigate a Twine story, that can be handled directly by the component. Otherwise, you can use the Twine2Handler's DisplayPassage() and FindPassageByName() methods to handle navigating the story manually. Either way, the event message will still be sent and you can handle it however you see fit.
Normally, this event will be OnTwineNavigation, and the argument object includes references to the current passage, the destination passage, the link the user clicked, and the interaction information from TextInteractor in case that's needed.
I've also included support for another event. If a user clicks on a link where the destination begins with "event:" or a passage is displayed with a tag that begins with "event:", then Twine2Handler will send an OnTwineEvent message containing the destination or tag with the "event:" removed. This allows a Twine passage to display links which have no effect within Twine, but fire an event that you can react to in whatever way you need to.
Limitations, Usage, Notes and Whatnots
The source code for TextInteractor and TwineUI is available below, and just to be clear, it's 100% free to use in any way for anybody anytime anywhere. Just maybe shoot me a message and let me know if you found it useful; that's always nice to hear.
The main limitation of TextInteractor, and therefore of TwineUI, is that it doesn't support a UI canvas in ScreenSpaceOverlay render mode. There's something going on there with the coordinate translation that I haven't sorted out. I found a thread on the Unity community forum with someone trying to figure out how to translate ScreenSpaceOverlay elements to world space coordinates, and the response from one of the Unity devs pretty much boiled down to "good luck, or use a different canvas render mode." ScreenSpaceCamera and WorldSpace canvases seem to be working just fine though. I tried to make sure they handle things like rotation and scaling and whatnot and they seem to be behaving themselves, but if not, you've got the source and good luck to you. ;)
Short version: Add a BasicSugarcubeHandler to a GameObject with a Text component in a Unity UI canvas, assign a Twine 2 HTML export as a TextAsset to its Twine Data File property, enable Auto Navigate, and if all goes as expected you've got a clickable, navigable Twine story display. Hopefully that's enough to get started on whatever you'd like to do.
If nothing else, I'm happy with this because I've been wondering since the new Unity UI was released how difficult it would be to add this kind of per-character click event support, and although the coordinate system of the UI system in various rendering moeds is kind of a zoo, it wasn't too bad to get it working, at least for ScreenSpaceCamera and WorldSpace modes. Not sure if I'll use this for any actual projects, but it works, I'm happy, and maybe someone will have use of this code.
Note: the following builds are available for testing purposes but I don't have the ability to test them. These are Unity builds so I don't anticipate any serious issues, but I can't vouch for correctness or performance of these builds.