Files
spritesheet-generator/src/composables/useLayers.ts
2026-01-07 16:39:13 +01:00

393 lines
11 KiB
TypeScript

import { computed, ref, watch } from 'vue';
import type { Layer, Sprite } from '@/types/sprites';
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
import { useSettingsStore } from '@/stores/useSettingsStore';
export const createEmptyLayer = (name: string): Layer => ({
id: crypto.randomUUID(),
name,
sprites: [],
visible: true,
locked: false,
});
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
const activeLayerId = ref<string>(layers.value[0].id);
const columns = ref(4);
const createEmptySprite = (): Sprite => ({
id: crypto.randomUUID(),
file: new File([], 'empty'),
img: new Image(),
url: '',
width: 0,
height: 0,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
});
export const useLayers = () => {
const settingsStore = useSettingsStore();
watch(columns, val => {
const num = typeof val === 'number' ? val : parseInt(String(val));
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
if (safe !== columns.value) columns.value = safe;
});
const activeLayer = computed(() => layers.value.find(l => l.id === activeLayerId.value) || layers.value[0]);
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
const hasSprites = computed(() => layers.value.some(l => l.sprites.length > 0));
const updateSpritePosition = (id: string, x: number, y: number) => {
const l = activeLayer.value;
if (!l) return;
const i = l.sprites.findIndex(s => s.id === id);
if (i !== -1) {
l.sprites[i].x = Math.floor(x);
l.sprites[i].y = Math.floor(y);
}
};
const updateSpriteInLayer = (layerId: string, spriteId: string, x: number, y: number) => {
const l = layers.value.find(layer => layer.id === layerId);
if (!l) return;
const i = l.sprites.findIndex(s => s.id === spriteId);
if (i !== -1) {
l.sprites[i].x = Math.floor(x);
l.sprites[i].y = Math.floor(y);
}
};
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
const l = activeLayer.value;
if (!l || !l.sprites.length) return;
let cellWidth: number;
let cellHeight: number;
if (settingsStore.manualCellSizeEnabled) {
cellWidth = settingsStore.manualCellWidth;
cellHeight = settingsStore.manualCellHeight;
} else {
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
cellWidth = maxWidth;
cellHeight = maxHeight;
}
l.sprites = l.sprites.map(sprite => {
let x = sprite.x;
let y = sprite.y;
switch (position) {
case 'left':
x = 0;
break;
case 'center':
x = Math.floor((cellWidth - sprite.width) / 2);
break;
case 'right':
x = Math.floor(cellWidth - sprite.width);
break;
case 'top':
y = 0;
break;
case 'middle':
y = Math.floor((cellHeight - sprite.height) / 2);
break;
case 'bottom':
y = Math.floor(cellHeight - sprite.height);
break;
}
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
});
};
const updateSpriteCell = (id: string, newIndex: number) => {
const l = activeLayer.value;
if (!l) return;
const currentIndex = l.sprites.findIndex(s => s.id === id);
if (currentIndex === -1 || currentIndex === newIndex) return;
const next = [...l.sprites];
const [moving] = next.splice(currentIndex, 1);
while (next.length < newIndex) {
next.push(createEmptySprite());
}
if (newIndex < l.sprites.length) {
const target = l.sprites[newIndex];
const moving = l.sprites[currentIndex];
const newSprites = [...l.sprites];
newSprites[currentIndex] = target;
newSprites[newIndex] = moving;
l.sprites = newSprites;
} else {
const newSprites = [...l.sprites];
const [moved] = newSprites.splice(currentIndex, 1);
while (newSprites.length < newIndex) {
newSprites.push(createEmptySprite());
}
newSprites.splice(newIndex, 0, moved);
l.sprites = newSprites;
}
};
const removeSprite = (id: string) => {
const l = activeLayer.value;
if (!l) return;
const i = l.sprites.findIndex(s => s.id === id);
if (i === -1) return;
const s = l.sprites[i];
if (s.url && s.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(s.url);
} catch {}
}
if (layers.value.length > 1) {
// If there are multiple layers, we want to maintain frame alignment
// so we replace the sprite with an empty one instead of shifting
l.sprites[i] = createEmptySprite();
} else {
// If there's only one layer, we can safely remove the frame
l.sprites.splice(i, 1);
}
};
const removeSprites = (ids: string[]) => {
const l = activeLayer.value;
if (!l) return;
const indicesToRemove: number[] = [];
ids.forEach(id => {
const i = l.sprites.findIndex(s => s.id === id);
if (i !== -1) indicesToRemove.push(i);
});
indicesToRemove.sort((a, b) => b - a);
indicesToRemove.forEach(i => {
const s = l.sprites[i];
if (s.url && s.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(s.url);
} catch {}
}
if (layers.value.length > 1) {
l.sprites[i] = createEmptySprite();
} else {
l.sprites.splice(i, 1);
}
});
};
const rotateSprite = (id: string, angle: number) => {
const l = activeLayer.value;
if (!l) return;
const s = l.sprites.find(s => s.id === id);
if (s) {
s.rotation = (s.rotation + angle) % 360;
}
};
const flipSprite = (id: string, direction: 'horizontal' | 'vertical') => {
const l = activeLayer.value;
if (!l) return;
const s = l.sprites.find(s => s.id === id);
if (s) {
if (direction === 'horizontal') s.flipX = !s.flipX;
if (direction === 'vertical') s.flipY = !s.flipY;
}
};
const replaceSprite = (id: string, file: File) => {
const l = activeLayer.value;
if (!l) return;
const i = l.sprites.findIndex(s => s.id === id);
if (i === -1) return;
const old = l.sprites[i];
if (old.url && old.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(old.url);
} catch {}
}
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y, rotation: old.rotation, flipX: old.flipX || false, flipY: old.flipY || false };
};
img.onerror = () => {
console.error('Failed to load replacement image:', file.name);
};
img.src = url;
};
reader.onerror = () => {
console.error('Failed to read replacement image file:', file.name);
};
reader.readAsDataURL(file);
};
const addSprite = (file: File, index?: number) => {
const l = activeLayer.value;
if (!l) return;
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
const next: Sprite = {
id: crypto.randomUUID(),
file,
img,
url,
width: img.width,
height: img.height,
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
};
const currentSprites = [...l.sprites];
if (typeof index === 'number') {
while (currentSprites.length < index) {
currentSprites.push(createEmptySprite());
}
currentSprites.splice(index, 0, next);
} else {
currentSprites.push(next);
}
l.sprites = currentSprites;
};
img.onerror = () => {
console.error('Failed to load sprite image:', file.name);
};
img.src = url;
};
reader.onerror = () => {
console.error('Failed to read sprite image file:', file.name);
};
reader.readAsDataURL(file);
};
const processImageFiles = async (files: File[]) => {
for (const f of files) addSprite(f);
};
const addLayer = (name?: string) => {
const l = createEmptyLayer(name || `Layer ${layers.value.length + 1}`);
layers.value.push(l);
activeLayerId.value = l.id;
};
const removeLayer = (id: string) => {
if (layers.value.length === 1) return;
const idx = layers.value.findIndex(l => l.id === id);
if (idx === -1) return;
layers.value.splice(idx, 1);
if (activeLayerId.value === id) activeLayerId.value = layers.value[0].id;
};
const moveLayer = (id: string, direction: 'up' | 'down') => {
const idx = layers.value.findIndex(l => l.id === id);
if (idx === -1) return;
if (direction === 'up' && idx > 0) {
const [l] = layers.value.splice(idx, 1);
layers.value.splice(idx - 1, 0, l);
}
if (direction === 'down' && idx < layers.value.length - 1) {
const [l] = layers.value.splice(idx, 1);
layers.value.splice(idx + 1, 0, l);
}
};
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
let sourceSprite: Sprite | undefined;
for (const layer of layers.value) {
sourceSprite = layer.sprites.find(s => s.id === spriteId);
if (sourceSprite) break;
}
if (!sourceSprite) return;
const targetLayer = layers.value.find(l => l.id === targetLayerId);
if (!targetLayer) return;
const copiedSprite: Sprite = {
id: crypto.randomUUID(),
file: sourceSprite.file,
img: sourceSprite.img,
url: sourceSprite.url,
width: sourceSprite.width,
height: sourceSprite.height,
x: sourceSprite.x,
y: sourceSprite.y,
rotation: sourceSprite.rotation,
flipX: sourceSprite.flipX,
flipY: sourceSprite.flipY,
};
while (targetLayer.sprites.length < targetFrameIndex) {
targetLayer.sprites.push(createEmptySprite());
}
if (targetFrameIndex < targetLayer.sprites.length) {
const old = targetLayer.sprites[targetFrameIndex];
if (old.url && old.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(old.url);
} catch {}
}
targetLayer.sprites[targetFrameIndex] = copiedSprite;
} else {
targetLayer.sprites.push(copiedSprite);
}
};
return {
layers,
visibleLayers,
activeLayerId,
activeLayer,
columns,
getMaxDimensions,
updateSpritePosition,
updateSpriteInLayer,
updateSpriteCell,
removeSprite,
removeSprites,
rotateSprite,
flipSprite,
replaceSprite,
addSprite,
processImageFiles,
alignSprites,
addLayer,
removeLayer,
moveLayer,
copySpriteToFrame,
hasSprites,
};
};
export const getMaxDimensionsAcrossLayers = (layers: Layer[], visibleOnly: boolean = false) => {
const sprites = layers.flatMap(l => (visibleOnly ? (l.visible ? l.sprites : []) : l.sprites));
return getMaxDimensionsSingle(sprites);
};