413 lines
10 KiB
TypeScript
413 lines
10 KiB
TypeScript
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<number>;
|
|
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<Match3DSpritesheetRegion, 'label'> & {
|
|
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 sortMatch3DSpritesheetComponentsByRows(components)
|
|
.map((component, index) => ({
|
|
label: labels[index] ?? `素材${index + 1}`,
|
|
x: component.x,
|
|
y: component.y,
|
|
width: component.width,
|
|
height: component.height,
|
|
}));
|
|
}
|
|
|
|
function sortMatch3DSpritesheetComponentsByRows(
|
|
components: Match3DDetectedComponent[],
|
|
) {
|
|
const rows: Array<{
|
|
top: number;
|
|
bottom: number;
|
|
components: Match3DDetectedComponent[];
|
|
}> = [];
|
|
|
|
[...components]
|
|
.sort(
|
|
(left, right) =>
|
|
resolveMatch3DSpritesheetComponentCenterY(left) -
|
|
resolveMatch3DSpritesheetComponentCenterY(right) ||
|
|
left.x - right.x,
|
|
)
|
|
.forEach((component) => {
|
|
const row = rows.find((entry) =>
|
|
isMatch3DSpritesheetComponentInRow(component, entry),
|
|
);
|
|
if (!row) {
|
|
rows.push({
|
|
top: component.y,
|
|
bottom: component.y + component.height - 1,
|
|
components: [component],
|
|
});
|
|
return;
|
|
}
|
|
|
|
row.top = Math.min(row.top, component.y);
|
|
row.bottom = Math.max(row.bottom, component.y + component.height - 1);
|
|
row.components.push(component);
|
|
});
|
|
|
|
return rows
|
|
.sort((left, right) => left.top - right.top)
|
|
.flatMap((row) =>
|
|
row.components.sort((left, right) => left.x - right.x || left.y - right.y),
|
|
);
|
|
}
|
|
|
|
function resolveMatch3DSpritesheetComponentCenterY(
|
|
component: Match3DDetectedComponent,
|
|
) {
|
|
return component.y + component.height / 2;
|
|
}
|
|
|
|
function isMatch3DSpritesheetComponentInRow(
|
|
component: Match3DDetectedComponent,
|
|
row: { top: number; bottom: number },
|
|
) {
|
|
const bottom = component.y + component.height - 1;
|
|
if (component.y <= row.bottom && bottom >= row.top) {
|
|
return true;
|
|
}
|
|
|
|
const gap =
|
|
component.y > row.bottom ? component.y - row.bottom : row.top - bottom;
|
|
return gap <= Math.max(2, component.height * 0.25);
|
|
}
|
|
|
|
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<HTMLImageElement>((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<string>((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<number>;
|
|
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<number>,
|
|
visited: Uint8Array,
|
|
stack: number[],
|
|
alphaThreshold: number,
|
|
) {
|
|
if (!inBounds || visited[index] || (alpha[index] ?? 0) <= alphaThreshold) {
|
|
return;
|
|
}
|
|
visited[index] = 1;
|
|
stack.push(index);
|
|
}
|