ID:2968944
 
Hey y'all, a lot of you probably know that my BYOND K-hole is basically UI. Over the years, I've gotten a lot of features added to BYOND, and those features can now be leveraged to do some really great stuff that was never possible before.

I've been on the hunt for a Unicorn this whole time, searching for a way to make a UI system that's easy to learn, easy to write code for, and very efficient. Again, it's a unicorn hunt, and while I don't have a horn to show for my efforts, I am not empty-handed.

Maybe instead of gracing the community with a monolithic library, I can share some concepts here in the coming days and weeks that will help you in the long run.

Check out the Library and follow along while you read!

DM UI: Ease of use



The most important aspect of creating a new HUD system is ensuring that it's easy to use, and that you don't need to do a ton of work or know a bunch of esoteric rules for working with UI objects. We want those rules to be communicated intuitively based on our understanding of how the engine works under the hood, and we want this system to be built in such a way where the right way to do something is also the easy way to do it.

So let's get some axioms out of the way. I have three simple rules that I follow in creating UIs:

1) UIs are never shared or saved. UI belongs to the player that can see it, and ONLY that player. UIs live only as long as there is a valid client connection, and will die with that client connection.

2) UIs are made from many elements (containerization), and should be structurally related to all of their elements, sharing a common polymorphic hierarchy for their behaviors (widgetization), and a common path hierarchy for their construction (relatedness).

3) UIs should never hold hard "upward" references, but should hold on to "downward" references. This means that a UI element should not immediately know who its parent is, but should always know what its children are. This has consequences for how you write your UI code that forces you to use good practices, and simplifies setup and teardown of deeply nested UI elements. A parent being destroyed means that all of its children should be destroyed in short order without demanding additional work from you. This reduces the possibility of memory islands, and prevents you from having to write complex, repetitive reference management code.

We also need to go over a few ideas I've stumbled on over the years of working on this.

Path and Polymorphism Are Not the Same



In BYOND, objects are referred to by their path. Generally, each node in the path refers to an object that adds additional behavior and properties to an object. But this is optional. BYOND allows you to deviate from this practice, and have nodes in the path mean something else.

When we set an prototype's parent_type, the object's path is not altered.

mob
corpse
parent_type = /obj


In the above example, we have created a type path for /mob/corpse. The path tells us it's a mob, but when we declared this prototype, we told the engine that the /corpse node isn't a mob, but actually an object. This has consequences:

mob
var
new_property = 0
corpse
parent_type = /obj


If you assume that /mob/corpse is a mob, you would assume it has a variable called "new_property", but it doesn't. The reason it doesn't, is because the path is just where in the object node tree we are placing the prototype, the parent_type is what we're actually deriving that node from. So if you assume that /mob/corpse has all the properties of a /mob, you are gonna wind up making bad assumptions, and the engine is going to complain.

Now, I know some of you are gonna start sharpening up your pitchforks, saying this is a bad idea, because it creates confusion, and I agree with you. The above example is extremely bad practice. However, this bad practice can be used for good, provided the end-user is armed with information ahead of time.

The real reason that the above example is so bad, is because every other object in your game most likely makes the assumption that the nodes in your path have meaning, and that meaning indicates what capabilities and behavior your objects have. We've just created an arbitrary break in this logic for one particular object, and this one object violates those assumptions in the middle of an established axiom for no good reason.

The practice of decoupling path and polymorphism isn't the part that's bad. It's the reason, and place we did it that is bad.

UI is one of the places where we can take advantage of this concept, and create additional meaning for the path of an object. The way we do this is by creating an arbitrary set of rules that apply to an entirely new kind of object in the type tree. This kind of object is walled off from everything else in your type tree, and inclusion in this part of the tree is indicated by root-level objects that share a common naming convention. Enter the /hud.

hud_obj
parent_type = /atom/movable
layer = HUD_LAYER
plane = HUD_PLANE
appearance_flags = TILE_BOUND

hud/component
parent_type = /hud_obj
vis_flags = VIS_INHERIT_LAYER | VIS_INHERIT_PLANE

