Spell Caster Game Using HTML CSS And Javascript

Hey there, You are most welcome to this article. I hope you will enjoy this article. If you like this article then please share this article with your friends and colleagues. If you have any questions or suggestions regarding this article then please comment down below.

๐Ÿ“™Table Of Content

Spell Caster Game Using HTML CSS And Javascript Project Folder Structure:

  • Create a file called  index.html  to serve as the main file.

  • Create a file called  style.css  for the CSS code.
  • Create a file called  script.js  for the JavaScript code.

Spell Caster Game HTML CODE

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <link rel="stylesheet" href="style.scss">
</head>
<body>
  <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>
  <script type="importmap">
    {
      "imports": {
        "three": "https://unpkg.com/three@0.152.0/build/three.module.js",
        "three/addons/": "https://unpkg.com/three@0.152.0/examples/jsm/"
      }
    }
  </script>
  <script src="script.js"></script>
</body>
</html>

Spell Caster GameCSS CODE

SCSS
@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;
}

Spell Caster Game JavaScript CODE

JS
/*  
  Comments here are a work in progress, 
  check back soon for more :)

  I also have this same code
  in Github where things are broken up
  into more files. 
  
  https://github.com/ste-vg/spell-caster
  
  Right lets do this, we have a lot to 
  cover! Lets get started with...

  _____                            _       
 |_   _|                          | |      
   | |  _ __ ___  _ __   ___  _ __| |_ ___ 
   | | | '_ ` _ \| '_ \ / _ \| '__| __/ __|
  _| |_| | | | | | |_) | (_) | |  | |_\__ \
 |_____|_| |_| |_| .__/ \___/|_|   \__|___/
                 | |                       
                 |_|                       

  GSAP first. We'll use this to do a lot
  of animations. What's nice about GSAP is
  it's happy animating almost anything...
  We'll be animating SVGs, HTML, shaders 
  and 3D objects!
*/

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 . 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 ",
              `
									#include 
					
									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 ",
              `
            		uniform float uIn;
            		uniform float uOut;
            		uniform float uTime;
            		varying vec2 vUv;
            		varying float vNoise;

								${includes.noise}

            		#include 
            `
            )
            shader.fragmentShader = shader.fragmentShader.replace(
              "#include ",
              `#include 

              // 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 ",
        `
    		uniform float uGlow;

    		#include 
    	`
      )
      shader.fragmentShader = shader.fragmentShader.replace(
        "#include ",
        `#include 

    		

    		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 ",
            `
            uniform float progress;
            // varying vec2 vUv;
            #include 
            `
          )
          shader.fragmentShader = shader.fragmentShader.replace(
            "#include ",
            `#include 

              // 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()

Live Preview Of Spell Caster Game

See the Pen Spell Caster by Codewithshobhit (@Codewithshobhit) on CodePen.


Download

How to run this Html Css and Js Project in Our Browser?

first, you need a code editor either you can use VS code studio or notepad and then copy the html,css, and javascript code, create separate or different files for coding then combine them, after creating file just click .html file or run from VS Code studio and you can project preview.

Which Code Editor do you use to create those projects?

I am using VS Code Studio.

is this project responsive or not?

Yes! this project is a responsive project.

If you enjoyed reading this post and have found it useful for you, then please give share it with your friends, and follow me to get updates on my upcoming posts. You can connect with me on  Instagram

if you have any confusion Comment below or you can contact us by filling out our Contact Us form from the home section. ๐Ÿคž๐ŸŽ‰