hzengine-core
Version:
An Visual Novel Engine for Zepp OS
543 lines (497 loc) • 16.6 kB
text/typescript
/**
* animation.js
* @description A library for providing multi-track animations for HZEngine. 一个用于在HZEngine中提供简单动画的库
* @date 2024/10/2
* @author CuberQAQ
*/
export class Animation<P extends Profile.PropsType = unknown> {
normalizedProfile: Profile.Track<P>;
initProps: Partial<P> = {};
activeRootTrack: ActiveTrackNode<P> | null = null;
lastTime: number = 0;
public onFrame: ((props: Partial<P>) => void) | null = null;
public onEnd: (() => void) | null = null;
customWrappers: Record<string, Wrapper> = {};
constructor(profile: Profile<P>, options?: Animation.Options<P>) {
this.normalizedProfile = Profile.normalize(profile);
console.log(`Animation: ${JSON.stringify(profile)}`);
if (options) {
options.initProps && (this.initProps = options.initProps);
options.wrappers && (this.customWrappers = options.wrappers);
}
}
public onStop: ((animation: Animation<P>) => void) | null = null;
serialize(): Animation.Serialized {
return {
normalizedProfile: this.normalizedProfile,
initProps: this.initProps,
activeRootTrack: this.activeRootTrack?.serialize(),
lastTime: this.lastTime,
};
}
static unserialize(
serialized: Animation.Serialized,
wrappers?: Record<string, Wrapper>
): Animation<unknown> {
let profile = serialized.normalizedProfile;
let animation = new Animation(profile, {
initProps: serialized.initProps,
wrappers,
});
animation.lastTime = serialized.lastTime;
serialized.activeRootTrack &&
(animation.activeRootTrack = ActiveTrackNode.unserialize(
animation,
serialized.activeRootTrack
));
return animation;
}
timerCb(): void {}
init(): void {
this.activeRootTrack = new ActiveTrackNode(this, {
accessKeys: [],
index: 0,
initProps: this.initProps,
});
}
goto(time: number) {
if (!this.activeRootTrack) {
this.init();
}
if (time < 0) time = 0;
if (time < this.lastTime) {
this.init(); // reset the tracknode
try {
this.activeRootTrack!.step(time);
} catch (e) {
console.log(e);
this.activeRootTrack!.status = ActiveTrackNode.Status.Pause;
}
if (this.activeRootTrack!.status === ActiveTrackNode.Status.Pause) {
if (this.onEnd) this.onEnd();
}
} else {
if (this.activeRootTrack!.status !== ActiveTrackNode.Status.Pause) {
try {
this.activeRootTrack!.step(time - this.lastTime);
} catch (e) {
console.log(e);
this.activeRootTrack!.status = ActiveTrackNode.Status.Pause;
}
}
}
this.lastTime = time;
if (this.onFrame) {
// console.log("on frame", JSON.stringify(this.calcProps()));
this.onFrame(this.calcProps());
}
if (this.activeRootTrack!.status === ActiveTrackNode.Status.Pause) {
if (this.onEnd) this.onEnd();
}
}
step(delta_time: number) {
this.goto(this.lastTime + delta_time);
}
calcProps(): Partial<P> {
return this.activeRootTrack!.calcProps();
}
reverse(): void {}
stop(): void {}
_accessProfile(accessKeys: string[]): unknown {
let section: any = this.normalizedProfile;
for (let i = 0; i < accessKeys.length; i++) {
section = section[accessKeys[i]];
}
return section;
}
_getWrapper(wrapper: string | Wrapper | undefined): Wrapper {
if (typeof wrapper === "string") {
return (
this.customWrappers[wrapper] ??
buildInWrappers[wrapper as keyof typeof buildInWrappers] ??
buildInWrappers.none
);
} else if (wrapper === undefined) {
return buildInWrappers.none;
} else return wrapper;
}
_calcWrappedProps<P extends Profile.PropsType>(
former_props: Partial<P>,
props: Partial<P>,
wrapper: Wrapper | string | undefined,
progress: number
): Partial<P> {
let _former_props: any = { ...former_props };
let wrapper_func = this._getWrapper(wrapper);
let wraped_prog = wrapper_func(progress);
let res = { ..._former_props };
for (let key in props) {
if (typeof props[key] === "number") {
if (_former_props[key] == undefined) _former_props[key] = 0;
res[key] =
_former_props[key] + wraped_prog * ((props[key] as any) - _former_props[key]);
} else if (typeof props[key] === "boolean") {
if (_former_props[key] == undefined) _former_props[key] = false;
res[key] = wraped_prog === 1 ? props[key] : _former_props[key];
} else if (typeof props[key] === "string") {
if (_former_props[key] == undefined) _former_props[key] = "";
res[key] = wraped_prog === 1 ? props[key] : _former_props[key];
} else {
res[key] = props[key];
}
}
return res;
}
}
export namespace Animation {
export interface Options<P extends Profile.PropsType> {
// @ts-ignore
initProps?: Partial<P>;
wrappers?: Record<string, Wrapper>;
}
export interface Serialized {
normalizedProfile: Profile.Serialized;
initProps?: Partial<unknown>;
activeRootTrack?: ActiveTrackNode.Serialized;
lastTime: number;
}
}
export type Profile<P extends Profile.PropsType = unknown> =
| Profile.Section<P>
| Profile.Track<P>
| Profile.TrackList<P>;
export namespace Profile {
export type Serialized = Profile;
export type TrackList<P extends PropsType> = Track<P>[];
export type Track<P extends PropsType> = Section<P>[];
export type Section<P extends PropsType> = {
time?: number;
repeat?: number;
wrapper?: keyof typeof buildInWrappers | string | Wrapper;
frame?: Partial<P>;
syncs?: Profile<P>[];
asyncs?: Profile<P>[];
};
export type FrameItem = number | boolean | string;
export type PropsType = Record<string, FrameItem> | unknown;
export function normalize<P extends PropsType>(
profile: Profile<P>
): Track<P> {
if (!Array.isArray(profile)) {
// section
return [profile];
} else if (profile.length === 0) {
// empty track/tracklist
return [];
} else if (!Array.isArray(profile[0])) {
// track
return profile as Track<P>;
} else {
// tracklist
return [
{
syncs: profile,
},
];
}
}
}
export interface Timer<TimerIdType> {
init(animation: Animation): void;
// 回調時調用animation.timerCallback
setTimeout(timeout?: number): TimerIdType;
clearTimeout(timerId: TimerIdType): void;
setInterval(interval?: number): TimerIdType;
clearInterval(timerId: TimerIdType): void;
getTime(): number; // in ms
setOption?(option: Timer.Options): void;
}
export namespace Timer {
export interface Options {
fps?: number;
}
}
export class ActiveTrackNode<P extends Profile.PropsType> {
accessKeys: string[];
currentIndex: number | null;
repeated: number = 0;
status: ActiveTrackNode.Status = ActiveTrackNode.Status.Playing;
now_time: number = 0;
#currentSection: Profile.Section<P> | null = null;
#currentIndex: number | null = null;
get currentSection() {
if (this.#currentSection === null) {
if (this.currentIndex === null) {
this.#currentSection = this.animation._accessProfile(
this.accessKeys
) as Profile.Section<P>;
} else {
this.#currentSection = this.currentTrack![this.currentIndex!];
}
this.#currentIndex = this.currentIndex;
} else if (this.currentIndex !== this.#currentIndex) {
this.#currentSection = this.currentTrack![this.currentIndex!];
this.#currentIndex = this.currentIndex;
}
return this.#currentSection;
}
#currentTrack: Profile.Track<P> | null = null;
get currentTrack() {
if (this.#currentTrack === null && this.currentIndex !== null) {
this.#currentTrack = this.animation._accessProfile(
this.accessKeys
) as Profile.Track<P>;
}
return this.#currentTrack;
}
lastProps: Partial<P> = {};
nowProps: Partial<P> = {};
constructor(
public animation: Animation,
{
accessKeys,
index,
initProps,
}: {
accessKeys: string[];
index: number | null;
initProps: Partial<P>;
}
) {
this.accessKeys = accessKeys;
this.currentIndex = index;
this.lastProps = { ...initProps };
this.nowProps = { ...initProps };
}
serialize(): ActiveTrackNode.Serialized {
return {
accessKeys: this.accessKeys,
currentIndex: this.currentIndex,
repeated: this.repeated,
status: this.status,
now_time: this.now_time,
lastProps: this.lastProps,
nowProps: this.nowProps,
activeSyncs: this.activeSyncs.map((sync) => sync.serialize()),
};
}
static unserialize<P>(
animation: Animation,
serialized: ActiveTrackNode.Serialized
): ActiveTrackNode<P> {
let node = new ActiveTrackNode(animation, {
accessKeys: serialized.accessKeys,
index: serialized.currentIndex,
initProps: serialized.lastProps,
});
node.lastProps = serialized.lastProps;
node.nowProps = serialized.nowProps;
node.status = serialized.status;
node.repeated = serialized.repeated;
node.now_time = serialized.now_time;
node.activeSyncs = serialized.activeSyncs.map((sync) =>
ActiveTrackNode.unserialize<P>(animation, sync)
);
return node;
}
/**
*
* @param delta_time
* @returns 剩餘的時間
*/
step(delta_time: number): number {
// console.log(`node step: ${delta_time}`);
// if (this.status === ActiveTrackNode.Status.Pause) return delta_time;
// because we should proceed async list
// normal
if(!this.currentSection) {
this.status = ActiveTrackNode.Status.Pause;
return delta_time;
}
while (true) {
// constant field: time, wrapper, frame
// instant filed: syncs, asyncs
if (this.status === ActiveTrackNode.Status.Playing) {
if (
this.currentSection.time === undefined ||
this.currentSection.time <= 0
) {
this.nowProps = { ...this.nowProps, ...this.currentSection.frame };
} else {
if (this.now_time + delta_time >= this.currentSection.time) {
// this.now_time = this.currentSection.time
delta_time -= this.currentSection.time - this.now_time;
this.nowProps = { ...this.nowProps, ...this.currentSection.frame };
} else {
this.now_time += delta_time;
delta_time = 0;
// calc
let progress = this.now_time / this.currentSection.time;
// console.log(`progress: ${progress}`);
this.nowProps = this.animation._calcWrappedProps(
this.lastProps,
this.currentSection.frame ?? {},
this.currentSection.wrapper,
progress
);
break;
}
}
// sync async
if (this.currentSection.syncs && this.currentSection.syncs.length > 0) {
this.initSyncs();
this.status = ActiveTrackNode.Status.WaitingSyncs;
}
}
// step and check sync
if (this.status === ActiveTrackNode.Status.WaitingSyncs) {
delta_time = this.stepSyncs(delta_time); // returns lefted time
this.nowProps = Object.assign(this.nowProps, this.getSyncsProps());
if (this.checkSyncsFinished()) {
this.status = ActiveTrackNode.Status.Playing;
this.activeSyncs = [];
} else {
// debug
if (delta_time !== 0)
throw "syncs not finished while lefted time != 0. may be bugs";
break;
}
}
// check repeat , finish
if (this.currentSection.repeat !== undefined) {
this.repeated++;
if (this.repeated >= this.currentSection.repeat) {
// finish and proceed next section
// TODO
if (this._switchNextSection()) continue;
else break;
} else {
// continue this section
this.now_time = 0;
this.nowProps = { ...this.lastProps };
continue;
}
} else {
if (this._switchNextSection()) continue;
else break;
}
}
// waiting syncs
return delta_time;
}
calcProps(): Partial<P> {
return this.nowProps;
}
_switchNextSection(): boolean {
if (
this.currentIndex === null ||
this.currentTrack!.length === this.currentIndex + 1
) {
// already reach the end
this.status = ActiveTrackNode.Status.Pause;
return false;
} else {
this.lastProps = { ...this.nowProps };
this.repeated = 0;
this.now_time = 0;
this.currentIndex++;
return true;
}
}
activeSyncs: ActiveTrackNode<P>[] = [];
initSyncs() {
// console.log("init syncs");
let activeSyncs: ActiveTrackNode<P>[] = [];
// if (this.currentSection.syncs == null) return;
let subprofile = this.currentSection.syncs!;
let activeTrackNode;
if (Array.isArray(subprofile)) {
if (subprofile.length > 0 && Array.isArray(subprofile[0])) {
// tracklist
for (let j = 0; j < subprofile.length; ++j) {
activeTrackNode = new ActiveTrackNode(this.animation, {
accessKeys: [
...this.accessKeys,
"" + this.currentIndex,
"syncs",
"" + j,
],
index: 0,
initProps: this.nowProps, // TODO 因为 lastProps在切换的时候才会更新,所以用nowProps
});
activeSyncs.push(activeTrackNode);
}
} else {
// Track
activeTrackNode = new ActiveTrackNode(this.animation, {
accessKeys: [...this.accessKeys, "" + this.currentIndex, "syncs"],
index: 0,
initProps: this.nowProps, // TODO 因为 lastProps在切换的时候才会更新,所以用nowProps
});
activeSyncs.push(activeTrackNode);
console
.log
// "create subtrack=" + JSON.stringify(activeTrackNode.serialize())
();
}
} else {
// section
activeTrackNode = new ActiveTrackNode(this.animation, {
accessKeys: [...this.accessKeys, "" + this.currentIndex, "syncs"],
index: null,
initProps: this.nowProps, // TODO 因为 lastProps在切换的时候才会更新,所以用nowProps
});
activeSyncs.push(activeTrackNode);
}
// activeTrackNode?.step(0); // TODO
this.activeSyncs = activeSyncs;
}
stepSyncs(delta_time: number): number {
let lefted_time = delta_time;
for (let i = 0; i < this.activeSyncs.length; ++i) {
lefted_time = Math.min(this.activeSyncs[i].step(delta_time), lefted_time);
}
return lefted_time;
}
checkSyncsFinished(): boolean {
for (let i = 0; i < this.activeSyncs.length; ++i) {
if (this.activeSyncs[i].status !== ActiveTrackNode.Status.Pause)
return false;
}
return true;
}
getSyncsProps() {
let res: Partial<P> = {};
for (let i = 0; i < this.activeSyncs.length; ++i) {
Object.assign(res, this.activeSyncs[i].calcProps());
}
return res;
}
}
export namespace ActiveTrackNode {
export enum Status {
Playing,
WaitingSyncs,
Pause,
}
export interface Serialized {
accessKeys: string[];
currentIndex: number | null;
repeated: number;
status: ActiveTrackNode.Status;
now_time: number;
lastProps: Partial<unknown>;
nowProps: Partial<unknown>;
activeSyncs: ActiveTrackNode.Serialized[];
}
}
// Map a number from [0, 1] (progress time percentage) to [0, 1] (animation parameter value percentage).
export type Wrapper = (x: number) => number;
const buildInWrappers = {
none: (x: number) => (x < 1 ? 0 : 1),
linear: (x: number) => x,
easeout: (x: number) => Math.pow(x, 3),
easein: (x: number) => 1 - Math.pow(1 - x, 3),
easeinout: (x: number) =>
x < 0.5 ? 0.5 * Math.pow(x, 3) : 1 - 0.5 * Math.pow(1 - x, 3),
};