AsciiDocでBouncing Ball

HTML5のCanvasを画面いっぱいに表示して,Webページにアニメーションを重ねる方法を説明します.実際のデモであるこのページは,AsciiDoc原稿にCanvasを埋め込んで作成したので,AsciiDocでの設定方法についても説明したいと思います.アニメーションのバウンシング・ボールは,中1の息子が夏休みの自由研究で作った Scratchプロジェクトへのオマージュです(笑).

ソースコードはこちらです: FullCanvas.js, bouncing-ball.js.ご自由にお使いください.

基本設定

まず,ページの好きな場所にCanvas要素とアニメーションのスクリプトを記述します(コード1).Canvasの表示位置はCSSでコントロールするので,どこに記述しても構いません.style 属性の中身はCSSファイルに書いても良いのですが.このページは Hugo で作られており,テーマのスタイルシートをいじりたくないのでCanvas要素に直接書いています.

HTMLファイルの記述
<canvas id="canvas", style="position:fixed; top:0; right:0; z-index:10; pointer-events: none"></canvas>
<script src="FullCanvas.js"></script>
<script src="bouncing-ball.js"></script>

Canvasをページ全体に表示させるために,position:fixed; top:0; right:0 でページ左上に原点を固定します.z-index:10 はページに表示される要素の重なり具合を設定します.z-index のデフォルト値は0で,値が大きい要素ほど上に表示されます.pointer-events:none は,マウス等のポインタ・デバイスのイベントを受け取らず,下の要素にパススルーする設定です.z-index で一番上に表示されているので,イベントを下の要素に渡さないとページ内のリンクをクリックすることができません.

AsciiDocの場合は,コード2 のように AsciiDocのパススルー・ブロック にHTMLタグを書くと,変換後の出力にそのままコードが残ります.

AsciiDocでの記述の仕方
++++
<canvas id="canvas", style="position:fixed; top:0; right:0; z-index:10; pointer-events: none"></canvas>
<script src="FullCanvas.js"></script>
<script src="bouncing-ball.js"></script>
++++

Canvasのサイズ設定やアニメーションの描画処理は FullCanvas.js ファイルにまとめています.バウンシング・ボールの定義は bouncing-ball.js ファイルに記述しています.

アニメーションを実行するには,FullCanvas() 関数に以下の引数を指定し呼び出してから,init() 関数を実行します(コード3).

  • 第1引数: Canvas要素のID.

  • 第2引数: アニメーションの実行前に一度だけ呼び出されるコールバック関数.

  • 第3引数: アニメーションの描画ループごとに呼び出されるコールバック関数.

  • 第4引数: FPS(フレーム数/秒).

FullCanvas()の使い方(bouncing-ball.js)
var mycanvas = FullCanvas("#canvas", mySketch.setup, mySketch.draw, fps);
mycanvas.init();

第2引数に渡すコールバック関数(mySketch.setup)関数は,描画Contextオブジェクトを引数に渡されてコールバックされます.bouncing-ball.js では Ball オブジェクトを生成しています.第3引数に渡すコールバックルーチンは,アニメーションの描画ループ毎に2つの引数を渡されてコールバックされます.1つ目の引数は前回の描画ループからの経過時間をフレーム数で表したもので,2つ目の引数は描画Contextオブジェクトです.第1引数で渡される経過時間は,FullCanvas() を最初に呼び出した時に設定したFPSを基準にしています.Canvas APIでは正確なFPSを指定できないので,経過時間に応じてユーザが描画位置をアップデートする必要があります.