hud/widget
parent_type = /hud/component

hud
parent_type = /hud_obj


What we've just done is create four new object types that we can work within that are walled off from everything else in the type tree. They all share a common naming convention that indicates that they live in the screen. If you see "hud", you can instantly know that this object, and anything derived from it shares special rules different from other kinds of objects.

The goal here, is to use the path of an object to group objects by their relationship with a container, and their parent_type will be used to inform the object of its capabilities and its behavior. In doing this, we're making creating UIs much easier. We're forcing you to organize your code effectively, and we're reducing the amount of stuff you need to keep in your head and document in order to understand how complex multi-object UIs actually work.

We're also defining this in the hopes that you can define common packages of behavior that you then recycle throughout your project, helping to reduce how bug-prone your code is, and how much work you need to do to debug and fix your game. By sharing behavior between common "widgets", you can be certain that one button won't have a minor mistake in it that makes it not work: All buttons having a package of shared behavior makes adding a button to a hud faster for you, AND it means if you make a mistake in your button widget, you don't need to search through the whole project for every single button in the game to fix some faulty logic.


Fig 1a) left: What these types derive from.
Fig 1b) right: How these objects are structured during play.

Relatedness in the HUD



What we just established is a pattern of specifying both behavior and relatedness. First let's differentiate what a /hud is. A /hud is a top-level container. When you want to put something on the screen that is comprised of multiple objects all cooperating to create a single interactive menu or element that stands alone, we'd create a new child of /hud. In the object tree, we can see what elements go in the player's screen just by opening the /hud node. All the children of /hud will be top-level menus, like windows, or self-contained screen elements like a player status HUD, or a hotkeys HUD, or a nav menu HUD.

/hud/component, on the other hand, shouldn't have any child types in the object tree at all. This object's purpose is just to differentiate between a top-level container, and a piece of a container that is created on the fly to serve a one-off purpose. Objects will inherit from this, but they won't do so directly. For instance, if you have a simple object that serves as like text label or a graphic that can change during the lifecycle of the hud, we create a component to represent that child element of the /hud.

Let's take a look at how that would work:

hud/info_menu
icon = 'info_background.dmi'
screen_loc = "TOP,LEFT"
vis_contents = newlist(/hud/info_menu/name_label,/hud/info_menu/level_label)

name_label
parent_type = /hud/component

//position the name label 8,32 pixels northeast of the container's bottom-left corner
pixel_w = 8
pixel_z = 32

maptext = "Mister Anderson"

level_label
parent_type = /hud/component

//position the level label 8,16 pixels northeast of the container's bottom-left corner
pixel_w = 8
pixel_z = 16

maptext = "99"


We still don't have the tools that we need to actually update these objects, but don't worry, I'll cover those tools later. They are very simple, but very powerful.

What's going on here, is we're creating a top-level hud element, and then defining its child elements within the type path of that hud element. So the children have a full type path of:

/hud/[container]/[child]


However, these child nodes specify that they aren't actually /hud objects. Instead, they are /hud/component objects, which in turn derive directly from /hud_obj.

I call this the boilerplate pattern. The boilerplate pattern allows you to quickly construct HUD and keep their code clean and with everything it is related to. You shouldn't need to puzzle out which menu a particular button you've designed belongs to. The path of that button should tell you exactly what menu it belongs to. You shouldn't have to guess what children a HUD has. You should be able to look at the hud's object tree and know exactly which children it has. You shouldn't have to guess what behavior a child has. You should be able to infer that from 1) Its name, and 2) its parent_type.

So while decoupling type from polymorphism isn't good practice in the first example we shared, it actually forces you to engage with better practices in this example. By walling off a number of our objects, and setting this rule up ahead of time for those objects, we're not creating a confusing mess. You just need to know going in that for other objects in your object tree, path informs you of polymorphic hierarchy, while in the HUD, path informs you of structural hierarchy.

Registration and Traversal



