Skip to content

Latest commit

 

History

History
164 lines (115 loc) · 11.2 KB

t06.md

File metadata and controls

164 lines (115 loc) · 11.2 KB

Tutorial 6: Danmokou's Design Philosophy

This tutorial is written to be accessible to people who have no knowledge of Danmokou. If you were linked here from an external site, it was not a mistake. You can keep reading.

When we talk about how bullets in a bullet hell game move, our first assumption is that they move straight, at a constant velocity. But this isn't always the case.

There are a number of common movement methods that we might want to use, beyond just a constant velocity:

  • Moving straight with an accelerating velocity
  • Moving straight with a velocity that constantly switches between slow and fast
  • Moving back and forth between two locations
  • Polar movement (ie. angular velocity)
  • Moving in squiggly lines
  • Rotating the direction of movement, eg. when homing on the player
  • Moving relative to another moving object
  • Moving along complex parametric equations

We can make this list much longer, but the point is that there are a lot of ways to configure the movement of an object.

In other popular engines, bullets support straight movement with a handful of modifying parameters. These parameters are largely limited to straight movement with acceleration, and sometimes the occasional oddity like constant angular speed. Any type of movement outside of this structure requires the user to fall back to low-level manual object creation.

In DMK, bullet movement is function-based. Instead of providing a "speed" and "acceleration" parameter, you provide a function that tells the engine how to calculate the next position. The engine provides pre-implemented functions several layers down, and if you ever think you need to add a new one, you implement it once and use it everywhere. Because every layer of the bullet stack is based on functions, the user never needs to fall back to low-level manual object manipulation.

For example, the function for making a bullet go straight at speed 3 is rvelocity(px(3)), where rvelocity is a movement function that uses velocity, and px(3) is the vector (3, 0). But what if we wanted to make the bullet have a time-variant velocity of 3 + sine(period=4, amplitude=0.6, time)? In another engine, this requires the user to manually handle object movement, since there is no handling for nontrivial time-variant velocity. In DMK, we just drop this new velocity function at the correct layer: rvelocity(px(3 + sine(4, 0.6, t))).

Is DMK any slower for supporting generalized movement functions instead of a few fixed parameters? No-- if anything, it's faster. DMK is one of the strongest bullet hell engines out there, supporting over 100,000 bullets at 4K 120 FPS, whereas many engines struggle getting 10,000 with much lower specs. It's able to do this precisely because its bullet update loop is just one function invocation with no bloat.

Field Experiment

Let's say we want to summon 4 bullets. Each of them moves in a spiral, but their starting angles are evenly spread around a circle. (graph: https://www.desmos.com/calculator/fksoefvpo7)

This is the code implementation in LuaSTG. Since the engine's movement functions do not support equations (polar or cartesian) as arguments, the user must instead define a custom object and write a custom update function bullet_define_spiral:frame(). The user must also manually set each bullet's position and manually rotate each bullet's angle by the starting angle in this function.

bullet_define_spiral = Class(object)
function bullet_define_spiral:init(x,y,ang)
    self._x = x
    self._y = y
    self.group = GROUP_ENEMY_BULLET
    self.layer = LAYER_ENEMY_BULLET
    self.img = "arrow"
    self.ang = ang
    self.navi = true
end
function bullet_define_spiral:frame()
    self.x = self._x + (self.timer*2)*cos(self.timer + self.ang)
    self.y = self._y + (self.timer*2)*sin(self.timer + self.ang)
end

for i = 1, 4 do
  New(bullet_define_spiral, boss.x, boss.y, 90*i)
end

Here's another example, this time from Danmakufu (DNH). Again, the engine's movement functions do not support equations as arguments, so the user must define a custom coroutine updater for the bullet and manually update its position every frame. Furthermore, since the engine has limited support for cancellation, the user must also check if the bullet has been deleted every frame.

task TBullet(ang) {
    let ang_d = 180 / time;
    let dist = 0;
    let dist_d = 1 / time;
    let bullet = CreateShotB1(x, y, 0, 0, DS_BALL_S_RED, 0);
    while(!Obj_IsDeleted(bullet)) {
        let x_d = (x + cos(ang) * dist) - ObjMove_GetX(bullet);
        let y_d = (y + sin(ang) * dist) - ObjMove_GetY(bullet);
        ObjMove_AddPatternB1(bullet, 0, x_d, y_d);
        ang += ang_d;
        dist += dist_d;
        yield;
    }
}
ascent(i in 0..4) {
    TBullet(i * 360 / 4);
}

Finally, this is DMK code to solve this problem. The gsrepeat function handles repeating and spreading the bullets around the circle, and the polar movement function creates a polar equation from two equations r=2*t and theta=80*t. Rotation is automatically handled within the engine when the circle command is used to spread the bullets around.

sync(arrow-red/w, <>, gsrepeat(
	{ times(4) circle }, 
	s(polar(2 * t, 80 * t))
))

