import { readAssetBytes } from './assetReadUrlService'; export type Match3DSpritesheetRegion = { label: string; x: number; y: number; width: number; height: number; }; export type Match3DItemSpritesheetViewRegion = Match3DSpritesheetRegion & { itemIndex: number; itemName: string; viewIndex: number; }; export type DetectMatch3DSpritesheetRegionsInput = { alpha: ArrayLike; width: number; height: number; labels?: readonly string[]; minArea?: number; alphaThreshold?: number; }; export type Match3DDecodedSpritesheetRegion = Match3DSpritesheetRegion & { imageSrc: string; sheetWidth: number; sheetHeight: number; }; export type LoadMatch3DSpritesheetAssetRegionsInput = { source: string; labels?: readonly string[]; minArea?: number; alphaThreshold?: number; maxRegions?: number; signal?: AbortSignal; }; type Match3DDetectedComponent = Omit & { area: number; }; /** * 中文注释:AI spritesheet 只保证透明背景,不保证固定坐标;运行态和编辑器统一按 alpha 连通域识别独立素材矩形。 */ export function detectMatch3DSpritesheetRegions({ alpha, width, height, labels = [], minArea = 1, alphaThreshold = 0, }: DetectMatch3DSpritesheetRegionsInput): Match3DSpritesheetRegion[] { const pixelCount = width * height; if (width <= 0 || height <= 0 || alpha.length < pixelCount) { return []; } const visited = new Uint8Array(pixelCount); const components: Match3DDetectedComponent[] = []; for (let start = 0; start < pixelCount; start += 1) { if (visited[start] || (alpha[start] ?? 0) <= alphaThreshold) { continue; } const component = floodFillMatch3DSpritesheetComponent({ alpha, visited, width, height, start, alphaThreshold, }); if (component.area >= minArea) { components.push(component); } } return components .sort((left, right) => left.y - right.y || left.x - right.x) .map((component, index) => ({ label: labels[index] ?? `素材${index + 1}`, x: component.x, y: component.y, width: component.width, height: component.height, })); } export function buildMatch3DItemSpritesheetViewRegions< Region extends Match3DSpritesheetRegion, >( regions: readonly Region[], itemNames: readonly string[], ): Array<{ itemIndex: number; itemName: string; regions: Region[]; }> { if (regions.length <= 0 || itemNames.length <= 0) { return []; } const itemCount = Math.min(20, Math.floor(regions.length / 5)); return Array.from({ length: itemCount }, (_, itemIndex) => { const itemName = itemNames[itemIndex]?.trim() || `物品${itemIndex + 1}`; return { itemIndex, itemName, regions: regions.slice(itemIndex * 5, itemIndex * 5 + 5).map( (region, viewIndex): Region => ({ ...region, label: `${itemName}-形态${viewIndex + 1}`, }) as Region, ), }; }); } export async function loadMatch3DSpritesheetAssetRegions({ source, labels = [], minArea = 16, alphaThreshold = 8, maxRegions, signal, }: LoadMatch3DSpritesheetAssetRegionsInput): Promise< Match3DDecodedSpritesheetRegion[] > { const decoded = await decodeMatch3DSpritesheetImage(source, signal); if (signal?.aborted) { throw new DOMException('spritesheet 读取已取消', 'AbortError'); } const regions = detectMatch3DSpritesheetRegions({ alpha: decoded.alpha, width: decoded.width, height: decoded.height, labels, minArea, alphaThreshold, }).slice(0, maxRegions ?? Number.POSITIVE_INFINITY); return regions.map((region) => ({ ...region, imageSrc: cropMatch3DSpritesheetRegionToDataUrl(decoded.image, region), sheetWidth: decoded.width, sheetHeight: decoded.height, })); } function loadMatch3DSpritesheetImage(source: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败')); image.src = source; }); } async function readMatch3DSpritesheetImageSource( source: string, signal?: AbortSignal, ) { const response = await readAssetBytes(source, { signal, expireSeconds: 300, }); const blob = await response.blob(); const canCreateObjectUrl = typeof URL.createObjectURL === 'function' && typeof URL.revokeObjectURL === 'function'; if (canCreateObjectUrl) { return { imageSource: URL.createObjectURL(blob), revoke: (imageSource: string) => URL.revokeObjectURL(imageSource), }; } return { imageSource: await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result ?? '')); reader.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败')); reader.readAsDataURL(blob); }), revoke: () => {}, }; } async function decodeMatch3DSpritesheetImage( source: string, signal?: AbortSignal, ) { const { imageSource, revoke } = await readMatch3DSpritesheetImageSource( source, signal, ); try { const image = await loadMatch3DSpritesheetImage(imageSource); if (signal?.aborted) { throw new DOMException('spritesheet 读取已取消', 'AbortError'); } const width = Math.max(1, image.naturalWidth || image.width || 1); const height = Math.max(1, image.naturalHeight || image.height || 1); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d', { willReadFrequently: true, }); if (!context) { throw new Error('浏览器不支持解析抓大鹅 spritesheet'); } context.clearRect(0, 0, width, height); context.drawImage(image, 0, 0, width, height); const pixels = context.getImageData(0, 0, width, height).data; const alpha = new Uint8ClampedArray(width * height); for (let index = 0; index < alpha.length; index += 1) { alpha[index] = pixels[index * 4 + 3] ?? 0; } return { alpha, height, image, width, }; } finally { revoke(imageSource); } } function cropMatch3DSpritesheetRegionToDataUrl( image: HTMLImageElement, region: Match3DSpritesheetRegion, ) { const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(region.width)); canvas.height = Math.max(1, Math.round(region.height)); const context = canvas.getContext('2d'); if (!context) { throw new Error('浏览器不支持裁切抓大鹅 spritesheet'); } context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage( image, region.x, region.y, region.width, region.height, 0, 0, canvas.width, canvas.height, ); return canvas.toDataURL('image/png'); } function floodFillMatch3DSpritesheetComponent({ alpha, visited, width, height, start, alphaThreshold, }: { alpha: ArrayLike; visited: Uint8Array; width: number; height: number; start: number; alphaThreshold: number; }): Match3DDetectedComponent { const stack = [start]; visited[start] = 1; let minX = start % width; let maxX = minX; let minY = Math.floor(start / width); let maxY = minY; let area = 0; while (stack.length > 0) { const index = stack.pop()!; const x = index % width; const y = Math.floor(index / width); area += 1; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); visitMatch3DSpritesheetNeighbor( index - 1, x > 0, alpha, visited, stack, alphaThreshold, ); visitMatch3DSpritesheetNeighbor( index + 1, x + 1 < width, alpha, visited, stack, alphaThreshold, ); visitMatch3DSpritesheetNeighbor( index - width, y > 0, alpha, visited, stack, alphaThreshold, ); visitMatch3DSpritesheetNeighbor( index + width, y + 1 < height, alpha, visited, stack, alphaThreshold, ); } return { area, x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, }; } function visitMatch3DSpritesheetNeighbor( index: number, inBounds: boolean, alpha: ArrayLike, visited: Uint8Array, stack: number[], alphaThreshold: number, ) { if (!inBounds || visited[index] || (alpha[index] ?? 0) <= alphaThreshold) { return; } visited[index] = 1; stack.push(index); }