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

■概要
------------------------------

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

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

![ECSの概念](https://upload.wikimedia.org/wikipedia/commons/2/23/ECS_Simple_Layout.svg)
※[WikipediaのECS記事](https://en.wikipedia.org/wiki/Entity_component_system)より

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

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


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


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

### 動作サンプル

<p class="codepen" data-height="414" data-default-tab="html,result" data-slug-hash="bGPbEbN" data-pen-title="ECS Sample1" data-user="culage" style="height: 414px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/culage/pen/bGPbEbN">
  ECS Sample1</a> by culage (<a href="https://codepen.io/culage">@culage</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

### 説明

#### 全体構成

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

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

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


#### EntityManager

```javascript
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

```javascript
class Component {}

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

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


#### System

```javascript
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

```javascript
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の解説](https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame)





■改造版サンプル
------------------------------

### 動作サンプル

<p class="codepen" data-height="414" data-default-tab="html,result" data-slug-hash="GRbKoKa" data-pen-title="ECS Sample1" data-user="culage" style="height: 414px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/culage/pen/GRbKoKa">
  ECS Sample1</a> by culage (<a href="https://codepen.io/culage">@culage</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

### 説明

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

|バウンド   |重力   |色変化 |
|:---------:|:-----:|:-----:|
|○         |☓     |☓     |
|○         |○     |☓     |
|○         |☓     |○     |
|○         |○     |○     |

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

#### 追加したComponent

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

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

```

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


#### 追加したSystem

```javascript
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関数](https://developer.mozilla.org/ja/docs/Web/CSS/color_value/hsl)に渡す色相(0度～360度)の値を持っている。

#### World、createChar関数の修正

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

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

```javascript
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/


■おわり
------------------------------

以上。お疲れ様でした。