p5.jsで「3D迷路脱出ゲーム」を作ってみた


2018年06月10日
With
p5.jsで「3D迷路脱出ゲーム」を作ってみた はコメントを受け付けていません

前回のエントリでは、穴掘り法を使って迷路を自動生成するスケッチを作ったのですが、

p5.jsで一生遊ぶのに困らないくらいの迷路を自動生成する

これを作っている途中で、どうしても自動生成した迷路をスケッチ上でそのまま遊べるようにしたくなり、現実逃避勉強も兼ねてミニゲームに作り替えてみました。

●Maze Explorer 3D(クリックすると別タブが開きます。キーボード必須。速度面でWin&Chrome&できるだけ強いグラボ推奨)

起動すると、最初は前回のスケッチと同じように迷路を自動生成するのですが…

迷路が完成すると…

おや?

おやおや?

ぐおーん。

ストン。という感じで、画面がプレイヤー主観の3Dに変化します。この状態から1~2秒ほど待って頂くと、以下のキーでプレイヤーを操作できるようになります。

「スペースキー」を押すと、画面が主観モードからマップモードに切り替わります。もう一度押すと戻ります。画面の見方は次のとおり。

プレイヤーは最初、右下の赤いフロアの上にいます。目指すゴールは左上の青いフロアなのですが、ゴールするためには、迷路内にランダムにセットされる3つの「キーオブジェクト」(回転している半透明の緑色の立方体)をすべて集めた上で、青いフロアに乗る必要があります。キーオブジェクトを取るときは、道の中心近くを通るような感じで突っ込めばOKです。

主観モードでの操作方法は、「W」で前進「S」で後退、「A」「D」では位置は動かず、左または右に身体の向きを回転します。FPSゲームっぽくしてみました。ちなみに、前進と後退は主観モードでのみ可能で、マップモードの時には向きの回転しかできません。マップで自分の位置と向き、目的地の方向を頭に入れて、主観で記憶を頼りに進んでいきましょう。

3つのキーオブジェクトをすべて取ってから、青のフロアに乗ればゴール。その後は視点が俯瞰に戻って迷路の自動生成から再スタートです。スコアもタイムも出ません(笑)。時間など気にせず、コンピューターが次々と作り出す迷路の中を心ゆくまで彷徨っていただければ幸いです。

この1年の集大成的なスケッチに

僕が「[普及版]ジェネラティブ・アート―Processingによる実践ガイド」という本と出会ってクリエイティブコーディングを始め、このブログにp5.jsに関する最初のエントリを書いたのは、昨年の6月のことでした。あれから、そろそろ1年になるのですね。

ド文系が「p5.js」でジェネラティブアートに目覚める

今回のミニゲームは、結果的にあれから1年の間にポツポツと描いてきたスケッチに含まれていたいろんな要素の集大成になりました。

「WebGLモード」やそこでのカメラの扱い方などについては、昨年末から今年のはじめにかけていろいろ試しました。2次元配列やオブジェクトの扱い方あたりは、最初に読んだ「[普及版]ジェネラティブ・アート―Processingによる実践ガイド」のサンプルスケッチをp5.jsに移植しながら勉強しました。

迷路生成が終わってから、主観モードへ視点が移動する際の動きには、ちょっと前に試した「イージング」を。FPSゲーム的なプレイヤー移動については「ベクトル」を使っています。細かいところだと、WebGLモードでは直接テキストを表示できないので、迷路生成の進行状況を示す数字を「createGraphics」を使って別に描いておき、平面(plane)のテクスチャとして張り付けることで表示したりもしています。

手前味噌は承知の上ですが、1年前の段階ではプログラミング経験ほぼゼロだった、文学部出身のド文系(しかも結構イイ歳)の自分が、超シンプルとはいえ、いちおう形になっているブラウザ用のミニゲームを完成させられたという事実には、何とも感慨深いものがあります。ホント、p5.jsに出会って人生豊かになりました(笑)。

