「ゲーム作りって大変そう。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 == 0 | X方向への自由移動をしていないことを調べる。 |
this.my == 0 | Y方向への自由移動をしていないことを調べる。 |
round(random(0, 120)) == 0 | 0~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も登場して自由に歩き回り、画面がにぎやかになってきました。次回は壁への衝突方法について解説していきたいと思います!