Files
Genarrative/src/services/match3dSpritesheetParser.ts
T

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);
}