Riichi/src/utils/index.ts
2026-01-02 18:39:20 +01:00

335 lines
7.5 KiB
TypeScript

/**
* 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<T>(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<T>(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<T>(array: T[]): T[] {
return [...new Set(array)];
}
// ============ DOM Utilities ============
/**
* Get element by ID with type safety
*/
export function getElementById<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
/**
* Create an element with attributes
*/
export function createElement<K extends keyof HTMLElementTagNameMap>(
tag: K,
attributes?: Partial<HTMLElementTagNameMap[K]>,
): 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<HTMLImageElement> {
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<HTMLImageElement[]> {
return Promise.all(sources.map(loadImage));
}
// ============ Time Utilities ============
/**
* Create a debounced function
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => fn(...args), delay);
};
}
/**
* Create a throttled function
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
limit: number,
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => { inThrottle = false; }, limit);
}
};
}
/**
* Wait for a specified duration
*/
export function wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ============ Storage Utilities ============
/**
* Safe localStorage get with JSON parsing
*/
export function getStorageItem<T>(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<T>(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}`);
}
}