Chapter 36

スプライトアニメーション

miku
miku
2021.11.15に更新

キャラクターやエフェクトなどを複数の画像で表現する場合、その画像をまとめて一枚の画像にしたものをスプライトと呼ぶ。そのスプライトから画像を切り出して、アニメーションとして切り替えて再生することをスプライトアニメーションと呼び、この章ではそのスプライトアニメーションを実装する方法について学ぶ。

スプライトアニメーション

スプライト画像はローグライクゲームの所持品画面のように、うまくパッキングされていて、それぞれの画像の位置情報をJSONかなにかで保持しているものが多いが、ここでは単純に一枚一枚のサイズが固定の縦横に並んだスプライト画像を利用する。

解説で使用するスプライト画像は上記である。一枚一枚が30 x 15pxで、縦横に15枚並んでいる状態である。

この画像は左から右に、上から下へ、切り出してアニメーションをするものなので、そのとおりに実装するコードを考える。実装の方針としては今表示を行う画像に対する番号を保持しておき、その番号に対応する画像がある座標を計算してそこから30x15pxを切り抜き、拡大して表示させるという具合だ。

最低限の実装

const frameWidth = 30;
const frameHeight = 15;
const xNum = 3;
const yNum = 5;
const sx = 100;
const sy = 100;
let img, frameIndex;

function preload() {
  img = loadImage("0.png");
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  frameIndex = 0;
}

function draw() {
  clear();

  const x = frameIndex % xNum;
  const y = floor(frameIndex / xNum);

  copy(img, x * frameWidth, y * frameHeight, frameWidth, frameHeight, sx, sy, frameWidth, frameHeight);

  frameIndex++;
  frameIndex %= xNum * yNum;
}

preload() でスプライト画像を読み込んだあと、setup()frameIndex0 にする。frameIndex は上の表の番号を格納するためのものだ。

const x = frameIndex % xNum;
const y = floor(frameIndex / xNum);

一次元の配列の添字を二次元の添字に変える。

copy(img, x * frameWidth, y * frameHeight, frameWidth, frameHeight, sx, sy, frameWidth, frameHeight);

元画像の左上座標は (x * frameWidth, y * frameHeight) でサイズは (frameWidth, frameHeight) なので、第2~第5引数までそれらを指定する。貼り付け先の座標は前もって決めておいた (sx, sy) で、サイズは変更しないので (frameWidth, frameHeight) を残りの引数として指定する。

frameIndex++;
frameIndex %= xNum * yNum;

frameIndex を増やし、サイズになったら 0 に戻してループさせる。

拡大対応

const frameWidth = 30;
const frameHeight = 15;
const xNum = 3;
const yNum = 3;
const scale = 3;
let img, frameIndex;

function preload() {
  img = loadImage("0.png");
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  noSmooth();
  frameIndex = 0;
}

function draw() {
  clear();

  const x = frameIndex % xNum;
  const y = floor(frameIndex / xNum);

  copy(img, x * frameWidth, y * frameHeight, frameWidth, frameHeight, 100, 100, frameWidth * scale, frameHeight * scale);

  frameIndex++;
  frameIndex %= xNum * yNum;
}

もとのサイズが小さすぎるので、拡大して表示したい。

画像を拡大するとデフォルトではスムージング(アンチエイリアス)処理がかかるのだが、ドット絵にスムージングがかかるとぼやけて見づらいだけなので、setup() の中に noSmooth() を追加してスムージングを無効化する。

copy(img, x * frameWidth, y * frameHeight, frameWidth, frameHeight, 100, 100, frameWidth * scale, frameHeight * scale);

copy() の最後2つの引数は、コピー先のサイズの指定で、ここを大きくすると自動で画像の拡大が行われる。なので定数値 scale を用意しておき、それを幅、高さ両方にかけ合わせたものを指定すればいい。

更新速度

const frameWidth = 30;
const frameHeight = 15;
const xNum = 3;
const yNum = 5;
const s = 3;
const sx = 100;
const sy = 100;
const interval = 5;
let img, frameIndex, time;

