2018/07/18

[JS]クリックした位置までオブジェクトを最短距離で移動させる

シミュレーションゲームやリアルタイムストラテジーゲーム(RTS)でよくある「クリックした位置までキャラクタを移動させる」動作をJavaScript+Canvas APIで実装する。

イメージは以下のような感じ。

現在位置からクリックした位置までの最短距離と移動速度を求める



現在位置からクリックした位置までの最短距離を求める

まずは現在位置からクリックした位置までの最短距離を求める。

現在位置(x0,y0)からクリックした位置(x1,y1)までの直線を斜辺とした直角三角形をつくれば、その「斜辺」=「最短距離」となる。では、斜辺はどのように求めれば良いかというと、ピタゴラスの定理(c^2=a^2+b^2)を使う。
// 現在位置(x0,y0)を仮に(3,5)と置く
const [x0, y0] = [3, 5];

// クリックした位置(x1,y1)を仮に(8,10)と置く
const [x1, y1] = [8, 10];

// 最短距離(斜辺)
const d = Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2);
// const d = Math.sqrt(Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2);

分割代入(const [x0, y0] = [3, 5])や指数演算子((x1 - x0) ** 2)を使っているので、古いブラウザで確認するときは注意してください。

ピタゴラスの定理より「x軸の距離の2乗」と「y軸の距離の2乗」は「斜辺の2乗」と等しくなるので、それを利用して(x0,y0)〜(x1,y1)間の距離を求めている。


x、y軸方向のそれぞれの移動速度を求める

次に斜辺の軌道をするためには、x軸、y軸方向のそれぞれの移動速度を求める。

基準となる速度(v)を5px/frame(1フレームで5px移動)とした場合、そのままx軸、y軸方向の速度に当てはめてしまうと最短距離の移動ではなくなってしまう。そのためにそれぞれの移動速度を求める必要がある。

まずは現在位置からクリックした位置までの移動に要する時間を求める。
次にその時間を使って、x軸、y軸それぞれの移動速度を求める。

このあたりは小学校の算数で習った「距離、時間、速さ」の式(「はじき」の法則などと呼ばれるやつ)を使う。
// 基準となる速度: 5px/frame
const v = 5;

// 最短距離を5px/frameで移動したときに要する時間(距離÷速さ)
const t = d / v;

// x軸、y軸のそれぞれの距離
const [dx, dy] = [x1 - x0, y1 - y0];

// x軸方向の速度(距離÷時間)
const vx = dx / t;

// y軸方向の速度(距離÷時間)
const vy = dy / t;

これでx軸、y軸方向のそれぞれの速度が求められたので、あとはこの速度でオブジェクトを移動させれば良い。



クリックした位置までオブジェクトを最短距離で移動させる


<div id="app"></div>
class Game {
  constructor(el) {
    /** Canvas */
    this.canvas = document.createElement("canvas");
    this.canvas.width = 256;
    this.canvas.height = 224;
    this.ctx = this.canvas.getContext("2d");
    document.querySelector(el).appendChild(this.canvas);

    /** Controller */
    this.tap = { isTap: false };
    this.canvas.addEventListener("mousedown", e => {
      this.tap = {
        isTap: true,
        x: e.layerX,
        y: e.layerY
      };
    });
    this.canvas.addEventListener("mousemove", e => {
      if (this.tap.isTap) {
        this.tap = {
          isTap: true,
          x: e.layerX,
          y: e.layerY
        };
      }
    });
    this.canvas.addEventListener("mouseup", () => {
      this.tap = {
        isTap: false
      };
    });

    this.items = [];
  }

  add(item) {
    this.items.push(item);
  }

  remove(item) {
    this.items = this.items.filter(a => a !== item);
  }

  start() {
    this.tick();
  }

  stop() {
    cancelAnimationFrame(this.timer);
  }

  tick() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.items.forEach(a => {
      a.update(this);
      a.draw(this);
    });

    this.timer = requestAnimationFrame(this.tick.bind(this));
  }
}

class BaseItem {
  constructor(opt) {
    this.x = opt.x || 0;
    this.y = opt.y || 0;
    this.w = opt.w || 10;
    this.h = opt.h || 10;
  }

  draw(game) {}
  update(game) {}
}

class Player extends BaseItem {
  constructor() {
    super({ w: 16, h: 16 });

    // 速度の定数
    this.v = 3;
    // 実際のx/y軸方向の速度
    this.vx = this.vy = 0;
    // 現在座標〜タップした座標の距離
    this.dx = this.dy = 0;

    // タップした座標
    this.x1 = this.y1 = 0;
    // タップした場所に表示する円の半径
    this.r = 0;

  }

  draw(game) {
    game.ctx.save();
    // オブジェクトの描画
    game.ctx.fillRect(this.x, this.y, this.w, this.h);

    // タップした場所
    if (this.x1 > 0 && this.y1 > 0) {
      game.ctx.strokeStyle = "gray";
      game.ctx.beginPath();
      game.ctx.arc(this.x1, this.y1, this.r, 0, Math.PI * 2);
      game.ctx.stroke();
      if (this.r < this.w) {
        this.r++;
      }
    } else {
      this.r = 0;
    }
    game.ctx.restore();
  }

  update(game) {
    if (game.tap.isTap) {
      this.x1 = game.tap.x - this.w / 2
      this.y1 = game.tap.y - this.h / 2
      this.dx = this.x1 - this.x;
      this.dy = this.y1 - this.y;

      // 最短距離 / 速度 = 1フレームで進む速度の割合
      const t = Math.sqrt(this.dx ** 2 + this.dy ** 2) / this.v;
      // const t = Math.sqrt(Math.pow(this.dx, 2) + Math.pow(this.dy, 2)) / this.v;

      this.vx = this.dx / t;
      this.vy = this.dy / t;
    }

    // 座標に速度を足し込む
    this.x += this.vx;
    this.y += this.vy;

    // タップした場所に到達したか
    if (Math.abs(this.x - this.x1) < this.w / 2) {
      this.vx = 0;
      this.x1 = 0;
    }
    if (Math.abs(this.y - this.y1) < this.h / 2) {
      this.vy = 0;
      this.y1 = 0;
    }
  }
}

const game = new Game("#app");
const player = new Player();
game.add(player);

game.start();

大半はループ処理させるためのコードなので、Player.update()のメソッドを見てもらえると、どのように実装しているかわかると思う。

最後に注意点なのだが、Canvas APIはサブピクセル(浮動小数点の座標)のレンダリングがサポートされているので、当記事のコードでは小数点がでても気にせず使っている。しかし、整数以外の座標で描画する場合、画像にアンチエイリアスが自動的にかかり輪郭がボヤけることがあるので注意が必要だ。



以上

written by @bc_rikko

0 件のコメント :

コメントを投稿