R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

一次 OSU! in Browser 的尝试

注意:本文发布于 2797 天前,文章中的一些内容可能已经过时。

OSU! 这个游戏可能许多同学非常陌生,这是一个有着 std、taiko、ctb、mania 四种模式的音乐游戏,其中除了 ctb 我玩不习惯以外,剩下的三个模式均有涉及。一天我突然想到,网上有没有那种能在浏览器中玩的版本?在网上搜了好久,并没有发现“能读取 OSU! 谱面并显示”的代码,只找到了一个有点类似的 Demo(传送门:HTML5太鼓达人),看了一下,并不是我想要的那种效果,而且也不是从 OSU! 谱面中读取的数据。于是我就从太鼓入手,自己写一个吧!

但是这工程量实在是大,所以我还是先拆成几个部分慢慢来。我大概列了一下需要完成的技术问题:

  • 加载资源
  • 按键处理
  • 读取谱面
  • 更新画面

    • 绘制背景
    • 绘制物件
  • 命中判定
  • 计算分数

其中命中判定和计算分数这两个其实不难,但我并不知道 OSU! 中的规则究竟是什么(究竟误差多少毫秒才算命中,连击加成的公式我也不知道),所以最终还是没有做。目前的进度是这样的:OSU! taiko

我就知道你们又懒得点开→_→

0

那么一项一项来吧!

开始之前的工作

样式其实很简单,写一个 canvas 标签和一个 audio 标签,然后样式随便写一写就可以搞定了。所以从下文开始我们都是在讨论 JavaScript。

最基本的几个规范我们还是要遵守的。为了不污染全局变量,我们需要写一个大的 function,然后把变量什么的放在里面,就像这样:

function OSUWeb () {
    this.ctx = document.getElementById("main-canvas").getContext("2d");
}

然后我们需要一个函数来不断更新画面:

OSUWeb.prototype.update = function () {
    var self = this;
    function update () {
        // 获取当前时间(单位:毫秒),注意这里必须用当前音乐的播放时间,不能每次加上多少,因为会有浮点数的累计误差以及偶尔因为卡帧引起的延迟
        self.time = parseInt(self.audio.currentTime * 1000);
        // TODO:这里将要写许多更新画面用的函数
        // 这个函数可以保证比 setInterval 更加平滑的刷新(可以与浏览器界面的更新速率相同,前提是你的 update 函数很高效),大家可以搜一下原理
        requestAnimationFrame(update);
    }
    update();
};

然后,封装几个函数总是好的:

function get(url, callback) {
    var xhr=new XMLHttpRequest();
    xhr.open("GET", url, true);
    xhr.onreadystatechange = function () {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                callback(xhr.responseText);
            }
            else {
                alert("Beatmap 不存在!");
            }
        }
    };
    xhr.send();
};
OSUWeb.prototype.drawImage = function (name, x, y, width, height, opacity) {
    switch (arguments.length) {
    // drawImage(name, x, y)
    case 3:
        this.ctx.globalAlpha = 1;
        this.ctx.drawImage(this.res[name], x, y);
        break;
    // drawImage(name, x, y, opacity)
    case 4:
        this.ctx.globalAlpha = width;
        this.ctx.drawImage(this.res[name], x, y);
        break;
    // drawImage(name, x, y, width, height)
    case 5:
        this.ctx.globalAlpha = 1;
        this.ctx.drawImage(this.res[name], x, y, width, height);
        break;
    // drawImage(name, x, y, width, height, opacity)
    case 6:
        this.ctx.globalAlpha = opacity;
        this.ctx.drawImage(this.res[name], x, y, width, height);
        break;
    }
}

加载资源

我这里直接用了 OSU! 自带的太鼓皮肤。因为偷懒所以资源的命名与原皮肤中的文件名是相同的。

