Subterranean Flower

JavaScriptでFlashのような表示システムを構築する

Author
古都こと
ふよんとえんよぷよぐやま!!!

かつてWebにはFlashというプラットフォームがありました。Flashはグラフィックの描画に長けており、標準ライブラリだけでも、かなり柔軟な表示を行うことができました。しかしJavaScriptの標準ライブラリにはそういったものがありません。グラフィックを描画するためには、原始的なCanvasAPIを利用するか、外部のライブラリを利用するか、自分でライブラリを作ることになります。

そこで今回は「自分でライブラリを作る」をやってみましょう。Flashライクな表示システムを作ってみるのです。Flash風のAPIを備えたライブラリはすでに存在しますが、自分で作ってみると楽しいですし、いろんな発見があるかもしれません。実用には程遠いかもしれませんが、一度挑戦してみましょう。

Flashの表示の仕組み

まず初めにFlashの「表示リスト」について軽くおさらいしておきましょう。表示リストは次のようなシステムでした。

まず、システムには唯一のStageが存在します。Stageは表示オブジェクトDisplayObjectまたはDisplayObjectContainer)を複数個持つことができ、Stageに追加された表示オブジェクトは画面に表示されます。

表示オブジェクトは、その名の通り画面に表示するためのオブジェクトです。表示オブジェクトは様々なプロパティを持ちます。例えば表示オブジェクトの持つxプロパティやyプロパティを操作すると、画面上での表示もその通りに移動します。

そして、DisplayObjectContainerはその名の通り、自身の子としてさらに表示オブジェクトを持つことができます。

(※実際のFlashのシステムではStageとDisplayObjectの間に、タイムラインを表す表示オブジェクトが存在しますが、今回はタイムラインを作らないため省略しています。)

Flashではこのようにして表示オブジェクトを入れ子にし、階層構造を使って描画を管理していました。これが表示リストです。

JavaScriptでFlashのような表示システムを作る

RenderContext

さて、これからFlash風の表示システムを構築していきます。このライブラリを「Flesh」と名付けましょう!FleshはFlash風のAPIを備えており、単純なグラフィックを簡単に扱うことのできるライブラリです。flesh.jsというファイルを作り、書き込んでいきましょう。

まずcanvas要素の操作をラップするRenderContextクラスを作りましょう。

もちろんRenderCoxtextクラスなど作らずに、直接canvasを操作しても構いません。しかし例えば、描画をWebGLに切り替えたくなった場合、操作をラップしたクラスがないと、煩雑なコードの書き換えが発生することになります。そこで、中間層を提供することで、描画方法の差異を吸収することができます。

もちろん、そういった心配がない場合、例えば絶対にWebGLは使うことがないと分かっている場合、この作業は不要になります。不要な方は、この手順を飛ばしてもらっても構いません。

実際にRenderCotextクラスを定義してみましょう。最低限必要なものは以下の通りです:

class RenderContext {
    clear() {}
    flush() {}
    beginPath() {}
    rect(x, y, width, height) {}
    circle(x, y, radius) {}
    fillColor(red, green, blue) {}
    translate(x, y) {}
    rotate(angle) {}
    setAlpha(alpha) {}
}

このクラスを継承して、canvasのコンテキストを作成しましょう。内容としてはcanvasの同名のメソッドを呼び出すだけです。

class RenderContextCanvas extends RenderContext {
    constructor(canvas) {
        super();
        this._canvas = canvas;
        this._context = canvas.getContext('2d');
    }

    clear() {
        this._context.setTransform(1, 0, 0, 1, 0, 0);
        this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
    }

    flush() {
        // WebGLのときのみ実装する。
    }

    beginPath() {
        this._context.beginPath();
    }

    rect(x, y, width, height) {
        this._context.rect(x, y, width, height);
    }

    circle(x, y, radius) {
        this._context.arc(x, y, radius, 0 , Math.PI * 2);
    }

    fillColor(red, green, blue) {
        this._context.fillStyle = `rgb(${red}, ${green}, ${blue})`;
        this._context.fill();
    }

