6
6

More than 3 years have passed since last update.

【JavaScript】canvas上でオブジェクトを動かしてみよう【HTML5/canvas】

Last updated at Posted at 2020-03-09

はじめに

前の記事の続編です。
今回は前回のプログラムを拡張します。
ビューの回転、
オブジェクトの操作、追加、削除
ミニマップの追加
と少しアプリっぽくなってきました。

前の記事との変更点

ビューを回転できるようにした

ビューの回転.png

オブジェクトを平行移動、拡大縮小、回転できるようにした

image.png

オブジェクトの種類に画像を追加した

画像追加.png

オブジェクトを追加、削除できるようにした

add xxxボタン押下で、オブジェクトが任意の位置に追加できる
delete objectボタン押下で、オブジェクトが削除できる

zindexを変更できるようにした

to topボタン押下で、最前面に来る
to bottomボタン押下で、最背面に来る

左下にミニマップを表示するようにした。

可視範囲が左下のミニマップの薄赤色で表示されています。
ミニマップ.png

変数からビューボリュームを取り除いた

前回の記事でビューポートとビューボリュームから射影行列を計算していました。
ビューポートはブラウザのリサイズなどが行われない限り一定なので、
ビューボリュームが決まれば射影行列が決まります。
ということは、ビューボリュームを考える必要がないわけです。
射影行列を直接変換するように変更しています。

ブラウザリサイズ時の処理を1種類にした

4種類もいらんなーと思ったので。

オブジェクト毎に変換行列を持つ

オブジェクトの座標系を「オブジェクト座標系」と呼ぶことにします。
オブジェクト座標系からワールド座標系への変換を射影変換同様、行列で表現します。
その行列をオブジェクト毎に持つことにより、オブジェクトの平行移動、拡大縮小、回転が実現可能になります。
(変換イメージ画像作成中...)

ソース

.html1つです。
jQueryだけご準備ください。
ヘルプボタンを押下すると、操作方法が表示されます。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>canvas sample(ES6 PC Chrome)</title>
<script src="./js/jquery-3.4.1.min.js" type="text/javascript"></script>
<style>
body {
    overflow: hidden;
}
#canvas {
    position: absolute;
    left: 0;
    top: 0;    
}
#mini-map-canvas {
    position: absolute;
    left: 10px;
    bottom: 10px;
}
#message-div {
    position: absolute;
    left: 20px;
    top: 20px;
    font-size: 24px;
    pointer-events: none;
    color: yellow;
    text-shadow: 2px 2px 2px #888;
}
#button-area {
    position: absolute;
    left: 20px;
    top: 70px;
    font-size: 24px;
    pointer-events: none;
}
#button-area input[type="button"], #button-area select {
    width: 160px;
    font-size: 24px;
    margin-bottom: 20px;
    pointer-events: auto;
}
</style>
<script>
// 行列クラス(行列の計算に使用)
class Matrix {
    // m0は行列、m1は行列又はベクトル
    // 行列は大きさ9の1次元配列であること。 ex. [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ]
    // ベクトルはxとyをプロパティに持つ連想配列であること。 ex. { x: 2, y: 4 }
    // 左からベクトルをかけることは想定していない
    static multiply(m0, m1) {
        if(m1.length && m1.length === 9) {// m1は行列
            return [
                m0[0] * m1[0] + m0[1] * m1[3] + m0[2] * m1[6],
                m0[0] * m1[1] + m0[1] * m1[4] + m0[2] * m1[7],
                m0[0] * m1[2] + m0[1] * m1[5] + m0[2] * m1[8],
                m0[3] * m1[0] + m0[4] * m1[3] + m0[5] * m1[6],
                m0[3] * m1[1] + m0[4] * m1[4] + m0[5] * m1[7],
                m0[3] * m1[2] + m0[4] * m1[5] + m0[5] * m1[8],
                m0[6] * m1[0] + m0[7] * m1[3] + m0[8] * m1[6],
                m0[6] * m1[1] + m0[7] * m1[4] + m0[8] * m1[7],
                m0[6] * m1[2] + m0[7] * m1[5] + m0[8] * m1[8],
            ];
        } else {// m1はベクトル
            return {
                x: m0[0] * m1.x + m0[1] * m1.y + m0[2],
                y: m0[3] * m1.x + m0[4] * m1.y + m0[5],                
            };
        }
    }
    // 単位行列
    static identify() {
        return [1, 0, 0, 0, 1, 0, 0, 0, 1];
    }
    // 平行移動行列
    static translate(x, y) {
        return [1, 0, x, 0, 1, y, 0, 0, 1];
    }
    // 拡大縮小行列
    static scale(x, y) {
        return [x, 0, 0, 0, y, 0, 0, 0, 1];
    }
    // 回転行列
    static rotate(theta) {
        const cos = Math.cos(theta),
            sin = Math.sin(theta);
        return [cos, -sin, 0, sin, cos, 0, 0, 0, 1];
    }
    // 逆行列を求める
    static inverse(m) {
        const det = Matrix.determinant(m),
            inv = [
                m[4] * m[8] - m[5] * m[7],    -(m[1] * m[8] - m[2] * m[7]),   m[1] * m[5] - m[2] * m[4],
                -(m[3] * m[8] - m[5] * m[6]), m[0] * m[8] - m[2] * m[6],      -(m[0] * m[5] - m[2] * m[3]),
                m[3] * m[7] - m[4] * m[6],    -(m[0] * m[7] - m[1] * m[6]),   m[0] * m[4] - m[1] * m[3]
            ];
        return inv.map(elm => elm / det);
    }
    // 行列式を求める
    static determinant(m) {
        return m[0] * m[4] * m[8] 
        + m[1] * m[5] * m[6] 
        + m[2] * m[3] * m[7]
        - m[2] * m[4] * m[6]
        - m[1] * m[3] * m[8]
        - m[0] * m[5] * m[7];
    }
}

