Tutorial: Advanced Script (Dynamic Stream) - Poyo-SSB/osu-playground GitHub Wiki
In this tutorial, we're going to create this script, which converts a predefined slider into a stream with dynamic spacing.
This script needs four variables:
- [Int] Circle count – How many circles to turn the slider into.
- [Int] Exponent – The exponent of the smoothing function used to sample the curve.
- [Bool] In – Whether to apply smoothing going into the stream.
- [Bool] Out – Whether to apply smoothing at the end of the stream.
The bool
type stands for "Boolean" and represents a binary value—true or false. In the options panel, this is represented by a checkbox.
function start() {
var circleCount = Playground.AddInt('circleCount', 5);
var exponent = Playground.AddInt('exponent', 2);
var easeIn = Playground.AddBool('easeIn', true);
var easeOut = Playground.AddBool('easeOut', false);
Playground.AddOptionInt(circleCount, 'Circle count', 2, 32);
Playground.AddOptionInt(exponent, 'Exponent', 1, 8);
Playground.AddOptionBool(easeIn, 'In');
Playground.AddOptionBool(easeOut, 'Out');
}
As usual, we'll first get the values of our bindable variables:
function update() {
var circleCount = Playground.GetValueInt('circleCount');
var exponent = Playground.GetValueInt('exponent');
var easeIn = Playground.GetValueBool('easeIn');
var easeOut = Playground.GetValueBool('easeOut');
}
...and immediately, we run into a problem. We need a slider to convert into a stream.
Unfortunately, there's no really good way to dynamically import something like that into osu!Playground as of yet, so we can just manually define one for the time being. Here's an array of points defining a cool spiraling curve:
var points = [
new Vector2(173, 248),
new Vector2(214, 240),
new Vector2(234, 164),
new Vector2(188, 128),
new Vector2(94, 121),
new Vector2(17, 189),
new Vector2(18, 314),
new Vector2(66, 390),
new Vector2(179, 406),
new Vector2(284, 391),
new Vector2(310, 275),
new Vector2(328, 221),
new Vector2(268, 178),
new Vector2(257, 92),
new Vector2(307, 53),
new Vector2(397, 53),
new Vector2(458, 75)
];
Now, we'll create the slider to use as reference:
var slider = Playground.AddSlider(CurveType.Bezier, points, false);
Note that extra false
at the end. That's the optional draw
parameter, which defaults to true
if excluded. When false
, however, the slider is not drawn, simply created in memory. The Playground.AddSlider
function conveniently returns the slider as a variable for us to get extra information about.
Let's do that in a for
loop which runs once for every circle needed:
for (var i = 0; i < circleCount; i++) {
var inT = i / (circleCount - 1);
Playground.AddHitCircle(slider.PositionAt(ease(inT, exponent, easeIn, easeOut)));
}
In this code, we first define a variable called inT
, starts at 0 and interpolates to 1 over the course of the for
loop, reaching 1 on the final iteration.
Next, we reference a function that every slider provides called Slider.PositionAt
. It takes one parameter, a number from 0 to 1, and returns a position on the slider from its start to its end. Not coincidentally, we defined a variable earlier which represents exactly that. However, we don't pass that in directly. Instead, it's sent into a function called ease
which takes four parameters.
If one were to run this code now, it would throw an error. Why? Becuase the ease
function doesn't exist. In fact, we're about to create it ourselves.
This function is much more about math than it is about code, so here it is in its entirety:
function ease(t, exponent, easeIn, easeOut) {
if (exponent == 1 || (!easeIn && !easeOut)) {
return t;
}
var multiplier = exponent % 2 == 0 ? -1 : 1;
if (easeIn && !easeOut) {
return Math.pow(t, exponent);
}
else if (!easeIn && easeOut) {
return multiplier * Math.pow(t - 1, exponent) + 1;
}
else {
var power = 1 << exponent - 1;
if (t < 0.5) {
return power * Math.pow(t, exponent);
}
return multiplier * power * Math.pow(t - 1, exponent) + 1;
}
}
Let's step through the function one piece at a time.
function ease(t, exponent, easeIn, easeOut) {
First, we define the function and give it four parameters. The first is the input from inside our for loop, and the other three bindable variables.
if (exponent == 1 || (!easeIn && !easeOut)) {
return t;
}
Before we do any exponential calculations, we first want to save some processing power by immediately bailing if we notice that the input conditions seek a linear output. Since the input itself is already linear, no transformation is needed.
To check for this, we use an if
statement with two conditions separated with a boolean or operator (||
). The first checks if the exponent
value is 1. The second checks if both easeIn
and (&&
) easeOut
are false (!
).
var multiplier = exponent % 2 == 0 ? -1 : 1;
This variable will be used later. The %
symbol is the modulo operation. To quote Wikipedia, the modulo operation "finds the remainder after division of one number by another." In this case, we use a divisor of 2. Let's make a table of results for modulo 2:
exponent | exponent / 2 | exponent % 2 |
---|---|---|
1 | 0.5 | 1 |
2 | 1 | 0 |
3 | 1.5 | 1 |
4 | 2 | 0 |
5 | 2.5 | 1 |
6 | 3 | 0 |
Notice that the modulo function returns 0 when exponent
is even and 1 when odd.
So the first part of that statement checks if exponent
is even:
exponent % 2 == 0
The second part of the statement is what's called a ternary operator, and returns the second expression if true and the third expression if false. The syntax is a little bit convoluted, but these two bits of code are functionally identical.
var multiplier = exponent % 2 == 0 ? -1 : 1;
var multiplier;
if (exponent % 2 == 0) {
multiplier = -1;
} else {
multiplier = 1;
}
One's just shorter. Effectively, the code dictates that multiplier
should be -1 if exponent
is even, and 1 if exponent
is odd.
if (easeIn && !easeOut) {
return Math.pow(t, exponent);
}
Next, we do the most simple operation of the three remaining conditions. If easeIn
is true
and easeOut
is false, then we simply return our input t
to the power of exponent
using the Math.pow
function. Because of how math works, 0x is always 0 and 1x is always 1, so no transformations are needed:
else if (!easeIn && easeOut) {
return multiplier * Math.pow(t - 1, exponent) + 1;
}
This code checks if easeIn
is false
and easeOut
is true
and uses our previously defined multiplier
to determine whether or not to flip our exponent function.
else {
var power = 1 << exponent - 1;
if (t < 0.5) {
return power * Math.pow(t, exponent);
}
return multiplier * power * Math.pow(t - 1, exponent) + 1;
}
The final part of our easing function is the most complicated. It defines what's known in mathematics as a piecewise function, which is defined by multiple functions dependent on several conditions. In this case, our condition is t < 0.5
, which means exactly what it looks like it means. If t
is less than 0.5, the first return
statement is executed. Otherwise, the second one is executed.
In both of these statements, we use a variable called power
, but its definition looks rather arcane...
var power = 1 << exponent - 1;
The <<
defines a bit-shift operation; in this case, the operation shifts all the bits left by exponent - 1
positions. This is a sneaky and blazingly fast way of calculating powers of two. Here's why.
The number 1 in binary is 1
.
The number 2 in binary is 10
.
The number 4 in binary is 100
.
The number 8 in binary is 1000
.
With leading zeros, these can be written as follows:
1 = 0001
2 = 0010
4 = 0100
8 = 1000
When we shift all of the bits of 0001
leftward by one position, the result is 0010
. This simple fact means that bit shifting any number left by one position results in it doubling.
In our case, when exponent
is 1, we don't want any bit shifting, so the number remains zero. But for any larger values, the value increases as powers of two, which is useful for accounting for the stretching that we do with our ease function.
The left side of the graph is the first return
statement, executed when t < 0.5
. The right is the second one, which is executed in all other conditions (practically, when t
is greater than or equal to 0.5).
And with that, our script is complete. The final script made in this tutorial can be found here.
In our case, when exponent
is 1, we don't want any bit shifting, so the number remains zero. But for any larger values, the value increases as powers of two, which is useful for accounting for the stretching that we do with our ease function.
The left side of the graph is the first return
statement, executed when t < 0.5
. The right is the second one, which is executed in all other conditions (practically, when t
is greater than or equal to 0.5).
And with that, our script is complete. The final script made in this tutorial can be found here.