    translate(x, y) {
        this._context.translate(x, y);
    }

    rotate(angle) {
        this._context.rotate(angle);
    }

    setAlpha(alpha) {
        this._context.globalAlpha = alpha;
    }
}

これでRenderContextの準備ができました。

DisplayObjectとDisplayObjectContainer

次に一番重要なStageを……と行きたいところですが、まずは表示オブジェクトからです。なぜならStageも表示オブジェクトのひとつなので、先に表示オブジェクトを作らないとStageも作ることができません!

よって最も基本的な表示オブジェクトとなるDisplayObjectを作成しましょう!だいたい以下のようなプロパティを持ちます:

class DisplayObject {
    constructor() {
        this.x = 0; // ローカルx座標
        this.y = 0; // ローカルy座標
        this.rotation = 0; // ローカル角度
        this.alpha = 1; // ローカルアルファ
        this.visible = true; // このオブジェクトを描画するかどうか
        this._parent = null; // 親となるDisplayObjectContainer
    }
}

とりあえず、xy座標、回転角度、アルファ、表示・非表示、親要素をプロパティとして持たせました。より多くのプロパティをもたせても構いませんが、ここではシンプルにするため、数を絞りました。

次にDisplayObjectに、必要なgetter/setter、メソッドを実装していきましょう。

class DisplayObject {
    constructor() {
        this.x = 0; // ローカルx座標
        this.y = 0; // ローカルy座標
        this.rotation = 0; // ローカル角度
        this._alpha = 1; // ローカルアルファ
        this.visible = true; // このオブジェクトを描画するかどうか
        this._parent = null; // 親となるDisplayObjectContainer
    }

    get alpha() {
        return this._alpha;
    }

    set alpha(value) {
        // 数値を0〜1にする。
        this._alpha = Math.min(Math.max(0, value), 1);
    }

    get parent() {
        return this._parent;
    }

    // グローバル(Stageから見た)アルファを求める。
    // 自分のアルファ値と親のアルファ値を掛けて、その親のアルファ値を掛けて…
    // とやれば計算できる。
    // 描画の時に使う。
    get globalAlpha() {
        let dObj = this;
        let gAlpha = 1;
        while (dObj != null) {
            gAlpha *= dObj.alpha;
            dObj = dObj.parent;
        }
        return gAlpha;
    }
    
    // 描画メソッド。
    // 内容はそれぞれの継承先で実装するので空っぽ。
    render(context) { }
}

DisplayObjectクラスは、すべての根幹となるクラスですが、このクラスのオブジェクトを直接使用するわけではありません。よって実装は最低限でいいでしょう。

renderメソッドは最も重要なメソッドです。これは画面上に表示オブジェクトの内容を描画するメソッドです。しかし先述の通りDisplayObjectクラスを直接使用するわけではないので、ここでは空白にしておいて、それぞれの継承先で実装を行います。

次にDisplayObjectContainerを作りましょう。DisplayObjectContainerはDisplayObjectを継承します。DisplayObjectとの違いは子要素を持つことです。子要素を保持するプロパティと子要素の追加/削除をするメソッドを持ちます。これを実装してみましょう。DisplayObjectの下に追加します。

class DisplayObjectContainer extends DisplayObject {
    constructor() {
        super();

        // DisplayObjectContainerは複数の子を持つ。
        this._children = [];
    }

    // _childrenを直接いじられないようにするため、
    // 配列のコピーを返すようにする。
    // 場合によっては不要かもしれないので、
    // _childrenを直接返しても良い。
    get children() {
        return [].concat(this._children);
    }

    // 子要素を追加する。
    addChild(child) {
        if(child === this) {
            throw new Error();
        }

        this._children.push(child);
        child._parent = this;
    }

    // 子要素を削除する。
    removeChild(child) {
        const index = this._children.indexOf(child);
        if(index != -1) {
            this._children.splice(index, 1);
            child._parent = null;
        }
    }

