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:
220
src/store/atlasStore.ts
Normal file
220
src/store/atlasStore.ts
Normal 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,
|
||||
}));
|
||||
Reference in New Issue
Block a user