// ベクトルクラス(ベクトルの計算に使用)
class Vector {
    // 足し算
    static add(v0, v1) {
        return {
            x: v0.x + v1.x,
            y: v0.y + v1.y,
        };
    }
    // 引き算
    static subtract(v0, v1) {
        return {
            x: v0.x - v1.x,
            y: v0.y - v1.y,
        };
    }
    // ベクトルの長さを返す
    static length(v) {
        return Math.sqrt(v.x * v.x + v.y * v.y);
    }
    // 単位ベクトルを返す(非破壊的)
    static unit(v) {
        const len = Vector.length(v);
        return {
            x: v.x / len,
            y: v.y / len
        };
    }
    // 内積
    static innerProduct(v0, v1) {
        return v0.x * v1.x + v0.y + v1.y;
    }
}

// モデルクラス
class Model {

    static CANVAS_ID = 'canvas';                      // canvasのID
    static MINI_MAP_CANVAS_ID = 'mini-map-canvas';    // minimapのcanvasのID
    static WHEEL_ZOOM_RATE = 1.2;                     // ホイール操作時の拡大率
    static WHEEL_ROTATE_ANGLE = 5 * (Math.PI / 180);  // ホイール操作時の回転角度(単位はラジアン)
    static WINDOW_RESIZE_INTERVAL = 500;              // ブラウザリサイズ時の遅延処理のインターバル(ms)
    static SELECTED_LINE_WIDTH = 5;                   // 選択オブジェクトの線分の太さ
    static SELECTED_STROKE_STYLE = 'cyan';            // 選択オブジェクトの線分の色
    static ON_ADD_BACKGROUND_COLOR = 'gray';          // オブジェクト追加中の背景色
    static ON_ADD_GLOBAL_ALPHA = 0.5;                 // オブジェクト追加中の背景色の透過度

    static INIT_RECT_POS_X = 400;                     // 初期配置される矩形のX座標
    static INIT_RECT_POS_Y = 200;                     // 初期配置される矩形のY座標
    static INIT_CIRCLE_POS_X = 300;                   // 初期配置される円のX座標
    static INIT_CIRCLE_POS_Y = 600;                   // 初期配置される円のY座標
    static INIT_TRIANGLE_POS_X = 800;                 // 初期配置される三角のX座標
    static INIT_TRIANGLE_POS_Y = 500;                 // 初期配置される三角のY座標

    static RECT_COLOR = 'blue';                       // 矩形の色
    static RECT_WIDTH = 100;                          // 矩形の幅
    static RECT_HEIGHT = 100;                         // 矩形の高さ
    static CIRCLE_COLOR = 'red';                      // 円の色
    static CIRCLE_RADIUS = 50;                        // 円の半径
    static TRIANGLE_COLOR = 'green';                  // 三角の色
    static TRIANGLE_WIDTH = 100;                      // 三角の幅(底辺)
    static TRIANGLE_HEIGHT = 100;                     // 三角の高さ

    static MINI_MAP_CANVAS_WIDTH = 320;                // ミニマップのcanvasの幅
    static MINI_MAP_CANVAS_HEIGHT = 180;               // ミニマップのcanvasの高さ
    static MINI_MAP_GLOBAL_ALPHA = 0.5;                // ミニマップの透過度
    static MINI_MAP_BACKGROUND_COLOR = '#ccc';         // ミニマップの背景色
    static MINI_MAP_VIEW_VOLUME_COLOR = 'red';         // ミニマップに表示する表示範囲の色
    static MINI_MAP_VIEW_VOLUME_GLOBAL_ALPHA = 0.2;    // ミニマップに表示する表示範囲の透過度

    static operation;   // '': 何もしていない, 
                        // 'VIEW_TRANSLATE': ビュー平行移動中, 'OBJECT_TRANSLATE': オブジェクト平行移動中 
                        // 'VIEW_ROTATE': ビュー回転中, 'OBJECT_ROTATE': オブジェクト回転中 

    static redrawFlag;  // 再描画用フラグ

    static matrix;      // 行列

    static vp;          // ビューポート

    static objects;     // オブジェクト配列

    static selected;    // 選択中のオブジェクト(未選択時はnull)

    static tmpImg;      // 画像オブジェクト追加時のユーザーが選択した画像ファイルのimgオブジェクト

    static mmMatrix;    // ミニマップの射影行列

    // 初期化用メソッド
    static init() {

        Model.operation = '';
        Model.redrawFlag = true;

        // 射影行列を初期化する 
        Model.matrix = Matrix.identify();

        // ビューポートを初期化する
        Model.vp = { x: 0, y: 0, w: window.innerWidth, h: window.innerHeight };

        Model.objects = [
            Model.createRect({ x: Model.INIT_RECT_POS_X, y: Model.INIT_RECT_POS_Y }),
            Model.createCircle({ x: Model.INIT_CIRCLE_POS_X, y: Model.INIT_CIRCLE_POS_Y }),
            Model.createTriangle({ x: Model.INIT_TRIANGLE_POS_X, y: Model.INIT_TRIANGLE_POS_Y }),
        ];

        Model.selected = null;

        Model.tmpImg = null;

        // ミニマップの射影行列
        Model.mmMatrix = Matrix.identify();
        Model.fitMiniMap();
    }

    // 画像オブジェクトを作成
    static createImage(img, worldPos) {
        return {                    
            type: 'image',       // 画像
            img: img,           // 画像データ
            width: img.width,         // 幅
            height: img.height,        // 高さ
            m: Matrix.translate(worldPos.x, worldPos.y),      // オブジェクト座標系からワールド座標系へ変換する行列
        };
    }

    // 矩形オブジェクトを作成
    static createRect(worldPos) {
        return {                    
            type: 'rect',               // 長方形
            color: Model.RECT_COLOR,         // 色
            width: Model.RECT_WIDTH,         // 幅
            height: Model.RECT_HEIGHT,       // 高さ
            m: Matrix.translate(worldPos.x, worldPos.y),      // オブジェクト座標系からワールド座標系へ変換する行列
        };
    }

