JavaScriptでRPGを作ろう!壁やキャラクタへの衝突判定

JavaScriptでRPGを作ろう!壁やキャラクタへの衝突判定

「ゲーム作りって大変そう。RPGのWebアプリを作ってみたいけどどうやるの?」そんな疑問に答えていきます!前回はNPCが自由に歩き回るようになりました。今回はプレイヤー,NPC同士の衝突や壁などの境界への衝突について解説していきます。

前回の記事を見逃した方は「JavaScriptでRPGを作ろう!自由に歩き回るNPCも登場」をご覧ください。

今回の記事の完成版はこちらで確認できます。[デモ]

HTML解説

以前作成したHTMLと変わらないので割愛します。記事を見逃した方は「JavaScriptでRPGを作ろう!自作したマップをブラウザに表示しよう!」をご覧ください。

マップファイルに壁を追加

Tiled Map Editorを起動して自作したマップを編集します。その前に壁画像を準備しましょう。

壁画像

黒で塗りつぶした32×32ピクセルのpng画像を準備します。ご自分で作成されてもかまいませんが、こちらの画像を使ってもかまいません

壁画像
wall.png

マップの編集

まずはじめに、壁用レイヤーを作ります。作成した壁レイヤーはレイヤーの中で最上位にすると便利です。私の作成したマップでは壁レイヤーを7番目に作りました。

Tiledマップレイヤー追加

次に壁画像をタイルセットに読み込みます。

tiledタイルセット追加

続いて壁レイヤーに壁画像をスタンプしていきます。壁や通り抜けさせたくない家具などを壁画像で塗りつぶしてください

tiled壁塗りつぶし

終わりましたら、壁レイヤーを非表示に設定してJavaScriptマップファイルとしてエクスポートしておきましょう。

JavaScript解説

変数定義

var posChar = [];	// NPCの位置

NPCの位置を格納する配列を定義します。

Characterクラスの改造

PlayerCharacter()クラスやNonPlayerCharacter()クラスから呼び出す衝突判定メソッドを定義します。メソッドにはxとyの仮引数がありますが、いずれも移動後の座標(マス)を指し示すように呼び出します。

////////////////////////////////////////////////////////
// NPCとの衝突判定
collisionNpc(x, y) {
    for(k in posChar) {
        if(x == posChar[k][0] && y == posChar[k][1])	return true;
    }
    return false;
}

////////////////////////////////////////////////////////
// 壁との衝突判定
collisionKabe(x, y) {
    if(tmap.getTileIndex(6, x, y) != 0) return true;
    else								return false;
}

////////////////////////////////////////////////////////
// マップの外との衝突判定
collisionOutsideMap(x, y) {
    if(tmap.getTileIndex(0, x, y) == undefined)
        return true;
    else
        return false;
}
collisionNpc()NPCとの衝突判定メソッド。衝突時trueを返す。
collisionKabe()壁との衝突判定メソッド。タイルインデックスが0でない時衝突とみなしtrueを返す。
collisionOutsideMap()マップの外との衝突判定メソッド。タイルインデックスがundefinedの時衝突とみなしtrueを返す。

collisionKabe()メソッドについて補足します。tmap.getTileIndex()メソッドの呼び出しの第1引数に’6’を指定していますが、ここは壁レイヤーのインデックスを指定します。今回の場合’7’番目のレイヤーが壁レイヤーなのですが、インデックスは’0’から数えるので’6’となります

PlayerCharacterクラスの改造

////////////////////////////////////////////////////////
// 衝突判定
collision(x, y) {
  // 壁との衝突判定
  if(this.collisionKabe(x, y))	return true;

  // NPCとの衝突判定
  if(this.collisionNpc(x, y))		return true;

  // マップの外との衝突判定
  if(this.collisionOutsideMap(x, y))	return true;

  return false;
}

衝突判定をまとめて行うcollision()メソッドを定義します。このメソッドでは親クラスで定義した各衝突判定メソッドを次々と呼び出していきます。

// 向き決定
if (this.x%1 == 0 && this.y%1 == 0 && keyIsPressed) {
  if(keyCode == LEFT_ARROW) {
    this.direction = 1;
    x -= walkSpeed;

    // 衝突判定
    if(this.collision(floor(x), this.y))	x = this.x;
  }
  if(keyCode == RIGHT_ARROW) {
    this.direction = 2;
    x += walkSpeed;

    // 衝突判定
    if(this.collision(ceil(x), this.y))	x = this.x;
  }
  if(keyCode == UP_ARROW) {
    this.direction = 3;
    y -= walkSpeed;

    // 衝突判定
    if(this.collision(this.x, floor(y)))	y = this.y;
  }
  if(keyCode == DOWN_ARROW) {
    this.direction = 0;
    y += walkSpeed;

    // 衝突判定
    if(this.collision(this.x, ceil(y)))	y = this.y;
  }
}

