From 555cf96d761489c71cc0a79641879d1be2c3c5c2 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Tue, 10 Mar 2026 15:03:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A8=E7=94=BB=E7=B1=BB=20Animater?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + packages/animate/src/animater.ts | 505 +++++++++++++++++++++++++++++ packages/animate/src/excitation.ts | 18 +- packages/animate/src/index.ts | 1 + packages/animate/src/types.ts | 167 ++++++++++ packages/animate/src/utils.ts | 427 +++++++++++++++++++++++- packages/common/src/logger.json | 2 + packages/common/src/utils/func.ts | 19 ++ pnpm-lock.yaml | 231 +++++++++++++ 9 files changed, 1363 insertions(+), 9 deletions(-) create mode 100644 packages/animate/src/animater.ts diff --git a/package.json b/package.json index 2650895..a009600 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "tsx script/dev.ts", + "test": "vitest", "preview": "vite preview", "declare": "tsx script/declare.ts", "type": "vue-tsc --noEmit", @@ -87,6 +88,7 @@ "vite-plugin-dts": "^4.5.4", "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17", + "vitest": "^4.0.18", "vue-tsc": "^2.2.12", "ws": "^8.19.0" } diff --git a/packages/animate/src/animater.ts b/packages/animate/src/animater.ts new file mode 100644 index 0000000..6ec833a --- /dev/null +++ b/packages/animate/src/animater.ts @@ -0,0 +1,505 @@ +import { logger } from '@motajs/common'; +import { + ExcitationCurve, + IAnimatable, + IAnimater, + IAnimationPlan, + IExcitableController, + IExcitation, + IAnimatePlanIdentifier +} from './types'; +import { linear } from './utils'; + +interface IAnimaterTimedInfo extends IAnimatePlanIdentifier { + /** 等待时长 */ + readonly time: number; +} + +interface IAnimaterResolvedTimedInfo extends IAnimaterTimedInfo { + /** 当前定时动画计划被唤起的时刻 */ + readonly arousedTime: number; +} + +interface IAnimaterRawPlan extends IAnimationPlan { + /** 当此动画开始时需要执行的计划 */ + readonly when: Set; + /** 当此动画结束后需要执行的计划 */ + readonly after: Set; + /** 兑现函数,当本次动画计划执行完毕后执行 */ + readonly resolve: () => void; +} + +interface IAnimatingContent extends IAnimaterRawPlan { + /** 动画执行开始的时刻 */ + readonly startTime: number; + /** 动画执行的初始值 */ + readonly startValue: number; + /** 动画终值与初值的差值 */ + readonly diff: number; +} + +interface IAnimaterContentPlan { + /** 当前动画对象中所有的动画计划 */ + readonly animationPlan: Map; + /** 计数器,用于计算动画计划的索引 */ + counter: number; +} + +interface IAnimaterPlanGroupBase { + /** 计划执行前的等待时间 */ + readonly preTime: number; + /** 计划执行后的等待时间 */ + readonly postTime: number; + /** 计划组的首个动画计划 */ + readonly planStart: Set; +} + +interface IAnimaterPlanGroup extends IAnimaterPlanGroupBase { + /** 当前计划组中所有的动画对象计划 */ + readonly contentStore: Map; +} + +export class Animater implements IAnimater { + /** 当前是否正在定义动画计划 */ + private planning: boolean = false; + + /** 当前绑定的激励源 */ + private excitation: IExcitation | null = null; + /** 当前定义在绑定激励源上的可激励对象 */ + private controller: IExcitableController | null = null; + + /** 计划组计数器 */ + private groupCounter: number = 0; + /** 当前正在计划的动画计划 */ + private planningStore: Map = new Map(); + /** 计划存储 */ + private groupStore: Map = new Map(); + /** 需要执行的计划队列 */ + private pendingGroups: number[] = []; + + /** 当前所有正在等待执行的 `when` 操作 */ + private whens: Set = new Set(); + /** 当前所有正在等待执行的 `after` 操作 */ + private afters: Set = new Set(); + + /** 当前正在执行的计划组 */ + private executingGroup: number = -1; + /** 当前正在执行的计划组对象 */ + private executingGroupObj: IAnimaterPlanGroup | null = null; + /** 当前正在执行的动画 */ + private executing: Set = new Set(); + /** 当前动画对象正在被哪个动画执行 */ + private executingMap: Map = new Map(); + + /** 当前使用的速率曲线 */ + private curveStatus: ExcitationCurve = linear(); + /** 当前使用的动画对象计划 */ + private animatableStatus: IAnimaterContentPlan | null = null; + /** 当前使用的动画对象 */ + private currentAnimatable: IAnimatable | null = null; + /** 当前正在计划的计划组的起始动画 */ + private planningStart: Set = new Set(); + + /** 当前的 `when` 计划内容 */ + private whenBind: IAnimaterRawPlan | null = null; + /** 当前的 `after` 计划内容 */ + private afterBind: IAnimaterRawPlan | null = null; + /** 当前的 `when` 等待时长 */ + private whenTime: number = 0; + /** 当前的 `after` 等待时长 */ + private afterTime: number = 0; + + /** 计划组是否正在进行执行前等待 */ + private waitingPre = false; + /** 计划组执行前等待的时间 */ + private waitingPreStart = 0; + /** 计划组是否正在进行执行后等待 */ + private waitingPost = false; + /** 计划组执行后等待的时间 */ + private waitingPostStart = 0; + + /** 上一个定义的动画计划 */ + private lastAnimatable: IAnimaterRawPlan | null = null; + + constructor() { + this.excited = this.excited.bind(this); + } + + bindExcitation(excitation: IExcitation): void { + if (excitation === this.excitation) return; + this.unbindExcitation(); + this.controller = excitation.add(this); + this.excitation = excitation; + } + + unbindExcitation(): void { + if (!this.excitation) return; + this.controller?.revoke(); + this.excitation = null; + } + + //#region 动画计划 + + animate(content: IAnimatable): this { + if (!this.planningStore.has(content)) { + const plan: IAnimaterContentPlan = { + animationPlan: new Map(), + counter: 0 + }; + this.animatableStatus = plan; + this.planningStore.set(content, plan); + } else { + const plan = this.planningStore.get(content)!; + this.animatableStatus = plan; + } + this.currentAnimatable = content; + return this; + } + + curve(curve: ExcitationCurve): this { + this.curveStatus = curve; + return this; + } + + to(value: number, time: number): this { + if (!this.animatableStatus || !this.currentAnimatable) return this; + this.planning = true; + // 定义动画计划 + const index = ++this.animatableStatus.counter; + const { promise, resolve } = Promise.withResolvers(); + const plan: IAnimaterRawPlan = { + identifier: { content: this.currentAnimatable, index }, + curve: this.curveStatus, + targetValue: value, + time, + promise, + resolve, + when: new Set(), + after: new Set() + }; + this.animatableStatus.animationPlan.set(index, plan); + // 检查 when after 状态,如果是在这个状态下,加入到对应的计划中 + if (this.whenBind) { + const identifier: IAnimaterTimedInfo = { + content: this.currentAnimatable, + index, + time: this.whenTime + }; + this.whenBind.when.add(identifier); + } else if (this.afterBind) { + const identifier: IAnimaterTimedInfo = { + content: this.currentAnimatable, + index, + time: this.afterTime + }; + this.afterBind.after.add(identifier); + } else { + const identifier: IAnimatePlanIdentifier = { + content: this.currentAnimatable, + index + }; + this.planningStart.add(identifier); + } + // 将上一次的计划设为本计划,用于 after when 的调用 + this.lastAnimatable = plan; + return this; + } + + after(time: number = 0): this { + if (!this.lastAnimatable) return this; + this.afterBind = this.lastAnimatable; + this.afterTime = time; + this.whenBind = null; + return this; + } + + afterPlan(content: IAnimatable, index: number, time: number = 0): this { + const plan = this.queryRaw(content, index); + if (!plan) return this; + this.afterBind = plan; + this.afterTime = time; + this.whenBind = null; + return this; + } + + when(time: number = 0): this { + if (!this.lastAnimatable) return this; + this.whenBind = this.lastAnimatable; + this.whenTime = time; + this.afterBind = null; + return this; + } + + whenPlan(content: IAnimatable, index: number, time: number = 0): this { + const plan = this.queryRaw(content, index); + if (!plan) return this; + this.whenBind = plan; + this.whenTime = time; + this.afterBind = null; + return this; + } + + /** + * 类内查询动画计划,可以获取到外界获取不到的信息 + * @param content 动画对象 + * @param index 动画计划索引 + * @param plan 计划组索引,不填时表示当前正在计划中的计划组,即从上一次调用 `planEnd` 至现在的这个区间。 + */ + private queryRaw( + content: IAnimatable, + index: number, + plan: number = -1 + ): IAnimaterRawPlan | null { + if (plan === -1) { + const animation = this.planningStore.get(content); + if (!animation) return null; + const result = animation.animationPlan.get(index); + return result ?? null; + } else { + const group = this.groupStore.get(plan); + if (!group) return null; + const animation = group.contentStore.get(content); + if (!animation) return null; + const result = animation.animationPlan.get(index); + return result ?? null; + } + } + + query( + content: IAnimatable, + index: number, + plan: number = -1 + ): IAnimationPlan | null { + return this.queryRaw(content, index, plan); + } + + wait( + content: IAnimatable, + index: number, + plan: number = this.executingGroup + ): Promise | undefined { + const raw = this.query(content, index, plan); + return raw?.promise; + } + + planEnd(preTime: number = 0, postTime: number = 0): number { + if (!this.planning) return -1; + const group: IAnimaterPlanGroup = { + contentStore: this.planningStore, + planStart: this.planningStart, + preTime, + postTime + }; + this.whenBind = null; + this.afterBind = null; + this.currentAnimatable = null; + this.animatableStatus = null; + this.lastAnimatable = null; + this.planningStart = new Set(); + this.planningStore = new Map(); + this.planning = false; + const index = ++this.groupCounter; + this.groupStore.set(index, group); + this.pendingGroups.push(index); + this.startPlanGroup(); + return index; + } + + //#endregion + + //#region 动画执行 + + excited(payload: number): void { + if (this.planning) { + logger.error(50); + return; + } + // 计划组未执行 + if (this.executingGroup === -1 || !this.executingGroupObj) return; + + // 计划组 preTime 等待 + if (this.waitingPre) { + const dt = payload - this.waitingPreStart; + if (dt < this.executingGroupObj.preTime) return; + this.waitingPre = false; + // 启动所有 planStart 动画 + for (const identifier of this.executingGroupObj.planStart) { + this.executeAnimate(identifier); + } + } + + // 计划组 postTime 等待 + if (this.waitingPost) { + const dt = payload - this.waitingPostStart; + if (dt >= this.executingGroupObj.postTime) { + this.waitingPost = false; + this.endPlanGroup(); + } + return; + } + + // 处理 when/after 等待 + const whenToDelete = new Set(); + for (const w of this.whens) { + if (payload - w.arousedTime >= w.time) { + whenToDelete.add(w); + this.executeAnimate(w); + } + } + whenToDelete.forEach(v => this.whens.delete(v)); + + const afterToDelete = new Set(); + for (const a of this.afters) { + if (payload - a.arousedTime >= a.time) { + afterToDelete.add(a); + this.executeAnimate(a); + } + } + afterToDelete.forEach(v => this.afters.delete(v)); + + // 动画执行 + const endedAnimate = new Set(); + const afters = new Set(); + for (const anim of this.executing) { + const progress = (payload - anim.startTime) / anim.time; + const content = anim.identifier.content; + if (progress >= 1) { + // 动画结束 + content.value = anim.targetValue; + anim.resolve(); + endedAnimate.add(anim); + // 检查 after + anim.after.forEach(v => afters.add(v)); + } else { + const completion = anim.curve(progress); + content.value = completion * anim.diff + anim.startValue; + } + } + afters.forEach(v => { + this.startAfter({ ...v, arousedTime: payload }); + }); + // 必要清理 + endedAnimate.forEach(v => { + this.executing.delete(v); + this.executingMap.delete(v.identifier.content); + }); + + // 检查计划组是否全部结束 + if ( + this.executing.size === 0 && + this.whens.size === 0 && + this.afters.size === 0 + ) { + this.waitingPost = true; + this.waitingPostStart = payload; + } + } + + /** + * 开始执行指定的动画 + * @param identifier 要执行的动画标识符 + */ + private executeAnimate(identifier: IAnimatePlanIdentifier) { + if (!this.executingGroupObj || !this.excitation) return; + const plan = this.queryRaw( + identifier.content, + identifier.index, + this.executingGroup + ); + if (!plan) return; + // 冲突检测 + if (this.executingMap.has(identifier.content)) { + const current = this.executingMap.get(identifier.content)!; + if (current.startTime === this.excitation?.payload()) { + logger.error(51); + return; + } + // 终止前一个动画 + current.resolve(); + this.executing.delete(current); + } + // 记录动画初始值和开始时间 + const startValue = identifier.content.value; + const startTime = this.excitation.payload(); + const anim: IAnimatingContent = { + ...plan, + startTime, + startValue, + diff: plan.targetValue - startValue + }; + this.executing.add(anim); + this.executingMap.set(identifier.content, anim); + // 检查 when + for (const when of plan.when) { + this.startWhen({ ...when, arousedTime: startTime }); + } + } + + /** + * 开始 `when` 状态计时 + * @param when `when` 状态 + */ + private startWhen(when: IAnimaterResolvedTimedInfo) { + if (when.time === 0) { + this.executeAnimate(when); + } else { + this.whens.add(when); + } + } + + /** + * 开始 `after` 状态计时 + * @param after `after` 状态 + */ + private startAfter(after: IAnimaterResolvedTimedInfo) { + if (after.time === 0) { + this.executeAnimate(after); + } else { + this.afters.add(after); + } + } + + /** + * 开始指定计划组动画的执行 + */ + private startPlanGroup() { + if (this.executingGroup !== -1) return; + if (this.pendingGroups.length === 0) return; + if (!this.excitation) return; + const group = this.pendingGroups.shift(); + if (group === void 0) return; + const obj = this.groupStore.get(group); + if (!obj) return; + this.executingGroup = group; + this.executingGroupObj = obj; + // preTime 等待 + if (obj.preTime > 0) { + this.waitingPre = true; + this.waitingPreStart = this.excitation.payload(); + } else { + // 立即启动所有 planStart 动画 + for (const identifier of this.executingGroupObj.planStart) { + this.executeAnimate(identifier); + } + } + } + + /** + * 结束计划组的执行 + */ + private endPlanGroup() { + // 清理状态 + this.executingGroup = -1; + this.executingGroupObj = null; + this.executing.clear(); + this.executingMap.clear(); + this.whens.clear(); + this.afters.clear(); + this.waitingPre = false; + this.waitingPost = false; + // 启动下一个计划组 + this.startPlanGroup(); + } + + //#endregion +} diff --git a/packages/animate/src/excitation.ts b/packages/animate/src/excitation.ts index df70cd0..4f25f0a 100644 --- a/packages/animate/src/excitation.ts +++ b/packages/animate/src/excitation.ts @@ -214,8 +214,10 @@ export class ExcitationVariator return; } + const now = excitation.payload(); this.source = excitation; - this.sourceTs = excitation.payload(); + this.sourceTs = now; + this.now = now; this.selfTs = this.sourceTs; this.speed = 1; @@ -271,14 +273,14 @@ export class ExcitationVariator return Promise.resolve(); } - return new Promise(resolve => { - this.curveQueue.push({ curve, time, mode, resolve }); + const { promise, resolve } = Promise.withResolvers(); + this.curveQueue.push({ curve, time, mode, resolve }); + // 如果没有正在执行的曲线,立即开始 + if (this.currentCurve === null) { + this.startNextCurve(); + } - // 如果没有正在执行的曲线,立即开始 - if (this.currentCurve === null) { - this.startNextCurve(); - } - }); + return promise; } private startNextCurve(): void { diff --git a/packages/animate/src/index.ts b/packages/animate/src/index.ts index 4d66c9b..db6e3db 100644 --- a/packages/animate/src/index.ts +++ b/packages/animate/src/index.ts @@ -1,3 +1,4 @@ +export * from './animater'; export * from './excitation'; export * from './types'; export * from './utils'; diff --git a/packages/animate/src/types.ts b/packages/animate/src/types.ts index eb7b416..2886016 100644 --- a/packages/animate/src/types.ts +++ b/packages/animate/src/types.ts @@ -127,3 +127,170 @@ export interface IExcitationVariator extends IExcitation { */ endAllCurves(): void; } + +export interface IAnimatable { + /** 动画数值 */ + value: number; +} + +export interface IAnimatePlanIdentifier { + /** 动画对象 */ + readonly content: IAnimatable; + /** 动画对象对应的动画计划 */ + readonly index: number; +} + +export interface IAnimationPlan { + /** 动画计划的标识符 */ + readonly identifier: IAnimatePlanIdentifier; + /** 动画的速率曲线 */ + readonly curve: ExcitationCurve; + /** 动画的目标值 */ + readonly targetValue: number; + /** 动画时长 */ + readonly time: number; + /** 动画结束后兑现的 `Promise` */ + readonly promise: Promise; +} + +export interface IAnimater extends IExcitable { + /** + * 在动画执行器上绑定激励源 + * @param excitation 绑定的激励源 + */ + bindExcitation(excitation: IExcitation): void; + + /** + * 取消绑定激励源 + */ + unbindExcitation(): void; + + /** + * 绑定动画对象,之后的接口调用都将施加在此对象上 + * @param content 动画对象 + */ + animate(content: IAnimatable): this; + + /** + * 设置当前的速率曲线 + * @param curve 速率曲线 + */ + curve(curve: ExcitationCurve): this; + + /** + * 将动画对象的值按照当前设置改变至目标值,为一次动画操作,计入动画索引(参考 {@link query} 的描述)。 + * 如果动画开始时当前动画对象有动画正在执行,那么会立刻结束正在执行的动画,开始执行当前动画。 + * @param value 目标值 + * @param time 动画时长 + */ + to(value: number, time: number): this; + + /** + * 在刚刚定义的动画结束后指定时长再开始后续计划。 + * + * ```ts + * const obj1 = { value: 0 }; + * const obj2 = { value: 0 }; + * animater.animate(obj1) + * .curve(linear()) + * .to(100, 500) + * .animate(obj2) + * .after() + * .to(200, 200) + * .planEnd(); + * ``` + * + * @param time 等待时长,默认为 0 + */ + after(time?: number): this; + + /** + * 在指定动画结束后指定时长再开始后续计划。计划索引参考 {@link query} 的定义。 + * + * ```ts + * const obj1 = { value: 0 }; + * const obj2 = { value: 0 }; + * animater.animate(obj1) + * .curve(linear()) + * .to(100, 500) + * .animate(obj2) + * .to(200, 200) + * .afterObject(obj1, 1) + * ... + * .planEnd(); + * ``` + * + * @param content 动画对象 + * @param index 动画对象对应的计划索引 + * @param time 等待时长,默认为 0 + */ + afterPlan(content: IAnimatable, index: number, time?: number): this; + + /** + * 等待刚刚定义的动画开始指定时长后再开始后续计划,与 {@link after} 类似,但是是以刚刚定义的动画开始执行为基准 + * @param time 等待时长 + */ + when(time?: number): this; + + /** + * 等待指定动画计划开始指定时长后再开始后续计划,与 {@link afterPlan} 类似,但是是以指定动画开始执行为基准 + * @param content 动画对象 + * @param index 动画对象对应的计划索引 + * @param time 等待时长,默认为 0 + */ + whenPlan(content: IAnimatable, index: number, time?: number): this; + + /** + * 查询动画计划。 + * + * 动画计划采用计划组的方式查询,每个计划组之间的动画计划互不干扰。`plan` 描述需要查询的计划组, + * `index` 表示在这个计划组内的动画计划索引,`content` 表示要查询哪个动画对象的动画计划。 + * 动画计划索引描述的是该动画对象在本计划组中的第几次动画计划,从 1 开始计算。举例来说: + * + * ```ts + * const obj = { value: 0 }; + * animater.animate() + * .curve(linear()) + * .to(100, 1000) // 索引为 1 + * .after() + * .curve(sin(CurveMode.EaseOut)) + * .to(200, 500) // 索引为 2 + * .animate(obj2) + * ... + * .animate(obj) + * .to(100, 500) // 索引为 3 + * .planEnd(); + * ``` + * + * @param content 动画对象 + * @param index 动画计划索引 + * @param plan 计划组索引,不填时表示当前正在计划中的计划组,即从上一次调用 `planEnd` 至现在的这个区间, + * 如果期间没有没有任何动画计划,那么会返回 `null`,并不会自动回退到上一次定义的的计划组。 + */ + query( + content: IAnimatable, + index: number, + plan?: number + ): IAnimationPlan | null; + + /** + * 等待指定动画计划执行完毕,参考 {@link query} 的描述 + * @param content 动画对象 + * @param index 动画计划索引 + * @param plan 计划组索引,不填时表示当前正在执行动画的计划组。 + */ + wait( + content: IAnimatable, + index: number, + plan?: number + ): Promise | undefined; + + /** + * 结束当前动画计划的定义,形成动画计划组。该函数必须在动画计划定义完毕后立刻执行来进行必要的处理工作。 + * 后面的计划组会等待前面的计划组的动画全部执行完毕后再开始执行。 + * 参考 {@link query} 中对计划组的描述。 + * @param preTime 计划执行前的等待时长,等待这么长时间之后开始执行首个动画 + * @param postTime 计划执行后的等待时长,等待这么长时间之后计划才真正结束 + */ + planEnd(preTime?: number, postTime?: number): number; +} diff --git a/packages/animate/src/utils.ts b/packages/animate/src/utils.ts index 3562a34..c664dbc 100644 --- a/packages/animate/src/utils.ts +++ b/packages/animate/src/utils.ts @@ -1,4 +1,13 @@ -import { IExcitable } from './types'; +import { cumsum } from '@motajs/common'; +import { + ExcitationCurve, + ExcitationCurve2D, + ExcitationCurve3D, + GeneralExcitationCurve, + IExcitable +} from './types'; + +//#region 工具函数 /** * 将一个函数转换为可激励对象 @@ -15,3 +24,419 @@ export function excited( return { excited: func }; } } + +//#endregion + +//#region 曲线计算 + +/** + * 曲线相加 `a(p) + b(p)` + * @param curve1 加数 + * @param curve2 加数 + */ +export function addCurve( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve { + return p => curve1(p) + curve2(p); +} + +/** + * 曲线相减 `a(p) - b(p)` + * @param curve1 被减数 + * @param curve2 减数 + */ +export function subCurve( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve { + return p => curve1(p) - curve2(p); +} + +/** + * 曲线相乘 `a(p) * b(p)` + * @param curve1 乘数 + * @param curve2 乘数 + */ +export function mulCurve( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve { + return p => curve1(p) * curve2(p); +} + +/** + * 曲线相除 `a(p) / b(p)` + * @param curve1 乘数 + * @param curve2 乘数 + */ +export function divCurve( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve { + return p => curve1(p) / curve2(p); +} + +/** + * 曲线取幂 `a(p) ** b(p)` + * @param curve1 底数 + * @param curve2 指数 + */ +export function powCurve( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve { + return p => curve1(p) ** curve2(p); +} + +/** + * 曲线组合,`a(b(p))` + * @param curve1 外层曲线 + * @param curve2 内层曲线 + */ +export function compositeCurve( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve { + return p => curve1(curve2(p)); +} + +/** + * 平移曲线,`a(p) + b` + * @param curve 曲线 + * @param move 纵轴平移量 + */ +export function moveCurve( + curve: ExcitationCurve, + move: number = 0 +): ExcitationCurve { + return p => curve(p) + move; +} + +/** + * 曲线求相反数并平移,`b - a(p)` + * @param curve 曲线 + * @param move 纵轴平移量 + */ +export function oppsiteCurve( + curve: ExcitationCurve, + move: number = 0 +): ExcitationCurve { + return p => move - curve(p); +} + +/** + * 纵向缩放曲线,`a(p) * b` + * @param curve 曲线 + * @param scale 缩放比例 + */ +export function scaleCurve( + curve: ExcitationCurve, + scale: number = 1 +): ExcitationCurve { + return p => curve(p) * scale; +} + +/** + * 求曲线的倒数,并缩放,`g(x) = b / f(x)` + * @param curve 曲线 + * @param scale 缩放比例 + */ +export function reciprocalCurve( + curve: ExcitationCurve, + scale: number = 1 +): ExcitationCurve { + return p => scale / curve(p); +} + +/** + * 曲线拼接函数,将按照序列顺序依次调用曲线,序列长度过长(> 100)可能会导致性能下降 + * @param seq 曲线序列 + * @param duration 每个曲线的持续时长,范围在 `[0,1]` 之间,所有值之和应该是 1,否则超出 1 的部分不会被执行 + * @param scale 每个曲线拼接时的缩放比例 + * @param move 每个曲线拼接时在纵轴上的偏移量 + */ +export function sequenceCurve( + seq: ExcitationCurve[], + duration: number[], + scale: number[], + move: number[] +): ExcitationCurve { + const keep = cumsum(duration); + return p => { + const index = keep.findIndex(sum => p >= sum); + if (index === -1) return 0; + const progress = (p - keep[index]) / duration[index]; + return seq[index](progress) * scale[index] + move[index]; + }; +} + +/** + * 将二维速率曲线分割为两个一维速率曲线 + * @param curve 二维速率曲线 + */ +export function splitCurve2D( + curve: ExcitationCurve2D +): [ExcitationCurve, ExcitationCurve] { + return [p => curve(p)[0], p => curve(p)[1]]; +} + +/** + * 将三维速率曲线分割为三个一维速率曲线 + * @param curve 三维速率曲线 + */ +export function splitCurve3D( + curve: ExcitationCurve3D +): [ExcitationCurve, ExcitationCurve, ExcitationCurve] { + return [p => curve(p)[0], p => curve(p)[1], p => curve(p)[2]]; +} + +/** + * 将 n 维速率曲线分割为 n 个一维速率曲线 + * @param curve n 维速率曲线 + */ +export function splitCurve(curve: GeneralExcitationCurve): ExcitationCurve[] { + const n = curve(0).length; + const arr: ExcitationCurve[] = []; + for (let i = 0; i < n; i++) { + arr.push(p => curve(p)[i]); + } + return arr; +} + +/** + * 将两个一维速率曲线合并为一个二维速率曲线 + * @param curve1 第一个速率曲线 + * @param curve2 第二个速率曲线 + */ +export function stackCurve2D( + curve1: ExcitationCurve, + curve2: ExcitationCurve +): ExcitationCurve2D { + return p => [curve1(p), curve2(p)]; +} + +/** + * 将三个一维速率曲线合并为一个三维速率曲线 + * @param curve1 第一个速率曲线 + * @param curve2 第二个速率曲线 + * @param curve3 第三个速率曲线 + */ +export function stackCurve3D( + curve1: ExcitationCurve, + curve2: ExcitationCurve, + curve3: ExcitationCurve +): ExcitationCurve3D { + return p => [curve1(p), curve2(p), curve3(p)]; +} + +/** + * 将 n 个一维速率曲线合并为一个 n 维速率曲线 + * @param curves 速率曲线列表 + */ +export function stackCurve(curves: ExcitationCurve[]): GeneralExcitationCurve { + return p => curves.map(v => v(p)); +} + +/** + * 对速率曲线归一化,此函数假设传入的曲线单调,会使用 `curve(0)` 和 `curve(1)` 作为最大值或最小值。 + * + * - `f(0) > f(1)`: `g(x) = (f(x) - f(0)) / (f(0) - f(1))` + * - `f(0) < f(1)`: `g(x) = (f(x) - f(1)) / (f(1) - f(0))` + * - `f(0) = f(1)`: `g(x) = f(x)` + * @param curve 需要归一化的曲线 + * @returns + */ +export function normalize(curve: ExcitationCurve): ExcitationCurve { + const head = curve(1); + const tail = curve(0); + if (head > tail) { + const diff = head - tail; + return p => (curve(p) - tail) / diff; + } else if (head < tail) { + const diff = tail - head; + return p => (curve(p) - head) / diff; + } else { + return curve; + } +} + +//#endregion + +//#region 内置曲线 + +export const enum CurveMode { + /** 缓进快出 */ + EaseIn, + /** 快进缓出 */ + EaseOut, + /** 缓进缓出,中间快 */ + EaseInOut, + /** 快进快出,中间缓 */ + EaseCenter +} + +/** 输入缓进快出,输出缓进快出 */ +function easeIn(curve: ExcitationCurve): ExcitationCurve { + return curve; +} + +/** 输入缓进快出,输出快进缓出 */ +function easeOut(curve: ExcitationCurve): ExcitationCurve { + return p => 1 - curve(1 - p); +} + +/** 输入缓进快出,输出缓进缓出,中间快 */ +function easeInOut(curve: ExcitationCurve): ExcitationCurve { + return p => (p < 0.5 ? curve(p * 2) * 0.5 : 1 - curve((1 - p) * 2) * 0.5); +} + +/** 输入缓进快出,输出快进快出,中间缓 */ +function easeCenter(curve: ExcitationCurve): ExcitationCurve { + return p => + p < 0.5 + ? 0.5 - curve(1 - p * 2) * 0.5 + : 0.5 + curve((p - 0.5) * 2) * 0.5; +} + +/** + * 实施曲线模式,传入曲线的缓进快出模式,根据传入的参数输出对应的模式 + * - `CurveMode.EaseIn`: `g(x) = f(x)` + * - `CurveMode.EaseOut`: `g(x) = 1 - f(1 - x)` + * - `CurveMode.EaseInOut`: `g(x) = 0.5 * f(2x) if x < 0.5 else 1 - 0.5 * f(2 - 2x)` + * - `CurveMode.EaseCenter`: `g(x) = 0.5 - 0.5 * f(1 - 2x) if x < 0.5 else 0.5 + 0.5 * f(2x - 1)` + * @param func 速率曲线 + * @param mode 曲线模式 + * @returns + */ +export function applyCurveMode(func: ExcitationCurve, mode: CurveMode) { + switch (mode) { + case CurveMode.EaseIn: + return easeIn(func); + case CurveMode.EaseOut: + return easeOut(func); + case CurveMode.EaseInOut: + return easeInOut(func); + case CurveMode.EaseCenter: + return easeCenter(func); + } +} + +/** f(x) = 1 - cos(x * pi/2), x∈[0,1], f(x)∈[0,1] */ +const sinfunc: ExcitationCurve = p => 1 - Math.cos((p * Math.PI) / 2); + +/** + * 正弦速率曲线,EaseIn: `f(x) = 1 - cos(x * pi/2), x∈[0,1], f(x)∈[0,1]` + * @param mode 曲线模式 + */ +export function sin(mode: CurveMode = CurveMode.EaseIn): ExcitationCurve { + return applyCurveMode(sinfunc, mode); +} + +/** f(x) = tan(x * pi/4), x∈[0,1], f(x)∈[0,1] */ +const tanfunc: ExcitationCurve = p => Math.tan((p * Math.PI) / 4); + +/** + * 正切速率曲线,EaseIn: `f(x) = tan(x * pi/4), x∈[0,1], f(x)∈[0,1]` + * @param mode 曲线模式 + */ +export function tan(mode: CurveMode = CurveMode.EaseIn): ExcitationCurve { + return applyCurveMode(tanfunc, mode); +} + +/** f(x) = sec(x * pi/3) - 1, x∈[0,1], f(x)∈[0,1] */ +const secfunc: ExcitationCurve = p => 1 / Math.cos((p * Math.PI) / 3) - 1; + +/** + * 正割速率曲线,EaseIn: `f(x) = sec(x * pi/3)-1, x∈[0,1], f(x)∈[0,1]` + * @param mode 曲线模式 + */ +export function sec(mode: CurveMode = CurveMode.EaseIn): ExcitationCurve { + return applyCurveMode(secfunc, mode); +} + +/** + * 幂函数速率曲线,EaseIn: `f(x) = x ** n, x∈[0,1], f(x)∈[0,1]` + * @param exp 指数 + * @param mode 曲线模式 + */ +export function pow( + exp: number, + mode: CurveMode = CurveMode.EaseIn +): ExcitationCurve { + // f(x) = x ** n, x∈[0,1], f(x)∈[0,1] + const powfunc: ExcitationCurve = p => Math.pow(p, exp); + return applyCurveMode(powfunc, mode); +} + +/** + * 双曲余弦速率曲线,EaseIn: `f(x) = (cosh(x * k) - 1) / (cosh(k) - 1), x∈[0,1], f(x)∈[0,1]` + * @param k 比例参数 + * @param mode 曲线模式 + */ +export function cosh( + k: number = 2, + mode: CurveMode = CurveMode.EaseIn +): ExcitationCurve { + // f(x) = (cosh(x * k) - 1) / cosh(k), x∈[0,1], f(x)∈[0,1] + const s = Math.cosh(k) - 1; + const coshfunc: ExcitationCurve = p => (Math.cosh(p * k) - 1) / s; + return applyCurveMode(coshfunc, mode); +} + +/** + * 双曲正切速率曲线,EaseIn: `f(x) = 1 + tanh((x - 1) * k) / tanh(k), x∈[0,1], f(x)∈[0,1]` + * @param k 比例参数 + * @param mode 曲线模式 + */ +export function tanh( + k: number = 2, + mode: CurveMode = CurveMode.EaseIn +): ExcitationCurve { + // f(x) = 1 + tanh((x - 1) * k) / tanh(k), x∈[0,1], f(x)∈[0,1] + const s = Math.tanh(k); + const tanhfunc: ExcitationCurve = p => 1 + Math.tanh((p - 1) * k) / s; + return applyCurveMode(tanhfunc, mode); +} + +/** + * 双曲正割速率曲线,EaseIn: `f(x) = 1 - sech(x * k) / sech(k), x∈[0,1], f(x)∈[0,1]` + * @param k 比例参数 + * @param mode 曲线模式 + */ +export function sech( + k: number = 2, + mode: CurveMode = CurveMode.EaseIn +): ExcitationCurve { + // f(x) = 1 - sech(x * k) / sech(k), x∈[0,1], f(x)∈[0,1] + // sech(x) = 1 / cosh(x) + const s = 1 / Math.cosh(k); + const sechfunc: ExcitationCurve = p => 1 - 1 / Math.cosh(p * k) / s; + return applyCurveMode(sechfunc, mode); +} + +/** + * 常数速率曲线,`f(x) = b, x∈[0,1], f(x)∈R` + * @param k 常数值 + */ +export function constant(k: number): ExcitationCurve { + return _ => k; +} + +/** + * 线性速率曲线,`f(x) = x, x∈[0,1], f(x)∈[0,1]` + */ +export function linear(): ExcitationCurve { + return p => p; +} + +/** + * 阶梯速率曲线,`f(x) = floor(x * k) / k, x∈[0,1], f(x)∈[0,1]` + * @param k 阶梯参数 + */ +export function step(k: number): ExcitationCurve { + // f(x) = floor(x * k) / k, x∈[0,1], f(x)∈[0,1] + return p => Math.floor(p * k) / k; +} + +//#endregion diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index dc76f8e..c72445f 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -49,6 +49,8 @@ "47": "Cannot require text area outside the target map.", "48": "Cannot $1 excitables on destroyed excitation.", "49": "Cannot $1 on variator without excitation binding.", + "50": "Expected a planEnd call after animation plan calling.", + "51": "Animatable object cannot be animated by plans with the same start time.", "1201": "Floor-damage extension needs 'floor-binder' extension as dependency." }, "warn": { diff --git a/packages/common/src/utils/func.ts b/packages/common/src/utils/func.ts index b25856b..527590d 100644 --- a/packages/common/src/utils/func.ts +++ b/packages/common/src/utils/func.ts @@ -1,3 +1,22 @@ +/** + * 创建一个等待指定时间长度的 `Promise` + * @param time 等待时间 + * @example await sleep(1000); + */ export function sleep(time: number) { return new Promise(res => setTimeout(res, time)); } + +/** + * 对序列依次求和,结果为一个数组,每一项的值为该项及其前面所有项的和 + * @param seq 数字序列 + * @example cumsum([1, 2, 3, 4]); // [1, 3, 6, 10] + */ +export function cumsum(seq: Iterable): number[] { + const result: number[] = []; + let now = 0; + for (const ele of seq) { + result.push((now += ele)); + } + return result; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3605ce..a24bd39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: vitepress-plugin-mermaid: specifier: ^2.0.17 version: 2.0.17(mermaid@11.12.3)(vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(async-validator@4.2.5)(axios@1.13.6)(less@4.5.1)(markdown-it-mathjax3@4.3.2(encoding@0.1.13))(postcss@8.5.8)(search-insights@2.17.3)(terser@5.46.0)(typescript@5.9.3)) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0) vue-tsc: specifier: ^2.2.12 version: 2.2.12(typescript@5.9.3) @@ -2107,6 +2110,9 @@ packages: '@simonwep/pickr@1.8.2': resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@ts-graphviz/adapter@2.0.6': resolution: {integrity: sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q==} engines: {node: '>=18'} @@ -2144,6 +2150,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2240,6 +2249,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2414,6 +2426,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.2.25 + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@volar/language-core@2.4.15': resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} @@ -2718,6 +2759,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-module-types@6.0.1: resolution: {integrity: sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==} engines: {node: '>=18'} @@ -2948,6 +2993,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3553,6 +3602,9 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3693,6 +3745,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3715,6 +3770,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -4771,6 +4830,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ogg-opus-decoder@1.7.3: resolution: {integrity: sha512-w47tiZpkLgdkpa+34VzYD8mHUj8I9kfWVZa82mBbNwDvB1byfLXSSzW/HxA4fI3e9kVlICSpXGFwMLV1LPdjwg==} @@ -5440,6 +5502,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -5502,10 +5567,16 @@ packages: resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -5659,6 +5730,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -5667,6 +5741,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -5981,6 +6059,40 @@ packages: postcss: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -6073,6 +6185,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wicked-good-xpath@1.3.0: resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} @@ -7816,6 +7933,8 @@ snapshots: core-js: 3.48.0 nanopop: 2.4.2 + '@standard-schema/spec@1.1.0': {} + '@ts-graphviz/adapter@2.0.6': dependencies: '@ts-graphviz/common': 2.1.5 @@ -7863,6 +7982,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.15 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.15 @@ -7984,6 +8108,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -8214,6 +8340,45 @@ snapshots: vite: 7.3.1(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0) vue: 3.5.29(typescript@5.9.3) + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@volar/language-core@2.4.15': dependencies: '@volar/source-map': 2.4.15 @@ -8619,6 +8784,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-module-types@6.0.1: {} async-function@1.0.0: {} @@ -8875,6 +9042,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -9574,6 +9743,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -9798,6 +9969,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -9814,6 +9989,8 @@ snapshots: events@3.3.0: {} + expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} express@5.2.1: @@ -10978,6 +11155,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + ogg-opus-decoder@1.7.3: dependencies: '@wasm-audio-decoders/common': 9.0.7 @@ -11802,6 +11981,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -11854,8 +12035,12 @@ snapshots: dependencies: minipass: 7.1.3 + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -12067,6 +12252,8 @@ snapshots: through@2.3.8: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -12074,6 +12261,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -12445,6 +12634,43 @@ snapshots: - typescript - universal-cookie + vitest@4.0.18(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.15)(less@4.5.1)(terser@5.46.0)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -12573,6 +12799,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wicked-good-xpath@1.3.0: {} word-wrap@1.2.5: {}