function preload() {
  img = loadImage("0.png");
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  frameIndex = 0;
  time = 0;
}

function draw() {
  clear();

  const x = frameIndex % xNum;
  const y = floor(frameIndex / xNum);

  copy(img, x * frameWidth, y * frameHeight, frameWidth, frameHeight, sx, sy, frameWidth * s, frameHeight * s);

  if (time % interval == 0) {
    frameIndex++;
    frameIndex %= xNum * yNum;
  }
  time++;
}

毎フレーム更新すると、切り替わりが速すぎるので、更新速度を調整できるようにしたい。

if (time % interval == 0) {
  frameIndex++;
  frameIndex %= xNum * yNum;
}
time++;

毎フレーム増加する time を用意して経過時間を測り、interval 時間おきに frameIndex を更新する。

フレーム番号を指定する

今使用しているスプライト画像は、すべての画像を使用するが、そうでないケースもある。たとえばそもそも別々の画像が一つのスプライトとしてまとまっていたり、キャラクターの向きが違う、たとえばジャンプやしゃがむのような動作ごとの画像がまとまっている、などのケースがあるのでフレーム番号を指定できるようにしたほうが都合がいい。

const frameWidth = 16;
const frameHeight = 18;
const xNum = 3;
const yNum = 4;
const scale = 3;
const interval = 15;
const frames = [9, 10];
let img, frameIndex, time;

function preload() {
  img = loadImage("0.png");
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  noSmooth();
  frameIndex = 0;
  time = 0;
}

function draw() {
  clear();

  const f = frames[frameIndex];
  const x = f % xNum;
  const y = floor(f / xNum);

  copy(img, x * frameWidth, y * frameHeight, frameWidth, frameHeight, 100, 100, frameWidth * scale, frameHeight * scale);

  if (time % interval == 0) {
    frameIndex++;
    frameIndex %= frames.length;
  }
  time++;
}

frames 配列に再生する番号を入れておく。今まで再生する番号は frameIndex が担っていたが、ここから frameIndexframes の添字になる。なので、実際に再生する番号は frames[frameIndex] になる。frameIndex を増やしていくのは今までと同様だが、ループの上限が frames.length になる。

今までの機能をまとめたもの

let img, anim;

function preload() {
  img = loadImage("0.png");
}

function setup() {
  createCanvas(windowWidth, windowHeight);
  noSmooth();

  anim = Anim.init(100, 100, img, 30, 15, 5);
  Anim.addAction(anim, "normal", [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], 5, true);
  Anim.play(anim, "normal");
}

function draw() {
  clear();
  Anim.update(anim);
}

const Anim = {
  init: function (x, y, img, width, height, scale) {
    return {
      x,
      y,
      image: img,
      width,
      height,
      scale,
      group: [],
      xNum: img.width / width,
      action: {},
    };
  },

  addAction: function (anim, name, frames, interval, looped) {
    anim.group.push({ name, frames, frameIndex: 0, interval, looped });
  },

  play: function (anim, name) {
    anim.group.forEach((action) => {
      if (action.name === name) {
        anim.action = action;
        anim.finished = false;
        anim.time = 0;
        anim.frameIndex = 0;
      }
    });
  },

  update: function (anim) {
    const frame = anim.action.frames[anim.action.frameIndex];
    const x = frame % anim.xNum;
    const y = floor(frame / anim.xNum);

    copy(anim.image, x * anim.width, y * anim.height, anim.width, anim.height, anim.x, anim.y, anim.width * anim.scale, anim.height * anim.scale);

    if (anim.action.looped || !anim.finished) {
      anim.time++;
      if (anim.time % anim.action.interval === 0) {
        if (anim.action.frameIndex >= anim.action.frames.length - 1) {
          anim.finished = true;
          if (anim.action.looped) {
            anim.action.frameIndex = 0;
          }
        } else {
          anim.action.frameIndex++;
        }
      }
    }
  },
};

これまでのコードをまとめた作例。