We need a way to make parents and children talk. Earlier, I mentioned the idea that parents should know who their children are, but children should not immediately know who their parents are. This is because of the way garbage collection works. If object A references object B, object B won't be deleted until object A stops referencing object B. But if object B references object A too, both the link from object A and object B need to be deleted for either one of them to be destroyed. This is called a circular reference. Circular references can get complicated too. The relationship isn't just A->B->A, it can be A->B->C->D->E->A. This kind of a circular reference is sometimes referred to as a "memory island". When a memory island doesn't get cleaned up manually, you develop a memory leak, where objects become inaccessible, but also won't self-delete. If the part of your code that is actively running no longer has access to any of the nodes in the circular reference, you lose the ability to break that reference chain, and it becomes an "island". In some cases, where lots of these islands are being created on accident, and then set adrift in the memory ocean, you can develop a catastrophic memory leak where you quickly begin to run out of memory and your project grinds to a halt as the housekeeping BYOND has to do becomes harder and harder as this problem grows out of control.

UI has a high potential for the creation of memory islands and memory leaks, owing to the simple nature that UIs consume lots of objects that we need to have talk to one another. When a child talks to its parent, we call this upward traversal. When a parent talks to its child, we call this downward traversal. When a child talks to another child of the same parent, we call that a lateral traversal. For example, let's say you have a menu that has a button. The button knows when it is clicked on, and does a thing. We don't need to do any traversal, because that behavior alone doesn't need to consult a parent or a child to do its work. But let's say clicking this button causes a graphic in the same container as a button to change. The button and the graphic would be siblings in the UI context, living in the same parent container. If we had a reference to all an object's siblings, this would create an A->B relationship for every single object in the hud. Now, let's say you release the hud, allowing it to be destroyed, but we don't write the code to clear up all these sibling relationships. Congratulations, we just created a little archipelago and sent it out to sea. Without the container, we can't know which children need cleaned up, so we can't break these relationships, and for the rest of the time your game is running, this memory island is lost to you, but is still stealing your resources.

So what's the solution here? I'm telling you to write a bunch of code every single time you make a UI that cleans up all these memory islands? Nah fam, fuck that. That makes developing UI slow and annoying and bug-prone. You shouldn't have to worry about this. Instead, what I'm offering you is a data structure that makes upward and lateral traversal super intuitive and convenient. Once we have these structures, you won't even WANT to do the extra work to make these upward or lateral references in the first place, and you won't even have to think about cleaning up your uis anymore. You'll do less work, write less code, and just make gam faster.

hud_obj
parent_type = /atom/movable
layer = HUD_LAYER
plane = HUD_PLANE
appearance_flags = TILE_BOUND

var/tmp
vis_id
alist/vis_registry

proc
parent() as /hud_obj
return length(vis_locs) ? astype(vis_locs[1],/hud_obj) : null

root() as /hud_obj
var/hud_obj/seek = src, hud_obj/parent, list/locs

while(seek)
parent = seek
seek = length((locs = seek.vis_locs)) ? locs[1] : null

if(parent==src || !istype(parent))
return null

return parent

operator[](idx)
if(!vis_registry) return null

var/hud_obj/seek = src, alist/registry
for(var/id in splittext(idx,"."))
registry = seek.vis_registry

switch(id)
if("parent")
seek = seek.parent()
if("root")
seek = seek.root()
else
seek = registry?[id]

if(!seek)
return null

return seek

operator[]=(idx,hud/component/child)
if(!isnull(child) && !istype(child))
throw EXCEPTION("Invalid type for list")

var/hud_obj/seek = src, alist/registry, list/splits = splittext(idx,"."), len = length(splits)-1
for(var/split in 1 to len)
var/id = splits[split]

switch(id)
if("parent")
seek = seek.parent()
if("root")
seek = seek.root()
else
registry = seek.vis_registry
seek = registry?[id]

if(!seek)
throw EXCEPTION("Unknown hud object: [idx]")

child.vis_id = splits[len+1]
seek += child

operator+=(hud/component/child)
var/list/adding = islist(child) ? child : list(child), alist/registry = (vis_registry ||= alist())