// 全部的静态图片名
this.resArr = [
    "taikoBarTarget", "taikoSlider", "playField", "taikoGlow",
    "scorebarBg", "scorebarColor",
    "taikoBarLeft", "taikoBarRight", "taikoBarRightGlow",
    "drumInnerLeft", "drumInnerRight", "drumOuterLeft", "drumOuterRight",
];
// 全部的动态图片名:帧数,例如 `pippidonidle:6` 就是从 `pippidonidle0.png` 到 `pippidonidle5.png`,这样可以比较方便地做出一些动画的效果
this.resAnimationArr = ["pippidonidle:6", "taikohitcircleso:2", "taikohitcirclesx:2", "taikohitcirclelo:2", "taikohitcirclelx:2"];

有了图片名,就可以开始加载图片了。因为在 Canvas 中画图需要用 Image 对象,因此我们将这些图片逐一加载为 Image 对象备用。

// 静态图
for (var i = 0; i < this.resArr.length; i++) {
    var item = this.resArr[i];
    this.res[item] = new Image();
    this.res[item].src = "img/" + item + ".png";
}
// 动态图
for (var i = 0; i < this.resAnimationArr.length; i++) {
    var item = this.resAnimationArr[i];
    var t = item.split(":");
    t[1] = parseInt(t[1]);
    // 分别加载每一帧
    for (var j = 0; j < t[1]; j++) {
        this.res[t[0] + j] = new Image();
        this.res[t[0] + j].src = "img/" + t[0] + j + ".png";
    }
}

按键处理

太鼓模式其实就是 DFJK 四个键,分别对应着两边的红和蓝。为了统计四个键一共按了多少次,以及在屏幕上的显示效果,我又加了几个变量:drumDispTimeLast 统计本键在屏幕上还需要显示多少帧(例如按了 F 键则图中的鼓左边的红色区域会亮),drumHitsTotal 统计本键一共被按了多少次。其实后续的命中判定也要加在这里,然而我并没有写。

OSUWeb.prototype.initKeyboard = function () {
    var self = this;
    window.addEventListener("keydown", function (event) {
        switch (event.keyCode) {
        case 68: // D
            self.drumDispTimeLast.OuterLeft = 100;
            ++self.drumHitsTotal.OuterLeft;
            break;
        case 70: // F
            self.drumDispTimeLast.InnerLeft = 100;
            ++self.drumHitsTotal.InnerLeft;
            break;
        case 74: // J
            self.drumDispTimeLast.InnerRight = 100;
            ++self.drumHitsTotal.InnerRight;
            break;
        case 75: // K
            self.drumDispTimeLast.OuterRight = 100;
            ++self.drumHitsTotal.OuterRight;
            break;
        }
    });
}

读取谱面

加载谱面非常简单,一个 AJAX 就可以了,OSU! 的谱面格式是 .osu,具体的参数可以看这个帖子。当然,由于年代久远,有一些代码的含义已经不适用了,但是并没有太大的影响,自己稍微推一下还是能推出来的。

然后就是解析谱面。OSU! 的谱面文件十分良心,保证了输入格式完全准确,所以只需要按照规则解析即可。目前我们需要解析的有时间点和物件,前者有两种类型:主时间点(主要负责 BPM 和 Offset)和连带时间点(主要负责 Kiai time 和 Speedscale);后者有三种类型:圆圈、滑条、拨浪鼓(由于时间有限,我只实现了圆圈),其中圆圈主要有颜色、大小、击打时间这几个属性。

但是,如果你只记录这几个属性,那么过会儿显示的时候是没法显示的,因为你要确定圆圈飘过来的速度,这取决于圆圈所在的那一刻的 BPM 和 Speedscale,所以这两个属性也要算进去。那么如何快速确定某一时刻的这两个值呢?你可以说:“反正时间点已经读出来了,每读入一个圆圈,我直接找最接近这个圆圈的击打时间、且在其之前的时间点不就好了?”然而对于某些鬼畜图来说,几十个时间点、上千个圆圈,虽说不会特别慢(时间复杂度为 O(NM)),但是让人看了确实不爽。我的方法是设一个 currentTimingPoint 的变量,一开始是第一个,然后我们逐一读入圆圈,如果圆圈的击打时间超过了 currentTimingPoint 的 Offset,那就不断将其置为下一个时间点,直到 Offset 恰好大于当前圆圈的击打时间(或者没有下一个时间点)为止,那么现在这个时间点之前的那个时间点,就是最后一个比当前圆圈的击打时间靠前的那个时间点。这样的时间复杂度是 O(N+M),极大地提高了效率。