向き決定のブロックから、それぞれthis.collision()衝突判定メソッドを呼び出していきます。this.xとthis.yに移動前の位置を保持しているので、衝突した場合は移動前の位置に戻してあげます。

NonPlayerCharacterクラスの改造

var befX = this.mx;	// 現在位置
var befY = this.my;	// 現在位置

現在位置を退避しておきます。

// NPCの位置
posChar[this.name] = [this.cx, this.cy];

NPCの位置を配列に格納します。

////////////////////////////////////////////////////////
// PCとの衝突判定
collisionPc(x, y) {
  if(pc.x == x && pc.y == y)	return true;
  else						return false;
}

PCとの衝突判定メソッドcollisionPc()を定義します。衝突時はtrueを返します。

////////////////////////////////////////////////////////
// 衝突判定
collision(x, y) {
  // PCとの衝突判定
  if(this.collisionPc(x, y))	return true;

  // NPCとの衝突判定
  if(this.collisionNpc(x, y))	return true;

  // 壁との衝突判定
  if(this.collisionKabe(x, y))	return true;

  // マップの外との衝突判定
  if(this.collisionOutsideMap(x, y))	return true;

  return false;
}

衝突判定をまとめて行うcollision()メソッドを定義します。このメソッドでは親クラスで定義した各衝突判定メソッドと、先ほど定義したcollisionPc()メソッドを呼び出して衝突判定を行います。

// 自由移動
if(!this.fixed && this.mx == 0 && this.my == 0 && round(random(0, 120)) == 0) {
  this.direction = round(random(0, 3));
  switch(this.direction) {
  case 1:
    this.mx -= walkSpeed;

    // 衝突判定
    if(this.collision(floor(this.cx+this.mx), this.cy+this.my))	this.mx = befX;
    break;
  case 2:
    this.mx += walkSpeed;

    // 衝突判定
    if(this.collision(ceil(this.cx+this.mx), this.cy+this.my))	this.mx = befX;
    break;
  case 3:
    this.my -= walkSpeed;

    // 衝突判定
    if(this.collision(this.cx+this.mx, floor(this.cy+this.my)))	this.my = befY;
    break;
  case 0:
    this.my += walkSpeed;

    // 衝突判定
    if(this.collision(this.cx+this.mx, ceil(this.cy+this.my)))	this.my = befY;
    break;
  }
}

自由移動のブロックから、それぞれthis.collision()衝突判定メソッドを呼び出していきます。befXとbefYに移動前の位置を保持しているので、衝突した場合は移動前の位置に戻してあげます。

完成ソースコード

以上で今回の解説はおわりです。完成したソースをブラウザで確認してみましょう。ブラウザで動作確認するにはWebサーバー環境である必要があります。あるいはローカルWebサーバーを立ち上げて確認してみてください。

フォルダ構成

/(root)
 ┣━ /data
 ┃   ┣━ village.js  <- JavaScriptマップファイル
 ┃   ┣━ CastleTown-C-1.png  <- マップチップ
 ┃   ┣━ pipo-map001_at-sabaku.png  <- マップチップ
 ┃   ┣━ wall.png  <- マップチップ [今回用意したもの]
 ┃   ┣━   :
 ┃   ┣━ pipo-charachip029.png  <-  キャラクタチップ
 ┃   ┣━ pipo-charachip004.png  <-  キャラクタチップ
 ┃   ┗━ pipo-charachip001.png  <-  キャラクタチップ
 ┣━ index.html
 ┣━ main.js
 ┗━ p5.tiledmap.js

HTML

<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title>RPGサンプル</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.1/p5.min.js" integrity="sha512-NxocnqsXP3zm0Xb42zqVMvjQIktKEpTIbCXXyhBPxqGZHqhcOXHs4pXI/GoZ8lE+2NJONRifuBpi9DxC58L0Lw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="./p5.tiledmap.js"></script>
<script src="./data/village.js"></script>
<script src="./main.js"></script>

<style type="text/css">
body {
  background-color: #666666;
}
h1 {
  color: #ffffff;
}
main {
  width: 640px;
  height: 640px;
  margin: 0 auto;
  background-color: #333333;
}
</style>

</head>

<body>
  <header>
    <h1>RPGサンプル</h1>
  </header>
  <main></main>
  <p style="text-align: center;">©2022 qoomei.com All rights reserved.</p>
</body>
</html>

main.js

const IMG_CHAR_W = 32;	// キャラクタ幅
const IMG_CHAR_H = 32;	// キャラクタ高さ

var tmap;	// カレントマップ
var x, y;	// キャラクタ位置

// インスタンス
var pc;				// プレイヤー
var npc = [];		// NPC

var posChar = [];	// NPCの位置

