TECH BOX

Technology blog from Web Engineer

この記事は最終更新日から4年以上経過しているため正確ではないかもしれません

Konva.jsを使って雫アニメーションを簡単に作る

先日、自身のサイト(arc-one.jp)をリニューアルしました。
せっかくなので遊び心を取り入れて、キービジュアル部分をクリックすると「花火」か「雫」が出現するようになっています。

両方とも本来はモーフィングにしたかったんですが、モーフィングにする方法が結構複雑だったので、擬似的にモーフィングしているように見せてます。

今回はそのうちの一つ「雫」の作り方を公開します。

まずは完成形。

See the Pen konva – drop by Nobuyuki Kondo (@artprojectteam) on CodePen.0

これを元に一つずつ解説します。

使用ライブラリ

エリアはcanvasを利用していて、2DライブラリであるKonva.jsを使用。
シンプルな記法なので学習コストは低めです。

今回の記事はこのライブラリがあれば事足りますが、クロスデバイス対応をするために下記も一緒にインストールすることをおすすめします。

  • RxJS – クロスデバイスイベント制御用 (lodash等で代用も可)

ステージの作り方

GitHubのREADMEを見ればステージの書き方は記載されているので参考にしてほしいですが念の為。

const stage = new Konva.Stage({
  container: 'xxx' // <- 表示したいHTMLのID,
  width: 500,
  height: 500
})
stage.draw()

KonvaはOverviewを見るとわかりますが、LayerとGroupという概念が存在します。
KonvaではPixiのようにLayerの中にLayerは作れず、Layer内はGroupか各シェイプで内包するようにします。
そのため、Layerはある一定数超えるとコンソールに警告が出ますので注意が必要です。

雫の作り方

雫はSVGで作ってもいいですが、せっかくなのでCircleStarで作ります。
組み合わせは下記を参考にしてください。
三角の下に円を置くことで雫のように見えます。

See the Pen konva – drop – type by Nobuyuki Kondo (@artprojectteam) on CodePen.0

Starで三角をつくる

KonvaにはTriangleというものはないのでStarを使います。

const size = 40
const triangle = new Konva.Star({
  x: size,
  y: 0,
  numPoints: 3,
  innerRadius: size / 2,
  outerRadius: size,
  fill: '#fff',
  scale: {
    x: 1,
    y: 1
  },
  opacity: 1
})

numPointsは頂点の数になります。
innerRadiusを指定サイズの半分にし、outerRadiusを指定サイズにすると上向きの正三角形が作れます。
ちなみにinnerRadiusouterRadiusの値を逆にすると下向きの正三角形が作れます。

この後に説明するCircleも同様ですが、オブジェクトのアンカー位置は中心になるので、xを指定サイズ分ずらしてあげるとクリック判定時のx,y座標を利用した際にオブジェクトをカーソルの中心に表示する再計算が不要になります。

Circleで雫の下部分を作る

const size = 40
const circle = new Konva.Circle({
  x: size,
  y: size,
  radius: size,
  fill: '#fff',
  opacity: 1
})

円形に関しては特に説明することはありませんが、先程の三角でも説明したとおりオブジェクトのアンカー位置は中心になるので、yを指定サイズ分下げてあげるときれいに円の上部に三角が収まります。

グルーピングする

本体ができたらグルーピングします。
最終的にはレイヤーに貼り付けると雫が表示されます。

// xとyは一旦適当
const group = new Konva.Group({ x: 10, y: 10})
group.add(circle)
group.add(triangle)

// 一旦表示する場合はレイヤーにもぶっこむ
const layer = new Konva.Layer()
layer.add(group)

stage.add(layer)
stage.draw()

モーフィングっぽくアニメーションさせる

まずは三角がうにょっと出るような感じにします。
scaleYを0から1にしてあげればいいはず…ということで失敗パターンと成功パターンを紹介。

scaleYのみとscaleYとy座標を変化させた時の動きが下記。

See the Pen konva – drop – triangle-y by Nobuyuki Kondo (@artprojectteam) on CodePen.0

【失敗:左】scaleYのみを変化させる

scaleYは中心から変化するため、モーフィングっぽくなるどころか雫の体をなしません。

初期設定