    // 円オブジェクトを作成
    static createCircle(worldPos) {
        return {
            type: 'circle',             // 円
            color: Model.CIRCLE_COLOR,       // 色
            radius: Model.CIRCLE_RADIUS,     // 半径
            m: Matrix.translate(worldPos.x, worldPos.y),      // オブジェクト座標系からワールド座標系へ変換する行列
        };
    }

    // 三角オブジェクトを作成
    static createTriangle(worldPos) {
        return {
            type: 'triangle',               // 三角形
            color: Model.TRIANGLE_COLOR,         // 色
            width: Model.TRIANGLE_WIDTH,         // 底辺の幅
            height: Model.TRIANGLE_HEIGHT,       // 高さ
            m: Matrix.translate(worldPos.x, worldPos.y),      // オブジェクト座標系からワールド座標系へ変換する行列
        };
    }

    // スクリーン座標系をワールド座標系へ変換する
    static miniMapScreenToWorld(screenPos) {
        const inv = Matrix.inverse(Model.mmMatrix)
        return Matrix.multiply(inv, screenPos);
    }

    // スクリーン座標系をワールド座標系へ変換する
    static screenToWorld(screenPos) {
        const inv = Matrix.inverse(Model.matrix)
        return Matrix.multiply(inv, screenPos);
    }

    // ワールド座標系をオブジェクト座標系へ変換する
    static worldToObject(obj, worldPos) {
        const inv = Matrix.inverse(obj.m)
        return Matrix.multiply(inv, worldPos);
    }

    // オブジェクト座標系をワールド座標系へ変換
    static objectToWorld(obj, objectPos) {
        return Matrix.multiply(obj.m, objectPos);
    }

    // ワールド座標系をスクリーン座標系へ変換
    static worldToScreen(worldPos) {
        return Matrix.multiply(Model.matrix, worldPos);
    } 

    // 行列の更新(原点へ移動→任意の変換→もとの位置へ戻す ex.拡大縮小, 回転)
    static updateMatrix(base, center, m) {
        let ret = base;
        // 中心座標を原点へ移動する
        const trans = Matrix.translate(-center.x, -center.y);
        // 原点を中心座標へ戻す
        const rev = Matrix.translate(center.x, center.y);

        // 行列を更新
        ret = Matrix.multiply(ret, rev);
        ret = Matrix.multiply(ret, m);
        ret = Matrix.multiply(ret, trans);

        return ret;
    }    

    // ピック(オブジェクトができたら、オブジェクト。できない場合はnullを返す)
    static pick(worldPos) {
        // .slice().reverse() とすると、非破壊的
        let ret = Model.objects.slice().reverse().find(obj => {
            if(obj.type === 'image' || obj.type === 'rect') {
                return Model.pickRect(obj, worldPos);
            } else if(obj.type === 'circle') {
                return Model.pickCircle(obj, worldPos);
            } else if(obj.type === 'triangle') {
                return Model.pickTriangle(obj, worldPos);
            } else {
                return false;
            }
        });
        if(!ret) { ret = null; }    // findは見つからない場合、undeinfedとなるので、nullを格納しておく
        return ret;
    }

    // 矩形のピック判定
    static pickRect(obj, worldPos) {
        // ピック点をオブジェクト座標系へ変換
        const objectPos = Model.worldToObject(obj, worldPos);         
        return 0 <= objectPos.x && objectPos.x <= obj.width && 0 <= objectPos.y && objectPos.y <= obj.height;
    }

    // 円のピック判定
    static pickCircle(obj, worldPos) {
        // ピック点をオブジェクト座標系へ変換
        const objectPos = Model.worldToObject(obj, worldPos),
            dist = Math.sqrt(objectPos.x * objectPos.x + objectPos.y * objectPos.y);
        return dist <= obj.radius;
    }

    // 三角のピック判定
    static pickTriangle(obj, worldPos) {
        // ピック点をオブジェクト座標系へ変換
        const objectPos = Model.worldToObject(obj, worldPos);
        // yをまず判定する
        if(objectPos.y < 0 || obj.height < objectPos.y) {
            return false;
        } 
        // xを判定する
        const widthRate = objectPos.y / obj.height;
        return - obj.width * widthRate / 2 <= objectPos.x && objectPos.x <= obj.width * widthRate / 2;
    }

    // オブジェクトの平行移動
    static translateObject(obj, worldVec) {
        // ワールド座標系のベクトルをオブジェクト座標系へ変換
        const objectTemp = Model.worldToObject(obj, worldVec);
        const objectOrigin = Model.worldToObject(obj, { x: 0, y: 0 });
        const objectVec = Vector.subtract(objectTemp, objectOrigin);

        const trans = Matrix.translate(objectVec.x, objectVec.y);
        obj.m = Matrix.multiply(obj.m, trans);
    }

    // ビューの平行移動
    static translateView(worldVec) {
        // 射影行列の行列を更新する
        const trans = Matrix.translate(worldVec.x, worldVec.y);
        Model.matrix = Matrix.multiply(Model.matrix, trans);
    }

    // オブジェクトの拡大縮小
    static scaleObject(obj, worldCenter, rate) {
        // ワールド座標系のベクトルをオブジェクト座標系へ変換
        const objectCenter = Model.worldToObject(obj, worldCenter),
            scale = Matrix.scale(rate, rate);
        obj.m = Model.updateMatrix(obj.m, objectCenter, scale);
    }

    // ビューの拡大縮小
    static scaleView(worldCenter, rate) {
        const scale = Matrix.scale(rate, rate);
        Model.matrix = Model.updateMatrix(Model.matrix, worldCenter, scale);
    }

    // オブジェクトの回転(マウスホイール操作時)
    static rotateObject(obj, worldCenter, theta) {
        // ワールド座標系のベクトルをオブジェクト座標系へ変換
        const objectCenter = Model.worldToObject(obj, worldCenter),
            rotate = Matrix.rotate(theta);
        obj.m = Model.updateMatrix(obj.m, objectCenter, rotate);
    }

    // ビューの回転(マウスホイール操作時)
    static rotateView(worldCenter, theta) {
        const rotate = Matrix.rotate(theta);
        Model.matrix = Model.updateMatrix(Model.matrix, worldCenter, rotate);
    } 

