🍧

Canvasグラフィックアニメーションの学び方&楽しみ方

2024/06/18に公開

はじめに

最近表現の幅を広げたいと思いまして、CSSや2Dや3Dアニメーションを誠意勉強しはじめました。そして2Dアニメーションの看板であるCanvasを今更ながら学びまして、そして見事Canvasの表現の世界にどハマりしたという話でございます。

本記事では、Canvasをどう学んだか、どのような表現ができるのか、学んでみてわかった面白さなどをお伝えしてきます。

また、Canvasアニメーションを利用したちょっとしたポケモン題材のアプリを作ってみたので、そのアプリを中心にどんな表現ができるのかもお伝えできたらなと思います。

Canvasをこれから学んでみようか迷っている方。
もうめちゃくちゃに面白いので、ぜひこの記事を読んでCanvas使いがちょっと増えてくれたら嬉しいです。

Canvasはもう知ってるよという方。
ぜひこの記事を読んでCanvas面白いよねと共感してくださったら嬉しいです。
ついでに間違っているところなどあれば、ぜひコメントなどで教えてくださると嬉しいです。

これまでの勉強方法と学びポイント

『グラフィックスプログラミング入門』の写経

Canvasをはじめるにあたって、まずわたしがはじめたのが『[ゲーム&モダン JavaScript文法で2倍楽しい]グラフィックスプログラミング入門』の写経です。この本には大変お世話になったと思っており、Canvasの細かい話は一切なしに、写経を通して、それっぽいシューティングゲームがどんどん作られていく楽しさを学ばせてもらいました。

https://gihyo.jp/book/2020/978-4-297-11085-7

JavaScriptの入門的な知恵が必要になります。コードは非常に平易なので、JavaScriptの基礎があれば、Canvasの表現がどのように行われているかにフォーカスして学ぶことができます。

この本を通して、Canvasの2Dアニメーションには何が必要なのか、またアニメーションはどのように作られているかの感覚的なものをつかむことができます。

数学を叩き込む

わたしは根っからのド文系なので、ここが2Dアニメーションの最初にして最大の難関でした…。

2Dアニメーションをはじめてすぐに、数学の、特に三角比やベクトルの知識が必要なことに気づきました。

ただ、計算自体はコンピューター(=JavaScript)にやってもらうことになるため、どういう計算をしたらどういう数字が出来上がるのかの理解を深めることが非常に重要でした。

何かをCanvas上で動かすとなると、与えられたCanvasにおいてx,y座標として位置情報を持つことになるため、サイン波のようなアニメーションには三角比を、対象との距離を求めるにはベクトルを…のような感じで使っていくことになります。

わたしの場合は基礎がほぼ0だったので、まずYoutube等(平易じゃないとわからない。泣)を利用して基礎を叩き込み、それから徐々にどういうアニメーションにはどういう計算が必要なのかを、実際に動かして感覚で覚えるようになりました。ちなみにこの試みはまだ道半ばで、今もChatGPT先生に頼りつつ数学の復習をしながらアニメーションを作っています。

Youtubeで数学をやるって、現代っ子だなと思いました。
ただ、今時のYoutubeはかなりわかりやすくて大助かりでした。現代っ子の気持ちもわかる気がします。

アプリを作ってひたすら遊ぶ

本を利用して基礎を叩き込み、補足的に基礎的な数学を学んだら、あとはCanvasという道具の使い方がわかるまで遊ぶだけ。ということで、ここからはポケモンアプリを作って遊びました。

ベースは『グラフィックスプログラミング入門』を利用しつつ、独自で設定を作っていった次第です。『グラフィックスプログラミング入門』にはなかった応用的な機能を、自分自身で調べながら作り込み表現する力を身につけます。

そんなこんなで出来上がったミニポケモンアプリはこんな感じ。

大枠

※gifで動画登録した影響なのか、なんだかとってもノロノロしています。あまり気にしないでください。
※年代がバレそうで恐縮ですが、ルビー・サファイアまでしか知らないので、そこまでのポケモンがランダムに出るようにしています。

Canvasでできるアニメーション表現Tips

