335 lines
7.5 KiB
TypeScript
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}`);
|
|
}
|
|
}
|