    // ビュー回転時のスクリーン座標系の回転中心座標を取得
    static getViewRotateScreenCenter() {
        return { x: (Model.vp.x + Model.vp.w) / 2, y: (Model.vp.y + Model.vp.h) / 2 };
    }

    // オブジェクト回転時のワールド座標系の回転中心座標を取得(回転中心は重心)
    static getObjectRotateWorldCenter(obj) {
        let objectCenter;
        if(obj.type === 'image' || obj.type === 'rect') {
            objectCenter = { x: obj.width / 2, y: obj.height / 2 };
        } else if(obj.type === 'circle') {
            objectCenter = { x: 0, y: 0 };
        } else if(obj.type === 'triangle') {
            objectCenter = { x: 0, y: 2 * obj.height / 3 };
        } 
        return Model.objectToWorld(obj, objectCenter);
    }

    // 回転角度を求める
    static getAngle(center, base, moved) {
        const baseVec = { x: base.x - center.x , y: base.y - center.y },
            movedVec = { x: moved.x - center.x , y: moved.y - center.y };
        return Math.atan2(movedVec.y, movedVec.x) - Math.atan2(baseVec.y, baseVec.x);
    }

    // 行列の回転角度を取得する(単位はラジアン) -> 使ってないけど、残しておく
    static getRotatedAngle(m) {
        const pos0 = { x: 0, y: 0 },
            pos1 = { x: 1, y: 0 },
            rotated0 = Matrix.multiply(m, pos0),
            rotated1 = Matrix.multiply(m, pos1);
        const rvec = { x: rotated1.x - rotated0.x, y: rotated1.y - rotated0.y };
        return Math.atan2(rvec.y, rvec.x);
    }

    // ビューポートをミニマップで求める
    static vpBoundsToMiniMap(margin = 0.2) {

        // vpの4隅のminimapのスクリーン座標系座標を取得する
        let m = Matrix.inverse(Model.matrix);
        m = Matrix.multiply(Model.mmMatrix, m);
        const corners = [];
        corners.push(Matrix.multiply(m, { x: 0, y: 0 }));
        corners.push(Matrix.multiply(m, { x: Model.vp.w, y: 0 }));
        corners.push(Matrix.multiply(m, { x: Model.vp.w, y: Model.vp.h }));
        corners.push(Matrix.multiply(m, { x: 0, y: Model.vp.h }));

        // vpの4隅のminimapのスクリーン座標系のminimaxを計算する
        let minX = Number.MAX_VALUE,
            maxX = Number.MIN_VALUE,
            minY = Number.MAX_VALUE,
            maxY = Number.MIN_VALUE;
        corners.forEach(c => {
            if(c.x < minX) { minX = c.x; }
            if(c.x > maxX) { maxX = c.x; }
            if(c.y < minY) { minY = c.y; }
            if(c.y > maxY) { maxY = c.y; }
        });

        const center = {
            x: (minX + maxX) / 2,
            y: (minY + maxY) / 2
        };
        const width = maxX - minX;
        const height = maxY - minY;

        return {
            x: center.x - (width / 2) * (1 + margin),
            y: center.y - (height / 2) * (1 + margin),
            w: width * (1 + margin),
            h: height * (1 + margin)
        };
    }

    // ミニマップにビューボリュームをフィットさせる
    static fitMiniMap() {
        const rect = Model.vpBoundsToMiniMap();

        // 矩形が中央に来るようにする
        // ミニマップのビューボリュームを求める
        const mainAspect = rect.w / rect.h;
        const mmAspect = Model.MINI_MAP_CANVAS_WIDTH / Model.MINI_MAP_CANVAS_HEIGHT;
        const mmRect = {};
        if(mmAspect > mainAspect) {// ミニマップのビューポートの方が横長
            mmRect.y = rect.y;
            mmRect.h = rect.h;
            mmRect.w = mmRect.h * mmAspect;
            mmRect.x = rect.x - (mmRect.w - rect.w) / 2;
        } else {// ミニマップのビューポートの方が縦長
            mmRect.x = rect.x;
            mmRect.w = rect.w;
            mmRect.h = mmRect.w / mmAspect;
            mmRect.y = rect.y - (mmRect.h - rect.h) / 2;
        }

        // miniMapの射影行列を更新する
        const trans = Matrix.translate(-mmRect.x, -mmRect.y);
        const scale = Matrix.scale(Model.MINI_MAP_CANVAS_WIDTH / mmRect.w, Model.MINI_MAP_CANVAS_HEIGHT / mmRect.h);
        Model.mmMatrix = Matrix.multiply(trans, Model.mmMatrix);
        Model.mmMatrix = Matrix.multiply(scale, Model.mmMatrix);        
    }
}

// ビュークラス
class View {
    static init() {
        View.resizeCanvas();        // ビュー(DOM)の初期化
        View.updateDom();

        $(`#${Model.MINI_MAP_CANVAS_ID}`).prop({
            width: Model.MINI_MAP_CANVAS_WIDTH,
            height: Model.MINI_MAP_CANVAS_HEIGHT,
        });
    }

    // canvasをリサイズ  
    static resizeCanvas() {  
        $(`#${Model.CANVAS_ID}`).prop({
            width: Model.vp.w,
            height: Model.vp.h
        }); 

        $(`#${Model.PEN_CANVAS_ID}`).prop({
            width: Model.vp.w,
            height: Model.vp.h
        }); 
    }
    // ビュー(DOM)の更新
    static updateDom() {        

        if(Model.operation === '') {
            $('#message-div').text('');
            $('#button-area').show();
        } else if(Model.operation === 'VIEW_TRANSLATE'|| Model.operation === 'OBJECT_TRANSLATE'
            || Model.operation === 'VIEW_ROTATE' || Model.operation === 'OBJECT_ROTATE' || Model.operation === 'MINI_MAP_VIEW_TRANSLATE' ) {
            $('#message-div').text('');
        } else if(Model.operation.indexOf('ADD_') >= 0) {
            $('#message-div').text('Click where you want to add');
            $('#button-area').hide();        
        }
    }

