refactor: 重构纹理图集工具,实现浏览器端实时处理

将合图处理从服务端迁移到浏览器端,使用 Web Worker 实现高性能打包算法,新增三栏布局界面和精灵动画预览功能

- 新增 atlasStore 状态管理,实现文件、配置、结果的统一管理
- 新增 atlas-packer 打包算法库(MaxRects/Shelf),支持浏览器端快速合图
- 新增 atlas-worker Web Worker,实现异步打包处理避免阻塞 UI
- 新增三栏布局组件:FileListPanel、CanvasPreview、AtlasConfigPanel
- 新增 AnimationPreviewDialog 支持精灵动画帧预览和帧率控制
- 优化所有工具页面的响应式布局和交互体验

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 22:05:25 +08:00
parent 663917f663
commit 140608845a
27 changed files with 4034 additions and 499 deletions

220
src/store/atlasStore.ts Normal file
View File

@@ -0,0 +1,220 @@
import { create } from "zustand";
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
import type { PackerPlacement } from "@/lib/atlas-packer";
/**
* Browser-side sprite with ImageBitmap
*/
export interface BrowserSprite {
id: string;
name: string;
width: number;
height: number;
image: ImageBitmap;
file: File;
}
/**
* Complete atlas result
*/
export interface AtlasResult {
width: number;
height: number;
placements: PackerPlacement[];
frames: AtlasFrame[];
imageDataUrl: string | null;
}
/**
* Processing status
*/
export type AtlasProcessStatus = "idle" | "loading" | "packing" | "rendering" | "completed" | "error";
/**
* Atlas Store State
*/
interface AtlasState {
// Sprite data
sprites: BrowserSprite[];
folderName: string;
// Configuration
config: TextureAtlasConfig;
// Processing state
status: AtlasProcessStatus;
progress: number;
errorMessage: string | null;
// Result
result: AtlasResult | null;
// Preview state
previewScale: number;
previewOffset: { x: number; y: number };
selectedSpriteIds: string[];
// Animation preview
isAnimationDialogOpen: boolean;
animationFps: number;
// Actions
addSprites: (sprites: BrowserSprite[]) => void;
removeSprite: (id: string) => void;
clearSprites: () => void;
setFolderName: (name: string) => void;
updateConfig: (config: Partial<TextureAtlasConfig>) => void;
resetConfig: () => void;
setStatus: (status: AtlasProcessStatus) => void;
setProgress: (progress: number) => void;
setError: (message: string | null) => void;
setResult: (result: AtlasResult | null) => void;
setPreviewScale: (scale: number) => void;
setPreviewOffset: (offset: { x: number; y: number }) => void;
selectSprite: (id: string, multi?: boolean) => void;
deselectAllSprites: () => void;
openAnimationDialog: () => void;
closeAnimationDialog: () => void;
setAnimationFps: (fps: number) => void;
}
/**
* Default texture atlas configuration
*/
const defaultConfig: TextureAtlasConfig = {
maxWidth: 1024,
maxHeight: 1024,
padding: 2,
allowRotation: false,
pot: true,
format: "png",
quality: 90,
outputFormat: "cocos2d",
algorithm: "MaxRects",
};
/**
* Atlas Store
*/
export const useAtlasStore = create<AtlasState>((set, get) => ({
// Initial state
sprites: [],
folderName: "",
config: { ...defaultConfig },
status: "idle",
progress: 0,
errorMessage: null,
result: null,
previewScale: 1,
previewOffset: { x: 0, y: 0 },
selectedSpriteIds: [],
isAnimationDialogOpen: false,
animationFps: 12,
// Sprite actions
addSprites: (newSprites) => {
set((state) => {
// Filter out duplicates by name
const existingNames = new Set(state.sprites.map((s) => s.name));
const uniqueSprites = newSprites.filter((s) => !existingNames.has(s.name));
// Sort by name (natural sort for frame sequences)
const allSprites = [...state.sprites, ...uniqueSprites].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
);
return { sprites: allSprites, result: null };
});
},
removeSprite: (id) => {
set((state) => ({
sprites: state.sprites.filter((s) => s.id !== id),
selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id),
result: null,
}));
},
clearSprites: () => {
// Release ImageBitmap resources
const { sprites } = get();
sprites.forEach((s) => s.image.close());
set({
sprites: [],
folderName: "",
result: null,
selectedSpriteIds: [],
status: "idle",
progress: 0,
errorMessage: null,
});
},
setFolderName: (name) => set({ folderName: name }),
// Config actions
updateConfig: (partialConfig) => {
set((state) => ({
config: { ...state.config, ...partialConfig },
result: null, // Clear result when config changes
}));
},
resetConfig: () => set({ config: { ...defaultConfig }, result: null }),
// Status actions
setStatus: (status) => set({ status }),
setProgress: (progress) => set({ progress }),
setError: (message) => set({ errorMessage: message, status: message ? "error" : "idle" }),
// Result actions
setResult: (result) => set({ result, status: result ? "completed" : "idle" }),
// Preview actions
setPreviewScale: (scale) => set({ previewScale: Math.max(0.1, Math.min(4, scale)) }),
setPreviewOffset: (offset) => set({ previewOffset: offset }),
selectSprite: (id, multi = false) => {
set((state) => {
if (multi) {
const isSelected = state.selectedSpriteIds.includes(id);
return {
selectedSpriteIds: isSelected
? state.selectedSpriteIds.filter((sid) => sid !== id)
: [...state.selectedSpriteIds, id],
};
}
return { selectedSpriteIds: [id] };
});
},
deselectAllSprites: () => set({ selectedSpriteIds: [] }),
// Animation dialog actions
openAnimationDialog: () => set({ isAnimationDialogOpen: true }),
closeAnimationDialog: () => set({ isAnimationDialogOpen: false }),
setAnimationFps: (fps) => set({ animationFps: Math.max(1, Math.min(60, fps)) }),
}));
/**
* Selector hooks for optimized re-renders
*/
export const useAtlasSprites = () => useAtlasStore((state) => state.sprites);
export const useAtlasConfig = () => useAtlasStore((state) => state.config);
export const useAtlasResult = () => useAtlasStore((state) => state.result);
export const useAtlasStatus = () => useAtlasStore((state) => ({
status: state.status,
progress: state.progress,
errorMessage: state.errorMessage,
}));
export const useAtlasPreview = () => useAtlasStore((state) => ({
scale: state.previewScale,
offset: state.previewOffset,
}));