ここでは、ポケモンを作りながら学んだCanvasの表現について、(せっかくなので☺️)実際に該当するミニポケモンアプリの紹介をしつつTipsをお伝えします。

サンプルリポジトリは以下となっています。
今回は概要コードだけ載せますが、全部見たいよって方はそちらもご覧ください。

https://github.com/moepyxxx/canvas_pokemon/

アニメーションはrequestAnimationFrameを利用

Canvasにおいて何かを動かしたいという時に、まず利用することになるのがrequestAnimationFrameです。この関数についての詳細な説明は割愛しますが、めちゃくちゃ簡単にいうとこの関数の引数にコールバックを渡すと、JavaScriptが最適なタイミングでコールバックを発火してくれるよ、というものです。

https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame

ミニポケモンアプリでは、requestAnimationFrameを繰り返し処理として以下のように利用します(いわゆる無限ループさせるイメージ)。前述のようにJavaScriptが最適なタイミングでコールバックを発火してくれるため、滑らかにアニメーションしてくれるというわけです。

index.ts
function render() {
    util.drawRect(0, 0, util.canvas.width, util.canvas.height, "#9CDF7D");

    // 主人公を動かす
    hero.update()

    // ポケモンたちを動かす
    pokemons.forEach((pokemon) => {
      pokemon.update(args);
    });

    // 他にも色々と毎フレーム実行する処理を追加

    requestAnimationFrame(render);
}

render関数が1回呼ばれると1フレーム目、再起的に呼び出されて2回目を2フレーム目…とするとき、このフレームという概念を非常によく使います。アニメーションは、このフレームを重ねるごとにちょっとずつ主人公の位置を変えることによって歩く状態を表現し、ちょっとずつモンスターボールの位置を変えることによってモンスターボールが投げられる状態を表現する、というわけです。

フレームとアニメーションの感覚は、パラパラ漫画のように考えるとわかりやすいです。
この感覚を利用すれば、歩くの時は座標をフレームごとに少しずつ移動、走るのときは座標を大胆に移動、というふうに制御できてきます。

わたしはCanvasの2Dアニメーションという表現において、この繰り返しとフレームを監視するという法則を知ってからは、あらゆるアニメーション表現をどう実現するかを非常に考えやすくなりました。

キーボード操作と連動した処理

今回のポケモンアプリのように、ゲームを作るというのであれば、ユーザーのキーボード入力に応じてキャラクターを動かす等の表現は必須で必要になってくるはずです。

基本的にキーボード操作は、JavaScriptのイベント登録で行います。以下のようなファイルを1つ用意しておき、アプリにおいて必要な操作がされた場合に合わせてインタラクティブな動作を入れる形になります。

keyboardInput.ts
export type Keys = {
  ArrowLeft: boolean;
  ArrowRight: boolean;
  ArrowUp: boolean;
  ArrowDown: boolean;
  a: boolean;
};

export class KeyboardInput {
  private _downKeys: Keys;
  private _upKeys: Keys;

  constructor() {
    this._downKeys = {
      ArrowLeft: false,
      ArrowRight: false,
      ArrowUp: false,
      ArrowDown: false,
      a: false,
    };
    this._upKeys = {
      ArrowLeft: false,
      ArrowRight: false,
      ArrowUp: false,
      ArrowDown: false,
      a: false,
    };
  }

  get downKeys() {
    return this._downKeys;
  }

  get upKeys() {
    return this._upKeys;
  }

  initialize() {
    const validKeys = Object.keys(this._downKeys);
    window.addEventListener(
      "keydown",
      (e) => {
        if (validKeys.includes(e.key as keyof typeof this._downKeys)) {
          this._downKeys[e.key as keyof typeof this._downKeys] = true;
          this._upKeys[e.key as keyof typeof this._upKeys] = false;
        }
      },
      false
    );
    window.addEventListener(
      "keyup",
      (e) => {
        if (validKeys.includes(e.key as keyof typeof this._upKeys)) {
          this._upKeys[e.key as keyof typeof this._upKeys] = true;
          this._downKeys[e.key as keyof typeof this._downKeys] = false;
          requestAnimationFrame(() => {
            this._upKeys[e.key as keyof typeof this._upKeys] = false;
          });
        }
      },
      false
    );
  }
}

