ニコニコ動画でよく見える弾幕機能はどうやって実装するかが気になっていました。

ライブラリを使ったら、すぐにできると思いますが、サイトも重くなるリスクもあります。

今回はブログでライブラリとフレームワークを使わず、弾幕を実装できるテクニックを紹介します。

 

 

フレームアニメーション

フレームにより、アニメーションを作る論理はゲーム開発でよく使っています。

基本のフレームアニメーションの手順は下記です。

  1. htmlのcavansのキャッシュをクリアする
  2. フレームの状態により、フレームを描画する
  3. 次のフレームの状態更新する
  4. 1~3を振り返りする

web開発で固定時間で振り返りする方法は二つがあります。今回にrequestAnimationFrameを使います。

 

実装必要なクラス

ロジックを理解しやすいように、今回実装するクラスの役割を紹介します。

  • LineManager:  画面の弾幕行数を設定できる
  • Line: 弾幕発射頻度、位置管理できる
  • TextEnemy:弾幕の位置、スピード、サイズ、削除を設定できる

 

Step1: htmlを設定する

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <title>Document</title>
</head>
<body>
    <canvas id="canvans_text_animation"></canvas>
    <script src="script.js"></script>
</body>
</html>

 

Step2: CSSを設定する

#canvans_text_animation {

      position:absolute;

      top: 50%;

      left: 50%;

      transform: translate(-50%,-50%);

      background-color: black;

      max-height: 100%;

      max-width: 100%;

}

 

Step3: Javascriptを設定する


window.addEventListener('load', function(){
    const canvas = document.getElementById('canvans_text_animation');
    canvas.height= 820
    canvas.width= 1800
    const ctx = canvas.getContext('2d');
    const commands = [
        '頑張れ!?',
        'いつもお世話になってありがとうございます。',
        '意外な結果が............',
        'こんにちは〜今日は天気がいいですね!',
        'おめでとうございます。',
        '長い文が失礼しました。長い文が失礼しました。長い文が失礼しました。長い文が失礼しました。長い文が失礼しました。長い文が失礼しました。',
        '?????????✊✋✊✋??✊✋???✊✋?????????????????✊✋',
        '最大何人使いますか?',
        'Liveアンケート!Liveアンケート!Liveアンケート!Liveアンケート!Liveアンケート!',
        '最高!',
    ]
    //LineManager実装
    class LineManager {
        constructor(maxLines){
            this.lines = [];
            this.texts = [];
            for(let i = 0; i<maxLines;i++){
                this.lines.push(new Line(i))
            }
        }
        push(text){
            if(text){
                this.texts.push(text);
            }
        }
        update(){
            if(this.texts.length > 0){
                for(let i = 0; i<this.texts.length; i++){
                    // 使えるLineオブジェクトを取得する
                    const line = this.getPushEnableLine()
                    // 使えるLineオブジェクトにテキストをプッシュする
                    if(line){
                        line.push(this.texts[i]);
                        this.texts.splice(i,1);
                        i--;
                    }
                }
            }
            for(const line of this.lines){
                line.update();
            }
        }
        draw(context){
            for(const line of this.lines){
                line.draw(context);
            }
        }
        getPushEnableLine(){
            for(const line of this.lines){
                if(line.getPushEnable()){
                    return line;
                }
            }
            return undefined;
        }
    }

    //Lineクラス実装
    class Line {
        constructor(index){
            //Lineの行番
            this.index = index;
            //Lineの高さ
            this.lineHeight = 40;
            this.textEnemys = [];
        }
        push(text){
            const textEnemy = new TextEnemy(text,(this.index + 1)*(this.lineHeight + 30), canvas.width,canvas.height)
            this.textEnemys.push(textEnemy);
        }
        update(){
            this.textEnemys = this.textEnemys.filter(e=>!e.markForDeletion);
            for(const textEnemy of this.textEnemys){
                textEnemy.update();
            }
        }
        draw(context){
            for(const textEnemy of this.textEnemys){
                textEnemy.draw(context);
            }
        }
        getPushEnable(){
            if(this.textEnemys.length>0){
                return this.textEnemys[this.textEnemys.length-1].hasFinishedShowed();
            }
            return true;
        }
    }


    //弾幕クライス実装する
    class TextEnemy {
        constructor(text, y, screenWidth, screenHeight){
            //弾幕開始の位置設定する
            this.x= screenWidth;
            this.y=y;

            //長さと高さ、記録するように変数を宣言する
            this.height=0;
            this.width=0;

            //screenのサイズ記録する
            this.screenWidth = screenWidth;
            this.screenHeight = screenHeight;

            //この弾幕スピードを設定する
            this.speed = -2;

            //文字サイズ
            this.font = "28px serif";

            //弾幕内容
            this.text = text;

            //削除用フラグ
            this.markForDeletion = false;

            //次の弾幕とスペース
            this.nextSpace = 50;

            //弾幕色
            this.color = 'white';
        };
        hasFinishedShowed(){
            return this.x < this.screenWidth - this.width - this.nextSpace;
        }
        update(){
            this.x += this.speed
            if(this.x < -this.width){
                this.markForDeletion = true;
            }
        }
        draw(context){
            context.save();
            context.font = this.font;
            context.fillStyle = this.color;
            context.fillText(this.text,this.x,this.y);
            if(this.text&&this.width===0){
                let metrics = context.measureText(this.text);
                this.width = metrics.width;
                // let fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
                // let actualHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
                // console.log(`『${this.text}』のwidthは`,metrics.width)
                // console.log(`『${this.text}』のheightは`,fontHeight)
                // console.log(`『${this.text}』のactualHeightは`,actualHeight)
            }
            context.restore();
        }

    }

    //LineManagerのオブジェクトを新規する 最大行数を3を設定する
    const lineManager = new LineManager(3);
    let lastTime = 0;
    //弾幕発射スード
    let createTextInternal = 500
    function animate(timeStamp){
        if(timeStamp-lastTime > createTextInternal){
            lineManager.push(commands[Math.floor(Math.random() * 11)]);
            lastTime = timeStamp
        }
        ctx.clearRect(0,0,canvas.width,canvas.height);
        lineManager.update();
        lineManager.draw(ctx);
        requestAnimationFrame(animate);
    }
    animate(0);
});

 

もっと面白くする

  • 弾幕の色をランダムにする
  • 最大行列を8列にする
  • 発射スードを10倍にする
    ....
    class TextEnemy {
        constructor(text, y, screenWidth, screenHeight){
           ....
            //弾幕色ランダムにする
            this.color = 'rgb('+Math.random()*255+','+Math.random()*255+','+Math.random()*255+')';
        };
        ....
   }
   ....
    //LineManagerのオブジェクトを新規する 最大行数を8を設定する
    const lineManager = new LineManager(8);
    let lastTime = 0;
    //弾幕発射スードを10倍にする
    let createTextInternal = 50
   ....

いい感じになりますね!笑

 

皆さんが普段に開発する時に参考になれば、幸いです。

最後まで読んでいただきありがとうございます!

 

参考資料

cavans API

No Library No Frameworkゲーム開発