////////////////////////////////////////////////////////////////////////////////
// プリロード
function preload() {
  // 主人公の位置
  x = 11;
  y = 4;

  // キャラクタクラス
  pc = new PlayerCharacter(loadImage("data/pipo-charachip029.png"));

  // NPCクラス
  npc["mother"] = new NonPlayerCharacter(loadImage("data/pipo-charachip004.png"));
  npc["npc2"] = new NonPlayerCharacter(loadImage("data/pipo-charachip001.png"), 1);

  // デフォルトマップ
  loadTiledMap("village", "data", mapLoaded);
}

////////////////////////////////////////////////////////////////////////////////
// セットアップ
function setup() {
  let cnv = createCanvas(640, 640);
}

////////////////////////////////////////////////////////////////////////////////
// ドロー
function draw() {
  background(tmap.getBackgroundColor());
  tmap.draw(x, y);

  // プレイヤー描画
  pc.draw();

  // NPC描画
  for(k in npc) {
    npc[k].draw();
  }
}

////////////////////////////////////////////////////////////////////////////////
// マップロード
function mapLoaded(newMap) {
  tmap = newMap;
  tmap.setPositionMode("MAP");
  tmap.setDrawMode(CENTER);

  // NPC初期化
  npc["mother"].initialize(5, 3, "mother");
  npc["npc2"].initialize(6, 4, "npc2");
}

////////////////////////////////////////////////////////////////////////////////
// キャラクタクラス
class Character {
  ////////////////////////////////////////////////////////
  // 初期化
  constructor(img, d = 0) {
    this.img = img;		// キャラクタチップ

    this.direction = d;	// 向き
  }

  ////////////////////////////////////////////////////////
  // キャラクタ描画
  drawCharacter(cx, cy) {
    // アニメーション
    var dx = floor(frameCount/15)%4;
    if(dx == 3)	dx = 1;	// 0->1->2->1

    imageMode(CENTER);
    image(this.img,
      cx, cy,
      IMG_CHAR_W, IMG_CHAR_H,
      dx * IMG_CHAR_W, this.direction * IMG_CHAR_H,
      IMG_CHAR_W, IMG_CHAR_H);
  }

  ////////////////////////////////////////////////////////
  // NPCとの衝突判定
  collisionNpc(x, y) {
    for(k in posChar) {
        if(x == posChar[k][0] && y == posChar[k][1])	return true;
    }
    return false;
  }

  ////////////////////////////////////////////////////////
  // 壁との衝突判定
  collisionKabe(x, y) {
    if(tmap.getTileIndex(6, x, y) != 0) return true;
    else								return false;
  }

  ////////////////////////////////////////////////////////
  // マップの外との衝突判定
  collisionOutsideMap(x, y) {
    if(tmap.getTileIndex(0, x, y) == undefined)
      return true;
    else
      return false;
  }
}

////////////////////////////////////////////////////////////////////////////////
// プレイヤークラス
class PlayerCharacter extends Character {
  ////////////////////////////////////////////////////////
  // 初期化
  constructor(img) {
    super(img);

    this.x;
    this.y;
  }

  ////////////////////////////////////////////////////////
  // キャラクタ描画
  draw() {
    // 歩く速さ
    let walkSpeed = 0.05;

    // 描画
    this.drawCharacter(width / 2, height / 2);

    this.x = round(x*100)/100;
    this.y = round(y*100)/100;

    // 向き決定
    if (this.x%1 == 0 && this.y%1 == 0 && keyIsPressed) {
      if(keyCode == LEFT_ARROW) {
        this.direction = 1;
        x -= walkSpeed;

        // 衝突判定
        if(this.collision(floor(x), this.y))	x = this.x;
      }
      if(keyCode == RIGHT_ARROW) {
        this.direction = 2;
        x += walkSpeed;

        // 衝突判定
        if(this.collision(ceil(x), this.y))	x = this.x;
      }
      if(keyCode == UP_ARROW) {
        this.direction = 3;
        y -= walkSpeed;

        // 衝突判定
        if(this.collision(this.x, floor(y)))	y = this.y;
      }
      if(keyCode == DOWN_ARROW) {
        this.direction = 0;
        y += walkSpeed;

        // 衝突判定
        if(this.collision(this.x, ceil(y)))	y = this.y;
      }
    }

    // 1マス進める
    if (this.x%1 != 0) {
      if(this.direction == 1)	x -= walkSpeed;
      if(this.direction == 2)	x += walkSpeed;
    }
    if (this.y%1 != 0) {
      if(this.direction == 3)	y -= walkSpeed;
      if(this.direction == 0)	y += walkSpeed;
    }
  }