バウンシング・ボールの描画ルーチン(bouncing-ball.js)
var mySketch = (function() {
  var ball;
  var prevTime;

  function setup(context) {                    (1)
    ball = new Ball(25, "#0000ff", context);
  }

  function draw(dt, context) {                 (2)
    if (ball.isStopped()) {
      ball = new Ball(25, "#0000ff", context); (3)
    }
    context.clearRect(0, 0, context.canvas.width, context.canvas.height); (4)
    ball.draw(dt);                             (5)
  }

  return {
    setup: setup,
    draw: draw
  };
})();
  1. FullCanvas の第2引数に渡すコールバック関数の定義.Contextオブジェクトを引数に取る.

  2. FullCanvas の第3引数に渡すコールバック関数の定義.第1引数に前回の描画ループからの経過フレーム数 dt と,Contextオブジェクトを引数に取る.

  3. ボールが停止したら新たなボールを生成して再びバウンシングさせる.

  4. Canvas全体をクリア.

  5. Ball オブジェクトの draw() メソッドを呼び出して,dt フレーム後の位置に描画する.

基本的な使い方は以上です.

FullCanvas関数の詳細

すでに説明した通り,アニメーションの実行とCanvasの設置は FullCanvas.js 内の FullCanvas() 関数が担当しています.ここで実装の詳細を解説します.

パブリックなAPIとプライベート関数を分けるために,FullCanvas はプロトタイプベースのオブジェクトではなく,モジュールパターンを使った関数として実装しています.FullCanvas() を呼ぶと以下のパブリック関数を格納するオブジェクトを受け取ります.

  • init() メソッドでCanvasの初期化設定.

  • start() メソッドが描画開始.`setup()`コールバックを実行し,`draw()`コールバックを呼び続ける.

  • stop() メソッドで描画終了.

  • pause() メソッドで描画一時停止.

  • resume() メソッドで描画再開.`setup()`は呼ばない.

Canvasのサイズの設定は _init() プライベート関数が行います.Canvasの初期化と描画のためのグラフィック・コンテキストを準備しています.

Canvasのサイズ設定とグラフィック・コンテキストの準備
  var _init = function() {
    canvas = $(id)[0];
    context = this.canvas.getContext("2d");
    canvas.width = $(window).width(); (1)
    canvas.height = $(window).height();
    prevTime = new Date().getTime();
  };
  1. ブラウザ間の違いに対応するためJQueryを使用.

FullCanvas モジュールはjQueryを利用するため,上の _init() 関数の呼び出しはjQueryのロードを待ってから呼び出しています.http://gohugo.io[Hugo] 等のCMSのテーマは,ページの最後にjQuery等のライブラリを読み込むため,HTMLページの真ん中に コード1 を記述した場合は,DOMContentLoaded イベントを待ち,全てのリソースが読み込まれたことを確認してから実行する必要があります.

jQueryのロードを待ってからFullCanvasを初期化
if (typeof jQuery == 'undefined') {
  document.addEventListener("DOMContentLoaded", function invokeLater() {
    _registerEventHandlers();   (1)
    start();                    (2)
  });
} else {
  _registerEventHandlers();
  start();
}
  1. ウィンドウ・サイズが変更された時のイベント・ハンドラを登録.

  2. 内部で _init() をコールし,Canvasのサイズを画面の大きさに設定.

_registerEventHandlers() プライベート関数は,画面サイズが変更されるたびに _init() 関数が呼び出されるようにイベント登録します.

_registerEventHandlers()関数の実装
var _registerEventHandlers = function() {
    // Window のリサイズ・イベントでcanvasのcontextを更新
    $(window).resize(_init);
    // ページが非表示・表示になった時の対応
    $(document).on('visibilitychange', function(e) {
      if (e.target.visibilityState === 'visible') {
        _init();
        resume();
      } else if (e.target.visibilityState === 'hidden') {
        stop();
      }
    });
  };

最後に,アニメーションの描画ループの実体,_run() プライベート関数を見てみましょう.前回の呼び出しタイム prevTime からの経過フレーム数と描画コンテキストを引数にして,ユーザの draw() コールバック関数を呼び出しています.

描画ループの実装
  var _run = function() {
    // 経過フレーム数を渡す
    draw((new Date().getTime() - prevTime) / (1000 / fps), context);
    prevTime = new Date().getTime();
    if (isRunning)
      requestAnimationFrame(_run);
  };

カスタマイズ