for(child in adding)
if(!istype(child))
throw EXCEPTION("Invalid type for list")

var/id = child.vis_id
if(id)
var/hud/component/old_component = registry[id]

if(old_component && old_component != child)
src -= id

vis_registry[id] = child

vis_contents += child

operator-=(hud/component/child)
var/list/removing = islist(child) ? child : list(child), list/registry = vis_registry

for(child in removing)
if(!istype(child))
continue

var/id = child.vis_id
if(id)
var/hud/component/old_component = vis_registry?[id]

if(old_component==child)
registry -= id

vis_contents -= child

Del()
if(length(vis_locs))
var/hud_obj/parent = parent()
if(parent)
parent -= src
vis_locs.len = 0
..()

hud/component
parent_type = /hud_obj
vis_flags = VIS_INHERIT_LAYER | VIS_INHERIT_PLANE

hud/widget
parent_type = /hud/component

hud
parent_type = /hud_obj




What we just created is a data structure that allows you to step through the container hierarchy like a tree. /hud_objs, and by extension, /huds, /hud/components, and /hud/widgets can now have a vis_registry where they will store objects by an id. This id is a per-container unique name for a child component, as well as a unique identifier for a top-level hud. The way we built it was using a concept called operator overloading. By overloading an operator, we make objects easier to work with. Instead of having to remember a bunch of complicated names and arguments for a function, we can just use operators to do behavior instead.

So instead of something like:

container.AddComponent(child,vis_id="child_node")
var/child = container.GetComponent("child_node")
container.SetComponent("child_node",child)
container.RemoveComponent("child_node")


We can do this instead:

container += child
var/child = container["child_node"]
container["child_node"] = child
container -= child


We also built some great functions for doing upward, downward, and lateral traversal:

//getting the parent
var/container = child["parent"]
//OR:
var/container = child.parent()

//getting a sibling
var/sibling = child["parent.sibling"]
//OR:
var/sibling = child.parent()?["sibling"]

//getting a parent's parent
var/grandparent = child["parent.parent"]
//OR:
var/grandparent = child.parent()?.parent()

//getting the root container:
var/root = child["root"]
//OR:
var/root = child.root()


These convenience functions mean that we don't need to hang on to lateral or upward reference for longer than we need it. They make it really easy to grab a reference to something, and then throw it away when we no longer need it.

These functions also serve another really important purpose: We don't want to spread out all the behavior of a UI throughout our whole project. These convenience functions allow you to one-off custom behavior up to the root hud, and process all of the custom functionality in that object, instead of spread among dozens or even hundreds of custom objects we create and use one time. Essentially, custom ui components become nothing more than quick look-and-feel boilerplate code, and the top-level hud is the brains of the whole operation. We'll go over that soon, but for now, we also really want to avoid building our UIs on the fly every single time.

Templating



Right now, we don't have a good way to go about constructing a complex hud consisting of dozens of elements. We have to write the same code over and over again to pull it off: When the hud is created, create the children, register them, and add them to the vis_contents. When the children are created, register their children, and add them to the vis_contents, etc. This is boring code that follows basic logic, so we can train the engine to do it all for us very easily so we never have to write this code again. We're already half way there, we just need to introduce a new concept: Templates.

What we have already set up allows us to do quickly set up something called a template. Essentially, a template is a list of types of components, and a vis_id for where they go in the hierarchy. We need to set up a small system for analyzing our huds on world startup, and creating these template data structures, as well as a function for creating a whole hud element and its children all at once, and then placing them in vis_contents of the correct components.

The advantage of the way we set up the paths of our huds is that we can look at the type of an object and know exactly which hud it belongs to. The objects we care about also all derive from /hud/component. This allows us to use typesof() to get a list of all the components, and widgets in your entire project, as well as to use the nodes on the path tree to determine which hud those widgets and components are related to.

All we need to do is loop over every component one time at world startup, break apart its path, and crawl upward along that path to build our template lists and associate them with the correct hud type. We only need to do this during world startup. Once we have that data, we're keeping it as long as the game is live so we can efficiently spin up a whole hud from that template.

