@eva/plugin-tween 2.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/Tween.ts +206 -0
- package/lib/TweenSystem.ts +9 -0
- package/lib/easing.ts +48 -0
- package/lib/index.ts +4 -0
- package/lib/path.ts +75 -0
- package/lib/types.ts +37 -0
- package/package.json +22 -0
package/lib/Tween.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Component, decorators } from '@eva/eva.js';
|
|
2
|
+
import { getSignalBus } from '@eva/plugin-signal-bus';
|
|
3
|
+
import { Easing } from './easing';
|
|
4
|
+
import { bindPath, PathBinding } from './path';
|
|
5
|
+
import type { TweenParams, TweenStep, EasingName } from './types';
|
|
6
|
+
|
|
7
|
+
interface RunningStep {
|
|
8
|
+
binding: PathBinding;
|
|
9
|
+
from: number;
|
|
10
|
+
to: number;
|
|
11
|
+
duration: number;
|
|
12
|
+
easing: (t: number) => number;
|
|
13
|
+
delay: number;
|
|
14
|
+
elapsed: number;
|
|
15
|
+
done: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tween 组件 — Godot Tween 等价物。
|
|
20
|
+
*
|
|
21
|
+
* 与 plugin-transition 区别:
|
|
22
|
+
* - 不限制 target 类型(transform/Component/store 都行)
|
|
23
|
+
* - 内置 sequence 与 parallel 编排
|
|
24
|
+
* - 完成 emit 命名信号
|
|
25
|
+
*
|
|
26
|
+
* DSL 用法:
|
|
27
|
+
* ```json
|
|
28
|
+
* {
|
|
29
|
+
* "type": "Tween",
|
|
30
|
+
* "props": {
|
|
31
|
+
* "step": {
|
|
32
|
+
* "target": "components.RocketAimer.currentAngleDeg",
|
|
33
|
+
* "from": -60, "to": 60, "duration": 1500, "easing": "easeInOutQuad"
|
|
34
|
+
* },
|
|
35
|
+
* "yoyo": true, "loop": -1, "autostart": true
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
@decorators.componentObserver({})
|
|
41
|
+
export class Tween extends Component<TweenParams> {
|
|
42
|
+
static componentName = 'Tween';
|
|
43
|
+
|
|
44
|
+
private steps: TweenStep[] = [];
|
|
45
|
+
private parallel = false;
|
|
46
|
+
private yoyo = false;
|
|
47
|
+
private loop = 0;
|
|
48
|
+
private autostart = false;
|
|
49
|
+
private signal?: string;
|
|
50
|
+
|
|
51
|
+
private running: RunningStep[] = [];
|
|
52
|
+
private isPlaying = false;
|
|
53
|
+
private playedLoops = 0;
|
|
54
|
+
private cursor = 0; // sequence 当前 step
|
|
55
|
+
private direction: 1 | -1 = 1;
|
|
56
|
+
|
|
57
|
+
init(params?: TweenParams) {
|
|
58
|
+
if (!params) return;
|
|
59
|
+
this.steps = params.step ? [params.step] : (params.steps ?? []);
|
|
60
|
+
this.parallel = params.parallel ?? false;
|
|
61
|
+
this.yoyo = params.yoyo ?? false;
|
|
62
|
+
this.loop = params.loop ?? 0;
|
|
63
|
+
this.autostart = params.autostart ?? false;
|
|
64
|
+
this.signal = params.signal;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
awake() {
|
|
68
|
+
if (this.autostart) this.play();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 重新开始 */
|
|
72
|
+
play() {
|
|
73
|
+
this.buildRunning();
|
|
74
|
+
this.isPlaying = true;
|
|
75
|
+
this.playedLoops = 0;
|
|
76
|
+
this.cursor = 0;
|
|
77
|
+
this.direction = 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pause() {
|
|
81
|
+
this.isPlaying = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resume() {
|
|
85
|
+
if (this.running.length) this.isPlaying = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
stop() {
|
|
89
|
+
this.isPlaying = false;
|
|
90
|
+
this.running = [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private buildRunning() {
|
|
94
|
+
this.running = [];
|
|
95
|
+
for (const s of this.steps) {
|
|
96
|
+
const binding = bindPath(this.gameObject, s.target);
|
|
97
|
+
if (!binding) {
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.warn(`[plugin-tween] binding miss: ${s.target}`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const from = s.from ?? binding.read();
|
|
103
|
+
this.running.push({
|
|
104
|
+
binding,
|
|
105
|
+
from,
|
|
106
|
+
to: s.to,
|
|
107
|
+
duration: Math.max(1, s.duration),
|
|
108
|
+
easing: Easing[(s.easing ?? 'linear') as EasingName] ?? Easing.linear,
|
|
109
|
+
delay: s.delay ?? 0,
|
|
110
|
+
elapsed: 0,
|
|
111
|
+
done: false,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
update(e: { deltaTime: number }) {
|
|
117
|
+
if (!this.isPlaying || this.running.length === 0) return;
|
|
118
|
+
const dt = e.deltaTime;
|
|
119
|
+
|
|
120
|
+
if (this.parallel) {
|
|
121
|
+
this.advanceParallel(dt);
|
|
122
|
+
} else {
|
|
123
|
+
this.advanceSequence(dt);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private advanceParallel(dt: number) {
|
|
128
|
+
let allDone = true;
|
|
129
|
+
for (const r of this.running) {
|
|
130
|
+
if (r.done) continue;
|
|
131
|
+
this.tickStep(r, dt);
|
|
132
|
+
if (!r.done) allDone = false;
|
|
133
|
+
}
|
|
134
|
+
if (allDone) this.onCycleEnd();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private advanceSequence(dt: number) {
|
|
138
|
+
while (dt > 0 && this.cursor < this.running.length) {
|
|
139
|
+
const r = this.running[this.cursor];
|
|
140
|
+
if (r.done) {
|
|
141
|
+
this.cursor++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const before = r.elapsed;
|
|
145
|
+
this.tickStep(r, dt);
|
|
146
|
+
const used = r.elapsed - before;
|
|
147
|
+
dt -= used;
|
|
148
|
+
if (!r.done) break;
|
|
149
|
+
this.cursor++;
|
|
150
|
+
}
|
|
151
|
+
if (this.cursor >= this.running.length) this.onCycleEnd();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private tickStep(r: RunningStep, dt: number) {
|
|
155
|
+
if (r.delay > 0) {
|
|
156
|
+
const used = Math.min(r.delay, dt);
|
|
157
|
+
r.delay -= used;
|
|
158
|
+
// 把延时算进 elapsed 不合适,留给 sequence 减 dt
|
|
159
|
+
// 这里直接消耗 dt 后返回
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
r.elapsed += dt;
|
|
163
|
+
const t = Math.min(1, r.elapsed / r.duration);
|
|
164
|
+
const v = r.from + (r.to - r.from) * r.easing(t);
|
|
165
|
+
r.binding.write(v);
|
|
166
|
+
if (t >= 1) {
|
|
167
|
+
r.done = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private onCycleEnd() {
|
|
172
|
+
this.playedLoops++;
|
|
173
|
+
const shouldLoop = this.loop === -1 || this.playedLoops <= this.loop;
|
|
174
|
+
if (this.yoyo) {
|
|
175
|
+
this.direction = (this.direction === 1 ? -1 : 1) as 1 | -1;
|
|
176
|
+
// yoyo:反向重置
|
|
177
|
+
for (const r of this.running) {
|
|
178
|
+
const a = r.from;
|
|
179
|
+
r.from = r.to;
|
|
180
|
+
r.to = a;
|
|
181
|
+
r.elapsed = 0;
|
|
182
|
+
r.done = false;
|
|
183
|
+
}
|
|
184
|
+
this.cursor = 0;
|
|
185
|
+
if (!shouldLoop && this.direction === 1) {
|
|
186
|
+
this.finish();
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (shouldLoop) {
|
|
191
|
+
for (const r of this.running) {
|
|
192
|
+
r.elapsed = 0;
|
|
193
|
+
r.done = false;
|
|
194
|
+
}
|
|
195
|
+
this.cursor = 0;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.finish();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private finish() {
|
|
202
|
+
this.isPlaying = false;
|
|
203
|
+
if (this.signal) getSignalBus().emit(this.signal, { component: this });
|
|
204
|
+
getSignalBus().emit('tween:finish', { component: this });
|
|
205
|
+
}
|
|
206
|
+
}
|
package/lib/easing.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EasingName } from './types';
|
|
2
|
+
|
|
3
|
+
const PI = Math.PI;
|
|
4
|
+
const c1 = 1.70158;
|
|
5
|
+
const c2 = c1 * 1.525;
|
|
6
|
+
const c3 = c1 + 1;
|
|
7
|
+
const c4 = (2 * PI) / 3;
|
|
8
|
+
const c5 = (2 * PI) / 4.5;
|
|
9
|
+
const n1 = 7.5625;
|
|
10
|
+
const d1 = 2.75;
|
|
11
|
+
|
|
12
|
+
function easeInBounce(x: number): number {
|
|
13
|
+
return 1 - easeOutBounce(1 - x);
|
|
14
|
+
}
|
|
15
|
+
function easeOutBounce(x: number): number {
|
|
16
|
+
if (x < 1 / d1) return n1 * x * x;
|
|
17
|
+
if (x < 2 / d1) return n1 * (x -= 1.5 / d1) * x + 0.75;
|
|
18
|
+
if (x < 2.5 / d1) return n1 * (x -= 2.25 / d1) * x + 0.9375;
|
|
19
|
+
return n1 * (x -= 2.625 / d1) * x + 0.984375;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const Easing: Record<EasingName, (t: number) => number> = {
|
|
23
|
+
linear: (t) => t,
|
|
24
|
+
easeInQuad: (t) => t * t,
|
|
25
|
+
easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
|
|
26
|
+
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
|
|
27
|
+
easeInCubic: (t) => t * t * t,
|
|
28
|
+
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
|
|
29
|
+
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
|
|
30
|
+
easeInElastic: (t) => (t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4)),
|
|
31
|
+
easeOutElastic: (t) => (t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1),
|
|
32
|
+
easeInOutElastic: (t) => {
|
|
33
|
+
if (t === 0) return 0;
|
|
34
|
+
if (t === 1) return 1;
|
|
35
|
+
return t < 0.5
|
|
36
|
+
? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2
|
|
37
|
+
: (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1;
|
|
38
|
+
},
|
|
39
|
+
easeInBack: (t) => c3 * t * t * t - c1 * t * t,
|
|
40
|
+
easeOutBack: (t) => 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2),
|
|
41
|
+
easeInOutBack: (t) =>
|
|
42
|
+
t < 0.5
|
|
43
|
+
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
|
44
|
+
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2,
|
|
45
|
+
easeInBounce,
|
|
46
|
+
easeOutBounce,
|
|
47
|
+
easeInOutBounce: (t) => (t < 0.5 ? (1 - easeOutBounce(1 - 2 * t)) / 2 : (1 + easeOutBounce(2 * t - 1)) / 2),
|
|
48
|
+
};
|
package/lib/index.ts
ADDED
package/lib/path.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Component, GameObject } from '@eva/eva.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 把字符串路径解析成 (target, key) 二元组,以便就地写入。
|
|
5
|
+
*
|
|
6
|
+
* 支持:
|
|
7
|
+
* - "transform.position.x"
|
|
8
|
+
* - "transform.rotation"
|
|
9
|
+
* - "components.<ComponentName>.<key>" // 通过 componentName 找
|
|
10
|
+
* - "store.<keyPath>" // 走 mx.store(若存在)
|
|
11
|
+
*/
|
|
12
|
+
export interface PathBinding {
|
|
13
|
+
read(): number;
|
|
14
|
+
write(v: number): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function bindPath(go: GameObject, path: string): PathBinding | null {
|
|
18
|
+
const parts = path.split('.');
|
|
19
|
+
if (parts[0] === 'transform') {
|
|
20
|
+
const obj = go.transform as any;
|
|
21
|
+
return diveIntoObject(obj, parts.slice(1));
|
|
22
|
+
}
|
|
23
|
+
if (parts[0] === 'components' && parts.length >= 3) {
|
|
24
|
+
const compName = parts[1];
|
|
25
|
+
const comp = findComponent(go, compName);
|
|
26
|
+
if (!comp) return null;
|
|
27
|
+
return diveIntoObject(comp as any, parts.slice(2));
|
|
28
|
+
}
|
|
29
|
+
if (parts[0] === 'store' && parts.length >= 2) {
|
|
30
|
+
return bindStore(parts.slice(1).join('.'));
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function diveIntoObject(root: any, keys: string[]): PathBinding | null {
|
|
36
|
+
if (!root || keys.length === 0) return null;
|
|
37
|
+
let parent = root;
|
|
38
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
39
|
+
const k = keys[i];
|
|
40
|
+
if (parent[k] == null) return null;
|
|
41
|
+
parent = parent[k];
|
|
42
|
+
}
|
|
43
|
+
const last = keys[keys.length - 1];
|
|
44
|
+
return {
|
|
45
|
+
read: () => Number(parent[last] ?? 0),
|
|
46
|
+
write: (v: number) => {
|
|
47
|
+
parent[last] = v;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findComponent(go: GameObject, name: string): Component | null {
|
|
53
|
+
const comps: any[] = (go as any).components || [];
|
|
54
|
+
for (const c of comps) {
|
|
55
|
+
const cn = c?.constructor?.componentName;
|
|
56
|
+
if (cn === name) return c;
|
|
57
|
+
}
|
|
58
|
+
// 退路:Eva.js 部分版本支持 getComponent(name)
|
|
59
|
+
if (typeof (go as any).getComponent === 'function') {
|
|
60
|
+
return ((go as any).getComponent(name) as Component) ?? null;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bindStore(key: string): PathBinding | null {
|
|
66
|
+
// mx.store 是宿主全局,不强依赖
|
|
67
|
+
const mx: any = (globalThis as any).mx;
|
|
68
|
+
if (!mx?.store) return null;
|
|
69
|
+
return {
|
|
70
|
+
read: () => Number(mx.store.get?.(key) ?? 0),
|
|
71
|
+
write: (v: number) => {
|
|
72
|
+
mx.store.update?.({ [key]: v });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type EasingName =
|
|
2
|
+
| 'linear'
|
|
3
|
+
| 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad'
|
|
4
|
+
| 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic'
|
|
5
|
+
| 'easeInElastic' | 'easeOutElastic' | 'easeInOutElastic'
|
|
6
|
+
| 'easeInBack' | 'easeOutBack' | 'easeInOutBack'
|
|
7
|
+
| 'easeInBounce' | 'easeOutBounce' | 'easeInOutBounce';
|
|
8
|
+
|
|
9
|
+
export interface TweenStep {
|
|
10
|
+
/** 目标路径,例如:
|
|
11
|
+
* - "transform.position.x"
|
|
12
|
+
* - "transform.rotation"
|
|
13
|
+
* - "components.RocketAimer.currentAngleDeg"
|
|
14
|
+
* - "store.score" (走 mx.store)
|
|
15
|
+
*/
|
|
16
|
+
target: string;
|
|
17
|
+
from?: number;
|
|
18
|
+
to: number;
|
|
19
|
+
duration: number;
|
|
20
|
+
easing?: EasingName;
|
|
21
|
+
delay?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TweenParams {
|
|
25
|
+
/** 单步 = 一次性 to;steps = 多步序列 */
|
|
26
|
+
step?: TweenStep;
|
|
27
|
+
steps?: TweenStep[];
|
|
28
|
+
/** 是否并行执行 steps(默认 sequence) */
|
|
29
|
+
parallel?: boolean;
|
|
30
|
+
yoyo?: boolean;
|
|
31
|
+
/** -1 = 无限循环 */
|
|
32
|
+
loop?: number;
|
|
33
|
+
/** 是否自动开始 */
|
|
34
|
+
autostart?: boolean;
|
|
35
|
+
/** 完成后 emit 的信号 */
|
|
36
|
+
signal?: string;
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eva/plugin-tween",
|
|
3
|
+
"version": "2.1.0-beta.1",
|
|
4
|
+
"description": "Godot Tween 等价物 — 任意字段补间(transform / 自定义 Component / mx.store),支持 sequence/parallel/yoyo/loop。",
|
|
5
|
+
"main": "lib/index.ts",
|
|
6
|
+
"module": "lib/index.ts",
|
|
7
|
+
"types": "lib/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"lib"
|
|
10
|
+
],
|
|
11
|
+
"keywords": [
|
|
12
|
+
"eva.js",
|
|
13
|
+
"plugin",
|
|
14
|
+
"tween",
|
|
15
|
+
"godot"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@eva/eva.js": "2.1.0-beta.1",
|
|
20
|
+
"@eva/plugin-signal-bus": "2.1.0-beta.1"
|
|
21
|
+
}
|
|
22
|
+
}
|