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.
Files changed (73) hide show
  1. package/README.md +15 -0
  2. package/biome.json +51 -0
  3. package/bun.lock +192 -0
  4. package/index.ts +1 -0
  5. package/package.json +25 -0
  6. package/serve.ts +125 -0
  7. package/src/audio/AudioEngine.ts +61 -0
  8. package/src/components/Animator3D.ts +65 -0
  9. package/src/components/AudioSource.ts +26 -0
  10. package/src/components/BitmapText.ts +25 -0
  11. package/src/components/Camera.ts +33 -0
  12. package/src/components/CameraFollow.ts +5 -0
  13. package/src/components/Collider.ts +16 -0
  14. package/src/components/Components.test.ts +68 -0
  15. package/src/components/Light.ts +15 -0
  16. package/src/components/MeshRenderer.ts +58 -0
  17. package/src/components/ParticleEmitter.ts +59 -0
  18. package/src/components/RigidBody.ts +9 -0
  19. package/src/components/ShadowCaster.ts +3 -0
  20. package/src/components/SkinnedMeshRenderer.ts +25 -0
  21. package/src/components/SpriteAnimator.ts +42 -0
  22. package/src/components/SpriteRenderer.ts +26 -0
  23. package/src/components/Transform.test.ts +39 -0
  24. package/src/components/Transform.ts +54 -0
  25. package/src/core/AssetManager.ts +123 -0
  26. package/src/core/Input.test.ts +67 -0
  27. package/src/core/Input.ts +94 -0
  28. package/src/core/Scene.ts +24 -0
  29. package/src/core/SceneManager.ts +57 -0
  30. package/src/core/Storage.ts +161 -0
  31. package/src/desktop/SteamClient.ts +52 -0
  32. package/src/ecs/System.ts +11 -0
  33. package/src/ecs/World.test.ts +29 -0
  34. package/src/ecs/World.ts +149 -0
  35. package/src/index.ts +115 -0
  36. package/src/math/Color.ts +100 -0
  37. package/src/math/Vector2.ts +96 -0
  38. package/src/math/Vector3.ts +103 -0
  39. package/src/math/math.test.ts +168 -0
  40. package/src/renderer/GlowMaterial.ts +66 -0
  41. package/src/renderer/LitMaterial.ts +337 -0
  42. package/src/renderer/Material.test.ts +23 -0
  43. package/src/renderer/Material.ts +80 -0
  44. package/src/renderer/OcclusionMaterial.ts +43 -0
  45. package/src/renderer/ParticleMaterial.ts +66 -0
  46. package/src/renderer/Shader.ts +44 -0
  47. package/src/renderer/SkinnedLitMaterial.ts +55 -0
  48. package/src/renderer/WaterMaterial.ts +298 -0
  49. package/src/renderer/WebGLRenderer.ts +917 -0
  50. package/src/systems/Animation3DSystem.ts +148 -0
  51. package/src/systems/AnimationSystem.ts +58 -0
  52. package/src/systems/AudioSystem.ts +62 -0
  53. package/src/systems/LightingSystem.ts +114 -0
  54. package/src/systems/ParticleSystem.ts +278 -0
  55. package/src/systems/PhysicsSystem.ts +211 -0
  56. package/src/systems/Systems.test.ts +165 -0
  57. package/src/systems/TextSystem.ts +153 -0
  58. package/src/ui/AnimationEditor.tsx +639 -0
  59. package/src/ui/BottomPanel.tsx +443 -0
  60. package/src/ui/EntityExplorer.tsx +420 -0
  61. package/src/ui/GameState.ts +286 -0
  62. package/src/ui/Icons.tsx +239 -0
  63. package/src/ui/InventoryPanel.tsx +335 -0
  64. package/src/ui/PlayerHUD.tsx +250 -0
  65. package/src/ui/SpriteEditor.tsx +3241 -0
  66. package/src/ui/SpriteSheetManager.tsx +198 -0
  67. package/src/utils/GLTFLoader.ts +257 -0
  68. package/src/utils/ObjLoader.ts +81 -0
  69. package/src/utils/idb.ts +137 -0
  70. package/src/utils/packer.ts +85 -0
  71. package/test_obj.ts +12 -0
  72. package/tsconfig.json +21 -0
  73. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,198 @@