    // ここでrenderメソッドを実装する。
    // 子要素の扱いに注意。子要素のローカル座標が(10,10)でも、親要素の座標が(50,50)なら、
    // 子要素の座標は(60, 60)となる。
    // 回転も同様に、親要素が回転するだけ子要素も回転する。
    // つまり、
    // 親の移動回転→親の描画→子の移動回転を親の移動回転に"加算"→子の描画
    // という処理が必要になる。
    render(context) {
        // rotationだけ回転、xyだけ平行移動する。
        // 逆順になることに注意。
        // つまり回転→平行移動=translate→rotate
        context.translate(this.x, this.y);
        context.rotate(this.rotation);

        // それぞれの子に対して、visibleならrenderメソッドを呼び出す。
        this._children.forEach((obj) => {
            if (obj.visible) {
                obj.render(context);
            }
        });

        // 平行移動・回転を戻す。
        context.rotate(-this.angle);
        context.translate(-this.x, -this.y);
    }
}

ここで大切なのはrenderメソッドをオーバーライドしておくことです。DisplayObjectContainerは子要素の描画も行う必要があるので、子要素それぞれについてrenderメソッドを呼び出しておきます。

このとき、DisplayObjectContainerは階層構造を持つことに注意してください。つまり、親が移動した分だけ、子も一緒に移動するということです。これは親要素の平行移動・回転を戻さずに子要素を描画し、子要素の描画が終わってから平行移動・回転を戻すという作業で実現することができます。

GraphicとGraphicCommand

次に、DisplayObjectContainerを継承し、実際にグラフィックの描画が可能な、Spriteクラスを作っていきます。

描画周りの命令はSpriteクラスに直接実装していっても良いのですが、Spriteクラスをシンプルに保つため、Graphicクラスという別のクラスを作り、SpriteオブジェクトにGraphicオブジェクトを持たせる方法を選びます。この方がSpriteのコードがすっきりします。

GraphicクラスはSpriteからの描画命令に応じてGraphicCommandを発行し、描画命令リストを作ります。そして描画の合図(renderメソッド)があったら、すべてのGraphicCommandを実行し、実際に描画します。

この方式の利点は、表示オブジェクトの管理が簡単になることです。描画命令を受け取って即座に描画してしまうと、オブジェクトの親子関係や上下関係の管理が面倒になります。しかし描画命令をためこんでおき、合図があったときに一括して処理することで、依存関係を単純に処理できるようになります。

まずはGraphicCommandを作ってみましょう。GraphicCommandクラスはexecuteメソッドを持つだけの単純なクラスです。

class GraphicCommand {
    execute(context) {}
}

そして各命令に応じたGraphicCommandを作成していきます。内容としては、単純にパラメータを保持して、executeが呼び出されたらcontextに対して命令を行うだけです。

class GraphicCommandBeginPath extends GraphicCommand {
    execute(context) {
        context.beginPath();
    }
}

class GraphicCommandCircle extends GraphicCommand {
    constructor(x, y, radius) {
        super();
        this.x = x;
        this.y = y;
        this.radius = radius;
    }

    execute(context) {
        context.circle(this.x, this.y, this.radius);
    }
}

class GraphicCommandRect extends GraphicCommand {
    constructor(x, y, width, height) {
        super();
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    execute(context) {
        context.rect(this.x, this.y, this.width, this.height);
    }
}

class GraphicCommandFillColor extends GraphicCommand {
    constructor(red, green, blue) {
        super();
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    execute(context) {
        context.fillColor(this.red, this.green, this.blue);
    }
}

今回はtranslate、rotate、setAlphaの3つは内部的に使用するだけなので、GraphicCommandからは省きました。

次にこれを利用したGraphicクラスを作成しましょう。GraphicクラスはSpriteクラスからの命令に従ってGraphicCommandを発行していくクラスです。基本的には命令どおりのコマンドを発行して配列に追加していくだけで出来上がります。

class Graphic {
    constructor() {
        this._commandList = [];
    }

