PageSourceSearch

https://d3ep.com/js/sticker-engine/sticker-engine.js?v=1790389509

js d3ep.com collected 2026-09-26 02:25:18 UTC 22,169 bytes, 1,032 lines download raw bytes

1/**
2 * D3EP Sticker Engine
3 * Version: 1.0
4 * By Grant Nelson
5 */
6
7const StickerEngine = {
8
9    version: "1.0",
10
11    api: {
12        url: "https://d3ep.com/app_api",
13        origin: "d3epweb"
14    },
15
16    assets: {
17        effects: "https://d3ep.com/js/sticker-engine/effects"
18    },
19
20    environment:
21    {
22        topInset: 120, // Screen top for animations like "drive" and "bounce"
23        bottomInset: 180 // Screen bottom for animations like "drive" and "bounce"
24    },
25
26    collision:
27    {
28        overlap: 0.08 // 8% of the smaller overlap axis
29    },
30
31    loaded: false,
32
33    initPromise: null,
34
35    container: null,
36
37    nextId: 1,
38
39    stickers: {},
40
41    active: [],
42
43    animations: {},
44
45    effects: {},
46
47    collisionFrame: null,
48    collisionRunning: false,
49
50    /**
51     * Initialise the engine
52     */
53    async init()
54    {
55        // Already initialising? Return the existing promise.
56        if (this.initPromise)
57        {
58            return this.initPromise;
59        }
60
61        this.initPromise = (async () =>
62        {
63            let container = document.getElementById("sticker-engine");
64
65            if (!container)
66            {
67                container = document.createElement("div");
68                container.id = "sticker-engine";
69
70                document.body.appendChild(container);
71            }
72
73            this.container = container;
74
75            if (!this.loaded)
76            {
77                this.loaded = await this.load();
78
79                // Preload built-in effects
80                if (this.loaded)
81                {
82                    await this.loadEffect("explosion");
83                }
84            }
85
86            return this.loaded;
87        })();
88
89        return this.initPromise;
90    },
91
92    /**
93     * Register one or more stickers
94     */
95    register(stickers)
96    {
97        Object.assign(this.stickers, stickers);
98    },
99
100    /**
101     * Register animation
102     */
103    registerAnimation(name, fn)
104    {
105        this.animations[name] = fn;
106    },
107
108    /**
109     * Get a sticker configuration
110     */
111    get(code)
112    {
113        return this.stickers[code] || null;
114    },
115
116    /**
117     * Check if a sticker exists
118     */
119    has(code)
120    {
121        return this.stickers.hasOwnProperty(code);
122    },
123
124    /**
125     * Return every registered sticker
126     */
127    all()
128    {
129        return this.stickers;
130    },
131
132    /**
133     * Create a sticker element
134     */
135    createElement(instance)
136    {
137        const img = document.createElement("img");
138
139        img.src = instance.data.image;
140        img.className = "sticker sticker-" + instance.code;
141        img.style.position = "absolute";
142
143        img.dataset.stickerId = instance.id;
144        img.dataset.stickerCode = instance.code;
145
146        const engine = instance.engine;
147
148        img.width = engine.displayWidth;
149        img.height = engine.displayHeight;
150
151        img.style.opacity = engine.opacity;
152        img.style.zIndex = engine.zIndex;
153        img.style.transformOrigin = engine.transformOrigin;
154        img.style.transform = `rotate(${engine.rotation}deg) scale(${engine.scale})`;
155
156        return img;
157    },
158
159    /**
160     * Create an effect element
161     */
162    createEffectElement(effect)
163    {
164        const div = document.createElement("div");
165
166        div.className = `effect effect-${effect.name}`;
167
168        div.style.position = "absolute";
169
170        div.style.width =
171            effect.frame.width + "px";
172
173        div.style.height =
174            effect.frame.height + "px";
175
176        div.style.backgroundImage =
177            `url("${effect.image}")`;
178
179        div.style.backgroundRepeat = "no-repeat";
180
181        div.style.backgroundPosition = "0px 0px";
182
183        div.style.pointerEvents = "none";
184
185        div.style.overflow = "hidden";
186
187        div.style.zIndex = 9999;
188
189        return div;
190    },
191
192    /**
193     * Set the current frame of an effect
194     */
195    setEffectFrame(element, effect, frame)
196    {
197        const column =
198            frame % effect.sheet.columns;
199
200        const row =
201            Math.floor(frame / effect.sheet.columns);
202
203        const x = -(column * effect.frame.width);
204        const y = -(row * effect.frame.height);
205
206        element.style.backgroundPosition =
207            `${x}px ${y}px`;
208    },
209
210    /**
211     * Load an effect definition
212     */
213    async loadEffect(name)
214    {
215        if (this.effects[name])
216        {
217            return this.effects[name];
218        }
219
220        try
221        {
222            const response = await fetch(
223                `${this.assets.effects}/${name}/${name}.json`
224            );
225
226            if (!response.ok)
227            {
228                throw new Error(`HTTP ${response.status}`);
229            }
230
231            const effect = await response.json();
232
233            effect.name = name;
234
235            effect.image =
236                `${this.assets.effects}/${name}/${effect.image}`;
237
238            // Preload and decode the sprite sheet
239            await new Promise((resolve, reject) =>
240            {
241                const image = new Image();
242
243                image.onload = () =>
244                {
245                    effect.sprite = image;
246                    resolve();
247                };
248
249                image.onerror = reject;
250
251                image.src = effect.image;
252            });
253
254            this.effects[name] = effect;
255
256            return effect;
257        }
258        catch (error)
259        {
260            console.error(
261                `StickerEngine: Failed to load effect "${name}"`,
262                error
263            );
264
265            return null;
266        }
267    },
268
269    /**
270     * Resolve a named position into viewport coordinates.
271     */
272    resolvePosition(position, instance)
273    {
274        const engine = instance.engine;
275
276        // Positioning uses the layout size, not the scaled size.
277        const width  = engine.displayWidth;
278        const height = engine.displayHeight;
279
280        const vw = window.innerWidth;
281        const vh = window.innerHeight;
282
283        //
284        // Random positions
285        //
286        if (position === "random")
287        {
288            return {
289                x: Math.random() * (vw - width),
290                y: Math.random() * (vh - height)
291            };
292        }
293
294        if (position.startsWith("random-"))
295        {
296            switch (position)
297            {
298                case "random-top":
299                    return {
300                        x: Math.random() * (vw - width),
301                        y: 0
302                    };
303
304                case "random-bottom":
305                    return {
306                        x: Math.random() * (vw - width),
307                        y: vh - height
308                    };
309
310                case "random-left":
311                    return {
312                        x: 0,
313                        y: Math.random() * (vh - height)
314                    };
315
316                case "random-right":
317                    return {
318                        x: vw - width,
319                        y: Math.random() * (vh - height)
320                    };
321            }
322        }
323
324        //
325        // Determine mode
326        //
327        let mode = "inside";
328
329        const prefixes = [
330            ["outside-", "outside"],
331            ["enter-",   "enter"],
332            ["exit-",    "exit"],
333            ["edge-",    "edge"]
334        ];
335
336        for (const [prefix, name] of prefixes)
337        {
338            if (position.startsWith(prefix))
339            {
340                mode = name;
341                position = position.substring(prefix.length);
342                break;
343            }
344        }
345
346        //
347        // Parse remaining position
348        //
349        const parts = position.split("-");
350
351        let vertical = "centre";
352        let horizontal = "centre";
353
354        if (parts.length === 2)
355        {
356            vertical = parts[0];
357            horizontal = parts[1];
358        }
359        else if (parts.length === 1)
360        {
361            if (["top", "centre", "bottom"].includes(parts[0]))
362            {
363                vertical = parts[0];
364            }
365            else
366            {
367                horizontal = parts[0];
368            }
369        }
370
371        //
372        // Horizontal anchors
373        //
374        const xTable = {
375
376            inside: {
377                left: 0,
378                centre: (vw - width) / 2,
379                right: vw - width
380            },
381
382            edge: {
383                left: 0,
384                centre: (vw - width) / 2,
385                right: vw - width
386            },
387
388            outside: {
389                left: -width,
390                centre: (vw - width) / 2,
391                right: vw
392            },
393
394            // Starts off-screen and enters.
395            enter: {
396                left: -width,
397                centre: (vw - width) / 2,
398                right: vw - width
399            },
400
401            // Starts on-screen and exits.
402            exit: {
403                left: 0,
404                centre: (vw - width) / 2,
405                right: vw
406            }
407
408        };
409
410        //
411        // Vertical anchors
412        //
413        const yTable = {
414
415            inside: {
416                top: 0,
417                centre: (vh - height) / 2,
418                bottom: vh - height
419            },
420
421            edge: {
422                top: 0,
423                centre: (vh - height) / 2,
424                bottom: vh - height
425            },
426
427            outside: {
428                top: -height,
429                centre: (vh - height) / 2,
430                bottom: vh
431            },
432
433            // Starts off-screen and enters.
434            enter: {
435                top: -height,
436                centre: (vh - height) / 2,
437                bottom: vh - height
438            },
439
440            // Starts on-screen and exits.
441            exit: {
442                top: 0,
443                centre: (vh - height) / 2,
444                bottom: vh
445            }
446
447        };
448
449        if (
450            !xTable[mode] ||
451            xTable[mode][horizontal] === undefined ||
452            !yTable[mode] ||
453            yTable[mode][vertical] === undefined
454        )
455        {
456            console.warn(`Unknown position "${position}"`);
457
458            return {
459                x: 0,
460                y: 0
461            };
462        }
463
464        return {
465            x: xTable[mode][horizontal],
466            y: yTable[mode][vertical]
467        };
468    },
469
470    runAnimation(instance)
471    {
472        const animation =
473            this.animations[instance.engine.animation];
474
475        if (!animation)
476        {
477            console.error(
478                "Unknown sticker animation:",
479                instance.engine.animation
480            );
481
482            this.destroy(instance);
483            return;
484        }
485
486        const result = animation(instance);
487
488        if (!result)
489        {
490            this.destroy(instance);
491            return;
492        }
493
494        instance.collidesWith =
495            result.collidesWith ??
496            (() => true);
497
498        instance.player = instance.element.animate(
499            result.frames,
500            {
501                fill: "forwards",
502                ...result.options
503            }
504        );
505
506        instance.player.onfinish = () =>
507        {
508            this.destroy(instance);
509        };
510
511        return instance.player;
512    },
513
514    /**
515     * Play a sticker
516     */
517    play(code, position = null)
518    {
519        if (!this.loaded)
520        {
521            console.warn("StickerEngine is not initialised. Call await StickerEngine.init() first.");
522            return false;
523        }
524
525        const sticker = this.get(code);
526
527        if (!sticker)
528        {
529            console.warn(`Sticker "${code}" does not exist.`);
530            return false;
531        }
532
533        if (!sticker.engine || !sticker.engine.enabled)
534        {
535            console.warn(`Sticker "${code}" has no enabled engine.`);
536            return false;
537        }
538
539        const instance = {
540            
541            id: this.nextId++,
542
543            code: code,
544            data: sticker,
545
546            engine:
547            {
548                duration: 1000,
549                easing: "linear",
550                scale: 1,
551                rotation: 0,
552                opacity: 1,
553                zIndex: 1,
554                transformOrigin: "center center",
555
556                ...sticker.engine
557            },
558
559            origin: null,
560            destination: null,
561
562            element: null,
563
564            player: null,
565
566            destroyed: false,
567
568            collidable:
569                sticker.engine.collidable !== false,
570
571            created: Date.now()
572
573        };
574
575        // Create the DOM element using the runtime engine
576        instance.element = this.createElement(instance);
577
578        this.container.appendChild(instance.element);
579
580        // Resolve positions
581        instance.origin = position
582            ? { ...position }
583            : this.resolvePosition(
584                instance.engine.origin,
585                instance
586            );
587
588        instance.destination = this.resolvePosition(
589            instance.engine.destination,
590            instance
591        );
592
593        instance.element.style.left = instance.origin.x + "px";
594        instance.element.style.top  = instance.origin.y + "px";
595
596        this.active.push(instance);
597
598        if (this.active.length === 2)
599        {
600            this.startCollisionLoop();
601        }
602
603        this.runAnimation(instance);
604
605        return instance;
606    },
607
608    playAt(code, position, anchor = "centre")
609    {
610        return this.play(code, position);
611    },
612
613    /**
614     * Play an effect
615     */
616    async playEffect(name, position)
617    {
618        const effect = await this.loadEffect(name);
619
620        if (!effect)
621        {
622            return false;
623        }
624
625        const element = this.createEffectElement(effect);
626
627        // Centre on the collision point
628        element.style.left =
629            (position.x - effect.origin.x) + "px";
630
631        element.style.top =
632            (position.y - effect.origin.y) + "px";
633
634        this.container.appendChild(element);
635
636        const frameTime =
637            1000 / effect.animation.fps;
638
639        const start =
640            performance.now();
641
642        let currentFrame = -1;
643
644        const tick = (now) =>
645        {
646            const elapsed = now - start;
647
648            const frame =
649                Math.floor(elapsed / frameTime);
650
651            if (frame >= effect.animation.frames)
652            {
653                element.remove();
654                return;
655            }
656
657            // Only update the DOM when the frame changes
658            if (frame !== currentFrame)
659            {
660                currentFrame = frame;
661
662                this.setEffectFrame(
663                    element,
664                    effect,
665                    frame
666                );
667            }
668
669            requestAnimationFrame(tick);
670        };
671
672        requestAnimationFrame(tick);
673
674        return element;
675    },
676
677    destroy(instance)
678    {
679        if (instance.element?.parentNode)
680        {
681            instance.element.remove();
682        }
683
684        this.active = this.active.filter(item => item !== instance);
685
686        if (this.active.length < 2)
687        {
688            this.stopCollisionLoop();
689        }
690    },
691
692    startCollisionLoop()
693    {
694        if (this.collisionRunning)
695        {
696            return;
697        }
698
699        this.collisionRunning = true;
700
701        const tick = () =>
702        {
703            this.checkCollisions();
704
705            if (this.collisionRunning)
706            {
707                this.collisionFrame =
708                    requestAnimationFrame(tick);
709            }
710        };
711
712        this.collisionFrame =
713            requestAnimationFrame(tick);
714    },
715
716    stopCollisionLoop()
717    {
718        this.collisionRunning = false;
719
720        if (this.collisionFrame)
721        {
722            cancelAnimationFrame(this.collisionFrame);
723            this.collisionFrame = null;
724        }
725    },
726
727    intersects(a, b)
728    {
729        const overlapX =
730            Math.min(a.right, b.right) -
731            Math.max(a.left, b.left);
732
733        if (overlapX <= 0)
734        {
735            return false;
736        }
737
738        const overlapY =
739            Math.min(a.bottom, b.bottom) -
740            Math.max(a.top, b.top);
741
742        if (overlapY <= 0)
743        {
744            return false;
745        }
746
747        const requiredOverlap =
748            Math.min(
749                Math.min(a.width, b.width),
750                Math.min(a.height, b.height)
751            ) *
752            this.collision.overlap;
753
754        return (
755            overlapX >= requiredOverlap &&
756            overlapY >= requiredOverlap
757        );
758    },
759
760    checkCollisions()
761    {
762        const active = this.active;
763
764        for (let i = 0; i < active.length - 1; i++)
765        {
766            const a = active[i];
767
768            if (
769                a.destroyed ||
770                !a.collidable ||
771                !a.element.isConnected
772            )
773            {
774                continue;
775            }
776
777            const rectA = a.element.getBoundingClientRect();
778
779            for (let j = i + 1; j < active.length; j++)
780            {
781                const b = active[j];
782
783                if (
784                    b.destroyed ||
785                    !b.collidable ||
786                    !b.element.isConnected
787                )
788                {
789                    continue;
790                }
791
792                if (
793                    !a.collidesWith(b) ||
794                    !b.collidesWith(a)
795                )
796                {
797                    continue;
798                }
799
800                const rectB = b.element.getBoundingClientRect();
801
802                if (!this.intersects(rectA, rectB))
803                {
804                    continue;
805                }
806
807                this.collide(a, b);
808
809                return;
810            }
811        }
812    },
813
814    getCollisionPoint(a, b)
815    {
816        const rectA = a.element.getBoundingClientRect();
817        const rectB = b.element.getBoundingClientRect();
818
819        return {
820
821            x:
822                (Math.max(rectA.left, rectB.left) +
823                Math.min(rectA.right, rectB.right)) / 2,
824
825            y:
826                (Math.max(rectA.top, rectB.top) +
827                Math.min(rectA.bottom, rectB.bottom)) / 2
828
829        };
830    },
831
832    collide(a, b)
833    {
834        if (a.destroyed || b.destroyed)
835        {
836            return;
837        }
838
839        a.destroyed = true;
840        b.destroyed = true;
841
842        const point = this.getCollisionPoint(a, b);
843
844        a.player?.cancel();
845        b.player?.cancel();
846
847        this.destroy(a);
848        this.destroy(b);
849
850        this.playEffect(
851            "explosion",
852            point
853        );
854    },
855
856    /**
857     * Fetch all sticker definitions from the API.
858     */
859    async fetchStickers()
860    {
861        const response = await fetch(this.api.url,
862        {
863            method: "POST",
864
865            headers:
866            {
867                "Content-Type": "application/x-www-form-urlencoded"
868            },
869
870            body: new URLSearchParams({
871                origin: this.api.origin,
872                action: "get_all_stickers"
873            })
874        });
875
876        if (!response.ok)
877        {
878            throw new Error(`HTTP ${response.status}`);
879        }
880
881        const json = await response.json();
882
883        if (json.code !== 200)
884        {
885            throw new Error(
886                json.msg ?? "Sticker API returned an error."
887            );
888        }
889
890        return json.stickers;
891    },
892
893    async load()
894    {
895        try
896        {
897            this.register(await this.fetchStickers());
898
899            return true;
900        }
901        catch (error)
902        {
903            console.error("StickerEngine:", error);
904
905            return false;
906        }
907    },
908
909    /**
910     * Reload sticker definitions from the API.
911     */
912    async reload()
913    {
914        try
915        {
916            this.stickers = await this.fetchStickers();
917
918            console.log(
919                `StickerEngine: Reloaded ${Object.keys(this.stickers).length} sticker(s).`
920            );
921
922            return true;
923        }
924        catch (error)
925        {
926            console.error("StickerEngine:", error);
927
928            return false;
929        }
930    },
931
932    /**
933     * Calculate how much scaling moves the visual centre.
934     */
935    getScaleOffset(instance)
936    {
937        const scale = instance.engine.scale ?? 1;
938
939        return {
940
941            x:
942                (
943                    (instance.engine.displayWidth * scale) -
944                    instance.engine.displayWidth
945                ) / 2,
946
947            y:
948                (
949                    (instance.engine.displayHeight * scale) -
950                    instance.engine.displayHeight
951                ) / 2
952
953        };
954    },
955
956    /**
957     * Adjust a resolved position for the environment.
958     */
959    adjustForEnvironment(position, point, instance)
960    {
961        const adjusted = { ...point };
962
963        const scaleOffset =
964            this.getScaleOffset(instance);
965
966        if (position.includes("top"))
967        {
968            adjusted.y +=
969                this.environment.topInset +
970                scaleOffset.y;
971        }
972
973        if (position.includes("bottom"))
974        {
975            adjusted.y -=
976                this.environment.bottomInset +
977                scaleOffset.y;
978        }
979
980        return adjusted;
981    },
982
983    /**
984     * Resolve a position and adjust it for the environment.
985     */
986    resolveEnvironmentPosition(position, instance)
987    {
988        return this.adjustForEnvironment(
989            position,
990            this.resolvePosition(position, instance),
991            instance
992        );
993    },
994
995    // Test Functions
996    crashtest(
997        first = "drop",
998        second = "bee",
999        delay = 50
1000    )
1001    {
1002        console.log(
1003            `💥 Crash test: ${first} vs ${second}`
1004        );
1005
1006        this.play(first);
1007
1008        setTimeout(() =>
1009        {
1010            this.play(second);
1011        }, delay);
1012    },
1013
1014    // Helper Methods
1015    getInstance(id)
1016    {
1017        return this.active.find(
1018            sticker => sticker.id === id
1019        ) ?? null;
1020    },
1021
1022    destroyById(id)
1023    {
1024        const instance = this.getInstance(id);
1025
1026        if (instance)
1027        {
1028            this.destroy(instance);
1029        }
1030    },
1031
1032};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.