Tutorial 2: Bullet Controls
Part 1: Flipping
Go back to DMK Tutorial Setup 00. We'll use the same file.
Drop this code in at the bottom and run the script: (Note: I have removed some parentheses. Recall that parentheses and argument separators are generally.)
bulletcontrol(persist, { { "circle-*" } }, flipygt(4, _))
async("circle-red/w", <1;:>, gcrepeat {
wait(60)
times(inf)
} gsrepeat {
times(50)
circle
} s(rvelocity(cx(3))))
_ 4
poolcontrol({ { "circle-*" } }, reset)
You can view all bullet controls here: BulletManager.SimpleBulletControls, or in Danmaku/BulletManagement/BulletControls.cs:SimpleBulletControls.
The bulletcontrol
command creates a bullet control. It takes three arguments:
- A predicate that runs every frame to check if the control should continue to exist.
Persist
is an alias forTrue
andOnce
is an alias forFalse
. If you set this toOnce
/False
, the control will run exactly once. - A style selector. We'll discuss this in a later part, but for now, just think of
{{ "circle-*" }}
as "select all circle bullet types". - A control. We are using the FlipYGT control, which takes two arguments:
- A y-value "ceiling" against which to check flipping,
- A predicate to filter which bullets get controlled.
_
is an alias forTrue
, which means all bullets get controlled.
Therefore, we have created a control that "flips all circle bullets when they go higher than y=4".
There are several ways to destroy controls. If you want to clear all controls on a bullet style, you can use the reset pool control.
Before continuing, let's establish the difference between a bullet control and a pool control.
- A bullet control is an object that persists over time and is applied to every bullet of a given style.
- A pool control is a function that is run once on the metadata for a bullet style (ie. it does not affect individual bullets).
The reset
pool control simply destroys all current bullet controls on a bullet style.
To use pool controls, we invoke the poolcontrol
command, which takes a style selector and a pool control as arguments.
However, we don't want to destroy the control immediately. Instead, we use the delay command _ delaySeconds
to destroy it after four seconds.
Another way to destroy controls is to use timers.
exec b{
hvar tm = newtimer();
tm.Restart();
}
bulletcontrol(tm.Seconds < 4, { { "circle-*" } }, flipygt(4, _))
async "circle-red/w" <1;:> gcrepeat {
wait(60)
times(inf)
} gsrepeat {
times(50)
circle
} s(rvelocity(cx(3)))
The exec
StateMachine converts a block of code into a StateMachine, SyncPattern, or AsyncPattern. Every time this exec
is run, it will create a new timer and restart it (setting the elapsed time to zero and starting recording).
Since the control should be destroyed after four seconds, we use the persistence predicate of tm.Seconds < 4
.
Part 2: Style Selector
The style selector is an array of arrays. If we take one element from each array and combine them according to wildcard rules, then we get a target style. The engine takes every possible combination and applies the control to all the applicable styles.
For example, let's say we have the selector { { "circle-*", "ellipse-*" }, { "red/w", "blue/w" } }
.
The engine generates:
("circle-*", "red/w")
("circle-*", "blue/w")
("ellipse-*", "red/w")
("ellipse-*", "blue/w")
and then merges them using wildcard rules into
"circle-red/w"
"circle-blue/w"
"ellipse-red/w"
"ellipse-blue/w"
To test your understanding, try this question:
- Let's say we wanted to select circle and ellipses; in colors red, green, and blue; and in colorization methods /w and /b. What style selector should we use?
If you remember the previous example, we used a style selector {{ circle-* }}
. There's one unresolved wildcard! If you try to summon a bullet with style circle-*
, the engine will throw an error telling you that it can't find a simple bullet style with that name. However, in a style selector, you can leave one wildcard in the final style string, and it will match all existing styles that fit the format.
Knowing this, answer this question:
- Let's say we wanted to select circle and ellipses; in all colors; and in colorization methods /w and /b. What style selector should we use?
Now look at this code and try to figure out what it will do. Hint: The cull
control deletes bullets if the condition is satisfied.
bulletcontrol(persist, { { "circle-*", "ellipse-*" }, { "red/w", "blue/w" } }, cull(y < -2))
async "*/w" <1;:5> girepeat {
wait(60)
times(inf)
color({ "*-red", "*-blue", "*-green" })
} gcrepeat {
wait(10)
times(3)
color({ "circle", "ellipse", "triangle" })
rv2incr(<2>)
} gsrepeat {
times(24)
circle
} s(rvelocity(cx(2)))
Part 3: Fun With GTRepeat
Take a look at this code:
gtrepeat {
wait(40)
times(inf)
fortime(3s)
} bulletcontrol(once, "circle-red/w", batch(t > 1, {
sfx("x-transform-1", _)
restyleeffect("arrow-red/b", "cwheel-red/", _)
}))
async "circle-red/w" <1;:2> gcrepeat {
wait(10)
times(inf)
fortime(4s)
rv2incr(<6>)
} gsrepeat {
times(30)
circle
} s(rvelocity(cx(2)))
There are a few new features here, so let's go through them.
This time, we're actually using once
(False
) as the persistence predicate to bulletcontrol. This means that the control executes for exactly one frame.
We also introduce the batch
command. This allows us to apply multiple controls with a single predicate, which is highly useful for complex effects. In this case, we want to change the style of any circle bullet older than 1 second, and also play a sound effect if this happens.
The sfx
control takes two arguments: a sound effect and a predicate. You can see the list of usable sound effects in GameManagement/SFXService. Click on any of the "SFX Config" objects and look at the "Default Name" property. Sound effects have a cooldown (see the "Timeout" property) which prevents them from getting invoked too many times at once. Even if this control is applied to a hundred bullets in one frame, it will only play the sound once. This is for ear safety. For the second argument, since the predicate t is greater than 1
is already checked by the batch command, we don't need to put anything here, so we just use _
(True
).
The restyleeffect
control takes three arguments: a target style, an effect style, and a predicate. In this case, we are transforming the bullets into arrows, so we provide the target style arrow-red/b
. Effect styles are a special type of bullet style that you should not try to create directly. They are used for simple frame-animated particle effects, but because they are bullets and not actual particle effects, we can use tens of thousands of them simultaneously. For now, the only effect style is cwheel
, so we'll use that. You could also set this to null
, which creates an effect bullet that simulates the original bullet fading out. As with sfx
, we use _
(True
) for the predicate.
Note that the order of controls inside batch
is significant. If we put the restyleeffect
control first, then the sfx
control would not take effect. The reason is that controls stop running when a bullet is destroyed, and restyleeffect
destroys the bullet (by transferring it to another style).
Now, we can describe the control as follows: "Every 40 frames for 3 seconds, for every circle-red/w bullet that is older than 1 second, transform it into an arrow-red/b while playing the sound effect x-transform-1."
Part 4: The SM Control
All bullet controls are equal, but the SM control is the most equal of them all.
The SM control allows us to summon an invisible node (inode) at the location of a bullet and run an arbitrary state machine on it. This is generally how you "fire bullets from bullets", or perform single-to-many bullet transformations.
Let's start by summoning some spinning stars.
sync "lstar-blue/w" <> gsr {
times(8)
circle
preloop b{
hvar nStar = i;
}
} simple(rvelocity(lerp(0.2, 1, t, cx 3, zero)), {
dir(200 * t)
})
We're now using simple
instead of s
. This command allows us to provide an array of simple bullet options (see SBOption or Danmaku/BulletManagement/SimpleBulletOptions.cs), which are used for fancy effects. In our case, we want the stars to rotate independently of their movement, so we provide a custom direction function in degrees. The function 200 * t
makes the bullets rotate 200 degrees counterclockwise per second.
Also, our velocity function is now using some fancy math. The Lerp function allows us to linearly interpolate between two values. The signature of Lerp is Lerp<T>(float zeroBound, float oneBound, float controller, T val1, T val2)
. When the controller is between zeroBound
and oneBound
, the return value is weighted between val1
and val2
depending on the proximity to each of the bounds.
In other words, lerp(0.2, 1, t, cx 3, zero)
means "go from speed (3, 0) to speed (0, 0) when time is between 0.2 and 1".
Now, below the code you already wrote, let's add an SM control.
To make this fancier, let's make the stars burst one-by-one instead of all at the same time. We can use nStar
in the control predicate to achieve this. Let's say the first star explodes when it is 1.2 seconds old, and the second at 1.5 seconds, and so on. We could write the condition as: t > (1.2 + 0.3 * nStar)
.
...Well, not exactly. There's a minor issue with this, by design. In BDSL, as in many programming languages, accessing variables like nStar
can occur in one of two contexts: lexical or dynamic. Lexical access occurs when a parent declares the variable and then a child tries to read it. For example, this example from the first tutorial is lexical, because gcrepeat binds the variable loop
and its child, s, tries to read it. The s function is able to determine where the loop
variable is declared by looking upwards in the script code.
async("arrow-red/w", <0;0:1;0:0>, gcrepeat({
wait(10)
times(10)
rv2incr(<4>)
preloop b{
hvar loop = i;
}
}, s(rvelocity(pxy(1 + 0.4 * loop, 0))))
)
Dynamic access occurs when a third party tried to read the variable. In this case, the predicate in a bullet control function is dynamic. Bullet controls can execute on any bullets, including bullets fired from other scripts, so the predicate cannot uniquely determine where a variable is declared, or even if it is declared at all. When accessing bound variables from an dynamic context, you must write it as &nStar
instead (prefix &
). Dynamic variable access goes through a different (and slower) logical pathway. It is also possible for dynamic variable access to fail, so if you misspell the variable name, you will get a runtime error stating the following:
The variable nStarr<float> was referenced in a dynamic context (eg. a bullet control), but some bullets do not have this variable defined
With those caveats stated, drop this control function under the star code:
bulletcontrol(persist, "lstar-blue/w", batch(t > (1.2 + 0.3 * &nStar), {
sm(_, sync "star-*/w" <> gsrepeat {
circle
times(22)
sfx("x-fire-burst-1")
colorf({ "red", "pink", "purple", "blue", "teal", "green", "yellow", "orange" }, &nStar)
} simple(rvelocity(lerp(0.2, 1, t, cx 2, cx 4)), {
dir(starrotb3)
}))
softcull("cwheel-blue/w", _)
}))
The SM control takes two arguments: a predicate, and a StateMachine to execute. The predicate comes first due to my formatting preferences, but feel free to change it. It's as easy as changing the argument order of the SM function in the file Danmaku/BulletManagement/BulletControls.cs (though you'll break many of the example scripts if you do that).
The StateMachine here is nothing too new. We use the &nStar variable, which is passed from the bullet data to the inode, to determine the color of the small stars. We also use the starrot3
rotation function. The starrotX
and starrotbX
functions in BPYRepo give slightly randomized rotation functions in one or two directions respectively.
We also use the softcull
control. This is almost the same as the cull
control, but in addition to deleting the bullet, it also spawns an effect bullet at the position.
There's only one control that's as interesting as SM, which is Exec. However, Exec is too complex for the second tutorial, so we'll come back to it another time. That's all for this tutorial!
Problem Solutions
{ "circle-**", "ellipse-**" }, { "red", "blue", "green" }, { "/w" "/b" }
{ "circle-*", "ellipse-*" }, { "*/w", "*/b" }