今回のミニゲームのコードは、前回の「迷路自動生成」に、思いつくままにいろんな機能を足した「なれの果て」なので、見た目は汚いですし、各所の処理などにももっと良いやり方があるのだろうと思います。若干躊躇はしたのですが、ひとまず、この1年での自分の到達点を記録しておくという意味で公開しておきます。

let maze, player, keys;
let stageMode, easeTime;
let cameraIsOrtho = true;
function setup() {
	createCanvas(600, 600, WEBGL);
	colorMode(HSB);
	maze = new MazeObj();
	maze.init();
	player = new PlayerObj();
	keys = new KeyOrbs();
	noStroke();
	ambientLight(255, 0, 100, 1);
	directionalLight(255, 100, 100, 1, .5, -1);
}
function draw() {
	background(60, 5, 90, 1);
	player.cameraMode(cameraIsOrtho);
	maze.drawFloor();
	maze.drawWall();
	keys.drawOrbs();
	if (maze.inDigLoop) {
		maze.digMaze(keys);
	} else {
		if (stageMode == 1 || stageMode == 3 || stageMode == 4 || stageMode == 5) {
			cameraIsOrtho = false;
			switch (stageMode) {
				case 1: easeTime = 3500; break;
				case 3: easeTime = 1000; break;
				case 4: easeTime = 1000; break;
				case 5: easeTime = 3500; break;
				default: break;
			}
			player.easeMove(stageMode, maze.aside, easeTime);
		}
		player.update(maze, keys);
		keys.checkOrbs(player);
	}
	player.getKeyInput(maze);
	if (stageMode > 5) {
		cameraIsOrtho = true;
		maze.init();
		player.dir = PI;
	}
}
class MazeObj {
	constructor() {
		this.aside = 200;
		this.xCount = 21;
		this.yCount = 21;
		this.cells = [];
		this.checkedCells = [];
		this.cellX, this.cellY;
		this.tmpX, this.tmpY;
		this.dirs = [];
		this.nextDir;
		this.inDigLoop;
		this.startPointTime = 0;
		this.goalPointTime = null;
		this.timeGraphic = createGraphics(400, 400);
		this.timeGraphic.textFont('Anton');
		this.timeGraphic.pixelDensity(displayDensity());
		this.timeGraphic.colorMode(HSB);
	}
	init() {
		stageMode = 0;
		this.dirs = [0, 1, 2, 3];
		this.inDigLoop = true;
		this.cells = Array.from(new Array(this.xCount), () => new Array(this.yCount).fill(null));
		for (let j = 0; j < this.yCount; j++) {
			for (let i = 0; i < this.xCount; i++) {
				this.cells[i][j] = new BlockObj(i, j, this.aside);
			}
		}
		this.cellX = floor(random(1, this.xCount - 2));
		if (this.cellX % 2 == 0) { this.cellX++; }
		this.cellY = floor(random(1, this.yCount - 2));
		if (this.cellY % 2 == 0) { this.cellY++; }
		this.cells[this.cellX][this.cellY].state = false;
		this.checkedCells.push(new Points(this.cellX, this.cellY));
	}
	drawFloor() {
		push();
		translate(0, 0, -150);
		ambientMaterial(0, 0, 90, 1);
		box(4200, 4200, 100);
		pop();
		push();
		translate(-this.aside * 10, -this.aside * 9, -110);
		ambientMaterial(240, 70, 100, 1);
		box(this.aside * .9, this.aside * .9, 50);
		pop();
		push();
		translate(this.aside * 10, this.aside * 9, -110);
		ambientMaterial(0, 70, 100, 1);
		box(this.aside * .9, this.aside * .9, 50);
		pop();
		if (this.inDigLoop) {
			push();
			this.timeGraphic.background(230, 25, 100, 1);
			this.timeGraphic.textSize(300);
			this.timeGraphic.fill(0, 0, 100, 1);
			this.timeGraphic.text(this.checkedCells.length, width / 2 - 190, height / 2 + 20);
			translate(0, 0, 3550);
			fill(0, 100, 100, 1);
			texture(this.timeGraphic);
			plane(200, 200);
			pop();
		}
	}
	drawWall() {
		for (let j = 0; j < this.yCount; j++) {
			for (let i = 0; i < this.xCount; i++) {
				this.cells[i][j].drawBox(this.aside);
			}
		}
	}
	digMaze(key) {
		if (this.dirs.length == 0) {
			let pickNextIndex = floor(random(0, this.checkedCells.length));
			let othercheckedCell = this.checkedCells[pickNextIndex];
			this.checkedCells.splice(pickNextIndex, 1);
			if (this.checkedCells.length == 0) {
				this.cells[0][1].state = false;
				this.cells[this.xCount - 1][this.yCount - 2].state = false;
				this.inDigLoop = false;
				key.setOrbs(this);
				stageMode += 1;
			}
			this.cellX = othercheckedCell.x;
			this.cellY = othercheckedCell.y;
			this.dirs = [0, 1, 2, 3];
		}
		let tmpDirIndex = floor(random(0, this.dirs.length));
		this.nextDir = this.dirs[tmpDirIndex];
		this.dirs.splice(tmpDirIndex, 1);
		switch (this.nextDir) {
			case 0: this.tmpX = 0; this.tmpY = -2; break;
			case 1: this.tmpX = 2; this.tmpY = 0; break;
			case 2: this.tmpX = 0; this.tmpY = 2; break;
			case 3: this.tmpX = -2; this.tmpY = 0; break;
			default: break;
		}
		let nextX = this.cellX + this.tmpX, nextY = this.cellY + this.tmpY;
		let betNextX = this.cellX + this.tmpX / 2, betNextY = this.cellY + this.tmpY / 2;
		if (nextX >= 0 && nextX < this.xCount && nextY >= 0 && nextY < this.yCount) {
			if (this.cells[nextX][nextY].state == true) {
				this.cells[nextX][nextY].state = false;
				this.cells[betNextX][betNextY].state = false;
				this.checkedCells.push(new Points(this.cellX, this.cellY));
				this.cellX = nextX; this.cellY = nextY; this.dirs = [0, 1, 2, 3];
			}
		}
	}
}
class BlockObj {
	constructor(i, j, k) {
		this.state = true;
		this.x = -2000 + i * k;
		this.y = -2000 + j * k;
	}
	drawBox(aside) {
		if (this.state) {
			push();
			translate(this.x, this.y, 0);
			ambientMaterial(230, 20, 90, 1);
			box(aside, aside, aside);
			pop();
		}
	}
}
class PlayerObj {
	constructor() {
		this.pos = createVector(0, 0, 3920);
		this.sight = createVector(0, 0, 0);
		this.dir = PI;
		this.upVector = createVector(0, 1, 0);
		this.dist = createVector(0, 0, 3920);
		this.distSight = createVector(0, 0, 0);
		this.distUp = createVector(0, 1, 0);
		this.prePos = createVector(0, 0, 3920);
		this.preSight = createVector(0, 0, 0);
		this.preUpVector = createVector(0, 1, 0);
		this.easeStartTime = Date.now();
		this.easeMoveTime = 0;
		this.easeMoveLimit = 0;
		this.isEaseMove = false;
		this.easeInOutQuad = function (t) { return t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t };
	}
	cameraMode(isOrtho) {
		if (isOrtho) {
			camera(0, 0, 4000, 0, 0, 0, 0, 1, 0);
			ortho(-2200, 2200, -2200, 2200, 1, 6500);
		} else {
			camera(this.pos.x, this.pos.y, this.pos.z, this.sight.x, this.sight.y, this.sight.z, this.upVector.x, this.upVector.y, this.upVector.z);
			perspective(PI / 3.0, width / height, 1, 6000);
		}
	}
	easeMove(stage, aside, t) {
		if (!this.isEaseMove) {
			this.isEaseMove = true;
			this.easeStartTime = Date.now();
			this.easeMoveLimit = t;
			if (stage == 1) {
				this.dist.x = aside * 10;
				this.dist.y = aside * 9;
				this.dist.z = -20;
				this.dir = PI;
				this.distSight.x = cos(this.dir) * 2000 + this.dist.x;
				this.distSight.y = sin(this.dir) * 2000 + this.dist.y;
				this.distSight.z = this.dist.z;
				this.distUp.x = 0;
				this.distUp.y = 0;
				this.distUp.z = -1;
			} else if (stage == 3) {
				this.dist.x = this.pos.x;
				this.dist.y = this.pos.y;
				this.dist.z = this.pos.z;
				this.dir = -HALF_PI;
				this.distSight.x = cos(this.dir) * 2000 + this.dist.x;
				this.distSight.y = sin(this.dir) * 2000 + this.dist.y;
				this.distSight.z = this.dist.z;
				this.distUp.x = this.upVector.x;
				this.distUp.y = this.upVector.y;
				this.distUp.z = this.upVector.z;
			} else if (stage == 4) {
				this.dist.x = this.pos.x;
				this.dist.y = this.pos.y;
				this.dist.z = this.pos.z;
				this.dir = 0;
				this.distSight.x = cos(this.dir) * 2000 + this.dist.x;
				this.distSight.y = sin(this.dir) * 2000 + this.dist.y;
				this.distSight.z = this.dist.z;
				this.distUp.x = this.upVector.x;
				this.distUp.y = this.upVector.y;
				this.distUp.z = this.upVector.z;
			} else if (stage == 5) {
				this.dist.x = 0;
				this.dist.y = 0;
				this.dist.z = 4000;
				this.distSight.x = 0;
				this.distSight.y = 0;
				this.distSight.z = 0;
				this.distUp.x = 0;
				this.distUp.y = 1;
				this.distUp.z = 0;
			}
			this.prePos = this.pos;
			this.preSight = this.sight;
			this.preUpVector = this.upVector;
		}
	}
	update(field, key) {
		if (this.isEaseMove) {
			this.easeMoveTime = (Date.now() - this.easeStartTime) / this.easeMoveLimit;
			this.pos.x = this.prePos.x + (this.dist.x - this.prePos.x) * this.easeInOutQuad(this.easeMoveTime);
			this.pos.y = this.prePos.y + (this.dist.y - this.prePos.y) * this.easeInOutQuad(this.easeMoveTime);
			this.pos.z = this.prePos.z + (this.dist.z - this.prePos.z) * this.easeInOutQuad(this.easeMoveTime);
			this.sight.x = this.preSight.x + (this.distSight.x - this.preSight.x) * this.easeInOutQuad(this.easeMoveTime);
			this.sight.y = this.preSight.y + (this.distSight.y - this.preSight.y) * this.easeInOutQuad(this.easeMoveTime);
			this.sight.z = this.preSight.z + (this.distSight.z - this.preSight.z) * this.easeInOutQuad(this.easeMoveTime);
			this.upVector.x = this.preUpVector.x + (this.distUp.x - this.preUpVector.x) * this.easeInOutQuad(this.easeMoveTime);
			this.upVector.y = this.preUpVector.y + (this.distUp.y - this.preUpVector.y) * this.easeInOutQuad(this.easeMoveTime);
			this.upVector.z = this.preUpVector.z + (this.distUp.z - this.preUpVector.z) * this.easeInOutQuad(this.easeMoveTime);
			if (Date.now() - this.easeStartTime > this.easeMoveLimit) {
				this.isEaseMove = false;
				stageMode += 1;
			}
		}
		if (this.pos.x < field.cells[0][1].x + field.aside / 3 && key.left.length == 0 && stageMode == 2) {
			stageMode += 1;
		}
		if (stageMode == 2) {
			push();
			translate(this.pos.x, this.pos.y, -50);
			rotateZ(this.dir - HALF_PI);
			specularMaterial(240, 70, 100, 1);
			cone(20, 100);
			pop();
		}
	}
	getKeyInput(field) {
		if (stageMode == 2) {
			let tempPosVec = createVector(this.pos.x + field.aside / 2, this.pos.y + field.aside / 2);
			let sightVec = p5.Vector.sub(this.sight, this.pos);
			sightVec.normalize();
			sightVec.mult(10);
			let tempVec;
			if (keyIsDown(65)) {
				this.dir -= PI / 90;
			}
			if (keyIsDown(87) && cameraIsOrtho == false) {
				tempVec = p5.Vector.add(tempPosVec, sightVec);
				if (this.collideCheck(tempVec, field)) {
					this.pos.add(sightVec);
				}
			}
			if (keyIsDown(68)) {
				this.dir += PI / 90;
			}
			if (keyIsDown(83) && cameraIsOrtho == false) {
				tempVec = p5.Vector.sub(tempPosVec, sightVec);
				if (this.collideCheck(tempVec, field)) {
					this.pos.sub(sightVec);
				}
			}
			this.sight.x = cos(this.dir) * 2000 + this.pos.x;
			this.sight.y = sin(this.dir) * 2000 + this.pos.y;
		}
	}
	collideCheck(tempVec, field) {
		let tempX = floor(tempVec.x / field.aside + floor(field.xCount / 2));
		let tempY = floor(tempVec.y / field.aside + floor(field.yCount / 2));
		if (tempVec.x > -field.xCount * field.aside / 2 + field.aside / 2 && tempVec.x < field.xCount * field.aside / 2 + field.aside / 2
			&& tempVec.y > -field.yCount * field.aside / 2 + field.aside / 2 && tempVec.y < field.yCount * field.aside / 2 + field.aside / 2
			&& field.cells[tempX][tempY].state == false) {
			return true;
		} else {
			return false;
		}
	}
}
class KeyOrbs {
	constructor() {
		this.left = [];
		this.orbCount = 3;
	}
	setOrbs(field) {
		this.left = [];
		let tmpX, tmpY, checkCount;
		let checkCord = [];
		let doubleCheck;
		while (checkCord.length < this.orbCount) {
			tmpX = floor(random(1, field.xCount - 2));
			tmpY = floor(random(1, field.yCount - 2));
			checkCount = 0;
			for (let i = 0; i < checkCord.length; i++) {
				if (checkCord[i].x == tmpX && checkCord[i].y == tmpY) {
					checkCount++;
				}
			}
			if (checkCount == 0) {
				doubleCheck = true;
			} else {
				doubleCheck = false;
			}
			if (!field.cells[tmpX][tmpY].state && doubleCheck) {
				checkCord.push(new Points(tmpX, tmpY));
				this.left.push(field.cells[tmpX][tmpY]);
			}
		}
	}
	drawOrbs() {
		this.left.forEach(thisOrb => {
			push();
			translate(thisOrb.x, thisOrb.y, -20);
			rotateZ(frameCount / 100);
			specularMaterial(90, 100, 95, .6);
			box(80, 80, 80);
			pop();
		});
	}
	checkOrbs(mover) {
		for (let i = 0; i < this.left.length; i++) {
			if (dist(this.left[i].x, this.left[i].y, mover.pos.x, mover.pos.y) < 30) {
				this.left.splice(i, 1);
			}
		}
	}
}
class Points {
	constructor(i, j) {
		this.x = i;
		this.y = j;
	}
}
function keyPressed() {
	if (stageMode == 2 && keyCode == 32) {
		cameraIsOrtho = !cameraIsOrtho;
	}
}

長いなぁ(笑)。作りながら、敵キャラ的なものを出したり、制限時間を設けたり、スコアを付けたりするようなことも考えてはみたのですが、たぶん、ここからさらにゲーム的に作り込もうとするのであれば、p5.jsではなくUnityあたりを使った方がいいのでしょうね。

この1年で、p5.jsには、p5.jsに合った表現や規模があり、それがどんなものかというのもボンヤリと見えてきたような気がしています。ただ、p5.jsには、僕がまだ使いきれていない機能がたくさんありますし、使い慣れたつもりの機能でも、使い方の工夫次第で予想を超えた面白い表現ができたりする奥の深さもあります。これからも当面は、p5.jsをメインに使いつつ、その時々に思いついたアイデアをスケッチとして形にすることを楽しんでみたいと思っています。