JavaScriptでRPGを作ろう!自由に歩き回るNPCも登場

JavaScriptでRPGを作ろう!自由に歩き回るNPCも登場

「ゲーム作りって大変そう。RPGのWebアプリを作ってみたいけどどうやるの?」そんな疑問に答えていきます!前回は主人公の描画とマップがスクロールするところまで解説していきましたが、今回はNPC(ノンプレイヤーキャラクター)を描画して、そのNPCが自由に歩き回るところまで解説していきます。

前回の記事を見逃した方は「JavaScriptでRPGを作ろう!主人公のアニメーションとマップスクロールを解説」をご覧ください。

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

HTML解説

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

キャラチップの準備

画像素材を無料配布している超大手サイトの「ぴぽや倉庫」さんから、サイズ32×32ピクセルのキャラクタチップをダウンロードしましょう。

ダウンロードしてきたら、dataフォルダにキャラクタチップファイルを置きます。

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

JavaScript解説

変数定義

// インスタンス
var npc = [];	// NPC

NPC用のインスタンスを定義しましょう。NPCは複数いるので配列で定義します。

NonPlayerCharacterクラス

NonPlayerCharacterクラスはCharacterクラスを継承して定義します。

コンストラクタ

////////////////////////////////////////////////////////
// 初期化
constructor(img, d = 0) {
  super(img, d);

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

親クラスコンストラクタを呼び出します。this.cx, this.cyメンバ変数を定義します。

初期化(initialize)メソッド

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

初期化メソッドを用意します。

this.cxキャラクタ位置Xを格納するメンバ変数(単位:セル)
this.cyキャラクタ位置Yを格納するメンバ変数(単位:セル)
this.nameキャラクタ名を格納するメンバ変数(今回は使用しない)

キャラクタ描画(draw)メソッド

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

  // キャラクタ位置
  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;

  // 描画
  this.drawCharacter(cx, cy);
}

5行目では歩く速さを定義します。1フレームあたり0.05なので1秒間で3マス進ませます。

7~9行目ではキャラクタの描画位置をピクセルで算出しています。

11~12行目では親クラスのdrawCharacter()メソッドを呼び出します。先ほど算出した座標を指定します。

インスタンスの作成

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

preload()関数でNPCクラスのインスタンスを作成します。

NPCの描画

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

draw()関数でnpc[].draw()メソッドを呼び出して、NPCを描画します。

npcインスタンスの初期化

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

mapLoaded()関数でnpc[]インスタンスの初期化メソッドを呼び出します。

途中経過をデモで確認

ここまでのコードでNPCが画面定位置に表示されるようになりました。デモで確認してみてください。[デモ]

NPCの自由移動

続いてNPCが自由に移動をするように実装を加えていきます。

NonPlayerCharacterクラスの改造

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

NonPlayerCharacterクラスのコンストラクタにメンバ変数を定義し初期化も行います。

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

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

initialize()メソッドは上記のように書き換えます。第4番目の仮引数はNPCに自由移動を許可するかを指定します。デフォルトではfalseなので自由移動を許可します。

ここから先は、NonPlayerCharacterクラスのdraw()メソッドに追記・改造していく解説です。

// 画面外は描画しない
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()メソッドを呼び出す処理の前に上記の処理を追加します。ここでは画面の外にNPCがいる場合、描画処理を行わないようにアレンジしています。

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

描画処理は上記のように書き換えます。this.mxとthis.myはNPCが移動した距離をそれぞれ保持しています。

// 自由移動
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;
    break;
  case 2:
    this.mx += walkSpeed;
    break;
  case 3:
    this.my -= walkSpeed;
    break;
  case 0:
    this.my += walkSpeed;
    break;
  }
}

if分の中での条件を次の表にまとめました。すべての条件に当てはまる時、自由移動が始まります。自由移動中(隣のマスへの移動が終わるまで)はこのブロックには入らない仕組みとなっています。

!this.fixed自由移動の許可を調べる。
this.mx == 0X方向への自由移動をしていないことを調べる。
this.my == 0Y方向への自由移動をしていないことを調べる。
round(random(0, 120)) == 00~120までの乱数を求め、その結果が0となる場合自由移動を許可する。
// 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;
  }
}

「this.mx != 0 || this.my != 0」はXまたはY方向への移動中かを調べています。1マス進むまでwalkSpeed分移動を進めます。

// 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;
}

移動中の場合、1マス進んだかを判定しています。進んだ場合this.cxまたはthis.cyを加算し移動を終了します。

完成ソースコード

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

フォルダ構成

/(root)
 ┣━ /data
 ┃   ┣━ village.js  <- JavaScriptマップファイル
 ┃   ┣━ CastleTown-C-1.png  <- マップチップ 1つ目
 ┃   ┣━ pipo-map001_at-sabaku.png  <- マップチップ 2つ目
 ┃   ┣━   :
 ┃   ┣━ 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

////////////////////////////////////////////////////////////////////////////////
// プリロード
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);
    }
}

////////////////////////////////////////////////////////////////////////////////
// プレイヤークラス
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(keyCode == RIGHT_ARROW) {
        this.direction = 2;
        x += walkSpeed;
      }
      if(keyCode == UP_ARROW) {
        this.direction = 3;
        y -= walkSpeed;
      }
      if(keyCode == DOWN_ARROW) {
        this.direction = 0;
        y += walkSpeed;
      }
    }

    // 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;
    }
  }
}

////////////////////////////////////////////////////////////////////////////////
// ノンプレイヤークラス
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() {
    // 歩く速さ
    let walkSpeed = 0.05;

    // キャラクタ位置
    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;
        break;
      case 2:
        this.mx += walkSpeed;
        break;
      case 3:
        this.my -= walkSpeed;
        break;
      case 0:
        this.my += walkSpeed;
        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;
    }
  }
}

おわりに

NCPも登場して自由に歩き回り、画面がにぎやかになってきました。次回は壁への衝突方法について解説していきたいと思います!