对于每一个时间点,存储如下:

switch (parseInt(timingPoint[6])) {
case 1:
    // 主时间点
    self.beatmap.timingPoints.push({
        type: 1,
        // 显示时间
        offset: parseInt(timingPoint[0]),
        // 当前的节奏
        bpm: parseFloat(timingPoint[1]),
        speedscale: 1,
        // 大多数歌的 4/4 拍,或华尔兹的 3/4 拍,或其它诡异的节拍
        tick: parseInt(timingPoint[2]),
        // 是否是 Kiai time
        kiai: parseInt(timingPoint[7])
    });
    break;
case 0:
    // 连带时间点
    self.beatmap.timingPoints.push({
        type: 2,
        offset: parseInt(timingPoint[0]),
        // 移动速度的加成
        speedscale: -100 / parseFloat(timingPoint[1]),
        tick: parseInt(timingPoint[2]),
        kiai: parseInt(timingPoint[7])
    });
    break;
}

对于每一个物件(圆圈),存储如下,不得不说按照规则写出来的公式真麻烦:

// 找到最接近的那个时间点
while (curTimIndex < self.beatmap.timingPoints.length && self.beatmap.timingPoints[curTimIndex].offset <= parseInt(hitObject[2])) {
    curTimObj = self.beatmap.timingPoints[curTimIndex];
    if (curTimObj.bpm) {
        bpm = curTimObj.bpm;
    }
    if (curTimObj.speedscale) {
        speedscale = curTimObj.speedscale;
    }
    // speed 表示每过一帧这个物件要移动多少像素
    speed = speedscale * 62 * 4 / bpm;
    curTimIndex++;
}
// 如果是圆圈
if (hitObject.length == 5 || hitObject[5].indexOf("0:") > -1) {
    // 击打时间
    var starttime = parseInt(hitObject[2]);
    var style = parseInt(hitObject[4]);
    var color;
    // 大圆还是小圆
    color = ((style & 4) != 0) ? "l" : "s";
    // 红还是蓝
    color += ((style & 2) != 0 || (style & 8) != 0) ? "x" : "o";
    self.beatmap.hitObjects.push({
        type: 1,
        // 击打时间
        starttime: starttime,
        // 移动速度
        speed: speed,
        // 大小和颜色
        color: color,
        bpm: bpm,
        speedscale: speedscale
    });
}

更新画面

这个我们一点一点来。

更新背景

背景、血条、鼓、鼓点移动的灰色区域都是静态图片,直接用 drawImage 来画即可;上面横向滚动的那个“祭”,我是用三个图像拼起来的(反正是横向平铺图,位置只跟当前时间有关);Kiai 的时候灰色的区域会变成橙色,我们用 kiaiTimeChangeDurationkiai 来控制它;按键时鼓面上的颜色高亮我们用 drumDispTimeLast 来控制;那个萌萌的太鼓是会动的,我们过会儿再画它。

// 上面的“祭”,三个图片的横向位置的计算
var x1 = 775 - this.time / 1000 * 32 % 775;
var x2 = x1 - 775;
var x3 = x1 + 775;
var kiaiTimeChangeDuration = 200;
this.drawImage("playField", 0, 32, 800, 600);
this.drawImage("taikoSlider", x1, 0);
this.drawImage("taikoSlider", x2, 0);
this.drawImage("taikoSlider", x3, 0);
this.drawImage("taikoBarRight", 0, 155, 800, 150);
// Kiai 的橙色区域
if (this.time - this.kiaiChangeTime <= kiaiTimeChangeDuration || this.kiai) {
    var opacity = 0.2;
    if (this.kiai == 0) {
        opacity = 1 - (this.time - this.kiaiChangeTime) / kiaiTimeChangeDuration;
    }
    else {
        opacity = (this.time - this.kiaiChangeTime) / kiaiTimeChangeDuration;
    }
    this.drawImage("taikoBarRightGlow", 0, 155, 800, 150, opacity);
    this.drawImage("taikoGlow", 120, 160, 140, 140, opacity);
}
this.drawImage("taikoBarLeft", 0, 155, 133, 157);
this.drawImage("taikoBarTarget", 150, 190, 80, 80);
this.drawImage("scorebarBg", 0, 0, 550, 53);
// 按键之后鼓面上的高亮
for (var i in this.drumDispTimeLast) {
    var pos = this.drumDispPos[i];
    if (this.drumDispTimeLast[i] > 0) {
        this.drawImage("drum" + i, pos[0], pos[1], pos[2], pos[3], this.drumDispTimeLast[i] / 100);
        this.drumDispTimeLast[i] -= 10;
    }
}