const size = 40
const triangle = new Konva.Star({
  x: size,
  y: 0,
  numPoints: 3,
  innerRadius: size / 2,
  outerRadius: size,
  fill: '#fff',
  scale: {
    x: 1,
    y: 0 // <- このscaleYを変更
  },
  opacity: 1
})

Tween

const anim = new Konva.Tween({
  node: triangle,
  scaleY: 1,
  duration: 0.5,
  easing: Konva.Easings.Linear
})
anim.play()

【成功:右】三角の初期Y位置もずらしてscaleYも変化させる

三角の初期Y位置を指定サイズの半分ほど下にずらして上げつつ、scaleYも変化させるとモーフィングっぽくみえる。

初期設定

const size = 40
const triangle = new Konva.Star({
  x: size,
  y: size / 2, // <- このyを指定サイズの半分にする
  numPoints: 3,
  innerRadius: size / 2,
  outerRadius: size,
  fill: '#fff',
  scale: {
    x: 1,
    y: 0 // <- このscaleYを変更
  },
  opacity: 1
})

Tween

const anim = new Konva.Tween({
  node: triangle,
  y: 0,
  scaleY: 1,
  duration: 0.5,
  easing: Konva.Easings.Linear
})
anim.play()

雫に変化させつつ全体を下へ落とす

雫のモーフィングっぽいのが出来たので画面の下まで動かします。
ここで雫をグルーピングした意味が出てきます。

See the Pen konva – drop – fall by Nobuyuki Kondo (@artprojectteam) on CodePen.0

Tween

const anim = new Konva.Tween({
  node: triangle,
  y: 0,
  scaleY: 1,
  duration: 0.3,
  easing: Konva.Easings.Linear
})
anim.play()

const anim2 = new Konva.Tween({
  node: group1,
  y: 500 + size + (size / 2 + 10),
  duration: 1.3,
  easing: Konva.Easings.EaseIn
})
anim2.play()

anim2がグループになっており落下するアニメーションになります。
y値はcanvasの高さ + 三角形の高さ + (三角形の高さ / 2 +α)で画面からちょうど消えます。
プラスアルファは余裕値なので10程度でいいと思います。

落下秒数(duration)には基本的に三角が出現する秒数分をプラスするといい感じに見えます。
ここで言えば三角部分が0.3sでグループは1s + 0.3sになります。

グループ側はEaseInにすることで最後のほうが早くなります。

クリックしたところから雫を表示する

ここまでで雫にしつつ落下するところまで来ました。
このアニメーションをクリックしたところからスタートさせます。

Konvaの場合はクリック位置はstage.getPointerPosition()で取得できるので簡単です。

クロスデバイス対応

せっかくなので、マウス・ポインター・タッチデバイスでうまく動作するようにします。
イベント名はそれぞれmouseup touchend pointerup ですが、このままイベントを発生させるとmouseuptouchendと2回実行されたりします。
そこで、最近流行りのRxJSを使います。

import { fromEvent } from 'rxjs'
import { throttleTime } from 'rxjs/operators'

fromEvent(stage, 'mouseup touchend pointerup')
  .pipe(throttleTime(50))
  .subscribe((): void => {
    // 実行処理を書く
  })

これで、'mouseup touchend pointerup'のどれかが実行した時に50ms以内に発生した次のイベントは破棄されます(lodashにも同様のがあります)

クリック座標を取得して雫を生成し落下させる

あとは座標を取得して表示→落下させます。

import { fromEvent } from 'rxjs'
import { throttleTime } from 'rxjs/operators'

const stage = (中略)
const layer = new Konva.Layer()
stage.add(layer)
stage.draw()

fromEvent(stage, 'mouseup touchend pointerup')
  .pipe(throttleTime(50))
  .subscribe((): void => {
    const { x, y } = this._stage.getPointerPosition()

    const group = new Konva.Group({
      x: x,
      y: y,
      offset: {
        x: size, // 三角形や円の大きさ
        y: 0
      }
    })

    // (中略) 三角形と円を作ってgroupにaddする

    layer.add(group)

    // (略) 雫と落下アニメーションをさせる
  })

略しているところは今までに書いた内容が入ります。
ただし、このままだと作った雫データは使わなくなっても残ったままになってしますのでアニメーションが終わったらグループを破棄してメモリから開放できるようにします。

グループを破棄して軽くする