たとえば前述した主人公が歩く動作についてですが、render関数におけるhero.update()の中では、主人公の歩くアニメーションが出てきます。この場合、KeyboardInputのインスタンスから現在のキーボード入力状況を受け取って、下記のように表現できます。

主人公が歩く動作

hero.ts
export class Hero extends Character {
  // 省略
  walk(keys: Keys) {
    // 現在の主人公の位置を取得
    let x = this.position.target.x;
    let y = this.position.target.y;

    // キーボードの状況に応じて主人公の位置を更新
    // speedをもっと大きくしたら、「走る」にできるかも💡
    const speed = 3;
    if (keys.ArrowUp) {
      this.heroDirection = "above";
      y -= speed;
    }
    if (keys.ArrowDown) {
      this.heroDirection = "below";
      y += speed;
    }
    if (keys.ArrowLeft) {
      this.heroDirection = "left";
      x -= speed;
    }
    if (keys.ArrowRight) {
      this.heroDirection = "right";
      x += speed;
    }

    // 位置を更新
    const { x: finalX, y: finalY } = this.validPosition(x, y);
    this.position.set({ x: finalX, y: finalY });
  }
}

余談ですが、validPositionは主人公の位置がCanvas上やCanvas全体を囲う木の向こうにアウトしないように、また、白いセリフの下に潜らないように調整したx,y座標を取得しています(ポケモンでも、→をずっと押し続けた場合、1番右まで行ったら主人公は止まりますよね)。

モーションアニメーション

ポケモンが逃げるアニメーションやポケモンがボールの中に入るアニメーションは、細かいですがこだわることによってちょっとそれらしさが出ますよね(笑)。

これらのアニメーションには、requestAnimationFrameについて記載したときと同じく、フレームという概念が役に立ちます。また、ちょっとした数学が入るのもポイントです。

今回の例は、逃げるアニメーションです。

ポケモンが逃げる動作

pokemon.ts
export class Pokemon extends Character {

  // 必要なプロパティ  
  private runningDirection: "up" | "down" | "left" | "right" | null = null;
  isRunning: boolean = false;
  isRan: boolean = false;
  private runningFrame: number = 0;

  // 逃げるアニメーションが発火したら…
  run() {
    this.isRunning = true;

    // ランダムに逃げる方向を決める
    if (this.runningDirection === null) {
      // 省略
    }

    // すでに終点まで走っていたら終了
    if (
      this.position.target.x < 0 ||
      this.position.target.x > this.canvasUtil.canvas.width ||
      this.position.target.y < 0 ||
      this.position.target.y > this.canvasUtil.canvas.height
    ) {
      // 省略
    }

    // 驚きのアニメーション
    if (this.runningFrame > 20 && this.runningFrame < 60) {
      // 20↗︎40↘︎60となるイメージでこのフレーム内でポケモンが驚く動作を実現
      // 20↗︎40↘︎60のようなy座標の変化を求めるにはMath.sinを利用
      const radians = (Math.PI * this.runningFrame) / 40;
      const y = 1 * Math.sin(radians);

      this.position.set({
        x: this.position.target.x,
        y: this.position.target.y - y,
      });
    }

    // 焦りのアニメーション
    if (this.runningFrame % 80 < 40) {
      // 40フレームごとに焦りマークがチカチカ表示されるような状態を作る
      this.canvasUtil.drawRect(
        this.position.target.x,
        this.position.target.y - this.height / 2 - 8,
        2,
        10,
        "gray"
      );
      this.canvasUtil.context.save();
      this.canvasUtil.context.translate(
        this.position.target.x,
        this.position.target.y - this.height / 2 - 8
      );
      this.canvasUtil.context.rotate((30 * Math.PI) / 180);
      this.canvasUtil.drawRect(10, 0, 2, 6, "gray");
      this.canvasUtil.context.restore();

      this.canvasUtil.context.save();
      this.canvasUtil.context.translate(
        this.position.target.x,
        this.position.target.y - this.height / 2 - 8
      );
      this.canvasUtil.context.rotate((-30 * Math.PI) / 180);
      this.canvasUtil.drawRect(-10, 0, 2, 6, "gray");
      this.canvasUtil.context.restore();
    }

    // 80(=おどろきアニメーションのちょっと後)を超えたらポケモンが決めた方向へ去っていく
    if (this.runningFrame > 80) {
      const x = this.position.target.x;
      const y = this.position.target.y;
      const runSpeed = 3.5;
      switch (this.runningDirection) {
        case "up":
          this.position.set({ x, y: y - runSpeed });
          break;
        case "down":
          this.position.set({ x, y: y + runSpeed });
          break;
        case "left":
          this.position.set({ x: x - runSpeed, y });
          break;
        case "right":
          this.position.set({ x: x + runSpeed, y });
          break;
      }
    }

    this.runningFrame++;
    this.draw(this.images["pokemon"] as HTMLImageElement);
  }
}

