2
4

javascriptによるECS - Entity Componet System (NOT Unity DOTS)

Last updated at Posted at 2024-07-04

■概要

javascriptのみで作成したECS(Entity Componet System)のサンプルを示し、その解説を行う。
ピュアな javascript で作られたECSのサンプルがQiitaで見つからなかったので、記事を書いてみた。
これは Unity ECS (Unity DOTS) の記事ではありません。

ECSはゲームやシミュレーションによく使われる設計パターンで、ゲームに登場する「もの」をEntity、「もの」の持つ属性をComponentとしてデータを持つ。

ECSの概念
WikipediaのECS記事より

こうしたデータを対象にSystemによって毎フレーム処理を行うことでゲームを動かしていくのがECS。

ECSは継承やインターフェースを活用せず単にComponentのデータをSystemで処理するだけという意味で、オブジェクト指向設計ではないパラダイムである。
(実際にコードを書くときは、オブジェクト指向言語で書かれることが大半だと思うけれど)

  • エンティティ(Entity)
    ゲーム内の「もの」。Entityはデータやロジックを直接持たない単なるidとして実装されることが多い。
     
  • コンポーネント(Component)
    Entityの持つプロパティにあたるデータ。種類ごと別々のコンポーネントを持つ。
    例えば「位置コンポーネント」、「速度コンポーネント」、「体力コンポーネント」など。
     
  • システム(System)
    実際にゲームを動かすために実行されるロジック。
    例えば「移動システム」「表示システム」など。

■複数のボールが画面内をバウンドするサンプル

動作サンプル

See the Pen ECS Sample1 by culage (@culage) on CodePen.

説明

全体構成

全体的なオブジェクト関係は以下のようになっている。

     (update event) ※イベントで処理対象を更新
   ┌─────────────────────────────────┐
   │                   ↓

[EntityManager] ◇── [Component]  [System] ──◇ [World]

EntityManager

class EntityManager {
	constructor() {
		this.entityList = {};
		var ed = new EventDispatcher();
		this.addEventListener = (t, h) => ed.addEventListener(t, h);
		this.dispatchEvent = (ev) => ed.dispatchEvent(ev);
	}
	createEntity(id) {
		id = (id == undefined ? crypto.randomUUID() : id);
		this.entityList[id] = {};
		this.dispatchEvent({type: "update", id});
		return id;
	}
	addComponent(id, component) {
		var componentClass = component.constructor;
		this.entityList[id][componentClass] = component;
		this.dispatchEvent({type: "update", id});
	}
	getComponent(id, componentClass) {
		return this.entityList[id][componentClass];
	}
}

createEntityでランダムなUUIDを作成して、Entityの生成・管理するクラス。
Entity自体は単なるidなのでEntity1つ1つをオブジェクトとして生成はせず、管理クラスですべて管理する。(ECSをどう実装するかの問題なので、Entity1つ1つをオブジェクトとして作る設計にしても別に構わない)
Entity自体は単なるidなのでaddComponentでコンポーネント追加することで初めてエンティティに位置や色のデータが紐づけることができる。
EventDispatcherは、独自のイベント処理したいので入れているユーティリティクラスなので、あんまり気にしないでください。

Component

class Component {}

class BodyComponent extends Component {
	constructor(x, y, size) {
		super();
		this.x = x;
		this.y = y;
		this.size = size;
	}
}

エンティティに位置や色のデータがもたせるためのクラス。
重要な点は、Componentはロジックを持たず純粋にデータを保持するためだけに使われるということ。
実際にコンポーネントのデータを書き換えたりそれを元に処理を行うのはSystemである。

System

class System {
	constructor(entityManager) {
		this.em = entityManager;
		this.em.addEventListener("update", ev => this.updateTargetEntities(ev.id));
		this.targetEntities = [];
	}
	updateTargetEntities(id) {
		var isTarget = this.isTargetEntity(id);
		var isIncludes = this.targetEntities.includes(id);
		if (isTarget == true && isIncludes == false) {
			this.targetEntities.push(id);
		}
		if (isTarget == false && isIncludes == true) {
			this.targetEntities = this.targetEntities.filter(listId => listId !== id);
		}
	}
	isTargetEntity(id) { return true; }
	update() { }
}

class RenderSystem extends System {
	constructor(entityManager, canvas) {
		super(entityManager);
		this.canvas = canvas;
		this.ctx = canvas.getContext("2d");
	}
	isTargetEntity(id) {
		return Boolean(this.em.getComponent(id, RenderComponent));
	}
	update() {
		this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
		for (var id of this.targetEntities) {
			var body = this.em.getComponent(id, BodyComponent);
			var render = this.em.getComponent(id, RenderComponent);
			this.ctx.beginPath();
			this.ctx.arc(body.x, body.y, body.size, 0, 2 * Math.PI);
			this.ctx.fillStyle = `hsl(${render.color}, 100%, 50%)`;
			this.ctx.fill();
		}
	}
}

