-
Notifications
You must be signed in to change notification settings - Fork 5
language design
Barrage: A declarative scripting language for creating 2D bullet patterns for Haxe shooting games.
A certain breed of 2D shooting games depend on bullet patterns for much of their design. Some shooters have no such dependency on patterns, and instead rely on individual ship AI routines to decide when and where bullets are fired. This type of game tends to be horizontally oriented and depend on ship positioning and scenery dodging for difficulty and balancing. Barrage can aid such games, but Barrage is about creating systems where the bullets themselves are the scenery to be dodged.
Bullet pattern complexity can be finicky to work with for artists. In essence, a bullet pattern is really just a particle emitter or set thereof, and particle behaviors based on age and lifespan. Hardcoding such systems is efficient, but increases iteration times and complicates rebalancing. Therefore, to rapidly iterate on complex bullet firing patterns and behaviors, some form of scripting is useful.
Given that bullet behaviors depend on timings and event triggers, I found a sequential, declarative form to be expressive and readable, as opposed to markup-based approaches such as BulletML.
Ideally, a Barrage script should read like a narrative, describing concrete events with intuitvely implicit event order.
I use the term "block" when describing a line of script and its associated lines. For instance take the following lines.
bullet called myBullet
speed is 24
In this case both lines are considered part of the bullet block, though the speed line is also called a speed block.
- Barrage uses a small set of keywords and indentation for scoping, similar to Python.
- Barrage is not case intensive, and differences in case are not ambiguous.
- A barrage script (henceforth "a barrage") is a single file, consisting of a list of declarations of "actions" and "bullets".
- An "action" is roughly analogous to a function.
- Action block members are triggered sequentially, top down.
- Actions can trigger other actions.
- A "bullet" is a collection of properties that define a bullet/particle's initial state, and an optional action that triggers at the moment the bullet is created.
- Both actions and bullets can be predefined with names for reference, or defined anonymously locally if no reference is required.
- A barrage has a required entry-point action named "start".
barrage called myBarrage
The first, unindented line of a barrage simply defines its name as a root block for the script entire. This name is not used internally for anything of importance, but can be useful for distinction. Note the "called" keyword. Actions, barrages and bullets use "called" to define their unique internal identifier. Also note that Barrage has no internal concept of string types. During parse it simply maps these IDs against sequential numeric UIDs. For this reason, any unrecognized non-numeric word is seen as an identifier, and the use of quotation marks is never needed.
From this line on, every line needs to be indented at least once to be a part of the definition block.
bullet called myBullet
speed is 2.5
direction is 10
myVariable is 1
myOtherVariable is 2
do myAction
Bullet definitions express the initial state of a bullet at the point of creation through the use of "is" blocks. Every bullet has three predefined floating point fields: speed, direction and acceleration. Directions are in degrees, with 0 pointing to 12 o'clock in world space. The actual meaning of the other values expressed in these fields is down to the implementation, though Barrage is designed to work with floating point numbers and normalized values are recommended.
A defined bullet will typically "do" an action, either referred to by identifier or defined locally. In addition to the predefined fields, every bullet can define its own custom float fields simply by setting them in is-blocks. These variables can then be used as arguments by the bullet's action.
bullet called myBullet
speed is 2
targetSpeed is 20
do action
set speed to targetSpeed over 5 seconds
wait 5 seconds
die
In this example, a bullet defines its initial speed, and then a targetSpeed field. It then defines and executes an anonymous action which alters the speed of the bullet over time, before self-destructing. Note that the action's scope, when dealing with bullet properties, always refer to the bullet that triggered the chain of actions that resulted in it being triggered. A bullet being fired can be considered a change of scope.
A bullet can only do/define a single action as part of its definition.
action called myAction
targetSpeed is 20
changeDuration is 5
wait 5 seconds
set speed to targetSpeed over changeDuration seconds
An action has two kinds of block member: Events and declarations. "is" blocks are declarations, while all other types of blocks are events. Declarations are always handled first no matter where in the action block they are defined, while event blocks are turned into an ordered list, triggering top-down. These event elements can be temporally separated with "wait" blocks.
Actions are triggered with "do" blocks, either with an identifier...
do myAction
...or anonymously, in which case the do block becomes an action definition, as in the second bullet definition example.
Named actions that define properties can have those properties overridden by declarations nested under a do block:
do myAction
targetSpeed is 10
This effectively allows actions to be called with arguments.
repeat blocks allow an action to repeat itself a number of times.
do action
fire bullet
wait 1 frames
repeat 5 times
When parsed, this "loop" is effectively unrolled, meaning the actual resulting action is.
do action
fire bullet
wait 1 frames
fire bullet
wait 1 frames
fire bullet
wait 1 frames
fire bullet
wait 1 frames
fire bullet
wait 1 frames
The basic purpose of an action is to cause bullets to be created. This is accomplished with "fire" blocks. Let's look at a very simple (kind of pointless) barrage.
barrage called gun
action called start
fire bullet in absolute direction 0
The fire block causes a bullet to be emitted. The location from which it is emitted depends on the context of the action block. From the starting action, the firing position is defined by the implementation. From actions called by bullets, the firing position is the current position of the triggering bullet. A fire block can either create an anonymous "bullet", again defined by the implementation, or a predefined bullet by name.
fire myBullet
Furthermore, the fire block can override the three built-in properties of a bullet in the following fashion.
fire bullet in absolute direction 0
fire bullet at absolute speed 10
fire bullet with absolute acceleration 1
These overrides can be mixed freely
fire bullet in absolute direction 0 with absolute acceleration 10
fire bullet with absolute acceleration 10 in absolute direction 0 at absolute speed 3
And so forth.
Each of these overrides carry an associated flag, in this example "absolute". The possible values for this flag depend on the property being set. For acceleration and speed, the options are "absolute", "relative" and "incremental". For direction, there is also "aimed".
Absolute values are just that, a simple setting of the property to a value.
Incremental values are offset from the previous value used. Take this example:
do action
fire bullet at absolute speed 10
do action
fire bullet at incremental speed 2
repeat 10 times
In this action, a bullet is first fired with an absolute speed, defining the "base speed" in this case. Then, an action is triggered that fires progressively faster bullets. The first bullet in the second do-block would be at speed 12, the next 14 and so on.
Relative values are based on the default value of the bullet fired.
bullet called myBullet
direction is 20
do action
fire myBullet in relative direction -5
In this example, the myBullet fired in the action is 20-5=15.
Aimed values are offsets from a direction pointing from the emitter towards the player entity (both defined by the implementation). For instance, firing in aimed direction 0 is directly towards the player, whereas aimed direction 10 is
For directions, the default direction of a bullet is "aimed" with an offset of 0. By using an offset aimed direction in concert with incremental value, we can create fans of bullets based on the player's position at the start of a barrage.
bullet called simplebullet
someValue is 0
speed is 10
action called fan
fire simplebullet in aimed direction -20
do action
fire simplebullet in incremental direction 10
repeat 4 times
First, we've fired a bullet -20 degrees off from the player. Then we fire 4 more bullets, each offset +10 degrees from the preceding bullet. The final angles then are -20, -10, 0, 10, 20: A 5-bullet fan.
Fire blocks can also use nested property overrides much the same way do-blocks can.
fire simplebullet
speed is 20
someValue is 10
The two primary event blocks for temporal control of bullets are wait and set.
wait blocks tell the action to hold for a set period of time before continuing to the next event.
wait 1 seconds
wait 60 frames
As you can see, wait accepts both a count of updates and a number of seconds as arguments.
set blocks change properties of the current bullet.
set speed to 20
By default, set is simply a way to drive properties mid-action. Where it gets interesting is when you tell it to carry out those changes over time.
set speed to 0 over 1 seconds
In this case, the set-event triggers a linear property tween. Note that this tween does not halt the action from continuing, so you may commonly want to combine set blocks with wait blocks.
set speed to 0 over 2 seconds
wait 2 seconds
die
die blocks simply tell the bullet triggering the action to immediately vanish. This can be used to create splitting bullets, for instance, that create a number of other bullets
As you can see, Barrage trades in numbers. In some cases, however, you may want more fine control over values and the interactions between variables. In those cases, Barrage supports math expressions and scripting access. This can be applied to any number argument, and is done by wrapping the script in parentheses, like so:
# nonsense math follows
set speed to (5 + myVariable * 0.5)
set direction to (52 - 24*9)
These cases differ in one key way; The latter contains no variables. In this case, math expressions are evaluated during parse, and the result is made constant. For expressions with variables, however, an HScript Expr is stored and evaluated at runtime. As long as the expression returns a number, any hscript is valid.
Key variables exposed to hscript in Barrage:
- age the number of seconds the bullet has been alive
- actionTime the number of seconds the current action has been active
- barrageTime the number of seconds since the barrage was started
- degRad a scalar for converting degrees to radians
- radDeg a scalar for converting radians to degrees
- aimRad a direction towards the player entity in radians
- aimDeg a direction towards the player entity in degrees
- Math. the Haxe Math class
- vars a dynamic object holding every property available in the current scope
Scope in barrage is best illustrated by example.
action called foo
myVar is 20
myOtherVar is 10
do foo
myVar is 10
myOtherVar is 0
This highlights a slight puzzle when using predefined actions. The action "foo" needs to be independent, so it cannot rely on properties in a parent scope to function. This means that it first needs to define defaults, and a triggering action needs to explicitly override those defaults.
bullet called bar
speed is 10
do action
set speed to 5 over 2 seconds
In this case, the action depends wholly on the bullet that triggered it, and can thus safely access properties therein.
In general, it is best practice to rely on constants over variables, and using relative/aimed/incremental in fire blocks to give bullets interesting behavior, rather than script everything. HScript is fast but not that fast, and juggling properties across action and bullet definitions can introduce some headaches.