const anim2 = new Konva.Tween({
  node: group,
  y: 500 + size + (size / 2 + 10),
  duration: 1.3,
  easing: Konva.Easings.EaseIn,
  onFinish: ()=>{
    group.destroy()
  }
})
anim2.play()

上記は上で説明したグループ全体が落下するアニメーションです。
アニメーション終了時にonFinishが実行されるのでそこでgroup.destroy()とするとlayerから該当オブジェクトが削除されます。

座標値によって速度を調整

何も考えずに一定の速度にしてしまうと、上の方でのクリックでも下の方でのクリックでも同じ速度になってしまいます。
つまり、下の方だとものすごくゆっくりになってしまいます。

それを解消するために計算をします。

// ステージの高さ(h)を元に、Y座標の位置を0〜1間で表す。
// yが0に近いほど差分は1に近くなるように計算
const diff = (100 - Math.round((y / h) * 100)) / 100

// 係数はドロップの上部が表示される速度を足して最低限の速度を確保する
const factor = diff + 0.3

// 1.3sを超える場合は1.3固定にする
let duration = 1.3 * factor
if (duration > 1.3) duration = 1.3

最初の計算式は高さが500pxだと仮定した場合。
y = 0:diff = 1
y = 50:diff = 0.9
y = 220:diff = 0.56

ここで算出した秒数をグループアニメーションのdurationにセットしてあげるといい感じになります。

雫をクラス化する

せっかくなので雫部分の一連の処理をクラス化します。

// Drop.ts
import Konva from 'konva'

export default class Drop {
  private readonly _group: Konva.Group
  private readonly _triangle: Konva.Star

  // ステージの高さ
  private readonly _stageH: number

  // クリック座標
  private readonly _y: number

  // 三角や円の大きさ
  private readonly _size = 30

  public constructor(x: number, y: number, stageH: number) {
    this._stageH = stageH
    this._y = y

    this._group = new Konva.Group({
      x: x,
      y: y,
      offset: {
        x: this._size,
        y: 0
      }
    })

    const circle = new Konva.Circle({
      x: this._size,
      y: this._size,
      radius: this._size,
      fill: '#fff'
    })
    this._group.add(circle)

    this._triangle = new Konva.Star({
      x: this._size,
      y: this._size / 2,
      numPoints: 3,
      innerRadius: this._size / 2,
      outerRadius: size,
      fill: '#fff',
      scale: {
        x: 1,
        y: 0
      }
    })
    this._group.add(this._triangle)
  }

  // 雫データ取得
  pubic group(): Konva.Group {
    return this._group
  }

  // アニメーション
  public animation(cb: () => void): void {
    // 落下速度をY座標の位置によって調整
    const diff = (100 - Math.round((this._y / this._stageH) * 100)) / 100
    const factor = diff + 0.3
    let duration = 1.3 * factor
    if (duration > 1.3) duration = 1.3

    // 三角部分を表示
    const showDrop = new Konva.Tween({
      node: this._triangle,
      scaleY: 1,
      y: 0,
      duration: 0.3,
      easing: Konva.Easings.Linear
    })
    showDrop.play()

    // 落下アニメーション
    const fall = new Konva.Tween({
      node: this._group,
      y: this._stageH + size + (size / 2 + 10),
      duration: duration,
      easing: Konva.Easings.EaseIn,
      onFinish: (): void => {
        cb()
      }
    })
    fall.play()
  }
}

雫クラスを呼び出す

作ったクラスを呼び出します。

// index.ts
import Konva from 'konva'
import { fromEvent } from 'rxjs'
import { throttleTime } from 'rxjs/operators'
import Drop from './Drop'

const stage = new Konva.Stage({
  container: 'canvas',
  width: 500,
  height: 500
})

const layer = new Konva.Layer()
stage.add(layer)
stage.draw()

// イベント
fromEvent(stage, 'mouseup touchend pointerend')
  .pipe(throttleTime(50))
  .subscribe((): void => {
    const { x, y } = this._stage.getPointerPosition()

    const drop = new Drop(x, y, 500)
    const group = drop.group()

    layer.add(group)
    layer.draw()

    drop.animation((): void => {
      // アニメーション終了したらレイヤーからグループを破棄する
      group.destroy()
    })
  })

説明が長々とはなりましたが、コード自体は実はそんなに多くないのがわかったと思います。

次回はarc-one.jpに実装しているもう一つの「花火」について説明する予定です。