Continuation of separting logic into domain specific composables
This commit is contained in:
164
src/composables/useCanvas2D.ts
Normal file
164
src/composables/useCanvas2D.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
|
||||
export interface Canvas2DOptions {
|
||||
pixelPerfect?: Ref<boolean> | boolean;
|
||||
}
|
||||
|
||||
export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?: Canvas2DOptions) {
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const initContext = () => {
|
||||
if (canvasRef.value) {
|
||||
ctx.value = canvasRef.value.getContext('2d');
|
||||
applySmoothing();
|
||||
}
|
||||
return ctx.value;
|
||||
};
|
||||
|
||||
const applySmoothing = () => {
|
||||
if (ctx.value) {
|
||||
const pixelPerfect = options?.pixelPerfect;
|
||||
const isPixelPerfect = typeof pixelPerfect === 'boolean'
|
||||
? pixelPerfect
|
||||
: pixelPerfect?.value ?? settingsStore.pixelPerfect;
|
||||
ctx.value.imageSmoothingEnabled = !isPixelPerfect;
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (!canvasRef.value || !ctx.value) return;
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
};
|
||||
|
||||
const setCanvasSize = (width: number, height: number) => {
|
||||
if (canvasRef.value) {
|
||||
canvasRef.value.width = width;
|
||||
canvasRef.value.height = height;
|
||||
}
|
||||
};
|
||||
|
||||
const fillRect = (x: number, y: number, width: number, height: number, color: string) => {
|
||||
if (!ctx.value) return;
|
||||
ctx.value.fillStyle = color;
|
||||
ctx.value.fillRect(Math.floor(x), Math.floor(y), width, height);
|
||||
};
|
||||
|
||||
const strokeRect = (x: number, y: number, width: number, height: number, color: string, lineWidth = 1) => {
|
||||
if (!ctx.value) return;
|
||||
ctx.value.strokeStyle = color;
|
||||
ctx.value.lineWidth = lineWidth;
|
||||
ctx.value.strokeRect(Math.floor(x), Math.floor(y), width, height);
|
||||
};
|
||||
|
||||
const drawImage = (
|
||||
img: HTMLImageElement | HTMLCanvasElement,
|
||||
x: number,
|
||||
y: number,
|
||||
alpha = 1
|
||||
) => {
|
||||
if (!ctx.value) return;
|
||||
const prevAlpha = ctx.value.globalAlpha;
|
||||
ctx.value.globalAlpha = alpha;
|
||||
ctx.value.drawImage(img, Math.floor(x), Math.floor(y));
|
||||
ctx.value.globalAlpha = prevAlpha;
|
||||
};
|
||||
|
||||
const setGlobalAlpha = (alpha: number) => {
|
||||
if (ctx.value) {
|
||||
ctx.value.globalAlpha = alpha;
|
||||
}
|
||||
};
|
||||
|
||||
const resetGlobalAlpha = () => {
|
||||
if (ctx.value) {
|
||||
ctx.value.globalAlpha = 1.0;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to ensure integer positions for pixel-perfect rendering
|
||||
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
|
||||
items.forEach(item => {
|
||||
item.x = Math.floor(item.x);
|
||||
item.y = Math.floor(item.y);
|
||||
});
|
||||
};
|
||||
|
||||
// Centralized force redraw handler
|
||||
const createForceRedrawHandler = <T extends { x: number; y: number }>(
|
||||
items: T[],
|
||||
drawCallback: () => void
|
||||
) => {
|
||||
return () => {
|
||||
ensureIntegerPositions(items);
|
||||
applySmoothing();
|
||||
drawCallback();
|
||||
};
|
||||
};
|
||||
|
||||
// Get mouse position relative to canvas, accounting for zoom
|
||||
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
|
||||
if (!canvasRef.value) return null;
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const scaleX = canvasRef.value.width / (rect.width / zoom);
|
||||
const scaleY = canvasRef.value.height / (rect.height / zoom);
|
||||
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / zoom) * scaleX,
|
||||
y: ((event.clientY - rect.top) / zoom) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to attach load/error listeners to images that aren't yet loaded
|
||||
const attachImageListeners = (
|
||||
sprites: Sprite[],
|
||||
onLoad: () => void,
|
||||
tracked: WeakSet<HTMLImageElement>
|
||||
) => {
|
||||
sprites.forEach(sprite => {
|
||||
const img = sprite.img as HTMLImageElement | undefined;
|
||||
if (img && !tracked.has(img)) {
|
||||
tracked.add(img);
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', onLoad, { once: true });
|
||||
img.addEventListener('error', onLoad, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fill cell background with theme-aware color
|
||||
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
|
||||
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
|
||||
fillRect(x, y, width, height, color);
|
||||
};
|
||||
|
||||
// Stroke grid with theme-aware color
|
||||
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
|
||||
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
|
||||
strokeRect(x, y, width, height, color, 1);
|
||||
};
|
||||
|
||||
return {
|
||||
ctx,
|
||||
canvasRef,
|
||||
initContext,
|
||||
applySmoothing,
|
||||
clear,
|
||||
setCanvasSize,
|
||||
fillRect,
|
||||
strokeRect,
|
||||
drawImage,
|
||||
setGlobalAlpha,
|
||||
resetGlobalAlpha,
|
||||
ensureIntegerPositions,
|
||||
createForceRedrawHandler,
|
||||
getMousePosition,
|
||||
attachImageListeners,
|
||||
fillCellBackground,
|
||||
strokeGridCell,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user