    clear() {
        this._commandList = [];
    }

    beginPath() {
        const cmd = new GraphicCommandBeginPath();
        this._commandList.push(cmd);
    }

    circle(x, y, radius) {
        const cmd = new GraphicCommandCircle(x, y, radius);
        this._commandList.push(cmd);
    }

    rect(x, y, width, height) {
        const cmd = new GraphicCommandRect(x, y, width, height);
        this._commandList.push(cmd);
    }
    
    fillColor(red, green, blue) {
        const cmd = new GraphicCommandFillColor(red, green, blue);
        this._commandList.push(cmd);
    }
    
    render(state, context) {
        // アルファ設定・平行移動・回転をして
        context.setAlpha(state.globalAlpha);
        context.translate(state.x, state.y);
        context.rotate(state.rotation);
        
        // すべての描画コマンドを実行して
        this._commandList.forEach((cmd) => cmd.execute(context));
        
        // 戻す
        context.rotate(-state.rotation);
        context.translate(-state.x, -state.y);
    }  
}

Graphicクラスにもrenderメソッドはありますが、これは今までと違い、stateという引数を受け取ります。stateはSpriteクラスのことだと思ってもらって構いません。stateには平行移動やアルファ値などの情報が入っています。

描画の際は、平行移動・回転を忘れないように注意してください。描画コマンドの実行が終わった後に元に戻すのも忘れないように。

Sprite

Graphicクラスができたら具体的な表示オブジェクトを作っていきます。この具体的な表示オブジェクトのことをSpriteと名付けます。SpriteクラスはGraphicオブジェクトを持つだけの簡単なクラスです。Spriteは子要素を持つことができるので、DisplayObjectContainerクラスを継承しています。

class Sprite extends DisplayObjectContainer {
    constructor() {
        super();
        this._graphic = new Graphic();
    }

    get graphic() {
        return this._graphic;
    }

    render(context) {
        this._graphic.render(this, context);
        super.render(context);
    }
}

renderメソッド内において、描画はGraphicオブジェクトに任せます。スーパークラスのメソッド呼び出しを忘れないようにしてください。

Stage

最後に(ようやく!)Stageクラスを作りましょう。Stageは複数の子要素を持つので、当然DisplayObjectContainerになります。

class Stage extends DisplayObjectContainer {
    constructor(canvas) {
        super();
        this._canvas = canvas;
        this._context = new RenderContextCanvas(canvas);
    }

    render() {
        this._context.clear();
        super.render(this._context);
        this._context.flush(); // WebGLを使わない場合は不要。
    }
}

これで完成です!次は実際に動かしてみましょう。

ここまでのflesh.jsコードまとめ

class RenderContext {
    clear() {}
    flush() {}
    beginPath() {}
    rect(x, y, width, height) {}
    circle(x, y, radius) {}
    fillColor(red, green, blue) {}
    translate(x, y) {}
    rotate(angle) {}
    setAlpha(alpha) {}
}

class RenderContextCanvas extends RenderContext {
    constructor(canvas) {
        super();
        this._canvas = canvas;
        this._context = canvas.getContext('2d');
    }

    clear() {
        this._context.setTransform(1, 0, 0, 1, 0, 0);
        this._context.clearRect(0, 0, this._canvas.width, this._canvas.height);
    }

    flush() {
        // WebGLのときのみ実装する。
    }

    beginPath() {
        this._context.beginPath();
    }

    rect(x, y, width, height) {
        this._context.rect(x, y, width, height);
    }

    circle(x, y, radius) {
        this._context.arc(x, y, radius, 0 , Math.PI * 2);
    }

    fillColor(red, green, blue) {
        this._context.fillStyle = `rgb(${red}, ${green}, ${blue})`;
        this._context.fill();
    }

    translate(x, y) {
        this._context.translate(x, y);
    }

    rotate(angle) {
        this._context.rotate(angle);
    }