コードイメージは上記の通りですが、なんというかとっても、泥臭くないですか?
(後述しますが)わたしはこの泥臭い感じのコードが、なんともいえず好きです。

このように、アニメーションはフレームと計算を利用して表現することができました。どのような表現が良いのか、それはどうアニメーションに落とし込むのか、そのためにはどのような計算が必要なのか…、このあたりがとってもクリエイティブで楽しいです。

アニメーションについてはまだまだ学び途中だと思っています。たとえば以下のようなポケモンがボールに入って捕まるまでのアニメーションですが…

ポケモンが捕まるまで

まあまあ本家のそれとは異なり、伸び代があると思いませんか? 本家アニメーションはもっとボールが弾むような感じだし、左右に傾くときも、もっとカクッと傾くと思います。こういった細かいアニメーション表現を極めるとなると、コードはより複雑に緻密になっていくでしょう。

シューティング・衝突判定

ポケモンにモンスターボールが当たれば、ポケモンはモンスターボールの中に入りますし、ポケモンと主人公が当たれば、ポケモンは逃げていきます。この衝突判定には、衝突判定をしたい対象の座標との距離と対象の大きさを利用します。

対象との距離には、数学の知識を利用します。以下のような計算が公式となっています。高校数学Ⅱでやるみたいですね、ちなみにわたしは1ミリも覚えていませんでした。

position.ts
export class Position {
  /**
   * 対象との距離を求める
   * @param target
   */
  distance(target: PositionType) {
    const x = this.target.x - target.x;
    const y = this.target.y - target.y;
    return Math.sqrt(x * x + y * y);
  }
}

衝突判定の方法については『グラフィックスプログラミング入門』をまるっと真似させてもらっていますので、ここでの説明は省略します。ぜひ、『グラフィックスプログラミング入門』すばらしいので読んでください! 衝突判定は他にも色々な求め方があるみたいなので、探索してみたいです。

乱数

モンスターボールを投げた際にポケモンが捕まるかどうか、ポケモンたちがフィールドを歩く方向やタイミング…それらが一定だとしたら、そのゲームはきっとあんまり面白くないです。

このあたりで登場してくる、アニメーション的にも大変よくお世話になるのが、乱数の世界です。ミニポケモンアプリでもたくさんの乱数を使います。

乱数についてはCanvasアニメーションとはちょっとズレてくるため、サンプルをサクッと載せてみます。
こちらはポケモンがゲットできるかという判定に乱数を利用した例です。これまでモンスターボールを投げた回数や、でんせつのポケモンかどうかによって、比率が変わったりすると面白いですね。

pokemon.ts
export class Pokemon extends Character {
  intoMonsterBall() {
    // 省略
    if (this.intoMonsterBallFrame === 399) {
      // ゲットできない時もある
      const isGet = Calculate.percentage(0.5);
      if (!isGet) {
        this.isIntoMonsterBall = false;
        this.intoMonsterBallFrame = 0;
        return;
      }
    }
    // 省略
  }
}
calculate.ts
export class Calculate {
  // 0~1の範囲で指定した確率でtrueを返す
  static percentage(num: number) {
    return Math.random() < num;
  }
}

こちらは最初にポケモンをフィールドに誕生させる時の位置を確定させる関数です。フィールドの制御を加えつつ、真ん中には主人公が存在しており初回に被らせたくないため、真ん中の方の座標を除外しています。

