summaryrefslogtreecommitdiffstats
path: root/_posts/2015-06-14-osuweb.md
blob: b44846ffabad96b5eaad7d73e2562594ccf944b3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
---
# vim: tw=80
title: osu!web - WebGL & Web Audio
layout: post
tags: [games, osu]
---

<script src="/js/underscore-min.js"></script>

I've taken a liking to a video game called [osu!](https://osu.ppy.sh) over the
past few months. It's a rhythm game where you use move your mouse to circles
that appear with the beat, and click (or press a key) at the right time. It
looks something like this:

<iframe src="https://www.youtube.com/embed/qdaZnQQAPqQ" frameborder="0" allowfullscreen></iframe>

The key of this game is that the "beatmaps" (a song plus notes to hit) are
user-submitted. There are thousands of them, and the difficulty curve is very
long - I've been playing for 10 months and I'm only maybe 70% of the way up the
difficulty curve. It's also a competitive game, which leads to a lot more fun,
where each user tries to complete maps a little bit better than everyone else
can. You can see on the left in that video - this is a very good player who
earned the #1 rank during this play.

In my tendency to start writing code related to every game I play, I've been
working on a cool project called [osu!web](http://www.drewdevault.com/osuweb).
This is a Javascript project that can:

* Decompress osu beatmap archives
* Decode the music with Web Audio
* Decode the osu! beatmap format
* Play the map!

In case you don't have any osz files hanging around, try out [this
one](https://sr.ht/f30.osz), which is the one from the video above.

![](https://sr.ht/044.png)

## osu!web and the future

This part of the blog post is for non-technical readers, mostly osu players.
osu!web is pretty cool, and I want to make it even better. My current plans are
just to make it a beatmap viewer, and I'm working now on achieving that goal. I
have to finish sliders and add spinners, and eventually work on things like
storyboards. Playing background videos is not in the cards because of
limitations with HTML5 video.

Eventually, I'd like to make it possible to link to a certain time in a certain
map, or in a certain replay. Oh yeah, I want to make it support replays, too.
If I get replays working, though, then I don't see any reason not to let players
try the maps out in their web browsers, too. Keep an eye out!

## Technical Details

This project is only possible thanks to a whole bunch of new web technologies
that have been stabilizing in the past year or so. The source code is [on
Github](https://github.com/SirCmpwn/osuweb) if you want to check it out.

### Loading beatmaps

When the user [drags and
drops](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/scenes/need-files.js#L8-L41)
an osz file, we use [zip.js](https://github.com/gildas-lormeau/zip.js) and
create a virtual filesystem of sorts to browse through the archive. In this
archive we have several things:

* A number of "tracks" - osu files that define notes and such for various
    difficulties
* The song (mp3) and optionally a video background (avi)
* Assets - a background image and optionally a skin (like a Minecraft texture
    pack)

![](https://sr.ht/ce6.png)

We then load the *.osu files and decode them. They look similar to ini files or
Unix config files. Here's a snippet:

    [General]
    AudioFilename: MuryokuP - Sweet Sweet Cendrillon Drug.mp3
    AudioLeadIn: 1000
    PreviewTime: 69853

    # snip

    [Metadata]
    Title:Sweet Sweet Cendrillon Drug
    TitleUnicode:Sweet Sweet Cendrillon Drug
    Artist:MuryokuP
    ArtistUnicode:MuryokuP
    Creator:Smoothie
    Version:Cendrillon

    # snip

    [HitObjects]
    104,308,1246,5,0,0:0:0:0:
    68,240,1553,1,0,0:0:0:0:
    68,164,1861,1,0,0:0:0:0:
    104,96,2169,1,0,0:0:0:0:
    172,60,2476,2,0,P|256:48|340:60,1,170,0|0,0:0|0:0,0:0:0:0:
    404,104,3399,5,0,0:0:0:0:
    
    # snip

This is decoded by
[osu.js](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/osu.js). For
some sections (like `[Metadata]`), it just puts each entry into a dict that you
can pull from later. It does more for things like hit objects, and understands
which of these lines is a slider versus a hit circle versus a spinner and so on.

I sneakily loaded a beatmap in the background in your browser as you were
reading. If you want to check it out, open up your console and play with the
`track` object. Ignore all the disqus errors, they're irrelevant.

![](https://sr.ht/a81.png)

## Enter stage: Web Audio

Web Audio had a bit of a rocky development cycle, what with Chrome thinking it's
special and implementing a completely different standard from everyone else.
Things have [settled](http://caniuse.com/#feat=audio-api) by now, and I can
start playing with it 😁 Bonus: Mozilla finally added mp3 support to all
platforms, including Linux (which my dev machine runs).

The osz file includes an mp3, which we
[extract](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/osu.js#L209-L224)
into an ArrayBuffer, and
[load](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/osu-audio.js)
into a Web Audio context. This is super cool and totally would not have been
possible even a few months ago - kudos to the teams implementing all this
exciting stuff in the browsers.

That's about all we're doing with Web Audio right now. I do add a gain node so
that you can control the volume with your mouse wheel. In the future, we can get
more creative by:

* Adding support for HT/DT mods
* Adding support for NC

## Enter stage: PIXI

Once we've decoded the beatmap and loaded the audio, we can play it. After
briefly showing the user a difficulty selection, we jump into rendering the map.
For this, I've decided to use [PIXI.js](http://pixijs.com/), which gives us a
really nice API to use on top of WebGL with a canvas fallback for when WebGL is
not available. I was originally just using canvas, but it wasn't very
performant, so I went looking for a 2D WebGL framework and found PIXI. It's
pretty cool.

First, we iterate over all of the hit objects on the beatmap and generate
sprites for them:

{% highlight javascript %}
this.populateHit = function(hit) {
    // Creates PIXI objects for a given hit
    hit.objects = [];
    hit.score = -1;
    switch (hit.type) {
        case "circle":
            self.createHitCircle(hit);
            break;
        case "slider":
            self.createSlider(hit);
            break;
    }
}

for (var i = 0; i < this.hits.length; i++) {
    this.populateHit(this.hits[i]); // Prepare sprites and such
}
{% endhighlight %}

This is all done before we start playing. We consider the timestamp in the music
that the hit is scheduled for, and then we place *all* of the hit objects into
an array and start the song. See code for
[createHitCircle](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/scenes/playback.js#L88-L143),
which puts together a bunch of sprites for each hit cirlce and sets their alpha
to zero. See also
[createSlider](https://github.com/SirCmpwn/osuweb/blob/gh-pages/scripts/scenes/playback.js#L145-L228),
which is more complicated (I'll go into detail later).

Each frame, we get the current time from the Web Audio layer, and we run a
function that updates a list of upcoming hit objects:

{% highlight javascript %}
this.updateUpcoming = function(timestamp) {
    // Cache the next ten seconds worth of hit objects
    while (current < self.hits.length
        && futuremost < timestamp + (10 * TIME_CONSTANT)) {
        var hit = self.hits[current++];
        for (var i = hit.objects.length - 1; i >= 0; i--) {
            self.game.stage.addChildAt(hit.objects[i], 2);
        }
        self.upcomingHits.push(hit);
        if (hit.time > futuremost) {
            futuremost = hit.time;
        }
    }
    for (var i = 0; i < self.upcomingHits.length; i++) {
        var hit = self.upcomingHits[i];
        var diff = hit.time - timestamp;
        var despawn = NOTE_DESPAWN;
        if (hit.type === "slider") {
            despawn -= hit.sliderTimeTotal;
        }
        if (diff < despawn) {
            self.upcomingHits.splice(i, 1);
            i--;
            _.each(hit.objects, function(o) {
                self.game.stage.removeChild(o);
                o.destroy();
            });
        }
    }
}
{% endhighlight %}

I adopted this pattern early on for performance reasons. During each frame's
rendering step, we only have the sprites and such loaded for hit objects in the
near future. This saves a lot of time. PIXI has all of these sprites loaded and
draws them for us each frame. During each frame, all we have to do is update
them:

{% highlight javascript %}
this.updateHitObjects = function(time) {
    self.updateUpcoming(time);
    for (var i = self.upcomingHits.length - 1; i >= 0; i--) {
        var hit = self.upcomingHits[i];
        switch (hit.type) {
            case "circle":
                self.updateHitCircle(hit, time);
                break;
            case "slider":
                self.updateSlider(hit, time);
                break;
            case "spinner":
                //self.updateSpinner(hit, time); // TODO
                break;
        }
    }
}
{% endhighlight %}

This is passed in the current timestamp in the song, and based on this we are
able to do some simple math to calculate how much alpha each note should have,
as well as the scale of the approach circle (which tells you when to click the
note):

{% highlight javascript %}
this.updateHitCircle = function(hit, time) {
    var diff = hit.time - time;
    var alpha = 0;
    if (diff <= NOTE_APPEAR && diff > NOTE_FULL_APPEAR) {
        alpha = diff / NOTE_APPEAR;
        alpha -= 0.5; alpha = -alpha; alpha += 0.5;
    } else if (diff <= NOTE_FULL_APPEAR && diff > 0) {
        alpha = 1;
    } else if (diff > NOTE_DISAPPEAR && diff < 0) {
        alpha = diff / NOTE_DISAPPEAR;
        alpha -= 0.5; alpha = -alpha; alpha += 0.5;
    }
    if (diff <= NOTE_APPEAR && diff > 0) {
        hit.approach.scale.x = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
        hit.approach.scale.y = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
    } else {
        hit.approach.scale.x = hit.objects[2].scale.y = 1;
    }
    _.each(hit.objects, function(o) { o.alpha = alpha; });
}
{% endhighlight %}

I've left out sliders, which again are pretty complicated. We'll get to them
after you look at this screenshot again:

![](https://sr.ht/044.png)

All of these hit objects are having their alpha and approach circle scale
adjusted each frame by the above method. Since we're basing this on the
timestamp of the map, a convenient side effect is that we can pass in any time
to see what the map should look like at that time.

## Curves

The hardest thing so far has been rendering sliders, which are hit objects that
you're meant to click and hold as you move across the "slider". They look like
this:

![](https://sr.ht/c97.png)

The golden circle is the area you need to keep your mouse in if you want to pass
this slider. Sliders are defined as a series of curves. There are a few kinds:

* Linear sliders (not curves)
* Catmull sliders
* Bezier sliders

For now I've only done bezier sliders. I give many thanks to
[opsu](https://github.com/itdelatrisu/opsu), which I learned a lot of useful
stuff about sliders from. Each slider is currently generated using the
now-deprecated "peppysliders" method, where the sprite is repeated along the
curve several times. If you look carefully as a slider fades out, you can notice
that this is the case.

![](https://sr.ht/787.png)

The newer style of sliders involves rendering them with a custom shader. This
should be possible with PIXI, but I haven't done any research on them yet.
Again, I expect to be able to draw a lot of knowledge from reading the opsu
source code.

I left out the initializer for sliders earlier, because it's long and
complicated. I'll include it here so you can see how this goes:

{% highlight javascript %}
this.createSlider = function(hit) {
    var lastFrame = hit.keyframes[hit.keyframes.length - 1];
    var timing = track.timingPoints[0];
    for (var i = 1; i < track.timingPoints.length; i++) {
        var t = track.timingPoints[i];
        if (t.offset < hit.time) {
            break;
        }
        timing = t;
    }
    hit.sliderTime = timing.millisecondsPerBeat *
        (hit.pixelLength / track.difficulty.SliderMultiplier) / 100;
    hit.sliderTimeTotal = hit.sliderTime * hit.repeat;
    // TODO: Other sorts of curves besides LINEAR and BEZIER
    // TODO: Something other than shit peppysliders
    hit.curve = new LinearBezier(hit, hit.type === SLIDER_LINEAR);
    for (var i = 0; i < hit.curve.curve.length; i++) {
        var c = hit.curve.curve[i];
        var base = new PIXI.Sprite(Resources["hitcircle.png"]);
        base.anchor.x = base.anchor.y = 0.5;
        base.x = gfx.xoffset + c.x * gfx.width;
        base.y = gfx.yoffset + c.y * gfx.height;
        base.alpha = 0;
        base.tint = combos[hit.combo % combos.length];
        hit.objects.push(base);
    }
    self.createHitCircle({ // Far end
        time: hit.time,
        combo: hit.combo,
        index: -1,
        x: lastFrame.x,
        y: lastFrame.y,
        objects: hit.objects
    });
    self.createHitCircle(hit); // Near end
    // Add follow circle
    var follow = hit.follow =
        new PIXI.Sprite(Resources["sliderfollowcircle.png"]);
    follow.visible = false;
    follow.alpha = 0;
    follow.anchor.x = follow.anchor.y = 0.5;
    follow.manualAlpha = true;
    hit.objects.push(follow);
    // Add follow ball
    var ball = hit.ball = new PIXI.Sprite(Resources["sliderb0.png"]);
    ball.visible = false;
    ball.alpha = 0;
    ball.anchor.x = ball.anchor.y = 0.5;
    ball.tint = 0;
    ball.manualAlpha = true;
    hit.objects.push(ball);

    if (hit.repeat !== 1) {
        // Add reverse symbol
        var reverse = hit.reverse =
            new PIXI.Sprite(Resources["reversearrow.png"]);
        reverse.alpha = 0;
        reverse.anchor.x = reverse.anchor.y = 0.5;
        reverse.x = gfx.xoffset + lastFrame.x * gfx.width;
        reverse.y = gfx.yoffset + lastFrame.y * gfx.height;
        reverse.scale.x = reverse.scale.y = 0.8;
        reverse.tint = 0;
        // This makes the arrow point back towards the start of the slider
        // TODO: Make it point at the previous keyframe instead
        var deltaX = lastFrame.x - hit.x;
        var deltaY = lastFrame.y - hit.y;
        reverse.rotation = Math.atan2(deltaY, deltaX) + Math.PI;

        hit.objects.push(reverse);
    }
    if (hit.repeat > 2) {
        // Add another reverse symbol
        var reverse = hit.reverse_b
            = new PIXI.Sprite(Resources["reversearrow.png"]);
        reverse.alpha = 0;
        reverse.anchor.x = reverse.anchor.y = 0.5;
        reverse.x = gfx.xoffset + hit.x * gfx.width;
        reverse.y = gfx.yoffset + hit.y * gfx.height;
        reverse.scale.x = reverse.scale.y = 0.8;
        reverse.tint = 0;
        var deltaX = lastFrame.x - hit.x;
        var deltaY = lastFrame.y - hit.y;
        reverse.rotation = Math.atan2(deltaY, deltaX);
        // Only visible when it's the next end to hit:
        reverse.visible = false;

        hit.objects.push(reverse);
    }
}
{% endhighlight %}

As you can see, there are many more moving pieces here. The important part is
the curve:

{% highlight javascript %}
hit.curve = new LinearBezier(hit, hit.type === SLIDER_LINEAR);
for (var i = 0; i < hit.curve.curve.length; i++) {
    var c = hit.curve.curve[i];
    var base = new PIXI.Sprite(Resources["hitcircle.png"]);
    base.anchor.x = base.anchor.y = 0.5;
    base.x = gfx.xoffset + c.x * gfx.width;
    base.y = gfx.yoffset + c.y * gfx.height;
    base.alpha = 0;
    base.tint = combos[hit.combo % combos.length];
    hit.objects.push(base);
}
{% endhighlight %}

In the [curve
code](https://github.com/SirCmpwn/osuweb/tree/gh-pages/scripts/curves), a
series of points along each curve are generated for us to place sprites at.
These are precomputed like all other hit objects to save time during playback.
However, the render updater is still quite complicated:

{% highlight javascript %}
this.updateSlider = function(hit, time) {
    var diff = hit.time - time;
    var alpha = 0;
    if (diff <= NOTE_APPEAR && diff > NOTE_FULL_APPEAR) {
        // Fade in (before hit)
        alpha = diff / NOTE_APPEAR;
        alpha -= 0.5; alpha = -alpha; alpha += 0.5;

        hit.approach.scale.x = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
        hit.approach.scale.y = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
    } else if (diff <= NOTE_FULL_APPEAR && diff > -hit.sliderTimeTotal) {
        // During slide
        alpha = 1;
    } else if (diff > NOTE_DISAPPEAR - hit.sliderTimeTotal && diff < 0) {
        // Fade out (after slide)
        alpha = diff / (NOTE_DISAPPEAR - hit.sliderTimeTotal);
        alpha -= 0.5; alpha = -alpha; alpha += 0.5;
    }

    // Update approach circle
    if (diff >= 0) {
        hit.approach.scale.x = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
        hit.approach.scale.y = ((diff / NOTE_APPEAR * 2) + 1) * 0.9;
    } else if (diff > NOTE_DISAPPEAR - hit.sliderTimeTotal) {
        hit.approach.visible = false;
        hit.follow.visible = true;
        hit.follow.alpha = 1;
        hit.ball.visible = true;
        hit.ball.alpha = 1;

        // Update ball and follow circle
        var t = -diff / hit.sliderTimeTotal;
        var at = hit.curve.pointAt(t);
        var at_next = hit.curve.pointAt(t + 0.01);
        hit.follow.x = at.x * gfx.width + gfx.xoffset;
        hit.follow.y = at.y * gfx.height + gfx.yoffset;
        hit.ball.x = at.x * gfx.width + gfx.xoffset;
        hit.ball.y = at.y * gfx.height + gfx.yoffset;
        var deltaX = at.x - at_next.x;
        var deltaY = at.y - at_next.y;
        if (at.x !== at_next.x || at.y !== at_next.y) {
            hit.ball.rotation = Math.atan2(deltaY, deltaX) + Math.PI;
        }

        if (diff > -hit.sliderTimeTotal) {
            var index = Math.floor(t * hit.sliderTime * 60 / 1000) % 10;
            hit.ball.texture = Resources["sliderb" + index + ".png"];
        }
    }

    if (hit.reverse) {
        hit.reverse.scale.x = hit.reverse.scale.y = 1 + Math.abs(diff % 300) * 0.001;
    }
    if (hit.reverse_b) {
        hit.reverse_b.scale.x = hit.reverse_b.scale.y = 1 + Math.abs(diff % 300) * 0.001;
    }
    _.each(hit.objects, function(o) {
        if (_.isUndefined(o._manualAlpha)) {
            o.alpha = alpha;
        }
    });
}
{% endhighlight %}

Much of this is the same as the hit circle updater, since we have a similar hit
circle at the start of the slider that needs to update in a similar fashion.
However, we also have to move the rolling ball and the follow circle along the
slider as the song progresses. This involves calling out to the curve code to
figure out what point is (`current_time / slider_end`) along the length of the
slider. We put the ball there, and we also ask for the point at (`(current_time +
0.01) / slider_end`) and make the ball rotate to face that direction.

## Conclusions

That's the bulk of the work neccessary to make an osu renderer. I'll have to add
spinners once I feel like the slider code is complete, and a friend is working
on adding hit sounds (sound effects that play when you correctly hit a note).
The biggest problem he's facing is that Web Audio has no good solution for
low-latency audio playback. On my side of things, though, everything is going
great. PIXI was a really good choice - it's an easy to use API and the WebGL
frontend is fast as hell. osu!web plays a map with performance that compares to
the performance of osu! native.

<script src="/js/osu.js"></script>

<script>
var xhr = new XMLHttpRequest();
xhr.open("GET", "/example.osu");
xhr.onload = function() {
    window.track = new Track(xhr.responseText);
    window.track.decode();
};
xhr.send();
</script>