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.