绘制物件

怎样把若干个圆圈都画在屏幕上呢?圆圈的横向位置很好算,只需要用 目标位置 + 速度 * (击打时间 - 当前时间) 即可。但是我们肯定不能每一次更新都把这几千个圆圈同时画到屏幕上(每秒 60 次更新啊亲),所以只能画目前屏幕能显示的那些圆圈。那么如何获取这些圆圈又是一个问题了,你可以说:“我每次枚举一遍这些圆圈,如果击打时间大于当前时间且小于某某某(说了一堆公式)时间则显示。”那其实跟全部显示没有太大的区别,除了内存可能占的少了一点以外。

我的做法是:维护一个存放当前屏幕上需要绘制的物件的队列,每次只绘制队列中的元素即可。那么如何维护呢?我们需要将那些圆圈按照进入屏幕的时间排序,然后设一个 currentHitObject 变量,一开始指向第一个物件,然后每次更新的时候:如果发现下一个物件可以在屏幕上显示了(目标位置 + 速度 * (击打时间 - 当前时间) ≤ 屏幕宽度),则将其加入队列;如果队首元素已经显示完了(当前时间 > 击打时间),则将其从队首删除。我们稍微修改一下圆圈的存储:

self.beatmap.hitObjects.push({
    type: 1,
    // 击打时间
    starttime: starttime,
    // 进入屏幕的时间,20 是一个小修正,因为圆圈不是一个点,而是有宽度的
    dispstarttime: starttime - 645.7 / speed - 20,
    // 离开屏幕的时间
    dispendtime: starttime,
    speed: speed,
    color: color,
    bpm: bpm,
    speedscale: speedscale
});

然后对其进行排序:

self.objdispstarttimesort = self.beatmap.hitObjects.sort(function (x, y) {
    return x.dispstarttime - y.dispstarttime;
});

还要注意的是,越靠前的圆圈越要显示在最上面,所以我们需要按照队列的倒序来绘制。下面的代码是我最初写的,没有经过优化,也比较难看懂。其实那个名叫 tmpobj 的数组根本不需要,也不用复制什么元素,只需要有两个指针就可以了。==如果大家有兴趣,可以试着按照刚才讲的思路优化一下下面这段代码。好吧其实就是因为我懒得改了。

// 队列为 this.dispobj
while (this.curHitObj < this.beatmap.hitObjects.length && this.objdispstarttimesort[this.curHitObj].dispstarttime <= this.time) {
    // 入队
    this.dispobj[this.curHitObj] = this.objdispstarttimesort[this.curHitObj];
    ++this.curHitObj;
}
var tmpobj = [];
for (var i in this.dispobj) {
    var obj = this.dispobj[i];
    if (obj.dispendtime < this.time) {
        // 出队
        delete this.dispobj[i];
        continue;
    }
    tmpobj.push(obj);
}
tmpobj.sort(function (x, y) {
    return x.hitstarttime - y.hitstarttime;
});

然而绘制的时候还要考虑圆圈上画的脸是否张嘴,于是我新写了一个函数专门用来计算当前的节拍,为了效率也使用了类似刚才队列的实现方式:

