A spooky game where you cast spells to save a crystal from demons.
Created
November 3, 2025 12:57
-
-
Save Litas-dev/0629d9f58754a6c61d8ae59d033f72a5 to your computer and use it in GitHub Desktop.
Spell Caster
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <svg id="spells" aria-hidden="true"> | |
| <refs> | |
| <path | |
| id="spell-shape-arcane" | |
| data-spell="arcane" | |
| class="spell" | |
| d="M1 5L1 24.5L1 37L4 50.5L9.5 61.5L16.5 69.5L25.5 77L35.5 81.5L46 85L57 86L67.5 85L76.5 81.5L86.5 77L96 69.5L102.5 61.5L108 52.5L111 43.5L112 34L112.5 26L112.5 17.5L112.5 7.5L112.5 2L57.5 1L58.5 43.5" | |
| /> | |
| <path | |
| id="spell-shape-fire" | |
| data-spell="fire" | |
| class="spell" | |
| d="M1.38133 71L38.7997 2L72.643 71L110.061 2L143.905 71" | |
| /> | |
| <path | |
| id="spell-shape-vortex" | |
| data-spell="vortex" | |
| class="spell" | |
| d="M48.8852 110L47.4198 2L1 65.6158L85 65.6158L85 110" | |
| /> | |
| <path id="check" d="M9.44172 20L0 10.5198L2.36043 8.14969L9.44172 15.2599L24.6396 0L27 2.37006L9.44172 20Z" fill="white"/> | |
| </refs> | |
| </svg> | |
| <!-- | |
| GAME SCREENS | |
| --> | |
| <div class="app"> | |
| <!-- THREE JS DOES IT'S RENDERERING IN HERE --> | |
| <div class="canvas"></div> | |
| <!-- TOP STATUS BAR, FOR THINGS LIKE LIFE INDICATOR, SOUND CONTROLS AND OTHER QUICK OPTIONS --> | |
| <div class="top-bar"> | |
| <div class="left"> | |
| <div class="health" id="health-bar" data-show-on="GAME_RUNNING,PAUSED,SPELL_OVERLAY"> | |
| <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="22" height="20" viewBox="0 0 22 20" fill="none"> | |
| <g clip-path="url(#clip0_203_249)"> | |
| <path d="M17.7218 0H4.2774C4.12462 0 3.97948 0.078125 3.89546 0.210938L0.0760111 5.96094C-0.0347528 6.13281 -0.0232945 6.35938 0.102747 6.51563L10.6444 19.8281C10.8277 20.0586 11.1715 20.0586 11.3548 19.8281L21.8965 6.51563C22.0225 6.35547 22.034 6.13281 21.9232 5.96094L18.1076 0.210938C18.0198 0.078125 17.8784 0 17.7218 0ZM16.9847 1.875L19.4024 5.625H16.7899L14.8152 1.875H16.9847ZM9.26559 1.875H12.7298L14.7045 5.625H7.29476L9.26559 1.875ZM5.01455 1.875H7.184L5.20934 5.625H2.59684L5.01455 1.875ZM3.37219 7.5H5.33539L7.94407 13.75L3.37219 7.5ZM7.3024 7.5H14.6968L10.9996 17.0039L7.3024 7.5ZM14.0552 13.75L16.66 7.5H18.6232L14.0552 13.75Z" fill="white"/> | |
| </g> | |
| <defs> | |
| <clipPath id="clip0_203_249"> | |
| <rect width="22" height="20" fill="white"/> | |
| </clipPath> | |
| </defs> | |
| </svg> | |
| <div class="info health-bar"> | |
| <span class="sr-only"></span> | |
| </div> | |
| </div> | |
| <div class="demons" id="demon-state" data-show-on="GAME_RUNNING,SPELL_OVERLAY"> | |
| <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22" fill="none"> | |
| <path d="M9.77778 11.7857C9.77778 12.2519 9.63441 12.7076 9.36581 13.0953C9.09722 13.4829 8.71545 13.785 8.26878 13.9634C7.82212 14.1418 7.33062 14.1885 6.85645 14.0976C6.38227 14.0066 5.94671 13.7821 5.60485 13.4525C5.26299 13.1228 5.03018 12.7028 4.93586 12.2456C4.84154 11.7883 4.88995 11.3144 5.07496 10.8837C5.25998 10.453 5.57329 10.0848 5.97527 9.82582C6.37726 9.56682 6.84987 9.42857 7.33333 9.42857C7.98164 9.42857 8.60339 9.67691 9.06182 10.119C9.52024 10.561 9.77778 11.1606 9.77778 11.7857ZM14.6667 9.42857C14.1832 9.42857 13.7106 9.56682 13.3086 9.82582C12.9066 10.0848 12.5933 10.453 12.4083 10.8837C12.2233 11.3144 12.1749 11.7883 12.2692 12.2456C12.3635 12.7028 12.5963 13.1228 12.9382 13.4525C13.28 13.7821 13.7156 14.0066 14.1898 14.0976C14.664 14.1885 15.1555 14.1418 15.6021 13.9634C16.0488 13.785 16.4305 13.4829 16.6991 13.0953C16.9677 12.7076 17.1111 12.2519 17.1111 11.7857C17.1111 11.1606 16.8536 10.561 16.3952 10.119C15.9367 9.67691 15.315 9.42857 14.6667 9.42857ZM22 10.2143C22 13.146 20.6708 15.8891 18.3333 17.8279V20.0357C18.3333 20.5567 18.1187 21.0563 17.7367 21.4247C17.3547 21.793 16.8366 22 16.2963 22H5.7037C5.16345 22 4.64532 21.793 4.2633 21.4247C3.88128 21.0563 3.66667 20.5567 3.66667 20.0357V17.8279C1.32407 15.8891 0 13.146 0 10.2143C0 4.5817 4.93472 0 11 0C17.0653 0 22 4.5817 22 10.2143ZM19.5556 10.2143C19.5556 5.88205 15.7178 2.35714 11 2.35714C6.28222 2.35714 2.44444 5.88205 2.44444 10.2143C2.44444 12.6019 3.60657 14.8304 5.63343 16.333C5.78206 16.4431 5.90245 16.5847 5.98528 16.7468C6.06811 16.909 6.11117 17.0873 6.11111 17.268V19.6429H7.74074V17.6786C7.74074 17.366 7.86951 17.0662 8.09872 16.8452C8.32793 16.6242 8.63881 16.5 8.96296 16.5C9.28712 16.5 9.59799 16.6242 9.8272 16.8452C10.0564 17.0662 10.1852 17.366 10.1852 17.6786V19.6429H11.8148V17.6786C11.8148 17.366 11.9436 17.0662 12.1728 16.8452C12.402 16.6242 12.7129 16.5 13.037 16.5C13.3612 16.5 13.6721 16.6242 13.9013 16.8452C14.1305 17.0662 14.2593 17.366 14.2593 17.6786V19.6429H15.8889V17.268C15.889 17.0875 15.9321 16.9093 16.0149 16.7474C16.0978 16.5854 16.2181 16.444 16.3666 16.334C18.3934 14.8304 19.5556 12.6019 19.5556 10.2143Z" fill="white"/> | |
| </svg> | |
| <span class="info"> | |
| <span class="sr-only">Demons killed:</span> <span class="count" data-demon-count>0</span> / <span class="count" data-demon-total>50</span> | |
| </span> | |
| </div> | |
| <div class="endless" id="endless-mode" data-show-on="ENDLESS_MODE"> | |
| <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="42" height="20" viewBox="0 0 42 20" fill="none"> | |
| <path d="M42 10C42.0002 11.9777 41.4043 13.9111 40.2876 15.5557C39.171 17.2002 37.5837 18.4819 35.7265 19.2388C33.8693 19.9957 31.8257 20.1937 29.8542 19.8078C27.8826 19.4219 26.0717 18.4694 24.6504 17.0708L24.5674 16.9825L14.4283 5.71885C13.5711 4.89093 12.4844 4.33055 11.3047 4.10802C10.125 3.8855 8.90474 4.01075 7.79711 4.46806C6.68947 4.92536 5.74379 5.69435 5.07874 6.67851C4.41369 7.66268 4.05891 8.81818 4.05891 10C4.05891 11.1818 4.41369 12.3373 5.07874 13.3215C5.74379 14.3057 6.68947 15.0746 7.79711 15.5319C8.90474 15.9892 10.125 16.1145 11.3047 15.892C12.4844 15.6695 13.5711 15.1091 14.4283 14.2812L14.95 13.7012C15.1269 13.5043 15.3416 13.3435 15.5817 13.2282C15.8217 13.1128 16.0826 13.0451 16.3492 13.029C16.6159 13.0128 16.8832 13.0485 17.1359 13.1339C17.3885 13.2194 17.6216 13.353 17.8218 13.5271C18.022 13.7012 18.1854 13.9123 18.3026 14.1486C18.4199 14.3848 18.4887 14.6414 18.5051 14.9038C18.5215 15.1661 18.4853 15.4291 18.3984 15.6777C18.3115 15.9263 18.1758 16.1556 17.9988 16.3526L17.4314 16.9825L17.3484 17.0708C15.927 18.469 14.1162 19.4211 12.1448 19.8068C10.1735 20.1925 8.13019 19.9944 6.27328 19.2375C4.41636 18.4807 2.82925 17.1991 1.71262 15.5549C0.595995 13.9106 0 11.9775 0 10C0 8.0225 0.595995 6.0894 1.71262 4.44514C2.82925 2.80088 4.41636 1.51931 6.27328 0.762477C8.13019 0.00564237 10.1735 -0.192466 12.1448 0.193202C14.1162 0.578871 15.927 1.531 17.3484 2.92918L17.4314 3.01751L27.5705 14.2812C28.4277 15.1091 29.5143 15.6695 30.6941 15.892C31.8738 16.1145 33.094 15.9892 34.2017 15.5319C35.3093 15.0746 36.255 14.3057 36.92 13.3215C37.5851 12.3373 37.9399 11.1818 37.9399 10C37.9399 8.81818 37.5851 7.66268 36.92 6.67851C36.255 5.69435 35.3093 4.92536 34.2017 4.46806C33.094 4.01075 31.8738 3.8855 30.6941 4.10802C29.5143 4.33055 28.4277 4.89093 27.5705 5.71885L27.0488 6.29878C26.8719 6.49574 26.6572 6.65648 26.4171 6.77182C26.177 6.88717 25.9162 6.95486 25.6495 6.97103C25.3829 6.9872 25.1156 6.95154 24.8629 6.86607C24.6102 6.7806 24.3772 6.64701 24.177 6.47292C23.9768 6.29883 23.8134 6.08765 23.6962 5.85144C23.5789 5.61523 23.5101 5.35862 23.4937 5.09624C23.4772 4.83387 23.5135 4.57088 23.6004 4.3223C23.6872 4.07371 23.823 3.84439 24 3.64743L24.5674 3.01751L24.6504 2.92918C26.0717 1.53059 27.8826 0.578099 29.8542 0.192194C31.8257 -0.193711 33.8693 0.00430047 35.7265 0.761184C37.5837 1.51807 39.171 2.79982 40.2876 4.44434C41.4043 6.08885 42.0002 8.02225 42 10Z" fill="white"/> | |
| </svg> | |
| <span class="info">Endless Mode</span> | |
| </div> | |
| <div class="paused" id="paused" data-show-on="ENDLESS_PAUSE,PAUSED"> | |
| <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"> | |
| <path d="M14.4444 3.44444L12 1M12 1L9.55556 3.44444M12 1V23M12 23L14.4444 20.5556M12 23L9.55556 20.5556M20.5556 14.4444L23 12M23 12L20.5556 9.55556M23 12H1M1 12L3.44444 14.4444M1 12L3.44444 9.55556" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| <span class="info">Paused, drag to move around the scene</span> | |
| </div> | |
| </div> | |
| <div class="right"> | |
| <button id="close-button" data-send="end" data-show-on="ENDLESS_MODE,ENDLESS_PAUSE"> | |
| <span class="sr-only">Back to the menu.</span> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none"> | |
| <path d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z" fill="white"/> | |
| </svg> | |
| </button> | |
| <button id="pause-button" data-send="pause" data-show-on="GAME_RUNNING,PAUSED,ENDLESS_MODE,ENDLESS_PAUSE"> | |
| <span class="sr-only">Pause the game.</span> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="12" height="14" viewBox="0 0 12 14" fill="none"> | |
| <path d="M8 14V0H12V14H8ZM0 14V0H4V14H0Z" fill="white"/> | |
| </svg> | |
| </button> | |
| <button id="sounds-button" class="show-unless" > | |
| <span class="sr-only" data-copy="Turn sounds $$state."></span> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="17" viewBox="0 0 18 17" fill="none"> | |
| <path d="M17.4776 0.522705V11.6023C17.4776 12.425 17.1508 13.2141 16.569 13.7959C15.9872 14.3777 15.1981 14.7045 14.3754 14.7045C13.5526 14.7045 12.7635 14.3777 12.1817 13.7959C11.5999 13.2141 11.2731 12.425 11.2731 11.6023C11.2731 10.7795 11.5999 9.9904 12.1817 9.40861C12.7635 8.82682 13.5526 8.49998 14.3754 8.49998C14.854 8.49998 15.306 8.60634 15.7049 8.80134V3.59839L6.84126 5.48634V13.375C6.84126 14.1978 6.51442 14.9868 5.93263 15.5686C5.35084 16.1504 4.56177 16.4773 3.73899 16.4773C2.91622 16.4773 2.12714 16.1504 1.54535 15.5686C0.963564 14.9868 0.636719 14.1978 0.636719 13.375C0.636719 12.5522 0.963564 11.7631 1.54535 11.1813C2.12714 10.5996 2.91622 10.2727 3.73899 10.2727C4.21763 10.2727 4.66967 10.3791 5.06854 10.5741V3.1818L17.4776 0.522705Z" fill="white"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- MAIN CONTENT THAT SITS OVER THE GAME --> | |
| <div class="screens"> | |
| <!-- SPELLS --> | |
| <div class="spells" data-send="spells"> | |
| <div class="background" data-flip-spell></div> | |
| <div class="spell-details"> | |
| <div class="spell-path" id="spell-svg-viz-arcane" > | |
| <svg class="check" xmlns="http://www.w3.org/2000/svg" width="27" height="20" viewBox="0 0 27 20" fill="none"> | |
| <use href="#check" /> | |
| </svg> | |
| <svg data-flip-spell xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129 98" fill="none"> | |
| <path class="guide-path" d="M5 5V76C5 87.0457 13.9543 96 25 96H107C118.046 96 127 87.0457 127 76V25C127 13.9543 118.046 5 107 5H83.0416C71.9959 5 63.0416 13.9543 63.0416 25V53.6304" stroke-width="4"/> | |
| <path id="spell-path-viz-arcane" class="charge-path" d="M5 5V76C5 87.0457 13.9543 96 25 96H107C118.046 96 127 87.0457 127 76V25C127 13.9543 118.046 5 107 5H83.0416C71.9959 5 63.0416 13.9543 63.0416 25V53.6304" stroke="#9BCFFF"/> | |
| <circle cx="5" cy="5" r="5" fill="#9BCFFF"/> | |
| <path d="M54 44L63 56.5L71.5 44" stroke="#9BCFFF" stroke-width="4"/> | |
| </svg> | |
| </div> | |
| <div class="info"> | |
| <h4 data-flip-spell>Arcane</h4> | |
| <p data-flip-spell>The reliable Arcane spell shoots a powerful bolt of magic, killing one demon. It has a fast recharge.</p> | |
| </div> | |
| </div> | |
| <div class="spell-details"> | |
| <div class="spell-path" id="spell-svg-viz-fire"> | |
| <svg class="check" xmlns="http://www.w3.org/2000/svg" width="27" height="20" viewBox="0 0 27 20" fill="none"> | |
| <use href="#check" /> | |
| </svg> | |
| <svg data-flip-spell xmlns="http://www.w3.org/2000/svg" viewBox="0 0 148 120" fill="none"> | |
| <path class="guide-path" d="M5 93.4113L17.9176 55.9579C23.6054 39.4668 47.2737 40.5189 51.4757 57.4497V57.4497C55.7764 74.778 80.2103 75.318 85.2723 58.1966L86.0675 55.507C91.1314 38.379 115.23 37.9355 120.92 54.8656L138 105.677" stroke-width="4"/> | |
| <path id="spell-path-viz-fire" class="charge-path" d="M5 93.4113L17.9176 55.9579C23.6054 39.4668 47.2737 40.5189 51.4757 57.4497V57.4497C55.7764 74.778 80.2103 75.318 85.2723 58.1966L86.0675 55.507C91.1314 38.379 115.23 37.9355 120.92 54.8656L138 105.677" stroke="#F2C092" /> | |
| <circle cx="5" cy="93" r="5" fill="#F2C092"/> | |
| <path d="M126.359 99.4829L139.186 108.011L142.738 93.3183" stroke="#F2C092" stroke-width="4"/> | |
| </svg> | |
| </div> | |
| <div class="info"> | |
| <h4 data-flip-spell>Fire</h4> | |
| <p data-flip-spell>The Fire spell releases two fireballs, kills two unsuspected demons!</p> | |
| </div> | |
| </div> | |
| <div class="spell-details"> | |
| <div class="spell-path" id="spell-svg-viz-vortex"> | |
| <svg class="check" xmlns="http://www.w3.org/2000/svg" width="27" height="20" viewBox="0 0 27 20" fill="none"> | |
| <use href="#check" /> | |
| </svg> | |
| <svg data-flip-spell xmlns="http://www.w3.org/2000/svg" viewBox="0 0 136 170" fill="none"> | |
| <path class="guide-path" d="M75 166V61.7475C75 42.3543 50.1681 34.3112 38.798 50.0217L22.9608 71.9046C13.3899 85.129 22.8384 103.63 39.1628 103.63H78H105C116.046 103.63 125 112.585 125 123.63V166" stroke-width="4"/> | |
| <path id="spell-path-viz-vortex" class="charge-path" d="M75 166V61.7475C75 42.3543 50.1681 34.3112 38.798 50.0217L22.9608 71.9046C13.3899 85.129 22.8384 103.63 39.1628 103.63H78H105C116.046 103.63 125 112.585 125 123.63V166" stroke="#C5F298"/> | |
| <circle cx="75" cy="165" r="5" fill="#C5F298"/> | |
| <path d="M116 154L125 166.5L133.5 154" stroke="#C5F298" stroke-width="4"/> | |
| </svg> | |
| </div> | |
| <div class="info"> | |
| <h4 data-flip-spell>Vortex</h4> | |
| <p data-flip-spell>Opens a vortex that sucks in all the demons in the room. This one takes a while to charge so choose when to use it wisely!</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div data-screen="LOADING" class="loading"> | |
| <div class="content"> | |
| <span>Loading...</span> | |
| <div class="loading-bar"></div> | |
| </div> | |
| </div> | |
| <div data-screen="LOAD_ERROR" class="load-error"> | |
| <div class="content"> | |
| <span >Load Error</span> | |
| </div > | |
| </div> | |
| <div data-screen="TITLE_SCREEN" class="title"> | |
| <div class="content"> | |
| <h1 data-fade>Spell<br/>Caster</h1> | |
| <button data-send="next" data-fade>Start</button> | |
| <ul class="button-row"> | |
| <li><button data-fade class="simple" data-send="skip">Skip instructions</button></li> | |
| <li><button data-fade class="simple" data-send="endless">Endless mode</button></li> | |
| <li><button data-fade class="simple" data-send="credits">Credits</button></li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div data-screen="CREDITS" > | |
| <div class="content"> | |
| <h3 data-fade>Credits</h3> | |
| <ul> | |
| <li data-fade>Game code: <a href="https://twitter.com/steeevg" target="_blank">Steve Gardner</a></li> | |
| <li data-fade>Room model: <a href="https://quaternius.com/packs/ultimatemodularruins.html" target="_blank">Modular Ruins Pack</a> by <a href="https://quaternius.com/" target="_blank">Quaternius</a> </li> | |
| <li data-fade><a href="https://poly.pizza/m/3b3VmmxXZ7S" target="_blank" >Skeletal Hand</a>: by <a href="https://poly.pizza/u/Jeremy%20Swan" target="_blank">Jeremy Swan</a> </li> | |
| <li data-fade>Demon: An edited version of <a href="https://poly.pizza/m/Q0ZWVssZCg" target="_blank">Skeleton Boy</a> by <a href="https://poly.pizza/u/Polygonal%20Mind" target="_blank">Polygonal Mind</a></li> | |
| <li data-fade>Sound from <a href="https://zapsplat.com" target="_blank">Zapsplat.com</a></li> | |
| </ul> | |
| <button data-fade data-send="close">Back</button> | |
| </div> | |
| </div> | |
| <div data-screen="INSTRUCTIONS_CRYSTAL" class="instructions-crystal"> | |
| <div class="content"> | |
| <h3 data-fade>Protect the crystal</h3> | |
| <p data-fade>Welcome, Guardian. Your mission is clear: safeguard this crystal. Demons seek to destroy it, for if they succeed, the consequences will be catastrophic.</p> | |
| <button data-fade data-send="next">Next</button> | |
| </div> | |
| </div> | |
| <div data-screen="INSTRUCTIONS_DEMON" class="instructions-demon"> | |
| <div class="content"> | |
| <h3 data-fade>Face the onslaught</h3> | |
| <p data-fade>A horde of <span data-demon-total>50</span> demons approaches, relentless in their quest to seize the crystal's power. Stand resolute, for you alone are its defender. Ready your spells and prepare to face the coming onslaught.</p> | |
| <button data-send="next" data-fade>Next</button> | |
| </div> | |
| </div> | |
| <div data-screen="INSTRUCTIONS_CAST" class="instructions-cast"> | |
| <div class="content"> | |
| <svg id="spell-guide" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 505 385" fill="none"> | |
| <g filter="url(#filter0_d_200_49)"> | |
| <path d="M20.3333 47C20.3333 61.7276 32.2724 73.6667 47 73.6667C61.7276 73.6667 73.6667 61.7276 73.6667 47C73.6667 32.2724 61.7276 20.3333 47 20.3333C32.2724 20.3333 20.3333 32.2724 20.3333 47ZM249.464 217.536C251.417 219.488 254.583 219.488 256.536 217.536L288.355 185.716C290.308 183.763 290.308 180.597 288.355 178.645C286.403 176.692 283.237 176.692 281.284 178.645L253 206.929L224.716 178.645C222.763 176.692 219.597 176.692 217.645 178.645C215.692 180.597 215.692 183.763 217.645 185.716L249.464 217.536ZM42 47V339.5H52V47H42ZM67 364.5H460V354.5H67V364.5ZM485 339.5V67H475V339.5H485ZM460 42H273V52H460V42ZM248 67V214H258V67H248ZM273 42C259.193 42 248 53.1929 248 67H258C258 58.7157 264.716 52 273 52V42ZM485 67C485 53.1929 473.807 42 460 42V52C468.284 52 475 58.7157 475 67H485ZM460 364.5C473.807 364.5 485 353.307 485 339.5H475C475 347.784 468.284 354.5 460 354.5V364.5ZM42 339.5C42 353.307 53.1929 364.5 67 364.5V354.5C58.7157 354.5 52 347.784 52 339.5H42Z" fill="white"/> | |
| </g> | |
| <defs> | |
| <filter id="filter0_d_200_49" x="0.333008" y="0.333313" width="504.667" height="384.167" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> | |
| <feFlood flood-opacity="0" result="BackgroundImageFix"/> | |
| <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> | |
| <feOffset/> | |
| <feGaussianBlur stdDeviation="10"/> | |
| <feComposite in2="hardAlpha" operator="out"/> | |
| <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/> | |
| <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_200_49"/> | |
| <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_200_49" result="shape"/> | |
| </filter> | |
| </defs> | |
| </svg> | |
| <p data-fade>Drawing upon ancient magic, you can protect the crystal by casting spells in the air. Try drawing this shape to destroy the demon. </p> | |
| </div> | |
| </div> | |
| <div data-screen="INSTRUCTIONS_SPELLS" class="instructions-spells"> | |
| <div class="content"> | |
| <p data-fade>You possess three potent spells: Arcane, Fire, and Vortex. Each wields unique power, but beware, they take time to recharge.</p> | |
| <p data-fade>Now, stand tall and protect the crystal. The fate of our world rests in your hands.</p> | |
| <button data-fade data-send="next">Start</button> | |
| </div> | |
| </div> | |
| <div data-screen="PAUSED" class="paused"> | |
| <div class="content"> | |
| <button data-fade data-send="resume">Resume</button> | |
| <button data-fade class="simple" data-send="end">Back to menu</button> | |
| </div> | |
| </div> | |
| <div data-screen="SPELL_OVERLAY" class="spell-overlay"> | |
| <div class="content"> | |
| <button data-fade data-send="close">Close</button> | |
| </div> | |
| </div> | |
| <div data-screen="GAME_OVER" class="game-over"> | |
| <div class="content"> | |
| <h2 data-fade data-split>Game Over</h2> | |
| <button data-fade data-send="restart">Try again</button> | |
| <ul class="button-row"> | |
| <li><button data-fade class="simple" data-send="instructions">Instructions</button></li> | |
| <li><button data-fade class="simple" data-send="endless">Endless mode</button></li> | |
| <li><button data-fade class="simple" data-send="credits">Credits</button></li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div data-screen="WINNER" class="winner"> | |
| <div class="content"> | |
| <h2 data-fade>You did it!</h2> | |
| <button data-fade data-send="restart">Play again</button> | |
| <ul class="button-row"> | |
| <li><button data-fade class="simple" data-send="instructions">Instructions</button></li> | |
| <li><button data-fade class="simple" data-send="endless">Endless mode</button></li> | |
| <li><button data-fade class="simple" data-send="credits">Credits</button></li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="charging-notification"> | |
| <p>The <span class="charging-spell">Spell Name</span> spell is still charging</p> | |
| </div> | |
| <!-- | |
| DEBUG SCREENS AND OVERLAYS. | |
| THESE ONLY SHOW IF THEY ARE ENABLED IN JS | |
| --> | |
| <div class="debug-overlays"> | |
| <svg class="overlay" id="spell-helper" style="display: none"> | |
| <path id="spell-path" /> | |
| <g id="spell-points"></g> | |
| </svg> | |
| </div> | |
| <div class="debug-panels"> | |
| <div id="fps" class="panel" style="display: none"></div> | |
| <div id="health-states" class="panel" style="display: none"> | |
| <div class="health-bar"></div> | |
| </div> | |
| <div id="app-state" class="panel" style="display: none"> | |
| <div class="state"></div> | |
| <div class="controls"></div> | |
| </div> | |
| <div id="endless-mode" class="panel" style="display: none"> | |
| <p>Endless Mode</p> | |
| </div> | |
| <div class="panel" id="spell-stats" style="display: none"> | |
| <div class="spell-stat" data-spell-shape="spell-shape-arcane"> | |
| <h2>Arcane</h2> | |
| <div> | |
| <svg class="spell-preview" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113 86" fill="none"> | |
| <use href="#spell-shape-arcane" /> | |
| </svg> | |
| <div class="score">0</div> | |
| </div> | |
| </div> | |
| <div class="spell-stat" data-spell-shape="spell-shape-fire"> | |
| <h2>Fire</h2> | |
| <div> | |
| <svg class="spell-preview" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145 73" fill="none"> | |
| <use href="#spell-shape-fire" /> | |
| </svg> | |
| <div class="score">0</div> | |
| </div> | |
| </div> | |
| <div class="spell-stat" data-spell-shape="spell-shape-vortex"> | |
| <h2>Vortex</h2> | |
| <div> | |
| <svg class="spell-preview" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 111" fill="none"> | |
| <use href="#spell-shape-vortex" /> | |
| </svg> | |
| <div class="score">0</div> | |
| </div> | |
| </div> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { gsap } from "https://cdn.skypack.dev/gsap" | |
| import { MotionPathPlugin } from "https://cdn.skypack.dev/gsap/MotionPathPlugin" | |
| import { Flip } from "https://cdn.skypack.dev/gsap/Flip" | |
| gsap.registerPlugin(MotionPathPlugin, Flip) | |
| /* | |
| We need some simplex noise to help | |
| with the particle animations. More on | |
| that later, but for now we'll import | |
| it and create. | |
| */ | |
| import { createNoise3D } from "https://cdn.skypack.dev/simplex-noise" | |
| const noise3D = createNoise3D() | |
| /* | |
| We need a lot of Three.js features for | |
| this one. We could have just imported | |
| everything as just THREE but but I | |
| prefer to just grab what I need. | |
| You'll notice I'm just importing these | |
| from just 'three' rather than the skypack | |
| url. Thats because I have included an | |
| 'importmap' to the html <head>. You | |
| can see that in the settings under HTML. | |
| I could have probably done the same for | |
| others. | |
| */ | |
| import { | |
| AnimationMixer, | |
| Clock, | |
| PointLight, | |
| AmbientLight, | |
| ColorManagement, | |
| DirectionalLight, | |
| Group, | |
| LinearSRGBColorSpace, | |
| Mesh, | |
| PCFSoftShadowMap, | |
| PerspectiveCamera, | |
| ReinhardToneMapping, | |
| Scene, | |
| ShaderMaterial, | |
| WebGLRenderer, | |
| Color, | |
| Raycaster, | |
| ArrowHelper, | |
| Box3, | |
| Box3Helper, | |
| ConeGeometry, | |
| DoubleSide, | |
| MeshBasicMaterial, | |
| MeshMatcapMaterial, | |
| Plane, | |
| Vector2, | |
| AdditiveBlending, | |
| BufferAttribute, | |
| CustomBlending, | |
| OneFactor, | |
| Points, | |
| ZeroFactor, | |
| AxesHelper, | |
| BufferGeometry, | |
| TubeGeometry, | |
| CatmullRomCurve3, | |
| Vector3, | |
| PlaneGeometry, | |
| Audio, | |
| AudioListener, | |
| SphereGeometry, | |
| LoadingManager, | |
| TextureLoader, | |
| AudioLoader, | |
| } from "three" | |
| /* | |
| Some extra bits we need from Three.js. | |
| */ | |
| import { OrbitControls } from "three/addons/controls/OrbitControls.js" | |
| import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js" | |
| import { RenderPass } from "three/addons/postprocessing/RenderPass.js" | |
| import { SSAOPass } from "three/addons/postprocessing/SSAOPass.js" | |
| import { GUI } from "three/addons/libs/lil-gui.module.min.js" | |
| import Stats from "three/addons/libs/stats.module.js" | |
| import { GLTFLoader } from "three/addons/loaders/GLTFLoader" | |
| import { DRACOLoader } from "three/addons/loaders/DRACOLoader" | |
| /* | |
| And finally we need to import xState. | |
| There are a lot of states for the game | |
| and things can get tricky to manage with | |
| just booleans. I love state machines and | |
| XState combined with stately.ai makes | |
| things so much easier. More on this later... | |
| */ | |
| import { interpret, createMachine } from "https://cdn.skypack.dev/xstate@4.33.6" | |
| /* | |
| _____ _ | |
| | __ \ | | | |
| | | | | ___| |__ _ _ __ _ | |
| | | | |/ _ \ '_ \| | | |/ _` | | |
| | |__| | __/ |_) | |_| | (_| | | |
| |_____/ \___|_.__/ \__,_|\__, | | |
| __/ | | |
| |___/ | |
| There are a lot of features built just | |
| to help debugging. I left most of them | |
| in so you can turn them on and off here. | |
| Many of these do a great job helping | |
| explain how things are working under | |
| the hood. So have a play!! Playing | |
| the game with them all set to true | |
| is hard mode! | |
| (You might be wondering why I stored | |
| these on window. It's mainly becuase | |
| I didn't know where I would need these | |
| settings and what their scope would be. | |
| So just to make things simpiler I just | |
| plonked them on window. Not something I | |
| recommend you do, there are usually | |
| better ways.) | |
| */ | |
| window.DEBUG = { | |
| /* | |
| Show FPS meter. | |
| */ | |
| fps: false, | |
| /* | |
| Show current app state | |
| and available state actions | |
| as defined in the app-machine | |
| */ | |
| appState: false, | |
| /* | |
| Show information about the | |
| the available spells, the current | |
| spell being drawn and confidence | |
| score for each | |
| */ | |
| casting: false, | |
| /* | |
| Add yellow arrows that show the | |
| particle sim flow field, this is | |
| a vector grid that applies directional | |
| and speed influence on each particle | |
| */ | |
| simFlow: false, | |
| /* | |
| The flow field also has noise | |
| applied to it. This shows those | |
| values with red arrows. | |
| */ | |
| simNoise: false, | |
| /* | |
| Some particles are invisible | |
| and just there to apply force | |
| to the flow field. Setting this | |
| to true renders them as red | |
| particles | |
| */ | |
| forceParticles: false, | |
| /* | |
| This shows where the pre-defined | |
| demon locations are. Also all | |
| the paths the can use to enter | |
| the room. It's a messy view but | |
| fun to see! | |
| */ | |
| locations: false, | |
| /* | |
| This adds 2 white spheres for each | |
| entrance: The door, the trapdoor, | |
| the right window and the left hole. | |
| We use 2 points for each to help | |
| define the initial angle of the path. | |
| */ | |
| entrances: false, | |
| /* | |
| The exact plae of point lights can | |
| sometimes be tricky to see, so this | |
| just adds some spheres to help | |
| position things. | |
| */ | |
| lights: false, | |
| /* | |
| The animation for the demon trails | |
| as they enter the room happen pretty | |
| fast so this just renders them as | |
| solid red while they are in the | |
| scene. Useful early on when I was | |
| debugging their positions. | |
| */ | |
| trail: false, | |
| /* | |
| The sounds can be annoying while | |
| developing. So this just has them | |
| turned off by default | |
| */ | |
| disableSounds: false, | |
| /* | |
| While the game is paused I enable | |
| orbit controls, which means the user | |
| can move around. Orbit controlls | |
| normally allow you to move the | |
| 'look at' point while holding shift | |
| but that was disabled by my tick | |
| function. Enabling this stops that | |
| code in the tick but also breaks some | |
| of the camera angles. This one is | |
| super useful to turn on and get | |
| some screenshots. | |
| */ | |
| allowLookAtMoveWhenPaused: false, | |
| /* | |
| Aligning the HTML elements over | |
| the top of the 3D scene can sometimes | |
| be tricky so this just adds some | |
| debug outlines to the HTML layout | |
| elements. | |
| */ | |
| layoutDebug: false, | |
| } | |
| /* | |
| _____ _ | |
| / ____| | | | |
| | | ___ _ __ ___| |_ ___ | |
| | | / _ \| '_ \/ __| __/ __| | |
| | |___| (_) | | | \__ \ |_\__ \ | |
| \_____\___/|_| |_|___/\__|___/ | |
| We sometimes need to do the same | |
| operation on all three axes. So | |
| we can use this array to loop of | |
| them rather than writing them 3 times. | |
| */ | |
| const AXIS = ["x", "y", "z"] | |
| /* | |
| The particle shapes are stored in one | |
| image. So this just stores the position | |
| of each shape so we can just reference | |
| them in a handy name rather than | |
| remembering all the numbers. | |
| */ | |
| const PARTICLE_STYLES = { | |
| invisible: 0, | |
| smoke: 1, | |
| plus: 2, | |
| soft: 3, | |
| point: 4, | |
| circle: 5, | |
| flame: 6, | |
| } | |
| /* | |
| Putting names in an object like this | |
| is useful for IDE auto complete and | |
| helps prevent typos. If you were using | |
| Typescript you might have used an Enum | |
| for this instead. | |
| */ | |
| const SPELLS = { | |
| arcane: "arcane", | |
| fire: "fire", | |
| vortex: "vortex", | |
| } | |
| /* | |
| We'll talk more about the emitters later | |
| on but for now just know that there are | |
| a few of them but only a few settings | |
| change between each. It's useful to store | |
| the common settings here and overwrite | |
| the ones that changed in each instance. | |
| */ | |
| const DEFAULT_EMITTER_SETTINGS = { | |
| startingPosition: { x: 0.5, y: 0.5, z: 0.5 }, | |
| startingDirection: { x: 0, y: 0, z: 0 }, | |
| emitRate: 0.001, | |
| particleOrder: [], | |
| model: null, | |
| animationDelay: 0, | |
| lightColor: { r: 1, g: 1, b: 1 }, | |
| group: "magic", | |
| } | |
| /* | |
| I originally planned to have more enemy | |
| types, with certain spells only working | |
| on certain enemies. I also wanted to have | |
| this complete for Halloween and it turns | |
| out those 2 ideas were not compatible! | |
| Anyways, that was a long way to say the | |
| object isn't all that useful now but it's | |
| still good practice to store settings | |
| like this anyways. | |
| */ | |
| const DEFAULT_ENEMY_SETTINGS = { | |
| position: { x: 0, y: 0, z: 0 }, | |
| model: null, | |
| animationDelay: 0, | |
| } | |
| /* | |
| Like the emitter defaults above the particles | |
| also share a lot of common settings. This | |
| object saves the most common ones and each | |
| instance overwites the ones it needs to | |
| change. | |
| */ | |
| const DEFAULT_PARTICLE_SETTINGS = { | |
| speed: 0.2, | |
| speedDecay: 0.6, | |
| speedSpread: 0, | |
| force: 0.2, | |
| forceDecay: 0.1, | |
| forceSpread: 0, | |
| life: 1, | |
| lifeDecay: 0.6, | |
| directionSpread: { x: 0.001, y: 0.001, z: 0.001 }, | |
| positionSpread: { x: 0.01, y: 0.01, z: 0.01 }, | |
| color: { r: 1, g: 1, b: 1 }, | |
| scale: 1, | |
| scaleSpread: 0, | |
| style: PARTICLE_STYLES.soft, | |
| acceleration: 0.1, | |
| } | |
| /* | |
| I wanted to build this without a framework | |
| like React. Mainly becuase I think it's good | |
| practice to every now and then. So this | |
| just grabs and stores some useful DOM elements | |
| we're going to need later. | |
| */ | |
| const DOM = { | |
| body: document.body, | |
| app: document.querySelector(".app"), | |
| state: document.querySelector(".state"), | |
| controls: document.querySelector(".controls"), | |
| canvas: document.querySelector(".canvas"), | |
| svg: document.querySelector("#spell-helper"), | |
| demonCount: document.querySelector("[data-demon-count]"), | |
| spellGuide: document.querySelector("#spell-guide"), | |
| } | |
| /* | |
| This sounds like the same thing as the | |
| DEFAULT_ENEMY_SETTINGS above, I perhaps | |
| did a bad job naming this, but this is state | |
| settings for when and how many enemies | |
| to send. The reason it's a seperate const | |
| is beacuse we use this as the reset at | |
| the start of the game. We can then change | |
| a few depending on the game mode too. | |
| */ | |
| const ENEMY_SETTINGS = { | |
| lastSent: 0, | |
| sendFrequency: 5, | |
| sendFrequencyReduceBy: 0.2, | |
| minSendFrequency: 2, | |
| totalSend: 42, | |
| sendCount: 0, | |
| killCount: 0, | |
| } | |
| /* | |
| These are all the assets the game needs. | |
| We'll load all of these while showing the | |
| loading screen. We define them all here in | |
| this handy dandy object. I also define some | |
| transforms to the models, their often massive | |
| or their default position isn't ideal. | |
| */ | |
| const TO_LOAD = { | |
| models: [ | |
| { id: "room", file: "https://assets.codepen.io/557388/room.glb", scale: 0.15, position: [0.03, -0.26, -0.55] }, | |
| { id: "demon", file: "https://assets.codepen.io/557388/demon.glb", scale: 0.1, position: [0, 0, 0] }, | |
| { id: "crystal", file: "https://assets.codepen.io/557388/crystal.glb", scale: 0.05, position: [0, 0, 0] }, | |
| ], | |
| sounds: [ | |
| { id: "music", file: "https://assets.codepen.io/557388/music.mp3" }, | |
| { id: "kill-1", file: "https://assets.codepen.io/557388/kill-1.mp3" }, | |
| { id: "kill-2", file: "https://assets.codepen.io/557388/kill-2.mp3" }, | |
| { id: "kill-3", file: "https://assets.codepen.io/557388/kill-3.mp3" }, | |
| { id: "enter-1", file: "https://assets.codepen.io/557388/enter-1.mp3" }, | |
| { id: "enter-2", file: "https://assets.codepen.io/557388/enter-2.mp3" }, | |
| { id: "error-1", file: "https://assets.codepen.io/557388/error-1.mp3" }, | |
| { id: "cast-1", file: "https://assets.codepen.io/557388/cast-1.mp3" }, | |
| { id: "cast-2", file: "https://assets.codepen.io/557388/cast-2.mp3" }, | |
| { id: "ping-1", file: "https://assets.codepen.io/557388/ping-1.mp3" }, | |
| { id: "ping-2", file: "https://assets.codepen.io/557388/ping-2.mp3" }, | |
| { id: "laugh-1", file: "https://assets.codepen.io/557388/laugh-1.mp3" }, | |
| { id: "laugh-2", file: "https://assets.codepen.io/557388/laugh-2.mp3" }, | |
| { id: "laugh-3", file: "https://assets.codepen.io/557388/laugh-3.mp3" }, | |
| { id: "spell-travel-1", file: "https://assets.codepen.io/557388/spell-travel-1.mp3" }, | |
| { id: "spell-travel-2", file: "https://assets.codepen.io/557388/spell-travel-2.mp3" }, | |
| { id: "spell-travel-3", file: "https://assets.codepen.io/557388/spell-travel-3.mp3" }, | |
| { id: "spell-failed-1", file: "https://assets.codepen.io/557388/spell-failed-1.mp3" }, | |
| { id: "spell-failed-2", file: "https://assets.codepen.io/557388/spell-failed-2.mp3" }, | |
| { id: "trapdoor-close-1", file: "https://assets.codepen.io/557388/trapdoor-close-1.mp3" }, | |
| { id: "trapdoor-close-2", file: "https://assets.codepen.io/557388/trapdoor-close-2.mp3" }, | |
| { id: "torch-1", file: "https://assets.codepen.io/557388/torch-1.mp3" }, | |
| { id: "torch-2", file: "https://assets.codepen.io/557388/torch-2.mp3" }, | |
| { id: "torch-3", file: "https://assets.codepen.io/557388/torch-3.mp3" }, | |
| { id: "crystal-explode", file: "https://assets.codepen.io/557388/crystal-explode.mp3" }, | |
| { id: "crystal-reform", file: "https://assets.codepen.io/557388/crystal-reform.mp3" }, | |
| { id: "glitch", file: "https://assets.codepen.io/557388/glitch.mp3" }, | |
| { id: "portal", file: "https://assets.codepen.io/557388/portal.mp3" }, | |
| { id: "crumble", file: "https://assets.codepen.io/557388/crumble.mp3" }, | |
| { id: "reform", file: "https://assets.codepen.io/557388/reform.mp3" }, | |
| ], | |
| textures: [ | |
| { id: "magic-particles", file: "https://assets.codepen.io/557388/magic-particles.png" }, | |
| { id: "smoke-particles", file: "https://assets.codepen.io/557388/smoke-particles.png" }, | |
| { id: "spell-arcane", file: "https://assets.codepen.io/557388/spell-arcane.png" }, | |
| { id: "crystal-matcap", file: "https://assets.codepen.io/557388/crystal-matcap.png" }, | |
| ], | |
| } | |
| /* | |
| There are 2 groups of particles and they | |
| both work almost identically. The only | |
| real difference is their blend modes. So | |
| these are the shared material settings for | |
| these. | |
| */ | |
| const DEFAULT_PARTICLE_MATERIAL_SETTINGS = { | |
| depthWrite: false, | |
| vertexColors: true, | |
| vertexShader: ` | |
| uniform float uSize; | |
| uniform float uTime; | |
| uniform bool uGrow; | |
| attribute float scale; | |
| attribute float life; | |
| attribute float type; | |
| attribute vec3 random; | |
| varying vec3 vColor; | |
| varying float vLife; | |
| varying float vType; | |
| varying vec3 vRandom; | |
| void main() | |
| { | |
| /** | |
| * Position | |
| */ | |
| vec4 modelPosition = modelMatrix * vec4(position, 1.0); | |
| vec4 viewPosition = viewMatrix * modelPosition; | |
| vec4 projectedPosition = projectionMatrix * viewPosition; | |
| float spiralRadius = 0.1; | |
| gl_Position = projectedPosition; | |
| vColor = color; | |
| vRandom = random; | |
| vLife = life; | |
| vType = type; | |
| /** | |
| * Size | |
| */ | |
| if(uGrow) { | |
| gl_PointSize = uSize * scale * (2.5 - life); | |
| } | |
| else { | |
| gl_PointSize = uSize * scale * life; | |
| } | |
| gl_PointSize *= (1.0 / - viewPosition.z); | |
| } | |
| `, | |
| } | |
| /* | |
| The particle emmiters need to copy a lot | |
| of particle settings around so we use these | |
| arrays to help loop through thems. | |
| */ | |
| const PROPERTIES = { | |
| vec3: ["position", "direction", "random", "color"], | |
| float: ["type", "speed", "speedDecay", "force", "forceDecay", "acceleration", "life", "lifeDecay", "size"], | |
| } | |
| /* | |
| _____ _ _ | |
| / ____| | | | | |
| | (___ | |_ __ _| |_ ___ | |
| \___ \| __/ _ | __/ _ \ | |
| ____) | || (_| | || __/ | |
| |_____/ \__\__,_|\__\___| _ | |
| | | (_) | |
| _ __ ___ __ _ ___| |__ _ _ __ ___ ___ | |
| | _ _ \ / _ |/ __| _ \| | _ \ / _ \/ __| | |
| | | | | | | (_| | (__| | | | | | | | __/\__ \ | |
| |_| |_| |_|\__,_|\___|_| |_|_|_| |_|\___||___/ | |
| */ | |
| const AppMachine = createMachine( | |
| { | |
| id: "App", | |
| initial: "IDLE", | |
| states: { | |
| IDLE: { | |
| on: { | |
| load: { | |
| target: "LOADING", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| LOADING: { | |
| on: { | |
| error: { | |
| target: "LOAD_ERROR", | |
| internal: false, | |
| }, | |
| complete: { | |
| target: "INIT", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| LOAD_ERROR: { | |
| on: { | |
| reload: { | |
| target: "LOADING", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| INIT: { | |
| on: { | |
| begin: { | |
| target: "TITLE_SCREEN", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| TITLE_SCREEN: { | |
| on: { | |
| next: { | |
| target: "INSTRUCTIONS_CRYSTAL", | |
| internal: false, | |
| }, | |
| skip: { | |
| target: "SETUP_GAME", | |
| internal: false, | |
| }, | |
| credits: { | |
| target: "CREDITS", | |
| internal: false, | |
| }, | |
| endless: { | |
| target: "SETUP_ENDLESS", | |
| internal: false, | |
| }, | |
| debug: { | |
| target: "SCENE_DEBUG", | |
| }, | |
| }, | |
| }, | |
| INSTRUCTIONS_CRYSTAL: { | |
| on: { | |
| next: { | |
| target: "INSTRUCTIONS_DEMON", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| SETUP_GAME: { | |
| on: { | |
| run: { | |
| target: "GAME_RUNNING", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| CREDITS: { | |
| on: { | |
| close: { | |
| target: "TITLE_SCREEN", | |
| internal: false, | |
| }, | |
| end: { | |
| target: "TITLE_SCREEN", | |
| }, | |
| }, | |
| }, | |
| SETUP_ENDLESS: { | |
| on: { | |
| run: { | |
| target: "ENDLESS_MODE", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| SCENE_DEBUG: { | |
| on: { | |
| close: { | |
| target: "TITLE_SCREEN", | |
| }, | |
| }, | |
| }, | |
| INSTRUCTIONS_DEMON: { | |
| on: { | |
| next: { | |
| target: "INSTRUCTIONS_CAST", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| GAME_RUNNING: { | |
| on: { | |
| pause: { | |
| target: "PAUSED", | |
| internal: false, | |
| }, | |
| "game-over": { | |
| target: "GAME_OVER_ANIMATION", | |
| internal: false, | |
| }, | |
| spells: { | |
| target: "SPELL_OVERLAY", | |
| internal: false, | |
| }, | |
| win: { | |
| target: "WIN_ANIMATION", | |
| }, | |
| special: { | |
| target: "SPECIAL_SPELL", | |
| }, | |
| }, | |
| }, | |
| ENDLESS_MODE: { | |
| on: { | |
| end: { | |
| target: "CLEAR_ENDLESS", | |
| internal: false, | |
| }, | |
| pause: { | |
| target: "ENDLESS_PAUSE", | |
| internal: false, | |
| }, | |
| spells: { | |
| target: "ENDLESS_SPELL_OVERLAY", | |
| }, | |
| special: { | |
| target: "ENDLESS_SPECIAL_SPELL", | |
| }, | |
| }, | |
| }, | |
| INSTRUCTIONS_CAST: { | |
| on: { | |
| next: { | |
| target: "INSTRUCTIONS_SPELLS", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| PAUSED: { | |
| on: { | |
| resume: { | |
| target: "GAME_RUNNING", | |
| internal: false, | |
| }, | |
| end: { | |
| target: "CLEAR_GAME", | |
| }, | |
| }, | |
| }, | |
| GAME_OVER_ANIMATION: { | |
| on: { | |
| end: { | |
| target: "GAME_OVER", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| SPELL_OVERLAY: { | |
| on: { | |
| close: { | |
| target: "GAME_RUNNING", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| WIN_ANIMATION: { | |
| on: { | |
| end: { | |
| target: "WINNER", | |
| }, | |
| }, | |
| }, | |
| SPECIAL_SPELL: { | |
| on: { | |
| complete: { | |
| target: "GAME_RUNNING", | |
| }, | |
| win: { | |
| target: "WIN_ANIMATION", | |
| }, | |
| }, | |
| }, | |
| CLEAR_ENDLESS: { | |
| on: { | |
| end: { | |
| target: "TITLE_SCREEN", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| ENDLESS_PAUSE: { | |
| on: { | |
| end: { | |
| target: "CLEAR_ENDLESS", | |
| internal: false, | |
| }, | |
| resume: { | |
| target: "ENDLESS_MODE", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| ENDLESS_SPELL_OVERLAY: { | |
| on: { | |
| close: { | |
| target: "ENDLESS_MODE", | |
| }, | |
| }, | |
| }, | |
| ENDLESS_SPECIAL_SPELL: { | |
| on: { | |
| complete: { | |
| target: "ENDLESS_MODE", | |
| }, | |
| }, | |
| }, | |
| INSTRUCTIONS_SPELLS: { | |
| on: { | |
| next: { | |
| target: "SETUP_GAME", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| CLEAR_GAME: { | |
| on: { | |
| end: { | |
| target: "TITLE_SCREEN", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| GAME_OVER: { | |
| on: { | |
| restart: { | |
| target: "SETUP_GAME", | |
| internal: false, | |
| }, | |
| instructions: { | |
| target: "RESETTING_FOR_INSTRUCTIONS", | |
| internal: false, | |
| }, | |
| credits: { | |
| target: "RESETTING_FOR_CREDITS", | |
| internal: false, | |
| }, | |
| endless: { | |
| target: "SETUP_ENDLESS", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| WINNER: { | |
| on: { | |
| restart: { | |
| target: "SETUP_GAME", | |
| }, | |
| instructions: { | |
| target: "INSTRUCTIONS_CRYSTAL", | |
| }, | |
| credits: { | |
| target: "CREDITS", | |
| }, | |
| endless: { | |
| target: "SETUP_ENDLESS", | |
| }, | |
| }, | |
| }, | |
| RESETTING_FOR_INSTRUCTIONS: { | |
| on: { | |
| run: { | |
| target: "INSTRUCTIONS_CRYSTAL", | |
| }, | |
| }, | |
| }, | |
| RESETTING_FOR_CREDITS: { | |
| on: { | |
| run: { | |
| target: "CREDITS", | |
| }, | |
| }, | |
| }, | |
| }, | |
| predictableActionArguments: true, | |
| preserveActionOrder: true, | |
| }, | |
| { | |
| actions: {}, | |
| services: {}, | |
| guards: {}, | |
| delays: {}, | |
| } | |
| ) | |
| const CasterMachine = createMachine( | |
| { | |
| id: "Caster", | |
| initial: "IDLE", | |
| states: { | |
| IDLE: { | |
| on: { | |
| ready: { | |
| target: "INACTIVE", | |
| }, | |
| }, | |
| }, | |
| INACTIVE: { | |
| on: { | |
| activate: { | |
| target: "ACTIVE", | |
| }, | |
| }, | |
| }, | |
| ACTIVE: { | |
| on: { | |
| start_cast: { | |
| target: "CASTING", | |
| }, | |
| deactivate: { | |
| target: "INACTIVE", | |
| }, | |
| }, | |
| }, | |
| CASTING: { | |
| on: { | |
| finished: { | |
| target: "PROCESSING", | |
| }, | |
| deactivate: { | |
| target: "INACTIVE", | |
| }, | |
| }, | |
| }, | |
| PROCESSING: { | |
| on: { | |
| success: { | |
| target: "SUCCESS", | |
| }, | |
| fail: { | |
| target: "FAIL", | |
| }, | |
| deactivate: { | |
| target: "INACTIVE", | |
| }, | |
| }, | |
| }, | |
| SUCCESS: { | |
| on: { | |
| complete: { | |
| target: "ACTIVE", | |
| }, | |
| deactivate: { | |
| target: "INACTIVE", | |
| }, | |
| }, | |
| }, | |
| FAIL: { | |
| on: { | |
| complete: { | |
| target: "ACTIVE", | |
| }, | |
| deactivate: { | |
| target: "INACTIVE", | |
| }, | |
| }, | |
| }, | |
| }, | |
| predictableActionArguments: true, | |
| preserveActionOrder: true, | |
| }, | |
| { | |
| actions: {}, | |
| services: {}, | |
| guards: {}, | |
| delays: {}, | |
| } | |
| ) | |
| const CrystalMachine = createMachine( | |
| { | |
| id: "Crystal", | |
| initial: "IDLE", | |
| states: { | |
| IDLE: { | |
| on: { | |
| start: { | |
| target: "INIT", | |
| }, | |
| }, | |
| }, | |
| INIT: { | |
| on: { | |
| ready: { | |
| target: "WHOLE", | |
| }, | |
| }, | |
| }, | |
| WHOLE: { | |
| on: { | |
| overload: { | |
| target: "OVERLOADING", | |
| }, | |
| }, | |
| }, | |
| OVERLOADING: { | |
| on: { | |
| break: { | |
| target: "BREAKING", | |
| }, | |
| }, | |
| }, | |
| BREAKING: { | |
| on: { | |
| broke: { | |
| target: "BROKEN", | |
| }, | |
| }, | |
| }, | |
| BROKEN: { | |
| on: { | |
| fix: { | |
| target: "FIXING", | |
| }, | |
| }, | |
| }, | |
| FIXING: { | |
| on: { | |
| fixed: { | |
| target: "WHOLE", | |
| }, | |
| }, | |
| }, | |
| }, | |
| predictableActionArguments: true, | |
| preserveActionOrder: true, | |
| }, | |
| { | |
| actions: {}, | |
| services: {}, | |
| guards: {}, | |
| delays: {}, | |
| } | |
| ) | |
| const EnemyMachine = createMachine( | |
| { | |
| id: "Enemy", | |
| initial: "IDLE", | |
| states: { | |
| IDLE: { | |
| on: { | |
| spawn: { | |
| target: "ANIMATING_IN", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| ANIMATING_IN: { | |
| on: { | |
| complete: { | |
| target: "ALIVE", | |
| internal: false, | |
| }, | |
| accend: { | |
| target: "ACCEND", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| ALIVE: { | |
| on: { | |
| incoming: { | |
| target: "TAGGED", | |
| internal: false, | |
| }, | |
| accend: { | |
| target: "ACCEND", | |
| internal: false, | |
| }, | |
| vortex: { | |
| target: "VORTEX_ANIMATION", | |
| }, | |
| }, | |
| }, | |
| ACCEND: { | |
| on: { | |
| leave: { | |
| target: "GONE", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| TAGGED: { | |
| on: { | |
| kill: { | |
| target: "ANIMATING_OUT", | |
| internal: false, | |
| }, | |
| accend: { | |
| target: "ANIMATING_OUT", | |
| }, | |
| }, | |
| }, | |
| VORTEX_ANIMATION: { | |
| on: { | |
| complete: { | |
| target: "DEAD", | |
| }, | |
| }, | |
| }, | |
| GONE: {}, | |
| ANIMATING_OUT: { | |
| on: { | |
| complete: { | |
| target: "DEAD", | |
| internal: false, | |
| }, | |
| }, | |
| }, | |
| DEAD: {}, | |
| }, | |
| predictableActionArguments: true, | |
| preserveActionOrder: true, | |
| }, | |
| { | |
| actions: {}, | |
| services: {}, | |
| guards: {}, | |
| delays: {}, | |
| } | |
| ) | |
| // UTILS | |
| const degToRad = (value) => { | |
| return (value * Math.PI) / 180 | |
| } | |
| const simplelerp = (start, end, amount) => { | |
| return start + amount * (end - start) | |
| } | |
| const randomFromArray = (arr) => { | |
| if (!arr || !arr.length) return null | |
| return arr[Math.floor(Math.random() * arr.length)] | |
| } | |
| // VECTOR UTILS | |
| const lerpVectors = (start, end, amount) => { | |
| // return { | |
| // x: lerp(start.x, end.x, amount), | |
| // y: lerp(start.y, end.y, amount), | |
| // z: lerp(start.z, end.z, amount), | |
| // } | |
| return { | |
| x: start.x + (end.x - start.x) * amount, | |
| y: start.y + (end.y - start.y) * amount, | |
| z: start.z + (end.z - start.z) * amount, | |
| } | |
| // return this; | |
| } | |
| const multiplyScalar = (vector, amount) => { | |
| return { | |
| x: vector.x * amount, | |
| y: vector.y * amount, | |
| z: vector.z * amount, | |
| } | |
| } | |
| const divideScalar = (vector, amount) => { | |
| return multiplyScalar(vector, 1 / amount) | |
| } | |
| const add = (a, b) => { | |
| return { | |
| x: a.x + b.x, | |
| y: a.y + b.y, | |
| z: a.z + b.z, | |
| } | |
| } | |
| const normalize = (vector) => { | |
| const length = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z) | |
| return { | |
| x: vector.x / length, | |
| y: vector.y / length, | |
| z: vector.z / length, | |
| } | |
| } | |
| const clamp = (vector, min, max) => { | |
| vector.x = Math.max(min.x, Math.min(max.x, vector.x)) | |
| vector.y = Math.max(min.y, Math.min(max.y, vector.y)) | |
| vector.z = Math.max(min.z, Math.min(max.z, vector.z)) | |
| return vector | |
| } | |
| const length = (vector) => { | |
| return Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z) | |
| } | |
| const clampLength = (vector, min, max) => { | |
| const l = length(vector) | |
| const divided = divideScalar(vector, l || 1) | |
| return multiplyScalar(divided, Math.max(min, Math.min(max, l))) | |
| } | |
| const vector = { | |
| lerpVectors, | |
| multiplyScalar, | |
| divideScalar, | |
| add, | |
| normalize, | |
| clamp, | |
| length, | |
| clampLength, | |
| } | |
| const math = { | |
| degToRad, | |
| } | |
| // EMITTERS | |
| class ControlledEmitter { | |
| constructor(sim) { | |
| this.sim = sim | |
| this.particles = {} | |
| this.remainingTime = 0 | |
| this.active = false | |
| this.gsapDefaults = { | |
| onUpdate: this.update, | |
| onUpdateProperties: [], | |
| } | |
| } | |
| emit(particle = {}) { | |
| const newParticle = { | |
| size: 1, | |
| color: { r: 1, g: 1, b: 1 }, | |
| position: { z: 0.5, y: 0.35, x: 0.5 }, | |
| life: 0.9, | |
| style: PARTICLE_STYLES.point, | |
| ...particle, | |
| lifeDecay: 0, | |
| speed: 0, | |
| speedDecay: 0, | |
| force: 0, | |
| forceDecay: 0, | |
| acceleration: 0, | |
| } | |
| const particleIndex = this.sim.createParticle("magic", newParticle) | |
| this.particles[particleIndex] = { | |
| index: particleIndex, | |
| ...newParticle.color, | |
| ...newParticle.position, | |
| size: newParticle.size, | |
| life: newParticle.life, | |
| lastPosition: null, | |
| animation: null, | |
| } | |
| return this.particles[particleIndex] | |
| } | |
| update(particle) { | |
| const { index, size, life } = particle | |
| const color = { r: particle.r, g: particle.g, b: particle.b } | |
| const position = { x: particle.x, y: particle.y, z: particle.z } | |
| const updates = ["size", "life", "color", "position"] | |
| const values = { size, life, color, position } | |
| for (let i = 0; i < updates.length; i++) { | |
| const update = updates[i] | |
| this.sim.setParticleProperty("magic", index, update, values[update]) | |
| } | |
| // this.sim() | |
| } | |
| destory(particle) { | |
| // this.sim | |
| this.sim.setParticleProperty("magic", particle.index, "life", 0) | |
| delete this.particles[particle.index] | |
| } | |
| release(particle) { | |
| //this.sim.setParticleProperty("magic", particle.index, "force", 0) | |
| delete this.particles[index] | |
| // this.sim | |
| } | |
| } | |
| class Emitter { | |
| constructor(sim, emitterSettings, particleTypes, light) { | |
| this.sim = sim | |
| this.light = light | |
| this.active = true | |
| this.animations = [] | |
| this.settings = { ...DEFAULT_EMITTER_SETTINGS, ...emitterSettings } | |
| this.delay = this.settings.animationDelay | |
| if (this.light) { | |
| gsap.killTweensOf(this.light) | |
| this.light.color.setRGB(this.settings.lightColor.r, this.settings.lightColor.g, this.settings.lightColor.b) | |
| this.light.intensity = 3 | |
| // this.animations.push(gsap.to(this.light, { intensity: 5, duration: this.delay })) | |
| } | |
| this.particles = { ...particleTypes } | |
| // particleSettings.forEach((settings) => { | |
| // this.particles.push({ ...DEFAULT_PARTICLE_SETTINGS, ...settings }) | |
| // }) | |
| this.position = { ...this.settings.startingPosition } | |
| // this.previousPosition = { ...this.settings.startingPosition } | |
| this.direction = { ...this.settings.startingDirection } | |
| this.remainingTime = 0 | |
| this.destroyed = false | |
| this.modelScale = 1 | |
| this.count = 0 | |
| this.moveFunction() | |
| if (this.settings.model) { | |
| this.model = this.settings.model | |
| // this.model.group.rotation.y = Math.PI * 0.5 | |
| if (this.model.animations && this.model.animations.length) { | |
| this.mixer = new AnimationMixer(this.model.scene) | |
| this.mixer.timeScale = 1.3 | |
| this.mixer.clipAction(this.model.animations[0]).play() | |
| } | |
| } | |
| } | |
| moveFunction = (delta, elapsedTime) => { | |
| if (this.model) { | |
| // this.model.group.scale.set(this.modelScale, this.modelScale, this.modelScale) | |
| } | |
| if (this.light) | |
| this.light.position.set( | |
| this.position.x * this.sim.size.x, | |
| this.position.y * this.sim.size.y, | |
| this.position.z * this.sim.size.z | |
| ) | |
| } | |
| pause() { | |
| this.animations.map((animation) => animation.pause()) | |
| } | |
| resume() { | |
| this.animations.map((animation) => animation.resume()) | |
| } | |
| destory() { | |
| if (this.model) { | |
| this.model.group.parent.remove(this.model.group) | |
| // this.model.group.traverse((obj) => { | |
| // if (obj.geometry) obj.geometry.dispose() | |
| // if (obj.material) obj.material.dispose() | |
| // }) | |
| this.model = null | |
| } | |
| if (this.light) { | |
| // this.light.intensity = 0 | |
| this.animations.push(gsap.fromTo(this.light, { intensity: 15 }, { intensity: 0, ease: "power1.in", duration: 1 })) | |
| } | |
| this.destroyed = true | |
| } | |
| emit(particle, group, casted = false) { | |
| if (!group) group = this.settings.group | |
| const positionAlongLine = this.previousPosition | |
| ? vector.lerpVectors(this.previousPosition, this.position, Math.random()) | |
| : this.position | |
| const position = { | |
| x: positionAlongLine.x + (Math.random() * 2 - 1) * particle.positionSpread.x, | |
| y: positionAlongLine.y + (Math.random() * 2 - 1) * particle.positionSpread.y, | |
| z: positionAlongLine.z + (Math.random() * 2 - 1) * particle.positionSpread.z, | |
| } | |
| let direction = {} | |
| // console.log("direction", particle.direction) | |
| if (!particle.direction) { | |
| direction = { | |
| x: Math.random() * 2 - 1, | |
| y: Math.random() * 2 - 1, | |
| z: Math.random() * 2 - 1, | |
| } | |
| } else { | |
| direction = { | |
| x: this.direction.x * particle.direction.x + (Math.random() * 2 - 1) * particle.directionSpread.x, | |
| y: this.direction.y * particle.direction.y + (Math.random() * 2 - 1) * particle.directionSpread.y, | |
| z: this.direction.z * particle.direction.z + (Math.random() * 2 - 1) * particle.directionSpread.z, | |
| } | |
| } | |
| // console.log("direction", direction) | |
| const speed = particle.speed + Math.random() * particle.speedSpread | |
| const force = particle.force + Math.random() * particle.forceSpread | |
| const scale = particle.scale * (particle.scaleSpread > 0 ? Math.random() * particle.scaleSpread : 1) | |
| // console.log(particle) | |
| this.sim.createParticle(group, { | |
| ...particle.settings, | |
| position, | |
| direction, | |
| speed, | |
| force, | |
| scale, | |
| casted, | |
| }) | |
| } | |
| tick(delta, elapsedTime) { | |
| if (this.active && this.settings.emitRate > 0) { | |
| this.remainingTime += delta | |
| if (this.mixer) this.mixer.update(delta * this.mixer.timeScale) | |
| if (this.moveFunction) this.moveFunction(delta, elapsedTime) | |
| const emitCount = Math.floor(this.remainingTime / this.settings.emitRate) | |
| // console.logLimited(emitCount) | |
| this.remainingTime -= emitCount * this.settings.emitRate | |
| for (let i = 0; i < emitCount; i++) { | |
| this.emit(this.particles[this.settings.particleOrder[this.count % this.settings.particleOrder.length]]) | |
| this.count++ | |
| } | |
| } | |
| this.previousPosition = { ...this.position } | |
| } | |
| } | |
| class ArcaneSpellEmitter extends Emitter { | |
| constructor(sim, light, startPosition, enemy) { | |
| const color = { r: 0.2, g: 0, b: 1 } | |
| const settings = { | |
| // model: ASSETS.getModel("parrot"), | |
| emitRate: 0.001, | |
| animationDelay: 1, | |
| startingPosition: startPosition, | |
| lightColor: color, | |
| particleOrder: [ | |
| "smoke", | |
| "smoke", | |
| "smoke", | |
| "smoke", | |
| "smoke", | |
| "circle", | |
| // "smoke", | |
| // "smoke", | |
| "circle", | |
| // "smoke", | |
| // "smoke", | |
| // "smoke", | |
| // "smoke", | |
| // "sparkle", | |
| ], | |
| } | |
| const particles = { | |
| smoke: new SpellTrailParticle({ | |
| color, | |
| }), | |
| sparkle: new SpellTrailParticle({ | |
| style: PARTICLE_STYLES.point, | |
| scale: 0.1, | |
| }), | |
| circle: new SpellTrailParticle({ | |
| color, | |
| style: PARTICLE_STYLES.disc, | |
| // scale: 4, | |
| }), | |
| explodeSmoke: new ExplodeParticle({ color }), | |
| explodeSpark: new ExplodeParticle({ | |
| speed: 0.4, | |
| color: { r: 1, g: 1, b: 1 }, | |
| // force: 2, | |
| forceDecay: 2, | |
| style: PARTICLE_STYLES.point, | |
| }), | |
| explodeShape: new ExplodeParticle({ | |
| color, | |
| style: PARTICLE_STYLES.disc, | |
| scale: 0.5, | |
| }), | |
| } | |
| super(sim, settings, particles, light) | |
| this.active = false | |
| this.enemy = enemy | |
| // console.log("startPostion", startPosition) | |
| this.particleOrder = ["smoke"] | |
| // this.lastPosition = { ...this.settings.startingPosition, z: this.settings.startingPosition.z + 0.001 } | |
| this.lookAt = { x: 0, y: 0, z: 0 } | |
| this.lookAtTarget = null | |
| // this.scale = 1 | |
| this.scale = 0.1 | |
| SOUNDS.play("spell-travel") | |
| this.animations.push( | |
| gsap.to( | |
| this.position, | |
| { | |
| duration: 0.9, | |
| delay: this.delay, | |
| motionPath: { | |
| curviness: 1.5, | |
| // resolution: 6, | |
| path: [ | |
| this.position, | |
| { x: 0.5, y: Math.random(), z: 0.8 }, | |
| { x: 0.1 + Math.random() * 0.8, y: 0.2 + Math.random() * 0.4, z: 0.4 }, | |
| this.enemy | |
| ? { x: this.enemy.location.x, y: 0.4, z: this.enemy.location.z } | |
| : { x: 0.1 + Math.random() * 0.8, y: 1, z: 0.2 }, | |
| ], | |
| }, | |
| ease: "linear", | |
| onStart: () => { | |
| if (this.enemy) this.enemy.incoming() | |
| }, | |
| onComplete: () => this.onComplete(), | |
| onUpdate: () => this.onUpdate(), | |
| } | |
| ) | |
| ) | |
| // gsap.to(this, { duration: 0.3, scale: 1, ease: "linear" }) | |
| // gsap.to(this.settings.directionSpread, { duration: 1, x: 0.001, y: 0.001, z: 0.001, ease: "power4.in" }) | |
| } | |
| onComplete = () => { | |
| if (this.enemy) { | |
| this.enemy.kill() | |
| SOUNDS.play("kill") | |
| const explode = 200 | |
| for (let i = 0; i < explode; i++) { | |
| const random = Math.random() | |
| if (random > 0.55) this.emit(this.particles["explodeSmoke"]) | |
| else if (random > 0.1) this.emit(this.particles["explodeSpark"]) | |
| else this.emit(this.particles["explodeShape"]) | |
| } | |
| } | |
| this.destory() | |
| } | |
| onUpdate = () => { | |
| if (this.lastPosition) { | |
| this.direction = { | |
| x: this.position.x - this.lastPosition.x, | |
| y: this.position.y - this.lastPosition.y, | |
| z: this.position.z - this.lastPosition.z, | |
| } | |
| if (this.model) { | |
| this.model.group.position.set( | |
| this.position.x * this.sim.size.x, | |
| this.position.y * this.sim.size.y, | |
| this.position.z * this.sim.size.z | |
| ) | |
| this.lookAtTarget = { | |
| x: this.sim.startCoords.x + (this.position.x + this.direction.x) * this.sim.size.x, | |
| y: this.sim.startCoords.y + (this.position.y + this.direction.y) * this.sim.size.y, | |
| z: this.sim.startCoords.z + (this.position.z + this.direction.z) * this.sim.size.z, | |
| } | |
| const lerpAmount = 0.08 | |
| this.lookAt.x = lerp(this.lookAt.x, this.lookAtTarget.x, lerpAmount) | |
| this.lookAt.y = lerp(this.lookAt.y, this.lookAtTarget.y, lerpAmount) | |
| this.lookAt.z = lerp(this.lookAt.z, this.lookAtTarget.z, lerpAmount) | |
| this.model.group.lookAt(this.lookAt.x, this.lookAt.y, this.lookAt.z) | |
| } | |
| } | |
| this.active = true | |
| this.lastPosition = { ...this.position } | |
| } | |
| } | |
| class CastEmitter extends Emitter { | |
| constructor(sim) { | |
| const settings = { | |
| emitRate: 0, | |
| particleOrder: ["sparkle", "sparkle", "sparkle", "sparkle", "smoke"], | |
| } | |
| const particles = { | |
| smoke: new SmokeParticle(), | |
| sparkle: new SparkleParticle(), | |
| } | |
| super(sim, settings, particles) | |
| } | |
| move(position) { | |
| this.position = position | |
| const emitCount = 10 | |
| for (let i = 0; i < emitCount; i++) { | |
| this.emit(this.particles[randomFromArray(this.settings.particleOrder)], "magic", true) | |
| } | |
| this.previousPosition = { ...this.position } | |
| } | |
| reset() { | |
| this.previousPosition = null | |
| } | |
| } | |
| class CrystalEnergyEmitter extends ControlledEmitter { | |
| constructor(sim) { | |
| super(sim) | |
| this.emitRate = 0.2 | |
| this._active = false | |
| } | |
| tick(delta, elapsedTime) { | |
| if (this.active && this.emitRate > 0) { | |
| this.remainingTime += delta | |
| const emitCount = Math.floor(this.remainingTime / this.emitRate) | |
| this.remainingTime -= emitCount * this.emitRate | |
| for (let i = 0; i < emitCount; i++) { | |
| const particle = this.emit({ | |
| life: 1, | |
| size: 0.4, | |
| color: { r: 0.8, g: Math.random(), b: 1 }, | |
| position: { | |
| x: 0.5 + (Math.random() * 0.05 - 0.025), | |
| y: 0.5 + (Math.random() * 0.05 - 0.025), | |
| z: 0.5 + (Math.random() * 0.05 - 0.025), | |
| }, | |
| }) | |
| particle.animation = gsap.to(particle, { | |
| y: 0.35, | |
| x: 0.5, | |
| z: 0.5, | |
| duration: 2, | |
| life: 0.5, | |
| ease: "power4.in", | |
| onUpdate: () => this.update(particle), | |
| onComplete: () => this.destory(particle), | |
| }) | |
| } | |
| } | |
| } | |
| set active(value) { | |
| this.remainingTime = 0 | |
| this._active = value | |
| } | |
| get active() { | |
| return this._active | |
| } | |
| } | |
| class DustEmitter extends Emitter { | |
| constructor(sim, assets) { | |
| const settings = { | |
| emitRate: 0.03, | |
| particleOrder: ["dust"], | |
| } | |
| const particles = { | |
| dust: new DustParticle(), | |
| } | |
| super(sim, settings, particles) | |
| const startCount = 5 | |
| for (let i = 0; i < startCount; i++) { | |
| this.emit(this.particles["dust"], "smoke") | |
| } | |
| } | |
| } | |
| class EnemyEnergyEmitter extends ControlledEmitter { | |
| constructor(sim, location) { | |
| super(sim) | |
| this.location = location | |
| this.emitRate = 0.05 | |
| // this.active = true | |
| } | |
| start() { | |
| this.active = true | |
| } | |
| stop() { | |
| this.active = false | |
| } | |
| tick(delta, elapsedTime) { | |
| if (this.active && this.emitRate > 0) { | |
| this.remainingTime += delta | |
| const emitCount = Math.floor(this.remainingTime / this.emitRate) | |
| // console.logLimited(emitCount) | |
| this.remainingTime -= emitCount * this.emitRate | |
| for (let i = 0; i < emitCount; i++) { | |
| const particle = this.emit({ | |
| life: 1, | |
| size: 0.3 + Math.random() * 0.1, | |
| style: Math.random() > 0.5 ? PARTICLE_STYLES.plus : PARTICLE_STYLES.point, | |
| color: { r: 0.8, g: Math.random(), b: 1 }, | |
| }) | |
| particle.aniamtion = gsap.to(particle, { | |
| motionPath: [ | |
| { x: 0.5, y: 0.35, z: 0.5 }, | |
| { | |
| x: simplelerp(0.5, this.location.x, 0.5) + Math.random() * 0.1, | |
| y: 0.4 + Math.random() * 0.1, | |
| z: simplelerp(0.5, this.location.z, 0.5) + Math.random() * 0.1, | |
| }, | |
| { x: this.location.x, y: 0.3, z: this.location.z }, | |
| ], | |
| duration: 1 + Math.random() * 0.5, | |
| life: 0.1, | |
| ease: "none", | |
| onUpdate: () => this.update(particle), | |
| onComplete: () => this.destory(particle), | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| class FireSpellEmitter extends Emitter { | |
| constructor(sim, light, startPosition, enemy) { | |
| const settings = { | |
| // model: ASSETS.getModel("skull"), | |
| emitRate: 0.01, | |
| animationDelay: 1, | |
| startingDirection: { x: 0, y: 1, z: 0 }, | |
| startingPosition: startPosition, | |
| particleOrder: ["flame"], | |
| lightColor: { r: 0.9, g: 0.8, b: 0.1 }, | |
| } | |
| const color = { r: 1, g: 0.8, b: 0 } | |
| const particles = { | |
| flame: new FlameParticle({ | |
| scale: 2, | |
| }), | |
| ember: { | |
| speed: 0.5, | |
| color: { r: 1, g: 0.3, b: 0 }, | |
| speedSpread: 0.3, | |
| forceSpread: 0, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| lifeDecay: 1.5, | |
| force: 0, | |
| type: 1, | |
| directionSpread: { x: 0.1, y: 0.1, z: 0.1 }, | |
| positionSpread: { x: 0, y: 0, z: 0 }, | |
| acceleration: 0.02, | |
| }, | |
| explodeSmoke: new ExplodeParticle({ color, speed: 0.1, forceDecay: 1.1 }), | |
| explodeSpark: new ExplodeParticle({ | |
| speed: 0.4, | |
| color: { r: 1, g: 1, b: 1 }, | |
| // force: 2, | |
| forceDecay: 2, | |
| // style: PARTICLE_STYLES.point, | |
| }), | |
| explodeShape: new ExplodeParticle({ | |
| color, | |
| style: PARTICLE_STYLES.circle, | |
| scale: 0.5, | |
| }), | |
| } | |
| super(sim, settings, particles, light) | |
| this.active = false | |
| // console.log("startPostion", startPosition) | |
| this.particleOrder = ["flame"] | |
| this.lastPosition = { ...this.settings.startingPosition, z: this.settings.startingPosition.z + 0.001 } | |
| this.lookAt = null | |
| this.lookAtTarget = null | |
| // this.scale = 1 | |
| this.scale = 0.1 | |
| if (this.model) { | |
| this.model.group.rotateX(math.degToRad(-160)) | |
| this.model.group.rotateZ(math.degToRad(-40)) | |
| this.model.group.scale.set(0, 0, 0) | |
| } | |
| this.onUpdate(true) | |
| this.enemy = enemy | |
| const introDuration = 0.5 | |
| SOUNDS.play("spell-travel") | |
| if (this.model) { | |
| this.animations.push( | |
| gsap.to(this.model.group.scale, { | |
| motionPath: [ | |
| // { x: 0, y: 0, z: 0 }, | |
| { x: 2, y: 2, z: 2 }, | |
| { x: 1, y: 1, z: 1 }, | |
| ], | |
| ease: "power1.inOut", | |
| duration: this.delay + introDuration * 1.2, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.model.group.rotation, { | |
| motionPath: [ | |
| { y: math.degToRad(0), x: math.degToRad(-160), z: math.degToRad(-40) }, | |
| { y: math.degToRad(0), x: math.degToRad(-90), z: math.degToRad(192) }, | |
| ], | |
| ease: "power1.inOut", | |
| duration: this.delay + introDuration, | |
| }) | |
| ) | |
| } | |
| this.animations.push( | |
| gsap.to(this.position, { | |
| duration: 1, | |
| delay: this.delay + introDuration * 0.25, | |
| motionPath: { | |
| curviness: 1.5, | |
| // resolution: 6, | |
| path: [ | |
| this.position, | |
| { x: 0.5, y: Math.random(), z: 0.8 }, | |
| { x: 0.1 + Math.random() * 0.8, y: 0.2 + Math.random() * 0.4, z: 0.4 }, | |
| this.enemy | |
| ? { x: this.enemy.location.x, y: 0.4, z: this.enemy.location.z } | |
| : { x: 0.1 + Math.random() * 0.8, y: 1, z: 0.2 }, | |
| ], | |
| }, | |
| ease: "power1.in", | |
| onStart: () => { | |
| if (this.enemy) this.enemy.incoming() | |
| this.settings.emitRate = 0.005 | |
| }, | |
| onComplete: () => this.onComplete(), | |
| onUpdate: () => this.onUpdate(), | |
| }) | |
| ) | |
| // gsap.to(this, { duration: 0.3, scale: 1, ease: "linear" }) | |
| // gsap.to(this.settings.directionSpread, { duration: 1, x: 0.001, y: 0.001, z: 0.001, ease: "power4.in" }) | |
| } | |
| onComplete = () => { | |
| if (this.enemy) { | |
| SOUNDS.play("kill") | |
| this.enemy.kill() | |
| const explode = 500 | |
| for (let i = 0; i < explode; i++) { | |
| const random = Math.random() | |
| if (random > 0.55) this.emit(this.particles["explodeSmoke"]) | |
| else if (random > 0.1) this.emit(this.particles["explodeSpark"]) | |
| else this.emit(this.particles["explodeShape"]) | |
| } | |
| } | |
| this.destory() | |
| } | |
| onUpdate = (skipDirection = false) => { | |
| // if (this.lastPosition) { | |
| if (!skipDirection) | |
| this.direction = { | |
| x: this.position.x - this.lastPosition.x, | |
| y: this.position.y - this.lastPosition.y, | |
| z: this.position.z - this.lastPosition.z, | |
| } | |
| if (this.model) { | |
| this.model.group.position.set( | |
| this.position.x * this.sim.size.x, | |
| this.position.y * this.sim.size.y, | |
| this.position.z * this.sim.size.z | |
| ) | |
| // this.lookAtTarget = { | |
| // x: this.sim.startCoords.x + (this.position.x + this.direction.x) * this.sim.size.x, | |
| // y: this.sim.startCoords.y + (this.position.y + this.direction.y) * this.sim.size.y, | |
| // z: this.sim.startCoords.z + (this.position.z + this.direction.z) * this.sim.size.z, | |
| // } | |
| // const lerpAmount = 0.08 | |
| // this.lookAt.x = lerp(this.lookAt.x, this.lookAtTarget.x, lerpAmount) | |
| // this.lookAt.y = lerp(this.lookAt.y, this.lookAtTarget.y, lerpAmount) | |
| // this.lookAt.z = lerp(this.lookAt.z, this.lookAtTarget.z, lerpAmount) | |
| // this.model.group.lookAt(this.lookAt.x, this.lookAt.y, this.lookAt.z) | |
| } | |
| // } | |
| this.active = true | |
| this.lastPosition = { ...this.position } | |
| } | |
| } | |
| class GhostEmitter extends Emitter { | |
| constructor(sim) { | |
| const settings = { | |
| emitRate: 0, | |
| particleOrder: ["trailSmoke"], | |
| startingDirection: { x: 0, y: -1, z: 0 }, | |
| group: "smoke", | |
| // direction: { x: -1, y: -1, z: -1 }, | |
| } | |
| const particles = { | |
| trailSmoke: new SmokeParticle({ | |
| positionSpread: { x: 0.03, y: 0.03, z: 0.03 }, | |
| directionSpread: { x: 0.3, y: 0, z: 0.3 }, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| force: 0.2, | |
| speed: 0.3, | |
| speedDecay: 0.2, | |
| lifeDecay: 0.8, | |
| acceleration: 0.1, | |
| scale: 0.4, | |
| }), | |
| smoke: new SmokeParticle({ | |
| color: { r: 0, g: 0, b: 0 }, | |
| positionSpread: { x: 0.05, y: 0, z: 0.05 }, | |
| directionSpread: { x: 0.3, y: 0, z: 0.3 }, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| force: 0, | |
| speed: 0.3, | |
| speedDecay: 0.2, | |
| lifeDecay: 0.4, | |
| acceleration: 0.1, | |
| scale: 1, | |
| }), | |
| force: new ForceParticle({ | |
| directionSpread: { x: 0.4, y: 0, z: 0.4 }, | |
| }), | |
| smokeUp: new SmokeParticle({ | |
| color: { r: 0, g: 0, b: 0 }, | |
| positionSpread: { x: 0.1, y: 0.3, z: 0.1 }, | |
| directionSpread: { x: 0.3, y: 0, z: 0.3 }, | |
| direction: { x: -1, y: -1, z: -1 }, | |
| force: 0.2, | |
| speed: 0.6, | |
| speedDecay: 0.2, | |
| lifeDecay: 0.6, | |
| acceleration: 0, | |
| scale: 1, | |
| }), | |
| sparkle: new SparkleParticle({ | |
| speed: 0.6, | |
| life: 1.0, | |
| lifeDecay: 0.7, | |
| positionSpread: { x: 0.1, y: 0.1, z: 0.1 }, | |
| directionSpread: { x: 1, y: 1, z: 1 }, | |
| // style: PARTICLE_STYLES.skull, | |
| // scaleSpread: 0, | |
| }), | |
| } | |
| super(sim, settings, particles) | |
| } | |
| puffOfSmoke(sparkles = false) { | |
| const smokePuff = 50 | |
| for (let i = 0; i < smokePuff; i++) { | |
| this.emit(this.particles["smokeUp"], "smoke") | |
| } | |
| if (sparkles) { | |
| const sparks = 100 | |
| for (let i = 0; i < sparks; i++) { | |
| this.emit(this.particles["sparkle"], "magic") | |
| } | |
| } | |
| } | |
| animatingIn() { | |
| this.settings.emitRate = 0.0015 | |
| } | |
| idle() { | |
| this.settings.particleOrder = ["force", "smoke", "smoke", "smoke", "smoke", "smoke", "smoke"] | |
| this.settings.emitRate = 0.03 | |
| } | |
| } | |
| class TorchEmitter extends Emitter { | |
| constructor(position, sim) { | |
| const settings = { | |
| emitRate: 0.03, | |
| particleOrder: ["force", "flame", "redFlame", "smoke", "flame", "redFlame", "smoke", "flame", "flame"], | |
| startingPosition: position, | |
| startingDirection: { x: 0, y: 1, z: 0 }, | |
| // direction: { x: -1, y: -1, z: -1 }, | |
| } | |
| const particles = { | |
| flame: new FlameParticle({ | |
| positionSpread: { x: 0, y: 0, z: 0 }, | |
| directionSpread: { x: 0.4, y: 0, z: 0.4 }, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| force: 0.1, | |
| speed: 0.25, | |
| speedDecay: 0.99, | |
| lifeDecay: 1.7, | |
| acceleration: 0.2, | |
| scale: 2.5, | |
| scaleSpread: 0.3, | |
| }), | |
| redFlame: new FlameParticle({ | |
| color: { r: 1, g: 0.3, b: 0 }, | |
| positionSpread: { x: 0, y: 0, z: 0 }, | |
| directionSpread: { x: 0.4, y: 0, z: 0.4 }, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| force: 0.1, | |
| speed: 0.3, | |
| speedDecay: 0.99, | |
| lifeDecay: 1, | |
| acceleration: 0.2, | |
| scale: 2.5, | |
| scaleSpread: 0.3, | |
| }), | |
| smoke: new SmokeParticle({ | |
| positionSpread: { x: 0, y: 0, z: 0 }, | |
| directionSpread: { x: 0.4, y: 0, z: 0.4 }, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| force: 0.1, | |
| speed: 0.3, | |
| speedDecay: 0.6, | |
| lifeDecay: 0.7, | |
| acceleration: 0.2, | |
| color: { r: 0.1, g: 0.1, b: 0.1 }, | |
| scale: 4, | |
| scaleSpread: 0.3, | |
| }), | |
| force: new ForceParticle(), | |
| } | |
| super(sim, settings, particles) | |
| } | |
| flamePuff() { | |
| gsap.fromTo(this.particles.flame, { scale: 5 }, { scale: 2.5, duration: 1 }) | |
| } | |
| set green(value) { | |
| if (value) { | |
| this.particles.flame.color = { r: 0, g: 1, b: 0 } | |
| this.particles.redFlame.color = { r: 0.5, g: 1, b: 0.2 } | |
| } else { | |
| this.particles.flame.color = { r: 1, g: 1.0, b: 0.3 } | |
| this.particles.redFlame.color = { r: 1, g: 0.3, b: 0 } | |
| } | |
| } | |
| } | |
| class VortexSpellEmitter extends Emitter { | |
| constructor(sim, light, startPosition) { | |
| const color = { r: 0, g: 1, b: 0 } | |
| const settings = { | |
| // model: ASSETS.getModel("parrot"), | |
| emitRate: 0.0001, | |
| animationDelay: 1, | |
| startingPosition: startPosition, | |
| lightColor: color, | |
| particleOrder: ["smoke", "smoke", "smoke", "smoke", "smoke", "circle", "circle"], | |
| } | |
| const particles = { | |
| smoke: new SpellTrailParticle({ | |
| color, | |
| }), | |
| sparkle: new SpellTrailParticle({ | |
| style: PARTICLE_STYLES.point, | |
| scale: 0.1, | |
| }), | |
| circle: new SpellTrailParticle({ | |
| color, | |
| style: PARTICLE_STYLES.disc, | |
| // scale: 4, | |
| }), | |
| explodeSmoke: new ExplodeParticle({ | |
| color, | |
| // force: 0, | |
| direction: { x: 0, y: 1, z: 0 }, | |
| directionSpread: { x: 0.05, y: 0.05, z: 0.05 }, | |
| }), | |
| explodeSpark: new ExplodeParticle({ | |
| speed: 0.1, | |
| direction: { x: 0, y: 1, z: 0 }, | |
| directionSpread: { x: 0.05, y: 0.05, z: 0.05 }, | |
| color: { r: 1, g: 1, b: 1 }, | |
| // force: 2, | |
| speedDecay: 0.99, | |
| lifeDecay: 0.9, | |
| style: PARTICLE_STYLES.point, | |
| acceleration: 0.01, | |
| }), | |
| explodeShape: new ExplodeParticle({ | |
| color, | |
| direction: { x: 0, y: 1, z: 0 }, | |
| directionSpread: { x: 0.05, y: 0.05, z: 0.05 }, | |
| style: PARTICLE_STYLES.disc, | |
| speedDecay: 0.99, | |
| scale: 0.9, | |
| speed: 0.1, | |
| lifeDecay: 0.8, | |
| acceleration: 0.01, | |
| }), | |
| } | |
| super(sim, settings, particles, light) | |
| this.active = false | |
| this.particleOrder = ["smoke"] | |
| this.lookAt = { x: 0, y: 0, z: 0 } | |
| this.lookAtTarget = null | |
| this.scale = 0.1 | |
| SOUNDS.play("spell-travel") | |
| this.animations.push( | |
| gsap.to( | |
| this.position, | |
| { | |
| duration: 0.6, | |
| delay: this.delay, | |
| motionPath: { | |
| curviness: 0.5, | |
| // resolution: 6, | |
| path: [ | |
| { x: 0.5, y: 1, z: 0.5 }, | |
| { x: 0.5, y: 0.1, z: 0.5 }, | |
| ], | |
| }, | |
| ease: "linear", | |
| onComplete: () => this.onComplete(), | |
| onUpdate: () => this.onUpdate(), | |
| } | |
| ) | |
| ) | |
| } | |
| onComplete = () => { | |
| const explode = 1000 | |
| for (let i = 0; i < explode; i++) { | |
| const random = Math.random() | |
| if (random > 0.55) this.emit(this.particles["explodeSmoke"]) | |
| else if (random > 0.1) this.emit(this.particles["explodeSpark"]) | |
| else this.emit(this.particles["explodeShape"]) | |
| } | |
| this.destory() | |
| } | |
| onUpdate = () => { | |
| // if (this.lastPosition) { | |
| // this.direction = { | |
| // x: this.position.x - this.lastPosition.x, | |
| // y: this.position.y - this.lastPosition.y, | |
| // z: this.position.z - this.lastPosition.z, | |
| // } | |
| // } | |
| this.active = true | |
| this.lastPosition = { ...this.position } | |
| } | |
| } | |
| class WinEmitter extends Emitter { | |
| constructor(sim, assets) { | |
| const settings = { | |
| emitRate: 0.001, | |
| particleOrder: ["dustRed", "dustGreen", "dustBlue"], | |
| } | |
| const particles = { | |
| dustRed: new DustParticle({ color: { r: 1, g: 1, b: 0 }, style: PARTICLE_STYLES.point, scale: 0.5 }), | |
| dustGreen: new DustParticle({ color: { r: 0, g: 1, b: 1 }, style: PARTICLE_STYLES.point, scale: 0.5 }), | |
| dustBlue: new DustParticle({ color: { r: 1, g: 0, b: 1 }, style: PARTICLE_STYLES.point, scale: 0.5 }), | |
| } | |
| super(sim, settings, particles) | |
| // const startCount = 5 | |
| // for (let i = 0; i < startCount; i++) { | |
| // this.emit(this.particles["dustGreen"], "magic") | |
| // } | |
| } | |
| } | |
| // DEMON | |
| class Enemy { | |
| constructor(sim, demon, spell) { | |
| this.machine = interpret(EnemyMachine) | |
| this.sim = sim | |
| this.timeOffset = Math.random() * (Math.PI * 2) | |
| this.state = this.machine.initialState.value | |
| this.uniforms = demon.uniforms | |
| this.onDeadCallback = null | |
| this.animations = [] | |
| const availableSpellTypes = Object.keys(SPELLS).map((key) => SPELLS[key]) | |
| this.spellType = spell ? spell : availableSpellTypes[Math.floor(Math.random() * availableSpellTypes.length)] | |
| // const geometry = new BoxGeometry(0.1, 0.15, 0.05) | |
| // const material = new MeshStandardMaterial({ color: this.spellType === SPELLS.arcane ? 0xbb11ff : 0xbbff11 }) | |
| // this.model = new Mesh(geometry, material) | |
| this.demon = demon | |
| this.model = demon.demon | |
| this.elements = { | |
| leftHand: null, | |
| rightHand: null, | |
| sphere: null, | |
| cloak: null, | |
| skullParts: [], | |
| } | |
| this.model.scene.traverse((item) => { | |
| if (this.elements[item.name] === null) this.elements[item.name] = item | |
| else if (item.name.includes("skull")) this.elements.skullParts.push(item) | |
| if (item.name === "cloak") { | |
| item.material.onBeforeCompile = (shader) => { | |
| console.log("COMPILING SHADER") | |
| } | |
| } | |
| }) | |
| this.modelOffset = { x: 0, y: -0.6, z: 0 } | |
| this.group = this.model.group | |
| // this.group.add(this.model) | |
| this.position = { x: 0, y: 0, z: 0 } | |
| this.emitter = new GhostEmitter(sim) | |
| this.emitter.emitRate = 0 | |
| this.machine.onTransition((s) => this.onStateChange(s)) | |
| this.machine.start() | |
| } | |
| moveFunction(delta, elapsedTime) { | |
| if (this.state === "ALIVE" || this.state === "TAGGED") { | |
| this.position.y = 0.2 + 0.15 * ((Math.sin(elapsedTime + this.timeOffset) + 1) * 0.5) | |
| } | |
| } | |
| pause() { | |
| this.animations.map((animation) => animation.pause()) | |
| } | |
| resume() { | |
| this.animations.map((animation) => animation.resume()) | |
| } | |
| spawn(location) { | |
| this.location = location | |
| this.location.add(this.group) | |
| this.group.rotation.y = this.location.rotation | |
| this.model.scene.visible = false | |
| this.machine.send("spawn") | |
| } | |
| incoming() { | |
| this.machine.send("incoming") | |
| } | |
| kill() { | |
| this.machine.send("kill") | |
| } | |
| accend() { | |
| this.machine.send("accend") | |
| } | |
| getSuckedIntoTheAbyss() { | |
| this.machine.send("vortex") | |
| } | |
| onStateChange = (state) => { | |
| this.state = state.value | |
| if (state.changed || this.state === "IDLE") { | |
| switch (this.state) { | |
| case "IDLE": | |
| this.model.scene.rotation.set(0, 0, 0) | |
| break | |
| case "ANIMATING_IN": | |
| if (this.location) { | |
| SOUNDS.play("enter") | |
| const entrancePath = this.location.getRandomEntrance() | |
| this.animations.push( | |
| gsap.fromTo( | |
| this.position, | |
| { ...entrancePath.points[0] }, | |
| { | |
| motionPath: { path: entrancePath.points, curviness: 2 }, | |
| ease: "none", | |
| duration: 1.1, | |
| onStart: () => { | |
| setTimeout(() => { | |
| this.emitter.animatingIn() | |
| }, 100) | |
| }, | |
| } | |
| ) | |
| ) | |
| this.animations.push(gsap.from(this.elements.leftHand.position, { z: -0.1, duration: 2 })) | |
| this.animations.push(gsap.from(this.elements.leftHand.scale, { x: 0, y: 0, z: 0, duration: 2 })) | |
| this.animations.push(gsap.from(this.elements.rightHand.position, { z: -0.1, duration: 2 })) | |
| this.animations.push(gsap.from(this.elements.rightHand.scale, { x: 0, y: 0, z: 0, duration: 2 })) | |
| this.elements.skullParts.forEach((part) => { | |
| this.animations.push( | |
| gsap.from(part.rotation, { | |
| y: (Math.random() - 0.5) * 0.1, | |
| x: 1.5, | |
| ease: "power2.inOut", | |
| delay: 0.8, | |
| duration: 1, | |
| }) | |
| ) | |
| this.animations.push(gsap.from(part.scale, { x: 0.2, y: 4, z: 0.2, delay: 0.8, duration: 1 })) | |
| this.animations.push(gsap.from(part.position, { y: 0.1, delay: 0.8, duration: 1 })) | |
| }) | |
| this.animations.push(gsap.from(this.elements.cloak.scale, { y: 0.2, duration: 1.7 })) | |
| this.animations.push(gsap.delayedCall(1, this.machine.send, ["complete"])) | |
| const trail = entrancePath.trail | |
| const material = trail.material | |
| entrancePath.entrance.enter() | |
| this.animations.push( | |
| gsap.fromTo( | |
| material.uniforms.progress, | |
| { value: 0 }, | |
| { | |
| duration: 1.1, | |
| delay: 0.2, | |
| value: 1, | |
| ease: "none", | |
| onStart: () => { | |
| trail.visible = true | |
| }, | |
| onComplete: () => { | |
| trail.visible = false | |
| }, | |
| } | |
| ) | |
| ) | |
| } else { | |
| this.machine.send("complete") | |
| } | |
| break | |
| case "ALIVE": | |
| SOUNDS.play("laugh") | |
| this.emitter.puffOfSmoke() | |
| this.emitter.idle() | |
| this.model.scene.visible = true | |
| this.location.energyEmitter.start() | |
| this.animations.push( | |
| gsap.fromTo( | |
| this.model.scene.scale, | |
| { x: 0.1, y: 0.001, z: 0.1 }, | |
| { x: 0.9, y: 0.9, z: 0.9, ease: "power4.out", duration: 0.2 } | |
| ) | |
| ) | |
| this.animations.push(gsap.fromTo(this.modelOffset, { y: 0 }, { y: -0.05, ease: "back", duration: 0.5 })) | |
| break | |
| case "TAGGED": | |
| break | |
| case "ANIMATING_OUT": | |
| this.emitter.puffOfSmoke() | |
| this.emitter.destory() | |
| this.location.energyEmitter.stop() | |
| this.animations.push( | |
| gsap.to(this.elements.cloak.scale, { | |
| x: 12, | |
| y: 9, | |
| z: 9, | |
| ease: "power3.out", | |
| duration: 1.5, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.uniforms.out, { | |
| value: 1, | |
| ease: "back.in", | |
| delay: 0.2, | |
| duration: 1, | |
| onComplete: () => { | |
| this.emitter.puffOfSmoke(true) | |
| this.machine.send("complete") | |
| }, | |
| }) | |
| ) | |
| this.elements.skullParts.forEach((part) => { | |
| const duration = 1 + Math.random() * 0.3 | |
| this.animations.push( | |
| gsap.to(part.position, { | |
| delay: 0.15, | |
| y: (Math.random() - 0.5) * 0.1, | |
| x: (Math.random() - 0.5) * 0.3, | |
| z: (Math.random() - 0.5) * 0.3, | |
| ease: "back.in", | |
| duration, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(part.rotation, { | |
| delay: 0, | |
| y: (Math.random() - 0.5) * 0.8, | |
| x: (Math.random() - 0.5) * 0.8, | |
| z: (Math.random() - 0.5) * 0.8, | |
| ease: "power2.inOut", | |
| duration, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(part.scale, { | |
| delay: 0.2, | |
| y: 0, | |
| x: 0, | |
| z: 0, | |
| ease: "back.in", | |
| duration: duration * 0.6, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.elements.leftHand.scale, { | |
| x: 0, | |
| y: 0, | |
| z: 0, | |
| ease: "power3.out", | |
| duration: 0.6, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.elements.rightHand.scale, { | |
| x: 0, | |
| y: 0, | |
| z: 0, | |
| ease: "power3.out", | |
| duration: 0.6, | |
| }) | |
| ) | |
| // this.elements.sphere.visible = false | |
| // this.animations.push(gsap.from(part.scale, { x: 0.2, y: 4, z: 0.2, delay: 0.8, duration: 1 })) | |
| // this.animations.push(gsap.from(part.position, { y: 0.1, delay: 0.8, duration: 1 })) | |
| }) | |
| break | |
| case "VORTEX_ANIMATION": | |
| this.emitter.destory() | |
| this.location.energyEmitter.stop() | |
| const mainDelay = Math.random() * 0.5 | |
| const moveDelay = mainDelay + 1.5 | |
| this.animations.push( | |
| gsap.to(this.modelOffset, { | |
| y: -1, | |
| z: 0.2, | |
| ease: "power4.in", | |
| duration: 1, | |
| delay: moveDelay, | |
| onComplete: () => this.machine.send("complete"), | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.model.scene.rotation, { | |
| y: Math.random() * 2, | |
| ease: "power4.in", | |
| duration: 1.5, | |
| delay: mainDelay + 1, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.model.scene.scale, { | |
| y: 1.2, | |
| ease: "power4.in", | |
| duration: 1.5, | |
| delay: mainDelay + 1, | |
| }) | |
| ) | |
| this.animations.push( | |
| gsap.to(this.uniforms.stretch, { | |
| value: 1, | |
| ease: "power4.in", | |
| delay: mainDelay, | |
| duration: 2, | |
| }) | |
| ) | |
| break | |
| case "DEAD": | |
| this.destory() | |
| break | |
| case "GONE": | |
| this.emitter.destory() | |
| this.destory() | |
| break | |
| case "ACCEND": | |
| this.location.energyEmitter.stop() | |
| this.animations.push( | |
| gsap.to(this.position, { | |
| y: 1.1, | |
| ease: "Power4.in", | |
| duration: 0.6, | |
| delay: Math.random(), | |
| onStart: () => this.emitter.puffOfSmoke(), | |
| onComplete: () => { | |
| this.destory() | |
| this.machine.send("leave") | |
| }, | |
| }) | |
| ) | |
| } | |
| } | |
| } | |
| resetDemon() { | |
| console.log("----reseting demon") | |
| console.log(this.uniforms) | |
| this.uniforms.in.value = 0 | |
| this.uniforms.out.value = 0 | |
| this.uniforms.stretch.value = 0 | |
| this.model.scene.traverse((item) => { | |
| if (item.isMesh) { | |
| const types = ["position", "rotation", "scale"] | |
| types.forEach((type) => { | |
| item[type].set(item.home[type].x, item.home[type].y, item.home[type].z) | |
| }) | |
| } | |
| }) | |
| } | |
| destory() { | |
| if (this.model) { | |
| this.group.removeFromParent() | |
| this.resetDemon() | |
| this.demon.returnToPool() | |
| // this.model.scene.parent.remove(this.model.scene) | |
| // this.model = null | |
| } | |
| this.animations.forEach((animation) => { | |
| animation.kill() | |
| animation = null | |
| }) | |
| if (this.location) this.location.release() | |
| if (this.onDeadCallback) { | |
| this.onDeadCallback() | |
| this.onDeadCallback = null | |
| } | |
| } | |
| tick(delta, elapsedTime) { | |
| this.uniforms.time.value = elapsedTime | |
| this.moveFunction(delta, elapsedTime) | |
| this.group.position.set( | |
| this.position.x * this.sim.size.x, | |
| this.position.y * this.sim.size.y, | |
| this.position.z * this.sim.size.z | |
| ) | |
| this.model.scene.position.set( | |
| this.modelOffset.x * this.sim.size.x, | |
| this.modelOffset.y * this.sim.size.y, | |
| this.modelOffset.z * this.sim.size.z | |
| ) | |
| if (this.location) | |
| this.emitter.position = { | |
| x: this.position.x + this.location.position.x, | |
| y: this.position.y + this.location.position.y, | |
| z: this.position.z + this.location.position.z, | |
| } | |
| } | |
| get dead() { | |
| return this.state === "DEAD" || this.state === "GONE" | |
| } | |
| get active() { | |
| return this.state === "ALIVE" | |
| } | |
| } | |
| /* | |
| The demon needs a little moment to get loaded | |
| into memory. So rather than wait for the first | |
| in game enemy to appear and get hit with a | |
| stutter, we use this preloader to do some | |
| heavy lifting during the loading screen | |
| */ | |
| class EnemyPreloader { | |
| constructor(stage) { | |
| this.totalDemons = 6 | |
| this.demons = [] | |
| for (let i = 0; i < this.totalDemons; i++) { | |
| this.demons.push({ | |
| isAvailable: true, | |
| returnToPool: function () { | |
| this.isAvailable = true | |
| }, | |
| uniforms: { | |
| in: { value: 0 }, | |
| out: { value: 0 }, | |
| stretch: { value: 0 }, | |
| time: { value: 1 }, | |
| }, | |
| demon: ASSETS.getModel("demon", true), | |
| }) | |
| } | |
| this.demons.forEach((enemy, i) => { | |
| enemy.demon.group.position.y = -0.1 | |
| enemy.demon.group.position.x = 0.05 + 0.02 * (i + 1) | |
| stage.add(enemy.demon.group) | |
| enemy.demon.scene.traverse((item) => { | |
| if (item.name === "cloak") { | |
| // item.castShadow = true | |
| // item.material.transparent = true | |
| // item.material.forceSinglePass = true | |
| // item.renderOrder = 0 | |
| // item.material.writeDepth = false | |
| item.material.onBeforeCompile = (shader) => { | |
| // const uniform = { value: 1 } | |
| shader.uniforms.uIn = enemy.uniforms.in | |
| shader.uniforms.uOut = enemy.uniforms.out | |
| shader.uniforms.uStretch = enemy.uniforms.stretch | |
| shader.uniforms.uTime = enemy.uniforms.time | |
| shader.vertexShader = shader.vertexShader.replace( | |
| "#define STANDARD", | |
| `#define STANDARD | |
| ${includes.noise} | |
| uniform float uOut; | |
| uniform float uTime; | |
| uniform float uStretch; | |
| varying vec2 vUv; | |
| varying float vNoise; | |
| ` | |
| ) | |
| shader.vertexShader = shader.vertexShader.replace( | |
| "#include <begin_vertex>", | |
| ` | |
| #include <begin_vertex> | |
| vUv = uv; | |
| float xNoise = snoise(vec2((position.x * 200.0) + (position.z * 100.0), uTime * (0.6 + (0.3 * uOut)))); | |
| float yNoise = snoise(vec2((position.y * 200.0) + (position.z * 100.0), uTime * (0.6 + (0.3 * uOut)))); | |
| float amount = (0.0015 + 0.02 * uOut) ; | |
| float moveAmount = smoothstep(0.02 + (1.0 * uOut), 0.0, position.y); | |
| transformed.x += moveAmount * amount * xNoise; | |
| transformed.y += moveAmount * amount * yNoise; | |
| transformed.x = transformed.x * (1.0 - uOut); | |
| transformed.y = transformed.y * (1.0 - uOut)+ (0.0 * uOut); | |
| transformed.z = transformed.z * (1.0 - uOut); | |
| transformed.y -= (moveAmount * uStretch) * 0.01; | |
| transformed.x += (moveAmount * uStretch) * 0.003; | |
| vNoise = snoise(vec2(position.x * 500.0, position.y * 500.0 )); | |
| ` | |
| ) | |
| shader.fragmentShader = shader.fragmentShader.replace( | |
| "#include <common>", | |
| ` | |
| uniform float uIn; | |
| uniform float uOut; | |
| uniform float uTime; | |
| varying vec2 vUv; | |
| varying float vNoise; | |
| ${includes.noise} | |
| #include <common> | |
| ` | |
| ) | |
| shader.fragmentShader = shader.fragmentShader.replace( | |
| "#include <output_fragment>", | |
| `#include <output_fragment> | |
| // float noise = snoise(vUv); | |
| // vec3 blackout = mix(vec3(vUv, 1.0), gl_FragColor.rgb, uOut); | |
| float noise = snoise(vUv * 80.0); | |
| float glowNoise = snoise((vUv * 4.0) + (uTime * 0.75)); | |
| float glow = smoothstep(0.3, 0.5, glowNoise); | |
| glow *= smoothstep(0.7, 0.5, glowNoise); | |
| // glowNoise = smoothstep(0.7, 0.5, glowNoise); | |
| float grad = smoothstep(0.925 + (uOut * 0.2), 1.0, vUv.y) * noise; | |
| // gl_FragColor = vec4(vec3(grad, 0.0, 0.0), 1.0 - grad); | |
| // gl_FragColor.a = 1.0 - grad; | |
| gl_FragColor.rgb *= 1.0 - grad; | |
| gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(1.0), glow * 0.2 * pow(uOut, 0.25) ) ; | |
| ` | |
| ) | |
| enemy.demon.group.removeFromParent() | |
| } | |
| } | |
| }) | |
| }) | |
| } | |
| resetAll() { | |
| this.demons.forEach((d) => (d.isAvailable = true)) | |
| } | |
| borrowDemon() { | |
| const availableDemons = this.demons.filter((d) => d.isAvailable) | |
| const demon = availableDemons[0] | |
| demon.isAvailable = false | |
| return demon | |
| } | |
| } | |
| // LIGHTS | |
| class CrystalLight { | |
| constructor(position, offset) { | |
| const color = new Color("#861388") | |
| this.position = position | |
| this.offset = offset | |
| this.group = new Group() | |
| this.pointLight = new PointLight(color, 5, 0.8) | |
| this.group.add(this.pointLight) | |
| this.group.position.set(position.x, position.y, position.z) | |
| if (window.DEBUG.lights) { | |
| const helper = new Mesh(new SphereGeometry(0.02), new MeshBasicMaterial(0xffffff)) | |
| this.group.add(helper) | |
| } | |
| } | |
| get light() { | |
| return this.group | |
| } | |
| tick(delta, elapsedTime) { | |
| // this.group.position.set( | |
| // this.position.x, // * this.offset.x, | |
| // this.position.y, // * this.offset.y, | |
| // this.position.z // * this.offset.z | |
| // ) | |
| // const n = (Math.cos(elapsedTime * 1.8) + 1) * 0.5 | |
| // this.pointLight.intensity = 8 + 6 * n | |
| } | |
| } | |
| class TorchLight { | |
| constructor(position, offset, noise) { | |
| const color = new Color("#FA9638") | |
| this.position = position | |
| this.offset = offset | |
| this.group = new Group() | |
| this.pointLight = new PointLight(color, 0, 0.6) | |
| this.group.add(this.pointLight) | |
| this.group.position.set( | |
| this.position.x * this.offset.x, | |
| this.position.y * this.offset.y, | |
| this.position.z * this.offset.z | |
| ) | |
| this.noise = noise | |
| this._active = false | |
| this.baseIntesity = 1 | |
| if (window.DEBUG.lights) { | |
| const helper = new Mesh(new SphereGeometry(0.02), new MeshBasicMaterial({ color: 0xff0000 })) | |
| this.group.add(helper) | |
| } | |
| } | |
| get light() { | |
| return this.group | |
| } | |
| get object() { | |
| return this.group | |
| } | |
| set active(value) { | |
| if (value !== this._active) { | |
| this._active = value | |
| if (this._active) { | |
| gsap.fromTo(this, { baseIntesity: 3 }, { baseIntesity: 1, duration: 0.3 }) | |
| } | |
| } | |
| } | |
| set color(newColor) { | |
| this.pointLight.color = new Color(newColor) | |
| } | |
| tick(delta, elapsedTime) { | |
| const n = this.noise(this.position.x * 2, this.position.y * 2, elapsedTime * 3) + 1 * 0.5 | |
| this.pointLight.intensity = this._active ? this.baseIntesity + 0.5 * n : 0 | |
| } | |
| } | |
| // PARTICLES | |
| class ParticleType { | |
| constructor(settings) { | |
| this.settings = { ...DEFAULT_PARTICLE_SETTINGS, ...settings } | |
| } | |
| get speed() { | |
| return this.settings.speed | |
| } | |
| get speedDecay() { | |
| return this.settings.speedDecay | |
| } | |
| get speedSpread() { | |
| return this.settings.speedSpread | |
| } | |
| get force() { | |
| return this.settings.force | |
| } | |
| get forceDecay() { | |
| return this.settings.forceDecay | |
| } | |
| get forceSpread() { | |
| return this.settings.forceSpread | |
| } | |
| get life() { | |
| return this.settings.life | |
| } | |
| get lifeDecay() { | |
| return this.settings.lifeDecay | |
| } | |
| get directionSpread() { | |
| return this.settings.directionSpread | |
| } | |
| get direction() { | |
| return this.settings.direction | |
| } | |
| get position() { | |
| return this.settings.position | |
| } | |
| get positionSpread() { | |
| return this.settings.positionSpread | |
| } | |
| get color() { | |
| return this.settings.color | |
| } | |
| set color(value) { | |
| this.settings.color = value | |
| } | |
| get scale() { | |
| return this.settings.scale | |
| } | |
| set scale(value) { | |
| this.settings.scale = value | |
| } | |
| get scaleSpread() { | |
| return this.settings.scaleSpread | |
| } | |
| get style() { | |
| return this.settings.style | |
| } | |
| get acceleration() { | |
| return this.settings.acceleration | |
| } | |
| } | |
| class DustParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0, | |
| speedDecay: 0.4, | |
| color: { r: 0.5, g: 0.5, b: 0.5 }, | |
| speedSpread: 0, | |
| forceSpread: 0, | |
| force: 0, | |
| style: PARTICLE_STYLES.circle, | |
| life: 1, | |
| lifeDecay: 0.3, | |
| scale: 0.06, | |
| acceleration: 1, | |
| positionSpread: { x: 0.5, y: 0.5, z: 0.5 }, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| class ExplodeParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0.4, | |
| speedSpread: 0, | |
| speedDecay: 0.8, | |
| forceSpread: 0, | |
| force: 2, | |
| forceDecay: 0.9, | |
| type: PARTICLE_STYLES.smoke, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| class FlameParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0.5, | |
| speedDecay: 0.4, | |
| color: { r: 1, g: 1.0, b: 0.3 }, | |
| speedSpread: 0.1, | |
| forceSpread: 0.2, | |
| force: 0.8, | |
| forceDecay: 0.8, | |
| scale: 1, | |
| scaleSpread: 1, | |
| lifeDecay: 1.5, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| directionSpread: { x: 0.1, y: 0.1, z: 0.1 }, | |
| positionSpread: { x: 0, y: 0, z: 0 }, | |
| acceleration: 0.02, | |
| style: PARTICLE_STYLES.flame, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| class ForceParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0.4, | |
| speedDecay: 0.4, | |
| color: { r: 1, g: 0, b: 0 }, | |
| force: 1, | |
| forceDecay: 0, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| directionSpread: { x: 0.3, y: 0, z: 0.3 }, | |
| acceleration: 0, | |
| scale: 0.3, | |
| style: window.DEBUG.forceParticles ? PARTICLE_STYLES.circle : PARTICLE_STYLES.invisible, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| class SmokeParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0, | |
| speedDecay: 0, | |
| speedSpread: 0, | |
| forceSpread: 0, | |
| force: 0, | |
| life: 1, | |
| lifeDecay: 0, | |
| scaleSpread: 1, | |
| acceleration: 0, | |
| positionSpread: { x: 0.02, y: 0, z: 0.001 }, | |
| color: { r: 0.75, g: 0.75, b: 0.75 }, | |
| style: PARTICLE_STYLES.smoke, | |
| scale: 1, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| class SparkleParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0.1, | |
| speedSpread: 0, | |
| forceSpread: 0, | |
| force: 0, | |
| life: 0.5, | |
| lifeDecay: 0, | |
| scaleSpread: 1, | |
| acceleration: 0, | |
| color: { r: 1, g: 1, b: 1 }, | |
| style: PARTICLE_STYLES.point, | |
| scale: 1.2, | |
| speedDecay: 0.2, | |
| positionSpread: { x: 0.01, y: 0.001, z: 0.01 }, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| class SpellTrailParticle extends ParticleType { | |
| constructor(overrides) { | |
| const _overides = overrides ? overrides : {} | |
| super({ | |
| speed: 0.5, | |
| speedDecay: 0.4, | |
| color: { r: 1, g: 1, b: 1 }, | |
| speedSpread: 0.1, | |
| forceSpread: 0.2, | |
| force: 0.8, | |
| forceDecay: 0.8, | |
| scale: 1, | |
| scaleSpread: 1, | |
| lifeDecay: 1.5, | |
| direction: { x: 1, y: 1, z: 1 }, | |
| directionSpread: { x: 0.1, y: 0.1, z: 0.1 }, | |
| positionSpread: { x: 0, y: 0, z: 0 }, | |
| acceleration: 0.02, | |
| style: PARTICLE_STYLES.smoke, | |
| ..._overides, | |
| }) | |
| } | |
| } | |
| // SOUNDS | |
| class SoundController { | |
| constructor() { | |
| this.audioListener = new AudioListener() | |
| this.ready = false | |
| this.sounds = [ | |
| { id: "music", loop: true, volume: 0.5 }, | |
| { id: "kill", files: ["kill-1", "kill-2", "kill-3"] }, | |
| { id: "enter", files: ["enter-1", "enter-2"] }, | |
| { id: "cast", files: ["cast-1", "cast-2"] }, | |
| { id: "ping", files: ["ping-1", "ping-2"] }, | |
| { id: "laugh", files: ["laugh-1", "laugh-2", "laugh-3"] }, | |
| { id: "error", files: ["error-1"] }, | |
| { id: "spell-travel", files: ["spell-travel-1", "spell-travel-2", "spell-travel-3"] }, | |
| { id: "spell-failed", volume: 0.5, files: ["spell-failed-1", "spell-failed-2"] }, | |
| { id: "trapdoor-close", files: ["trapdoor-close-1", "trapdoor-close-2"] }, | |
| { id: "torch", files: ["torch-1", "torch-2", "torch-3"] }, | |
| { id: "crystal-explode", files: ["crystal-explode"] }, | |
| { id: "crystal-reform", files: ["crystal-reform"] }, | |
| { id: "glitch", volume: 0.8, files: ["glitch"] }, | |
| { id: "portal", files: ["portal"] }, | |
| { id: "crumble", files: ["crumble"] }, | |
| { id: "reform", files: ["reform"] }, | |
| ] | |
| this.soundMap = {} | |
| // I initial had the background 'music' as seperate option but decided to merge both options into one. But the logic for supporting more is still here, hence the object and arrays | |
| this.state = { | |
| sounds: true, | |
| } | |
| this.buttons = { | |
| // music: document.querySelector("#music-button"), | |
| sounds: document.querySelector("#sounds-button"), | |
| soundsText: document.querySelector("#sounds-button .sr-only"), | |
| } | |
| for (let i = this.sounds.length - 1; i >= 0; i--) { | |
| const sound = this.sounds[i] | |
| if (sound.files) { | |
| sound.files.forEach((id) => { | |
| this.sounds.push({ | |
| id, | |
| loop: sound.loop ? sound.loop : false, | |
| volume: sound.volume ? sound.volume : 1, | |
| }) | |
| }) | |
| } | |
| } | |
| } | |
| init(stage) { | |
| if (window.DEBUG.disableSounds) { | |
| this.state = { sounds: false } | |
| } | |
| stage.camera.add(this.audioListener) | |
| this.sounds.forEach((d) => { | |
| if (d.files) { | |
| this.soundMap[d.id] = { | |
| selection: d.files, | |
| } | |
| } else { | |
| let buffer = ASSETS.getSound(d.id) | |
| const sound = new Audio(this.audioListener) | |
| stage.add(sound) | |
| sound.setBuffer(buffer) | |
| sound.setLoop(d.loop ? d.loop : false) | |
| sound.setVolume(d.volume ? d.volume : 1) | |
| this.soundMap[d.id] = sound | |
| d.sound = sound | |
| } | |
| this.ready = true | |
| }) | |
| const types = ["sounds"] | |
| types.forEach((type) => { | |
| this.buttons[type].addEventListener("click", () => this.toggleState(type)) | |
| this.updateButton(type) | |
| }) | |
| } | |
| initError() { | |
| return console.error("sounds not initialized") | |
| } | |
| toggleState(type) { | |
| if (!this.ready) return this.initError() | |
| console.log("toggling", type) | |
| this.state[type] = !this.state[type] | |
| this.updateButton(type) | |
| if (this.state.sounds) { | |
| this.startMusic() | |
| } else { | |
| this.stopAll() | |
| this.stopMusic() | |
| } | |
| } | |
| updateButton(type) { | |
| if (this.state[type]) delete this.buttons.sounds.dataset.off | |
| else this.buttons.sounds.dataset.off = "true" | |
| const copy = this.buttons.soundsText.dataset.copy | |
| this.buttons.soundsText.innerText = copy.replace("$$state", this.state[type] ? "off" : "on") | |
| } | |
| // setMusicState(state) { | |
| // if (!this.ready) return this.initError() | |
| // this.state.music = state | |
| // if (this.state.music) this.startMusic() | |
| // else this.stopMusic() | |
| // this.updateButton("music") | |
| // } | |
| setSoundsState(state) { | |
| if (!this.ready) return this.initError() | |
| this.state.sounds = state | |
| if (this.state.sounds) { | |
| this.startMusic() | |
| } else { | |
| this.stopAll() | |
| this.stopMusic() | |
| } | |
| this.updateButton("sounds") | |
| } | |
| startMusic() { | |
| if (!this.ready) return this.initError() | |
| if (this.state.sounds) { | |
| this.soundMap.music.play() | |
| } else { | |
| this.stopMusic() | |
| } | |
| } | |
| stopMusic() { | |
| if (!this.ready) return this.initError() | |
| this.soundMap.music.pause() | |
| this.soundMap.music.isPlaying = false | |
| // this.soundMap.music.currentTime = 0 | |
| } | |
| play(id, restart = true) { | |
| if (!this.ready) return this.initError() | |
| if (this.state.sounds) { | |
| const sound = this.soundMap[id]?.selection | |
| ? this.soundMap[randomFromArray(this.soundMap[id].selection)] | |
| : this.soundMap[id] | |
| if (sound) { | |
| // console.log("playing", id, sound) | |
| // if (restart) sound.currentTime = 0 | |
| sound.play() | |
| sound.isPlaying = false | |
| } | |
| } | |
| } | |
| stopAll() { | |
| if (!this.ready) return this.initError() | |
| this.sounds.forEach((d) => { | |
| if (d.id !== "music" && d.sound) d.sound.pause() | |
| }) | |
| } | |
| } | |
| const SOUNDS = new SoundController() | |
| // ASSETS | |
| class Assets { | |
| constructor() { | |
| this.loadSequence = ["loadModels", "loadSounds", "loadTextures"] | |
| this.assets = { | |
| models: {}, | |
| sounds: {}, | |
| textures: {}, | |
| } | |
| this.manager = new LoadingManager() | |
| this.loaders = { | |
| models: new GLTFLoader(this.manager), | |
| sounds: new AudioLoader(this.manager), | |
| textures: new TextureLoader(this.manager), | |
| } | |
| this.completedSteps = { | |
| download: false, | |
| audioBuffers: false, | |
| models: false, | |
| } | |
| this.audioBufferCount = 0 | |
| this.modelLoadCount = 0 | |
| const dracoLoader = new DRACOLoader() | |
| dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/") | |
| this.loaders.models.setDRACOLoader(dracoLoader) | |
| // this.init() | |
| } | |
| checkComplete() { | |
| const complete = Object.keys(this.completedSteps).reduce((previous, current) => | |
| !previous ? false : this.completedSteps[current] | |
| ) | |
| if (complete) this.onLoadSuccess() | |
| } | |
| checkBuffers() { | |
| // console.log("checking buffers", this.audioBufferCount, TO_LOAD.sounds.length) | |
| if (this.audioBufferCount === TO_LOAD.sounds.length) { | |
| this.completedSteps.audioBuffers = true | |
| this.checkComplete() | |
| } | |
| } | |
| checkModels() { | |
| // console.log("checking buffers", this.audioBufferCount, TO_LOAD.sounds.length) | |
| if (this.modelLoadCount === TO_LOAD.models.length) { | |
| this.completedSteps.models = true | |
| this.checkComplete() | |
| } | |
| } | |
| load(onLoadSuccess, onLoadError) { | |
| this.onLoadSuccess = onLoadSuccess | |
| this.onLoadError = (err) => { | |
| console.error(err) | |
| onLoadError(err) | |
| } | |
| this.manager.onStart = (url, itemsLoaded, itemsTotal) => { | |
| console.log(`Started loading file: ${url} \nLoaded ${itemsLoaded} of ${itemsTotal} files.`) | |
| } | |
| this.manager.onLoad = () => { | |
| console.log("Loading complete!") | |
| this.completedSteps.download = true | |
| this.checkComplete() | |
| } | |
| // this.manager.on | |
| this.manager.onProgress = (url, itemsLoaded, itemsTotal) => { | |
| document.body.style.setProperty("--loaded", itemsLoaded / itemsTotal) | |
| // console.log(`Progress. Loading file: ${url} \nLoaded ${itemsLoaded} of ${itemsTotal} files.`) | |
| } | |
| this.manager.onError = (url) => { | |
| console.log("There was an error loading " + url) | |
| this.onLoadError(`error loading ${url}`) | |
| } | |
| this.loadNext() | |
| } | |
| loadNext() { | |
| if (this.loadSequence.length) { | |
| this[this.loadSequence.shift()]() | |
| } else { | |
| } | |
| } | |
| loadModels() { | |
| TO_LOAD.models.forEach((item) => { | |
| this.loaders.models.load(item.file, (gltf) => { | |
| // const group = new Group() | |
| if (item.position) gltf.scene.position.set(...item.position) | |
| if (item.scale) gltf.scene.scale.set(item.scale, item.scale, item.scale) | |
| // group.add(gltf.scene) | |
| this.assets.models[item.id] = gltf | |
| // if (item.id === "horse" || item.id === "parrot") { | |
| // var basicMaterial = new MeshBasicMaterial({ | |
| // color: 0xffffff, | |
| // }) | |
| // gltf.scene.traverse((child) => { | |
| // if (child.isMesh) { | |
| // child.material = basicMaterial | |
| // } | |
| // }) | |
| // } | |
| this.modelLoadCount++ | |
| this.checkModels() | |
| }) | |
| }) | |
| this.loadNext() | |
| } | |
| loadSounds() { | |
| TO_LOAD.sounds.forEach((item) => { | |
| this.assets.sounds[item.id] = null | |
| this.loaders.sounds.load(item.file, (buffer) => { | |
| // console.log("--- sound loaded") | |
| // console.log("loaded buffer", buffer) | |
| this.assets.sounds[item.id] = buffer //audio | |
| this.audioBufferCount++ | |
| this.checkBuffers() | |
| }) | |
| }) | |
| this.loadNext() | |
| } | |
| loadTextures() { | |
| TO_LOAD.textures.forEach((item) => { | |
| this.loaders.textures.load(item.file, (texture) => { | |
| this.assets.textures[item.id] = texture | |
| }) | |
| }) | |
| this.loadNext() | |
| } | |
| getModel(id, deepClone) { | |
| console.log("--GET MODEL:", id, this.assets.models[id]) | |
| const group = new Group() | |
| const scene = this.assets.models[id].scene.clone() | |
| scene.traverse((item) => { | |
| if (item.isMesh) { | |
| item.home = { | |
| position: item.position.clone(), | |
| rotation: item.rotation.clone(), | |
| scale: item.scale.clone(), | |
| } | |
| if (deepClone) item.material = item.material.clone() | |
| } | |
| }) | |
| group.add(scene) | |
| return { group, scene, animations: this.assets.models[id].animations } | |
| } | |
| getTexture(id) { | |
| // console.log("getting", id, "from", this.assets.textures) | |
| return this.assets.textures[id] | |
| } | |
| getSound(id) { | |
| // console.log("getting", id, "from", this.assets.sounds) | |
| return this.assets.sounds[id] | |
| } | |
| // setSoundCallback(id, cb) { | |
| // console.log(id, this.assets.sounds[id], cb) | |
| // if (!this.assets.sounds[id]) this.assets.sounds[id] = cb | |
| // } | |
| } | |
| const ASSETS = new Assets() | |
| // CRYSTAL | |
| class Crystal { | |
| constructor(sim, onWhole, onBroken) { | |
| this.machine = interpret(CrystalMachine) | |
| this.state = this.machine.initialState.value | |
| this.wholeCallback = onWhole | |
| this.brokeCallback = onBroken | |
| this.model = ASSETS.getModel("crystal") | |
| this.energy = new CrystalEnergyEmitter(sim) | |
| this.smashItems = [] | |
| this.beams = [] | |
| this.group = this.model.group | |
| this.scene = this.model.scene | |
| this.crystal = null | |
| this.position = { | |
| x: 0, | |
| y: -0.05, | |
| z: 0.165, | |
| } | |
| this.spin = 1 | |
| this.brokenSpin = 0 | |
| this.glitch = 0 | |
| this.elapsedTime = 0 | |
| this.uniforms = { | |
| uTime: { value: 0 }, | |
| uGlow: { value: 0 }, | |
| } | |
| this.material = new MeshMatcapMaterial({ | |
| side: DoubleSide, | |
| }) | |
| this.light = new CrystalLight({ x: 0, y: 0.05, z: 0 }, sim.size) | |
| this.group.add(this.light.light) | |
| this.material.matcap = ASSETS.getTexture("crystal-matcap") | |
| this.material.onBeforeCompile = (shader) => { | |
| shader.uniforms.uTime = this.uniforms.uTime | |
| shader.uniforms.uGlow = this.uniforms.uGlow | |
| shader.fragmentShader = shader.fragmentShader.replace( | |
| "#include <common>", | |
| ` | |
| uniform float uGlow; | |
| #include <common> | |
| ` | |
| ) | |
| shader.fragmentShader = shader.fragmentShader.replace( | |
| "#include <output_fragment>", | |
| `#include <output_fragment> | |
| vec3 color = mix(gl_FragColor.rgb, vec3(1.0), uGlow); | |
| gl_FragColor = vec4(color, gl_FragColor.a); | |
| ` | |
| ) | |
| } | |
| this.model.scene.traverse((item) => { | |
| if (item.type === "Mesh") { | |
| item.material = this.material | |
| if (item.name === "Ruby") { | |
| this.crystal = item | |
| } else { | |
| this.smashItems.push(item) | |
| item.home = { | |
| position: item.position.clone(), | |
| rotation: item.rotation.clone(), | |
| scale: item.scale.clone(), | |
| } | |
| item.random = { | |
| x: Math.random() * 2 - 1, | |
| y: Math.random() * 2 - 1, | |
| z: Math.random() * 2 - 1, | |
| } | |
| } | |
| } | |
| }) | |
| const beams = [ | |
| { | |
| x: 0, | |
| y: Math.PI * 2 * 0, | |
| z: 0, | |
| }, | |
| { | |
| x: 0, | |
| y: Math.PI * 2 * 0.33, | |
| z: 0.5, | |
| }, | |
| { | |
| x: 0, | |
| y: Math.PI * 2 * 0.66, | |
| z: -1, | |
| }, | |
| ] | |
| this.beams = beams.map((r) => { | |
| const plane = new PlaneGeometry(8, 2) | |
| const material = new ShaderMaterial({ | |
| side: DoubleSide, | |
| transparent: true, | |
| vertexShader: ` | |
| uniform float uSize; | |
| uniform float uTime; | |
| varying vec2 vUv; | |
| void main() | |
| { | |
| vUv = uv; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position * (0.1 + uv.x), 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float progress; | |
| uniform bool debug; | |
| varying vec2 vUv; | |
| // #include noise | |
| void main() { | |
| float alpha = (1.0 - smoothstep(0.3, 1.0, vUv.x)); | |
| gl_FragColor.rgb = vec3(1.0, 1.0, 1.0); | |
| gl_FragColor.a = alpha; | |
| } | |
| `, | |
| }) | |
| const mesh = new Mesh(plane, material) | |
| mesh.rotation.set(r.x, r.y, r.z) | |
| mesh.visible = false | |
| this.scene.add(mesh) | |
| return mesh | |
| }) | |
| this.machine.onTransition((s) => this.onStateChange(s)) | |
| this.machine.start() | |
| // this.showFull() | |
| } | |
| onStateChange = (state) => { | |
| this.state = state.value | |
| if (state.changed || this.state === "IDLE") { | |
| switch (this.state) { | |
| case "IDLE": | |
| this.machine.send("start") | |
| break | |
| case "INIT": | |
| this.machine.send("ready") | |
| case "WHOLE": | |
| this.showFull() | |
| this.energy.active = true | |
| if (this.wholeCallback) this.wholeCallback() | |
| break | |
| case "OVERLOADING": | |
| this.energy.active = false | |
| SOUNDS.play("glitch") | |
| this.overloadAnimation() | |
| break | |
| case "BREAKING": | |
| this.showBroken() | |
| SOUNDS.play("crystal-explode") | |
| this.explodeAnimation() | |
| break | |
| case "BROKEN": | |
| if (this.brokeCallback) this.brokeCallback() | |
| break | |
| case "FIXING": | |
| this.energy.active = true | |
| setTimeout(() => SOUNDS.play("crystal-reform"), 200) | |
| this.rewindAnimation() | |
| break | |
| } | |
| } | |
| } | |
| showFull() { | |
| this.crystal.visible = true | |
| this.smashItems.forEach((item) => (item.visible = false)) | |
| } | |
| showBroken() { | |
| this.crystal.visible = false | |
| this.smashItems.forEach((item) => (item.visible = true)) | |
| } | |
| spinDown() { | |
| gsap.to(this, { | |
| spin: 0, | |
| duration: 2.5, | |
| ease: "power2.inOut", | |
| }) | |
| } | |
| spinUp() { | |
| gsap.to(this, { | |
| spin: 1, | |
| duration: 2.5, | |
| ease: "power2.inOut", | |
| }) | |
| } | |
| brokenSpinUp() { | |
| gsap.to(this, { | |
| brokenSpin: 1, | |
| duration: 1, | |
| ease: "power2.inOut", | |
| }) | |
| } | |
| brokenSpinDown() { | |
| this.brokenSpin = 0 | |
| } | |
| glitchSpinUp() { | |
| gsap.to(this, { | |
| glitch: 1, | |
| duration: 2, | |
| ease: "power3.in", | |
| }) | |
| gsap.to(this.uniforms.uGlow, { | |
| value: 0.5, | |
| duration: 4, | |
| ease: "power2.in", | |
| }) | |
| } | |
| glitchSpinDown() { | |
| this.glitch = 0 | |
| gsap.to(this.uniforms.uGlow, { | |
| value: 0, | |
| duration: 1, | |
| ease: "power2.out", | |
| }) | |
| } | |
| overloadAnimation() { | |
| this.spinDown() | |
| this.glitchSpinUp() | |
| gsap.to(this.group.scale, { x: 1.5, z: 1.5, y: 1.5, ease: "power1.in", duration: 4 }) | |
| const tl = gsap.timeline({ | |
| defaults: { duration: 0.4, ease: "power2.inOut" }, | |
| onComplete: () => this.machine.send("break"), | |
| }) | |
| const rotationOffset = this.group.rotation.y % (Math.PI * 2) | |
| tl.to( | |
| this.scene.rotation, | |
| { | |
| x: "+=" + Math.PI * 0, | |
| y: "+=" + Math.PI * 2 * 0.33, | |
| z: "+=" + Math.PI * 0, | |
| onComplete: () => (this.beams[0].visible = true), | |
| }, | |
| 1 | |
| ) | |
| tl.to( | |
| this.scene.rotation, | |
| { | |
| x: "+=" + Math.PI * 0, | |
| y: "+=" + Math.PI * 2 * 0.33, | |
| z: "+=" + Math.PI * 0, | |
| onComplete: () => (this.beams[2].visible = true), | |
| }, | |
| 2 | |
| ) | |
| tl.to( | |
| this.scene.rotation, | |
| { | |
| x: "+=" + Math.PI * 0, | |
| y: "+=" + Math.PI * 2 * 0.33, | |
| z: "+=" + Math.PI * 0.25, | |
| onComplete: () => (this.beams[1].visible = true), | |
| }, | |
| 3 | |
| ) | |
| tl.to(this.scene.rotation, {}, 3.5) | |
| } | |
| explodeAnimation() { | |
| const duration = 3 | |
| this.showBroken() | |
| this.glitchSpinDown() | |
| this.brokenSpinUp() | |
| this.beams.forEach((beam) => (beam.visible = false)) | |
| gsap.delayedCall(duration * 0.8, () => { | |
| this.machine.send("broke") | |
| }) | |
| this.smashItems.forEach((item) => { | |
| gsap.to(item.position, { | |
| x: Math.random() * 10 - 5, | |
| y: Math.random() * 5 - 1, | |
| z: Math.random() * 8 - 4, | |
| ease: "power4.out", | |
| duration, | |
| }) | |
| // gsap.to(item.rotation, { | |
| // x: Math.random() * 6 - 3, | |
| // y: Math.random() * 6 - 3, | |
| // z: Math.random() * 6 - 3, | |
| // ease: "power4.out", | |
| // duration, | |
| // }) | |
| }) | |
| } | |
| rewindAnimation() { | |
| const duration = 2 | |
| this.brokenSpinDown() | |
| gsap.delayedCall(duration * 0.5, () => { | |
| if (this.wholeCallback) this.wholeCallback() | |
| }) | |
| this.spinUp() | |
| gsap.delayedCall(duration, () => this.machine.send("fixed")) | |
| gsap.to(this.scene.rotation, { x: 0, y: "+=" + Math.PI * 3, z: 0, ease: "power4.inOut", duration: duration * 1.5 }) | |
| gsap.to(this.group.scale, { x: 1, z: 1, y: 1, ease: "power4.inOut", duration: duration * 1.5 }) | |
| // gsap.to(this.scene.rotation, { x: 0, z: 0, y: "+=" + Math.PI * 3, ease: "power4.inOut", duration: duration * 1.5 }) | |
| this.smashItems.forEach((item) => { | |
| gsap.to(item.position, { | |
| ...item.home.position, | |
| duration, | |
| ease: "power2.inOut", | |
| }) | |
| gsap.to(item.rotation, { | |
| x: item.home.rotation.x, | |
| y: item.home.rotation.y, | |
| z: item.home.rotation.z, | |
| duration, | |
| ease: "power2.inOut", | |
| }) | |
| }) | |
| } | |
| explode() { | |
| this.machine.send("overload") | |
| } | |
| reset() { | |
| if (this.state === "WHOLE") { | |
| if (this.wholeCallback) this.wholeCallback() | |
| } else this.machine.send("fix") | |
| } | |
| tick(delta) { | |
| this.elapsedTime += delta | |
| this.uniforms.uTime.value = this.elapsedTime | |
| const float = Math.cos(this.elapsedTime) * 0.015 | |
| this.group.rotation.x = Math.cos(this.elapsedTime) * 0.1 * this.spin | |
| this.group.rotation.z = Math.cos(this.elapsedTime) * 0.07 * this.spin | |
| this.group.rotation.y += 0.5 * delta * this.spin | |
| this.group.position.x = this.position.x | |
| this.group.position.y = this.position.y + float * this.spin | |
| this.group.position.z = this.position.z | |
| if (this.light) this.light.tick(delta, this.elapsedTime) | |
| if (this.energy) this.energy.tick(delta, this.elapsedTime) | |
| const rotateFactor = 0.25 | |
| if (this.brokenSpin > 0) { | |
| this.smashItems.forEach((item) => { | |
| item.rotation.x += delta * item.random.x * rotateFactor * this.brokenSpin | |
| item.rotation.y += delta * item.random.y * rotateFactor * this.brokenSpin | |
| item.rotation.z += delta * item.random.z * rotateFactor * this.brokenSpin | |
| }) | |
| } | |
| const glitchAmount = 0.007 | |
| if (this.glitch > 0) { | |
| this.scene.position.x = (Math.random() - 0.5) * glitchAmount * this.glitch | |
| this.scene.position.y = (Math.random() - 0.5) * glitchAmount * this.glitch | |
| this.scene.position.z = (Math.random() - 0.5) * glitchAmount * this.glitch | |
| } | |
| } | |
| } | |
| // ENTRANCE | |
| class Entrance { | |
| constructor(name, points, enterFunc) { | |
| this.name = name | |
| this.points = points | |
| this.enterFunc = enterFunc | |
| } | |
| createPathTo(destination, offset, offsetFromDestination) { | |
| // const waypointCount = 1 | |
| const shift = offsetFromDestination ? { ...destination } : { x: 0, y: 0, z: 0 } | |
| let waypoints = this.calculateEvenlySpacedVectors({ x: 0.5, y: 0.5, z: 0.5 }, destination, 5) | |
| // waypoints.push({ | |
| // x: destination.x, | |
| // y: 0.5, | |
| // z: destination.z, | |
| // }) | |
| let newPath = [...this.points, ...waypoints, destination].map((p) => ({ | |
| x: p.x - shift.x, | |
| y: p.y - shift.y, | |
| z: p.z - shift.z, | |
| })) | |
| const curve = new CatmullRomCurve3(newPath.map((p) => new Vector3(p.x, p.y, p.z))) | |
| return curve | |
| } | |
| calculateEvenlySpacedVectors(center, vector1, numVectors = 2) { | |
| const angleBetweenVectors = (2 * Math.PI) / numVectors | |
| const x1 = vector1.x - center.x | |
| const z1 = vector1.z - center.z | |
| const radius = Math.sqrt(x1 ** 2 + z1 ** 2) | |
| const angle1 = Math.atan2(z1, x1) | |
| const evenlySpacedVectors = [] | |
| for (let i = 1; i <= numVectors; i++) { | |
| const angle = angle1 + i * angleBetweenVectors | |
| const vector = { | |
| x: center.x + radius * Math.cos(angle), | |
| y: 0.2 + Math.random() * 0.6, | |
| z: center.z + radius * Math.sin(angle), | |
| } | |
| evenlySpacedVectors.push(vector) | |
| } | |
| return evenlySpacedVectors | |
| } | |
| createDebugMarkers(container, offset) { | |
| const group = new Group() | |
| this.points.forEach((p) => { | |
| const helper = new Mesh(new SphereGeometry(0.01), new MeshBasicMaterial({ color: 0xffffff })) | |
| helper.position.x = p.x * offset.x | |
| helper.position.y = p.y * offset.y | |
| helper.position.z = p.z * offset.z | |
| group.add(helper) | |
| }) | |
| container.add(group) | |
| } | |
| enter() { | |
| if (this.enterFunc) this.enterFunc() | |
| } | |
| } | |
| // LOCATION | |
| class Location { | |
| #position | |
| #offset | |
| #index | |
| constructor(position, offset, entrances, releaseCallback, markerColor = 0xffffff) { | |
| this.#position = position | |
| this.rotation = position.r | |
| this.#offset = offset | |
| this.group = new Group() | |
| this.releaseCallback = releaseCallback | |
| this.markerColor = markerColor | |
| this.entranceOptions = entrances.map((e) => e.name) | |
| this.entrancePaths = {} | |
| this.energyEmitter = null | |
| this.init() | |
| this.createEntrancePaths(entrances) | |
| } | |
| init() { | |
| this.setPosition() | |
| // this.group.rotation.y = this.rotation | |
| if (window.DEBUG.locations) { | |
| const axesHelper = new AxesHelper(0.1) | |
| axesHelper.rotation.y = this.rotation | |
| this.group.add(axesHelper) | |
| const helper = new Mesh(new SphereGeometry(0.01), new MeshBasicMaterial({ color: this.markerColor })) | |
| this.group.add(helper) | |
| } | |
| } | |
| getRandomEntrance() { | |
| const entrance = randomFromArray(this.entranceOptions) | |
| return this.entrancePaths[entrance] | |
| } | |
| createEntranceTrail(curve) { | |
| const pointsCount = 200 | |
| const frenetFrames = curve.computeFrenetFrames(pointsCount, false) | |
| const points = curve.getSpacedPoints(pointsCount) | |
| const width = [-0.05, 0.05] | |
| let point = new Vector3() | |
| let shift = new Vector3() | |
| let newPoint = new Vector3() | |
| let planePoints = [] | |
| width.forEach((d) => { | |
| for (let i = 0; i < points.length; i++) { | |
| point = points[i] | |
| shift.add(frenetFrames.binormals[i]).multiplyScalar(d) | |
| planePoints.push(new Vector3().copy(point).add(shift)) | |
| } | |
| }) | |
| const geometry = new PlaneGeometry(0.1, 0.1, points.length - 1, 1).setFromPoints(planePoints) | |
| const material = new ShaderMaterial({ | |
| vertexShader: ` | |
| uniform float uSize; | |
| uniform float uTime; | |
| varying vec2 vUv; | |
| void main() | |
| { | |
| vUv = uv; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: FragmentShader(` | |
| uniform float progress; | |
| uniform bool debug; | |
| varying vec2 vUv; | |
| #include noise | |
| void main() { | |
| float length = 0.2; | |
| float strength = 1.0; | |
| float xRange = 1.0 + length * 2.0; | |
| strength *= 1.0 - smoothstep(vUv.x - length * 0.5, xRange * progress, xRange * progress - length * 0.5); | |
| // strength *= step(xRange * progress, vUv.x + length * 0.5 ); | |
| float noiseSmooth = (snoise(vec2((vUv.x) * 5.0, (vUv.y) * 3.0)) + 1.0) / 2.0; | |
| float noiseStepped = step(0.5, noiseSmooth); | |
| float debugColor = 0.0; | |
| if(debug) debugColor = 1.0; | |
| gl_FragColor.rgb = vec3(0.05 + debugColor * (1.0 - noiseSmooth), 0.05, 0.05); | |
| gl_FragColor.a = debugColor + strength * noiseSmooth; | |
| } | |
| `), | |
| side: DoubleSide, | |
| transparent: true, | |
| uniforms: { | |
| progress: { value: 0.0 }, | |
| debug: { value: window.DEBUG.trail }, | |
| }, | |
| }) | |
| const plane = new Mesh(geometry, material) | |
| plane.scale.set(this.#offset.x, this.#offset.y, this.#offset.z) | |
| this.group.add(plane) | |
| plane.visible = false | |
| return plane | |
| } | |
| createEntrancePaths(entrances) { | |
| entrances.forEach((entrance) => { | |
| const curve = entrance.createPathTo(this.#position, this.#offset, true) | |
| // .map((vec3) => vec3.multiply(new Vector3(this.#offset.x, this.#offset.y, this.#offset.z))) | |
| const points = curve.getSpacedPoints(30) | |
| const trail = this.createEntranceTrail(curve) | |
| this.entrancePaths[entrance.name] = { points, curve, trail, entrance } | |
| if (window.DEBUG.locations) { | |
| const geometry = new TubeGeometry(curve, 50, 0.001, 8, false) | |
| const material = new MeshBasicMaterial({ color: this.markerColor }) | |
| const curveObject = new Mesh(geometry, material) | |
| curveObject.scale.set(this.#offset.x, this.#offset.y, this.#offset.z) | |
| // curveObject.rotation.y = -this.rotation | |
| this.group.add(curveObject) | |
| } | |
| }) | |
| } | |
| setPosition() { | |
| const p = { | |
| x: this.#position.x * this.#offset.x, | |
| y: this.#position.y * this.#offset.y, | |
| z: this.#position.z * this.#offset.z, | |
| } | |
| this.group.position.set(p.x, p.y, p.z) | |
| } | |
| add(item) { | |
| this.group.add(item) | |
| } | |
| release() { | |
| this.releaseCallback(this.#index) | |
| } | |
| get x() { | |
| return this.#position.x | |
| } | |
| get y() { | |
| return this.#position.y | |
| } | |
| get z() { | |
| return this.#position.z | |
| } | |
| get position() { | |
| return this.#position | |
| } | |
| set index(newIndex) { | |
| this.#index = newIndex | |
| } | |
| set position(newPosition) { | |
| this.#position = newPosition | |
| this.setPosition() | |
| } | |
| set x(newX) { | |
| this.#position.x = newX | |
| this.setPosition() | |
| } | |
| set y(newY) { | |
| this.#position.y = newY | |
| this.setPosition() | |
| } | |
| set z(newZ) { | |
| this.#position.z = newZ | |
| this.setPosition() | |
| } | |
| } | |
| // PARTICLE SIM | |
| class ParticleSim { | |
| constructor(settings) { | |
| this.settings = { | |
| size: { x: 11, y: 5, z: 12 }, | |
| particles: 5000, | |
| noiseStrength: 0.8, | |
| flowStrength: 0.03, | |
| pixelRatio: 1, | |
| gridFlowDistance: 1, | |
| flowDecay: 0.95, | |
| ...settings, | |
| } | |
| this._size = new Vector3(this.settings.size.x, this.settings.size.y, this.settings.size.z) | |
| const max = Math.max(...this._size.toArray()) | |
| this._size.divideScalar(max) | |
| this.gridCellCount = this.settings.size.x * this.settings.size.y * this.settings.size.z | |
| this._grid = new Float32Array(this.gridCellCount * 3) | |
| this._flow = new Float32Array(this.gridCellCount * 3) | |
| this._noise = new Float32Array(this.gridCellCount * 3) | |
| this.offset = new Vector3( | |
| (this.size.x / this.grid.x) * 0.5, | |
| (this.size.y / this.grid.y) * 0.5, | |
| (this.size.z / this.grid.z) * 0.5 | |
| ) | |
| this.startCoords = new Vector3( | |
| 0 - this.offset.x * this.settings.size.x, | |
| 0 - this.offset.y * this.settings.size.y, | |
| 0 - this.offset.z * this.settings.size.z | |
| ) | |
| this.particleGroups = { | |
| smoke: { | |
| count: 1000, | |
| nextParticle: 0, | |
| newParticles: false, | |
| geometry: new BufferGeometry(), | |
| material: new ShaderMaterial({ | |
| ...DEFAULT_PARTICLE_MATERIAL_SETTINGS, | |
| fragmentShader: ` | |
| const float spriteSheetCount = 8.0; | |
| uniform sampler2D spriteSheet; | |
| varying float vLife; | |
| varying vec3 vColor; | |
| varying vec3 vRandom; | |
| vec4 getSprite(vec2 uv, float i) { | |
| float chunkSize = 1.0 / spriteSheetCount; | |
| return texture( spriteSheet, vec2((chunkSize * i) + uv.x * chunkSize, uv.y) ); | |
| } | |
| void main() | |
| { | |
| if(vLife <= 0.0) discard; | |
| vec2 uv = vec2( gl_PointCoord.x, 1.0 - gl_PointCoord.y ); | |
| vec4 tex = getSprite(uv, floor(vRandom.y * spriteSheetCount)); | |
| vec3 color = mix(tex.rgb, vec3(0.02, 0.0, 0.0), 0.8 + vRandom.x * 0.2 ); | |
| float strength = tex.a * 1.0 ; | |
| if(strength < 0.0) strength = 0.0; | |
| float fade = 1.0; | |
| if(vLife < 0.6) { | |
| fade = smoothstep(0.0, 0.6, vLife); | |
| } else { | |
| fade = 1.0 - smoothstep(0.8, 1.0, vLife); | |
| } | |
| gl_FragColor = vec4(color, strength * fade); | |
| } | |
| `, | |
| blending: CustomBlending, | |
| blendDstAlpha: OneFactor, | |
| blendSrcAlpha: ZeroFactor, | |
| uniforms: { | |
| uTime: { value: 0 }, | |
| uGrow: { value: true }, | |
| uSize: { value: 250 * this.settings.pixelRatio }, | |
| spriteSheet: { value: ASSETS.getTexture("smoke-particles") }, | |
| }, | |
| }), | |
| mesh: null, | |
| properties: {}, | |
| }, | |
| magic: { | |
| count: 4000, | |
| nextParticle: 0, | |
| newParticles: false, | |
| geometry: new BufferGeometry(), | |
| material: new ShaderMaterial({ | |
| ...DEFAULT_PARTICLE_MATERIAL_SETTINGS, | |
| fragmentShader: ` | |
| #define wtf 0x5f3759df; | |
| const float spriteSheetCount = 7.0; | |
| uniform sampler2D spriteSheet; | |
| varying float vLife; | |
| varying float vType; | |
| varying vec3 vColor; | |
| varying vec3 vRandom; | |
| vec4 getSprite(vec2 uv, float i) { | |
| float chunkSize = 1.0 / spriteSheetCount; | |
| return texture( spriteSheet, vec2((chunkSize * i) + uv.x * chunkSize, uv.y) ); | |
| } | |
| void main() | |
| { | |
| if(vLife <= 0.0) discard; | |
| vec2 uv = vec2( gl_PointCoord.x, 1.0 - gl_PointCoord.y ); | |
| vec4 tex = getSprite(uv, vType); | |
| float strength = tex.r; | |
| // Diffuse point | |
| if(vType == 1.0) { | |
| if(vRandom.r >= 0.5) strength = tex.r; | |
| else strength = tex.g; | |
| } | |
| if(vType == 6.0) { | |
| if(vRandom.r <= 0.33) strength = tex.r; | |
| else if(vRandom.r >= 0.66) strength = tex.g; | |
| else strength = tex.b; | |
| } | |
| vec3 color = mix(vColor, vec3(1.0), vRandom.x * 0.4 ); | |
| float fade = 1.0; | |
| if(vLife < 0.5) { | |
| fade = smoothstep(0.0, 0.5, vLife); | |
| } else { | |
| fade = 1.0 - smoothstep(0.9, 1.0, vLife); | |
| } | |
| gl_FragColor = vec4(color, strength * fade); | |
| } | |
| `, | |
| blending: AdditiveBlending, | |
| uniforms: { | |
| uGrow: { value: false }, | |
| uTime: { value: 0 }, | |
| uSize: { value: 75 * this.settings.pixelRatio }, | |
| spriteSheet: { value: ASSETS.getTexture("magic-particles") }, | |
| }, | |
| }), | |
| mesh: null, | |
| properties: {}, | |
| }, | |
| } | |
| this.particleGroupsArray = Object.keys(this.particleGroups).map((key) => this.particleGroups[key]) | |
| this.particleGroupsArray.forEach((group) => { | |
| group.mesh = new Points(group.geometry, group.material) | |
| group.mesh.frustumCulled = false | |
| // group.mesh.renderOrder = 10000 | |
| PROPERTIES.vec3.forEach((propertyName) => { | |
| group.properties[propertyName] = new Float32Array(group.count * 3) | |
| }) | |
| PROPERTIES.float.forEach((propertyName) => { | |
| group.properties[propertyName] = new Float32Array(group.count) | |
| }) | |
| group.mesh.position.x -= this.offset.x * this.settings.size.x | |
| group.mesh.position.y -= this.offset.y * this.settings.size.y | |
| group.mesh.position.z -= this.offset.z * this.settings.size.z | |
| group.mesh.scale.set(this._size.x, this._size.y, this._size.z) | |
| group.mesh.renderOrder = 1 | |
| group.geometry.setAttribute("position", new BufferAttribute(group.properties.position, 3)) | |
| group.geometry.setAttribute("color", new BufferAttribute(group.properties.color, 3)) | |
| group.geometry.setAttribute("scale", new BufferAttribute(group.properties.size, 1)) | |
| group.geometry.setAttribute("life", new BufferAttribute(group.properties.life, 1)) | |
| group.geometry.setAttribute("type", new BufferAttribute(group.properties.type, 1)) | |
| group.geometry.setAttribute("random", new BufferAttribute(group.properties.random, 3)) | |
| }) | |
| // this.nextParticle = 0 | |
| // this.newParticles = false | |
| this.castParticles = [] | |
| // this.particlesGeometry = new BufferGeometry() | |
| // this.particlesMaterial = new ShaderMaterial({ | |
| // depthWrite: false, | |
| // blending: AdditiveBlending, | |
| // // blending: CustomBlending, | |
| // // blendDstAlpha: OneFactor, | |
| // // blendSrcAlpha: ZeroFactor, | |
| // vertexColors: true, | |
| // vertexShader, | |
| // fragmentShader, | |
| // uniforms: { | |
| // uTime: { value: 0 }, | |
| // uSize: { value: 75 * this.settings.pixelRatio }, | |
| // spriteSheet: { value: ASSETS.getTexture("magic-particles") }, | |
| // }, | |
| // }) | |
| // this._particles = new Points(this.particlesGeometry, this.particlesMaterial) | |
| // this._particles.frustumCulled = false | |
| // this.particlePosition = new Float32Array(this.settings.particles * 3) | |
| // this.particleDirection = new Float32Array(this.settings.particles * 3) | |
| // this.particleRandom = new Float32Array(this.settings.particles * 3) | |
| // this.particleColor = new Float32Array(this.settings.particles * 3) | |
| // this.particleType = new Float32Array(this.settings.particles) | |
| // this.particleType = new Float32Array(this.settings.particles) | |
| // this.particleSpeed = new Float32Array(this.settings.particles) | |
| // this.particleSpeedDecay = new Float32Array(this.settings.particles) | |
| // this.particleForce = new Float32Array(this.settings.particles) | |
| // this.particleForceDecay = new Float32Array(this.settings.particles) | |
| // this.particleAcceleration = new Float32Array(this.settings.particles) | |
| // this.particleLife = new Float32Array(this.settings.particles) | |
| // this.particleLifeDecay = new Float32Array(this.settings.particles) | |
| // this.particleSize = new Float32Array(this.settings.particles) | |
| this.init() | |
| } | |
| init() { | |
| for (let i = 0; i < this._grid.length; i += 3) { | |
| this._grid[i] = Math.random() * 2 - 1 | |
| this._grid[i + 1] = Math.random() * 2 - 1 | |
| this._grid[i + 2] = Math.random() * 2 - 1 | |
| } | |
| Object.keys(this.particleGroups).forEach((key) => { | |
| const group = this.particleGroups[key] | |
| for (let i = 0; i < group.count; i++) { | |
| this.createParticle(key) | |
| } | |
| for (let i = 0; i < group.properties.random.length; i++) { | |
| group.properties.random[i] = Math.random() | |
| } | |
| }) | |
| // this._particles.position.x -= this.offset.x * this.settings.size.x | |
| // this._particles.position.y -= this.offset.y * this.settings.size.y | |
| // this._particles.position.z -= this.offset.z * this.settings.size.z | |
| // this._particles.scale.set(this._size.x, this._size.y, this._size.z) | |
| // this.particlesGeometry.setAttribute("position", new BufferAttribute(this.particlePosition, 3)) | |
| // this.particlesGeometry.setAttribute("color", new BufferAttribute(this.particleColor, 3)) | |
| // this.particlesGeometry.setAttribute("scale", new BufferAttribute(this.particleSize, 1)) | |
| // this.particlesGeometry.setAttribute("life", new BufferAttribute(this.particleLife, 1)) | |
| // this.particlesGeometry.setAttribute("type", new BufferAttribute(this.particleType, 1)) | |
| // this.particlesGeometry.setAttribute("random", new BufferAttribute(this.particleRandom, 3)) | |
| this.gridFlowLookup = this.setupGridFlowLookup() | |
| } | |
| setupGridFlowLookup() { | |
| let lookupArray = [] | |
| const d = this.settings.gridFlowDistance | |
| for (let z = 0; z < this.settings.size.z; z++) { | |
| for (let y = 0; y < this.settings.size.y; y++) { | |
| for (let x = 0; x < this.settings.size.x; x++) { | |
| const position = { x, y, z } //new Vector3(x, y, z) | |
| let group = [] | |
| for (let _z = position.z - d; _z <= position.z + d; _z++) { | |
| for (let _y = position.y - d; _y <= position.y + d; _y++) { | |
| for (let _x = position.x - d; _x <= position.x + d; _x++) { | |
| const newPosition = { x: _x, y: _y, z: _z } | |
| if (this.validGridPosition(newPosition)) { | |
| group.push(this.getGridIndexFromPosition(newPosition)) | |
| } | |
| } | |
| } | |
| } | |
| lookupArray.push(group) | |
| } | |
| } | |
| } | |
| return lookupArray | |
| } | |
| getVectorFromArray(array, index) { | |
| if (typeof array === "string") array = this["_" + array] | |
| if (array) | |
| return { | |
| x: array[index * 3], | |
| y: array[index * 3 + 1], | |
| z: array[index * 3 + 2], | |
| } | |
| return null | |
| } | |
| getGridSpaceFromPosition(position) { | |
| // position.x *= this.settings.size.x | |
| // position.y *= this.settings.size.y | |
| // position.z *= this.settings.size.z | |
| const gridSpace = { | |
| x: Math.floor(position.x * this.settings.size.x), | |
| y: Math.floor(position.y * this.settings.size.y), | |
| z: Math.floor(position.z * this.settings.size.z), | |
| } | |
| return gridSpace | |
| } | |
| updateArrayFromVector(array, index, vector) { | |
| if (typeof array === "string") array = this["_" + array] | |
| // else | |
| // console.logLimited(vector) | |
| if (array) { | |
| array[index * 3] = vector.x !== undefined ? vector.x : vector.r | |
| array[index * 3 + 1] = vector.y !== undefined ? vector.y : vector.g | |
| array[index * 3 + 2] = vector.z !== undefined ? vector.z : vector.b | |
| } else { | |
| console.logLimited("invalid array") | |
| } | |
| } | |
| getGridIndexFromPosition(position) { | |
| let index = position.x | |
| index += position.y * this.settings.size.x | |
| index += position.z * this.settings.size.x * this.settings.size.y | |
| return index | |
| } | |
| validGridPosition(position) { | |
| if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) { | |
| return false | |
| } | |
| if (position.x < 0 || position.y < 0 || position.z < 0) { | |
| return false | |
| } | |
| if ( | |
| position.x >= this.settings.size.x || | |
| position.y >= this.settings.size.y || | |
| position.z >= this.settings.size.z | |
| ) { | |
| return false | |
| } | |
| return true | |
| } | |
| getGridSpaceDirection(position, source = "grid") { | |
| if (!this.validGridPosition(position)) { | |
| return null | |
| } | |
| let index = this.getGridIndexFromPosition(position) | |
| const direction = this.getVectorFromArray(this[`_${source}`], index) | |
| return direction | |
| } | |
| // getGridSpeed(position) { | |
| // if (!this.validGridPosition(position)) { | |
| // return 0 | |
| // } | |
| // let index = this.getGridIndexFromPosition(position) | |
| // return this._speed[index] | |
| // } | |
| getSurroundingGrid(index) { | |
| const surrounding = this.gridFlowLookup[index] | |
| const toReturn = [] | |
| for (let i = 0; i < surrounding.length; i++) { | |
| const j = surrounding[i] | |
| const direction = { x: this._grid[j * 3], y: this._grid[j * 3 + 1], z: this._grid[j * 3 + 2] } | |
| toReturn.push({ | |
| x: direction.x, | |
| y: direction.y, | |
| z: direction.z, | |
| // speed: this._speed[j], | |
| }) | |
| } | |
| return toReturn | |
| } | |
| getGridCoordsFromIndex(index) { | |
| const z = Math.floor(index / (this.settings.size.z * this.settings.size.y)) | |
| const y = Math.floor((index - z * this.settings.size.x * this.settings.size.y) / this.settings.size.x) | |
| const x = index % this.settings.size.x | |
| return { x, y, z } | |
| } | |
| step(delta, elapsedTime) { | |
| for (let i = 0; i < this.gridCellCount; i++) { | |
| // NOISE | |
| // | |
| // For each cell we update the noise direction. | |
| const coords = this.getGridCoordsFromIndex(i) | |
| const t = elapsedTime * 0.05 | |
| const nc = { | |
| x: coords.x * 0.05 + t, | |
| y: coords.y * 0.05 + t, | |
| z: coords.z * 0.05 + t, | |
| } | |
| const noiseX = noise3D(nc.x, nc.y, nc.z) | |
| const noiseY = noise3D(nc.y, nc.z, nc.x) | |
| const noiseZ = noise3D(nc.z, nc.x, nc.y) | |
| const noise = vector.normalize({ | |
| x: Math.cos(noiseX * Math.PI * 2), | |
| y: Math.sin(noiseY * Math.PI * 2), | |
| z: Math.cos(noiseZ * Math.PI * 2), | |
| }) | |
| // FLOW | |
| // | |
| // For each cell, record the average direction of the | |
| // surrounding cells | |
| const surroundingPositions = this.getSurroundingGrid(i) | |
| // surroundingPositions.push(vector.multiplyScalar(this.getGridSpaceDirection(coords), 0.1)) | |
| const sum = vector.multiplyScalar(noise, this.settings.noiseStrength) | |
| for (let j = 0; j < surroundingPositions.length; j++) { | |
| const direction = surroundingPositions[j] | |
| sum.x += direction.x | |
| sum.y += direction.y | |
| sum.z += direction.z | |
| } | |
| const average = { | |
| x: sum.x / surroundingPositions.length, | |
| y: sum.y / surroundingPositions.length, | |
| z: sum.z / surroundingPositions.length, | |
| } | |
| // Save the FLOW and Noise values. We don't | |
| // apply them the grid yet. | |
| this.updateArrayFromVector("flow", i, average) | |
| this.updateArrayFromVector("noise", i, noise) | |
| } | |
| // Once we have the FLOW and NOISE for the whole | |
| // grid we now go back through and apply the changes | |
| for (let i = 0; i < this._grid.length; i++) { | |
| // Combine the NOISE with the FLOW based on the noiseStrength | |
| // const flowNoise = this._flow[i] + this._noise[i] * this.settings.noiseStrength | |
| // Add the new NOISE+FLOW value to the grid. | |
| this._grid[i] += this._flow[i] * this.settings.flowStrength | |
| } | |
| // PARTICLES | |
| this.particleGroupsArray.map((group) => { | |
| // console.log("group", group) | |
| const { life, lifeDecay, position, direction, force, forceDecay, speed, speedDecay, acceleration } = | |
| group.properties | |
| for (let i = 0; i < group.count; i++) { | |
| // We only update particles that have a life more than 0. | |
| if (life[i] > 0) { | |
| let particlePosition = this.getVectorFromArray(position, i) | |
| let particleDirection = this.getVectorFromArray(direction, i) | |
| const gridSpace = this.getGridSpaceFromPosition(particlePosition) | |
| let gridDirection = this.getGridSpaceDirection(gridSpace) | |
| if (gridDirection) { | |
| particleDirection = vector.lerpVectors( | |
| particleDirection, | |
| vector.normalize(gridDirection), | |
| 1 - Math.max(0, Math.min(1, force[i])) | |
| ) | |
| } | |
| const move = vector.multiplyScalar(particleDirection, delta ? delta * speed[i] : 0.01) | |
| particlePosition = vector.add(particlePosition, move) | |
| // Bounce off edges | |
| AXIS.forEach((xyz) => { | |
| if (particlePosition[xyz] < 0 || particlePosition[xyz] > 1) { | |
| particlePosition[xyz] = particlePosition[xyz] < 0 ? 0 : 1 | |
| particleDirection[xyz] *= -1 | |
| } | |
| }) | |
| this.updateArrayFromVector(position, i, particlePosition) | |
| this.updateArrayFromVector(direction, i, vector.normalize(particleDirection)) | |
| // this.updateArrayFromVector("grid", i, particleDirection) | |
| // this.updateGridSpace(gridSpace, particleDirection) | |
| const gridIndex = this.getGridIndexFromPosition(gridSpace) | |
| if (gridDirection) { | |
| // console.log("force", vector.multiplyScalar(particleDirection, this.particleForce[i])) | |
| const newGridDirection = vector.add( | |
| gridDirection, | |
| vector.multiplyScalar(particleDirection, force[i] * 0.05) | |
| ) | |
| this.updateArrayFromVector("grid", gridIndex, newGridDirection) | |
| speed[i] += vector.length(newGridDirection) * acceleration[i] * delta | |
| if (speed[i] > 1) speed[i] = 1 | |
| } | |
| speed[i] -= speedDecay[i] * delta | |
| force[i] -= forceDecay[i] * delta | |
| life[i] -= lifeDecay[i] * delta | |
| if (speed[i] < 0.015) speed[i] = 0.015 | |
| if (force[i] < 0) force[i] = 0 | |
| if (life[i] < 0) life[i] = 0 | |
| } | |
| } | |
| group.material.uniforms.uTime.value = elapsedTime | |
| group.geometry.attributes.position.needsUpdate = true | |
| group.geometry.attributes.life.needsUpdate = true | |
| if (group.newParticles) { | |
| group.geometry.attributes.scale.needsUpdate = true | |
| group.geometry.attributes.color.needsUpdate = true | |
| group.geometry.attributes.type.needsUpdate = true | |
| group.newParticles = false | |
| } | |
| }) | |
| // console.logLimited(this.settings.flowDecay * delta, delta) | |
| for (let i = 0; i < this._grid.length; i++) { | |
| this._grid[i] *= this.settings.flowDecay //* delta | |
| } | |
| } | |
| getRandomPosition() { | |
| return { | |
| x: Math.random(), | |
| y: Math.random(), | |
| z: Math.random(), | |
| } | |
| } | |
| getRandomDirection() { | |
| return { | |
| x: Math.random() * 2 - 1, | |
| y: Math.random() * 2 - 1, | |
| z: Math.random() * 2 - 1, | |
| } | |
| } | |
| // setParticleMoving(index, position, direction, speed, force) { | |
| // this.updateArrayFromVector(this.particlePosition, index, position) | |
| // this.updateArrayFromVector(this.particleDirection, index, direction) | |
| // this.particleSpeed[index] = speed | |
| // this.particleForce[index] = force | |
| // } | |
| createParticle(groupID, settings) { | |
| const defaults = { | |
| color: { r: 1, g: 1, b: 1 }, | |
| position: { x: 0, y: 0, z: 0 }, | |
| direction: { x: 0, y: 0, z: 0 }, | |
| speed: 0, | |
| speedDecay: 0.6, | |
| force: 0, | |
| forceDecay: 0.1, | |
| life: 0, | |
| lifeDecay: 0.6, | |
| scale: 0.1, | |
| style: PARTICLE_STYLES.soft, | |
| acceleration: 0.1, | |
| casted: false, | |
| } | |
| const particleSettings = { | |
| ...defaults, | |
| ...settings, | |
| } | |
| const group = this.particleGroups[groupID] | |
| if (particleSettings.casted) this.castParticles.push(group.nextParticle) | |
| const { | |
| position, | |
| direction, | |
| color, | |
| speed, | |
| speedDecay, | |
| force, | |
| forceDecay, | |
| life, | |
| lifeDecay, | |
| size, | |
| type, | |
| acceleration, | |
| } = group.properties | |
| this.updateArrayFromVector(position, group.nextParticle, particleSettings.position) | |
| this.updateArrayFromVector(direction, group.nextParticle, particleSettings.direction) | |
| this.updateArrayFromVector(color, group.nextParticle, particleSettings.color) | |
| speed[group.nextParticle] = particleSettings.speed | |
| speedDecay[group.nextParticle] = particleSettings.speedDecay | |
| force[group.nextParticle] = particleSettings.force | |
| forceDecay[group.nextParticle] = particleSettings.forceDecay | |
| life[group.nextParticle] = particleSettings.life | |
| lifeDecay[group.nextParticle] = particleSettings.lifeDecay | |
| size[group.nextParticle] = particleSettings.scale | |
| type[group.nextParticle] = particleSettings.style | |
| acceleration[group.nextParticle] = particleSettings.acceleration | |
| const createdParticleIndex = group.nextParticle | |
| group.nextParticle++ | |
| group.newParticles = true | |
| if (group.nextParticle >= group.count) group.nextParticle = 0 | |
| return createdParticleIndex | |
| } | |
| getParticles(groupID) { | |
| const group = this.particleGroups[groupID] | |
| if (!group) return null | |
| return group.mesh | |
| } | |
| getParticlesProperties(groupID, prop) { | |
| const group = this.particleGroups[groupID] | |
| if (!group) return null | |
| return group.properties[prop] | |
| } | |
| setParticleProperty(group, index, property, value) { | |
| const properies = this.particleGroups[group].properties | |
| const vectors = ["position", "direction", "color"] | |
| if (vectors.indexOf(property) >= 0) this.updateArrayFromVector(properies[property], index, value) | |
| else properies[property][index] = value | |
| } | |
| get grid() { | |
| return { ...this.settings.size, points: this._grid.length / 3 } | |
| } | |
| get size() { | |
| return this._size | |
| } | |
| get particleMeshes() { | |
| return this.particleGroupsArray.map((group) => group.mesh) | |
| } | |
| // get particles() { | |
| // return this._particles | |
| // } | |
| } | |
| // ROOM | |
| class Room { | |
| constructor() { | |
| this.group = new Group() | |
| this.group.scale.set(0.7, 0.72, 0.7) | |
| this.group.position.set(0, -0.03, -0.12) | |
| this.paused = false | |
| this.uniforms = [] | |
| this.items = { | |
| "trapdoor-door": null, | |
| "door-right": null, | |
| "sub-floor": null, | |
| bookshelf: null, | |
| } | |
| this.afterCompile = null | |
| this.allItems = [] | |
| this.vortexItems = [] | |
| const room = ASSETS.getModel("room") | |
| this.group.add(room.group) | |
| this.scene = room.scene | |
| this.skirt = new Mesh(new PlaneGeometry(2, 1), new MeshBasicMaterial({ color: new Color("#000000") })) | |
| this.skirt.position.set(0, -0.77, 0.9) | |
| this.group.add(this.skirt) | |
| const vortexGeometry = new ConeGeometry(0.7, 1, 100, 1, true, Math.PI) | |
| this.vortexMaterial = new ShaderMaterial({ | |
| vertexShader: ` | |
| uniform float uSize; | |
| uniform float uTime; | |
| varying vec2 vUv; | |
| varying vec3 vNormal; | |
| void main() | |
| { | |
| vUv = uv; | |
| vNormal = normal; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: FragmentShader(` | |
| uniform float uTime; | |
| varying vec2 vUv; | |
| varying vec3 vNormal; | |
| #include noise | |
| void main() { | |
| // float fade = smoothstep(0.6, 1.0, 1.0 - vUv.y) ; | |
| float fade = vUv.y ; | |
| float noise = snoise(vec2(vUv.x + vUv.y + uTime * 0.2, vUv.y - uTime * 0.5) * 6.0) ; | |
| noise = 0.2 + smoothstep(0.0, 2.0, noise + 1.0) * 0.8; | |
| float fineNoise = snoise(vec2(vUv.x, vUv.y * uTime)) ; | |
| gl_FragColor = vec4(vec3(fineNoise * 0.8, 1.0, fineNoise * 0.8) * noise * fade, 1.0); | |
| } | |
| `), | |
| side: DoubleSide, | |
| uniforms: { | |
| uTime: { value: 0 }, | |
| }, | |
| // depthTest: false, | |
| // depthWrite: false, | |
| }) | |
| this.vortex = new Mesh(vortexGeometry, this.vortexMaterial) | |
| this.vortex.position.set(0, -0.77, 0.165) | |
| this.vortex.rotation.x = Math.PI | |
| this.vortex.visible = false | |
| this.group.add(this.vortex) | |
| room.scene.traverse((item) => { | |
| /* | |
| We want the shader to compile before the game starts. | |
| We take a bit of a hit on performance on zoomed in scenes | |
| but most of the time the whole room is in view anyways. | |
| */ | |
| item.frustumCulled = false | |
| if (this.items[item.name] !== undefined) { | |
| const object = item | |
| this.items[item.name] = { | |
| object, | |
| uniforms: {}, | |
| originalPosition: { x: object.position.x, y: object.position.y, z: object.position.z }, | |
| originalRotation: { x: object.rotation.x, y: object.rotation.y, z: object.rotation.z }, | |
| } | |
| } | |
| if (item.type === "Mesh") { | |
| this.allItems.push(item) | |
| item.home = { | |
| position: item.position.clone(), | |
| rotation: item.rotation.clone(), | |
| scale: item.scale.clone(), | |
| } | |
| const itemWorldPos = new Vector3() | |
| item.getWorldPosition(itemWorldPos) | |
| const distance = 0.3 | |
| const vortexable = | |
| item.name !== "sub-floor" && Math.abs(itemWorldPos.x) < distance && Math.abs(itemWorldPos.z) < distance | |
| if (vortexable) { | |
| this.vortexItems.push({ | |
| worldPosition: itemWorldPos, | |
| distance: Math.abs(itemWorldPos.x) + Math.abs(itemWorldPos.z), | |
| item, | |
| }) | |
| } | |
| item.pointsUvs = true | |
| // this.meshes.push(item) | |
| item.material.defines.USE_UV = "" | |
| item.material.onBeforeCompile = (shader) => { | |
| const uniform = { value: 0 } | |
| this.uniforms.push(uniform) | |
| shader.uniforms.progress = uniform | |
| shader.fragmentShader = shader.fragmentShader.replace( | |
| "#include <common>", | |
| ` | |
| uniform float progress; | |
| // varying vec2 vUv; | |
| #include <common> | |
| ` | |
| ) | |
| shader.fragmentShader = shader.fragmentShader.replace( | |
| "#include <output_fragment>", | |
| `#include <output_fragment> | |
| // float noise = snoise(vUv); | |
| vec3 blackout = mix(vec3(0.0), gl_FragColor.rgb, progress); | |
| gl_FragColor = vec4(blackout, gl_FragColor.a); | |
| ` | |
| ) | |
| if (this.afterCompile) { | |
| this.afterCompile() | |
| this.afterCompile = null | |
| } | |
| } | |
| } | |
| }) | |
| } | |
| show(amount = 1) { | |
| const duration = 2.5 | |
| this.scene.visible = true | |
| gsap.killTweensOf(this.uniforms) | |
| gsap.to(this.uniforms, { | |
| value: amount, | |
| ease: "power1.in", | |
| duration, | |
| }) | |
| } | |
| hide(instant = false) { | |
| console.log("HIDE ROOM") | |
| const duration = instant ? 0 : 1.5 | |
| gsap.killTweensOf(this.uniforms) | |
| gsap.to(this.uniforms, { | |
| value: 0.0, | |
| duration, | |
| onComplete: () => { | |
| this.scene.visible = false | |
| }, | |
| }) | |
| } | |
| showVortex(cb) { | |
| const duration = 1 | |
| this.vortexMaterial.uniforms.uTime.value = 0 | |
| this.vortex.visible = true | |
| gsap.fromTo(this.vortex.scale, { y: 0.4 }, { y: 1, duration, delay: 0.5 }) | |
| const getRandomAngle = () => Math.random() * (Math.PI * 0.5) - Math.PI * 0.25 | |
| SOUNDS.play("portal") | |
| setTimeout(() => { | |
| SOUNDS.play("crumble") | |
| }, 300) | |
| // gsap.fromTo( | |
| // this.vortexPlaneMaterial.uniforms.uProgress, | |
| // { value: 0 }, | |
| // { delay: 0.5, value: 1, duration: duration * 5 } | |
| // ) | |
| // gsap.to(this.vortexPlane.rotation, { delay: 0.5, z: "+=2", duration: duration * 5, ease: "none" }) | |
| // const getRandomAngle = () => 0.2 | |
| this.items["sub-floor"].object.visible = false | |
| for (let i = 0; i < this.vortexItems.length; i++) { | |
| const obj = this.vortexItems[i] | |
| gsap.to(obj.item.position, { | |
| x: "*= 1.5", | |
| z: "-=0.5", | |
| y: obj.item.name === "pedestal" ? "-=5" : "-=0", | |
| delay: obj.distance * 1.2, | |
| duration, | |
| ease: "power4.in", | |
| }) | |
| // gsap.to(obj.item.rotation, { | |
| // z: getRandomAngle(), | |
| // delay: obj.distance * 1.5, | |
| // duration, | |
| // ease: "power3.in", | |
| // }) | |
| gsap.to(obj.item.scale, { | |
| x: 0, | |
| y: 0, | |
| z: 0, | |
| delay: obj.distance * 1.5, | |
| duration, | |
| ease: "power3.in", | |
| }) | |
| } | |
| gsap.delayedCall(duration * 2, () => { | |
| if (cb) cb() | |
| }) | |
| } | |
| hideVortex(cb) { | |
| const duration = 0.6 | |
| let longestDelay = 0 | |
| SOUNDS.play("reform") | |
| for (let i = 0; i < this.vortexItems.length; i++) { | |
| const obj = this.vortexItems[i] | |
| const delay = Math.max(0, 0.4 - obj.distance * 0.5) | |
| longestDelay = Math.max(longestDelay, delay) | |
| const values = ["position", "rotation", "scale"] | |
| values.forEach((type) => { | |
| gsap.to(obj.item[type], { | |
| x: obj.item.home[type].x, | |
| y: obj.item.home[type].y, | |
| z: obj.item.home[type].z, | |
| delay, | |
| duration, | |
| ease: "power4.out", | |
| }) | |
| }) | |
| } | |
| gsap.delayedCall(duration + longestDelay, () => { | |
| this.items["sub-floor"].object.visible = true | |
| this.vortex.visible = false | |
| if (cb) cb() | |
| }) | |
| } | |
| trapdoorEnter = () => { | |
| const tl = gsap.timeline() | |
| const item = this.items["trapdoor-door"] | |
| tl.to(item.object.rotation, { x: item.originalRotation.x - Math.PI * 0.5, ease: "power2.out", duration: 0.4 }) | |
| tl.to(item.object.rotation, { | |
| onStart: () => { | |
| setTimeout(() => SOUNDS.play("trapdoor-close"), 300) | |
| }, | |
| x: item.originalRotation.x, | |
| ease: "bounce", | |
| duration: 0.9, | |
| }) | |
| } | |
| doorEnter = () => { | |
| const tl = gsap.timeline() | |
| const item = this.items["door-right"] | |
| tl.to(item.object.rotation, { z: item.originalRotation.z + Math.PI * 0.7, ease: "none", duration: 0.3 }) | |
| tl.to(item.object.rotation, { z: item.originalRotation.z, ease: "elastic", duration: 2.5 }) | |
| } | |
| add(item) { | |
| this.group.add(item) | |
| } | |
| pause() { | |
| this.paused = true | |
| this.skirt.visible = false | |
| } | |
| resume() { | |
| this.paused = false | |
| this.skirt.visible = true | |
| } | |
| tick(delta, elapsedTime) { | |
| // console.log("tick") | |
| this.vortexMaterial.uniforms.uTime.value += delta | |
| } | |
| } | |
| // SCREENS | |
| class Screens { | |
| constructor(appElement, machine) { | |
| this.body = document.body | |
| this.screensElement = this.body.querySelector(".screens") | |
| this.spellsInfoElement = document.querySelector(".spells") | |
| this.appElement = appElement | |
| this.machine = machine | |
| this.state = null | |
| this.spellCornerScreens = ["SETUP_GAME", "GAME_RUNNING", "ENDLESS_MODE", "SPECIAL_SPELL", "ENDLESS_SPECIAL_SPELL"] | |
| this.spellDetailScreens = ["INSTRUCTIONS_SPELLS", "SPELL_OVERLAY", "ENDLESS_SPELL_OVERLAY"] | |
| this.setupButtons() | |
| } | |
| setupButtons() { | |
| const buttons = [...this.appElement.querySelectorAll("[data-send]")] | |
| buttons.forEach((button) => { | |
| if (button.dataset.send) { | |
| button.addEventListener("click", () => this.machine.send(button.dataset.send)) | |
| } | |
| }) | |
| } | |
| update(newState) { | |
| this.state = newState | |
| this.appElement.dataset.state = this.state | |
| let delay = 1 | |
| let screen = this.screensElement.querySelector(`[data-screen="${this.state}"]`) | |
| if (screen) { | |
| console.log("screen", screen) | |
| const fades = screen.querySelectorAll("[data-fade]") | |
| gsap.fromTo( | |
| fades, | |
| { opacity: 0, y: 30 }, | |
| { opacity: 1, y: 0, delay, duration: 1, stagger: 0.1, ease: "power2.out" } | |
| ) | |
| } | |
| const state = Flip.getState("[data-flip-spell]") | |
| this.spellsInfoElement.classList[this.spellCornerScreens.includes(this.state) ? "add" : "remove"]("corner") | |
| this.spellsInfoElement.classList[this.spellDetailScreens.includes(this.state) ? "add" : "remove"]("full") | |
| const flipDelay = this.state === "INSTRUCTIONS_SPELLS" ? 1.5 : 0.6 | |
| Flip.from(state, { | |
| duration: 0.8, | |
| ease: "power2.inOut", | |
| onEnter: (elements) => | |
| gsap.fromTo( | |
| elements, | |
| { opacity: 0, y: 30 }, | |
| { duration: 1, y: 0, delay: flipDelay, stagger: 0.1, opacity: 1, ease: "power2.out" } | |
| ), | |
| onLeave: (elements) => gsap.fromTo(elements, { opacity: 1 }, { opacity: 0 }), | |
| // absolute: true, | |
| }) | |
| } | |
| } | |
| // SIM VIZ | |
| class SimViz { | |
| constructor(stage, sim, showDirection = true, showNoise = true) { | |
| this.stage = stage | |
| this.sim = sim | |
| this.container = new Group() | |
| this.stage.add(this.container) | |
| const box = new Box3() | |
| box.setFromCenterAndSize(new Vector3(0, 0, 0), this.sim.size) | |
| const helper = new Box3Helper(box, 0xffffff) | |
| this.container.add(helper) | |
| let x = 0 | |
| let y = 0 | |
| let z = 0 | |
| this.directionArrows = [] | |
| this.noiseArrows = [] | |
| const offset = this.sim.offset | |
| for (let i = 0; i < this.sim.grid.points; i++) { | |
| const gridSpace = new Vector3(x, y, z) | |
| const dir = this.sim.getGridSpaceDirection(gridSpace) | |
| // dir.normalize(); | |
| const origin = new Vector3( | |
| (x / this.sim.grid.x) * this.sim.size.x - this.sim.size.x * 0.5, | |
| (y / this.sim.grid.y) * this.sim.size.y - this.sim.size.y * 0.5, | |
| (z / this.sim.grid.z) * this.sim.size.z - this.sim.size.z * 0.5 | |
| ) | |
| origin.add(offset) | |
| const length = 0.05 | |
| if (showDirection) { | |
| const directionArrowHelper = new ArrowHelper(dir, origin, length, 0xffff00, 0.02, 0.01) | |
| this.directionArrows.push({ helper: directionArrowHelper, gridSpace }) | |
| this.container.add(directionArrowHelper) | |
| } | |
| if (showNoise) { | |
| const noiseArrowHelper = new ArrowHelper(dir, origin, length, 0xff0000, 0.02, 0.01) | |
| this.noiseArrows.push({ helper: noiseArrowHelper, gridSpace }) | |
| this.container.add(noiseArrowHelper) | |
| } | |
| x++ | |
| if (x >= this.sim.grid.x) { | |
| x = 0 | |
| y++ | |
| if (y >= this.sim.grid.y) { | |
| y = 0 | |
| z++ | |
| } | |
| } | |
| } | |
| // this.stage.addTickFunction(() => this.tick()) | |
| } | |
| tick() { | |
| for (const arrow of this.directionArrows) { | |
| // console.log('arrow', arrow) | |
| const direction = this.sim.getGridSpaceDirection(arrow.gridSpace) | |
| arrow.helper.setDirection(vector.normalize(direction)) | |
| arrow.helper.setLength(Math.max(0.01, vector.length(direction) * 0.1)) | |
| } | |
| for (const arrow of this.noiseArrows) { | |
| // console.log('arrow', arrow) | |
| arrow.helper.setDirection(this.sim.getGridSpaceDirection(arrow.gridSpace, "noise")) | |
| } | |
| } | |
| } | |
| // SPELL CASTER | |
| class SpellCaster { | |
| constructor(sim, container, stage, DOMElement, onSpellSuccess, onSpellFail) { | |
| this.machine = interpret(CasterMachine) | |
| this.state = this.machine.initialState.value | |
| this.sim = sim | |
| this.container = container | |
| this.stage = stage | |
| this.successCallback = onSpellSuccess | |
| this.failCallback = onSpellFail | |
| this.DOMElement = DOMElement | |
| this.currentTouchId = null | |
| this.pathElement = document.querySelector("#spell-path") | |
| this.pathPointsGroup = document.querySelector("#spell-points") | |
| this.spellsInfoElement = document.querySelector(".spells") | |
| this.chargingNotification = document.querySelector(".charging-notification") | |
| this.chargingNotificationSpellName = this.chargingNotification.querySelector(".charging-spell") | |
| this.rechargeNotificationTimeout = null | |
| this.noRecharge = false | |
| this.spellStates = { | |
| arcane: { | |
| charge: 0, | |
| rechargeRate: 0.25, | |
| svg: this.spellsInfoElement.querySelector("#spell-svg-viz-arcane"), | |
| path: this.spellsInfoElement.querySelector("#spell-path-viz-arcane"), | |
| }, | |
| fire: { | |
| charge: 0, | |
| rechargeRate: 0.09, | |
| svg: this.spellsInfoElement.querySelector("#spell-svg-viz-fire"), | |
| path: this.spellsInfoElement.querySelector("#spell-path-viz-fire"), | |
| }, | |
| vortex: { | |
| charge: 0, | |
| rechargeRate: 0.05, | |
| svg: this.spellsInfoElement.querySelector("#spell-svg-viz-vortex"), | |
| path: this.spellsInfoElement.querySelector("#spell-path-viz-vortex"), | |
| }, | |
| } | |
| this.spellNames = Object.keys(this.spellStates) | |
| this.allowed = this.spellNames | |
| if (window.DEBUG.casting) { | |
| document.querySelector("#spell-stats").style.display = "block" | |
| document.querySelector("#spell-helper").style.display = "block" | |
| } | |
| this.spellPath = [] | |
| this.spells = [] | |
| this.emitter = new CastEmitter(sim) | |
| this.raycaster = new Raycaster() | |
| this.DOMElementSize = { width: 0, height: 0 } | |
| this.emitPoint = { x: 0, y: 0, z: 0 } | |
| this.touchOffset = { x: 0, y: 0 } | |
| this.init() | |
| } | |
| init() { | |
| this.clearSpell() | |
| this.machine.onTransition((s) => this.onStateChange(s)) | |
| this.machine.start() | |
| this.pointLight = new PointLight(new Color("#ffffff"), 0, 1.2) | |
| this.pointLight.castShadow = true | |
| this.container.add(this.pointLight) | |
| this.pointLight.position.x = 0.5 | |
| this.pointLight.position.y = 0.5 | |
| this.pointLight.position.z = 1 | |
| this.hitPlane = new Mesh( | |
| new PlaneGeometry(this.sim.size.x, this.sim.size.y), | |
| new MeshBasicMaterial({ | |
| color: 0x248f24, | |
| alphaTest: 0, | |
| wireframe: true, | |
| visible: window.DEBUG.casting, | |
| }) | |
| ) | |
| this.hitPlane.position.set(this.sim.size.x * 0.5, this.sim.size.y * 0.5, this.sim.size.z * 0.95) | |
| this.spellPlane = new Mesh( | |
| new PlaneGeometry(1, 1), | |
| new ShaderMaterial({ | |
| // depthWrite: false, | |
| transparent: true, | |
| vertexShader: ` | |
| uniform float uTime; | |
| varying vec2 vUv; | |
| void main() | |
| { | |
| vUv = uv; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: FragmentShader(` | |
| #define PI 3.141592 | |
| #define PI_2 6.283185 | |
| uniform sampler2D uTexture; | |
| uniform float uTime; | |
| uniform float uProgress; | |
| uniform vec3 uColor; | |
| uniform float uSeed; | |
| varying vec2 vUv; | |
| float noiseSize = 30.0 ; | |
| float fadeLength = 3.0; | |
| #include noise | |
| float swipe(vec2 uv, float progress, float direction) { | |
| float x = ((PI_2 + (fadeLength * 2.0)) * progress) - fadeLength; | |
| float angle = (PI - atan(uv.y - 0.5, uv.x - 0.5)); | |
| return smoothstep(x + fadeLength, x - fadeLength, angle * direction) * 0.5; | |
| } | |
| vec2 rotatedUV(float angle, vec2 uv) { | |
| mat2 rotationMatrix = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)); | |
| return rotationMatrix * uv; | |
| } | |
| vec4 sampleRotatedTexture(float angle, vec2 texCoord) | |
| { | |
| // Translate texture coordinates to center | |
| vec2 centeredTexCoord = texCoord - vec2(0.5); | |
| // Create a 2x2 rotation matrix | |
| mat2 rotationMatrix = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)); | |
| // Apply rotation to centered texture coordinates | |
| vec2 rotatedTexCoord = rotatedUV(angle, centeredTexCoord); | |
| // Translate texture coordinates back to original position | |
| rotatedTexCoord += vec2(0.5); | |
| // Sample the texture | |
| return texture(uTexture, rotatedTexCoord); | |
| } | |
| void main() | |
| { | |
| if(uProgress >= 1.0) discard; | |
| // float a = noise * shape.r; | |
| float left = uProgress * 1.0; | |
| float right = -uProgress * 0.5; | |
| float leftNoise = step((snoise((vUv.xy + uSeed )* noiseSize) + 1.0) / 2.0, smoothstep(0.0, 0.9, uProgress)); | |
| float rightNoise = step((snoise((vUv.xy + uSeed )* noiseSize) + 1.0) / 2.0, smoothstep(0.3, 0.9, uProgress)); | |
| // Sample the texture twice with different rotations | |
| vec4 leftTex = sampleRotatedTexture(left, vUv); | |
| vec4 rightTex = texture(uTexture, vUv); | |
| float fade = 1.0 - smoothstep(0.7, 1.0, uProgress); | |
| fade *= smoothstep(0.0, 0.1, uProgress); | |
| float red = leftTex.r * leftNoise; //tex.r; | |
| float green = rightTex.g * rightNoise; | |
| float blue = rightTex.b; | |
| float alpha = min(1.0, red + green); | |
| vec3 color = mix(vec3(1.0), uColor, smoothstep(0.5, 0.7, uProgress)); | |
| gl_FragColor = vec4(color * alpha, alpha * fade); | |
| // gl_FragColor = vec4(vec3(rightNoise, 0.0 ,0.0), 1.0); | |
| } | |
| `), | |
| // blending: CustomBlending, | |
| // blendDstAlpha: OneFactor, | |
| // blendSrcAlpha: ZeroFactor, | |
| uniforms: { | |
| uSeed: { value: Math.random() }, | |
| uColor: { value: new Color("#E1BBFF") }, | |
| uTime: { value: 0 }, | |
| uProgress: { value: 0 }, | |
| uTexture: { value: ASSETS.getTexture("spell-arcane") }, | |
| }, | |
| }) | |
| ) | |
| this.spellPlane.position.set(this.sim.size.x * 0.5, this.sim.size.y * 0.5, this.sim.size.z * 0.93) | |
| this.spellPlane.visible = false | |
| this.container.add(this.hitPlane) | |
| this.container.add(this.spellPlane) | |
| // setup viz | |
| this.spellNames.forEach((spell) => { | |
| const length = this.spellStates[spell].path.getTotalLength() | |
| this.spellStates[spell].svg.style.setProperty("--length", length) | |
| }) | |
| this.onResize() | |
| this.setupSpells() | |
| } | |
| onResize() { | |
| this.DOMElementSize = { | |
| width: this.DOMElement.clientWidth, | |
| height: this.DOMElement.clientHeight, | |
| } | |
| const bbox = this.DOMElement.getBoundingClientRect() | |
| this.touchOffset.x = bbox.left | |
| this.touchOffset.y = bbox.top | |
| } | |
| setupSpells() { | |
| this.spells = [...document.querySelectorAll(".spell")].map((pathElement) => { | |
| // const pathElement = group.querySelector("path") | |
| // console.log(pathElement) | |
| const pathString = pathElement.getAttribute("d") | |
| const spellType = pathElement.dataset.spell | |
| const spellID = pathElement.id | |
| const group = document.querySelector(`[data-spell-shape="${spellID}"]`) | |
| // const | |
| const points = pathString.replace("M", "").split("L") | |
| const path = this.getEvenlySpacedPoints( | |
| points.map((p) => { | |
| const arr = p.split(" ") | |
| return { x: Number(arr[0]), y: Number(arr[1]) } | |
| }) | |
| ) | |
| return { | |
| scoreElement: group.querySelector(".score"), | |
| groupElement: group, | |
| type: spellType, | |
| id: spellID, | |
| path, | |
| lengths: { | |
| x: this.getPathLengths(path, "x"), | |
| y: this.getPathLengths(path, "y"), | |
| }, | |
| } | |
| }) | |
| // console.log("spells = ", this.spells) | |
| } | |
| getLength(pointA, pointB) { | |
| const deltaX = pointA.x - pointB.x | |
| const deltaY = pointA.y - pointB.y | |
| return Math.sqrt(deltaX * deltaX + deltaY * deltaY) | |
| } | |
| getPathLengths(path, type) { | |
| //const checks = [path[0], path[path.length - 1]] | |
| // const start = { x: 0, y: 0 } | |
| const lengths = [] | |
| for (let i = 0; i < path.length; i++) { | |
| const point = path[i] | |
| lengths.push(this.getLength({ ...point, [type]: 0 }, point)) | |
| } | |
| return lengths | |
| } | |
| clearSpell() { | |
| this.spellPath = [] | |
| } | |
| addSpellPathPoint(x, y, clearBoundingBox = false) { | |
| this.spellPath.push({ x, y }) | |
| let mouse = new Vector2() | |
| mouse.x = (x / this.DOMElementSize.width) * 2 - 1 | |
| mouse.y = -(y / this.DOMElementSize.height) * 2 + 1 | |
| this.raycaster.setFromCamera(mouse, this.stage.camera) | |
| var intersects = this.raycaster.intersectObject(this.hitPlane) | |
| if (intersects.length) { | |
| const uv = intersects[0].uv | |
| this.newPoint = { | |
| x: uv.x, | |
| y: uv.y, | |
| z: this.hitPlane.position.z, | |
| } | |
| if (clearBoundingBox) this.resetBoundingBox(this.newPoint) | |
| else this.addToBoundingBox(this.newPoint) | |
| this.emitter.move(this.newPoint) | |
| this.pointLight.position.x = this.newPoint.x * this.sim.size.x | |
| this.pointLight.position.y = this.newPoint.y * this.sim.size.y | |
| this.pointLight.position.z = this.newPoint.z * this.sim.size.z | |
| } | |
| } | |
| animateSpellPlane() { | |
| gsap.killTweensOf(this.spellPlane.material.uniforms.uProgress) | |
| gsap.killTweensOf(this.spellPlane.scale) | |
| this.spellPlane.visible = true | |
| this.spellPlane.position.x = this.boundingBox.center.x * this.sim.size.x | |
| this.spellPlane.position.y = this.boundingBox.center.y * this.sim.size.y | |
| this.spellPlane.scale.x = this.boundingBox.scale.value | |
| this.spellPlane.scale.y = this.boundingBox.scale.value | |
| this.spellPlane.material.uniforms.uSeed.value = Math.random() | |
| const duration = 2 | |
| gsap.fromTo( | |
| this.spellPlane.material.uniforms.uProgress, | |
| { value: 0 }, | |
| { duration, value: 1, onComplete: () => (this.spellPlane.visible = false) } | |
| ) | |
| gsap.fromTo( | |
| this.spellPlane.scale, | |
| { x: this.boundingBox.scale.value + 0.1, y: this.boundingBox.scale.value + 0.1 }, | |
| { x: this.boundingBox.scale.value, y: this.boundingBox.scale.value, duration: duration * 0.9, ease: "power2.out" } | |
| ) | |
| gsap.fromTo( | |
| this.spellPlane.rotation, | |
| { z: (Math.random() > 0.5 ? -Math.PI : Math.PI) * 0.5 }, | |
| { z: 0, duration: duration, ease: "power2.out" } | |
| ) | |
| // gsap.fromTo(this.spellPlane.position, { z: 0.95 }, { z: 0.1, duration, ease: "power4.in" }) | |
| } | |
| addToBoundingBox(point) { | |
| const { topLeft, bottomRight, center, scale } = this.boundingBox | |
| if (!point) point = { x: 0.5, y: 0.5 } | |
| if(point.x === undefined) point.x = 0.5 | |
| if(point.y === undefined) point.y = 0.5 | |
| topLeft.x = Math.min(topLeft.x, point.x) | |
| topLeft.y = Math.min(topLeft.y, point.y) | |
| bottomRight.x = Math.max(bottomRight.x, point.x) | |
| bottomRight.y = Math.max(bottomRight.y, point.y) | |
| center.x = topLeft.x + (bottomRight.x - topLeft.x) * 0.5 | |
| center.y = topLeft.y + (bottomRight.y - topLeft.y) * 0.5 | |
| scale.value = | |
| Math.max((bottomRight.x - topLeft.x) * this.sim.size.x, (bottomRight.y - topLeft.y) * this.sim.size.y) * 1.1 | |
| if (scale.value < 0.2) scale.value = 0.2 | |
| if (scale.value > 0.4) scale.value = 0.4 | |
| this.emitPoint = { x: center.x, y: center.y, z: this.hitPlane.position.z } | |
| } | |
| resetBoundingBox(firstPoint) { | |
| console.log("Reseting bounding box", firstPoint) | |
| if (!firstPoint) firstPoint = { x: 0.5, y: 0.5 } | |
| if(firstPoint.x === undefined) firstPoint.x = 0.5 | |
| if(firstPoint.y === undefined) firstPoint.y = 0.5 | |
| this.boundingBox = { | |
| topLeft: { x: firstPoint.x, y: firstPoint.y }, | |
| bottomRight: { x: firstPoint.x, y: firstPoint.y }, | |
| center: { x: firstPoint.x, y: firstPoint.y }, | |
| scale: { value: 0.25 }, | |
| } | |
| } | |
| setDownListeners(type) { | |
| this.DOMElement[type + "EventListener"]("mousedown", this.onMouseDown) | |
| this.DOMElement[type + "EventListener"]("touchstart", this.onTouchStart) | |
| } | |
| setMoveListeners(type) { | |
| this.DOMElement[type + "EventListener"]("mousemove", this.onMouseMove) | |
| this.DOMElement[type + "EventListener"]("touchmove", this.onTouchMove) | |
| } | |
| setUpListeners(type) { | |
| this.DOMElement[type + "EventListener"]("mouseup", this.onMouseUp) | |
| this.DOMElement[type + "EventListener"]("touchend", this.onTouchEndOrCancel) | |
| this.DOMElement[type + "EventListener"]("touchcancel", this.onTouchEndOrCancel) | |
| } | |
| onStateChange = (state) => { | |
| this.lastState = this.state | |
| this.state = state.value | |
| if (state.changed || this.state === "IDLE") { | |
| switch (this.state) { | |
| case "IDLE": | |
| this.machine.send("ready") | |
| break | |
| case "ACTIVE": | |
| this.clearSpell() | |
| this.setDownListeners("add") | |
| break | |
| case "SUCCESS": | |
| this.successCallback(this.castSpell.type) | |
| this.machine.send("complete") | |
| break | |
| case "FAIL": | |
| this.failCallback() | |
| this.machine.send("complete") | |
| break | |
| case "CASTING": | |
| this.castSpell = null | |
| this.clearInput() | |
| this.setDownListeners("remove") | |
| this.setMoveListeners("add") | |
| this.setUpListeners("add") | |
| break | |
| case "PROCESSING": | |
| this.setMoveListeners("remove") | |
| this.setUpListeners("remove") | |
| this.proccessPath() | |
| break | |
| case "INACTIVE": | |
| this.setDownListeners("remove") | |
| this.setMoveListeners("remove") | |
| this.setUpListeners("remove") | |
| this.castSpell = null | |
| this.clearInput() | |
| if (this.lastState === "CASTING") { | |
| this.clearSpell() | |
| this.onCastFail() | |
| } | |
| } | |
| } | |
| } | |
| drawInputPath() { | |
| if (window.DEBUG.casting) { | |
| this.pathElement.setAttribute( | |
| "d", | |
| `M${this.spellPath[0].x} ${this.spellPath[0].y}L${this.spellPath | |
| .map((point) => `${point.x} ${point.y}`) | |
| .join("L")}` | |
| ) | |
| } | |
| } | |
| clearInput() { | |
| if (window.DEBUG.casting) { | |
| this.pathElement.setAttribute("d", "") | |
| this.pathPointsGroup.innerHTML = "" | |
| } | |
| } | |
| drawInputPoints(points) { | |
| if (window.DEBUG.casting) { | |
| points.forEach((point) => { | |
| var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle") | |
| circle.setAttributeNS(null, "cx", point.x) | |
| circle.setAttributeNS(null, "cy", point.y) | |
| circle.setAttributeNS(null, "r", 2) | |
| this.pathPointsGroup.appendChild(circle) | |
| }) | |
| } | |
| } | |
| proccessPath() { | |
| this.drawInputPath() | |
| const points = this.getEvenlySpacedPoints(this.spellPath) | |
| this.drawInputPoints(points) | |
| const checks = { | |
| x: this.getPathLengths(points, "x"), | |
| y: this.getPathLengths(points, "y"), | |
| } | |
| // console.log("checks", checks) | |
| const results = this.spells.map((spell, i) => { | |
| const result = { | |
| x: this.getCorrelation(checks.x, spell.lengths.x), | |
| y: this.getCorrelation(checks.y, spell.lengths.y), | |
| } | |
| const score = (result.x + result.y) / 2 | |
| return { type: spell.type, spell, score, index: i, result } | |
| }) | |
| const winner = results.reduce( | |
| (currentWinner, contender) => { | |
| if (contender.score <= 1 && contender.score >= 0.8 && contender.score > currentWinner.score) return contender | |
| return currentWinner | |
| }, | |
| { score: 0, type: null, index: -1 } | |
| ) | |
| if (winner.type) { | |
| if ((this.spellStates[winner.type].charge === 1 || this.noRecharge) && this.allowed.includes(winner.type)) { | |
| this.onCastSuccess(winner) | |
| } else { | |
| this.onInsufficientPower(winner) | |
| } | |
| } else this.onCastFail() | |
| this.outputResults(results, winner) | |
| this.emitter.reset() | |
| } | |
| getSpellColor(type) { | |
| switch (type) { | |
| case "arcane": | |
| return { r: 0.2, g: 0, b: 1 } | |
| case "fire": | |
| return { r: 1, g: 0.8, b: 0 } | |
| case "vortex": | |
| default: | |
| return { r: 0, g: 1, b: 0 } | |
| } | |
| } | |
| onCastSuccess(spell) { | |
| this.castSpell = spell | |
| this.spellStates[spell.type].charge = this.noRecharge ? 1 : 0 | |
| this.machine.send("success") | |
| SOUNDS.play("cast") | |
| // SOUNDS.play("ping") | |
| // if (this.castSpell.type === "arcane") setTimeout(() => this.animateSpellPlane(), 300) | |
| this.sim.castParticles.forEach((index) => { | |
| const point = { | |
| index, | |
| life: 0.5, | |
| ...this.sim.getVectorFromArray(this.sim.getParticlesProperties("magic", "position"), index), | |
| } | |
| const newPointType = Math.random() > 0.5 ? PARTICLE_STYLES.circle : PARTICLE_STYLES.point | |
| const spark = newPointType === PARTICLE_STYLES.point | |
| setTimeout( | |
| () => (this.sim.getParticlesProperties("magic", "type")[index] = newPointType), | |
| Math.random() * (spark ? 10 : 100) | |
| ) | |
| this.sim.updateArrayFromVector( | |
| this.sim.getParticlesProperties("magic", "color"), | |
| index, | |
| spark ? { r: 1, g: 1, b: 1 } : this.getSpellColor(spell.type) | |
| ) | |
| gsap.to(point, { | |
| // life: 0.2, | |
| motionPath: [ | |
| { | |
| x: this.emitPoint.x, | |
| y: point.y, | |
| z: 0.9, | |
| }, | |
| { | |
| x: this.emitPoint.x + Math.random() * 0.1 - 0.05, | |
| y: point.y + Math.random() * 0.1 - 0.05, | |
| z: 0.9, | |
| }, | |
| !spark | |
| ? this.emitPoint | |
| : { | |
| x: this.emitPoint.x + Math.random() * 0.4 - 0.2, | |
| y: point.y + Math.random() * 0.4 - 0.2, | |
| z: 0.9 - Math.random() * 0.2, | |
| }, | |
| ], | |
| ease: !spark ? "power4.in" : "power1.out", | |
| duration: (spark ? 2 : 0.9) + Math.random() * 0.05, | |
| life: spark ? 0 : 0.3, | |
| onUpdateParams: [point], | |
| onUpdate: (d) => { | |
| this.sim.getParticlesProperties("magic", "life")[d.index] = d.life | |
| this.sim.updateArrayFromVector(this.sim.getParticlesProperties("magic", "position"), d.index, { | |
| x: d.x, | |
| y: d.y, | |
| z: d.z, | |
| }) | |
| }, | |
| onCompleteParams: [point], | |
| onComplete: (d) => { | |
| this.sim.getParticlesProperties("magic", "life")[d.index] = 0 | |
| }, | |
| }) | |
| }) | |
| this.sim.castParticles = [] | |
| } | |
| onInsufficientPower(spell) { | |
| this.machine.send("fail") | |
| this.castSpell = spell | |
| const newPointType = PARTICLE_STYLES.circle | |
| this.sim.castParticles.forEach((index) => { | |
| this.sim.updateArrayFromVector(this.sim.getParticlesProperties("magic", "color"), index, { r: 1, g: 0, b: 0 }) | |
| }) | |
| setTimeout(() => { | |
| while (this.sim.castParticles.length) { | |
| const particleIndex = this.sim.castParticles.shift() | |
| this.sim.getParticlesProperties("magic", "lifeDecay")[particleIndex] = 0.4 | |
| this.sim.getParticlesProperties("magic", "force")[particleIndex] = 0 | |
| this.sim.getParticlesProperties("magic", "forceDecay")[particleIndex] = 0.2 | |
| } | |
| }, 500) | |
| if (this.rechargeNotificationTimeout) clearTimeout(this.rechargeNotificationTimeout) | |
| this.rechargeNotificationTimeout = setTimeout(() => { | |
| this.chargingNotification.classList.remove("show") | |
| }, 2000) | |
| this.chargingNotificationSpellName.innerText = SPELLS[spell.type] | |
| this.chargingNotification.classList.add("show") | |
| } | |
| onCastFail() { | |
| this.castSpell = null | |
| this.machine.send("fail") | |
| SOUNDS.play("spell-failed") | |
| while (this.sim.castParticles.length) { | |
| const particleIndex = this.sim.castParticles.shift() | |
| this.sim.getParticlesProperties("magic", "lifeDecay")[particleIndex] = 0.4 | |
| // this.sim.updateArrayFromVector(this.sim.particleDirection, particleIndex, { x: 0, y: -1, z: 0 }) | |
| this.sim.getParticlesProperties("magic", "force")[particleIndex] = 0 | |
| this.sim.getParticlesProperties("magic", "forceDecay")[particleIndex] = 0.2 | |
| // this.sim.particleSpeed[particleIndex] = 0.1 | |
| const point = { | |
| index: particleIndex, | |
| speed: 0.015, | |
| } | |
| gsap.to(point, { | |
| // life: 0.2, | |
| speed: 0.15, | |
| ease: "power2.in", | |
| duration: 0.3, | |
| onUpdateParams: [point], | |
| onUpdate: (d) => { | |
| this.sim.getParticlesProperties("magic", "speed")[d.index] = d.speed | |
| // this.sim.updateArrayFromVector(this.sim.particlePosition, d.index, { x: d.x, y: d.y, z: d.z }) | |
| }, | |
| }) | |
| } | |
| } | |
| outputResults(results, winner) { | |
| if (window.DEBUG.casting) { | |
| this.spells.forEach((spell, i) => { | |
| spell.groupElement.classList[i === winner.index ? "add" : "remove"]("cast") | |
| }) | |
| results.forEach((result) => { | |
| result.spell.scoreElement.innerText = result.score | |
| }) | |
| } | |
| } | |
| getCorrelation(x, y) { | |
| var shortestArrayLength = 0 | |
| if (x.length == y.length) { | |
| shortestArrayLength = x.length | |
| } else if (x.length > y.length) { | |
| shortestArrayLength = y.length | |
| // console.error("x has more items in it, the last " + (x.length - shortestArrayLength) + " item(s) will be ignored") | |
| } else { | |
| shortestArrayLength = x.length | |
| // console.error("y has more items in it, the last " + (y.length - shortestArrayLength) + " item(s) will be ignored") | |
| } | |
| var xy = [] | |
| var x2 = [] | |
| var y2 = [] | |
| for (var i = 0; i < shortestArrayLength; i++) { | |
| xy.push(x[i] * y[i]) | |
| x2.push(x[i] * x[i]) | |
| y2.push(y[i] * y[i]) | |
| } | |
| var sum_x = 0 | |
| var sum_y = 0 | |
| var sum_xy = 0 | |
| var sum_x2 = 0 | |
| var sum_y2 = 0 | |
| for (var i = 0; i < shortestArrayLength; i++) { | |
| sum_x += x[i] | |
| sum_y += y[i] | |
| sum_xy += xy[i] | |
| sum_x2 += x2[i] | |
| sum_y2 += y2[i] | |
| } | |
| var step1 = shortestArrayLength * sum_xy - sum_x * sum_y | |
| var step2 = shortestArrayLength * sum_x2 - sum_x * sum_x | |
| var step3 = shortestArrayLength * sum_y2 - sum_y * sum_y | |
| var step4 = Math.sqrt(step2 * step3) | |
| var answer = step1 / step4 | |
| return answer | |
| } | |
| getEvenlySpacedPoints(path, numPoints = 100) { | |
| const totalLength = path.reduce((length, point, index) => { | |
| if (index > 0) { | |
| const prevPoint = path[index - 1] | |
| const deltaX = point.x - prevPoint.x | |
| const deltaY = point.y - prevPoint.y | |
| length += Math.sqrt(deltaX * deltaX + deltaY * deltaY) | |
| } | |
| return length | |
| }, 0) | |
| // console.log("length:", totalLength) | |
| const segmentLength = totalLength / (numPoints - 1) | |
| let currentLength = 0 | |
| let currentPointIndex = 0 | |
| const evenlySpacedPoints = [path[0]] | |
| let lastPoint = null | |
| for (let i = 1; i < numPoints - 1; i++) { | |
| const targetLength = i * segmentLength | |
| while (currentLength < targetLength) { | |
| const startPoint = lastPoint ? lastPoint : path[currentPointIndex] | |
| const endPoint = path[currentPointIndex + 1] | |
| const deltaX = endPoint.x - startPoint.x | |
| const deltaY = endPoint.y - startPoint.y | |
| const segmentLength = Math.sqrt(deltaX * deltaX + deltaY * deltaY) | |
| if (currentLength + segmentLength >= targetLength) { | |
| const t = (targetLength - currentLength) / segmentLength | |
| lastPoint = { | |
| x: startPoint.x + t * deltaX, | |
| y: startPoint.y + t * deltaY, | |
| } | |
| evenlySpacedPoints.push(lastPoint) | |
| currentLength = targetLength | |
| } else { | |
| currentLength += segmentLength | |
| lastPoint = null | |
| currentPointIndex++ | |
| } | |
| } | |
| } | |
| evenlySpacedPoints.push(path[path.length - 1]) // Add the last point | |
| return evenlySpacedPoints | |
| } | |
| activate(limit) { | |
| this.allowed = limit ? [limit] : this.spellNames | |
| this.machine.send("activate") | |
| } | |
| deactivate() { | |
| this.machine.send("deactivate") | |
| } | |
| getXYFromTouch = (event) => { | |
| const touches = event.changedTouches | |
| for (let i = 0; i < touches.length; i++) { | |
| const touch = touches[i] | |
| if (!this.currentTouchId || this.currentTouchId === touch.identifier) { | |
| this.currentTouchId = touch.identifier | |
| return { x: touch.clientX - this.touchOffset.x, y: touch.clientY - this.touchOffset.y } | |
| } | |
| } | |
| } | |
| onTouchStart = (event) => { | |
| const touchPoint = this.getXYFromTouch(event) | |
| this.onSpellStart(touchPoint.x, touchPoint.y) | |
| } | |
| onMouseDown = (event) => { | |
| this.onSpellStart(event.offsetX, event.offsetY) | |
| } | |
| onSpellStart = (x, y) => { | |
| gsap.killTweensOf(this.pointLight) | |
| this.pointLight.intensity = 0.6 | |
| // console.log(this.pointLight.intensity) | |
| this.machine.send("start_cast") | |
| this.addSpellPathPoint(x, y, true) | |
| } | |
| onTouchMove = (event) => { | |
| const touchPoint = this.getXYFromTouch(event) | |
| this.onSpellMove(touchPoint.x, touchPoint.y) | |
| } | |
| onMouseMove = (event) => { | |
| this.onSpellMove(event.offsetX, event.offsetY) | |
| } | |
| onSpellMove = (x, y) => { | |
| this.addSpellPathPoint(x, y) | |
| this.drawInputPath() | |
| } | |
| onTouchEndOrCancel = (event) => { | |
| const touchPoint = this.getXYFromTouch(event) | |
| this.currentTouchId = null | |
| this.onSpellEnd(touchPoint.x, touchPoint.y) | |
| } | |
| onMouseUp = (event) => { | |
| this.onSpellEnd(event.offsetX, event.offsetY) | |
| } | |
| onSpellEnd = (x, y) => { | |
| gsap.to(this.pointLight, { intensity: 0 }) | |
| this.addSpellPathPoint(x, y) | |
| this.machine.send("finished") | |
| } | |
| reset(disableCharging = false) { | |
| this.spellNames.forEach((spell) => { | |
| this.spellStates[spell].charge = disableCharging ? 1 : 0 | |
| }) | |
| this.noRecharge = disableCharging ? true : false | |
| this.updateViz() | |
| } | |
| updateViz() { | |
| this.spellNames.forEach((spell) => { | |
| this.spellStates[spell].svg.style.setProperty("--charge", this.spellStates[spell].charge) | |
| this.spellStates[spell].svg.classList[this.spellStates[spell].charge === 1 ? "add" : "remove"]("ready") | |
| }) | |
| } | |
| tick(delta) { | |
| if (this.state !== "IDLE" && !this.noRecharge) { | |
| this.spellNames.forEach((spell) => { | |
| const state = this.spellStates[spell] | |
| if (state.charge < 1) state.charge += state.rechargeRate * delta | |
| if (state.charge > 1) state.charge = 1 | |
| this.updateViz() | |
| }) | |
| } | |
| } | |
| } | |
| // STAGE | |
| class Stage { | |
| constructor(mount) { | |
| this.container = mount | |
| this.scene = new Scene() | |
| this.scene.background = new Color("#000000") | |
| this.group = new Group() | |
| this.scene.add(this.group) | |
| this.paused = false | |
| const overlayGeometry = new PlaneGeometry(2, 2, 1, 1) | |
| this.overlayMaterial = new ShaderMaterial({ | |
| transparent: true, | |
| vertexShader: ` | |
| void main() | |
| { | |
| vec3 p = vec3(position.x, position.y, -0.1); | |
| gl_Position = vec4(p, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| uniform float uAlpha; | |
| void main() | |
| { | |
| gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha); | |
| } | |
| `, | |
| uniforms: { | |
| uAlpha: { value: 1 }, | |
| }, | |
| }) | |
| this.overlay = new Mesh(overlayGeometry, this.overlayMaterial) | |
| this.scene.add(this.overlay) | |
| // this.gui = new GUI() | |
| this.size = { | |
| width: 1, | |
| height: 1, | |
| } | |
| ColorManagement.enabled = false | |
| this.cameraPositions = { | |
| playing: { x: 0, y: 0.3, z: 1.3 }, | |
| overhead: { x: 0, y: 2, z: 0 }, | |
| paused: { x: 0, y: 0.6, z: 1.6 }, | |
| crystalOffset: { x: 0, y: 0.02, z: 0.2 }, | |
| crystalIntro: { x: 0, y: 0.02, z: 0.3 }, | |
| demon: { x: 0.05, y: -0.1, z: 0.3 }, | |
| crystal: { x: 0, y: 0.02, z: 0.3 }, | |
| bookshelf: { x: 0, y: 0, z: 0.2 }, | |
| spellLesson: { x: 0.1, y: 0.3, z: 1.2 }, | |
| vortex: { x: 0, y: 0.7, z: 1.3 }, | |
| win: { x: 0, y: 0.02, z: 0.3 }, | |
| } | |
| this.cameraLookAts = { | |
| playing: { x: 0, y: -0.12, z: 0 }, | |
| overhead: { x: 0, y: -0.12, z: 0 }, | |
| paused: { x: 0, y: -0.12, z: 0 }, | |
| crystalOffset: { x: 0.05, y: -0.065, z: 0 }, | |
| crystalIntro: { x: 0, y: -0.1, z: 0 }, | |
| demon: { x: -0.2, y: -0.1, z: -0.2 }, | |
| crystal: { x: 0, y: -0.065, z: 0 }, | |
| bookshelf: { x: -0.3, y: -0.07, z: -0.15 }, | |
| spellLesson: { x: 0.15, y: -0.12, z: 0 }, | |
| vortex: { x: 0, y: -0.12, z: 0 }, | |
| win: { x: 0, y: -0.1, z: 0 }, | |
| } | |
| this.defaultCameraPosition = "crystalOffset" | |
| this.setupCamera() | |
| this.setupRenderer() | |
| this.setupLights() | |
| // this.setupRenderPasses() | |
| this.setupOrbitControls() | |
| this.onResize() | |
| this.render() | |
| } | |
| setupRenderPasses() { | |
| this.composer = new EffectComposer(this.renderer) | |
| this.composer.setSize(this.size.width, this.size.height) | |
| this.composer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) | |
| const renderPass = new RenderPass(this.scene, this.camera) | |
| this.composer.addPass(renderPass) | |
| const ssaoPass = new SSAOPass(this.scene, this.camera, this.size.width, this.size.height) | |
| this.composer.addPass(ssaoPass) | |
| this.gui | |
| .add(ssaoPass, "output", { | |
| Default: SSAOPass.OUTPUT.Default, | |
| "SSAO Only": SSAOPass.OUTPUT.SSAO, | |
| "SSAO Only + Blur": SSAOPass.OUTPUT.Blur, | |
| Depth: SSAOPass.OUTPUT.Depth, | |
| Normal: SSAOPass.OUTPUT.Normal, | |
| }) | |
| .onChange(function (value) { | |
| ssaoPass.output = value | |
| }) | |
| this.gui.add(ssaoPass, "kernelRadius").min(0).max(32) | |
| this.gui.add(ssaoPass, "minDistance").min(0.001).max(0.02) | |
| this.gui.add(ssaoPass, "maxDistance").min(0.01).max(0.3) | |
| this.gui.add(ssaoPass, "enabled") | |
| } | |
| setupCamera() { | |
| const lookat = this.cameraLookAts[this.defaultCameraPosition] | |
| this.lookAt = new Vector3(lookat.x, lookat.y, lookat.z) | |
| this.camera = new PerspectiveCamera(35, this.size.width / this.size.height, 0.1, 3) | |
| this.camera.position.set( | |
| this.cameraPositions[this.defaultCameraPosition].x, | |
| this.cameraPositions[this.defaultCameraPosition].y, | |
| this.cameraPositions[this.defaultCameraPosition].z | |
| ) | |
| this.camera.home = { | |
| position: { ...this.camera.position }, | |
| } | |
| this.scene.add(this.camera) | |
| } | |
| reveal() { | |
| gsap.to(this.overlayMaterial.uniforms.uAlpha, { | |
| value: 0, | |
| duration: 2, | |
| onComplete: () => { | |
| this.overlay.visible = false | |
| }, | |
| }) | |
| } | |
| setupOrbitControls() { | |
| this.controls = new OrbitControls(this.camera, this.renderer.domElement) | |
| this.controls.enableDamping = true | |
| this.controls.enabled = false | |
| // this.controls. | |
| } | |
| moveCamera(state, cb) { | |
| if (this.cameraPositions[state] && this.cameraLookAts[state]) { | |
| gsap.killTweensOf(this.camera.position) | |
| gsap.killTweensOf(this.lookAt) | |
| gsap.to(this.camera.position, { | |
| ...this.cameraPositions[state], | |
| duration: 2, | |
| ease: "power2.inOut", | |
| onComplete: () => { | |
| if (cb) cb() | |
| }, | |
| }) | |
| gsap.to(this.lookAt, { ...this.cameraLookAts[state], duration: 2, ease: "power2.inOut" }) | |
| } | |
| } | |
| resetCamera() { | |
| this.moveCamera(this.defaultCameraPosition) | |
| } | |
| setupRenderer() { | |
| this.renderer = new WebGLRenderer({ | |
| canvas: this.canvas, | |
| antialias: true, | |
| }) | |
| this.renderer.outputColorSpace = LinearSRGBColorSpace | |
| this.renderer.toneMapping = ReinhardToneMapping | |
| this.renderer.toneMappingExposure = 8 | |
| this.container.appendChild(this.renderer.domElement) | |
| } | |
| setupLights() { | |
| this.scene.add(new AmbientLight(0xffffff, 0.1)) | |
| const light = new DirectionalLight(0xfcc088, 0.1) | |
| light.position.set(0, 3, -2) | |
| this.scene.add(light) | |
| } | |
| onResize() { | |
| this.size.width = this.container.clientWidth | |
| this.size.height = this.container.clientHeight | |
| this.camera.aspect = this.size.width / this.size.height | |
| this.camera.updateProjectionMatrix() | |
| this.renderer.setSize(this.size.width, this.size.height) | |
| this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) | |
| if (this.composer) { | |
| this.composer.setSize(this.size.width, this.size.height) | |
| this.composer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) | |
| } | |
| } | |
| compile() { | |
| this.renderer.compile(this.scene, this.camera) | |
| } | |
| render() { | |
| if (!this.paused || !window.DEBUG.allowLookAtMoveWhenPaused) { | |
| this.camera.lookAt(this.lookAt) | |
| this.controls.target.x = this.lookAt.x | |
| this.controls.target.y = this.lookAt.y | |
| this.controls.target.z = this.lookAt.z | |
| } | |
| this.controls.update() | |
| if (this.composer) this.composer.render() | |
| else this.renderer.render(this.scene, this.camera) | |
| } | |
| add(element) { | |
| this.group.add(element) | |
| } | |
| destroy() { | |
| this.container.removeChild(this.renderer.domElement) | |
| window.removeEventListener("resize", this.onResize) | |
| } | |
| get everything() { | |
| return this.group | |
| } | |
| set defaultCamera(state) { | |
| console.log(state, this.cameraPositions[state]) | |
| if (this.cameraPositions[state]) { | |
| this.defaultCameraPosition = state | |
| this.resetCamera() | |
| } | |
| } | |
| set useOrbitControls(enabled) { | |
| this.controls.enabled = enabled | |
| } | |
| } | |
| // TORCH | |
| class Torch { | |
| constructor(sim, position, noise) { | |
| this.state = "OFF" | |
| this.elapsedTime = 0 | |
| this._light = new TorchLight(position, sim.size, noise) | |
| this.emitter = new TorchEmitter(position, sim) | |
| // this.sceneObjects.add(torch.light) | |
| } | |
| on() { | |
| if (this.state !== "ON") { | |
| SOUNDS.play("torch") | |
| this.state = "ON" | |
| this._light.active = true | |
| this._light.color = "#FA9638" | |
| this.emitter.green = false | |
| } | |
| } | |
| off() { | |
| this.state = "OFF" | |
| this._light.active = false | |
| } | |
| green() { | |
| if (this.state !== "VORTEX") { | |
| SOUNDS.play("torch") | |
| this.state = "VORTEX" | |
| this._light.active = true | |
| this._light.color = "#00FF00" | |
| this.emitter.green = true | |
| this.emitter.flamePuff() | |
| } | |
| } | |
| tick(delta, elapsedTime) { | |
| if (this._light) this._light.tick(delta, this.elapsedTime) | |
| if (this.state !== "OFF") { | |
| this.elapsedTime += delta | |
| if (this.emitter) this.emitter.tick(delta, this.elapsedTime) | |
| } | |
| } | |
| get light() { | |
| return this._light.object | |
| } | |
| } | |
| // FRAGMENT SHADER UTIL | |
| const includes = { | |
| noise: ` | |
| // | |
| // Description : Array and textureless GLSL 2D simplex noise function. | |
| // Author : Ian McEwan, Ashima Arts. | |
| // Maintainer : ijm | |
| // Lastmod : 20110822 (ijm) | |
| // License : Copyright (C) 2011 Ashima Arts. All rights reserved. | |
| // Distributed under the MIT License. See LICENSE file. | |
| // https://github.com/ashima/webgl-noise | |
| // | |
| vec3 mod289(vec3 x) { | |
| return x - floor(x * (1.0 / 289.0)) * 289.0; | |
| } | |
| vec2 mod289(vec2 x) { | |
| return x - floor(x * (1.0 / 289.0)) * 289.0; | |
| } | |
| vec3 permute(vec3 x) { | |
| return mod289(((x*34.0)+1.0)*x); | |
| } | |
| float snoise(vec2 v) | |
| { | |
| const vec4 C = vec4(0.211324865405187, // (3.0-sqrt(3.0))/6.0 | |
| 0.366025403784439, // 0.5*(sqrt(3.0)-1.0) | |
| -0.577350269189626, // -1.0 + 2.0 * C.x | |
| 0.024390243902439); // 1.0 / 41.0 | |
| // First corner | |
| vec2 i = floor(v + dot(v, C.yy) ); | |
| vec2 x0 = v - i + dot(i, C.xx); | |
| // Other corners | |
| vec2 i1; | |
| //i1.x = step( x0.y, x0.x ); // x0.x > x0.y ? 1.0 : 0.0 | |
| //i1.y = 1.0 - i1.x; | |
| i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); | |
| // x0 = x0 - 0.0 + 0.0 * C.xx ; | |
| // x1 = x0 - i1 + 1.0 * C.xx ; | |
| // x2 = x0 - 1.0 + 2.0 * C.xx ; | |
| vec4 x12 = x0.xyxy + C.xxzz; | |
| x12.xy -= i1; | |
| // Permutations | |
| i = mod289(i); // Avoid truncation effects in permutation | |
| vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 )) | |
| + i.x + vec3(0.0, i1.x, 1.0 )); | |
| vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); | |
| m = m*m ; | |
| m = m*m ; | |
| // Gradients: 41 points uniformly over a line, mapped onto a diamond. | |
| // The ring size 17*17 = 289 is close to a multiple of 41 (41*7 = 287) | |
| vec3 x = 2.0 * fract(p * C.www) - 1.0; | |
| vec3 h = abs(x) - 0.5; | |
| vec3 ox = floor(x + 0.5); | |
| vec3 a0 = x - ox; | |
| // Normalise gradients implicitly by scaling m | |
| // Approximation of: m *= inversesqrt( a0*a0 + h*h ); | |
| m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h ); | |
| // Compute final noise value at P | |
| vec3 g; | |
| g.x = a0.x * x0.x + h.x * x0.y; | |
| g.yz = a0.yz * x12.xz + h.yz * x12.yw; | |
| return 130.0 * dot(m, g); | |
| } | |
| `, | |
| } | |
| function FragmentShader(shader) { | |
| const importTypes = Object.keys(includes) | |
| importTypes.forEach((type) => { | |
| shader = shader.replace(`#include ${type}`, includes[type]) | |
| }) | |
| return shader | |
| } | |
| // APP | |
| class App { | |
| constructor() { | |
| this.stage = new Stage(DOM.canvas) | |
| this.machine = interpret(AppMachine) | |
| this.animations = [] | |
| this.frame = 0 | |
| this.elapsedGameTime = 0 | |
| this.health = 1 | |
| this.healthDecay = 0.01 | |
| this.healthReplenish = 0.015 | |
| this.rotating = false | |
| this.noise = createNoise3D() | |
| this.rotationSpeed = 0.2 | |
| this.gameSpeed = 1 | |
| this.endlessMode = false | |
| if (window.DEBUG.endlessMode) { | |
| document.querySelector("#endless-mode").style.display = "block" | |
| } | |
| if (window.DEBUG.appState) { | |
| document.querySelector("#app-state").style.display = "block" | |
| DOM.app.classList.add("showState") | |
| } | |
| if (window.DEBUG.layoutDebug) { | |
| DOM.body.classList.add("debug-layout") | |
| } | |
| this.screens = new Screens(DOM.app, this.machine) | |
| this.enemyState = { ...ENEMY_SETTINGS } | |
| this.appState = this.machine.initialState.value | |
| this.emitters = [] | |
| this.enemies = [] | |
| this.torches = [] | |
| this.init() | |
| } | |
| init() { | |
| this.clock = new Clock() | |
| this.clockWasPaused = false | |
| this.machine.onTransition((s) => this.onStateChange(s)) | |
| this.machine.start() | |
| document.body.addEventListener("keyup", (event) => { | |
| console.log("KEYUP", event) | |
| switch (event.key) { | |
| case "p": | |
| this.machine.send(this.isPaused ? "resume" : "pause") | |
| break | |
| case "d": | |
| this.stage.defaultCamera = this.stage.defaultCameraPosition === "playing" ? "overhead" : "playing" | |
| break | |
| case "c": | |
| DOM.body.classList.toggle("clear-interface") | |
| break | |
| } | |
| }) | |
| const demonTotalElementa = [...document.querySelectorAll("[data-demon-total]")] | |
| demonTotalElementa.forEach((el) => (el.innerText = ENEMY_SETTINGS.totalSend)) | |
| document.addEventListener("visibilitychange", () => { | |
| if (document.hidden) { | |
| this.clockWasPaused = true | |
| this.machine.send("pause") | |
| } | |
| }) | |
| this.onResize() | |
| setTimeout(() => this.onResize(), 500) | |
| window.addEventListener("resize", this.onResize) | |
| this.setupStats() | |
| this.tick() | |
| } | |
| onLocationRelease = (index) => { | |
| if (this.freeLocations.indexOf(index) < 0) this.freeLocations.push(index) | |
| } | |
| onResize = () => { | |
| this.stage.onResize() | |
| if (this.spellCaster) this.spellCaster.onResize() | |
| DOM.svg.setAttribute("width", DOM.body.offsetWidth) | |
| DOM.svg.setAttribute("height", DOM.body.offsetHeight) | |
| // this. | |
| } | |
| createScene = () => { | |
| this.room = new Room() | |
| this.sim = new ParticleSim({ pixelRatio: this.stage.renderer.getPixelRatio() }) | |
| if (window.DEBUG.simNoise || window.DEBUG.simFlow) | |
| this.viz = new SimViz(this.stage, this.sim, window.DEBUG.simFlow, window.DEBUG.simNoise) | |
| this.sceneObjects = new Group() | |
| this.sceneObjects.position.add(this.sim.startCoords) | |
| this.stage.add(this.sceneObjects) | |
| this.sim.particleMeshes.forEach((mesh) => { | |
| this.stage.add(mesh) | |
| }) | |
| this.crystal = new Crystal( | |
| this.sim, | |
| () => this.machine.send("run"), | |
| () => this.machine.send("end") | |
| ) | |
| this.room.add(this.crystal.group) | |
| // this.sim.particles.renderOrder = 1 | |
| this.entrances = [ | |
| new Entrance( | |
| "door", | |
| [ | |
| { x: 0.7, y: 0.35, z: -0.1 }, | |
| { x: 0.47, y: 0.3, z: 0.05 }, | |
| ], | |
| this.room.doorEnter | |
| ), | |
| new Entrance("bookcase", [ | |
| { x: -0.1, y: 0.4, z: 0.45 }, | |
| { x: 0.05, y: 0.4, z: 0.53 }, | |
| ]), | |
| new Entrance("large-window", [ | |
| { x: 1.01, y: 0.4, z: 0.5 }, | |
| { x: 0.95, y: 0.5, z: 0.5 }, | |
| ]), | |
| new Entrance( | |
| "trapdoor", | |
| [ | |
| { x: 0.83, y: -0.1, z: 0.2 }, | |
| { x: 0.83, y: 0.3, z: 0.2 }, | |
| ], | |
| this.room.trapdoorEnter | |
| ), | |
| ] | |
| if (window.DEBUG.entrances) this.entrances.forEach((e) => e.createDebugMarkers(this.sceneObjects, this.sim.size)) | |
| this.freeLocations = [] | |
| const locationColors = [0xff0000, 0x00ff00, 0x0000ff, 0xff00ff, 0xffff00] | |
| this.enemyLocations = [ | |
| { x: 0.25, y: 0, z: 0.3, r: Math.PI * 2 * 0.1 }, | |
| { x: 0.8, y: 0, z: 0.4, r: Math.PI * 2 * 0.8 }, | |
| { x: 0.6, y: 0, z: 0.2, r: Math.PI * 2 * 0.95 }, | |
| { x: 0.2, y: 0, z: 0.7, r: Math.PI * 2 * 0.3 }, | |
| { x: 0.81, y: 0, z: 0.68, r: Math.PI * 2 * 0.7 }, | |
| ].map((d, i) => { | |
| return new Location(d, this.sim.size, this.entrances, this.onLocationRelease, locationColors[i]) | |
| }) | |
| this.enemyLocations.forEach((location, i) => { | |
| this.sceneObjects.add(location.group) | |
| location.index = i | |
| location.energyEmitter = new EnemyEnergyEmitter(this.sim, location.position) | |
| this.freeLocations.push(i) | |
| }) | |
| } | |
| onStateChange = (state) => { | |
| this.appState = state.value | |
| if (state.changed || this.appState === "IDLE") { | |
| // temporary state controls | |
| console.log("NEW APP STATE:", this.appState) | |
| DOM.controls.innerHTML = "" | |
| if (this.winEmitter) this.winEmitter.active = false | |
| this.screens.update(this.appState) | |
| DOM.state.innerText = this.appState | |
| state.nextEvents.forEach((event) => { | |
| const button = document.createElement("BUTTON") | |
| button.innerHTML = event | |
| button.addEventListener("click", () => { | |
| this.machine.send(event) | |
| }) | |
| DOM.controls.appendChild(button) | |
| }) | |
| switch (this.appState) { | |
| case "IDLE": | |
| this.machine.send("load") | |
| break | |
| case "LOADING": | |
| ASSETS.load( | |
| () => { | |
| this.machine.send("complete") | |
| }, | |
| (err) => { | |
| this.machine.send("error") | |
| } | |
| ) | |
| break | |
| case "INIT": | |
| this.createScene() | |
| SOUNDS.init(this.stage) | |
| //add a little demo in the scene so it gets all loaded into memory | |
| // const demon = ASSETS.getModel("demon") | |
| // demon.scene.position.y = -0.2 | |
| // this.stage.add(demon.scene) | |
| this.enemyPool = new EnemyPreloader(this.stage) | |
| this.stage.add(this.room.group) | |
| this.addEmitter(new DustEmitter(this.sim)) | |
| this.winEmitter = new WinEmitter(this.sim) | |
| this.winEmitter.active = false | |
| this.addEmitter(this.winEmitter) | |
| this.spellLights = [0xffffff, 0xffffff, 0xffffff].map((color) => this.makePointLight(color)) | |
| this.spellLightsCount = -1 | |
| this.spellCaster = new SpellCaster( | |
| this.sim, | |
| this.sceneObjects, | |
| this.stage, | |
| DOM.canvas, | |
| (spellID) => { | |
| DOM.spellGuide.classList.remove("show") | |
| console.log("casting spell ", spellID) | |
| switch (spellID) { | |
| case "arcane": | |
| let arcaneEnemies = this.getEnemy(spellID, 1) | |
| arcaneEnemies.forEach((enemy) => { | |
| let spell = new ArcaneSpellEmitter(this.sim, this.spellLight, this.spellCaster.emitPoint, enemy) | |
| this.addEmitter(spell) | |
| }) | |
| break | |
| case "fire": | |
| let fireEnemies = this.getEnemy(spellID, 2) | |
| fireEnemies.forEach((enemy) => { | |
| let spell = new FireSpellEmitter(this.sim, this.spellLight, this.spellCaster.emitPoint, enemy) | |
| this.addEmitter(spell) | |
| }) | |
| break | |
| case "vortex": | |
| let spell = new VortexSpellEmitter(this.sim, this.spellLight, this.spellCaster.emitPoint) | |
| this.machine.send("special") | |
| this.addEmitter(spell) | |
| break | |
| } | |
| }, | |
| () => {} | |
| ) | |
| const torchPositions = [ | |
| { x: 0.036, y: 0.45, z: 0.845 }, | |
| { x: 0.14, y: 0.45, z: 0.035 }, | |
| { x: 0.865, y: 0.45, z: 0.035 }, | |
| { x: 0.952, y: 0.63, z: 0.632 }, | |
| ] | |
| this.torches = torchPositions.map((position) => { | |
| const torch = new Torch(this.sim, position, this.noise) | |
| this.sceneObjects.add(torch.light) | |
| // const emitter = new torchEmitter(position, this.sim) | |
| // this.addEmitter(emitter) | |
| return torch | |
| }) | |
| this.room.afterCompile = () => { | |
| setTimeout(() => { | |
| this.machine.send("begin") | |
| }, 500) | |
| } | |
| break | |
| case "TITLE_SCREEN": | |
| this.stage.reveal() | |
| this.staggerTorchesOff() | |
| this.room.hide() | |
| this.resetRotation() | |
| this.stage.useOrbitControls = false | |
| this.stage.moveCamera("crystalOffset") | |
| break | |
| case "SCENE_DEBUG": | |
| this.resetRotation() | |
| this.stage.moveCamera("playing") | |
| this.stage.useOrbitControls = true | |
| this.room.show() | |
| this.staggerTorchesOn() | |
| this.enemyState.sendCount = 0 | |
| break | |
| case "SETUP_GAME": | |
| this.startGame() | |
| break | |
| case "SETUP_ENDLESS": | |
| this.startEndless() | |
| break | |
| case "INSTRUCTIONS_CRYSTAL": | |
| // this.rotate() | |
| this.resetRotation() | |
| this.room.hide() | |
| this.stage.moveCamera("crystalIntro") | |
| // SOUNDS.prep() | |
| SOUNDS.startMusic() | |
| break | |
| case "INSTRUCTIONS_DEMON": | |
| this.resetLocations() | |
| this.enemyState = { ...ENEMY_SETTINGS } | |
| this.stage.moveCamera("demon") | |
| // setTimeout(() => { | |
| const demoDemon = this.addEnemy(this.enemyLocations[0], "arcane") | |
| console.log("demoDemon:", demoDemon) | |
| demoDemon.onDeadCallback = () => { | |
| this.machine.send("next") | |
| } | |
| // }, 500) | |
| // this.stage.moveCamera("crystal") | |
| break | |
| case "INSTRUCTIONS_CAST": | |
| // this.staggerTorchesOn() | |
| setTimeout(() => { | |
| DOM.spellGuide.classList.add("show") | |
| }, 500) | |
| this.stage.moveCamera("spellLesson") | |
| this.spellCaster.reset(true) | |
| this.spellCaster.activate("arcane") | |
| break | |
| case "INSTRUCTIONS_SPELLS": | |
| this.stage.moveCamera("playing") | |
| this.spellCaster.deactivate() | |
| this.room.show(0.2) | |
| // this.stage.moveCamera("crystal") | |
| break | |
| case "GAME_RUNNING": | |
| case "ENDLESS_MODE": | |
| this.resumeGame() | |
| break | |
| case "PAUSED": | |
| case "ENDLESS_PAUSE": | |
| this.pauseGame() | |
| break | |
| case "SPECIAL_SPELL": | |
| case "ENDLESS_SPECIAL_SPELL": | |
| this.yayItsVortexTime() | |
| break | |
| case "SPELL_OVERLAY": | |
| case "ENDLESS_SPELL_OVERLAY": | |
| this.pauseGame() | |
| break | |
| case "CLEAR_GAME": | |
| case "CLEAR_ENDLESS": | |
| this.endGame() | |
| this.machine.send("end") | |
| break | |
| case "GAME_OVER_ANIMATION": | |
| this.endGame() | |
| this.room.hide() | |
| // this.rotate() | |
| this.stage.moveCamera("crystal") | |
| this.crystal.explode() | |
| break | |
| case "RESETTING_FOR_INSTRUCTIONS": | |
| this.crystal.reset() | |
| break | |
| case "RESETTING_FOR_CREDITS": | |
| this.crystal.reset() | |
| break | |
| case "GAME_OVER": | |
| break | |
| case "WIN_ANIMATION": | |
| this.endGame() | |
| this.room.hide() | |
| this.machine.send("end") | |
| this.rotate() | |
| break | |
| case "WINNER": | |
| this.stage.moveCamera("win") | |
| setTimeout(() => (this.winEmitter.active = true), 500) | |
| break | |
| case "CREDITS": | |
| this.resetRotation() | |
| this.room.show(0.2) | |
| this.staggerTorchesOn() | |
| this.stage.moveCamera("bookshelf") | |
| SOUNDS.startMusic() | |
| break | |
| default: | |
| break | |
| } | |
| } | |
| } | |
| yayItsVortexTime() { | |
| this.spellCaster.deactivate() | |
| this.torches.forEach((torch, i) => { | |
| gsap.delayedCall(i * 0.1, () => torch.green()) | |
| }) | |
| gsap.delayedCall(1.3, () => { | |
| this.stage.moveCamera("vortex") | |
| this.enemies.forEach((enemy) => { | |
| if (enemy && enemy.state === "ALIVE") { | |
| enemy.getSuckedIntoTheAbyss() | |
| } | |
| }) | |
| this.room.showVortex(() => { | |
| gsap.delayedCall(1, () => { | |
| this.room.hideVortex(() => { | |
| if (this.appState === "SPECIAL_SPELL" || this.appState === "ENDLESS_SPECIAL_SPELL") | |
| this.staggerTorchesOn(true) | |
| this.machine.send("complete") | |
| // it's callbacks all the way down | |
| }) | |
| }) | |
| }) | |
| }) | |
| } | |
| resetLocations() { | |
| this.freeLocations = [] | |
| this.enemyLocations.forEach((location, i) => { | |
| this.freeLocations.push(i) | |
| }) | |
| this.enemyPool.resetAll() | |
| } | |
| staggerTorchesOn(instant = false) { | |
| this.torches.forEach((torch, i) => { | |
| gsap.delayedCall((instant ? 0 : 1.5) + i * 0.1, () => torch.on()) | |
| }) | |
| } | |
| staggerTorchesOff() { | |
| this.torches.forEach((torch, i) => { | |
| gsap.delayedCall(i * 0.1, () => torch.off()) | |
| }) | |
| } | |
| setInitialStates() { | |
| SOUNDS.startMusic() | |
| this.resetLocations() | |
| this.spellCaster.reset(this.appState === "SETUP_ENDLESS") | |
| this.staggerTorchesOn() | |
| this.health = 1 | |
| this.elapsedGameTime = 0 // we start a little bit in so that the first demon appears a little quicker | |
| // this.room.hide() | |
| this.room.show() | |
| } | |
| setupStats() { | |
| if (window.DEBUG.fps) { | |
| this.stats = new Stats() | |
| const element = document.querySelector("#fps") | |
| element.style.display = "block" | |
| element.appendChild(this.stats.dom) | |
| } | |
| } | |
| startGame() { | |
| // this.crystalEnergy.active = true | |
| this.enemyState = { ...ENEMY_SETTINGS, lastSent: 2.5 } // we don't start on zero so that the first demon enters faster | |
| this.setInitialStates() | |
| this.endlessMode = false | |
| this.crystal.reset() | |
| // this.machine.send("run") | |
| } | |
| startEndless() { | |
| this.enemyState = { ...ENEMY_SETTINGS, sendFrequency: 2 } | |
| this.setInitialStates() | |
| this.endlessMode = true | |
| this.crystal.reset() | |
| // this.machine.send("run") | |
| } | |
| pauseGame() { | |
| // this.clock.stop() | |
| this.room.pause() | |
| this.spellCaster.deactivate() | |
| this.stage.moveCamera("paused") | |
| this.emitters.forEach((e) => e.pause()) | |
| this.enemies.forEach((e) => e.pause()) | |
| this.stage.paused = true | |
| this.stage.useOrbitControls = true | |
| } | |
| resumeGame() { | |
| // this.clock.start() | |
| // this.staggerTorchesOn(true) | |
| this.room.resume() | |
| this.stage.paused = false | |
| this.stage.useOrbitControls = false | |
| this.spellCaster.activate() | |
| this.stage.moveCamera("playing") | |
| this.emitters.forEach((e) => e.resume()) | |
| this.enemies.forEach((e) => e.resume()) | |
| this.resetRotation() | |
| } | |
| endGame() { | |
| // this.crystalEnergy.active = false | |
| this.spellCaster.deactivate() | |
| this.staggerTorchesOff() | |
| // this.room.crystal.explode() | |
| this.enemies.forEach((e) => e.accend()) | |
| } | |
| getEnemy(type, count) { | |
| if (!type) return null | |
| const toReturn = [] | |
| for (let i = 0; i < this.enemies.length; i++) { | |
| const enemy = this.enemies[i] | |
| if (enemy.state === "ALIVE" && toReturn.length < count) { | |
| toReturn.push(enemy) | |
| } | |
| } | |
| if (toReturn.length) return toReturn | |
| return [null] | |
| } | |
| makePointLight = (color) => { | |
| const pointLight = new PointLight(color, 0, 0.8) | |
| // pointLight.castShadow = true | |
| this.sceneObjects.add(pointLight) | |
| return pointLight | |
| } | |
| addEmitter(emitter) { | |
| if (emitter.model) { | |
| console.log("model", emitter.model) | |
| this.sceneObjects.add(emitter.model.group) | |
| } | |
| this.emitters.push(emitter) | |
| } | |
| getFreeLocation() { | |
| if (!this.freeLocations.length) return null | |
| const i = Math.floor(Math.random() * this.freeLocations.length) | |
| const nextLocation = this.freeLocations.splice(i, 1) | |
| return this.enemyLocations[nextLocation] | |
| } | |
| updateOnScreenEnemyInfo() { | |
| DOM.demonCount.innerText = this.enemyState.killCount | |
| } | |
| addEnemy(forceLocation, forceSpell) { | |
| if (["GAME_RUNNING", "ENDLESS_MODE", "INSTRUCTIONS_DEMON"].indexOf(this.appState) >= 0) { | |
| console.log("add enemy", forceLocation) | |
| const location = forceLocation ? forceLocation : this.getFreeLocation() | |
| if (location && this.enemyState.sendCount < this.enemyState.totalSend) { | |
| const enemy = new Enemy(this.sim, this.enemyPool.borrowDemon(), forceSpell) | |
| // if (enemy.model) this.sceneObjects.add(enemy.model) | |
| enemy.spawn(location) | |
| if (enemy.emitter) this.addEmitter(enemy.emitter) | |
| enemy.onDeadCallback = () => { | |
| this.enemyState.killCount++ | |
| if (this.enemyState.killCount === this.enemyState.totalSend) this.machine.send("win") | |
| } | |
| if (this.appState === "GAME_RUNNING") this.enemyState.sendCount++ | |
| this.enemies.push(enemy) | |
| return enemy | |
| } | |
| } | |
| return null | |
| } | |
| rotate() { | |
| this.rotating = true | |
| gsap.to(this, { rotationSpeed: 0.2, duration: 1, ease: "power2.in" }) | |
| } | |
| resetRotation() { | |
| this.rotating = false | |
| const goClockwise = this.stage.everything.rotation.y > Math.PI ? true : false | |
| gsap.to(this, { rotationSpeed: 0, duration: 1, ease: "power2.out" }) | |
| gsap.to(this.stage.everything.rotation, { y: goClockwise ? Math.PI * 2 : 0, duration: 1, ease: "power2.inOut" }) | |
| } | |
| tick() { | |
| if (this.stats) this.stats.begin() | |
| this.updateOnScreenEnemyInfo() | |
| document.body.style.setProperty("--health", this.health) | |
| let delta = this.clock.getDelta() | |
| if (this.clockWasPaused) { | |
| delta = 0 | |
| this.clockWasPaused = false | |
| } | |
| if (this.spellCaster) { | |
| const rechargeableStates = ["GAME_RUNNING", "ENDLESS_MODE", "SPECIAL_SPELL", "ENDLESS_SPECIAL_SPELL"] | |
| if (rechargeableStates.includes(this.appState)) { | |
| this.spellCaster.tick(delta) | |
| } | |
| } | |
| if (this.sim) { | |
| if (!this.isPaused) { | |
| this.elapsedGameTime += delta | |
| // const elapsedTime = this.clock.getElapsedTime() | |
| this.animations.map((mixer) => { | |
| mixer.update(delta * mixer.timeScale) | |
| }) | |
| for (let i = this.emitters.length - 1; i >= 0; i--) { | |
| let emitter = this.emitters[i] | |
| if (emitter === null || emitter.destroyed) { | |
| emitter = null | |
| this.emitters.splice(i, 1) | |
| } else { | |
| emitter.tick(delta, this.elapsedGameTime) | |
| } | |
| } | |
| for (let i = this.enemies.length - 1; i >= 0; i--) { | |
| let enemy = this.enemies[i] | |
| if (enemy === null || enemy.dead) { | |
| enemy = null | |
| this.enemies.splice(i, 1) | |
| } else { | |
| enemy.tick(delta, this.elapsedGameTime) | |
| } | |
| } | |
| for (let i = this.torches.length - 1; i >= 0; i--) { | |
| this.torches[i].tick(delta, this.elapsedGameTime) | |
| } | |
| const es = this.enemyState | |
| if (this.endlessMode && this.enemies.length) { | |
| es.lastSent = 0 | |
| } | |
| if (this.isPlaying) { | |
| es.lastSent += delta | |
| if (es.lastSent >= es.sendFrequency) { | |
| if (!this.endlessMode || !this.enemies.length) { | |
| es.lastSent = 0 | |
| es.sendFrequency -= es.sendFrequencyReduceBy | |
| if (es.sendFrequency < es.minSendFrequency) es.sendFrequency = es.minSendFrequency | |
| this.addEnemy() | |
| } | |
| } | |
| } | |
| if (this.isPlaying && !this.endlessMode) { | |
| this.health += this.healthReplenish * delta | |
| this.health -= this.enemies.length * (this.healthDecay * delta) | |
| this.health = Math.min(1, Math.max(0, this.health)) | |
| } | |
| if (this.isPlaying && this.health <= 0) this.machine.send("game-over") | |
| this.sim.step(delta, this.elapsedGameTime) | |
| if (this.viz) this.viz.tick() | |
| } | |
| if (this.rotating) { | |
| this.stage.everything.rotation.y += this.rotationSpeed * delta | |
| this.stage.everything.rotation.y = this.stage.everything.rotation.y % (Math.PI * 2) | |
| } | |
| for (let i = this.enemyLocations.length - 1; i >= 0; i--) { | |
| const location = this.enemyLocations[i] | |
| if (location.energyEmitter) location.energyEmitter.tick(delta, this.elapsedGameTime) | |
| } | |
| this.stage.render() | |
| this.frame++ | |
| } | |
| if (this.room) this.room.tick(delta, this.elapsedGameTime) | |
| if (this.crystal) this.crystal.tick(delta) | |
| if (this.stats) this.stats.end() | |
| window.requestAnimationFrame(() => this.tick()) | |
| } | |
| get spellLight() { | |
| this.spellLightsCount++ | |
| return this.spellLights[this.spellLightsCount % this.spellLights.length] | |
| } | |
| get isPaused() { | |
| return ["PAUSED", "ENDLESS_PAUSE", "SPELL_OVERLAY", "ENDLESS_SPELL_OVERLAY"].indexOf(this.appState) >= 0 | |
| } | |
| get isPlaying() { | |
| return ["GAME_RUNNING", "ENDLESS_MODE"].indexOf(this.appState) >= 0 | |
| } | |
| } | |
| // lets get this party started: | |
| const app = new App() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @import url("https://fonts.googleapis.com/css2?family=Henny+Penny&family=Tinos:wght@400;700&display=swap"); | |
| :root { | |
| --font-body: "Tinos", serif; | |
| --font-heading: "Henny Penny", cursive; | |
| --font-weight-body: 400; | |
| --font-weight-bold: 700; | |
| --font-weight-heading: 400; | |
| --color-black: black; | |
| --color-black-alpha: rgba(0, 0, 0, 0.7); | |
| --color-white: white; | |
| --color-grey: #767474; | |
| --color-grey-dark: #3e3e3e; | |
| --color-crystal: #d54adf; | |
| --color-crystal-light: #d68ddc; | |
| } | |
| html, | |
| body, | |
| .app { | |
| overflow: hidden; | |
| width: 100%; | |
| height: 100%; | |
| margin: 0; | |
| padding: 0; | |
| font-family: var(--font-body); | |
| font-weight: var(--font-weight-body); | |
| } | |
| body { | |
| font-size: clamp(20px, 4vmin, 26px); | |
| line-height: 110%; | |
| } | |
| .app { | |
| background-color: var(--color-black); | |
| color: #f9f9f9; | |
| h1, | |
| h2, | |
| h3, | |
| h4, | |
| h5, | |
| h6 { | |
| font-family: var(--font-heading); | |
| font-weight: var(--font-weight-heading); | |
| margin: 0; | |
| } | |
| h1 { | |
| font-size: clamp(30px, 14vmin, 130px); | |
| } | |
| h2 { | |
| font-size: clamp(30px, 11vmin, 100px); | |
| } | |
| h3 { | |
| font-size: clamp(24px, 6.5vmin, 60px); | |
| } | |
| h4 { | |
| font-size: clamp(20px, 4vmin, 40px); | |
| } | |
| a, | |
| a:visited { | |
| color: var(--color-crystal-light); | |
| pointer-events: all; | |
| &:hover { | |
| color: var(--color-crystal); | |
| } | |
| } | |
| } | |
| .top-bar { | |
| position: absolute; | |
| top: 1em; | |
| left: 1em; | |
| right: 1em; | |
| z-index: 100; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| font-size: 80%; | |
| pointer-events: none; | |
| .left, | |
| .right { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5em; | |
| } | |
| .left { | |
| gap: 1em; | |
| } | |
| .left > * { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5em; | |
| display: none; | |
| } | |
| button { | |
| --size: 40px; | |
| --border-color: var(--color-grey); | |
| display: none; | |
| width: var(--size); | |
| height: var(--size); | |
| background-color: var(--color-black-alpha); | |
| border: solid 2px var(--border-color); | |
| border-radius: 40px; | |
| cursor: pointer; | |
| position: relative; | |
| align-items: center; | |
| justify-content: center; | |
| pointer-events: all; | |
| svg { | |
| transition: transform 0.2s ease-in-out; | |
| } | |
| &.show-unless { | |
| display: flex; | |
| } | |
| &[data-off] { | |
| svg { | |
| transform: scale(0.8); | |
| } | |
| &::after { | |
| content: ""; | |
| width: 100%; | |
| height: 2px; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| background-color: var(--border-color); | |
| transform: translate(-50%, -50%) rotate(-45deg); | |
| } | |
| } | |
| &:hover, | |
| &:active { | |
| --border-color: var(--color-crystal); | |
| } | |
| } | |
| } | |
| .count { | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .health-bar { | |
| width: 260px; | |
| height: 20px; | |
| border: 2px solid var(--color-grey); | |
| background-color: var(--color-black-alpha); | |
| overflow: hidden; | |
| position: relative; | |
| &::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 5px; | |
| // margin: 2px; | |
| background-color: var(--color-crystal); | |
| transform-origin: left center; | |
| transform: scaleX(calc(1 * var(--health))); | |
| } | |
| } | |
| .canvas, | |
| .overlay, | |
| .screens { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| width: 100%; | |
| height: 100%; | |
| max-height: 100vw; | |
| max-width: 2000px; | |
| transform: translate(-50%, -50%); | |
| } | |
| .screens { | |
| pointer-events: none; | |
| max-width: 1280px; | |
| margin: 0 auto; | |
| > * { | |
| --pad: 5vmin; | |
| position: absolute; | |
| inset: var(--pad); | |
| display: grid; | |
| align-items: stretch; | |
| justify-items: stretch; | |
| justify-content: center; | |
| display: none; | |
| &::after { | |
| grid-area: space; | |
| } | |
| } | |
| .spells { | |
| inset: unset; | |
| bottom: 3vmin; | |
| right: 3vmin; | |
| z-index: 10; | |
| max-width: 46%; | |
| display: none; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| grid-template-rows: 1fr; | |
| gap: 1.5rem; | |
| justify-content: center; | |
| align-items: center; | |
| padding: 0.5rem 1.5rem; | |
| .spell-path { | |
| width: 50px; | |
| position: relative; | |
| .check { | |
| opacity: 0; | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translate(-50%, 0%); | |
| transition-property: opacity, transform; | |
| transition-duration: 0.4s; | |
| transition-timing-function: cubic-bezier(0.52, -0.47, 0.37, 1); | |
| } | |
| svg { | |
| width: 100%; | |
| fill: none; | |
| } | |
| } | |
| .info { | |
| display: none; | |
| flex-direction: column; | |
| h4 { | |
| margin-bottom: 1rem; | |
| } | |
| p { | |
| font-size: 20px; | |
| } | |
| } | |
| .charge-path { | |
| stroke-width: 6; | |
| stroke-dasharray: var(--length) var(--length); | |
| stroke-dashoffset: calc(((1 - var(--charge))) * var(--length)); | |
| } | |
| .guide-path { | |
| stroke: rgba(255, 255, 255, 0.2); | |
| } | |
| .spell-details { | |
| display: flex; | |
| flex-direction: row; | |
| gap: 3rem; | |
| align-items: center; | |
| z-index: 2; | |
| } | |
| .background { | |
| position: absolute; | |
| inset: 0; | |
| border: solid 2px var(--color-grey); | |
| background-color: var(--color-black-alpha); | |
| } | |
| &.corner { | |
| cursor: pointer; | |
| pointer-events: all; | |
| display: grid; | |
| .spell-path { | |
| &.ready { | |
| .check { | |
| opacity: 1; | |
| transform: translate(-50%, -200%); | |
| } | |
| } | |
| } | |
| &:hover { | |
| .background { | |
| border-color: var(--color-crystal); | |
| } | |
| } | |
| } | |
| &.full { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| grid-template-rows: 1fr 1fr 1fr; | |
| bottom: 50%; | |
| gap: 2rem; | |
| transform: translateY(50%); | |
| padding: 2rem 3rem; | |
| .spell-path { | |
| width: 160px; | |
| .check { | |
| transition: none; | |
| } | |
| svg { | |
| --charge: 1 !important; | |
| } | |
| } | |
| .info { | |
| display: flex; | |
| } | |
| } | |
| } | |
| .content { | |
| text-align: center; | |
| grid-area: content; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| > *:not(:last-child) { | |
| margin-bottom: clamp(20px, 5vmin, 50px); | |
| } | |
| } | |
| .button-row { | |
| list-style: none; | |
| margin: 0; | |
| padding: 0; | |
| display: flex; | |
| flex-direction: row; | |
| gap: 0.7em; | |
| } | |
| button { | |
| --border-color: var(--color-grey); | |
| color: var(--color-white); | |
| pointer-events: all; | |
| cursor: pointer; | |
| font-family: var(--font-body); | |
| font-weight: var(--font-weight-body); | |
| &:not(.simple, .no-style) { | |
| background-color: var(--color-black-alpha); | |
| border: 2px solid var(--border-color); | |
| // text-transform: uppercase; | |
| font-size: 30px; | |
| padding: 0.2em 1.4em; | |
| } | |
| &.simple { | |
| background-color: transparent; | |
| border: none; | |
| text-decoration: underline; | |
| text-decoration-color: var(--border-color); | |
| text-decoration-thickness: 2px; | |
| text-underline-offset: 5px; | |
| font-size: 20px; | |
| } | |
| &.no-style { | |
| } | |
| &:hover, | |
| &:active { | |
| --border-color: var(--color-crystal); | |
| } | |
| } | |
| p { | |
| max-width: 600px; | |
| margin: 0; | |
| } | |
| } | |
| .loading-bar { | |
| width: 260px; | |
| height: 2px; | |
| background-color: var(--color-grey-dark); | |
| overflow: hidden; | |
| position: relative; | |
| &::after { | |
| content: ""; | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| top: 0; | |
| left: 0; | |
| background-color: var(--color-crystal); | |
| transform-origin: left center; | |
| transform: scaleX(calc(1 * var(--loaded))); | |
| } | |
| } | |
| [data-state="IDLE"], | |
| [data-state="INIT"] { | |
| #sounds-button { | |
| display: none; | |
| } | |
| } | |
| [data-state="INIT"] { | |
| #sounds-button { | |
| display: none; | |
| } | |
| } | |
| [data-state="LOADING"] { | |
| [data-screen="LOADING"] { | |
| display: grid; | |
| grid-template-columns: 0px 1fr; | |
| grid-template-areas: "space content"; | |
| } | |
| #sounds-button { | |
| display: none; | |
| } | |
| } | |
| [data-state="LOAD_ERROR"] { | |
| #sounds-button { | |
| display: none; | |
| } | |
| } | |
| [data-state="TITLE_SCREEN"] { | |
| [data-screen="TITLE_SCREEN"] { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| grid-template-areas: "space content"; | |
| h1 { | |
| line-height: 1.2em; | |
| } | |
| } | |
| } | |
| [data-state="CREDITS"] { | |
| [data-screen="CREDITS"] { | |
| display: grid; | |
| grid-template-columns: 2fr 1fr; | |
| grid-template-areas: "content space"; | |
| h3, | |
| .content { | |
| width: auto; | |
| text-align: left; | |
| } | |
| ul { | |
| // max-width: 400px; | |
| } | |
| li { | |
| margin-bottom: 1rem; | |
| } | |
| } | |
| } | |
| [data-state="INSTRUCTIONS_CRYSTAL"] { | |
| [data-screen="INSTRUCTIONS_CRYSTAL"] { | |
| display: grid; | |
| grid-template-rows: 1fr 1.2fr; | |
| grid-template-areas: "space" "content"; | |
| } | |
| } | |
| [data-state="INSTRUCTIONS_DEMON"] { | |
| [data-screen="INSTRUCTIONS_DEMON"] { | |
| display: grid; | |
| grid-template-columns: 1.5fr 1fr; | |
| grid-template-areas: "content space"; | |
| .content { | |
| justify-content: flex-end; | |
| } | |
| } | |
| } | |
| [data-state="INSTRUCTIONS_CAST"] { | |
| [data-screen="INSTRUCTIONS_CAST"] { | |
| display: grid; | |
| grid-template-columns: 0px 1fr; | |
| grid-template-areas: "space content"; | |
| } | |
| } | |
| #spell-guide { | |
| width: 70%; | |
| max-width: 400px; | |
| opacity: 0; | |
| transition: opacity 1s ease-in-out; | |
| &.show { | |
| opacity: 0.5; | |
| } | |
| } | |
| [data-state="INSTRUCTIONS_SPELLS"] { | |
| [data-screen="INSTRUCTIONS_SPELLS"] { | |
| display: grid; | |
| grid-template-columns: 1fr 1.5fr; | |
| grid-template-areas: "content space"; | |
| } | |
| } | |
| [data-state="GAME_RUNNING"], | |
| [data-state="SPECIAL_SPELL"] { | |
| #health-bar { | |
| display: flex; | |
| } | |
| #demon-state { | |
| display: flex; | |
| } | |
| #pause-button { | |
| display: flex; | |
| } | |
| } | |
| [data-state="ENDLESS_MODE"], | |
| [data-state="ENDLESS_SPECIAL_SPELL"] { | |
| #endless-mode { | |
| display: flex; | |
| } | |
| #close-button { | |
| display: flex; | |
| } | |
| #pause-button { | |
| display: flex; | |
| } | |
| } | |
| [data-state="ENDLESS_SPELL_OVERLAY"] { | |
| #endless-mode { | |
| display: flex; | |
| } | |
| } | |
| [data-state="PAUSED"], | |
| [data-state="ENDLESS_PAUSE"] { | |
| [data-screen="PAUSED"] { | |
| display: grid; | |
| grid-template-rows: 2fr 1fr; | |
| grid-template-areas: "space" "content"; | |
| .content { | |
| justify-content: flex-end; | |
| } | |
| } | |
| #paused { | |
| display: flex; | |
| } | |
| #pause-button { | |
| display: flex; | |
| } | |
| } | |
| [data-state="ENDLESS_PAUSE"], | |
| [data-state="CREDITS"] { | |
| #close-button { | |
| display: flex; | |
| } | |
| } | |
| [data-state="SPELL_OVERLAY"], | |
| [data-state="ENDLESS_SPELL_OVERLAY"] { | |
| [data-screen="SPELL_OVERLAY"] { | |
| display: grid; | |
| grid-template-columns: 1fr 2fr; | |
| grid-template-areas: "content space"; | |
| } | |
| } | |
| [data-state="SPELL_OVERLAY"] { | |
| #health-bar { | |
| display: flex; | |
| } | |
| #demon-state { | |
| display: flex; | |
| } | |
| } | |
| [data-state="GAME_OVER"] { | |
| [data-screen="GAME_OVER"] { | |
| display: grid; | |
| grid-template-columns: 0px 1fr; | |
| grid-template-areas: "space content"; | |
| .content { | |
| justify-content: flex-end; | |
| } | |
| } | |
| } | |
| [data-state="WINNER"] { | |
| [data-screen="WINNER"] { | |
| display: grid; | |
| grid-template-columns: 0px 1fr; | |
| grid-template-areas: "space content"; | |
| .content { | |
| justify-content: flex-end; | |
| } | |
| } | |
| } | |
| .charging-notification { | |
| position: absolute; | |
| bottom: 40px; | |
| left: 50%; | |
| background-color: rgba(0, 0, 0, 0.7); | |
| border: solid 1px red; | |
| padding: 0.5em 1em; | |
| transform: translate(-50%, 0%); | |
| color: rgb(255, 112, 112); | |
| pointer-events: none; | |
| opacity: 0; | |
| transition-property: opacity, transform; | |
| transition-duration: 0.3s; | |
| transition-timing-function: ease-in-out; | |
| p { | |
| padding: 0; | |
| margin: 0; | |
| } | |
| &.show { | |
| opacity: 1; | |
| transform: translate(-50%, -50%); | |
| } | |
| } | |
| .debug-panels { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| display: flex; | |
| flex-direction: column; | |
| flex-wrap: wrap; | |
| color: white; | |
| // font-size: 12px; | |
| padding: 10px; | |
| gap: 10px; | |
| z-index: 100; | |
| pointer-events: none; | |
| } | |
| .panel { | |
| border: 1px solid white; | |
| padding: 10px; | |
| max-width: 250px; | |
| width: 250px; | |
| p { | |
| margin: 0; | |
| padding: 0; | |
| } | |
| button { | |
| border: 0; | |
| background-color: #f9f9f9; | |
| color: #444; | |
| font-size: 1em; | |
| padding: 6px 10px; | |
| cursor: pointer; | |
| pointer-events: all; | |
| } | |
| > div { | |
| position: relative !important; | |
| } | |
| } | |
| #spell-path { | |
| stroke: red; | |
| stroke-width: 2; | |
| fill: none; | |
| } | |
| #spell-points { | |
| circle { | |
| fill: white; | |
| } | |
| } | |
| #spells { | |
| width: 0; | |
| height: 0; | |
| overflow: hidden; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| } | |
| .controls { | |
| display: flex; | |
| flex-direction: row; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| } | |
| .state { | |
| padding-bottom: 0.5em; | |
| } | |
| .spell-stat { | |
| padding: 0 1rem; | |
| font-size: 14px; | |
| border-left: 5px solid transparent; | |
| &:not(:last-child) { | |
| // border-bottom: 1px solid grey; | |
| padding-bottom: 1rem; | |
| } | |
| .spell-preview { | |
| stroke: white; | |
| stroke-width: 2; | |
| fill: none; | |
| width: 60px; | |
| } | |
| .score { | |
| font-size: 1.4em; | |
| width: 120px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| display: inline; | |
| } | |
| div { | |
| display: flex; | |
| align-items: center; | |
| gap: 2rem; | |
| flex-direction: row; | |
| // font-size: 2rem; | |
| } | |
| &.cast { | |
| border-left: 5px solid red; | |
| } | |
| } | |
| .debug-overlays { | |
| pointer-events: none; | |
| } | |
| .clear-interface { | |
| .debug-panels, | |
| .debug-overlays, | |
| .audio-controls, | |
| .top-bar, | |
| .screens { | |
| display: none; | |
| } | |
| } | |
| .debug-layout { | |
| .top-bar { | |
| outline: solid 2px purple; | |
| } | |
| .screens { | |
| outline: solid 2px green; | |
| > * { | |
| &::after { | |
| display: grid; | |
| align-items: center; | |
| justify-content: center; | |
| content: "SPACE"; | |
| background-color: #ff000055; | |
| outline: solid 2px red; | |
| } | |
| } | |
| } | |
| .content { | |
| background-color: #0000ff55; | |
| outline: solid 2px blue; | |
| } | |
| } | |
| .sr-only { | |
| position: absolute; | |
| left: -9999px; | |
| width: 1px; | |
| height: 1px; | |
| overflow: hidden; | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
duno