    setAlpha(alpha) {
        this._context.globalAlpha = alpha;
    }
}

class DisplayObject {
    constructor() {
        this.x = 0; // ローカルx座標
        this.y = 0; // ローカルy座標
        this.rotation = 0; // ローカル角度
        this._alpha = 1; // ローカルアルファ
        this.visible = true; // このオブジェクトを描画するかどうか
        this._parent = null; // 親となるDisplayObjectContainer
    }

    get alpha() {
        return this._alpha;
    }

    set alpha(value) {
        // 数値を0〜1にする。
        this._alpha = Math.min(Math.max(0, value), 1);
    }

    get parent() {
        return this._parent;
    }

    // グローバル(Stageから見た)アルファを求める。
    // 自分のアルファ値と親のアルファ値を掛けて、その親のアルファ値を掛けて…
    // とやれば計算できる。
    // 描画の時に使う。
    get globalAlpha() {
        let dObj = this;
        let gAlpha = 1;
        while (dObj != null) {
            gAlpha *= dObj.alpha;
            dObj = dObj.parent;
        }
        return gAlpha;
    }

    // 描画メソッド。
    // 内容はそれぞれの継承先で実装するので空っぽ。
    render(context) { }
}

class DisplayObjectContainer extends DisplayObject {
    constructor() {
        super();

        // DisplayObjectContainerは複数の子を持つ。
        this._children = [];
    }

    // _childrenを直接いじられないようにするため、
    // 配列のコピーを返すようにする。
    // 場合によっては不要かもしれないので、
    // _childrenを直接返しても良い。
    get children() {
        return [].concat(this._children);
    }

    // 子要素を追加する。
    addChild(child) {
        if(child === this) {
            throw new Error();
        }

        this._children.push(child);
        child._parent = this;
    }

    // 子要素を削除する。
    removeChild(child) {
        const index = this._children.indexOf(child);
        if(index != -1) {
            this._children.splice(index, 1);
            child._parent = null;
        }
    }

    // ここでrenderメソッドを実装する。
    // 子要素の扱いに注意。子要素のローカル座標が(10,10)でも、親要素の座標が(50,50)なら、
    // 子要素の座標は(60, 60)となる。
    // 回転も同様に、親要素が回転するだけ子要素も回転する。
    // つまり、
    // 親の移動回転→親の描画→子の移動回転を親の移動回転に"加算"→子の描画
    // という処理が必要になる。
    render(context) {
        // rotationだけ回転、xyだけ平行移動する。
        // 逆順になることに注意。
        // つまり回転→平行移動=translate→rotate
        context.translate(this.x, this.y);
        context.rotate(this.rotation);

        // それぞれの子に対して、visibleならrenderメソッドを呼び出す。
        this._children.forEach((obj) => {
            if (obj.visible) {
                obj.render(context);
            }
        });

        // 平行移動・回転を戻す。
        context.rotate(-this.angle);
        context.translate(-this.x, -this.y);
    }
}

class GraphicCommand {
    execute(context) {}
}

class GraphicCommandBeginPath extends GraphicCommand {
    execute(context) {
        context.beginPath();
    }
}

class GraphicCommandCircle extends GraphicCommand {
    constructor(x, y, radius) {
        super();
        this.x = x;
        this.y = y;
        this.radius = radius;
    }

    execute(context) {
        context.circle(this.x, this.y, this.radius);
    }
}

class GraphicCommandRect extends GraphicCommand {
    constructor(x, y, width, height) {
        super();
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    execute(context) {
        context.rect(this.x, this.y, this.width, this.height);
    }
}

class GraphicCommandFillColor extends GraphicCommand {
    constructor(red, green, blue) {
        super();
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    execute(context) {
        context.fillColor(this.red, this.green, this.blue);
    }
}

class Graphic {
    constructor() {
        this._commandList = [];
    }

    clear() {
        this._commandList = [];
    }

    beginPath() {
        const cmd = new GraphicCommandBeginPath();
        this._commandList.push(cmd);
    }

    circle(x, y, radius) {
        const cmd = new GraphicCommandCircle(x, y, radius);
        this._commandList.push(cmd);
    }