    // スクリーン座標系からオブジェクト座標系への変換の拡大率取得
    static getScaleRateFromScreenToObject(obj) {
        // 単位ベクトルの端点を変換し、長さを取得する
        const pos0 = { x: 0, y: 0 },
            pos1 = { x: 1, y: 0 },
            m = Matrix.multiply(Model.matrix, obj.m),
            obj0 = Matrix.multiply(m, pos0),
            obj1 = Matrix.multiply(m, pos1);
        return Vector.length(Vector.subtract(obj1, obj0));
    }

    // 線の太さを設定
    static setLineWidth(ctx, width, obj) {
        const rate = View.getScaleRateFromScreenToObject(obj);
        ctx.lineWidth = width / rate;   
    }

    // オブジェクトを描画
    static drawObj(ctx, obj) {
        if(obj.type === 'image') {
            View.drawImage(ctx, obj);
        } else if(obj.type === 'rect') {
            View.drawRect(ctx, obj);
        } else if(obj.type === 'circle') {
            View.drawCircle(ctx, obj);
        } else if(obj.type === 'triangle') {
            View.drawTriangle(ctx, obj);
        }
    }

    // 画像を描画
    static drawImage(ctx, obj) {

        const m = obj.m;

        ctx.save();

        // 射影行列をセットする(これによりワールド座標系からスクリーン座標系への射影が行われる)
        ctx.transform(m[0], m[3], m[1], m[4], m[2], m[5]);

        // 矩形を描画
        ctx.drawImage(obj.img, 0, 0);  
        if(obj === Model.selected && ctx.canvas.id === Model.CANVAS_ID) {         
            View.setLineWidth(ctx, Model.SELECTED_LINE_WIDTH, obj);
            ctx.strokeRect(0, 0, obj.width, obj.height);
        }

        ctx.restore();
    }

    // 矩形を描画
    static drawRect(ctx, obj) {

        const m = obj.m;

        ctx.save();

        // 射影行列をセットする(これによりワールド座標系からスクリーン座標系への射影が行われる)
        ctx.transform(m[0], m[3], m[1], m[4], m[2], m[5]);

        // 矩形を描画
        ctx.fillStyle = obj.color;
        ctx.fillRect(0, 0, obj.width, obj.height);  
        if(obj === Model.selected && ctx.canvas.id === Model.CANVAS_ID) {         
            View.setLineWidth(ctx, Model.SELECTED_LINE_WIDTH, obj);
            ctx.strokeRect(0, 0, obj.width, obj.height);
        }

        ctx.restore();
    }

    // 円を描画
    static drawCircle(ctx, obj) {

        const m = obj.m;

        ctx.save();

        // 射影行列をセットする(これによりワールド座標系からスクリーン座標系への射影が行われる)
        ctx.transform(m[0], m[3], m[1], m[4], m[2], m[5]);

        // 矩形を描画
        ctx.fillStyle = obj.color;
        ctx.beginPath();
        ctx.arc(0, 0, obj.radius, 0, 360 * Math.PI / 180, false);
        ctx.fill(); 
        if(obj === Model.selected && ctx.canvas.id === Model.CANVAS_ID) {   
            View.setLineWidth(ctx, Model.SELECTED_LINE_WIDTH, obj);      
            ctx.stroke();
        }

        ctx.restore();
    }

    // 三角形を描画
    static drawTriangle(ctx, obj) {

        const m = obj.m;

        ctx.save();

        // 射影行列をセットする(これによりワールド座標系からスクリーン座標系への射影が行われる)
        ctx.transform(m[0], m[3], m[1], m[4], m[2], m[5]);

        // 矩形を描画
        ctx.fillStyle = obj.color;
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(- obj.width / 2, obj.height);
        ctx.lineTo(obj.width / 2, obj.height);
        ctx.closePath();  
        ctx.fill();
        if(obj === Model.selected && ctx.canvas.id === Model.CANVAS_ID) {
            View.setLineWidth(ctx, Model.SELECTED_LINE_WIDTH, obj);
            ctx.stroke();
        }

        ctx.restore();
    }

    // ビュー(canvas)更新(※本関数はanim関数で呼ばれる)
    static update() {
        const ctx = $(`#${Model.CANVAS_ID}`)[0].getContext('2d');        

        ctx.save();

        ctx.strokeStyle = Model.SELECTED_STROKE_STYLE;

        // canvasをクリアする
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        // 射影行列をセットする(これによりワールド座標系からスクリーン座標系への射影が行われる)
        ctx.setTransform(Model.matrix[0], Model.matrix[3], Model.matrix[1], Model.matrix[4], Model.matrix[2], Model.matrix[5]);

        // オブジェクトを描画を描画
        Model.objects.forEach(obj => {
            View.drawObj(ctx, obj);
        });   

        ctx.restore();

        if(Model.operation.indexOf('ADD_') >= 0) {// 選択中 -> 半透過の灰色で塗りつぶす
            ctx.save();
            ctx.globalAlpha = Model.ON_ADD_GLOBAL_ALPHA;
            ctx.fillStyle = Model.ON_ADD_BACKGROUND_COLOR;
            ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
            ctx.restore();
        }   

        // ミニマップの表示更新
        View.updateMiniMapView();     
    }

