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,3241 @@
1
+ import {
2
+ createEffect,
3
+ createRoot,
4
+ createSignal,
5
+ For,
6
+ onCleanup,
7
+ onMount,
8
+ Show,
9
+ } from "solid-js";
10
+ import {
11
+ type AetherEngine,
12
+ SpriteAnimator,
13
+ SpriteRenderer,
14
+ WebGLRenderer,
15
+ } from "../index";
16
+ import {
17
+ getActiveHistoryId,
18
+ getHistory,
19
+ getLatestHistoryId,
20
+ getNextHistoryId,
21
+ getPrevHistoryId,
22
+ pushHistory,
23
+ setActiveHistoryId,
24
+ truncateHistoryAfter,
25
+ } from "../utils/idb";
26
+ import { packAtlas } from "../utils/packer";
27
+ import {
28
+ IconBucket,
29
+ IconCheck,
30
+ IconCircle,
31
+ IconCopy,
32
+ IconDown,
33
+ IconDuplicate,
34
+ IconEraser,
35
+ IconEye,
36
+ IconEyeOff,
37
+ IconFlipHorizontal,
38
+ IconFlipVertical,
39
+ IconLine,
40
+ IconMergeDown,
41
+ IconPan,
42
+ IconPaste,
43
+ IconPencil,
44
+ IconPlus,
45
+ IconRect,
46
+ IconSelect,
47
+ IconTrash,
48
+ IconUp,
49
+ IconX,
50
+ } from "./Icons";
51
+
52
+ type Tool =
53
+ | "pencil"
54
+ | "eraser"
55
+ | "fill"
56
+ | "line"
57
+ | "rect"
58
+ | "circle"
59
+ | "pan"
60
+ | "select";
61
+
62
+ interface Layer {
63
+ id: string;
64
+ name: string;
65
+ visible: boolean;
66
+ opacity: number;
67
+ }
68
+
69
+ interface Frame {
70
+ id: string;
71
+ name?: string;
72
+ width?: number;
73
+ height?: number;
74
+ x?: number;
75
+ y?: number;
76
+ cels: Record<string, HTMLCanvasElement>;
77
+ }
78
+
79
+ interface Project {
80
+ id: string;
81
+ name: string;
82
+ width: number;
83
+ height: number;
84
+ frames: Frame[];
85
+ layers: Layer[];
86
+ activeFrameId: string;
87
+ activeLayerId: string;
88
+ isAtlas?: boolean;
89
+ metadata?: any;
90
+ }
91
+
92
+ const uid = () => Math.random().toString(36).substring(2, 9);
93
+ const uDark = "#383838";
94
+ const uHeader = "#2D2D2D";
95
+ const uBorder = "#222222";
96
+ const uActive = "#3C3C3C";
97
+ const uAcc = "#3A72B0";
98
+ const uBtn = "#4A4A4A";
99
+ const uText = "#B4B4B4";
100
+ const uActiveText = "#EEEEEE";
101
+
102
+ export const {
103
+ projects,
104
+ setProjects,
105
+ activeProjId,
106
+ setActiveProjId,
107
+ tool,
108
+ setTool,
109
+ color,
110
+ setColor,
111
+ zoom,
112
+ setZoom,
113
+ panX,
114
+ setPanX,
115
+ panY,
116
+ setPanY,
117
+ saveStatus,
118
+ setSaveStatus,
119
+ } = createRoot(() => {
120
+ const [projects, setProjects] = createSignal<Project[]>([]);
121
+ const [activeProjId, setActiveProjId] = createSignal<string>("");
122
+ const [tool, setTool] = createSignal<Tool>("pencil");
123
+ const [color, setColor] = createSignal<string>("#EEEEEE");
124
+ const [zoom, setZoom] = createSignal<number>(12);
125
+ const [panX, setPanX] = createSignal<number>(0);
126
+ const [panY, setPanY] = createSignal<number>(0);
127
+ const [saveStatus, setSaveStatus] = createSignal<string>("Ready");
128
+ return {
129
+ projects,
130
+ setProjects,
131
+ activeProjId,
132
+ setActiveProjId,
133
+ tool,
134
+ setTool,
135
+ color,
136
+ setColor,
137
+ zoom,
138
+ setZoom,
139
+ panX,
140
+ setPanX,
141
+ panY,
142
+ setPanY,
143
+ saveStatus,
144
+ setSaveStatus,
145
+ };
146
+ });
147
+
148
+ export function SpriteEditor(props: {
149
+ engine: AetherEngine;
150
+ onClose?: () => void;
151
+ embedded?: boolean;
152
+ }) {
153
+ const [atlasViewMode, setAtlasViewMode] = createSignal<"subimage" | "atlas">(
154
+ "subimage",
155
+ );
156
+ const [atlasDataUri, setAtlasDataUri] = createSignal<string>("");
157
+ const [atlasMapW, setAtlasMapW] = createSignal<number>(32);
158
+ const [atlasMapH, setAtlasMapH] = createSignal<number>(32);
159
+
160
+ const [entities, setEntities] = createSignal<
161
+ { id: number; renderer: SpriteRenderer; animator?: SpriteAnimator }[]
162
+ >([]);
163
+ const [targetEntity, setTargetEntity] = createSignal<number>(-1);
164
+
165
+ const [dialog, setDialog] = createSignal<{
166
+ title: string;
167
+ placeholder: string;
168
+ toggleText?: string;
169
+ onConfirm: (v: string, toggleValue?: boolean) => void;
170
+ onCancel?: () => void;
171
+ } | null>(null);
172
+ const [dialogToggle, setDialogToggle] = createSignal<boolean>(false);
173
+ let dialogInputRef!: HTMLInputElement;
174
+
175
+ const [_tick, setTick] = createSignal(0);
176
+
177
+ let compositeCanvasRef!: HTMLCanvasElement;
178
+ let previewCanvasRef!: HTMLCanvasElement;
179
+
180
+ const offscreenFrame = document.createElement("canvas");
181
+ const offscreenMap = document.createElement("canvas");
182
+
183
+ let isDrawing = false;
184
+ let startX = 0,
185
+ startY = 0;
186
+ let lastX = 0,
187
+ lastY = 0;
188
+ let isPanning = false;
189
+ let panStartX = 0,
190
+ panStartY = 0;
191
+
192
+ let currentHistoryId: number | null = null;
193
+ const setCurrentHistoryId = (id: number) => {
194
+ currentHistoryId = id;
195
+ setActiveHistoryId(id);
196
+ };
197
+
198
+ let isRestoringHistory = false;
199
+
200
+ interface SelectionBox {
201
+ x: number;
202
+ y: number;
203
+ w: number;
204
+ h: number;
205
+ }
206
+ const [selectionBox, setSelectionBox] = createSignal<SelectionBox | null>(
207
+ null,
208
+ );
209
+ let clipboardData: { w: number; h: number; data: Uint8ClampedArray } | null =
210
+ null;
211
+ let isMovingSelection = false;
212
+ let moveStartX = 0,
213
+ moveStartY = 0;
214
+ let movingImageData: ImageData | null = null;
215
+ let initialSelectionX = 0,
216
+ initialSelectionY = 0;
217
+
218
+ const captureState = () => {
219
+ return projects().map((p) => ({
220
+ id: p.id,
221
+ name: p.name,
222
+ width: p.width,
223
+ height: p.height,
224
+ isAtlas: p.isAtlas,
225
+ metadata: p.metadata,
226
+ activeFrameId: p.activeFrameId,
227
+ activeLayerId: p.activeLayerId,
228
+ layers: JSON.parse(JSON.stringify(p.layers)),
229
+ frames: p.frames.map((f) => {
230
+ const fW = f.width || p.width;
231
+ const fH = f.height || p.height;
232
+ const newCels: Record<string, Uint8ClampedArray> = {};
233
+ Object.keys(f.cels).forEach((lId) => {
234
+ const ctx = f.cels[lId].getContext("2d", {
235
+ willReadFrequently: true,
236
+ })!;
237
+ newCels[lId] = ctx.getImageData(0, 0, fW, fH).data;
238
+ });
239
+ return {
240
+ id: f.id,
241
+ name: f.name,
242
+ width: f.width,
243
+ height: f.height,
244
+ x: f.x,
245
+ y: f.y,
246
+ cels: newCels,
247
+ };
248
+ }),
249
+ }));
250
+ };
251
+
252
+ const loadState = (serialized: any[]) => {
253
+ const newProjects = serialized
254
+ .filter((p) => p.width > 0 && p.height > 0)
255
+ .map((p) => {
256
+ return {
257
+ ...p,
258
+ frames: p.frames.map((f: any) => {
259
+ const fW = f.width || p.width;
260
+ const fH = f.height || p.height;
261
+ const newCels: Record<string, HTMLCanvasElement> = {};
262
+ Object.keys(f.cels).forEach((lId) => {
263
+ const cvs = initCel(fW, fH);
264
+ const ctx = cvs.getContext("2d", { willReadFrequently: true })!;
265
+ ctx.putImageData(new ImageData(f.cels[lId], fW, fH), 0, 0);
266
+ newCels[lId] = cvs;
267
+ });
268
+ return { ...f, cels: newCels, x: f.x, y: f.y };
269
+ }),
270
+ };
271
+ });
272
+ setProjects(newProjects);
273
+ if (newProjects.length > 0 && !activeProjId()) {
274
+ setActiveProjId(newProjects[newProjects.length - 1].id);
275
+ }
276
+
277
+ // Delay explicitly providing SolidJS physical execution time natively batching `<canvas>` `<For>` arrays safely into DOM!
278
+ setTimeout(() => syncToEngine(), 10);
279
+ };
280
+
281
+ const pushState = async () => {
282
+ if (isRestoringHistory) return;
283
+ const state = captureState();
284
+ if (currentHistoryId !== null) {
285
+ await truncateHistoryAfter(currentHistoryId);
286
+ }
287
+ setCurrentHistoryId(await pushHistory(state));
288
+ };
289
+
290
+ const initCel = (w: number, h: number) => {
291
+ const cvs = document.createElement("canvas");
292
+ cvs.width = w;
293
+ cvs.height = h;
294
+ return cvs;
295
+ };
296
+
297
+ const createProject = (
298
+ name: string,
299
+ w: number,
300
+ h: number,
301
+ isAtlas: boolean = false,
302
+ ) => {
303
+ const layerId = uid();
304
+ const frameId = uid();
305
+ const proj: Project = {
306
+ id: uid(),
307
+ name,
308
+ width: w,
309
+ height: h,
310
+ isAtlas,
311
+ layers: [{ id: layerId, name: "Layer 1", visible: true, opacity: 1.0 }],
312
+ frames: [
313
+ {
314
+ id: frameId,
315
+ name: isAtlas ? "cell_0" : undefined,
316
+ width: w,
317
+ height: h,
318
+ cels: { [layerId]: initCel(w, h) },
319
+ },
320
+ ],
321
+ activeFrameId: frameId,
322
+ activeLayerId: layerId,
323
+ };
324
+ setProjects((p) => [...p, proj]);
325
+ setActiveProjId(proj.id);
326
+ };
327
+
328
+ const loadProjectFromDisk = async (
329
+ name: string,
330
+ engineCols?: number,
331
+ engineRows?: number,
332
+ engineAtlasFrames?: { x: number; y: number; w: number; h: number }[],
333
+ ): Promise<Project | null> => {
334
+ try {
335
+ const res = await fetch(
336
+ `/public/assets/sprites/${name}.json?t=${Date.now()}`,
337
+ );
338
+ let meta: any = {};
339
+ let cols = engineCols || 1;
340
+ let rows = engineRows || 1;
341
+ let isAtlasJSON = false;
342
+
343
+ if (res.ok) {
344
+ meta = await res.json();
345
+ isAtlasJSON =
346
+ meta.frames &&
347
+ !Array.isArray(meta.frames) &&
348
+ typeof meta.frames === "object";
349
+ cols = meta.columns || engineCols || 1;
350
+ rows = meta.rows || engineRows || 1;
351
+ }
352
+
353
+ const img = new Image();
354
+ img.src = `/public/assets/sprites/${name}.png`;
355
+ await new Promise((resolve, reject) => {
356
+ img.onload = resolve;
357
+ img.onerror = reject;
358
+ });
359
+
360
+ const projId = uid();
361
+ const layerId = uid();
362
+ const frames: Frame[] = [];
363
+
364
+ let pW = 32,
365
+ pH = 32;
366
+
367
+ if (isAtlasJSON) {
368
+ const keys = Object.keys(meta.frames);
369
+ if (keys.length > 0) {
370
+ pW =
371
+ meta.frames[keys[0]].sourceSize?.w || meta.frames[keys[0]].frame.w;
372
+ pH =
373
+ meta.frames[keys[0]].sourceSize?.h || meta.frames[keys[0]].frame.h;
374
+ }
375
+ keys.forEach((k) => {
376
+ const rect = meta.frames[k].frame;
377
+ const cvs = initCel(rect.w, rect.h);
378
+ const ctx = cvs.getContext("2d", { willReadFrequently: true })!;
379
+ ctx.drawImage(
380
+ img,
381
+ rect.x,
382
+ rect.y,
383
+ rect.w,
384
+ rect.h,
385
+ 0,
386
+ 0,
387
+ rect.w,
388
+ rect.h,
389
+ );
390
+ frames.push({
391
+ id: uid(),
392
+ name: k,
393
+ width: rect.w,
394
+ height: rect.h,
395
+ x: rect.x,
396
+ y: rect.y,
397
+ cels: { [layerId]: cvs },
398
+ });
399
+ });
400
+ } else if (engineAtlasFrames && engineAtlasFrames.length > 0) {
401
+ isAtlasJSON = true;
402
+ pW = engineAtlasFrames[0].w || 32;
403
+ pH = engineAtlasFrames[0].h || 32;
404
+ engineAtlasFrames.forEach((rect, i) => {
405
+ const cvs = initCel(rect.w, rect.h);
406
+ const ctx = cvs.getContext("2d", { willReadFrequently: true })!;
407
+ ctx.drawImage(
408
+ img,
409
+ rect.x,
410
+ rect.y,
411
+ rect.w,
412
+ rect.h,
413
+ 0,
414
+ 0,
415
+ rect.w,
416
+ rect.h,
417
+ );
418
+ frames.push({
419
+ id: uid(),
420
+ name: `cell_${i}`,
421
+ width: rect.w,
422
+ height: rect.h,
423
+ x: rect.x,
424
+ y: rect.y,
425
+ cels: { [layerId]: cvs },
426
+ });
427
+ });
428
+ } else {
429
+ pW = Math.floor(img.width / cols);
430
+ pH = Math.floor(img.height / rows);
431
+ for (let r = 0; r < rows; r++) {
432
+ for (let c = 0; c < cols; c++) {
433
+ const cvs = initCel(pW, pH);
434
+ const ctx = cvs.getContext("2d", { willReadFrequently: true })!;
435
+ ctx.drawImage(img, c * pW, r * pH, pW, pH, 0, 0, pW, pH);
436
+ frames.push({
437
+ id: uid(),
438
+ name: `cell_${r}_${c}`,
439
+ width: pW,
440
+ height: pH,
441
+ x: c * pW,
442
+ y: r * pH,
443
+ cels: { [layerId]: cvs },
444
+ });
445
+ }
446
+ }
447
+ }
448
+
449
+ return {
450
+ id: projId,
451
+ name,
452
+ width: pW,
453
+ height: pH,
454
+ isAtlas: isAtlasJSON,
455
+ metadata: {
456
+ ...meta,
457
+ columns: cols,
458
+ rows: rows,
459
+ fullWidth: img.width,
460
+ fullHeight: img.height,
461
+ },
462
+ activeFrameId: frames[0]?.id || uid(),
463
+ activeLayerId: layerId,
464
+ layers: [{ id: layerId, name: "Layer 1", visible: true, opacity: 1.0 }],
465
+ frames,
466
+ };
467
+ } catch (e) {
468
+ console.error(`Failed to load ${name} from disk:`, e);
469
+ return null;
470
+ }
471
+ };
472
+
473
+ let editorRef!: HTMLDivElement;
474
+
475
+ onMount(async () => {
476
+ if (editorRef) {
477
+ let pinchAcc = 0;
478
+ editorRef.addEventListener(
479
+ "wheel",
480
+ (e: WheelEvent) => {
481
+ if (e.ctrlKey || e.metaKey) e.preventDefault();
482
+ pinchAcc -= e.deltaY; // Negative deltaY means zooming IN
483
+
484
+ const threshold = 5;
485
+ const steps = Math.trunc(pinchAcc / threshold);
486
+
487
+ if (Math.abs(steps) >= 1) {
488
+ setZoom((z) => Math.max(2, Math.min(60, z + steps)));
489
+ pinchAcc -= steps * threshold; // Preserve remainder for smoothness
490
+ }
491
+ },
492
+ { passive: false },
493
+ );
494
+ }
495
+
496
+ const handleKey = async (e: KeyboardEvent) => {
497
+ if (document.activeElement?.tagName === "INPUT") return;
498
+
499
+ if (e.code === "KeyZ" && (e.ctrlKey || e.metaKey)) {
500
+ e.preventDefault();
501
+ if (currentHistoryId === null || isRestoringHistory) return;
502
+ isRestoringHistory = true;
503
+ try {
504
+ if (e.shiftKey) {
505
+ const nextId = await getNextHistoryId(currentHistoryId);
506
+ if (nextId !== null) {
507
+ const state = await getHistory(nextId);
508
+ if (state) {
509
+ loadState(state);
510
+ setCurrentHistoryId(nextId);
511
+ }
512
+ }
513
+ } else {
514
+ const prevId = await getPrevHistoryId(currentHistoryId);
515
+ if (prevId !== null) {
516
+ const state = await getHistory(prevId);
517
+ if (state) {
518
+ loadState(state);
519
+ setCurrentHistoryId(prevId);
520
+ }
521
+ }
522
+ }
523
+ } finally {
524
+ isRestoringHistory = false;
525
+ }
526
+ }
527
+
528
+ if ((e.ctrlKey || e.metaKey) && e.code === "KeyV") {
529
+ e.preventDefault();
530
+ selectionAction("paste");
531
+ }
532
+ if (selectionBox()) {
533
+ if ((e.ctrlKey || e.metaKey) && e.code === "KeyC") {
534
+ e.preventDefault();
535
+ selectionAction("copy");
536
+ }
537
+ if ((e.ctrlKey || e.metaKey) && e.code === "KeyD") {
538
+ e.preventDefault();
539
+ selectionAction("duplicate");
540
+ }
541
+ if (e.code === "Delete" || e.code === "Backspace") {
542
+ e.preventDefault();
543
+ selectionAction("delete");
544
+ }
545
+ }
546
+ };
547
+ window.addEventListener("keydown", handleKey);
548
+ onCleanup(() => window.removeEventListener("keydown", handleKey));
549
+
550
+ const loadActiveAssets = async () => {
551
+ const all = props.engine.world.query(SpriteRenderer);
552
+ const uniqueSources = new Map<
553
+ string,
554
+ {
555
+ cols: number;
556
+ rows: number;
557
+ atlasFrames?: { x: number; y: number; w: number; h: number }[];
558
+ forceAtlasMode?: boolean;
559
+ engineUvRects?: { u: number; v: number; su: number; sv: number }[];
560
+ }
561
+ >();
562
+
563
+ for (const id of all) {
564
+ const rnd = props.engine.world.getComponent(id, SpriteRenderer);
565
+ const anim = props.engine.world.getComponent(id, SpriteAnimator);
566
+ if (rnd?.imageSrc?.includes("/public/assets/sprites/")) {
567
+ const n = rnd.imageSrc
568
+ .replace("/public/assets/sprites/", "")
569
+ .replace(".png", "");
570
+ if (!uniqueSources.has(n)) {
571
+ // Infer grid dimensions directly from engine uv payload or animator
572
+ let cols = Math.round(1.0 / rnd.uvScale[0]) || 1;
573
+ let rows = Math.round(1.0 / rnd.uvScale[1]) || 1;
574
+ let atlasFrames;
575
+
576
+ if (anim) {
577
+ if (anim.columns > 1 || anim.rows > 1) {
578
+ cols = Math.max(cols, anim.columns);
579
+ rows = Math.max(rows, anim.rows);
580
+ } else if (anim.atlasFrames && anim.atlasFrames.length > 0) {
581
+ atlasFrames = anim.atlasFrames;
582
+ }
583
+ }
584
+ uniqueSources.set(n, { cols, rows, atlasFrames });
585
+ }
586
+ }
587
+ }
588
+
589
+ // Note: Project purging explicitly disabled here to prevent spontaneous UI component collapse
590
+ // occurring uniquely when managing internally generated IDB entities (e.g. Atlases/unnamed_sprite).
591
+
592
+ if (uniqueSources.size > 0) {
593
+ const toLoad = Array.from(uniqueSources.entries()).filter(
594
+ ([s, _data]) => !projects().find((p) => p.name === s),
595
+ );
596
+ if (toLoad.length > 0) {
597
+ const loaded = (
598
+ await Promise.all(
599
+ toLoad.map(([s, data]) =>
600
+ loadProjectFromDisk(s, data.cols, data.rows, data.atlasFrames),
601
+ ),
602
+ )
603
+ ).filter(Boolean) as Project[];
604
+ if (loaded.length > 0) {
605
+ setProjects((prev) => [...prev, ...loaded]);
606
+ if (!activeProjId()) setActiveProjId(loaded[loaded.length - 1].id);
607
+ pushState();
608
+ }
609
+ }
610
+ }
611
+ if (projects().length === 0) {
612
+ createProject("unnamed_sprite", 32, 32);
613
+ }
614
+ };
615
+
616
+ if (projects().length === 0) {
617
+ const savedId = await getActiveHistoryId();
618
+ let targetId = savedId !== null ? savedId : null;
619
+ let state = targetId !== null ? await getHistory(targetId) : null;
620
+
621
+ // Fallback to highest ID if active_history record went missing or was wiped
622
+ if (!state) {
623
+ targetId = await getLatestHistoryId();
624
+ if (targetId !== null) {
625
+ state = await getHistory(targetId);
626
+ }
627
+ }
628
+
629
+ if (state && targetId !== null) {
630
+ isRestoringHistory = true;
631
+ loadState(state);
632
+ setCurrentHistoryId(targetId);
633
+ isRestoringHistory = false;
634
+ }
635
+
636
+ // Sync missing active game assets ONLY when booting cleanly from cache or missing UI states!
637
+ setTimeout(loadActiveAssets, 500);
638
+ }
639
+
640
+ const pollEntities = () => {
641
+ const all = props.engine.world.query(SpriteRenderer);
642
+ const list = all.map((id) => ({
643
+ id,
644
+ renderer: props.engine.world.getComponent(id, SpriteRenderer)!,
645
+ animator: props.engine.world.getComponent(id, SpriteAnimator),
646
+ }));
647
+ setEntities((prev) => {
648
+ if (prev.length !== list.length) return list;
649
+ for (let i = 0; i < prev.length; i++)
650
+ if (prev[i].id !== list[i].id) return list;
651
+ return prev;
652
+ });
653
+ if (targetEntity() === -1 && list.length > 0) {
654
+ setTargetEntity(list[0].id);
655
+ const target = list[0];
656
+ if (target?.renderer?.imageSrc) {
657
+ const name = target.renderer.imageSrc
658
+ .replace("/public/assets/sprites/", "")
659
+ .replace(".png", "");
660
+ const proj = projects().find((p) => p.name === name);
661
+ if (proj && activeProjId() !== proj.id) setActiveProjId(proj.id);
662
+ }
663
+ }
664
+ };
665
+ pollEntities();
666
+ const interval = setInterval(pollEntities, 1000);
667
+ onCleanup(() => clearInterval(interval));
668
+ });
669
+
670
+ const activeProj = () => projects().find((p) => p.id === activeProjId());
671
+
672
+ const compositeFrame = (
673
+ proj: Project,
674
+ frameId: string,
675
+ ): HTMLCanvasElement => {
676
+ const frame = proj.frames.find((f) => f.id === frameId);
677
+ if (!frame) return offscreenFrame;
678
+
679
+ const fW = frame.width || proj.width;
680
+ const fH = frame.height || proj.height;
681
+ offscreenFrame.width = fW;
682
+ offscreenFrame.height = fH;
683
+ const ctx = offscreenFrame.getContext("2d", { willReadFrequently: true })!;
684
+ ctx.clearRect(0, 0, fW, fH);
685
+
686
+ // draw bottom to top
687
+ const reversedLayers = [...proj.layers].reverse();
688
+ reversedLayers.forEach((l) => {
689
+ if (!l.visible) return;
690
+ const cel = frame.cels[l.id];
691
+ if (cel) {
692
+ ctx.globalAlpha = l.opacity;
693
+ ctx.drawImage(cel, 0, 0);
694
+ ctx.globalAlpha = 1.0;
695
+ }
696
+ });
697
+ return offscreenFrame;
698
+ };
699
+
700
+ const syncToEngine = () => {
701
+ const proj = activeProj();
702
+ if (!proj) return;
703
+
704
+ // Repaint composite viewport
705
+ if (compositeCanvasRef) {
706
+ const frame = proj.frames.find((f) => f.id === proj.activeFrameId);
707
+ const fW = frame?.width || proj.width;
708
+ const fH = frame?.height || proj.height;
709
+ compositeCanvasRef.width = fW * zoom();
710
+ compositeCanvasRef.height = fH * zoom();
711
+ const ctx = compositeCanvasRef.getContext("2d", {
712
+ willReadFrequently: true,
713
+ })!;
714
+ ctx.imageSmoothingEnabled = false;
715
+ ctx.clearRect(0, 0, fW * zoom(), fH * zoom());
716
+ const comp = compositeFrame(proj, proj.activeFrameId);
717
+ ctx.save();
718
+ ctx.scale(zoom(), zoom());
719
+ ctx.drawImage(comp, 0, 0);
720
+ ctx.restore();
721
+ }
722
+
723
+ if (targetEntity() === -1) return;
724
+ const target = entities().find((e) => e.id === targetEntity());
725
+ if (!target) return;
726
+
727
+ // Strict Source-of-Truth Validation Gate
728
+ // Entirely prevents the UI from overwriting unrelated textures on async activeProject drift!
729
+ const targetName = target.renderer?.imageSrc
730
+ ?.replace("/public/assets/sprites/", "")
731
+ .replace(".png", "");
732
+ if (targetName !== proj.name) return;
733
+
734
+ if (proj.isAtlas) {
735
+ const hasExplicitLayout = proj.frames.every(
736
+ (f) => f.x !== undefined && f.y !== undefined,
737
+ );
738
+ if (hasExplicitLayout) {
739
+ let maxW = proj.metadata?.fullWidth || 0;
740
+ let maxH = proj.metadata?.fullHeight || 0;
741
+ if (!maxW || !maxH) {
742
+ proj.frames.forEach((f) => {
743
+ const rw = f.width || proj.width;
744
+ const rh = f.height || proj.height;
745
+ if (f.x !== undefined && f.x + rw > maxW) maxW = f.x + rw;
746
+ if (f.y !== undefined && f.y + rh > maxH) maxH = f.y + rh;
747
+ });
748
+ }
749
+
750
+ offscreenMap.width = maxW;
751
+ offscreenMap.height = maxH;
752
+ const ctx = offscreenMap.getContext("2d", {
753
+ willReadFrequently: true,
754
+ })!;
755
+ ctx.clearRect(0, 0, offscreenMap.width, offscreenMap.height);
756
+
757
+ const metaFrames: any = {};
758
+ proj.frames.forEach((fr) => {
759
+ const c = compositeFrame(proj, fr.id);
760
+ ctx.drawImage(c, fr.x!, fr.y!);
761
+ metaFrames[fr.name || fr.id] = {
762
+ frame: {
763
+ x: fr.x,
764
+ y: fr.y,
765
+ w: fr.width || proj.width,
766
+ h: fr.height || proj.height,
767
+ },
768
+ sourceSize: {
769
+ w: fr.width || proj.width,
770
+ h: fr.height || proj.height,
771
+ },
772
+ };
773
+
774
+ const thumbId = `thumb_${fr.id}`;
775
+ const cvs = document.getElementById(thumbId) as HTMLCanvasElement;
776
+ if (cvs) {
777
+ const tCtx = cvs.getContext("2d", { willReadFrequently: true })!;
778
+ tCtx.clearRect(0, 0, cvs.width, cvs.height);
779
+ tCtx.drawImage(c, 0, 0);
780
+ }
781
+ });
782
+
783
+ setAtlasMapW(offscreenMap.width);
784
+ setAtlasMapH(offscreenMap.height);
785
+ const uri = offscreenMap.toDataURL("image/png");
786
+ setAtlasDataUri(uri);
787
+ const _dataUri = uri;
788
+ meta.frames = metaFrames;
789
+ if (!meta.animations) meta.animations = {};
790
+ } else {
791
+ const inputs = proj.frames.map((fr) => {
792
+ const c = compositeFrame(proj, fr.id);
793
+
794
+ const thumbId = `thumb_${fr.id}`;
795
+ const tCvs = document.getElementById(thumbId) as HTMLCanvasElement;
796
+ if (tCvs) {
797
+ const tCtx = tCvs.getContext("2d", { willReadFrequently: true })!;
798
+ tCtx.clearRect(0, 0, tCvs.width, tCvs.height);
799
+ tCtx.drawImage(c, 0, 0);
800
+ }
801
+
802
+ const dup = document.createElement("canvas");
803
+ dup.width = c.width;
804
+ dup.height = c.height;
805
+ dup
806
+ .getContext("2d", { willReadFrequently: true })
807
+ ?.drawImage(c, 0, 0);
808
+ return {
809
+ id: fr.name || fr.id,
810
+ width: fr.width || proj.width,
811
+ height: fr.height || proj.height,
812
+ data: dup,
813
+ };
814
+ });
815
+ const result = packAtlas(inputs);
816
+
817
+ offscreenMap.width = result.atlasCanvas.width;
818
+ offscreenMap.height = result.atlasCanvas.height;
819
+ const ctx = offscreenMap.getContext("2d", {
820
+ willReadFrequently: true,
821
+ })!;
822
+ ctx.clearRect(0, 0, offscreenMap.width, offscreenMap.height);
823
+ ctx.drawImage(result.atlasCanvas, 0, 0);
824
+
825
+ setAtlasMapW(offscreenMap.width);
826
+ setAtlasMapH(offscreenMap.height);
827
+ const uri = offscreenMap.toDataURL("image/png");
828
+ setAtlasDataUri(uri);
829
+ const _dataUri = uri;
830
+ meta.frames = result.meta.frames;
831
+ if (!meta.animations) meta.animations = {};
832
+ }
833
+ } else {
834
+ const inferredCols = proj.metadata?.columns || proj.frames.length;
835
+ const inferredRows =
836
+ proj.metadata?.rows || Math.ceil(proj.frames.length / inferredCols);
837
+
838
+ offscreenMap.width = inferredCols * proj.width;
839
+ offscreenMap.height = inferredRows * proj.height;
840
+ const mapCtx = offscreenMap.getContext("2d", {
841
+ willReadFrequently: true,
842
+ })!;
843
+ mapCtx.clearRect(0, 0, offscreenMap.width, offscreenMap.height);
844
+
845
+ proj.frames.forEach((fr, i) => {
846
+ const c = compositeFrame(proj, fr.id);
847
+ const gridX = i % inferredCols;
848
+ const gridY = Math.floor(i / inferredCols);
849
+ mapCtx.drawImage(c, gridX * proj.width, gridY * proj.height);
850
+
851
+ const thumbId = `thumb_${fr.id}`;
852
+ const cvs = document.getElementById(thumbId) as HTMLCanvasElement;
853
+ if (cvs) {
854
+ const ctx = cvs.getContext("2d", { willReadFrequently: true })!;
855
+ ctx.clearRect(0, 0, cvs.width, cvs.height);
856
+ ctx.drawImage(c, 0, 0);
857
+ }
858
+ });
859
+
860
+ setAtlasMapW(offscreenMap.width);
861
+ setAtlasMapH(offscreenMap.height);
862
+ setAtlasDataUri(offscreenMap.toDataURL("image/png"));
863
+
864
+ const renderSystems = props.engine
865
+ .getSystems()
866
+ .filter((s) => s instanceof WebGLRenderer) as WebGLRenderer[];
867
+ if (renderSystems.length > 0)
868
+ renderSystems[0].updateTexture(target.renderer, offscreenMap);
869
+
870
+ if (target.animator) {
871
+ target.animator.atlasFrames = undefined;
872
+ target.animator.atlasSize = undefined;
873
+ target.animator.columns = inferredCols;
874
+ target.animator.rows = inferredRows;
875
+ target.animator.addAnimation(
876
+ "preview",
877
+ proj.frames.map((_, i) => i),
878
+ 4,
879
+ );
880
+ if (!target.animator.currentAnimation) {
881
+ target.animator.play("preview");
882
+ }
883
+ }
884
+ }
885
+ autoSaveToDisk();
886
+ setTick((t) => t + 1);
887
+ };
888
+
889
+ createEffect(() => {
890
+ zoom(); // subscribe
891
+ activeProjId();
892
+ atlasViewMode();
893
+ setTimeout(() => {
894
+ if (projects().length > 0) syncToEngine();
895
+ }, 10);
896
+ });
897
+
898
+ createEffect(() => {
899
+ const _bx = selectionBox();
900
+ if (!previewCanvasRef) return;
901
+ const pCtx = previewCanvasRef.getContext("2d", {
902
+ willReadFrequently: true,
903
+ });
904
+ if (!pCtx) return;
905
+ const proj = activeProj();
906
+ if (!proj) return;
907
+
908
+ if (!isDrawing && !isMovingSelection) {
909
+ pCtx.clearRect(0, 0, proj.width, proj.height);
910
+ // DOM Overlay natively handles crisp layout selection bounds instead
911
+ }
912
+ });
913
+
914
+ createEffect(() => {
915
+ const tId = targetEntity();
916
+ const projId = activeProjId();
917
+ if (tId === -1 || !projId) return;
918
+ const target = entities().find((e) => e.id === tId);
919
+ if (!target?.animator) return;
920
+
921
+ if (!target.animator.isPlaying && target.animator.currentAnimation) {
922
+ const anim = target.animator.animations.get(
923
+ target.animator.currentAnimation,
924
+ );
925
+ if (anim && target.animator.currentFrameIndex < anim.frames.length) {
926
+ const fData = anim.frames[target.animator.currentFrameIndex];
927
+ // fData is the requested index. Verify it exists in our project.
928
+ let outOfBounds = false;
929
+
930
+ setProjects((projs) =>
931
+ projs.map((p) => {
932
+ if (p.id !== projId) return p;
933
+ if (fData < p.frames.length) {
934
+ if (p.activeFrameId !== p.frames[fData].id) {
935
+ return { ...p, activeFrameId: p.frames[fData].id };
936
+ }
937
+ } else {
938
+ outOfBounds = true;
939
+ }
940
+ return p;
941
+ }),
942
+ );
943
+
944
+ if (outOfBounds) {
945
+ // Gracefully handle the orphaned index by shifting engine back to safe preview!
946
+ target.animator.currentAnimation = "preview";
947
+ target.animator.currentFrameIndex = 0;
948
+ }
949
+ }
950
+ }
951
+ });
952
+
953
+ createEffect(() => {
954
+ const proj = activeProj();
955
+ if (proj && entities().length > 0) {
956
+ const currentTarget = entities().find((e) => e.id === targetEntity());
957
+ const currentTargetName = currentTarget?.renderer?.imageSrc
958
+ ?.replace("/public/assets/sprites/", "")
959
+ .replace(".png", "");
960
+
961
+ if (currentTargetName !== proj.name) {
962
+ const newTarget = entities().find((e) => {
963
+ if (!e.renderer?.imageSrc) return false;
964
+ return (
965
+ e.renderer.imageSrc
966
+ .replace("/public/assets/sprites/", "")
967
+ .replace(".png", "") === proj.name
968
+ );
969
+ });
970
+ if (newTarget) setTargetEntity(newTarget.id);
971
+ }
972
+ }
973
+ });
974
+
975
+ const getPos = (e: MouseEvent) => {
976
+ const rect = compositeCanvasRef.getBoundingClientRect();
977
+ // Localize mouse positions explicitly against the atlas grid
978
+ let x = Math.floor((e.clientX - rect.left) / zoom());
979
+ let y = Math.floor((e.clientY - rect.top) / zoom());
980
+ const proj = activeProj();
981
+
982
+ if (atlasViewMode() === "atlas" && proj) {
983
+ const activeF = proj.frames.find((f) => f.id === proj.activeFrameId);
984
+ if (proj.isAtlas) {
985
+ if (activeF && activeF.x !== undefined && activeF.y !== undefined) {
986
+ x -= activeF.x;
987
+ y -= activeF.y;
988
+ }
989
+ } else {
990
+ const inferredCols = proj.metadata?.columns || proj.frames.length;
991
+ const idx = proj.frames.findIndex((f) => f.id === proj.activeFrameId);
992
+ if (idx !== -1) {
993
+ const c = idx % inferredCols;
994
+ const rFrame = Math.floor(idx / inferredCols);
995
+ x -= c * proj.width;
996
+ y -= rFrame * proj.height;
997
+ }
998
+ }
999
+ }
1000
+
1001
+ return { x, y };
1002
+ };
1003
+
1004
+ const drawBresenhamLine = (
1005
+ ctx: CanvasRenderingContext2D,
1006
+ x0: number,
1007
+ y0: number,
1008
+ x1: number,
1009
+ y1: number,
1010
+ col: string,
1011
+ ) => {
1012
+ ctx.fillStyle = col;
1013
+ const dx = Math.abs(x1 - x0),
1014
+ sx = x0 < x1 ? 1 : -1;
1015
+ const dy = -Math.abs(y1 - y0),
1016
+ sy = y0 < y1 ? 1 : -1;
1017
+ let err = dx + dy,
1018
+ e2;
1019
+ while (true) {
1020
+ ctx.fillRect(x0, y0, 1, 1);
1021
+ if (x0 === x1 && y0 === y1) break;
1022
+ e2 = 2 * err;
1023
+ if (e2 >= dy) {
1024
+ err += dy;
1025
+ x0 += sx;
1026
+ }
1027
+ if (e2 <= dx) {
1028
+ err += dx;
1029
+ y0 += sy;
1030
+ }
1031
+ }
1032
+ };
1033
+
1034
+ const drawPixelCircle = (
1035
+ ctx: CanvasRenderingContext2D,
1036
+ xc: number,
1037
+ yc: number,
1038
+ r: number,
1039
+ col: string,
1040
+ ) => {
1041
+ ctx.fillStyle = col;
1042
+ let x = 0,
1043
+ y = r;
1044
+ let d = 3 - 2 * r;
1045
+ const plot = (cx: number, cy: number, px: number, py: number) => {
1046
+ ctx.fillRect(cx + px, cy + py, 1, 1);
1047
+ ctx.fillRect(cx - px, cy + py, 1, 1);
1048
+ ctx.fillRect(cx + px, cy - py, 1, 1);
1049
+ ctx.fillRect(cx - px, cy - py, 1, 1);
1050
+ ctx.fillRect(cx + py, cy + px, 1, 1);
1051
+ ctx.fillRect(cx - py, cy + px, 1, 1);
1052
+ ctx.fillRect(cx + py, cy - px, 1, 1);
1053
+ ctx.fillRect(cx - py, cy - px, 1, 1);
1054
+ };
1055
+ plot(xc, yc, x, y);
1056
+ while (y >= x) {
1057
+ x++;
1058
+ if (d > 0) {
1059
+ y--;
1060
+ d = d + 4 * (x - y) + 10;
1061
+ } else {
1062
+ d = d + 4 * x + 6;
1063
+ }
1064
+ plot(xc, yc, x, y);
1065
+ }
1066
+ };
1067
+
1068
+ const hexToRgba = (hex: string) => {
1069
+ const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
1070
+ return res
1071
+ ? [parseInt(res[1], 16), parseInt(res[2], 16), parseInt(res[3], 16), 255]
1072
+ : [0, 0, 0, 255];
1073
+ };
1074
+
1075
+ const floodFill = (
1076
+ ctx: CanvasRenderingContext2D,
1077
+ x: number,
1078
+ y: number,
1079
+ w: number,
1080
+ h: number,
1081
+ hexColor: string,
1082
+ ) => {
1083
+ const imageData = ctx.getImageData(0, 0, w, h);
1084
+ const data = imageData.data;
1085
+ const targetColor = hexToRgba(hexColor);
1086
+
1087
+ const startPos = (y * w + x) * 4;
1088
+ const startR = data[startPos],
1089
+ startG = data[startPos + 1],
1090
+ startB = data[startPos + 2],
1091
+ startA = data[startPos + 3];
1092
+ if (
1093
+ startR === targetColor[0] &&
1094
+ startG === targetColor[1] &&
1095
+ startB === targetColor[2] &&
1096
+ startA === targetColor[3]
1097
+ )
1098
+ return;
1099
+
1100
+ const matchStartColor = (pos: number) =>
1101
+ data[pos] === startR &&
1102
+ data[pos + 1] === startG &&
1103
+ data[pos + 2] === startB &&
1104
+ data[pos + 3] === startA;
1105
+ const colorPixel = (pos: number) => {
1106
+ data[pos] = targetColor[0];
1107
+ data[pos + 1] = targetColor[1];
1108
+ data[pos + 2] = targetColor[2];
1109
+ data[pos + 3] = targetColor[3];
1110
+ };
1111
+
1112
+ const stack = [[x, y]];
1113
+ while (stack.length) {
1114
+ const [px, py] = stack.pop()!;
1115
+ let pyy = py;
1116
+ let pos = (pyy * w + px) * 4;
1117
+ while (pyy-- >= 0 && matchStartColor(pos)) pos -= w * 4;
1118
+ pos += w * 4;
1119
+ pyy++;
1120
+ let reachL = false,
1121
+ reachR = false;
1122
+ while (pyy++ < h - 1 && matchStartColor(pos)) {
1123
+ colorPixel(pos);
1124
+ if (px > 0) {
1125
+ if (matchStartColor(pos - 4)) {
1126
+ if (!reachL) {
1127
+ stack.push([px - 1, pyy]);
1128
+ reachL = true;
1129
+ }
1130
+ } else if (reachL) reachL = false;
1131
+ }
1132
+ if (px < w - 1) {
1133
+ if (matchStartColor(pos + 4)) {
1134
+ if (!reachR) {
1135
+ stack.push([px + 1, pyy]);
1136
+ reachR = true;
1137
+ }
1138
+ } else if (reachR) reachR = false;
1139
+ }
1140
+ pos += w * 4;
1141
+ }
1142
+ }
1143
+ ctx.putImageData(imageData, 0, 0);
1144
+ };
1145
+
1146
+ const getActiveCel = (proj: Project) => {
1147
+ const f = proj.frames.find((f) => f.id === proj.activeFrameId);
1148
+ if (!f) return null;
1149
+ return f.cels[proj.activeLayerId];
1150
+ };
1151
+
1152
+ const handlePointerDown = (e: MouseEvent) => {
1153
+ if (e.button === 1 || tool() === "pan" || (e.button === 0 && e.shiftKey))
1154
+ return;
1155
+ const proj = activeProj();
1156
+ if (!proj) return;
1157
+
1158
+ const { x, y } = getPos(e);
1159
+
1160
+ if (atlasViewMode() === "atlas") {
1161
+ if (proj.isAtlas) {
1162
+ const clickedFrame = proj.frames.find(
1163
+ (f) =>
1164
+ x >= f.x! &&
1165
+ y >= f.y! &&
1166
+ x <= f.x! + (f.width || proj.width) &&
1167
+ y <= f.y! + (f.height || proj.height),
1168
+ );
1169
+ if (clickedFrame) {
1170
+ setProjects((projs) =>
1171
+ projs.map((p) =>
1172
+ p.id === activeProjId()
1173
+ ? { ...p, activeFrameId: clickedFrame.id }
1174
+ : p,
1175
+ ),
1176
+ );
1177
+ return;
1178
+ }
1179
+ } else {
1180
+ const inferredCols = proj.metadata?.columns || proj.frames.length;
1181
+ const c = Math.floor(x / proj.width);
1182
+ const r = Math.floor(y / proj.height);
1183
+ const idx = r * inferredCols + c;
1184
+ if (idx >= 0 && idx < proj.frames.length) {
1185
+ setProjects((projs) =>
1186
+ projs.map((p) =>
1187
+ p.id === activeProjId()
1188
+ ? { ...p, activeFrameId: proj.frames[idx].id }
1189
+ : p,
1190
+ ),
1191
+ );
1192
+ return;
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ if (tool() === "select") {
1198
+ const celCvs = getActiveCel(proj);
1199
+ if (!celCvs) return;
1200
+ const bx = selectionBox();
1201
+ if (bx) {
1202
+ const rx = bx.x;
1203
+ const ry = bx.y;
1204
+ if (x >= rx && x <= rx + bx.w && y >= ry && y <= ry + bx.h) {
1205
+ isMovingSelection = true;
1206
+ initialSelectionX = rx;
1207
+ initialSelectionY = ry;
1208
+ moveStartX = x;
1209
+ moveStartY = y;
1210
+ const ctx = celCvs.getContext("2d", { willReadFrequently: true })!;
1211
+ movingImageData = ctx.getImageData(rx, ry, bx.w, bx.h);
1212
+ ctx.clearRect(rx, ry, bx.w, bx.h);
1213
+ syncToEngine();
1214
+ return;
1215
+ } else {
1216
+ setSelectionBox(null);
1217
+ }
1218
+ }
1219
+ setSelectionBox({ x, y, w: 1, h: 1 });
1220
+ }
1221
+
1222
+ isDrawing = true;
1223
+ startX = x;
1224
+ startY = y;
1225
+ lastX = x;
1226
+ lastY = y;
1227
+ handlePointerMove(e);
1228
+ };
1229
+
1230
+ const handlePointerMove = (e: MouseEvent) => {
1231
+ const proj = activeProj();
1232
+ if (!proj) return;
1233
+ const celCvs = getActiveCel(proj);
1234
+ if (!celCvs) return;
1235
+ const { x, y } = getPos(e);
1236
+ const pCtx = previewCanvasRef.getContext("2d", {
1237
+ willReadFrequently: true,
1238
+ })!;
1239
+
1240
+ const t = tool();
1241
+ if (t === "select") {
1242
+ pCtx.clearRect(0, 0, proj.width, proj.height);
1243
+ const bx = selectionBox();
1244
+ if (isMovingSelection && bx && movingImageData) {
1245
+ const dx = x - moveStartX;
1246
+ const dy = y - moveStartY;
1247
+ pCtx.putImageData(
1248
+ movingImageData,
1249
+ initialSelectionX + dx,
1250
+ initialSelectionY + dy,
1251
+ );
1252
+ } else if (bx && isDrawing) {
1253
+ const rx = Math.min(startX, x);
1254
+ const ry = Math.min(startY, y);
1255
+ const rw = Math.abs(x - startX) + 1;
1256
+ const rh = Math.abs(y - startY) + 1;
1257
+ setSelectionBox({ x: rx, y: ry, w: rw, h: rh });
1258
+ }
1259
+ return;
1260
+ }
1261
+
1262
+ if (!isDrawing) return;
1263
+ pCtx.clearRect(0, 0, proj.width, proj.height);
1264
+
1265
+ if (t === "pencil" || t === "eraser") {
1266
+ const ctx = celCvs.getContext("2d", { willReadFrequently: true })!;
1267
+ if (t === "pencil") {
1268
+ drawBresenhamLine(ctx, lastX, lastY, x, y, color());
1269
+ } else {
1270
+ ctx.clearRect(x, y, 1, 1);
1271
+ }
1272
+ lastX = x;
1273
+ lastY = y;
1274
+ syncToEngine();
1275
+ } else if (t === "line") {
1276
+ drawBresenhamLine(pCtx, startX, startY, x, y, color());
1277
+ } else if (t === "rect") {
1278
+ pCtx.fillStyle = color();
1279
+ const rx = Math.min(x, startX),
1280
+ ry = Math.min(y, startY);
1281
+ const rw = Math.abs(x - startX) + 1,
1282
+ rh = Math.abs(y - startY) + 1;
1283
+ pCtx.fillRect(rx, ry, rw, rh);
1284
+ } else if (t === "circle") {
1285
+ const radius = Math.round(
1286
+ Math.sqrt((x - startX) ** 2 + (y - startY) ** 2),
1287
+ );
1288
+ drawPixelCircle(pCtx, startX, startY, radius, color());
1289
+ }
1290
+ };
1291
+
1292
+ const handlePointerUp = (e: MouseEvent) => {
1293
+ const proj = activeProj();
1294
+ if (!proj) return;
1295
+ const celCvs = getActiveCel(proj);
1296
+ if (!celCvs) return;
1297
+ const t = tool();
1298
+
1299
+ if (t === "select") {
1300
+ const { x, y } = getPos(e);
1301
+ if (isMovingSelection && movingImageData && selectionBox()) {
1302
+ const _bx = selectionBox()!;
1303
+ const dx = x - moveStartX;
1304
+ const dy = y - moveStartY;
1305
+ const ctx = celCvs.getContext("2d", { willReadFrequently: true })!;
1306
+
1307
+ const tmpCanvas = document.createElement("canvas");
1308
+ tmpCanvas.width = movingImageData.width;
1309
+ tmpCanvas.height = movingImageData.height;
1310
+ tmpCanvas
1311
+ .getContext("2d", { willReadFrequently: true })
1312
+ ?.putImageData(movingImageData, 0, 0);
1313
+ ctx.drawImage(
1314
+ tmpCanvas,
1315
+ initialSelectionX + dx,
1316
+ initialSelectionY + dy,
1317
+ );
1318
+
1319
+ const pCtx = previewCanvasRef.getContext("2d", {
1320
+ willReadFrequently: true,
1321
+ })!;
1322
+ pCtx.clearRect(0, 0, proj.width, proj.height);
1323
+
1324
+ setSelectionBox({
1325
+ x: initialSelectionX + dx,
1326
+ y: initialSelectionY + dy,
1327
+ w: movingImageData.width,
1328
+ h: movingImageData.height,
1329
+ });
1330
+
1331
+ isMovingSelection = false;
1332
+ movingImageData = null;
1333
+ syncToEngine();
1334
+ pushState();
1335
+ } else if (selectionBox() && isDrawing) {
1336
+ const bx = selectionBox()!;
1337
+ if (bx.w <= 1 && bx.h <= 1) {
1338
+ setSelectionBox(null);
1339
+ }
1340
+ // Geometric alignment strictly normalized efficiently iteratively during Move natively
1341
+ }
1342
+ isDrawing = false;
1343
+ return;
1344
+ }
1345
+
1346
+ if (!isDrawing) return;
1347
+ isDrawing = false;
1348
+
1349
+ if (t === "line" || t === "rect" || t === "circle" || t === "fill") {
1350
+ const { x, y } = getPos(e);
1351
+ const ctx = celCvs.getContext("2d", { willReadFrequently: true })!;
1352
+ if (t === "fill") {
1353
+ floodFill(ctx, x, y, proj.width, proj.height, color());
1354
+ } else {
1355
+ ctx.drawImage(previewCanvasRef, 0, 0);
1356
+ const pCtx = previewCanvasRef.getContext("2d", {
1357
+ willReadFrequently: true,
1358
+ })!;
1359
+ pCtx.clearRect(0, 0, proj.width, proj.height);
1360
+ }
1361
+ syncToEngine();
1362
+ }
1363
+
1364
+ pushState();
1365
+ };
1366
+
1367
+ const selectionAction = (action: string) => {
1368
+ const proj = activeProj();
1369
+ if (!proj) return;
1370
+ const celCvs = getActiveCel(proj);
1371
+ if (!celCvs) return;
1372
+ const ctx = celCvs.getContext("2d", { willReadFrequently: true })!;
1373
+ const bx = selectionBox();
1374
+ if (!bx && action !== "paste") return;
1375
+
1376
+ if (action === "copy") {
1377
+ clipboardData = {
1378
+ w: bx?.w,
1379
+ h: bx?.h,
1380
+ data: ctx.getImageData(bx?.x, bx?.y, bx?.w, bx?.h).data,
1381
+ };
1382
+ } else if (action === "paste") {
1383
+ if (!clipboardData) return;
1384
+ const nw = clipboardData.w;
1385
+ const nh = clipboardData.h;
1386
+ const nx = 0;
1387
+ const ny = 0;
1388
+ const tmpCanvas = document.createElement("canvas");
1389
+ tmpCanvas.width = nw;
1390
+ tmpCanvas.height = nh;
1391
+ tmpCanvas
1392
+ .getContext("2d", { willReadFrequently: true })
1393
+ ?.putImageData(
1394
+ new ImageData(new Uint8ClampedArray(clipboardData.data), nw, nh),
1395
+ 0,
1396
+ 0,
1397
+ );
1398
+ ctx.drawImage(tmpCanvas, nx, ny);
1399
+ setSelectionBox({ x: nx, y: ny, w: nw, h: nh });
1400
+ syncToEngine();
1401
+ pushState();
1402
+ } else if (action === "delete") {
1403
+ ctx.clearRect(bx?.x, bx?.y, bx?.w, bx?.h);
1404
+ syncToEngine();
1405
+ pushState();
1406
+ } else if (action === "duplicate") {
1407
+ const img = ctx.getImageData(bx?.x, bx?.y, bx?.w, bx?.h);
1408
+ const nx = bx?.x + 2;
1409
+ const ny = bx?.y + 2;
1410
+ const tmpCanvas = document.createElement("canvas");
1411
+ tmpCanvas.width = bx?.w;
1412
+ tmpCanvas.height = bx?.h;
1413
+ tmpCanvas
1414
+ .getContext("2d", { willReadFrequently: true })
1415
+ ?.putImageData(img, 0, 0);
1416
+ ctx.drawImage(tmpCanvas, nx, ny);
1417
+ setSelectionBox({ x: nx, y: ny, w: bx?.w, h: bx?.h });
1418
+ syncToEngine();
1419
+ pushState();
1420
+ } else if (action === "flip_h" || action === "flip_v") {
1421
+ const img = ctx.getImageData(bx?.x, bx?.y, bx?.w, bx?.h);
1422
+ const tmpCvs = document.createElement("canvas");
1423
+ tmpCvs.width = bx?.w;
1424
+ tmpCvs.height = bx?.h;
1425
+ const tCtx = tmpCvs.getContext("2d", { willReadFrequently: true })!;
1426
+ tCtx.putImageData(img, 0, 0);
1427
+
1428
+ ctx.clearRect(bx?.x, bx?.y, bx?.w, bx?.h);
1429
+ ctx.save();
1430
+ ctx.translate(bx?.x + bx?.w / 2, bx?.y + bx?.h / 2);
1431
+ if (action === "flip_h") ctx.scale(-1, 1);
1432
+ if (action === "flip_v") ctx.scale(1, -1);
1433
+ ctx.drawImage(tmpCvs, -bx?.w / 2, -bx?.h / 2);
1434
+ ctx.restore();
1435
+
1436
+ syncToEngine();
1437
+ pushState();
1438
+ }
1439
+ };
1440
+
1441
+ // Layer Actions
1442
+ const addLayer = () => {
1443
+ setProjects((projs) =>
1444
+ projs.map((p) => {
1445
+ if (p.id !== activeProjId()) return p;
1446
+ const lId = uid();
1447
+ p.layers.unshift({
1448
+ id: lId,
1449
+ name: `Layer ${p.layers.length + 1}`,
1450
+ visible: true,
1451
+ opacity: 1.0,
1452
+ });
1453
+ p.frames.forEach((f) => {
1454
+ f.cels[lId] = initCel(f.width || p.width, f.height || p.height);
1455
+ });
1456
+ p.activeLayerId = lId;
1457
+ return p;
1458
+ }),
1459
+ );
1460
+ syncToEngine();
1461
+ pushState();
1462
+ };
1463
+
1464
+ const toggleLayerVis = (lId: string) => {
1465
+ setProjects((projs) =>
1466
+ projs.map((p) => {
1467
+ if (p.id !== activeProjId()) return p;
1468
+ const l = p.layers.find((x) => x.id === lId);
1469
+ if (l) l.visible = !l.visible;
1470
+ return p;
1471
+ }),
1472
+ );
1473
+ syncToEngine();
1474
+ pushState();
1475
+ };
1476
+
1477
+ const duplicateLayer = (lId: string) => {
1478
+ setProjects((projs) =>
1479
+ projs.map((p) => {
1480
+ if (p.id !== activeProjId()) return p;
1481
+ const srcIdx = p.layers.findIndex((x) => x.id === lId);
1482
+ if (srcIdx === -1) return p;
1483
+ const srcL = p.layers[srcIdx];
1484
+ const newId = uid();
1485
+
1486
+ const newL = { ...srcL, id: newId, name: `${srcL.name} Copy` };
1487
+ p.layers.splice(srcIdx, 0, newL);
1488
+
1489
+ p.frames.forEach((f) => {
1490
+ const newC = initCel(f.width || p.width, f.height || p.height);
1491
+ newC
1492
+ .getContext("2d", { willReadFrequently: true })
1493
+ ?.drawImage(f.cels[lId], 0, 0);
1494
+ f.cels[newId] = newC;
1495
+ });
1496
+ p.activeLayerId = newId;
1497
+ return { ...p, layers: [...p.layers] };
1498
+ }),
1499
+ );
1500
+ syncToEngine();
1501
+ pushState();
1502
+ };
1503
+
1504
+ const deleteLayer = (lId: string) => {
1505
+ setProjects((projs) =>
1506
+ projs.map((p) => {
1507
+ if (p.id !== activeProjId() || p.layers.length <= 1) return p;
1508
+ const srcIdx = p.layers.findIndex((x) => x.id === lId);
1509
+ if (srcIdx === -1) return p;
1510
+
1511
+ p.layers.splice(srcIdx, 1);
1512
+ p.frames.forEach((f) => {
1513
+ delete f.cels[lId];
1514
+ });
1515
+ p.activeLayerId =
1516
+ p.layers[Math.max(0, Math.min(srcIdx, p.layers.length - 1))].id;
1517
+ return { ...p, layers: [...p.layers] };
1518
+ }),
1519
+ );
1520
+ syncToEngine();
1521
+ pushState();
1522
+ };
1523
+
1524
+ const moveLayer = (lId: string, dir: -1 | 1) => {
1525
+ setProjects((projs) =>
1526
+ projs.map((p) => {
1527
+ if (p.id !== activeProjId()) return p;
1528
+ const idx = p.layers.findIndex((x) => x.id === lId);
1529
+ if (idx === -1) return p;
1530
+ const tIdx = idx + dir;
1531
+ if (tIdx < 0 || tIdx >= p.layers.length) return p;
1532
+
1533
+ const newLayers = [...p.layers];
1534
+ const tmp = newLayers[idx];
1535
+ newLayers[idx] = newLayers[tIdx];
1536
+ newLayers[tIdx] = tmp;
1537
+ return { ...p, layers: newLayers };
1538
+ }),
1539
+ );
1540
+ syncToEngine();
1541
+ pushState();
1542
+ };
1543
+
1544
+ const mergeLayerDown = (lId: string) => {
1545
+ setProjects((projs) =>
1546
+ projs.map((p) => {
1547
+ if (p.id !== activeProjId() || p.layers.length <= 1) return p;
1548
+ const srcIdx = p.layers.findIndex((x) => x.id === lId);
1549
+ if (srcIdx === -1 || srcIdx === p.layers.length - 1) return p; // Cannot merge the absolute bottom layer natively
1550
+
1551
+ const targetL = p.layers[srcIdx + 1];
1552
+ p.frames.forEach((f) => {
1553
+ const targetCtx = f.cels[targetL.id].getContext("2d", {
1554
+ willReadFrequently: true,
1555
+ })!;
1556
+ targetCtx.save();
1557
+ targetCtx.globalAlpha = p.layers[srcIdx].opacity;
1558
+ targetCtx.drawImage(f.cels[lId], 0, 0);
1559
+ targetCtx.restore();
1560
+ delete f.cels[lId];
1561
+ });
1562
+
1563
+ p.layers.splice(srcIdx, 1);
1564
+ p.activeLayerId = targetL.id;
1565
+ return { ...p, layers: [...p.layers] };
1566
+ }),
1567
+ );
1568
+ syncToEngine();
1569
+ pushState();
1570
+ };
1571
+
1572
+ // Frame Actions
1573
+ const addFrame = () => {
1574
+ const pRef = activeProj();
1575
+ if (!pRef) return;
1576
+
1577
+ if (pRef.isAtlas) {
1578
+ setDialog({
1579
+ title: "Enter new cell name:",
1580
+ placeholder: `cell_${pRef.frames.length}`,
1581
+ onConfirm: (fName) => {
1582
+ applyNewFrame(fName);
1583
+ },
1584
+ });
1585
+ } else {
1586
+ applyNewFrame();
1587
+ }
1588
+ };
1589
+
1590
+ const applyNewFrame = (fName?: string) => {
1591
+ setProjects((projs) =>
1592
+ projs.map((p) => {
1593
+ if (p.id !== activeProjId()) return p;
1594
+ const fId = uid();
1595
+ const currF = p.frames.find((fr) => fr.id === p.activeFrameId);
1596
+ const fW = currF?.width || p.width;
1597
+ const fH = currF?.height || p.height;
1598
+ const f: Frame = {
1599
+ id: fId,
1600
+ name: fName,
1601
+ width: fW,
1602
+ height: fH,
1603
+ cels: {},
1604
+ };
1605
+ p.layers.forEach((l) => {
1606
+ f.cels[l.id] = initCel(fW, fH);
1607
+ });
1608
+ p.frames.push(f);
1609
+ p.activeFrameId = fId;
1610
+ return p;
1611
+ }),
1612
+ );
1613
+ syncToEngine();
1614
+ pushState();
1615
+ };
1616
+
1617
+ const duplicateFrame = () => {
1618
+ const pRef = activeProj();
1619
+ if (!pRef || pRef.frames.length === 0) return;
1620
+ const src = pRef.frames.find((f) => f.id === pRef.activeFrameId);
1621
+ if (!src) return;
1622
+
1623
+ if (pRef.isAtlas) {
1624
+ setDialog({
1625
+ title: "Enter duplicate cell name:",
1626
+ placeholder: `${src.name || "cell"}_copy`,
1627
+ onConfirm: (fName) => applyDupeFrame(src.id, fName),
1628
+ });
1629
+ } else {
1630
+ applyDupeFrame(src.id);
1631
+ }
1632
+ };
1633
+
1634
+ const applyDupeFrame = (srcId: string, fName?: string) => {
1635
+ setProjects((projs) =>
1636
+ projs.map((p) => {
1637
+ if (p.id !== activeProjId()) return p;
1638
+ const src = p.frames.find((f) => f.id === srcId);
1639
+ if (!src) return p;
1640
+ const fId = uid();
1641
+ const f: Frame = {
1642
+ id: fId,
1643
+ name: fName,
1644
+ width: src.width,
1645
+ height: src.height,
1646
+ cels: {},
1647
+ };
1648
+ p.layers.forEach((l) => {
1649
+ const newC = initCel(src.width || p.width, src.height || p.height);
1650
+ newC
1651
+ .getContext("2d", { willReadFrequently: true })
1652
+ ?.drawImage(src.cels[l.id], 0, 0);
1653
+ f.cels[l.id] = newC;
1654
+ });
1655
+ p.frames.push(f);
1656
+ p.activeFrameId = fId;
1657
+ return p;
1658
+ }),
1659
+ );
1660
+ syncToEngine();
1661
+ pushState();
1662
+ };
1663
+
1664
+ const deleteFrame = () => {
1665
+ const pRef = activeProj();
1666
+ if (!pRef || pRef.frames.length <= 1) return; // Disallow implicitly deleting final context bounds
1667
+
1668
+ setProjects((projs) =>
1669
+ projs.map((p) => {
1670
+ if (p.id !== activeProjId() || p.frames.length <= 1) return p;
1671
+ const idx = p.frames.findIndex((f) => f.id === p.activeFrameId);
1672
+ if (idx === -1) return p;
1673
+ const updated = { ...p, frames: [...p.frames] };
1674
+ updated.frames.splice(idx, 1);
1675
+ updated.activeFrameId =
1676
+ updated.frames[
1677
+ Math.max(0, Math.min(idx, updated.frames.length - 1))
1678
+ ].id;
1679
+ return updated;
1680
+ }),
1681
+ );
1682
+ syncToEngine();
1683
+ pushState();
1684
+ };
1685
+
1686
+ let saveTimeout: any;
1687
+ const autoSaveToDisk = () => {
1688
+ clearTimeout(saveTimeout);
1689
+ setSaveStatus("Saving...");
1690
+ saveTimeout = setTimeout(async () => {
1691
+ const proj = activeProj();
1692
+ if (!proj) return;
1693
+
1694
+ let dataUri = "";
1695
+ let meta = proj.metadata ? { ...proj.metadata } : {};
1696
+
1697
+ if (proj.isAtlas) {
1698
+ const hasExplicitLayout = proj.frames.every(
1699
+ (f) => f.x !== undefined && f.y !== undefined,
1700
+ );
1701
+ if (hasExplicitLayout) {
1702
+ let maxW = proj.metadata?.fullWidth || 0;
1703
+ let maxH = proj.metadata?.fullHeight || 0;
1704
+ if (!maxW || !maxH) {
1705
+ proj.frames.forEach((f) => {
1706
+ const rw = f.width || proj.width;
1707
+ const rh = f.height || proj.height;
1708
+ if (f.x !== undefined && f.x + rw > maxW) maxW = f.x + rw;
1709
+ if (f.y !== undefined && f.y + rh > maxH) maxH = f.y + rh;
1710
+ });
1711
+ }
1712
+
1713
+ offscreenMap.width = maxW;
1714
+ offscreenMap.height = maxH;
1715
+ const ctx = offscreenMap.getContext("2d", {
1716
+ willReadFrequently: true,
1717
+ })!;
1718
+ ctx.clearRect(0, 0, offscreenMap.width, offscreenMap.height);
1719
+
1720
+ const metaFrames: any = {};
1721
+ proj.frames.forEach((fr) => {
1722
+ const c = compositeFrame(proj, fr.id);
1723
+ ctx.drawImage(c, fr.x!, fr.y!);
1724
+ metaFrames[fr.name || fr.id] = {
1725
+ frame: {
1726
+ x: fr.x,
1727
+ y: fr.y,
1728
+ w: fr.width || proj.width,
1729
+ h: fr.height || proj.height,
1730
+ },
1731
+ sourceSize: {
1732
+ w: fr.width || proj.width,
1733
+ h: fr.height || proj.height,
1734
+ },
1735
+ };
1736
+ });
1737
+
1738
+ setAtlasMapW(offscreenMap.width);
1739
+ setAtlasMapH(offscreenMap.height);
1740
+ const uri = offscreenMap.toDataURL("image/png");
1741
+ setAtlasDataUri(uri);
1742
+ dataUri = uri;
1743
+ meta.frames = metaFrames;
1744
+ if (!meta.animations) meta.animations = {};
1745
+ } else {
1746
+ const inputs = proj.frames.map((fr) => {
1747
+ const c = compositeFrame(proj, fr.id);
1748
+ const dup = document.createElement("canvas");
1749
+ dup.width = c.width;
1750
+ dup.height = c.height;
1751
+ dup
1752
+ .getContext("2d", { willReadFrequently: true })
1753
+ ?.drawImage(c, 0, 0);
1754
+ return {
1755
+ id: fr.name || fr.id,
1756
+ width: fr.width || proj.width,
1757
+ height: fr.height || proj.height,
1758
+ data: dup,
1759
+ };
1760
+ });
1761
+ const result = packAtlas(inputs);
1762
+
1763
+ offscreenMap.width = result.atlasCanvas.width;
1764
+ offscreenMap.height = result.atlasCanvas.height;
1765
+ const ctx = offscreenMap.getContext("2d", {
1766
+ willReadFrequently: true,
1767
+ })!;
1768
+ ctx.clearRect(0, 0, offscreenMap.width, offscreenMap.height);
1769
+ ctx.drawImage(result.atlasCanvas, 0, 0);
1770
+
1771
+ setAtlasMapW(offscreenMap.width);
1772
+ setAtlasMapH(offscreenMap.height);
1773
+ const uri = offscreenMap.toDataURL("image/png");
1774
+ setAtlasDataUri(uri);
1775
+ dataUri = uri;
1776
+ meta.frames = result.meta.frames;
1777
+ if (!meta.animations) meta.animations = {};
1778
+ }
1779
+ } else {
1780
+ const cC = proj.metadata?.columns || proj.frames.length;
1781
+ const cR = proj.metadata?.rows || Math.ceil(proj.frames.length / cC);
1782
+
1783
+ offscreenMap.width = cC * proj.width;
1784
+ offscreenMap.height = cR * proj.height;
1785
+ const mapCtx = offscreenMap.getContext("2d", {
1786
+ willReadFrequently: true,
1787
+ })!;
1788
+ mapCtx.clearRect(0, 0, offscreenMap.width, offscreenMap.height);
1789
+
1790
+ proj.frames.forEach((fr, i) => {
1791
+ const r = Math.floor(i / cC);
1792
+ const c = i % cC;
1793
+ const cv = compositeFrame(proj, fr.id);
1794
+ mapCtx.drawImage(cv, c * proj.width, r * proj.height);
1795
+ });
1796
+
1797
+ setAtlasMapW(offscreenMap.width);
1798
+ setAtlasMapH(offscreenMap.height);
1799
+ const uri = offscreenMap.toDataURL("image/png");
1800
+ setAtlasDataUri(uri);
1801
+ dataUri = uri;
1802
+ meta = { ...proj.metadata, columns: cC, rows: cR };
1803
+ }
1804
+
1805
+ try {
1806
+ await fetch("/__aether_dev/save-sprite", {
1807
+ method: "POST",
1808
+ headers: { "Content-Type": "application/json" },
1809
+ body: JSON.stringify({
1810
+ name: proj.name,
1811
+ png: dataUri,
1812
+ metadata: meta,
1813
+ }),
1814
+ });
1815
+ setSaveStatus("Saved");
1816
+ setTimeout(() => setSaveStatus("Ready"), 2000);
1817
+ } catch (err) {
1818
+ console.error("Auto-save failed:", err);
1819
+ setSaveStatus("Error");
1820
+ }
1821
+ }, 600);
1822
+ };
1823
+
1824
+ const resizeCanvas = (newW: number, newH: number) => {
1825
+ if (newW < 2 || newH < 2) return;
1826
+ setProjects((projs) =>
1827
+ projs.map((p) => {
1828
+ if (p.id !== activeProjId()) return p;
1829
+ const pCopy = { ...p };
1830
+
1831
+ if (pCopy.isAtlas) {
1832
+ const activeFrIdx = pCopy.frames.findIndex(
1833
+ (f) => f.id === pCopy.activeFrameId,
1834
+ );
1835
+ if (activeFrIdx > -1) {
1836
+ pCopy.frames[activeFrIdx].width = Math.floor(newW);
1837
+ pCopy.frames[activeFrIdx].height = Math.floor(newH);
1838
+ Object.keys(pCopy.frames[activeFrIdx].cels).forEach((layerId) => {
1839
+ const oldCel = pCopy.frames[activeFrIdx].cels[layerId];
1840
+ const newCel = initCel(Math.floor(newW), Math.floor(newH));
1841
+ newCel
1842
+ .getContext("2d", { willReadFrequently: true })
1843
+ ?.drawImage(oldCel, 0, 0);
1844
+ pCopy.frames[activeFrIdx].cels[layerId] = newCel;
1845
+ });
1846
+ }
1847
+ } else {
1848
+ pCopy.width = Math.floor(newW);
1849
+ pCopy.height = Math.floor(newH);
1850
+ pCopy.frames.forEach((f) => {
1851
+ Object.keys(f.cels).forEach((layerId) => {
1852
+ const oldCel = f.cels[layerId];
1853
+ const newCel = initCel(pCopy.width, pCopy.height);
1854
+ newCel
1855
+ .getContext("2d", { willReadFrequently: true })
1856
+ ?.drawImage(oldCel, 0, 0);
1857
+ f.cels[layerId] = newCel;
1858
+ });
1859
+ });
1860
+ }
1861
+ return pCopy;
1862
+ }),
1863
+ );
1864
+ syncToEngine();
1865
+ };
1866
+
1867
+ return (
1868
+ <div
1869
+ ref={editorRef}
1870
+ style={
1871
+ props.embedded
1872
+ ? {
1873
+ width: "100%",
1874
+ height: "100%",
1875
+ display: "flex",
1876
+ "flex-direction": "column",
1877
+ "pointer-events": "auto",
1878
+ "font-family": "Arial, sans-serif",
1879
+ color: uActiveText,
1880
+ background: uDark,
1881
+ }
1882
+ : {
1883
+ position: "fixed",
1884
+ top: "50%",
1885
+ left: "50%",
1886
+ transform: "translate(-50%, -50%)",
1887
+ width: "900px",
1888
+ height: "600px",
1889
+ background: uDark,
1890
+ padding: "5px",
1891
+ "border-radius": "4px",
1892
+ border: `1px solid ${uBorder}`,
1893
+ "box-shadow": "0 10px 30px rgba(0,0,0,0.5)",
1894
+ "font-family": "Arial, sans-serif",
1895
+ "z-index": "9999",
1896
+ display: "flex",
1897
+ "flex-direction": "column",
1898
+ color: uActiveText,
1899
+ "pointer-events": "auto",
1900
+ }
1901
+ }
1902
+ >
1903
+ {/* Document Tabs */}
1904
+ <div
1905
+ style={{
1906
+ display: "flex",
1907
+ background: uHeader,
1908
+ "border-bottom": `1px solid ${uBorder}`,
1909
+ "margin-bottom": "5px",
1910
+ }}
1911
+ >
1912
+ <For each={projects()}>
1913
+ {(p) => (
1914
+ <button
1915
+ onClick={() => {
1916
+ setActiveProjId(p.id);
1917
+ const target = entities().find((e) => {
1918
+ if (!e.renderer?.imageSrc) return false;
1919
+ const n = e.renderer.imageSrc
1920
+ .replace("/public/assets/sprites/", "")
1921
+ .replace(".png", "");
1922
+ return n === p.name;
1923
+ });
1924
+ if (target) setTargetEntity(target.id);
1925
+ syncToEngine();
1926
+ }}
1927
+ style={{
1928
+ padding: "6px 12px",
1929
+ background: p.id === activeProjId() ? uActive : "transparent",
1930
+ color: p.id === activeProjId() ? uActiveText : uText,
1931
+ border: "none",
1932
+ "border-right": `1px solid ${uBorder}`,
1933
+ "border-top":
1934
+ p.id === activeProjId()
1935
+ ? `2px solid ${uAcc}`
1936
+ : "2px solid transparent",
1937
+ cursor: "pointer",
1938
+ "font-size": "12px",
1939
+ "flex-shrink": 0,
1940
+ }}
1941
+ >
1942
+ {p.name}
1943
+ </button>
1944
+ )}
1945
+ </For>
1946
+ <button
1947
+ title="Create New SpriteSheet"
1948
+ onClick={() => {
1949
+ setDialogToggle(false);
1950
+ setDialog({
1951
+ title: "SpriteSheet/Atlas Name:",
1952
+ placeholder: "new_sheet",
1953
+ toggleText:
1954
+ "Initialize as a Texture Atlas (variable cell sizes)?",
1955
+ onConfirm: (name, isAtlas) => {
1956
+ if (name && name.trim().length > 0) {
1957
+ const n = name.trim();
1958
+ createProject(n, 32, 32, isAtlas);
1959
+ pushState();
1960
+ }
1961
+ },
1962
+ });
1963
+ }}
1964
+ style={{
1965
+ padding: "6px 12px",
1966
+ background: "transparent",
1967
+ color: uText,
1968
+ border: "none",
1969
+ "border-right": `1px solid ${uBorder}`,
1970
+ "border-top": "2px solid transparent",
1971
+ cursor: "pointer",
1972
+ "font-size": "14px",
1973
+ "font-weight": "bold",
1974
+ "line-height": "14px",
1975
+ "flex-shrink": 0,
1976
+ }}
1977
+ >
1978
+ <IconPlus size={14} />
1979
+ </button>
1980
+ </div>
1981
+
1982
+ <Show when={activeProj()}>
1983
+ {(() => {
1984
+ const proj = activeProj()!;
1985
+ return (
1986
+ <div
1987
+ style={{ display: "flex", flex: 1, "min-height": 0, gap: "5px" }}
1988
+ >
1989
+ {/* Left Toolbar (Tools & Color) */}
1990
+ <div
1991
+ style={{
1992
+ display: "flex",
1993
+ "flex-direction": "column",
1994
+ gap: "2px",
1995
+ background: uHeader,
1996
+ padding: "5px",
1997
+ "border-top": `1px solid ${uBorder}`,
1998
+ }}
1999
+ >
2000
+ <button
2001
+ onClick={() => setTool("pencil")}
2002
+ style={{
2003
+ display: "inline-flex",
2004
+ height: "32px",
2005
+ width: "32px",
2006
+ padding: "0",
2007
+ margin: "0",
2008
+ "align-items": "center",
2009
+ "justify-content": "center",
2010
+ background: tool() === "pencil" ? uBtn : "transparent",
2011
+ border: "none",
2012
+ color: tool() === "pencil" ? uActiveText : uText,
2013
+ cursor: "pointer",
2014
+ }}
2015
+ >
2016
+ <IconPencil size={18} />
2017
+ </button>
2018
+ <button
2019
+ onClick={() => setTool("eraser")}
2020
+ style={{
2021
+ display: "inline-flex",
2022
+ height: "32px",
2023
+ width: "32px",
2024
+ padding: "0",
2025
+ margin: "0",
2026
+ "align-items": "center",
2027
+ "justify-content": "center",
2028
+ background: tool() === "eraser" ? uBtn : "transparent",
2029
+ border: "none",
2030
+ color: tool() === "eraser" ? uActiveText : uText,
2031
+ cursor: "pointer",
2032
+ }}
2033
+ >
2034
+ <IconEraser size={18} />
2035
+ </button>
2036
+ <button
2037
+ onClick={() => setTool("fill")}
2038
+ style={{
2039
+ display: "inline-flex",
2040
+ height: "32px",
2041
+ width: "32px",
2042
+ padding: "0",
2043
+ margin: "0",
2044
+ "align-items": "center",
2045
+ "justify-content": "center",
2046
+ background: tool() === "fill" ? uBtn : "transparent",
2047
+ border: "none",
2048
+ color: tool() === "fill" ? uActiveText : uText,
2049
+ cursor: "pointer",
2050
+ }}
2051
+ >
2052
+ <IconBucket size={18} />
2053
+ </button>
2054
+ <button
2055
+ onClick={() => setTool("line")}
2056
+ style={{
2057
+ display: "inline-flex",
2058
+ height: "32px",
2059
+ width: "32px",
2060
+ padding: "0",
2061
+ margin: "0",
2062
+ "align-items": "center",
2063
+ "justify-content": "center",
2064
+ background: tool() === "line" ? uBtn : "transparent",
2065
+ border: "none",
2066
+ color: tool() === "line" ? uActiveText : uText,
2067
+ cursor: "pointer",
2068
+ }}
2069
+ >
2070
+ <IconLine size={18} />
2071
+ </button>
2072
+ <button
2073
+ onClick={() => setTool("rect")}
2074
+ style={{
2075
+ display: "inline-flex",
2076
+ height: "32px",
2077
+ width: "32px",
2078
+ padding: "0",
2079
+ margin: "0",
2080
+ "align-items": "center",
2081
+ "justify-content": "center",
2082
+ background: tool() === "rect" ? uBtn : "transparent",
2083
+ border: "none",
2084
+ color: tool() === "rect" ? uActiveText : uText,
2085
+ cursor: "pointer",
2086
+ }}
2087
+ >
2088
+ <IconRect size={18} />
2089
+ </button>
2090
+ <button
2091
+ onClick={() => setTool("circle")}
2092
+ style={{
2093
+ display: "inline-flex",
2094
+ height: "32px",
2095
+ width: "32px",
2096
+ padding: "0",
2097
+ margin: "0",
2098
+ "align-items": "center",
2099
+ "justify-content": "center",
2100
+ background: tool() === "circle" ? uBtn : "transparent",
2101
+ border: "none",
2102
+ color: tool() === "circle" ? uActiveText : uText,
2103
+ cursor: "pointer",
2104
+ }}
2105
+ >
2106
+ <IconCircle size={18} />
2107
+ </button>
2108
+ <button
2109
+ onClick={() => setTool("select")}
2110
+ style={{
2111
+ display: "inline-flex",
2112
+ height: "32px",
2113
+ width: "32px",
2114
+ padding: "0",
2115
+ margin: "0",
2116
+ "align-items": "center",
2117
+ "justify-content": "center",
2118
+ background: tool() === "select" ? uBtn : "transparent",
2119
+ border: "none",
2120
+ color: tool() === "select" ? uActiveText : uText,
2121
+ cursor: "pointer",
2122
+ }}
2123
+ >
2124
+ <IconSelect size={18} />
2125
+ </button>
2126
+ <div style={{ margin: "5px 0" }}></div>
2127
+ <button
2128
+ onClick={() => setTool("pan")}
2129
+ onDblClick={() => {
2130
+ setPanX(0);
2131
+ setPanY(0);
2132
+ }}
2133
+ style={{
2134
+ padding: "8px 0",
2135
+ background: tool() === "pan" ? uBtn : "transparent",
2136
+ border: "none",
2137
+ color: tool() === "pan" ? uActiveText : uText,
2138
+ cursor: "pointer",
2139
+ }}
2140
+ title="Pan (Middle Click or Shift-Click) | Double-Click to Reset"
2141
+ >
2142
+ <IconPan size={18} />
2143
+ </button>
2144
+ <div
2145
+ style={{ margin: "5px 0", "border-top": "1px solid #444" }}
2146
+ ></div>
2147
+ <input
2148
+ type="color"
2149
+ value={color()}
2150
+ onInput={(e) => setColor(e.currentTarget.value)}
2151
+ style={{
2152
+ width: "32px",
2153
+ height: "32px",
2154
+ padding: 0,
2155
+ border: "none",
2156
+ "border-radius": "4px",
2157
+ cursor: "pointer",
2158
+ }}
2159
+ />
2160
+ </div>
2161
+
2162
+ {/* Center Workspace (Canvas) */}
2163
+ <div
2164
+ style={{
2165
+ flex: 1,
2166
+ "min-width": 0,
2167
+ display: "flex",
2168
+ "flex-direction": "column",
2169
+ background: uHeader,
2170
+ border: `1px solid ${uBorder}`,
2171
+ }}
2172
+ >
2173
+ <div
2174
+ style={{
2175
+ background: uDark,
2176
+ padding: "5px",
2177
+ "border-bottom": `1px solid ${uBorder}`,
2178
+ display: "flex",
2179
+ "justify-content": "space-between",
2180
+ "align-items": "center",
2181
+ gap: "10px",
2182
+ }}
2183
+ >
2184
+ <div
2185
+ style={{
2186
+ display: "flex",
2187
+ gap: "10px",
2188
+ "align-items": "center",
2189
+ }}
2190
+ >
2191
+ <span style={{ "font-size": "11px", color: uText }}>
2192
+ Zoom: {zoom()}x
2193
+ </span>
2194
+ <input
2195
+ type="range"
2196
+ min="2"
2197
+ max="60"
2198
+ value={zoom()}
2199
+ onInput={(e) =>
2200
+ setZoom(parseInt(e.currentTarget.value, 10))
2201
+ }
2202
+ style={{ width: "100px" }}
2203
+ />
2204
+ </div>
2205
+ <div
2206
+ style={{
2207
+ display: "flex",
2208
+ gap: "5px",
2209
+ "align-items": "center",
2210
+ "font-size": "11px",
2211
+ color: uText,
2212
+ }}
2213
+ >
2214
+ <Show when={true}>
2215
+ <div
2216
+ style={{
2217
+ display: "flex",
2218
+ gap: "5px",
2219
+ background: "#181818",
2220
+ padding: "2px",
2221
+ "border-radius": "3px",
2222
+ border: `1px solid ${uBorder}`,
2223
+ }}
2224
+ >
2225
+ <button
2226
+ onClick={() => setAtlasViewMode("subimage")}
2227
+ style={{
2228
+ border: "none",
2229
+ "border-radius": "2px",
2230
+ background:
2231
+ atlasViewMode() === "subimage"
2232
+ ? uActive
2233
+ : "transparent",
2234
+ color:
2235
+ atlasViewMode() === "subimage"
2236
+ ? uActiveText
2237
+ : uText,
2238
+ cursor: "pointer",
2239
+ "font-size": "11px",
2240
+ }}
2241
+ >
2242
+ Cell View
2243
+ </button>
2244
+ <button
2245
+ onClick={() => setAtlasViewMode("atlas")}
2246
+ style={{
2247
+ border: "none",
2248
+ "border-radius": "2px",
2249
+ background:
2250
+ atlasViewMode() === "atlas"
2251
+ ? uActive
2252
+ : "transparent",
2253
+ color:
2254
+ atlasViewMode() === "atlas" ? uActiveText : uText,
2255
+ cursor: "pointer",
2256
+ "font-size": "11px",
2257
+ }}
2258
+ >
2259
+ Atlas Layout
2260
+ </button>
2261
+ </div>
2262
+ </Show>
2263
+ Grid Size:
2264
+ <input
2265
+ type="number"
2266
+ min="2"
2267
+ max="1024"
2268
+ value={
2269
+ proj.isAtlas
2270
+ ? proj.frames.find((f) => f.id === proj.activeFrameId)
2271
+ ?.width || proj.width
2272
+ : proj.width
2273
+ }
2274
+ onInput={(e) =>
2275
+ resizeCanvas(
2276
+ parseInt(e.currentTarget.value, 10) || 32,
2277
+ proj.isAtlas
2278
+ ? proj.frames.find(
2279
+ (f) => f.id === proj.activeFrameId,
2280
+ )?.height || proj.height
2281
+ : proj.height,
2282
+ )
2283
+ }
2284
+ style={{
2285
+ width: "45px",
2286
+ background: uBtn,
2287
+ color: uActiveText,
2288
+ border: `1px solid ${uBorder}`,
2289
+ padding: "2px",
2290
+ }}
2291
+ />
2292
+ x
2293
+ <input
2294
+ type="number"
2295
+ min="2"
2296
+ max="1024"
2297
+ value={
2298
+ proj.isAtlas
2299
+ ? proj.frames.find((f) => f.id === proj.activeFrameId)
2300
+ ?.height || proj.height
2301
+ : proj.height
2302
+ }
2303
+ onInput={(e) =>
2304
+ resizeCanvas(
2305
+ proj.isAtlas
2306
+ ? proj.frames.find(
2307
+ (f) => f.id === proj.activeFrameId,
2308
+ )?.width || proj.width
2309
+ : proj.width,
2310
+ parseInt(e.currentTarget.value, 10) || 32,
2311
+ )
2312
+ }
2313
+ style={{
2314
+ width: "45px",
2315
+ background: uBtn,
2316
+ color: uActiveText,
2317
+ border: `1px solid ${uBorder}`,
2318
+ padding: "2px",
2319
+ }}
2320
+ />
2321
+ </div>
2322
+ </div>
2323
+
2324
+ <Show when={selectionBox() !== null}>
2325
+ <div
2326
+ style={{
2327
+ background: uDark,
2328
+ padding: "5px 10px",
2329
+ "border-bottom": `1px solid ${uBorder}`,
2330
+ display: "flex",
2331
+ gap: "10px",
2332
+ "align-items": "center",
2333
+ "font-size": "11px",
2334
+ }}
2335
+ >
2336
+ <span style={{ color: uActiveText, "font-weight": "bold" }}>
2337
+ Selection
2338
+ </span>
2339
+ <div style={{ display: "flex", gap: "5px" }}>
2340
+ <button
2341
+ onClick={() => selectionAction("copy")}
2342
+ title="Copy (Ctrl+C)"
2343
+ style={{
2344
+ display: "flex",
2345
+ "align-items": "center",
2346
+ gap: "4px",
2347
+ background: uBtn,
2348
+ color: uText,
2349
+ border: `1px solid ${uBorder}`,
2350
+ padding: "2px 6px",
2351
+ cursor: "pointer",
2352
+ }}
2353
+ >
2354
+ <IconCopy size={12} /> Copy
2355
+ </button>
2356
+ <button
2357
+ onClick={() => selectionAction("paste")}
2358
+ title="Paste (Ctrl+V)"
2359
+ style={{
2360
+ display: "flex",
2361
+ "align-items": "center",
2362
+ gap: "4px",
2363
+ background: uBtn,
2364
+ color: uText,
2365
+ border: `1px solid ${uBorder}`,
2366
+ padding: "2px 6px",
2367
+ cursor: "pointer",
2368
+ }}
2369
+ >
2370
+ <IconPaste size={12} /> Paste
2371
+ </button>
2372
+ <button
2373
+ onClick={() => selectionAction("duplicate")}
2374
+ title="Duplicate (Ctrl+D)"
2375
+ style={{
2376
+ display: "flex",
2377
+ "align-items": "center",
2378
+ gap: "4px",
2379
+ background: uBtn,
2380
+ color: uText,
2381
+ border: `1px solid ${uBorder}`,
2382
+ padding: "2px 6px",
2383
+ cursor: "pointer",
2384
+ }}
2385
+ >
2386
+ <IconDuplicate size={12} /> Duplicate
2387
+ </button>
2388
+ <button
2389
+ onClick={() => selectionAction("delete")}
2390
+ title="Delete (Del)"
2391
+ style={{
2392
+ display: "flex",
2393
+ "align-items": "center",
2394
+ gap: "4px",
2395
+ background: uBtn,
2396
+ color: "#f55",
2397
+ border: `1px solid ${uBorder}`,
2398
+ padding: "2px 6px",
2399
+ cursor: "pointer",
2400
+ }}
2401
+ >
2402
+ <IconTrash size={12} /> Delete
2403
+ </button>
2404
+ <span style={{ color: "#555" }}>|</span>
2405
+ <button
2406
+ onClick={() => selectionAction("flip_h")}
2407
+ style={{
2408
+ display: "flex",
2409
+ "align-items": "center",
2410
+ gap: "4px",
2411
+ background: uBtn,
2412
+ color: uText,
2413
+ border: `1px solid ${uBorder}`,
2414
+ padding: "2px 6px",
2415
+ cursor: "pointer",
2416
+ }}
2417
+ >
2418
+ <IconFlipHorizontal size={12} /> Flip H
2419
+ </button>
2420
+ <button
2421
+ onClick={() => selectionAction("flip_v")}
2422
+ style={{
2423
+ display: "flex",
2424
+ "align-items": "center",
2425
+ gap: "4px",
2426
+ background: uBtn,
2427
+ color: uText,
2428
+ border: `1px solid ${uBorder}`,
2429
+ padding: "2px 6px",
2430
+ cursor: "pointer",
2431
+ }}
2432
+ >
2433
+ <IconFlipVertical size={12} /> Flip V
2434
+ </button>
2435
+ <span style={{ color: "#555" }}>|</span>
2436
+ <button
2437
+ onClick={() => setSelectionBox(null)}
2438
+ style={{
2439
+ background: "transparent",
2440
+ color: uText,
2441
+ border: "none",
2442
+ cursor: "pointer",
2443
+ "text-decoration": "underline",
2444
+ }}
2445
+ >
2446
+ Deselect
2447
+ </button>
2448
+ </div>
2449
+ </div>
2450
+ </Show>
2451
+
2452
+ <div
2453
+ onMouseDown={(e) => {
2454
+ if (
2455
+ e.button === 1 ||
2456
+ tool() === "pan" ||
2457
+ (e.button === 0 && e.shiftKey)
2458
+ ) {
2459
+ isPanning = true;
2460
+ panStartX = e.clientX;
2461
+ panStartY = e.clientY;
2462
+ }
2463
+ }}
2464
+ onMouseMove={(e) => {
2465
+ if (isPanning) {
2466
+ setPanX((px) => px + (e.clientX - panStartX));
2467
+ setPanY((py) => py + (e.clientY - panStartY));
2468
+ panStartX = e.clientX;
2469
+ panStartY = e.clientY;
2470
+ }
2471
+ }}
2472
+ onMouseUp={() => (isPanning = false)}
2473
+ onMouseLeave={() => (isPanning = false)}
2474
+ style={{
2475
+ flex: 1,
2476
+ display: "flex",
2477
+ "justify-content": "center",
2478
+ "align-items": "center",
2479
+ background: "#181818",
2480
+ position: "relative",
2481
+ overflow: "hidden",
2482
+ }}
2483
+ >
2484
+ <div
2485
+ style={{
2486
+ transform: `translate(${panX()}px, ${panY()}px)`,
2487
+ width: `${(atlasViewMode() === "atlas" ? (proj.isAtlas ? atlasMapW() : proj.width * (proj.metadata?.columns || 1)) : proj.frames.find((f) => f.id === proj.activeFrameId)?.width || proj.width) * zoom()}px`,
2488
+ height: `${(atlasViewMode() === "atlas" ? (proj.isAtlas ? atlasMapH() : proj.height * (proj.metadata?.rows || Math.ceil(proj.frames.length / (proj.metadata?.columns || 1)))) : proj.frames.find((f) => f.id === proj.activeFrameId)?.height || proj.height) * zoom()}px`,
2489
+ position: "relative",
2490
+ background: `repeating-conic-gradient(#333 0% 25%, #2a2a2a 0% 50%) 50% / 16px 16px`,
2491
+ "box-shadow": "0 0 20px rgba(0,0,0,0.8)",
2492
+ }}
2493
+ >
2494
+ <div
2495
+ style={{
2496
+ display: atlasViewMode() === "atlas" ? "none" : "block",
2497
+ width: "100%",
2498
+ height: "100%",
2499
+ }}
2500
+ >
2501
+ {/* Composite Canvas (Behind) */}
2502
+ <canvas
2503
+ ref={compositeCanvasRef}
2504
+ width={
2505
+ (atlasViewMode() === "atlas"
2506
+ ? proj.isAtlas
2507
+ ? atlasMapW()
2508
+ : proj.width * (proj.metadata?.columns || 1)
2509
+ : proj.frames.find(
2510
+ (f) => f.id === proj.activeFrameId,
2511
+ )?.width || proj.width) * zoom()
2512
+ }
2513
+ height={
2514
+ (atlasViewMode() === "atlas"
2515
+ ? proj.isAtlas
2516
+ ? atlasMapH()
2517
+ : proj.height *
2518
+ (proj.metadata?.rows ||
2519
+ Math.ceil(
2520
+ proj.frames.length /
2521
+ (proj.metadata?.columns || 1),
2522
+ ))
2523
+ : proj.frames.find(
2524
+ (f) => f.id === proj.activeFrameId,
2525
+ )?.height || proj.height) * zoom()
2526
+ }
2527
+ style={{
2528
+ position: "absolute",
2529
+ "pointer-events": "none",
2530
+ "image-rendering": "pixelated",
2531
+ width: "100%",
2532
+ height: "100%",
2533
+ }}
2534
+ ></canvas>
2535
+
2536
+ {/* Preview UI Overlay (Interactive) */}
2537
+ <canvas
2538
+ ref={previewCanvasRef}
2539
+ width={
2540
+ atlasViewMode() === "atlas"
2541
+ ? proj.isAtlas
2542
+ ? atlasMapW()
2543
+ : proj.width * (proj.metadata?.columns || 1)
2544
+ : proj.frames.find(
2545
+ (f) => f.id === proj.activeFrameId,
2546
+ )?.width || proj.width
2547
+ }
2548
+ height={
2549
+ atlasViewMode() === "atlas"
2550
+ ? proj.isAtlas
2551
+ ? atlasMapH()
2552
+ : proj.height *
2553
+ (proj.metadata?.rows ||
2554
+ Math.ceil(
2555
+ proj.frames.length /
2556
+ (proj.metadata?.columns || 1),
2557
+ ))
2558
+ : proj.frames.find(
2559
+ (f) => f.id === proj.activeFrameId,
2560
+ )?.height || proj.height
2561
+ }
2562
+ onMouseDown={handlePointerDown}
2563
+ onMouseMove={handlePointerMove}
2564
+ onMouseUp={handlePointerUp}
2565
+ onMouseLeave={handlePointerUp}
2566
+ style={{
2567
+ position: "absolute",
2568
+ width: "100%",
2569
+ height: "100%",
2570
+ "image-rendering": "pixelated",
2571
+ cursor: "crosshair",
2572
+ "z-index": 2,
2573
+ }}
2574
+ ></canvas>
2575
+ <Show when={selectionBox()}>
2576
+ {(_) => (
2577
+ <div
2578
+ style={{
2579
+ position: "absolute",
2580
+ left: `${((selectionBox()?.x || 0) / (proj.frames.find((f) => f.id === proj.activeFrameId)?.width || proj.width)) * 100}%`,
2581
+ top: `${((selectionBox()?.y || 0) / (proj.frames.find((f) => f.id === proj.activeFrameId)?.height || proj.height)) * 100}%`,
2582
+ width: `${((selectionBox()?.w || 0) / (proj.frames.find((f) => f.id === proj.activeFrameId)?.width || proj.width)) * 100}%`,
2583
+ height: `${((selectionBox()?.h || 0) / (proj.frames.find((f) => f.id === proj.activeFrameId)?.height || proj.height)) * 100}%`,
2584
+ border: "1px dashed #fff",
2585
+ outline: "1px solid rgba(0,0,0,0.5)",
2586
+ "box-sizing": "border-box",
2587
+ "pointer-events": "none",
2588
+ "z-index": 10,
2589
+ "box-shadow": "inset 0 0 0 1px rgba(0,0,0,0.5)",
2590
+ }}
2591
+ />
2592
+ )}
2593
+ </Show>
2594
+ </div>
2595
+ <Show when={atlasViewMode() === "atlas"}>
2596
+ <img
2597
+ src={atlasDataUri()}
2598
+ style={{
2599
+ position: "absolute",
2600
+ width: "100%",
2601
+ height: "100%",
2602
+ "object-fit": "contain",
2603
+ "image-rendering": "pixelated",
2604
+ "pointer-events": "none",
2605
+ }}
2606
+ />
2607
+ </Show>
2608
+ </div>
2609
+ </div>
2610
+
2611
+ {/* Timeline Strip */}
2612
+ <div
2613
+ style={{
2614
+ background: uDark,
2615
+ "border-top": `1px solid ${uBorder}`,
2616
+ padding: "5px",
2617
+ display: "flex",
2618
+ gap: "5px",
2619
+ "overflow-x": "auto",
2620
+ "align-items": "center",
2621
+ }}
2622
+ >
2623
+ <div
2624
+ style={{
2625
+ display: "flex",
2626
+ "flex-direction": "column",
2627
+ gap: "2px",
2628
+ "padding-right": "5px",
2629
+ "border-right": `1px solid ${uBorder}`,
2630
+ }}
2631
+ >
2632
+ <button
2633
+ title="Delete Frame"
2634
+ onClick={deleteFrame}
2635
+ style={{
2636
+ padding: "0",
2637
+ background: "transparent",
2638
+ color: proj.frames.length <= 1 ? "#884444" : "#ff5555",
2639
+ border: "none",
2640
+ cursor:
2641
+ proj.frames.length <= 1 ? "not-allowed" : "pointer",
2642
+ "font-size": "14px",
2643
+ "font-weight": "bold",
2644
+ "flex-shrink": 0,
2645
+ "line-height": "16px",
2646
+ }}
2647
+ >
2648
+ <IconTrash size={14} />
2649
+ </button>
2650
+ <button
2651
+ title="Duplicate Frame"
2652
+ onClick={duplicateFrame}
2653
+ style={{
2654
+ padding: "0",
2655
+ background: "transparent",
2656
+ color: uActiveText,
2657
+ border: "none",
2658
+ cursor: "pointer",
2659
+ "font-size": "14px",
2660
+ "font-weight": "bold",
2661
+ "flex-shrink": 0,
2662
+ "line-height": "16px",
2663
+ }}
2664
+ >
2665
+ <IconDuplicate size={14} />
2666
+ </button>
2667
+ </div>
2668
+ <button
2669
+ title="Add Blank Frame"
2670
+ onClick={addFrame}
2671
+ style={{
2672
+ padding: "10px",
2673
+ background: uBtn,
2674
+ color: uActiveText,
2675
+ border: `1px solid ${uBorder}`,
2676
+ cursor: "pointer",
2677
+ "border-radius": "3px",
2678
+ "font-weight": "bold",
2679
+ "flex-shrink": 0,
2680
+ }}
2681
+ >
2682
+ <IconPlus size={14} />
2683
+ </button>
2684
+ <For each={proj.frames}>
2685
+ {(fr, i) => (
2686
+ <div
2687
+ onClick={() => {
2688
+ setProjects((ps) =>
2689
+ ps.map((p) =>
2690
+ p.id === proj.id
2691
+ ? { ...p, activeFrameId: fr.id }
2692
+ : p,
2693
+ ),
2694
+ );
2695
+ const target = entities().find(
2696
+ (e) => e.id === targetEntity(),
2697
+ );
2698
+ if (target?.animator && !target.animator.isPlaying) {
2699
+ const anim = target.animator.animations.get(
2700
+ target.animator.currentAnimation,
2701
+ );
2702
+ const idxInAnim = anim
2703
+ ? anim.frames.indexOf(i())
2704
+ : -1;
2705
+ if (idxInAnim !== -1) {
2706
+ target.animator.currentFrameIndex = idxInAnim;
2707
+ } else {
2708
+ target.animator.currentAnimation = "preview";
2709
+ target.animator.currentFrameIndex = i();
2710
+ }
2711
+ }
2712
+ syncToEngine();
2713
+ }}
2714
+ style={{
2715
+ width: "40px",
2716
+ height: "40px",
2717
+ "flex-shrink": 0,
2718
+ background: `repeating-conic-gradient(#333 0% 25%, #2a2a2a 0% 50%) 50% / 8px 8px`,
2719
+ border: `2px solid ${fr.id === proj.activeFrameId ? uAcc : uBorder}`,
2720
+ cursor: "pointer",
2721
+ "border-radius": "3px",
2722
+ overflow: "hidden",
2723
+ }}
2724
+ >
2725
+ <canvas
2726
+ id={`thumb_${fr.id}`}
2727
+ width={
2728
+ proj.isAtlas ? fr.width || proj.width : proj.width
2729
+ }
2730
+ height={
2731
+ proj.isAtlas
2732
+ ? fr.height || proj.height
2733
+ : proj.height
2734
+ }
2735
+ style={{
2736
+ width: "100%",
2737
+ height: "100%",
2738
+ "object-fit": "contain",
2739
+ "image-rendering": "pixelated",
2740
+ }}
2741
+ ></canvas>
2742
+ </div>
2743
+ )}
2744
+ </For>
2745
+ </div>
2746
+ </div>
2747
+
2748
+ {/* Right Panels (Layers / Options) */}
2749
+ <div
2750
+ style={{
2751
+ width: "220px",
2752
+ display: "flex",
2753
+ "flex-direction": "column",
2754
+ gap: "5px",
2755
+ }}
2756
+ >
2757
+ <div
2758
+ style={{
2759
+ flex: 1,
2760
+ background: uHeader,
2761
+ border: `1px solid ${uBorder}`,
2762
+ display: "flex",
2763
+ "flex-direction": "column",
2764
+ }}
2765
+ >
2766
+ <div
2767
+ style={{
2768
+ padding: "5px",
2769
+ background: uDark,
2770
+ "border-bottom": `1px solid ${uBorder}`,
2771
+ "font-size": "12px",
2772
+ "font-weight": "bold",
2773
+ display: "flex",
2774
+ "align-items": "center",
2775
+ "justify-content": "space-between",
2776
+ }}
2777
+ >
2778
+ Layers
2779
+ <button
2780
+ onClick={addLayer}
2781
+ style={{
2782
+ background: "transparent",
2783
+ appearance: "none",
2784
+ color: uText,
2785
+ border: "none",
2786
+ cursor: "pointer",
2787
+ }}
2788
+ >
2789
+ <IconPlus size={14} />
2790
+ </button>
2791
+ </div>
2792
+ <div
2793
+ style={{
2794
+ flex: 1,
2795
+ "overflow-y": "auto",
2796
+ padding: "5px",
2797
+ display: "flex",
2798
+ "flex-direction": "column",
2799
+ gap: "2px",
2800
+ }}
2801
+ >
2802
+ <For each={proj.layers}>
2803
+ {(l) => (
2804
+ <div
2805
+ style={{
2806
+ display: "flex",
2807
+ "align-items": "center",
2808
+ background:
2809
+ l.id === proj.activeLayerId
2810
+ ? uBtn
2811
+ : "transparent",
2812
+ padding: "4px",
2813
+ "border-radius": "3px",
2814
+ cursor: "pointer",
2815
+ }}
2816
+ onClick={() =>
2817
+ setProjects((ps) =>
2818
+ ps.map((p) =>
2819
+ p.id === proj.id
2820
+ ? { ...p, activeLayerId: l.id }
2821
+ : p,
2822
+ ),
2823
+ )
2824
+ }
2825
+ >
2826
+ <button
2827
+ onClick={(e) => {
2828
+ e.stopPropagation();
2829
+ toggleLayerVis(l.id);
2830
+ }}
2831
+ style={{
2832
+ background: "transparent",
2833
+ border: "none",
2834
+ cursor: "pointer",
2835
+ padding: "2px",
2836
+ display: "flex",
2837
+ "align-items": "center",
2838
+ "justify-content": "center",
2839
+ "border-radius": "3px",
2840
+ }}
2841
+ >
2842
+ {l.visible ? (
2843
+ <IconEye size={14} />
2844
+ ) : (
2845
+ <IconEyeOff size={14} color="#555" />
2846
+ )}
2847
+ </button>
2848
+ <span
2849
+ style={{
2850
+ "font-size": "11px",
2851
+ flex: 1,
2852
+ color:
2853
+ l.id === proj.activeLayerId
2854
+ ? uActiveText
2855
+ : uText,
2856
+ }}
2857
+ >
2858
+ {l.name}
2859
+ </span>
2860
+ <Show when={l.id === proj.activeLayerId}>
2861
+ <div
2862
+ style={{
2863
+ display: "flex",
2864
+ gap: "2px",
2865
+ "align-items": "center",
2866
+ }}
2867
+ >
2868
+ <button
2869
+ title="Move Up"
2870
+ onClick={(e) => {
2871
+ e.stopPropagation();
2872
+ moveLayer(l.id, -1);
2873
+ }}
2874
+ style={{
2875
+ background: "transparent",
2876
+ color:
2877
+ proj.layers.findIndex(
2878
+ (x) => x.id === l.id,
2879
+ ) === 0
2880
+ ? "#777"
2881
+ : uText,
2882
+ border: "none",
2883
+ cursor:
2884
+ proj.layers.findIndex(
2885
+ (x) => x.id === l.id,
2886
+ ) === 0
2887
+ ? "not-allowed"
2888
+ : "pointer",
2889
+ padding: "2px",
2890
+ display: "flex",
2891
+ "align-items": "center",
2892
+ "justify-content": "center",
2893
+ "border-radius": "3px",
2894
+ }}
2895
+ >
2896
+ <IconUp size={14} />
2897
+ </button>
2898
+ <button
2899
+ title="Move Down"
2900
+ onClick={(e) => {
2901
+ e.stopPropagation();
2902
+ moveLayer(l.id, 1);
2903
+ }}
2904
+ style={{
2905
+ background: "transparent",
2906
+ color:
2907
+ proj.layers.findIndex(
2908
+ (x) => x.id === l.id,
2909
+ ) ===
2910
+ proj.layers.length - 1
2911
+ ? "#777"
2912
+ : uText,
2913
+ border: "none",
2914
+ cursor:
2915
+ proj.layers.findIndex(
2916
+ (x) => x.id === l.id,
2917
+ ) ===
2918
+ proj.layers.length - 1
2919
+ ? "not-allowed"
2920
+ : "pointer",
2921
+ padding: "2px",
2922
+ display: "flex",
2923
+ "align-items": "center",
2924
+ "justify-content": "center",
2925
+ "border-radius": "3px",
2926
+ }}
2927
+ >
2928
+ <IconDown size={14} />
2929
+ </button>
2930
+ <button
2931
+ title="Merge Down"
2932
+ onClick={(e) => {
2933
+ e.stopPropagation();
2934
+ mergeLayerDown(l.id);
2935
+ }}
2936
+ style={{
2937
+ background: "transparent",
2938
+ color:
2939
+ proj.layers.findIndex(
2940
+ (x) => x.id === l.id,
2941
+ ) ===
2942
+ proj.layers.length - 1
2943
+ ? "#777"
2944
+ : uText,
2945
+ border: "none",
2946
+ cursor:
2947
+ proj.layers.findIndex(
2948
+ (x) => x.id === l.id,
2949
+ ) ===
2950
+ proj.layers.length - 1
2951
+ ? "not-allowed"
2952
+ : "pointer",
2953
+ padding: "2px",
2954
+ display: "flex",
2955
+ "align-items": "center",
2956
+ "justify-content": "center",
2957
+ "border-radius": "3px",
2958
+ }}
2959
+ >
2960
+ <IconMergeDown size={14} />
2961
+ </button>
2962
+ <button
2963
+ title="Duplicate Layer"
2964
+ onClick={(e) => {
2965
+ e.stopPropagation();
2966
+ duplicateLayer(l.id);
2967
+ }}
2968
+ style={{
2969
+ background: "transparent",
2970
+ color: uText,
2971
+ border: "none",
2972
+ cursor: "pointer",
2973
+ padding: "2px",
2974
+ display: "flex",
2975
+ "align-items": "center",
2976
+ "justify-content": "center",
2977
+ "border-radius": "3px",
2978
+ }}
2979
+ >
2980
+ <IconDuplicate size={14} />
2981
+ </button>
2982
+ <button
2983
+ title="Delete Layer"
2984
+ onClick={(e) => {
2985
+ e.stopPropagation();
2986
+ deleteLayer(l.id);
2987
+ }}
2988
+ style={{
2989
+ background: "transparent",
2990
+ color:
2991
+ proj.layers.length <= 1
2992
+ ? "#884444"
2993
+ : "#ff5555",
2994
+ border: "none",
2995
+ cursor:
2996
+ proj.layers.length <= 1
2997
+ ? "not-allowed"
2998
+ : "pointer",
2999
+ padding: "2px",
3000
+ display: "flex",
3001
+ "align-items": "center",
3002
+ "justify-content": "center",
3003
+ "border-radius": "3px",
3004
+ }}
3005
+ >
3006
+ <IconTrash size={14} />
3007
+ </button>
3008
+ </div>
3009
+ </Show>
3010
+ </div>
3011
+ )}
3012
+ </For>
3013
+ </div>
3014
+ </div>
3015
+
3016
+ <div
3017
+ style={{
3018
+ flex: 1,
3019
+ background: uHeader,
3020
+ border: `1px solid ${uBorder}`,
3021
+ padding: "5px",
3022
+ "font-size": "11px",
3023
+ color: uText,
3024
+ display: "flex",
3025
+ "flex-direction": "column",
3026
+ gap: "10px",
3027
+ }}
3028
+ >
3029
+ <div>
3030
+ Target Engine Entity:
3031
+ <select
3032
+ value={targetEntity()}
3033
+ onChange={(e) => {
3034
+ const newId = parseInt(e.currentTarget.value, 10);
3035
+ setTargetEntity(newId);
3036
+ const target = entities().find((x) => x.id === newId);
3037
+ if (target?.renderer?.imageSrc) {
3038
+ const name = target.renderer.imageSrc
3039
+ .replace("/public/assets/sprites/", "")
3040
+ .replace(".png", "");
3041
+ const proj = projects().find((p) => p.name === name);
3042
+ if (proj) setActiveProjId(proj.id);
3043
+ }
3044
+ }}
3045
+ style={{
3046
+ width: "100%",
3047
+ padding: "4px",
3048
+ "margin-top": "5px",
3049
+ background: "#282828",
3050
+ color: uActiveText,
3051
+ border: `1px solid ${uBorder}`,
3052
+ }}
3053
+ >
3054
+ <For each={entities()}>
3055
+ {(e) => <option value={e.id}>Entity #{e.id}</option>}
3056
+ </For>
3057
+ </select>
3058
+ </div>
3059
+
3060
+ <div style={{ "border-top": `1px solid ${uBorder}` }}></div>
3061
+
3062
+ <span
3063
+ style={{
3064
+ color:
3065
+ saveStatus() === "Saved"
3066
+ ? "#10b981"
3067
+ : saveStatus() === "Error"
3068
+ ? "#ef4444"
3069
+ : "#888",
3070
+ "font-style": "italic",
3071
+ "font-size": "10px",
3072
+ "text-align": "center",
3073
+ padding: "5px",
3074
+ }}
3075
+ >
3076
+ {saveStatus() === "Ready" ? (
3077
+ ""
3078
+ ) : saveStatus() === "Saved" ? (
3079
+ <div
3080
+ style={{
3081
+ display: "flex",
3082
+ "justify-content": "center",
3083
+ gap: "4px",
3084
+ "align-items": "center",
3085
+ }}
3086
+ >
3087
+ <IconCheck size={10} /> Saved to Disk
3088
+ </div>
3089
+ ) : saveStatus() === "Error" ? (
3090
+ <div
3091
+ style={{
3092
+ display: "flex",
3093
+ "justify-content": "center",
3094
+ gap: "4px",
3095
+ "align-items": "center",
3096
+ }}
3097
+ >
3098
+ <IconX size={10} /> Error Persisting
3099
+ </div>
3100
+ ) : (
3101
+ "Auto-saving to Server Disk..."
3102
+ )}
3103
+ </span>
3104
+ </div>
3105
+ </div>
3106
+ </div>
3107
+ );
3108
+ })()}
3109
+ </Show>
3110
+
3111
+ <Show when={dialog() !== null}>
3112
+ <div
3113
+ style={{
3114
+ position: "absolute",
3115
+ top: 0,
3116
+ left: 0,
3117
+ right: 0,
3118
+ bottom: 0,
3119
+ background: "rgba(0,0,0,0.8)",
3120
+ display: "flex",
3121
+ "align-items": "center",
3122
+ "justify-content": "center",
3123
+ "z-index": 99999,
3124
+ }}
3125
+ >
3126
+ <div
3127
+ style={{
3128
+ background: uDark,
3129
+ padding: "20px",
3130
+ "border-radius": "8px",
3131
+ border: `1px solid ${uBorder}`,
3132
+ width: "300px",
3133
+ "box-shadow": "0 10px 40px rgba(0,0,0,1)",
3134
+ }}
3135
+ >
3136
+ <h3
3137
+ style={{
3138
+ margin: "0 0 15px 0",
3139
+ color: uActiveText,
3140
+ "font-size": "14px",
3141
+ }}
3142
+ >
3143
+ {dialog()?.title}
3144
+ </h3>
3145
+ <input
3146
+ type="text"
3147
+ ref={(el) => {
3148
+ dialogInputRef = el;
3149
+ setTimeout(() => el.focus(), 10);
3150
+ }}
3151
+ value={dialog()?.placeholder}
3152
+ onKeyDown={(e) => {
3153
+ if (e.key === "Enter") {
3154
+ const v = e.currentTarget.value || dialog()?.placeholder;
3155
+ dialog()?.onConfirm(v, dialogToggle());
3156
+ setDialog(null);
3157
+ } else if (e.key === "Escape") {
3158
+ if (dialog()?.onCancel) dialog()?.onCancel?.();
3159
+ setDialog(null);
3160
+ }
3161
+ }}
3162
+ style={{
3163
+ width: "100%",
3164
+ padding: "8px",
3165
+ background: "#111",
3166
+ color: "#ddd",
3167
+ border: `1px solid ${uBorder}`,
3168
+ "border-radius": "4px",
3169
+ "box-sizing": "border-box",
3170
+ "margin-bottom": "15px",
3171
+ }}
3172
+ />
3173
+ <Show when={dialog()?.toggleText}>
3174
+ <label
3175
+ style={{
3176
+ display: "flex",
3177
+ "align-items": "center",
3178
+ gap: "10px",
3179
+ color: "#ddd",
3180
+ "font-size": "12px",
3181
+ "margin-bottom": "15px",
3182
+ cursor: "pointer",
3183
+ }}
3184
+ >
3185
+ <input
3186
+ type="checkbox"
3187
+ checked={dialogToggle()}
3188
+ onChange={(e) => setDialogToggle(e.currentTarget.checked)}
3189
+ />
3190
+ {dialog()?.toggleText}
3191
+ </label>
3192
+ </Show>
3193
+ <div
3194
+ style={{
3195
+ display: "flex",
3196
+ "justify-content": "flex-end",
3197
+ gap: "10px",
3198
+ }}
3199
+ >
3200
+ <button
3201
+ onClick={() => {
3202
+ if (dialog()?.onCancel) dialog()?.onCancel?.();
3203
+ setDialog(null);
3204
+ }}
3205
+ style={{
3206
+ padding: "6px 12px",
3207
+ background: "transparent",
3208
+ color: uText,
3209
+ border: "none",
3210
+ cursor: "pointer",
3211
+ }}
3212
+ >
3213
+ Cancel
3214
+ </button>
3215
+ <button
3216
+ onClick={() => {
3217
+ dialog()?.onConfirm(
3218
+ dialogInputRef.value || dialog()?.placeholder,
3219
+ dialogToggle(),
3220
+ );
3221
+ setDialog(null);
3222
+ }}
3223
+ style={{
3224
+ padding: "6px 12px",
3225
+ background: uAcc,
3226
+ color: "#fff",
3227
+ border: "none",
3228
+ "border-radius": "4px",
3229
+ cursor: "pointer",
3230
+ "font-weight": "bold",
3231
+ }}
3232
+ >
3233
+ OK
3234
+ </button>
3235
+ </div>
3236
+ </div>
3237
+ </div>
3238
+ </Show>
3239
+ </div>
3240
+ );
3241
+ }