DMK's code is terser and avoids repetitive setup code around update loops and position assignment. It doesn't require you to manually convert polar coordinates to Cartesian coordinates (though there's PolarToXY if you really want to) or write custom update functions. It doesn't even require you to manually rotate each of the four bullets by 90º. All of this behavior is abstracted by the engine, so you only have to write the important code.

Functions as a Philosophy

The reason DMK can make this work so cleanly is because its execution stack is based entirely on functions. Instead of trying to parametrize important functions with a few simple variables, DMK passes functions as arguments to other functions. As a result, each layer of the execution stack is fully modular and independent of other layers, and adding new features to a layer is as simple as declaring a new function in the corresponding C# file. In other engines, supporting new features requires falling back to the most low-level engine capabilities, and forcing them into the semantically appropriate layer may break existing code dependent on that layer, since layers are not modular.

Let's discuss adding new features a bit more. DMK is, at all levels, extensible. In the process of making a game, it's not unlikely that you'll find some feature or another that's not already present in the engine. For example, someone recently asked me if DMK had support for contorting the bullet time variable so that its speed along a parametric equation would be constant. My response was:

No, but if you can figure out the math, it'd be easy to add.

This is the fundamental philosophy behind new features in DMK. Adding a new feature should always be as simple as writing some C# code and putting a function definition for it somewhere. The most useful code in DMK is just repositories full of functions that "do things", whether that be xref:Danmokou.Danmaku.BulletManager.SimpleBulletControls, or xref:Danmokou.Danmaku.Options.GenCtxProperty, or xref:Danmokou.SM.SMReflection, or xref:Danmokou.DMath.Functions.ExM. If a feature you want isn't available, you can write a function and make it available everywhere instantly.

(We did not manage to figure out the math on this problem, incidentally. There doesn't seem to be a closed-form solution, so some iterative solver seems to be necessary. As a result, DMK doesn't currently support it, but feel free to write the solver and make a PR.)

Modifiers as a Philosophy

In a similar vein to functions, DMK makes heavy use of modifiers (also called options and properties depending on context). We can think about modifiers from the following perspective:

In summoning a bullet, there are a few more attributes you may want to modify, other than just the movement function. Maybe you want to give it a scaling function, so it changes size dynamically. Maybe you want to give it a direction function, so it has its own rotation (think of a spinning star). Maybe you want it to change color dynamically, or maybe you want to summon it on a different layer, or maybe you want to provide a few custom variables to the shader. Given all these possibilities, what should be the signature of the bullet-summoning function?

Now, most engines would require you to create custom objects and handle everything manually to do any of these things. Danmakufu (DNH) has a little bit of an answer to this problem, in the form of multiple functions. There, when summoning a bullet, you just select the function that has support for the attributes you need, out of:

  • CreateShotA1
  • CreateShotA2
  • CreateShotOA1
  • CreateShotB1
  • CreateShotB2
  • CreateShotOB2

All this for four variable attributes-- root firing position, acceleration, max speed, and angle!

But we want to support an indefinite number of attributes, and we also want our function names to be more informative. To do this, DMK has functions which take an array of modifiers, and the engine handles all behavior related to these modifiers. The function for firing a simple bullet with modifiers is simple.

How do we fire a simple bullet with a custom scaling function?

simple(rvelocity(cx(2)), {
	scale(1 + sine(1, 0.4, t))
})

And what if we want to add a custom direction function? We simply add a modifier.

simple(rvelocity(cx(2)), {
	scale(1 + sine(1, 0.4, t))
	dir(400 * t)
})

Modifiers are also critical in making DMK extensible. You can link a complex behavior pathway from a single modifier and then start invoking it freely, without changing the existing code interface or breaking backwards compatibility.

As an example, at the time this document was written, DMK did not support per-bullet opacity multipliers for simple bullets. Let's say that you wanted to add support for this. You might do the following:

  • Add a Func<SimpleBullet, float> opacityMultiplier to the SimpleBullet struct.
  • Add shader support for opacity to the Bullet Indirect shader.
  • In the rendering code, for each SimpleBullet sb, set the opacity variable on the shader to sb.opacityMultiplier?.Invoke(sb) ?? 1f.
  • Add a simple bullet modifier (xref:Danmokou.Danmaku.Options.SBOption) like Opacity(Func<SimpleBullet, float> opacityMultiplier), and add support in the engine for setting SimpleBullet.opacityMultiplier when the modifier is present.

Now, you have perfect backwards compatibility, and you can make a bullet that fades in and out as simply as:

simple(rvelocity(cx(2)), {
	opacity(0.5 - cosine(1, 0.5, t))
})

Because of modifiers and functions, even changes to low-level entities like SimpleBullet can be handled with perfect backwards compatibility in all standard cases.

Conclusion

DMK is not only fast and concise, it also centers a flexible and extensible design that makes modifying and extending the engine an easy task. Even if you don't need its raw horsepower or graphical fidelity, you'll benefit from the simplicity and abstraction of the function- and modifier-based architecture.

Also, it's FOSS (licensed under MIT).

If you're interested in playing around with DMK, you can read the setup guide here.