Alright, most of you have read that I've sort of been working in Ue4 recently. I've got a lot of stuff on my plate, what with doing HVAC repair this summer at about 40 hours a week and a number of pressing concerns on my mind about things that I always meant to write down that I know about DM that I've never taken the time to do.
That said, Movement is one of my biggest issues with DM as an engine that is pushing me away. It's certainly good enough to create solid games, but it's one of the biggest issues to a power-user that wants to craft something that controls well and isn't hacked together.
We're in for a long journey. If you can't read code and don't understand the built-in movement system, here's a basic rundown of how it all works:
Terms:
density - density affects how objects determine what is "solid" or not. Two solid objects cannot move through one another by default. A non-solid object can move through solid objects by default. In other words, BOTH objects must be dense in order for blockages to occur.
locality - locality refers to the property of having a discrete location.
/atom does not have locality
/area does not have locality
/turf does not have locality
/atom/movable has locality
/obj has locality
/mob has locality
/image has locality
every other type in DM does not have locality
spacial - spacial refers to the property of being a space where things are laid out in reference to one another. Objects with a spacial property will exist with reference to one another when in a spacial atom. Atoms that are not spacial can contain other objects, but the objects that they contain have no spacial correlation to one another. We can call this "on map".
/turf is the only spacial atom subtype.
null space - Any atom without locality is in null space. Null space is an empty area where all objects live if they lack locality or they are merely not currently assigned a location. We can call this "off map"
unsafe movement - Movement contains a series of hooks that make your life easier for tracking ingress and egress of objects with locality or spacial properties. An unsafe movement is a movement that does not call any of these functions. A safe movement is one that calls all of these functions.
step - A step is a movement by less or equal to the number of pixels defined on a movable's step_size variable.
jump - A jump is a movement by greater than the number of pixels defined on a movable's step_size variable.
teleport - A teleport is a movement between disconnected localities. This means that it is a jump between Z-levels on the map, or from the map into the contents of an object that lacks locality, or from a non-locality object onto the map, or from null space to the map, or from the map to null space. A teleport is only distinct from a jump because I say so. The hook structure of Move() calls is different in this case. This is why I say it is distinct.
gliding - Gliding is a client-side animation that attempts to smooth out tile-based movement, making stepping objects slide according to glide_size every visual frame. Every movable object can only be gliding one at a time. This means that any movement or change in positional appearance (glide_size, dir, loc, step_x, step_y, etc.) may override the existing glide and cause the movable to instantly teleport to their final destination. Glides can only occur over a single tile and thus can only occur in exactly 8 distinct angles.
bounds - Bounds refer to what portion of the movable atom is dense. The visual area of an atom can be larger than its actual physical coverage. bound_width, bound_height, bound_x, and bound_y can be changed to make this bounding region occupy a different space relative to the bottom-left corner of the movable.
translation - Translation refers to moving something according to X,Y offsets in either pixels or tiles.
projection - Projection refers to moving something according to an angular and distance component, using trigonometry to retrieve the X,Y coordinates of the final translation.
movement delay - movement delay refers to how long between movements the character is prevented from moving.
self movement - self movement is a concept of a movement that occurs when something attempts to move by itself (A fish swimming or a person walking, etc.) under its own power. This is distinct from a directed movement.
directed movement - directed movement is a concept of a movement that occurs by the power of another object (A person being dragged, a boulder being pushed, an object slipping on ice, etc.). This is distinct from a self movement.
directional step - a directional step is a movement that takes a direction (N, S, E, W, NE, NW, SE, SW) and a distance as an argument. This is transformed into a translation using simple logic.
Bump - a bump is when an object fails to finish a movement because it reached an object or location that would not allow overlap or entry.
Bumped - when an object fails to finish a movement because of an obstacle, the object that was the obstacle is considered to have been "Bumped".
Cross - when an object attempts to overlap another one in a spacial area. Antonym of Uncross
Uncross - when an object attempts to stop overlapping another one in a spacial area. Anytonym of Cross
Crossed - when an object successfully overlaps another one in a spacial area. Antonym of Uncrossed
Uncrossed - when an object successfully stops overlapping another one in a spacial area. Antonym of Crossed
Enter - when an object attempts to move into a spacial location or container. Antonym of Exit.
Exit - when an object attempts to move out of a spacial location or container. Antonym of Enter.
Entered - when an object successfully moves into a spacial location or container. Antonym of Exited.
Exited - when an object successfully moves out of a spacial location or container. Antonym of Entered.
Enter - when an object successfully moves into a spacial location or container. Antonym of Exit.
walk - A movement that repeats regularly.
peek-in - when an object peeks over the edge of another object from the inside. Antonym of peek-out.
peek-out - when an object peeks out over the edge of another object from the outside. Antonym of peek-in.
envelop - when an object fully overlaps another object. Antonym of escape.
escape - when an object fully stops overlapping another object. Antonym of escape.
edge-slide - when an object fails to move but continues to slide along the edge of its blockage.
ID:2101373
Jun 16 2016, 3:20 pm
|
|
So let's tackle reciprocity and improving density calculations.
Most game engines have collision channels that allow for multiple different kinds of density to coexist. This will break pathfinding, so beware. Implementing reciprocity: Reciprocity is a concept where an action concerning two objects will give both a mutual a say in whether an action succeeds or fails and both objects will be notified when an action occurs. BYOND's movement system lacks reciprocity, making the embedding of behavior tend to go in the wrong place. For instance, you might want something to happen when a player bumps into a certain object. But the only function that is called on a bump is Bump() for the object that moves. The object that was bumped into doesn't know that it has happened by default. Similarly, a movable atom is never informed when it enters or exits a location or when it overlaps or stops overlapping an object. Reciprocity is important for maintainable codebases and makes localizing the code for a new feature much easier. When you are working in environments like SS13's where many developers are touching many things, you need to be able to ensure that your changes are modular. If the behavior for a prototype winds up touching a dozen other object prototypes, you are going to resort to really ugly hacks like using the global root path hack to assure that code stays local and winds up in the right place (which the majority of the SS13 codebases have resorted to thanks to bad practices spread by programmers who didn't deeply understand DM making assumptions about how it works while maintaining a very volatile but understandable separation from those who did understand DM.) atom Okay, a few things here to go over: Origin Hook -> Response Hook Permissive: Enter() -> onEnter() Exit() -> onExit() Cross() -> onCross() Uncross() -> onUncross() Behavior: Entered() -> onEntered() Exited() -> onExited() Crossed() -> onCrossed() Uncrossed() -> onUncrossed() Bump() -> Bumped() The origin hook is called normally by Move(). The response hook is called from the origin hook and the return value of the origin hook is fed through as the final positional argument of the response hook. This allows the response hook to know what the origin hook permitted. The retval argument is only valid on permissive response hooks (onCross()/onUncross()/onEnter()/onExit()) onCrossed()/onUncrossed()/onEntered()/onExited()/Bumped() are behavior hooks. They do not return any values by default. All of these functions are set to not wait for slept instructions just like all other movement-related functions (Move() refuses to be slept), so any return values must be set before the sleep otherwise the default value (null) will be returned and any return values after the sleep will no longer be part of the callstack. Collision channels: Collision channels allow us to avoid writing a metric buttload of hard-to-maintain and easily breakable Enter()/Exit() overrides. This makes for cleaner, smarter code that is more responsive, faster, and easier to understand. You need to understand binary to use this feature well. #define COLLIDE_WORLD 1 Alright, now let's start overriding the hooks we set up earlier. The new logic pattern for collision is: !(obstacle.density&&mover.density&&obstacle.collision_layer&movable.collision_mask) Both objects must be dense and at least one collision channel must match for objects to collide with something. If one of these factors is not applicable, the permissive function will return true. We need to implement a hack though to prevent some default behavior from messing everything up. I'll explain it once you've seen the code. var/list/movable_blockages = 0 The hack I mentioned earlier makes sure that the turf doesn't erroneously prevent movement. This can only happen when both the turf and the movable are dense but don't share any collision channels in common. The built-in implementation is just a hot god damn mess and I've complained about it loudly for a while. If you don't want reciprocity (in other words: you are an idiot) you could use this alternative: var/list/movable_blockages = 0 Essentials: Let's add a few other essentials that are pixel/tile movement agnostic while we're at it: atom The above snippet includes a new built-in function for overriding pre-movement testing. This allows you to do some neat things that are pretty standard more simply, such as only allowing movement if the player is facing that direction, or preventing movement based on a movement delay, etc. It also allows you to more efficiently override the movement behavior of mobs without having to worry so much about screwing up the entire movement function and unknowingly breaking about a billion things. Moved() allows you to build post-movement behavior more reliably as well as get information about the mover's relative change in location and more. It does happen to fire in the case of a simple change in direction and in some cases of failed movements due to bumps, so make sure you check the delta distance based on the old and current locations if you really depend on movement having happened for your override. atom These functions give you quick and dirty means of doing pixel projection, translation, and step offsets and allow you to use overridden arguments unlike the built-in functions. The built-in functions should have NEVER been made global scope because of the arguments. step(), step_to(), step_toward() are just not well thought out or user-friendly and yet another of the things that should never have existed in the first place. >=( ForceMove() Now let's get to the most important part of snippet set #1. ForceMove(). A lot of times you want to move a player forcibly from one place to another. If you ever set the location of an object by assignment, Entered() and Exited(), Crossed() and Uncrossed() are no longer reliable ways of keeping track of objects that are currently on/in something. This means that once you have done this a single time, the only way to reliably figure out if something is on top of something else is via costly polling via bounds() or in contents checks. This is one of the major reasons that my approaches and my code don't work with the majority of BYOND games in existence. It's because almost everyone does this one thing that leads to massive headaches and problems down the line and the vast majority of you continue to do it, defending the practice ad populum without even the slightest clue why you are wrong. I cannot be held responsible for demolishing your argument and then refusing to put up with your passive-aggressive butthurtedness every time we ever interact int he future if you try to tell me I'm wrong about this. I'm not wrong about this. My approach is better even if it performs worse. Deal with it. What is ForceMove() exactly? It's a means of forcing movement by ensuring that Entered()/Exited()/Crossed()/Uncrossed()/Moved() are called, but Enter()/Exit()/onEnter()/onExit() are not. Any time you'd manually set the location of an object, you will want to use this function. ForceMove() should always be considered a teleport, never a step or a jump. atom Of course, ForceMove() is more or less a duplication of Move(). It returns 1 if the mover was actually relocated in any way or changed directions. It will return 0 if the forced movement would have no effect (IE currently in the position being forced to move to. Either way, it should be considered successful. The need to test the forced movement probably isn't there, but whatever. Distinguishing between the two cases could in theory happen, so why not? Now for those among you that like shortcuts, there may be a situation where you want to force a translation, a projection, or a step: atom Rebounding: For the final feature from snippet set #1, we're going to implement a reliable way to change an object's bounding box dynamically while keeping the object in place according to an anchor and calling the proper Crossed()/Uncrossed()/Entered()/Exited() hooks: the Rebound() function: atom Rebound() takes the following arguments: x, y, width, height, anchor, offset. x and y are the bound_x and bound_y operands. width and height around the bound_width and bound_height operands. anchor is the point that the bounding box will stay anchored to. use a direction or 0 (specifies centered) to set the anchor. offset determines whether the x and y values are offsets. If this is true, x,y,width, and height will all be added to the current bounding box. Otherwise, the default action is to change to the values specified. If x,y,width, or height are null, the current values will be used. Additionally, if width or height are 0 or negative, the current values will be used. To summarize: All of these functions should have been built into BYOND from day #1. They aren't and that's just you know, kind of sad because the realization of these functions is the culmination of 15 years using the engine, exploring how it works, and finding its weak spots. Every other game engine in existence provided this level of functionality from launch. There's just no sense in calling this an engine for beginners when someone who has mastered the language and the environment inside and out has to bring this level of customization to bear in order to make the environment bearable to work in. |
Metadata:
Now that we've totally upended the garbage heap that is the built-in movement functions, let's pull some information out of them. This section relates to getting metadata from movement functions that will help us to achieve a reasonable means of figuring out what happened during a movement. Sometimes you need to know what an object bumped into during a movement. Sometimes you need to figure out how far a mob moved during a movement. Other times you need to know how far the player was attempting to move after a movement. None of this information is readily available to you by default. We're going to fix that and we're going to do it as cleanly as possible. Fair warning, this uses some hacks to prevent circular references persisting when they aren't needed. You need to be aware of how garbage collection works to use some parts of this and you really need to understand the scheduler before you start trying to get reliable information about movement metadata from the systems I'm setting up. atom These are the new variables we're going to be implementing. moving_ prefixed variables refer to the current ongoing movement and should only be checked inside of movement-related procs. Once the Moved() has returned, these are no longer valid. That includes inside of spawned or slept child calls from within any movement-related functions. moving_obstacles can be checked just after the movement ends, but only within the same frame. So cache the data if you need it later on downstream by Copy()ing it into another variable. Don't rely on the data hanging around or the list contents referring to the same movement. Here's why: To ensure that garbage collection doesn't get ruined by our metadata, we need to implement a function that will cull the metadata of all movers at the start of the next frame. var/move_tracker/move_tracker = new/move_tracker() The move_tracker datum will keep track of all movables that have moved in the current frame and set their moving_ prefixed variables to null. Let's start adding some of this metadata tracking: #define MOVE_STEP 1 This metadata is damn useful. Take advantage of it instead of constantly homebrewing your own patterns over and over again. This will tell you most everything you would probably want to know. moving_x/y could be used to get the angle of the attempted movement for calculating rebounds and impact velocities. moved_x/y would be used to calculate how much of a step is left to continue attempting in cases where movement should fail and then continue (like edge sliding). moving_dir can be used to prevent entry from a particular side of an object on Cross()/Uncross()/Enter()/Exit() without having to actually figure out the direction per-object, making such calculations much more efficient and less repetitive. moving_flags could be useful for preventing Crossed()/Uncrossed()/Entered()/Exited() calls from doing certain things when certain objects teleport, step, or jump onto them. This is a common pattern that many would argue that my ForceMove() implementation makes more difficult. This is why the metadata is useful. If you were to step on a teleporter that uses Crossed() to detect what objects to teleport, ForceMove() would cause an infinite loop of teleportations without the metadata telling you that the player was forcibly moved into place and thus shouldn't be teleported. Movement delays: Movement delays are one of the most common things you can do in DM. In order to make a professional looking tile game, you need movement delays. You can probably get away without them in a pixel movement game TBH. But there's a downside to movement delays. If you implement a naive solution, you can't make the object respond to external forces or teleport it around during a delay. Don't worry fam, I got you covered. This is a hard modification to the call structure of the Move() proc. Get ready for the difficulty spike and breaking changes. They aren't going to stop from here. #define MOVE_DIRECTED 8 Directed() is called on an object when it is the source of another movement. It is the equivalent of Moved(). canDirect() is similar, but it is the equivalent of canMove(). Smoothed gliding: Smoothed gliding is for tile-based games only. A move_delay is REQUIRED for this to work properly. atom/movable Note that you will need to multiply delays by SQRT2 and remove LONG_GLIDE if you want diagonal movements to delay properly. Move Queries: Breaking changes ahoy! Movement querying allows you to check if something will successfully move into place. This changes a lot of the built-in guts of the movement functions and you need to know how it works. What we're going to do is add return values to Entered()/Exited()/Crossed()/Uncrossed() and Moved(). If it returns 0 that means that the behavior function should not actually do anything. This makes it so that your downstream overrides can target or ignore queried behavior calls selectively. atom This might be a bit tough to understand the reason behind, but it's by far the easiest way to implement proper movement querying without reimplementing BYOND's entire native movement system as a separate proc and maintaining multiple similar rulesets for different movement subsets. Centering: This is another highly useful, but fairly low effort set of functions you will find useful. Centering functions. These functions will attempt to force an object to move to a location based on the center of another atom. These should only be used if you are using pixel movement: atom It only gets crazier and more involved from here. I still need to cover subpixel movement, sliding movement and atomic parentage, but the end is within sight if you can stick with it. |
I don't know if "/atom/movable has locality" is really valid.
In a bug report Lummox explained to some of us tgstation13 SS13 devs that our /atom/movable subtypes were internally /obj, as in to Byond they were /obj, but they lacked the /obj path, so I would say here that /obj has locality, not /atom/movable. I *suppose* you're just writing it out to cover all bases, but it'd be nice to label it as a special case of /obj (and the same with /atom being a special case of /turf) Also I excitedly await the rest of this snippet. |
This should be hawt. I was planning on playing around with unreal too near the end of summer since it's now free, would look good on a resume and gives some nice experience
|
This should be hawt. Don't count on it. Code Dump means: "Semi-functional, but unpolished". I don't know if "/atom/movable has locality" is really valid. True in a way, but /obj, /mob, and /image all have locality and they all inherit that behavior from /atom/movable's core structure. It's just easier to think about the polymorphic cascade and consider /atom/movable derived types as being a compiler quirk. |
Sets #1 and #2 have been written out and separated into their constituent parts.
Those of you that are fluent in code are going to have to work out a lot of my reasoning for yourself and explain it to those who aren't. I don't really have time/energy to explain a lot of things any more in depth than I already have. Please take advantage of this and other coming braindumps because I'm tying up loose ends and trying to write down all the things that I've accumulated over the years for you guys' benefit. I won't be around as much as I have been the past few years and I'd like to leave without taking a huge chunk of this knowledge to the grave. |
Is there a particular reason you use waitfor as you do?
Alright, most of you have read that I've sort of been working in Ue4 recently. Are you working on releasing something? That's the idea I get based on your reasons stated. |
Is there a particular reason you use waitfor as you do? All movement-related procs are internally set to waitfor = 0. My reciprocals therefore mimic this behavior. sleep() is incorrect in movement-related procs: Move()/Enter()/Exit()/Entered()/Exited()/Crossed()/ Uncrossed()/Cross()/Uncross()/Bump(). Therefore their reciprocals inherit this behavior. spawn() can be correct in these functions, but the occasion where it is is rarer than when it isn't. Are you working on releasing something? Yep, but it's not ready to show. |
if(!moving_obstacles) Is this in error? I think you meant moving_obstacles = list(o). Also fixed the syntax in some variables used in Step(): var/mx = dir&EAST ? 1 : dir&WEST ? -1 : 0 Edit: I'm also catching on to your use of len. Useful alternative for when I would have normally used Cut(). |
A typo in your first post?
escape - when an object fully stops overlapping another object. Antonym of escape. |
One nitpick is that "spacial" is actually spelled "spatial"
And "spacial" looks like you typo'd special |
Well, it's too generalized for one. It's naive second. It's also extremely difficult to get metadata out of many of the built in procs without a herculean amount of reworking.
In pixel movement mode, BYOND's movements will refire every time that the step distance exceeds the bounding width or height. This means that every movement is broken up into max(ceil(abs(ox)/bound_width),ceil(abs(oy)/bound_height)) separate individual movements. This causes Cross()/Uncross()/Uncrossed()/Crossed() calls to refire more than once because larger steps can cause multiple movements to occur, some of the objects that are concerned with movement will not properly be culled from the incoming list of objects and tiles we are moving across.
If multiple objects exist on a tile, and only one of them is dense, Bump() will be called even on the objects that aren't technically blocking the movement.
step() functions return the wrong values according to Move().
step()/walk() functions are not overridable, so if you want to change how movement works at the core level, you can't use them. This makes working with a codebase you haven't written violate an awful lot of expectations that newer programmers would have that are familiar with the core.
locations are specified in x:step_x,y:step_y,z coordinates. The functions that require these values do not adjust them properly and there exists a massive amount of feature gap regarding these values like structs that make storing them easy.
You cannot easily get the direction a Bump() occurs from.
You cannot easily get information about obstacles encountered during the last movement.
Built-in pathfinding does not respect Enter()/Exit() rules.
Built-in pathfinding does not return paths, but instead refires every single frame it is used, giving players no option to automatically implement naive or self-adjusting pathfinding without fully writing their own pathfinding solution. Documentation doesn't even warn you about this. The naive will just assume that BYOND is slow upon using pathfinding.
Cross()/Uncross()/Crossed()/Uncrossed() are not built in as separate hooks. They are built in as part of a turf's enter/exit procs. This makes overriding the built-in Enter()/Exit() functions immensely problematic because you can't distinguish between the turf's default behavior and overlapping during a supercall. If at any point, you override Enter() or Exit() without performing a supercall, you lose the built-in behavior of checking objects and have to rewrite it manually further down in the chain. The logic for checking which objects will collide or won't is immensely non-trivial because of massive, glaring, 7+ year unaddressed feature gaps in the engine.
Bounding boxes are singular and movable atoms cannot be parented together. There are almost no engines where this is impossible without writing your own code.
I could go on, but I don't want to complain for too long.