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,16 @@
|
|
|
1
|
+
import { vec3 } from "gl-matrix";
|
|
2
|
+
|
|
3
|
+
export class Collider {
|
|
4
|
+
public type: "AABB" | "Sphere" = "AABB";
|
|
5
|
+
|
|
6
|
+
public size: vec3 = vec3.fromValues(1, 1, 1); // For AABB
|
|
7
|
+
public radius: number = 0.5; // For Sphere
|
|
8
|
+
public centerOffset: vec3 = vec3.create();
|
|
9
|
+
|
|
10
|
+
public isTrigger: boolean = false;
|
|
11
|
+
|
|
12
|
+
// Collision state tracking
|
|
13
|
+
public collisions: number[] = []; // Entity IDs we currently overlap
|
|
14
|
+
public enterCollisions: number[] = []; // Entity IDs we just started overlapping this frame
|
|
15
|
+
public exitCollisions: number[] = []; // Entity IDs we just stopped overlapping this frame
|
|
16
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BitmapText } from "./BitmapText";
|
|
3
|
+
import { Collider } from "./Collider";
|
|
4
|
+
import { Light } from "./Light";
|
|
5
|
+
import { MeshRenderer } from "./MeshRenderer";
|
|
6
|
+
import { ParticleEmitter } from "./ParticleEmitter";
|
|
7
|
+
import { RigidBody } from "./RigidBody";
|
|
8
|
+
|
|
9
|
+
describe("ECS Components", () => {
|
|
10
|
+
test("RigidBody defaults", () => {
|
|
11
|
+
const rb = new RigidBody();
|
|
12
|
+
expect(rb.velocity[0]).toBe(0);
|
|
13
|
+
expect(rb.isKinematic).toBe(false);
|
|
14
|
+
expect(rb.mass).toBe(1.0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("Collider intersection mapping", () => {
|
|
18
|
+
const c1 = new Collider();
|
|
19
|
+
c1.size[0] = 10;
|
|
20
|
+
c1.size[1] = 10;
|
|
21
|
+
expect(c1.size[0]).toBe(10);
|
|
22
|
+
expect(c1.size[1]).toBe(10);
|
|
23
|
+
expect(c1.isTrigger).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("Light types and parameters", () => {
|
|
27
|
+
const l = new Light("Ambient");
|
|
28
|
+
expect(l.type).toBe("Ambient");
|
|
29
|
+
expect(l.intensity).toBe(1.0);
|
|
30
|
+
|
|
31
|
+
const l2 = new Light("Point");
|
|
32
|
+
expect(l2.type).toBe("Point");
|
|
33
|
+
expect(l2.radius).toBe(100.0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("ParticleEmitter initializes buffers correctly", () => {
|
|
37
|
+
const pe = new ParticleEmitter(500);
|
|
38
|
+
expect(pe.maxParticles).toBe(500);
|
|
39
|
+
expect(pe.pLife.length).toBe(500);
|
|
40
|
+
expect(pe.pPosX.length).toBe(500);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("BitmapText sets dirty flag on text change", () => {
|
|
44
|
+
const bt = new BitmapText("Hello", "font1");
|
|
45
|
+
bt.isDirty = false;
|
|
46
|
+
bt.text = "World";
|
|
47
|
+
expect(bt.isDirty).toBe(true);
|
|
48
|
+
expect(bt.text).toBe("World");
|
|
49
|
+
|
|
50
|
+
// Setting same string should not dirty
|
|
51
|
+
bt.isDirty = false;
|
|
52
|
+
bt.text = "World";
|
|
53
|
+
expect(bt.isDirty).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("MeshRenderer processes advanced geometry arrays", () => {
|
|
57
|
+
const positions = [0, 0, 0, 1, 0, 0, 1, 1, 0];
|
|
58
|
+
const uvs = [0, 0, 1, 0, 1, 1];
|
|
59
|
+
const indices = [0, 1, 2];
|
|
60
|
+
const colors = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
|
|
61
|
+
|
|
62
|
+
const mesh = new MeshRenderer(positions, undefined, colors, uvs, indices);
|
|
63
|
+
expect(mesh.vertexCount).toBe(3);
|
|
64
|
+
expect(mesh.indexCount).toBe(3);
|
|
65
|
+
expect(mesh.uvs?.length).toBe(6);
|
|
66
|
+
expect(mesh.indices?.length).toBe(3);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Color } from "../math/Color";
|
|
2
|
+
|
|
3
|
+
export class Light {
|
|
4
|
+
public type: "Point" | "Ambient" | "Directional" = "Point";
|
|
5
|
+
public color: Color = Color.White();
|
|
6
|
+
public intensity: number = 1.0;
|
|
7
|
+
public radius: number = 100.0; // Falloff for point lights
|
|
8
|
+
public elevation: number = 0.0; // Z-axis physical offset from its transform base
|
|
9
|
+
public positionOffset: [number, number, number] = [0, 0, 0]; // Visual screen-space offset
|
|
10
|
+
public flickerIntensity: number = 0.0; // Mixes in procedural flicker
|
|
11
|
+
|
|
12
|
+
constructor(type: "Point" | "Ambient" | "Directional" = "Point") {
|
|
13
|
+
this.type = type;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MESH_FS,
|
|
3
|
+
DEFAULT_MESH_VS,
|
|
4
|
+
Material,
|
|
5
|
+
} from "../renderer/Material";
|
|
6
|
+
|
|
7
|
+
export class MeshRenderer {
|
|
8
|
+
public positions: Float32Array;
|
|
9
|
+
public normals?: Float32Array;
|
|
10
|
+
public colors?: Float32Array;
|
|
11
|
+
public uvs?: Float32Array;
|
|
12
|
+
public indices?: Uint16Array;
|
|
13
|
+
|
|
14
|
+
public positionBuffer?: WebGLBuffer;
|
|
15
|
+
public normalBuffer?: WebGLBuffer;
|
|
16
|
+
public colorBuffer?: WebGLBuffer;
|
|
17
|
+
public uvBuffer?: WebGLBuffer;
|
|
18
|
+
public indexBuffer?: WebGLBuffer;
|
|
19
|
+
|
|
20
|
+
public vertexCount: number = 0;
|
|
21
|
+
public indexCount: number = 0;
|
|
22
|
+
public isDirty: boolean = false;
|
|
23
|
+
|
|
24
|
+
public material: Material;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
positions: number[],
|
|
28
|
+
normals?: number[],
|
|
29
|
+
colors?: number[],
|
|
30
|
+
uvs?: number[],
|
|
31
|
+
indices?: number[],
|
|
32
|
+
material?: Material,
|
|
33
|
+
) {
|
|
34
|
+
this.material = material || new Material(DEFAULT_MESH_VS, DEFAULT_MESH_FS);
|
|
35
|
+
this.positions = new Float32Array(positions);
|
|
36
|
+
this.vertexCount = Math.floor(positions.length / 3);
|
|
37
|
+
|
|
38
|
+
if (normals) {
|
|
39
|
+
this.normals = new Float32Array(normals);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (colors) {
|
|
43
|
+
if (colors.length / 4 !== this.vertexCount) {
|
|
44
|
+
console.warn("Color array length does not match vertex count expected");
|
|
45
|
+
}
|
|
46
|
+
this.colors = new Float32Array(colors);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (uvs) {
|
|
50
|
+
this.uvs = new Float32Array(uvs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (indices) {
|
|
54
|
+
this.indices = new Uint16Array(indices);
|
|
55
|
+
this.indexCount = indices.length;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Color } from "../math/Color";
|
|
2
|
+
import { Material } from "../renderer/Material";
|
|
3
|
+
|
|
4
|
+
export class ParticleEmitter {
|
|
5
|
+
public maxParticles: number;
|
|
6
|
+
public emissionRate: number = 100;
|
|
7
|
+
public emitAccumulator: number = 0;
|
|
8
|
+
|
|
9
|
+
public minSpeed: number = 2.0;
|
|
10
|
+
public maxSpeed: number = 5.0;
|
|
11
|
+
public lifespan: number = 1.0;
|
|
12
|
+
|
|
13
|
+
public startColor: Color = Color.White();
|
|
14
|
+
public endColor: Color = Color.White();
|
|
15
|
+
public startSize: number = 0.5;
|
|
16
|
+
public endSize: number = 0.1;
|
|
17
|
+
|
|
18
|
+
// Tailored Tweakable Options
|
|
19
|
+
public material?: Material;
|
|
20
|
+
public billboard: boolean = true;
|
|
21
|
+
public shape: "square" | "circle" = "square";
|
|
22
|
+
public depthWrite: boolean = false;
|
|
23
|
+
public blendMode: "Alpha" | "Additive" = "Additive";
|
|
24
|
+
|
|
25
|
+
public lightRadius: number = 2.236; // Default to old hardcoded ~2.236 radius (radiusSq = 5)
|
|
26
|
+
|
|
27
|
+
public activeCount: number = 0;
|
|
28
|
+
|
|
29
|
+
public useSuperformula: boolean = false;
|
|
30
|
+
public superformulaMode: "velocity" | "position" = "velocity";
|
|
31
|
+
public superformula = {
|
|
32
|
+
m: 5,
|
|
33
|
+
n1: 1,
|
|
34
|
+
n2: 1,
|
|
35
|
+
n3: 1,
|
|
36
|
+
a: 1,
|
|
37
|
+
b: 1,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
public pLife: Float32Array;
|
|
41
|
+
public pPosX: Float32Array;
|
|
42
|
+
public pPosY: Float32Array;
|
|
43
|
+
public pPosZ: Float32Array;
|
|
44
|
+
public pVelX: Float32Array;
|
|
45
|
+
public pVelY: Float32Array;
|
|
46
|
+
public pVelZ: Float32Array;
|
|
47
|
+
|
|
48
|
+
constructor(maxParticles: number = 1000) {
|
|
49
|
+
this.maxParticles = maxParticles;
|
|
50
|
+
|
|
51
|
+
this.pLife = new Float32Array(maxParticles);
|
|
52
|
+
this.pPosX = new Float32Array(maxParticles);
|
|
53
|
+
this.pPosY = new Float32Array(maxParticles);
|
|
54
|
+
this.pPosZ = new Float32Array(maxParticles);
|
|
55
|
+
this.pVelX = new Float32Array(maxParticles);
|
|
56
|
+
this.pVelY = new Float32Array(maxParticles);
|
|
57
|
+
this.pVelZ = new Float32Array(maxParticles);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Material } from "../renderer/Material";
|
|
2
|
+
import { MeshRenderer } from "./MeshRenderer";
|
|
3
|
+
|
|
4
|
+
export class SkinnedMeshRenderer extends MeshRenderer {
|
|
5
|
+
public joints: Float32Array;
|
|
6
|
+
public weights: Float32Array;
|
|
7
|
+
|
|
8
|
+
public jointBuffer?: WebGLBuffer;
|
|
9
|
+
public weightBuffer?: WebGLBuffer;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
positions: number[],
|
|
13
|
+
normals: number[],
|
|
14
|
+
colors: number[] | undefined,
|
|
15
|
+
uvs: number[],
|
|
16
|
+
indices: number[] | undefined,
|
|
17
|
+
joints: number[],
|
|
18
|
+
weights: number[],
|
|
19
|
+
material?: Material,
|
|
20
|
+
) {
|
|
21
|
+
super(positions, normals, colors, uvs, indices, material);
|
|
22
|
+
this.joints = new Float32Array(joints);
|
|
23
|
+
this.weights = new Float32Array(weights);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type AnimationDefinition = {
|
|
2
|
+
name: string;
|
|
3
|
+
frames: number[]; // Array of frame indices via flattened grid (0 to cols*rows - 1)
|
|
4
|
+
fps: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export class SpriteAnimator {
|
|
8
|
+
public columns: number = 1;
|
|
9
|
+
public rows: number = 1;
|
|
10
|
+
public atlasFrames?: { x: number; y: number; w: number; h: number }[];
|
|
11
|
+
public atlasSize?: { w: number; h: number };
|
|
12
|
+
|
|
13
|
+
public flipX: boolean = false;
|
|
14
|
+
|
|
15
|
+
public animations = new Map<string, AnimationDefinition>();
|
|
16
|
+
|
|
17
|
+
public currentAnimation: string = "";
|
|
18
|
+
public currentFrameIndex: number = 0; // The active frame index within the mapped animation's frames array
|
|
19
|
+
public isPlaying: boolean = true;
|
|
20
|
+
public timeAccumulator: number = 0;
|
|
21
|
+
|
|
22
|
+
constructor(columns: number = 1, rows: number = 1) {
|
|
23
|
+
this.columns = columns;
|
|
24
|
+
this.rows = rows;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addAnimation(name: string, frames: number[], fps: number) {
|
|
28
|
+
this.animations.set(name, { name, frames, fps });
|
|
29
|
+
if (!this.currentAnimation) {
|
|
30
|
+
this.play(name);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
play(name: string) {
|
|
35
|
+
if (this.currentAnimation !== name) {
|
|
36
|
+
this.currentAnimation = name;
|
|
37
|
+
this.currentFrameIndex = 0;
|
|
38
|
+
this.timeAccumulator = 0;
|
|
39
|
+
}
|
|
40
|
+
this.isPlaying = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SPRITE_FS,
|
|
3
|
+
DEFAULT_SPRITE_VS,
|
|
4
|
+
Material,
|
|
5
|
+
} from "../renderer/Material";
|
|
6
|
+
|
|
7
|
+
export class SpriteRenderer {
|
|
8
|
+
public texture?: WebGLTexture;
|
|
9
|
+
public imageSrc: string;
|
|
10
|
+
public color: Float32Array = new Float32Array([1, 1, 1, 1]); // Tint
|
|
11
|
+
|
|
12
|
+
// UV mapping for sprite sheets
|
|
13
|
+
public uvOffset: Float32Array = new Float32Array([0, 0]);
|
|
14
|
+
public uvScale: Float32Array = new Float32Array([1, 1]);
|
|
15
|
+
|
|
16
|
+
public material: Material;
|
|
17
|
+
|
|
18
|
+
// WebGL specific tracking
|
|
19
|
+
public _loaded: boolean = false;
|
|
20
|
+
|
|
21
|
+
constructor(imageSrc: string, material?: Material) {
|
|
22
|
+
this.imageSrc = imageSrc;
|
|
23
|
+
this.material =
|
|
24
|
+
material || new Material(DEFAULT_SPRITE_VS, DEFAULT_SPRITE_FS);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { quat, vec3 } from "gl-matrix";
|
|
3
|
+
import { Transform } from "./Transform";
|
|
4
|
+
|
|
5
|
+
describe("Transform Hierarchy", () => {
|
|
6
|
+
test("Parent child global position transformation", () => {
|
|
7
|
+
const root = new Transform();
|
|
8
|
+
vec3.set(root.localPosition, 10, 0, 0);
|
|
9
|
+
root.updateMatrix();
|
|
10
|
+
|
|
11
|
+
const child = new Transform();
|
|
12
|
+
child.setParent(root);
|
|
13
|
+
vec3.set(child.localPosition, 5, 0, 0);
|
|
14
|
+
|
|
15
|
+
// Update root which propagates to children
|
|
16
|
+
root.updateMatrix();
|
|
17
|
+
|
|
18
|
+
expect(root.position[0]).toBe(10);
|
|
19
|
+
expect(child.position[0]).toBe(15);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("Parent child global rotation transformation", () => {
|
|
23
|
+
const root = new Transform();
|
|
24
|
+
// Rotate 90 degrees on Y axis
|
|
25
|
+
quat.setAxisAngle(root.localRotation, [0, 1, 0], Math.PI / 2);
|
|
26
|
+
|
|
27
|
+
const child = new Transform();
|
|
28
|
+
child.setParent(root);
|
|
29
|
+
// Move child by 5 units on X in local space
|
|
30
|
+
vec3.set(child.localPosition, 5, 0, 0);
|
|
31
|
+
|
|
32
|
+
root.updateMatrix();
|
|
33
|
+
|
|
34
|
+
// In global space, 5 units on X rotated 90 deg around Y should result in 5 units on -Z
|
|
35
|
+
expect(child.position[0]).toBeCloseTo(0, 5);
|
|
36
|
+
expect(child.position[1]).toBeCloseTo(0, 5);
|
|
37
|
+
expect(child.position[2]).toBeCloseTo(-5, 5);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mat4, quat, vec3 } from "gl-matrix";
|
|
2
|
+
|
|
3
|
+
export class Transform {
|
|
4
|
+
public position: vec3 = vec3.create();
|
|
5
|
+
public rotation: quat = quat.create();
|
|
6
|
+
public scale: vec3 = vec3.fromValues(1, 1, 1);
|
|
7
|
+
|
|
8
|
+
public localPosition: vec3 = vec3.create();
|
|
9
|
+
public localRotation: quat = quat.create();
|
|
10
|
+
public localScale: vec3 = vec3.fromValues(1, 1, 1);
|
|
11
|
+
|
|
12
|
+
public matrix: mat4 = mat4.create();
|
|
13
|
+
public localMatrix: mat4 = mat4.create();
|
|
14
|
+
|
|
15
|
+
public parent: Transform | null = null;
|
|
16
|
+
public children: Set<Transform> = new Set();
|
|
17
|
+
|
|
18
|
+
setParent(newParent: Transform | null) {
|
|
19
|
+
if (this.parent === newParent) return;
|
|
20
|
+
|
|
21
|
+
if (this.parent) {
|
|
22
|
+
this.parent.children.delete(this);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.parent = newParent;
|
|
26
|
+
|
|
27
|
+
if (this.parent) {
|
|
28
|
+
this.parent.children.add(this);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
updateMatrix() {
|
|
33
|
+
mat4.fromRotationTranslationScale(
|
|
34
|
+
this.localMatrix,
|
|
35
|
+
this.localRotation,
|
|
36
|
+
this.localPosition,
|
|
37
|
+
this.localScale,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (this.parent) {
|
|
41
|
+
mat4.multiply(this.matrix, this.parent.matrix, this.localMatrix);
|
|
42
|
+
} else {
|
|
43
|
+
mat4.copy(this.matrix, this.localMatrix);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mat4.getTranslation(this.position, this.matrix);
|
|
47
|
+
mat4.getRotation(this.rotation, this.matrix);
|
|
48
|
+
mat4.getScaling(this.scale, this.matrix);
|
|
49
|
+
|
|
50
|
+
for (const child of this.children) {
|
|
51
|
+
child.updateMatrix();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export class TextureAsset {
|
|
2
|
+
public image: HTMLImageElement;
|
|
3
|
+
public glTexture?: WebGLTexture;
|
|
4
|
+
public loaded: boolean = false;
|
|
5
|
+
|
|
6
|
+
constructor(public url: string) {
|
|
7
|
+
this.image = new Image();
|
|
8
|
+
this.image.crossOrigin = "anonymous";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async load(): Promise<this> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (this.loaded) {
|
|
14
|
+
return resolve(this);
|
|
15
|
+
}
|
|
16
|
+
this.image.onload = () => {
|
|
17
|
+
this.loaded = true;
|
|
18
|
+
resolve(this);
|
|
19
|
+
};
|
|
20
|
+
this.image.onerror = reject;
|
|
21
|
+
this.image.src = this.url;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BMFontChar {
|
|
27
|
+
id: number;
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
xoffset: number;
|
|
33
|
+
yoffset: number;
|
|
34
|
+
xadvance: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BMFontMetrics {
|
|
38
|
+
textureWidth: number;
|
|
39
|
+
textureHeight: number;
|
|
40
|
+
lineHeight: number;
|
|
41
|
+
base: number;
|
|
42
|
+
chars: Map<number, BMFontChar>;
|
|
43
|
+
textureAsset: TextureAsset;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AssetManagerImpl {
|
|
47
|
+
public textures = new Map<string, TextureAsset>();
|
|
48
|
+
public fonts = new Map<string, BMFontMetrics>();
|
|
49
|
+
|
|
50
|
+
public loadTexture(url: string): TextureAsset {
|
|
51
|
+
if (this.textures.has(url)) {
|
|
52
|
+
const asset = this.textures.get(url)!;
|
|
53
|
+
asset.load().catch(console.error);
|
|
54
|
+
return asset;
|
|
55
|
+
}
|
|
56
|
+
const tex = new TextureAsset(url);
|
|
57
|
+
this.textures.set(url, tex);
|
|
58
|
+
tex.load().catch(console.error);
|
|
59
|
+
return tex;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public getTexture(url: string): TextureAsset | undefined {
|
|
63
|
+
return this.textures.get(url);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public async loadBMFont(
|
|
67
|
+
fontId: string,
|
|
68
|
+
xmlUrl: string,
|
|
69
|
+
textureUrl: string,
|
|
70
|
+
): Promise<BMFontMetrics> {
|
|
71
|
+
let metrics = this.fonts.get(fontId);
|
|
72
|
+
if (metrics) return metrics;
|
|
73
|
+
|
|
74
|
+
const textureAsset = this.loadTexture(textureUrl);
|
|
75
|
+
await textureAsset.load();
|
|
76
|
+
|
|
77
|
+
const response = await fetch(xmlUrl);
|
|
78
|
+
const xmlText = await response.text();
|
|
79
|
+
|
|
80
|
+
const parser = new DOMParser();
|
|
81
|
+
const doc = parser.parseFromString(xmlText, "text/xml");
|
|
82
|
+
|
|
83
|
+
const common = doc.querySelector("common")!;
|
|
84
|
+
const textureWidth = parseInt(common.getAttribute("scaleW") || "256", 10);
|
|
85
|
+
const textureHeight = parseInt(common.getAttribute("scaleH") || "256", 10);
|
|
86
|
+
const lineHeight = parseInt(common.getAttribute("lineHeight") || "32", 10);
|
|
87
|
+
const base = parseInt(common.getAttribute("base") || "32", 10);
|
|
88
|
+
|
|
89
|
+
const charsMap = new Map<number, BMFontChar>();
|
|
90
|
+
const chars = doc.querySelectorAll("char");
|
|
91
|
+
for (let i = 0; i < chars.length; i++) {
|
|
92
|
+
const c = chars.item(i);
|
|
93
|
+
charsMap.set(parseInt(c.getAttribute("id")!, 10), {
|
|
94
|
+
id: parseInt(c.getAttribute("id")!, 10),
|
|
95
|
+
x: parseInt(c.getAttribute("x")!, 10),
|
|
96
|
+
y: parseInt(c.getAttribute("y")!, 10),
|
|
97
|
+
width: parseInt(c.getAttribute("width")!, 10),
|
|
98
|
+
height: parseInt(c.getAttribute("height")!, 10),
|
|
99
|
+
xoffset: parseInt(c.getAttribute("xoffset")!, 10),
|
|
100
|
+
yoffset: parseInt(c.getAttribute("yoffset")!, 10),
|
|
101
|
+
xadvance: parseInt(c.getAttribute("xadvance")!, 10),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
metrics = {
|
|
106
|
+
textureWidth,
|
|
107
|
+
textureHeight,
|
|
108
|
+
lineHeight,
|
|
109
|
+
base,
|
|
110
|
+
chars: charsMap,
|
|
111
|
+
textureAsset,
|
|
112
|
+
};
|
|
113
|
+
this.fonts.set(fontId, metrics);
|
|
114
|
+
return metrics;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public getFont(fontId: string): BMFontMetrics | undefined {
|
|
118
|
+
return this.fonts.get(fontId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const GlobalAssets = new AssetManagerImpl();
|
|
123
|
+
export class AssetManager extends AssetManagerImpl {}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Input } from "./Input";
|
|
3
|
+
|
|
4
|
+
describe("Input Manager", () => {
|
|
5
|
+
let listeners: Record<string, (...args: any[]) => void> = {};
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
listeners = {};
|
|
9
|
+
// Mock Window
|
|
10
|
+
globalThis.window = {
|
|
11
|
+
addEventListener: (type: string, fn: (...args: any[]) => void) => {
|
|
12
|
+
listeners[type] = fn;
|
|
13
|
+
},
|
|
14
|
+
} as any;
|
|
15
|
+
(Input as any)._initialized = false;
|
|
16
|
+
Input.initialize();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Action Mapping and binding", () => {
|
|
20
|
+
Input.bind("Jump", ["Space"]);
|
|
21
|
+
// Simulate keydown
|
|
22
|
+
(Input as any)._keys.add("Space");
|
|
23
|
+
|
|
24
|
+
expect(Input.isActionPressed("Jump")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Edge detection logic", () => {
|
|
28
|
+
Input.bind("Fire", ["Enter"]);
|
|
29
|
+
(Input as any)._keys.add("Enter");
|
|
30
|
+
|
|
31
|
+
expect(Input.isActionJustPressed("Fire")).toBe(true);
|
|
32
|
+
expect(Input.isActionPressed("Fire")).toBe(true);
|
|
33
|
+
|
|
34
|
+
Input.update(); // End of Frame 1
|
|
35
|
+
expect(Input.isActionJustPressed("Fire")).toBe(false);
|
|
36
|
+
expect(Input.isActionPressed("Fire")).toBe(true);
|
|
37
|
+
|
|
38
|
+
(Input as any)._keys.delete("Enter");
|
|
39
|
+
|
|
40
|
+
expect(Input.isActionJustReleased("Fire")).toBe(true);
|
|
41
|
+
expect(Input.isActionPressed("Fire")).toBe(false);
|
|
42
|
+
|
|
43
|
+
Input.update(); // End of Frame 2
|
|
44
|
+
expect(Input.isActionJustReleased("Fire")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("Mouse interactions", () => {
|
|
48
|
+
listeners.mousemove({ clientX: 100, clientY: 200 });
|
|
49
|
+
expect(Input.getMousePosition().x).toBe(100);
|
|
50
|
+
expect(Input.getMousePosition().y).toBe(200);
|
|
51
|
+
|
|
52
|
+
listeners.mousedown();
|
|
53
|
+
expect(Input.isMouseDown()).toBe(true);
|
|
54
|
+
expect(Input.isMouseJustPressed()).toBe(true);
|
|
55
|
+
|
|
56
|
+
Input.update();
|
|
57
|
+
expect(Input.isMouseDown()).toBe(true);
|
|
58
|
+
expect(Input.isMouseJustPressed()).toBe(false);
|
|
59
|
+
|
|
60
|
+
listeners.mouseup();
|
|
61
|
+
expect(Input.isMouseDown()).toBe(false);
|
|
62
|
+
expect(Input.isMouseJustReleased()).toBe(true);
|
|
63
|
+
|
|
64
|
+
Input.update();
|
|
65
|
+
expect(Input.isMouseJustReleased()).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|