@ai-game-assets/phaser 0.5.6 → 0.7.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 (44) hide show
  1. package/dist/animations.d.ts +2 -0
  2. package/dist/animations.d.ts.map +1 -1
  3. package/dist/animations.js +4 -3
  4. package/dist/animations.js.map +1 -1
  5. package/dist/debug-client.d.ts +52 -2
  6. package/dist/debug-client.d.ts.map +1 -1
  7. package/dist/debug-client.js +66 -0
  8. package/dist/debug-client.js.map +1 -1
  9. package/dist/designer-support.d.ts +31 -4
  10. package/dist/designer-support.d.ts.map +1 -1
  11. package/dist/designer-support.js +732 -80
  12. package/dist/designer-support.js.map +1 -1
  13. package/dist/designer.d.ts +19 -2
  14. package/dist/designer.d.ts.map +1 -1
  15. package/dist/designer.js +1735 -185
  16. package/dist/designer.js.map +1 -1
  17. package/dist/first-drafts.js +21 -9
  18. package/dist/first-drafts.js.map +1 -1
  19. package/dist/index.d.ts +7 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/keys.d.ts +2 -0
  24. package/dist/keys.d.ts.map +1 -1
  25. package/dist/keys.js +6 -0
  26. package/dist/keys.js.map +1 -1
  27. package/dist/loader.d.ts.map +1 -1
  28. package/dist/loader.js +50 -3
  29. package/dist/loader.js.map +1 -1
  30. package/dist/phaser-types.d.ts +1 -0
  31. package/dist/phaser-types.d.ts.map +1 -1
  32. package/dist/runtime.d.ts +30 -2
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +235 -8
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/tileset-dialog.d.ts +121 -0
  37. package/dist/tileset-dialog.d.ts.map +1 -0
  38. package/dist/tileset-dialog.js +1594 -0
  39. package/dist/tileset-dialog.js.map +1 -0
  40. package/dist/tilesets.d.ts +13 -0
  41. package/dist/tilesets.d.ts.map +1 -0
  42. package/dist/tilesets.js +60 -0
  43. package/dist/tilesets.js.map +1 -0
  44. package/package.json +3 -2