//when the world starts up, build the templates for every hud
hud/component
var/tmp
vis_parent

var
list/hud_templates = init_hud_templates()

proc
//constructs a list of hud templates for each /hud descendant
init_hud_templates()
var/list/templates = list()

//this regex helps us break apart the path of an object and step backward through it
var/regex/regex = new/regex(@"^((?:\/[^\/\n]+)+)\/[^\/\n]+$")

//loop over everything that polymorphically derives from /hud/component, except for the system types (/hud/component, /hud/widget)
for(var/v in typesof(/hud/component) - list(/hud/component,/hud/widget))

//if the type has a compile-time vis_id, that means it's part of a boilerplate template
if(initial(v:vis_id))

//step backward through the path until we find the /hud that contains this component descendant
var/parent_path = v
while(regex.Find("[parent_path]",1))
parent_path = regex.group[1]
if(parent_path)
parent_path = text2path(parent_path)
if(ispath(parent_path,/hud))
break

//if we found a parent /hud, store this child in the template for that /hud
if(parent_path)
(templates[parent_path] ||= list())[v] = initial(v:vis_parent)

return templates

//this function constructs all of the child components of a /hud and organizes them structurally
hud_template(type)
var/list/template = hud_templates[type], list/root = list(), list/parenting = list(), list/registry = alist()
var/hud/component/component, hud/component/parent, vis_path

//loop over all the children in the template to create new instances of each of them
for(var/child_type,parent_id in template)
component = new child_type()

//set up the structural link table
vis_path = parent_id ? "[parent_id].[component.vis_id]" : "[component.vis_id]"
if(parent_id)
(parenting[parent_id]||=list()) += component
else
root += component

//store each component within a single registry
registry[vis_path] = component

//loop over all the children that have children
for(var/path,items in parenting)
//use the registry to look up the child that will have children
parent = registry[path]
//if the parent element exists, add all the children we just created that belong to that parent to the parent
if(parent)
parent += items

return root


Now we have all the tools we need to automatically construct a hud container. We just need to make it happen.

hud
New()
InitComponents()
..()

proc
InitComponents()
src += hud_template(type)


Let's take a look at your boilerplate now using an earlier example:

hud/info_menu
icon = 'info_background.dmi'
screen_loc = "TOP,LEFT"

Update(mob/watching)
src["name_label"].maptext = watching.name
src["level_label"].maptext = watching.level

name_label
parent_type = /hud/component
vis_id = "name_label"

//position the name label 8,32 pixels northeast of the container's bottom-left corner
pixel_w = 8
pixel_z = 32

maptext = "Mister Anderson"

level_label
parent_type = /hud/component
vis_id = "level_label"

//position the level label 8,16 pixels northeast of the container's bottom-left corner
pixel_w = 8
pixel_z = 16

maptext = "99"


We're not doing anything particularly crazy here, but as you can see, the info menu has a new function called update. We no longer need to do the setup work for adding the children to the vis contents of the hud. All we have to do is give them a vis_id and the template system automatically grabs those children when this hud is created, and shoves them into both the vis contents and the vis registry. We can use the registry with the operator overloads that we set up earlier to traverse downward from the parent using hard references, or we can traverse upward from the child back to the parent using soft references.

The really cool part about this system, is that the children are defined under the parent. Your code is organized for you very intuitively, and you don't have to write a bunch of the same code over and over again every time you want to spin up a new ui. You just define your structure in the boilerplate, name stuff, and then under the root hud, you write the code that drives the parent and the children, so it's all together, sanely named, and well walled off from other code that could be difficult to disentangle, disable, or update.

This system also allows deeply nested objects by setting the vis_parent of a component to a path string:

hud/grandchild_example
child
parent_type = /hud/component
vis_id = "child"

grandchild
parent_type = /hud/component
vis_id = "grandchild"
vis_parent = "child"

greatgrandchild
parent_type = /hud/component
vis_id = "greatgrandchild"
vis_parent = "child.grandchild"


When this hud's template is created, you will wind up with a node structure where the child contains the grandchild which in turn contains the great grandchild.

