Search Results for

    Show / Hide Table of Contents

    Tutorial 3: Danmokou Chimera

    This tutorial will work through implementing Miracle Fruit and Danmaku Chimera in DMK using the tools in the first two tutorials.

    Unity_r4djD5Y7Jv

    Part 1: Miracle Mima

    To hopefully make the latter part of this tutorial easier, we'll first start with a simpler example that should be easily digestible.

    Let's start with this code. You can create a new text file, dump this code in it, and then drag it to the "Behavior Script" property of the boss object you are using for testing in the inspector.

    pattern { } {
    	phase 0 { } {
    		paction 0 {
    			shiftphaseto(1)
    		}
    	}
    			
    	phase 40 {
    		type(spell, "Miracle Mima")
    		hp(14000)
    		root(0, 2)
    	} {
    		paction 0 {
    			//Insert code here
    		}
    	}
    }
    

    The phase StateMachine takes three arguments: a timeout, a list of phase properties, and a list of child states. Phase properties (PhaseProperty) are generally used to provide metadata to the boss about the spellcard. They will be discussed more in a later tutorial, but I want to bring your attention to the root(x, y) property. This property tells the boss what position it should be at before starting the card, and the boss will take 2 seconds to move to this position before actually starting the card. In effect, you never need to write movement code for the boss to move to the starting point of a spellcard.

    Furthermore, when you're testing, you can set the value TeleportAtPhaseStart in Services/SaveData.cs to true. This will make the boss instantly move to the starting position every time you restart the script, which should save you a small amount of time.

    Go ahead and check the value in SaveData.cs. (Note: If you change C# code, you will need to recompile and restart the game. If you change BDSM script code, you can press R to reload instantly.)

    The spell we'll be copying is Miracle Fruit from the Subterranean Animism extra stage (video).

    We can describe the pattern as follows:

    • Summon several potato bullets evenly spread out over a circle, moving straight.
    • After some time, destroy the potato bullets, and for each of them, summon several circles of bullets at increasing radius, that are initially stationary and then move straight outwards.
    • Repeat.

    In DMK, we would structure the code as follows:

    • Create an async command that repeatedly summons the potato bullets.
    • Create a persistent bullet control that handles the second effect.

    The code to summon the potato bullets is as follows:

    		async "lellipse-red/w" <> gcrepeat {
    			wait(3s)
    			times(inf)
    			sfx("x-fire-burst-1")
    		} gsrepeat {
    			times(8)
    			circle
    		} s(rvelocity(cx(3)))
    

    This structure should be completely familiar. Let's move on to the control.

    First, let's say that we'll do the control effect 0.7 seconds after the summon begins. Since we're planning to use multiple controls at the same time (destroy and summon), we should use batch.

    		bulletcontrol(persist, "lellipse-red/w", batch(t > 0.7, {
    			sm(_, async "ellipse-red/w" <> gcrepeat {
    				times(20)
    				circle
    			} s(rvelocity(cx(2))))
    			cull(_)
    		}))
    

    This code will delete the potatoes and summon one circle of ellipses in its place. Let's make the speed at zero and then lerp in:

    		bulletcontrol(persist, "lellipse-red/w", batch(t > 0.7, {
                sm(_, async "ellipse-red/w" <> gcrepeat {
                    times(20)
                    circle
                } s(rvelocity(px(lerpt(0.3, 1.4, 0, 2.6)))))
                cull(_)
            }))
    

    Now, let's make multiple circles of ellipses using gcrepeat, and incrementing the RX value in between loops so each circle summons at a larger radius than the previous.

    		bulletcontrol(persist, "lellipse-red/w", batch(t > 0.7, {
    			sm(_, async "ellipse-red/w" <> gcrepeat {
    				wait(10)
    				times(6)
    				rv2incr(<0.4;:>)
    				sfx("x-transform-1")
    			} gsrepeat {
    				times(20)
    				circle
    			} s(rvelocity(px(lerpt(0.3, 1.4, 0, 2.6)))))
    			cull(_)
    		}))
    

    With this, Miracle Fruit is mechanically complete. But before we finish, why don't we also make the bullets colorful? Instead of making everything red, we can give each potato a unique color.

    Update the bullet firing code to use color. Since we will need the loop number to determine the color of the ellipses in the SM control, let's bind and expose this number as colorIndex.

    		async "lellipse-*/w" <> gcrepeat {
    			wait(3s)
    			times(inf)
    			sfx("x-fire-burst-1")
    		} gsrepeat {
    			times(8)
    			circle
    			color({ "red", "pink", "purple", "blue", "teal", "green", "yellow", "orange" })
                preloop b{ 
                	hvar colorIndex = i;
                }
    		} s(rvelocity(cx(3)))
    

    Then, update the control to operate over all potato bullets:

    		bulletcontrol(persist, "lellipse-*/w", batch(t > 0.7, {
    

    And finally, change the SM control so it selects a color by using colorIndex.

    			sm(_, async "ellipse-*/w" <> gcrepeat {
    				wait(12)
    				times(6)
    				rv2incr(<0.4;:>)
    				sfx("x-transform-1")
    				colorf({ "red", "pink", "purple", "blue", "teal", "green", "yellow", "orange" }, &colorIndex)
                } ...
    

    The final code is as follows:

    phase 40 {
        type(spell, "Miracle Mima")
        hp(14000)
        root(0, 2)
    } {
        paction 0 {
            async "lellipse-*/w" <> gcrepeat {
                wait(3s)
                times(inf)
                sfx("x-fire-burst-1")
            } gsrepeat {
                times(8)
                circle
                color({ "red", "pink", "purple", "blue", "teal", "green", "yellow", "orange" })
                preloop b{ 
                    hvar colorIndex = i;
                }
            } s(rvelocity(cx(3)))
    
            bulletcontrol(persist, "lellipse-*/w", batch(t > 0.7, {
                sm(_, async "ellipse-*/w" <> gcrepeat {
                    wait(12)
                    times(6)
                    rv2incr(<0.4;:>)
                    sfx("x-transform-1")
                    colorf({ "red", "pink", "purple", "blue", "teal", "green", "yellow", "orange" }, &colorIndex)
                } gsrepeat {
                    times(20)
                    circle
                } s(rvelocity(px(lerpt(0.3, 1.4, 0, 2.6)))))
                cull(_)
            }))
        }
    }
    

    Part 2: Danmokou Chimera

    Let's start with this code. You can create a new text file, dump this code in it, and then drag it to the "Behavior Script" property of the boss object you are using for testing in the inspector.

    pattern { } {
    	phase 0 {} {
    		paction 0 {
    			shiftphaseto(1)
    		}
    	}
    			
    	phase 40 {
    		type(spell, "Danmokou Chimera")
    		hp(14000)
    		root(0, 0.5)
    	} {
    		paction 0 {
    			move(inf, nroffset(px(sine(8p, 2, t))))
    		}
    	}
    }
    

    The move function here should be fairly familiar. We have made two minor modifications: now we are moving for an infinite length of time (t = inf), and we are using the px function. px(X) = pxy(X, 0)-- there are many simplified functions in DMK to help you avoid writing zeros and redundant code.

    Now let's fire some long arrow bullets. In DMK, these are called keine bullets, because I made it after practicing Keine's final spell for a bit too long.

    Drop this under the movement code.

    gtrepeat {
        wait(2s)
        times(inf)
        rv2incr(<22h>)
        waitchild
    } saction 0 {
        sync "keine-purple/w" <1;:> gsrepeat {
            times(16)
            circle
            sfx("x-fire-tech-8")
            start b{
                hvar rootloc = loc;
    			hvar circTimes = times;
            }
        } s(rvelocity(px(2)))
    }
    

    Go ahead and run it. You'll see that the boss moves around while also firing bullets. (Note: h is the numerical suffix for the multiplier 1/phi. See the parsing reference for details.)

    Some readers may be wondering why this firing code executes at all if it comes after a move function that runs for infinite time. The answer is that the paction state machine runs all its children in parallel, ie. at the same time. On the other hand, the saction state machine runs its children sequentially.

    The firing code introduces a new modifier: start. start, preloop, postloop, and end are all modifiers that allow executing code during various points in the repeater lifetime. start occurs when the repeater starts, preloop occurs before every repeater iteration, postloop occurs after every repeater iteration, and end occurs when the repeater is about to return. All of them take a block (enclosed by b{ }) of code as an argument.

    In this case, we want to save the location of the firing entity (loc), which is a vector2, into the variable rootloc. We also want to save the number of times the repeater is executing for (16) into a variable so we can use it later.

    Now, let's add some code to transform the keine bullets into circle bullets. Let's say that we want the keine bullets to fire for 1 second and then stay transformed for 3 seconds.

    Add this under the firing code in saction:

    wait(1)
    bulletcontrol(once, "keine-purple/w", cull _)
    bulletcontrol once "keine-purple/w" sm _
    	sync "gcircle-blue/w" <> s(none)
    

    Now, you should see the keine bullets fire, and after 1 second, they will get culled and summon a single unmoving circle in their place.

    You may be wondering why the SM control even works if we put the cull control first. How can you run a control on a deleted object? The answer is that there is an enforced internal ordering for bullet controls, and cull is the last. When an SM and Cull control are both present, the SM control will run first. (Note that within a batch command, there is no enforced ordering.)

    Now, let's summon multiple circle bullets along the length of the keine bullet. Replace the SM control with this:

    			bulletcontrol once "keine-purple/w" sm(_,
    				sync "gcircle-blue/w" <> gsrepeat {
    					times(13)
    					sfx("x-fire-tech-6")
    					root(&rootloc)
    					start b{
    						hvar r = dist(loc, &rootloc);
    					}
                        preloop b{
                            hvar bulletn = i;
                        }
    				} s polar(r - 0.2 * bulletn, 0))
    

    If you run this, you'll see that the keine bullets summons bullets along the length of its body. The way this works is:

    • We determine the distance from the original firing location to the keine bullet and store that as r.
    • Since the angle of the firing V2RV2 is inherited between fires, the position of the head of the keine bullet is now polar(r, 0) relative to the original firing location. We can go backwards along its body by doing polar(r - 0.1, 0), etc.
    • To fire the bullet from the original firing location, we provide a root modifier, which tells the repeater what origin to orient the final bullet against. By default, the root is the location of the boss at the time of firing, and this modifier overrides it with a fixed value.

    Now, let's rotate the bullets. We'll add some colorization to make the structure more explicit:

    bulletcontrol once "keine-purple/w" sm(_,
    	sync "gcircle-*/w" <> gsrepeat {
    		times(13)
    		sfx("x-fire-tech-6")
    		root(&rootloc)
    		bindlr
    		start b{
    			hvar r = dist(loc, &rootloc);
    		}
    		preloop b{
    			hvar bulletn = i;
    		}
    		color({"blue", "purple"})
    	} s polar(r - 0.2 * bulletn,
    		lerpsmooth($(eiosine), 0, 3, t, 0, (lr * 1.5 * 360 / &circTimes))))
    

    You've already seen the color modifier, but this is the first time we're using the bindLR modifier. This modifier binds the variables lr and rl. lr is 1 when the loop iteration is even and -1 when the loop iteration is odd, and rl is the reverse. This will allow us to easily make alternating bullets rotate in opposite directions.

    The next complexity is the lerpsmooth in the polar angle.

    First, we decide how many degrees we want to rotate through. Here, we start at 0º and end at ±1.5*(360/circTimes)º, where the even (blue) bullets rotate counterclockwise and the odd (purple) bullets rotate clockwise. The reason we need to make this relative to 360/circTimes is so that the bullets line up when they end their rotation. You can try setting this to a random number like 50, and observe how the bullets don't line up when they finish rotating.

    We use lerpsmooth because we want the bullets to start off slowly and end slowly. The easing function for starting and ending slowly is eiosine (there are also einsine and eoutsine). And we want this to occur while the bullet's time is between 0 and 3 seconds. Normally, eiosine is a function that takes a float and returns a float, so we could write eg. hvar myFloat = eiosine(0.5). However, the first argument to lerpsmooth must be a lambda of type Func<float,float>. To convert a function into a lambda, we use the syntax $(functionName).

    Next, add the following lines to the end. Also, go back to the GTRepeat and set the wait time to 0s.

    			wait(3)
    			bulletcontrol(once, "gcircle-*", cull _)
    

    With this, we're almost done. We just need to write a bullet control to transform the circle bullets back into keine bullets. Let's go ahead and add the control:

    			bulletcontrol once "gcircle-*" sm(&bulletn::float == 0,
    				sync "keine-purple/w" <> gsrepeat {
    				} s rvelocity(cx(2))
                )
    

    We only want to resummon the keine bullets from the first bullet in each set, so we add a predicate that only passes if bulletn is zero. (Note: this means that if the player runs into the first bullet in a set, the keine bullet will not resummon. If you require that it always resummon, you would need to use empty bullets, which will be discussed in a later tutorial.)

    If you run this code, you'll see that the angle of the resummoned bullets is completely wrong. This is because we rotated the circle bullets, so if we fire keine bullets along the circle bullets' firing angle, it won't take into effect the rotation we applied. There are a number of ways to solve this, but there's a simple trick that will suffice here. Since the bullets are rotating in a circle with constant radius, and we want to fire them outwards, we can just fire them perpendicular to the movement delta. Let's introduce the face modifier:

    				sync "keine-purple/w" <> gsrepeat {
    						face(velocity)
    					} s rvelocity(cx(2))
    

    Run this and you'll see that the resummoned keine bullets fire on the tangent to the circle of rotation. The face modifier applies a global rotation to the entire V2RV2 and firing direction of any bullet. By default, it is set to original, which applies the same rotation a bullet has to any children it summons. See Facing. The other two important options are derot (Derotated) and velocity. Go ahead and try setting the face variable to derot.

    Now that we have the resummoned bullets firing along the tangent, all we need to do is add an angle offset of ±90º to make them summon outwards:

    				sync "keine-purple/w" <-90> gsrepeat {
    					face(velocity)
    				} s rvelocity(cx(2))
    

    With this, Danmokou Chimera is mechanically complete. However, you may be worried about the resummoned bullets. By default, DMK has handling for making bullets scale in when they are summoned, since large bullets appearing instantly looks very strange. As a result, the resummoned bullets are slowly scaling in when they are resummoned, and this makes the spellcard visually unclear.

    The easy solution for this is simply to add a time offset.

    				sync "keine-purple/w" <-90> addtime 1s gsrepeat {
                        face(velocity)
                    } s rvelocity(t > 1 ? cx(2) : zero)
    

    By offsetting the time of the resummoned keine bullets by 1 second, they no longer scale in.

    Note that we also have to change the velocity function to be zero when time is less than one. This is because addtime will actually simulate the skipped time when creating the bullet. The engine actually handles this the same way it does half-frame fires-- you can fire something every 0.5 frames and the result will be smooth. As a result, we need to make sure the keine bullet has zero velocity when its time is less than the time offset. You can try setting the velocity to just cx(2) and seeing what happens.

    That's all for this tutorial!

    Final code:

    pattern { } {
    	phase 0 {} {
    		paction 0 {
    			shiftphaseto(1)
    		}
    	}
    			
    	phase 40 {
    		type(spell, "Danmokou Chimera")
    		hp(14000)
    		root(0, 0.5)
    	} {
    		paction 0 {
    			move(inf, nroffset(px(sine(8p, 2, t))))
    			gtrepeat {
    				wait(0s)
    				times(inf)
    				rv2incr(<22h>)
    				waitchild
    			} saction 0 {
    				sync "keine-purple/w" <1;:> gsrepeat {
    					times(16)
    					circle
    					sfx("x-fire-tech-8")
    					start b{
    						hvar rootloc = loc;
    						hvar circTimes = times;
    					}
    				} s(rvelocity(px(2)))
    				wait(1)
    				bulletcontrol(once, "keine-purple/w", cull _)
    				bulletcontrol once "keine-purple/w" sm(_,
    					sync "gcircle-*/w" <> gsrepeat {
    						times(13)
    						sfx("x-fire-tech-6")
    						root(&rootloc)
    						bindlr
    						start b{
    							hvar r = dist(loc, &rootloc);
    						}
    						preloop b{
    							hvar bulletn = i;
    						}
    						color({"blue", "purple"})
    					} s polar(r - 0.2 * bulletn,
    						lerpsmooth($(eiosine), 0, 3, t, 0, (lr * 1.5 * 360 / &circTimes))))
    				wait(3)
    				bulletcontrol(once, "gcircle-*", cull _)
    				bulletcontrol once "gcircle-*" sm(&bulletn::float == 0,
    					sync "keine-purple/w" <-90> addtime 1s gsrepeat {
    						face(velocity)
    					} s rvelocity(t > 1 ? cx(2) : zero)
    				)
    			}
    		}
    	}
    }
    
    
    • Improve this Doc
    In This Article
    Back to top Generated by DocFX