    // ミニマップのビューの更新
    static updateMiniMapView() {
        const ctx = $(`#${Model.MINI_MAP_CANVAS_ID}`)[0].getContext('2d');

        ctx.save();

        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        // canvasを塗りつぶす
        ctx.globalAlpha = Model.MINI_MAP_GLOBAL_ALPHA;
        ctx.fillStyle = Model.MINI_MAP_BACKGROUND_COLOR;
        ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        const m = Model.mmMatrix;

        ctx.setTransform(m[0], m[3], m[1], m[4], m[2], m[5]);

        ctx.save();

        // 表示領域を表示するため、射影行列の逆行列をかける
        const inv = Matrix.inverse(Model.matrix);
        ctx.transform(inv[0], inv[3], inv[1], inv[4], inv[2], inv[5]);
        ctx.globalAlpha = Model.MINI_MAP_VIEW_VOLUME_GLOBAL_ALPHA;
        ctx.fillStyle = Model.MINI_MAP_VIEW_VOLUME_COLOR;
        ctx.fillRect(0, 0, Model.vp.w, Model.vp.h);

        ctx.restore();

        ctx.globalAlpha = 1;

        // オブジェクトを描画を描画
        Model.objects.forEach(obj => {
            if(obj.type === 'image') {
                View.drawImage(ctx, obj);
            } else if(obj.type === 'rect') {
                View.drawRect(ctx, obj);
            } else if(obj.type === 'circle') {
                View.drawCircle(ctx, obj);
            } else if(obj.type === 'triangle') {
                View.drawTriangle(ctx, obj);
            }
        });   

        ctx.restore();
    }
}