@@ -1,3 +1,4 @@
1
+ import { aiTextureKey } from "./keys.js";
1
2
  const assetFormatOptions = [
2
3
  { value: "png", label: "PNG" },
3
4
  { value: "jpg", label: "JPEG" },
@@ -69,6 +70,19 @@ export function createDesignerElements(options, manifest, selectedAssetId) {
69
70
  const promptInput = document.createElement("textarea");
70
71
  promptInput.className = "ai-game-assets-designer__prompt";
71
72
  promptInput.rows = 6;
73
+ const promptField = labelWrap("Prompt", promptInput);
74
+ const tilesetPromptsField = document.createElement("section");
75
+ tilesetPromptsField.className = "ai-game-assets-designer__tile-prompts";
76
+ tilesetPromptsField.hidden = true;
77
+ const tilesetPromptsTitle = document.createElement("div");
78
+ tilesetPromptsTitle.className = "ai-game-assets-designer__tile-prompts-title";
79
+ tilesetPromptsTitle.textContent = "Tile prompts";
80
+ const tilesetPromptsHint = document.createElement("div");
81
+ tilesetPromptsHint.className = "ai-game-assets-designer__tile-prompts-hint";
82
+ tilesetPromptsHint.textContent = "Describe each tile in exact sheet order.";
83
+ const tilesetPromptsList = document.createElement("div");
84
+ tilesetPromptsList.className = "ai-game-assets-designer__tile-prompts-list";
85
+ tilesetPromptsField.append(tilesetPromptsTitle, tilesetPromptsHint, tilesetPromptsList);
72
86
  const widthInput = numericInput();
73
87
  const heightInput = numericInput();
74
88
  const frameCountInput = numericInput();
@@ -98,7 +112,9 @@ export function createDesignerElements(options, manifest, selectedAssetId) {
98
112
  }
99
113
  const dimensionGrid = document.createElement("div");
100
114
  dimensionGrid.className = "ai-game-assets-designer__dimensions";
101
- dimensionGrid.append(labelWrap("Width", widthInput), labelWrap("Height", heightInput));
115
+ const widthField = labelWrap("Width", widthInput);
116
+ const heightField = labelWrap("Height", heightInput);
117
+ dimensionGrid.append(widthField, heightField);
102
118
  const frameCountField = labelWrap("Frames", frameCountInput);
103
119
  const formatField = labelWrap("Format", formatSelect);
104
120
  const audioFormatField = labelWrap("Audio format", audioFormatSelect);
@@ -126,10 +142,19 @@ export function createDesignerElements(options, manifest, selectedAssetId) {
126
142
  currentTouchUpButton.className = "ai-game-assets-designer__animate-button";
127
143
  currentTouchUpButton.textContent = "Touch up...";
128
144
  currentTouchUpButton.hidden = true;
129
- currentPreview.append(currentImage, currentAudio, currentAnimation, currentAnimationButton, currentTouchUpButton);
145
+ const currentRevertButton = document.createElement("button");
146
+ currentRevertButton.type = "button";
147
+ currentRevertButton.className = "ai-game-assets-designer__animate-button";
148
+ currentRevertButton.textContent = "Revert preview";
149
+ currentRevertButton.hidden = true;
150
+ currentPreview.append(currentImage, currentAudio, currentAnimation, currentAnimationButton, currentTouchUpButton, currentRevertButton);
130
151
  const regenerateButton = document.createElement("button");
131
152
  regenerateButton.type = "button";
132
153
  regenerateButton.textContent = "Regenerate";
154
+ const mixTilesetButton = document.createElement("button");
155
+ mixTilesetButton.type = "button";
156
+ mixTilesetButton.textContent = "Mix tileset";
157
+ mixTilesetButton.hidden = true;
133
158
  const uploadButton = document.createElement("button");
134
159
  uploadButton.type = "button";
135
160
  uploadButton.textContent = "Upload...";
@@ -143,24 +168,29 @@ export function createDesignerElements(options, manifest, selectedAssetId) {
143
168
  promoteButton.type = "button";
144
169
  promoteButton.textContent = "Promote";
145
170
  promoteButton.disabled = true;
171
+ const promoteAllButton = document.createElement("button");
172
+ promoteAllButton.type = "button";
173
+ promoteAllButton.textContent = "Promote all";
174
+ promoteAllButton.disabled = true;
146
175
  const restartButton = document.createElement("button");
147
176
  restartButton.type = "button";
148
177
  restartButton.textContent = "Restart";
149
178
  const actions = document.createElement("div");
150
179
  actions.className = "ai-game-assets-designer__actions";
151
- actions.append(regenerateButton, uploadButton, deriveButton, versionsButton, promoteButton, restartButton);
180
+ actions.append(regenerateButton, mixTilesetButton, uploadButton, deriveButton, versionsButton, promoteButton, promoteAllButton, restartButton);
152
181
  const versionLabel = document.createElement("div");
153
182
  versionLabel.className = "ai-game-assets-designer__meta";
154
183
  const optionsGrid = document.createElement("div");
155
184
  optionsGrid.className = "ai-game-assets-designer__options";
156
185
  const status = document.createElement("div");
157
186
  status.className = "ai-game-assets-designer__status";
158
- panel.append(header, targetField, labelWrap("Asset", assetBrowser), targetVariantLabel, animationField, dimensionGrid, frameCountField, formatField, audioFormatField, audioDurationField, audioLoopField, voiceTextField, labelWrap("Current", currentPreview), labelWrap("Prompt", promptInput), actions, versionLabel, optionsGrid, status);
187
+ panel.append(header, targetField, labelWrap("Asset", assetBrowser), targetVariantLabel, animationField, dimensionGrid, frameCountField, formatField, audioFormatField, audioDurationField, audioLoopField, voiceTextField, labelWrap("Current", currentPreview), promptField, tilesetPromptsField, actions, versionLabel, optionsGrid, status);
159
188
  root.append(toggle, panel);
160
189
  return {
161
190
  root,
162
191
  toggle,
163
192
  panel,
193
+ title,
164
194
  styleButton,
165
195
  assetSelect,
166
196
  assetBrowser,
@@ -170,7 +200,9 @@ export function createDesignerElements(options, manifest, selectedAssetId) {
170
200
  animationSelect,
171
201
  animationField,
172
202
  widthInput,
203
+ widthField,
173
204
  heightInput,
205
+ heightField,
174
206
  dimensionGrid,
175
207
  frameCountInput,
176
208
  frameCountField,
@@ -185,17 +217,23 @@ export function createDesignerElements(options, manifest, selectedAssetId) {
185
217
  voiceTextInput,
186
218
  voiceTextField,
187
219
  promptInput,
220
+ promptField,
221
+ tilesetPromptsField,
222
+ tilesetPromptsList,
188
223
  currentImage,
189
224
  currentAudio,
190
225
  currentAnimation,
191
226
  currentAnimationButton,
227
+ currentRevertButton,
192
228
  currentTouchUpButton,
193
229
  currentPreview,
194
230
  uploadButton,
195
231
  deriveButton,
196
232
  regenerateButton,
233
+ mixTilesetButton,
197
234
  versionsButton,
198
235
  promoteButton,
236
+ promoteAllButton,
199
237
  restartButton,
200
238
  versionLabel,
201
239
  options: optionsGrid,
@@ -214,7 +252,7 @@ export function renderAssetFolderBrowser(options) {
214
252
  const storedPathValue = options.container.getAttribute("data-path");
215
253
  const storedPath = storedPathValue !== null
216
254
  ? storedPathValue.split("/").filter(Boolean)
217
- : selectedPath;
255
+ : [];
218
256
  const currentPath = pathHasAnyAsset(storedPath, assetPathEntries)
219
257
  ? storedPath
220
258
  : selectedPath;
@@ -390,7 +428,7 @@ export function renderOptions(options) {
390
428
  element: animationStage,
391
429
  src: option.dataUrl,
392
430
  asset: optionAsset,
393
- displaySize: resolvePreviewDisplaySize(options.designerOptions, options.assetId, optionAsset),
431
+ displaySize: { width: 68, height: 68 },
394
432
  applyFrameTransforms: false
395
433
  });
396
434
  });
@@ -399,6 +437,8 @@ export function renderOptions(options) {
399
437
  options.elements.options.append(card);
400
438
  }
401
439
  }
440
+ const pendingImagePreviews = new WeakMap();
441
+ let promotedTextureInstallId = 0;
402
442
  export function previewOption(options) {
403
443
  const textureKey = `ai-preview:${options.assetId}:${options.option.index}:${Date.now()}`;
404
444
  previewImageSource({
@@ -418,25 +458,80 @@ export function previewCurrentAsset(options) {
418
458
  textureKey
419
459
  });
420
460
  }
461
+ export function installPromotedImageTexture(options) {
462
+ const textureKey = aiTextureKey({ assetId: options.assetId });
463
+ promotedTextureInstallId += 1;
464
+ const requestKey = `promote:${options.assetId}:${promotedTextureInstallId}`;
465
+ return new Promise((resolve, reject) => {
466
+ previewImageSource({
467
+ ...options,
468
+ textureKey,
469
+ requestKey,
470
+ onPreview(assetId, installedTextureKey, asset) {
471
+ resolve({ assetId, textureKey: installedTextureKey, asset });
472
+ },
473
+ onError: reject
474
+ });
475
+ });
476
+ }
421
477
  export function previewImageSource(options) {
422
478
  const image = new Image();
479
+ const pendingForScene = pendingImagePreviews.get(options.scene) ?? new Map();
480
+ const requestKey = options.requestKey ?? options.assetId;
481
+ const previousImage = pendingForScene.get(requestKey);
482
+ if (previousImage) {
483
+ previousImage.onload = null;
484
+ previousImage.onerror = null;
485
+ }
486
+ pendingForScene.set(requestKey, image);
487
+ pendingImagePreviews.set(options.scene, pendingForScene);
488
+ if (shouldUseAnonymousCrossOrigin(options.src)) {
489
+ image.crossOrigin = "anonymous";
490
+ }
423
491
  image.onload = () => {
424
- if (options.scene.textures.exists(options.textureKey)) {
425
- options.scene.textures.remove(options.textureKey);
426
- }
427
- const asset = options.assetOverride ?? options.manifest.assets[options.assetId];
428
- if (asset.frameGrid && options.scene.textures.addSpriteSheet) {
429
- options.scene.textures.addSpriteSheet(options.textureKey, image, {
430
- frameWidth: asset.frameGrid.frameWidth,
431
- frameHeight: asset.frameGrid.frameHeight,
432
- margin: asset.frameGrid.margin,
433
- spacing: asset.frameGrid.spacing
434
- });
492
+ if (pendingForScene.get(requestKey) !== image)
493
+ return;
494
+ pendingForScene.delete(requestKey);
495
+ try {
496
+ if (options.scene.textures.exists(options.textureKey)) {
497
+ options.scene.textures.remove(options.textureKey);
498
+ }
499
+ const asset = options.assetOverride ?? options.manifest.assets[options.assetId];
500
+ const tileset = designerTilesetMetadata(asset);
501
+ if (tileset && options.scene.textures.addSpriteSheet) {
502
+ options.scene.textures.addSpriteSheet(options.textureKey, image, {
503
+ frameWidth: tileset.tileWidth,
504
+ frameHeight: tileset.tileHeight,
505
+ margin: tileset.margin,
506
+ spacing: tileset.spacing
507
+ });
508
+ }
509
+ else if (asset.frameGrid && options.scene.textures.addSpriteSheet) {
510
+ options.scene.textures.addSpriteSheet(options.textureKey, image, {
511
+ frameWidth: asset.frameGrid.frameWidth,
512
+ frameHeight: asset.frameGrid.frameHeight,
513
+ margin: asset.frameGrid.margin,
514
+ spacing: asset.frameGrid.spacing
515
+ });
516
+ }
517
+ else {
518
+ options.scene.textures.addImage(options.textureKey, image);
519
+ }
520
+ options.onPreview(options.assetId, options.textureKey, asset);
435
521
  }
436
- else {
437
- options.scene.textures.addImage(options.textureKey, image);
522
+ catch (error) {
523
+ const previewError = error instanceof Error ? error : new Error(String(error));
524
+ if (options.onError)
525
+ options.onError(previewError);
526
+ else
527
+ console.error("Could not preview AI asset image.", previewError);
438
528
  }
439
- options.onPreview(options.assetId, options.textureKey, asset);
529
+ };
530
+ image.onerror = () => {
531
+ if (pendingForScene.get(requestKey) === image) {
532
+ pendingForScene.delete(requestKey);
533
+ }
534
+ options.onError?.(new Error(`Could not load preview image for "${options.assetId}".`));
440
535
  };
441
536
  image.src = options.src;
442
537
  }
@@ -464,7 +559,9 @@ export async function uploadedOptionFromFile(options) {
464
559
  };
465
560
  }
466
561
  const imageSize = await imageSizeFromSource(dataUrl);
467
- const geometry = uploadedImageGeometry(options.asset, imageSize);
562
+ const format = normalizeAssetFormatFromMimeType(mimeType, options.file.name);
563
+ const generationGeometry = generationOverridesFromInputs(options.elements, options.asset, format);
564
+ const geometry = uploadedImageGeometry(options.asset, imageSize, generationGeometry.tileset);
468
565
  return {
469
566
  index: 0,
470
567
  dataUrl,
@@ -473,13 +570,38 @@ export async function uploadedOptionFromFile(options) {
473
570
  model: "uploaded",
474
571
  dimensions: geometry.dimensions,
475
572
  frameGrid: geometry.frameGrid,
573
+ tileset: geometry.tileset,
476
574
  settings: {
477
575
  ...options.asset.settings,
478
- format: normalizeAssetFormatFromMimeType(mimeType, options.file.name)
576
+ format
479
577
  }
480
578
  };
481
579
  }
482
- export function uploadedImageGeometry(asset, imageSize) {
580
+ export function uploadedImageGeometry(asset, imageSize, tilesetOverride) {
581
+ const baseTileset = designerTilesetMetadata(asset);
582
+ if (baseTileset) {
583
+ const tileset = {
584
+ ...baseTileset,
585
+ ...tilesetOverride
586
+ };
587
+ const capacity = tileset.columns * tileset.rows;
588
+ const tileCount = tileset.tileCount ?? capacity;
589
+ if (tileCount > capacity) {
590
+ throw new Error(`Tile count must not exceed this tileset's ${capacity}-tile capacity.`);
591
+ }
592
+ const margin = tileset.margin ?? 0;
593
+ const spacing = tileset.spacing ?? 0;
594
+ const dimensions = {
595
+ width: margin * 2 + tileset.columns * tileset.tileWidth +
596
+ Math.max(0, tileset.columns - 1) * spacing,
597
+ height: margin * 2 + tileset.rows * tileset.tileHeight +
598
+ Math.max(0, tileset.rows - 1) * spacing
599
+ };
600
+ if (imageSize.width !== dimensions.width || imageSize.height !== dimensions.height) {
601
+ throw new Error(`Tileset upload must be exactly ${dimensions.width}x${dimensions.height}px for the selected tile geometry; received ${imageSize.width}x${imageSize.height}px.`);
602
+ }
603
+ return { dimensions, tileset };
604
+ }
483
605
  if (!asset.frameGrid) {
484
606
  return {
485
607
  dimensions: imageSize
@@ -574,6 +696,14 @@ export async function spriteSheetFrameToDataUrl(options) {
574
696
  export function isSvgSource(src) {
575
697
  return src.startsWith("data:image/svg+xml") || /\.svg(?:$|\?)/i.test(src);
576
698
  }
699
+ const frameTouchUpPreferences = {
700
+ alpha: 255,
701
+ antiAlias: true,
702
+ brushSize: 4,
703
+ color: "#f8fafc",
704
+ tool: "brush"
705
+ };
706
+ let frameTouchUpClipboard;
577
707
  export async function openFrameTouchUpEditor(options) {
578
708
  const sourceImage = await loadImageElement(options.frameSrc);
579
709
  const dialog = document.createElement("div");
@@ -615,7 +745,7 @@ export async function openFrameTouchUpEditor(options) {
615
745
  brushSizeInput.min = "1";
616
746
  brushSizeInput.max = "32";
617
747
  brushSizeInput.step = "1";
618
- brushSizeInput.value = "4";
748
+ brushSizeInput.value = String(frameTouchUpPreferences.brushSize);
619
749
  const brushSizeValue = document.createElement("span");
620
750
  brushSizeValue.textContent = brushSizeInput.value;
621
751
  const brushSizeField = document.createElement("label");
@@ -623,7 +753,7 @@ export async function openFrameTouchUpEditor(options) {
623
753
  brushSizeField.append("Size", brushSizeInput, brushSizeValue);
624
754
  const antiAliasInput = document.createElement("input");
625
755
  antiAliasInput.type = "checkbox";
626
- antiAliasInput.checked = true;
756
+ antiAliasInput.checked = frameTouchUpPreferences.antiAlias;
627
757
  const antiAliasField = document.createElement("label");
628
758
  antiAliasField.className = "ai-game-assets-designer__inline-checkbox";
629
759
  antiAliasField.append(antiAliasInput, "Anti-aliased round tip");
@@ -632,6 +762,7 @@ export async function openFrameTouchUpEditor(options) {
632
762
  const eraserButton = touchUpButton("Eraser");
633
763
  const pickerButton = touchUpButton("Picker");
634
764
  const selectButton = touchUpButton("Select");
765
+ selectButton.title = "Select pixels. Cmd/Ctrl+C copies; Cmd/Ctrl+V pastes.";
635
766
  const fillButton = touchUpButton("Fill");
636
767
  const undoButton = touchUpButton("Undo");
637
768
  const redoButton = touchUpButton("Redo");
@@ -639,14 +770,14 @@ export async function openFrameTouchUpEditor(options) {
639
770
  saveButton.classList.add("is-primary");
640
771
  const colorInput = document.createElement("input");
641
772
  colorInput.type = "color";
642
- colorInput.value = "#f8fafc";
773
+ colorInput.value = frameTouchUpPreferences.color;
643
774
  colorInput.setAttribute("aria-label", "Brush color");
644
775
  const alphaInput = document.createElement("input");
645
776
  alphaInput.type = "number";
646
777
  alphaInput.min = "0";
647
778
  alphaInput.max = "255";
648
779
  alphaInput.step = "1";
649
- alphaInput.value = "255";
780
+ alphaInput.value = String(frameTouchUpPreferences.alpha);
650
781
  alphaInput.setAttribute("aria-label", "Alpha");
651
782
  const rgbaLabel = document.createElement("label");
652
783
  rgbaLabel.className = "ai-game-assets-designer__touchup-color";
@@ -665,6 +796,13 @@ export async function openFrameTouchUpEditor(options) {
665
796
  const selectionBox = document.createElement("div");
666
797
  selectionBox.className = "ai-game-assets-designer__touchup-selection";
667
798
  selectionBox.hidden = true;
799
+ const rotateSelectionButton = document.createElement("button");
800
+ rotateSelectionButton.type = "button";
801
+ rotateSelectionButton.className = "ai-game-assets-designer__touchup-selection-rotate";
802
+ rotateSelectionButton.textContent = "↻";
803
+ rotateSelectionButton.setAttribute("aria-label", "Rotate selection 90 degrees clockwise");
804
+ rotateSelectionButton.title = "Rotate selection 90° clockwise";
805
+ selectionBox.append(rotateSelectionButton);
668
806
  canvasWrap.append(canvas, selectionBox);
669
807
  const side = document.createElement("div");
670
808
  side.className = "ai-game-assets-designer__touchup-side";
@@ -698,11 +836,13 @@ export async function openFrameTouchUpEditor(options) {
698
836
  context.clearRect(0, 0, canvas.width, canvas.height);
699
837
  context.drawImage(sourceImage, 0, 0, canvas.width, canvas.height);
700
838
  let zoom = Math.max(1, Math.floor(Math.min(8, 480 / Math.max(canvas.width, canvas.height))));
701
- let tool = "brush";
839
+ let tool = frameTouchUpPreferences.tool;
702
840
  let dirty = false;
703
841
  let drawing = false;
842
+ let drawingPointerId;
704
843
  let selectionStart;
705
844
  let selection;
845
+ let floatingSelection;
706
846
  let animationTimeout;
707
847
  let animationCursor = 0;
708
848
  let disposed = false;
@@ -750,11 +890,73 @@ export async function openFrameTouchUpEditor(options) {
750
890
  }[tool];
751
891
  activeButton.classList.add("is-active");
752
892
  };
893
+ const selectTool = (nextTool) => {
894
+ tool = nextTool;
895
+ frameTouchUpPreferences.tool = nextTool;
896
+ updateToolButtons();
897
+ };
753
898
  const updateUndoRedo = () => {
754
899
  undoButton.disabled = undoStack.length === 0;
755
900
  redoButton.disabled = redoStack.length === 0;
756
901
  };
757
902
  const snapshot = () => context.getImageData(0, 0, canvas.width, canvas.height);
903
+ const clipboardImageData = () => {
904
+ if (!frameTouchUpClipboard)
905
+ return undefined;
906
+ const width = Math.min(frameTouchUpClipboard.width, canvas.width);
907
+ const height = Math.min(frameTouchUpClipboard.height, canvas.height);
908
+ const imageData = context.createImageData(width, height);
909
+ const sourceStride = frameTouchUpClipboard.width * 4;
910
+ const targetStride = width * 4;
911
+ for (let row = 0; row < height; row += 1) {
912
+ imageData.data.set(frameTouchUpClipboard.pixels.subarray(row * sourceStride, (row * sourceStride) + targetStride), row * targetStride);
913
+ }
914
+ return imageData;
915
+ };
916
+ const drawFloatingSelection = () => {
917
+ if (!selection || !floatingSelection)
918
+ return;
919
+ context.putImageData(floatingSelection.background, 0, 0);
920
+ const floatingCanvas = document.createElement("canvas");
921
+ floatingCanvas.width = floatingSelection.pixels.width;
922
+ floatingCanvas.height = floatingSelection.pixels.height;
923
+ const floatingContext = floatingCanvas.getContext("2d");
924
+ if (!floatingContext)
925
+ return;
926
+ floatingContext.putImageData(floatingSelection.pixels, 0, 0);
927
+ context.drawImage(floatingCanvas, selection.x, selection.y);
928
+ };
929
+ const rotateImageDataWithinBounds = (imageData) => {
930
+ const sourceCanvas = document.createElement("canvas");
931
+ sourceCanvas.width = imageData.width;
932
+ sourceCanvas.height = imageData.height;
933
+ const sourceContext = sourceCanvas.getContext("2d");
934
+ const targetCanvas = document.createElement("canvas");
935
+ targetCanvas.width = imageData.width;
936
+ targetCanvas.height = imageData.height;
937
+ const targetContext = targetCanvas.getContext("2d", { willReadFrequently: true });
938
+ if (!sourceContext || !targetContext)
939
+ return imageData;
940
+ sourceContext.putImageData(imageData, 0, 0);
941
+ targetContext.imageSmoothingEnabled = false;
942
+ targetContext.translate(imageData.width / 2, imageData.height / 2);
943
+ targetContext.scale(imageData.width / imageData.height, imageData.height / imageData.width);
944
+ targetContext.rotate(Math.PI / 2);
945
+ targetContext.drawImage(sourceCanvas, -imageData.width / 2, -imageData.height / 2);
946
+ targetContext.setTransform(1, 0, 0, 1, 0, 0);
947
+ return targetContext.getImageData(0, 0, imageData.width, imageData.height);
948
+ };
949
+ const copySelection = () => {
950
+ if (!selection)
951
+ return false;
952
+ const imageData = floatingSelection?.pixels ?? context.getImageData(selection.x, selection.y, selection.width, selection.height);
953
+ frameTouchUpClipboard = {
954
+ width: imageData.width,
955
+ height: imageData.height,
956
+ pixels: new Uint8ClampedArray(imageData.data)
957
+ };
958
+ return true;
959
+ };
758
960
  const pushUndo = () => {
759
961
  undoStack.push(snapshot());
760
962
  if (undoStack.length > 60)
@@ -765,9 +967,26 @@ export async function openFrameTouchUpEditor(options) {
765
967
  const restore = (imageData) => {
766
968
  context.putImageData(imageData, 0, 0);
767
969
  selection = undefined;
970
+ floatingSelection = undefined;
768
971
  redrawEditor();
769
972
  setDirty(true);
770
973
  };
974
+ const undo = () => {
975
+ const previous = undoStack.pop();
976
+ if (!previous)
977
+ return;
978
+ redoStack.push(snapshot());
979
+ restore(previous);
980
+ updateUndoRedo();
981
+ };
982
+ const redo = () => {
983
+ const next = redoStack.pop();
984
+ if (!next)
985
+ return;
986
+ undoStack.push(snapshot());
987
+ restore(next);
988
+ updateUndoRedo();
989
+ };
771
990
  const syncSelectionBox = () => {
772
991
  if (!selection) {
773
992
  selectionBox.hidden = true;
@@ -778,6 +997,12 @@ export async function openFrameTouchUpEditor(options) {
778
997
  selectionBox.style.top = `${selection.y * zoom}px`;
779
998
  selectionBox.style.width = `${selection.width * zoom}px`;
780
999
  selectionBox.style.height = `${selection.height * zoom}px`;
1000
+ const renderedWidth = selection.width * zoom;
1001
+ const renderedHeight = selection.height * zoom;
1002
+ rotateSelectionButton.hidden = renderedWidth < 12 || renderedHeight < 12;
1003
+ const handleSize = Math.max(0, Math.min(18, renderedWidth - 4, renderedHeight - 4));
1004
+ rotateSelectionButton.style.width = `${handleSize}px`;
1005
+ rotateSelectionButton.style.height = `${handleSize}px`;
781
1006
  };
782
1007
  const refreshPreviews = () => {
783
1008
  stillContext.clearRect(0, 0, stillPreview.width, stillPreview.height);
@@ -912,6 +1137,8 @@ export async function openFrameTouchUpEditor(options) {
912
1137
  const data = context.getImageData(point.x, point.y, 1, 1).data;
913
1138
  colorInput.value = `#${hexByte(data[0])}${hexByte(data[1])}${hexByte(data[2])}`;
914
1139
  alphaInput.value = String(data[3]);
1140
+ frameTouchUpPreferences.color = colorInput.value;
1141
+ frameTouchUpPreferences.alpha = data[3];
915
1142
  };
916
1143
  const fillAt = (point) => {
917
1144
  const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
@@ -959,6 +1186,13 @@ export async function openFrameTouchUpEditor(options) {
959
1186
  pushUndo();
960
1187
  const nextX = clamp(selection.x + deltaX, 0, canvas.width - selection.width);
961
1188
  const nextY = clamp(selection.y + deltaY, 0, canvas.height - selection.height);
1189
+ if (floatingSelection) {
1190
+ selection = { ...selection, x: nextX, y: nextY };
1191
+ drawFloatingSelection();
1192
+ setDirty(true);
1193
+ redrawEditor();
1194
+ return;
1195
+ }
962
1196
  const imageData = context.getImageData(selection.x, selection.y, selection.width, selection.height);
963
1197
  context.clearRect(selection.x, selection.y, selection.width, selection.height);
964
1198
  context.putImageData(imageData, nextX, nextY);
@@ -966,15 +1200,60 @@ export async function openFrameTouchUpEditor(options) {
966
1200
  setDirty(true);
967
1201
  redrawEditor();
968
1202
  };
1203
+ const rotateSelection = () => {
1204
+ if (!selection)
1205
+ return;
1206
+ pushUndo();
1207
+ const pixels = floatingSelection?.pixels ?? context.getImageData(selection.x, selection.y, selection.width, selection.height);
1208
+ const rotated = rotateImageDataWithinBounds(pixels);
1209
+ if (floatingSelection) {
1210
+ floatingSelection.pixels = rotated;
1211
+ drawFloatingSelection();
1212
+ }
1213
+ else {
1214
+ context.putImageData(rotated, selection.x, selection.y);
1215
+ }
1216
+ setDirty(true);
1217
+ redrawEditor();
1218
+ };
969
1219
  const deleteSelection = () => {
970
1220
  if (!selection)
971
1221
  return;
972
1222
  pushUndo();
973
- context.clearRect(selection.x, selection.y, selection.width, selection.height);
1223
+ if (floatingSelection) {
1224
+ context.putImageData(floatingSelection.background, 0, 0);
1225
+ floatingSelection = undefined;
1226
+ }
1227
+ else {
1228
+ context.clearRect(selection.x, selection.y, selection.width, selection.height);
1229
+ }
974
1230
  selection = undefined;
975
1231
  setDirty(true);
976
1232
  redrawEditor();
977
1233
  };
1234
+ const pasteSelection = () => {
1235
+ const pixels = clipboardImageData();
1236
+ if (!pixels)
1237
+ return false;
1238
+ const x = clamp(selection?.x ?? Math.floor((canvas.width - pixels.width) / 2), 0, canvas.width - pixels.width);
1239
+ const y = clamp(selection?.y ?? Math.floor((canvas.height - pixels.height) / 2), 0, canvas.height - pixels.height);
1240
+ pushUndo();
1241
+ floatingSelection = {
1242
+ background: snapshot(),
1243
+ pixels
1244
+ };
1245
+ selection = {
1246
+ x,
1247
+ y,
1248
+ width: pixels.width,
1249
+ height: pixels.height
1250
+ };
1251
+ selectTool("select");
1252
+ drawFloatingSelection();
1253
+ setDirty(true);
1254
+ redrawEditor();
1255
+ return true;
1256
+ };
978
1257
  const drawAnimationFrame = () => {
979
1258
  if (disposed)
980
1259
  return;
@@ -1016,12 +1295,18 @@ export async function openFrameTouchUpEditor(options) {
1016
1295
  brushMenu.hidden = true;
1017
1296
  };
1018
1297
  const close = () => {
1298
+ if (disposed)
1299
+ return;
1019
1300
  disposed = true;
1020
1301
  if (animationTimeout !== undefined)
1021
1302
  window.clearTimeout(animationTimeout);
1022
- window.removeEventListener("keydown", keyHandler);
1303
+ window.removeEventListener("keydown", keyHandler, true);
1304
+ window.removeEventListener("pointerup", finishPointer, true);
1305
+ window.removeEventListener("pointercancel", finishPointer, true);
1306
+ window.removeEventListener("blur", cancelDrawing);
1023
1307
  document.removeEventListener("pointerdown", closeBrushMenuOnOutsidePointer);
1024
1308
  dialog.remove();
1309
+ options.onClose?.();
1025
1310
  };
1026
1311
  const requestClose = () => {
1027
1312
  if (dirty && !window.confirm("You have unsaved changes. Close without saving?"))
@@ -1031,33 +1316,72 @@ export async function openFrameTouchUpEditor(options) {
1031
1316
  const keyHandler = (event) => {
1032
1317
  if (!dialog.isConnected)
1033
1318
  return;
1319
+ const key = event.key.toLowerCase();
1320
+ const commandModifier = event.metaKey || event.ctrlKey;
1321
+ const target = event.target;
1322
+ const isEditingInput = target instanceof HTMLInputElement ||
1323
+ target instanceof HTMLTextAreaElement ||
1324
+ target instanceof HTMLSelectElement ||
1325
+ (target instanceof HTMLElement && target.isContentEditable);
1326
+ if (isEditingInput && event.key !== "Escape")
1327
+ return;
1328
+ if (commandModifier && !event.altKey && key === "c" && copySelection()) {
1329
+ event.preventDefault();
1330
+ event.stopPropagation();
1331
+ return;
1332
+ }
1333
+ if (commandModifier && !event.altKey && key === "v" && pasteSelection()) {
1334
+ event.preventDefault();
1335
+ event.stopPropagation();
1336
+ return;
1337
+ }
1338
+ if (commandModifier && !event.altKey && (key === "z" || key === "y")) {
1339
+ event.preventDefault();
1340
+ event.stopPropagation();
1341
+ if (key === "y" || event.shiftKey) {
1342
+ redo();
1343
+ }
1344
+ else {
1345
+ undo();
1346
+ }
1347
+ return;
1348
+ }
1034
1349
  if (event.key === "Escape") {
1350
+ event.preventDefault();
1351
+ event.stopPropagation();
1035
1352
  requestClose();
1036
1353
  return;
1037
1354
  }
1038
1355
  if (event.key === "Backspace" || event.key === "Delete") {
1039
1356
  event.preventDefault();
1357
+ event.stopPropagation();
1040
1358
  deleteSelection();
1041
1359
  return;
1042
1360
  }
1043
1361
  if (event.key === "ArrowLeft") {
1044
1362
  event.preventDefault();
1363
+ event.stopPropagation();
1045
1364
  moveSelection(-1, 0);
1046
1365
  }
1047
1366
  else if (event.key === "ArrowRight") {
1048
1367
  event.preventDefault();
1368
+ event.stopPropagation();
1049
1369
  moveSelection(1, 0);
1050
1370
  }
1051
1371
  else if (event.key === "ArrowUp") {
1052
1372
  event.preventDefault();
1373
+ event.stopPropagation();
1053
1374
  moveSelection(0, -1);
1054
1375
  }
1055
1376
  else if (event.key === "ArrowDown") {
1056
1377
  event.preventDefault();
1378
+ event.stopPropagation();
1057
1379
  moveSelection(0, 1);
1058
1380
  }
1059
1381
  };
1060
1382
  canvas.addEventListener("pointerdown", (event) => {
1383
+ if (event.pointerType !== "touch" && event.button !== 0)
1384
+ return;
1061
1385
  if (event.pointerType === "touch") {
1062
1386
  activeTouchPointers.set(event.pointerId, {
1063
1387
  x: event.clientX,
@@ -1065,6 +1389,7 @@ export async function openFrameTouchUpEditor(options) {
1065
1389
  });
1066
1390
  if (activeTouchPointers.size >= 2) {
1067
1391
  drawing = false;
1392
+ drawingPointerId = undefined;
1068
1393
  selectionStart = undefined;
1069
1394
  pinchStartDistance = touchPointerDistance();
1070
1395
  pinchStartZoom = zoom;
@@ -1077,8 +1402,10 @@ export async function openFrameTouchUpEditor(options) {
1077
1402
  pickColorAt(point);
1078
1403
  return;
1079
1404
  }
1405
+ floatingSelection = undefined;
1080
1406
  pushUndo();
1081
1407
  drawing = true;
1408
+ drawingPointerId = event.pointerId;
1082
1409
  canvas.setPointerCapture(event.pointerId);
1083
1410
  if (tool === "select") {
1084
1411
  selectionStart = point;
@@ -1089,6 +1416,7 @@ export async function openFrameTouchUpEditor(options) {
1089
1416
  if (tool === "fill") {
1090
1417
  fillAt(point);
1091
1418
  drawing = false;
1419
+ drawingPointerId = undefined;
1092
1420
  setDirty(true);
1093
1421
  redrawEditor();
1094
1422
  return;
@@ -1111,8 +1439,12 @@ export async function openFrameTouchUpEditor(options) {
1111
1439
  return;
1112
1440
  }
1113
1441
  }
1114
- if (!drawing)
1442
+ if (!drawing || drawingPointerId !== event.pointerId)
1443
+ return;
1444
+ if (event.pointerType !== "touch" && (event.buttons & 1) === 0) {
1445
+ finishPointer(event);
1115
1446
  return;
1447
+ }
1116
1448
  const point = canvasPoint(event);
1117
1449
  if (tool === "select" && selectionStart) {
1118
1450
  const x = Math.min(selectionStart.x, point.x);
@@ -1132,7 +1464,7 @@ export async function openFrameTouchUpEditor(options) {
1132
1464
  redrawEditor();
1133
1465
  }
1134
1466
  });
1135
- const finishPointer = (event) => {
1467
+ function finishPointer(event) {
1136
1468
  if (event.pointerType === "touch") {
1137
1469
  activeTouchPointers.delete(event.pointerId);
1138
1470
  if (activeTouchPointers.size < 2) {
@@ -1143,15 +1475,29 @@ export async function openFrameTouchUpEditor(options) {
1143
1475
  pinchStartZoom = zoom;
1144
1476
  }
1145
1477
  }
1146
- drawing = false;
1147
- selectionStart = undefined;
1478
+ if (drawingPointerId === event.pointerId) {
1479
+ drawing = false;
1480
+ drawingPointerId = undefined;
1481
+ selectionStart = undefined;
1482
+ }
1148
1483
  if (canvas.hasPointerCapture(event.pointerId)) {
1149
1484
  canvas.releasePointerCapture(event.pointerId);
1150
1485
  }
1151
1486
  redrawEditor();
1152
- };
1487
+ }
1488
+ function cancelDrawing() {
1489
+ drawing = false;
1490
+ drawingPointerId = undefined;
1491
+ selectionStart = undefined;
1492
+ activeTouchPointers.clear();
1493
+ pinchStartDistance = undefined;
1494
+ }
1153
1495
  canvas.addEventListener("pointerup", finishPointer);
1154
1496
  canvas.addEventListener("pointercancel", finishPointer);
1497
+ canvas.addEventListener("lostpointercapture", finishPointer);
1498
+ window.addEventListener("pointerup", finishPointer, true);
1499
+ window.addEventListener("pointercancel", finishPointer, true);
1500
+ window.addEventListener("blur", cancelDrawing);
1155
1501
  canvasWrap.addEventListener("wheel", (event) => {
1156
1502
  if (!event.ctrlKey && !event.metaKey)
1157
1503
  return;
@@ -1162,6 +1508,15 @@ export async function openFrameTouchUpEditor(options) {
1162
1508
  clientY: event.clientY
1163
1509
  });
1164
1510
  }, { passive: false });
1511
+ rotateSelectionButton.addEventListener("pointerdown", (event) => {
1512
+ event.preventDefault();
1513
+ event.stopPropagation();
1514
+ });
1515
+ rotateSelectionButton.addEventListener("click", (event) => {
1516
+ event.preventDefault();
1517
+ event.stopPropagation();
1518
+ rotateSelection();
1519
+ });
1165
1520
  closeButton.addEventListener("click", requestClose);
1166
1521
  zoomOutButton.addEventListener("click", () => {
1167
1522
  setZoom(zoom - 1);
@@ -1170,54 +1525,45 @@ export async function openFrameTouchUpEditor(options) {
1170
1525
  setZoom(zoom + 1);
1171
1526
  });
1172
1527
  brushButton.addEventListener("click", () => {
1173
- tool = "brush";
1174
- updateToolButtons();
1528
+ selectTool("brush");
1175
1529
  });
1176
1530
  brushMenuButton.addEventListener("click", () => {
1177
1531
  brushMenu.hidden = !brushMenu.hidden;
1178
1532
  });
1179
1533
  brushSizeInput.addEventListener("input", () => {
1180
1534
  brushSizeValue.textContent = brushSizeInput.value;
1535
+ frameTouchUpPreferences.brushSize = brushSize();
1536
+ });
1537
+ antiAliasInput.addEventListener("change", () => {
1538
+ frameTouchUpPreferences.antiAlias = antiAliasInput.checked;
1539
+ });
1540
+ colorInput.addEventListener("input", () => {
1541
+ frameTouchUpPreferences.color = colorInput.value;
1542
+ });
1543
+ alphaInput.addEventListener("input", () => {
1544
+ frameTouchUpPreferences.alpha = clamp(Math.round(Number(alphaInput.value)), 0, 255);
1181
1545
  });
1182
1546
  document.addEventListener("pointerdown", closeBrushMenuOnOutsidePointer);
1183
1547
  eraserButton.addEventListener("click", () => {
1184
- tool = "eraser";
1185
- updateToolButtons();
1548
+ selectTool("eraser");
1186
1549
  });
1187
1550
  pickerButton.addEventListener("click", () => {
1188
- tool = "picker";
1189
- updateToolButtons();
1551
+ selectTool("picker");
1190
1552
  });
1191
1553
  selectButton.addEventListener("click", () => {
1192
- tool = "select";
1193
- updateToolButtons();
1554
+ selectTool("select");
1194
1555
  });
1195
1556
  fillButton.addEventListener("click", () => {
1196
- tool = "fill";
1197
- updateToolButtons();
1198
- });
1199
- undoButton.addEventListener("click", () => {
1200
- const previous = undoStack.pop();
1201
- if (!previous)
1202
- return;
1203
- redoStack.push(snapshot());
1204
- restore(previous);
1205
- updateUndoRedo();
1206
- });
1207
- redoButton.addEventListener("click", () => {
1208
- const next = redoStack.pop();
1209
- if (!next)
1210
- return;
1211
- undoStack.push(snapshot());
1212
- restore(next);
1213
- updateUndoRedo();
1557
+ selectTool("fill");
1214
1558
  });
1559
+ undoButton.addEventListener("click", undo);
1560
+ redoButton.addEventListener("click", redo);
1215
1561
  saveButton.addEventListener("click", async () => {
1216
1562
  await options.onSave(canvas.toDataURL("image/png"));
1217
1563
  setDirty(false);
1218
1564
  close();
1219
1565
  });
1220
- window.addEventListener("keydown", keyHandler);
1566
+ window.addEventListener("keydown", keyHandler, true);
1221
1567
  updateZoom();
1222
1568
  updateToolButtons();
1223
1569
  updateUndoRedo();
@@ -1358,6 +1704,14 @@ export function renderAudioPlayer(options) {
1358
1704
  state.frame = window.requestAnimationFrame(tick);
1359
1705
  }
1360
1706
  };
1707
+ const handleEnded = () => {
1708
+ if (!options.playback?.loop || state.disposed) {
1709
+ sync();
1710
+ return;
1711
+ }
1712
+ audio.currentTime = state.trimStart;
1713
+ void audio.play().then(() => tick()).catch(() => sync());
1714
+ };
1361
1715
  playButton.addEventListener("click", async () => {
1362
1716
  if (audio.paused) {
1363
1717
  pauseSiblingAudioPlayers(options.container);
@@ -1392,12 +1746,15 @@ export function renderAudioPlayer(options) {
1392
1746
  state.trimStart = clamp(options.playback?.trimStartSeconds ?? 0, 0, state.duration);
1393
1747
  state.trimEnd = clamp(options.playback?.trimEndSeconds ?? state.duration, state.trimStart, state.duration);
1394
1748
  trimLabel.hidden = state.trimStart === 0 && state.trimEnd === state.duration;
1395
- trimLabel.textContent = `Trim ${formatAudioTime(state.trimStart)} – ${formatAudioTime(state.trimEnd)}`;
1749
+ trimLabel.textContent = `Trim ${formatAudioTime(state.trimStart, true)} – ${formatAudioTime(state.trimEnd, true)}`;
1750
+ audio.loop = Boolean(options.playback?.loop) &&
1751
+ state.trimStart === 0 &&
1752
+ state.trimEnd >= state.duration - 0.05;
1396
1753
  audio.currentTime = state.trimStart;
1397
1754
  sync();
1398
1755
  });
1399
1756
  audio.addEventListener("timeupdate", sync);
1400
- audio.addEventListener("ended", sync);
1757
+ audio.addEventListener("ended", handleEnded);
1401
1758
  draw();
1402
1759
  void audioWaveformPeaks(options.src).then((peaks) => {
1403
1760
  if (state.disposed)
@@ -1515,13 +1872,17 @@ export function progressForAudio(audio) {
1515
1872
  export function clamp(value, min, max) {
1516
1873
  return Math.min(max, Math.max(min, value));
1517
1874
  }
1518
- export function formatAudioTime(value) {
1875
+ export function formatAudioTime(value, fractional = false) {
1519
1876
  if (!Number.isFinite(value) || value < 0) {
1520
1877
  return "0:00";
1521
1878
  }
1522
- const minutes = Math.floor(value / 60);
1523
- const seconds = Math.floor(value % 60);
1524
- return `${minutes}:${String(seconds).padStart(2, "0")}`;
1879
+ const precision = fractional ? 2 : 0;
1880
+ const multiplier = 10 ** precision;
1881
+ const roundedValue = Math.round(value * multiplier) / multiplier;
1882
+ const minutes = Math.floor(roundedValue / 60);
1883
+ const seconds = roundedValue % 60;
1884
+ const formattedSeconds = seconds.toFixed(precision).padStart(fractional ? 5 : 2, "0");
1885
+ return `${minutes}:${formattedSeconds}`;
1525
1886
  }
1526
1887
  export function startSpritesheetPreview(options) {
1527
1888
  const frameGrid = options.asset.frameGrid;
@@ -1657,9 +2018,12 @@ export async function openStyleGuideEditor(options) {
1657
2018
  const promoteButton = document.createElement("button");
1658
2019
  promoteButton.type = "button";
1659
2020
  promoteButton.textContent = "Promote style";
2021
+ const regenerateAllButton = document.createElement("button");
2022
+ regenerateAllButton.type = "button";
2023
+ regenerateAllButton.textContent = "Regenerate all...";
1660
2024
  const actions = document.createElement("div");
1661
2025
  actions.className = "ai-game-assets-designer__modal-actions";
1662
- actions.append(cancelButton, promoteButton, confirmButton);
2026
+ actions.append(cancelButton, promoteButton, regenerateAllButton, confirmButton);
1663
2027
  const draft = () => ({
1664
2028
  prompt: promptInput.value.trim(),
1665
2029
  images: images.map((image) => ({ ...image }))
@@ -1685,6 +2049,12 @@ export async function openStyleGuideEditor(options) {
1685
2049
  promoteButton.disabled = false;
1686
2050
  }
1687
2051
  });
2052
+ regenerateAllButton.addEventListener("click", () => {
2053
+ const current = draft();
2054
+ options.onConfirm(current);
2055
+ close();
2056
+ void options.onRegenerateAll(current);
2057
+ });
1688
2058
  card.append(title, promptField, dropZone, uploadButton, fileInput, imageList, actions);
1689
2059
  dialog.append(card);
1690
2060
  options.root.append(dialog);
@@ -2364,11 +2734,33 @@ export function setFrameBackground(element, options) {
2364
2734
  export function setFrameElementBackground(frameElement, options) {
2365
2735
  const column = options.frame % options.frameGrid.columns;
2366
2736
  const row = Math.floor(options.frame / options.frameGrid.columns);
2737
+ const margin = options.frameGrid.margin ?? 0;
2738
+ const spacing = options.frameGrid.spacing ?? 0;
2739
+ const frameWidth = options.frameGrid.frameWidth;
2740
+ const frameHeight = options.frameGrid.frameHeight;
2741
+ const scale = Math.min(options.displaySize.width / frameWidth, options.displaySize.height / frameHeight);
2742
+ const sheetWidth = (margin * 2) +
2743
+ (options.frameGrid.columns * frameWidth) +
2744
+ (Math.max(0, options.frameGrid.columns - 1) * spacing);
2745
+ const sheetHeight = (margin * 2) +
2746
+ (options.frameGrid.rows * frameHeight) +
2747
+ (Math.max(0, options.frameGrid.rows - 1) * spacing);
2748
+ const sourceX = margin + (column * (frameWidth + spacing));
2749
+ const sourceY = margin + (row * (frameHeight + spacing));
2750
+ const displayFrameWidth = frameWidth * scale;
2751
+ const displayFrameHeight = frameHeight * scale;
2752
+ const frameOffsetX = (options.displaySize.width - displayFrameWidth) / 2;
2753
+ const frameOffsetY = (options.displaySize.height - displayFrameHeight) / 2;
2754
+ frameElement.style.inset = "auto";
2755
+ frameElement.style.left = `${frameOffsetX}px`;
2756
+ frameElement.style.top = `${frameOffsetY}px`;
2757
+ frameElement.style.width = `${displayFrameWidth}px`;
2758
+ frameElement.style.height = `${displayFrameHeight}px`;
2367
2759
  frameElement.style.backgroundImage = `url("${cssUrl(options.src)}")`;
2368
2760
  frameElement.style.backgroundSize =
2369
- `${options.frameGrid.columns * options.displaySize.width}px ${options.frameGrid.rows * options.displaySize.height}px`;
2761
+ `${sheetWidth * scale}px ${sheetHeight * scale}px`;
2370
2762
  frameElement.style.backgroundPosition =
2371
- `${-(column * options.displaySize.width)}px ${-(row * options.displaySize.height)}px`;
2763
+ `${-(sourceX * scale)}px ${-(sourceY * scale)}px`;
2372
2764
  }
2373
2765
  export function ensureFramePreviewElement(element) {
2374
2766
  const existing = element.querySelector(":scope > .ai-game-assets-designer__frame-image");
@@ -2380,10 +2772,21 @@ export function ensureFramePreviewElement(element) {
2380
2772
  return frameElement;
2381
2773
  }
2382
2774
  export function generationOverridesFromInputs(elements, asset, format) {
2775
+ const tileset = designerTilesetMetadata(asset);
2383
2776
  const dimensions = {
2384
- width: positiveIntegerInput(elements.widthInput, asset.frameGrid?.frameWidth ?? asset.dimensions?.width ?? 1),
2385
- height: positiveIntegerInput(elements.heightInput, asset.frameGrid?.frameHeight ?? asset.dimensions?.height ?? 1)
2777
+ width: positiveIntegerInput(elements.widthInput, asset.frameGrid?.frameWidth ?? tileset?.tileWidth ?? asset.dimensions?.width ?? 1),
2778
+ height: positiveIntegerInput(elements.heightInput, asset.frameGrid?.frameHeight ?? tileset?.tileHeight ?? asset.dimensions?.height ?? 1)
2386
2779
  };
2780
+ if (tileset) {
2781
+ return {
2782
+ tileset: {
2783
+ tileWidth: dimensions.width,
2784
+ tileHeight: dimensions.height,
2785
+ tileCount: positiveIntegerInput(elements.frameCountInput, tileset.tileCount ?? tileset.columns * tileset.rows)
2786
+ },
2787
+ settings: { format }
2788
+ };
2789
+ }
2387
2790
  if (!asset.frameGrid) {
2388
2791
  return {
2389
2792
  dimensions,
@@ -2452,6 +2855,9 @@ export function isAudioAsset(asset) {
2452
2855
  asset?.kind === "voice" ||
2453
2856
  asset?.kind === "voice-line";
2454
2857
  }
2858
+ function designerTilesetMetadata(asset) {
2859
+ return asset?.tileset;
2860
+ }
2455
2861
  export function isVoiceAsset(asset) {
2456
2862
  return asset?.kind === "voice" || asset?.kind === "voice-line";
2457
2863
  }
@@ -2460,6 +2866,7 @@ export function assetWithGeneratedGeometry(asset, option, options = {}) {
2460
2866
  ...asset,
2461
2867
  dimensions: option.dimensions ?? asset.dimensions,
2462
2868
  frameGrid: option.frameGrid ?? asset.frameGrid,
2869
+ tileset: option.tileset ?? asset.tileset,
2463
2870
  animations: option.animations ??
2464
2871
  (options.inheritAnimations
2465
2872
  ? asset.animations
@@ -2481,7 +2888,11 @@ export function positiveIntegerInput(input, fallback) {
2481
2888
  const value = Number(input.value);
2482
2889
  if (!Number.isFinite(value))
2483
2890
  return fallback;
2484
- return Math.max(1, Math.floor(value));
2891
+ const normalized = Math.max(1, Math.floor(value));
2892
+ const maximum = Number(input.max);
2893
+ return input.max && Number.isFinite(maximum)
2894
+ ? Math.min(normalized, Math.max(1, Math.floor(maximum)))
2895
+ : normalized;
2485
2896
  }
2486
2897
  export function positiveNumberInput(input, fallback) {
2487
2898
  const value = Number(input.value);
@@ -2612,7 +3023,7 @@ export async function imageSourceToDataUrl(src) {
2612
3023
  return src;
2613
3024
  const response = await fetch(src);
2614
3025
  if (!response.ok) {
2615
- throw new Error(`Could not load current animation image (${response.status}).`);
3026
+ throw new Error(`Could not load current asset image (${response.status}).`);
2616
3027
  }
2617
3028
  const responseBlob = await response.blob();
2618
3029
  const blob = responseBlob.type === "application/octet-stream"
@@ -2625,11 +3036,11 @@ export async function imageSourceToDataUrl(src) {
2625
3036
  resolve(reader.result);
2626
3037
  }
2627
3038
  else {
2628
- reject(new Error("Could not convert current animation image to a data URL."));
3039
+ reject(new Error("Could not convert current asset image to a data URL."));
2629
3040
  }
2630
3041
  });
2631
3042
  reader.addEventListener("error", () => {
2632
- reject(reader.error ?? new Error("Could not read current animation image."));
3043
+ reject(reader.error ?? new Error("Could not read current asset image."));
2633
3044
  });
2634
3045
  reader.readAsDataURL(blob);
2635
3046
  });
@@ -2835,6 +3246,36 @@ export function ensureDesignerStyles() {
2835
3246
  font: inherit;
2836
3247
  }
2837
3248
  .ai-game-assets-designer__field textarea { resize: vertical; }
3249
+ .ai-game-assets-designer__tile-prompts {
3250
+ display: grid;
3251
+ gap: 7px;
3252
+ margin-bottom: 12px;
3253
+ padding: 10px;
3254
+ border: 1px solid #303949;
3255
+ border-radius: 8px;
3256
+ background: rgba(11, 15, 22, 0.58);
3257
+ }
3258
+ .ai-game-assets-designer__tile-prompts[hidden] { display: none; }
3259
+ .ai-game-assets-designer__tile-prompts-title {
3260
+ color: #e5edf9;
3261
+ font-size: 13px;
3262
+ font-weight: 700;
3263
+ }
3264
+ .ai-game-assets-designer__tile-prompts-hint {
3265
+ color: #8f9cb5;
3266
+ font-size: 11px;
3267
+ line-height: 1.35;
3268
+ }
3269
+ .ai-game-assets-designer__tile-prompts-list {
3270
+ display: grid;
3271
+ gap: 8px;
3272
+ }
3273
+ .ai-game-assets-designer__tile-prompts-list .ai-game-assets-designer__field {
3274
+ margin-bottom: 0;
3275
+ }
3276
+ .ai-game-assets-designer__tile-prompts-list textarea {
3277
+ min-height: 62px;
3278
+ }
2838
3279
  .ai-game-assets-designer__target-variant {
2839
3280
  margin: -4px 0 12px;
2840
3281
  padding: 7px 9px;
@@ -3059,6 +3500,17 @@ export function ensureDesignerStyles() {
3059
3500
  gap: 8px;
3060
3501
  margin-top: 10px;
3061
3502
  }
3503
+ .ai-game-assets-designer__tileset-tile-prompt {
3504
+ margin-top: 10px;
3505
+ padding: 9px 10px;
3506
+ border: 1px solid #354058;
3507
+ border-radius: 6px;
3508
+ background: rgba(37, 49, 73, 0.48);
3509
+ color: #d8e4f7;
3510
+ font-size: 12px;
3511
+ line-height: 1.45;
3512
+ white-space: pre-wrap;
3513
+ }
3062
3514
  .ai-game-assets-designer__options.is-audio {
3063
3515
  grid-template-columns: 1fr;
3064
3516
  }
@@ -3085,6 +3537,7 @@ export function ensureDesignerStyles() {
3085
3537
  background: transparent;
3086
3538
  display: grid;
3087
3539
  place-items: center;
3540
+ overflow: hidden;
3088
3541
  cursor: pointer;
3089
3542
  color: #dbeafe;
3090
3543
  font: inherit;
@@ -3105,12 +3558,18 @@ export function ensureDesignerStyles() {
3105
3558
  object-fit: contain;
3106
3559
  image-rendering: pixelated;
3107
3560
  }
3561
+ .ai-game-assets-designer__option-animation {
3562
+ width: 68px;
3563
+ height: 68px;
3564
+ max-width: 100%;
3565
+ }
3108
3566
  .ai-game-assets-designer__option .ai-game-assets-designer__animate-button {
3109
3567
  width: 100%;
3110
3568
  }
3111
3569
  .ai-game-assets-designer__modal {
3112
3570
  position: fixed;
3113
3571
  inset: 0;
3572
+ z-index: 2147483647;
3114
3573
  display: grid;
3115
3574
  place-items: center;
3116
3575
  padding: 18px;
@@ -3126,6 +3585,159 @@ export function ensureDesignerStyles() {
3126
3585
  box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
3127
3586
  padding: 14px;
3128
3587
  }
3588
+ .ai-game-assets-designer__tileset-mixer-card {
3589
+ width: min(980px, calc(100vw - 36px));
3590
+ }
3591
+ .ai-game-assets-designer__tileset-editor-card {
3592
+ width: min(720px, calc(100vw - 36px));
3593
+ }
3594
+ .ai-game-assets-designer__tileset-editor-stage {
3595
+ border: 1px solid #303a49;
3596
+ border-radius: 8px;
3597
+ }
3598
+ .ai-game-assets-designer__tileset-editor-strip {
3599
+ margin-top: 8px;
3600
+ }
3601
+ .ai-game-assets-designer__tileset-transform-fields {
3602
+ margin-bottom: 6px;
3603
+ }
3604
+ .ai-game-assets-designer__tileset-transform-hint {
3605
+ margin: 0 0 10px;
3606
+ color: #aeb8c8;
3607
+ font-size: 12px;
3608
+ line-height: 1.4;
3609
+ }
3610
+ .ai-game-assets-designer__tileset-transform-thumb {
3611
+ width: 48px;
3612
+ height: 48px;
3613
+ display: grid;
3614
+ place-items: center;
3615
+ }
3616
+ .ai-game-assets-designer__tileset-transform-thumb-viewport {
3617
+ position: relative;
3618
+ display: block;
3619
+ overflow: hidden;
3620
+ border-radius: 3px;
3621
+ pointer-events: none;
3622
+ }
3623
+ @media (max-width: 520px) {
3624
+ .ai-game-assets-designer__tileset-transform-fields {
3625
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3626
+ }
3627
+ }
3628
+ .ai-game-assets-designer__tileset-regenerate-card {
3629
+ width: min(620px, calc(100vw - 36px));
3630
+ }
3631
+ .ai-game-assets-designer__tileset-regenerate-card .ai-game-assets-designer__tileset-tile-prompt {
3632
+ margin: 0 0 12px;
3633
+ }
3634
+ .ai-game-assets-designer__tileset-mixer-hint {
3635
+ margin: -5px 0 12px;
3636
+ color: #aeb8c8;
3637
+ font-size: 12px;
3638
+ line-height: 1.4;
3639
+ }
3640
+ .ai-game-assets-designer__tileset-mixer-body {
3641
+ display: grid;
3642
+ grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
3643
+ gap: 14px;
3644
+ min-height: 300px;
3645
+ }
3646
+ .ai-game-assets-designer__tileset-navigator-panel,
3647
+ .ai-game-assets-designer__tileset-preview-panel {
3648
+ min-width: 0;
3649
+ border: 1px solid #303a49;
3650
+ border-radius: 8px;
3651
+ background: #0f1218;
3652
+ padding: 10px;
3653
+ }
3654
+ .ai-game-assets-designer__tileset-section-title {
3655
+ min-height: 20px;
3656
+ margin-bottom: 8px;
3657
+ color: #dbeafe;
3658
+ font-size: 12px;
3659
+ font-weight: 700;
3660
+ }
3661
+ .ai-game-assets-designer__tileset-navigator {
3662
+ display: grid;
3663
+ grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
3664
+ gap: 7px;
3665
+ max-height: min(58vh, 540px);
3666
+ overflow: auto;
3667
+ padding: 2px;
3668
+ }
3669
+ .ai-game-assets-designer__tileset-tile {
3670
+ position: relative;
3671
+ overflow: hidden;
3672
+ border: 2px solid #384251;
3673
+ border-radius: 6px;
3674
+ background: #111827;
3675
+ cursor: pointer;
3676
+ }
3677
+ .ai-game-assets-designer__tileset-tile::after {
3678
+ content: attr(data-source);
3679
+ position: absolute;
3680
+ right: 2px;
3681
+ bottom: 2px;
3682
+ z-index: 3;
3683
+ border-radius: 999px;
3684
+ background: rgba(8, 12, 20, 0.88);
3685
+ color: #9ca8ba;
3686
+ padding: 1px 4px;
3687
+ font-size: 9px;
3688
+ line-height: 1.4;
3689
+ }
3690
+ .ai-game-assets-designer__tileset-tile.is-selected {
3691
+ border-color: #6ed3ff;
3692
+ box-shadow: 0 0 0 1px rgba(110, 211, 255, 0.35);
3693
+ }
3694
+ .ai-game-assets-designer__tileset-tile.is-mixed::after {
3695
+ background: #155e75;
3696
+ color: #ecfeff;
3697
+ }
3698
+ .ai-game-assets-designer__tileset-choices {
3699
+ display: grid;
3700
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
3701
+ gap: 10px;
3702
+ align-items: start;
3703
+ width: 100%;
3704
+ min-width: 0;
3705
+ }
3706
+ .ai-game-assets-designer__tileset-choice {
3707
+ display: grid;
3708
+ justify-items: center;
3709
+ gap: 7px;
3710
+ min-width: 0;
3711
+ max-width: 100%;
3712
+ overflow: hidden;
3713
+ border: 2px solid #384251;
3714
+ border-radius: 8px;
3715
+ background: #151b25;
3716
+ color: #dbeafe;
3717
+ padding: 9px;
3718
+ font: inherit;
3719
+ font-size: 12px;
3720
+ cursor: pointer;
3721
+ }
3722
+ .ai-game-assets-designer__tileset-choice.is-selected {
3723
+ border-color: #6ed3ff;
3724
+ background: #16283a;
3725
+ }
3726
+ .ai-game-assets-designer__tileset-choice-preview {
3727
+ position: relative;
3728
+ overflow: hidden;
3729
+ max-width: 100%;
3730
+ border-radius: 6px;
3731
+ background: #0a0d12;
3732
+ }
3733
+ @media (max-width: 760px) {
3734
+ .ai-game-assets-designer__tileset-mixer-body {
3735
+ grid-template-columns: 1fr;
3736
+ }
3737
+ .ai-game-assets-designer__tileset-navigator {
3738
+ max-height: 180px;
3739
+ }
3740
+ }
3129
3741
  .ai-game-assets-designer__modal-title {
3130
3742
  margin-bottom: 12px;
3131
3743
  font-weight: 700;
@@ -3149,6 +3761,9 @@ export function ensureDesignerStyles() {
3149
3761
  .ai-game-assets-designer__modal-feedback[data-kind="error"] {
3150
3762
  color: #ffb4b4;
3151
3763
  }
3764
+ .ai-game-assets-designer__modal-feedback[data-kind="success"] {
3765
+ color: #86efac;
3766
+ }
3152
3767
  .ai-game-assets-designer__version-list {
3153
3768
  display: grid;
3154
3769
  gap: 10px;
@@ -3242,16 +3857,29 @@ export function ensureDesignerStyles() {
3242
3857
  }
3243
3858
  .ai-game-assets-designer__frame-thumb {
3244
3859
  flex: 0 0 auto;
3245
- border: 2px solid #384251;
3860
+ border: 0;
3246
3861
  border-radius: 6px;
3247
3862
  background-color: #0f1218;
3248
3863
  position: relative;
3249
3864
  overflow: hidden;
3250
3865
  cursor: pointer;
3251
3866
  }
3252
- .ai-game-assets-designer__frame-thumb.is-selected {
3867
+ .ai-game-assets-designer__frame-thumb::after {
3868
+ content: "";
3869
+ position: absolute;
3870
+ inset: 0;
3871
+ z-index: 2;
3872
+ border: 2px solid #384251;
3873
+ border-radius: inherit;
3874
+ pointer-events: none;
3875
+ box-sizing: border-box;
3876
+ }
3877
+ .ai-game-assets-designer__frame-thumb.is-selected::after {
3253
3878
  border-color: #6ed3ff;
3254
3879
  }
3880
+ .ai-game-assets-designer__frame-thumb.is-previewing::after {
3881
+ box-shadow: inset 0 -3px 0 #facc15;
3882
+ }
3255
3883
  .ai-game-assets-designer__frame-fields {
3256
3884
  display: grid;
3257
3885
  grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -3401,7 +4029,7 @@ export function ensureDesignerStyles() {
3401
4029
  .ai-game-assets-designer__touchup {
3402
4030
  position: fixed;
3403
4031
  inset: 0;
3404
- z-index: 1;
4032
+ z-index: 2147483647;
3405
4033
  display: grid;
3406
4034
  grid-template-rows: auto auto minmax(0, 1fr);
3407
4035
  gap: 10px;
@@ -3587,6 +4215,30 @@ export function ensureDesignerStyles() {
3587
4215
  box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.9);
3588
4216
  transform: translate(24px, 24px);
3589
4217
  }
4218
+ .ai-game-assets-designer__touchup-selection-rotate {
4219
+ position: absolute;
4220
+ top: 2px;
4221
+ right: 2px;
4222
+ z-index: 2;
4223
+ display: grid;
4224
+ place-items: center;
4225
+ min-width: 0;
4226
+ min-height: 0;
4227
+ border: 1px solid #dbeafe;
4228
+ border-radius: 999px;
4229
+ background: rgba(15, 23, 42, 0.94);
4230
+ color: #f8fafc;
4231
+ padding: 0;
4232
+ font: 700 11px/1 ui-sans-serif, system-ui, sans-serif;
4233
+ cursor: pointer;
4234
+ pointer-events: auto;
4235
+ }
4236
+ .ai-game-assets-designer__touchup-selection-rotate:hover,
4237
+ .ai-game-assets-designer__touchup-selection-rotate:focus-visible {
4238
+ border-color: #6ed3ff;
4239
+ color: #6ed3ff;
4240
+ outline: none;
4241
+ }
3590
4242
  .ai-game-assets-designer__touchup-side {
3591
4243
  min-height: 0;
3592
4244
  display: grid;