aether-engine 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/biome.json +51 -0
- package/bun.lock +192 -0
- package/index.ts +1 -0
- package/package.json +25 -0
- package/serve.ts +125 -0
- package/src/audio/AudioEngine.ts +61 -0
- package/src/components/Animator3D.ts +65 -0
- package/src/components/AudioSource.ts +26 -0
- package/src/components/BitmapText.ts +25 -0
- package/src/components/Camera.ts +33 -0
- package/src/components/CameraFollow.ts +5 -0
- package/src/components/Collider.ts +16 -0
- package/src/components/Components.test.ts +68 -0
- package/src/components/Light.ts +15 -0
- package/src/components/MeshRenderer.ts +58 -0
- package/src/components/ParticleEmitter.ts +59 -0
- package/src/components/RigidBody.ts +9 -0
- package/src/components/ShadowCaster.ts +3 -0
- package/src/components/SkinnedMeshRenderer.ts +25 -0
- package/src/components/SpriteAnimator.ts +42 -0
- package/src/components/SpriteRenderer.ts +26 -0
- package/src/components/Transform.test.ts +39 -0
- package/src/components/Transform.ts +54 -0
- package/src/core/AssetManager.ts +123 -0
- package/src/core/Input.test.ts +67 -0
- package/src/core/Input.ts +94 -0
- package/src/core/Scene.ts +24 -0
- package/src/core/SceneManager.ts +57 -0
- package/src/core/Storage.ts +161 -0
- package/src/desktop/SteamClient.ts +52 -0
- package/src/ecs/System.ts +11 -0
- package/src/ecs/World.test.ts +29 -0
- package/src/ecs/World.ts +149 -0
- package/src/index.ts +115 -0
- package/src/math/Color.ts +100 -0
- package/src/math/Vector2.ts +96 -0
- package/src/math/Vector3.ts +103 -0
- package/src/math/math.test.ts +168 -0
- package/src/renderer/GlowMaterial.ts +66 -0
- package/src/renderer/LitMaterial.ts +337 -0
- package/src/renderer/Material.test.ts +23 -0
- package/src/renderer/Material.ts +80 -0
- package/src/renderer/OcclusionMaterial.ts +43 -0
- package/src/renderer/ParticleMaterial.ts +66 -0
- package/src/renderer/Shader.ts +44 -0
- package/src/renderer/SkinnedLitMaterial.ts +55 -0
- package/src/renderer/WaterMaterial.ts +298 -0
- package/src/renderer/WebGLRenderer.ts +917 -0
- package/src/systems/Animation3DSystem.ts +148 -0
- package/src/systems/AnimationSystem.ts +58 -0
- package/src/systems/AudioSystem.ts +62 -0
- package/src/systems/LightingSystem.ts +114 -0
- package/src/systems/ParticleSystem.ts +278 -0
- package/src/systems/PhysicsSystem.ts +211 -0
- package/src/systems/Systems.test.ts +165 -0
- package/src/systems/TextSystem.ts +153 -0
- package/src/ui/AnimationEditor.tsx +639 -0
- package/src/ui/BottomPanel.tsx +443 -0
- package/src/ui/EntityExplorer.tsx +420 -0
- package/src/ui/GameState.ts +286 -0
- package/src/ui/Icons.tsx +239 -0
- package/src/ui/InventoryPanel.tsx +335 -0
- package/src/ui/PlayerHUD.tsx +250 -0
- package/src/ui/SpriteEditor.tsx +3241 -0
- package/src/ui/SpriteSheetManager.tsx +198 -0
- package/src/utils/GLTFLoader.ts +257 -0
- package/src/utils/ObjLoader.ts +81 -0
- package/src/utils/idb.ts +137 -0
- package/src/utils/packer.ts +85 -0
- package/test_obj.ts +12 -0
- package/tsconfig.json +21 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// biome-ignore lint/complexity/noStaticOnlyClass: Engine architecture uses static manager classes
|
|
2
|
+
export class Input {
|
|
3
|
+
private static _keys = new Set<string>();
|
|
4
|
+
private static _previousKeys = new Set<string>();
|
|
5
|
+
private static _actions = new Map<string, string[]>();
|
|
6
|
+
private static _mouseDown = false;
|
|
7
|
+
private static _previousMouseDown = false;
|
|
8
|
+
private static _mousePosition = { x: 0, y: 0 };
|
|
9
|
+
private static _initialized = false;
|
|
10
|
+
|
|
11
|
+
static initialize() {
|
|
12
|
+
if (Input._initialized) return;
|
|
13
|
+
Input._initialized = true;
|
|
14
|
+
|
|
15
|
+
window.addEventListener("keydown", (e) => Input._keys.add(e.code));
|
|
16
|
+
window.addEventListener("keyup", (e) => Input._keys.delete(e.code));
|
|
17
|
+
window.addEventListener("mousedown", () => {
|
|
18
|
+
Input._keys.add("Mouse0");
|
|
19
|
+
Input._mouseDown = true;
|
|
20
|
+
});
|
|
21
|
+
window.addEventListener("mouseup", () => {
|
|
22
|
+
Input._keys.delete("Mouse0");
|
|
23
|
+
Input._mouseDown = false;
|
|
24
|
+
});
|
|
25
|
+
window.addEventListener("mousemove", (e) => {
|
|
26
|
+
Input._mousePosition.x = e.clientX;
|
|
27
|
+
Input._mousePosition.y = e.clientY;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static update() {
|
|
32
|
+
Input._previousKeys.clear();
|
|
33
|
+
for (const key of Input._keys) {
|
|
34
|
+
Input._previousKeys.add(key);
|
|
35
|
+
}
|
|
36
|
+
Input._previousMouseDown = Input._mouseDown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static bind(action: string, keycodes: string[]) {
|
|
40
|
+
Input._actions.set(action, keycodes);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static isKeyDown(code: string): boolean {
|
|
44
|
+
return Input._keys.has(code);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static isKeyJustPressed(code: string): boolean {
|
|
48
|
+
return Input._keys.has(code) && !Input._previousKeys.has(code);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static isKeyJustReleased(code: string): boolean {
|
|
52
|
+
return !Input._keys.has(code) && Input._previousKeys.has(code);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static isActionPressed(action: string): boolean {
|
|
56
|
+
const codes = Input._actions.get(action) || [];
|
|
57
|
+
for (const code of codes) {
|
|
58
|
+
if (Input.isKeyDown(code)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static isActionJustPressed(action: string): boolean {
|
|
64
|
+
const codes = Input._actions.get(action) || [];
|
|
65
|
+
for (const code of codes) {
|
|
66
|
+
if (Input.isKeyJustPressed(code)) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static isActionJustReleased(action: string): boolean {
|
|
72
|
+
const codes = Input._actions.get(action) || [];
|
|
73
|
+
for (const code of codes) {
|
|
74
|
+
if (Input.isKeyJustReleased(code)) return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static isMouseDown(): boolean {
|
|
80
|
+
return Input._mouseDown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static isMouseJustPressed(): boolean {
|
|
84
|
+
return Input._mouseDown && !Input._previousMouseDown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static isMouseJustReleased(): boolean {
|
|
88
|
+
return !Input._mouseDown && Input._previousMouseDown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static getMousePosition() {
|
|
92
|
+
return Input._mousePosition;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AetherEngine } from "../index";
|
|
2
|
+
|
|
3
|
+
export abstract class Scene {
|
|
4
|
+
protected engine: AetherEngine;
|
|
5
|
+
|
|
6
|
+
constructor(engine: AetherEngine) {
|
|
7
|
+
this.engine = engine;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Called before the scene starts. Ideal for loading assets.
|
|
12
|
+
*/
|
|
13
|
+
async load(): Promise<void> {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Called to instantiate entities and begin the scene.
|
|
17
|
+
*/
|
|
18
|
+
abstract start(): void;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Called when transitioning away from the scene.
|
|
22
|
+
*/
|
|
23
|
+
stop(): void {}
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AudioSource } from "../components/AudioSource";
|
|
2
|
+
import type { AetherEngine } from "../index";
|
|
3
|
+
import type { Scene } from "./Scene";
|
|
4
|
+
|
|
5
|
+
export type SceneFactory = (engine: AetherEngine) => Scene;
|
|
6
|
+
|
|
7
|
+
export class SceneManager {
|
|
8
|
+
private engine: AetherEngine;
|
|
9
|
+
private scenes = new Map<string, SceneFactory>();
|
|
10
|
+
public currentScene: Scene | null = null;
|
|
11
|
+
public currentSceneName: string | null = null;
|
|
12
|
+
|
|
13
|
+
constructor(engine: AetherEngine) {
|
|
14
|
+
this.engine = engine;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public register(name: string, factory: SceneFactory) {
|
|
18
|
+
this.scenes.set(name, factory);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public get registeredScenes(): string[] {
|
|
22
|
+
return Array.from(this.scenes.keys());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public async changeScene(name: string): Promise<void> {
|
|
26
|
+
if (this.currentScene) {
|
|
27
|
+
this.currentScene.stop();
|
|
28
|
+
|
|
29
|
+
// Stop any looping/playing background audio to prevent layer bleeding across scenes
|
|
30
|
+
const audioSources = this.engine.world.query(AudioSource);
|
|
31
|
+
for (const e of audioSources) {
|
|
32
|
+
const source = this.engine.world.getComponent(e, AudioSource);
|
|
33
|
+
if (source?._sourceNode) {
|
|
34
|
+
try {
|
|
35
|
+
source._sourceNode.stop();
|
|
36
|
+
} catch (_e) {
|
|
37
|
+
// Ignored if already stopped naturally
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.engine.world.clearEntities();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const factory = this.scenes.get(name);
|
|
46
|
+
if (!factory) {
|
|
47
|
+
throw new Error(`Scene '${name}' not found.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scene = factory(this.engine);
|
|
51
|
+
this.currentScene = scene;
|
|
52
|
+
this.currentSceneName = name;
|
|
53
|
+
|
|
54
|
+
await scene.load();
|
|
55
|
+
scene.start();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export class GameStorage {
|
|
2
|
+
private dbName: string;
|
|
3
|
+
private dbVersion: number;
|
|
4
|
+
private stores: string[];
|
|
5
|
+
private db: IDBDatabase | null = null;
|
|
6
|
+
private initPromise: Promise<void> | null = null;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
dbName = "aether_storage",
|
|
10
|
+
stores = ["saves", "settings", "global"],
|
|
11
|
+
dbVersion = 1,
|
|
12
|
+
) {
|
|
13
|
+
this.dbName = dbName;
|
|
14
|
+
this.stores = stores;
|
|
15
|
+
this.dbVersion = dbVersion;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Initializes the IndexedDB connection and provisions stores.
|
|
20
|
+
* This is called automatically by operations, but can be invoked manually to preload the DB.
|
|
21
|
+
*/
|
|
22
|
+
public async connect(): Promise<void> {
|
|
23
|
+
if (this.db) return;
|
|
24
|
+
if (this.initPromise) return this.initPromise;
|
|
25
|
+
|
|
26
|
+
this.initPromise = new Promise((resolve, reject) => {
|
|
27
|
+
if (typeof window === "undefined" || !window.indexedDB) {
|
|
28
|
+
console.warn("IndexedDB is not supported in this environment.");
|
|
29
|
+
reject(new Error("IndexedDB not supported"));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const request = window.indexedDB.open(this.dbName, this.dbVersion);
|
|
34
|
+
|
|
35
|
+
request.onupgradeneeded = (event) => {
|
|
36
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
37
|
+
for (const store of this.stores) {
|
|
38
|
+
if (!db.objectStoreNames.contains(store)) {
|
|
39
|
+
db.createObjectStore(store);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
request.onsuccess = (event) => {
|
|
45
|
+
this.db = (event.target as IDBOpenDBRequest).result;
|
|
46
|
+
|
|
47
|
+
// Handle db errors globally
|
|
48
|
+
this.db.onerror = (e) => {
|
|
49
|
+
console.error(
|
|
50
|
+
"GameStorage Database error: ",
|
|
51
|
+
(e.target as IDBRequest).error,
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
request.onerror = (event) => {
|
|
59
|
+
reject((event.target as IDBOpenDBRequest).error);
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return this.initPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async getStore(
|
|
67
|
+
storeName: string,
|
|
68
|
+
mode: IDBTransactionMode = "readonly",
|
|
69
|
+
): Promise<IDBObjectStore> {
|
|
70
|
+
await this.connect();
|
|
71
|
+
if (!this.db) throw new Error("GameStorage: Database not connected.");
|
|
72
|
+
|
|
73
|
+
// If they query a generic store without creating it initially, we should inform them.
|
|
74
|
+
if (!this.db.objectStoreNames.contains(storeName)) {
|
|
75
|
+
throw new Error(`GameStorage: Store '${storeName}' does not exist.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const transaction = this.db.transaction(storeName, mode);
|
|
79
|
+
return transaction.objectStore(storeName);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Save a value to a specific store instance.
|
|
84
|
+
*/
|
|
85
|
+
public async set<T>(storeName: string, key: string, value: T): Promise<void> {
|
|
86
|
+
const store = await this.getStore(storeName, "readwrite");
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const request = store.put(value, key);
|
|
90
|
+
request.onsuccess = () => resolve();
|
|
91
|
+
request.onerror = () => reject(request.error);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Retrieve a value from a specific store instance.
|
|
97
|
+
*/
|
|
98
|
+
public async get<T>(storeName: string, key: string): Promise<T | null> {
|
|
99
|
+
const store = await this.getStore(storeName, "readonly");
|
|
100
|
+
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const request = store.get(key);
|
|
103
|
+
request.onsuccess = () => {
|
|
104
|
+
resolve(request.result !== undefined ? request.result : null);
|
|
105
|
+
};
|
|
106
|
+
request.onerror = () => reject(request.error);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Delete a specific key from a store.
|
|
112
|
+
*/
|
|
113
|
+
public async delete(storeName: string, key: string): Promise<void> {
|
|
114
|
+
const store = await this.getStore(storeName, "readwrite");
|
|
115
|
+
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const request = store.delete(key);
|
|
118
|
+
request.onsuccess = () => resolve();
|
|
119
|
+
request.onerror = () => reject(request.error);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clear all entries from a specific store.
|
|
125
|
+
*/
|
|
126
|
+
public async clear(storeName: string): Promise<void> {
|
|
127
|
+
const store = await this.getStore(storeName, "readwrite");
|
|
128
|
+
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const request = store.clear();
|
|
131
|
+
request.onsuccess = () => resolve();
|
|
132
|
+
request.onerror = () => reject(request.error);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get all values within a specific store.
|
|
138
|
+
*/
|
|
139
|
+
public async getAll<T>(storeName: string): Promise<T[]> {
|
|
140
|
+
const store = await this.getStore(storeName, "readonly");
|
|
141
|
+
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const request = store.getAll();
|
|
144
|
+
request.onsuccess = () => resolve(request.result);
|
|
145
|
+
request.onerror = () => reject(request.error);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get all keys present in a specific store.
|
|
151
|
+
*/
|
|
152
|
+
public async getAllKeys(storeName: string): Promise<string[]> {
|
|
153
|
+
const store = await this.getStore(storeName, "readonly");
|
|
154
|
+
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const request = store.getAllKeys();
|
|
157
|
+
request.onsuccess = () => resolve(request.result as string[]);
|
|
158
|
+
request.onerror = () => reject(request.error);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { invoke } from "@tauri-apps/api/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A safe facade for interacting with Steam through Tauri's IPC.
|
|
5
|
+
* It degrades gracefully into no-ops if not running within a Tauri context,
|
|
6
|
+
* ensuring web builds do completely crash.
|
|
7
|
+
*/
|
|
8
|
+
export class SteamClient {
|
|
9
|
+
private static isTauri = Boolean(
|
|
10
|
+
typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Initializes the Steam API. Should be called early in the game lifecycle.
|
|
15
|
+
*/
|
|
16
|
+
static async init(): Promise<boolean> {
|
|
17
|
+
if (!this.isTauri) return false;
|
|
18
|
+
try {
|
|
19
|
+
return await invoke<boolean>("steam_init");
|
|
20
|
+
} catch (error) {
|
|
21
|
+
console.error("SteamClient: Failed to init Steam", error);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unlocks a specific Steam achievement by name/ID.
|
|
28
|
+
* @param achievementId The identifier for the achievement.
|
|
29
|
+
*/
|
|
30
|
+
static async unlockAchievement(achievementId: string): Promise<boolean> {
|
|
31
|
+
if (!this.isTauri) return false;
|
|
32
|
+
try {
|
|
33
|
+
return await invoke<boolean>("steam_unlock_achievement", { id: achievementId });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`SteamClient: Failed to unlock achievement ${achievementId}`, error);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Retrieves the current user's local Steam ID.
|
|
42
|
+
*/
|
|
43
|
+
static async getSteamId(): Promise<string | null> {
|
|
44
|
+
if (!this.isTauri) return null;
|
|
45
|
+
try {
|
|
46
|
+
return await invoke<string>("steam_get_user");
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("SteamClient: Failed to get Steam User ID", error);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Transform } from "../components/Transform";
|
|
3
|
+
import { World } from "./World";
|
|
4
|
+
|
|
5
|
+
describe("ECS World Fundamentals", () => {
|
|
6
|
+
let world: World;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
world = new World();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("Entity creation and destruction", () => {
|
|
13
|
+
const e1 = world.createEntity();
|
|
14
|
+
expect(world.getEntityCount()).toBe(1);
|
|
15
|
+
|
|
16
|
+
world.destroyEntity(e1);
|
|
17
|
+
expect(world.getEntityCount()).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("Component removal", () => {
|
|
21
|
+
const e1 = world.createEntity();
|
|
22
|
+
world.addComponent(e1, Transform);
|
|
23
|
+
|
|
24
|
+
expect(world.getComponent(e1, Transform)).toBeDefined();
|
|
25
|
+
|
|
26
|
+
world.removeComponent(e1, Transform);
|
|
27
|
+
expect(world.getComponent(e1, Transform)).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/ecs/World.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type Entity = number;
|
|
2
|
+
export type ComponentClass<T = any> = { new (...args: any[]): T };
|
|
3
|
+
|
|
4
|
+
export class World {
|
|
5
|
+
private nextEntityId: Entity = 1;
|
|
6
|
+
private entityPool: Entity[] = [];
|
|
7
|
+
private entities = new Set<Entity>();
|
|
8
|
+
private componentsByEntity = new Map<Entity, Map<ComponentClass, any>>();
|
|
9
|
+
private queries = new Map<
|
|
10
|
+
string,
|
|
11
|
+
{ componentClasses: ComponentClass[]; entities: Set<Entity>; cachedArray: Entity[] | null }
|
|
12
|
+
>();
|
|
13
|
+
|
|
14
|
+
createEntity(): Entity {
|
|
15
|
+
const id =
|
|
16
|
+
this.entityPool.length > 0 ? this.entityPool.pop()! : this.nextEntityId++;
|
|
17
|
+
this.entities.add(id);
|
|
18
|
+
const compMap = this.componentsByEntity.get(id);
|
|
19
|
+
if (!compMap) {
|
|
20
|
+
this.componentsByEntity.set(id, new Map());
|
|
21
|
+
} else {
|
|
22
|
+
compMap.clear();
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
clearEntities() {
|
|
28
|
+
this.entities.clear();
|
|
29
|
+
this.componentsByEntity.clear();
|
|
30
|
+
// We purposefully do not reset nextEntityId to avoid any potential dangling references grabbing a resurrected entity ID
|
|
31
|
+
this.entityPool = [];
|
|
32
|
+
for (const q of this.queries.values()) {
|
|
33
|
+
if (q.entities.size > 0) {
|
|
34
|
+
q.entities.clear();
|
|
35
|
+
q.cachedArray = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
destroyEntity(entity: Entity) {
|
|
41
|
+
if (this.entities.has(entity)) {
|
|
42
|
+
this.entities.delete(entity);
|
|
43
|
+
this.componentsByEntity.get(entity)?.clear();
|
|
44
|
+
|
|
45
|
+
for (const q of this.queries.values()) {
|
|
46
|
+
if (q.entities.has(entity)) {
|
|
47
|
+
q.entities.delete(entity);
|
|
48
|
+
q.cachedArray = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.entityPool.push(entity);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addComponent<T>(
|
|
57
|
+
entity: Entity,
|
|
58
|
+
componentClass: ComponentClass<T>,
|
|
59
|
+
...args: any[]
|
|
60
|
+
): T {
|
|
61
|
+
const map = this.componentsByEntity.get(entity);
|
|
62
|
+
if (!map) throw new Error(`Entity ${entity} does not exist`);
|
|
63
|
+
const component = new componentClass(...args);
|
|
64
|
+
map.set(componentClass, component);
|
|
65
|
+
|
|
66
|
+
for (const q of this.queries.values()) {
|
|
67
|
+
if (this.hasAllComponents(entity, q.componentClasses)) {
|
|
68
|
+
if (!q.entities.has(entity)) {
|
|
69
|
+
q.entities.add(entity);
|
|
70
|
+
q.cachedArray = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return component;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
removeComponent<T>(entity: Entity, componentClass: ComponentClass<T>) {
|
|
79
|
+
const map = this.componentsByEntity.get(entity);
|
|
80
|
+
if (map?.has(componentClass)) {
|
|
81
|
+
map.delete(componentClass);
|
|
82
|
+
|
|
83
|
+
for (const q of this.queries.values()) {
|
|
84
|
+
if (!this.hasAllComponents(entity, q.componentClasses)) {
|
|
85
|
+
if (q.entities.has(entity)) {
|
|
86
|
+
q.entities.delete(entity);
|
|
87
|
+
q.cachedArray = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getComponent<T>(
|
|
95
|
+
entity: Entity,
|
|
96
|
+
componentClass: ComponentClass<T>,
|
|
97
|
+
): T | undefined {
|
|
98
|
+
return this.componentsByEntity.get(entity)?.get(componentClass);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private hasAllComponents(
|
|
102
|
+
entity: Entity,
|
|
103
|
+
componentClasses: ComponentClass[],
|
|
104
|
+
): boolean {
|
|
105
|
+
const map = this.componentsByEntity.get(entity);
|
|
106
|
+
if (!map) return false;
|
|
107
|
+
for (let i = 0; i < componentClasses.length; i++) {
|
|
108
|
+
if (!map.has(componentClasses[i])) return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
query(...componentClasses: ComponentClass[]): Entity[] {
|
|
114
|
+
const key = componentClasses
|
|
115
|
+
.map((c) => c.name)
|
|
116
|
+
.sort()
|
|
117
|
+
.join("|");
|
|
118
|
+
|
|
119
|
+
let q = this.queries.get(key);
|
|
120
|
+
if (!q) {
|
|
121
|
+
const set = new Set<Entity>();
|
|
122
|
+
for (const entity of this.entities) {
|
|
123
|
+
if (this.hasAllComponents(entity, componentClasses)) {
|
|
124
|
+
set.add(entity);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
q = { componentClasses, entities: set, cachedArray: Array.from(set) };
|
|
128
|
+
this.queries.set(key, q);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (q.cachedArray === null) {
|
|
132
|
+
q.cachedArray = Array.from(q.entities);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return q.cachedArray;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getEntityCount() {
|
|
139
|
+
return this.entities.size;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getAllEntities(): Entity[] {
|
|
143
|
+
return Array.from(this.entities);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getComponentsForEntity(entity: Entity): Map<ComponentClass, any> | undefined {
|
|
147
|
+
return this.componentsByEntity.get(entity);
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { GlobalAssets } from "./core/AssetManager";
|
|
2
|
+
import { Input } from "./core/Input";
|
|
3
|
+
import { SceneManager } from "./core/SceneManager";
|
|
4
|
+
import { GameStorage } from "./core/Storage";
|
|
5
|
+
import type { System } from "./ecs/System";
|
|
6
|
+
import { World } from "./ecs/World";
|
|
7
|
+
|
|
8
|
+
export interface EngineConfig {
|
|
9
|
+
storage?: {
|
|
10
|
+
dbName?: string;
|
|
11
|
+
stores?: string[];
|
|
12
|
+
dbVersion?: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AetherEngine {
|
|
17
|
+
public world: World;
|
|
18
|
+
public canvas: HTMLCanvasElement;
|
|
19
|
+
public stats = { fps: 0 };
|
|
20
|
+
public assets = GlobalAssets;
|
|
21
|
+
public sceneManager: SceneManager;
|
|
22
|
+
public storage: GameStorage;
|
|
23
|
+
|
|
24
|
+
private lastTime = 0;
|
|
25
|
+
private running = false;
|
|
26
|
+
private frameId = 0;
|
|
27
|
+
private systems: System[] = [];
|
|
28
|
+
|
|
29
|
+
constructor(canvas: HTMLCanvasElement, config?: EngineConfig) {
|
|
30
|
+
this.canvas = canvas;
|
|
31
|
+
this.world = new World();
|
|
32
|
+
this.sceneManager = new SceneManager(this);
|
|
33
|
+
this.storage = new GameStorage(
|
|
34
|
+
config?.storage?.dbName,
|
|
35
|
+
config?.storage?.stores,
|
|
36
|
+
config?.storage?.dbVersion,
|
|
37
|
+
);
|
|
38
|
+
Input.initialize();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public addSystem(system: System) {
|
|
42
|
+
this.systems.push(system);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public getSystems() {
|
|
46
|
+
return this.systems;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start() {
|
|
50
|
+
if (this.running) return;
|
|
51
|
+
this.running = true;
|
|
52
|
+
this.lastTime = performance.now();
|
|
53
|
+
this.loop(this.lastTime);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop() {
|
|
57
|
+
this.running = false;
|
|
58
|
+
cancelAnimationFrame(this.frameId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private loop = (time: number) => {
|
|
62
|
+
if (!this.running) return;
|
|
63
|
+
|
|
64
|
+
// Safety cap on dt to prevent physics/logic explosions during lag
|
|
65
|
+
let dt = (time - this.lastTime) / 1000;
|
|
66
|
+
if (dt > 0.1) dt = 0.1;
|
|
67
|
+
|
|
68
|
+
this.lastTime = time;
|
|
69
|
+
|
|
70
|
+
// Moving average for fps
|
|
71
|
+
this.stats.fps = this.stats.fps * 0.9 + (1 / dt) * 0.1;
|
|
72
|
+
|
|
73
|
+
for (const system of this.systems) {
|
|
74
|
+
system.update(dt);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Input.update();
|
|
78
|
+
|
|
79
|
+
this.frameId = requestAnimationFrame(this.loop);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { AudioEngine } from "./audio/AudioEngine";
|
|
84
|
+
export { Animator3D } from "./components/Animator3D";
|
|
85
|
+
export { AudioSource } from "./components/AudioSource";
|
|
86
|
+
export { BitmapText } from "./components/BitmapText";
|
|
87
|
+
export { Camera } from "./components/Camera";
|
|
88
|
+
export { CameraFollow } from "./components/CameraFollow";
|
|
89
|
+
export { Collider } from "./components/Collider";
|
|
90
|
+
export { Light } from "./components/Light";
|
|
91
|
+
export { MeshRenderer } from "./components/MeshRenderer";
|
|
92
|
+
export { ParticleEmitter } from "./components/ParticleEmitter";
|
|
93
|
+
export { RigidBody } from "./components/RigidBody";
|
|
94
|
+
export { ShadowCaster } from "./components/ShadowCaster";
|
|
95
|
+
export { SkinnedMeshRenderer } from "./components/SkinnedMeshRenderer";
|
|
96
|
+
export { SpriteAnimator } from "./components/SpriteAnimator";
|
|
97
|
+
export { SpriteRenderer } from "./components/SpriteRenderer";
|
|
98
|
+
export { Transform } from "./components/Transform";
|
|
99
|
+
export { Input } from "./core/Input";
|
|
100
|
+
export { Scene } from "./core/Scene";
|
|
101
|
+
export { SceneManager } from "./core/SceneManager";
|
|
102
|
+
export { GameStorage } from "./core/Storage";
|
|
103
|
+
export { System } from "./ecs/System";
|
|
104
|
+
export { World } from "./ecs/World";
|
|
105
|
+
export { WebGLRenderer } from "./renderer/WebGLRenderer";
|
|
106
|
+
export { Animation3DSystem } from "./systems/Animation3DSystem";
|
|
107
|
+
export { AnimationSystem } from "./systems/AnimationSystem";
|
|
108
|
+
export { AudioSystem } from "./systems/AudioSystem";
|
|
109
|
+
export { LightingSystem } from "./systems/LightingSystem";
|
|
110
|
+
export { ParticleSystem } from "./systems/ParticleSystem";
|
|
111
|
+
export { PhysicsSystem } from "./systems/PhysicsSystem";
|
|
112
|
+
export { TextSystem } from "./systems/TextSystem";
|
|
113
|
+
export { GLTFLoader } from "./utils/GLTFLoader";
|
|
114
|
+
export { ObjLoader } from "./utils/ObjLoader";
|
|
115
|
+
export { SteamClient } from "./desktop/SteamClient";
|