    rect(x, y, width, height) {
        const cmd = new GraphicCommandRect(x, y, width, height);
        this._commandList.push(cmd);
    }

    fillColor(red, green, blue) {
        const cmd = new GraphicCommandFillColor(red, green, blue);
        this._commandList.push(cmd);
    }

    render(state, context) {
        // アルファ設定・平行移動・回転をして
        context.setAlpha(state.globalAlpha);
        context.translate(state.x, state.y);
        context.rotate(state.rotation);

        // すべての描画コマンドを実行して
        this._commandList.forEach((cmd) => cmd.execute(context));

        // 戻す
        context.rotate(-state.rotation);
        context.translate(-state.x, -state.y);
    }
}

class Sprite extends DisplayObjectContainer {
    constructor() {
        super();
        this._graphic = new Graphic();
    }

    get graphic() {
        return this._graphic;
    }

    render(context) {
        this._graphic.render(this, context);
        super.render(context);
    }
}

class Stage extends DisplayObjectContainer {
    constructor(canvas) {
        super();
        this._canvas = canvas;
        this._context = new RenderContextCanvas(canvas);
    }

    render() {
        this._context.clear();
        super.render(this._context);
        this._context.flush(); // WebGLを使わない場合は不要。
    }
}

動作確認

静止画像

まずは静止画像で確認してみましょう。main.jsというファイルを作り、flesh.jsの後に読み込みます。

<!DOCTYPE html>
<html lang="ja-jp">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <title>flesh</title>
        <script src="flesh.js" defer></script>
        <script src="main.js" defer></script>
    </head>

    <body>
    </body>
</html>

main.jsの中で試しにSpriteを作ってStageに追加してみます。

const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// Stageを作る。
const stage = new Stage(canvas);

// 四角形を作る。
const rectangle = new Sprite();
rectangle.graphic.beginPath();
rectangle.graphic.rect(0, 0, 50, 50);
rectangle.graphic.fillColor(255, 0, 0);
rectangle.x = 100;
rectangle.y = 250;

// 四角形の子要素としての四角形を作る。
const subRectangle = new Sprite();
subRectangle.graphic.beginPath();
subRectangle.graphic.rect(0, 0, 50, 50);
subRectangle.graphic.fillColor(255, 0, 0);
subRectangle.x = 50;
subRectangle.y = 100;

rectangle.addChild(subRectangle);

// 円を作る。
const circle = new Sprite();
circle.graphic.beginPath();
circle.graphic.circle(0, 0, 25);
circle.graphic.fillColor(0, 0, 255);
circle.x = 400;
circle.y = 250;

// Stageに追加する
stage.addChild(rectangle);
stage.addChild(circle);

// 描画する
stage.render();

そしてこれを実行します。

やったー!表示されました!

動かしてみる

次にアニメーションを作ってみましょう。main.jsを編集します。

const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// Stageを作る。
const stage = new Stage(canvas);

// 四角形を作る。
const rectangle = new Sprite();
rectangle.graphic.beginPath();
rectangle.graphic.rect(-25, -25, 50, 50);
rectangle.graphic.fillColor(255, 0, 0);
rectangle.x = 250;
rectangle.y = 250;

// 衛星を作る。
const satellite = new Sprite();
satellite.graphic.beginPath();
satellite.graphic.rect(-25, -25, 50, 50);
satellite.graphic.fillColor(0, 255, 0);
satellite.x = 0;
satellite.y = 150;

rectangle.addChild(satellite);

// Stageに追加する。
stage.addChild(rectangle);

// アニメーションループ。
function loop(timestamp) {
    rectangle.rotation += 1 * Math.PI / 180;
    satellite.rotation -= 2 * Math.PI / 180;

    // 描画する。
    stage.render();

    // 次のフレームをリクエストする。
    window.requestAnimationFrame(loop);
}

// アニメーションを開始する。
window.requestAnimationFrame(loop);

アニメーションにも成功しました!