毎フレームupdateメソッドが呼び出されて処理を行う、ゲーム処理の実行を担うクラス。
Entityにコンポーネント追加されたときのイベントで、このSystemで処理する対象のEntityのリストであるthis.targetEntitiesを更新するようにしており、毎回エンティティが処理対象かチェックしなくても済むようにしている。

World

class World {
	constructor() {
		this.canvas = document.getElementById("gameCanvas");
		this.em = new EntityManager();
		this.moveSystem = new MoveSystem(this.em);
		this.renderSystem = new RenderSystem(this.em, this.canvas);
		
		var fieldSize = this.canvas.width;
		var fieldEntityId = this.em.createEntity();
		this.em.addComponent(fieldEntityId, new BodyComponent(0, 0, fieldSize));
		this.moveSystem.setField(fieldEntityId);
		
		for (var i = 0; i < 10; i++) {
			var x = Math.floor(Math.random() * (fieldSize - 80)) + 40;
			var y = Math.floor(Math.random() * (fieldSize - 80)) + 40;
			var size = Math.floor(Math.random() * 50) + 10;
			var color = Math.floor(Math.random() * 360);;
			createChar(this.em, x, y, size, color);
		}
	}
	update() {
		this.moveSystem.update();
		this.renderSystem.update();
	}
	run() {
		var gameLoop = () => {
			this.update();
			requestAnimationFrame(gameLoop);
		};
		gameLoop();
	}
}

var w = new World();
w.run();

ここまでで「Entity」「Component」「System」は揃ったが、実際にそれを組み合わせて動かさないとゲームは動いてくれない。
Worldは、1.「Entity」「Component」を適切に組み立てる、2.組み立てたものを毎フレーム「System」で動かす……といった機能を担うクラス。

ちなみに普段見慣れない関数であろうrequestAnimationFrameはブラウザで「毎フレーム処理を行わせる」ために用意されている標準関数。
やっていることはsetIntervalと同じような事である。
MDNの解説

■改造版サンプル

動作サンプル

See the Pen ECS Sample1 by culage (@culage) on CodePen.

説明

ECSの特徴を活かした例として「 ボールに重力を加える 」「 ボールの色を変化させる 」という機能を追加した。
各ボールはランダムに機能を持つようにしてあるため、以下のパターンのいずれかのボールが生成される。

バウンド 重力 色変化

こういった機能追加をコードを綺麗に保ちながら行えたり、エンティティに持たせる機能の組み合わせを自由に決められるのがECSの優れた点。

追加したComponent

class GravityComponent extends Component {
	constructor() {
		super();
	}
}

class ColorChangeComponent extends Component {
	constructor() {
		super();
	}
}

これらはボールに重力を加えたり、ボールの色を変化させたりする対象であることを示すフラグとして働くComponentであるため、プロパティは何も持たない。
(このコンポーネントが追加されたEntityが対象になる)

追加したSystem

class GravitySystem extends System {
	constructor(entityManager) {
		super(entityManager);
	}
	isTargetEntity(id) {
		return Boolean(this.em.getComponent(id, GravityComponent));
	}
	update() {
		for (var id of this.targetEntities) {
			var move = this.em.getComponent(id, MoveComponent);
			move.vy += 0.1;
			if (move.vy > 20) { move.vy = 20; }
		}
	}
}


class ColorChangeSystem extends System {
	constructor(entityManager) {
		super(entityManager);
	}
	isTargetEntity(id) {
		return Boolean(this.em.getComponent(id, ColorChangeComponent));
	}
	update() {
		for (var id of this.targetEntities) {
			var render = this.em.getComponent(id, RenderComponent);
			render.color = (render.color + 1) % 360;
		}
	}
}

ボールに重力を加えたり、ボールの色を変化させる処理の実装。
ちなみに色を保存しているrender.colorは、加算で色を変更しやすいようにcss hsl関数に渡す色相(0度~360度)の値を持っている。

World、createChar関数の修正

	constructor() {
		(省略)
		this.gravitySystem = new GravitySystem(this.em);
		this.colorChangeSystem = new ColorChangeSystem(this.em);
		(省略)
	}
	update() {
		(省略)
		this.gravitySystem.update();
		this.colorChangeSystem.update();
		(省略)
	}

追加したシステムを呼び出すように修正。

function createChar(em, x, y, size, color) {
	var id = em.createEntity();
	em.addComponent(id, new BodyComponent(x, y, size));
	em.addComponent(id, new RenderComponent(color));
	em.addComponent(id, new MoveComponent((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10));
	if (Math.random() < 0.5) { em.addComponent(id, new GravityComponent()); }
	if (Math.random() < 0.5) { em.addComponent(id, new ColorChangeComponent()); }
}

上記で追加したコンポーネントをランダムに持つように修正。

■参考ページ

JavaScript for Games
https://jsforgames.com/ecs/

■おわり

以上。お疲れ様でした。

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4