// コントローラークラス
class Controller {
    // 初期化
    static init() {        

        let resizeTimeoutId = -1,
            worldPrePos,           // マウス操作時の1つ前のワールド座標系のカーソル座標    
            screenRotateCenter,     // スクリーン座標系の回転中心座標
            worldRotateCenter,      // ワールド座標系の回転中心
            screenRotateBase,       // スクリーン座標系の回転基準座標
            tmpMat;

        Controller.anim();             // ビュー(canvas)の更新は、ここで行う

        $(`#${Model.MINI_MAP_CANVAS_ID}`).on('mousedown', e => {
            var screenCursorPos;  // スクリーン座標系のカーソルの座標

            // ブラウザのデフォルト動作を抑止(これをしないと、canvas上でのdragによる操作がうまく動作しない)
            e.preventDefault();

            if (Model.operation) {
                return;
            }

            Model.operation = 'MINI_MAP_VIEW_TRANSLATE';

            // スクリーン座標系のカーソルの座標を取得
            screenCursorPos = { x: e.pageX, y: e.pageY };

            // スクリーン座標系をワールド座標系に変換
            worldPrePos = Model.miniMapScreenToWorld(screenCursorPos);   

            Model.redrawFlag = true;
            View.updateDom();

        });
        // mousedown
        $(`#${Model.CANVAS_ID}`).on('mousedown', e => {
            var screenCursorPos;  // スクリーン座標系のカーソルの座標

            // ブラウザのデフォルト動作を抑止(これをしないと、canvas上でのdragによる操作がうまく動作しない)
            e.preventDefault();

            if(resizeTimeoutId !== -1) { return; } // リサイズ処理待ち

            if (Model.operation) {
                return;
            }

            // スクリーン座標系のカーソルの座標を取得
            screenCursorPos = { x: e.pageX, y: e.pageY };

            // スクリーン座標系をワールド座標系に変換
            worldPrePos = Model.screenToWorld(screenCursorPos);   

            // オブジェクトがピック出来るか調べる
            Model.selected = Model.pick(worldPrePos);
            if(!Model.selected) {
                if(!e.ctrlKey) {
                    Model.operation = 'VIEW_TRANSLATE';
                } else {
                    Model.operation = 'VIEW_ROTATE';
                    screenRotateCenter = Model.getViewRotateScreenCenter();
                    worldRotateCenter = Model.screenToWorld(screenRotateCenter);
                    screenRotateBase = screenCursorPos;
                    tmpMat = JSON.parse(JSON.stringify(Model.matrix));    // 回転前の射影行列を保持する
                }                
            } else {
                if(!e.ctrlKey) {
                    Model.operation = 'OBJECT_TRANSLATE';
                } else {
                    Model.operation = 'OBJECT_ROTATE';
                    worldRotateCenter = Model.getObjectRotateWorldCenter(Model.selected);
                    screenRotateCenter = Model.worldToScreen(worldRotateCenter);
                    screenRotateBase = screenCursorPos;
                    tmpMat = JSON.parse(JSON.stringify(Model.selected.m)); // 回転前の射影行列を保持する
                }
            }       
            Model.redrawFlag = true;
            View.updateDom();
        }); 

        // mousemove
        $(window).on('mousemove', e => {
            let screenCursorPos,  // スクリーン座標系のカーソルの座標
                worldCursorPos,     // ワールド座標系のカーソルの座標
                worldVec;   // ワールド座標系で、現在のカーソル位置と1つ前のカーソル位置の差(ベクトル)

            if(Model.operation !== 'VIEW_TRANSLATE' && Model.operation !== 'OBJECT_TRANSLATE'
                && Model.operation !== 'VIEW_ROTATE' && Model.operation !== 'OBJECT_ROTATE'
                && Model.operation !== 'MINI_MAP_VIEW_TRANSLATE') {
                return;
            }

            // スクリーン座標系のカーソルの座標を取得
            screenCursorPos = { x: e.pageX, y: e.pageY };

            // スクリーン座標系をワールド座標系に変換
            if(Model.operation !== 'MINI_MAP_VIEW_TRANSLATE') {
                worldCursorPos = Model.screenToWorld(screenCursorPos);  
            } else {
                worldCursorPos = Model.miniMapScreenToWorld(screenCursorPos);
            }            

            // ワールド座標系で、現在のカーソル位置と1つ前のカーソル位置の差(ベクトル)を求める
            worldVec = Vector.subtract(worldCursorPos, worldPrePos);

            if(Model.operation === 'VIEW_TRANSLATE') {// ビューの平行移動する                
                Model.translateView(worldVec);
            } else if(Model.operation === 'OBJECT_TRANSLATE') {// オブジェクトの平行移動をする
                Model.translateObject(Model.selected, worldVec);
            } else if(Model.operation === 'VIEW_ROTATE') {
                // 基準位置からの回転角度を求める
                const theta = Model.getAngle(screenRotateCenter, screenRotateBase, screenCursorPos);
                Model.matrix = tmpMat;  // 射影行列を回転前のものに戻す
                Model.rotateView(worldRotateCenter, theta);
            } else if(Model.operation === 'OBJECT_ROTATE') {
                // 基準位置からの回転角度を求める
                const theta = Model.getAngle(screenRotateCenter, screenRotateBase, screenCursorPos);
                Model.selected.m = tmpMat;  // 射影行列を開店前のものに戻す
                Model.rotateObject(Model.selected, worldRotateCenter, theta);
            } else if(Model.operation === 'MINI_MAP_VIEW_TRANSLATE') {// ミニマップのビューの平行移動する
                worldVec.x *= -1;
                worldVec.y *= -1;
                Model.translateView(worldVec);
            }

            // カーソルの座標をワールド座標系へ変換
            if(Model.operation !== 'MINI_MAP_VIEW_TRANSLATE') {
                worldPrePos = Model.screenToWorld(screenCursorPos);  
            } else {
                worldPrePos = Model.miniMapScreenToWorld(screenCursorPos);
            }

            // 再描画
            Model.redrawFlag = true;
        });  

        // mouseup
        $(window).on('mouseup', e => {
            if(Model.operation === 'VIEW_TRANSLATE' || Model.operation === 'OBJECT_TRANSLATE'
                || Model.operation === 'VIEW_ROTATE' || Model.operation === 'OBJECT_ROTATE'
                || Model.operation === 'MINI_MAP_VIEW_TRANSLATE' ) {
                Model.operation = '';
                //Model.fitMiniMap();
                Model.redrawFlag = true;
                View.updateDom();
            }            
        });

        // click(オブジェクト配置時)
        $(`#${Model.CANVAS_ID}`).on('click', e => {
            if(Model.operation.indexOf('ADD_') < 0) {// オブジェクト配置中でない
                return;
            }

            // スクリーン座標系のカーソルの座標を取得
            const screenCursorPos = { x: e.pageX, y: e.pageY };

            // スクリーン座標系をワールド座標系に変換
            const worldCursorPos = Model.screenToWorld(screenCursorPos);

            if(Model.operation === 'ADD_IMAGE_OBJECT') {
                if(!Model.tmpImg) { return; }
                worldCursorPos.x -= Model.tmpImg.width / 2;
                worldCursorPos.y -= Model.tmpImg.height / 2;
                Model.objects.push(Model.createImage(Model.tmpImg, worldCursorPos));
                Model.tmpImg = null;
            } else if(Model.operation === 'ADD_RECT_OBJECT') {
                worldCursorPos.x -= Model.RECT_WIDTH / 2;
                worldCursorPos.y -= Model.RECT_HEIGHT / 2;
                Model.objects.push(Model.createRect(worldCursorPos));
            } else if(Model.operation === 'ADD_CIRCLE_OBJECT') {
                Model.objects.push(Model.createCircle(worldCursorPos));
            } else if(Model.operation === 'ADD_TRIANGLE_OBJECT') {
                worldCursorPos.y -= Model.TRIANGLE_HEIGHT / 2;
                Model.objects.push(Model.createTriangle(worldCursorPos));
            }

            Model.operation = '';       
            Model.redrawFlag = true;
            View.updateDom();
        });

        // mousewheel
        $(`#${Model.CANVAS_ID}`).on('mousewheel', e => {

            let screenCursorPos,  // スクリーン座標系のカーソルの座標
                worldCursorPos,     // ワールド座標系のカーソルの座標
                rate, theta;

            e.preventDefault();

            if(Model.operation !== '') { return; }

            if(resizeTimeoutId !== -1) { return; } // リサイズ処理待ち

            // スクリーン座標系のカーソルの座標を取得
            screenCursorPos = { x: e.pageX, y: e.pageY };

            // スクリーン座標系をワールド座標系に変換
            worldCursorPos = Model.screenToWorld(screenCursorPos);

            // 拡大率又は回転角度を決定
            if(!e.ctrlKey) {
                if (e.originalEvent.wheelDelta > 0) {// 奥へ動かした -> 拡大する -> ビューボリュームを縮小する
                    rate = Model.WHEEL_ZOOM_RATE;
                } else {// 手前へ動かした -> 縮小する -> ビューボリュームを拡大する
                    rate = 1 / Model.WHEEL_ZOOM_RATE;
                }
            } else {
                if (e.originalEvent.wheelDelta > 0) {// 奥へ動かした -> 左回り
                    theta = -Model.WHEEL_ROTATE_ANGLE;
                } else {// 手前へ動かした -> 右回り
                    theta = Model.WHEEL_ROTATE_ANGLE;
                }
            }

            if(!Model.selected) {// ビューを拡大縮小又は回転する
                if(!e.ctrlKey) {
                    Model.scaleView(worldCursorPos, rate);
                } else {
                    Model.rotateView(worldCursorPos, theta);
                }                
            } else {// オブジェクトを拡大縮小又は回転する
                if(!e.ctrlKey) {                    
                    Model.scaleObject(Model.selected, worldCursorPos, rate);
                } else {
                    Model.rotateObject(Model.selected, worldCursorPos, theta);
                }
            }
            //Model.fitMiniMap();

            // 再描画
            Model.redrawFlag = true;
        });

        // resize
        $(window).on('resize', e => {
            // リサイズイベント毎に処理しないように少し時間をおいて処理する
            if(resizeTimeoutId !== -1) {
                clearTimeout(resizeTimeoutId);
                resizeTimeoutId = -1;
            }
            resizeTimeoutId = setTimeout(() => {
                // ビューポートの更新
                Model.vp = { x: 0, y: 0, w: window.innerWidth, h: window.innerHeight };
                Model.redrawFlag = true;
                resizeTimeoutId = -1;
                View.resizeCanvas();                
            }, Model.WINDOW_RESIZE_INTERVAL);
        });

        $('#add-image-button').click(Controller.addImageButton);
        $('#add-rect-button').click(Controller.addRectButton);
        $('#add-circle-button').click(Controller.addCircleButton);
        $('#add-triangle-button').click(Controller.addTriangleButton);
        $('#delete-object-button').click(Controller.deleteButton);
        $('#to-top-button').click(Controller.toTopButton);
        $('#to-bottom-button').click(Controller.toBottomButton);
        $('#fit-mini-map-button').click(Controller.fitMiniMapButton);
        $('#help-button').click(Controller.helpButton);
    }

