@caperjs/plugin-rive 0.1.0

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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@caperjs/plugin-rive",
3
+ "version": "0.1.0",
4
+ "description": "Rive",
5
+ "files": [
6
+ "lib",
7
+ "src"
8
+ ],
9
+ "main": "lib/caper-plugin-rive.mjs",
10
+ "types": "lib/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "default": "./lib/caper-plugin-rive.mjs",
14
+ "types": "./lib/index.d.ts"
15
+ }
16
+ },
17
+ "sideEffects": false,
18
+ "keywords": [
19
+ "rive",
20
+ "rive.js",
21
+ "@caperjs/core",
22
+ "caper",
23
+ "animation",
24
+ "vector"
25
+ ],
26
+ "author": "",
27
+ "license": "MIT",
28
+ "devDependencies": {
29
+ "vite": "^8.0.7",
30
+ "vite-plugin-dts": "^4.5.3",
31
+ "@caperjs/core": "0.1.0"
32
+ },
33
+ "peerDependencies": {
34
+ "pixi.js": "8.19.0",
35
+ "@caperjs/core": "0.1.0"
36
+ },
37
+ "dependencies": {
38
+ "@rive-app/canvas-advanced-lite": "^2.26.1"
39
+ },
40
+ "scripts": {
41
+ "build": "pnpm run generate-version && vite build",
42
+ "test": "echo \"Error: no test specified\" && exit 1",
43
+ "generate-version": "node generateVersion.mjs"
44
+ }
45
+ }
@@ -0,0 +1,605 @@
1
+ import {
2
+ Artboard,
3
+ File,
4
+ LinearAnimationInstance,
5
+ Mat2D,
6
+ RiveCanvas,
7
+ SMIInput,
8
+ StateMachineInstance,
9
+ WrappedRenderer,
10
+ } from '@rive-app/canvas-advanced-lite';
11
+ import { Container, destroyCanvas, Logger, Signal, WithSignals } from '@caperjs/core';
12
+ import { Assets, FederatedPointerEvent, Sprite, Texture, Ticker } from 'pixi.js';
13
+ import { RivePlugin } from './RivePlugin';
14
+ import { Alignment, Fit, RiveOptions } from './types';
15
+
16
+ /**
17
+ * @see https://www.npmjs.com/package/pixi-rive
18
+ * RiveEntity component extended from Caper Container
19
+ * PIXI.Assets.add({ alias: 'vehicles', src: 'https://cdn.rive.app/animations/vehicles.riv' });
20
+ * const vehicles = new RiveEntity({ asset: 'vehicles', autoPlay: true });
21
+ * app.stage.addChild(vehicles);
22
+ *
23
+ * @param {Artboard} artboard current Rive Artboard instance
24
+ * @param {LinearAnimationInstance} animations current Animation instances
25
+ * @param {StateMachineInstance} stateMachines current Rive State Machine instances
26
+ * @param {Map<string, SMIInput>} inputFields current artboard input fields from all state machines
27
+ * @param {Function} onStateChange callback method for catching state machines changes
28
+ * @param {Fit} fit fit Rive component into container sizes (Contain by default)
29
+ * @param {Alignment} align align Rive component in container (Center by default)
30
+ * @param {number} maxWidth max width of sprite (original Rive artboard size will be used if maxWidth is not set)
31
+ * @param {number} maxHeight max height of sprite (original Rive artboard size will be used if maxHeight is not set)
32
+ */
33
+ export class RiveEntity extends WithSignals(Container) {
34
+ // signals
35
+ onStateChange: Signal<(states?: any) => void> = new Signal();
36
+ onReady: Signal<(rive?: RiveCanvas) => void> = new Signal();
37
+ onPlay: Signal<(animations: string | string[]) => void> = new Signal();
38
+ onStop: Signal<(animations: string | string[]) => void> = new Signal();
39
+ onPause: Signal<() => void> = new Signal();
40
+ onResume: Signal<() => void> = new Signal();
41
+ // TODO: Can't figure out how to implement this yet
42
+ // can't seem to get animation duration or loop values
43
+ // perhaps it's a bug?
44
+ // onLoop: Signal<() => void> = new Signal();
45
+ //
46
+ animations: LinearAnimationInstance[] = [];
47
+ stateMachines: StateMachineInstance[] = [];
48
+ inputFields: Map<string, SMIInput> = new Map();
49
+ artboard?: Artboard;
50
+ private _debug: boolean = false;
51
+ private _file?: File;
52
+ private _aligned?: Mat2D;
53
+ private _renderer?: WrappedRenderer;
54
+ private _canvas?: OffscreenCanvas | HTMLCanvasElement;
55
+ private _paused: boolean;
56
+ private _enabled: boolean = false;
57
+
58
+ /**
59
+ * Constructor will load Rive wasm if it not loaded yet
60
+ * and create instances of Rive scene components (artboard, animation, stateMachine)
61
+ * after initialize will call onReady method and run animation if autoPlay was setted
62
+ * @param {RiveOptions} options initial component options
63
+ */
64
+ constructor(public options: RiveOptions) {
65
+ super({ autoResize: false, autoUpdate: false, priority: -1 });
66
+ this._debug = options.debug ?? false;
67
+ this.initEvents(options!.interactive ?? false);
68
+ if (options.autoInit !== false) {
69
+ void this.initialize();
70
+ }
71
+ }
72
+
73
+ private _align: Alignment = 'center';
74
+
75
+ get align(): Alignment {
76
+ return this._align;
77
+ }
78
+
79
+ set align(value: Alignment) {
80
+ this._align = value;
81
+ }
82
+
83
+ private _fit: Fit = 'contain';
84
+
85
+ get fit(): Fit {
86
+ return this._fit;
87
+ }
88
+
89
+ set fit(value: Fit) {
90
+ this._fit = value;
91
+ this.updateSize();
92
+ }
93
+
94
+ private _maxWidth: number = 0;
95
+
96
+ get maxWidth(): number {
97
+ return this._maxWidth;
98
+ }
99
+
100
+ set maxWidth(value: number) {
101
+ this._maxWidth = value;
102
+ this.updateSize();
103
+ }
104
+
105
+ private _maxHeight: number = 0;
106
+
107
+ get maxHeight(): number {
108
+ return this._maxHeight;
109
+ }
110
+
111
+ set maxHeight(value: number) {
112
+ this._maxHeight = value;
113
+ this.updateSize();
114
+ }
115
+
116
+ private _rive?: RiveCanvas;
117
+
118
+ get rive(): RiveCanvas {
119
+ return this.plugin.rive;
120
+ }
121
+
122
+ private _view: Sprite;
123
+
124
+ get view(): Sprite {
125
+ return this._view;
126
+ }
127
+
128
+ get plugin(): RivePlugin {
129
+ return this.app.getPlugin(RivePlugin.ID);
130
+ }
131
+
132
+ async initialize() {
133
+ await this.initRive(this.options.asset).then(() => {
134
+ try {
135
+ this.loadArtboard(this.options.artboard);
136
+ this.loadStateMachine(this.options.stateMachine);
137
+ this.playAnimation(this.options.animation);
138
+
139
+ if (this.options.fit) {
140
+ this.fit = this.options.fit;
141
+ }
142
+ if (this.options.align) {
143
+ this.align = this.options.align;
144
+ }
145
+ if (this.options.maxWidth) {
146
+ this.maxWidth = this.options.maxWidth;
147
+ }
148
+ if (this.options.maxHeight) {
149
+ this.maxHeight = this.options.maxHeight;
150
+ }
151
+ if (this.options.maxHeight !== undefined || this.options.maxWidth !== undefined) {
152
+ this.updateSize();
153
+ }
154
+
155
+ if (this.options.autoPlay) {
156
+ this.enable();
157
+ } else this.update();
158
+ this.onReady.emit(this.rive);
159
+ } catch (error) {
160
+ console.error(error);
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Enable rive scene animation
167
+ */
168
+ enable(): void {
169
+ this._enabled = true;
170
+ if (this.rive) {
171
+ this.app.ticker.add(this.update);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Disable rive scene animation
177
+ */
178
+ disable(): void {
179
+ this._enabled = false;
180
+ if (this.rive) {
181
+ this.app.ticker.remove(this.update);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Load Rive scene artboard by name or load default artboard if name is not set
187
+ * Rive should be initialized before (RiveOptions.onReady was emited)
188
+ * @param {string|number} artboard name of the loading artboard
189
+ */
190
+ loadArtboard(artboard: string | undefined): void | Promise<void> {
191
+ if (this.artboard) {
192
+ this._destroyInternals();
193
+ this.options.artboard = artboard;
194
+ return this.initialize();
195
+ }
196
+ if (this._file && this._canvas) {
197
+ this.artboard = artboard ? this._file.artboardByName(artboard) : this._file.defaultArtboard();
198
+ if (!this._view) {
199
+ this._view = this.add.sprite({
200
+ asset: Texture.from(this._canvas, false),
201
+ anchor: this.options?.anchor ?? 0,
202
+ scale: this.options?.scale ?? 1,
203
+ layout: false,
204
+ });
205
+ } else {
206
+ this._view.texture.source.update();
207
+ }
208
+ }
209
+ this.updateSize();
210
+ }
211
+
212
+ /**
213
+ * Load Rive state machines by names or load first state machine
214
+ * Artbaord should be loaded before
215
+ * Will load first state machine if name is empty
216
+ * @param {string|number} machines name or names of the loading state machines
217
+ */
218
+ loadStateMachine(machines: string | string[] = []): void {
219
+ if (!this.artboard || !this.rive) return;
220
+ if (typeof machines === 'string') machines = [machines];
221
+ else if (!machines.length) {
222
+ const defaultMachine = this.artboard!.stateMachineByIndex(0);
223
+ machines = defaultMachine ? [defaultMachine.name] : [];
224
+ }
225
+ machines.map((name) => {
226
+ const machine = this.artboard!.stateMachineByName(name);
227
+ this.unloadStateMachine(name);
228
+ this.stateMachines.push(new this.rive!.StateMachineInstance(machine, this.artboard!));
229
+ });
230
+ this.initInputFields();
231
+ }
232
+
233
+ /**
234
+ * Unload state machine and destroy instance
235
+ * @param {string} name name of the state machine
236
+ */
237
+ unloadStateMachine(name: string): void {
238
+ this.stateMachines = this.stateMachines.filter((machine) => {
239
+ if (machine.name === name) {
240
+ machine.delete();
241
+ return false;
242
+ } else return true;
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Play Rive animation by name (artbaord should be loaded before)
248
+ * You can play only one timeline animation at the same time.
249
+ * If animation is looped or it's a pingpong, it will be repeated endlessly
250
+ * otherwise it plays only once
251
+ *
252
+ * TODO: add onStart/onEnd/onLoop methods for animation
253
+ *
254
+ * @param {string|number} animations animation name or array of nmaes
255
+ */
256
+ playAnimation(animations: string | string[] = []): void {
257
+ if (!this.artboard && !this.rive) return;
258
+ if (typeof animations === 'string') animations = [animations];
259
+ else if (!animations.length && !this.stateMachines.length) {
260
+ const defaultAnimation = this.artboard!.animationByIndex(0);
261
+ animations = defaultAnimation ? [defaultAnimation.name] : [];
262
+ }
263
+ animations.map((name) => {
264
+ const animation = this.artboard!.animationByName(name);
265
+ this.stopAnimation(name);
266
+
267
+ const anim = new this.rive!.LinearAnimationInstance(animation, this.artboard!);
268
+
269
+ this.animations.push(anim);
270
+ });
271
+
272
+ this.onPlay.emit(animations);
273
+ }
274
+
275
+ /**
276
+ * Stop current animation and destroy Rive animation instance
277
+ */
278
+ stopAnimation(animations: string | string[]): void {
279
+ const animationsToStop = Array.isArray(animations) ? animations : [animations];
280
+ this.animations = this.animations.filter((animation) => {
281
+ if (animationsToStop.includes(animation.name)) {
282
+ animation.delete();
283
+ return false;
284
+ } else return true;
285
+ });
286
+
287
+ this.onStop.emit(animations);
288
+ }
289
+
290
+ pause() {
291
+ this._paused = true;
292
+ this.onPause.emit();
293
+ }
294
+
295
+ resume() {
296
+ this._paused = false;
297
+ this.onResume.emit();
298
+ }
299
+
300
+ /**
301
+ * Get list of available artboards in current Rive file
302
+ */
303
+ getAvailableArtboards(): string[] {
304
+ const available: string[] = [];
305
+ if (this._file) {
306
+ for (let i = 0; i < this._file.artboardCount(); i++) {
307
+ available[i] = this._file.artboardByIndex(i).name;
308
+ }
309
+ }
310
+ return available;
311
+ }
312
+
313
+ /**
314
+ * Get list of available state machines in current artboard
315
+ */
316
+ getAvailableStateMachines(): string[] {
317
+ const available: string[] = [];
318
+ if (this.artboard) {
319
+ for (let i = 0; i < this.artboard.stateMachineCount(); i++) {
320
+ available[i] = this.artboard.stateMachineByIndex(i).name;
321
+ }
322
+ }
323
+ return available;
324
+ }
325
+
326
+ /**
327
+ * Get list of available animations in current artboard
328
+ */
329
+ getAvailableAnimations(): string[] {
330
+ const available: string[] = [];
331
+ if (this.artboard) {
332
+ for (let i = 0; i < this.artboard.animationCount(); i++) {
333
+ available[i] = this.artboard.animationByIndex(i).name;
334
+ }
335
+ }
336
+ return available;
337
+ }
338
+
339
+ /**
340
+ * Recalculate and update sizes of ofscreencanvas due to artboard size
341
+ * Artboard should be loaded before
342
+ */
343
+ updateSize(): void {
344
+ if (this.artboard && this.rive && this._renderer && this._canvas) {
345
+ const bounds = this.artboard.bounds;
346
+ const { minX, minY, maxX, maxY } = bounds;
347
+ const width = maxX - minX;
348
+ const height = maxY - minY;
349
+ const maxWidth = this._maxWidth || width;
350
+ const maxHeight = this._maxHeight || height;
351
+ const fit = this.rive.Fit[this._fit];
352
+ const align = this.rive.Alignment[this._align];
353
+ const frame = { minX: 0, minY: 0, maxX: maxWidth, maxY: maxHeight };
354
+
355
+ this._canvas.width = maxWidth;
356
+ this._canvas.height = maxHeight;
357
+ this._aligned = this.rive?.computeAlignment(fit, align, frame, bounds);
358
+ this._renderer.align(fit, align, frame, bounds);
359
+ this._view.texture.source.update();
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Receive input fields from all active state machines
365
+ */
366
+ initInputFields(): void {
367
+ const { bool, trigger } = this.rive!.SMIInput;
368
+ this.inputFields.clear();
369
+ this.stateMachines.forEach((m) => {
370
+ for (let i = 0; i < m.inputCount(); i++) {
371
+ let field: SMIInput;
372
+ const input = m.input(i);
373
+ if (input.type == bool) field = input.asBool();
374
+ else if (input.type == trigger) field = input.asTrigger();
375
+ else field = input.asNumber();
376
+ this.inputFields.set(input.name, field);
377
+ }
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Get state machine input field by name
383
+ * @param {string} name input field name
384
+ * @returns {number|boolean} value of the input field
385
+ */
386
+ getInputValue(name: string): number | boolean | undefined {
387
+ const input = this.inputFields.get(name);
388
+ return input && input.value;
389
+ }
390
+
391
+ /**
392
+ * Set state machine input field value by name
393
+ * @param {string} name of the input field
394
+ * @param {number|boolean} value of the input field
395
+ */
396
+ setInput(name: string, value: number | boolean): void {
397
+ const input = this.inputFields.get(name);
398
+ if (input && input.type !== this.rive?.SMIInput.trigger) {
399
+ input.value = value;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Trigger state machine input field
405
+ * @param {string} name of the trigger field
406
+ */
407
+ fireTrigger(name: string): void {
408
+ const input = this.inputFields.get(name);
409
+ if (input && input.type === this.rive?.SMIInput.trigger) {
410
+ input.fire();
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Destroy all component resources
416
+ */
417
+ destroy() {
418
+ this._destroyInternals();
419
+ try {
420
+ this._renderer?.delete();
421
+ } catch (e) {
422
+ // nothing
423
+ }
424
+ try {
425
+ this._file?.delete();
426
+ } catch (e) {
427
+ // nothing
428
+ }
429
+ super.destroy();
430
+ }
431
+
432
+ public update(ticker: Ticker = this.app.ticker) {
433
+ if (this._paused || !this.artboard || !this._renderer || !this.rive) {
434
+ return;
435
+ }
436
+ const elapsedTime = ticker.elapsedMS / 1000;
437
+ this.advanceStateMachines(elapsedTime);
438
+ this.advanceAnimations(elapsedTime);
439
+ this.artboard.advance(elapsedTime);
440
+ this._renderer.clear();
441
+ this._renderer.save();
442
+ this.artboard.draw(this._renderer);
443
+ this._renderer.restore();
444
+ this._renderer.flush();
445
+ this.view.texture.source.update();
446
+
447
+ /**
448
+ * https://rive.app/community/doc/low-level-api-usage/doctAfBY6v3P#integrating-rive-into-existing-raf-loop
449
+ */
450
+ this.rive.resolveAnimationFrame();
451
+ }
452
+
453
+ protected handlePointerdown(e: FederatedPointerEvent) {
454
+ const point = this.translatePoint(e.global);
455
+ this.stateMachines.map((m) => m.pointerDown(...point));
456
+ }
457
+
458
+ protected handlePointerup(e: FederatedPointerEvent) {
459
+ const point = this.translatePoint(e.global);
460
+ this.stateMachines.map((m) => m.pointerUp(...point));
461
+ }
462
+
463
+ protected handlePointermove(e: FederatedPointerEvent) {
464
+ const point = this.translatePoint(e.global);
465
+ this.stateMachines.map((m) => m.pointerMove(...point));
466
+ }
467
+
468
+ private _destroyInternals() {
469
+ this.disable();
470
+ this.off('pointerdown', this.handlePointerdown);
471
+ this.off('pointerup', this.handlePointerup);
472
+ this.off('pointermove', this.handlePointermove);
473
+
474
+ try {
475
+ this.stateMachines.map((machine) => machine.delete());
476
+ } catch (e) {
477
+ // nothing
478
+ }
479
+ try {
480
+ this.animations.map((animation) => animation.delete());
481
+ } catch (e) {
482
+ // nothing
483
+ }
484
+ try {
485
+ this.artboard?.delete();
486
+ } catch (e) {
487
+ // nothing
488
+ }
489
+
490
+ this.animations = [];
491
+ this.stateMachines = [];
492
+
493
+ this.view.texture.destroy(true);
494
+ this.view.destroy();
495
+
496
+ // @ts-expect-error artboard can't be null
497
+ this.artboard = null;
498
+
499
+ // @ts-expect-error view can't be null
500
+ this._view = null;
501
+
502
+ if (this._canvas) {
503
+ destroyCanvas(this._canvas);
504
+ }
505
+ // @ts-expect-error canvas can't be null
506
+ this._canvas = null;
507
+ }
508
+
509
+ /**
510
+ * Will load wasm and rive sprite asset from assets library
511
+ * also create offscreen canvas and rive renderer
512
+ * @param riv
513
+ */
514
+ private async initRive(riv: string | Uint8Array): Promise<void> {
515
+ const asset = typeof riv === 'string' ? await Assets.load(riv) : riv;
516
+ this._file = await this.rive.load(asset);
517
+ this._canvas = this.createCanvas();
518
+ this._renderer = this.rive.makeRenderer(this._canvas, true);
519
+ }
520
+
521
+ /**
522
+ * Attach pointer events to the pixi sprite and pass them to the Rive state machine
523
+ * @param {boolean} interactive true if we need to attach pointer events to sprite
524
+ */
525
+ private initEvents(interactive: boolean): void {
526
+ if (!interactive) return;
527
+ if (this.options.cursor) {
528
+ this.cursor = this.options.cursor;
529
+ }
530
+ this.eventMode = 'static';
531
+ this.on('pointerdown', this.handlePointerdown);
532
+ this.on('onpointerup', this.handlePointerup);
533
+ this.on('onpointermove', this.handlePointermove);
534
+ }
535
+
536
+ /**
537
+ * Convert global Pixi.js coordinates to Rive point coordinates
538
+ * @param {{x:number,y:number}} global point coordinates
539
+ * @returns
540
+ */
541
+ private translatePoint(global: { x: number; y: number }): [number, number] {
542
+ const { x, y } = this.toLocal(global);
543
+ const { tx, ty, xx, yy } = this._aligned || { tx: 0, ty: 0, xx: 1, yy: 1 };
544
+ return [(x - tx) / xx, (y - ty) / yy];
545
+ }
546
+
547
+ /**
548
+ * Will create offscreen canvas
549
+ * In debug mode will create a regular canvas and display it over the page
550
+ */
551
+ private createCanvas(): OffscreenCanvas | HTMLCanvasElement {
552
+ if (this._debug) {
553
+ const canvas = document.createElement('canvas');
554
+ canvas.style.position = 'absolute';
555
+ canvas.style.right = '0';
556
+ canvas.style.top = '0';
557
+ canvas.style.width = 'auto';
558
+ canvas.style.height = 'auto';
559
+ document.body.appendChild(canvas);
560
+ return canvas;
561
+ }
562
+ return new OffscreenCanvas(100, 100);
563
+ }
564
+
565
+ /**
566
+ * Play all state machines animations
567
+ * @param {number} elapsed time from last update
568
+ */
569
+ private advanceStateMachines(elapsed: number): void {
570
+ this.stateMachines.map((m) => {
571
+ const numFiredEvents = m.reportedEventCount();
572
+ if (numFiredEvents) {
573
+ Logger.log(`state machine ${m.name} reported ${numFiredEvents} events`);
574
+ for (let i = 0; i < numFiredEvents; i++) {
575
+ const event = m.reportedEventAt(i);
576
+ if (event) {
577
+ Logger.log(`event ${event.name} fired`);
578
+ }
579
+ }
580
+ }
581
+ m.advance(elapsed);
582
+
583
+ if (m.stateChangedCount()) {
584
+ const states = [];
585
+ for (let i = 0; i < m.stateChangedCount(); i++) {
586
+ states.push(m.stateChangedNameByIndex(i));
587
+ }
588
+ if (states.length) {
589
+ this.onStateChange.emit(states);
590
+ }
591
+ }
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Play all scene animations
597
+ * @param {number} elapsed time from last update
598
+ */
599
+ private advanceAnimations(elapsed: number): void {
600
+ this.animations.map((a) => {
601
+ a.advance(elapsed);
602
+ a.apply(1);
603
+ });
604
+ }
605
+ }