WARNING: If you choose to proceed, read this carefully and understand it, or it may end up confusing the shit of of you. ;)
I'm not really sure why I'm revisiting the Starcraft Campaign editor after almost a year of basically nonuse, but, hey, I'm doing it. :) The ideas I want to expand upon in this article aren't really limited to the Staredit trigger system at all actually, and are really good basic design philosophy tips for any programming language. Yes, the trigger system is a sort of programming language in itself, though obviously very high level. Don't fear the abstract correlations I'm making however, because, as you'll see, many, if not all of the constructs I will name explicitly, you've already done at one point or another before. So then, what is the point of giving them fancy complicated names at all? Well, for one, these are the names that "real" programmers use for those ideas and two, is to show you that the basic notions are really very simple and you can transfer them from Staredit to a "real" programming language, or vis versa. Don't be afraid of the jargon and the abstract explanations; they are there to generalize, but you've probably already executed them concretely before. The idea is to form general ideas so you can more easily implement them explicitly in later projects.
Abstraction is the idea of generalizing a specific process. For example, here is a concrete notion of addition: 1 + 1 = 2. Here is an abstract notion: x + y = sum of x and y. Sometimes you want to build upon more "primitive" processes to get more complex processes. For example, you can define multiplication in terms of addition (+):
To multiply x and y is to add (+) y copies of x together; or, explicitly, x*y = x1 + x2 + ... + xy
But obviously when you multiply two numbers together you don't actually add the number up that many times in your head. In fact, you probably don't even explicitly think about this process. That is what you would call abstracting the idea of multiplication. E.G., you don't actually have to know the explicit process that * carries out its operation, you just have to know "what it does," not "how it does it."
So what does this have to do with triggers? The basic idea of abstraction can be applied in many powerful ways to your trigger design philosophy that can make creating complicated and enormous maps manageable, and, in fact, simple. Here is the basic idea: When you think about how you are going to layout your map's events (triggers), you don't always want to explicitly think about the entire system in terms of just the triggers. You want to compartmentalize your triggers into distinct sections or groups, each one performing a specific task. You shouldn't have to worry about the bits and pieces of each part of your system (i.e., each + in the * process) so long as you know they are going to do they should do.
Here is a concrete example. Let's say you have a cutscene that you want to begin when a certain unit reaches a certain location. Here's what you don't want your layout to look like:
unit to area => cutscene trigger 1 => cutscene trigger 2 => cutscene trigger 3 => background process trigger => end cutscene triggers => trigger give control back to player => start next process trigger ...
This may make sense if you're just "going with the flow" but it will ultimately become unmanageable when your number of triggers grows very large. You want to abstract the entire process of the cutscene into its own process, even if it consists of many triggers (don't think about how the cutscene does what it does, just assume that it works and you can figure out the nitty gritty later):
unit to area => [cutscene] => start next process trigger ...
Notice that "background process trigger" and "give control back to player" were both incorporated into [cutscene]. Whether this is a good idea or not depends on what those triggers are supposed to do. For example, "give control back to player" seems like a trigger that really just uninitializes the cutscene to the state the game was at before the cutscene started (or similar). So really, it is the part of the cutscene that really ends it and should be treated as part of it. The greater distinctions you can draw between abstract processes, the better you can encapsulate them into a solid whole. How the "nitty gritty" is actually done is explained in the next sections.
One of the biggest bugs I find in maps that have lots of triggers is that some of them tend to conflict with each other in unexpected circumstances. Such is similar in the case of large programming where you might have functions conflicting with each other's variables because they have the same identifiers. The way to avoid this in programming is localizing variables to specific functions so no other function can touch them. A similar idea can be applied to triggers to prevent them from stomping on each other.
Through abstraction, say you have two processes that you want to be carried out by triggers, cutscene1 and spawnenemies2. These processes are distinct and have nothing do to with each other, but say they both occur and use the same locations and involve some player interaction. Now, even if they occur at different times, and especially if they have preserve trigger actions, there is a good chance that some triggers of one process might accidentally wind up executing in the other process. You don't want that to happen.
Here is one way to localize the triggers of one process so they can not occur during another time. When the process begins, set switch Z. For every trigger that is part of that process, include a condition that switch Z is set. When the process is over, clear switch Z. Simple eh? You've probably even done this before. But the idea becomes very powerful when you always do it. This way you never have to worry about a unit stepping into a location that is used by two separate processes and having the wrong one execute. And you won't have to worry about some trigger of a process executing after that process is complete. (Well, almost never, concurrent processes may disrupt your balance, so you still have to think about that)
This probably seems pretty mundane and obvious. Well, really, it is. The problem is, most people just don't do it, because, of course, you know those triggers will never conflict, in fact you've planned it out to the smallest detail and practically have scripted out all of your triggers by hand already, so why the hell go to the trouble of adding in a switch in all those triggers, right? Well that certainly isn't a good attitude. =P This isn't about "taking chances" or "maybe something will go wrong," this is about encapsulating a group of triggers into a distinct abstract process, so you don't have to think about every single trigger in your map that you're going to make. Its so you can do one process now, and do all the rest a month from now and not have to worry about what the first one did. By localizing your triggers into their distinct processes, you are more able to think of those processes as separate objects that can be manipulated just like the more primitive triggers:
[cutscene 1: dropships fly in and land troops] => [initialize player control: set up units 'n stuff] => [enemy attack patterns] ...
This is a lot easier to think about than this:
create 4 dropships => issue order to dropships to move to land location => when dropships reach land location, create 4 marines => issue order dropships to move back to starting point => when dropships reach starting point, remove them => issue orders to marines, move to base => give marines to player => give base to player => send transmission to player => give resources to player => if player moves units to my_hero, send a transmission from my_hero and give him to player => create enemy units at location [loop] => issue order to enemy units attack player's base => player reaches destination_X, stop creating units at location ...
This mutes everything together and is too easy to jumble up.
Here is another other localization method. In a lot of maps, you just want to have a bunch of linear events that occur in order. E.G.,
[initialize resources and stuff] => [cutscene 1] => [mission part 2 to kill big enemy dude] => [kills big enemy dude, victory]
What's common is to set a switch in the last trigger of each process to signal the next one to begin. While even Blizzard does this, its not really the best way to deal with a linear sequence of events. The better way (which has been used for a very, very long time already) is to use a Custom Score to keep track of events and increment it when you want to move on. E.G., at custom score = 20, [initialize resources and stuff] happens, then the score is set to 40 which causes [cutscene 1] to happen which then sets the score to 60 causing [mission part 2 to kill big enemy dude] to happen, etc. Why is this method better? For one, you are modeling a linear sequence of events, so why not just use a linear incrementation of a score to represent it? But more importantly, this isolates each particular process and prevents each one from conflicting with the other. Since each process can only occur during a certain value of the custom score, and the custom score can't have 2 of the same values at the same time (duh), it is impossible for two processes to overlap.
O.K. So now we've been able to encapsulate many primitive and confusing triggers into very distinct abstract processes. We can now think more abstractly about how we set up our larger trigger system and perhaps make it neater. But really, most people would say, so what? Never fear, I've held out on the biggest benefit of this design philosophy for last. :) The biggest and overall most important reason this is a good way to design your triggers is that it makes them a WHOLE LOT EASIER TO DEBUG. You know when you make one of those horridly long maps, maybe has branching paths for the player to take, some random events that can happen, the works. And then you're done, but there's this one bug with, say, the final boss of the map. So you think you see the problem, you fix it, then you trod through the map (with power overwhelming and all the other associated cheats of course ;), and an hour later, you're at the end boss. The bug is still there. Fuck, right? ;)
No longer will you have to do such a thing. If you've ever looked inside one of my own huge maps (such as New Avalon II or even the Millennial Project which was just a cutscene), you'll notice a little trigger I have at the top labeled "Main Initialization" or something like that. All it does is clear some switches and set a few scores to their initial values. Study the map a bit more and you'll see that its almost completely unnecessary. So why is it there? Recall that each "process" should be made abstract and as distinct from the others as possible. That is, if you were to start that process in the beginning of the map instead of the middle, then it shouldn't make one bit of difference. So really, all you should have to do is initialize that specific process to get it started (e.g., set a switch, set the custom score to a specific value, etc.), not trod through all the triggers that were supposed to happen before that.
What do we have now? Well, just to initialize ourselves to the end boss, and we can start there to test our bug fix. But wait? How do we know it isn't something else? We skipped a whole bunch of stuff... what if some of that stuff conflicts? ... We shouldn't have to worry about that at all, because if we did our job right, each process is distinct and localized from the rest, so they can't conflict because by design it's impossible.
Here are a few more pointers to help localize events or processes from each other:
1) Whenever possible, at the beginning of a process, create the units that will be involved manually with triggers instead of assuming they will be there or preplaced on the map, and remove them afterward. In the best case scenario, the critical preplacement of units on the map should remain static between all processes, I.E., there shouldn't be any units preplaced on the map that are essential to a process and that won't remain in basically the same spot from one process to the next (any other critical units should be spawned via triggers in a designated process). This will prevent you from accidentally "sharing" a unit between two separate processes or accidentally having units in a place you didn't expect them when you debug. Sometimes this is difficult to do (e.g., you might be worried about running out of locations if you have to spawn all your critical units), and usually you won't want to do it for every critical unit. The main thing you should keep in mind is that the units preplaced on the map or left over from process to process act as global variables which can break the localization of some of your processes (e.g., if you had one process create some units and then another which changed ownership of those same units, then those processes have become interlinked, and one is not necessarily isolated from the other).
2) In the case of (1) and you are debugging, make sure you initialize the "environment" (the map) to the state that you expect it to be in when you test a specific process (e.g., skipping to the end boss, you probably have to spawn your hero at the end boss rather than at the normal start location). Spawn units that are assumed to be spawned, and remove units that are assumed to be removed. The more units are localized to your processes, the less work you have to do here. But the important thing here is to remember to DO THIS VIA TRIGGER, DO NOT INITIALIZE A MAP STATE BY PLACING AND REMOVING UNITS ON THE MAP. If you remove units somewhere and create some somewhere else and maybe change the state of some others (burrow, invincibility), there is a good chance that you'll forget to revert one of these things back to normal when you finish. By creating the necessary units, removing, and setting states as needed in a single trigger, you can easily just delete it when you're done.
3) Sometimes you can have two processes running concurrently (e.g., say, randomly spawning enemies and a perpetual cutscene at the same time). Whenever possible, I would try to combine these things into one single process with unique sets of locations, etc. so you know how they will interact with each other. That is usually not possible however, since you may want one process to end before the other, or they are just separate things and it wouldn't make sense to combine them. In that case, separate them as much as possible (different location sets used, try to keep units from each process from interacting with each other, etc.) and remember that they may conflict. This way, if you find a bug, you can turn off one of the processes and see if the bug still occurs. If so, then you know it is either because of the other process or because they are conflicting. And then there's still only a limited number of triggers you have to look through to determine what's wrong.
4) Just a reminder to always END processes when they're finished, either by clearing the associated switch, or moving along the custom score. Don't let it trail on longer than absolutely necessary. Make bright lines between when something is on and something is off. That way you can't have a situation where you think, well *maybe* this is the problem.
Iteration, Recursion, and Looping Structures
Here comes the big fancy programming jargon. ;) Don't worry about it. Its not really important what they mean. Basically, iteration is an ideas in which you cause something to happen over and over again (e.g., add x to itself 5 times). Recursion is the idea in which you define something in terms of itself (e.g., a dog is a dog; here's a more complicated one for math people: f(n) = f(n-1)+1). In both cases, what you are doing is causing a "loop." Iteration: add x to the initial x: x + x = 2x, then you add x to 2x: x + 2x = 3x, then you add x to 3x: x + 3x = 4x, ... each step you essentially loop back to the start and repeat the process of "adding x to the result." Recursion: a dog is a dog. But what is a dog? a dog is a dog. But what is a dog? a dog is a dog... ;) Or for the more math oriented example: f(n) = f(n-1)+1, f(n-1) = f([n-1]-1)+1 = f(n-2)+1, f(n-2) = f(n-3)+1, f(n-3) = f(n-4)+1, etc. In each case, you have to loop back to the original definition because it is defined in terms of itself.
Application in triggers? Here's one: Madness maps. ;) How does one continually spawn units in a location? By creating an iterative process of course: each step creates a unit, then you loop back and create a unit again. Simple, eh? But what's the problem here? We have an infinite loop. The "create unit" trigger is preserved forever and has no "exit case" (condition where it would stop executing). Thankfully, Starcraft only executes triggers every 2 seconds so there usually isn't a problem. But, you've probably noticed, if you've played madness maps at all, that eventually you will begin to get "ERROR: Zergling unplacable at [XXX,YYY]!" messages if you camp in your base too long and let those lings pile up. Those are pretty annoying after a while and should qualify as a bug. To avoid the infinite loop, we want to add an exit case to the iterative process.
Our process is something like this "Create zergling, loop to start." What we need to do is this: "If Player brings at most 100 Zerglings to area, Create zergling, loop to start." Simple enough. Just add that as a condition to our preserved trigger. You probably never even thought if it this way, huh? Just complicated things for you. J.K. ;) This is just a simple example to get you to think this way.
Sometimes in an iterative loop, we want to increment the value of something during each iteration (each time we repeat the loop). E.G., the first time we might want 1 zergling, the second 2 zerglings, the third time 3, then 4, then 5, etc. Unfortunately, Staredit doesn't provide a strait forward way to implement variables (except for boolean switches) into triggers. However, there still is a very nifty use of this idea that you've probably seen before. Using a custom score, you can create a preserved trigger that increments it every 2 seconds (Add one to custom score, loop). This is essentially the same construct as before, but now you have a variable that's counting up. You can set up a trigger to create 1 zergling when the score = 1, 2 when the score = 2, 3 when the score = 3, and thus implement the idea we had before. Nevertheless, this is again, and infinite loop.
To correct this, we have to give an "exit case" to the preserved incrementing trigger again. Usually we say something like "Custom score is at most 100" indicating that the score will stop incrementing at 100. One popular and cool use of this is to have it actually loop back to the start when it reaches its exit case. (Create another trigger: "When Custom Score = 100, set custom score to 0") This creates a looping construct which causes the iteration to start over again. Of course, this again is an infinite loop, though we can easily stop it by inserting an additional condition into the incrementing trigger (a good way would be to associate this loop with a localized process as discussed before; the loop begins when the process starts, and ends when the process ends). There's an older article on "Hidden Timers" that deals with this in further depth (and is not as abstract ;).
Though we can't really create recursive processes using triggers, we can kind of model recursive definitions. Consider: "Switch X is set => Clear switch X, Do some stuff, set switch X." This trigger can be modeled as having switch X set, but it is also defined as setting switch X, so it loops itself. The usefulness of this is that we clear switch X before initiating the main action of the trigger (do some stuff), so it doesn't loop over itself. We can think of the looping construct (set switch X) at the end actually "calling" the trigger itself again within itself. If we were to add more actions after the "set switch x," the actual place where the trigger "calls itself" isn't changed, but now there would be extra actions after the loop. Like the iterative case, we need to add in an exit case condition so it doesn't loop forever (probably dependent on what our "do some stuff" is, or the process which this loop is associated with). This is useful in a looping trigger in which you want stuff to happen between each intervening loop (and not for the trigger to loop over its own actions). More information can be found on a similar subject in the "Functions" article.
So that was basically a really complicated explanation of a simple thing. ;) Why include it? The main reason is that looping trigger structures work very well within the localized process model. Too often preserved triggers are left looping without an exit case condition and inadvertently cause weird stuff to happen. The good design model would localize a loop to a specific process so that the loop would terminate with that process.
Heh. This article has already begun to degenerate into meaninglessness, so I'll end it here. Not really sure why I begun it in the first place. ;)
January 4, 2000