canvasengine 2.0.0-beta.61 → 2.0.0-beta.63

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasengine",
3
- "version": "2.0.0-beta.61",
3
+ "version": "2.0.0-beta.63",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -5,6 +5,7 @@ import { ViewportFollowProps } from "../../directives/ViewportFollow";
5
5
  import { ShakeProps } from "../../directives/Shake";
6
6
  import { FlashProps } from "../../directives/Flash";
7
7
  import { FogVisibilityProps } from "../../directives/FogVisibility";
8
+ import type { ClipProps, OcclusionProps, OutlineProps } from "../../directives/SpriteEffects";
8
9
 
9
10
  export type FlexDirection = 'row' | 'column' | 'row-reverse' | 'column-reverse';
10
11
  export type JustifyContent = 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around';
@@ -82,6 +83,9 @@ export interface DisplayObjectProps {
82
83
  shake?: ShakeProps;
83
84
  flash?: FlashProps;
84
85
  fogVisibility?: FogVisibilityProps;
86
+ outline?: OutlineProps;
87
+ clip?: ClipProps;
88
+ occlusion?: OcclusionProps;
85
89
 
86
90
  // Events
87
91
  click?: PIXI.FederatedEventHandler;
@@ -0,0 +1,461 @@
1
+ import { effect, isSignal } from "@signe/reactive";
2
+ import { Container, Graphics, Rectangle, Sprite as PixiSprite } from "pixi.js";
3
+ import { OutlineFilter } from "pixi-filters/outline";
4
+ import { Directive, registerDirective } from "../engine/directive";
5
+ import { isElement } from "../engine/reactive";
6
+ import type { Element } from "../engine/reactive";
7
+ import type { SignalOrPrimitive } from "../components/types";
8
+ import type { Subscription } from "rxjs";
9
+
10
+ type MaybeSignal<T> = SignalOrPrimitive<T> | T;
11
+
12
+ const valueOf = <T>(value: MaybeSignal<T> | undefined, fallback: T): T => {
13
+ if (value === undefined) return fallback;
14
+ return isSignal(value as any) ? (value as any)() : (value as T);
15
+ };
16
+
17
+ const rawValueOf = <T>(value: MaybeSignal<T> | undefined): T | undefined => {
18
+ if (value === undefined) return undefined;
19
+ return isSignal(value as any) ? (value as any)() : (value as T);
20
+ };
21
+
22
+ const asArray = <T>(value: T | T[] | undefined): T[] => {
23
+ if (value === undefined) return [];
24
+ return Array.isArray(value) ? value : [value];
25
+ };
26
+
27
+ const getFilters = (instance: Container): any[] => {
28
+ const filters = (instance as any).filters;
29
+ if (!filters) return [];
30
+ return Array.isArray(filters) ? [...filters] : [filters];
31
+ };
32
+
33
+ const setFilters = (instance: Container, filters: any[]) => {
34
+ (instance as any).filters = filters.length > 0 ? filters : [];
35
+ };
36
+
37
+ const getElementInstance = (value: unknown): Container | null => {
38
+ const resolved = rawValueOf(value as any);
39
+ if (isElement(resolved)) {
40
+ return (resolved.componentInstance as unknown as Container) ?? null;
41
+ }
42
+ if (resolved instanceof Container) return resolved;
43
+ if (resolved && typeof resolved === "object" && typeof (resolved as any).getBounds === "function") {
44
+ return resolved as Container;
45
+ }
46
+ return null;
47
+ };
48
+
49
+ const normalizeBounds = (bounds: any): Rectangle => {
50
+ return new Rectangle(bounds?.x ?? 0, bounds?.y ?? 0, bounds?.width ?? 0, bounds?.height ?? 0);
51
+ };
52
+
53
+ const getWorldBounds = (instance: any, mode: "bounds" | "hitbox" = "bounds"): Rectangle => {
54
+ const bounds = normalizeBounds(
55
+ typeof instance.getBounds === "function"
56
+ ? instance.getBounds()
57
+ : { x: instance.x ?? 0, y: instance.y ?? 0, width: instance.width ?? 0, height: instance.height ?? 0 }
58
+ );
59
+
60
+ if (mode !== "hitbox" || !instance.hitbox) {
61
+ return bounds;
62
+ }
63
+
64
+ const hitbox = instance.hitbox;
65
+ return new Rectangle(
66
+ bounds.x + Math.max(0, (bounds.width - hitbox.w) / 2),
67
+ bounds.y + Math.max(0, bounds.height - hitbox.h),
68
+ hitbox.w,
69
+ hitbox.h
70
+ );
71
+ };
72
+
73
+ const intersectRect = (a: Rectangle, b: Rectangle): Rectangle | null => {
74
+ const x = Math.max(a.x, b.x);
75
+ const y = Math.max(a.y, b.y);
76
+ const right = Math.min(a.x + a.width, b.x + b.width);
77
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
78
+ const width = right - x;
79
+ const height = bottom - y;
80
+ if (width <= 0 || height <= 0) return null;
81
+ return new Rectangle(x, y, width, height);
82
+ };
83
+
84
+ const drawFilledRect = (graphics: Graphics, rect: Rectangle) => {
85
+ graphics.rect(rect.x, rect.y, rect.width, rect.height);
86
+ graphics.fill(0xffffff);
87
+ };
88
+
89
+ const addMaskToParent = (instance: Container, mask: Graphics) => {
90
+ if (mask.parent === instance.parent) return;
91
+ if (mask.parent) {
92
+ mask.parent.removeChild(mask);
93
+ }
94
+ instance.parent?.addChild(mask);
95
+ };
96
+
97
+ const setMask = (instance: any, mask: any, inverse = false) => {
98
+ if (!instance) return;
99
+ if (typeof instance.setMask === "function") {
100
+ if (mask == null) {
101
+ instance.mask = null;
102
+ }
103
+ instance.setMask({ mask: mask ?? null, inverse });
104
+ } else {
105
+ instance.mask = mask ?? null;
106
+ }
107
+ };
108
+
109
+ const clearMask = (instance: any, fallbackMask: any) => {
110
+ setMask(instance, fallbackMask ?? null);
111
+ };
112
+
113
+ const copyTransform = (target: any, source: any) => {
114
+ const position = { x: source.x ?? source.position?.x ?? 0, y: source.y ?? source.position?.y ?? 0 };
115
+ if (target.position?.copyFrom) {
116
+ target.position.copyFrom(position);
117
+ } else {
118
+ target.x = position.x;
119
+ target.y = position.y;
120
+ }
121
+ target.scale?.copyFrom?.(source.scale ?? { x: 1, y: 1 });
122
+ target.pivot?.copyFrom?.(source.pivot ?? { x: 0, y: 0 });
123
+ target.skew?.copyFrom?.(source.skew ?? { x: 0, y: 0 });
124
+ target.rotation = source.rotation ?? 0;
125
+ };
126
+
127
+ const drawDisplayRect = (graphics: Graphics, instance: any, rect: Rectangle) => {
128
+ copyTransform(graphics, instance);
129
+
130
+ const textureWidth = instance.texture?.orig?.width ?? instance.texture?.width ?? instance.width ?? rect.width;
131
+ const textureHeight = instance.texture?.orig?.height ?? instance.texture?.height ?? instance.height ?? rect.height;
132
+ const displayWidth = instance.width || textureWidth || 1;
133
+ const displayHeight = instance.height || textureHeight || 1;
134
+ const anchorX = instance.anchor?.x ?? 0;
135
+ const anchorY = instance.anchor?.y ?? 0;
136
+
137
+ drawFilledRect(
138
+ graphics,
139
+ new Rectangle(
140
+ -anchorX * textureWidth + (rect.x * textureWidth) / displayWidth,
141
+ -anchorY * textureHeight + (rect.y * textureHeight) / displayHeight,
142
+ (rect.width * textureWidth) / displayWidth,
143
+ (rect.height * textureHeight) / displayHeight
144
+ )
145
+ );
146
+ };
147
+
148
+ export type OutlineProps = {
149
+ enabled?: SignalOrPrimitive<boolean>;
150
+ color?: SignalOrPrimitive<number>;
151
+ thickness?: SignalOrPrimitive<number>;
152
+ quality?: SignalOrPrimitive<number>;
153
+ alpha?: SignalOrPrimitive<number>;
154
+ };
155
+
156
+ export type ClipShape =
157
+ | {
158
+ type: "rect";
159
+ x: SignalOrPrimitive<number>;
160
+ y: SignalOrPrimitive<number>;
161
+ width: SignalOrPrimitive<number>;
162
+ height: SignalOrPrimitive<number>;
163
+ };
164
+
165
+ export type ClipProps = {
166
+ enabled?: SignalOrPrimitive<boolean>;
167
+ mode?: SignalOrPrimitive<"keep" | "hide">;
168
+ shape: ClipShape;
169
+ };
170
+
171
+ export type OcclusionProps = {
172
+ enabled?: SignalOrPrimitive<boolean>;
173
+ obstacles: SignalOrPrimitive<Element | Element[] | Container | Container[]>;
174
+ bounds?: SignalOrPrimitive<"bounds" | "hitbox">;
175
+ padding?: SignalOrPrimitive<number>;
176
+ alpha?: SignalOrPrimitive<number>;
177
+ zIndex?: SignalOrPrimitive<number>;
178
+ };
179
+
180
+ export class Outline extends Directive {
181
+ private elementRef: Element<Container> | null = null;
182
+ private filter: OutlineFilter | null = null;
183
+ private updateEffect: ReturnType<typeof effect> | null = null;
184
+
185
+ onInit(element: Element<Container>) {
186
+ this.elementRef = element;
187
+ }
188
+
189
+ onMount() {
190
+ this.updateEffect = effect(() => {
191
+ this.apply();
192
+ });
193
+ }
194
+
195
+ onUpdate() {
196
+ this.apply();
197
+ }
198
+
199
+ onDestroy() {
200
+ this.removeFilter();
201
+ this.updateEffect?.subscription.unsubscribe();
202
+ this.updateEffect = null;
203
+ this.elementRef = null;
204
+ }
205
+
206
+ private get props(): OutlineProps {
207
+ const props = rawValueOf(this.elementRef?.props.outline);
208
+ if (props === true) return {};
209
+ if (props === false) return { enabled: false };
210
+ return (props as OutlineProps | undefined) ?? {};
211
+ }
212
+
213
+ private apply() {
214
+ const instance = this.elementRef?.componentInstance;
215
+ if (!instance) return;
216
+
217
+ const props = this.props;
218
+ const enabled = valueOf(props.enabled, true);
219
+
220
+ if (!enabled) {
221
+ this.removeFilter();
222
+ return;
223
+ }
224
+
225
+ if (!this.filter) {
226
+ this.filter = new OutlineFilter({
227
+ thickness: valueOf(props.thickness, 1),
228
+ color: valueOf(props.color, 0xffffff),
229
+ quality: valueOf(props.quality, 0.1),
230
+ alpha: valueOf(props.alpha, 1),
231
+ });
232
+ setFilters(instance, [...getFilters(instance), this.filter]);
233
+ }
234
+
235
+ this.filter.thickness = valueOf(props.thickness, 1);
236
+ this.filter.color = valueOf(props.color, 0xffffff);
237
+ this.filter.quality = valueOf(props.quality, 0.1);
238
+ this.filter.alpha = valueOf(props.alpha, 1);
239
+ }
240
+
241
+ private removeFilter() {
242
+ const instance = this.elementRef?.componentInstance;
243
+ if (!instance || !this.filter) return;
244
+ setFilters(instance, getFilters(instance).filter((filter) => filter !== this.filter));
245
+ this.filter = null;
246
+ }
247
+ }
248
+
249
+ export class Clip extends Directive {
250
+ private elementRef: Element<Container> | null = null;
251
+ private maskGraphics: Graphics | null = null;
252
+ private previousMask: any = null;
253
+ private tickSubscription: Subscription | null = null;
254
+ private updateEffect: ReturnType<typeof effect> | null = null;
255
+
256
+ onInit(element: Element<Container>) {
257
+ this.elementRef = element;
258
+ }
259
+
260
+ onMount(element: Element<Container>) {
261
+ this.tickSubscription = element.props.context?.tick?.observable?.subscribe(() => {
262
+ this.apply();
263
+ }) ?? null;
264
+ this.updateEffect = effect(() => {
265
+ this.apply();
266
+ });
267
+ }
268
+
269
+ onUpdate() {
270
+ this.apply();
271
+ }
272
+
273
+ onDestroy() {
274
+ const instance = this.elementRef?.componentInstance as any;
275
+ if (instance && this.maskGraphics) {
276
+ clearMask(instance, this.previousMask);
277
+ }
278
+ if (this.maskGraphics?.parent) {
279
+ this.maskGraphics.parent.removeChild(this.maskGraphics);
280
+ }
281
+ this.maskGraphics?.destroy();
282
+ this.maskGraphics = null;
283
+ this.previousMask = null;
284
+ this.tickSubscription?.unsubscribe();
285
+ this.tickSubscription = null;
286
+ this.updateEffect?.subscription.unsubscribe();
287
+ this.updateEffect = null;
288
+ this.elementRef = null;
289
+ }
290
+
291
+ private get props(): ClipProps | null {
292
+ return (rawValueOf(this.elementRef?.props.clip) as ClipProps | undefined) ?? null;
293
+ }
294
+
295
+ private apply() {
296
+ const instance = this.elementRef?.componentInstance as any;
297
+ const props = this.props;
298
+ if (!instance || !instance.parent || !props?.shape) return;
299
+
300
+ if (!valueOf(props.enabled, true)) {
301
+ if (this.maskGraphics) {
302
+ clearMask(instance, this.previousMask);
303
+ this.maskGraphics.clear();
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (!this.maskGraphics) {
309
+ this.maskGraphics = new Graphics();
310
+ this.previousMask = instance.mask ?? null;
311
+ }
312
+
313
+ addMaskToParent(instance, this.maskGraphics);
314
+ this.maskGraphics.clear();
315
+
316
+ const shape = props.shape;
317
+ if (shape.type === "rect") {
318
+ const rect = new Rectangle(
319
+ valueOf(shape.x, 0),
320
+ valueOf(shape.y, 0),
321
+ valueOf(shape.width, 0),
322
+ valueOf(shape.height, 0)
323
+ );
324
+ drawDisplayRect(this.maskGraphics, instance, rect);
325
+ }
326
+
327
+ const inverse = valueOf(props.mode, "keep") === "hide";
328
+ setMask(instance, this.maskGraphics, inverse);
329
+ }
330
+ }
331
+
332
+ export class Occlusion extends Directive {
333
+ private elementRef: Element<Container> | null = null;
334
+ private maskGraphics: Graphics | null = null;
335
+ private ghostSprite: PixiSprite | null = null;
336
+ private tickSubscription: Subscription | null = null;
337
+ private updateEffect: ReturnType<typeof effect> | null = null;
338
+
339
+ onInit(element: Element<Container>) {
340
+ this.elementRef = element;
341
+ }
342
+
343
+ onMount(element: Element<Container>) {
344
+ this.tickSubscription = element.props.context?.tick?.observable?.subscribe(() => {
345
+ this.apply();
346
+ }) ?? null;
347
+ this.updateEffect = effect(() => {
348
+ this.apply();
349
+ });
350
+ }
351
+
352
+ onUpdate() {
353
+ this.apply();
354
+ }
355
+
356
+ onDestroy() {
357
+ clearMask(this.ghostSprite, null);
358
+ if (this.ghostSprite?.parent) {
359
+ this.ghostSprite.parent.removeChild(this.ghostSprite);
360
+ }
361
+ this.ghostSprite?.destroy();
362
+ if (this.maskGraphics?.parent) {
363
+ this.maskGraphics.parent.removeChild(this.maskGraphics);
364
+ }
365
+ this.maskGraphics?.destroy();
366
+ this.maskGraphics = null;
367
+ this.ghostSprite = null;
368
+ this.tickSubscription?.unsubscribe();
369
+ this.tickSubscription = null;
370
+ this.updateEffect?.subscription.unsubscribe();
371
+ this.updateEffect = null;
372
+ this.elementRef = null;
373
+ }
374
+
375
+ private get props(): OcclusionProps | null {
376
+ return (rawValueOf(this.elementRef?.props.occlusion) as OcclusionProps | undefined) ?? null;
377
+ }
378
+
379
+ private apply() {
380
+ const instance = this.elementRef?.componentInstance as any;
381
+ const props = this.props;
382
+ if (!instance || !props) return;
383
+
384
+ if (!valueOf(props.enabled, true)) {
385
+ if (this.maskGraphics) {
386
+ this.maskGraphics.clear();
387
+ }
388
+ if (this.ghostSprite) {
389
+ this.ghostSprite.visible = false;
390
+ }
391
+ return;
392
+ }
393
+
394
+ if (!this.maskGraphics || !this.ghostSprite) {
395
+ if (!instance.texture) return;
396
+ this.maskGraphics = new Graphics();
397
+ this.ghostSprite = new PixiSprite(instance.texture);
398
+ }
399
+
400
+ addMaskToParent(instance, this.maskGraphics);
401
+ if (this.ghostSprite.parent !== instance.parent) {
402
+ if (this.ghostSprite.parent) {
403
+ this.ghostSprite.parent.removeChild(this.ghostSprite);
404
+ }
405
+ instance.parent?.addChild(this.ghostSprite);
406
+ }
407
+
408
+ this.maskGraphics.clear();
409
+ this.ghostSprite.texture = instance.texture;
410
+ this.ghostSprite.width = instance.width;
411
+ this.ghostSprite.height = instance.height;
412
+ this.ghostSprite.visible = false;
413
+ this.ghostSprite.alpha = valueOf(props.alpha, 0.35);
414
+ copyTransform(this.ghostSprite, instance);
415
+ if (instance.anchor && this.ghostSprite.anchor) {
416
+ this.ghostSprite.anchor.copyFrom(instance.anchor);
417
+ }
418
+
419
+ const boundsMode = valueOf(props.bounds, "bounds");
420
+ const padding = valueOf(props.padding, 0);
421
+ const targetBounds = getWorldBounds(instance, boundsMode);
422
+ let hasIntersection = false;
423
+ let maxObstacleZIndex = instance.zIndex ?? 0;
424
+
425
+ for (const obstacle of asArray(rawValueOf(props.obstacles) as any)) {
426
+ const obstacleInstance = getElementInstance(obstacle);
427
+ if (!obstacleInstance) continue;
428
+
429
+ maxObstacleZIndex = Math.max(maxObstacleZIndex, (obstacleInstance as any).zIndex ?? 0);
430
+ const obstacleBounds = getWorldBounds(obstacleInstance, boundsMode);
431
+ const intersection = intersectRect(targetBounds, obstacleBounds);
432
+ if (!intersection) continue;
433
+
434
+ hasIntersection = true;
435
+ const parentLocalTopLeft = instance.parent?.toLocal?.({ x: intersection.x - padding, y: intersection.y - padding })
436
+ ?? { x: intersection.x - padding, y: intersection.y - padding };
437
+ drawFilledRect(
438
+ this.maskGraphics,
439
+ new Rectangle(
440
+ parentLocalTopLeft.x,
441
+ parentLocalTopLeft.y,
442
+ intersection.width + padding * 2,
443
+ intersection.height + padding * 2
444
+ )
445
+ );
446
+ }
447
+
448
+ if (hasIntersection) {
449
+ this.ghostSprite.visible = true;
450
+ this.ghostSprite.zIndex = valueOf(props.zIndex, maxObstacleZIndex + 1);
451
+ setMask(this.ghostSprite, this.maskGraphics, false);
452
+ } else {
453
+ this.ghostSprite.visible = false;
454
+ clearMask(this.ghostSprite, null);
455
+ }
456
+ }
457
+ }
458
+
459
+ registerDirective("outline", Outline);
460
+ registerDirective("clip", Clip);
461
+ registerDirective("occlusion", Occlusion);
@@ -12,3 +12,4 @@ export * from './Transition'
12
12
  export * from './Shake'
13
13
  export * from './Flash'
14
14
  export * from './FogVisibility'
15
+ export * from './SpriteEffects'
@@ -697,6 +697,19 @@ export function createComponent(tag: string, props?: Props): Element {
697
697
 
698
698
  if (child instanceof Observable) {
699
699
  const mountedFlowElements = childGroup.mounted;
700
+ const flowEffectSubscriptions = ((child as any).effectSubscriptions ?? []) as Subscription[];
701
+ const flowEffectMounts = ((child as any).effectMounts ?? []) as Array<(element: Element) => any>;
702
+
703
+ const applyFlowEffects = (element: Element) => {
704
+ if (!flowEffectMounts.length) {
705
+ return;
706
+ }
707
+
708
+ element.effectMounts = [
709
+ ...flowEffectMounts,
710
+ ...(element.effectMounts ?? []),
711
+ ];
712
+ };
700
713
 
701
714
  const createFragmentOwner = (): Element => ({
702
715
  tag: 'fragment',
@@ -724,6 +737,7 @@ export function createComponent(tag: string, props?: Props): Element {
724
737
  }
725
738
 
726
739
  const routed = routeDomComponent(parent, element);
740
+ applyFlowEffects(routed);
727
741
  mountedFlowElements.set(element, routed);
728
742
  onMount(parent, routed, getInsertIndex(sourceIndex, orderedSources));
729
743
  propagateContext(routed);
@@ -801,6 +815,7 @@ export function createComponent(tag: string, props?: Props): Element {
801
815
  } else if (isElement(value)) {
802
816
  // Handle direct Element emission
803
817
  const routed = routeDomComponent(parent, value);
818
+ applyFlowEffects(routed);
804
819
  childGroup.mounted.set(value, routed);
805
820
  onMount(parent, routed, getInsertIndex(0, [value]));
806
821
  propagateContext(routed);
@@ -821,6 +836,7 @@ export function createComponent(tag: string, props?: Props): Element {
821
836
  destroyElement(mounted);
822
837
  });
823
838
  mountedFlowElements.clear();
839
+ flowEffectSubscriptions.forEach((sub) => sub.unsubscribe());
824
840
  });
825
841
 
826
842
  // Store subscription for cleanup