Tutorial 1: The First Bullets
Part 1: One Bullet
Make sure to check out the setup guide first.
Open up the BasicSceneOPENME scene and click on the mokou-boss object. In the inspector window, you should see a variable "Behavior Script" under "Boss BEH", with the text file "DMK Tutorial Setup 00". Go ahead and double click this to open it. It is a .bdsl file, which you can open in a text editor, or using the VSCode extension. Also go ahead and run the scene in Unity.
The script file should have the following contents:
// Go to https://dmk.bagoum.com/docs/articles/t01.html for the tutorial.
pattern { } {
phase 0 {} {
paction 0 {
shiftphaseto(1)
}
}
// This is phase #1.
phase 0 {
type(non, "Hello World")
hp(4000)
} {
paction 0 {
position(0, 1)
}
}
}
We will discuss most of the structure later. For now, just note that phase(0)
means "create a phase with no timeout". Now let's get some bullets on screen.
Add the following line at the end under position(0, 1)
:
createshot2(2, 0, 2, -90, "fireball-red/w")
We will use this simplified function to explore some of the basics of the engine. The arguments to CreateShot2 are as follows:
- X-offset
- Y-offset
- Speed
- Angle
- Bullet style
Therefore, this command will summon a red fireball 2 units to the right of the boss, with speed 2, at angle -90º (straight downwards). (The screen is 16x9 units at all resolutions.)
Click in the Unity game view and press R. The script will restart instantaneously and you should see one fireball bullet fired. When following through tutorials, you should always copy code into your script and load it in the game view to see how it works.
- If the script restarts but no bullet appears, then you might have Auto Refresh (Edit > Preferences > Asset Pipeline) disabled. Make sure to turn it on.
Note: If the bullet appears to be stuck in the center of the screen, press Esc to open the in-game pause menu and turn Legacy Renderer ON.
Try modifying the code to do the following:
- Make the bullet go to the left.
- Make the bullet go up, but faster.
Once you've understood this basic example, we can discuss DMK's first abstraction, called V2RV2 (Vector2 and Rotated Vector2). In the above example, we are representing the offset with an (X, Y) pair. However, in many cases in danmaku design, we want patterns to have rotational symmetry, so we want each bullet to spawn at (X, Y) rotated by some angle. To make this behavior easier, DMK uses five numbers to represent an offset:
- An (X, Y) pair that does not rotate,
- An (X, Y) pair that does rotate,
- an angle of rotation.
For example, let's say we want to spawn a circle of bullets that all are 1 unit away from the player. We would start with a base offset of rotational X = 1, and then for each bullet, increment the angle. If there are 4 bullets, then the first bullet has angle 0 and spawns at (1, 0), the second bullet has angle 90 and spawns at (0, 1), and so on.
The format for a V2RV2 is <NX;NY:RX;RY:ANGLE>
, where (NX, NY) is the Nonrotating pair, and (RX, RY) is the Rotating pair.
Let's write the X, Y offset as a V2RV2. Since we had X = 2 and Y = 0, let's set RX to 2 and everything else to 0, and use CreateShot1
with V2RV2:
createshot1(<0;0:2;0:0>, 2, -90, "fireball-green/w")
This code will make the bullet spawn from the same location and fire downwards at the same speed.
Now, let's try manipulating the angle. Let's set the angle to 90:
createshot1(<0;0:2;0:90>, 2, -90, "fireball-green/w")
You'll notice two things when you run this. First, the bullet now spawns above the boss, because the offset has been rotated by 90º. Second, the bullet now moves to the right, because the bullet movement path has also been rotated by 90º.
Try setting the angle to different values until you understand what the angle control is doing.
Let's try using the nonrotational X instead:
createshot1(<2;0:0;0:90>, 2, -90, "fireball-green/w")
With this, the starting position does not rotate, but the bullet movement is still rotated by 90º.
What if we don't want the bullet movement to rotate when we do this? Of course, DMK has support for this-- read on to the next section.
Part 2: One Slightly Different Bullet
Now that you understand how CreateShot1
and CreateShot2
work, forget about them, because you will never use them again.
We used this CreateShot1
code in Part 1:
createshot1(<0;0:2;0:0>, 2, -90, "fireball-green/w")
The way this really works under the hood is:
sync("fireball-green/w", <0;0:2;0:0>, s(rvelocity(cr(2, -90))))
Can you see the difference? We've reordered some of the arguments, but more importantly, we have moved 2
and -90
into the nested function call s(rvelocity(cr(...)))
. In later sections, we will add more nesting to this nested function call in order to add increasing functionality to the command.
We will discuss what s
does in Part 3. For now, think of it as the Simple Bullet Firer. In the laser tutorial, we replace s
with functions like laser
.
rvelocity
is a movement function that tells the engine to treat its argument as a Rotational Velocity. This means that the argument works just like an (X, Y) speed, but is also rotated by whatever angle is in the V2RV2.
cr(radius, angle)
is a math function that returns the (X, Y) equivalent for an (R, THETA) pair (it converts polar to cartesian coordinates). If we want to have a speed of 2 in the direction -90º, then cr(2, -90)
gives us the actual (X, Y) speed, which is {x = 0, y = -2}
.
More commonly than cr
, you will see cxy
, which takes an X and Y as arguments and simply returns them. For example, this code does the same thing as above, because cr(2, -90) = {x = 0, y = -2} = cxy(0, -2)
:
sync("fireball-green/w", <0;0:2;0:0>, s(rvelocity(cxy(0, -2))))
Try modifying the code to do the following: (solutions at the end)
- Summon a blue circle.
- Make the bullet go to the right.
- Make the bullet go up, but faster.
nrvelocity
(NonRotational Velocity) works similarly to rvelocity
, but it is not rotated by the angle of the V2RV2. What do you think would happen if you ran
sync("fireball-green/w", <0;0:2;0:90>, s(rvelocity(cr(2, -90))))
as opposed to
sync("fireball-green/w", <0;0:2;0:90>, s(nrvelocity(cr(2, -90))))
?
You may be wondering if you have to write out all five numbers every time you want to use a V2RV2. Luckily, you don't. There are a number of ways to simplify V2RV2 expressions:
- You can leave zeros blank.
<0;0:2;0:90>
=<;:2;:90>
. - If your NX and NY components are zero, then you can omit them altogether.
<0;0:2;0:90>
=<2;0:90>
=<2;:90>
- If your NX, NY, RX, and RY components are zero, then you can omit them altogether.
<0;0:0;0:90>
=<90>
- If everything is zero, you can just write
<>
.
From here on out, we will no longer refer to the CreateShot
functions. Make sure you understand this part, and then move on to part 3, where we work with multiple bullets.
Part 3: GSRepeat
sync
is a "State Machine". This is a type for functions that carry out generic commands, and is the basic unit of behavior scripts. If you look at the basic document, paction
, position
, phase
, and pattern
are all state machines of different kinds. In fact, the entire behavior script is a single State Machine nested under pattern
.
GTRepeat
(Generalized Task Repeat) (type: GTRepeat) is a State Machine. It is one of the Four General Repeaters. The general repeaters use a common set of modifiers, but have different types and different requirements. GTRepeat is a State Machine, and its children (all of the objects that it repeats) must also be State Machines.
In the line sync(fireball-red/w, <0;0:2;0:0>, s(rvelocity(cxy(0, -2))))
, s
is a "patterning function". More specifically, it is a SyncPattern (synchronous patterning function). A SyncPattern is not a State Machine (SM); if you want to run a SyncPattern, you must put it as an argument to a sync
SM.
SyncPatterns are synchronous, which means that they run immediately and cannot have waits. There are also AsyncPatterns, which are asynchronous and can use waits or delays. To use an AsyncPattern, we put it as an argument to an async
SM, which looks almost the same as the sync
SM (we'll show async
in the next section).
The other three General Repeaters are all patterning functions.
- GIRepeat (Generalized IEnumerator Repeat) (type: AsyncPatterns) is an AsyncPattern, so it can use waits and delays. Its children must be AsyncPatterns.
- GCRepeat (Generalized Coroutine Repeat) (type: AsyncPatterns) is an AsyncPattern, so it can use waits and delays. Its children must be SyncPatterns.
- GSRepeat (Generalized Synchronous Repeat) (type: SyncPatterns) is a SyncPattern. Its children must be SyncPatterns.
In essence, there is a hierarchy of call order: GTRepeat > GIRepeat > GCRepeat > GSRepeat.
Let's deal with GSRepeat first.
Replace the code from the first part with this:
sync("arrow-red/w", <0;0:1;0:0>, gsrepeat({
times(30)
rv2incr(<10>)
}, s(rvelocity(cxy(2, 0))))
)
GSRepeat is a synchronous pattern function that takes synchronous pattern functions as children. In this case, we want to repeat the function s
, so we put it inside a GSRepeat.
GSRepeat takes two arguments:
- an array of repeater modifiers (GenCtxProperty),
- an array of children (or a single child).
To write an array, we use the following format:
{ item1, item2, item3 }
OR:
{
item1
item2
item3
}
In this case, there are two modifiers: times(30)
and rv2incr(<10>)
.
times
tells the engine how many times to run the repeater. Since we set it to 30, the engine will run the child 30 times, so 30 bullets will be summoned.
rv2incr
tells the engine to increment the offset by a certain amount every iteration. Here, we are incrementing the offset by 10º every iteration.
Run the code in the engine and observe how the bullets are summoned. Then try to answer the following questions based on the previous section:
- What happens if you use
nrvelocity
instead ofrvelocity
? - What happens if you use a nonrotational offset instead of a rotational offset?
- BONUS: What happens if you use a nonrotational offset AND a rotational offset, ie.
<2;0:1;0:0>
?
Let's quickly introduce a few modifiers:
circle
overridesrv2incr
and instead summons all the bullets spread evenly around a circle.spread(<RV2>)
overridesrv2incr
and instead summons all the bullets spread evenly within the range described.
Try answering the following questions:
- How would we summon all the bullets evenly in a circle?
- How would we summon all the bullets spread evenly across half a circle?
- If you figured out #5, then your half circle probably pointed upwards. How could you make it point to the right instead? (Hint: go back to the starting offset.)
- BONUS: If you figured out #6, then set the starting offset back to its original value and instead add the
center
modifier. Can you figure out what the center modifier does?
Part 4: Nesting Repeaters
We can arbitrarily nest repeaters. First, run the following code:
sync("arrow-red/w", <0;0:1;0:0>, gsrepeat({
times(3)
rv2incr(<4>)
}, s(rvelocity(cxy(2, 0))))
)
Now, let's add another repeater:
sync("arrow-red/w", <0;0:1;0:0>, gsrepeat({
times(10)
rv2incr(<30>)
}, gsrepeat({
times(3)
rv2incr(<4>)
}, s(rvelocity(cxy(2, 0))))
))
Now, each of the smaller groups are separated by 30º.
Let's take a look at how color modification works in DMK. Let's say we want each group to have a single color that switches between red, blue, and green. We can use the color modifier:
sync("arrow-*/w", <0;0:1;0:0>, gsrepeat({
times(10)
rv2incr(<30>)
color({ "red", "blue", "green" })
}, gsrepeat({
times(3)
rv2incr(<4>)
}, s(rvelocity(cxy(2, 0))))
))
Note how we add a wildcard *
in the starting bullet style. This indicates where the color modifier should make changes.
Remember /w
from the beginning of this tutorial? It has two friends, which are /
and /b
. These are color variations which each apply a different gradient for the same color. Try using the other two variants in your existing code.
We can switch between them as well, in the same way we switch between colors. Let's switch between colors and variations:
sync("arrow-**", <0;0:1;0:0>, gsrepeat({
times(10)
rv2incr(<30>)
color({ "red", "blue", "green" })
}, gsrepeat({
times(3)
rv2incr(<4>)
color({ "/w", "/", "/b" })
}, s(rvelocity(cxy(2, 0))))
))
To make this work, all we need to do is use two wildcards instead of one. Wildcards will be resolved in order.
But what if we want to switch between colors in the other order, so each group has a single colorization method and multiple colors?
sync("arrow-*", <0;0:1;0:0>, gsrepeat({
times(10)
rv2incr(<30>)
color({ "*/w", "*/", "*/b" })
}, gsrepeat({
times(3)
rv2incr(<4>)
color({ "red", "blue", "green" })
}, s(rvelocity(cxy(2, 0))))
))
The code above uses a single wildcard in the original style so the colorization method is placed at the end, and then adds another wildcard before the colorization method.
Now that we have a grasp of a few basic controls, let's look at the other repeater types.
Part 5: GCRepeat
GCRepeat is an asynchronous pattern function that takes synchronous pattern functions as children. Despite it being stuck in the middle of GIRepeat and GSRepeat, you will use it quite often.
Since GCRepeat is asynchronous, we need to use the async
command. Here's the code from the previous example with GCRepeat instead:
async("arrow-red/w", <0;0:1;0:0>, gcrepeat({
times(3)
rv2incr(<4>)
}, s(rvelocity(cxy(2, 0))))
)
Run it, and you'll see that it does the same thing as GSRepeat!
The difference between GCRepeat and GSRepeat is that we get access to time-based modifiers. The most important such modifier is wait
, which creates a delay between each iteration.
async("arrow-red/w", <0;0:1;0:0>, gcrepeat({
wait(30)
times(3)
rv2incr(<4>)
}, s(rvelocity(cxy(2, 0))))
)
(There are 120 frames per second and wait
takes an argument in frames, so this waits 0.25 seconds between each iteration.)
Observe how there is a delay between each bullet spawn.
Let's give a sneak peek to one of the more powerful features of DMK. What if we wanted to change the amount of wait for every iteration? We could simply provide a function to the wait
modifier:
async("arrow-red/w", <0;0:1;0:0>, gcrepeat({
wait(10 * i)
times(10)
rv2incr(<4>)
}, s(rvelocity(cxy(2, 0))))
)
When used inside a repeater modifier, i
refers to the iteration number of the modifier. Therefore, this new modifier waits "ten frames times the iteration number of the repeater".
In DMK, almost every input can be changed into a function. For example, let's make the increment based on the iteration number as well:
async("arrow-red/w", <0;0:1;0:0>, gcrepeat({
wait(10)
times(10)
rv2incr(angle(3 * i))
}, s(rvelocity(cxy(2, 0))))
)
Note: The <NX;NY:RX;RY:A>
format for V2RV2 is a shorthand for constants only. If we want to use V2RV2 functions, we need to use the functions in ExMRV2.
We can even use the iteration number to modify the bullet movement function. However, since the variable i
is only accurate within the repeater modifiers, we have to use a slightly different method. A later tutorial will explain how this works, but observe how simple it is to do:
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))))
)
Note: Use pxy
if you require functions. cxy
only accepts constants, but is more optimized internally.
Part 6: GIRepeat
GIRepeat is an asynchronous pattern function that takes asynchronous pattern functions as children. This makes it almost identical to GCRepeat, but there is one critical modifier that GIRepeat uses often.
Let's start with some simple code.
async("ellipse-*/w", <1;:>, girepeat({
wait(4)
times(30)
rv2incr(<10>)
}, gcrepeat({
wait(15)
times(4)
color({ "pink", "blue", "green" })
}, s(rvelocity(cx(2))))
))
(Note: cx(2)
is the same as cxy(2, 0)
.)
The GCRepeat spawns a pillar of bullets, and the GIRepeat repeats the GCRepeat. Note how the GIRepeat starts the second iteration before the first iteration's GCRepeat is done executing. If you want to wait for the previous execution to finish, then use the waitchild
modifier:
async("ellipse-*/w", <1;:>, girepeat({
wait(4)
times(30)
rv2incr(<10>)
waitchild
}, gcrepeat({
wait(15)
times(4)
color({ "pink", "blue", "green" })
}, s(rvelocity(cx(2))))
))
The waitchild
modifier is critical for timing asynchronous actions. Make sure you understand how it works when it's present or absent!
Part 7: GTRepeat
GTRepeat is the final repeater. It is a State Machine (SM) that takes other SM as children. This means that almost all script commands can be nested under GTRepeat.
Before we show GTRepeat, let's take a look at the move
State Machine, defined in SMReflection. Replace your firing code with this:
move(2, nroffset(pxy(sine(2, 3, t), 0)))
The move function takes two arguments:
- Time, in seconds, of movement
- Path function
nroffset
(Non-Rotational Offset) is a path function that uses offset instead of velocity to calculate position. In this case, we are using the sine(period, amplitude, controller)
function to determine the x-offset of the boss.
Note: In path functions, t
is the lifetime of the moving object. In move
functions, t
is more specifically the amount of time that the move function has been active for.
What do you think will happen if you use cosine
instead of sine
? Go ahead and try it.
Now that you understand the move function, let's use it with gtrepeat
.
gtrepeat({
wait(1s)
times(2)
waitchild
}, move(2, nroffset(pxy(sine(2, 3, t), 0))))
Note: 1s
is the same as 120
. Since the wait modifier is in frames, this is equal to 1 second. See the parsing reference for an explanation of numeric suffixes.
The modifiers that GTRepeat uses are the same as GIRepeat. In this code, it will run the move function twice, waiting 1 second after each is finished.
Before we continue, try removing the waitchild
property. What happens?
It might appear that the move command gets interrupted by the second move command after 1 second. However, this is not actually the case. Overlapping move commands can give you strange results, and we will see some very strange results at the end of this section.
Now, let's make the boss fire some bullets while moving.
gtrepeat({
wait(1s)
times(2)
waitchild
}, {
move(2, nroffset(pxy(sine(2, 3, t), 0)))
async("fireball-teal/", <>, gcrepeat({
wait(20)
times(inf)
fortime(2s)
}, gsrepeat({
times(10)
circle
}, s(rvelocity(cx(3))))))
})
Note that we are now using an array of children for GTRepeat: a move command and an async firing command. The firing command should be fairly familiar, but we have introduced a fortime
property. The fortime
property limits the maximum number of frames allowed for the repeater. The repeater will stop if it iterates times
times or if it takes more than fortime
frames. In this case, we set times
to inf
(infinity) and fortime
to 2 seconds so it lines up with the movement function.
As a result, the boss will fire while moving.
What do you think will happen if we set fortime
to 4s
? Go ahead and try it.
Now, let's enhance this simple pattern with some cool modifiers. This part may be a bit difficult, and most of the cool stuff here will be explained in a later tutorial.
gtrepeat({
wait(1s)
times(2)
waitchild
preloop b{
hvar loop = i;
}
}, {
move(2, nroffset(pxy(sine(2, 3, t), 0)))
async("fireball-*/", <>, gcrepeat({
wait(20)
times(inf)
fortime(2s)
colorf({ "teal", "orange" }, loop)
}, gsrepeat({
times(20)
circle
}, s(polar(3*t, pm1(loop)*30*t)))))
})
First, we use the preloop
command to set a variable loop
to the iteration number (0 or 1, since there are two iterations) of the gtrepeat. We could give it any other name.
Then, we use the colorf(colors, indexer)
modifier, which selects a color based on the indexer function instead of the iteration number. In the first GTR iteration, loop is 0, so it selects teal. In the second GTR iteration, loop is 1, so it selects orange. (If the indexer is out of bounds, it will loop back into the array, so don't worry about out-of-bounds errors.)
What do you think would happen if you used just color({ teal orange })
? Try it out.
The bullet function here is using the polar
path function, which is similar to roffset
(Rotational Offset), but is given in polar coordinates instead of cartesian coordinates. polar
is the standard method of implementing "angular velocity" and similar concepts in DMK. The input here is:
radius = 3 * t
theta = pm1(loop)*30*t
(in degrees)
pm1
or pm1mod
(Plus-Minus 1 Mod) is a function that returns 1 if the input is even, and -1 if the input is odd. This means that it will return 1 during the first GTR loop and -1 during the second GTR loop, so the bullets rotate counterclockwise (positive direction) during the first loop and clockwise (negative direction) during the second loop.
If you think you can make sense of this example, then try solving this problem:
- What if we wanted the rotation to switch direction on every iteration of the GCRepeat instead of every iteration of the GTRepeat? How would we implement that?
Finally, let's get back to that strange result I promised at the beginning of this part. Using the code snippet above, remove the waitchild
property and reload the script by pressing R.
Can you see what happens? While the boss is moving to the right, the teal bullets from the first iteration continue summoning on the left side! This is not a bug, but is one of the strange results that can occur when you overlap multiple movement functions on one entity. Try figuring out why this occurs.
That's all for this tutorial.
Pro tip: parentheses and argument commas are actually optional in BDSM, with the exception of parser macros. In fact, I usually don't use them. They're in tutorials for familiarity, but you may find it more efficient to drop them.
Problem Solutions
- Change
fireball-red/w
tocircle-blue/w
- Change
cxy(0, -2)
tocxy(2, 0)
- Change
cxy(0, -2)
tocxy(0, 4)
- Change
rv2incr(<10>)
tocircle
- Change
rv2incr(<10>)
tospread(<180>)
- Change
<0;0:1;0:0>
to<0;0:1;0:-90>
- When using RV2Incr/Circle/Spread, it summons half the bullets on one side of the starting offset and half the bullets on the other side.
- Add
preloop b{ hvar loop2 = i; }
to the GCRepeat modifiers, and change the pm1 topm1(loop2)
.