diff --git a/packages/math/build-rollup.cjs b/packages/math/build-rollup.cjs new file mode 100644 index 00000000..41baa0bc --- /dev/null +++ b/packages/math/build-rollup.cjs @@ -0,0 +1,48 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +console.log('开始构建 @esengine/ecs-framework-math...'); + +try { + // 检查bin目录是否存在 + if (!fs.existsSync('bin')) { + console.error('错误: bin目录不存在,请先运行 npm run build'); + process.exit(1); + } + + // 创建dist目录 + if (!fs.existsSync('dist')) { + fs.mkdirSync('dist'); + } + + // 运行rollup构建 + execSync('npx rollup -c rollup.config.cjs', { stdio: 'inherit' }); + + // 复制package.json到dist + const pkg = require('./package.json'); + const distPkg = { + ...pkg, + main: 'index.cjs.js', + module: 'index.esm.js', + types: 'index.d.ts', + scripts: undefined, + devDependencies: undefined + }; + + fs.writeFileSync( + path.join('dist', 'package.json'), + JSON.stringify(distPkg, null, 2) + ); + + // 复制README(如果存在) + if (fs.existsSync('README.md')) { + fs.copyFileSync('README.md', path.join('dist', 'README.md')); + } + + console.log('✓ @esengine/ecs-framework-math 构建完成'); + +} catch (error) { + console.error('构建失败:', error.message); + process.exit(1); +} \ No newline at end of file diff --git a/packages/math/jest.config.cjs b/packages/math/jest.config.cjs new file mode 100644 index 00000000..d6563ea1 --- /dev/null +++ b/packages/math/jest.config.cjs @@ -0,0 +1,36 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src', '/tests'], + testMatch: [ + '**/__tests__/**/*.ts', + '**/?(*.)+(spec|test).ts' + ], + transform: { + '^.+\\.ts$': ['ts-jest', { + useESM: true, + tsconfig: { + module: 'ESNext', + target: 'ES2020' + } + }] + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + extensionsToTreatAsEsm: ['.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: [ + 'text', + 'lcov', + 'html' + ], + testTimeout: 10000, + setupFilesAfterEnv: ['/tests/setup.ts'] +}; \ No newline at end of file diff --git a/packages/math/package.json b/packages/math/package.json new file mode 100644 index 00000000..fd09a9e1 --- /dev/null +++ b/packages/math/package.json @@ -0,0 +1,66 @@ +{ + "name": "@esengine/ecs-framework-math", + "version": "1.0.0", + "description": "ECS框架2D数学库 - 提供向量、矩阵、几何形状和碰撞检测功能", + "type": "module", + "main": "bin/index.js", + "types": "bin/index.d.ts", + "files": [ + "bin/**/*", + "README.md", + "LICENSE" + ], + "keywords": [ + "ecs", + "math", + "2d", + "vector", + "matrix", + "geometry", + "collision", + "game-engine", + "typescript" + ], + "scripts": { + "clean": "rimraf bin dist", + "build:ts": "tsc", + "prebuild": "npm run clean", + "build": "npm run build:ts", + "build:watch": "tsc --watch", + "rebuild": "npm run clean && npm run build", + "build:npm": "npm run build && node build-rollup.cjs", + "test": "jest --config jest.config.cjs", + "test:watch": "jest --watch --config jest.config.cjs", + "test:coverage": "jest --coverage --config jest.config.cjs", + "test:ci": "jest --ci --coverage --config jest.config.cjs", + "test:clear": "jest --clearCache", + "publish:patch": "npm version patch && npm publish", + "publish:minor": "npm version minor && npm publish", + "publish:major": "npm version major && npm publish" + }, + "author": "yhh", + "license": "MIT", + "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-terser": "^0.4.4", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "rimraf": "^5.0.0", + "rollup": "^4.42.0", + "rollup-plugin-dts": "^6.2.1", + "ts-jest": "^29.4.0", + "typescript": "^5.8.3" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "https://github.com/esengine/ecs-framework.git", + "directory": "packages/math" + } +} \ No newline at end of file diff --git a/packages/math/rollup.config.cjs b/packages/math/rollup.config.cjs new file mode 100644 index 00000000..e71a7ab7 --- /dev/null +++ b/packages/math/rollup.config.cjs @@ -0,0 +1,52 @@ +const { nodeResolve } = require('@rollup/plugin-node-resolve'); +const commonjs = require('@rollup/plugin-commonjs'); +const terser = require('@rollup/plugin-terser'); +const dts = require('rollup-plugin-dts').default; + +const pkg = require('./package.json'); + +const input = 'bin/index.js'; +const external = Object.keys(pkg.dependencies || {}).concat(Object.keys(pkg.peerDependencies || {})); + +module.exports = [ + // ES Module build + { + input, + output: { + file: 'dist/index.esm.js', + format: 'esm', + sourcemap: true + }, + external, + plugins: [ + nodeResolve(), + commonjs(), + terser() + ] + }, + // CommonJS build + { + input, + output: { + file: 'dist/index.cjs.js', + format: 'cjs', + sourcemap: true, + exports: 'named' + }, + external, + plugins: [ + nodeResolve(), + commonjs(), + terser() + ] + }, + // TypeScript declarations + { + input: 'bin/index.d.ts', + output: { + file: 'dist/index.d.ts', + format: 'esm' + }, + plugins: [dts()] + } +]; \ No newline at end of file diff --git a/packages/math/src/Animation/Easing.ts b/packages/math/src/Animation/Easing.ts new file mode 100644 index 00000000..675dc4e6 --- /dev/null +++ b/packages/math/src/Animation/Easing.ts @@ -0,0 +1,464 @@ +/** + * 缓动函数集合 + * + * 提供各种常用的缓动函数,用于创建平滑的动画效果 + * 所有函数接受时间参数 t (0-1),返回缓动后的值 (通常0-1) + */ +export class Easing { + + // 线性缓动 + + /** + * 线性缓动(无缓动) + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static linear(t: number): number { + return t; + } + + // 二次方缓动 (Quadratic) + + /** + * 二次方缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quadIn(t: number): number { + return t * t; + } + + /** + * 二次方缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quadOut(t: number): number { + return 1 - (1 - t) * (1 - t); + } + + /** + * 二次方缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quadInOut(t: number): number { + return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); + } + + // 三次方缓动 (Cubic) + + /** + * 三次方缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static cubicIn(t: number): number { + return t * t * t; + } + + /** + * 三次方缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static cubicOut(t: number): number { + return 1 - Math.pow(1 - t, 3); + } + + /** + * 三次方缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static cubicInOut(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + + // 四次方缓动 (Quartic) + + /** + * 四次方缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quartIn(t: number): number { + return t * t * t * t; + } + + /** + * 四次方缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quartOut(t: number): number { + return 1 - Math.pow(1 - t, 4); + } + + /** + * 四次方缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quartInOut(t: number): number { + return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2; + } + + // 五次方缓动 (Quintic) + + /** + * 五次方缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quintIn(t: number): number { + return t * t * t * t * t; + } + + /** + * 五次方缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quintOut(t: number): number { + return 1 - Math.pow(1 - t, 5); + } + + /** + * 五次方缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static quintInOut(t: number): number { + return t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2; + } + + // 正弦缓动 (Sine) + + /** + * 正弦缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static sineIn(t: number): number { + return 1 - Math.cos((t * Math.PI) / 2); + } + + /** + * 正弦缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static sineOut(t: number): number { + return Math.sin((t * Math.PI) / 2); + } + + /** + * 正弦缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static sineInOut(t: number): number { + return -(Math.cos(Math.PI * t) - 1) / 2; + } + + // 指数缓动 (Exponential) + + /** + * 指数缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static expoIn(t: number): number { + return t === 0 ? 0 : Math.pow(2, 10 * (t - 1)); + } + + /** + * 指数缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static expoOut(t: number): number { + return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); + } + + /** + * 指数缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static expoInOut(t: number): number { + if (t === 0) return 0; + if (t === 1) return 1; + + return t < 0.5 + ? Math.pow(2, 20 * t - 10) / 2 + : (2 - Math.pow(2, -20 * t + 10)) / 2; + } + + // 圆形缓动 (Circular) + + /** + * 圆形缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static circIn(t: number): number { + return 1 - Math.sqrt(1 - t * t); + } + + /** + * 圆形缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static circOut(t: number): number { + return Math.sqrt(1 - (t - 1) * (t - 1)); + } + + /** + * 圆形缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static circInOut(t: number): number { + return t < 0.5 + ? (1 - Math.sqrt(1 - 4 * t * t)) / 2 + : (Math.sqrt(1 - (-2 * t + 2) * (-2 * t + 2)) + 1) / 2; + } + + // 回弹缓动 (Back) + + /** + * 回弹缓入 + * @param t 时间参数 (0-1) + * @param s 回弹强度,默认1.70158 + * @returns 缓动值 + */ + static backIn(t: number, s: number = 1.70158): number { + const c1 = s; + const c3 = c1 + 1; + return c3 * t * t * t - c1 * t * t; + } + + /** + * 回弹缓出 + * @param t 时间参数 (0-1) + * @param s 回弹强度,默认1.70158 + * @returns 缓动值 + */ + static backOut(t: number, s: number = 1.70158): number { + const c1 = s; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + } + + /** + * 回弹缓入缓出 + * @param t 时间参数 (0-1) + * @param s 回弹强度,默认1.70158 + * @returns 缓动值 + */ + static backInOut(t: number, s: number = 1.70158): number { + const c1 = s; + const c2 = c1 * 1.525; + + return t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; + } + + // 弹性缓动 (Elastic) + + /** + * 弹性缓入 + * @param t 时间参数 (0-1) + * @param amplitude 振幅,默认1 + * @param period 周期,默认0.3 + * @returns 缓动值 + */ + static elasticIn(t: number, amplitude: number = 1, period: number = 0.3): number { + if (t === 0) return 0; + if (t === 1) return 1; + + const s = period / 4; + return -(amplitude * Math.pow(2, 10 * (t - 1)) * Math.sin((t - 1 - s) * (2 * Math.PI) / period)); + } + + /** + * 弹性缓出 + * @param t 时间参数 (0-1) + * @param amplitude 振幅,默认1 + * @param period 周期,默认0.3 + * @returns 缓动值 + */ + static elasticOut(t: number, amplitude: number = 1, period: number = 0.3): number { + if (t === 0) return 0; + if (t === 1) return 1; + + const s = period / 4; + return amplitude * Math.pow(2, -10 * t) * Math.sin((t - s) * (2 * Math.PI) / period) + 1; + } + + /** + * 弹性缓入缓出 + * @param t 时间参数 (0-1) + * @param amplitude 振幅,默认1 + * @param period 周期,默认0.45 + * @returns 缓动值 + */ + static elasticInOut(t: number, amplitude: number = 1, period: number = 0.45): number { + if (t === 0) return 0; + if (t === 1) return 1; + + const s = period / 4; + + if (t < 0.5) { + return -0.5 * (amplitude * Math.pow(2, 10 * (2 * t - 1)) * Math.sin((2 * t - 1 - s) * (2 * Math.PI) / period)); + } + + return amplitude * Math.pow(2, -10 * (2 * t - 1)) * Math.sin((2 * t - 1 - s) * (2 * Math.PI) / period) * 0.5 + 1; + } + + // 跳跃缓动 (Bounce) + + /** + * 跳跃缓入 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static bounceIn(t: number): number { + return 1 - Easing.bounceOut(1 - t); + } + + /** + * 跳跃缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static bounceOut(t: number): number { + const n1 = 7.5625; + const d1 = 2.75; + + if (t < 1 / d1) { + return n1 * t * t; + } else if (t < 2 / d1) { + return n1 * (t -= 1.5 / d1) * t + 0.75; + } else if (t < 2.5 / d1) { + return n1 * (t -= 2.25 / d1) * t + 0.9375; + } else { + return n1 * (t -= 2.625 / d1) * t + 0.984375; + } + } + + /** + * 跳跃缓入缓出 + * @param t 时间参数 (0-1) + * @returns 缓动值 + */ + static bounceInOut(t: number): number { + return t < 0.5 + ? (1 - Easing.bounceOut(1 - 2 * t)) / 2 + : (1 + Easing.bounceOut(2 * t - 1)) / 2; + } + + // 组合缓动 + + /** + * 创建自定义缓动函数(组合多个缓动) + * @param easingFunctions 缓动函数数组 + * @param weights 权重数组,默认均等 + * @returns 组合后的缓动函数 + */ + static combine( + easingFunctions: ((t: number) => number)[], + weights?: number[] + ): (t: number) => number { + if (!weights) { + weights = new Array(easingFunctions.length).fill(1 / easingFunctions.length); + } + + return (t: number): number => { + let result = 0; + for (let i = 0; i < easingFunctions.length; i++) { + result += easingFunctions[i](t) * (weights![i] || 0); + } + return result; + }; + } + + /** + * 创建分段缓动函数 + * @param segments 分段配置数组,每段包含 {duration, easing} + * @returns 分段缓动函数 + */ + static piecewise(segments: Array<{duration: number; easing: (t: number) => number}>): (t: number) => number { + // 计算总持续时间 + const totalDuration = segments.reduce((sum, seg) => sum + seg.duration, 0); + + // 归一化持续时间 + const normalizedSegments = segments.map(seg => ({ + ...seg, + duration: seg.duration / totalDuration + })); + + return (t: number): number => { + let accumulatedTime = 0; + + for (const segment of normalizedSegments) { + if (t <= accumulatedTime + segment.duration) { + const localT = (t - accumulatedTime) / segment.duration; + return segment.easing(Math.max(0, Math.min(1, localT))); + } + accumulatedTime += segment.duration; + } + + // 如果超出范围,返回最后一段的结束值 + return normalizedSegments[normalizedSegments.length - 1].easing(1); + }; + } + + /** + * 创建反向缓动函数 + * @param easing 原缓动函数 + * @returns 反向缓动函数 + */ + static reverse(easing: (t: number) => number): (t: number) => number { + return (t: number): number => 1 - easing(1 - t); + } + + /** + * 创建镜像缓动函数(先正向再反向) + * @param easing 原缓动函数 + * @returns 镜像缓动函数 + */ + static mirror(easing: (t: number) => number): (t: number) => number { + return (t: number): number => { + if (t < 0.5) { + return easing(t * 2); + } else { + return easing(2 - t * 2); + } + }; + } + + // 常用预设 + + /** 平滑进入(常用于UI动画) */ + static readonly smoothIn = Easing.quadOut; + + /** 平滑退出(常用于UI动画) */ + static readonly smoothOut = Easing.quadIn; + + /** 快速进入(常用于出现动画) */ + static readonly quickIn = Easing.cubicOut; + + /** 快速退出(常用于消失动画) */ + static readonly quickOut = Easing.cubicIn; + + /** 自然运动(模拟物理) */ + static readonly natural = Easing.quartOut; + + /** 强调效果(吸引注意力) */ + static readonly emphasize = Easing.backOut; +} \ No newline at end of file diff --git a/packages/math/src/Animation/Interpolation.ts b/packages/math/src/Animation/Interpolation.ts new file mode 100644 index 00000000..d9bf7f8d --- /dev/null +++ b/packages/math/src/Animation/Interpolation.ts @@ -0,0 +1,411 @@ +import { Vector2 } from '../Vector2'; +import { MathUtils } from '../MathUtils'; + +/** + * 插值器类型定义 + */ +export type InterpolatorFunction = (from: T, to: T, t: number) => T; + +/** + * 关键帧数据结构 + */ +export interface Keyframe { + time: number; + value: T; + easing?: (t: number) => number; +} + +/** + * 带缓存的插值器类 + * 用于需要重复插值相同起始和目标值的情况 + */ +export class CachedInterpolator { + private from?: T; + private to?: T; + private interpolator: InterpolatorFunction; + private cache: Map = new Map(); + + constructor(interpolator: InterpolatorFunction) { + this.interpolator = interpolator; + } + + /** + * 设置插值范围 + * @param from 起始值 + * @param to 目标值 + */ + setRange(from: T, to: T): void { + if (this.from !== from || this.to !== to) { + this.from = from; + this.to = to; + this.cache.clear(); + } + } + + /** + * 获取插值结果 + * @param t 插值参数 + * @returns 插值结果 + */ + get(t: number): T { + if (!this.from || !this.to) { + throw new Error('插值范围未设置'); + } + + if (!this.cache.has(t)) { + const result = this.interpolator(this.from, this.to, t); + this.cache.set(t, result); + } + + return this.cache.get(t)!; + } + + /** + * 清空缓存 + */ + clearCache(): void { + this.cache.clear(); + } +} + +/** + * 插值工具类 + * + * 提供各种类型的插值功能,用于创建平滑的数值变化 + */ +export class Interpolation { + + // 基础插值 + + /** + * 数值线性插值 + * @param from 起始值 + * @param to 目标值 + * @param t 插值参数 (0-1) + * @returns 插值结果 + */ + static number(from: number, to: number, t: number): number { + return MathUtils.lerp(from, to, t); + } + + /** + * 向量线性插值 + * @param from 起始向量 + * @param to 目标向量 + * @param t 插值参数 (0-1) + * @returns 插值结果向量 + */ + static vector2(from: Vector2, to: Vector2, t: number): Vector2 { + return Vector2.lerp(from, to, t); + } + + /** + * 角度插值(处理角度环绕) + * @param from 起始角度(弧度) + * @param to 目标角度(弧度) + * @param t 插值参数 (0-1) + * @returns 插值结果角度 + */ + static angle(from: number, to: number, t: number): number { + return MathUtils.lerpAngle(from, to, t); + } + + /** + * 颜色插值(RGB) + * @param from 起始颜色 [r, g, b, a?] + * @param to 目标颜色 [r, g, b, a?] + * @param t 插值参数 (0-1) + * @returns 插值结果颜色 + */ + static color(from: number[], to: number[], t: number): number[] { + const result: number[] = []; + const length = Math.max(from.length, to.length); + + for (let i = 0; i < length; i++) { + const fromVal = from[i] ?? (i === 3 ? 1 : 0); // alpha默认为1 + const toVal = to[i] ?? (i === 3 ? 1 : 0); + result[i] = MathUtils.lerp(fromVal, toVal, t); + } + + return result; + } + + // 高级插值 + + /** + * 三次样条插值 + * @param p0 控制点0 + * @param p1 控制点1(起点) + * @param p2 控制点2(终点) + * @param p3 控制点3 + * @param t 插值参数 (0-1) + * @returns 插值结果 + */ + static cubicSpline(p0: number, p1: number, p2: number, p3: number, t: number): number { + const t2 = t * t; + const t3 = t2 * t; + + return 0.5 * ( + (2 * p1) + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3 + ); + } + + /** + * Hermite插值 + * @param p0 起始点 + * @param m0 起始切线 + * @param p1 结束点 + * @param m1 结束切线 + * @param t 插值参数 (0-1) + * @returns 插值结果 + */ + static hermite(p0: number, m0: number, p1: number, m1: number, t: number): number { + const t2 = t * t; + const t3 = t2 * t; + + const h00 = 2 * t3 - 3 * t2 + 1; + const h10 = t3 - 2 * t2 + t; + const h01 = -2 * t3 + 3 * t2; + const h11 = t3 - t2; + + return h00 * p0 + h10 * m0 + h01 * p1 + h11 * m1; + } + + /** + * 球面线性插值(适用于方向向量) + * @param from 起始单位向量 + * @param to 目标单位向量 + * @param t 插值参数 (0-1) + * @returns 插值结果向量 + */ + static slerp(from: Vector2, to: Vector2, t: number): Vector2 { + let dot = Vector2.dot(from, to); + + // 如果点积为负,取反一个向量确保走最短路径 + let toVec = to; + if (dot < 0) { + dot = -dot; + toVec = to.clone().negate(); + } + + // 如果向量几乎平行,使用线性插值 + if (dot > 0.9995) { + return Vector2.lerp(from, toVec, t).normalize(); + } + + // 球面插值 + const theta = Math.acos(Math.abs(dot)); + const sinTheta = Math.sin(theta); + + const a = Math.sin((1 - t) * theta) / sinTheta; + const b = Math.sin(t * theta) / sinTheta; + + return new Vector2( + from.x * a + toVec.x * b, + from.y * a + toVec.y * b + ); + } + + // 缓存插值 + + /** + * 创建带缓存的插值器 + * 用于需要重复插值相同起始和目标值的情况 + * @param interpolator 插值函数 + * @returns 缓存插值器实例 + */ + static createCachedInterpolator(interpolator: InterpolatorFunction): CachedInterpolator { + return new CachedInterpolator(interpolator); + } + + // 多点插值 + + /** + * 样条曲线插值(通过多个控制点) + * @param points 控制点数组 + * @param t 插值参数 (0-1) + * @returns 插值结果 + */ + static spline(points: number[], t: number): number { + if (points.length === 0) return 0; + if (points.length === 1) return points[0]; + if (points.length === 2) return MathUtils.lerp(points[0], points[1], t); + + const n = points.length - 1; + const scaledT = t * n; + const segment = Math.floor(scaledT); + const localT = scaledT - segment; + + const i = Math.max(0, Math.min(n - 1, segment)); + + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[Math.min(n, i + 1)]; + const p3 = points[Math.min(n, i + 2)]; + + return Interpolation.cubicSpline(p0, p1, p2, p3, localT); + } + + /** + * 向量样条曲线插值 + * @param points 控制点数组 + * @param t 插值参数 (0-1) + * @returns 插值结果向量 + */ + static vectorSpline(points: Vector2[], t: number): Vector2 { + if (points.length === 0) return new Vector2(); + if (points.length === 1) return points[0].clone(); + if (points.length === 2) return Vector2.lerp(points[0], points[1], t); + + const xPoints = points.map(p => p.x); + const yPoints = points.map(p => p.y); + + return new Vector2( + Interpolation.spline(xPoints, t), + Interpolation.spline(yPoints, t) + ); + } + + // 时间轴插值 + + /** + * 关键帧动画插值 + * @param keyframes 关键帧数组(按时间排序) + * @param time 当前时间 + * @param interpolator 插值函数 + * @returns 插值结果 + */ + static keyframe( + keyframes: Keyframe[], + time: number, + interpolator: InterpolatorFunction + ): T { + if (keyframes.length === 0) { + throw new Error('至少需要一个关键帧'); + } + + if (keyframes.length === 1 || time <= keyframes[0].time) { + return keyframes[0].value; + } + + if (time >= keyframes[keyframes.length - 1].time) { + return keyframes[keyframes.length - 1].value; + } + + // 找到当前时间所在的区间 + for (let i = 0; i < keyframes.length - 1; i++) { + const current = keyframes[i]; + const next = keyframes[i + 1]; + + if (time >= current.time && time <= next.time) { + const duration = next.time - current.time; + const progress = duration > 0 ? (time - current.time) / duration : 0; + + // 应用缓动函数 + const easedProgress = current.easing ? current.easing(progress) : progress; + + return interpolator(current.value, next.value, easedProgress); + } + } + + return keyframes[keyframes.length - 1].value; + } + + // 路径插值 + + /** + * 路径插值(沿着由点组成的路径) + * @param path 路径点数组 + * @param t 插值参数 (0-1) + * @param closed 是否为闭合路径 + * @returns 路径上的点 + */ + static pathInterpolation(path: Vector2[], t: number, closed: boolean = false): Vector2 { + if (path.length === 0) return new Vector2(); + if (path.length === 1) return path[0].clone(); + + const totalLength = Interpolation.getPathLength(path, closed); + const targetDistance = t * totalLength; + + let accumulatedDistance = 0; + const segments = closed ? path.length : path.length - 1; + + for (let i = 0; i < segments; i++) { + const start = path[i]; + const end = path[(i + 1) % path.length]; + const segmentLength = Vector2.distance(start, end); + + if (accumulatedDistance + segmentLength >= targetDistance) { + const segmentT = (targetDistance - accumulatedDistance) / segmentLength; + return Vector2.lerp(start, end, segmentT); + } + + accumulatedDistance += segmentLength; + } + + return path[path.length - 1].clone(); + } + + /** + * 计算路径总长度 + * @param path 路径点数组 + * @param closed 是否为闭合路径 + * @returns 路径总长度 + */ + static getPathLength(path: Vector2[], closed: boolean = false): number { + if (path.length < 2) return 0; + + let totalLength = 0; + const segments = closed ? path.length : path.length - 1; + + for (let i = 0; i < segments; i++) { + const start = path[i]; + const end = path[(i + 1) % path.length]; + totalLength += Vector2.distance(start, end); + } + + return totalLength; + } + + // 实用工具 + + /** + * 创建数值插值器 + * @param from 起始值 + * @param to 目标值 + * @returns 插值器函数 + */ + static createNumberInterpolator(from: number, to: number): (t: number) => number { + return (t: number) => Interpolation.number(from, to, t); + } + + /** + * 创建向量插值器 + * @param from 起始向量 + * @param to 目标向量 + * @returns 插值器函数 + */ + static createVectorInterpolator(from: Vector2, to: Vector2): (t: number) => Vector2 { + return (t: number) => Interpolation.vector2(from, to, t); + } + + /** + * 创建组合插值器(插值多个值) + * @param interpolators 插值器数组 + * @returns 组合插值器函数 + */ + static createCompositeInterpolator( + interpolators: InterpolatorFunction[] + ): (from: T[], to: T[], t: number) => T[] { + return (from: T[], to: T[], t: number): T[] => { + const result: T[] = []; + for (let i = 0; i < Math.min(interpolators.length, from.length, to.length); i++) { + result[i] = interpolators[i](from[i], to[i], t); + } + return result; + }; + } +} \ No newline at end of file diff --git a/packages/math/src/Animation/index.ts b/packages/math/src/Animation/index.ts new file mode 100644 index 00000000..d1bd89d3 --- /dev/null +++ b/packages/math/src/Animation/index.ts @@ -0,0 +1,13 @@ +/** + * 动画和插值模块 + * + * 提供缓动函数和各种插值功能 + */ + +export { Easing } from './Easing'; +export { + Interpolation, + CachedInterpolator, + type InterpolatorFunction, + type Keyframe +} from './Interpolation'; \ No newline at end of file diff --git a/packages/math/src/Circle.ts b/packages/math/src/Circle.ts new file mode 100644 index 00000000..51893a3e --- /dev/null +++ b/packages/math/src/Circle.ts @@ -0,0 +1,594 @@ +import { Vector2 } from './Vector2'; +import { Rectangle } from './Rectangle'; + +/** + * 2D圆形类 + * + * 表示一个圆形,提供圆形相关的几何运算功能: + * - 圆形创建和属性获取 + * - 包含检测(点、圆形) + * - 相交检测和计算 + * - 变换和操作 + */ +export class Circle { + /** 圆心X坐标 */ + public x: number; + + /** 圆心Y坐标 */ + public y: number; + + /** 半径 */ + public radius: number; + + /** + * 创建圆形 + * @param x 圆心X坐标,默认为0 + * @param y 圆心Y坐标,默认为0 + * @param radius 半径,默认为0 + */ + constructor(x: number = 0, y: number = 0, radius: number = 0) { + this.x = x; + this.y = y; + this.radius = radius; + } + + // 静态常量 + /** 空圆形 */ + static readonly EMPTY = new Circle(0, 0, 0); + + /** 单位圆 */ + static readonly UNIT = new Circle(0, 0, 1); + + // 属性获取 + + /** 获取圆心坐标 */ + get center(): Vector2 { + return new Vector2(this.x, this.y); + } + + /** 设置圆心坐标 */ + set center(value: Vector2) { + this.x = value.x; + this.y = value.y; + } + + /** 获取直径 */ + get diameter(): number { + return this.radius * 2; + } + + /** 设置直径 */ + set diameter(value: number) { + this.radius = value * 0.5; + } + + /** 获取面积 */ + get area(): number { + return Math.PI * this.radius * this.radius; + } + + /** 获取周长 */ + get circumference(): number { + return 2 * Math.PI * this.radius; + } + + /** 获取包围矩形 */ + get bounds(): Rectangle { + return new Rectangle( + this.x - this.radius, + this.y - this.radius, + this.diameter, + this.diameter + ); + } + + /** 检查是否为空圆形 */ + get isEmpty(): boolean { + return this.radius <= 0; + } + + // 基础操作 + + /** + * 设置圆形属性 + * @param x 圆心X坐标 + * @param y 圆心Y坐标 + * @param radius 半径 + * @returns 当前圆形实例(链式调用) + */ + set(x: number, y: number, radius: number): this { + this.x = x; + this.y = y; + this.radius = radius; + return this; + } + + /** + * 复制另一个圆形的值 + * @param other 源圆形 + * @returns 当前圆形实例(链式调用) + */ + copy(other: Circle): this { + this.x = other.x; + this.y = other.y; + this.radius = other.radius; + return this; + } + + /** + * 克隆当前圆形 + * @returns 新的圆形实例 + */ + clone(): Circle { + return new Circle(this.x, this.y, this.radius); + } + + /** + * 设置圆心位置 + * @param x 新的X坐标 + * @param y 新的Y坐标 + * @returns 当前圆形实例(链式调用) + */ + setPosition(x: number, y: number): this { + this.x = x; + this.y = y; + return this; + } + + /** + * 设置圆心位置(使用向量) + * @param center 新的圆心位置 + * @returns 当前圆形实例(链式调用) + */ + setCenter(center: Vector2): this { + this.x = center.x; + this.y = center.y; + return this; + } + + /** + * 设置半径 + * @param radius 新的半径 + * @returns 当前圆形实例(链式调用) + */ + setRadius(radius: number): this { + this.radius = radius; + return this; + } + + // 变换操作 + + /** + * 平移圆形 + * @param dx X方向偏移 + * @param dy Y方向偏移 + * @returns 当前圆形实例(链式调用) + */ + translate(dx: number, dy: number): this { + this.x += dx; + this.y += dy; + return this; + } + + /** + * 平移圆形(使用向量) + * @param offset 偏移向量 + * @returns 当前圆形实例(链式调用) + */ + translateBy(offset: Vector2): this { + this.x += offset.x; + this.y += offset.y; + return this; + } + + /** + * 缩放圆形 + * @param scale 缩放因子 + * @returns 当前圆形实例(链式调用) + */ + scale(scale: number): this { + this.radius *= scale; + return this; + } + + /** + * 扩展圆形 + * @param amount 扩展量(正值扩大半径,负值缩小半径) + * @returns 当前圆形实例(链式调用) + */ + inflate(amount: number): this { + this.radius += amount; + return this; + } + + // 包含检测 + + /** + * 检查是否包含指定点 + * @param point 点 + * @returns 是否包含 + */ + containsPoint(point: Vector2): boolean { + const dx = point.x - this.x; + const dy = point.y - this.y; + return dx * dx + dy * dy <= this.radius * this.radius; + } + + /** + * 检查是否包含指定坐标 + * @param x X坐标 + * @param y Y坐标 + * @returns 是否包含 + */ + contains(x: number, y: number): boolean { + const dx = x - this.x; + const dy = y - this.y; + return dx * dx + dy * dy <= this.radius * this.radius; + } + + /** + * 检查是否完全包含另一个圆形 + * @param other 另一个圆形 + * @returns 是否完全包含 + */ + containsCircle(other: Circle): boolean { + const distance = this.distanceToCircle(other); + return distance + other.radius <= this.radius; + } + + /** + * 检查点是否在圆的边界上 + * @param point 点 + * @param epsilon 容差,默认为Number.EPSILON + * @returns 是否在边界上 + */ + pointOnBoundary(point: Vector2, epsilon: number = Number.EPSILON): boolean { + const distance = this.distanceToPoint(point); + return Math.abs(distance - this.radius) < epsilon; + } + + // 相交检测 + + /** + * 检查是否与另一个圆形相交 + * @param other 另一个圆形 + * @returns 是否相交 + */ + intersects(other: Circle): boolean { + const dx = this.x - other.x; + const dy = this.y - other.y; + const distanceSquared = dx * dx + dy * dy; + const radiusSum = this.radius + other.radius; + return distanceSquared <= radiusSum * radiusSum; + } + + /** + * 检查是否与矩形相交 + * @param rect 矩形 + * @returns 是否相交 + */ + intersectsRect(rect: Rectangle): boolean { + // 找到矩形上离圆心最近的点 + const closestX = Math.max(rect.x, Math.min(this.x, rect.right)); + const closestY = Math.max(rect.y, Math.min(this.y, rect.bottom)); + + // 计算圆心到最近点的距离 + const dx = this.x - closestX; + const dy = this.y - closestY; + + return dx * dx + dy * dy <= this.radius * this.radius; + } + + /** + * 计算与另一个圆形的相交面积 + * @param other 另一个圆形 + * @returns 相交面积 + */ + intersectionArea(other: Circle): number { + const d = this.distanceToCircle(other); + + // 不相交 + if (d >= this.radius + other.radius) { + return 0; + } + + // 一个圆完全包含另一个圆 + if (d <= Math.abs(this.radius - other.radius)) { + const smallerRadius = Math.min(this.radius, other.radius); + return Math.PI * smallerRadius * smallerRadius; + } + + // 部分相交 + const r1 = this.radius; + const r2 = other.radius; + + const part1 = r1 * r1 * Math.acos((d * d + r1 * r1 - r2 * r2) / (2 * d * r1)); + const part2 = r2 * r2 * Math.acos((d * d + r2 * r2 - r1 * r1) / (2 * d * r2)); + const part3 = 0.5 * Math.sqrt((-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2)); + + return part1 + part2 - part3; + } + + // 距离计算 + + /** + * 计算圆心到点的距离 + * @param point 点 + * @returns 距离 + */ + distanceToPoint(point: Vector2): number { + const dx = point.x - this.x; + const dy = point.y - this.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 计算圆形边界到点的最短距离 + * @param point 点 + * @returns 最短距离(点在圆内时为负值) + */ + distanceToPointFromBoundary(point: Vector2): number { + return this.distanceToPoint(point) - this.radius; + } + + /** + * 计算两个圆心之间的距离 + * @param other 另一个圆形 + * @returns 圆心距离 + */ + distanceToCircle(other: Circle): number { + const dx = this.x - other.x; + const dy = this.y - other.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 计算两个圆形边界之间的最短距离 + * @param other 另一个圆形 + * @returns 最短距离(相交时为负值) + */ + distanceToCircleFromBoundary(other: Circle): number { + return this.distanceToCircle(other) - this.radius - other.radius; + } + + /** + * 计算圆形到矩形的最短距离 + * @param rect 矩形 + * @returns 最短距离 + */ + distanceToRect(rect: Rectangle): number { + return Math.max(0, rect.distanceToPoint(this.center) - this.radius); + } + + /** + * 获取圆形上距离指定点最近的点 + * @param point 指定点 + * @returns 最近点 + */ + closestPointTo(point: Vector2): Vector2 { + const direction = Vector2.subtract(point, this.center); + if (direction.isZero) { + // 点在圆心,返回圆上任意点 + return new Vector2(this.x + this.radius, this.y); + } + return this.center.clone().add(direction.normalized().multiply(this.radius)); + } + + /** + * 获取圆形上距离指定点最远的点 + * @param point 指定点 + * @returns 最远点 + */ + farthestPointFrom(point: Vector2): Vector2 { + const direction = Vector2.subtract(point, this.center); + if (direction.isZero) { + // 点在圆心,返回圆上任意点 + return new Vector2(this.x - this.radius, this.y); + } + return this.center.clone().subtract(direction.normalized().multiply(this.radius)); + } + + // 几何运算 + + /** + * 获取指定角度上的圆周点 + * @param angle 角度(弧度) + * @returns 圆周点 + */ + getPointAtAngle(angle: number): Vector2 { + return new Vector2( + this.x + this.radius * Math.cos(angle), + this.y + this.radius * Math.sin(angle) + ); + } + + /** + * 获取点相对于圆心的角度 + * @param point 点 + * @returns 角度(弧度) + */ + getAngleToPoint(point: Vector2): number { + return Math.atan2(point.y - this.y, point.x - this.x); + } + + /** + * 获取圆形与直线的交点 + * @param lineStart 直线起点 + * @param lineEnd 直线终点 + * @returns 交点数组(0-2个点) + */ + getLineIntersections(lineStart: Vector2, lineEnd: Vector2): Vector2[] { + const dx = lineEnd.x - lineStart.x; + const dy = lineEnd.y - lineStart.y; + const fx = lineStart.x - this.x; + const fy = lineStart.y - this.y; + + const a = dx * dx + dy * dy; + const b = 2 * (fx * dx + fy * dy); + const c = fx * fx + fy * fy - this.radius * this.radius; + + const discriminant = b * b - 4 * a * c; + + if (discriminant < 0) { + return []; // 无交点 + } + + if (discriminant === 0) { + // 一个交点(切线) + const t = -b / (2 * a); + return [new Vector2(lineStart.x + t * dx, lineStart.y + t * dy)]; + } + + // 两个交点 + const sqrt = Math.sqrt(discriminant); + const t1 = (-b - sqrt) / (2 * a); + const t2 = (-b + sqrt) / (2 * a); + + return [ + new Vector2(lineStart.x + t1 * dx, lineStart.y + t1 * dy), + new Vector2(lineStart.x + t2 * dx, lineStart.y + t2 * dy) + ]; + } + + // 比较操作 + + /** + * 检查两个圆形是否相等 + * @param other 另一个圆形 + * @param epsilon 容差,默认为Number.EPSILON + * @returns 是否相等 + */ + equals(other: Circle, epsilon: number = Number.EPSILON): boolean { + return Math.abs(this.x - other.x) < epsilon && + Math.abs(this.y - other.y) < epsilon && + Math.abs(this.radius - other.radius) < epsilon; + } + + /** + * 检查两个圆形是否完全相等 + * @param other 另一个圆形 + * @returns 是否完全相等 + */ + exactEquals(other: Circle): boolean { + return this.x === other.x && this.y === other.y && this.radius === other.radius; + } + + // 静态方法 + + /** + * 从直径创建圆形 + * @param x 圆心X坐标 + * @param y 圆心Y坐标 + * @param diameter 直径 + * @returns 新的圆形实例 + */ + static fromDiameter(x: number, y: number, diameter: number): Circle { + return new Circle(x, y, diameter * 0.5); + } + + /** + * 从三个点创建外接圆 + * @param p1 第一个点 + * @param p2 第二个点 + * @param p3 第三个点 + * @returns 外接圆,如果三点共线返回null + */ + static fromThreePoints(p1: Vector2, p2: Vector2, p3: Vector2): Circle | null { + const ax = p1.x; const ay = p1.y; + const bx = p2.x; const by = p2.y; + const cx = p3.x; const cy = p3.y; + + const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + + if (Math.abs(d) < Number.EPSILON) { + return null; // 三点共线 + } + + const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d; + const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d; + + const radius = Math.sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy)); + + return new Circle(ux, uy, radius); + } + + /** + * 从点数组创建最小包围圆 + * @param points 点数组 + * @returns 最小包围圆 + */ + static fromPointArray(points: Vector2[]): Circle { + if (points.length === 0) { + return Circle.EMPTY.clone(); + } + + if (points.length === 1) { + return new Circle(points[0].x, points[0].y, 0); + } + + // 使用Welzl算法的简化版本 + // 这里使用更简单的方法:找到包围所有点的圆 + let minX = points[0].x, minY = points[0].y; + let maxX = points[0].x, maxY = points[0].y; + + for (const point of points) { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + } + + const centerX = (minX + maxX) * 0.5; + const centerY = (minY + maxY) * 0.5; + const center = new Vector2(centerX, centerY); + + let maxDistance = 0; + for (const point of points) { + const distance = Vector2.distance(center, point); + maxDistance = Math.max(maxDistance, distance); + } + + return new Circle(centerX, centerY, maxDistance); + } + + /** + * 线性插值两个圆形 + * @param a 起始圆形 + * @param b 目标圆形 + * @param t 插值参数(0到1) + * @returns 新的插值结果圆形 + */ + static lerp(a: Circle, b: Circle, t: number): Circle { + return new Circle( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.radius + (b.radius - a.radius) * t + ); + } + + // 字符串转换 + + /** + * 转换为字符串 + * @returns 字符串表示 + */ + toString(): string { + return `Circle(${this.x.toFixed(2)}, ${this.y.toFixed(2)}, r=${this.radius.toFixed(2)})`; + } + + /** + * 转换为数组 + * @returns [x, y, radius] 数组 + */ + toArray(): [number, number, number] { + return [this.x, this.y, this.radius]; + } + + /** + * 转换为普通对象 + * @returns {x, y, radius} 对象 + */ + toObject(): { x: number; y: number; radius: number } { + return { x: this.x, y: this.y, radius: this.radius }; + } +} \ No newline at end of file diff --git a/packages/math/src/Collision/CollisionDetector.ts b/packages/math/src/Collision/CollisionDetector.ts new file mode 100644 index 00000000..26ca3d01 --- /dev/null +++ b/packages/math/src/Collision/CollisionDetector.ts @@ -0,0 +1,445 @@ +import { Vector2 } from '../Vector2'; +import { Rectangle } from '../Rectangle'; +import { Circle } from '../Circle'; + +/** + * 碰撞信息接口 + */ +export interface CollisionInfo { + /** 是否发生碰撞 */ + collided: boolean; + /** 碰撞法线(指向第二个对象) */ + normal?: Vector2; + /** 穿透深度 */ + penetration?: number; + /** 碰撞点 */ + contactPoint?: Vector2; +} + +/** + * 碰撞检测器 + * + * 提供各种几何体之间的碰撞检测功能 + */ +export class CollisionDetector { + + // 点与几何体碰撞检测 + + /** + * 点与圆形碰撞检测 + * @param point 点 + * @param circle 圆形 + * @returns 碰撞信息 + */ + static pointCircle(point: Vector2, circle: Circle): CollisionInfo { + const distance = Vector2.distance(point, circle.center); + const collided = distance <= circle.radius; + + if (!collided) { + return { collided: false }; + } + + const normal = distance > 0 + ? Vector2.subtract(point, circle.center).normalize() + : new Vector2(1, 0); // 默认法线 + + return { + collided: true, + normal, + penetration: circle.radius - distance, + contactPoint: point.clone() + }; + } + + /** + * 点与矩形碰撞检测 + * @param point 点 + * @param rect 矩形 + * @returns 碰撞信息 + */ + static pointRect(point: Vector2, rect: Rectangle): CollisionInfo { + const collided = rect.containsPoint(point); + + if (!collided) { + return { collided: false }; + } + + // 计算到各边的距离 + const distLeft = point.x - rect.left; + const distRight = rect.right - point.x; + const distTop = point.y - rect.top; + const distBottom = rect.bottom - point.y; + + // 找到最小距离确定法线方向 + const minDist = Math.min(distLeft, distRight, distTop, distBottom); + let normal: Vector2; + let penetration = minDist; + + if (minDist === distLeft) { + normal = new Vector2(-1, 0); + } else if (minDist === distRight) { + normal = new Vector2(1, 0); + } else if (minDist === distTop) { + normal = new Vector2(0, -1); + } else { + normal = new Vector2(0, 1); + } + + return { + collided: true, + normal, + penetration, + contactPoint: point.clone() + }; + } + + // 圆形碰撞检测 + + /** + * 圆形与圆形碰撞检测 + * @param circle1 第一个圆形 + * @param circle2 第二个圆形 + * @returns 碰撞信息 + */ + static circleCircle(circle1: Circle, circle2: Circle): CollisionInfo { + const distance = Vector2.distance(circle1.center, circle2.center); + const radiusSum = circle1.radius + circle2.radius; + const collided = distance <= radiusSum; + + if (!collided) { + return { collided: false }; + } + + const normal = distance > 0 + ? Vector2.subtract(circle2.center, circle1.center).normalize() + : new Vector2(1, 0); // 默认法线 + + const penetration = radiusSum - distance; + const contactPoint = circle1.center.clone().add( + normal.clone().multiply(circle1.radius - penetration * 0.5) + ); + + return { + collided: true, + normal, + penetration, + contactPoint + }; + } + + /** + * 圆形与矩形碰撞检测 + * @param circle 圆形 + * @param rect 矩形 + * @returns 碰撞信息 + */ + static circleRect(circle: Circle, rect: Rectangle): CollisionInfo { + // 找到矩形上离圆心最近的点 + const closestPoint = rect.closestPointTo(circle.center); + + // 检查是否碰撞 + const distance = Vector2.distance(circle.center, closestPoint); + const collided = distance <= circle.radius; + + if (!collided) { + return { collided: false }; + } + + // 计算法线和穿透深度 + const normal = distance > 0 + ? Vector2.subtract(closestPoint, circle.center).normalize() + : new Vector2(0, -1); // 默认法线(圆心在矩形内部时) + + const penetration = circle.radius - distance; + + return { + collided: true, + normal, + penetration, + contactPoint: closestPoint + }; + } + + // 矩形碰撞检测 + + /** + * 矩形与矩形碰撞检测(AABB) + * @param rect1 第一个矩形 + * @param rect2 第二个矩形 + * @returns 碰撞信息 + */ + static rectRect(rect1: Rectangle, rect2: Rectangle): CollisionInfo { + const collided = rect1.intersects(rect2); + + if (!collided) { + return { collided: false }; + } + + // 计算重叠区域 + const overlapLeft = Math.max(rect1.left, rect2.left); + const overlapRight = Math.min(rect1.right, rect2.right); + const overlapTop = Math.max(rect1.top, rect2.top); + const overlapBottom = Math.min(rect1.bottom, rect2.bottom); + + const overlapWidth = overlapRight - overlapLeft; + const overlapHeight = overlapBottom - overlapTop; + + // 确定分离方向(最小重叠轴) + let normal: Vector2; + let penetration: number; + + if (overlapWidth < overlapHeight) { + // 水平分离 + penetration = overlapWidth; + if (rect1.centerX < rect2.centerX) { + normal = new Vector2(-1, 0); + } else { + normal = new Vector2(1, 0); + } + } else { + // 垂直分离 + penetration = overlapHeight; + if (rect1.centerY < rect2.centerY) { + normal = new Vector2(0, -1); + } else { + normal = new Vector2(0, 1); + } + } + + const contactPoint = new Vector2( + (overlapLeft + overlapRight) * 0.5, + (overlapTop + overlapBottom) * 0.5 + ); + + return { + collided: true, + normal, + penetration, + contactPoint + }; + } + + // 射线投射 + + /** + * 射线与圆形相交检测 + * @param rayOrigin 射线起点 + * @param rayDirection 射线方向(单位向量) + * @param circle 圆形 + * @param maxDistance 最大检测距离,默认无限 + * @returns 碰撞信息,包含距离信息 + */ + static rayCircle( + rayOrigin: Vector2, + rayDirection: Vector2, + circle: Circle, + maxDistance: number = Infinity + ): CollisionInfo & { distance?: number } { + + const oc = Vector2.subtract(rayOrigin, circle.center); + const a = rayDirection.lengthSquared; + const b = 2 * Vector2.dot(oc, rayDirection); + const c = oc.lengthSquared - circle.radius * circle.radius; + + const discriminant = b * b - 4 * a * c; + + if (discriminant < 0) { + return { collided: false }; + } + + const sqrt = Math.sqrt(discriminant); + const t1 = (-b - sqrt) / (2 * a); + const t2 = (-b + sqrt) / (2 * a); + + // 选择最近的正距离 + let t = t1 >= 0 ? t1 : t2; + + if (t < 0 || t > maxDistance) { + return { collided: false }; + } + + const contactPoint = rayOrigin.clone().add(rayDirection.clone().multiply(t)); + const normal = Vector2.subtract(contactPoint, circle.center).normalize(); + + return { + collided: true, + normal, + contactPoint, + distance: t, + penetration: 0 // 射线检测不计算穿透 + }; + } + + /** + * 射线与矩形相交检测 + * @param rayOrigin 射线起点 + * @param rayDirection 射线方向(单位向量) + * @param rect 矩形 + * @param maxDistance 最大检测距离,默认无限 + * @returns 碰撞信息,包含距离信息 + */ + static rayRect( + rayOrigin: Vector2, + rayDirection: Vector2, + rect: Rectangle, + maxDistance: number = Infinity + ): CollisionInfo & { distance?: number } { + + // 避免除零 + const invDirX = rayDirection.x !== 0 ? 1 / rayDirection.x : 1e10; + const invDirY = rayDirection.y !== 0 ? 1 / rayDirection.y : 1e10; + + // 计算与各边的交点参数 + const t1 = (rect.left - rayOrigin.x) * invDirX; + const t2 = (rect.right - rayOrigin.x) * invDirX; + const t3 = (rect.top - rayOrigin.y) * invDirY; + const t4 = (rect.bottom - rayOrigin.y) * invDirY; + + const tmin = Math.max(Math.min(t1, t2), Math.min(t3, t4)); + const tmax = Math.min(Math.max(t1, t2), Math.max(t3, t4)); + + // 没有交点或交点在射线反方向 + if (tmax < 0 || tmin > tmax || tmin > maxDistance) { + return { collided: false }; + } + + const t = tmin >= 0 ? tmin : tmax; + const contactPoint = rayOrigin.clone().add(rayDirection.clone().multiply(t)); + + // 确定法线方向 + let normal: Vector2; + const epsilon = 1e-6; + + if (Math.abs(contactPoint.x - rect.left) < epsilon) { + normal = new Vector2(-1, 0); + } else if (Math.abs(contactPoint.x - rect.right) < epsilon) { + normal = new Vector2(1, 0); + } else if (Math.abs(contactPoint.y - rect.top) < epsilon) { + normal = new Vector2(0, -1); + } else { + normal = new Vector2(0, 1); + } + + return { + collided: true, + normal, + contactPoint, + distance: t, + penetration: 0 // 射线检测不计算穿透 + }; + } + + // 线段相交检测 + + /** + * 线段与线段相交检测 + * @param p1 第一条线段起点 + * @param p2 第一条线段终点 + * @param p3 第二条线段起点 + * @param p4 第二条线段终点 + * @returns 碰撞信息 + */ + static lineSegmentLineSegment(p1: Vector2, p2: Vector2, p3: Vector2, p4: Vector2): CollisionInfo { + const d1 = Vector2.subtract(p2, p1); + const d2 = Vector2.subtract(p4, p3); + const d3 = Vector2.subtract(p3, p1); + + const cross = Vector2.cross(d1, d2); + + if (Math.abs(cross) < Number.EPSILON) { + // 平行或共线 + return { collided: false }; + } + + const t1 = Vector2.cross(d3, d2) / cross; + const t2 = Vector2.cross(d3, d1) / cross; + + if (t1 >= 0 && t1 <= 1 && t2 >= 0 && t2 <= 1) { + const contactPoint = p1.clone().add(d1.clone().multiply(t1)); + + // 计算法线(垂直于第一条线段) + const normal = d1.perpendicular().normalize(); + + return { + collided: true, + normal, + contactPoint, + penetration: 0 // 线段相交不计算穿透 + }; + } + + return { collided: false }; + } + + /** + * 线段与圆形相交检测 + * @param lineStart 线段起点 + * @param lineEnd 线段终点 + * @param circle 圆形 + * @returns 碰撞信息 + */ + static lineSegmentCircle(lineStart: Vector2, lineEnd: Vector2, circle: Circle): CollisionInfo { + const d = Vector2.subtract(lineEnd, lineStart); + const f = Vector2.subtract(lineStart, circle.center); + + const a = Vector2.dot(d, d); + const b = 2 * Vector2.dot(f, d); + const c = Vector2.dot(f, f) - circle.radius * circle.radius; + + const discriminant = b * b - 4 * a * c; + + if (discriminant < 0) { + return { collided: false }; + } + + const sqrt = Math.sqrt(discriminant); + const t1 = (-b - sqrt) / (2 * a); + const t2 = (-b + sqrt) / (2 * a); + + // 检查交点是否在线段上 + const validT = []; + if (t1 >= 0 && t1 <= 1) validT.push(t1); + if (t2 >= 0 && t2 <= 1) validT.push(t2); + + if (validT.length === 0) { + return { collided: false }; + } + + // 使用最近的交点 + const t = validT[0]; + const contactPoint = lineStart.clone().add(d.clone().multiply(t)); + const normal = Vector2.subtract(contactPoint, circle.center).normalize(); + + return { + collided: true, + normal, + contactPoint, + penetration: 0 // 线段相交不计算穿透 + }; + } + + // 快速排斥测试 + + /** + * AABB包围盒快速排斥测试 + * @param bounds1 第一个包围盒 + * @param bounds2 第二个包围盒 + * @returns 是否可能相交 + */ + static aabbTest(bounds1: Rectangle, bounds2: Rectangle): boolean { + return bounds1.intersects(bounds2); + } + + /** + * 圆形包围盒快速排斥测试 + * @param center1 第一个圆心 + * @param radius1 第一个半径 + * @param center2 第二个圆心 + * @param radius2 第二个半径 + * @returns 是否可能相交 + */ + static circleTest(center1: Vector2, radius1: number, center2: Vector2, radius2: number): boolean { + const distance = Vector2.distance(center1, center2); + return distance <= radius1 + radius2; + } +} \ No newline at end of file diff --git a/packages/math/src/Collision/index.ts b/packages/math/src/Collision/index.ts new file mode 100644 index 00000000..dac807ad --- /dev/null +++ b/packages/math/src/Collision/index.ts @@ -0,0 +1,7 @@ +/** + * 碰撞检测模块 + * + * 提供各种几何体间的碰撞检测功能 + */ + +export { CollisionDetector, type CollisionInfo } from './CollisionDetector'; \ No newline at end of file diff --git a/packages/math/src/MathUtils.ts b/packages/math/src/MathUtils.ts new file mode 100644 index 00000000..b5421162 --- /dev/null +++ b/packages/math/src/MathUtils.ts @@ -0,0 +1,567 @@ +import { Vector2 } from './Vector2'; + +/** + * 数学工具函数集合 + * + * 提供常用的数学运算、插值、随机数生成等实用工具函数 + */ +export class MathUtils { + // 数学常量 + /** 圆周率 */ + static readonly PI = Math.PI; + + /** 2π */ + static readonly TWO_PI = Math.PI * 2; + + /** π/2 */ + static readonly HALF_PI = Math.PI * 0.5; + + /** π/4 */ + static readonly QUARTER_PI = Math.PI * 0.25; + + /** 角度到弧度转换系数 */ + static readonly DEG_TO_RAD = Math.PI / 180; + + /** 弧度到角度转换系数 */ + static readonly RAD_TO_DEG = 180 / Math.PI; + + /** 黄金比例 */ + static readonly GOLDEN_RATIO = (1 + Math.sqrt(5)) * 0.5; + + /** 默认浮点数比较容差 */ + static readonly EPSILON = Number.EPSILON; + + // 角度转换 + + /** + * 角度转弧度 + * @param degrees 角度值 + * @returns 弧度值 + */ + static degToRad(degrees: number): number { + return degrees * MathUtils.DEG_TO_RAD; + } + + /** + * 弧度转角度 + * @param radians 弧度值 + * @returns 角度值 + */ + static radToDeg(radians: number): number { + return radians * MathUtils.RAD_TO_DEG; + } + + /** + * 规范化角度到[0, 2π)范围 + * @param radians 角度(弧度) + * @returns 规范化后的角度 + */ + static normalizeAngle(radians: number): number { + while (radians < 0) radians += MathUtils.TWO_PI; + while (radians >= MathUtils.TWO_PI) radians -= MathUtils.TWO_PI; + return radians; + } + + /** + * 规范化角度到(-π, π]范围 + * @param radians 角度(弧度) + * @returns 规范化后的角度 + */ + static normalizeAngleSigned(radians: number): number { + while (radians <= -Math.PI) radians += MathUtils.TWO_PI; + while (radians > Math.PI) radians -= MathUtils.TWO_PI; + return radians; + } + + /** + * 计算两个角度之间的最短角度差 + * @param from 起始角度(弧度) + * @param to 目标角度(弧度) + * @returns 角度差(-π到π) + */ + static angleDifference(from: number, to: number): number { + let diff = to - from; + diff = MathUtils.normalizeAngleSigned(diff); + return diff; + } + + /** + * 角度插值(处理角度环绕) + * @param from 起始角度(弧度) + * @param to 目标角度(弧度) + * @param t 插值参数(0到1) + * @returns 插值结果角度 + */ + static lerpAngle(from: number, to: number, t: number): number { + const diff = MathUtils.angleDifference(from, to); + return from + diff * t; + } + + // 数值操作 + + /** + * 限制数值在指定范围内 + * @param value 待限制的值 + * @param min 最小值 + * @param max 最大值 + * @returns 限制后的值 + */ + static clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); + } + + /** + * 限制数值在0到1之间 + * @param value 待限制的值 + * @returns 限制后的值 + */ + static clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); + } + + /** + * 线性插值 + * @param a 起始值 + * @param b 目标值 + * @param t 插值参数(0到1) + * @returns 插值结果 + */ + static lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; + } + + /** + * 反向线性插值(获取插值参数) + * @param a 起始值 + * @param b 目标值 + * @param value 当前值 + * @returns 插值参数 + */ + static inverseLerp(a: number, b: number, value: number): number { + if (Math.abs(b - a) < MathUtils.EPSILON) { + return 0; + } + return (value - a) / (b - a); + } + + /** + * 重映射数值从一个范围到另一个范围 + * @param value 输入值 + * @param inMin 输入范围最小值 + * @param inMax 输入范围最大值 + * @param outMin 输出范围最小值 + * @param outMax 输出范围最大值 + * @returns 重映射后的值 + */ + static remap(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number { + const t = MathUtils.inverseLerp(inMin, inMax, value); + return MathUtils.lerp(outMin, outMax, t); + } + + /** + * 平滑阶跃函数(Hermite插值) + * @param t 输入参数(0到1) + * @returns 平滑输出(0到1) + */ + static smoothStep(t: number): number { + t = MathUtils.clamp01(t); + return t * t * (3 - 2 * t); + } + + /** + * 更平滑的阶跃函数 + * @param t 输入参数(0到1) + * @returns 平滑输出(0到1) + */ + static smootherStep(t: number): number { + t = MathUtils.clamp01(t); + return t * t * t * (t * (t * 6 - 15) + 10); + } + + // 比较操作 + + /** + * 浮点数相等比较 + * @param a 数值a + * @param b 数值b + * @param epsilon 容差,默认为EPSILON + * @returns 是否相等 + */ + static approximately(a: number, b: number, epsilon: number = MathUtils.EPSILON): boolean { + return Math.abs(a - b) < epsilon; + } + + /** + * 检查数值是否为零 + * @param value 数值 + * @param epsilon 容差,默认为EPSILON + * @returns 是否为零 + */ + static isZero(value: number, epsilon: number = MathUtils.EPSILON): boolean { + return Math.abs(value) < epsilon; + } + + /** + * 获取数值的符号 + * @param value 数值 + * @returns 1、-1或0 + */ + static sign(value: number): number { + return value > 0 ? 1 : value < 0 ? -1 : 0; + } + + // 随机数生成 + + /** + * 生成指定范围内的随机数 + * @param min 最小值(包含) + * @param max 最大值(不包含) + * @returns 随机数 + */ + static random(min: number = 0, max: number = 1): number { + return Math.random() * (max - min) + min; + } + + /** + * 生成指定范围内的随机整数 + * @param min 最小值(包含) + * @param max 最大值(包含) + * @returns 随机整数 + */ + static randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + /** + * 随机选择数组中的一个元素 + * @param array 数组 + * @returns 随机元素 + */ + static randomChoice(array: T[]): T { + return array[Math.floor(Math.random() * array.length)]; + } + + /** + * 生成随机布尔值 + * @param probability 为true的概率(0到1),默认0.5 + * @returns 随机布尔值 + */ + static randomBoolean(probability: number = 0.5): boolean { + return Math.random() < probability; + } + + /** + * 生成单位圆内的随机点 + * @returns 随机向量 + */ + static randomInUnitCircle(): Vector2 { + const angle = Math.random() * MathUtils.TWO_PI; + const radius = Math.sqrt(Math.random()); + return Vector2.fromPolar(radius, angle); + } + + /** + * 生成单位圆上的随机点 + * @returns 随机单位向量 + */ + static randomOnUnitCircle(): Vector2 { + const angle = Math.random() * MathUtils.TWO_PI; + return Vector2.fromAngle(angle); + } + + // 数学函数 + + /** + * 快速平方根倒数(用于归一化) + * @param value 输入值 + * @returns 平方根倒数 + */ + static fastInverseSqrt(value: number): number { + // 简化版本,现代JavaScript引擎优化很好 + return 1 / Math.sqrt(value); + } + + /** + * 快速幂运算(整数指数) + * @param base 底数 + * @param exponent 指数(整数) + * @returns 幂运算结果 + */ + static fastPow(base: number, exponent: number): number { + if (exponent === 0) return 1; + if (exponent === 1) return base; + if (exponent === 2) return base * base; + if (exponent === 3) return base * base * base; + + return Math.pow(base, exponent); + } + + /** + * 阶乘 + * @param n 非负整数 + * @returns 阶乘结果 + */ + static factorial(n: number): number { + if (n < 0) return NaN; + if (n === 0 || n === 1) return 1; + + let result = 1; + for (let i = 2; i <= n; i++) { + result *= i; + } + return result; + } + + /** + * 最大公约数 + * @param a 整数a + * @param b 整数b + * @returns 最大公约数 + */ + static gcd(a: number, b: number): number { + a = Math.abs(Math.floor(a)); + b = Math.abs(Math.floor(b)); + + while (b !== 0) { + const temp = b; + b = a % b; + a = temp; + } + return a; + } + + /** + * 最小公倍数 + * @param a 整数a + * @param b 整数b + * @returns 最小公倍数 + */ + static lcm(a: number, b: number): number { + return Math.abs(a * b) / MathUtils.gcd(a, b); + } + + // 序列和级数 + + /** + * 斐波那契数列 + * @param n 项数 + * @returns 第n项斐波那契数 + */ + static fibonacci(n: number): number { + if (n <= 0) return 0; + if (n === 1) return 1; + + let a = 0, b = 1; + for (let i = 2; i <= n; i++) { + const temp = a + b; + a = b; + b = temp; + } + return b; + } + + /** + * 等差数列求和 + * @param first 首项 + * @param last 末项 + * @param count 项数 + * @returns 等差数列和 + */ + static arithmeticSum(first: number, last: number, count: number): number { + return (first + last) * count * 0.5; + } + + /** + * 等比数列求和 + * @param first 首项 + * @param ratio 公比 + * @param count 项数 + * @returns 等比数列和 + */ + static geometricSum(first: number, ratio: number, count: number): number { + if (Math.abs(ratio - 1) < MathUtils.EPSILON) { + return first * count; + } + return first * (1 - Math.pow(ratio, count)) / (1 - ratio); + } + + // 曲线和插值 + + /** + * 贝塞尔二次曲线 + * @param p0 控制点0 + * @param p1 控制点1 + * @param p2 控制点2 + * @param t 参数(0到1) + * @returns 曲线上的点 + */ + static quadraticBezier(p0: Vector2, p1: Vector2, p2: Vector2, t: number): Vector2 { + const u = 1 - t; + const tt = t * t; + const uu = u * u; + + return new Vector2( + uu * p0.x + 2 * u * t * p1.x + tt * p2.x, + uu * p0.y + 2 * u * t * p1.y + tt * p2.y + ); + } + + /** + * 贝塞尔三次曲线 + * @param p0 控制点0 + * @param p1 控制点1 + * @param p2 控制点2 + * @param p3 控制点3 + * @param t 参数(0到1) + * @returns 曲线上的点 + */ + static cubicBezier(p0: Vector2, p1: Vector2, p2: Vector2, p3: Vector2, t: number): Vector2 { + const u = 1 - t; + const tt = t * t; + const uu = u * u; + const uuu = uu * u; + const ttt = tt * t; + + return new Vector2( + uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y + ); + } + + /** + * Catmull-Rom样条插值 + * @param p0 控制点0 + * @param p1 控制点1 + * @param p2 控制点2 + * @param p3 控制点3 + * @param t 参数(0到1) + * @returns 插值结果点 + */ + static catmullRom(p0: Vector2, p1: Vector2, p2: Vector2, p3: Vector2, t: number): Vector2 { + const t2 = t * t; + const t3 = t2 * t; + + const x = 0.5 * ( + (2 * p1.x) + + (-p0.x + p2.x) * t + + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3 + ); + + const y = 0.5 * ( + (2 * p1.y) + + (-p0.y + p2.y) * t + + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3 + ); + + return new Vector2(x, y); + } + + // 噪声函数 + + /** + * 简单伪随机噪声(基于种子) + * @param x 输入X + * @param y 输入Y + * @param seed 种子 + * @returns 噪声值(0到1) + */ + static noise(x: number, y: number = 0, seed: number = 0): number { + let n = Math.sin(x * 12.9898 + y * 78.233 + seed * 37.719) * 43758.5453; + return n - Math.floor(n); + } + + /** + * 平滑噪声 + * @param x 输入X + * @param y 输入Y + * @param seed 种子 + * @returns 平滑噪声值(0到1) + */ + static smoothNoise(x: number, y: number = 0, seed: number = 0): number { + const intX = Math.floor(x); + const intY = Math.floor(y); + const fracX = x - intX; + const fracY = y - intY; + + const a = MathUtils.noise(intX, intY, seed); + const b = MathUtils.noise(intX + 1, intY, seed); + const c = MathUtils.noise(intX, intY + 1, seed); + const d = MathUtils.noise(intX + 1, intY + 1, seed); + + const i1 = MathUtils.lerp(a, b, fracX); + const i2 = MathUtils.lerp(c, d, fracX); + + return MathUtils.lerp(i1, i2, fracY); + } + + // 实用工具 + + /** + * 将数值转换为指定精度 + * @param value 数值 + * @param precision 精度(小数位数) + * @returns 转换后的数值 + */ + static toPrecision(value: number, precision: number): number { + const factor = Math.pow(10, precision); + return Math.round(value * factor) / factor; + } + + /** + * 检查数值是否在指定范围内 + * @param value 数值 + * @param min 最小值 + * @param max 最大值 + * @returns 是否在范围内 + */ + static inRange(value: number, min: number, max: number): boolean { + return value >= min && value <= max; + } + + /** + * 获取数组中的最小值 + * @param values 数值数组 + * @returns 最小值 + */ + static min(...values: number[]): number { + return Math.min(...values); + } + + /** + * 获取数组中的最大值 + * @param values 数值数组 + * @returns 最大值 + */ + static max(...values: number[]): number { + return Math.max(...values); + } + + /** + * 计算数组的平均值 + * @param values 数值数组 + * @returns 平均值 + */ + static average(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, val) => sum + val, 0) / values.length; + } + + /** + * 计算数组的中位数 + * @param values 数值数组 + * @returns 中位数 + */ + static median(values: number[]): number { + if (values.length === 0) return 0; + + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return (sorted[middle - 1] + sorted[middle]) / 2; + } + return sorted[middle]; + } +} \ No newline at end of file diff --git a/packages/math/src/Matrix3.ts b/packages/math/src/Matrix3.ts new file mode 100644 index 00000000..1cafef61 --- /dev/null +++ b/packages/math/src/Matrix3.ts @@ -0,0 +1,602 @@ +import { Vector2 } from './Vector2'; + +/** + * 3x3变换矩阵类 + * + * 用于2D变换(平移、旋转、缩放)的3x3矩阵 + * 矩阵布局: + * [m00, m01, m02] [scaleX * cos, -scaleY * sin, translateX] + * [m10, m11, m12] = [scaleX * sin, scaleY * cos, translateY] + * [m20, m21, m22] [0, 0, 1] + */ +export class Matrix3 { + /** 矩阵元素,按行优先存储 */ + public elements: Float32Array; + + /** + * 创建3x3矩阵 + * @param elements 矩阵元素数组(可选),默认为单位矩阵 + */ + constructor(elements?: ArrayLike) { + this.elements = new Float32Array(9); + + if (elements) { + this.elements.set(elements); + } else { + this.identity(); + } + } + + // 静态常量 + /** 单位矩阵 */ + static readonly IDENTITY = new Matrix3([ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + ]); + + /** 零矩阵 */ + static readonly ZERO = new Matrix3([ + 0, 0, 0, + 0, 0, 0, + 0, 0, 0 + ]); + + // 元素访问器 + + /** 获取矩阵元素 */ + get(row: number, col: number): number { + return this.elements[row * 3 + col]; + } + + /** 设置矩阵元素 */ + set(row: number, col: number, value: number): this { + this.elements[row * 3 + col] = value; + return this; + } + + // 快速访问器 + get m00(): number { return this.elements[0]; } + set m00(value: number) { this.elements[0] = value; } + + get m01(): number { return this.elements[1]; } + set m01(value: number) { this.elements[1] = value; } + + get m02(): number { return this.elements[2]; } + set m02(value: number) { this.elements[2] = value; } + + get m10(): number { return this.elements[3]; } + set m10(value: number) { this.elements[3] = value; } + + get m11(): number { return this.elements[4]; } + set m11(value: number) { this.elements[4] = value; } + + get m12(): number { return this.elements[5]; } + set m12(value: number) { this.elements[5] = value; } + + get m20(): number { return this.elements[6]; } + set m20(value: number) { this.elements[6] = value; } + + get m21(): number { return this.elements[7]; } + set m21(value: number) { this.elements[7] = value; } + + get m22(): number { return this.elements[8]; } + set m22(value: number) { this.elements[8] = value; } + + // 基础操作 + + /** + * 设置矩阵为单位矩阵 + * @returns 当前矩阵实例(链式调用) + */ + identity(): this { + this.elements.set([ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + ]); + return this; + } + + /** + * 设置矩阵为零矩阵 + * @returns 当前矩阵实例(链式调用) + */ + zero(): this { + this.elements.fill(0); + return this; + } + + /** + * 复制另一个矩阵的值 + * @param other 源矩阵 + * @returns 当前矩阵实例(链式调用) + */ + copy(other: Matrix3): this { + this.elements.set(other.elements); + return this; + } + + /** + * 克隆当前矩阵 + * @returns 新的矩阵实例 + */ + clone(): Matrix3 { + return new Matrix3(this.elements); + } + + /** + * 从数组设置矩阵元素 + * @param elements 矩阵元素数组 + * @returns 当前矩阵实例(链式调用) + */ + fromArray(elements: ArrayLike): this { + this.elements.set(elements); + return this; + } + + // 矩阵运算 + + /** + * 矩阵加法 + * @param other 另一个矩阵 + * @returns 当前矩阵实例(链式调用) + */ + add(other: Matrix3): this { + for (let i = 0; i < 9; i++) { + this.elements[i] += other.elements[i]; + } + return this; + } + + /** + * 矩阵减法 + * @param other 另一个矩阵 + * @returns 当前矩阵实例(链式调用) + */ + subtract(other: Matrix3): this { + for (let i = 0; i < 9; i++) { + this.elements[i] -= other.elements[i]; + } + return this; + } + + /** + * 矩阵标量乘法 + * @param scalar 标量 + * @returns 当前矩阵实例(链式调用) + */ + multiplyScalar(scalar: number): this { + for (let i = 0; i < 9; i++) { + this.elements[i] *= scalar; + } + return this; + } + + /** + * 矩阵乘法 + * @param other 另一个矩阵 + * @returns 当前矩阵实例(链式调用) + */ + multiply(other: Matrix3): this { + const a = this.elements; + const b = other.elements; + const result = new Float32Array(9); + + result[0] = a[0] * b[0] + a[1] * b[3] + a[2] * b[6]; + result[1] = a[0] * b[1] + a[1] * b[4] + a[2] * b[7]; + result[2] = a[0] * b[2] + a[1] * b[5] + a[2] * b[8]; + + result[3] = a[3] * b[0] + a[4] * b[3] + a[5] * b[6]; + result[4] = a[3] * b[1] + a[4] * b[4] + a[5] * b[7]; + result[5] = a[3] * b[2] + a[4] * b[5] + a[5] * b[8]; + + result[6] = a[6] * b[0] + a[7] * b[3] + a[8] * b[6]; + result[7] = a[6] * b[1] + a[7] * b[4] + a[8] * b[7]; + result[8] = a[6] * b[2] + a[7] * b[5] + a[8] * b[8]; + + this.elements.set(result); + return this; + } + + /** + * 左乘另一个矩阵(other * this) + * @param other 左乘矩阵 + * @returns 当前矩阵实例(链式调用) + */ + premultiply(other: Matrix3): this { + const a = other.elements; + const b = this.elements; + const result = new Float32Array(9); + + result[0] = a[0] * b[0] + a[1] * b[3] + a[2] * b[6]; + result[1] = a[0] * b[1] + a[1] * b[4] + a[2] * b[7]; + result[2] = a[0] * b[2] + a[1] * b[5] + a[2] * b[8]; + + result[3] = a[3] * b[0] + a[4] * b[3] + a[5] * b[6]; + result[4] = a[3] * b[1] + a[4] * b[4] + a[5] * b[7]; + result[5] = a[3] * b[2] + a[4] * b[5] + a[5] * b[8]; + + result[6] = a[6] * b[0] + a[7] * b[3] + a[8] * b[6]; + result[7] = a[6] * b[1] + a[7] * b[4] + a[8] * b[7]; + result[8] = a[6] * b[2] + a[7] * b[5] + a[8] * b[8]; + + this.elements.set(result); + return this; + } + + // 变换操作 + + /** + * 设置为平移矩阵 + * @param x X方向平移 + * @param y Y方向平移 + * @returns 当前矩阵实例(链式调用) + */ + makeTranslation(x: number, y: number): this { + this.elements.set([ + 1, 0, x, + 0, 1, y, + 0, 0, 1 + ]); + return this; + } + + /** + * 设置为旋转矩阵 + * @param angle 旋转角度(弧度) + * @returns 当前矩阵实例(链式调用) + */ + makeRotation(angle: number): this { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + this.elements.set([ + cos, -sin, 0, + sin, cos, 0, + 0, 0, 1 + ]); + return this; + } + + /** + * 设置为缩放矩阵 + * @param scaleX X方向缩放 + * @param scaleY Y方向缩放 + * @returns 当前矩阵实例(链式调用) + */ + makeScale(scaleX: number, scaleY: number): this { + this.elements.set([ + scaleX, 0, 0, + 0, scaleY, 0, + 0, 0, 1 + ]); + return this; + } + + /** + * 复合平移 + * @param x X方向平移 + * @param y Y方向平移 + * @returns 当前矩阵实例(链式调用) + */ + translate(x: number, y: number): this { + this.m02 += this.m00 * x + this.m01 * y; + this.m12 += this.m10 * x + this.m11 * y; + return this; + } + + /** + * 复合旋转 + * @param angle 旋转角度(弧度) + * @returns 当前矩阵实例(链式调用) + */ + rotate(angle: number): this { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const m00 = this.m00 * cos + this.m01 * sin; + const m01 = this.m00 * -sin + this.m01 * cos; + const m10 = this.m10 * cos + this.m11 * sin; + const m11 = this.m10 * -sin + this.m11 * cos; + + this.m00 = m00; + this.m01 = m01; + this.m10 = m10; + this.m11 = m11; + + return this; + } + + /** + * 复合缩放 + * @param scaleX X方向缩放 + * @param scaleY Y方向缩放 + * @returns 当前矩阵实例(链式调用) + */ + scale(scaleX: number, scaleY: number): this { + this.m00 *= scaleX; + this.m01 *= scaleY; + this.m10 *= scaleX; + this.m11 *= scaleY; + return this; + } + + // 矩阵变换 + + /** + * 矩阵转置 + * @returns 当前矩阵实例(链式调用) + */ + transpose(): this { + const elements = this.elements; + let tmp: number; + + tmp = elements[1]; elements[1] = elements[3]; elements[3] = tmp; + tmp = elements[2]; elements[2] = elements[6]; elements[6] = tmp; + tmp = elements[5]; elements[5] = elements[7]; elements[7] = tmp; + + return this; + } + + /** + * 计算矩阵行列式 + * @returns 行列式值 + */ + determinant(): number { + const e = this.elements; + + return e[0] * (e[4] * e[8] - e[5] * e[7]) - + e[1] * (e[3] * e[8] - e[5] * e[6]) + + e[2] * (e[3] * e[7] - e[4] * e[6]); + } + + /** + * 矩阵求逆 + * @returns 当前矩阵实例(链式调用),如果矩阵不可逆则保持不变 + */ + invert(): this { + const e = this.elements; + const det = this.determinant(); + + if (Math.abs(det) < Number.EPSILON) { + console.warn('Matrix3: 矩阵不可逆'); + return this; + } + + const invDet = 1 / det; + const result = new Float32Array(9); + + result[0] = (e[4] * e[8] - e[5] * e[7]) * invDet; + result[1] = (e[2] * e[7] - e[1] * e[8]) * invDet; + result[2] = (e[1] * e[5] - e[2] * e[4]) * invDet; + + result[3] = (e[5] * e[6] - e[3] * e[8]) * invDet; + result[4] = (e[0] * e[8] - e[2] * e[6]) * invDet; + result[5] = (e[2] * e[3] - e[0] * e[5]) * invDet; + + result[6] = (e[3] * e[7] - e[4] * e[6]) * invDet; + result[7] = (e[1] * e[6] - e[0] * e[7]) * invDet; + result[8] = (e[0] * e[4] - e[1] * e[3]) * invDet; + + this.elements.set(result); + return this; + } + + // 向量变换 + + /** + * 变换向量(应用完整的3x3变换) + * @param vector 向量 + * @returns 新的变换后的向量 + */ + transformVector(vector: Vector2): Vector2 { + const x = vector.x; + const y = vector.y; + const w = this.m20 * x + this.m21 * y + this.m22; + + return new Vector2( + (this.m00 * x + this.m01 * y + this.m02) / w, + (this.m10 * x + this.m11 * y + this.m12) / w + ); + } + + /** + * 变换向量(仅应用旋转和缩放,忽略平移) + * @param vector 向量 + * @returns 新的变换后的向量 + */ + transformDirection(vector: Vector2): Vector2 { + return new Vector2( + this.m00 * vector.x + this.m01 * vector.y, + this.m10 * vector.x + this.m11 * vector.y + ); + } + + /** + * 批量变换向量数组 + * @param vectors 向量数组 + * @returns 变换后的向量数组 + */ + transformVectors(vectors: Vector2[]): Vector2[] { + return vectors.map(v => this.transformVector(v)); + } + + // 属性提取 + + /** + * 获取平移分量 + * @returns 平移向量 + */ + getTranslation(): Vector2 { + return new Vector2(this.m02, this.m12); + } + + /** + * 获取旋转角度 + * @returns 旋转角度(弧度) + */ + getRotation(): number { + return Math.atan2(this.m10, this.m00); + } + + /** + * 获取缩放分量 + * @returns 缩放向量 + */ + getScale(): Vector2 { + const scaleX = Math.sqrt(this.m00 * this.m00 + this.m10 * this.m10); + const scaleY = Math.sqrt(this.m01 * this.m01 + this.m11 * this.m11); + + // 检查是否有反转 + const det = this.determinant(); + if (det < 0) { + return new Vector2(-scaleX, scaleY); + } + + return new Vector2(scaleX, scaleY); + } + + /** + * 分解变换矩阵为平移、旋转、缩放分量 + * @returns {translation, rotation, scale} + */ + decompose(): { translation: Vector2; rotation: number; scale: Vector2 } { + return { + translation: this.getTranslation(), + rotation: this.getRotation(), + scale: this.getScale() + }; + } + + // 比较操作 + + /** + * 检查两个矩阵是否相等 + * @param other 另一个矩阵 + * @param epsilon 容差,默认为Number.EPSILON + * @returns 是否相等 + */ + equals(other: Matrix3, epsilon: number = Number.EPSILON): boolean { + for (let i = 0; i < 9; i++) { + if (Math.abs(this.elements[i] - other.elements[i]) >= epsilon) { + return false; + } + } + return true; + } + + /** + * 检查两个矩阵是否完全相等 + * @param other 另一个矩阵 + * @returns 是否完全相等 + */ + exactEquals(other: Matrix3): boolean { + for (let i = 0; i < 9; i++) { + if (this.elements[i] !== other.elements[i]) { + return false; + } + } + return true; + } + + /** + * 检查是否为单位矩阵 + * @param epsilon 容差,默认为Number.EPSILON + * @returns 是否为单位矩阵 + */ + isIdentity(epsilon: number = Number.EPSILON): boolean { + return this.equals(Matrix3.IDENTITY, epsilon); + } + + // 静态方法 + + /** + * 矩阵乘法(静态方法) + * @param a 矩阵a + * @param b 矩阵b + * @returns 新的结果矩阵 + */ + static multiply(a: Matrix3, b: Matrix3): Matrix3 { + return a.clone().multiply(b); + } + + /** + * 创建平移矩阵(静态方法) + * @param x X方向平移 + * @param y Y方向平移 + * @returns 新的平移矩阵 + */ + static translation(x: number, y: number): Matrix3 { + return new Matrix3().makeTranslation(x, y); + } + + /** + * 创建旋转矩阵(静态方法) + * @param angle 旋转角度(弧度) + * @returns 新的旋转矩阵 + */ + static rotation(angle: number): Matrix3 { + return new Matrix3().makeRotation(angle); + } + + /** + * 创建缩放矩阵(静态方法) + * @param scaleX X方向缩放 + * @param scaleY Y方向缩放 + * @returns 新的缩放矩阵 + */ + static scale(scaleX: number, scaleY: number): Matrix3 { + return new Matrix3().makeScale(scaleX, scaleY); + } + + /** + * 创建TRS(平移-旋转-缩放)变换矩阵 + * @param translation 平移向量 + * @param rotation 旋转角度(弧度) + * @param scale 缩放向量 + * @returns 新的TRS矩阵 + */ + static TRS(translation: Vector2, rotation: number, scale: Vector2): Matrix3 { + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + + return new Matrix3([ + scale.x * cos, -scale.y * sin, translation.x, + scale.x * sin, scale.y * cos, translation.y, + 0, 0, 1 + ]); + } + + // 字符串转换 + + /** + * 转换为字符串 + * @returns 字符串表示 + */ + toString(): string { + const e = this.elements; + return `Matrix3(\n` + + ` ${e[0].toFixed(3)}, ${e[1].toFixed(3)}, ${e[2].toFixed(3)}\n` + + ` ${e[3].toFixed(3)}, ${e[4].toFixed(3)}, ${e[5].toFixed(3)}\n` + + ` ${e[6].toFixed(3)}, ${e[7].toFixed(3)}, ${e[8].toFixed(3)}\n` + + `)`; + } + + /** + * 转换为数组 + * @returns 矩阵元素数组 + */ + toArray(): number[] { + return Array.from(this.elements); + } + + /** + * 转换为CSS transform字符串 + * @returns CSS transform字符串 + */ + toCSSTransform(): string { + const e = this.elements; + return `matrix(${e[0]}, ${e[3]}, ${e[1]}, ${e[4]}, ${e[2]}, ${e[5]})`; + } +} \ No newline at end of file diff --git a/packages/math/src/Rectangle.ts b/packages/math/src/Rectangle.ts new file mode 100644 index 00000000..5407344d --- /dev/null +++ b/packages/math/src/Rectangle.ts @@ -0,0 +1,520 @@ +import { Vector2 } from './Vector2'; + +/** + * 2D矩形类 + * + * 表示一个轴对齐的矩形,提供矩形相关的几何运算功能: + * - 矩形创建和属性获取 + * - 包含检测(点、矩形) + * - 相交检测和计算 + * - 变换和操作 + */ +export class Rectangle { + /** 矩形左上角X坐标 */ + public x: number; + + /** 矩形左上角Y坐标 */ + public y: number; + + /** 矩形宽度 */ + public width: number; + + /** 矩形高度 */ + public height: number; + + /** + * 创建矩形 + * @param x 左上角X坐标,默认为0 + * @param y 左上角Y坐标,默认为0 + * @param width 宽度,默认为0 + * @param height 高度,默认为0 + */ + constructor(x: number = 0, y: number = 0, width: number = 0, height: number = 0) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + // 静态常量 + /** 空矩形 */ + static readonly EMPTY = new Rectangle(0, 0, 0, 0); + + // 属性获取 + + /** 获取左边界 */ + get left(): number { + return this.x; + } + + /** 获取右边界 */ + get right(): number { + return this.x + this.width; + } + + /** 获取上边界 */ + get top(): number { + return this.y; + } + + /** 获取下边界 */ + get bottom(): number { + return this.y + this.height; + } + + /** 获取中心X坐标 */ + get centerX(): number { + return this.x + this.width * 0.5; + } + + /** 获取中心Y坐标 */ + get centerY(): number { + return this.y + this.height * 0.5; + } + + /** 获取中心点 */ + get center(): Vector2 { + return new Vector2(this.centerX, this.centerY); + } + + /** 获取左上角点 */ + get topLeft(): Vector2 { + return new Vector2(this.x, this.y); + } + + /** 获取右上角点 */ + get topRight(): Vector2 { + return new Vector2(this.right, this.y); + } + + /** 获取左下角点 */ + get bottomLeft(): Vector2 { + return new Vector2(this.x, this.bottom); + } + + /** 获取右下角点 */ + get bottomRight(): Vector2 { + return new Vector2(this.right, this.bottom); + } + + /** 获取面积 */ + get area(): number { + return this.width * this.height; + } + + /** 获取周长 */ + get perimeter(): number { + return 2 * (this.width + this.height); + } + + /** 检查是否为空矩形 */ + get isEmpty(): boolean { + return this.width <= 0 || this.height <= 0; + } + + /** 检查是否为正方形 */ + get isSquare(): boolean { + return this.width === this.height && this.width > 0; + } + + // 基础操作 + + /** + * 设置矩形属性 + * @param x 左上角X坐标 + * @param y 左上角Y坐标 + * @param width 宽度 + * @param height 高度 + * @returns 当前矩形实例(链式调用) + */ + set(x: number, y: number, width: number, height: number): this { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + return this; + } + + /** + * 复制另一个矩形的值 + * @param other 源矩形 + * @returns 当前矩形实例(链式调用) + */ + copy(other: Rectangle): this { + this.x = other.x; + this.y = other.y; + this.width = other.width; + this.height = other.height; + return this; + } + + /** + * 克隆当前矩形 + * @returns 新的矩形实例 + */ + clone(): Rectangle { + return new Rectangle(this.x, this.y, this.width, this.height); + } + + /** + * 设置矩形位置 + * @param x 新的X坐标 + * @param y 新的Y坐标 + * @returns 当前矩形实例(链式调用) + */ + setPosition(x: number, y: number): this { + this.x = x; + this.y = y; + return this; + } + + /** + * 设置矩形大小 + * @param width 新的宽度 + * @param height 新的高度 + * @returns 当前矩形实例(链式调用) + */ + setSize(width: number, height: number): this { + this.width = width; + this.height = height; + return this; + } + + /** + * 设置矩形中心点 + * @param centerX 中心X坐标 + * @param centerY 中心Y坐标 + * @returns 当前矩形实例(链式调用) + */ + setCenter(centerX: number, centerY: number): this { + this.x = centerX - this.width * 0.5; + this.y = centerY - this.height * 0.5; + return this; + } + + // 变换操作 + + /** + * 平移矩形 + * @param dx X方向偏移 + * @param dy Y方向偏移 + * @returns 当前矩形实例(链式调用) + */ + translate(dx: number, dy: number): this { + this.x += dx; + this.y += dy; + return this; + } + + /** + * 缩放矩形(从中心缩放) + * @param scaleX X方向缩放因子 + * @param scaleY Y方向缩放因子,默认等于scaleX + * @returns 当前矩形实例(链式调用) + */ + scale(scaleX: number, scaleY: number = scaleX): this { + const centerX = this.centerX; + const centerY = this.centerY; + this.width *= scaleX; + this.height *= scaleY; + return this.setCenter(centerX, centerY); + } + + /** + * 扩展矩形 + * @param amount 扩展量(正值扩大,负值缩小) + * @returns 当前矩形实例(链式调用) + */ + inflate(amount: number): this { + this.x -= amount; + this.y -= amount; + this.width += amount * 2; + this.height += amount * 2; + return this; + } + + /** + * 扩展矩形(分别指定水平和垂直方向) + * @param horizontal 水平方向扩展量 + * @param vertical 垂直方向扩展量 + * @returns 当前矩形实例(链式调用) + */ + inflateXY(horizontal: number, vertical: number): this { + this.x -= horizontal; + this.y -= vertical; + this.width += horizontal * 2; + this.height += vertical * 2; + return this; + } + + // 包含检测 + + /** + * 检查是否包含指定点 + * @param point 点 + * @returns 是否包含 + */ + containsPoint(point: Vector2): boolean { + return point.x >= this.x && point.x <= this.right && + point.y >= this.y && point.y <= this.bottom; + } + + /** + * 检查是否包含指定坐标 + * @param x X坐标 + * @param y Y坐标 + * @returns 是否包含 + */ + contains(x: number, y: number): boolean { + return x >= this.x && x <= this.right && + y >= this.y && y <= this.bottom; + } + + /** + * 检查是否完全包含另一个矩形 + * @param other 另一个矩形 + * @returns 是否完全包含 + */ + containsRect(other: Rectangle): boolean { + return this.x <= other.x && this.y <= other.y && + this.right >= other.right && this.bottom >= other.bottom; + } + + // 相交检测 + + /** + * 检查是否与另一个矩形相交 + * @param other 另一个矩形 + * @returns 是否相交 + */ + intersects(other: Rectangle): boolean { + return this.x < other.right && this.right > other.x && + this.y < other.bottom && this.bottom > other.y; + } + + /** + * 计算与另一个矩形的相交矩形 + * @param other 另一个矩形 + * @returns 相交矩形,如果不相交返回空矩形 + */ + intersection(other: Rectangle): Rectangle { + if (!this.intersects(other)) { + return Rectangle.EMPTY.clone(); + } + + const x = Math.max(this.x, other.x); + const y = Math.max(this.y, other.y); + const right = Math.min(this.right, other.right); + const bottom = Math.min(this.bottom, other.bottom); + + return new Rectangle(x, y, right - x, bottom - y); + } + + /** + * 计算与另一个矩形的并集矩形 + * @param other 另一个矩形 + * @returns 并集矩形 + */ + union(other: Rectangle): Rectangle { + const x = Math.min(this.x, other.x); + const y = Math.min(this.y, other.y); + const right = Math.max(this.right, other.right); + const bottom = Math.max(this.bottom, other.bottom); + + return new Rectangle(x, y, right - x, bottom - y); + } + + /** + * 计算相交面积 + * @param other 另一个矩形 + * @returns 相交面积 + */ + intersectionArea(other: Rectangle): number { + const intersection = this.intersection(other); + return intersection.isEmpty ? 0 : intersection.area; + } + + // 距离计算 + + /** + * 计算点到矩形的最短距离 + * @param point 点 + * @returns 最短距离 + */ + distanceToPoint(point: Vector2): number { + const dx = Math.max(0, Math.max(this.x - point.x, point.x - this.right)); + const dy = Math.max(0, Math.max(this.y - point.y, point.y - this.bottom)); + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 计算两个矩形间的最短距离 + * @param other 另一个矩形 + * @returns 最短距离(相交时为0) + */ + distanceToRect(other: Rectangle): number { + if (this.intersects(other)) { + return 0; + } + + const dx = Math.max(0, Math.max(this.x - other.right, other.x - this.right)); + const dy = Math.max(0, Math.max(this.y - other.bottom, other.y - this.bottom)); + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 获取矩形上距离指定点最近的点 + * @param point 指定点 + * @returns 最近点 + */ + closestPointTo(point: Vector2): Vector2 { + return new Vector2( + Math.max(this.x, Math.min(this.right, point.x)), + Math.max(this.y, Math.min(this.bottom, point.y)) + ); + } + + // 比较操作 + + /** + * 检查两个矩形是否相等 + * @param other 另一个矩形 + * @param epsilon 容差,默认为Number.EPSILON + * @returns 是否相等 + */ + equals(other: Rectangle, epsilon: number = Number.EPSILON): boolean { + return Math.abs(this.x - other.x) < epsilon && + Math.abs(this.y - other.y) < epsilon && + Math.abs(this.width - other.width) < epsilon && + Math.abs(this.height - other.height) < epsilon; + } + + /** + * 检查两个矩形是否完全相等 + * @param other 另一个矩形 + * @returns 是否完全相等 + */ + exactEquals(other: Rectangle): boolean { + return this.x === other.x && this.y === other.y && + this.width === other.width && this.height === other.height; + } + + // 静态方法 + + /** + * 从中心点和大小创建矩形 + * @param centerX 中心X坐标 + * @param centerY 中心Y坐标 + * @param width 宽度 + * @param height 高度 + * @returns 新的矩形实例 + */ + static fromCenter(centerX: number, centerY: number, width: number, height: number): Rectangle { + return new Rectangle(centerX - width * 0.5, centerY - height * 0.5, width, height); + } + + /** + * 从两个点创建矩形 + * @param point1 第一个点 + * @param point2 第二个点 + * @returns 新的矩形实例 + */ + static fromPoints(point1: Vector2, point2: Vector2): Rectangle { + const x = Math.min(point1.x, point2.x); + const y = Math.min(point1.y, point2.y); + const width = Math.abs(point2.x - point1.x); + const height = Math.abs(point2.y - point1.y); + return new Rectangle(x, y, width, height); + } + + /** + * 从点数组创建包围矩形 + * @param points 点数组 + * @returns 包围矩形 + */ + static fromPointArray(points: Vector2[]): Rectangle { + if (points.length === 0) { + return Rectangle.EMPTY.clone(); + } + + let minX = points[0].x; + let minY = points[0].y; + let maxX = points[0].x; + let maxY = points[0].y; + + for (let i = 1; i < points.length; i++) { + minX = Math.min(minX, points[i].x); + minY = Math.min(minY, points[i].y); + maxX = Math.max(maxX, points[i].x); + maxY = Math.max(maxY, points[i].y); + } + + return new Rectangle(minX, minY, maxX - minX, maxY - minY); + } + + /** + * 创建正方形 + * @param x 左上角X坐标 + * @param y 左上角Y坐标 + * @param size 边长 + * @returns 新的正方形矩形 + */ + static square(x: number, y: number, size: number): Rectangle { + return new Rectangle(x, y, size, size); + } + + /** + * 线性插值两个矩形 + * @param a 起始矩形 + * @param b 目标矩形 + * @param t 插值参数(0到1) + * @returns 新的插值结果矩形 + */ + static lerp(a: Rectangle, b: Rectangle, t: number): Rectangle { + return new Rectangle( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.width + (b.width - a.width) * t, + a.height + (b.height - a.height) * t + ); + } + + // 字符串转换 + + /** + * 转换为字符串 + * @returns 字符串表示 + */ + toString(): string { + return `Rectangle(${this.x.toFixed(2)}, ${this.y.toFixed(2)}, ${this.width.toFixed(2)}, ${this.height.toFixed(2)})`; + } + + /** + * 转换为数组 + * @returns [x, y, width, height] 数组 + */ + toArray(): [number, number, number, number] { + return [this.x, this.y, this.width, this.height]; + } + + /** + * 转换为普通对象 + * @returns {x, y, width, height} 对象 + */ + toObject(): { x: number; y: number; width: number; height: number } { + return { x: this.x, y: this.y, width: this.width, height: this.height }; + } + + /** + * 获取四个顶点 + * @returns 顶点数组 [topLeft, topRight, bottomRight, bottomLeft] + */ + getVertices(): Vector2[] { + return [ + this.topLeft, + this.topRight, + this.bottomRight, + this.bottomLeft + ]; + } +} \ No newline at end of file diff --git a/packages/math/src/Vector2.ts b/packages/math/src/Vector2.ts new file mode 100644 index 00000000..886f5fae --- /dev/null +++ b/packages/math/src/Vector2.ts @@ -0,0 +1,541 @@ +/** + * 2D向量类 + * + * 提供完整的2D向量运算功能,包括: + * - 基础运算(加减乘除) + * - 向量运算(点积、叉积、归一化) + * - 几何运算(距离、角度、投影) + * - 变换操作(旋转、反射、插值) + */ +export class Vector2 { + /** X分量 */ + public x: number; + + /** Y分量 */ + public y: number; + + /** + * 创建2D向量 + * @param x X分量,默认为0 + * @param y Y分量,默认为0 + */ + constructor(x: number = 0, y: number = 0) { + this.x = x; + this.y = y; + } + + // 静态常量 + /** 零向量 (0, 0) */ + static readonly ZERO = new Vector2(0, 0); + + /** 单位向量 (1, 1) */ + static readonly ONE = new Vector2(1, 1); + + /** 右方向向量 (1, 0) */ + static readonly RIGHT = new Vector2(1, 0); + + /** 左方向向量 (-1, 0) */ + static readonly LEFT = new Vector2(-1, 0); + + /** 上方向向量 (0, 1) */ + static readonly UP = new Vector2(0, 1); + + /** 下方向向量 (0, -1) */ + static readonly DOWN = new Vector2(0, -1); + + // 基础属性 + + /** + * 获取向量长度(模) + */ + get length(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + /** + * 获取向量长度的平方 + */ + get lengthSquared(): number { + return this.x * this.x + this.y * this.y; + } + + /** + * 获取向量角度(弧度) + */ + get angle(): number { + return Math.atan2(this.y, this.x); + } + + /** + * 检查是否为零向量 + */ + get isZero(): boolean { + return this.x === 0 && this.y === 0; + } + + /** + * 检查是否为单位向量 + */ + get isUnit(): boolean { + const lenSq = this.lengthSquared; + return Math.abs(lenSq - 1) < Number.EPSILON; + } + + // 基础运算 + + /** + * 设置向量分量 + * @param x X分量 + * @param y Y分量 + * @returns 当前向量实例(链式调用) + */ + set(x: number, y: number): this { + this.x = x; + this.y = y; + return this; + } + + /** + * 复制另一个向量的值 + * @param other 源向量 + * @returns 当前向量实例(链式调用) + */ + copy(other: Vector2): this { + this.x = other.x; + this.y = other.y; + return this; + } + + /** + * 克隆当前向量 + * @returns 新的向量实例 + */ + clone(): Vector2 { + return new Vector2(this.x, this.y); + } + + /** + * 向量加法 + * @param other 另一个向量 + * @returns 当前向量实例(链式调用) + */ + add(other: Vector2): this { + this.x += other.x; + this.y += other.y; + return this; + } + + /** + * 向量减法 + * @param other 另一个向量 + * @returns 当前向量实例(链式调用) + */ + subtract(other: Vector2): this { + this.x -= other.x; + this.y -= other.y; + return this; + } + + /** + * 向量数乘 + * @param scalar 标量 + * @returns 当前向量实例(链式调用) + */ + multiply(scalar: number): this { + this.x *= scalar; + this.y *= scalar; + return this; + } + + /** + * 向量数除 + * @param scalar 标量 + * @returns 当前向量实例(链式调用) + */ + divide(scalar: number): this { + if (scalar === 0) { + throw new Error('不能除以零'); + } + this.x /= scalar; + this.y /= scalar; + return this; + } + + /** + * 向量取反 + * @returns 当前向量实例(链式调用) + */ + negate(): this { + this.x = -this.x; + this.y = -this.y; + return this; + } + + // 向量运算 + + /** + * 计算与另一个向量的点积 + * @param other 另一个向量 + * @returns 点积值 + */ + dot(other: Vector2): number { + return this.x * other.x + this.y * other.y; + } + + /** + * 计算与另一个向量的叉积(2D中返回标量) + * @param other 另一个向量 + * @returns 叉积值 + */ + cross(other: Vector2): number { + return this.x * other.y - this.y * other.x; + } + + /** + * 向量归一化(转换为单位向量) + * @returns 当前向量实例(链式调用) + */ + normalize(): this { + const len = this.length; + if (len === 0) { + return this; + } + return this.divide(len); + } + + /** + * 获取归一化后的向量(不修改原向量) + * @returns 新的单位向量 + */ + normalized(): Vector2 { + return this.clone().normalize(); + } + + // 几何运算 + + /** + * 计算到另一个向量的距离 + * @param other 另一个向量 + * @returns 距离值 + */ + distanceTo(other: Vector2): number { + const dx = this.x - other.x; + const dy = this.y - other.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 计算到另一个向量的距离平方 + * @param other 另一个向量 + * @returns 距离平方值 + */ + distanceToSquared(other: Vector2): number { + const dx = this.x - other.x; + const dy = this.y - other.y; + return dx * dx + dy * dy; + } + + /** + * 计算与另一个向量的夹角(弧度) + * @param other 另一个向量 + * @returns 夹角(0到π) + */ + angleTo(other: Vector2): number { + const dot = this.dot(other); + const lenProduct = this.length * other.length; + if (lenProduct === 0) return 0; + return Math.acos(Math.max(-1, Math.min(1, dot / lenProduct))); + } + + /** + * 计算向量在另一个向量上的投影 + * @param onto 投影目标向量 + * @returns 新的投影向量 + */ + projectOnto(onto: Vector2): Vector2 { + const dot = this.dot(onto); + const lenSq = onto.lengthSquared; + if (lenSq === 0) return new Vector2(); + return onto.clone().multiply(dot / lenSq); + } + + /** + * 计算向量在另一个向量上的投影长度 + * @param onto 投影目标向量 + * @returns 投影长度(带符号) + */ + projectOntoLength(onto: Vector2): number { + const len = onto.length; + if (len === 0) return 0; + return this.dot(onto) / len; + } + + /** + * 获取垂直向量(逆时针旋转90度) + * @returns 新的垂直向量 + */ + perpendicular(): Vector2 { + return new Vector2(-this.y, this.x); + } + + // 变换操作 + + /** + * 向量旋转 + * @param angle 旋转角度(弧度) + * @returns 当前向量实例(链式调用) + */ + rotate(angle: number): this { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = this.x * cos - this.y * sin; + const y = this.x * sin + this.y * cos; + this.x = x; + this.y = y; + return this; + } + + /** + * 获取旋转后的向量(不修改原向量) + * @param angle 旋转角度(弧度) + * @returns 新的旋转后向量 + */ + rotated(angle: number): Vector2 { + return this.clone().rotate(angle); + } + + /** + * 围绕一个点旋转 + * @param center 旋转中心点 + * @param angle 旋转角度(弧度) + * @returns 当前向量实例(链式调用) + */ + rotateAround(center: Vector2, angle: number): this { + return this.subtract(center).rotate(angle).add(center); + } + + /** + * 反射向量(关于法线) + * @param normal 法线向量(应为单位向量) + * @returns 当前向量实例(链式调用) + */ + reflect(normal: Vector2): this { + const dot = this.dot(normal); + this.x -= 2 * dot * normal.x; + this.y -= 2 * dot * normal.y; + return this; + } + + /** + * 获取反射后的向量(不修改原向量) + * @param normal 法线向量(应为单位向量) + * @returns 新的反射向量 + */ + reflected(normal: Vector2): Vector2 { + return this.clone().reflect(normal); + } + + // 插值和限制 + + /** + * 线性插值 + * @param target 目标向量 + * @param t 插值参数(0到1) + * @returns 当前向量实例(链式调用) + */ + lerp(target: Vector2, t: number): this { + this.x += (target.x - this.x) * t; + this.y += (target.y - this.y) * t; + return this; + } + + /** + * 限制向量长度 + * @param maxLength 最大长度 + * @returns 当前向量实例(链式调用) + */ + clampLength(maxLength: number): this { + const lenSq = this.lengthSquared; + if (lenSq > maxLength * maxLength) { + return this.normalize().multiply(maxLength); + } + return this; + } + + /** + * 限制向量分量 + * @param min 最小值向量 + * @param max 最大值向量 + * @returns 当前向量实例(链式调用) + */ + clamp(min: Vector2, max: Vector2): this { + this.x = Math.max(min.x, Math.min(max.x, this.x)); + this.y = Math.max(min.y, Math.min(max.y, this.y)); + return this; + } + + // 比较操作 + + /** + * 检查两个向量是否相等 + * @param other 另一个向量 + * @param epsilon 容差,默认为Number.EPSILON + * @returns 是否相等 + */ + equals(other: Vector2, epsilon: number = Number.EPSILON): boolean { + return Math.abs(this.x - other.x) < epsilon && + Math.abs(this.y - other.y) < epsilon; + } + + /** + * 检查两个向量是否完全相等 + * @param other 另一个向量 + * @returns 是否完全相等 + */ + exactEquals(other: Vector2): boolean { + return this.x === other.x && this.y === other.y; + } + + // 静态方法 + + /** + * 向量加法(静态方法) + * @param a 向量a + * @param b 向量b + * @returns 新的结果向量 + */ + static add(a: Vector2, b: Vector2): Vector2 { + return new Vector2(a.x + b.x, a.y + b.y); + } + + /** + * 向量减法(静态方法) + * @param a 向量a + * @param b 向量b + * @returns 新的结果向量 + */ + static subtract(a: Vector2, b: Vector2): Vector2 { + return new Vector2(a.x - b.x, a.y - b.y); + } + + /** + * 向量数乘(静态方法) + * @param vector 向量 + * @param scalar 标量 + * @returns 新的结果向量 + */ + static multiply(vector: Vector2, scalar: number): Vector2 { + return new Vector2(vector.x * scalar, vector.y * scalar); + } + + /** + * 向量点积(静态方法) + * @param a 向量a + * @param b 向量b + * @returns 点积值 + */ + static dot(a: Vector2, b: Vector2): number { + return a.x * b.x + a.y * b.y; + } + + /** + * 向量叉积(静态方法) + * @param a 向量a + * @param b 向量b + * @returns 叉积值 + */ + static cross(a: Vector2, b: Vector2): number { + return a.x * b.y - a.y * b.x; + } + + /** + * 计算两点间距离(静态方法) + * @param a 点a + * @param b 点b + * @returns 距离值 + */ + static distance(a: Vector2, b: Vector2): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * 线性插值(静态方法) + * @param a 起始向量 + * @param b 目标向量 + * @param t 插值参数(0到1) + * @returns 新的插值结果向量 + */ + static lerp(a: Vector2, b: Vector2, t: number): Vector2 { + return new Vector2( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t + ); + } + + /** + * 从角度创建单位向量(静态方法) + * @param angle 角度(弧度) + * @returns 新的单位向量 + */ + static fromAngle(angle: number): Vector2 { + return new Vector2(Math.cos(angle), Math.sin(angle)); + } + + /** + * 从极坐标创建向量(静态方法) + * @param length 长度 + * @param angle 角度(弧度) + * @returns 新的向量 + */ + static fromPolar(length: number, angle: number): Vector2 { + return new Vector2(length * Math.cos(angle), length * Math.sin(angle)); + } + + /** + * 获取两个向量中的最小分量向量(静态方法) + * @param a 向量a + * @param b 向量b + * @returns 新的最小分量向量 + */ + static min(a: Vector2, b: Vector2): Vector2 { + return new Vector2(Math.min(a.x, b.x), Math.min(a.y, b.y)); + } + + /** + * 获取两个向量中的最大分量向量(静态方法) + * @param a 向量a + * @param b 向量b + * @returns 新的最大分量向量 + */ + static max(a: Vector2, b: Vector2): Vector2 { + return new Vector2(Math.max(a.x, b.x), Math.max(a.y, b.y)); + } + + // 字符串转换 + + /** + * 转换为字符串 + * @returns 字符串表示 + */ + toString(): string { + return `Vector2(${this.x.toFixed(3)}, ${this.y.toFixed(3)})`; + } + + /** + * 转换为数组 + * @returns [x, y] 数组 + */ + toArray(): [number, number] { + return [this.x, this.y]; + } + + /** + * 转换为普通对象 + * @returns {x, y} 对象 + */ + toObject(): { x: number; y: number } { + return { x: this.x, y: this.y }; + } +} \ No newline at end of file diff --git a/packages/math/src/index.ts b/packages/math/src/index.ts new file mode 100644 index 00000000..e68db09b --- /dev/null +++ b/packages/math/src/index.ts @@ -0,0 +1,24 @@ +/** + * ECS Framework Math Library + * + * 2D数学库,为游戏开发提供完整的数学工具 + * - 基础数学类(向量、矩阵、几何形状) + * - 碰撞检测算法 + * - 动画插值和缓动函数 + * - 数学工具函数 + */ + +// 核心数学类 +export { Vector2 } from './Vector2'; +export { Matrix3 } from './Matrix3'; +export { Rectangle } from './Rectangle'; +export { Circle } from './Circle'; + +// 数学工具 +export { MathUtils } from './MathUtils'; + +// 碰撞检测 +export * from './Collision'; + +// 动画和插值 +export * from './Animation'; \ No newline at end of file diff --git a/packages/math/tests/CollisionDetector.test.ts b/packages/math/tests/CollisionDetector.test.ts new file mode 100644 index 00000000..b086e9e1 --- /dev/null +++ b/packages/math/tests/CollisionDetector.test.ts @@ -0,0 +1,212 @@ +import { CollisionDetector } from '../src/Collision/CollisionDetector'; +import { Vector2 } from '../src/Vector2'; +import { Rectangle } from '../src/Rectangle'; +import { Circle } from '../src/Circle'; + +declare global { + var expectFloatsEqual: (actual: number, expected: number, epsilon?: number) => void; +} + +describe('CollisionDetector', () => { + describe('点与几何体碰撞', () => { + test('点与圆形碰撞检测', () => { + const circle = new Circle(0, 0, 5); + const pointInside = new Vector2(3, 0); + const pointOutside = new Vector2(10, 0); + const pointOnBoundary = new Vector2(5, 0); + + const collision1 = CollisionDetector.pointCircle(pointInside, circle); + expect(collision1.collided).toBe(true); + expect(collision1.penetration).toBe(2); + + const collision2 = CollisionDetector.pointCircle(pointOutside, circle); + expect(collision2.collided).toBe(false); + + const collision3 = CollisionDetector.pointCircle(pointOnBoundary, circle); + expect(collision3.collided).toBe(true); + expectFloatsEqual(collision3.penetration!, 0, 1e-10); + }); + + test('点与矩形碰撞检测', () => { + const rect = new Rectangle(10, 10, 20, 20); + const pointInside = new Vector2(15, 15); + const pointOutside = new Vector2(5, 5); + + const collision1 = CollisionDetector.pointRect(pointInside, rect); + expect(collision1.collided).toBe(true); + expect(collision1.normal).toBeDefined(); + + const collision2 = CollisionDetector.pointRect(pointOutside, rect); + expect(collision2.collided).toBe(false); + }); + }); + + describe('圆形碰撞检测', () => { + test('圆形与圆形碰撞检测', () => { + const circle1 = new Circle(0, 0, 5); + const circle2 = new Circle(8, 0, 5); // 相交 + const circle3 = new Circle(15, 0, 5); // 不相交 + const circle4 = new Circle(10, 0, 5); // 边界接触 + + const collision1 = CollisionDetector.circleCircle(circle1, circle2); + expect(collision1.collided).toBe(true); + expect(collision1.penetration).toBe(2); + + const collision2 = CollisionDetector.circleCircle(circle1, circle3); + expect(collision2.collided).toBe(false); + + const collision3 = CollisionDetector.circleCircle(circle1, circle4); + expect(collision3.collided).toBe(true); + expectFloatsEqual(collision3.penetration!, 0, 1e-10); + }); + + test('圆形与矩形碰撞检测', () => { + const circle = new Circle(15, 15, 5); + const rect1 = new Rectangle(10, 10, 20, 20); // 相交 + const rect2 = new Rectangle(30, 30, 10, 10); // 不相交 + + const collision1 = CollisionDetector.circleRect(circle, rect1); + expect(collision1.collided).toBe(true); + + const collision2 = CollisionDetector.circleRect(circle, rect2); + expect(collision2.collided).toBe(false); + }); + }); + + describe('矩形碰撞检测', () => { + test('矩形与矩形碰撞检测', () => { + const rect1 = new Rectangle(10, 10, 20, 20); + const rect2 = new Rectangle(15, 15, 20, 20); // 相交 + const rect3 = new Rectangle(40, 40, 10, 10); // 不相交 + + const collision1 = CollisionDetector.rectRect(rect1, rect2); + expect(collision1.collided).toBe(true); + expect(collision1.penetration).toBeGreaterThan(0); + expect(collision1.normal).toBeDefined(); + + const collision2 = CollisionDetector.rectRect(rect1, rect3); + expect(collision2.collided).toBe(false); + }); + }); + + describe('射线投射', () => { + test('射线与圆形相交', () => { + const rayOrigin = new Vector2(-10, 0); + const rayDirection = new Vector2(1, 0); + const circle = new Circle(0, 0, 5); + + const collision = CollisionDetector.rayCircle(rayOrigin, rayDirection, circle); + expect(collision.collided).toBe(true); + expect(collision.distance).toBe(5); + expect(collision.contactPoint!.x).toBe(-5); + expect(collision.contactPoint!.y).toBe(0); + }); + + test('射线与圆形不相交', () => { + const rayOrigin = new Vector2(-10, 10); + const rayDirection = new Vector2(1, 0); + const circle = new Circle(0, 0, 5); + + const collision = CollisionDetector.rayCircle(rayOrigin, rayDirection, circle); + expect(collision.collided).toBe(false); + }); + + test('射线与矩形相交', () => { + const rayOrigin = new Vector2(-5, 15); + const rayDirection = new Vector2(1, 0); + const rect = new Rectangle(10, 10, 20, 20); + + const collision = CollisionDetector.rayRect(rayOrigin, rayDirection, rect); + expect(collision.collided).toBe(true); + expect(collision.distance).toBe(15); + }); + + test('射线距离限制', () => { + const rayOrigin = new Vector2(-10, 0); + const rayDirection = new Vector2(1, 0); + const circle = new Circle(0, 0, 5); + + const collision = CollisionDetector.rayCircle(rayOrigin, rayDirection, circle, 3); + expect(collision.collided).toBe(false); + }); + }); + + describe('线段相交', () => { + test('线段与线段相交', () => { + const p1 = new Vector2(0, 0); + const p2 = new Vector2(10, 10); + const p3 = new Vector2(0, 10); + const p4 = new Vector2(10, 0); + + const collision = CollisionDetector.lineSegmentLineSegment(p1, p2, p3, p4); + expect(collision.collided).toBe(true); + expect(collision.contactPoint!.x).toBe(5); + expect(collision.contactPoint!.y).toBe(5); + }); + + test('线段与线段不相交', () => { + const p1 = new Vector2(0, 0); + const p2 = new Vector2(5, 5); + const p3 = new Vector2(10, 0); + const p4 = new Vector2(15, 5); + + const collision = CollisionDetector.lineSegmentLineSegment(p1, p2, p3, p4); + expect(collision.collided).toBe(false); + }); + + test('线段与圆形相交', () => { + const lineStart = new Vector2(-10, 0); + const lineEnd = new Vector2(10, 0); + const circle = new Circle(0, 0, 5); + + const collision = CollisionDetector.lineSegmentCircle(lineStart, lineEnd, circle); + expect(collision.collided).toBe(true); + }); + }); + + describe('快速排斥测试', () => { + test('AABB包围盒测试', () => { + const bounds1 = new Rectangle(10, 10, 20, 20); + const bounds2 = new Rectangle(15, 15, 20, 20); + const bounds3 = new Rectangle(40, 40, 10, 10); + + expect(CollisionDetector.aabbTest(bounds1, bounds2)).toBe(true); + expect(CollisionDetector.aabbTest(bounds1, bounds3)).toBe(false); + }); + + test('圆形包围盒测试', () => { + const center1 = new Vector2(0, 0); + const center2 = new Vector2(8, 0); + const center3 = new Vector2(15, 0); + + expect(CollisionDetector.circleTest(center1, 5, center2, 5)).toBe(true); + expect(CollisionDetector.circleTest(center1, 5, center3, 5)).toBe(false); + }); + }); + + describe('边界情况', () => { + test('零半径圆形', () => { + const point = new Vector2(0, 0); + const circle = new Circle(0, 0, 0); + + const collision = CollisionDetector.pointCircle(point, circle); + expect(collision.collided).toBe(true); + }); + + test('零面积矩形', () => { + const point = new Vector2(10, 10); + const rect = new Rectangle(10, 10, 0, 0); + + const collision = CollisionDetector.pointRect(point, rect); + expect(collision.collided).toBe(true); + }); + + test('同心圆形', () => { + const circle1 = new Circle(0, 0, 5); + const circle2 = new Circle(0, 0, 3); + + const collision = CollisionDetector.circleCircle(circle1, circle2); + expect(collision.collided).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/packages/math/tests/Rectangle.test.ts b/packages/math/tests/Rectangle.test.ts new file mode 100644 index 00000000..adc64d35 --- /dev/null +++ b/packages/math/tests/Rectangle.test.ts @@ -0,0 +1,292 @@ +import { Rectangle } from '../src/Rectangle'; +import { Vector2 } from '../src/Vector2'; + +declare global { + var expectFloatsEqual: (actual: number, expected: number, epsilon?: number) => void; +} + +describe('Rectangle', () => { + describe('构造函数和基础属性', () => { + test('默认构造函数应创建空矩形', () => { + const rect = new Rectangle(); + expect(rect.x).toBe(0); + expect(rect.y).toBe(0); + expect(rect.width).toBe(0); + expect(rect.height).toBe(0); + }); + + test('应正确设置矩形参数', () => { + const rect = new Rectangle(10, 20, 100, 50); + expect(rect.x).toBe(10); + expect(rect.y).toBe(20); + expect(rect.width).toBe(100); + expect(rect.height).toBe(50); + }); + + test('边界属性应正确计算', () => { + const rect = new Rectangle(10, 20, 100, 50); + expect(rect.left).toBe(10); + expect(rect.right).toBe(110); + expect(rect.top).toBe(20); + expect(rect.bottom).toBe(70); + }); + + test('中心属性应正确计算', () => { + const rect = new Rectangle(10, 20, 100, 50); + expect(rect.centerX).toBe(60); + expect(rect.centerY).toBe(45); + + const center = rect.center; + expect(center.x).toBe(60); + expect(center.y).toBe(45); + }); + + test('角点属性应正确计算', () => { + const rect = new Rectangle(10, 20, 100, 50); + + expect(rect.topLeft.x).toBe(10); + expect(rect.topLeft.y).toBe(20); + expect(rect.topRight.x).toBe(110); + expect(rect.topRight.y).toBe(20); + expect(rect.bottomLeft.x).toBe(10); + expect(rect.bottomLeft.y).toBe(70); + expect(rect.bottomRight.x).toBe(110); + expect(rect.bottomRight.y).toBe(70); + }); + + test('面积和周长应正确计算', () => { + const rect = new Rectangle(0, 0, 10, 5); + expect(rect.area).toBe(50); + expect(rect.perimeter).toBe(30); + }); + }); + + describe('基础操作', () => { + test('set方法应正确设置值', () => { + const rect = new Rectangle(); + rect.set(1, 2, 3, 4); + expect(rect.x).toBe(1); + expect(rect.y).toBe(2); + expect(rect.width).toBe(3); + expect(rect.height).toBe(4); + }); + + test('copy方法应正确复制', () => { + const rect1 = new Rectangle(1, 2, 3, 4); + const rect2 = new Rectangle(); + rect2.copy(rect1); + expect(rect2.x).toBe(1); + expect(rect2.y).toBe(2); + expect(rect2.width).toBe(3); + expect(rect2.height).toBe(4); + }); + + test('clone方法应创建相同的新实例', () => { + const rect1 = new Rectangle(1, 2, 3, 4); + const rect2 = rect1.clone(); + expect(rect2.x).toBe(1); + expect(rect2.y).toBe(2); + expect(rect2.width).toBe(3); + expect(rect2.height).toBe(4); + expect(rect2).not.toBe(rect1); + }); + + test('setCenter方法应正确设置中心点', () => { + const rect = new Rectangle(0, 0, 10, 10); + rect.setCenter(50, 60); + expect(rect.x).toBe(45); + expect(rect.y).toBe(55); + expect(rect.centerX).toBe(50); + expect(rect.centerY).toBe(60); + }); + }); + + describe('变换操作', () => { + test('translate方法应正确平移', () => { + const rect = new Rectangle(10, 20, 30, 40); + rect.translate(5, 10); + expect(rect.x).toBe(15); + expect(rect.y).toBe(30); + expect(rect.width).toBe(30); + expect(rect.height).toBe(40); + }); + + test('scale方法应正确缩放', () => { + const rect = new Rectangle(10, 10, 20, 30); + const originalCenterX = rect.centerX; + const originalCenterY = rect.centerY; + + rect.scale(2, 3); + + expect(rect.width).toBe(40); + expect(rect.height).toBe(90); + expect(rect.centerX).toBe(originalCenterX); + expect(rect.centerY).toBe(originalCenterY); + }); + + test('inflate方法应正确扩展', () => { + const rect = new Rectangle(10, 10, 20, 30); + rect.inflate(5); + expect(rect.x).toBe(5); + expect(rect.y).toBe(5); + expect(rect.width).toBe(30); + expect(rect.height).toBe(40); + }); + }); + + describe('包含检测', () => { + const rect = new Rectangle(10, 10, 20, 30); + + test('containsPoint应正确检测点包含', () => { + const point1 = new Vector2(15, 15); + const point2 = new Vector2(5, 5); + const point3 = new Vector2(10, 10); // 边界点 + + expect(rect.containsPoint(point1)).toBe(true); + expect(rect.containsPoint(point2)).toBe(false); + expect(rect.containsPoint(point3)).toBe(true); + }); + + test('contains方法应正确检测坐标包含', () => { + expect(rect.contains(15, 15)).toBe(true); + expect(rect.contains(5, 5)).toBe(false); + }); + + test('containsRect应正确检测矩形包含', () => { + const inside = new Rectangle(12, 12, 5, 5); + const outside = new Rectangle(5, 5, 5, 5); + const overlapping = new Rectangle(8, 8, 5, 5); + + expect(rect.containsRect(inside)).toBe(true); + expect(rect.containsRect(outside)).toBe(false); + expect(rect.containsRect(overlapping)).toBe(false); + }); + }); + + describe('相交检测', () => { + const rect1 = new Rectangle(10, 10, 20, 20); + + test('intersects应正确检测相交', () => { + const rect2 = new Rectangle(15, 15, 20, 20); // 相交 + const rect3 = new Rectangle(40, 40, 10, 10); // 不相交 + const rect4 = new Rectangle(30, 10, 10, 20); // 边界接触 + + expect(rect1.intersects(rect2)).toBe(true); + expect(rect1.intersects(rect3)).toBe(false); + expect(rect1.intersects(rect4)).toBe(false); + }); + + test('intersection应正确计算相交矩形', () => { + const rect2 = new Rectangle(15, 15, 20, 20); + const intersection = rect1.intersection(rect2); + + expect(intersection.x).toBe(15); + expect(intersection.y).toBe(15); + expect(intersection.width).toBe(15); + expect(intersection.height).toBe(15); + }); + + test('union应正确计算并集矩形', () => { + const rect2 = new Rectangle(20, 20, 20, 20); + const union = rect1.union(rect2); + + expect(union.x).toBe(10); + expect(union.y).toBe(10); + expect(union.right).toBe(40); + expect(union.bottom).toBe(40); + }); + }); + + describe('距离计算', () => { + const rect = new Rectangle(10, 10, 20, 20); + + test('distanceToPoint应正确计算点到矩形的距离', () => { + const insidePoint = new Vector2(15, 15); + const outsidePoint = new Vector2(40, 40); + + expect(rect.distanceToPoint(insidePoint)).toBe(0); + expectFloatsEqual(rect.distanceToPoint(outsidePoint), Math.sqrt(200)); + }); + + test('closestPointTo应返回最近点', () => { + const point = new Vector2(5, 25); + const closest = rect.closestPointTo(point); + + expect(closest.x).toBe(10); + expect(closest.y).toBe(25); + }); + }); + + describe('静态方法', () => { + test('fromCenter应从中心点创建矩形', () => { + const rect = Rectangle.fromCenter(50, 60, 20, 30); + expect(rect.centerX).toBe(50); + expect(rect.centerY).toBe(60); + expect(rect.width).toBe(20); + expect(rect.height).toBe(30); + }); + + test('fromPoints应从两点创建矩形', () => { + const p1 = new Vector2(10, 20); + const p2 = new Vector2(30, 15); + const rect = Rectangle.fromPoints(p1, p2); + + expect(rect.x).toBe(10); + expect(rect.y).toBe(15); + expect(rect.width).toBe(20); + expect(rect.height).toBe(5); + }); + + test('fromPointArray应从点数组创建包围矩形', () => { + const points = [ + new Vector2(5, 10), + new Vector2(15, 5), + new Vector2(20, 25), + new Vector2(8, 30) + ]; + const rect = Rectangle.fromPointArray(points); + + expect(rect.x).toBe(5); + expect(rect.y).toBe(5); + expect(rect.right).toBe(20); + expect(rect.bottom).toBe(30); + }); + }); + + describe('比较操作', () => { + test('equals应正确比较矩形', () => { + const rect1 = new Rectangle(1, 2, 3, 4); + const rect2 = new Rectangle(1, 2, 3, 4); + const rect3 = new Rectangle(1.001, 2, 3, 4); + + expect(rect1.equals(rect2)).toBe(true); + expect(rect1.equals(rect3, 0.01)).toBe(true); + expect(rect1.equals(rect3, 0.0001)).toBe(false); + }); + }); + + describe('字符串转换', () => { + test('toString应返回正确格式', () => { + const rect = new Rectangle(1.234, 2.567, 3.891, 4.012); + const str = rect.toString(); + expect(str).toContain('Rectangle'); + }); + + test('toArray应返回数组', () => { + const rect = new Rectangle(1, 2, 3, 4); + const arr = rect.toArray(); + expect(arr).toEqual([1, 2, 3, 4]); + }); + + test('getVertices应返回四个顶点', () => { + const rect = new Rectangle(10, 20, 30, 40); + const vertices = rect.getVertices(); + + expect(vertices).toHaveLength(4); + expect(vertices[0].x).toBe(10); // topLeft + expect(vertices[0].y).toBe(20); + expect(vertices[1].x).toBe(40); // topRight + expect(vertices[1].y).toBe(20); + }); + }); +}); \ No newline at end of file diff --git a/packages/math/tests/Vector2.test.ts b/packages/math/tests/Vector2.test.ts new file mode 100644 index 00000000..0b7ea134 --- /dev/null +++ b/packages/math/tests/Vector2.test.ts @@ -0,0 +1,261 @@ +import { Vector2 } from '../src/Vector2'; + +declare global { + var expectFloatsEqual: (actual: number, expected: number, epsilon?: number) => void; +} + +describe('Vector2', () => { + describe('构造函数和基础属性', () => { + test('默认构造函数应创建零向量', () => { + const v = new Vector2(); + expect(v.x).toBe(0); + expect(v.y).toBe(0); + }); + + test('应正确设置x和y值', () => { + const v = new Vector2(3, 4); + expect(v.x).toBe(3); + expect(v.y).toBe(4); + }); + + test('length属性应正确计算', () => { + const v = new Vector2(3, 4); + expect(v.length).toBe(5); + }); + + test('lengthSquared属性应正确计算', () => { + const v = new Vector2(3, 4); + expect(v.lengthSquared).toBe(25); + }); + + test('angle属性应正确计算', () => { + const v = new Vector2(1, 0); + expect(v.angle).toBe(0); + + const v2 = new Vector2(0, 1); + expectFloatsEqual(v2.angle, Math.PI / 2); + }); + }); + + describe('基础运算', () => { + test('set方法应正确设置值', () => { + const v = new Vector2(); + v.set(5, 6); + expect(v.x).toBe(5); + expect(v.y).toBe(6); + }); + + test('copy方法应正确复制值', () => { + const v1 = new Vector2(1, 2); + const v2 = new Vector2(3, 4); + v2.copy(v1); + expect(v2.x).toBe(1); + expect(v2.y).toBe(2); + }); + + test('clone方法应创建相同的新实例', () => { + const v1 = new Vector2(1, 2); + const v2 = v1.clone(); + expect(v2.x).toBe(1); + expect(v2.y).toBe(2); + expect(v2).not.toBe(v1); + }); + + test('add方法应正确相加', () => { + const v1 = new Vector2(1, 2); + const v2 = new Vector2(3, 4); + v1.add(v2); + expect(v1.x).toBe(4); + expect(v1.y).toBe(6); + }); + + test('subtract方法应正确相减', () => { + const v1 = new Vector2(5, 7); + const v2 = new Vector2(2, 3); + v1.subtract(v2); + expect(v1.x).toBe(3); + expect(v1.y).toBe(4); + }); + + test('multiply方法应正确数乘', () => { + const v = new Vector2(2, 3); + v.multiply(4); + expect(v.x).toBe(8); + expect(v.y).toBe(12); + }); + + test('divide方法应正确数除', () => { + const v = new Vector2(8, 12); + v.divide(4); + expect(v.x).toBe(2); + expect(v.y).toBe(3); + }); + + test('divide方法应在除以零时抛出错误', () => { + const v = new Vector2(1, 2); + expect(() => v.divide(0)).toThrow('不能除以零'); + }); + }); + + describe('向量运算', () => { + test('dot方法应正确计算点积', () => { + const v1 = new Vector2(1, 2); + const v2 = new Vector2(3, 4); + expect(v1.dot(v2)).toBe(11); // 1*3 + 2*4 = 11 + }); + + test('cross方法应正确计算叉积', () => { + const v1 = new Vector2(1, 0); + const v2 = new Vector2(0, 1); + expect(v1.cross(v2)).toBe(1); + }); + + test('normalize方法应正确归一化向量', () => { + const v = new Vector2(3, 4); + v.normalize(); + expectFloatsEqual(v.length, 1); + expectFloatsEqual(v.x, 0.6); + expectFloatsEqual(v.y, 0.8); + }); + + test('零向量归一化应保持不变', () => { + const v = new Vector2(0, 0); + v.normalize(); + expect(v.x).toBe(0); + expect(v.y).toBe(0); + }); + }); + + describe('几何运算', () => { + test('distanceTo方法应正确计算距离', () => { + const v1 = new Vector2(0, 0); + const v2 = new Vector2(3, 4); + expect(v1.distanceTo(v2)).toBe(5); + }); + + test('angleTo方法应正确计算夹角', () => { + const v1 = new Vector2(1, 0); + const v2 = new Vector2(0, 1); + expectFloatsEqual(v1.angleTo(v2), Math.PI / 2); + }); + + test('projectOnto方法应正确投影', () => { + const v1 = new Vector2(2, 2); + const v2 = new Vector2(1, 0); + const projected = v1.projectOnto(v2); + expect(projected.x).toBe(2); + expect(projected.y).toBe(0); + }); + }); + + describe('变换操作', () => { + test('rotate方法应正确旋转向量', () => { + const v = new Vector2(1, 0); + v.rotate(Math.PI / 2); + expectFloatsEqual(v.x, 0, 1e-10); + expectFloatsEqual(v.y, 1, 1e-10); + }); + + test('reflect方法应正确反射向量', () => { + const v = new Vector2(1, 1); + const normal = new Vector2(0, 1); + v.reflect(normal); + expectFloatsEqual(v.x, 1); + expectFloatsEqual(v.y, -1); + }); + }); + + describe('插值和限制', () => { + test('lerp方法应正确插值', () => { + const v1 = new Vector2(0, 0); + const v2 = new Vector2(10, 10); + v1.lerp(v2, 0.5); + expect(v1.x).toBe(5); + expect(v1.y).toBe(5); + }); + + test('clampLength方法应正确限制长度', () => { + const v = new Vector2(6, 8); // 长度为10 + v.clampLength(5); + expectFloatsEqual(v.length, 5); + }); + }); + + describe('比较操作', () => { + test('equals方法应正确比较向量', () => { + const v1 = new Vector2(1, 2); + const v2 = new Vector2(1, 2); + const v3 = new Vector2(1.0001, 2); + + expect(v1.equals(v2)).toBe(true); + expect(v1.equals(v3, 0.001)).toBe(true); + expect(v1.equals(v3, 0.00001)).toBe(false); + }); + + test('exactEquals方法应检查完全相等', () => { + const v1 = new Vector2(1, 2); + const v2 = new Vector2(1, 2); + const v3 = new Vector2(1.0001, 2); + + expect(v1.exactEquals(v2)).toBe(true); + expect(v1.exactEquals(v3)).toBe(false); + }); + }); + + describe('静态方法', () => { + test('Vector2.add应创建新的相加结果', () => { + const v1 = new Vector2(1, 2); + const v2 = new Vector2(3, 4); + const result = Vector2.add(v1, v2); + + expect(result.x).toBe(4); + expect(result.y).toBe(6); + expect(v1.x).toBe(1); // 原向量不变 + expect(v1.y).toBe(2); + }); + + test('Vector2.fromAngle应从角度创建单位向量', () => { + const v = Vector2.fromAngle(Math.PI / 2); + expectFloatsEqual(v.x, 0, 1e-10); + expectFloatsEqual(v.y, 1, 1e-10); + }); + + test('Vector2.fromPolar应从极坐标创建向量', () => { + const v = Vector2.fromPolar(5, 0); + expect(v.x).toBe(5); + expectFloatsEqual(v.y, 0, 1e-10); + }); + }); + + describe('静态常量', () => { + test('静态常量应具有正确的值', () => { + expect(Vector2.ZERO.x).toBe(0); + expect(Vector2.ZERO.y).toBe(0); + expect(Vector2.ONE.x).toBe(1); + expect(Vector2.ONE.y).toBe(1); + expect(Vector2.RIGHT.x).toBe(1); + expect(Vector2.RIGHT.y).toBe(0); + }); + }); + + describe('字符串转换', () => { + test('toString应返回正确格式', () => { + const v = new Vector2(1.2345, 2.6789); + const str = v.toString(); + expect(str).toContain('1.234'); + expect(str).toContain('2.679'); + }); + + test('toArray应返回数组', () => { + const v = new Vector2(1, 2); + const arr = v.toArray(); + expect(arr).toEqual([1, 2]); + }); + + test('toObject应返回对象', () => { + const v = new Vector2(1, 2); + const obj = v.toObject(); + expect(obj).toEqual({ x: 1, y: 2 }); + }); + }); +}); \ No newline at end of file diff --git a/packages/math/tests/setup.ts b/packages/math/tests/setup.ts new file mode 100644 index 00000000..74dc3f5b --- /dev/null +++ b/packages/math/tests/setup.ts @@ -0,0 +1,23 @@ +/** + * Jest测试设置文件 + * 用于配置全局测试环境 + */ + +// 设置测试超时 +jest.setTimeout(10000); + +// 全局数学常量和工具函数 +(global as any).EPSILON = 1e-10; + +// 浮点数比较助手函数 +(global as any).expectFloatsEqual = (actual: number, expected: number, epsilon = (global as any).EPSILON) => { + expect(Math.abs(actual - expected)).toBeLessThan(epsilon); +}; + +// 声明全局类型扩展 +declare global { + var EPSILON: number; + var expectFloatsEqual: (actual: number, expected: number, epsilon?: number) => void; +} + +export {}; \ No newline at end of file diff --git a/packages/math/tsconfig.json b/packages/math/tsconfig.json new file mode 100644 index 00000000..ee8ca846 --- /dev/null +++ b/packages/math/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./bin", + "rootDir": "./src", + "declarationMap": true, + "declaration": true, + "sourceMap": true, + "strict": true, + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "bin", + "dist", + "tests", + "**/*.test.ts", + "**/*.spec.ts" + ] +} \ No newline at end of file