perf: 优化合图功能
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -30,6 +30,7 @@
|
|||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"upng-js": "^2.1.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -8957,6 +8958,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/upng-js": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/upng-js/-/upng-js-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"upng-js": "^2.1.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Layers,
|
||||||
Music,
|
Music,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -136,7 +137,7 @@ function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
|||||||
<span className="ml-3">{t("home.hero.previewTitle")}</span>
|
<span className="ml-3">{t("home.hero.previewTitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
icon: Video,
|
icon: Video,
|
||||||
@@ -159,6 +160,13 @@ function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
|||||||
href: "/tools/audio-compress",
|
href: "/tools/audio-compress",
|
||||||
tint: "from-emerald-500/20",
|
tint: "from-emerald-500/20",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Layers,
|
||||||
|
title: t("home.tools.textureAtlas.title"),
|
||||||
|
description: t("home.tools.textureAtlas.description"),
|
||||||
|
href: "/tools/texture-atlas",
|
||||||
|
tint: "from-orange-500/20",
|
||||||
|
},
|
||||||
].map((tool) => (
|
].map((tool) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tool.href}
|
key={tool.href}
|
||||||
@@ -241,6 +249,13 @@ function ToolsShowcase({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
|||||||
href: "/tools/audio-compress",
|
href: "/tools/audio-compress",
|
||||||
gradient: "from-emerald-500/20 via-white/[0.03] to-transparent",
|
gradient: "from-emerald-500/20 via-white/[0.03] to-transparent",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Layers,
|
||||||
|
title: t("home.tools.textureAtlas.title"),
|
||||||
|
description: t("home.tools.textureAtlas.description"),
|
||||||
|
href: "/tools/texture-atlas",
|
||||||
|
gradient: "from-orange-500/20 via-white/[0.03] to-transparent",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -252,7 +267,7 @@ function ToolsShowcase({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
|||||||
description={t("home.showcase.description")}
|
description={t("home.showcase.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function Header() {
|
|||||||
{ name: displayT("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
{ name: displayT("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
||||||
{ name: displayT("sidebar.imageCompression"), href: "/tools/image-compress" },
|
{ name: displayT("sidebar.imageCompression"), href: "/tools/image-compress" },
|
||||||
{ name: displayT("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
{ name: displayT("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
||||||
|
{ name: displayT("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[mounted, locale]
|
[mounted, locale]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
|
import UPNG from "upng-js";
|
||||||
import {
|
import {
|
||||||
Settings2,
|
Settings2,
|
||||||
Box,
|
Box,
|
||||||
@@ -11,14 +12,17 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Layers
|
Layers,
|
||||||
|
Copy,
|
||||||
|
AlertTriangle,
|
||||||
|
Zap
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useAtlasStore } from "@/store/atlasStore";
|
import { useAtlasStore, type SingleAtlasResult } from "@/store/atlasStore";
|
||||||
import { useAtlasWorker } from "@/hooks/useAtlasWorker";
|
import { useAtlasWorker } from "@/hooks/useAtlasWorker";
|
||||||
import {
|
import {
|
||||||
exportToCocos2dPlist,
|
exportToCocos2dPlist,
|
||||||
@@ -131,6 +135,76 @@ function SelectOption<T extends string | number | boolean>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata content and filename for an atlas
|
||||||
|
*/
|
||||||
|
function getMetadataForAtlas(
|
||||||
|
atlas: SingleAtlasResult,
|
||||||
|
config: TextureAtlasConfig,
|
||||||
|
atlasIndex: number,
|
||||||
|
totalAtlases: number
|
||||||
|
): { content: string; filename: string; mimeType: string } {
|
||||||
|
const suffix = totalAtlases > 1 ? `_${atlasIndex}` : "";
|
||||||
|
const imageFilename = `atlas${suffix}.${config.format}`;
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
let filename: string;
|
||||||
|
let mimeType: string;
|
||||||
|
|
||||||
|
if (config.outputFormat === "cocos2d") {
|
||||||
|
content = exportToCocos2dPlist(atlas.placements, atlas.width, atlas.height, imageFilename);
|
||||||
|
filename = `atlas${suffix}.plist`;
|
||||||
|
mimeType = "application/xml";
|
||||||
|
} else if (config.outputFormat === "cocos-creator") {
|
||||||
|
content = exportToCocosCreatorJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format);
|
||||||
|
filename = `atlas${suffix}.json`;
|
||||||
|
mimeType = "application/json";
|
||||||
|
} else {
|
||||||
|
content = exportToGenericJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format);
|
||||||
|
filename = `atlas${suffix}.json`;
|
||||||
|
mimeType = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content, filename, mimeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress PNG data using UPNG quantization (similar to TinyPNG)
|
||||||
|
* @param dataUrl Original PNG data URL
|
||||||
|
* @param width Image width
|
||||||
|
* @param height Image height
|
||||||
|
* @returns Compressed PNG as Blob
|
||||||
|
*/
|
||||||
|
async function compressPng(dataUrl: string, width: number, height: number): Promise<Blob> {
|
||||||
|
// Create image from data URL
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw to canvas to get ImageData
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Failed to get canvas context");
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height);
|
||||||
|
|
||||||
|
// Compress using UPNG (256 colors = 8-bit PNG)
|
||||||
|
const compressed = UPNG.encode(
|
||||||
|
[imageData.data.buffer],
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
256 // 256 colors for best quality quantization
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Blob([compressed], { type: "image/png" });
|
||||||
|
}
|
||||||
|
|
||||||
export function AtlasConfigPanel() {
|
export function AtlasConfigPanel() {
|
||||||
const { t } = useSafeTranslation();
|
const { t } = useSafeTranslation();
|
||||||
const { pack } = useAtlasWorker();
|
const { pack } = useAtlasWorker();
|
||||||
@@ -140,11 +214,20 @@ export function AtlasConfigPanel() {
|
|||||||
config,
|
config,
|
||||||
result,
|
result,
|
||||||
status,
|
status,
|
||||||
|
currentAtlasIndex,
|
||||||
|
enableMultiAtlas,
|
||||||
|
enableCompression,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
resetConfig,
|
resetConfig,
|
||||||
|
setEnableMultiAtlas,
|
||||||
|
setEnableCompression,
|
||||||
openAnimationDialog,
|
openAnimationDialog,
|
||||||
} = useAtlasStore();
|
} = useAtlasStore();
|
||||||
|
|
||||||
|
// Get current atlas
|
||||||
|
const currentAtlas = result?.atlases[currentAtlasIndex] || null;
|
||||||
|
const atlasCount = result?.atlases.length || 0;
|
||||||
|
|
||||||
// Config handlers
|
// Config handlers
|
||||||
const handleConfigChange = useCallback(
|
const handleConfigChange = useCallback(
|
||||||
<K extends keyof TextureAtlasConfig>(key: K, value: TextureAtlasConfig[K]) => {
|
<K extends keyof TextureAtlasConfig>(key: K, value: TextureAtlasConfig[K]) => {
|
||||||
@@ -160,76 +243,90 @@ export function AtlasConfigPanel() {
|
|||||||
}
|
}
|
||||||
}, [sprites.length, pack]);
|
}, [sprites.length, pack]);
|
||||||
|
|
||||||
// Download functions
|
// Download current atlas image
|
||||||
const downloadImage = useCallback(() => {
|
const downloadImage = useCallback(async () => {
|
||||||
if (!result?.imageDataUrl) return;
|
if (!currentAtlas?.imageDataUrl) return;
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : "";
|
||||||
link.href = result.imageDataUrl;
|
const filename = `atlas${suffix}.${config.format}`;
|
||||||
link.download = `atlas.${config.format}`;
|
|
||||||
document.body.appendChild(link);
|
let url: string;
|
||||||
link.click();
|
let needRevoke = false;
|
||||||
document.body.removeChild(link);
|
|
||||||
}, [result, config.format]);
|
// Apply compression if enabled and format is PNG
|
||||||
|
if (enableCompression && config.format === "png") {
|
||||||
const getMetadataInfo = useCallback(() => {
|
const compressedBlob = await compressPng(
|
||||||
if (!result) return null;
|
currentAtlas.imageDataUrl,
|
||||||
|
currentAtlas.width,
|
||||||
let content: string;
|
currentAtlas.height
|
||||||
let filename: string;
|
);
|
||||||
let mimeType: string;
|
url = URL.createObjectURL(compressedBlob);
|
||||||
|
needRevoke = true;
|
||||||
const imageFilename = `atlas.${config.format}`;
|
|
||||||
|
|
||||||
if (config.outputFormat === "cocos2d") {
|
|
||||||
content = exportToCocos2dPlist(result.placements, result.width, result.height, imageFilename);
|
|
||||||
filename = "atlas.plist";
|
|
||||||
mimeType = "application/xml";
|
|
||||||
} else if (config.outputFormat === "cocos-creator") {
|
|
||||||
content = exportToCocosCreatorJson(result.placements, result.width, result.height, imageFilename, config.format);
|
|
||||||
filename = "atlas.json";
|
|
||||||
mimeType = "application/json";
|
|
||||||
} else {
|
} else {
|
||||||
content = exportToGenericJson(result.placements, result.width, result.height, imageFilename, config.format);
|
url = currentAtlas.imageDataUrl;
|
||||||
filename = "atlas.json";
|
|
||||||
mimeType = "application/json";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content, filename, mimeType };
|
|
||||||
}, [result, config.format, config.outputFormat]);
|
|
||||||
|
|
||||||
const downloadMetadata = useCallback(() => {
|
|
||||||
const info = getMetadataInfo();
|
|
||||||
if (!info) return;
|
|
||||||
|
|
||||||
const { content, filename, mimeType } = info;
|
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
if (needRevoke) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}, [currentAtlas, currentAtlasIndex, atlasCount, config.format, enableCompression]);
|
||||||
|
|
||||||
|
// Download current atlas metadata
|
||||||
|
const downloadMetadata = useCallback(() => {
|
||||||
|
if (!currentAtlas) return;
|
||||||
|
|
||||||
|
const info = getMetadataForAtlas(currentAtlas, config, currentAtlasIndex, atlasCount);
|
||||||
|
const blob = new Blob([info.content], { type: info.mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = info.filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [getMetadataInfo]);
|
}, [currentAtlas, config, currentAtlasIndex, atlasCount]);
|
||||||
|
|
||||||
|
// Download all atlases as zip
|
||||||
const downloadAll = useCallback(async () => {
|
const downloadAll = useCallback(async () => {
|
||||||
if (!result?.imageDataUrl) return;
|
if (!result || result.atlases.length === 0) return;
|
||||||
|
|
||||||
const info = getMetadataInfo();
|
|
||||||
if (!info) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const imageFilename = `atlas.${config.format}`;
|
const totalAtlases = result.atlases.length;
|
||||||
|
const shouldCompress = enableCompression && config.format === "png";
|
||||||
|
|
||||||
// Add image to zip
|
for (let i = 0; i < totalAtlases; i++) {
|
||||||
const base64Data = result.imageDataUrl.split(",")[1];
|
const atlas = result.atlases[i];
|
||||||
zip.file(imageFilename, base64Data, { base64: true });
|
if (!atlas.imageDataUrl) continue;
|
||||||
|
|
||||||
// Add metadata to zip
|
const suffix = totalAtlases > 1 ? `_${i}` : "";
|
||||||
zip.file(info.filename, info.content);
|
const imageFilename = `atlas${suffix}.${config.format}`;
|
||||||
|
|
||||||
|
// Add image to zip (with optional compression)
|
||||||
|
if (shouldCompress) {
|
||||||
|
const compressedBlob = await compressPng(
|
||||||
|
atlas.imageDataUrl,
|
||||||
|
atlas.width,
|
||||||
|
atlas.height
|
||||||
|
);
|
||||||
|
zip.file(imageFilename, compressedBlob);
|
||||||
|
} else {
|
||||||
|
const base64Data = atlas.imageDataUrl.split(",")[1];
|
||||||
|
zip.file(imageFilename, base64Data, { base64: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add metadata to zip
|
||||||
|
const metaInfo = getMetadataForAtlas(atlas, config, i, totalAtlases);
|
||||||
|
zip.file(metaInfo.filename, metaInfo.content);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate and download zip
|
// Generate and download zip
|
||||||
const blob = await zip.generateAsync({ type: "blob" });
|
const blob = await zip.generateAsync({ type: "blob" });
|
||||||
@@ -244,11 +341,11 @@ export function AtlasConfigPanel() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create zip:", error);
|
console.error("Failed to create zip:", error);
|
||||||
}
|
}
|
||||||
}, [result, config.format, getMetadataInfo]);
|
}, [result, config, enableCompression]);
|
||||||
|
|
||||||
// Check if can process
|
// Check if can process
|
||||||
const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering";
|
const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering";
|
||||||
const hasResult = !!result;
|
const hasResult = !!result && result.atlases.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl">
|
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl">
|
||||||
@@ -257,7 +354,7 @@ export function AtlasConfigPanel() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings2 className="h-4 w-4 text-primary" />
|
<Settings2 className="h-4 w-4 text-primary" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t("config.textureAtlas.title") || "合图设置"}
|
{t("config.textureAtlas.title")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -266,17 +363,17 @@ export function AtlasConfigPanel() {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={resetConfig}
|
onClick={resetConfig}
|
||||||
>
|
>
|
||||||
{t("common.reset") || "重置"}
|
{t("common.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config content */}
|
{/* Config content */}
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-6">
|
<div className="flex-1 overflow-y-auto p-3 space-y-6">
|
||||||
{/* Size settings */}
|
{/* Size settings */}
|
||||||
<ConfigSection icon={Box} title={t("atlas.sizeSettings") || "尺寸设置"}>
|
<ConfigSection icon={Box} title={t("atlas.sizeSettings")}>
|
||||||
<SliderOption
|
<SliderOption
|
||||||
id="maxWidth"
|
id="maxWidth"
|
||||||
label={t("config.textureAtlas.maxWidth") || "最大宽度"}
|
label={t("config.textureAtlas.maxWidth")}
|
||||||
value={config.maxWidth}
|
value={config.maxWidth}
|
||||||
min={256}
|
min={256}
|
||||||
max={4096}
|
max={4096}
|
||||||
@@ -286,7 +383,7 @@ export function AtlasConfigPanel() {
|
|||||||
/>
|
/>
|
||||||
<SliderOption
|
<SliderOption
|
||||||
id="maxHeight"
|
id="maxHeight"
|
||||||
label={t("config.textureAtlas.maxHeight") || "最大高度"}
|
label={t("config.textureAtlas.maxHeight")}
|
||||||
value={config.maxHeight}
|
value={config.maxHeight}
|
||||||
min={256}
|
min={256}
|
||||||
max={4096}
|
max={4096}
|
||||||
@@ -296,7 +393,7 @@ export function AtlasConfigPanel() {
|
|||||||
/>
|
/>
|
||||||
<SliderOption
|
<SliderOption
|
||||||
id="padding"
|
id="padding"
|
||||||
label={t("config.textureAtlas.padding") || "内边距"}
|
label={t("config.textureAtlas.padding")}
|
||||||
value={config.padding}
|
value={config.padding}
|
||||||
min={0}
|
min={0}
|
||||||
max={16}
|
max={16}
|
||||||
@@ -307,9 +404,9 @@ export function AtlasConfigPanel() {
|
|||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|
||||||
{/* Layout settings */}
|
{/* Layout settings */}
|
||||||
<ConfigSection icon={LayoutGrid} title={t("atlas.layoutSettings") || "布局设置"}>
|
<ConfigSection icon={LayoutGrid} title={t("atlas.layoutSettings")}>
|
||||||
<SelectOption
|
<SelectOption
|
||||||
label={t("config.textureAtlas.algorithm") || "打包算法"}
|
label={t("config.textureAtlas.algorithm")}
|
||||||
value={config.algorithm}
|
value={config.algorithm}
|
||||||
options={[
|
options={[
|
||||||
{ label: "MaxRects", value: "MaxRects" as const },
|
{ label: "MaxRects", value: "MaxRects" as const },
|
||||||
@@ -318,29 +415,29 @@ export function AtlasConfigPanel() {
|
|||||||
onChange={(v) => handleConfigChange("algorithm", v)}
|
onChange={(v) => handleConfigChange("algorithm", v)}
|
||||||
/>
|
/>
|
||||||
<SelectOption
|
<SelectOption
|
||||||
label={t("config.textureAtlas.allowRotation") || "允许旋转"}
|
label={t("config.textureAtlas.allowRotation")}
|
||||||
value={config.allowRotation}
|
value={config.allowRotation}
|
||||||
options={[
|
options={[
|
||||||
{ label: t("common.no") || "否", value: false },
|
{ label: t("common.no"), value: false },
|
||||||
{ label: t("common.yes") || "是", value: true },
|
{ label: t("common.yes"), value: true },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => handleConfigChange("allowRotation", v)}
|
onChange={(v) => handleConfigChange("allowRotation", v)}
|
||||||
/>
|
/>
|
||||||
<SelectOption
|
<SelectOption
|
||||||
label={t("config.textureAtlas.pot") || "2的幂次"}
|
label={t("config.textureAtlas.pot")}
|
||||||
value={config.pot}
|
value={config.pot}
|
||||||
options={[
|
options={[
|
||||||
{ label: t("common.no") || "否", value: false },
|
{ label: t("common.no"), value: false },
|
||||||
{ label: t("common.yes") || "是", value: true },
|
{ label: t("common.yes"), value: true },
|
||||||
]}
|
]}
|
||||||
onChange={(v) => handleConfigChange("pot", v)}
|
onChange={(v) => handleConfigChange("pot", v)}
|
||||||
/>
|
/>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|
||||||
{/* Output settings */}
|
{/* Output settings */}
|
||||||
<ConfigSection icon={FileOutput} title={t("atlas.outputSettings") || "输出设置"}>
|
<ConfigSection icon={FileOutput} title={t("atlas.outputSettings")}>
|
||||||
<SelectOption
|
<SelectOption
|
||||||
label={t("config.textureAtlas.format") || "图片格式"}
|
label={t("config.textureAtlas.format")}
|
||||||
value={config.format}
|
value={config.format}
|
||||||
options={[
|
options={[
|
||||||
{ label: "PNG", value: "png" as const },
|
{ label: "PNG", value: "png" as const },
|
||||||
@@ -351,7 +448,7 @@ export function AtlasConfigPanel() {
|
|||||||
{config.format === "webp" && (
|
{config.format === "webp" && (
|
||||||
<SliderOption
|
<SliderOption
|
||||||
id="quality"
|
id="quality"
|
||||||
label={t("config.textureAtlas.quality") || "质量"}
|
label={t("config.textureAtlas.quality")}
|
||||||
value={config.quality}
|
value={config.quality}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -361,7 +458,7 @@ export function AtlasConfigPanel() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SelectOption
|
<SelectOption
|
||||||
label={t("config.textureAtlas.outputFormat") || "数据格式"}
|
label={t("config.textureAtlas.outputFormat")}
|
||||||
value={config.outputFormat}
|
value={config.outputFormat}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Cocos2d plist", value: "cocos2d" as const },
|
{ label: "Cocos2d plist", value: "cocos2d" as const },
|
||||||
@@ -372,25 +469,106 @@ export function AtlasConfigPanel() {
|
|||||||
/>
|
/>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|
||||||
|
{/* Advanced settings - Multi-atlas mode */}
|
||||||
|
<ConfigSection icon={Copy} title={t("atlas.advancedSettings")}>
|
||||||
|
<SelectOption
|
||||||
|
label={t("config.textureAtlas.multiAtlas")}
|
||||||
|
value={enableMultiAtlas}
|
||||||
|
options={[
|
||||||
|
{ label: t("common.off"), value: false },
|
||||||
|
{ label: t("common.on"), value: true },
|
||||||
|
]}
|
||||||
|
onChange={(v) => setEnableMultiAtlas(v)}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||||
|
{t("atlas.multiAtlasHint")}
|
||||||
|
</p>
|
||||||
|
</ConfigSection>
|
||||||
|
|
||||||
|
{/* Compression settings */}
|
||||||
|
<ConfigSection icon={Zap} title={t("atlas.compressionSettings")}>
|
||||||
|
<SelectOption
|
||||||
|
label={t("config.textureAtlas.compression")}
|
||||||
|
value={enableCompression}
|
||||||
|
options={[
|
||||||
|
{ label: t("common.off"), value: false },
|
||||||
|
{ label: t("common.on"), value: true },
|
||||||
|
]}
|
||||||
|
onChange={(v) => setEnableCompression(v)}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||||
|
{t("atlas.compressionHint")}
|
||||||
|
</p>
|
||||||
|
</ConfigSection>
|
||||||
|
|
||||||
|
{/* Unpacked sprites warning */}
|
||||||
|
{result && result.unpackedSpriteIds.length > 0 && !enableMultiAtlas && (
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-950/40 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-amber-200">
|
||||||
|
{t("atlas.unpackedCount", { count: result.unpackedSpriteIds.length })}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-200/60 mt-1 leading-relaxed">
|
||||||
|
{t("atlas.unpackedSuggestion")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Result info */}
|
{/* Result info */}
|
||||||
{result && (
|
{hasResult && (
|
||||||
<Card className="bg-primary/5 border-primary/20">
|
<Card className="bg-primary/5 border-primary/20">
|
||||||
<CardHeader className="pb-2 pt-3 px-3">
|
<CardHeader className="pb-2 pt-3 px-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
<Layers className="h-4 w-4 text-primary" />
|
<Layers className="h-4 w-4 text-primary" />
|
||||||
{t("atlas.resultInfo") || "合图信息"}
|
{t("atlas.resultInfo")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-3 pb-3">
|
<CardContent className="px-3 pb-3">
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<div>
|
{atlasCount > 1 ? (
|
||||||
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions") || "尺寸"}:</span>
|
<>
|
||||||
<p className="font-medium">{result.width} × {result.height}</p>
|
<div>
|
||||||
</div>
|
<span className="text-muted-foreground">{t("atlas.atlasCount")}:</span>
|
||||||
<div>
|
<p className="font-medium">{atlasCount}</p>
|
||||||
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites") || "精灵数"}:</span>
|
</div>
|
||||||
<p className="font-medium">{result.placements.length}</p>
|
<div>
|
||||||
</div>
|
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
|
||||||
|
<p className="font-medium">{result.packedCount}</p>
|
||||||
|
</div>
|
||||||
|
{currentAtlas && (
|
||||||
|
<>
|
||||||
|
<div className="col-span-2 pt-1 border-t border-white/10">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("atlas.currentAtlas", { index: currentAtlasIndex + 1 })}:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions")}:</span>
|
||||||
|
<p className="font-medium">{currentAtlas.width} × {currentAtlas.height}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
|
||||||
|
<p className="font-medium">{currentAtlas.placements.length}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : currentAtlas && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions")}:</span>
|
||||||
|
<p className="font-medium">{currentAtlas.width} × {currentAtlas.height}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
|
||||||
|
<p className="font-medium">{currentAtlas.placements.length}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -409,12 +587,12 @@ export function AtlasConfigPanel() {
|
|||||||
{status === "packing" || status === "rendering" ? (
|
{status === "packing" || status === "rendering" ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{t("common.processing") || "处理中..."}
|
{t("common.processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
<Layers className="mr-2 h-4 w-4" />
|
||||||
{t("tools.textureAtlas.createAtlas") || "生成合图"}
|
{t("tools.textureAtlas.createAtlas")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -427,7 +605,7 @@ export function AtlasConfigPanel() {
|
|||||||
disabled={sprites.length < 2}
|
disabled={sprites.length < 2}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
{t("atlas.previewAnimation") || "预览动画"}
|
{t("atlas.previewAnimation")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Download buttons */}
|
{/* Download buttons */}
|
||||||
@@ -439,7 +617,7 @@ export function AtlasConfigPanel() {
|
|||||||
onClick={downloadImage}
|
onClick={downloadImage}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{t("tools.textureAtlas.downloadImage") || "图片"}
|
{t("tools.textureAtlas.downloadImage")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -447,7 +625,7 @@ export function AtlasConfigPanel() {
|
|||||||
onClick={downloadMetadata}
|
onClick={downloadMetadata}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
{t("tools.textureAtlas.downloadData") || "数据"}
|
{t("tools.textureAtlas.downloadData")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -459,7 +637,10 @@ export function AtlasConfigPanel() {
|
|||||||
onClick={downloadAll}
|
onClick={downloadAll}
|
||||||
>
|
>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
{t("tools.textureAtlas.downloadAll") || "打包下载"}
|
{atlasCount > 1
|
||||||
|
? t("atlas.downloadAllAtlases", { count: atlasCount })
|
||||||
|
: t("tools.textureAtlas.downloadAll")
|
||||||
|
}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Layers,
|
Layers,
|
||||||
Move,
|
Move,
|
||||||
Download
|
Download,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -37,7 +40,7 @@ export function CanvasPreview() {
|
|||||||
const { t } = useSafeTranslation();
|
const { t } = useSafeTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const imageCacheRef = useRef<{ url: string; image: HTMLImageElement } | null>(null);
|
const imageCacheRef = useRef<Map<number, { url: string; image: HTMLImageElement }>>(new Map());
|
||||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [hasMoved, setHasMoved] = useState(false);
|
const [hasMoved, setHasMoved] = useState(false);
|
||||||
@@ -48,18 +51,24 @@ export function CanvasPreview() {
|
|||||||
result,
|
result,
|
||||||
status,
|
status,
|
||||||
progress,
|
progress,
|
||||||
|
currentAtlasIndex,
|
||||||
previewScale,
|
previewScale,
|
||||||
previewOffset,
|
previewOffset,
|
||||||
selectedSpriteIds,
|
selectedSpriteIds,
|
||||||
setPreviewScale,
|
setPreviewScale,
|
||||||
setPreviewOffset,
|
setPreviewOffset,
|
||||||
|
setCurrentAtlasIndex,
|
||||||
selectSprite,
|
selectSprite,
|
||||||
openAnimationDialog,
|
openAnimationDialog,
|
||||||
} = useAtlasStore();
|
} = useAtlasStore();
|
||||||
|
|
||||||
|
// Get current atlas
|
||||||
|
const currentAtlas = result?.atlases[currentAtlasIndex] || null;
|
||||||
|
const atlasCount = result?.atlases.length || 0;
|
||||||
|
|
||||||
// Calculate dimensions
|
// Calculate dimensions
|
||||||
const atlasWidth = result?.width || 0;
|
const atlasWidth = currentAtlas?.width || 0;
|
||||||
const atlasHeight = result?.height || 0;
|
const atlasHeight = currentAtlas?.height || 0;
|
||||||
|
|
||||||
// Update container size on resize
|
// Update container size on resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,7 +112,7 @@ export function CanvasPreview() {
|
|||||||
ctx.fillStyle = "#0f0f11";
|
ctx.fillStyle = "#0f0f11";
|
||||||
ctx.fillRect(0, 0, cw, ch);
|
ctx.fillRect(0, 0, cw, ch);
|
||||||
|
|
||||||
if (result && result.imageDataUrl) {
|
if (currentAtlas && currentAtlas.imageDataUrl) {
|
||||||
const drawImage = (img: HTMLImageElement) => {
|
const drawImage = (img: HTMLImageElement) => {
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
ctx.fillStyle = "#0f0f11";
|
ctx.fillStyle = "#0f0f11";
|
||||||
@@ -132,7 +141,7 @@ export function CanvasPreview() {
|
|||||||
|
|
||||||
// Draw selection highlight for all selected sprites
|
// Draw selection highlight for all selected sprites
|
||||||
if (selectedSpriteIds.length > 0) {
|
if (selectedSpriteIds.length > 0) {
|
||||||
result.placements.forEach(p => {
|
currentAtlas.placements.forEach(p => {
|
||||||
if (selectedSpriteIds.includes(p.id)) {
|
if (selectedSpriteIds.includes(p.id)) {
|
||||||
const pw = (p.rotated ? p.height : p.width) * previewScale;
|
const pw = (p.rotated ? p.height : p.width) * previewScale;
|
||||||
const ph = (p.rotated ? p.width : p.height) * previewScale;
|
const ph = (p.rotated ? p.width : p.height) * previewScale;
|
||||||
@@ -194,15 +203,16 @@ export function CanvasPreview() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use cached image if URL matches
|
// Use cached image if URL matches
|
||||||
if (imageCacheRef.current && imageCacheRef.current.url === result.imageDataUrl) {
|
const cached = imageCacheRef.current.get(currentAtlasIndex);
|
||||||
drawImage(imageCacheRef.current.image);
|
if (cached && cached.url === currentAtlas.imageDataUrl) {
|
||||||
|
drawImage(cached.image);
|
||||||
} else {
|
} else {
|
||||||
// Load and draw the atlas image
|
// Load and draw the atlas image
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = result.imageDataUrl;
|
img.src = currentAtlas.imageDataUrl;
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageCacheRef.current = { url: result.imageDataUrl!, image: img };
|
imageCacheRef.current.set(currentAtlasIndex, { url: currentAtlas.imageDataUrl!, image: img });
|
||||||
drawImage(img);
|
drawImage(img);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -213,38 +223,48 @@ export function CanvasPreview() {
|
|||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
t("atlas.emptyPreview") || "上传精灵图后预览合图效果",
|
t("atlas.emptyPreview"),
|
||||||
cw / 2,
|
cw / 2,
|
||||||
ch / 2
|
ch / 2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [containerSize, result, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, t]);
|
}, [containerSize, currentAtlas, currentAtlasIndex, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, selectedSpriteIds, t]);
|
||||||
|
|
||||||
// Render loop for animation (highlights)
|
// Render loop for animation (selection highlight pulsing)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (selectedSpriteIds.length === 0) return;
|
||||||
|
|
||||||
let animationFrame: number;
|
let animationFrame: number;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
// Re-trigger the main render useEffect by some means or just call a separate draw function
|
if (!mounted) return;
|
||||||
// For simplicity, we can just use a dummy state to force re-render if needed,
|
|
||||||
// but here we already have selectedSpriteIds in the dependency array of the main render effect.
|
// Force re-render by updating containerSize (triggers canvas redraw)
|
||||||
// To get smooth pulsing, we can just request another frame.
|
setContainerSize(s => ({ ...s }));
|
||||||
if (selectedSpriteIds.length > 0) {
|
|
||||||
// This is a bit hacky but works for a canvas in React
|
|
||||||
// A better way would be to move drawing logic to a separate function
|
|
||||||
setContainerSize(s => ({ ...s }));
|
|
||||||
}
|
|
||||||
animationFrame = requestAnimationFrame(render);
|
animationFrame = requestAnimationFrame(render);
|
||||||
};
|
};
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(render);
|
animationFrame = requestAnimationFrame(render);
|
||||||
return () => cancelAnimationFrame(animationFrame);
|
return () => {
|
||||||
}, [selectedSpriteIds]);
|
mounted = false;
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
};
|
||||||
|
}, [selectedSpriteIds.length > 0]); // Only care about whether there are selections
|
||||||
|
|
||||||
// Handle wheel zoom
|
// Handle wheel zoom - use native event listener with passive: false
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
useEffect(() => {
|
||||||
e.preventDefault();
|
const container = containerRef.current;
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
if (!container) return;
|
||||||
setPreviewScale(previewScale + delta);
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
setPreviewScale(previewScale + delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||||
|
return () => container.removeEventListener("wheel", handleWheel);
|
||||||
}, [previewScale, setPreviewScale]);
|
}, [previewScale, setPreviewScale]);
|
||||||
|
|
||||||
// Handle mouse down for panning and picking
|
// Handle mouse down for panning and picking
|
||||||
@@ -274,7 +294,7 @@ export function CanvasPreview() {
|
|||||||
|
|
||||||
// Handle mouse up (Picking logic)
|
// Handle mouse up (Picking logic)
|
||||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||||
if (isPanning && !hasMoved && result && containerRef.current) {
|
if (isPanning && !hasMoved && currentAtlas && containerRef.current) {
|
||||||
// Pick sprite
|
// Pick sprite
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
@@ -291,7 +311,7 @@ export function CanvasPreview() {
|
|||||||
const atlasY = (mouseY - centerY) / previewScale;
|
const atlasY = (mouseY - centerY) / previewScale;
|
||||||
|
|
||||||
// Find sprite under cursor
|
// Find sprite under cursor
|
||||||
const clickedSprite = result.placements.find(p => {
|
const clickedSprite = currentAtlas.placements.find(p => {
|
||||||
const pw = p.rotated ? p.height : p.width;
|
const pw = p.rotated ? p.height : p.width;
|
||||||
const ph = p.rotated ? p.width : p.height;
|
const ph = p.rotated ? p.width : p.height;
|
||||||
return atlasX >= p.x && atlasX <= p.x + pw &&
|
return atlasX >= p.x && atlasX <= p.x + pw &&
|
||||||
@@ -305,11 +325,11 @@ export function CanvasPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsPanning(false);
|
setIsPanning(false);
|
||||||
}, [isPanning, hasMoved, result, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]);
|
}, [isPanning, hasMoved, currentAtlas, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]);
|
||||||
|
|
||||||
// Fit to view
|
// Fit to view
|
||||||
const fitToView = useCallback(() => {
|
const fitToView = useCallback(() => {
|
||||||
if (!result || containerSize.width === 0) return;
|
if (!currentAtlas || containerSize.width === 0) return;
|
||||||
|
|
||||||
const padding = 40;
|
const padding = 40;
|
||||||
const availableWidth = containerSize.width - padding * 2;
|
const availableWidth = containerSize.width - padding * 2;
|
||||||
@@ -321,7 +341,7 @@ export function CanvasPreview() {
|
|||||||
|
|
||||||
setPreviewScale(newScale);
|
setPreviewScale(newScale);
|
||||||
setPreviewOffset({ x: 0, y: 0 });
|
setPreviewOffset({ x: 0, y: 0 });
|
||||||
}, [result, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]);
|
}, [currentAtlas, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]);
|
||||||
|
|
||||||
// Zoom controls
|
// Zoom controls
|
||||||
const zoomIn = useCallback(() => setPreviewScale(previewScale + 0.1), [previewScale, setPreviewScale]);
|
const zoomIn = useCallback(() => setPreviewScale(previewScale + 0.1), [previewScale, setPreviewScale]);
|
||||||
@@ -329,15 +349,29 @@ export function CanvasPreview() {
|
|||||||
|
|
||||||
// Download image
|
// Download image
|
||||||
const downloadImage = useCallback(() => {
|
const downloadImage = useCallback(() => {
|
||||||
if (!result?.imageDataUrl) return;
|
if (!currentAtlas?.imageDataUrl) return;
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = result.imageDataUrl;
|
link.href = currentAtlas.imageDataUrl;
|
||||||
link.download = `atlas_${atlasWidth}x${atlasHeight}.png`;
|
const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : "";
|
||||||
|
link.download = `atlas${suffix}_${atlasWidth}x${atlasHeight}.png`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}, [result, atlasWidth, atlasHeight]);
|
}, [currentAtlas, currentAtlasIndex, atlasCount, atlasWidth, atlasHeight]);
|
||||||
|
|
||||||
|
// Navigate atlases
|
||||||
|
const goToPrevAtlas = useCallback(() => {
|
||||||
|
if (currentAtlasIndex > 0) {
|
||||||
|
setCurrentAtlasIndex(currentAtlasIndex - 1);
|
||||||
|
}
|
||||||
|
}, [currentAtlasIndex, setCurrentAtlasIndex]);
|
||||||
|
|
||||||
|
const goToNextAtlas = useCallback(() => {
|
||||||
|
if (currentAtlasIndex < atlasCount - 1) {
|
||||||
|
setCurrentAtlasIndex(currentAtlasIndex + 1);
|
||||||
|
}
|
||||||
|
}, [currentAtlasIndex, atlasCount, setCurrentAtlasIndex]);
|
||||||
|
|
||||||
// Scale percentage
|
// Scale percentage
|
||||||
const scalePercent = Math.round(previewScale * 100);
|
const scalePercent = Math.round(previewScale * 100);
|
||||||
@@ -349,9 +383,9 @@ export function CanvasPreview() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t("atlas.preview") || "预览"}
|
{t("atlas.preview")}
|
||||||
</span>
|
</span>
|
||||||
{result && (
|
{currentAtlas && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{atlasWidth} × {atlasHeight}
|
{atlasWidth} × {atlasHeight}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -359,6 +393,37 @@ export function CanvasPreview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Atlas navigation (only show when multiple atlases) */}
|
||||||
|
{atlasCount > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={goToPrevAtlas}
|
||||||
|
disabled={currentAtlasIndex === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="px-2 text-xs text-muted-foreground min-w-[60px] text-center">
|
||||||
|
{currentAtlasIndex + 1} / {atlasCount}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={goToNextAtlas}
|
||||||
|
disabled={currentAtlasIndex === atlasCount - 1}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-4 w-px bg-border/40" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoom controls */}
|
{/* Zoom controls */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -391,7 +456,7 @@ export function CanvasPreview() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={fitToView}
|
onClick={fitToView}
|
||||||
disabled={!result}
|
disabled={!currentAtlas}
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -405,7 +470,7 @@ export function CanvasPreview() {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={openAnimationDialog}
|
onClick={openAnimationDialog}
|
||||||
disabled={sprites.length < 2}
|
disabled={sprites.length < 2}
|
||||||
title={t("atlas.previewAnimation") || "预览动画"}
|
title={t("atlas.previewAnimation")}
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -416,8 +481,8 @@ export function CanvasPreview() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={downloadImage}
|
onClick={downloadImage}
|
||||||
disabled={!result?.imageDataUrl}
|
disabled={!currentAtlas?.imageDataUrl}
|
||||||
title={t("common.download") || "下载"}
|
title={t("common.download")}
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -428,7 +493,6 @@ export function CanvasPreview() {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative flex-1 cursor-grab overflow-hidden bg-[#0a0a0b] active:cursor-grabbing"
|
className="relative flex-1 cursor-grab overflow-hidden bg-[#0a0a0b] active:cursor-grabbing"
|
||||||
onWheel={handleWheel}
|
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
@@ -450,9 +514,9 @@ export function CanvasPreview() {
|
|||||||
>
|
>
|
||||||
<div className="mb-4 h-10 w-10 animate-spin rounded-full border-3 border-primary border-t-transparent" />
|
<div className="mb-4 h-10 w-10 animate-spin rounded-full border-3 border-primary border-t-transparent" />
|
||||||
<p className="mb-2 text-sm font-medium shadow-sm">
|
<p className="mb-2 text-sm font-medium shadow-sm">
|
||||||
{status === "packing" && (t("atlas.packing") || "打包中...")}
|
{status === "packing" && t("atlas.packing")}
|
||||||
{status === "rendering" && (t("atlas.rendering") || "渲染中...")}
|
{status === "rendering" && t("atlas.rendering")}
|
||||||
{status === "loading" && (t("common.loading") || "加载中...")}
|
{status === "loading" && t("common.loading")}
|
||||||
</p>
|
</p>
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-white/10 shadow-inner">
|
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-white/10 shadow-inner">
|
||||||
@@ -473,19 +537,45 @@ export function CanvasPreview() {
|
|||||||
<Layers className="h-8 w-8 text-muted-foreground/60" />
|
<Layers className="h-8 w-8 text-muted-foreground/60" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-1 text-sm font-medium text-muted-foreground">
|
<p className="mb-1 text-sm font-medium text-muted-foreground">
|
||||||
{t("atlas.emptyPreview") || "上传精灵图后预览合图效果"}
|
{t("atlas.emptyPreview")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/50">
|
<p className="text-xs text-muted-foreground/50">
|
||||||
{t("atlas.dragHint") || "拖拽文件或文件夹到左侧面板"}
|
{t("atlas.dragHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pan hint */}
|
{/* Pan hint */}
|
||||||
{result && !isPanning && (
|
{currentAtlas && !isPanning && result && result.unpackedSpriteIds.length === 0 && (
|
||||||
<div className="absolute bottom-4 left-4 flex items-center gap-1.5 rounded-lg bg-black/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-sm">
|
<div className="absolute bottom-4 left-4 flex items-center gap-1.5 rounded-lg bg-black/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-sm">
|
||||||
<Move className="h-3 w-3" />
|
<Move className="h-3 w-3" />
|
||||||
{t("atlas.panHint") || "拖拽平移,滚轮缩放"}
|
{t("atlas.panHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Multi-atlas indicator */}
|
||||||
|
{atlasCount > 1 && (
|
||||||
|
<div className="absolute top-4 left-4 flex items-center gap-2 rounded-lg bg-primary/20 border border-primary/30 px-3 py-1.5 text-xs text-primary backdrop-blur-sm">
|
||||||
|
<Layers className="h-3 w-3" />
|
||||||
|
{t("atlas.atlasIndex", { current: currentAtlasIndex + 1, total: atlasCount })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unpacked sprites warning */}
|
||||||
|
{result && result.unpackedSpriteIds.length > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-2 border-t border-amber-500/30 bg-amber-950/80 px-4 py-2.5 backdrop-blur-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-amber-200">
|
||||||
|
{t("atlas.unpackedWarning", { count: result.unpackedSpriteIds.length })}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-200/60 truncate">
|
||||||
|
{t("atlas.unpackedHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 border-amber-500/50 bg-amber-500/10 text-amber-300">
|
||||||
|
{result.unpackedSpriteIds.length} / {sprites.length}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
AlertCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAtlasStore, type BrowserSprite } from "@/store/atlasStore";
|
import { useAtlasStore, type BrowserSprite } from "@/store/atlasStore";
|
||||||
@@ -81,6 +82,7 @@ export function FileListPanel() {
|
|||||||
const {
|
const {
|
||||||
sprites,
|
sprites,
|
||||||
folderName,
|
folderName,
|
||||||
|
result,
|
||||||
addSprites,
|
addSprites,
|
||||||
removeSprite,
|
removeSprite,
|
||||||
clearSprites,
|
clearSprites,
|
||||||
@@ -89,6 +91,9 @@ export function FileListPanel() {
|
|||||||
selectSprite,
|
selectSprite,
|
||||||
setStatus,
|
setStatus,
|
||||||
} = useAtlasStore();
|
} = useAtlasStore();
|
||||||
|
|
||||||
|
// Get unpacked sprite IDs
|
||||||
|
const unpackedSpriteIds = result?.unpackedSpriteIds || [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process uploaded files
|
* Process uploaded files
|
||||||
@@ -363,6 +368,7 @@ export function FileListPanel() {
|
|||||||
group flex items-center gap-2.5 rounded-xl px-2.5 py-2 cursor-pointer
|
group flex items-center gap-2.5 rounded-xl px-2.5 py-2 cursor-pointer
|
||||||
transition-all duration-150 hover:bg-white/[0.06]
|
transition-all duration-150 hover:bg-white/[0.06]
|
||||||
${selectedSpriteIds.includes(sprite.id) ? "bg-primary/15 ring-1 ring-primary/30" : ""}
|
${selectedSpriteIds.includes(sprite.id) ? "bg-primary/15 ring-1 ring-primary/30" : ""}
|
||||||
|
${unpackedSpriteIds.includes(sprite.id) ? "bg-amber-500/10 border border-amber-500/30" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
@@ -389,11 +395,19 @@ export function FileListPanel() {
|
|||||||
}}
|
}}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
{/* Unpacked indicator */}
|
||||||
|
{unpackedSpriteIds.includes(sprite.id) && (
|
||||||
|
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-amber-500 shadow-sm">
|
||||||
|
<AlertCircle className="h-3 w-3 text-amber-950" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-xs font-medium">{sprite.name}</p>
|
<p className={`truncate text-xs font-medium ${unpackedSpriteIds.includes(sprite.id) ? "text-amber-300" : ""}`}>
|
||||||
|
{sprite.name}
|
||||||
|
</p>
|
||||||
<p className="text-[10px] text-muted-foreground/70">
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
{sprite.width} × {sprite.height}
|
{sprite.width} × {sprite.height}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for managing Atlas Worker communication
|
* Hook for managing Atlas Worker communication
|
||||||
|
* Supports multi-atlas mode and PNG compression
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback, useEffect } from "react";
|
import { useRef, useCallback, useEffect } from "react";
|
||||||
import { useAtlasStore, type BrowserSprite, type AtlasResult } from "@/store/atlasStore";
|
import { useAtlasStore, type BrowserSprite, type AtlasResult, type SingleAtlasResult } from "@/store/atlasStore";
|
||||||
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
|
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
|
||||||
import type { PackerPlacement } from "@/lib/atlas-packer";
|
import type { PackerPlacement, SinglePackerResult } from "@/lib/atlas-packer";
|
||||||
|
|
||||||
interface WorkerInputMessage {
|
interface WorkerInputMessage {
|
||||||
type: "pack";
|
type: "pack";
|
||||||
sprites: { id: string; name: string; width: number; height: number }[];
|
sprites: { id: string; name: string; width: number; height: number }[];
|
||||||
config: TextureAtlasConfig;
|
config: TextureAtlasConfig;
|
||||||
|
enableMultiAtlas: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkerOutputMessage {
|
interface WorkerOutputMessage {
|
||||||
type: "result" | "progress" | "error";
|
type: "result" | "progress" | "error";
|
||||||
result?: {
|
result?: {
|
||||||
width: number;
|
atlases: SinglePackerResult[];
|
||||||
height: number;
|
packedCount: number;
|
||||||
placements: PackerPlacement[];
|
unpackedSprites: { id: string; name: string; width: number; height: number }[];
|
||||||
};
|
};
|
||||||
progress?: number;
|
progress?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -26,6 +28,7 @@ interface WorkerOutputMessage {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render sprites to canvas and get data URL
|
* Render sprites to canvas and get data URL
|
||||||
|
* Always returns uncompressed data URL for preview
|
||||||
*/
|
*/
|
||||||
async function renderAtlasToCanvas(
|
async function renderAtlasToCanvas(
|
||||||
sprites: BrowserSprite[],
|
sprites: BrowserSprite[],
|
||||||
@@ -91,7 +94,7 @@ function buildFrames(placements: PackerPlacement[]): AtlasFrame[] {
|
|||||||
|
|
||||||
export function useAtlasWorker() {
|
export function useAtlasWorker() {
|
||||||
const workerRef = useRef<Worker | null>(null);
|
const workerRef = useRef<Worker | null>(null);
|
||||||
const { sprites, config, setStatus, setProgress, setError, setResult } = useAtlasStore();
|
const { sprites, config, enableMultiAtlas, setStatus, setProgress, setError, setResult } = useAtlasStore();
|
||||||
|
|
||||||
// Initialize worker
|
// Initialize worker
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,23 +118,44 @@ export function useAtlasWorker() {
|
|||||||
setProgress(50);
|
setProgress(50);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Render to canvas
|
|
||||||
const currentSprites = useAtlasStore.getState().sprites;
|
const currentSprites = useAtlasStore.getState().sprites;
|
||||||
const imageDataUrl = await renderAtlasToCanvas(
|
const atlasCount = result.atlases.length;
|
||||||
currentSprites,
|
const atlasResults: SingleAtlasResult[] = [];
|
||||||
result.placements,
|
|
||||||
result.width,
|
// Render each atlas
|
||||||
result.height
|
for (let i = 0; i < atlasCount; i++) {
|
||||||
);
|
const atlas = result.atlases[i];
|
||||||
|
|
||||||
|
const imageDataUrl = await renderAtlasToCanvas(
|
||||||
|
currentSprites,
|
||||||
|
atlas.placements,
|
||||||
|
atlas.width,
|
||||||
|
atlas.height
|
||||||
|
);
|
||||||
|
|
||||||
const frames = buildFrames(result.placements);
|
const frames = buildFrames(atlas.placements);
|
||||||
|
|
||||||
|
atlasResults.push({
|
||||||
|
index: atlas.index,
|
||||||
|
width: atlas.width,
|
||||||
|
height: atlas.height,
|
||||||
|
placements: atlas.placements,
|
||||||
|
frames,
|
||||||
|
imageDataUrl,
|
||||||
|
spriteIds: atlas.spriteIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update progress for rendering
|
||||||
|
setProgress(50 + ((i + 1) / atlasCount) * 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unpacked sprite IDs
|
||||||
|
const unpackedSpriteIds = result.unpackedSprites.map(s => s.id);
|
||||||
|
|
||||||
const atlasResult: AtlasResult = {
|
const atlasResult: AtlasResult = {
|
||||||
width: result.width,
|
atlases: atlasResults,
|
||||||
height: result.height,
|
packedCount: result.packedCount,
|
||||||
placements: result.placements,
|
unpackedSpriteIds,
|
||||||
frames,
|
|
||||||
imageDataUrl,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setResult(atlasResult);
|
setResult(atlasResult);
|
||||||
@@ -176,10 +200,11 @@ export function useAtlasWorker() {
|
|||||||
height: s.height,
|
height: s.height,
|
||||||
})),
|
})),
|
||||||
config,
|
config,
|
||||||
|
enableMultiAtlas,
|
||||||
};
|
};
|
||||||
|
|
||||||
workerRef.current.postMessage(message);
|
workerRef.current.postMessage(message);
|
||||||
}, [sprites, config, setStatus, setProgress, setError]);
|
}, [sprites, config, enableMultiAtlas, setStatus, setProgress, setError]);
|
||||||
|
|
||||||
return { pack };
|
return { pack };
|
||||||
}
|
}
|
||||||
@@ -371,148 +396,196 @@ class ShelfPacker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function packWithMaxRects(sprites, config, postProgress) {
|
function packSingleAtlas(sprites, config, atlasIndex) {
|
||||||
const padding = config.padding;
|
const padding = config.padding;
|
||||||
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
|
let packWidth = config.maxWidth;
|
||||||
const placements = new Map();
|
let packHeight = config.maxHeight;
|
||||||
const sorted = sortSpritesBySize(sprites);
|
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
if (config.pot) {
|
||||||
const sprite = sorted[i];
|
packWidth = adjustSizeForPot(packWidth, true);
|
||||||
const paddedWidth = sprite.width + padding * 2;
|
packHeight = adjustSizeForPot(packHeight, true);
|
||||||
const paddedHeight = sprite.height + padding * 2;
|
packWidth = Math.min(packWidth, config.maxWidth);
|
||||||
const position = packer.insert(paddedWidth, paddedHeight);
|
packHeight = Math.min(packHeight, config.maxHeight);
|
||||||
|
|
||||||
if (!position) return null;
|
|
||||||
|
|
||||||
placements.set(sprite.id, {
|
|
||||||
x: position.x + padding,
|
|
||||||
y: position.y + padding,
|
|
||||||
width: sprite.width,
|
|
||||||
height: sprite.height,
|
|
||||||
rotated: position.rotated,
|
|
||||||
});
|
|
||||||
|
|
||||||
postProgress(((i + 1) / sorted.length) * 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedSprites = sortSpritesBySize(sprites);
|
||||||
|
const placements = new Map();
|
||||||
|
const unpackedSprites = [];
|
||||||
|
|
||||||
return placements;
|
if (config.algorithm === "MaxRects") {
|
||||||
|
const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation);
|
||||||
|
|
||||||
|
for (const sprite of sortedSprites) {
|
||||||
|
const paddedWidth = sprite.width + padding * 2;
|
||||||
|
const paddedHeight = sprite.height + padding * 2;
|
||||||
|
|
||||||
|
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||||
|
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||||
|
const position = packer.insert(paddedHeight, paddedWidth);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = packer.insert(paddedWidth, paddedHeight);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: position.rotated,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding);
|
||||||
|
|
||||||
|
for (const sprite of sortedSprites) {
|
||||||
|
const paddedWidth = sprite.width + padding * 2;
|
||||||
|
const paddedHeight = sprite.height + padding * 2;
|
||||||
|
|
||||||
|
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||||
|
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||||
|
const position = packer.insert(sprite.height, sprite.width);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = packer.insert(sprite.width, sprite.height);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: position.rotated,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxX = 0, maxY = 0;
|
||||||
|
for (const p of placements.values()) {
|
||||||
|
const effectiveWidth = p.rotated ? p.height : p.width;
|
||||||
|
const effectiveHeight = p.rotated ? p.width : p.height;
|
||||||
|
maxX = Math.max(maxX, p.x + effectiveWidth + padding);
|
||||||
|
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalWidth, finalHeight;
|
||||||
|
|
||||||
|
if (config.pot) {
|
||||||
|
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
|
||||||
|
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
|
} else {
|
||||||
|
finalWidth = Math.ceil(maxX);
|
||||||
|
finalHeight = Math.ceil(maxY);
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultPlacements = [];
|
||||||
|
const spriteIds = [];
|
||||||
|
|
||||||
|
for (const sprite of sprites) {
|
||||||
|
const placement = placements.get(sprite.id);
|
||||||
|
if (placement) {
|
||||||
|
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
|
||||||
|
spriteIds.push(sprite.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
index: atlasIndex,
|
||||||
|
width: finalWidth || 1,
|
||||||
|
height: finalHeight || 1,
|
||||||
|
placements: resultPlacements,
|
||||||
|
spriteIds,
|
||||||
|
},
|
||||||
|
unpackedSprites,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function packWithShelf(sprites, config, postProgress) {
|
function packSprites(sprites, config, enableMultiAtlas, postProgress) {
|
||||||
const padding = config.padding;
|
|
||||||
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
|
|
||||||
const placements = new Map();
|
|
||||||
const sorted = sortSpritesBySize(sprites);
|
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
|
||||||
const sprite = sorted[i];
|
|
||||||
const position = packer.insert(sprite.width, sprite.height);
|
|
||||||
|
|
||||||
if (!position) return null;
|
|
||||||
|
|
||||||
placements.set(sprite.id, {
|
|
||||||
x: position.x + padding,
|
|
||||||
y: position.y + padding,
|
|
||||||
width: sprite.width,
|
|
||||||
height: sprite.height,
|
|
||||||
rotated: position.rotated,
|
|
||||||
});
|
|
||||||
|
|
||||||
postProgress(((i + 1) / sorted.length) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return placements;
|
|
||||||
}
|
|
||||||
|
|
||||||
function packSprites(sprites, config, postProgress) {
|
|
||||||
if (sprites.length === 0) return null;
|
if (sprites.length === 0) return null;
|
||||||
|
|
||||||
const padding = config.padding;
|
const atlases = [];
|
||||||
const maxSpriteWidth = Math.max(...sprites.map(s => s.width));
|
let remainingSprites = [...sprites];
|
||||||
const maxSpriteHeight = Math.max(...sprites.map(s => s.height));
|
let atlasIndex = 0;
|
||||||
const totalArea = sprites.reduce((sum, s) => sum + (s.width + padding * 2) * (s.height + padding * 2), 0);
|
let totalProcessed = 0;
|
||||||
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
|
|
||||||
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
|
while (remainingSprites.length > 0) {
|
||||||
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
|
const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex);
|
||||||
|
|
||||||
const sizeAttempts = [];
|
if (result.placements.length === 0) {
|
||||||
|
break;
|
||||||
if (config.pot) {
|
|
||||||
const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(s => s <= config.maxWidth || s <= config.maxHeight);
|
|
||||||
for (const w of potSizes) {
|
|
||||||
for (const h of potSizes) {
|
|
||||||
if (w <= config.maxWidth && h <= config.maxHeight && w >= maxSpriteWidth + padding * 2 && h >= maxSpriteHeight + padding * 2) {
|
|
||||||
sizeAttempts.push({ w, h });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
|
|
||||||
} else {
|
atlases.push(result);
|
||||||
sizeAttempts.push(
|
totalProcessed += result.placements.length;
|
||||||
{ w: estimatedWidth, h: estimatedHeight },
|
postProgress((totalProcessed / sprites.length) * 100);
|
||||||
{ w: estimatedWidth * 1.5, h: estimatedHeight },
|
|
||||||
{ w: estimatedWidth, h: estimatedHeight * 1.5 },
|
if (!enableMultiAtlas) {
|
||||||
{ w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 },
|
return {
|
||||||
{ w: estimatedWidth * 2, h: estimatedHeight },
|
atlases,
|
||||||
{ w: estimatedWidth, h: estimatedHeight * 2 },
|
packedCount: result.placements.length,
|
||||||
{ w: estimatedWidth * 2, h: estimatedHeight * 2 },
|
unpackedSprites,
|
||||||
{ w: config.maxWidth, h: config.maxHeight }
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => {
|
|
||||||
const w = Math.min(Math.ceil(attempt.w), config.maxWidth);
|
|
||||||
const h = Math.min(Math.ceil(attempt.h), config.maxHeight);
|
|
||||||
return self.findIndex(a => Math.min(Math.ceil(a.w), config.maxWidth) === w && Math.min(Math.ceil(a.h), config.maxHeight) === h) === index;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const attempt of uniqueAttempts) {
|
|
||||||
const attemptWidth = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w), config.maxWidth);
|
|
||||||
const attemptHeight = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h), config.maxHeight);
|
|
||||||
|
|
||||||
if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) continue;
|
|
||||||
|
|
||||||
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
|
|
||||||
let placements;
|
|
||||||
|
|
||||||
if (config.algorithm === "MaxRects") {
|
|
||||||
placements = packWithMaxRects(sprites, testConfig, postProgress);
|
|
||||||
} else {
|
|
||||||
placements = packWithShelf(sprites, testConfig, postProgress);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placements) {
|
remainingSprites = unpackedSprites;
|
||||||
let maxX = 0, maxY = 0;
|
atlasIndex++;
|
||||||
for (const p of placements.values()) {
|
|
||||||
const effectiveWidth = p.rotated ? p.height : p.width;
|
if (atlasIndex > 100) {
|
||||||
const effectiveHeight = p.rotated ? p.width : p.height;
|
console.warn("Max atlas limit reached");
|
||||||
maxX = Math.max(maxX, p.x + effectiveWidth + padding);
|
break;
|
||||||
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
|
|
||||||
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
|
|
||||||
finalWidth = Math.min(finalWidth, attemptWidth);
|
|
||||||
finalHeight = Math.min(finalHeight, attemptHeight);
|
|
||||||
|
|
||||||
const resultPlacements = [];
|
|
||||||
for (const sprite of sprites) {
|
|
||||||
const placement = placements.get(sprite.id);
|
|
||||||
if (placement) {
|
|
||||||
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { width: finalWidth, height: finalHeight, placements: resultPlacements };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (atlases.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
atlases,
|
||||||
|
packedCount: totalPacked,
|
||||||
|
unpackedSprites: remainingSprites,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = function(event) {
|
self.onmessage = function(event) {
|
||||||
const { type, sprites, config } = event.data;
|
const { type, sprites, config, enableMultiAtlas } = event.data;
|
||||||
|
|
||||||
if (type === "pack") {
|
if (type === "pack") {
|
||||||
try {
|
try {
|
||||||
@@ -520,12 +593,12 @@ self.onmessage = function(event) {
|
|||||||
self.postMessage({ type: "progress", progress });
|
self.postMessage({ type: "progress", progress });
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = packSprites(sprites, config, postProgress);
|
const result = packSprites(sprites, config, enableMultiAtlas, postProgress);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
self.postMessage({ type: "result", result });
|
self.postMessage({ type: "result", result });
|
||||||
} else {
|
} else {
|
||||||
self.postMessage({ type: "error", error: "Failed to pack all sprites. Try increasing max size or enabling rotation." });
|
self.postMessage({ type: "error", error: "No sprites could be packed. Check sprite sizes and max dimensions." });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.postMessage({ type: "error", error: error.message || "Unknown error" });
|
self.postMessage({ type: "error", error: error.message || "Unknown error" });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Browser-side Texture Atlas Packing Algorithms
|
* Browser-side Texture Atlas Packing Algorithms
|
||||||
* Implements MaxRects and Shelf algorithms for packing sprites
|
* Implements MaxRects and Shelf algorithms for packing sprites
|
||||||
|
* Supports multi-atlas mode (like TexturePacker)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TextureAtlasConfig } from "@/types";
|
import type { TextureAtlasConfig } from "@/types";
|
||||||
@@ -39,12 +40,24 @@ export interface PackerPlacement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete packing result
|
* Single atlas packing result
|
||||||
*/
|
*/
|
||||||
export interface PackerResult {
|
export interface SinglePackerResult {
|
||||||
|
index: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
placements: PackerPlacement[];
|
placements: PackerPlacement[];
|
||||||
|
spriteIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete packing result (supports multiple atlases)
|
||||||
|
*/
|
||||||
|
export interface PackerResult {
|
||||||
|
atlases: SinglePackerResult[];
|
||||||
|
packedCount: number;
|
||||||
|
/** Sprites that couldn't fit (only when multi-atlas is disabled) */
|
||||||
|
unpackedSprites: PackerSprite[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,202 +359,233 @@ class ShelfPacker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pack sprites using MaxRects algorithm
|
* Pack sprites into a single atlas
|
||||||
|
* Returns placements and unpacked sprites
|
||||||
*/
|
*/
|
||||||
function packWithMaxRects(
|
function packSingleAtlas(
|
||||||
sprites: PackerSprite[],
|
sprites: PackerSprite[],
|
||||||
config: TextureAtlasConfig
|
config: TextureAtlasConfig,
|
||||||
): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
|
atlasIndex: number
|
||||||
|
): { result: SinglePackerResult; unpackedSprites: PackerSprite[] } {
|
||||||
const padding = config.padding;
|
const padding = config.padding;
|
||||||
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
|
|
||||||
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
|
// Use the max dimensions directly for packing
|
||||||
|
let packWidth = config.maxWidth;
|
||||||
for (const sprite of sortSpritesBySize(sprites)) {
|
let packHeight = config.maxHeight;
|
||||||
const paddedWidth = sprite.width + padding * 2;
|
|
||||||
const paddedHeight = sprite.height + padding * 2;
|
// For POT mode, ensure dimensions are power of two
|
||||||
|
if (config.pot) {
|
||||||
const position = packer.insert(paddedWidth, paddedHeight);
|
packWidth = adjustSizeForPot(packWidth, true);
|
||||||
|
packHeight = adjustSizeForPot(packHeight, true);
|
||||||
if (!position) {
|
// Clamp to max
|
||||||
return null; // Failed to pack
|
packWidth = Math.min(packWidth, config.maxWidth);
|
||||||
}
|
packHeight = Math.min(packHeight, config.maxHeight);
|
||||||
|
|
||||||
placements.set(sprite.id, {
|
|
||||||
x: position.x + padding,
|
|
||||||
y: position.y + padding,
|
|
||||||
width: sprite.width,
|
|
||||||
height: sprite.height,
|
|
||||||
rotated: position.rotated,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return placements;
|
// Sort sprites by size (largest first) for better packing
|
||||||
}
|
const sortedSprites = sortSpritesBySize(sprites);
|
||||||
|
|
||||||
/**
|
// Pack sprites one by one, tracking which ones fit
|
||||||
* Pack sprites using Shelf algorithm
|
|
||||||
*/
|
|
||||||
function packWithShelf(
|
|
||||||
sprites: PackerSprite[],
|
|
||||||
config: TextureAtlasConfig
|
|
||||||
): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
|
|
||||||
const padding = config.padding;
|
|
||||||
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
|
|
||||||
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
|
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
|
||||||
|
const unpackedSprites: PackerSprite[] = [];
|
||||||
for (const sprite of sortSpritesBySize(sprites)) {
|
|
||||||
const position = packer.insert(sprite.width, sprite.height);
|
// Create packer with max dimensions
|
||||||
|
if (config.algorithm === "MaxRects") {
|
||||||
if (!position) {
|
const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation);
|
||||||
return null;
|
|
||||||
|
for (const sprite of sortedSprites) {
|
||||||
|
const paddedWidth = sprite.width + padding * 2;
|
||||||
|
const paddedHeight = sprite.height + padding * 2;
|
||||||
|
|
||||||
|
// Check if sprite is too large even without packing
|
||||||
|
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||||
|
// Try rotated if allowed
|
||||||
|
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||||
|
const position = packer.insert(paddedHeight, paddedWidth);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = packer.insert(paddedWidth, paddedHeight);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: position.rotated,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Shelf algorithm
|
||||||
|
const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding);
|
||||||
|
|
||||||
|
for (const sprite of sortedSprites) {
|
||||||
|
const paddedWidth = sprite.width + padding * 2;
|
||||||
|
const paddedHeight = sprite.height + padding * 2;
|
||||||
|
|
||||||
|
// Check if sprite is too large
|
||||||
|
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||||
|
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||||
|
const position = packer.insert(sprite.height, sprite.width);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = packer.insert(sprite.width, sprite.height);
|
||||||
|
if (position) {
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: position.rotated,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unpackedSprites.push(sprite);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
placements.set(sprite.id, {
|
|
||||||
x: position.x + padding,
|
|
||||||
y: position.y + padding,
|
|
||||||
width: sprite.width,
|
|
||||||
height: sprite.height,
|
|
||||||
rotated: position.rotated,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return placements;
|
// Calculate actual dimensions based on sprite placements
|
||||||
|
let maxX = 0;
|
||||||
|
let maxY = 0;
|
||||||
|
for (const placement of placements.values()) {
|
||||||
|
const effectiveWidth = placement.rotated ? placement.height : placement.width;
|
||||||
|
const effectiveHeight = placement.rotated ? placement.width : placement.height;
|
||||||
|
maxX = Math.max(maxX, placement.x + effectiveWidth + padding);
|
||||||
|
maxY = Math.max(maxY, placement.y + effectiveHeight + padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the minimum required dimensions
|
||||||
|
let finalWidth: number;
|
||||||
|
let finalHeight: number;
|
||||||
|
|
||||||
|
if (config.pot) {
|
||||||
|
finalWidth = nextPowerOfTwo(Math.ceil(maxX));
|
||||||
|
finalHeight = nextPowerOfTwo(Math.ceil(maxY));
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
|
} else {
|
||||||
|
finalWidth = Math.ceil(maxX);
|
||||||
|
finalHeight = Math.ceil(maxY);
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result placements in original order
|
||||||
|
const resultPlacements: PackerPlacement[] = [];
|
||||||
|
const spriteIds: string[] = [];
|
||||||
|
|
||||||
|
for (const sprite of sprites) {
|
||||||
|
const placement = placements.get(sprite.id);
|
||||||
|
if (placement) {
|
||||||
|
resultPlacements.push({
|
||||||
|
id: sprite.id,
|
||||||
|
name: sprite.name,
|
||||||
|
...placement,
|
||||||
|
});
|
||||||
|
spriteIds.push(sprite.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
index: atlasIndex,
|
||||||
|
width: finalWidth || 1,
|
||||||
|
height: finalHeight || 1,
|
||||||
|
placements: resultPlacements,
|
||||||
|
spriteIds,
|
||||||
|
},
|
||||||
|
unpackedSprites,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main packing function
|
* Main packing function
|
||||||
|
* Supports multi-atlas mode
|
||||||
*/
|
*/
|
||||||
export function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig): PackerResult | null {
|
export function packSprites(
|
||||||
|
sprites: PackerSprite[],
|
||||||
|
config: TextureAtlasConfig,
|
||||||
|
enableMultiAtlas: boolean = false
|
||||||
|
): PackerResult | null {
|
||||||
if (sprites.length === 0) {
|
if (sprites.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const padding = config.padding;
|
const atlases: SinglePackerResult[] = [];
|
||||||
const maxSpriteWidth = Math.max(...sprites.map((s) => s.width));
|
let remainingSprites = [...sprites];
|
||||||
const maxSpriteHeight = Math.max(...sprites.map((s) => s.height));
|
let atlasIndex = 0;
|
||||||
|
|
||||||
// Calculate total area for estimation
|
// Pack into atlases
|
||||||
const totalArea = sprites.reduce((sum, s) => {
|
while (remainingSprites.length > 0) {
|
||||||
const pw = s.width + padding * 2;
|
const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex);
|
||||||
const ph = s.height + padding * 2;
|
|
||||||
return sum + pw * ph;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Start with estimated size
|
|
||||||
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
|
|
||||||
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
|
|
||||||
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
|
|
||||||
|
|
||||||
// Build size attempts
|
|
||||||
const sizeAttempts: { w: number; h: number }[] = [];
|
|
||||||
|
|
||||||
if (config.pot) {
|
|
||||||
const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(
|
|
||||||
(s) => s <= config.maxWidth || s <= config.maxHeight
|
|
||||||
);
|
|
||||||
for (const w of potSizes) {
|
|
||||||
for (const h of potSizes) {
|
|
||||||
if (
|
|
||||||
w <= config.maxWidth &&
|
|
||||||
h <= config.maxHeight &&
|
|
||||||
w >= maxSpriteWidth + padding * 2 &&
|
|
||||||
h >= maxSpriteHeight + padding * 2
|
|
||||||
) {
|
|
||||||
sizeAttempts.push({ w, h });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
|
|
||||||
} else {
|
|
||||||
sizeAttempts.push(
|
|
||||||
{ w: estimatedWidth, h: estimatedHeight },
|
|
||||||
{ w: estimatedWidth * 1.5, h: estimatedHeight },
|
|
||||||
{ w: estimatedWidth, h: estimatedHeight * 1.5 },
|
|
||||||
{ w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 },
|
|
||||||
{ w: estimatedWidth * 2, h: estimatedHeight },
|
|
||||||
{ w: estimatedWidth, h: estimatedHeight * 2 },
|
|
||||||
{ w: estimatedWidth * 2, h: estimatedHeight * 2 },
|
|
||||||
{ w: config.maxWidth, h: config.maxHeight }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates
|
|
||||||
const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => {
|
|
||||||
const w = Math.min(Math.ceil(attempt.w), config.maxWidth);
|
|
||||||
const h = Math.min(Math.ceil(attempt.h), config.maxHeight);
|
|
||||||
return (
|
|
||||||
self.findIndex(
|
|
||||||
(a) =>
|
|
||||||
Math.min(Math.ceil(a.w), config.maxWidth) === w &&
|
|
||||||
Math.min(Math.ceil(a.h), config.maxHeight) === h
|
|
||||||
) === index
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try each size
|
|
||||||
for (const attempt of uniqueAttempts) {
|
|
||||||
const attemptWidth = Math.min(
|
|
||||||
config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w),
|
|
||||||
config.maxWidth
|
|
||||||
);
|
|
||||||
const attemptHeight = Math.min(
|
|
||||||
config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h),
|
|
||||||
config.maxHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
|
|
||||||
|
|
||||||
let placements: Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null;
|
// If nothing was packed, we have sprites that are too large
|
||||||
|
if (result.placements.length === 0) {
|
||||||
if (config.algorithm === "MaxRects") {
|
break;
|
||||||
placements = packWithMaxRects(sprites, testConfig);
|
|
||||||
} else {
|
|
||||||
placements = packWithShelf(sprites, testConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placements) {
|
atlases.push(result);
|
||||||
// Calculate actual dimensions
|
|
||||||
let maxX = 0;
|
// If multi-atlas is disabled, stop after first atlas
|
||||||
let maxY = 0;
|
if (!enableMultiAtlas) {
|
||||||
for (const placement of placements.values()) {
|
// Return with unpacked sprites info
|
||||||
const effectiveWidth = placement.rotated ? placement.height : placement.width;
|
|
||||||
const effectiveHeight = placement.rotated ? placement.width : placement.height;
|
|
||||||
maxX = Math.max(maxX, placement.x + effectiveWidth + padding);
|
|
||||||
maxY = Math.max(maxY, placement.y + effectiveHeight + padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
|
|
||||||
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
|
|
||||||
|
|
||||||
finalWidth = Math.min(finalWidth, attemptWidth);
|
|
||||||
finalHeight = Math.min(finalHeight, attemptHeight);
|
|
||||||
|
|
||||||
// Build result
|
|
||||||
const resultPlacements: PackerPlacement[] = [];
|
|
||||||
for (const sprite of sprites) {
|
|
||||||
const placement = placements.get(sprite.id);
|
|
||||||
if (placement) {
|
|
||||||
resultPlacements.push({
|
|
||||||
id: sprite.id,
|
|
||||||
name: sprite.name,
|
|
||||||
...placement,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: finalWidth,
|
atlases,
|
||||||
height: finalHeight,
|
packedCount: result.placements.length,
|
||||||
placements: resultPlacements,
|
unpackedSprites,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Continue with remaining sprites
|
||||||
|
remainingSprites = unpackedSprites;
|
||||||
|
atlasIndex++;
|
||||||
|
|
||||||
|
// Safety limit to prevent infinite loops
|
||||||
|
if (atlasIndex > 100) {
|
||||||
|
console.warn("Max atlas limit reached");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atlases.length === 0) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
atlases,
|
||||||
|
packedCount: totalPacked,
|
||||||
|
unpackedSprites: remainingSprites,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -358,10 +358,25 @@ function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig, postPr
|
|||||||
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
|
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
|
// Calculate the minimum required dimensions based on actual content
|
||||||
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
|
let finalWidth: number;
|
||||||
finalWidth = Math.min(finalWidth, attemptWidth);
|
let finalHeight: number;
|
||||||
finalHeight = Math.min(finalHeight, attemptHeight);
|
|
||||||
|
if (config.pot) {
|
||||||
|
// Find smallest POT size that fits the actual content
|
||||||
|
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
|
||||||
|
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
|
||||||
|
// Ensure we don't exceed max limits
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
|
} else {
|
||||||
|
// For non-POT, use exact dimensions needed
|
||||||
|
finalWidth = Math.ceil(maxX);
|
||||||
|
finalHeight = Math.ceil(maxY);
|
||||||
|
// Ensure we don't exceed max limits
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
const resultPlacements: PackerPlacement[] = [];
|
const resultPlacements: PackerPlacement[] = [];
|
||||||
for (const sprite of sprites) {
|
for (const sprite of sprites) {
|
||||||
|
|||||||
@@ -540,16 +540,25 @@ export async function createTextureAtlas(
|
|||||||
maxY = Math.max(maxY, placement.y + placement.height + padding);
|
maxY = Math.max(maxY, placement.y + placement.height + padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust final dimensions based on actual usage if POT
|
// Calculate the minimum required dimensions based on actual content
|
||||||
|
// For POT mode: find the smallest power of two that fits the actual content
|
||||||
|
// For non-POT mode: use the actual content size
|
||||||
if (config.pot) {
|
if (config.pot) {
|
||||||
finalWidth = adjustSizeForPot(maxX, true);
|
// Find smallest POT size that fits the actual content
|
||||||
finalHeight = adjustSizeForPot(maxY, true);
|
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
|
||||||
// Make sure we don't exceed attempted dimensions
|
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
|
||||||
finalWidth = Math.min(finalWidth, attemptWidth);
|
|
||||||
finalHeight = Math.min(finalHeight, attemptHeight);
|
// Ensure we don't exceed max limits
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
} else {
|
} else {
|
||||||
|
// For non-POT, use exact dimensions needed
|
||||||
finalWidth = Math.ceil(maxX);
|
finalWidth = Math.ceil(maxX);
|
||||||
finalHeight = Math.ceil(maxY);
|
finalHeight = Math.ceil(maxY);
|
||||||
|
|
||||||
|
// Ensure we don't exceed max limits
|
||||||
|
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
success = true;
|
success = true;
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
"file": "File",
|
"file": "File",
|
||||||
"files": "files",
|
"files": "files",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
@@ -120,6 +122,10 @@
|
|||||||
"title": "Audio Compression",
|
"title": "Audio Compression",
|
||||||
"description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate."
|
"description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate."
|
||||||
},
|
},
|
||||||
|
"textureAtlas": {
|
||||||
|
"title": "Texture Atlas",
|
||||||
|
"description": "Combine multiple sprites into optimized texture atlases. Perfect for game performance."
|
||||||
|
},
|
||||||
"aiTools": {
|
"aiTools": {
|
||||||
"title": "More Tools",
|
"title": "More Tools",
|
||||||
"description": "Additional utilities for game development. Coming soon."
|
"description": "Additional utilities for game development. Coming soon."
|
||||||
@@ -240,7 +246,11 @@
|
|||||||
"outputCocosCreator": "Cocos Creator JSON",
|
"outputCocosCreator": "Cocos Creator JSON",
|
||||||
"outputGeneric": "Generic JSON",
|
"outputGeneric": "Generic JSON",
|
||||||
"algorithmMaxRects": "MaxRects (Best)",
|
"algorithmMaxRects": "MaxRects (Best)",
|
||||||
"algorithmShelf": "Shelf (Fast)"
|
"algorithmShelf": "Shelf (Fast)",
|
||||||
|
"multiAtlas": "Multi-Atlas",
|
||||||
|
"multiAtlasDescription": "Auto pack overflow sprites into multiple atlases",
|
||||||
|
"compression": "PNG Compression",
|
||||||
|
"compressionDescription": "Compress PNG using quantization algorithm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -355,7 +365,19 @@
|
|||||||
"fps": "FPS",
|
"fps": "FPS",
|
||||||
"spriteSize": "Sprite Size",
|
"spriteSize": "Sprite Size",
|
||||||
"totalFrames": "Total Frames",
|
"totalFrames": "Total Frames",
|
||||||
"duration": "Duration"
|
"duration": "Duration",
|
||||||
|
"advancedSettings": "Advanced Settings",
|
||||||
|
"multiAtlasHint": "When enabled, sprites that exceed the max size will be packed into multiple atlases",
|
||||||
|
"unpackedCount": "{{count}} sprites could not fit",
|
||||||
|
"unpackedSuggestion": "Try increasing max size or enable multi-atlas mode",
|
||||||
|
"unpackedWarning": "{{count}} sprites could not fit in the atlas",
|
||||||
|
"unpackedHint": "Try increasing max size or enable multi-atlas mode",
|
||||||
|
"atlasCount": "Atlas Count",
|
||||||
|
"atlasIndex": "Atlas {{current}} / {{total}}",
|
||||||
|
"currentAtlas": "Current Atlas #{{index}}",
|
||||||
|
"downloadAllAtlases": "Download All ({{count}} atlases)",
|
||||||
|
"compressionSettings": "Compression",
|
||||||
|
"compressionHint": "Enable PNG quantization to significantly reduce file size (similar to TinyPNG)"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",
|
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
"file": "文件",
|
"file": "文件",
|
||||||
"files": "文件",
|
"files": "文件",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"no": "否"
|
"no": "否",
|
||||||
|
"on": "开启",
|
||||||
|
"off": "关闭"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
@@ -120,6 +122,10 @@
|
|||||||
"title": "音频压缩",
|
"title": "音频压缩",
|
||||||
"description": "压缩并转换音频文件为多种格式。调整比特率和采样率。"
|
"description": "压缩并转换音频文件为多种格式。调整比特率和采样率。"
|
||||||
},
|
},
|
||||||
|
"textureAtlas": {
|
||||||
|
"title": "合图工具",
|
||||||
|
"description": "将多张精灵图合并为优化的纹理图集。提升游戏性能的利器。"
|
||||||
|
},
|
||||||
"aiTools": {
|
"aiTools": {
|
||||||
"title": "更多工具",
|
"title": "更多工具",
|
||||||
"description": "更多游戏开发实用工具,敬请期待。"
|
"description": "更多游戏开发实用工具,敬请期待。"
|
||||||
@@ -240,7 +246,11 @@
|
|||||||
"outputCocosCreator": "Cocos Creator JSON",
|
"outputCocosCreator": "Cocos Creator JSON",
|
||||||
"outputGeneric": "通用 JSON",
|
"outputGeneric": "通用 JSON",
|
||||||
"algorithmMaxRects": "MaxRects(最优)",
|
"algorithmMaxRects": "MaxRects(最优)",
|
||||||
"algorithmShelf": "Shelf(快速)"
|
"algorithmShelf": "Shelf(快速)",
|
||||||
|
"multiAtlas": "多图打包",
|
||||||
|
"multiAtlasDescription": "超出尺寸的精灵自动打包到多张图片",
|
||||||
|
"compression": "PNG 压缩",
|
||||||
|
"compressionDescription": "使用量化算法压缩 PNG 图片"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -355,7 +365,19 @@
|
|||||||
"fps": "帧率",
|
"fps": "帧率",
|
||||||
"spriteSize": "精灵尺寸",
|
"spriteSize": "精灵尺寸",
|
||||||
"totalFrames": "总帧数",
|
"totalFrames": "总帧数",
|
||||||
"duration": "时长"
|
"duration": "时长",
|
||||||
|
"advancedSettings": "高级设置",
|
||||||
|
"multiAtlasHint": "开启后,超出单张合图尺寸的精灵将自动打包到多张图片中",
|
||||||
|
"unpackedCount": "有 {{count}} 张图片未能放入",
|
||||||
|
"unpackedSuggestion": "建议增大最大尺寸,或开启多图打包功能",
|
||||||
|
"unpackedWarning": "{{count}} 张精灵图未能放入合图",
|
||||||
|
"unpackedHint": "请增大最大尺寸,或开启多图打包功能",
|
||||||
|
"atlasCount": "合图数量",
|
||||||
|
"atlasIndex": "合图 {{current}} / {{total}}",
|
||||||
|
"currentAtlas": "当前合图 #{{index}}",
|
||||||
|
"downloadAllAtlases": "打包下载全部 ({{count}} 张)",
|
||||||
|
"compressionSettings": "压缩设置",
|
||||||
|
"compressionHint": "开启后使用 PNG 量化压缩,可大幅减小文件体积(类似 TinyPNG)"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
|
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
|
||||||
|
|||||||
@@ -15,14 +15,41 @@ export interface BrowserSprite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete atlas result
|
* Single atlas result
|
||||||
*/
|
*/
|
||||||
export interface AtlasResult {
|
export interface SingleAtlasResult {
|
||||||
|
index: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
placements: PackerPlacement[];
|
placements: PackerPlacement[];
|
||||||
frames: AtlasFrame[];
|
frames: AtlasFrame[];
|
||||||
imageDataUrl: string | null;
|
imageDataUrl: string | null;
|
||||||
|
/** Sprite IDs in this atlas */
|
||||||
|
spriteIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete atlas result (supports multiple atlases)
|
||||||
|
*/
|
||||||
|
export interface AtlasResult {
|
||||||
|
/** All atlases */
|
||||||
|
atlases: SingleAtlasResult[];
|
||||||
|
/** Total number of packed sprites */
|
||||||
|
packedCount: number;
|
||||||
|
/** Sprites that couldn't fit in any atlas (only when multi-atlas is disabled) */
|
||||||
|
unpackedSpriteIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy single result format for backward compatibility
|
||||||
|
*/
|
||||||
|
export interface LegacyAtlasResult {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
placements: PackerPlacement[];
|
||||||
|
frames: AtlasFrame[];
|
||||||
|
imageDataUrl: string | null;
|
||||||
|
unpackedSpriteIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,14 +68,23 @@ interface AtlasState {
|
|||||||
// Configuration
|
// Configuration
|
||||||
config: TextureAtlasConfig;
|
config: TextureAtlasConfig;
|
||||||
|
|
||||||
|
// Multi-atlas mode
|
||||||
|
enableMultiAtlas: boolean;
|
||||||
|
|
||||||
|
// Compression mode (PNG quantization)
|
||||||
|
enableCompression: boolean;
|
||||||
|
|
||||||
// Processing state
|
// Processing state
|
||||||
status: AtlasProcessStatus;
|
status: AtlasProcessStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
|
|
||||||
// Result
|
// Result (supports multiple atlases)
|
||||||
result: AtlasResult | null;
|
result: AtlasResult | null;
|
||||||
|
|
||||||
|
// Current preview atlas index
|
||||||
|
currentAtlasIndex: number;
|
||||||
|
|
||||||
// Preview state
|
// Preview state
|
||||||
previewScale: number;
|
previewScale: number;
|
||||||
previewOffset: { x: number; y: number };
|
previewOffset: { x: number; y: number };
|
||||||
@@ -67,11 +103,15 @@ interface AtlasState {
|
|||||||
updateConfig: (config: Partial<TextureAtlasConfig>) => void;
|
updateConfig: (config: Partial<TextureAtlasConfig>) => void;
|
||||||
resetConfig: () => void;
|
resetConfig: () => void;
|
||||||
|
|
||||||
|
setEnableMultiAtlas: (enable: boolean) => void;
|
||||||
|
setEnableCompression: (enable: boolean) => void;
|
||||||
|
|
||||||
setStatus: (status: AtlasProcessStatus) => void;
|
setStatus: (status: AtlasProcessStatus) => void;
|
||||||
setProgress: (progress: number) => void;
|
setProgress: (progress: number) => void;
|
||||||
setError: (message: string | null) => void;
|
setError: (message: string | null) => void;
|
||||||
|
|
||||||
setResult: (result: AtlasResult | null) => void;
|
setResult: (result: AtlasResult | null) => void;
|
||||||
|
setCurrentAtlasIndex: (index: number) => void;
|
||||||
|
|
||||||
setPreviewScale: (scale: number) => void;
|
setPreviewScale: (scale: number) => void;
|
||||||
setPreviewOffset: (offset: { x: number; y: number }) => void;
|
setPreviewOffset: (offset: { x: number; y: number }) => void;
|
||||||
@@ -81,6 +121,9 @@ interface AtlasState {
|
|||||||
openAnimationDialog: () => void;
|
openAnimationDialog: () => void;
|
||||||
closeAnimationDialog: () => void;
|
closeAnimationDialog: () => void;
|
||||||
setAnimationFps: (fps: number) => void;
|
setAnimationFps: (fps: number) => void;
|
||||||
|
|
||||||
|
// Computed helpers
|
||||||
|
getCurrentAtlas: () => SingleAtlasResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +134,7 @@ const defaultConfig: TextureAtlasConfig = {
|
|||||||
maxHeight: 1024,
|
maxHeight: 1024,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
allowRotation: false,
|
allowRotation: false,
|
||||||
pot: true,
|
pot: false,
|
||||||
format: "png",
|
format: "png",
|
||||||
quality: 90,
|
quality: 90,
|
||||||
outputFormat: "cocos2d",
|
outputFormat: "cocos2d",
|
||||||
@@ -106,10 +149,13 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
sprites: [],
|
sprites: [],
|
||||||
folderName: "",
|
folderName: "",
|
||||||
config: { ...defaultConfig },
|
config: { ...defaultConfig },
|
||||||
|
enableMultiAtlas: false,
|
||||||
|
enableCompression: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
result: null,
|
result: null,
|
||||||
|
currentAtlasIndex: 0,
|
||||||
previewScale: 1,
|
previewScale: 1,
|
||||||
previewOffset: { x: 0, y: 0 },
|
previewOffset: { x: 0, y: 0 },
|
||||||
selectedSpriteIds: [],
|
selectedSpriteIds: [],
|
||||||
@@ -128,7 +174,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
|
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sprites: allSprites, result: null };
|
return { sprites: allSprites, result: null, currentAtlasIndex: 0 };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -137,6 +183,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
sprites: state.sprites.filter((s) => s.id !== id),
|
sprites: state.sprites.filter((s) => s.id !== id),
|
||||||
selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id),
|
selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id),
|
||||||
result: null,
|
result: null,
|
||||||
|
currentAtlasIndex: 0,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,6 +196,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
sprites: [],
|
sprites: [],
|
||||||
folderName: "",
|
folderName: "",
|
||||||
result: null,
|
result: null,
|
||||||
|
currentAtlasIndex: 0,
|
||||||
selectedSpriteIds: [],
|
selectedSpriteIds: [],
|
||||||
status: "idle",
|
status: "idle",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -163,10 +211,17 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
config: { ...state.config, ...partialConfig },
|
config: { ...state.config, ...partialConfig },
|
||||||
result: null, // Clear result when config changes
|
result: null, // Clear result when config changes
|
||||||
|
currentAtlasIndex: 0,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
resetConfig: () => set({ config: { ...defaultConfig }, result: null }),
|
resetConfig: () => set({ config: { ...defaultConfig }, result: null, currentAtlasIndex: 0 }),
|
||||||
|
|
||||||
|
// Multi-atlas mode
|
||||||
|
setEnableMultiAtlas: (enable) => set({ enableMultiAtlas: enable, result: null, currentAtlasIndex: 0 }),
|
||||||
|
|
||||||
|
// Compression mode (only affects download, not preview)
|
||||||
|
setEnableCompression: (enable) => set({ enableCompression: enable }),
|
||||||
|
|
||||||
// Status actions
|
// Status actions
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
@@ -174,7 +229,14 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
setError: (message) => set({ errorMessage: message, status: message ? "error" : "idle" }),
|
setError: (message) => set({ errorMessage: message, status: message ? "error" : "idle" }),
|
||||||
|
|
||||||
// Result actions
|
// Result actions
|
||||||
setResult: (result) => set({ result, status: result ? "completed" : "idle" }),
|
setResult: (result) => set({ result, status: result ? "completed" : "idle", currentAtlasIndex: 0 }),
|
||||||
|
|
||||||
|
setCurrentAtlasIndex: (index) => {
|
||||||
|
const { result } = get();
|
||||||
|
if (result && index >= 0 && index < result.atlases.length) {
|
||||||
|
set({ currentAtlasIndex: index, previewOffset: { x: 0, y: 0 } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Preview actions
|
// Preview actions
|
||||||
setPreviewScale: (scale) => set({ previewScale: Math.max(0.1, Math.min(4, scale)) }),
|
setPreviewScale: (scale) => set({ previewScale: Math.max(0.1, Math.min(4, scale)) }),
|
||||||
@@ -191,7 +253,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
: [...state.selectedSpriteIds, id],
|
: [...state.selectedSpriteIds, id],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { selectedSpriteIds: [id] };
|
return { selectedSpriteIds: id ? [id] : [] };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -201,6 +263,13 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
|
|||||||
openAnimationDialog: () => set({ isAnimationDialogOpen: true }),
|
openAnimationDialog: () => set({ isAnimationDialogOpen: true }),
|
||||||
closeAnimationDialog: () => set({ isAnimationDialogOpen: false }),
|
closeAnimationDialog: () => set({ isAnimationDialogOpen: false }),
|
||||||
setAnimationFps: (fps) => set({ animationFps: Math.max(1, Math.min(60, fps)) }),
|
setAnimationFps: (fps) => set({ animationFps: Math.max(1, Math.min(60, fps)) }),
|
||||||
|
|
||||||
|
// Computed helpers
|
||||||
|
getCurrentAtlas: () => {
|
||||||
|
const { result, currentAtlasIndex } = get();
|
||||||
|
if (!result || result.atlases.length === 0) return null;
|
||||||
|
return result.atlases[currentAtlasIndex] || null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,3 +287,7 @@ export const useAtlasPreview = () => useAtlasStore((state) => ({
|
|||||||
scale: state.previewScale,
|
scale: state.previewScale,
|
||||||
offset: state.previewOffset,
|
offset: state.previewOffset,
|
||||||
}));
|
}));
|
||||||
|
export const useCurrentAtlas = () => useAtlasStore((state) => {
|
||||||
|
if (!state.result || state.result.atlases.length === 0) return null;
|
||||||
|
return state.result.atlases[state.currentAtlasIndex] || null;
|
||||||
|
});
|
||||||
|
|||||||
54
src/types/upng-js.d.ts
vendored
Normal file
54
src/types/upng-js.d.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
declare module "upng-js" {
|
||||||
|
/**
|
||||||
|
* Encode RGBA image data to PNG
|
||||||
|
* @param imgs Array of ArrayBuffer containing RGBA data
|
||||||
|
* @param w Width of the image
|
||||||
|
* @param h Height of the image
|
||||||
|
* @param cnum Number of colors (0 = lossless, 256 = 8-bit quantization)
|
||||||
|
* @param dels Optional delays for APNG frames
|
||||||
|
* @returns ArrayBuffer containing PNG data
|
||||||
|
*/
|
||||||
|
export function encode(
|
||||||
|
imgs: ArrayBuffer[],
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
cnum: number,
|
||||||
|
dels?: number[]
|
||||||
|
): ArrayBuffer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode PNG to RGBA image data
|
||||||
|
* @param buffer PNG data
|
||||||
|
* @returns Decoded image info
|
||||||
|
*/
|
||||||
|
export function decode(buffer: ArrayBuffer): {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
depth: number;
|
||||||
|
ctype: number;
|
||||||
|
frames: Array<{
|
||||||
|
rect: { x: number; y: number; width: number; height: number };
|
||||||
|
delay: number;
|
||||||
|
dispose: number;
|
||||||
|
blend: number;
|
||||||
|
}>;
|
||||||
|
tabs: Record<string, unknown>;
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert decoded PNG to RGBA format
|
||||||
|
* @param img Decoded image from decode()
|
||||||
|
* @param frameIndex Frame index for APNG (default 0)
|
||||||
|
* @returns Uint8Array of RGBA data
|
||||||
|
*/
|
||||||
|
export function toRGBA8(img: ReturnType<typeof decode>, frameIndex?: number): Uint8Array[];
|
||||||
|
|
||||||
|
const UPNG: {
|
||||||
|
encode: typeof encode;
|
||||||
|
decode: typeof decode;
|
||||||
|
toRGBA8: typeof toRGBA8;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UPNG;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user