/** * Utility functions for the Riichi Mahjong Tutorial */ import type { AnimationConfig, AnimationState, Point, Rectangle } from '../types'; // ============ Canvas Utilities ============ /** * Get a canvas context with optimizations */ export function getOptimizedContext( canvas: HTMLCanvasElement, alpha: boolean = false, ): CanvasRenderingContext2D | null { return canvas.getContext('2d', { alpha, desynchronized: true, willReadFrequently: false, }); } /** * Clear a canvas with a solid color */ export function clearCanvas( ctx: CanvasRenderingContext2D, color: string, width: number, height: number, ): void { ctx.fillStyle = color; ctx.fillRect(0, 0, width, height); } /** * Create an offscreen canvas for double buffering */ export function createOffscreenCanvas( width: number, height: number, ): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = getOptimizedContext(canvas); if (!ctx) { return null; } return { canvas, ctx }; } // ============ Math Utilities ============ /** * Clamp a value between min and max */ export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } /** * Linear interpolation */ export function lerp(start: number, end: number, t: number): number { return start + (end - start) * clamp(t, 0, 1); } /** * Convert degrees to radians */ export function degToRad(degrees: number): number { return degrees * (Math.PI / 180); } /** * Convert radians to degrees */ export function radToDeg(radians: number): number { return radians * (180 / Math.PI); } /** * Get a random number between min and max */ export function randomRange(min: number, max: number): number { return min + Math.random() * (max - min); } /** * Get a random integer between min and max (inclusive) */ export function randomInt(min: number, max: number): number { return Math.floor(randomRange(min, max + 1)); } // ============ Geometry Utilities ============ /** * Check if a point is inside a rectangle */ export function pointInRect(point: Point, rect: Rectangle): boolean { return ( point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height ); } /** * Get the distance between two points */ export function distance(p1: Point, p2: Point): number { const dx = p2.x - p1.x; const dy = p2.y - p1.y; return Math.sqrt(dx * dx + dy * dy); } /** * Normalize an angle to be between 0 and 2π */ export function normalizeAngle(angle: number): number { const TWO_PI = Math.PI * 2; return ((angle % TWO_PI) + TWO_PI) % TWO_PI; } // ============ Animation Utilities ============ /** * Easing functions */ export const Easing = { linear: (t: number): number => t, easeIn: (t: number): number => t * t, easeOut: (t: number): number => t * (2 - t), easeInOut: (t: number): number => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, easeInCubic: (t: number): number => t * t * t, easeOutCubic: (t: number): number => (--t) * t * t + 1, easeInOutCubic: (t: number): number => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, easeOutBounce: (t: number): number => { if (t < 1 / 2.75) { return 7.5625 * t * t; } else if (t < 2 / 2.75) { return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; } else if (t < 2.5 / 2.75) { return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; } else { return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; } }, }; /** * Create an animation state */ export function createAnimation(config: AnimationConfig): AnimationState & { config: AnimationConfig } { return { config, startTime: 0, progress: 0, isComplete: false, }; } /** * Update an animation state */ export function updateAnimation( state: AnimationState & { config: AnimationConfig }, currentTime: number, ): void { if (state.startTime === 0) { state.startTime = currentTime + (state.config.delay ?? 0); } const elapsed = currentTime - state.startTime; state.progress = clamp(elapsed / state.config.duration, 0, 1); state.isComplete = state.progress >= 1; } // ============ Array Utilities ============ /** * Shuffle an array in place (Fisher-Yates) */ export function shuffleArray(array: T[]): T[] { for (let i = array.length - 1; i > 0; i--) { const j = randomInt(0, i); [array[i], array[j]] = [array[j], array[i]]; } return array; } /** * Remove an item from an array by index */ export function removeAt(array: T[], index: number): T | undefined { if (index >= 0 && index < array.length) { return array.splice(index, 1)[0]; } return undefined; } /** * Get unique values from an array */ export function unique(array: T[]): T[] { return [...new Set(array)]; } // ============ DOM Utilities ============ /** * Get element by ID with type safety */ export function getElementById(id: string): T | null { return document.getElementById(id) as T | null; } /** * Create an element with attributes */ export function createElement( tag: K, attributes?: Partial, ): HTMLElementTagNameMap[K] { const element = document.createElement(tag); if (attributes) { Object.assign(element, attributes); } return element; } // ============ Image Utilities ============ /** * Load an image as a Promise */ export function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); img.src = src; }); } /** * Preload multiple images */ export async function preloadImages(sources: string[]): Promise { return Promise.all(sources.map(loadImage)); } // ============ Time Utilities ============ /** * Create a debounced function */ export function debounce void>( fn: T, delay: number, ): (...args: Parameters) => void { let timeoutId: ReturnType | null = null; return (...args: Parameters) => { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => fn(...args), delay); }; } /** * Create a throttled function */ export function throttle void>( fn: T, limit: number, ): (...args: Parameters) => void { let inThrottle = false; return (...args: Parameters) => { if (!inThrottle) { fn(...args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } }; } /** * Wait for a specified duration */ export function wait(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } // ============ Storage Utilities ============ /** * Safe localStorage get with JSON parsing */ export function getStorageItem(key: string, defaultValue: T): T { try { const item = localStorage.getItem(key); return item ? JSON.parse(item) as T : defaultValue; } catch { return defaultValue; } } /** * Safe localStorage set with JSON stringify */ export function setStorageItem(key: string, value: T): void { try { localStorage.setItem(key, JSON.stringify(value)); } catch { // Storage might be full or disabled console.warn(`Failed to save to localStorage: ${key}`); } }