Finally, you can still do type path groups with this approach, sharing common features of children of those types in the boilerplate to make your code easier to read and less repetitive:

hud/category_example
navitems
parent_type = /hud/component
icon = '/hud/navitems.dmi'

item1
vis_id = "nav_item1"
icon_state = "1"
item2
vis_id = "nav_item2"
icon_state = "1"
item3
vis_id = "nav_item3"
icon_state = "1"


It is also important to note that you do not need to set the vis_id or vis_parent of a component. A component that doesn't have a vis_id isn't created by default. But if you have a UI that changes what is in its children, you may find it useful to define those children without specifying a vis_id, and dynamically adding them to the hud's registry in response to the things that happen to the hud during its lifecycle. So a component without a vis_id doesn't go into the template, but it is still something you can use inside of the hud it's defined under. You just need to manage the creation and vis_id registration yourself using the operator overloads we created above.

Widgetization



This tutorial is already getting insanely long, so I think I'm gonna only handle one more concept in this edition. Let's talk about a concept called widgetization that I mentioned earlier.

Let's say you have a button. When the button is pressed, you want to be able to do something with your UI. Okay, so we write the code that the button does first, then slap an appearance on it, then hook the MouseDown() proc and call it a day, yeah? Well, what if you want your button to change its sprite when it's hovered, pressed, released, and more, as well as to not do anything if you press it, but drag your mouse off of it? That's a bunch of behavior, and it would suck to rewrite it for every single button everywhere you need to do this.

This is where widgetization comes into play. So far, we've covered components. Components are just one-off portions of a UI. But we also defined another type of hud descendant called a /hud/widget, which derives from components. Widgets are also one-offs, but they allow you to create packages of common behavior that all components that derive from that widget can share. Let's make a VERY simple widget so you can see how this works.

hud/widget/button

//this is just an example, this behavior is way too simple to do this idea justice.
MouseEntered()
dir = EAST
MouseDown()
dir = SOUTH
MouseExited()
dir = NORTH
MouseUp()
dir = NORTH
Pressed()

proc
Pressed()
root()?:onButtonPressed(src)

hud
proc
onButtonPressed(hud/widget/button)
set waitfor = 0


Now when we go to create a component on a hud that should act like a button, we can harness all of this behavior without having to write a single line of code. The widget manages its own state, responding to the mouse actions, and then notifies the root element (the /hud) that a button was pressed.

hud/alert_modal
icon = 'hud/alert_box.dmi'
var/tmp
response

proc/Alert(message,title,button)
while(response)
sleep(world.tick_lag)

src.maptext = title
src["message"].maptext = message
src["button"].Value(button)
response = args

while(response==args)
sleep(world.tick_lag)

var/returning = response
response = null
return returning

onButtonPressed(hud/widget/button)
response = button.name

message
parent_type = /hud/component
vis_id = "message"

maptext = "This is an alert!"

button
parent_type = /hud/widget/button
vis_id = "button"
icon = 'hud/alert_button.dmi'

maptext = "Ok"

proc/Value(value)
name = value
maptext = value


This example responds to that onButtonPressed() function on the top-level hud. The advantage of doing as much as possible on the top-level hud, is that objects using your hud don't need to understand the structure of the hud's children in order to work with it, only the capabilities and behavior of the top-level hud. This same concept applies for wigets, but in the opposite direction. A widget doesn't need to understand its parent's context in order to function, the container is just given functions that allow the widget to notify the container when something it cares about has happened.

Understanding widgetization is important in order to keep your UI well separated from your game code, so you have to do less jumping around, and understand less about the project you are working on in order to add to it. You can live in an isolated code file in your project, and get solid results. I expect most of you are as ADHD as I am; constantly redirecting your focus and bouncing from file to file disrupts your flow and slows down content production.

On the surface, all of these systems seem extremely obtuse and non-obvious. They seem like more work, but once you start working with them, you will quickly fall in love and be capable of achieving stuff that would have given you anxiety or bored you to tears before hand.

Login to reply.