OSUWeb.prototype.updateTimingPoint = function () {
    if (this.curTimObj < this.beatmap.timingPoints.length) {
        // 当前是否应该显示 Kiai time
        if (this.kiai != this.beatmap.timingPoints[this.curTimObj].kiai) {
            this.kiaiChangeTime = this.time;
            this.kiai = this.beatmap.timingPoints[this.curTimObj].kiai;
        }
    }
    // 是否应该考虑下一个时间点了
    if (this.curTimObj + 1 < this.beatmap.timingPoints.length && this.beatmap.timingPoints[this.curTimObj + 1].offset <= this.time) {
        ++this.curTimObj;
        this.lastTimingPoint = this.beatmap.timingPoints[this.curTimObj];
        if (this.lastTimingPoint.bpm) {
            this.bpm = this.lastTimingPoint.bpm;
        }
    }
    // 计算当前的节拍,公式是:⌊(当前时间 - 上一个时间点) / BPM⌋
    var beatCount = parseInt((this.time - this.lastTimingPoint.offset) / this.bpm);
    // 例如 4/4 拍的时候,节拍数的取值是 0~3
    var t = beatCount % this.lastTimingPoint.tick;
    if (t != this.beatCount) {
        this.beatCount = t;
        // 顺便把那个萌萌的鼓的帧数也计算出来,那个鼓的动画一共有六帧
        if ((beatCount & 1) == 0) {
            this.pippidonidle = (this.pippidonidle + 1) % 6;
        }
    }
};

然后我们就可以放心地画圆了。

for (var i = tmpobj.length - 1; i >= 0; i--) {
    var obj = tmpobj[i];
    // 用 this.beatCount 的奇偶性来决定是否张嘴
    if (obj.type == 1) {
        // 小圆
        if (obj.color[0] == "s") {
            var x = 154.3 + obj.speed * (obj.hitstarttime - this.time);
            if (x < 154.3) x = 154.3;
            this.drawImage("taikohitcircle" + obj.color + (this.beatCount & 1), x, 192, 75, 75);
        }
        // 大圆
        else {
            var x = 140 + obj.speed * (obj.hitstarttime - this.time);
            if (x < 140) x = 140;
            this.drawImage("taikohitcircle" + obj.color + (this.beatCount & 1), x, 177, 105, 105);
        }
    }
}

哦,也别忘了把那个萌萌的鼓画出来:

this.drawImage("pippidonidle" + this.pippidonidle, -2, 20, 165, 160);

然后是一些细节处理。例如对于某些歌起始时间太早,我们需要在音乐播放之前延迟三毫秒。

// 在存储完圆圈后
if (self.beatmap.hitObjects[0].starttime < 3000) {
    self.delayStart = 3000 - self.beatmap.hitObjects[0].starttime;
}
else {
    self.audio.play();
}
// 在更新函数中
if (self.delayStart) {
    self.time = -(self.delayStartTimePoint + self.delayStart - new Date().valueOf());
    if (self.time >= 0) {
        self.delayStart = null;
        self.audio.play();
    }
}
else {
    self.time = parseInt(self.audio.currentTime * 1000);
}

以及计算 FPS 的函数,我们每一秒更新一次 FPS 读数,并且重新开始计算:

++self.fpsFrames;
// self.fpsLastTime 是上一次显示 FPS 的时间
self.fpsTimes += self.time - self.fpsLastTime;
self.fpsLastTime = self.time;
if (self.fpsTimes >= 1000) {
    self.fps = parseInt(self.fpsFrames * 1000 / self.fpsTimes);
    self.fpsFrames = 0;
    self.fpsTimes = 0;
}

命中判定和得分计算我没有做。前者只需要在你按键的时候计算一下最靠前的还没消失的元素的击打时间与当前时间的误差;后者则需要用一个变量保存当前连击数,在命中判定之后根据误差计算当前的命中得分(300、100、0),然后写一个计算连击加成比例的函数,那么这一击的得分就是 命中得分 * 连击加成比例(当前连击数)

其实界面上还有许多需要美化的地方,例如被击中之后圆圈应该飘到上面去啊,打得太差那个萌萌的鼓会泄气啊之类的。但是这次只是一个简单的尝试,所以这些效果就通通没有写。要想把它完善成一个真正能玩的游戏,估计要等我真正闲下来的时候吧。

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3049 天