1
+ import { createSignal } from "solid-js";
2
+ import type { AetherEngine } from "../index";
3
+ import { packAtlas as packAtlasAlgo } from "../utils/packer";
4
+
5
+ export function SpriteSheetManager(_props: { engine: AetherEngine }) {
6
+ const [images, setImages] = createSignal<
7
+ { name: string; img: HTMLImageElement }[]
8
+ >([]);
9
+ const [atlasName, setAtlasName] = createSignal("packed_atlas");
10
+ const [status, setStatus] = createSignal("");
11
+
12
+ let canvasRef!: HTMLCanvasElement;
13
+
14
+ const handleFileUpload = (e: Event) => {
15
+ const files = (e.target as HTMLInputElement).files;
16
+ if (!files) return;
17
+ Array.from(files).forEach((file) => {
18
+ const reader = new FileReader();
19
+ reader.onload = (ev) => {
20
+ const img = new Image();
21
+ img.onload = () => {
22
+ setImages((prev) => [
23
+ ...prev,
24
+ { name: file.name.split(".")[0], img },
25
+ ]);
26
+ };
27
+ img.src = ev.target?.result as string;
28
+ };
29
+ reader.readAsDataURL(file);
30
+ });
31
+ };
32
+
33
+ const packAtlas = async () => {
34
+ if (images().length === 0) return;
35
+ setStatus("Packing...");
36
+
37
+ const inputs = images().map((img) => {
38
+ const cvs = document.createElement("canvas");
39
+ cvs.width = img.img.width;
40
+ cvs.height = img.img.height;
41
+ cvs.getContext("2d")?.drawImage(img.img, 0, 0);
42
+ return {
43
+ id: img.name,
44
+ width: img.img.width,
45
+ height: img.img.height,
46
+ data: cvs,
47
+ };
48
+ });
49
+
50
+ const result = packAtlasAlgo(inputs);
51
+
52
+ const canvas = canvasRef;
53
+ canvas.width = result.atlasCanvas.width;
54
+ canvas.height = result.atlasCanvas.height;
55
+ const ctx = canvas.getContext("2d")!;
56
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
57
+ ctx.drawImage(result.atlasCanvas, 0, 0);
58
+
59
+ const dataUri = canvas.toDataURL("image/png");
60
+
61
+ try {
62
+ const res = await fetch("/__aether_dev/save-sprite", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ name: atlasName(),
67
+ png: dataUri,
68
+ metadata: result.meta,
69
+ }),
70
+ });
71
+ const out = await res.json();
72
+ if (out.success) {
73
+ setStatus(
74
+ `Saved successfully: public/assets/sprites/${atlasName()}.png`,
75
+ );
76
+ }
77
+ } catch (_err) {
78
+ setStatus("Failed to save via dev API endpoint!");
79
+ }
80
+ };
81
+
82
+ return (
83
+ <div
84
+ style={{
85
+ width: "100%",
86
+ height: "100%",
87
+ display: "flex",
88
+ gap: "5px",
89
+ color: "#EEEEEE",
90
+ }}
91
+ >
92
+ <div
93
+ style={{
94
+ width: "250px",
95
+ display: "flex",
96
+ "flex-direction": "column",
97
+ gap: "10px",
98
+ }}
99
+ >
100
+ <h3
101
+ style={{ margin: "2px 0 0 0", color: "#EEEEEE", "font-size": "14px" }}
102
+ >
103
+ Atlas Tools
104
+ </h3>
105
+ <p style={{ "font-size": "11px", color: "#B4B4B4", margin: 0 }}>
106
+ Upload loose sprites to dynamically package them into an optimized
107
+ Atlas map using bin packing.
108
+ </p>
109
+
110
+ <input
111
+ type="file"
112
+ multiple
113
+ accept="image/png, image/jpeg"
114
+ onChange={handleFileUpload}
115
+ style={{
116
+ background: "#282828",
117
+ padding: "6px",
118
+ color: "#EEEEEE",
119
+ "border-radius": "3px",
120
+ border: "1px solid #222222",
121
+ "font-size": "11px",
122
+ }}
123
+ />
124
+
125
+ <div
126
+ style={{ "border-top": "1px solid #222222", margin: "5px 0" }}
127
+ ></div>
128
+
129
+ <label style={{ "font-size": "11px", color: "#B4B4B4" }}>
130
+ Output Atlas Name:
131
+ </label>
132
+ <input
133
+ type="text"
134
+ value={atlasName()}
135
+ onInput={(e) => setAtlasName(e.currentTarget.value)}
136
+ style={{
137
+ padding: "6px",
138
+ "border-radius": "3px",
139
+ border: "1px solid #222222",
140
+ background: "#282828",
141
+ color: "#EEEEEE",
142
+ "font-size": "12px",
143
+ }}
144
+ />
145
+
146
+ <button
147
+ onClick={packAtlas}
148
+ style={{
149
+ padding: "8px",
150
+ "border-radius": "3px",
151
+ border: "1px solid #222222",
152
+ cursor: "pointer",
153
+ background: "#4A4A4A",
154
+ color: "#EEEEEE",
155
+ "font-weight": "bold",
156
+ "margin-top": "5px",
157
+ "font-size": "12px",
158
+ }}
159
+ >
160
+ Pack & Save to Disk
161
+ </button>
162
+
163
+ <div
164
+ style={{
165
+ "margin-top": "10px",
166
+ color: "#EEEEEE",
167
+ "font-size": "11px",
168
+ "word-wrap": "break-word",
169
+ }}
170
+ >
171
+ {status()}
172
+ </div>
173
+ </div>
174
+
175
+ <div
176
+ style={{
177
+ flex: 1,
178
+ background: `repeating-conic-gradient(#333 0% 25%, #2a2a2a 0% 50%) 50% / 16px 16px`,
179
+ "border-radius": "4px",
180
+ border: "1px solid #222222",
181
+ overflow: "auto",
182
+ display: "flex",
183
+ "justify-content": "center",
184
+ "align-items": "center",
185
+ }}
186
+ >
187
+ <canvas
188
+ ref={canvasRef}
189
+ style={{
190
+ "max-width": "100%",
191
+ "max-height": "100%",
192
+ "image-rendering": "pixelated",
193
+ }}
194
+ ></canvas>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
@@ -0,0 +1,257 @@
1
+ import { WebIO, Document } from "@gltf-transform/core";
2
+ import { mat4, quat, vec3 } from "gl-matrix";
3
+
4
+ export interface GLBData {
5
+ positions: number[];
6
+ normals: number[];
7
+ uvs: number[];
8
+ indices: number[];
9
+ joints: number[];
10
+ weights: number[];
11
+
12
+ // Animation Data
13
+ nodes: GLTFNode[];
14
+ animations: GLTFAnimation[];
15
+ skins: GLTFSkin[];
16
+ }
17
+
18
+ export interface GLTFNode {
19
+ id: number;
20
+ name: string;
21
+ translation: vec3;
22
+ rotation: quat;
23
+ scale: vec3;
24
+
25
+ baseTranslation: vec3;
26
+ baseRotation: quat;
27
+ baseScale: vec3;
28
+
29
+ children: number[];
30
+ parent: number;
31
+ globalMatrix: mat4;
32
+ }
33
+
34
+ export interface GLTFSkin {
35
+ name: string;
36
+ joints: number[]; // node indices
37
+ inverseBindMatrices: mat4[]; // length matches joints
38
+ }
39
+
40
+ export interface GLTFAnimation {
41
+ name: string;
42
+ duration: number;
43
+ channels: GLTFChannel[];
44
+ }
45
+
46
+ export interface GLTFChannel {
47
+ node: number; // node idx
48
+ path: "translation" | "rotation" | "scale";
49
+ timestamps: Float32Array; // seconds
50
+ values: Float32Array; // vec3 or quat per timestamp
51
+ }
52
+
53
+ export class GLTFLoader {
54
+ static async load(url: string): Promise<GLBData> {
55
+ const io = new WebIO();
56
+ const doc = await io.read(url);
57
+ const root = doc.getRoot();
58
+
59
+ const result: GLBData = {
60
+ positions: [],
61
+ normals: [],
62
+ uvs: [],
63
+ indices: [],
64
+ joints: [],
65
+ weights: [],
66
+ nodes: [],
67
+ animations: [],
68
+ skins: []
69
+ };
70
+
71
+ // 1. Build Node Hierarchy
72
+ const rootNodes = root.listNodes();
73
+ for (let i = 0; i < rootNodes.length; i++) {
74
+ const n = rootNodes[i];
75
+ const t = n.getTranslation();
76
+ const r = n.getRotation();
77
+ const s = n.getScale();
78
+
79
+ result.nodes.push({
80
+ id: i,
81
+ name: n.getName() || `Node_${i}`,
82
+ translation: vec3.fromValues(t[0], t[1], t[2]),
83
+ rotation: quat.fromValues(r[0], r[1], r[2], r[3]),
84
+ scale: vec3.fromValues(s[0], s[1], s[2]),
85
+ baseTranslation: vec3.fromValues(t[0], t[1], t[2]),
86
+ baseRotation: quat.fromValues(r[0], r[1], r[2], r[3]),
87
+ baseScale: vec3.fromValues(s[0], s[1], s[2]),
88
+ children: n.listChildren().map(c => rootNodes.indexOf(c)),
89
+ parent: -1,
90
+ globalMatrix: mat4.create()
91
+ });
92
+ }
93
+
94
+ // Assign parents
95
+ for (const node of result.nodes) {
96
+ for (const childId of node.children) {
97
+ result.nodes[childId].parent = node.id;
98
+ }
99
+ }
100
+
101
+ // 2. Extract Geometry (Assuming one main Skinned Mesh for simplicity)
102
+ let vertexOffset = 0;
103
+ for (const mesh of root.listMeshes()) {
104
+ for (const prim of mesh.listPrimitives()) {
105
+ const posAccessor = prim.getAttribute('POSITION');
106
+ const normAccessor = prim.getAttribute('NORMAL');
107
+ const uvAccessor = prim.getAttribute('TEXCOORD_0');
108
+ const jointAccessor = prim.getAttribute('JOINTS_0');
109
+ const weightAccessor = prim.getAttribute('WEIGHTS_0');
110
+ const indicesAccessor = prim.getIndices();
111
+
112
+ if (posAccessor) {
113
+ for (let i = 0; i < posAccessor.getCount(); i++) {
114
+ const el = posAccessor.getElement(i, []);
115
+ result.positions.push(el[0], el[1], el[2]);
116
+ }
117
+ }
118
+
119
+ if (normAccessor) {
120
+ for (let i = 0; i < normAccessor.getCount(); i++) {
121
+ const el = normAccessor.getElement(i, []);
122
+ result.normals.push(el[0], el[1], el[2]);
123
+ }
124
+ } else if (posAccessor) {
125
+ // Fallback fill
126
+ for(let i=0; i<posAccessor.getCount(); i++) result.normals.push(0,1,0);
127
+ }
128
+
129
+ if (uvAccessor) {
130
+ for (let i = 0; i < uvAccessor.getCount(); i++) {
131
+ const el = uvAccessor.getElement(i, []);
132
+ result.uvs.push(el[0], el[1]);
133
+ }
134
+ } else if (posAccessor) {
135
+ for(let i=0; i<posAccessor.getCount(); i++) result.uvs.push(0,0);
136
+ }
137
+
138
+ if (jointAccessor) {
139
+ for (let i = 0; i < jointAccessor.getCount(); i++) {
140
+ const el = jointAccessor.getElement(i, []);
141
+ result.joints.push(el[0], el[1], el[2], el[3]);
142
+ }
143
+ } else if (posAccessor) {
144
+ for(let i=0; i<posAccessor.getCount(); i++) result.joints.push(0,0,0,0);
145
+ }
146
+
147
+ if (weightAccessor) {
148
+ for (let i = 0; i < weightAccessor.getCount(); i++) {
149
+ const el = weightAccessor.getElement(i, []);
150
+
151
+ // Normalize weights just in case they are not unorm or slightly off
152
+ const sum = el[0] + el[1] + el[2] + el[3];
153
+ if (sum > 0) {
154
+ result.weights.push(el[0]/sum, el[1]/sum, el[2]/sum, el[3]/sum);
155
+ } else {
156
+ result.weights.push(1, 0, 0, 0);
157
+ }
158
+ }
159
+ } else if (posAccessor) {
160
+ for(let i=0; i<posAccessor.getCount(); i++) result.weights.push(1,0,0,0);
161
+ }
162
+
163
+ if (indicesAccessor) {
164
+ for (let i = 0; i < indicesAccessor.getCount(); i++) {
165
+ result.indices.push(indicesAccessor.getScalar(i) + vertexOffset);
166
+ }
167
+ } else if (posAccessor) {
168
+ // generate sequential indices
169
+ for (let i = 0; i < posAccessor.getCount(); i++) {
170
+ result.indices.push(i + vertexOffset);
171
+ }
172
+ }
173
+
174
+ if (posAccessor) {
175
+ vertexOffset += posAccessor.getCount();
176
+ }
177
+ }
178
+ }
179
+
180
+ // 3. Extract Skins (Inverse Bind Matrices)
181
+ for (const skin of root.listSkins()) {
182
+ const joints = skin.listJoints().map(j => rootNodes.indexOf(j));
183
+ const ibmAccessor = skin.getInverseBindMatrices();
184
+ const ibms: mat4[] = [];
185
+
186
+ if (ibmAccessor) {
187
+ for (let i = 0; i < ibmAccessor.getCount(); i++) {
188
+ const el = ibmAccessor.getElement(i, []);
189
+ ibms.push(mat4.fromValues(
190
+ el[0], el[1], el[2], el[3],
191
+ el[4], el[5], el[6], el[7],
192
+ el[8], el[9], el[10], el[11],
193
+ el[12], el[13], el[14], el[15]
194
+ ));
195
+ }
196
+ } else {
197
+ // Default Identity
198
+ for(let i=0; i<joints.length; i++) ibms.push(mat4.create());
199
+ }
200
+
201
+ result.skins.push({
202
+ name: skin.getName() || "Skin",
203
+ joints,
204
+ inverseBindMatrices: ibms
205
+ });
206
+ }
207
+
208
+ // 4. Extract Animations
209
+ for (const anim of root.listAnimations()) {
210
+ const channels: GLTFChannel[] = [];
211
+ let maxTime = 0;
212
+
213
+ for (const channel of anim.listChannels()) {
214
+ const sampler = channel.getSampler();
215
+ const targetNode = channel.getTargetNode();
216
+ if (!sampler || !targetNode) continue;
217
+
218
+ const inputAccessor = sampler.getInput();
219
+ const outputAccessor = sampler.getOutput();
220
+ if (!inputAccessor || !outputAccessor) continue;
221
+
222
+ // Timestamps
223
+ const times = new Float32Array(inputAccessor.getCount());
224
+ for (let i = 0; i < inputAccessor.getCount(); i++) {
225
+ const t = inputAccessor.getScalar(i);
226
+ times[i] = t;
227
+ if (t > maxTime) maxTime = t;
228
+ }
229
+
230
+ // Values (vec3 or vec4)
231
+ const outSize = outputAccessor.getElementSize();
232
+ const values = new Float32Array(outputAccessor.getCount() * outSize);
233
+ for (let i = 0; i < outputAccessor.getCount(); i++) {
234
+ const val = outputAccessor.getElement(i, []);
235
+ for(let j=0; j<outSize; j++) {
236
+ values[i*outSize + j] = val[j];
237
+ }
238
+ }
239
+
240
+ channels.push({
241
+ node: rootNodes.indexOf(targetNode),
242
+ path: channel.getTargetPath() as any, // "translation" | "rotation" | "scale"
243
+ timestamps: times,
244
+ values: values
245
+ });
246
+ }
247
+
248
+ result.animations.push({
249
+ name: anim.getName() || `Animation_${result.animations.length}`,
250
+ duration: maxTime,
251
+ channels
252
+ });
253
+ }
254
+
255
+ return result;
256
+ }
257
+ }
@@ -0,0 +1,81 @@
1
+ export class ObjLoader {
2
+ static async load(
3
+ url: string,
4
+ ): Promise<{ positions: number[]; uvs: number[]; normals: number[] }> {
5
+ const response = await fetch(url);
6
+ const text = await response.text();
7
+
8
+ const positions: number[] = [];
9
+ const uvs: number[] = [];
10
+ const normals: number[] = [];
11
+
12
+ const v: number[][] = [];
13
+ const vt: number[][] = [];
14
+ const vn: number[][] = [];
15
+
16
+ const lines = text.split("\n");
17
+
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const line = lines[i].trim();
20
+ if (line.length === 0 || line.startsWith("#")) continue;
21
+
22
+ const parts = line.split(/\s+/);
23
+ const type = parts[0];
24
+
25
+ if (type === "v") {
26
+ v.push([parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])]);
27
+ } else if (type === "vt") {
28
+ vt.push([parseFloat(parts[1]), parseFloat(parts[2])]);
29
+ } else if (type === "vn") {
30
+ vn.push([parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3])]);
31
+ } else if (type === "f") {
32
+ // faces can be triangles or quads (or more)
33
+ // format can be v, v/vt, v/vt/vn, or v//vn
34
+ const vertices = parts.slice(1);
35
+
36
+ // Triangulate if more than 3 vertices (primitive fan)
37
+ for (let j = 1; j < vertices.length - 1; j++) {
38
+ const v0 = vertices[0];
39
+ const v1 = vertices[j];
40
+ const v2 = vertices[j + 1];
41
+
42
+ const processVertex = (vertData: string) => {
43
+ const [vIdx, vtIdx, vnIdx] = vertData.split("/");
44
+
45
+ // Position (1-indexed)
46
+ if (vIdx) {
47
+ const vert = v[parseInt(vIdx) - 1];
48
+ positions.push(vert[0], vert[1], vert[2]);
49
+ }
50
+
51
+ // UVs (1-indexed)
52
+ if (vtIdx) {
53
+ const tex = vt[parseInt(vtIdx) - 1];
54
+ uvs.push(tex[0], tex[1]);
55
+ } else {
56
+ uvs.push(0, 0); // Default if missing
57
+ }
58
+
59
+ // Normals (1-indexed)
60
+ if (vnIdx) {
61
+ const norm = vn[parseInt(vnIdx) - 1];
62
+ normals.push(norm[0], norm[1], norm[2]);
63
+ } else {
64
+ normals.push(0, 0, 1); // Default if missing
65
+ }
66
+ };
67
+
68
+ processVertex(v0);
69
+ processVertex(v1);
70
+ processVertex(v2);
71
+ }
72
+ }
73
+ }
74
+
75
+ return {
76
+ positions,
77
+ uvs,
78
+ normals,
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,137 @@
1
+ const getAppNamespace = () => {
2
+ const meta = document.querySelector('meta[name="aether-app-id"]');
3
+ if (meta) {
4
+ const content = meta.getAttribute("content") || "default";
5
+ const safeStr = btoa(content).replace(/=/g, "");
6
+ return `AetherDevTools_${safeStr}`;
7
+ }
8
+ return "AetherDevTools";
9
+ };
10
+
11
+ export const getDB = (): Promise<IDBDatabase> => {
12
+ return new Promise((resolve, reject) => {
13
+ const req = indexedDB.open(getAppNamespace(), 5);
14
+ req.onerror = () => reject(req.error);
15
+ req.onsuccess = () => resolve(req.result);
16
+ req.onupgradeneeded = (e: any) => {
17
+ const db = e.target.result as IDBDatabase;
18
+ if (e.oldVersion < 4) {
19
+ if (db.objectStoreNames.contains("SpriteHistory")) {
20
+ db.deleteObjectStore("SpriteHistory");
21
+ }
22
+ }
23
+ if (!db.objectStoreNames.contains("SpriteHistory")) {
24
+ db.createObjectStore("SpriteHistory", {
25
+ keyPath: "id",
26
+ autoIncrement: true,
27
+ });
28
+ }
29
+ if (!db.objectStoreNames.contains("AppState")) {
30
+ db.createObjectStore("AppState", { keyPath: "key" });
31
+ }
32
+ };
33
+ });
34
+ };
35
+
36
+ export const setActiveHistoryId = async (id: number): Promise<void> => {
37
+ const db = await getDB();
38
+ return new Promise((resolve, reject) => {
39
+ const tx = db.transaction("AppState", "readwrite");
40
+ const st = tx.objectStore("AppState");
41
+ const req = st.put({ key: "active_history_id", value: id });
42
+ req.onsuccess = () => resolve();
43
+ req.onerror = () => reject(req.error);
44
+ });
45
+ };
46
+
47
+ export const getActiveHistoryId = async (): Promise<number | null> => {
48
+ const db = await getDB();
49
+ return new Promise((resolve, reject) => {
50
+ const tx = db.transaction("AppState", "readonly");
51
+ const st = tx.objectStore("AppState");
52
+ const req = st.get("active_history_id");
53
+ req.onsuccess = () =>
54
+ resolve(req.result !== undefined ? req.result.value : null);
55
+ req.onerror = () => reject(req.error);
56
+ });
57
+ };
58
+
59
+ export const pushHistory = async (state: any): Promise<number> => {
60
+ const db = await getDB();
61
+ return new Promise((resolve, reject) => {
62
+ const tx = db.transaction("SpriteHistory", "readwrite");
63
+ const st = tx.objectStore("SpriteHistory");
64
+ const req = st.add({ timestamp: Date.now(), state });
65
+ req.onsuccess = (e: any) => resolve(e.target.result);
66
+ req.onerror = () => reject(req.error);
67
+ });
68
+ };
69
+
70
+ export const getHistory = async (id: number): Promise<any> => {
71
+ const db = await getDB();
72
+ return new Promise((resolve, reject) => {
73
+ const tx = db.transaction("SpriteHistory", "readonly");
74
+ const st = tx.objectStore("SpriteHistory");
75
+ const req = st.get(id);
76
+ req.onsuccess = () => resolve(req.result?.state);
77
+ req.onerror = () => reject(req.error);
78
+ });
79
+ };
80
+
81
+ export const getLatestHistoryId = async (): Promise<number | null> => {
82
+ const db = await getDB();
83
+ return new Promise((resolve, reject) => {
84
+ const tx = db.transaction("SpriteHistory", "readonly");
85
+ const st = tx.objectStore("SpriteHistory");
86
+ const req = st.openCursor(null, "prev");
87
+ req.onsuccess = (e: any) => {
88
+ const cursor = e.target.result;
89
+ if (cursor) resolve(cursor.key);
90
+ else resolve(null);
91
+ };
92
+ req.onerror = () => reject(req.error);
93
+ });
94
+ };
95
+
96
+ export const getPrevHistoryId = async (
97
+ currentId: number,
98
+ ): Promise<number | null> => {
99
+ const db = await getDB();
100
+ return new Promise((resolve, reject) => {
101
+ const tx = db.transaction("SpriteHistory", "readonly");
102
+ const st = tx.objectStore("SpriteHistory");
103
+ const req = st.openCursor(IDBKeyRange.upperBound(currentId, true), "prev");
104
+ req.onsuccess = (e: any) => {
105
+ const cursor = e.target.result;
106
+ resolve(cursor ? cursor.key : null);
107
+ };
108
+ req.onerror = () => reject(req.error);
109
+ });
110
+ };
111
+
112
+ export const getNextHistoryId = async (
113
+ currentId: number,
114
+ ): Promise<number | null> => {
115
+ const db = await getDB();
116
+ return new Promise((resolve, reject) => {
117
+ const tx = db.transaction("SpriteHistory", "readonly");
118
+ const st = tx.objectStore("SpriteHistory");
119
+ const req = st.openCursor(IDBKeyRange.lowerBound(currentId, true), "next");
120
+ req.onsuccess = (e: any) => {
121
+ const cursor = e.target.result;
122
+ resolve(cursor ? cursor.key : null);
123
+ };
124
+ req.onerror = () => reject(req.error);
125
+ });
126
+ };
127
+
128
+ export const truncateHistoryAfter = async (id: number): Promise<void> => {
129
+ const db = await getDB();
130
+ return new Promise((resolve, reject) => {
131
+ const tx = db.transaction("SpriteHistory", "readwrite");
132
+ const st = tx.objectStore("SpriteHistory");
133
+ const req = st.delete(IDBKeyRange.lowerBound(id, true));
134
+ req.onsuccess = () => resolve();
135
+ req.onerror = () => reject(req.error);
136
+ });
137
+ };