pokemon.ts
  generateRandomPosition(): PositionType {
    return {
      x: Calculate.getRandomNumberExcludingRange(
        POKEMON_WIDTH / 2 + TREE_WIDTH,
        this.canvasUtil.canvas.width - TREE_WIDTH,
        (this.canvasUtil.canvas.width / 10) * 4,
        (this.canvasUtil.canvas.width / 10) * 6
      ),
      y: Calculate.getRandomNumberExcludingRange(
        POKEMON_HEIGHT / 2 + TREE_WIDTH,
        this.canvasUtil.canvas.height - TREE_WIDTH,
        (this.canvasUtil.canvas.height / 10) * 4,
        (this.canvasUtil.canvas.height / 10) * 6
      ),
    };
  }
calculate.ts
export class Calculate {
  static getRandomNumberExcludingRange(
    min: number,
    max: number,
    excludeMin: number,
    excludeMax: number
  ): number {
    if (excludeMin > excludeMax || excludeMin < min || excludeMax > max) {
      throw new Error("Invalid exclusion range");
    }

    let randomNum;
    do {
      randomNum = this.getRandomNumberFromRange(min, max);
    } while (randomNum >= excludeMin && randomNum <= excludeMax);

    return randomNum;
  }
}

こんな感じでいい感じに使えるので、アニメーションに乱数を活用するのもとってもおすすめです。

CanvasAPI

最後に、Canvasを利用するなら最初はCanvasAPIのチュートリアルから学んだ方が良いのかも?という方がいるかもしれませんので、補足を。個人の感覚なのですが、CanvasAPIの理解については最初から極めるように学ぶ必要はあまりないと感じています。

mozillaがチュートリアルを出してくれているので、それをさらっと確認して「何ができるか」を把握しておくくらいで良いかもしれません。

https://developer.mozilla.org/ja/docs/Web/API/Canvas_API

理由は主に2つです。

  • CanvasAPIの種類はあまり多くないため、表現したいことが出てきた時にリファレンス的に眺める
  • 渡す引数が多いので覚えるまでに時間がかかる

以上から、CanvasAPIから始めようとするとなかなかコツも掴めず、コツを掴んでもしばらく四角形や円しか描画できず…という感じで(これは言い過ぎかもですが💦)、最初からあんまり面白くないかもしれないんですよね。個人的には、最初はガリガリと写経しながらアニメーションを作って動かして感動してみるのがおすすめです。

Canvasに入門するためのハンズオン形式のあれやそれは、わたしがご紹介した通り本もありますし、Udemy等の学習サイトでも出回っていそうです。表現を作る楽しさから入っていくと、わたしみたいにどハマりしてもらえるかもしれません!

おわりに

「いや〜〜Canvas2Dアニメーションなにこれ! おもしろい! この気持ちを伝えたい!」と思いながらここまで書いてきましたが、ちょっとでも読んでくださった方がワクワクしてくれたら嬉しいです。

わたしは普段JavaScriptといえば、業務でReactをよく利用しますが、Reactは宣言的という特徴の通り、コード表現がすごく綺麗ですよね。美しい設計もさまざまな場所で見かけますし、比較的誰が書いても整ったコードになると思っています。

かたやアニメーションの世界といえば、(今のわたしのスキルだからというのはあるかもしれませんが)表現をゴリゴリ作っていくというだけあって、コードが泥臭くてけっこう汚い。今回ご紹介したフレーム、数学、乱数などはそれ自体が何をしているか具体的なアニメーション動作を表現するわけではないので、コードとしても理解するのも難しい!

なのですが、サンプルのミニポケモンアプリを作りながら、「どう表現するかを、0から考えて作っている」という感じがとってもすると思っていました。自分のアニメーション技術ひとつで、きっといろんなことがこれからできるようになるだろうなと、早くも表現の世界に心が躍っています。

わたしの2Dアニメーションライフはまだまだ始まったばかりですが、今回学んだことを活かして、さらに表現の世界を広げたいなと思っています。

ここまで読んでくださり、ありがとうございました。

Discussion