もし,Canvas全体を透明に設定したい場合は,以下のようにIDを指定してスタイルシートに記述します.

#canvas{
    opacity:0.5;
}

個別の描画要素を透明にしたい場合は,fillStyle で設定します.

context.fillStyle = "rgba(0, 0, 200, 0.5)";

おまけ Bouncing Ballの実装

Bouncing Ballの実装は誰も興味がないと思いますが,息子がJavaScriptに目覚めた時のために解説を残しておきます(笑).

コード5Ball オブジェクトのコンストラクタです.まずグローバル変数で重力や摩擦力,FPSを設定しています.位置の更新は FullCanvas に渡される経過時間(フレーム数)を基準に行われるので,重力が3ということは,60fpsの1フレームで3ピクセルのy方向加速度を仮定しているということです.摩擦はボールが地面を転がり始めた時,0.03ピクセル減速するように設定しています.

Bouncing Ballのコンストラクタ
var gravity = 3;
var friction = 0.03;
var fps = 60;

function Ball(radius, color, context) {
  this.context = context;
  this.radius = radius;
  this.color = color;
  this.x = Math.random() * (context.canvas.width - radius) + radius;   (1)
  this.y = radius;
  this.dir = [1, -1];
  this.vx = 8 * this.dir[Math.floor(Math.random() * this.dir.length)]; (2)
  this.vy = 0;
  this.ax = 0;
  this.ay = gravity;
}
  1. 初期時点のx座標をランダムに決定.

  2. 初期時点のx方向をランダムに決定.高さはCanvasの上端で常に同じ.

描画ルーチンは以下の通りです.アニメーションの更新タイミングの間に衝突するかどうかだけ気をつけている点以外は,普通の物理プログラムです.

Bouncing Ballの描画メソッド定義
Ball.prototype.draw = function(dt) {
  var vx, vy, x, y, t;

  vx = this.vx + (this.ax * dt);
  vy = this.vy + (this.ay * dt);
  x = this.x + (vx * dt);
  y = this.y + (vy * dt);

  if (y > (this.context.canvas.height - this.radius)) {
    t = (this.context.canvas.height - this.radius - this.y) / vy;
    this.vy += (this.ay * t);
    this.vy *= -1;
    this.y = (this.context.canvas.height - this.radius) + this.vy * (dt - t);
  } else if (y < this.radius) {
    t = (this.y - this.radius) / vy;
    this.vy += (this.ay * t);
    this.vy *= -1;
    this.y = this.radius + this.vy * (dt - t);
  } else {
    this.y = y;
    this.vy = vy;
  }

  // 地上を転がる間は摩擦でx方向減速
  if (this.y === (this.context.canvas.height - this.radius) && this.vy === 0) {
    this.ax = vx > 0 ? -friction : friction;
    if (vx < friction && vx > -friction) {
      this.ax = 0;
      vx = 0;
    }
  } else if (this.y > (this.context.canvas.height - this.radius - friction) &&
    this.vy < friction && this.vy > -friction) { // y方向速度が誤差以内なら0に設定
    this.vy = 0;
    this.y = (this.context.canvas.height - this.radius);
  }

  if (x < this.radius) {
    t = (this.x - this.radius) / vx; // 衝突までの時間
    this.vx += (this.ax * t); // 衝突時の速度
    this.vx *= -1;
    this.x = (this.radius + this.vx * (dt - t)); // はね返り後の位置
  } else if (x > (this.context.canvas.width - this.radius)) {
    t = (this.context.canvas.width - this.radius - this.x) / vx;
    this.vx += (this.ax * t);
    this.vx *= -1;
    this.x = (this.context.canvas.width - this.radius) + this.vx * (dt - t);
  } else {
    this.x = x;
    this.vx = vx;
  }

  this.context.fillStyle = this.color;
  this.context.beginPath();
  this.context.arc(this.x, this.y, this.radius, 0, 2 * Math.PI, true);
  this.context.closePath();
  this.context.fill();
}

wshito

Read more posts by this author.

Itoshima, Japan http://diary.wshito.com