  ////////////////////////////////////////////////////////
  // 衝突判定
  collision(x, y) {
    // 壁との衝突判定
    if(this.collisionKabe(x, y))	return true;

    // NPCとの衝突判定
    if(this.collisionNpc(x, y))		return true;

    // マップの外との衝突判定
    if(this.collisionOutsideMap(x, y))	return true;

    return false;
  }
}

////////////////////////////////////////////////////////////////////////////////
// ノンプレイヤークラス
class NonPlayerCharacter extends Character {
  ////////////////////////////////////////////////////////
  // 初期化
  constructor(img, d = 0) {
    super(img, d);

    this.cx;			// キャラクタ位置X
    this.cy;			// キャラクタ位置Y

    this.fixed = false;	// true:キャラクタ自由移動しない
    this.mx = 0;		// キャラクタ自由移動X
    this.my = 0;		// キャラクタ自由移動Y
  }

  ////////////////////////////////////////////////////////
  // 初期化
  initialize(cx, cy, name, fixed = false) {
    this.cx = cx;
    this.cy = cy;
    this.name = name;

    this.fixed = fixed;
    this.mx = 0;
    this.my = 0;
  }

  ////////////////////////////////////////////////////////
  // キャラクタ描画
  draw() {
    var befX = this.mx;	// 現在位置
    var befY = this.my;	// 現在位置

    // 歩く速さ
    let walkSpeed = 0.05;

    // NPCの位置
    posChar[this.name] = [this.cx, this.cy];

    // キャラクタ位置
    var cx = (width / 2 / IMG_CHAR_W + this.cx - x) * IMG_CHAR_W;
    var cy = (height / 2 / IMG_CHAR_H + this.cy - y) * IMG_CHAR_H;

    // 画面外は描画しない
    if(round((cx+this.mx*IMG_CHAR_W)*100)/100 <= IMG_CHAR_W * -1    || round((cy+this.my*IMG_CHAR_H)*100)/100 <= IMG_CHAR_H * -1)		return;
    if(round((cx+this.mx*IMG_CHAR_W)*100)/100 >= IMG_CHAR_W + width || round((cy+this.my*IMG_CHAR_H)*100)/100 >= IMG_CHAR_H + height)	return;

    // 描画
    this.drawCharacter(cx+this.mx*IMG_CHAR_W, cy+this.my*IMG_CHAR_H);

    // 自由移動
    if(!this.fixed && this.mx == 0 && this.my == 0 && round(random(0, 120)) == 0) {
      this.direction = round(random(0, 3));
      switch(this.direction) {
      case 1:
        this.mx -= walkSpeed;

        // 衝突判定
        if(this.collision(floor(this.cx+this.mx), this.cy+this.my))	this.mx = befX;
        break;
      case 2:
        this.mx += walkSpeed;

        // 衝突判定
        if(this.collision(ceil(this.cx+this.mx), this.cy+this.my))	this.mx = befX;
        break;
      case 3:
        this.my -= walkSpeed;

        // 衝突判定
        if(this.collision(this.cx+this.mx, floor(this.cy+this.my)))	this.my = befY;
        break;
      case 0:
        this.my += walkSpeed;

        // 衝突判定
        if(this.collision(this.cx+this.mx, ceil(this.cy+this.my)))	this.my = befY;
        break;
      }
    }

    // 1マス進める
    if (this.mx != 0 || this.my != 0) {
      switch(this.direction) {
      case 1:
        this.mx -= walkSpeed;
        break;
      case 2:
        this.mx += walkSpeed;
        break;
      case 3:
        this.my -= walkSpeed;
        break;
      case 0:
        this.my += walkSpeed;
        break;
      }
    }

    // 1マス進んだら止める
    if(this.mx != 0 && abs(round(this.mx*100)/100) == 1) {
      this.cx += round(this.mx);
      this.mx = 0;
    }
    if(this.my != 0 && abs(round(this.my*100)/100) == 1) {
      this.cy += round(this.my);
      this.my = 0;
    }
  }

  ////////////////////////////////////////////////////////
  // 衝突判定
  collision(x, y) {
    // PCとの衝突判定
    if(this.collisionPc(x, y))	return true;

    // NPCとの衝突判定
    if(this.collisionNpc(x, y))	return true;

    // 壁との衝突判定
    if(this.collisionKabe(x, y))	return true;

    // マップの外との衝突判定
    if(this.collisionOutsideMap(x, y))	return true;

    return false;
  }

  ////////////////////////////////////////////////////////
  // PCとの衝突判定
  collisionPc(x, y) {
    if(pc.x == x && pc.y == y)	return true;
    else						return false;
  }
}

おわりに

プレイヤーもNPCも壁やキャラクタに衝突するようになりました。第一弾~第六弾とJavaScriptでRPGを作ろう!シリーズを書き進めていきましたがいかがでしたでしょうか。まだまだ実装すべきところは多々ありますが、今回の記事でこのシリーズを一旦終了とします。ありがとうございました。