@ai-game-assets/phaser 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animations.d.ts +2 -0
- package/dist/animations.d.ts.map +1 -1
- package/dist/animations.js +4 -3
- package/dist/animations.js.map +1 -1
- package/dist/debug-client.d.ts +55 -2
- package/dist/debug-client.d.ts.map +1 -1
- package/dist/debug-client.js +77 -0
- package/dist/debug-client.js.map +1 -1
- package/dist/designer-support.d.ts +29 -3
- package/dist/designer-support.d.ts.map +1 -1
- package/dist/designer-support.js +557 -29
- package/dist/designer-support.js.map +1 -1
- package/dist/designer.d.ts +19 -2
- package/dist/designer.d.ts.map +1 -1
- package/dist/designer.js +1897 -143
- package/dist/designer.js.map +1 -1
- package/dist/first-drafts.d.ts.map +1 -1
- package/dist/first-drafts.js +68 -40
- package/dist/first-drafts.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +2 -0
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +6 -0
- package/dist/keys.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +50 -3
- package/dist/loader.js.map +1 -1
- package/dist/phaser-types.d.ts +1 -0
- package/dist/phaser-types.d.ts.map +1 -1
- package/dist/runtime.d.ts +30 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +235 -8
- package/dist/runtime.js.map +1 -1
- package/dist/tileset-dialog.d.ts +121 -0
- package/dist/tileset-dialog.d.ts.map +1 -0
- package/dist/tileset-dialog.js +1594 -0
- package/dist/tileset-dialog.js.map +1 -0
- package/dist/tilesets.d.ts +13 -0
- package/dist/tilesets.d.ts.map +1 -0
- package/dist/tilesets.js +60 -0
- package/dist/tilesets.js.map +1 -0
- package/dist/voice-line-regeneration.d.ts +47 -0
- package/dist/voice-line-regeneration.d.ts.map +1 -0
- package/dist/voice-line-regeneration.js +185 -0
- package/dist/voice-line-regeneration.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
import { assetWithGeneratedGeometry, decimalInput, integerInput, integerValue, labelWrap, loadImageElement, numberInput, numberValue, openFrameTouchUpEditor, positiveIntegerInput, positiveIntegerValue, readableAssetName, replaceSpriteSheetFrames, setFrameBackground, signedNumberInput } from "./designer-support.js";
|
|
2
|
+
const identityTilesetTileTransform = () => ({
|
|
3
|
+
offsetX: 0,
|
|
4
|
+
offsetY: 0,
|
|
5
|
+
scaleX: 1,
|
|
6
|
+
scaleY: 1
|
|
7
|
+
});
|
|
8
|
+
const minimumTilesetTileScale = 0.05;
|
|
9
|
+
export function normalizeTilesetTileTransform(transform) {
|
|
10
|
+
const scaleX = numberValue(transform?.scaleX, 1);
|
|
11
|
+
const scaleY = numberValue(transform?.scaleY, 1);
|
|
12
|
+
return {
|
|
13
|
+
offsetX: integerValue(transform?.offsetX, 0),
|
|
14
|
+
offsetY: integerValue(transform?.offsetY, 0),
|
|
15
|
+
scaleX: Math.max(minimumTilesetTileScale, scaleX),
|
|
16
|
+
scaleY: Math.max(minimumTilesetTileScale, scaleY)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the source, clipping, and destination rectangles used to render one
|
|
21
|
+
* transformed tile. Scaling is centered on the tile before its pixel offset is
|
|
22
|
+
* applied, and clipping always stays fixed to the tile cell.
|
|
23
|
+
*/
|
|
24
|
+
export function tilesetTileTransformDrawPlan(tileset, tile, transform) {
|
|
25
|
+
const source = tileRect(tileset, tile);
|
|
26
|
+
const normalized = normalizeTilesetTileTransform(transform);
|
|
27
|
+
const width = source.width * normalized.scaleX;
|
|
28
|
+
const height = source.height * normalized.scaleY;
|
|
29
|
+
return {
|
|
30
|
+
source,
|
|
31
|
+
clip: { ...source },
|
|
32
|
+
destination: {
|
|
33
|
+
x: source.x + ((source.width - width) / 2) + normalized.offsetX,
|
|
34
|
+
y: source.y + ((source.height - height) / 2) + normalized.offsetY,
|
|
35
|
+
width,
|
|
36
|
+
height
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function resolveTilesetBaseMixCurrent(asset, activeSheetSrc, currentOption) {
|
|
41
|
+
return currentOption ? {
|
|
42
|
+
asset: assetWithGeneratedGeometry(asset, currentOption),
|
|
43
|
+
sheetSrc: currentOption.dataUrl
|
|
44
|
+
} : {
|
|
45
|
+
asset,
|
|
46
|
+
sheetSrc: activeSheetSrc
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function tilesetTileGenerationOverride(tileset, tile) {
|
|
50
|
+
const prompt = tilesetTilePrompt(tileset, tile);
|
|
51
|
+
if (!prompt || tile < 0 || tile >= tilesetTileCount(tileset)) {
|
|
52
|
+
throw new Error(`Tile ${tile + 1} requires a prompt before it can be regenerated.`);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
tileWidth: tileset.tileWidth,
|
|
56
|
+
tileHeight: tileset.tileHeight,
|
|
57
|
+
columns: 1,
|
|
58
|
+
rows: 1,
|
|
59
|
+
tileCount: 1,
|
|
60
|
+
tiles: [{ prompt }]
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function createMixedTilesetOption(template, result, prompt, index = template.index) {
|
|
64
|
+
return {
|
|
65
|
+
...template,
|
|
66
|
+
index,
|
|
67
|
+
dataUrl: result.dataUrl,
|
|
68
|
+
mimeType: "image/png",
|
|
69
|
+
prompt,
|
|
70
|
+
revisedPrompt: undefined,
|
|
71
|
+
dimensions: result.dimensions,
|
|
72
|
+
tileset: result.tileset,
|
|
73
|
+
tilesetSourceDataUrl: result.dataUrl,
|
|
74
|
+
tilesetTransforms: Array.from({ length: tilesetTileCount(result.tileset) }, identityTilesetTileTransform),
|
|
75
|
+
settings: {
|
|
76
|
+
...template.settings,
|
|
77
|
+
format: "png"
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function isDesignerTilesetAsset(asset) {
|
|
82
|
+
return Boolean(asset?.kind === "tileset" && asset.tileset);
|
|
83
|
+
}
|
|
84
|
+
export function tilesetMetadataForAsset(asset) {
|
|
85
|
+
return asset?.tileset;
|
|
86
|
+
}
|
|
87
|
+
export function tilesetAnimationForKey(asset, animationKey) {
|
|
88
|
+
return tilesetMetadataForAsset(asset)?.animations?.find((animation) => (animation.key === animationKey));
|
|
89
|
+
}
|
|
90
|
+
export function tilesetTilePrompt(tileset, tile) {
|
|
91
|
+
const prompt = tileset?.tiles?.[tile]?.prompt?.trim();
|
|
92
|
+
return prompt || undefined;
|
|
93
|
+
}
|
|
94
|
+
export function tilesetAnimationWithFrameDelays(animation, frameDelays) {
|
|
95
|
+
if (frameDelays.length !== animation.frameCount) {
|
|
96
|
+
throw new Error(`Tileset animation "${animation.key}" requires ${animation.frameCount} frame delays, ` +
|
|
97
|
+
`received ${frameDelays.length}.`);
|
|
98
|
+
}
|
|
99
|
+
const fallbackDelayMs = Math.round(1000 / Math.max(1, animation.frameRate));
|
|
100
|
+
return {
|
|
101
|
+
...animation,
|
|
102
|
+
frameTimings: frameDelays.map((delayMs) => ({
|
|
103
|
+
delayMs: positiveIntegerValue(delayMs, fallbackDelayMs)
|
|
104
|
+
}))
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolves the generated base sheet geometry and the initial per-tile choices.
|
|
109
|
+
* Existing tile indexes keep the current tile by default. New indexes, which
|
|
110
|
+
* have no current tile to keep, default to the first generated option.
|
|
111
|
+
*/
|
|
112
|
+
export function planTilesetBaseMix(asset, candidates) {
|
|
113
|
+
const currentTileset = tilesetMetadataForAsset(asset);
|
|
114
|
+
if (!currentTileset) {
|
|
115
|
+
throw new Error(`AI asset "${asset.id}" is not a tileset.`);
|
|
116
|
+
}
|
|
117
|
+
if (candidates.length === 0) {
|
|
118
|
+
throw new Error("Tileset generation returned no candidates.");
|
|
119
|
+
}
|
|
120
|
+
const sortedCandidates = [...candidates].sort((left, right) => left.index - right.index);
|
|
121
|
+
assertUniqueCandidateIndexes(sortedCandidates);
|
|
122
|
+
const firstCandidate = sortedCandidates[0];
|
|
123
|
+
const targetTileset = firstCandidate.tileset ?? currentTileset;
|
|
124
|
+
const targetDimensions = normalizedTilesetDimensions(targetTileset, firstCandidate.dimensions, `Generated option ${firstCandidate.index + 1}`);
|
|
125
|
+
for (const candidate of sortedCandidates.slice(1)) {
|
|
126
|
+
const candidateTileset = candidate.tileset ?? currentTileset;
|
|
127
|
+
const candidateDimensions = normalizedTilesetDimensions(candidateTileset, candidate.dimensions, `Generated option ${candidate.index + 1}`);
|
|
128
|
+
if (!sameTilesetGrid(candidateTileset, targetTileset) ||
|
|
129
|
+
!sameDimensions(candidateDimensions, targetDimensions)) {
|
|
130
|
+
throw new Error("All generated tileset options must use the same tile grid and dimensions.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const currentTileCount = tilesetTileCount(currentTileset);
|
|
134
|
+
const targetTileCount = tilesetTileCount(targetTileset);
|
|
135
|
+
const selections = Array.from({ length: targetTileCount }, (_, tile) => tile < currentTileCount ? "base" : firstCandidate.index);
|
|
136
|
+
return {
|
|
137
|
+
dimensions: targetDimensions,
|
|
138
|
+
tileset: targetTileset,
|
|
139
|
+
selections
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export async function openTilesetEditor(options) {
|
|
143
|
+
const tileset = tilesetMetadataForAsset(options.asset);
|
|
144
|
+
if (!tileset)
|
|
145
|
+
return;
|
|
146
|
+
const tileCount = tilesetTileCount(tileset);
|
|
147
|
+
const sourceSrc = options.sourceSrc ?? options.src;
|
|
148
|
+
const initialTransforms = Array.from({ length: tileCount }, (_, tile) => normalizeTilesetTileTransform(options.initialTransforms?.[tile]));
|
|
149
|
+
const touchUpScale = Math.min(224 / tileset.tileWidth, 224 / tileset.tileHeight);
|
|
150
|
+
const touchUpDisplaySize = {
|
|
151
|
+
width: Math.max(1, Math.round(tileset.tileWidth * touchUpScale)),
|
|
152
|
+
height: Math.max(1, Math.round(tileset.tileHeight * touchUpScale))
|
|
153
|
+
};
|
|
154
|
+
const dialog = document.createElement("div");
|
|
155
|
+
dialog.className = "ai-game-assets-designer__modal ai-game-assets-designer__tileset-editor";
|
|
156
|
+
dialog.setAttribute("role", "dialog");
|
|
157
|
+
dialog.setAttribute("aria-modal", "true");
|
|
158
|
+
dialog.setAttribute("aria-label", `Edit ${readableAssetName(options.assetId)} tileset`);
|
|
159
|
+
const card = document.createElement("div");
|
|
160
|
+
card.className = "ai-game-assets-designer__modal-card ai-game-assets-designer__tileset-editor-card";
|
|
161
|
+
const title = document.createElement("div");
|
|
162
|
+
title.className = "ai-game-assets-designer__modal-title";
|
|
163
|
+
title.textContent = `Edit ${readableAssetName(options.assetId)} tileset`;
|
|
164
|
+
const stage = document.createElement("div");
|
|
165
|
+
stage.className = "ai-game-assets-designer__modal-stage ai-game-assets-designer__tileset-editor-stage";
|
|
166
|
+
const offsetXInput = signedNumberInput();
|
|
167
|
+
const offsetYInput = signedNumberInput();
|
|
168
|
+
const scaleXInput = decimalInput();
|
|
169
|
+
const scaleYInput = decimalInput();
|
|
170
|
+
scaleXInput.min = String(minimumTilesetTileScale);
|
|
171
|
+
scaleYInput.min = String(minimumTilesetTileScale);
|
|
172
|
+
scaleXInput.step = "0.05";
|
|
173
|
+
scaleYInput.step = "0.05";
|
|
174
|
+
const transformFields = document.createElement("div");
|
|
175
|
+
transformFields.className =
|
|
176
|
+
"ai-game-assets-designer__frame-fields ai-game-assets-designer__tileset-transform-fields";
|
|
177
|
+
transformFields.append(labelWrap("Offset X", offsetXInput), labelWrap("Offset Y", offsetYInput), labelWrap("Scale X", scaleXInput), labelWrap("Scale Y", scaleYInput));
|
|
178
|
+
const transformHint = document.createElement("div");
|
|
179
|
+
transformHint.id = "ai-game-assets-tileset-transform-hint";
|
|
180
|
+
transformHint.className = "ai-game-assets-designer__tileset-transform-hint";
|
|
181
|
+
transformHint.textContent =
|
|
182
|
+
"Offsets use tile pixels. Scaling is centered (1 = 100%). The tile cell clips overflow; " +
|
|
183
|
+
"source pixels stay intact while this editor is open.";
|
|
184
|
+
for (const input of [offsetXInput, offsetYInput, scaleXInput, scaleYInput]) {
|
|
185
|
+
input.setAttribute("aria-describedby", transformHint.id);
|
|
186
|
+
}
|
|
187
|
+
const prompt = document.createElement("div");
|
|
188
|
+
prompt.className = "ai-game-assets-designer__tileset-tile-prompt";
|
|
189
|
+
const strip = document.createElement("div");
|
|
190
|
+
strip.className = "ai-game-assets-designer__frame-strip ai-game-assets-designer__tileset-editor-strip";
|
|
191
|
+
const feedback = document.createElement("div");
|
|
192
|
+
feedback.className = "ai-game-assets-designer__modal-feedback";
|
|
193
|
+
feedback.hidden = true;
|
|
194
|
+
const touchUpButton = modalButton("Touch up...");
|
|
195
|
+
const regenerateButton = modalButton("Regenerate...");
|
|
196
|
+
const copyButton = modalButton("Copy");
|
|
197
|
+
const pasteButton = modalButton("Paste");
|
|
198
|
+
const cancelButton = modalButton("Cancel");
|
|
199
|
+
const confirmButton = modalButton("Confirm");
|
|
200
|
+
const actions = document.createElement("div");
|
|
201
|
+
actions.className = "ai-game-assets-designer__modal-actions";
|
|
202
|
+
actions.append(touchUpButton, regenerateButton, copyButton, pasteButton, cancelButton, confirmButton);
|
|
203
|
+
card.append(title, stage, prompt, transformFields, transformHint, strip, feedback, actions);
|
|
204
|
+
dialog.append(card);
|
|
205
|
+
options.root.append(dialog);
|
|
206
|
+
let selectedTile = 0;
|
|
207
|
+
const tileDrafts = Array.from({ length: tileCount }, (_, tile) => ({
|
|
208
|
+
source: {
|
|
209
|
+
src: sourceSrc,
|
|
210
|
+
tile,
|
|
211
|
+
tileset
|
|
212
|
+
},
|
|
213
|
+
transform: { ...initialTransforms[tile] }
|
|
214
|
+
}));
|
|
215
|
+
let copiedTile;
|
|
216
|
+
let busy = false;
|
|
217
|
+
let childModalOpen = false;
|
|
218
|
+
let closed = false;
|
|
219
|
+
const tileButtons = [];
|
|
220
|
+
const tilePreviewElements = [];
|
|
221
|
+
const setFeedback = (message, kind = "idle") => {
|
|
222
|
+
feedback.textContent = message;
|
|
223
|
+
feedback.dataset.kind = kind;
|
|
224
|
+
feedback.hidden = message.length === 0;
|
|
225
|
+
};
|
|
226
|
+
const syncButtons = () => {
|
|
227
|
+
touchUpButton.disabled = busy;
|
|
228
|
+
regenerateButton.disabled = busy || !tilesetTilePrompt(tileset, selectedTile);
|
|
229
|
+
copyButton.disabled = busy;
|
|
230
|
+
pasteButton.disabled = busy || !copiedTile;
|
|
231
|
+
cancelButton.disabled = busy;
|
|
232
|
+
confirmButton.disabled = busy;
|
|
233
|
+
offsetXInput.disabled = busy;
|
|
234
|
+
offsetYInput.disabled = busy;
|
|
235
|
+
scaleXInput.disabled = busy;
|
|
236
|
+
scaleYInput.disabled = busy;
|
|
237
|
+
tileButtons.forEach((button) => {
|
|
238
|
+
button.disabled = busy;
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
const previewTiming = (transform, displaySize) => {
|
|
242
|
+
const displayScale = Math.min(displaySize.width / tileset.tileWidth, displaySize.height / tileset.tileHeight);
|
|
243
|
+
return {
|
|
244
|
+
offsetX: transform.offsetX * displayScale,
|
|
245
|
+
offsetY: transform.offsetY * displayScale,
|
|
246
|
+
scaleX: transform.scaleX,
|
|
247
|
+
scaleY: transform.scaleY
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
const thumbnailScale = Math.min(48 / tileset.tileWidth, 48 / tileset.tileHeight);
|
|
251
|
+
const thumbnailDisplaySize = {
|
|
252
|
+
width: Math.max(1, Math.round(tileset.tileWidth * thumbnailScale)),
|
|
253
|
+
height: Math.max(1, Math.round(tileset.tileHeight * thumbnailScale))
|
|
254
|
+
};
|
|
255
|
+
const renderSelectedPreview = () => {
|
|
256
|
+
const selectedDraft = tileDrafts[selectedTile];
|
|
257
|
+
setFrameBackground(stage, {
|
|
258
|
+
src: selectedDraft.source.src,
|
|
259
|
+
frame: selectedDraft.source.tile,
|
|
260
|
+
frameGrid: tilesetFrameGrid(selectedDraft.source.tileset),
|
|
261
|
+
displaySize: touchUpDisplaySize,
|
|
262
|
+
timing: previewTiming(selectedDraft.transform, touchUpDisplaySize)
|
|
263
|
+
});
|
|
264
|
+
const selectedThumbnail = tilePreviewElements[selectedTile];
|
|
265
|
+
if (selectedThumbnail) {
|
|
266
|
+
setFrameBackground(selectedThumbnail, {
|
|
267
|
+
src: selectedDraft.source.src,
|
|
268
|
+
frame: selectedDraft.source.tile,
|
|
269
|
+
frameGrid: tilesetFrameGrid(selectedDraft.source.tileset),
|
|
270
|
+
displaySize: thumbnailDisplaySize,
|
|
271
|
+
timing: previewTiming(selectedDraft.transform, thumbnailDisplaySize)
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const renderPreviews = () => {
|
|
276
|
+
renderSelectedPreview();
|
|
277
|
+
tileButtons.forEach((button, tile) => {
|
|
278
|
+
const draft = tileDrafts[tile];
|
|
279
|
+
button.classList.toggle("is-selected", tile === selectedTile);
|
|
280
|
+
button.setAttribute("aria-pressed", String(tile === selectedTile));
|
|
281
|
+
setFrameBackground(tilePreviewElements[tile], {
|
|
282
|
+
src: draft.source.src,
|
|
283
|
+
frame: draft.source.tile,
|
|
284
|
+
frameGrid: tilesetFrameGrid(draft.source.tileset),
|
|
285
|
+
displaySize: thumbnailDisplaySize,
|
|
286
|
+
timing: previewTiming(draft.transform, thumbnailDisplaySize)
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
const syncTransformInputs = () => {
|
|
291
|
+
const transform = tileDrafts[selectedTile].transform;
|
|
292
|
+
offsetXInput.value = String(transform.offsetX);
|
|
293
|
+
offsetYInput.value = String(transform.offsetY);
|
|
294
|
+
scaleXInput.value = String(transform.scaleX);
|
|
295
|
+
scaleYInput.value = String(transform.scaleY);
|
|
296
|
+
};
|
|
297
|
+
const render = () => {
|
|
298
|
+
renderPreviews();
|
|
299
|
+
syncTransformInputs();
|
|
300
|
+
const tilePrompt = tilesetTilePrompt(tileset, selectedTile);
|
|
301
|
+
prompt.textContent = tilePrompt
|
|
302
|
+
? `Tile ${selectedTile + 1}: ${tilePrompt}`
|
|
303
|
+
: `Tile ${selectedTile + 1}`;
|
|
304
|
+
syncButtons();
|
|
305
|
+
};
|
|
306
|
+
const replaceTileSource = (tile, source, transform) => {
|
|
307
|
+
if (source.tileset.tileWidth !== tileset.tileWidth ||
|
|
308
|
+
source.tileset.tileHeight !== tileset.tileHeight) {
|
|
309
|
+
throw new Error(`Tile ${tile + 1} source must use ${tileset.tileWidth}x${tileset.tileHeight}px tiles.`);
|
|
310
|
+
}
|
|
311
|
+
if (source.tile < 0 || source.tile >= tilesetTileCount(source.tileset)) {
|
|
312
|
+
throw new Error(`Tile ${tile + 1} does not have a valid source tile.`);
|
|
313
|
+
}
|
|
314
|
+
tileDrafts[tile] = {
|
|
315
|
+
source: { ...source },
|
|
316
|
+
transform: transform ? { ...transform } : tileDrafts[tile].transform
|
|
317
|
+
};
|
|
318
|
+
selectedTile = tile;
|
|
319
|
+
render();
|
|
320
|
+
};
|
|
321
|
+
const close = () => {
|
|
322
|
+
if (closed)
|
|
323
|
+
return;
|
|
324
|
+
closed = true;
|
|
325
|
+
window.removeEventListener("keydown", keyHandler, true);
|
|
326
|
+
dialog.remove();
|
|
327
|
+
};
|
|
328
|
+
const keyHandler = (event) => {
|
|
329
|
+
if (event.key !== "Escape" || busy || childModalOpen)
|
|
330
|
+
return;
|
|
331
|
+
event.preventDefault();
|
|
332
|
+
close();
|
|
333
|
+
};
|
|
334
|
+
for (let tile = 0; tile < tileCount; tile += 1) {
|
|
335
|
+
const button = document.createElement("button");
|
|
336
|
+
button.type = "button";
|
|
337
|
+
button.className =
|
|
338
|
+
"ai-game-assets-designer__frame-thumb ai-game-assets-designer__tileset-transform-thumb";
|
|
339
|
+
button.setAttribute("aria-label", `Tile ${tile + 1}`);
|
|
340
|
+
const viewport = document.createElement("span");
|
|
341
|
+
viewport.className = "ai-game-assets-designer__tileset-transform-thumb-viewport";
|
|
342
|
+
button.append(viewport);
|
|
343
|
+
button.addEventListener("click", () => {
|
|
344
|
+
selectedTile = tile;
|
|
345
|
+
setFeedback("");
|
|
346
|
+
render();
|
|
347
|
+
});
|
|
348
|
+
tileButtons.push(button);
|
|
349
|
+
tilePreviewElements.push(viewport);
|
|
350
|
+
strip.append(button);
|
|
351
|
+
}
|
|
352
|
+
const updateSelectedTransform = () => {
|
|
353
|
+
const current = tileDrafts[selectedTile].transform;
|
|
354
|
+
tileDrafts[selectedTile].transform = normalizeTilesetTileTransform({
|
|
355
|
+
offsetX: integerInput(offsetXInput, current.offsetX),
|
|
356
|
+
offsetY: integerInput(offsetYInput, current.offsetY),
|
|
357
|
+
scaleX: numberInput(scaleXInput, current.scaleX),
|
|
358
|
+
scaleY: numberInput(scaleYInput, current.scaleY)
|
|
359
|
+
});
|
|
360
|
+
renderSelectedPreview();
|
|
361
|
+
};
|
|
362
|
+
offsetXInput.addEventListener("input", updateSelectedTransform);
|
|
363
|
+
offsetYInput.addEventListener("input", updateSelectedTransform);
|
|
364
|
+
scaleXInput.addEventListener("input", updateSelectedTransform);
|
|
365
|
+
scaleYInput.addEventListener("input", updateSelectedTransform);
|
|
366
|
+
const commitSelectedTransform = () => {
|
|
367
|
+
updateSelectedTransform();
|
|
368
|
+
syncTransformInputs();
|
|
369
|
+
};
|
|
370
|
+
offsetXInput.addEventListener("change", commitSelectedTransform);
|
|
371
|
+
offsetYInput.addEventListener("change", commitSelectedTransform);
|
|
372
|
+
scaleXInput.addEventListener("change", commitSelectedTransform);
|
|
373
|
+
scaleYInput.addEventListener("change", commitSelectedTransform);
|
|
374
|
+
offsetXInput.addEventListener("blur", commitSelectedTransform);
|
|
375
|
+
offsetYInput.addEventListener("blur", commitSelectedTransform);
|
|
376
|
+
scaleXInput.addEventListener("blur", commitSelectedTransform);
|
|
377
|
+
scaleYInput.addEventListener("blur", commitSelectedTransform);
|
|
378
|
+
touchUpButton.addEventListener("click", async () => {
|
|
379
|
+
if (busy)
|
|
380
|
+
return;
|
|
381
|
+
const tile = selectedTile;
|
|
382
|
+
busy = true;
|
|
383
|
+
syncButtons();
|
|
384
|
+
setFeedback(`Opening touch-up for tile ${tile + 1}...`, "busy");
|
|
385
|
+
try {
|
|
386
|
+
const draft = tileDrafts[tile];
|
|
387
|
+
const tileSrc = await extractTilesetTileDataUrl(draft.source.src, draft.source.tileset, draft.source.tile);
|
|
388
|
+
childModalOpen = true;
|
|
389
|
+
await openFrameTouchUpEditor({
|
|
390
|
+
root: options.root,
|
|
391
|
+
asset: options.asset,
|
|
392
|
+
title: `${readableAssetName(options.assetId)} tile ${tile + 1}`,
|
|
393
|
+
frameSrc: tileSrc,
|
|
394
|
+
displaySize: touchUpDisplaySize,
|
|
395
|
+
onSave: async (editedTileSrc) => {
|
|
396
|
+
if (closed)
|
|
397
|
+
return;
|
|
398
|
+
replaceTileSource(tile, {
|
|
399
|
+
src: editedTileSrc,
|
|
400
|
+
tile: 0,
|
|
401
|
+
tileset: singleTileTilesetMetadata(tileset, tile)
|
|
402
|
+
});
|
|
403
|
+
setFeedback(`Touched up tile ${tile + 1}.`, "success");
|
|
404
|
+
},
|
|
405
|
+
onClose: () => {
|
|
406
|
+
childModalOpen = false;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
setFeedback("");
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
childModalOpen = false;
|
|
413
|
+
setFeedback(`Could not open tile touch-up: ${tilesetErrorMessage(error)}`, "error");
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
busy = false;
|
|
417
|
+
syncButtons();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
regenerateButton.addEventListener("click", async () => {
|
|
421
|
+
if (busy || regenerateButton.disabled)
|
|
422
|
+
return;
|
|
423
|
+
const tile = selectedTile;
|
|
424
|
+
busy = true;
|
|
425
|
+
syncButtons();
|
|
426
|
+
setFeedback(`Generating 3 new options for tile ${tile + 1}...`, "busy");
|
|
427
|
+
try {
|
|
428
|
+
const draft = tileDrafts[tile];
|
|
429
|
+
const currentTileSrc = await extractTilesetTileDataUrl(draft.source.src, draft.source.tileset, draft.source.tile);
|
|
430
|
+
const generated = [...await options.regenerateTile(tile, tilesetTileGenerationOverride(tileset, tile), currentTileSrc)].sort((left, right) => left.index - right.index);
|
|
431
|
+
if (generated.length !== 3) {
|
|
432
|
+
throw new Error(`Expected 3 regenerated tile options, received ${generated.length}.`);
|
|
433
|
+
}
|
|
434
|
+
assertUniqueCandidateIndexes(generated);
|
|
435
|
+
const selected = await openTilesetTileChoiceDialog({
|
|
436
|
+
root: options.root,
|
|
437
|
+
assetId: options.assetId,
|
|
438
|
+
tile,
|
|
439
|
+
prompt: tilesetTilePrompt(tileset, tile),
|
|
440
|
+
candidates: generated
|
|
441
|
+
});
|
|
442
|
+
if (selected) {
|
|
443
|
+
replaceTileSource(tile, {
|
|
444
|
+
src: selected.dataUrl,
|
|
445
|
+
tile: 0,
|
|
446
|
+
tileset: selected.tileset ?? singleTileTilesetMetadata(tileset, tile)
|
|
447
|
+
});
|
|
448
|
+
setFeedback(`Replaced tile ${tile + 1}.`, "success");
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
setFeedback(`Kept tile ${tile + 1}.`, "idle");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
setFeedback(`Could not regenerate tile: ${tilesetErrorMessage(error)}`, "error");
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
busy = false;
|
|
459
|
+
syncButtons();
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
copyButton.addEventListener("click", () => {
|
|
463
|
+
if (busy)
|
|
464
|
+
return;
|
|
465
|
+
const draft = tileDrafts[selectedTile];
|
|
466
|
+
copiedTile = {
|
|
467
|
+
source: { ...draft.source },
|
|
468
|
+
transform: { ...draft.transform }
|
|
469
|
+
};
|
|
470
|
+
setFeedback(`Copied tile ${selectedTile + 1}. Select another tile and paste.`, "success");
|
|
471
|
+
syncButtons();
|
|
472
|
+
});
|
|
473
|
+
pasteButton.addEventListener("click", () => {
|
|
474
|
+
if (busy || !copiedTile)
|
|
475
|
+
return;
|
|
476
|
+
const tile = selectedTile;
|
|
477
|
+
replaceTileSource(tile, copiedTile.source, copiedTile.transform);
|
|
478
|
+
setFeedback(`Pasted into tile ${tile + 1}.`, "success");
|
|
479
|
+
});
|
|
480
|
+
cancelButton.addEventListener("click", close);
|
|
481
|
+
confirmButton.addEventListener("click", async () => {
|
|
482
|
+
if (busy)
|
|
483
|
+
return;
|
|
484
|
+
if (!tilesetEditorHasChanges(tileDrafts, sourceSrc, tileset, initialTransforms)) {
|
|
485
|
+
close();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
busy = true;
|
|
489
|
+
syncButtons();
|
|
490
|
+
setFeedback("Applying tileset edits...", "busy");
|
|
491
|
+
try {
|
|
492
|
+
const result = await composeTilesetEditorSheets({
|
|
493
|
+
originalSheetSrc: sourceSrc,
|
|
494
|
+
tileset,
|
|
495
|
+
tileDrafts
|
|
496
|
+
});
|
|
497
|
+
await options.onConfirm(result);
|
|
498
|
+
close();
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
setFeedback(`Could not apply tileset edits: ${tilesetErrorMessage(error)}`, "error");
|
|
502
|
+
busy = false;
|
|
503
|
+
syncButtons();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
window.addEventListener("keydown", keyHandler, true);
|
|
507
|
+
render();
|
|
508
|
+
tileButtons[0]?.focus();
|
|
509
|
+
}
|
|
510
|
+
export async function openTilesetAnimationEditor(options) {
|
|
511
|
+
const tileset = tilesetMetadataForAsset(options.asset);
|
|
512
|
+
const animation = tilesetAnimationForKey(options.asset, options.animationKey);
|
|
513
|
+
if (!tileset || !animation)
|
|
514
|
+
return;
|
|
515
|
+
if (options.frames.length !== animation.frameCount) {
|
|
516
|
+
throw new Error(`${readableAssetName(animation.key)} requires ${animation.frameCount} frames, ` +
|
|
517
|
+
`but ${options.frames.length} are available.`);
|
|
518
|
+
}
|
|
519
|
+
const tileCount = tilesetTileCount(tileset);
|
|
520
|
+
const frameGrid = tilesetFrameGrid(tileset);
|
|
521
|
+
const touchUpScale = Math.min(224 / tileset.tileWidth, 224 / tileset.tileHeight);
|
|
522
|
+
const touchUpDisplaySize = {
|
|
523
|
+
width: Math.max(1, Math.round(tileset.tileWidth * touchUpScale)),
|
|
524
|
+
height: Math.max(1, Math.round(tileset.tileHeight * touchUpScale))
|
|
525
|
+
};
|
|
526
|
+
const defaultDelayMs = Math.round(1000 / Math.max(1, animation.frameRate));
|
|
527
|
+
const frameDelays = Array.from({ length: animation.frameCount }, (_, frameSlot) => positiveIntegerValue(animation.frameTimings?.[frameSlot]?.delayMs, defaultDelayMs));
|
|
528
|
+
const frames = [...options.frames];
|
|
529
|
+
const dialog = document.createElement("div");
|
|
530
|
+
dialog.className = "ai-game-assets-designer__modal ai-game-assets-designer__tileset-animation-editor";
|
|
531
|
+
dialog.setAttribute("role", "dialog");
|
|
532
|
+
dialog.setAttribute("aria-modal", "true");
|
|
533
|
+
dialog.setAttribute("aria-label", `Edit ${readableAssetName(options.assetId)} ${readableAssetName(animation.key)} animation`);
|
|
534
|
+
const card = document.createElement("div");
|
|
535
|
+
card.className = "ai-game-assets-designer__modal-card ai-game-assets-designer__tileset-mixer-card";
|
|
536
|
+
const title = document.createElement("div");
|
|
537
|
+
title.className = "ai-game-assets-designer__modal-title";
|
|
538
|
+
title.textContent =
|
|
539
|
+
`Edit ${readableAssetName(options.assetId)} · ${readableAssetName(animation.key)}`;
|
|
540
|
+
const body = document.createElement("div");
|
|
541
|
+
body.className = "ai-game-assets-designer__tileset-mixer-body";
|
|
542
|
+
const navigatorPanel = document.createElement("section");
|
|
543
|
+
navigatorPanel.className = "ai-game-assets-designer__tileset-navigator-panel";
|
|
544
|
+
const navigatorTitle = document.createElement("div");
|
|
545
|
+
navigatorTitle.className = "ai-game-assets-designer__tileset-section-title";
|
|
546
|
+
navigatorTitle.textContent = "Tiles";
|
|
547
|
+
const navigator = document.createElement("div");
|
|
548
|
+
navigator.className = "ai-game-assets-designer__tileset-navigator";
|
|
549
|
+
navigatorPanel.append(navigatorTitle, navigator);
|
|
550
|
+
const previewPanel = document.createElement("section");
|
|
551
|
+
previewPanel.className = "ai-game-assets-designer__tileset-preview-panel";
|
|
552
|
+
const previewTitle = document.createElement("div");
|
|
553
|
+
previewTitle.className = "ai-game-assets-designer__tileset-section-title";
|
|
554
|
+
const stage = document.createElement("div");
|
|
555
|
+
stage.className =
|
|
556
|
+
"ai-game-assets-designer__modal-stage ai-game-assets-designer__tileset-editor-stage";
|
|
557
|
+
const prompt = document.createElement("div");
|
|
558
|
+
prompt.className = "ai-game-assets-designer__tileset-tile-prompt";
|
|
559
|
+
const strip = document.createElement("div");
|
|
560
|
+
strip.className = "ai-game-assets-designer__frame-strip ai-game-assets-designer__tileset-editor-strip";
|
|
561
|
+
const delayInput = document.createElement("input");
|
|
562
|
+
delayInput.type = "number";
|
|
563
|
+
delayInput.min = "1";
|
|
564
|
+
delayInput.step = "1";
|
|
565
|
+
delayInput.inputMode = "numeric";
|
|
566
|
+
const delayField = document.createElement("label");
|
|
567
|
+
delayField.className = "ai-game-assets-designer__field";
|
|
568
|
+
const delayLabel = document.createElement("span");
|
|
569
|
+
delayLabel.textContent = "Delay ms";
|
|
570
|
+
delayField.append(delayLabel, delayInput);
|
|
571
|
+
const fields = document.createElement("div");
|
|
572
|
+
fields.className = "ai-game-assets-designer__frame-fields";
|
|
573
|
+
fields.append(delayField);
|
|
574
|
+
previewPanel.append(previewTitle, stage, prompt, strip, fields);
|
|
575
|
+
body.append(navigatorPanel, previewPanel);
|
|
576
|
+
const feedback = document.createElement("div");
|
|
577
|
+
feedback.className = "ai-game-assets-designer__modal-feedback";
|
|
578
|
+
feedback.hidden = true;
|
|
579
|
+
const touchUpButton = modalButton("Touch up...");
|
|
580
|
+
const cancelButton = modalButton("Cancel");
|
|
581
|
+
const confirmButton = modalButton("Confirm");
|
|
582
|
+
const actions = document.createElement("div");
|
|
583
|
+
actions.className = "ai-game-assets-designer__modal-actions";
|
|
584
|
+
actions.append(touchUpButton, cancelButton, confirmButton);
|
|
585
|
+
card.append(title, body, feedback, actions);
|
|
586
|
+
dialog.append(card);
|
|
587
|
+
options.root.append(dialog);
|
|
588
|
+
let selectedTile = 0;
|
|
589
|
+
let selectedFrameSlot = 0;
|
|
590
|
+
let previewFrameSlot = 0;
|
|
591
|
+
let previewTimeout;
|
|
592
|
+
let busy = false;
|
|
593
|
+
let closed = false;
|
|
594
|
+
const tileButtons = [];
|
|
595
|
+
const frameButtons = [];
|
|
596
|
+
const setFeedback = (message, kind = "idle") => {
|
|
597
|
+
feedback.textContent = message;
|
|
598
|
+
feedback.dataset.kind = kind;
|
|
599
|
+
feedback.hidden = message.length === 0;
|
|
600
|
+
};
|
|
601
|
+
const syncButtons = () => {
|
|
602
|
+
touchUpButton.disabled = busy;
|
|
603
|
+
cancelButton.disabled = busy;
|
|
604
|
+
confirmButton.disabled = busy;
|
|
605
|
+
delayInput.disabled = busy;
|
|
606
|
+
tileButtons.forEach((button) => {
|
|
607
|
+
button.disabled = busy;
|
|
608
|
+
});
|
|
609
|
+
frameButtons.forEach((button) => {
|
|
610
|
+
button.disabled = busy;
|
|
611
|
+
});
|
|
612
|
+
};
|
|
613
|
+
const stopPreview = () => {
|
|
614
|
+
if (previewTimeout !== undefined) {
|
|
615
|
+
window.clearTimeout(previewTimeout);
|
|
616
|
+
previewTimeout = undefined;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
const showPreviewFrame = (frameSlot) => {
|
|
620
|
+
const src = frames[frameSlot];
|
|
621
|
+
if (!src)
|
|
622
|
+
return;
|
|
623
|
+
setFrameBackground(stage, {
|
|
624
|
+
src,
|
|
625
|
+
frame: selectedTile,
|
|
626
|
+
frameGrid,
|
|
627
|
+
displaySize: { width: 224, height: 224 }
|
|
628
|
+
});
|
|
629
|
+
tileButtons.forEach((button, tile) => {
|
|
630
|
+
setFrameBackground(button, {
|
|
631
|
+
src,
|
|
632
|
+
frame: tile,
|
|
633
|
+
frameGrid,
|
|
634
|
+
displaySize: { width: 56, height: 56 }
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
frameButtons.forEach((button, index) => {
|
|
638
|
+
button.classList.toggle("is-previewing", index === frameSlot);
|
|
639
|
+
button.setAttribute("aria-current", index === frameSlot ? "true" : "false");
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
const schedulePreview = (startFrameSlot = previewFrameSlot) => {
|
|
643
|
+
stopPreview();
|
|
644
|
+
previewFrameSlot = startFrameSlot % frames.length;
|
|
645
|
+
const step = () => {
|
|
646
|
+
if (closed || frames.length === 0)
|
|
647
|
+
return;
|
|
648
|
+
const frameSlot = previewFrameSlot;
|
|
649
|
+
showPreviewFrame(frameSlot);
|
|
650
|
+
previewFrameSlot = (frameSlot + 1) % frames.length;
|
|
651
|
+
previewTimeout = window.setTimeout(step, frameDelays[frameSlot] ?? defaultDelayMs);
|
|
652
|
+
};
|
|
653
|
+
step();
|
|
654
|
+
};
|
|
655
|
+
const render = () => {
|
|
656
|
+
previewTitle.textContent =
|
|
657
|
+
`Tile ${selectedTile + 1} · Frame ${selectedFrameSlot + 1}`;
|
|
658
|
+
const tilePrompt = animation.tiles?.[selectedTile]?.prompt?.trim();
|
|
659
|
+
prompt.textContent = tilePrompt
|
|
660
|
+
? `Tile ${selectedTile + 1}: ${tilePrompt}`
|
|
661
|
+
: `Tile ${selectedTile + 1}`;
|
|
662
|
+
delayInput.value = String(frameDelays[selectedFrameSlot] ?? defaultDelayMs);
|
|
663
|
+
tileButtons.forEach((button, tile) => {
|
|
664
|
+
button.classList.toggle("is-selected", tile === selectedTile);
|
|
665
|
+
button.setAttribute("aria-pressed", String(tile === selectedTile));
|
|
666
|
+
});
|
|
667
|
+
frameButtons.forEach((button, frameSlot) => {
|
|
668
|
+
button.classList.toggle("is-selected", frameSlot === selectedFrameSlot);
|
|
669
|
+
button.setAttribute("aria-pressed", String(frameSlot === selectedFrameSlot));
|
|
670
|
+
setFrameBackground(button, {
|
|
671
|
+
src: frames[frameSlot],
|
|
672
|
+
frame: selectedTile,
|
|
673
|
+
frameGrid,
|
|
674
|
+
displaySize: { width: 48, height: 48 }
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
syncButtons();
|
|
678
|
+
schedulePreview(selectedFrameSlot);
|
|
679
|
+
};
|
|
680
|
+
const close = () => {
|
|
681
|
+
if (closed)
|
|
682
|
+
return;
|
|
683
|
+
closed = true;
|
|
684
|
+
stopPreview();
|
|
685
|
+
window.removeEventListener("keydown", keyHandler, true);
|
|
686
|
+
dialog.remove();
|
|
687
|
+
};
|
|
688
|
+
const keyHandler = (event) => {
|
|
689
|
+
if (event.key !== "Escape" || busy)
|
|
690
|
+
return;
|
|
691
|
+
event.preventDefault();
|
|
692
|
+
close();
|
|
693
|
+
};
|
|
694
|
+
for (let tile = 0; tile < tileCount; tile += 1) {
|
|
695
|
+
const button = document.createElement("button");
|
|
696
|
+
button.type = "button";
|
|
697
|
+
button.className = "ai-game-assets-designer__tileset-tile";
|
|
698
|
+
button.dataset.source = String(tile + 1);
|
|
699
|
+
button.setAttribute("aria-label", `Tile ${tile + 1}`);
|
|
700
|
+
button.addEventListener("click", () => {
|
|
701
|
+
selectedTile = tile;
|
|
702
|
+
setFeedback("");
|
|
703
|
+
render();
|
|
704
|
+
});
|
|
705
|
+
tileButtons.push(button);
|
|
706
|
+
navigator.append(button);
|
|
707
|
+
}
|
|
708
|
+
for (let frameSlot = 0; frameSlot < frames.length; frameSlot += 1) {
|
|
709
|
+
const button = document.createElement("button");
|
|
710
|
+
button.type = "button";
|
|
711
|
+
button.className = "ai-game-assets-designer__frame-thumb";
|
|
712
|
+
button.setAttribute("aria-label", `Frame ${frameSlot + 1}`);
|
|
713
|
+
button.addEventListener("click", () => {
|
|
714
|
+
selectedFrameSlot = frameSlot;
|
|
715
|
+
setFeedback("");
|
|
716
|
+
render();
|
|
717
|
+
});
|
|
718
|
+
frameButtons.push(button);
|
|
719
|
+
strip.append(button);
|
|
720
|
+
}
|
|
721
|
+
delayInput.addEventListener("input", () => {
|
|
722
|
+
frameDelays[selectedFrameSlot] = positiveIntegerInput(delayInput, frameDelays[selectedFrameSlot] ?? defaultDelayMs);
|
|
723
|
+
schedulePreview(selectedFrameSlot);
|
|
724
|
+
});
|
|
725
|
+
touchUpButton.addEventListener("click", async () => {
|
|
726
|
+
if (busy)
|
|
727
|
+
return;
|
|
728
|
+
const tile = selectedTile;
|
|
729
|
+
const frameSlot = selectedFrameSlot;
|
|
730
|
+
busy = true;
|
|
731
|
+
syncButtons();
|
|
732
|
+
setFeedback(`Opening touch-up for tile ${tile + 1}, frame ${frameSlot + 1}...`, "busy");
|
|
733
|
+
try {
|
|
734
|
+
const tileSrc = await extractTilesetTileDataUrl(frames[frameSlot], tileset, tile);
|
|
735
|
+
await openFrameTouchUpEditor({
|
|
736
|
+
root: options.root,
|
|
737
|
+
asset: options.asset,
|
|
738
|
+
title: `${readableAssetName(options.assetId)} ${readableAssetName(animation.key)} ` +
|
|
739
|
+
`tile ${tile + 1}, frame ${frameSlot + 1}`,
|
|
740
|
+
frameSrc: tileSrc,
|
|
741
|
+
displaySize: touchUpDisplaySize,
|
|
742
|
+
onSave: async (editedTileSrc) => {
|
|
743
|
+
frames[frameSlot] = await replaceSpriteSheetFrames({
|
|
744
|
+
src: frames[frameSlot],
|
|
745
|
+
uploadSrc: editedTileSrc,
|
|
746
|
+
frameGrid,
|
|
747
|
+
frames: [tile]
|
|
748
|
+
});
|
|
749
|
+
selectedTile = tile;
|
|
750
|
+
selectedFrameSlot = frameSlot;
|
|
751
|
+
render();
|
|
752
|
+
setFeedback(`Touched up tile ${tile + 1}, frame ${frameSlot + 1}.`, "success");
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
setFeedback("");
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
setFeedback(`Could not open tile touch-up: ${tilesetErrorMessage(error)}`, "error");
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
busy = false;
|
|
762
|
+
syncButtons();
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
cancelButton.addEventListener("click", close);
|
|
766
|
+
confirmButton.addEventListener("click", async () => {
|
|
767
|
+
if (busy)
|
|
768
|
+
return;
|
|
769
|
+
busy = true;
|
|
770
|
+
syncButtons();
|
|
771
|
+
setFeedback("Applying tileset animation edits...", "busy");
|
|
772
|
+
try {
|
|
773
|
+
await options.onConfirm({
|
|
774
|
+
frames: [...frames],
|
|
775
|
+
animation: tilesetAnimationWithFrameDelays(animation, frameDelays)
|
|
776
|
+
});
|
|
777
|
+
close();
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
setFeedback(`Could not apply tileset animation edits: ${tilesetErrorMessage(error)}`, "error");
|
|
781
|
+
busy = false;
|
|
782
|
+
syncButtons();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
window.addEventListener("keydown", keyHandler, true);
|
|
786
|
+
render();
|
|
787
|
+
tileButtons[0]?.focus();
|
|
788
|
+
}
|
|
789
|
+
function openTilesetTileChoiceDialog(options) {
|
|
790
|
+
for (const [index, candidate] of options.candidates.entries()) {
|
|
791
|
+
if (!candidate.tileset || tilesetTileCount(candidate.tileset) < 1) {
|
|
792
|
+
throw new Error(`Regenerated option ${index + 1} did not return tile geometry.`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return new Promise((resolve) => {
|
|
796
|
+
const dialog = document.createElement("div");
|
|
797
|
+
dialog.className = "ai-game-assets-designer__modal ai-game-assets-designer__tileset-regenerate";
|
|
798
|
+
dialog.setAttribute("role", "dialog");
|
|
799
|
+
dialog.setAttribute("aria-modal", "true");
|
|
800
|
+
dialog.setAttribute("aria-label", `Choose regenerated ${readableAssetName(options.assetId)} tile ${options.tile + 1}`);
|
|
801
|
+
const card = document.createElement("div");
|
|
802
|
+
card.className = "ai-game-assets-designer__modal-card ai-game-assets-designer__tileset-regenerate-card";
|
|
803
|
+
const title = document.createElement("div");
|
|
804
|
+
title.className = "ai-game-assets-designer__modal-title";
|
|
805
|
+
title.textContent = `Choose tile ${options.tile + 1}`;
|
|
806
|
+
const prompt = document.createElement("div");
|
|
807
|
+
prompt.className = "ai-game-assets-designer__tileset-tile-prompt";
|
|
808
|
+
prompt.textContent = options.prompt;
|
|
809
|
+
const choices = document.createElement("div");
|
|
810
|
+
choices.className = "ai-game-assets-designer__tileset-choices";
|
|
811
|
+
const cancelButton = modalButton("Cancel");
|
|
812
|
+
const confirmButton = modalButton("Use selected");
|
|
813
|
+
confirmButton.disabled = true;
|
|
814
|
+
const actions = document.createElement("div");
|
|
815
|
+
actions.className = "ai-game-assets-designer__modal-actions";
|
|
816
|
+
actions.append(cancelButton, confirmButton);
|
|
817
|
+
card.append(title, prompt, choices, actions);
|
|
818
|
+
dialog.append(card);
|
|
819
|
+
options.root.append(dialog);
|
|
820
|
+
let selected;
|
|
821
|
+
let closed = false;
|
|
822
|
+
const choiceButtons = [];
|
|
823
|
+
const close = (result) => {
|
|
824
|
+
if (closed)
|
|
825
|
+
return;
|
|
826
|
+
closed = true;
|
|
827
|
+
window.removeEventListener("keydown", keyHandler, true);
|
|
828
|
+
dialog.remove();
|
|
829
|
+
resolve(result);
|
|
830
|
+
};
|
|
831
|
+
const keyHandler = (event) => {
|
|
832
|
+
if (event.key !== "Escape")
|
|
833
|
+
return;
|
|
834
|
+
event.preventDefault();
|
|
835
|
+
event.stopPropagation();
|
|
836
|
+
close(undefined);
|
|
837
|
+
};
|
|
838
|
+
options.candidates.forEach((candidate, index) => {
|
|
839
|
+
const button = document.createElement("button");
|
|
840
|
+
button.type = "button";
|
|
841
|
+
button.className = "ai-game-assets-designer__tileset-choice";
|
|
842
|
+
button.setAttribute("aria-label", `Option ${index + 1}`);
|
|
843
|
+
button.setAttribute("aria-pressed", "false");
|
|
844
|
+
const stage = document.createElement("div");
|
|
845
|
+
stage.className = "ai-game-assets-designer__tileset-choice-preview";
|
|
846
|
+
setFrameBackground(stage, {
|
|
847
|
+
src: candidate.dataUrl,
|
|
848
|
+
frame: 0,
|
|
849
|
+
frameGrid: tilesetFrameGrid(candidate.tileset),
|
|
850
|
+
displaySize: { width: 112, height: 112 }
|
|
851
|
+
});
|
|
852
|
+
const label = document.createElement("span");
|
|
853
|
+
label.textContent = `Option ${index + 1}`;
|
|
854
|
+
button.append(stage, label);
|
|
855
|
+
button.addEventListener("click", () => {
|
|
856
|
+
selected = candidate;
|
|
857
|
+
confirmButton.disabled = false;
|
|
858
|
+
choiceButtons.forEach((choice) => {
|
|
859
|
+
const isSelected = choice === button;
|
|
860
|
+
choice.classList.toggle("is-selected", isSelected);
|
|
861
|
+
choice.setAttribute("aria-pressed", String(isSelected));
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
button.addEventListener("dblclick", () => close(candidate));
|
|
865
|
+
choiceButtons.push(button);
|
|
866
|
+
choices.append(button);
|
|
867
|
+
});
|
|
868
|
+
cancelButton.addEventListener("click", () => close(undefined));
|
|
869
|
+
confirmButton.addEventListener("click", () => close(selected));
|
|
870
|
+
window.addEventListener("keydown", keyHandler, true);
|
|
871
|
+
choiceButtons[0]?.focus();
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
function modalButton(label) {
|
|
875
|
+
const button = document.createElement("button");
|
|
876
|
+
button.type = "button";
|
|
877
|
+
button.textContent = label;
|
|
878
|
+
return button;
|
|
879
|
+
}
|
|
880
|
+
function tilesetErrorMessage(error) {
|
|
881
|
+
return error instanceof Error ? error.message : String(error);
|
|
882
|
+
}
|
|
883
|
+
export async function openTilesetBaseMixerDialog(options) {
|
|
884
|
+
const currentTileset = tilesetMetadataForAsset(options.asset);
|
|
885
|
+
if (!currentTileset) {
|
|
886
|
+
throw new Error(`AI asset "${options.assetId}" is not a tileset.`);
|
|
887
|
+
}
|
|
888
|
+
const candidates = [...options.candidates].sort((left, right) => left.index - right.index);
|
|
889
|
+
const plan = planTilesetBaseMix(options.asset, candidates);
|
|
890
|
+
const sources = [
|
|
891
|
+
{
|
|
892
|
+
selection: "base",
|
|
893
|
+
label: "Keep current",
|
|
894
|
+
shortLabel: "Current",
|
|
895
|
+
overviewSheetSrc: options.baseSheetSrc,
|
|
896
|
+
sheetSrcs: [options.baseSheetSrc],
|
|
897
|
+
tileset: currentTileset
|
|
898
|
+
},
|
|
899
|
+
...candidates.map((candidate, index) => ({
|
|
900
|
+
selection: candidate.index,
|
|
901
|
+
label: `Option ${index + 1}`,
|
|
902
|
+
shortLabel: String(index + 1),
|
|
903
|
+
overviewSheetSrc: candidate.dataUrl,
|
|
904
|
+
sheetSrcs: [candidate.dataUrl],
|
|
905
|
+
tileset: candidate.tileset ?? currentTileset
|
|
906
|
+
}))
|
|
907
|
+
];
|
|
908
|
+
const mixed = await openTilesetMixerDialog({
|
|
909
|
+
root: options.root,
|
|
910
|
+
assetId: options.assetId,
|
|
911
|
+
ariaLabel: `Mix ${readableAssetName(options.assetId)} base tileset`,
|
|
912
|
+
title: `${readableAssetName(options.assetId)} · Base tileset`,
|
|
913
|
+
hint: "Choose the current tile or one of the generated options for each tile.",
|
|
914
|
+
confirmLabel: "Use mixed tileset",
|
|
915
|
+
busyLabel: "Compositing the mixed tileset...",
|
|
916
|
+
targetTileset: plan.tileset,
|
|
917
|
+
targetDimensions: plan.dimensions,
|
|
918
|
+
frameCount: 1,
|
|
919
|
+
sources,
|
|
920
|
+
selections: plan.selections,
|
|
921
|
+
regenerateTile: options.regenerateTile ? async (tile) => {
|
|
922
|
+
const currentTileSrc = await extractTilesetTileDataUrl(options.baseSheetSrc, currentTileset, tile);
|
|
923
|
+
const generated = [...await options.regenerateTile(tile, tilesetTileGenerationOverride(plan.tileset, tile), currentTileSrc)].sort((left, right) => left.index - right.index);
|
|
924
|
+
if (generated.length !== candidates.length) {
|
|
925
|
+
throw new Error(`Expected ${candidates.length} regenerated tile options, received ${generated.length}.`);
|
|
926
|
+
}
|
|
927
|
+
assertUniqueCandidateIndexes(generated);
|
|
928
|
+
return candidates.map((candidate, index) => {
|
|
929
|
+
const option = generated[index];
|
|
930
|
+
if (!option.tileset || tilesetTileCount(option.tileset) < 1) {
|
|
931
|
+
throw new Error(`Regenerated option ${index + 1} did not return tile geometry.`);
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
selection: candidate.index,
|
|
935
|
+
src: option.dataUrl,
|
|
936
|
+
tile: 0,
|
|
937
|
+
tileset: option.tileset
|
|
938
|
+
};
|
|
939
|
+
});
|
|
940
|
+
} : undefined
|
|
941
|
+
});
|
|
942
|
+
const dataUrl = mixed?.sheets[0];
|
|
943
|
+
if (!mixed || !dataUrl)
|
|
944
|
+
return undefined;
|
|
945
|
+
return {
|
|
946
|
+
dataUrl,
|
|
947
|
+
dimensions: plan.dimensions,
|
|
948
|
+
tileset: plan.tileset,
|
|
949
|
+
selections: mixed.selections
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
export async function openTilesetAnimationMixerDialog(options) {
|
|
953
|
+
const tileset = tilesetMetadataForAsset(options.asset);
|
|
954
|
+
const animation = tilesetAnimationForKey(options.asset, options.animationKey);
|
|
955
|
+
if (!tileset || !animation) {
|
|
956
|
+
throw new Error(`Tileset animation "${options.animationKey}" is not declared.`);
|
|
957
|
+
}
|
|
958
|
+
if (options.candidates.length === 0) {
|
|
959
|
+
throw new Error("Tileset animation generation returned no candidates.");
|
|
960
|
+
}
|
|
961
|
+
const frameCount = Math.max(1, animation.frameCount);
|
|
962
|
+
const candidates = [...options.candidates].sort((left, right) => left.index - right.index);
|
|
963
|
+
assertUniqueCandidateIndexes(candidates);
|
|
964
|
+
for (const candidate of candidates) {
|
|
965
|
+
if (candidate.animationKey !== options.animationKey) {
|
|
966
|
+
throw new Error(`Generated candidate ${candidate.index + 1} belongs to animation "${candidate.animationKey}".`);
|
|
967
|
+
}
|
|
968
|
+
if (candidate.frames.length < frameCount) {
|
|
969
|
+
throw new Error(`Generated candidate ${candidate.index + 1} has ${candidate.frames.length} of ${frameCount} frames.`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const targetDimensions = normalizedTilesetDimensions(tileset, options.asset.dimensions, `Tileset "${options.assetId}"`);
|
|
973
|
+
const baseFrameSrcs = Array.from({ length: frameCount }, (_, slot) => (options.baseAnimationFrameSrcs[slot] ?? options.baseSheetSrc));
|
|
974
|
+
const sources = [
|
|
975
|
+
{
|
|
976
|
+
selection: "base",
|
|
977
|
+
label: "Keep current",
|
|
978
|
+
shortLabel: "Current",
|
|
979
|
+
overviewSheetSrc: options.baseSheetSrc,
|
|
980
|
+
sheetSrcs: baseFrameSrcs,
|
|
981
|
+
tileset
|
|
982
|
+
},
|
|
983
|
+
...candidates.map((candidate, index) => ({
|
|
984
|
+
selection: candidate.index,
|
|
985
|
+
label: `Option ${index + 1}`,
|
|
986
|
+
shortLabel: String(index + 1),
|
|
987
|
+
overviewSheetSrc: candidate.frames[0].dataUrl,
|
|
988
|
+
sheetSrcs: candidate.frames.slice(0, frameCount).map((frame) => frame.dataUrl),
|
|
989
|
+
tileset
|
|
990
|
+
}))
|
|
991
|
+
];
|
|
992
|
+
const selections = Array.from({ length: tilesetTileCount(tileset) }, (_, tile) => options.initialSelections?.[tile] ?? "base");
|
|
993
|
+
const mixed = await openTilesetMixerDialog({
|
|
994
|
+
root: options.root,
|
|
995
|
+
assetId: options.assetId,
|
|
996
|
+
ariaLabel: `Mix ${readableAssetName(options.assetId)} ${readableAssetName(options.animationKey)}`,
|
|
997
|
+
title: `${readableAssetName(options.assetId)} · ${readableAssetName(options.animationKey)}`,
|
|
998
|
+
hint: "Choose one complete animation sequence for each tile. All previews are synchronized.",
|
|
999
|
+
confirmLabel: "Use mixed animation",
|
|
1000
|
+
busyLabel: "Compositing full animation sheets...",
|
|
1001
|
+
targetTileset: tileset,
|
|
1002
|
+
targetDimensions,
|
|
1003
|
+
frameCount,
|
|
1004
|
+
frameDelayMs(frameSlot) {
|
|
1005
|
+
const timing = animation.frameTimings?.[frameSlot];
|
|
1006
|
+
return timing?.delayMs ?? Math.round(1000 / Math.max(1, animation.frameRate));
|
|
1007
|
+
},
|
|
1008
|
+
navigatorSource: "base",
|
|
1009
|
+
sources,
|
|
1010
|
+
selections,
|
|
1011
|
+
tilePrompt: (tile) => options.tilePrompts?.[tile]?.prompt ??
|
|
1012
|
+
animation.tiles?.[tile]?.prompt,
|
|
1013
|
+
regenerateTile: options.regenerateTile ? async (tile) => {
|
|
1014
|
+
const currentTileSrc = await extractTilesetTileDataUrl(baseFrameSrcs[0] ?? options.baseSheetSrc, tileset, tile);
|
|
1015
|
+
const regenerated = [...await options.regenerateTile(tile, currentTileSrc)]
|
|
1016
|
+
.sort((left, right) => left.index - right.index);
|
|
1017
|
+
if (regenerated.length !== candidates.length) {
|
|
1018
|
+
throw new Error(`Expected ${candidates.length} regenerated animation options, received ${regenerated.length}.`);
|
|
1019
|
+
}
|
|
1020
|
+
assertUniqueCandidateIndexes(regenerated);
|
|
1021
|
+
return candidates.map((candidate, index) => {
|
|
1022
|
+
const option = regenerated[index];
|
|
1023
|
+
if (option.frames.length < frameCount) {
|
|
1024
|
+
throw new Error(`Regenerated option ${index + 1} is missing animation frames.`);
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
selection: candidate.index,
|
|
1028
|
+
src: option.frames[0].dataUrl,
|
|
1029
|
+
sheetSrcs: option.frames.slice(0, frameCount).map((frame) => frame.dataUrl),
|
|
1030
|
+
tile: 0,
|
|
1031
|
+
tileset: {
|
|
1032
|
+
...tileset,
|
|
1033
|
+
columns: 1,
|
|
1034
|
+
rows: 1,
|
|
1035
|
+
tileCount: 1,
|
|
1036
|
+
tiles: tileset.tiles?.[tile] ? [tileset.tiles[tile]] : undefined
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
});
|
|
1040
|
+
} : undefined
|
|
1041
|
+
});
|
|
1042
|
+
if (!mixed)
|
|
1043
|
+
return undefined;
|
|
1044
|
+
return {
|
|
1045
|
+
animationKey: options.animationKey,
|
|
1046
|
+
frames: mixed.sheets,
|
|
1047
|
+
selections: mixed.selections
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
function openTilesetMixerDialog(options) {
|
|
1051
|
+
const tileCount = tilesetTileCount(options.targetTileset);
|
|
1052
|
+
if (options.sources.length < 2) {
|
|
1053
|
+
throw new Error("Tileset mixing requires a current sheet and at least one generated option.");
|
|
1054
|
+
}
|
|
1055
|
+
if (options.selections.length !== tileCount) {
|
|
1056
|
+
throw new Error(`Tileset mixing requires exactly ${tileCount} tile selections.`);
|
|
1057
|
+
}
|
|
1058
|
+
const sourceBySelection = new Map(options.sources.map((source) => [source.selection, source]));
|
|
1059
|
+
for (const [tile, selection] of options.selections.entries()) {
|
|
1060
|
+
const source = sourceBySelection.get(selection);
|
|
1061
|
+
if (!source || tile >= tilesetTileCount(source.tileset)) {
|
|
1062
|
+
throw new Error(`Tile ${tile + 1} does not have a valid initial source.`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return new Promise((resolve) => {
|
|
1066
|
+
const dialog = document.createElement("div");
|
|
1067
|
+
dialog.className = "ai-game-assets-designer__modal ai-game-assets-designer__tileset-mixer";
|
|
1068
|
+
dialog.setAttribute("role", "dialog");
|
|
1069
|
+
dialog.setAttribute("aria-modal", "true");
|
|
1070
|
+
dialog.setAttribute("aria-label", options.ariaLabel);
|
|
1071
|
+
const card = document.createElement("div");
|
|
1072
|
+
card.className = "ai-game-assets-designer__modal-card ai-game-assets-designer__tileset-mixer-card";
|
|
1073
|
+
const title = document.createElement("div");
|
|
1074
|
+
title.className = "ai-game-assets-designer__modal-title";
|
|
1075
|
+
title.textContent = options.title;
|
|
1076
|
+
const hint = document.createElement("div");
|
|
1077
|
+
hint.className = "ai-game-assets-designer__tileset-mixer-hint";
|
|
1078
|
+
hint.textContent = options.hint;
|
|
1079
|
+
const body = document.createElement("div");
|
|
1080
|
+
body.className = "ai-game-assets-designer__tileset-mixer-body";
|
|
1081
|
+
const navigatorPanel = document.createElement("section");
|
|
1082
|
+
navigatorPanel.className = "ai-game-assets-designer__tileset-navigator-panel";
|
|
1083
|
+
const navigatorTitle = document.createElement("div");
|
|
1084
|
+
navigatorTitle.className = "ai-game-assets-designer__tileset-section-title";
|
|
1085
|
+
navigatorTitle.textContent = `Tiles (${tileCount})`;
|
|
1086
|
+
const navigator = document.createElement("div");
|
|
1087
|
+
navigator.className = "ai-game-assets-designer__tileset-navigator";
|
|
1088
|
+
navigatorPanel.append(navigatorTitle, navigator);
|
|
1089
|
+
const previewPanel = document.createElement("section");
|
|
1090
|
+
previewPanel.className = "ai-game-assets-designer__tileset-preview-panel";
|
|
1091
|
+
const previewTitle = document.createElement("div");
|
|
1092
|
+
previewTitle.className = "ai-game-assets-designer__tileset-section-title";
|
|
1093
|
+
const choices = document.createElement("div");
|
|
1094
|
+
choices.className = "ai-game-assets-designer__tileset-choices";
|
|
1095
|
+
const tilePrompt = document.createElement("div");
|
|
1096
|
+
tilePrompt.className = "ai-game-assets-designer__tileset-tile-prompt";
|
|
1097
|
+
previewPanel.append(previewTitle, choices, tilePrompt);
|
|
1098
|
+
body.append(navigatorPanel, previewPanel);
|
|
1099
|
+
const feedback = document.createElement("div");
|
|
1100
|
+
feedback.className = "ai-game-assets-designer__modal-feedback";
|
|
1101
|
+
feedback.hidden = true;
|
|
1102
|
+
const cancelButton = document.createElement("button");
|
|
1103
|
+
cancelButton.type = "button";
|
|
1104
|
+
cancelButton.textContent = "Cancel";
|
|
1105
|
+
const regenerateButton = document.createElement("button");
|
|
1106
|
+
regenerateButton.type = "button";
|
|
1107
|
+
regenerateButton.textContent = "Regenerate";
|
|
1108
|
+
regenerateButton.hidden = !options.regenerateTile;
|
|
1109
|
+
regenerateButton.setAttribute("aria-label", "Regenerate three options for the current tile");
|
|
1110
|
+
const confirmButton = document.createElement("button");
|
|
1111
|
+
confirmButton.type = "button";
|
|
1112
|
+
confirmButton.textContent = options.confirmLabel;
|
|
1113
|
+
const actions = document.createElement("div");
|
|
1114
|
+
actions.className = "ai-game-assets-designer__modal-actions";
|
|
1115
|
+
actions.append(regenerateButton, cancelButton, confirmButton);
|
|
1116
|
+
card.append(title, hint, body, feedback, actions);
|
|
1117
|
+
dialog.append(card);
|
|
1118
|
+
options.root.append(dialog);
|
|
1119
|
+
let selectedTile = 0;
|
|
1120
|
+
let frameSlot = 0;
|
|
1121
|
+
let timeout;
|
|
1122
|
+
let closed = false;
|
|
1123
|
+
let busy = false;
|
|
1124
|
+
const selections = [...options.selections];
|
|
1125
|
+
const navigatorButtons = [];
|
|
1126
|
+
const choiceButtons = new Map();
|
|
1127
|
+
const previewStages = new Map();
|
|
1128
|
+
const sourceIsAvailable = (source, tile) => (source.tileOverrides?.has(tile) || tile < tilesetTileCount(source.tileset));
|
|
1129
|
+
const sourceTileView = (source, tile, slot, overview = false) => {
|
|
1130
|
+
const override = source.tileOverrides?.get(tile);
|
|
1131
|
+
if (override) {
|
|
1132
|
+
return {
|
|
1133
|
+
...override,
|
|
1134
|
+
src: overview
|
|
1135
|
+
? override.src
|
|
1136
|
+
: override.sheetSrcs?.[slot] ?? override.src
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
src: overview
|
|
1141
|
+
? source.overviewSheetSrc
|
|
1142
|
+
: source.sheetSrcs[slot] ?? source.sheetSrcs[0] ?? source.overviewSheetSrc,
|
|
1143
|
+
tile,
|
|
1144
|
+
tileset: source.tileset
|
|
1145
|
+
};
|
|
1146
|
+
};
|
|
1147
|
+
const updateNavigatorSelection = () => {
|
|
1148
|
+
navigatorButtons.forEach((button, tile) => {
|
|
1149
|
+
const selection = selections[tile] ?? "base";
|
|
1150
|
+
const selectedSource = sourceBySelection.get(selection);
|
|
1151
|
+
const preferredSource = options.navigatorSource === undefined
|
|
1152
|
+
? undefined
|
|
1153
|
+
: sourceBySelection.get(options.navigatorSource);
|
|
1154
|
+
const source = preferredSource && sourceIsAvailable(preferredSource, tile)
|
|
1155
|
+
? preferredSource
|
|
1156
|
+
: selectedSource;
|
|
1157
|
+
if (!source || !selectedSource)
|
|
1158
|
+
return;
|
|
1159
|
+
const view = sourceTileView(source, tile, 0, true);
|
|
1160
|
+
button.classList.toggle("is-selected", tile === selectedTile);
|
|
1161
|
+
button.classList.toggle("is-mixed", selection !== "base");
|
|
1162
|
+
button.dataset.source = selectedSource.shortLabel;
|
|
1163
|
+
button.setAttribute("aria-pressed", String(tile === selectedTile));
|
|
1164
|
+
setFrameBackground(button, {
|
|
1165
|
+
src: view.src,
|
|
1166
|
+
frame: view.tile,
|
|
1167
|
+
frameGrid: tilesetFrameGrid(view.tileset),
|
|
1168
|
+
displaySize: { width: 56, height: 56 }
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
};
|
|
1172
|
+
const updateChoiceSelection = () => {
|
|
1173
|
+
const selectedSource = selections[selectedTile] ?? "base";
|
|
1174
|
+
for (const [selection, button] of choiceButtons) {
|
|
1175
|
+
const source = sourceBySelection.get(selection);
|
|
1176
|
+
const available = Boolean(source && sourceIsAvailable(source, selectedTile));
|
|
1177
|
+
const isSelected = available && selection === selectedSource;
|
|
1178
|
+
button.disabled = !available;
|
|
1179
|
+
button.classList.toggle("is-selected", isSelected);
|
|
1180
|
+
button.setAttribute("aria-pressed", String(isSelected));
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
const renderPreviews = () => {
|
|
1184
|
+
previewTitle.textContent = options.frameCount > 1
|
|
1185
|
+
? `Tile ${selectedTile + 1} · frame ${frameSlot + 1}/${options.frameCount}`
|
|
1186
|
+
: `Tile ${selectedTile + 1}`;
|
|
1187
|
+
const prompt = options.tilePrompt?.(selectedTile) ??
|
|
1188
|
+
tilesetTilePrompt(options.targetTileset, selectedTile);
|
|
1189
|
+
tilePrompt.hidden = !prompt;
|
|
1190
|
+
tilePrompt.textContent = prompt ? `Tile prompt: ${prompt}` : "";
|
|
1191
|
+
for (const [selection, stage] of previewStages) {
|
|
1192
|
+
const source = sourceBySelection.get(selection);
|
|
1193
|
+
if (!source || !sourceIsAvailable(source, selectedTile)) {
|
|
1194
|
+
stage.replaceChildren();
|
|
1195
|
+
stage.dataset.unavailable = "true";
|
|
1196
|
+
stage.style.width = "112px";
|
|
1197
|
+
stage.style.height = "112px";
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
delete stage.dataset.unavailable;
|
|
1201
|
+
const view = sourceTileView(source, selectedTile, frameSlot);
|
|
1202
|
+
setFrameBackground(stage, {
|
|
1203
|
+
src: view.src,
|
|
1204
|
+
frame: view.tile,
|
|
1205
|
+
frameGrid: tilesetFrameGrid(view.tileset),
|
|
1206
|
+
displaySize: { width: 112, height: 112 }
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
const schedulePreview = () => {
|
|
1211
|
+
if (closed)
|
|
1212
|
+
return;
|
|
1213
|
+
renderPreviews();
|
|
1214
|
+
if (options.frameCount <= 1)
|
|
1215
|
+
return;
|
|
1216
|
+
timeout = window.setTimeout(() => {
|
|
1217
|
+
frameSlot = (frameSlot + 1) % options.frameCount;
|
|
1218
|
+
schedulePreview();
|
|
1219
|
+
}, options.frameDelayMs?.(frameSlot) ?? 1000);
|
|
1220
|
+
};
|
|
1221
|
+
const selectTile = (tile) => {
|
|
1222
|
+
selectedTile = tile;
|
|
1223
|
+
updateNavigatorSelection();
|
|
1224
|
+
updateChoiceSelection();
|
|
1225
|
+
renderPreviews();
|
|
1226
|
+
};
|
|
1227
|
+
const setBusy = (nextBusy) => {
|
|
1228
|
+
busy = nextBusy;
|
|
1229
|
+
regenerateButton.disabled = nextBusy;
|
|
1230
|
+
cancelButton.disabled = nextBusy;
|
|
1231
|
+
confirmButton.disabled = nextBusy;
|
|
1232
|
+
navigatorButtons.forEach((button) => {
|
|
1233
|
+
button.disabled = nextBusy;
|
|
1234
|
+
});
|
|
1235
|
+
if (nextBusy) {
|
|
1236
|
+
choiceButtons.forEach((button) => {
|
|
1237
|
+
button.disabled = true;
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
updateChoiceSelection();
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
for (let tile = 0; tile < tileCount; tile += 1) {
|
|
1245
|
+
const button = document.createElement("button");
|
|
1246
|
+
button.type = "button";
|
|
1247
|
+
button.className = "ai-game-assets-designer__tileset-tile";
|
|
1248
|
+
button.setAttribute("aria-label", `Tile ${tile + 1}`);
|
|
1249
|
+
button.addEventListener("click", () => selectTile(tile));
|
|
1250
|
+
navigatorButtons.push(button);
|
|
1251
|
+
navigator.append(button);
|
|
1252
|
+
}
|
|
1253
|
+
for (const source of options.sources) {
|
|
1254
|
+
const button = document.createElement("button");
|
|
1255
|
+
button.type = "button";
|
|
1256
|
+
button.className = "ai-game-assets-designer__tileset-choice";
|
|
1257
|
+
const stage = document.createElement("div");
|
|
1258
|
+
stage.className = "ai-game-assets-designer__tileset-choice-preview";
|
|
1259
|
+
const choiceLabel = document.createElement("span");
|
|
1260
|
+
choiceLabel.textContent = source.label;
|
|
1261
|
+
button.append(stage, choiceLabel);
|
|
1262
|
+
button.addEventListener("click", () => {
|
|
1263
|
+
if (!sourceIsAvailable(source, selectedTile))
|
|
1264
|
+
return;
|
|
1265
|
+
selections[selectedTile] = source.selection;
|
|
1266
|
+
updateNavigatorSelection();
|
|
1267
|
+
updateChoiceSelection();
|
|
1268
|
+
});
|
|
1269
|
+
choiceButtons.set(source.selection, button);
|
|
1270
|
+
previewStages.set(source.selection, stage);
|
|
1271
|
+
choices.append(button);
|
|
1272
|
+
}
|
|
1273
|
+
const close = (result) => {
|
|
1274
|
+
if (closed)
|
|
1275
|
+
return;
|
|
1276
|
+
closed = true;
|
|
1277
|
+
if (timeout !== undefined)
|
|
1278
|
+
window.clearTimeout(timeout);
|
|
1279
|
+
window.removeEventListener("keydown", keyHandler, true);
|
|
1280
|
+
dialog.remove();
|
|
1281
|
+
resolve(result);
|
|
1282
|
+
};
|
|
1283
|
+
const keyHandler = (event) => {
|
|
1284
|
+
if (event.key !== "Escape")
|
|
1285
|
+
return;
|
|
1286
|
+
if (busy)
|
|
1287
|
+
return;
|
|
1288
|
+
event.preventDefault();
|
|
1289
|
+
close(undefined);
|
|
1290
|
+
};
|
|
1291
|
+
cancelButton.addEventListener("click", () => close(undefined));
|
|
1292
|
+
regenerateButton.addEventListener("click", async () => {
|
|
1293
|
+
if (!options.regenerateTile || busy)
|
|
1294
|
+
return;
|
|
1295
|
+
const tile = selectedTile;
|
|
1296
|
+
setBusy(true);
|
|
1297
|
+
feedback.hidden = false;
|
|
1298
|
+
feedback.dataset.kind = "busy";
|
|
1299
|
+
feedback.textContent = `Generating 3 new options for tile ${tile + 1}...`;
|
|
1300
|
+
try {
|
|
1301
|
+
const replacements = await options.regenerateTile(tile);
|
|
1302
|
+
if (replacements.length !== options.sources.length - 1) {
|
|
1303
|
+
throw new Error(`Expected ${options.sources.length - 1} tile replacements, received ${replacements.length}.`);
|
|
1304
|
+
}
|
|
1305
|
+
for (const replacement of replacements) {
|
|
1306
|
+
const source = sourceBySelection.get(replacement.selection);
|
|
1307
|
+
if (!source || replacement.selection === "base") {
|
|
1308
|
+
throw new Error("Regenerated tile options did not match the existing choices.");
|
|
1309
|
+
}
|
|
1310
|
+
source.tileOverrides ??= new Map();
|
|
1311
|
+
source.tileOverrides.set(tile, {
|
|
1312
|
+
src: replacement.src,
|
|
1313
|
+
sheetSrcs: replacement.sheetSrcs,
|
|
1314
|
+
tile: replacement.tile,
|
|
1315
|
+
tileset: replacement.tileset
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
const base = sourceBySelection.get("base");
|
|
1319
|
+
selections[tile] = base && sourceIsAvailable(base, tile)
|
|
1320
|
+
? "base"
|
|
1321
|
+
: replacements[0].selection;
|
|
1322
|
+
selectTile(tile);
|
|
1323
|
+
feedback.dataset.kind = "success";
|
|
1324
|
+
feedback.textContent = `Replaced the three options for tile ${tile + 1}.`;
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) {
|
|
1327
|
+
feedback.dataset.kind = "error";
|
|
1328
|
+
feedback.textContent = error instanceof Error ? error.message : String(error);
|
|
1329
|
+
}
|
|
1330
|
+
finally {
|
|
1331
|
+
setBusy(false);
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
confirmButton.addEventListener("click", async () => {
|
|
1335
|
+
setBusy(true);
|
|
1336
|
+
feedback.hidden = false;
|
|
1337
|
+
feedback.dataset.kind = "busy";
|
|
1338
|
+
feedback.textContent = options.busyLabel;
|
|
1339
|
+
try {
|
|
1340
|
+
const sheets = await composeTilesetSheets({
|
|
1341
|
+
targetTileset: options.targetTileset,
|
|
1342
|
+
targetDimensions: options.targetDimensions,
|
|
1343
|
+
frameCount: options.frameCount,
|
|
1344
|
+
sources: options.sources,
|
|
1345
|
+
selections
|
|
1346
|
+
});
|
|
1347
|
+
close({ sheets, selections: [...selections] });
|
|
1348
|
+
}
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
feedback.dataset.kind = "error";
|
|
1351
|
+
feedback.textContent = error instanceof Error ? error.message : String(error);
|
|
1352
|
+
setBusy(false);
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
window.addEventListener("keydown", keyHandler, true);
|
|
1356
|
+
updateNavigatorSelection();
|
|
1357
|
+
updateChoiceSelection();
|
|
1358
|
+
schedulePreview();
|
|
1359
|
+
navigatorButtons[0]?.focus();
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
function singleTileTilesetMetadata(tileset, tile) {
|
|
1363
|
+
const prompt = tilesetTilePrompt(tileset, tile);
|
|
1364
|
+
return {
|
|
1365
|
+
tileWidth: tileset.tileWidth,
|
|
1366
|
+
tileHeight: tileset.tileHeight,
|
|
1367
|
+
columns: 1,
|
|
1368
|
+
rows: 1,
|
|
1369
|
+
tileCount: 1,
|
|
1370
|
+
...(prompt ? { tiles: [{ prompt }] } : {})
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function tilesetEditorHasChanges(tileDrafts, originalSheetSrc, tileset, initialTransforms) {
|
|
1374
|
+
return tileDrafts.some((draft, tile) => (draft.source.src !== originalSheetSrc ||
|
|
1375
|
+
draft.source.tile !== tile ||
|
|
1376
|
+
!sameTilesetGrid(draft.source.tileset, tileset) ||
|
|
1377
|
+
!sameTilesetTileTransform(draft.transform, initialTransforms[tile])));
|
|
1378
|
+
}
|
|
1379
|
+
function sameTilesetTileTransform(left, right) {
|
|
1380
|
+
const normalizedLeft = normalizeTilesetTileTransform(left);
|
|
1381
|
+
const normalizedRight = normalizeTilesetTileTransform(right);
|
|
1382
|
+
return normalizedLeft.offsetX === normalizedRight.offsetX &&
|
|
1383
|
+
normalizedLeft.offsetY === normalizedRight.offsetY &&
|
|
1384
|
+
normalizedLeft.scaleX === normalizedRight.scaleX &&
|
|
1385
|
+
normalizedLeft.scaleY === normalizedRight.scaleY;
|
|
1386
|
+
}
|
|
1387
|
+
async function composeTilesetEditorSheets(options) {
|
|
1388
|
+
const sourceEntries = [
|
|
1389
|
+
{ src: options.originalSheetSrc, tileset: options.tileset, label: "Current tileset" },
|
|
1390
|
+
...options.tileDrafts.map((draft, tile) => ({
|
|
1391
|
+
src: draft.source.src,
|
|
1392
|
+
tileset: draft.source.tileset,
|
|
1393
|
+
label: `Tile ${tile + 1} source`
|
|
1394
|
+
}))
|
|
1395
|
+
];
|
|
1396
|
+
const loadedEntries = await Promise.all([...new Set(sourceEntries.map((entry) => entry.src))].map(async (src) => [src, await loadImageElement(src)]));
|
|
1397
|
+
const images = new Map(loadedEntries);
|
|
1398
|
+
for (const entry of sourceEntries) {
|
|
1399
|
+
const image = images.get(entry.src);
|
|
1400
|
+
const required = tilesetDimensions(entry.tileset);
|
|
1401
|
+
if (!image || image.naturalWidth !== required.width || image.naturalHeight !== required.height) {
|
|
1402
|
+
throw new Error(`${entry.label} must be exactly ${required.width}x${required.height}px ` +
|
|
1403
|
+
"for its declared tile grid.");
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const originalImage = images.get(options.originalSheetSrc);
|
|
1407
|
+
if (!originalImage)
|
|
1408
|
+
throw new Error("Could not load the current tileset source.");
|
|
1409
|
+
const compose = (applyTransforms) => {
|
|
1410
|
+
const dimensions = tilesetDimensions(options.tileset);
|
|
1411
|
+
const canvas = document.createElement("canvas");
|
|
1412
|
+
canvas.width = dimensions.width;
|
|
1413
|
+
canvas.height = dimensions.height;
|
|
1414
|
+
const context = canvas.getContext("2d");
|
|
1415
|
+
if (!context)
|
|
1416
|
+
throw new Error("Could not create a canvas for the edited tileset.");
|
|
1417
|
+
context.imageSmoothingEnabled = false;
|
|
1418
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
1419
|
+
context.drawImage(originalImage, 0, 0, canvas.width, canvas.height);
|
|
1420
|
+
for (const [tile, draft] of options.tileDrafts.entries()) {
|
|
1421
|
+
if (draft.source.tile < 0 || draft.source.tile >= tilesetTileCount(draft.source.tileset)) {
|
|
1422
|
+
throw new Error(`Tile ${tile + 1} does not have a valid source tile.`);
|
|
1423
|
+
}
|
|
1424
|
+
const image = images.get(draft.source.src);
|
|
1425
|
+
if (!image)
|
|
1426
|
+
throw new Error(`Tile ${tile + 1} source could not be loaded.`);
|
|
1427
|
+
const source = tileRect(draft.source.tileset, draft.source.tile);
|
|
1428
|
+
const transform = applyTransforms
|
|
1429
|
+
? draft.transform
|
|
1430
|
+
: identityTilesetTileTransform();
|
|
1431
|
+
const plan = tilesetTileTransformDrawPlan(options.tileset, tile, transform);
|
|
1432
|
+
context.clearRect(plan.clip.x, plan.clip.y, plan.clip.width, plan.clip.height);
|
|
1433
|
+
context.save();
|
|
1434
|
+
context.beginPath();
|
|
1435
|
+
context.rect(plan.clip.x, plan.clip.y, plan.clip.width, plan.clip.height);
|
|
1436
|
+
context.clip();
|
|
1437
|
+
context.imageSmoothingEnabled = false;
|
|
1438
|
+
context.drawImage(image, source.x, source.y, source.width, source.height, plan.destination.x, plan.destination.y, plan.destination.width, plan.destination.height);
|
|
1439
|
+
context.restore();
|
|
1440
|
+
}
|
|
1441
|
+
return canvas.toDataURL("image/png");
|
|
1442
|
+
};
|
|
1443
|
+
return {
|
|
1444
|
+
dataUrl: compose(true),
|
|
1445
|
+
sourceDataUrl: compose(false),
|
|
1446
|
+
transforms: options.tileDrafts.map((draft) => (normalizeTilesetTileTransform(draft.transform)))
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
async function composeTilesetSheets(options) {
|
|
1450
|
+
const sourceBySelection = new Map(options.sources.map((source) => [source.selection, source]));
|
|
1451
|
+
const sourceEntries = options.sources.flatMap((source) => ([
|
|
1452
|
+
...[...new Set([
|
|
1453
|
+
source.overviewSheetSrc,
|
|
1454
|
+
...source.sheetSrcs.slice(0, options.frameCount)
|
|
1455
|
+
])].map((src) => ({ label: source.label, src, tileset: source.tileset })),
|
|
1456
|
+
...[...(source.tileOverrides?.values() ?? [])].map((override) => ({
|
|
1457
|
+
label: source.label,
|
|
1458
|
+
src: override.src,
|
|
1459
|
+
tileset: override.tileset
|
|
1460
|
+
})),
|
|
1461
|
+
...[...(source.tileOverrides?.values() ?? [])].flatMap((override) => ((override.sheetSrcs ?? []).map((src) => ({
|
|
1462
|
+
label: source.label,
|
|
1463
|
+
src,
|
|
1464
|
+
tileset: override.tileset
|
|
1465
|
+
}))))
|
|
1466
|
+
]));
|
|
1467
|
+
const loadedEntries = await Promise.all([...new Set(sourceEntries.map((entry) => entry.src))].map(async (src) => [src, await loadImageElement(src)]));
|
|
1468
|
+
const images = new Map(loadedEntries);
|
|
1469
|
+
for (const { label, src, tileset } of sourceEntries) {
|
|
1470
|
+
const image = images.get(src);
|
|
1471
|
+
const required = tilesetDimensions(tileset);
|
|
1472
|
+
if (!image || image.naturalWidth !== required.width || image.naturalHeight !== required.height) {
|
|
1473
|
+
throw new Error(`${label} must be exactly ${required.width}x${required.height}px for its declared tile grid.`);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
const baseSource = sourceBySelection.get("base");
|
|
1477
|
+
const backgroundSource = baseSource &&
|
|
1478
|
+
sameTilesetGrid(baseSource.tileset, options.targetTileset)
|
|
1479
|
+
? baseSource
|
|
1480
|
+
: options.sources.find((source) => source.selection !== "base");
|
|
1481
|
+
if (!backgroundSource) {
|
|
1482
|
+
throw new Error("Tileset mixing requires a source for the output sheet background.");
|
|
1483
|
+
}
|
|
1484
|
+
const sheets = [];
|
|
1485
|
+
for (let frameSlot = 0; frameSlot < options.frameCount; frameSlot += 1) {
|
|
1486
|
+
const canvas = document.createElement("canvas");
|
|
1487
|
+
canvas.width = options.targetDimensions.width;
|
|
1488
|
+
canvas.height = options.targetDimensions.height;
|
|
1489
|
+
const context = canvas.getContext("2d");
|
|
1490
|
+
if (!context) {
|
|
1491
|
+
throw new Error("Could not create a canvas for the mixed tileset.");
|
|
1492
|
+
}
|
|
1493
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
1494
|
+
const backgroundSrc = backgroundSource.sheetSrcs[frameSlot] ?? backgroundSource.sheetSrcs[0];
|
|
1495
|
+
const backgroundImage = backgroundSrc ? images.get(backgroundSrc) : undefined;
|
|
1496
|
+
if (backgroundImage) {
|
|
1497
|
+
context.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
|
|
1498
|
+
}
|
|
1499
|
+
for (const [tile, selection] of options.selections.entries()) {
|
|
1500
|
+
const source = sourceBySelection.get(selection);
|
|
1501
|
+
const override = source?.tileOverrides?.get(tile);
|
|
1502
|
+
if (!source || (!override && tile >= tilesetTileCount(source.tileset))) {
|
|
1503
|
+
throw new Error(`Tile ${tile + 1} does not have a valid selected source.`);
|
|
1504
|
+
}
|
|
1505
|
+
const src = override?.sheetSrcs?.[frameSlot] ?? override?.src ??
|
|
1506
|
+
source.sheetSrcs[frameSlot] ?? source.sheetSrcs[0];
|
|
1507
|
+
const image = src ? images.get(src) : undefined;
|
|
1508
|
+
if (!image) {
|
|
1509
|
+
throw new Error(`${source.label} is missing frame ${frameSlot + 1}.`);
|
|
1510
|
+
}
|
|
1511
|
+
const sourceRect = tileRect(override?.tileset ?? source.tileset, override?.tile ?? tile);
|
|
1512
|
+
const targetRect = tileRect(options.targetTileset, tile);
|
|
1513
|
+
context.clearRect(targetRect.x, targetRect.y, targetRect.width, targetRect.height);
|
|
1514
|
+
context.drawImage(image, sourceRect.x, sourceRect.y, sourceRect.width, sourceRect.height, targetRect.x, targetRect.y, targetRect.width, targetRect.height);
|
|
1515
|
+
}
|
|
1516
|
+
sheets.push(canvas.toDataURL("image/png"));
|
|
1517
|
+
}
|
|
1518
|
+
return sheets;
|
|
1519
|
+
}
|
|
1520
|
+
function assertUniqueCandidateIndexes(candidates) {
|
|
1521
|
+
const indexes = new Set();
|
|
1522
|
+
for (const candidate of candidates) {
|
|
1523
|
+
if (indexes.has(candidate.index)) {
|
|
1524
|
+
throw new Error(`Tileset generation returned duplicate option index ${candidate.index}.`);
|
|
1525
|
+
}
|
|
1526
|
+
indexes.add(candidate.index);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
function normalizedTilesetDimensions(tileset, dimensions, label) {
|
|
1530
|
+
const required = tilesetDimensions(tileset);
|
|
1531
|
+
if (dimensions && !sameDimensions(dimensions, required)) {
|
|
1532
|
+
throw new Error(`${label} dimensions must match its declared tile grid (${required.width}x${required.height}).`);
|
|
1533
|
+
}
|
|
1534
|
+
return dimensions ?? required;
|
|
1535
|
+
}
|
|
1536
|
+
function tilesetDimensions(tileset) {
|
|
1537
|
+
const margin = tileset.margin ?? 0;
|
|
1538
|
+
const spacing = tileset.spacing ?? 0;
|
|
1539
|
+
return {
|
|
1540
|
+
width: margin * 2 + tileset.columns * tileset.tileWidth +
|
|
1541
|
+
Math.max(0, tileset.columns - 1) * spacing,
|
|
1542
|
+
height: margin * 2 + tileset.rows * tileset.tileHeight +
|
|
1543
|
+
Math.max(0, tileset.rows - 1) * spacing
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
async function extractTilesetTileDataUrl(src, tileset, tile) {
|
|
1547
|
+
const image = await loadImageElement(src);
|
|
1548
|
+
const source = tileRect(tileset, tile);
|
|
1549
|
+
const canvas = document.createElement("canvas");
|
|
1550
|
+
canvas.width = source.width;
|
|
1551
|
+
canvas.height = source.height;
|
|
1552
|
+
const context = canvas.getContext("2d");
|
|
1553
|
+
if (!context)
|
|
1554
|
+
throw new Error("Could not create a canvas for the current tile reference.");
|
|
1555
|
+
context.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, source.width, source.height);
|
|
1556
|
+
return canvas.toDataURL("image/png");
|
|
1557
|
+
}
|
|
1558
|
+
function tilesetTileCount(tileset) {
|
|
1559
|
+
return Math.min(tileset.tileCount ?? tileset.columns * tileset.rows, tileset.columns * tileset.rows);
|
|
1560
|
+
}
|
|
1561
|
+
function sameTilesetGrid(left, right) {
|
|
1562
|
+
return left.tileWidth === right.tileWidth &&
|
|
1563
|
+
left.tileHeight === right.tileHeight &&
|
|
1564
|
+
left.columns === right.columns &&
|
|
1565
|
+
left.rows === right.rows &&
|
|
1566
|
+
tilesetTileCount(left) === tilesetTileCount(right) &&
|
|
1567
|
+
(left.margin ?? 0) === (right.margin ?? 0) &&
|
|
1568
|
+
(left.spacing ?? 0) === (right.spacing ?? 0);
|
|
1569
|
+
}
|
|
1570
|
+
function sameDimensions(left, right) {
|
|
1571
|
+
return left.width === right.width && left.height === right.height;
|
|
1572
|
+
}
|
|
1573
|
+
function tilesetFrameGrid(tileset) {
|
|
1574
|
+
return {
|
|
1575
|
+
frameCount: tilesetTileCount(tileset),
|
|
1576
|
+
frameWidth: tileset.tileWidth,
|
|
1577
|
+
frameHeight: tileset.tileHeight,
|
|
1578
|
+
columns: tileset.columns,
|
|
1579
|
+
rows: tileset.rows,
|
|
1580
|
+
margin: tileset.margin,
|
|
1581
|
+
spacing: tileset.spacing
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
function tileRect(tileset, tile) {
|
|
1585
|
+
const column = tile % tileset.columns;
|
|
1586
|
+
const row = Math.floor(tile / tileset.columns);
|
|
1587
|
+
return {
|
|
1588
|
+
x: (tileset.margin ?? 0) + column * (tileset.tileWidth + (tileset.spacing ?? 0)),
|
|
1589
|
+
y: (tileset.margin ?? 0) + row * (tileset.tileHeight + (tileset.spacing ?? 0)),
|
|
1590
|
+
width: tileset.tileWidth,
|
|
1591
|
+
height: tileset.tileHeight
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
//# sourceMappingURL=tileset-dialog.js.map
|