Tutorial 4: Pathers, Lasers, and Summons
Part 1: Pathers
This tutorial will be focused on complex summonable objects.
Start off with this code:
pattern { } {
phase 0 {} {
paction 0 {
shiftphaseto(1)
}
}
phase 40 {
type(non, "Summonables")
hp(14000)
root(0, 0)
} {
paction 0 {
//Insert code here
}
}
}
The first thing we'll look at is pathers. These are often called "curvy lasers" in other engines, but DMK supports actual curved lasers, so we don't use confusing terminology.
Drop this code under "Insert code here":
sync "*-red*" <0.5;:> gsrepeat {
times(3)
color { "pather", "gpather", "lightning" }
circle
} gsrepeat {
times(6)
spread(<100>)
color { "/b", "/", "/w" }
} pather(2, 1 + sine(1.6, 0.4, t),
roffset(pxy(1 * t, sine(1, 0.2, t))), {
hueshift(120 * t)
})
There are three pather styles: pather
(opaque), gpather
(glowy/additive), and lightning
(fancy shader effects).
The key unique feature about pathers is "remembrance time". Pathers work by recording the positions they go through and drawing a line through the points. The remembrance time is the amount of time that the pather draws a line through.
Instead of using s
or simple
, we use the pather
summoning function. This takes four arguments:
- Maximum remembrance time (Note: Keep this low, as pathers are proportionally as expensive as this number)
- Remembrance time as a function of pather lifetime
- Path function (same as
s
) - Generic summonable options (BehOption or BulletManagement/BehOptions.cs)
We'll discuss the summonable options more later in this tutorial, but for now let's appreciate the vanity of the hueshift
option. The hue spectrum is 360 degrees, and the hueshift(X)
option makes the rendered data shift its hue by X
degrees at any point in time.
Advanced note: Consider what happens when the remember time increases faster then the number of new points being added. Internally, the pather will recuperate its old points and extend backwards when this occurs. However, in the current iteration of the engine, pathers use the Unity Trail Renderer for rendering, and the trail renderer does not do this. This means that if you drastically vary the remember time, pathers might have a larger hitbox than their visible renderer. There is another renderer implementation in the engine which supports this, but it is significantly slower.
Part 2: Lasers
There are several lasers styles: laser
(opaque), glaser
(glowy), gdlaser
(glowy with a super sick shader effect), mulaser
(glowy with giant kanji scrolling down), and zonelaser
(opaque with a texture scrolling up).
For stylistic consistency, it is advised that mulaser and zonelaser should only be used as safe lasers; ie. they should not be given a hitbox.
Drop this basic laser code into your script:
sync "laser-red/" <-1;:>
laser(rvelocity(cy(-1)), 1, 2, {
hueshift(120 * t)
sfx2("x-laser-fire", "x-laser-on")
})
The laser
summoning function takes the following arguments:
- A path describing the movement of the laser origin
- A function returning the "cold time", ie. the delay time, of the laser
- A function returning the "hot time", ie. the collision time, of the laser
- A list of LaserOptions (LaserOption or BulletManagement/LaserOptions.cs)
LaserOptions have some overlap with summonable options. For example, hueShift works the same between both of them.
The most useful laser option is sfx2(sound1, sound2)
, which plays a sound effect when the laser is fired and another sound effect when the laser collision is activated. You can also use dsfx()
, which resolves internally to sfx2("x-laser-fire", "x-laser-on")
.
By default, lasers are summoned as straight lasers, which are straight. We can explicitly use the straight
option, which also allows us to provide a base rotation:
sync "glaser-green/" <-1;:>
laser(rvelocity(cy(1)), 1, 2, {
dsfx
straight(-30)
})
The next laser type is rotating lasers, which are straight with a variable rotation. Try running this at the same time as the green laser to see how the rotate
option works.
sync "gdlaser-blue/b" <-1;:> laser(rvelocity(cy(1)), 1, 2, {
rotate(-30, 30 * t)
})
We can also make non-straight lasers. There are two types of non-straight lasers: static
(draw the laser once) and dynamic
(draw the laser every frame).
sync "gdlaser-yellow/b" <-2;:-140> laser none 1 4 {
s(0.5)
stagger(0.24)
static(polar(2 * t, sine(1.4, 12, t)))
}
The s
option controls the width of the laser as a multiplier, and the stagger
option controls the draw resolution of the laser as a multiplier. Try increasing the stagger to 1.5 and see what happens.
The static
option takes a path definition, but in this path, t
refers to the drawing time of the laser. You can think of a laser as drawing all the positions that a bullet would go through in one line. If we fired a bullet with the same path, it would follow the drawn laser.
We can also use dynamic
, which gives us access to an extra variable lt
. This is the lifetime of the laser. Try running this code together with the static laser.
sync "gdlaser-pink/b" <-2;:-140> laser none 1 4 {
s(0.5)
stagger(0.24)
dynamic(polar(2 * t, sine(1.4, 12, t + lt)))
}
There are more laser options, and you can see most of them in use in the example script Patterns/examples/new lasers
. Here's a particularly fun laser function:
sync "gdlaser-green/b" <;3:1;:270> gsr2 6 <24> {
center
preloop b{
hvar itr = i;
}
} laser roffset(px(sine(3, 0.5, t))) 1 12 {
stagger(0.4)
varlength(10, 8 + sine(3, 4, t))
start(2 + sine(3h, 2, t))
s(2)
dynamic(roffset(
pxy(0.5 * t,
sine(5, 0.2, (2.5 * itr + t + 8 * lt)))))
}
Part 3: Subfiring
Subfiring is a powerful capability for lasers and pathers. Since lasers and pathers are BehaviorEntities, they can run StateMachines natively. What happens if we do just that?
sync "lightning-blue/w" <-3;1:;:-40> pather 2 2 roffset(pxy(2 * t, sine 1 0.2 t)) {
sm async "amulet-pink/b" <> gcrepeat {
wait(10)
times(inf)
cancel(offscreen(loc))
face(velocity)
} s(rvelocity(cx(-2)))
}
The SM option allows us to run a state machine at the tip of the pather. The state machine here should be familiar, though we introduce the modifier cancel
. This takes a predicate and checks it before every iteration. If the predicate is true, then the repeater stops. We tell the pather to stop firing once the tip is offscreen.
Note: Once the state machine run via an SM option is finished running, the entity will continue to exist. This differs from the behavior in Part 4 on summonables.
Lasers also support an SM option, with the same basic functionality. However, they also support one extra feature.
sync "gdlaser-red/" <-2;-1:;0.7:20> laser(rvelocity(cy(-1)), 2, 2, {
sm async("gem-green/w", <90>, gcrepeat {
wait(4)
times(inf)
preloop b{
hvar itr = i;
}
cancel(offscreen(loc))
} gsrepeat {
onlaser(0.05 * itr)
} s(rvelocity(cx(1))))
dynamic polar(
2 * t,
-10 * lt + sine(3.1, 12, 1.1 * lt + t))
})
The onlaser
modifier is a powerful option, available only to lasers, that allows us to summon bullets from arbitrary points on the laser. It takes a function for the time-index we want to get from the laser. Recall that lasers are simulated along a time axis. It runs at the start of the repeater and adds the position and tangent angle of the laser at the given point to the V2RV2 location.
Observe how the green bullets are always firing normal to the laser point they are summoned from. This is due to the combination of the onlaser angle, which gives the tangent, and the <90> offset.
Try using onlaser on another entity, like the pather above. Do you think it'll work?
Also, try using a modifier like rv2incr
in the gcrepeat
to see how it changes the summoning pattern.
Part 4: Summons
Set the root property to (0, 1) and drop this code in your script:
async "icrow" <0.5;:> gcrepeat {
wait(30)
times(4)
circle
sfx("x-crow")
target(ang, Lplayer)
preloop b{
hvar itr = i;
}
} summon
roffset(px(1.3 * t) + circle(1, 0.4, t))
async("gem-*/", <90>, gcrepeat({
wait(4)
times(inf)
cancel(offscreenby(0.5, loc))
colorf({ "red", "pink", "blue", "teal" }, itr)
sfx("x-fire-burst-1")
face(velocity)
}, s rvelocity(lerp(0.6, 0.9, t, cy 0.7, cx 1.5)))
) { }
icrow
is a summonable. The list of summonables is at Assets/SO/References/Default Summonable Styles. Enemies are all summonables, although bosses are usually not handled via summoning.
Summonables may or may not be enemies. By convention, summonables whose names begin with i
are not enemies. You can check if a summonable is an enemy by checking if it has an Enemy component.
If a summonable is not an enemy, then player fire will not damage it or interact with it.
We introduce the modifier target
here. This runs at the start of the repeater and modifies the V2RV2 based on a target location. The most common method is Ang (see RV2ControlMethod), which rotates the V2RV2 to point at the target location. Lplayer is the location of the player. As a result, even though the original V2RV2 has an angle of zero, the first crow actually fires towards the player.
The summon function takes three arguments:
- A path function.
- A state machine to run. When the state machine is finished, the summonable will destroy itself. This is different from the SM option on lasers and pathers.
- A list of summonable options (same as Pather).
Warning: If you want to spawn a summon without a state machine, do NOT use the state machine null
. Instead, use the state machine stall
. The difference is that summons with state machine stall
will automatically get cleaned up when the summoner ends the phase, but summons will state machine null
will persist until explicitly destroyed via a Cull command.
Let's go ahead and make these crows damageable. Change icrow
to crow
, and add a summonable option hp(300)
. The player deals around 1000 DPS by default.
Now you can destroy the crows. Note that when you destroy a crow, it automatically drops some items. You can configure the item drops manually via the items
option, or you can change the default item drops in Entities/Enemy.cs:AutoDeathItems.
What do you think would happen if we used the hp(300)
option on icrow
? Go ahead and try it out.
That's all for this tutorial!