    // 滑らかに描画できるようにrequestAnimationFrameのタイミングで必要に応じて再描画する
    static anim() {
        if (Model.redrawFlag) {// 再描画する
            Model.redrawFlag = false;
            View.update();            
        } 
        requestAnimationFrame(Controller.anim);
    }

    // 画像追加ボタン押下時の処理
    static addImageButton() {
        clear();
        $('<input>').prop({
            type: 'file',
            id: 'add-image-file',
        })
        .css({ display: 'none' })
        .appendTo($('body'));

        $('#add-image-file').trigger('click');
        $('#add-image-file').on('change', e => {

            const file = $('#add-image-file').prop('files')[0];
            const blobUrl = window.URL.createObjectURL(file);

            const img = new Image();
            img.onload = function(e) {
                Model.tmpImg = img;
                Model.operation = 'ADD_IMAGE_OBJECT';
                Model.redrawFlag = true;
                View.updateDom();                
            };
            img.src = blobUrl;
            clear();   
        });

        function clear() {
            $('#add-image-file').off('change');
            if($('#add-image-file').length) {
                $('#add-image-file').remove();
            }
        }
    }

    // 矩形追加ボタン押下時の処理
    static addRectButton() {
        if(Model.operation) { return; }
        Model.operation = 'ADD_RECT_OBJECT';               
        Model.redrawFlag = true;
        View.updateDom(); 
    }

    // 円追加ボタン押下時の処理
    static addCircleButton() {
        if(Model.operation) { return; }
        Model.operation = 'ADD_CIRCLE_OBJECT';       
        Model.redrawFlag = true;
        View.updateDom();    
    }

    // 三角追加ボタン押下時の処理
    static addTriangleButton() {
        if(Model.operation) { return; }
        Model.operation = 'ADD_TRIANGLE_OBJECT';       
        Model.redrawFlag = true;
        View.updateDom();       
    }

    // 削除ボタン押下時の処理
    static deleteButton() {
        if(!Model.selected) { return; }
        const index = Model.objects.findIndex(elm => elm === Model.selected);
        if(index >= 0) {
            Model.objects.splice(index, 1);
            Model.selected = null;
            Model.redrawFlag = true;
        }        
    }

    // to top ボタン押下時の処理
    static toTopButton() {
        if(!Model.selected) { return; }
        const index = Model.objects.findIndex(elm => elm === Model.selected);
        if(index >= 0) {
            Model.objects.splice(index, 1);
            Model.objects.push(Model.selected);
            Model.redrawFlag = true;
        }    
    }

    // to bottom ボタン押下時の処理
    static toBottomButton() {
        if(!Model.selected) { return; }
        const index = Model.objects.findIndex(elm => elm === Model.selected);
        if(index >= 0) {
            Model.objects.splice(index, 1);
            Model.objects.unshift(Model.selected);
            Model.redrawFlag = true;
        }    
    }

    // fit mini map ボタン押下時の処理
    static fitMiniMapButton() {
        Model.fitMiniMap();
        Model.redrawFlag = true;
    }

    // ヘルプボタン押下時の処理
    static helpButton() {
        let content = `
        対応ブラウザ: PC版のChrome
        -----------------------------------------------
        [仕様]
        左下にミニマップ表示: ミニマップに可視範囲が表示される
        オブジェクトの選択: オブジェクトをマウスダウン
        ビューの平行移動: マウスドラッグ(オブジェクト未選択状態)
        ビューの拡大縮小: マウスホイール操作(オブジェクト未選択状態)
        ビューの回転: Ctrl + マウスドラッグ(オブジェクト未選択状態)
        ビューの回転: Ctrl + マウスホイール操作(オブジェクト未選択状態)
        オブジェクトの平行移動: オブジェクト選択状態でマウスドラッグ
        オブジェクトの拡大縮小: オブジェクト選択状態でマウスホイール操作
        オブジェクトの回転: オブジェクト選択状態でCtrl + マウス操作
        オブジェクトの回転: オブジェクト選択状態でCtrl + マウスホイール操作
        オブジェクトの追加: add xxxx ボタン押下
        オブジェクトの削除: delete object ボタン押下
        オブジェクトを最前面にする: to top ボタン押下
        オブジェクトを最背面にする: to bottom ボタン押下
        ミニマップのビューの平行移動: マウスドラッグ(オブジェクト未選択状態)
        ミニマップにビューボリュームをフィットする: fit mini map ボタン押下
        ※ドラッグによる回転時の中心は
          ビューの回転であれば、canvasの中心
          オブジェクトの回転であれば、オブジェクトの重心
        ※マウスホイールによる回転の中心は
         ビューの回転、オブジェクトの回転ともにカーソルの座標
        -----------------------------------------------
        `;    
        alert(content);
    }    
}

// 初期化
$(() => {
    Model.init();       // モデルの初期化
    View.init();        // ビューの初期化
    Controller.init();  // コントローラーの初期化    
});
</script>
</head>
<body>
<!-- メインのcanvas -->
<canvas id="canvas"></canvas>
<!-- ミニマップ用のcanvas -->
<canvas id="mini-map-canvas"></canvas>
<div id="message-div"></div>
<div id="button-area">
    <input id="add-image-button" type="button" value="add image"><br>
    <input id="add-rect-button" type="button" value="add rect"><br>
    <input id="add-circle-button" type="button" value="add circle"><br>
    <input id="add-triangle-button" type="button" value="add triangle"><br>
    <input id="delete-object-button" type="button" value="delete object"><br>
    <input id="to-top-button" type="button" value="to top"><br>
    <input id="to-bottom-button" type="button" value="to bottom"><br>
    <input id="fit-mini-map-button" type="button" value="fit mini map"><br>
    <input id="help-button" type="button" value="help"><br>
</div>
</body>
</html>
6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6