Meet 516: Vectors
Vectors are a nigh inescapable concept in gamedev. Until now, BYOND has largely gone without, unless you use one of the libraries that provides them for you.
But what are vectors? Vectors are a tool to help you represent spatial information and do common math operations. Vectors in DM are both 2D and 3D. 2D vectors have an x, and y value, while 3D vectors add a z value. These values are arbitrary coordinates in 2D or 3D space.
You can check whether you have gotten your hands on a 3d or a 2d vector by checking vector.len. So in a way, they're an awful lot like lists.
// x,y,z
var/vector/2dvec = vector(1,1) //see? Looks an awful lot like making a list.
var/vector/3dvec = vector(1,2,3)
world.log << 2dvec.len //outputs 2
world.log << 3dvec.len //outputs 3
But did we really need another primitive type? I prefer pixlocs! They aren't exclusive. Pixlocs are a special kind of vector with behavior specifically suited to working with a tilemap. Vectors are much more generally capable than pixlocs, and will help you do things that pixlocs alone can't do. Having an understanding of both of these tools will really help you accomplish more, with less code and less CPU usage.
Vectors can store arbitrary dimensional or spatial information. This means that you can use them to determine what direction something is facing, or you can use it to determine how far something should move, or what the distance is between two objects, or even whether two things are facing in the same direction.
Normally, to determine the distance between two objects, we'd need to do a bunch of math:
proc/distance(atom/a,atom/b)
var/dx = (b.x * TILE_WIDTH) + b.step_x - b.bound_x + b.bound_width/2) - (a.x * TILE_WIDTH) + a.step_x - a.bound_x + a.bound_width/2)
var/dy = (b.y * TILE_HEIGHT) + b.step_y - b.bound_y + b.bound_height/2) - (a.y * TILE_HEIGHT) + a.step_y - a.bound_y + a.bound_height/2)
return sqrt(dx*dx + dy*dy)
But vectors make this way easier:
var/dist = (b.pixloc - a.pixloc).size
Understanding vectors:
A vector is just a point.
But a vector implies the existence of another point: (0,0). This point is also called the origin.
If we plot a line segment (0,0) to (x,y), we wind up with another piece of information:
When we plot a line segment between a vector and the origin point, we can now figure out the length of the vector, or its distance from (0,0). This is often called its magnitude, or in DM, its size.
vector(12,13).size //returns the length of the vector (in arbitrary units)
Vectors don't just contain information about their position and size. They also have directionality. If we draw a right triangle with the vector as the hypotenuse, we can start to understand some of the properties of a vector. Using the size of the adjacent and opposite sides of this triangle, we can figure out the angle of the vector.
Working with angles
arctan(89,102) //about 48.9 degrees
arctan(vector(89,102)) //arctan also takes vectors
Another really cool thing about vectors, is that you can reduce them to be what's called a 'unit vector'. A unit vector has a size of exactly 1. We can due this by dividing the vector by its length, or normalizing it:
var/vector/point = vector(89,102)
var/vector/normalized = point / point.size
//Shortcut!
var/vector/shortnorm = vector(89,102).Normalize()
A normalized vector contains only the directionality of the vector, so you can take a normalized vector and multiply it by a number to get another vector with a size equal to that number in the same direction as the source vector.
Let's say you have a target in a game, and you want to move in a straight line toward smoothly over time. You would get a vector from a to b. You would normalize it, and then you would multiply it against the user's movement speed:
var/vector/beeline = (bound_pixloc(target,0) - bound_pixloc(src,0)).Normalize()
src.Move(beeline * step_size) //Move() takes vectors now.
What if we wanted to add a chaotic drunken stagger to the player's line of motion? We can turn() vectors too:
var/vector/beeline = (bound_pixloc(target,0) - bound_pixloc(src,0)).Normalize()
//rotate the normal randomly between -22.5 and 22.5 degrees
beeline.Turn( (rand() - 0.5) * 45)
src.Move(beeline * step_size)
The built in turn() proc also works, but this copies the vector and then rotates it instead of modifies it. You might prefer one over the other in some cases. Similarly, vector1 += vector2 will change vector 1, while vector1 + vector2 will create a new vector, and leave vector1 untouched. Most (but not all) vector procs that you call on a vector like vector.Turn() directly alter the vector instead of creating a copy. The global procs like turn(), round(), max(), and fract() will typically create a copy of the vector instead of altering it.
What if we want the player to run away from the target instead?
var/vector/beeline = (bound_pixloc(target,0) - bound_pixloc(src,0)).Normalize()
src.Move(-beeline * step_size)
You can invert a vector by using the negation operator, which creates a copy of the vector, with its coordinates' signs changed. This will give you a vector of the same length, but the opposite directionality. If you want to invert a vector without creating a copy, you can simply multiply the vector by -1 using the multiplication assignment operator:
vector *= -1
Normalization of a vector can also be done non-destructively, but there isn't a nice named operation for it. Normalizing a vector is just dividing it by its length:
vector.Normalize() //alters vector
vector /= vector.size //also alters the vector
vector2 = vector / vector.size //does not alter the vector.
Be careful though! Divisions by 0 need to be checked for when normalizing a vector. A vector with a size of 0 has no directionality or length.
Dot products
Another useful tool in your quiver with vectors is determining whether two vectors face the same direction. Let's say you are working on a game, where you can be a lil' sneaky boi and steal pies from window sills. You don't want your AI to always know what's going on behind them, so we need to know whether the AI is facing the player when they steal a pie.
//when you have a direction, but need a vector
proc
dir2vector(dir)
var/vector/forward = vector(0,0)
switch(dir&(EAST|WEST))
if(EAST)
forward.x = 1
if(WEST)
forward.x = -1
switch(dir&(NORTH|SOUTH))
if(NORTH)
forward.y = 1
if(SOUTH)
forward.y = -1
return forward
obj/pie
verb
steal()
set src in oview(1,usr)
//make sure the action is still possible
if(get_dist(usr,src)>1)
return
//alert the neighbors
for(var/mob/m in oview(usr,10))
//only alert neighbors that care about crimes
if(m.alarmable)
m.WitnessCrime(usr,src,"steal")
loc = usr
//set up a convenience macro for creating angular view cones
#define view_cone(a) -cos((a)/2)
mob
var
alarmable = 1
alarm_distance = 9
alarm_limit = view_cone(45) //the cone of view will span 45 degrees
proc
WitnessCrime(mob/criminal,atom/object,action)
//get a local vector from the mob to the criminal
var/vector/tocriminal = bound_pixloc(src,0) - bound_pixloc(criminal,0)
//if we're within range to see the crime
var/distance = tocriminal.size
if(ceil(distance / TILE_WIDTH) <= alarm_distance )
//get a vector that points in the direction this mob is facing
var/vector/eyeline = dir2vector(src.dir).Normalize()
//determine if the crime was committed in our cone of view
if(!distance || eyeline.Dot(tocriminal) / distance <= alarm_limit)
SeeCrime(criminal,object,action
SeeCrime(mob/criminal,atom/object,action)
criminal << "Stop right there, criminal scum!"
There's a lot going on here, but it's all stuff we've seen before. The only things that are really new here, is checking to see whether or not two vectors are facing a similar direction. Let's tease that out a little bit:
//this
value = a.Dot(b)
//is the same as this:
value = a.x * b.x + a.y * b.y
//both give you a value that is equal to:
value = a.size * b.size * cos(angle)
In our example, we're using a normalized vector for the direction the watcher mob is facing, so its size would be 1. We don't know the value of angle, so we're doing a little bit of algebra to solve for that angle. Let's plug in our known values:
value = 1 * b.size * cos(angle)
//one times anything is itself, so we can remove the term.
value = b.size * cos(angle)
//b is the distance between the mob and the criminal. We already know that, so let's divide it out:
value = (b.size * cos(angle)) / b.size
//the sizes cancel out, and we're just left with:
value = cos(angle)
The cosine of any angle will always yield a value between 1 and -1. In this case, the Dot product will yield -1 if the two vectors are facing the exact same direction, 0 if they are at right angles to one another, and 1 if they are facing the exact opposite direction.
When we set up our mob's sight line, we made a convenient little macro that allows us to set up a nice cone of view. Supplying 45 would mean that your cone of view would have -22.5 to 22.5 degrees of peripheral vision from the direction they are facing. Supplying 90 would mean they could see a 180 degree arc around themselves, and supplying 180 would mean they can see everything that goes on around them.
Dot products are incredibly useful, and have a wide variety of use cases in games well beyond what we're using them for here.
Lerp: Funny name, big utility
BYOND 516 also added a nice new tool for us to use. lerp() is a function that does linear interpolation. Lerp()ing a value allows you to supply a starting point, an end point, and a progress factor to receive a value that is progress% along a line between start and end. Let's take a look at that:
//lerp() interpolates:
world.log << lerp(0,10,0.9) //outputs 9
world.log << lerp(0,10,1.0) //outputs 10
world.log << lerp(0,10,0.0) //outputs 0
//lerp() can extrapolate too:
world.log << lerp(0,10,2.0) //outputs 20
world.log << lerp(0,10,-1.0) //outputs -10
lerp() works with vectors too! You know how animate() smoothly tweens values between a start and an end point? That's because animate() uses linear interpolation under the hood. All those fancy easing types are just formulas that alter the rate at which the progress reaches 1.0. There's some great resources on easing functions out there you can check out to get a peek at the math for creating them.
Generally, when we want to lerp between two values, the two values remain fixed, while the progress changes over time. This isn't always true, however. Sometimes we might want to lerp() between two constantly changing values at a fixed position between the two. This might be useful for instance, if you wanted to put a little graphical effect exactly in the middle of the line between two mobs who are engaged in combat.
//lerp() supports vectors, pixlocs, matrixes, turfs (bottom left corner), and numbers
var/pixloc/midpoint = lerp(bound_pixloc(mob1,0),bound_pixloc(mob2,0),0.5)
We can also use clamp() to keep that object within the bounds of the user's screen:
//find the midpoint between, and orient it on the bottom-left corner of the sprite
var/pixloc/midpoint = lerp(bound_pixloc(mob1,0),bound_pixloc(mob2,0),0.5) - vector2(floor(sprite.bound_width/2),floor(sprite.bound_height/2))
//find the bottom left and top right of the screen
var/pixloc/screenpoint1 = pixloc(client.bound_x,client.bound_y,client.bounds[5]) //client.bound_z isn't a thing)
var/pixloc/screenpoint2 = screenpoint1 + vector(client.bound_width - sprite.bound_width - 1,client.bound_height - sprite.bound_height - 1)
//create a clamped pixloc relative to the screen
var/pixloc/screenloc = clamp(midpoint,screenpoint1,screenpoint2)
//move the sprite to the location
sprite.pixloc = screenloc
Generally speaking, when lerp()ing over time, you will be finding the time elapsed, and then dividing that by the duration of your time period:
var/progress = (current_time - start_time) / duration
However, don't limit yourself to thinking about progress as time. You can treat other values like they were time, for instance, let's say you have a healthbar. The amount of the healthbar that should be full would of course be health / max_health.
So you could fill a masked healthbar GUI element with lerp():
var/progress = clamp(health / max_health,0,1)
healthbar.fill.transform = matrix().Translate(lerp(0, -healthbar.fill.width, progress ), 0)
Thank you!
That's about all I have for you guys today. I'm working on few things in the background that I can't release until 516 is out and in the hands of the public. Please, please please consider giving Lummox some love and encouragement (cold hard cash thrown directly into his wallet is very loving and encouraging, but words are good too) for all of these new, great things he's added to the engine. 516 doesn't exactly have a lot of big, sexy things to show off, but these are far and away some of the most important updates that have been made to the engine in the last decade. They are going to massively improve the ease of using BYOND for programmers, and directly increase the quality of the games on the platform as a result. If you have any cool stuff to show off that you've made, or even some corrections and tips for me here, feel free to toss them into the replies.
If you want to see what the community has been up to lately, come join BYONDiscord! and scope out our showcase channel and our hall of fame. We've been cooking some really neat stuff with the alpha, and there's some great people that offer daily support and encouragement to anybody that needs it.
Come show us what you've got!