Skip to content

Instantly share code, notes, and snippets.

@Litas-dev
Created November 3, 2025 12:57
Show Gist options
  • Select an option

  • Save Litas-dev/0629d9f58754a6c61d8ae59d033f72a5 to your computer and use it in GitHub Desktop.

Select an option

Save Litas-dev/0629d9f58754a6c61d8ae59d033f72a5 to your computer and use it in GitHub Desktop.
Spell Caster
<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>
import { gsap } from "https://cdn.skypack.dev/gsap"
import { MotionPathPlugin } from "https://cdn.skypack.dev/gsap/MotionPathPlugin"
import { Flip } from "https://cdn.skypack.dev/gsap/Flip"
gsap.registerPlugin(MotionPathPlugin, Flip)
/*
We need some simplex noise to help
with the particle animations. More on
that later, but for now we'll import
it and create.
*/
import { createNoise3D } from "https://cdn.skypack.dev/simplex-noise"
const noise3D = createNoise3D()
/*
We need a lot of Three.js features for
this one. We could have just imported
everything as just THREE but but I
prefer to just grab what I need.
You'll notice I'm just importing these
from just 'three' rather than the skypack
url. Thats because I have included an
'importmap' to the html <head>. You
can see that in the settings under HTML.
I could have probably done the same for
others.
*/
import {
AnimationMixer,
Clock,
PointLight,
AmbientLight,
ColorManagement,
DirectionalLight,
Group,
LinearSRGBColorSpace,
Mesh,
PCFSoftShadowMap,
PerspectiveCamera,
ReinhardToneMapping,
Scene,
ShaderMaterial,
WebGLRenderer,
Color,
Raycaster,
ArrowHelper,
Box3,
Box3Helper,
ConeGeometry,
DoubleSide,
MeshBasicMaterial,
MeshMatcapMaterial,
Plane,
Vector2,
AdditiveBlending,
BufferAttribute,
CustomBlending,
OneFactor,
Points,
ZeroFactor,
AxesHelper,
BufferGeometry,
TubeGeometry,
CatmullRomCurve3,
Vector3,
PlaneGeometry,
Audio,
AudioListener,
SphereGeometry,
LoadingManager,
TextureLoader,
AudioLoader,
} from "three"
/*
Some extra bits we need from Three.js.
*/
import { OrbitControls } from "three/addons/controls/OrbitControls.js"
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"
import { RenderPass } from "three/addons/postprocessing/RenderPass.js"
import { SSAOPass } from "three/addons/postprocessing/SSAOPass.js"
import { GUI } from "three/addons/libs/lil-gui.module.min.js"
import Stats from "three/addons/libs/stats.module.js"
import { GLTFLoader } from "three/addons/loaders/GLTFLoader"
import { DRACOLoader } from "three/addons/loaders/DRACOLoader"
/*
And finally we need to import xState.
There are a lot of states for the game
and things can get tricky to manage with
just booleans. I love state machines and
XState combined with stately.ai makes
things so much easier. More on this later...
*/
import { interpret, createMachine } from "https://cdn.skypack.dev/xstate@4.33.6"
/*
_____ _
| __ \ | |
| | | | ___| |__ _ _ __ _
| | | |/ _ \ '_ \| | | |/ _` |
| |__| | __/ |_) | |_| | (_| |
|_____/ \___|_.__/ \__,_|\__, |
__/ |
|___/
There are a lot of features built just
to help debugging. I left most of them
in so you can turn them on and off here.
Many of these do a great job helping
explain how things are working under
the hood. So have a play!! Playing
the game with them all set to true
is hard mode!
(You might be wondering why I stored
these on window. It's mainly becuase
I didn't know where I would need these
settings and what their scope would be.
So just to make things simpiler I just
plonked them on window. Not something I
recommend you do, there are usually
better ways.)
*/
window.DEBUG = {
/*
Show FPS meter.
*/
fps: false,
/*
Show current app state
and available state actions
as defined in the app-machine
*/
appState: false,
/*
Show information about the
the available spells, the current
spell being drawn and confidence
score for each
*/
casting: false,
/*
Add yellow arrows that show the
particle sim flow field, this is
a vector grid that applies directional
and speed influence on each particle
*/
simFlow: false,
/*
The flow field also has noise
applied to it. This shows those
values with red arrows.
*/
simNoise: false,
/*
Some particles are invisible
and just there to apply force
to the flow field. Setting this
to true renders them as red
particles
*/
forceParticles: false,
/*
This shows where the pre-defined
demon locations are. Also all
the paths the can use to enter
the room. It's a messy view but
fun to see!
*/
locations: false,
/*
This adds 2 white spheres for each
entrance: The door, the trapdoor,
the right window and the left hole.
We use 2 points for each to help
define the initial angle of the path.
*/
entrances: false,
/*
The exact plae of point lights can
sometimes be tricky to see, so this
just adds some spheres to help
position things.
*/
lights: false,
/*
The animation for the demon trails
as they enter the room happen pretty
fast so this just renders them as
solid red while they are in the
scene. Useful early on when I was
debugging their positions.
*/
trail: false,
/*
The sounds can be annoying while
developing. So this just has them
turned off by default
*/
disableSounds: false,
/*
While the game is paused I enable
orbit controls, which means the user
can move around. Orbit controlls
normally allow you to move the
'look at' point while holding shift
but that was disabled by my tick
function. Enabling this stops that
code in the tick but also breaks some
of the camera angles. This one is
super useful to turn on and get
some screenshots.
*/
allowLookAtMoveWhenPaused: false,
/*
Aligning the HTML elements over
the top of the 3D scene can sometimes
be tricky so this just adds some
debug outlines to the HTML layout
elements.
*/
layoutDebug: false,
}
/*
_____ _
/ ____| | |
| | ___ _ __ ___| |_ ___
| | / _ \| '_ \/ __| __/ __|
| |___| (_) | | | \__ \ |_\__ \
\_____\___/|_| |_|___/\__|___/
We sometimes need to do the same
operation on all three axes. So
we can use this array to loop of
them rather than writing them 3 times.
*/
const AXIS = ["x", "y", "z"]
/*
The particle shapes are stored in one
image. So this just stores the position
of each shape so we can just reference
them in a handy name rather than
remembering all the numbers.
*/
const PARTICLE_STYLES = {
invisible: 0,
smoke: 1,
plus: 2,
soft: 3,
point: 4,
circle: 5,
flame: 6,
}
/*
Putting names in an object like this
is useful for IDE auto complete and
helps prevent typos. If you were using
Typescript you might have used an Enum
for this instead.
*/
const SPELLS = {
arcane: "arcane",
fire: "fire",
vortex: "vortex",
}
/*
We'll talk more about the emitters later
on but for now just know that there are
a few of them but only a few settings
change between each. It's useful to store
the common settings here and overwrite
the ones that changed in each instance.
*/
const DEFAULT_EMITTER_SETTINGS = {
startingPosition: { x: 0.5, y: 0.5, z: 0.5 },
startingDirection: { x: 0, y: 0, z: 0 },
emitRate: 0.001,
particleOrder: [],
model: null,
animationDelay: 0,
lightColor: { r: 1, g: 1, b: 1 },
group: "magic",
}
/*
I originally planned to have more enemy
types, with certain spells only working
on certain enemies. I also wanted to have
this complete for Halloween and it turns
out those 2 ideas were not compatible!
Anyways, that was a long way to say the
object isn't all that useful now but it's
still good practice to store settings
like this anyways.
*/
const DEFAULT_ENEMY_SETTINGS = {
position: { x: 0, y: 0, z: 0 },
model: null,
animationDelay: 0,
}
/*
Like the emitter defaults above the particles
also share a lot of common settings. This
object saves the most common ones and each
instance overwites the ones it needs to
change.
*/
const DEFAULT_PARTICLE_SETTINGS = {
speed: 0.2,
speedDecay: 0.6,
speedSpread: 0,
force: 0.2,
forceDecay: 0.1,
forceSpread: 0,
life: 1,
lifeDecay: 0.6,
directionSpread: { x: 0.001, y: 0.001, z: 0.001 },
positionSpread: { x: 0.01, y: 0.01, z: 0.01 },
color: { r: 1, g: 1, b: 1 },
scale: 1,
scaleSpread: 0,
style: PARTICLE_STYLES.soft,
acceleration: 0.1,
}
/*
I wanted to build this without a framework
like React. Mainly becuase I think it's good
practice to every now and then. So this
just grabs and stores some useful DOM elements
we're going to need later.
*/
const DOM = {
body: document.body,
app: document.querySelector(".app"),
state: document.querySelector(".state"),
controls: document.querySelector(".controls"),
canvas: document.querySelector(".canvas"),
svg: document.querySelector("#spell-helper"),
demonCount: document.querySelector("[data-demon-count]"),
spellGuide: document.querySelector("#spell-guide"),
}
/*
This sounds like the same thing as the
DEFAULT_ENEMY_SETTINGS above, I perhaps
did a bad job naming this, but this is state
settings for when and how many enemies
to send. The reason it's a seperate const
is beacuse we use this as the reset at
the start of the game. We can then change
a few depending on the game mode too.
*/
const ENEMY_SETTINGS = {
lastSent: 0,
sendFrequency: 5,
sendFrequencyReduceBy: 0.2,
minSendFrequency: 2,
totalSend: 42,
sendCount: 0,
killCount: 0,
}
/*
These are all the assets the game needs.
We'll load all of these while showing the
loading screen. We define them all here in
this handy dandy object. I also define some
transforms to the models, their often massive
or their default position isn't ideal.
*/
const TO_LOAD = {
models: [
{ id: "room", file: "https://assets.codepen.io/557388/room.glb", scale: 0.15, position: [0.03, -0.26, -0.55] },
{ id: "demon", file: "https://assets.codepen.io/557388/demon.glb", scale: 0.1, position: [0, 0, 0] },
{ id: "crystal", file: "https://assets.codepen.io/557388/crystal.glb", scale: 0.05, position: [0, 0, 0] },
],
sounds: [
{ id: "music", file: "https://assets.codepen.io/557388/music.mp3" },
{ id: "kill-1", file: "https://assets.codepen.io/557388/kill-1.mp3" },
{ id: "kill-2", file: "https://assets.codepen.io/557388/kill-2.mp3" },
{ id: "kill-3", file: "https://assets.codepen.io/557388/kill-3.mp3" },
{ id: "enter-1", file: "https://assets.codepen.io/557388/enter-1.mp3" },
{ id: "enter-2", file: "https://assets.codepen.io/557388/enter-2.mp3" },
{ id: "error-1", file: "https://assets.codepen.io/557388/error-1.mp3" },
{ id: "cast-1", file: "https://assets.codepen.io/557388/cast-1.mp3" },
{ id: "cast-2", file: "https://assets.codepen.io/557388/cast-2.mp3" },
{ id: "ping-1", file: "https://assets.codepen.io/557388/ping-1.mp3" },
{ id: "ping-2", file: "https://assets.codepen.io/557388/ping-2.mp3" },
{ id: "laugh-1", file: "https://assets.codepen.io/557388/laugh-1.mp3" },
{ id: "laugh-2", file: "https://assets.codepen.io/557388/laugh-2.mp3" },
{ id: "laugh-3", file: "https://assets.codepen.io/557388/laugh-3.mp3" },
{ id: "spell-travel-1", file: "https://assets.codepen.io/557388/spell-travel-1.mp3" },
{ id: "spell-travel-2", file: "https://assets.codepen.io/557388/spell-travel-2.mp3" },
{ id: "spell-travel-3", file: "https://assets.codepen.io/557388/spell-travel-3.mp3" },
{ id: "spell-failed-1", file: "https://assets.codepen.io/557388/spell-failed-1.mp3" },
{ id: "spell-failed-2", file: "https://assets.codepen.io/557388/spell-failed-2.mp3" },
{ id: "trapdoor-close-1", file: "https://assets.codepen.io/557388/trapdoor-close-1.mp3" },
{ id: "trapdoor-close-2", file: "https://assets.codepen.io/557388/trapdoor-close-2.mp3" },
{ id: "torch-1", file: "https://assets.codepen.io/557388/torch-1.mp3" },
{ id: "torch-2", file: "https://assets.codepen.io/557388/torch-2.mp3" },
{ id: "torch-3", file: "https://assets.codepen.io/557388/torch-3.mp3" },
{ id: "crystal-explode", file: "https://assets.codepen.io/557388/crystal-explode.mp3" },
{ id: "crystal-reform", file: "https://assets.codepen.io/557388/crystal-reform.mp3" },
{ id: "glitch", file: "https://assets.codepen.io/557388/glitch.mp3" },
{ id: "portal", file: "https://assets.codepen.io/557388/portal.mp3" },
{ id: "crumble", file: "https://assets.codepen.io/557388/crumble.mp3" },
{ id: "reform", file: "https://assets.codepen.io/557388/reform.mp3" },
],
textures: [
{ id: "magic-particles", file: "https://assets.codepen.io/557388/magic-particles.png" },
{ id: "smoke-particles", file: "https://assets.codepen.io/557388/smoke-particles.png" },
{ id: "spell-arcane", file: "https://assets.codepen.io/557388/spell-arcane.png" },
{ id: "crystal-matcap", file: "https://assets.codepen.io/557388/crystal-matcap.png" },
],
}
/*
There are 2 groups of particles and they
both work almost identically. The only
real difference is their blend modes. So
these are the shared material settings for
these.
*/
const DEFAULT_PARTICLE_MATERIAL_SETTINGS = {
depthWrite: false,
vertexColors: true,
vertexShader: `
uniform float uSize;
uniform float uTime;
uniform bool uGrow;
attribute float scale;
attribute float life;
attribute float type;
attribute vec3 random;
varying vec3 vColor;
varying float vLife;
varying float vType;
varying vec3 vRandom;
void main()
{
/**
* Position
*/
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
float spiralRadius = 0.1;
gl_Position = projectedPosition;
vColor = color;
vRandom = random;
vLife = life;
vType = type;
/**
* Size
*/
if(uGrow) {
gl_PointSize = uSize * scale * (2.5 - life);
}
else {
gl_PointSize = uSize * scale * life;
}
gl_PointSize *= (1.0 / - viewPosition.z);
}
`,
}
/*
The particle emmiters need to copy a lot
of particle settings around so we use these
arrays to help loop through thems.
*/
const PROPERTIES = {
vec3: ["position", "direction", "random", "color"],
float: ["type", "speed", "speedDecay", "force", "forceDecay", "acceleration", "life", "lifeDecay", "size"],
}
/*
_____ _ _
/ ____| | | |
| (___ | |_ __ _| |_ ___
\___ \| __/ _ | __/ _ \
____) | || (_| | || __/
|_____/ \__\__,_|\__\___| _
| | (_)
_ __ ___ __ _ ___| |__ _ _ __ ___ ___
| _ _ \ / _ |/ __| _ \| | _ \ / _ \/ __|
| | | | | | (_| | (__| | | | | | | | __/\__ \
|_| |_| |_|\__,_|\___|_| |_|_|_| |_|\___||___/
*/
const AppMachine = createMachine(
{
id: "App",
initial: "IDLE",
states: {
IDLE: {
on: {
load: {
target: "LOADING",
internal: false,
},
},
},
LOADING: {
on: {
error: {
target: "LOAD_ERROR",
internal: false,
},
complete: {
target: "INIT",
internal: false,
},
},
},
LOAD_ERROR: {
on: {
reload: {
target: "LOADING",
internal: false,
},
},
},
INIT: {
on: {
begin: {
target: "TITLE_SCREEN",
internal: false,
},
},
},
TITLE_SCREEN: {
on: {
next: {
target: "INSTRUCTIONS_CRYSTAL",
internal: false,
},
skip: {
target: "SETUP_GAME",
internal: false,
},
credits: {
target: "CREDITS",
internal: false,
},
endless: {
target: "SETUP_ENDLESS",
internal: false,
},
debug: {
target: "SCENE_DEBUG",
},
},
},
INSTRUCTIONS_CRYSTAL: {
on: {
next: {
target: "INSTRUCTIONS_DEMON",
internal: false,
},
},
},
SETUP_GAME: {
on: {
run: {
target: "GAME_RUNNING",
internal: false,
},
},
},
CREDITS: {
on: {
close: {
target: "TITLE_SCREEN",
internal: false,
},
end: {
target: "TITLE_SCREEN",
},
},
},
SETUP_ENDLESS: {
on: {
run: {
target: "ENDLESS_MODE",
internal: false,
},
},
},
SCENE_DEBUG: {
on: {
close: {
target: "TITLE_SCREEN",
},
},
},
INSTRUCTIONS_DEMON: {
on: {
next: {
target: "INSTRUCTIONS_CAST",
internal: false,
},
},
},
GAME_RUNNING: {
on: {
pause: {
target: "PAUSED",
internal: false,
},
"game-over": {
target: "GAME_OVER_ANIMATION",
internal: false,
},
spells: {
target: "SPELL_OVERLAY",
internal: false,
},
win: {
target: "WIN_ANIMATION",
},
special: {
target: "SPECIAL_SPELL",
},
},
},
ENDLESS_MODE: {
on: {
end: {
target: "CLEAR_ENDLESS",
internal: false,
},
pause: {
target: "ENDLESS_PAUSE",
internal: false,
},
spells: {
target: "ENDLESS_SPELL_OVERLAY",
},
special: {
target: "ENDLESS_SPECIAL_SPELL",
},
},
},
INSTRUCTIONS_CAST: {
on: {
next: {
target: "INSTRUCTIONS_SPELLS",
internal: false,
},
},
},
PAUSED: {
on: {
resume: {
target: "GAME_RUNNING",
internal: false,
},
end: {
target: "CLEAR_GAME",
},
},
},
GAME_OVER_ANIMATION: {
on: {
end: {
target: "GAME_OVER",
internal: false,
},
},
},
SPELL_OVERLAY: {
on: {
close: {
target: "GAME_RUNNING",
internal: false,
},
},
},
WIN_ANIMATION: {
on: {
end: {
target: "WINNER",
},
},
},
SPECIAL_SPELL: {
on: {
complete: {
target: "GAME_RUNNING",
},
win: {
target: "WIN_ANIMATION",
},
},
},
CLEAR_ENDLESS: {
on: {
end: {
target: "TITLE_SCREEN",
internal: false,
},
},
},
ENDLESS_PAUSE: {
on: {
end: {
target: "CLEAR_ENDLESS",
internal: false,
},
resume: {
target: "ENDLESS_MODE",
internal: false,
},
},
},
ENDLESS_SPELL_OVERLAY: {
on: {
close: {
target: "ENDLESS_MODE",
},
},
},
ENDLESS_SPECIAL_SPELL: {
on: {
complete: {
target: "ENDLESS_MODE",
},
},
},
INSTRUCTIONS_SPELLS: {
on: {
next: {
target: "SETUP_GAME",
internal: false,
},
},
},
CLEAR_GAME: {
on: {
end: {
target: "TITLE_SCREEN",
internal: false,
},
},
},
GAME_OVER: {
on: {
restart: {
target: "SETUP_GAME",
internal: false,
},
instructions: {
target: "RESETTING_FOR_INSTRUCTIONS",
internal: false,
},
credits: {
target: "RESETTING_FOR_CREDITS",
internal: false,
},
endless: {
target: "SETUP_ENDLESS",
internal: false,
},
},
},
WINNER: {
on: {
restart: {
target: "SETUP_GAME",
},
instructions: {
target: "INSTRUCTIONS_CRYSTAL",
},
credits: {
target: "CREDITS",
},
endless: {
target: "SETUP_ENDLESS",
},
},
},
RESETTING_FOR_INSTRUCTIONS: {
on: {
run: {
target: "INSTRUCTIONS_CRYSTAL",
},
},
},
RESETTING_FOR_CREDITS: {
on: {
run: {
target: "CREDITS",
},
},
},
},
predictableActionArguments: true,
preserveActionOrder: true,
},
{
actions: {},
services: {},
guards: {},
delays: {},
}
)
const CasterMachine = createMachine(
{
id: "Caster",
initial: "IDLE",
states: {
IDLE: {
on: {
ready: {
target: "INACTIVE",
},
},
},
INACTIVE: {
on: {
activate: {
target: "ACTIVE",
},
},
},
ACTIVE: {
on: {
start_cast: {
target: "CASTING",
},
deactivate: {
target: "INACTIVE",
},
},
},
CASTING: {
on: {
finished: {
target: "PROCESSING",
},
deactivate: {
target: "INACTIVE",
},
},
},
PROCESSING: {
on: {
success: {
target: "SUCCESS",
},
fail: {
target: "FAIL",
},
deactivate: {
target: "INACTIVE",
},
},
},
SUCCESS: {
on: {
complete: {
target: "ACTIVE",
},
deactivate: {
target: "INACTIVE",
},
},
},
FAIL: {
on: {
complete: {
target: "ACTIVE",
},
deactivate: {
target: "INACTIVE",
},
},
},
},
predictableActionArguments: true,
preserveActionOrder: true,
},
{
actions: {},
services: {},
guards: {},
delays: {},
}
)
const CrystalMachine = createMachine(
{
id: "Crystal",
initial: "IDLE",
states: {
IDLE: {
on: {
start: {
target: "INIT",
},
},
},
INIT: {
on: {
ready: {
target: "WHOLE",
},
},
},
WHOLE: {
on: {
overload: {
target: "OVERLOADING",
},
},
},
OVERLOADING: {
on: {
break: {
target: "BREAKING",
},
},
},
BREAKING: {
on: {
broke: {
target: "BROKEN",
},
},
},
BROKEN: {
on: {
fix: {
target: "FIXING",
},
},
},
FIXING: {
on: {
fixed: {
target: "WHOLE",
},
},
},
},
predictableActionArguments: true,
preserveActionOrder: true,
},
{
actions: {},
services: {},
guards: {},
delays: {},
}
)
const EnemyMachine = createMachine(
{
id: "Enemy",
initial: "IDLE",
states: {
IDLE: {
on: {
spawn: {
target: "ANIMATING_IN",
internal: false,
},
},
},
ANIMATING_IN: {
on: {
complete: {
target: "ALIVE",
internal: false,
},
accend: {
target: "ACCEND",
internal: false,
},
},
},
ALIVE: {
on: {
incoming: {
target: "TAGGED",
internal: false,
},
accend: {
target: "ACCEND",
internal: false,
},
vortex: {
target: "VORTEX_ANIMATION",
},
},
},
ACCEND: {
on: {
leave: {
target: "GONE",
internal: false,
},
},
},
TAGGED: {
on: {
kill: {
target: "ANIMATING_OUT",
internal: false,
},
accend: {
target: "ANIMATING_OUT",
},
},
},
VORTEX_ANIMATION: {
on: {
complete: {
target: "DEAD",
},
},
},
GONE: {},
ANIMATING_OUT: {
on: {
complete: {
target: "DEAD",
internal: false,
},
},
},
DEAD: {},
},
predictableActionArguments: true,
preserveActionOrder: true,
},
{
actions: {},
services: {},
guards: {},
delays: {},
}
)
// UTILS
const degToRad = (value) => {
return (value * Math.PI) / 180
}
const simplelerp = (start, end, amount) => {
return start + amount * (end - start)
}
const randomFromArray = (arr) => {
if (!arr || !arr.length) return null
return arr[Math.floor(Math.random() * arr.length)]
}
// VECTOR UTILS
const lerpVectors = (start, end, amount) => {
// return {
// x: lerp(start.x, end.x, amount),
// y: lerp(start.y, end.y, amount),
// z: lerp(start.z, end.z, amount),
// }
return {
x: start.x + (end.x - start.x) * amount,
y: start.y + (end.y - start.y) * amount,
z: start.z + (end.z - start.z) * amount,
}
// return this;
}
const multiplyScalar = (vector, amount) => {
return {
x: vector.x * amount,
y: vector.y * amount,
z: vector.z * amount,
}
}
const divideScalar = (vector, amount) => {
return multiplyScalar(vector, 1 / amount)
}
const add = (a, b) => {
return {
x: a.x + b.x,
y: a.y + b.y,
z: a.z + b.z,
}
}
const normalize = (vector) => {
const length = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
return {
x: vector.x / length,
y: vector.y / length,
z: vector.z / length,
}
}
const clamp = (vector, min, max) => {
vector.x = Math.max(min.x, Math.min(max.x, vector.x))
vector.y = Math.max(min.y, Math.min(max.y, vector.y))
vector.z = Math.max(min.z, Math.min(max.z, vector.z))
return vector
}
const length = (vector) => {
return Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
}
const clampLength = (vector, min, max) => {
const l = length(vector)
const divided = divideScalar(vector, l || 1)
return multiplyScalar(divided, Math.max(min, Math.min(max, l)))
}
const vector = {
lerpVectors,
multiplyScalar,
divideScalar,
add,
normalize,
clamp,
length,
clampLength,
}
const math = {
degToRad,
}
// EMITTERS
class ControlledEmitter {
constructor(sim) {
this.sim = sim
this.particles = {}
this.remainingTime = 0
this.active = false
this.gsapDefaults = {
onUpdate: this.update,
onUpdateProperties: [],
}
}
emit(particle = {}) {
const newParticle = {
size: 1,
color: { r: 1, g: 1, b: 1 },
position: { z: 0.5, y: 0.35, x: 0.5 },
life: 0.9,
style: PARTICLE_STYLES.point,
...particle,
lifeDecay: 0,
speed: 0,
speedDecay: 0,
force: 0,
forceDecay: 0,
acceleration: 0,
}
const particleIndex = this.sim.createParticle("magic", newParticle)
this.particles[particleIndex] = {
index: particleIndex,
...newParticle.color,
...newParticle.position,
size: newParticle.size,
life: newParticle.life,
lastPosition: null,
animation: null,
}
return this.particles[particleIndex]
}
update(particle) {
const { index, size, life } = particle
const color = { r: particle.r, g: particle.g, b: particle.b }
const position = { x: particle.x, y: particle.y, z: particle.z }
const updates = ["size", "life", "color", "position"]
const values = { size, life, color, position }
for (let i = 0; i < updates.length; i++) {
const update = updates[i]
this.sim.setParticleProperty("magic", index, update, values[update])
}
// this.sim()
}
destory(particle) {
// this.sim
this.sim.setParticleProperty("magic", particle.index, "life", 0)
delete this.particles[particle.index]
}
release(particle) {
//this.sim.setParticleProperty("magic", particle.index, "force", 0)
delete this.particles[index]
// this.sim
}
}
class Emitter {
constructor(sim, emitterSettings, particleTypes, light) {
this.sim = sim
this.light = light
this.active = true
this.animations = []
this.settings = { ...DEFAULT_EMITTER_SETTINGS, ...emitterSettings }
this.delay = this.settings.animationDelay
if (this.light) {
gsap.killTweensOf(this.light)
this.light.color.setRGB(this.settings.lightColor.r, this.settings.lightColor.g, this.settings.lightColor.b)
this.light.intensity = 3
// this.animations.push(gsap.to(this.light, { intensity: 5, duration: this.delay }))
}
this.particles = { ...particleTypes }
// particleSettings.forEach((settings) => {
// this.particles.push({ ...DEFAULT_PARTICLE_SETTINGS, ...settings })
// })
this.position = { ...this.settings.startingPosition }
// this.previousPosition = { ...this.settings.startingPosition }
this.direction = { ...this.settings.startingDirection }
this.remainingTime = 0
this.destroyed = false
this.modelScale = 1
this.count = 0
this.moveFunction()
if (this.settings.model) {
this.model = this.settings.model
// this.model.group.rotation.y = Math.PI * 0.5
if (this.model.animations && this.model.animations.length) {
this.mixer = new AnimationMixer(this.model.scene)
this.mixer.timeScale = 1.3
this.mixer.clipAction(this.model.animations[0]).play()
}
}
}
moveFunction = (delta, elapsedTime) => {
if (this.model) {
// this.model.group.scale.set(this.modelScale, this.modelScale, this.modelScale)
}
if (this.light)
this.light.position.set(
this.position.x * this.sim.size.x,
this.position.y * this.sim.size.y,
this.position.z * this.sim.size.z
)
}
pause() {
this.animations.map((animation) => animation.pause())
}
resume() {
this.animations.map((animation) => animation.resume())
}
destory() {
if (this.model) {
this.model.group.parent.remove(this.model.group)
// this.model.group.traverse((obj) => {
// if (obj.geometry) obj.geometry.dispose()
// if (obj.material) obj.material.dispose()
// })
this.model = null
}
if (this.light) {
// this.light.intensity = 0
this.animations.push(gsap.fromTo(this.light, { intensity: 15 }, { intensity: 0, ease: "power1.in", duration: 1 }))
}
this.destroyed = true
}
emit(particle, group, casted = false) {
if (!group) group = this.settings.group
const positionAlongLine = this.previousPosition
? vector.lerpVectors(this.previousPosition, this.position, Math.random())
: this.position
const position = {
x: positionAlongLine.x + (Math.random() * 2 - 1) * particle.positionSpread.x,
y: positionAlongLine.y + (Math.random() * 2 - 1) * particle.positionSpread.y,
z: positionAlongLine.z + (Math.random() * 2 - 1) * particle.positionSpread.z,
}
let direction = {}
// console.log("direction", particle.direction)
if (!particle.direction) {
direction = {
x: Math.random() * 2 - 1,
y: Math.random() * 2 - 1,
z: Math.random() * 2 - 1,
}
} else {
direction = {
x: this.direction.x * particle.direction.x + (Math.random() * 2 - 1) * particle.directionSpread.x,
y: this.direction.y * particle.direction.y + (Math.random() * 2 - 1) * particle.directionSpread.y,
z: this.direction.z * particle.direction.z + (Math.random() * 2 - 1) * particle.directionSpread.z,
}
}
// console.log("direction", direction)
const speed = particle.speed + Math.random() * particle.speedSpread
const force = particle.force + Math.random() * particle.forceSpread
const scale = particle.scale * (particle.scaleSpread > 0 ? Math.random() * particle.scaleSpread : 1)
// console.log(particle)
this.sim.createParticle(group, {
...particle.settings,
position,
direction,
speed,
force,
scale,
casted,
})
}
tick(delta, elapsedTime) {
if (this.active && this.settings.emitRate > 0) {
this.remainingTime += delta
if (this.mixer) this.mixer.update(delta * this.mixer.timeScale)
if (this.moveFunction) this.moveFunction(delta, elapsedTime)
const emitCount = Math.floor(this.remainingTime / this.settings.emitRate)
// console.logLimited(emitCount)
this.remainingTime -= emitCount * this.settings.emitRate
for (let i = 0; i < emitCount; i++) {
this.emit(this.particles[this.settings.particleOrder[this.count % this.settings.particleOrder.length]])
this.count++
}
}
this.previousPosition = { ...this.position }
}
}
class ArcaneSpellEmitter extends Emitter {
constructor(sim, light, startPosition, enemy) {
const color = { r: 0.2, g: 0, b: 1 }
const settings = {
// model: ASSETS.getModel("parrot"),
emitRate: 0.001,
animationDelay: 1,
startingPosition: startPosition,
lightColor: color,
particleOrder: [
"smoke",
"smoke",
"smoke",
"smoke",
"smoke",
"circle",
// "smoke",
// "smoke",
"circle",
// "smoke",
// "smoke",
// "smoke",
// "smoke",
// "sparkle",
],
}
const particles = {
smoke: new SpellTrailParticle({
color,
}),
sparkle: new SpellTrailParticle({
style: PARTICLE_STYLES.point,
scale: 0.1,
}),
circle: new SpellTrailParticle({
color,
style: PARTICLE_STYLES.disc,
// scale: 4,
}),
explodeSmoke: new ExplodeParticle({ color }),
explodeSpark: new ExplodeParticle({
speed: 0.4,
color: { r: 1, g: 1, b: 1 },
// force: 2,
forceDecay: 2,
style: PARTICLE_STYLES.point,
}),
explodeShape: new ExplodeParticle({
color,
style: PARTICLE_STYLES.disc,
scale: 0.5,
}),
}
super(sim, settings, particles, light)
this.active = false
this.enemy = enemy
// console.log("startPostion", startPosition)
this.particleOrder = ["smoke"]
// this.lastPosition = { ...this.settings.startingPosition, z: this.settings.startingPosition.z + 0.001 }
this.lookAt = { x: 0, y: 0, z: 0 }
this.lookAtTarget = null
// this.scale = 1
this.scale = 0.1
SOUNDS.play("spell-travel")
this.animations.push(
gsap.to(
this.position,
{
duration: 0.9,
delay: this.delay,
motionPath: {
curviness: 1.5,
// resolution: 6,
path: [
this.position,
{ x: 0.5, y: Math.random(), z: 0.8 },
{ x: 0.1 + Math.random() * 0.8, y: 0.2 + Math.random() * 0.4, z: 0.4 },
this.enemy
? { x: this.enemy.location.x, y: 0.4, z: this.enemy.location.z }
: { x: 0.1 + Math.random() * 0.8, y: 1, z: 0.2 },
],
},
ease: "linear",
onStart: () => {
if (this.enemy) this.enemy.incoming()
},
onComplete: () => this.onComplete(),
onUpdate: () => this.onUpdate(),
}
)
)
// gsap.to(this, { duration: 0.3, scale: 1, ease: "linear" })
// gsap.to(this.settings.directionSpread, { duration: 1, x: 0.001, y: 0.001, z: 0.001, ease: "power4.in" })
}
onComplete = () => {
if (this.enemy) {
this.enemy.kill()
SOUNDS.play("kill")
const explode = 200
for (let i = 0; i < explode; i++) {
const random = Math.random()
if (random > 0.55) this.emit(this.particles["explodeSmoke"])
else if (random > 0.1) this.emit(this.particles["explodeSpark"])
else this.emit(this.particles["explodeShape"])
}
}
this.destory()
}
onUpdate = () => {
if (this.lastPosition) {
this.direction = {
x: this.position.x - this.lastPosition.x,
y: this.position.y - this.lastPosition.y,
z: this.position.z - this.lastPosition.z,
}
if (this.model) {
this.model.group.position.set(
this.position.x * this.sim.size.x,
this.position.y * this.sim.size.y,
this.position.z * this.sim.size.z
)
this.lookAtTarget = {
x: this.sim.startCoords.x + (this.position.x + this.direction.x) * this.sim.size.x,
y: this.sim.startCoords.y + (this.position.y + this.direction.y) * this.sim.size.y,
z: this.sim.startCoords.z + (this.position.z + this.direction.z) * this.sim.size.z,
}
const lerpAmount = 0.08
this.lookAt.x = lerp(this.lookAt.x, this.lookAtTarget.x, lerpAmount)
this.lookAt.y = lerp(this.lookAt.y, this.lookAtTarget.y, lerpAmount)
this.lookAt.z = lerp(this.lookAt.z, this.lookAtTarget.z, lerpAmount)
this.model.group.lookAt(this.lookAt.x, this.lookAt.y, this.lookAt.z)
}
}
this.active = true
this.lastPosition = { ...this.position }
}
}
class CastEmitter extends Emitter {
constructor(sim) {
const settings = {
emitRate: 0,
particleOrder: ["sparkle", "sparkle", "sparkle", "sparkle", "smoke"],
}
const particles = {
smoke: new SmokeParticle(),
sparkle: new SparkleParticle(),
}
super(sim, settings, particles)
}
move(position) {
this.position = position
const emitCount = 10
for (let i = 0; i < emitCount; i++) {
this.emit(this.particles[randomFromArray(this.settings.particleOrder)], "magic", true)
}
this.previousPosition = { ...this.position }
}
reset() {
this.previousPosition = null
}
}
class CrystalEnergyEmitter extends ControlledEmitter {
constructor(sim) {
super(sim)
this.emitRate = 0.2
this._active = false
}
tick(delta, elapsedTime) {
if (this.active && this.emitRate > 0) {
this.remainingTime += delta
const emitCount = Math.floor(this.remainingTime / this.emitRate)
this.remainingTime -= emitCount * this.emitRate
for (let i = 0; i < emitCount; i++) {
const particle = this.emit({
life: 1,
size: 0.4,
color: { r: 0.8, g: Math.random(), b: 1 },
position: {
x: 0.5 + (Math.random() * 0.05 - 0.025),
y: 0.5 + (Math.random() * 0.05 - 0.025),
z: 0.5 + (Math.random() * 0.05 - 0.025),
},
})
particle.animation = gsap.to(particle, {
y: 0.35,
x: 0.5,
z: 0.5,
duration: 2,
life: 0.5,
ease: "power4.in",
onUpdate: () => this.update(particle),
onComplete: () => this.destory(particle),
})
}
}
}
set active(value) {
this.remainingTime = 0
this._active = value
}
get active() {
return this._active
}
}
class DustEmitter extends Emitter {
constructor(sim, assets) {
const settings = {
emitRate: 0.03,
particleOrder: ["dust"],
}
const particles = {
dust: new DustParticle(),
}
super(sim, settings, particles)
const startCount = 5
for (let i = 0; i < startCount; i++) {
this.emit(this.particles["dust"], "smoke")
}
}
}
class EnemyEnergyEmitter extends ControlledEmitter {
constructor(sim, location) {
super(sim)
this.location = location
this.emitRate = 0.05
// this.active = true
}
start() {
this.active = true
}
stop() {
this.active = false
}
tick(delta, elapsedTime) {
if (this.active && this.emitRate > 0) {
this.remainingTime += delta
const emitCount = Math.floor(this.remainingTime / this.emitRate)
// console.logLimited(emitCount)
this.remainingTime -= emitCount * this.emitRate
for (let i = 0; i < emitCount; i++) {
const particle = this.emit({
life: 1,
size: 0.3 + Math.random() * 0.1,
style: Math.random() > 0.5 ? PARTICLE_STYLES.plus : PARTICLE_STYLES.point,
color: { r: 0.8, g: Math.random(), b: 1 },
})
particle.aniamtion = gsap.to(particle, {
motionPath: [
{ x: 0.5, y: 0.35, z: 0.5 },
{
x: simplelerp(0.5, this.location.x, 0.5) + Math.random() * 0.1,
y: 0.4 + Math.random() * 0.1,
z: simplelerp(0.5, this.location.z, 0.5) + Math.random() * 0.1,
},
{ x: this.location.x, y: 0.3, z: this.location.z },
],
duration: 1 + Math.random() * 0.5,
life: 0.1,
ease: "none",
onUpdate: () => this.update(particle),
onComplete: () => this.destory(particle),
})
}
}
}
}
class FireSpellEmitter extends Emitter {
constructor(sim, light, startPosition, enemy) {
const settings = {
// model: ASSETS.getModel("skull"),
emitRate: 0.01,
animationDelay: 1,
startingDirection: { x: 0, y: 1, z: 0 },
startingPosition: startPosition,
particleOrder: ["flame"],
lightColor: { r: 0.9, g: 0.8, b: 0.1 },
}
const color = { r: 1, g: 0.8, b: 0 }
const particles = {
flame: new FlameParticle({
scale: 2,
}),
ember: {
speed: 0.5,
color: { r: 1, g: 0.3, b: 0 },
speedSpread: 0.3,
forceSpread: 0,
direction: { x: 1, y: 1, z: 1 },
lifeDecay: 1.5,
force: 0,
type: 1,
directionSpread: { x: 0.1, y: 0.1, z: 0.1 },
positionSpread: { x: 0, y: 0, z: 0 },
acceleration: 0.02,
},
explodeSmoke: new ExplodeParticle({ color, speed: 0.1, forceDecay: 1.1 }),
explodeSpark: new ExplodeParticle({
speed: 0.4,
color: { r: 1, g: 1, b: 1 },
// force: 2,
forceDecay: 2,
// style: PARTICLE_STYLES.point,
}),
explodeShape: new ExplodeParticle({
color,
style: PARTICLE_STYLES.circle,
scale: 0.5,
}),
}
super(sim, settings, particles, light)
this.active = false
// console.log("startPostion", startPosition)
this.particleOrder = ["flame"]
this.lastPosition = { ...this.settings.startingPosition, z: this.settings.startingPosition.z + 0.001 }
this.lookAt = null
this.lookAtTarget = null
// this.scale = 1
this.scale = 0.1
if (this.model) {
this.model.group.rotateX(math.degToRad(-160))
this.model.group.rotateZ(math.degToRad(-40))
this.model.group.scale.set(0, 0, 0)
}
this.onUpdate(true)
this.enemy = enemy
const introDuration = 0.5
SOUNDS.play("spell-travel")
if (this.model) {
this.animations.push(
gsap.to(this.model.group.scale, {
motionPath: [
// { x: 0, y: 0, z: 0 },
{ x: 2, y: 2, z: 2 },
{ x: 1, y: 1, z: 1 },
],
ease: "power1.inOut",
duration: this.delay + introDuration * 1.2,
})
)
this.animations.push(
gsap.to(this.model.group.rotation, {
motionPath: [
{ y: math.degToRad(0), x: math.degToRad(-160), z: math.degToRad(-40) },
{ y: math.degToRad(0), x: math.degToRad(-90), z: math.degToRad(192) },
],
ease: "power1.inOut",
duration: this.delay + introDuration,
})
)
}
this.animations.push(
gsap.to(this.position, {
duration: 1,
delay: this.delay + introDuration * 0.25,
motionPath: {
curviness: 1.5,
// resolution: 6,
path: [
this.position,
{ x: 0.5, y: Math.random(), z: 0.8 },
{ x: 0.1 + Math.random() * 0.8, y: 0.2 + Math.random() * 0.4, z: 0.4 },
this.enemy
? { x: this.enemy.location.x, y: 0.4, z: this.enemy.location.z }
: { x: 0.1 + Math.random() * 0.8, y: 1, z: 0.2 },
],
},
ease: "power1.in",
onStart: () => {
if (this.enemy) this.enemy.incoming()
this.settings.emitRate = 0.005
},
onComplete: () => this.onComplete(),
onUpdate: () => this.onUpdate(),
})
)
// gsap.to(this, { duration: 0.3, scale: 1, ease: "linear" })
// gsap.to(this.settings.directionSpread, { duration: 1, x: 0.001, y: 0.001, z: 0.001, ease: "power4.in" })
}
onComplete = () => {
if (this.enemy) {
SOUNDS.play("kill")
this.enemy.kill()
const explode = 500
for (let i = 0; i < explode; i++) {
const random = Math.random()
if (random > 0.55) this.emit(this.particles["explodeSmoke"])
else if (random > 0.1) this.emit(this.particles["explodeSpark"])
else this.emit(this.particles["explodeShape"])
}
}
this.destory()
}
onUpdate = (skipDirection = false) => {
// if (this.lastPosition) {
if (!skipDirection)
this.direction = {
x: this.position.x - this.lastPosition.x,
y: this.position.y - this.lastPosition.y,
z: this.position.z - this.lastPosition.z,
}
if (this.model) {
this.model.group.position.set(
this.position.x * this.sim.size.x,
this.position.y * this.sim.size.y,
this.position.z * this.sim.size.z
)
// this.lookAtTarget = {
// x: this.sim.startCoords.x + (this.position.x + this.direction.x) * this.sim.size.x,
// y: this.sim.startCoords.y + (this.position.y + this.direction.y) * this.sim.size.y,
// z: this.sim.startCoords.z + (this.position.z + this.direction.z) * this.sim.size.z,
// }
// const lerpAmount = 0.08
// this.lookAt.x = lerp(this.lookAt.x, this.lookAtTarget.x, lerpAmount)
// this.lookAt.y = lerp(this.lookAt.y, this.lookAtTarget.y, lerpAmount)
// this.lookAt.z = lerp(this.lookAt.z, this.lookAtTarget.z, lerpAmount)
// this.model.group.lookAt(this.lookAt.x, this.lookAt.y, this.lookAt.z)
}
// }
this.active = true
this.lastPosition = { ...this.position }
}
}
class GhostEmitter extends Emitter {
constructor(sim) {
const settings = {
emitRate: 0,
particleOrder: ["trailSmoke"],
startingDirection: { x: 0, y: -1, z: 0 },
group: "smoke",
// direction: { x: -1, y: -1, z: -1 },
}
const particles = {
trailSmoke: new SmokeParticle({
positionSpread: { x: 0.03, y: 0.03, z: 0.03 },
directionSpread: { x: 0.3, y: 0, z: 0.3 },
direction: { x: 1, y: 1, z: 1 },
force: 0.2,
speed: 0.3,
speedDecay: 0.2,
lifeDecay: 0.8,
acceleration: 0.1,
scale: 0.4,
}),
smoke: new SmokeParticle({
color: { r: 0, g: 0, b: 0 },
positionSpread: { x: 0.05, y: 0, z: 0.05 },
directionSpread: { x: 0.3, y: 0, z: 0.3 },
direction: { x: 1, y: 1, z: 1 },
force: 0,
speed: 0.3,
speedDecay: 0.2,
lifeDecay: 0.4,
acceleration: 0.1,
scale: 1,
}),
force: new ForceParticle({
directionSpread: { x: 0.4, y: 0, z: 0.4 },
}),
smokeUp: new SmokeParticle({
color: { r: 0, g: 0, b: 0 },
positionSpread: { x: 0.1, y: 0.3, z: 0.1 },
directionSpread: { x: 0.3, y: 0, z: 0.3 },
direction: { x: -1, y: -1, z: -1 },
force: 0.2,
speed: 0.6,
speedDecay: 0.2,
lifeDecay: 0.6,
acceleration: 0,
scale: 1,
}),
sparkle: new SparkleParticle({
speed: 0.6,
life: 1.0,
lifeDecay: 0.7,
positionSpread: { x: 0.1, y: 0.1, z: 0.1 },
directionSpread: { x: 1, y: 1, z: 1 },
// style: PARTICLE_STYLES.skull,
// scaleSpread: 0,
}),
}
super(sim, settings, particles)
}
puffOfSmoke(sparkles = false) {
const smokePuff = 50
for (let i = 0; i < smokePuff; i++) {
this.emit(this.particles["smokeUp"], "smoke")
}
if (sparkles) {
const sparks = 100
for (let i = 0; i < sparks; i++) {
this.emit(this.particles["sparkle"], "magic")
}
}
}
animatingIn() {
this.settings.emitRate = 0.0015
}
idle() {
this.settings.particleOrder = ["force", "smoke", "smoke", "smoke", "smoke", "smoke", "smoke"]
this.settings.emitRate = 0.03
}
}
class TorchEmitter extends Emitter {
constructor(position, sim) {
const settings = {
emitRate: 0.03,
particleOrder: ["force", "flame", "redFlame", "smoke", "flame", "redFlame", "smoke", "flame", "flame"],
startingPosition: position,
startingDirection: { x: 0, y: 1, z: 0 },
// direction: { x: -1, y: -1, z: -1 },
}
const particles = {
flame: new FlameParticle({
positionSpread: { x: 0, y: 0, z: 0 },
directionSpread: { x: 0.4, y: 0, z: 0.4 },
direction: { x: 1, y: 1, z: 1 },
force: 0.1,
speed: 0.25,
speedDecay: 0.99,
lifeDecay: 1.7,
acceleration: 0.2,
scale: 2.5,
scaleSpread: 0.3,
}),
redFlame: new FlameParticle({
color: { r: 1, g: 0.3, b: 0 },
positionSpread: { x: 0, y: 0, z: 0 },
directionSpread: { x: 0.4, y: 0, z: 0.4 },
direction: { x: 1, y: 1, z: 1 },
force: 0.1,
speed: 0.3,
speedDecay: 0.99,
lifeDecay: 1,
acceleration: 0.2,
scale: 2.5,
scaleSpread: 0.3,
}),
smoke: new SmokeParticle({
positionSpread: { x: 0, y: 0, z: 0 },
directionSpread: { x: 0.4, y: 0, z: 0.4 },
direction: { x: 1, y: 1, z: 1 },
force: 0.1,
speed: 0.3,
speedDecay: 0.6,
lifeDecay: 0.7,
acceleration: 0.2,
color: { r: 0.1, g: 0.1, b: 0.1 },
scale: 4,
scaleSpread: 0.3,
}),
force: new ForceParticle(),
}
super(sim, settings, particles)
}
flamePuff() {
gsap.fromTo(this.particles.flame, { scale: 5 }, { scale: 2.5, duration: 1 })
}
set green(value) {
if (value) {
this.particles.flame.color = { r: 0, g: 1, b: 0 }
this.particles.redFlame.color = { r: 0.5, g: 1, b: 0.2 }
} else {
this.particles.flame.color = { r: 1, g: 1.0, b: 0.3 }
this.particles.redFlame.color = { r: 1, g: 0.3, b: 0 }
}
}
}
class VortexSpellEmitter extends Emitter {
constructor(sim, light, startPosition) {
const color = { r: 0, g: 1, b: 0 }
const settings = {
// model: ASSETS.getModel("parrot"),
emitRate: 0.0001,
animationDelay: 1,
startingPosition: startPosition,
lightColor: color,
particleOrder: ["smoke", "smoke", "smoke", "smoke", "smoke", "circle", "circle"],
}
const particles = {
smoke: new SpellTrailParticle({
color,
}),
sparkle: new SpellTrailParticle({
style: PARTICLE_STYLES.point,
scale: 0.1,
}),
circle: new SpellTrailParticle({
color,
style: PARTICLE_STYLES.disc,
// scale: 4,
}),
explodeSmoke: new ExplodeParticle({
color,
// force: 0,
direction: { x: 0, y: 1, z: 0 },
directionSpread: { x: 0.05, y: 0.05, z: 0.05 },
}),
explodeSpark: new ExplodeParticle({
speed: 0.1,
direction: { x: 0, y: 1, z: 0 },
directionSpread: { x: 0.05, y: 0.05, z: 0.05 },
color: { r: 1, g: 1, b: 1 },
// force: 2,
speedDecay: 0.99,
lifeDecay: 0.9,
style: PARTICLE_STYLES.point,
acceleration: 0.01,
}),
explodeShape: new ExplodeParticle({
color,
direction: { x: 0, y: 1, z: 0 },
directionSpread: { x: 0.05, y: 0.05, z: 0.05 },
style: PARTICLE_STYLES.disc,
speedDecay: 0.99,
scale: 0.9,
speed: 0.1,
lifeDecay: 0.8,
acceleration: 0.01,
}),
}
super(sim, settings, particles, light)
this.active = false
this.particleOrder = ["smoke"]
this.lookAt = { x: 0, y: 0, z: 0 }
this.lookAtTarget = null
this.scale = 0.1
SOUNDS.play("spell-travel")
this.animations.push(
gsap.to(
this.position,
{
duration: 0.6,
delay: this.delay,
motionPath: {
curviness: 0.5,
// resolution: 6,
path: [
{ x: 0.5, y: 1, z: 0.5 },
{ x: 0.5, y: 0.1, z: 0.5 },
],
},
ease: "linear",
onComplete: () => this.onComplete(),
onUpdate: () => this.onUpdate(),
}
)
)
}
onComplete = () => {
const explode = 1000
for (let i = 0; i < explode; i++) {
const random = Math.random()
if (random > 0.55) this.emit(this.particles["explodeSmoke"])
else if (random > 0.1) this.emit(this.particles["explodeSpark"])
else this.emit(this.particles["explodeShape"])
}
this.destory()
}
onUpdate = () => {
// if (this.lastPosition) {
// this.direction = {
// x: this.position.x - this.lastPosition.x,
// y: this.position.y - this.lastPosition.y,
// z: this.position.z - this.lastPosition.z,
// }
// }
this.active = true
this.lastPosition = { ...this.position }
}
}
class WinEmitter extends Emitter {
constructor(sim, assets) {
const settings = {
emitRate: 0.001,
particleOrder: ["dustRed", "dustGreen", "dustBlue"],
}
const particles = {
dustRed: new DustParticle({ color: { r: 1, g: 1, b: 0 }, style: PARTICLE_STYLES.point, scale: 0.5 }),
dustGreen: new DustParticle({ color: { r: 0, g: 1, b: 1 }, style: PARTICLE_STYLES.point, scale: 0.5 }),
dustBlue: new DustParticle({ color: { r: 1, g: 0, b: 1 }, style: PARTICLE_STYLES.point, scale: 0.5 }),
}
super(sim, settings, particles)
// const startCount = 5
// for (let i = 0; i < startCount; i++) {
// this.emit(this.particles["dustGreen"], "magic")
// }
}
}
// DEMON
class Enemy {
constructor(sim, demon, spell) {
this.machine = interpret(EnemyMachine)
this.sim = sim
this.timeOffset = Math.random() * (Math.PI * 2)
this.state = this.machine.initialState.value
this.uniforms = demon.uniforms
this.onDeadCallback = null
this.animations = []
const availableSpellTypes = Object.keys(SPELLS).map((key) => SPELLS[key])
this.spellType = spell ? spell : availableSpellTypes[Math.floor(Math.random() * availableSpellTypes.length)]
// const geometry = new BoxGeometry(0.1, 0.15, 0.05)
// const material = new MeshStandardMaterial({ color: this.spellType === SPELLS.arcane ? 0xbb11ff : 0xbbff11 })
// this.model = new Mesh(geometry, material)
this.demon = demon
this.model = demon.demon
this.elements = {
leftHand: null,
rightHand: null,
sphere: null,
cloak: null,
skullParts: [],
}
this.model.scene.traverse((item) => {
if (this.elements[item.name] === null) this.elements[item.name] = item
else if (item.name.includes("skull")) this.elements.skullParts.push(item)
if (item.name === "cloak") {
item.material.onBeforeCompile = (shader) => {
console.log("COMPILING SHADER")
}
}
})
this.modelOffset = { x: 0, y: -0.6, z: 0 }
this.group = this.model.group
// this.group.add(this.model)
this.position = { x: 0, y: 0, z: 0 }
this.emitter = new GhostEmitter(sim)
this.emitter.emitRate = 0
this.machine.onTransition((s) => this.onStateChange(s))
this.machine.start()
}
moveFunction(delta, elapsedTime) {
if (this.state === "ALIVE" || this.state === "TAGGED") {
this.position.y = 0.2 + 0.15 * ((Math.sin(elapsedTime + this.timeOffset) + 1) * 0.5)
}
}
pause() {
this.animations.map((animation) => animation.pause())
}
resume() {
this.animations.map((animation) => animation.resume())
}
spawn(location) {
this.location = location
this.location.add(this.group)
this.group.rotation.y = this.location.rotation
this.model.scene.visible = false
this.machine.send("spawn")
}
incoming() {
this.machine.send("incoming")
}
kill() {
this.machine.send("kill")
}
accend() {
this.machine.send("accend")
}
getSuckedIntoTheAbyss() {
this.machine.send("vortex")
}
onStateChange = (state) => {
this.state = state.value
if (state.changed || this.state === "IDLE") {
switch (this.state) {
case "IDLE":
this.model.scene.rotation.set(0, 0, 0)
break
case "ANIMATING_IN":
if (this.location) {
SOUNDS.play("enter")
const entrancePath = this.location.getRandomEntrance()
this.animations.push(
gsap.fromTo(
this.position,
{ ...entrancePath.points[0] },
{
motionPath: { path: entrancePath.points, curviness: 2 },
ease: "none",
duration: 1.1,
onStart: () => {
setTimeout(() => {
this.emitter.animatingIn()
}, 100)
},
}
)
)
this.animations.push(gsap.from(this.elements.leftHand.position, { z: -0.1, duration: 2 }))
this.animations.push(gsap.from(this.elements.leftHand.scale, { x: 0, y: 0, z: 0, duration: 2 }))
this.animations.push(gsap.from(this.elements.rightHand.position, { z: -0.1, duration: 2 }))
this.animations.push(gsap.from(this.elements.rightHand.scale, { x: 0, y: 0, z: 0, duration: 2 }))
this.elements.skullParts.forEach((part) => {
this.animations.push(
gsap.from(part.rotation, {
y: (Math.random() - 0.5) * 0.1,
x: 1.5,
ease: "power2.inOut",
delay: 0.8,
duration: 1,
})
)
this.animations.push(gsap.from(part.scale, { x: 0.2, y: 4, z: 0.2, delay: 0.8, duration: 1 }))
this.animations.push(gsap.from(part.position, { y: 0.1, delay: 0.8, duration: 1 }))
})
this.animations.push(gsap.from(this.elements.cloak.scale, { y: 0.2, duration: 1.7 }))
this.animations.push(gsap.delayedCall(1, this.machine.send, ["complete"]))
const trail = entrancePath.trail
const material = trail.material
entrancePath.entrance.enter()
this.animations.push(
gsap.fromTo(
material.uniforms.progress,
{ value: 0 },
{
duration: 1.1,
delay: 0.2,
value: 1,
ease: "none",
onStart: () => {
trail.visible = true
},
onComplete: () => {
trail.visible = false
},
}
)
)
} else {
this.machine.send("complete")
}
break
case "ALIVE":
SOUNDS.play("laugh")
this.emitter.puffOfSmoke()
this.emitter.idle()
this.model.scene.visible = true
this.location.energyEmitter.start()
this.animations.push(
gsap.fromTo(
this.model.scene.scale,
{ x: 0.1, y: 0.001, z: 0.1 },
{ x: 0.9, y: 0.9, z: 0.9, ease: "power4.out", duration: 0.2 }
)
)
this.animations.push(gsap.fromTo(this.modelOffset, { y: 0 }, { y: -0.05, ease: "back", duration: 0.5 }))
break
case "TAGGED":
break
case "ANIMATING_OUT":
this.emitter.puffOfSmoke()
this.emitter.destory()
this.location.energyEmitter.stop()
this.animations.push(
gsap.to(this.elements.cloak.scale, {
x: 12,
y: 9,
z: 9,
ease: "power3.out",
duration: 1.5,
})
)
this.animations.push(
gsap.to(this.uniforms.out, {
value: 1,
ease: "back.in",
delay: 0.2,
duration: 1,
onComplete: () => {
this.emitter.puffOfSmoke(true)
this.machine.send("complete")
},
})
)
this.elements.skullParts.forEach((part) => {
const duration = 1 + Math.random() * 0.3
this.animations.push(
gsap.to(part.position, {
delay: 0.15,
y: (Math.random() - 0.5) * 0.1,
x: (Math.random() - 0.5) * 0.3,
z: (Math.random() - 0.5) * 0.3,
ease: "back.in",
duration,
})
)
this.animations.push(
gsap.to(part.rotation, {
delay: 0,
y: (Math.random() - 0.5) * 0.8,
x: (Math.random() - 0.5) * 0.8,
z: (Math.random() - 0.5) * 0.8,
ease: "power2.inOut",
duration,
})
)
this.animations.push(
gsap.to(part.scale, {
delay: 0.2,
y: 0,
x: 0,
z: 0,
ease: "back.in",
duration: duration * 0.6,
})
)
this.animations.push(
gsap.to(this.elements.leftHand.scale, {
x: 0,
y: 0,
z: 0,
ease: "power3.out",
duration: 0.6,
})
)
this.animations.push(
gsap.to(this.elements.rightHand.scale, {
x: 0,
y: 0,
z: 0,
ease: "power3.out",
duration: 0.6,
})
)
// this.elements.sphere.visible = false
// this.animations.push(gsap.from(part.scale, { x: 0.2, y: 4, z: 0.2, delay: 0.8, duration: 1 }))
// this.animations.push(gsap.from(part.position, { y: 0.1, delay: 0.8, duration: 1 }))
})
break
case "VORTEX_ANIMATION":
this.emitter.destory()
this.location.energyEmitter.stop()
const mainDelay = Math.random() * 0.5
const moveDelay = mainDelay + 1.5
this.animations.push(
gsap.to(this.modelOffset, {
y: -1,
z: 0.2,
ease: "power4.in",
duration: 1,
delay: moveDelay,
onComplete: () => this.machine.send("complete"),
})
)
this.animations.push(
gsap.to(this.model.scene.rotation, {
y: Math.random() * 2,
ease: "power4.in",
duration: 1.5,
delay: mainDelay + 1,
})
)
this.animations.push(
gsap.to(this.model.scene.scale, {
y: 1.2,
ease: "power4.in",
duration: 1.5,
delay: mainDelay + 1,
})
)
this.animations.push(
gsap.to(this.uniforms.stretch, {
value: 1,
ease: "power4.in",
delay: mainDelay,
duration: 2,
})
)
break
case "DEAD":
this.destory()
break
case "GONE":
this.emitter.destory()
this.destory()
break
case "ACCEND":
this.location.energyEmitter.stop()
this.animations.push(
gsap.to(this.position, {
y: 1.1,
ease: "Power4.in",
duration: 0.6,
delay: Math.random(),
onStart: () => this.emitter.puffOfSmoke(),
onComplete: () => {
this.destory()
this.machine.send("leave")
},
})
)
}
}
}
resetDemon() {
console.log("----reseting demon")
console.log(this.uniforms)
this.uniforms.in.value = 0
this.uniforms.out.value = 0
this.uniforms.stretch.value = 0
this.model.scene.traverse((item) => {
if (item.isMesh) {
const types = ["position", "rotation", "scale"]
types.forEach((type) => {
item[type].set(item.home[type].x, item.home[type].y, item.home[type].z)
})
}
})
}
destory() {
if (this.model) {
this.group.removeFromParent()
this.resetDemon()
this.demon.returnToPool()
// this.model.scene.parent.remove(this.model.scene)
// this.model = null
}
this.animations.forEach((animation) => {
animation.kill()
animation = null
})
if (this.location) this.location.release()
if (this.onDeadCallback) {
this.onDeadCallback()
this.onDeadCallback = null
}
}
tick(delta, elapsedTime) {
this.uniforms.time.value = elapsedTime
this.moveFunction(delta, elapsedTime)
this.group.position.set(
this.position.x * this.sim.size.x,
this.position.y * this.sim.size.y,
this.position.z * this.sim.size.z
)
this.model.scene.position.set(
this.modelOffset.x * this.sim.size.x,
this.modelOffset.y * this.sim.size.y,
this.modelOffset.z * this.sim.size.z
)
if (this.location)
this.emitter.position = {
x: this.position.x + this.location.position.x,
y: this.position.y + this.location.position.y,
z: this.position.z + this.location.position.z,
}
}
get dead() {
return this.state === "DEAD" || this.state === "GONE"
}
get active() {
return this.state === "ALIVE"
}
}
/*
The demon needs a little moment to get loaded
into memory. So rather than wait for the first
in game enemy to appear and get hit with a
stutter, we use this preloader to do some
heavy lifting during the loading screen
*/
class EnemyPreloader {
constructor(stage) {
this.totalDemons = 6
this.demons = []
for (let i = 0; i < this.totalDemons; i++) {
this.demons.push({
isAvailable: true,
returnToPool: function () {
this.isAvailable = true
},
uniforms: {
in: { value: 0 },
out: { value: 0 },
stretch: { value: 0 },
time: { value: 1 },
},
demon: ASSETS.getModel("demon", true),
})
}
this.demons.forEach((enemy, i) => {
enemy.demon.group.position.y = -0.1
enemy.demon.group.position.x = 0.05 + 0.02 * (i + 1)
stage.add(enemy.demon.group)
enemy.demon.scene.traverse((item) => {
if (item.name === "cloak") {
// item.castShadow = true
// item.material.transparent = true
// item.material.forceSinglePass = true
// item.renderOrder = 0
// item.material.writeDepth = false
item.material.onBeforeCompile = (shader) => {
// const uniform = { value: 1 }
shader.uniforms.uIn = enemy.uniforms.in
shader.uniforms.uOut = enemy.uniforms.out
shader.uniforms.uStretch = enemy.uniforms.stretch
shader.uniforms.uTime = enemy.uniforms.time
shader.vertexShader = shader.vertexShader.replace(
"#define STANDARD",
`#define STANDARD
${includes.noise}
uniform float uOut;
uniform float uTime;
uniform float uStretch;
varying vec2 vUv;
varying float vNoise;
`
)
shader.vertexShader = shader.vertexShader.replace(
"#include <begin_vertex>",
`
#include <begin_vertex>
vUv = uv;
float xNoise = snoise(vec2((position.x * 200.0) + (position.z * 100.0), uTime * (0.6 + (0.3 * uOut))));
float yNoise = snoise(vec2((position.y * 200.0) + (position.z * 100.0), uTime * (0.6 + (0.3 * uOut))));
float amount = (0.0015 + 0.02 * uOut) ;
float moveAmount = smoothstep(0.02 + (1.0 * uOut), 0.0, position.y);
transformed.x += moveAmount * amount * xNoise;
transformed.y += moveAmount * amount * yNoise;
transformed.x = transformed.x * (1.0 - uOut);
transformed.y = transformed.y * (1.0 - uOut)+ (0.0 * uOut);
transformed.z = transformed.z * (1.0 - uOut);
transformed.y -= (moveAmount * uStretch) * 0.01;
transformed.x += (moveAmount * uStretch) * 0.003;
vNoise = snoise(vec2(position.x * 500.0, position.y * 500.0 ));
`
)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`
uniform float uIn;
uniform float uOut;
uniform float uTime;
varying vec2 vUv;
varying float vNoise;
${includes.noise}
#include <common>
`
)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <output_fragment>",
`#include <output_fragment>
// float noise = snoise(vUv);
// vec3 blackout = mix(vec3(vUv, 1.0), gl_FragColor.rgb, uOut);
float noise = snoise(vUv * 80.0);
float glowNoise = snoise((vUv * 4.0) + (uTime * 0.75));
float glow = smoothstep(0.3, 0.5, glowNoise);
glow *= smoothstep(0.7, 0.5, glowNoise);
// glowNoise = smoothstep(0.7, 0.5, glowNoise);
float grad = smoothstep(0.925 + (uOut * 0.2), 1.0, vUv.y) * noise;
// gl_FragColor = vec4(vec3(grad, 0.0, 0.0), 1.0 - grad);
// gl_FragColor.a = 1.0 - grad;
gl_FragColor.rgb *= 1.0 - grad;
gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(1.0), glow * 0.2 * pow(uOut, 0.25) ) ;
`
)
enemy.demon.group.removeFromParent()
}
}
})
})
}
resetAll() {
this.demons.forEach((d) => (d.isAvailable = true))
}
borrowDemon() {
const availableDemons = this.demons.filter((d) => d.isAvailable)
const demon = availableDemons[0]
demon.isAvailable = false
return demon
}
}
// LIGHTS
class CrystalLight {
constructor(position, offset) {
const color = new Color("#861388")
this.position = position
this.offset = offset
this.group = new Group()
this.pointLight = new PointLight(color, 5, 0.8)
this.group.add(this.pointLight)
this.group.position.set(position.x, position.y, position.z)
if (window.DEBUG.lights) {
const helper = new Mesh(new SphereGeometry(0.02), new MeshBasicMaterial(0xffffff))
this.group.add(helper)
}
}
get light() {
return this.group
}
tick(delta, elapsedTime) {
// this.group.position.set(
// this.position.x, // * this.offset.x,
// this.position.y, // * this.offset.y,
// this.position.z // * this.offset.z
// )
// const n = (Math.cos(elapsedTime * 1.8) + 1) * 0.5
// this.pointLight.intensity = 8 + 6 * n
}
}
class TorchLight {
constructor(position, offset, noise) {
const color = new Color("#FA9638")
this.position = position
this.offset = offset
this.group = new Group()
this.pointLight = new PointLight(color, 0, 0.6)
this.group.add(this.pointLight)
this.group.position.set(
this.position.x * this.offset.x,
this.position.y * this.offset.y,
this.position.z * this.offset.z
)
this.noise = noise
this._active = false
this.baseIntesity = 1
if (window.DEBUG.lights) {
const helper = new Mesh(new SphereGeometry(0.02), new MeshBasicMaterial({ color: 0xff0000 }))
this.group.add(helper)
}
}
get light() {
return this.group
}
get object() {
return this.group
}
set active(value) {
if (value !== this._active) {
this._active = value
if (this._active) {
gsap.fromTo(this, { baseIntesity: 3 }, { baseIntesity: 1, duration: 0.3 })
}
}
}
set color(newColor) {
this.pointLight.color = new Color(newColor)
}
tick(delta, elapsedTime) {
const n = this.noise(this.position.x * 2, this.position.y * 2, elapsedTime * 3) + 1 * 0.5
this.pointLight.intensity = this._active ? this.baseIntesity + 0.5 * n : 0
}
}
// PARTICLES
class ParticleType {
constructor(settings) {
this.settings = { ...DEFAULT_PARTICLE_SETTINGS, ...settings }
}
get speed() {
return this.settings.speed
}
get speedDecay() {
return this.settings.speedDecay
}
get speedSpread() {
return this.settings.speedSpread
}
get force() {
return this.settings.force
}
get forceDecay() {
return this.settings.forceDecay
}
get forceSpread() {
return this.settings.forceSpread
}
get life() {
return this.settings.life
}
get lifeDecay() {
return this.settings.lifeDecay
}
get directionSpread() {
return this.settings.directionSpread
}
get direction() {
return this.settings.direction
}
get position() {
return this.settings.position
}
get positionSpread() {
return this.settings.positionSpread
}
get color() {
return this.settings.color
}
set color(value) {
this.settings.color = value
}
get scale() {
return this.settings.scale
}
set scale(value) {
this.settings.scale = value
}
get scaleSpread() {
return this.settings.scaleSpread
}
get style() {
return this.settings.style
}
get acceleration() {
return this.settings.acceleration
}
}
class DustParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0,
speedDecay: 0.4,
color: { r: 0.5, g: 0.5, b: 0.5 },
speedSpread: 0,
forceSpread: 0,
force: 0,
style: PARTICLE_STYLES.circle,
life: 1,
lifeDecay: 0.3,
scale: 0.06,
acceleration: 1,
positionSpread: { x: 0.5, y: 0.5, z: 0.5 },
..._overides,
})
}
}
class ExplodeParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0.4,
speedSpread: 0,
speedDecay: 0.8,
forceSpread: 0,
force: 2,
forceDecay: 0.9,
type: PARTICLE_STYLES.smoke,
..._overides,
})
}
}
class FlameParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0.5,
speedDecay: 0.4,
color: { r: 1, g: 1.0, b: 0.3 },
speedSpread: 0.1,
forceSpread: 0.2,
force: 0.8,
forceDecay: 0.8,
scale: 1,
scaleSpread: 1,
lifeDecay: 1.5,
direction: { x: 1, y: 1, z: 1 },
directionSpread: { x: 0.1, y: 0.1, z: 0.1 },
positionSpread: { x: 0, y: 0, z: 0 },
acceleration: 0.02,
style: PARTICLE_STYLES.flame,
..._overides,
})
}
}
class ForceParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0.4,
speedDecay: 0.4,
color: { r: 1, g: 0, b: 0 },
force: 1,
forceDecay: 0,
direction: { x: 1, y: 1, z: 1 },
directionSpread: { x: 0.3, y: 0, z: 0.3 },
acceleration: 0,
scale: 0.3,
style: window.DEBUG.forceParticles ? PARTICLE_STYLES.circle : PARTICLE_STYLES.invisible,
..._overides,
})
}
}
class SmokeParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0,
speedDecay: 0,
speedSpread: 0,
forceSpread: 0,
force: 0,
life: 1,
lifeDecay: 0,
scaleSpread: 1,
acceleration: 0,
positionSpread: { x: 0.02, y: 0, z: 0.001 },
color: { r: 0.75, g: 0.75, b: 0.75 },
style: PARTICLE_STYLES.smoke,
scale: 1,
..._overides,
})
}
}
class SparkleParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0.1,
speedSpread: 0,
forceSpread: 0,
force: 0,
life: 0.5,
lifeDecay: 0,
scaleSpread: 1,
acceleration: 0,
color: { r: 1, g: 1, b: 1 },
style: PARTICLE_STYLES.point,
scale: 1.2,
speedDecay: 0.2,
positionSpread: { x: 0.01, y: 0.001, z: 0.01 },
..._overides,
})
}
}
class SpellTrailParticle extends ParticleType {
constructor(overrides) {
const _overides = overrides ? overrides : {}
super({
speed: 0.5,
speedDecay: 0.4,
color: { r: 1, g: 1, b: 1 },
speedSpread: 0.1,
forceSpread: 0.2,
force: 0.8,
forceDecay: 0.8,
scale: 1,
scaleSpread: 1,
lifeDecay: 1.5,
direction: { x: 1, y: 1, z: 1 },
directionSpread: { x: 0.1, y: 0.1, z: 0.1 },
positionSpread: { x: 0, y: 0, z: 0 },
acceleration: 0.02,
style: PARTICLE_STYLES.smoke,
..._overides,
})
}
}
// SOUNDS
class SoundController {
constructor() {
this.audioListener = new AudioListener()
this.ready = false
this.sounds = [
{ id: "music", loop: true, volume: 0.5 },
{ id: "kill", files: ["kill-1", "kill-2", "kill-3"] },
{ id: "enter", files: ["enter-1", "enter-2"] },
{ id: "cast", files: ["cast-1", "cast-2"] },
{ id: "ping", files: ["ping-1", "ping-2"] },
{ id: "laugh", files: ["laugh-1", "laugh-2", "laugh-3"] },
{ id: "error", files: ["error-1"] },
{ id: "spell-travel", files: ["spell-travel-1", "spell-travel-2", "spell-travel-3"] },
{ id: "spell-failed", volume: 0.5, files: ["spell-failed-1", "spell-failed-2"] },
{ id: "trapdoor-close", files: ["trapdoor-close-1", "trapdoor-close-2"] },
{ id: "torch", files: ["torch-1", "torch-2", "torch-3"] },
{ id: "crystal-explode", files: ["crystal-explode"] },
{ id: "crystal-reform", files: ["crystal-reform"] },
{ id: "glitch", volume: 0.8, files: ["glitch"] },
{ id: "portal", files: ["portal"] },
{ id: "crumble", files: ["crumble"] },
{ id: "reform", files: ["reform"] },
]
this.soundMap = {}
// I initial had the background 'music' as seperate option but decided to merge both options into one. But the logic for supporting more is still here, hence the object and arrays
this.state = {
sounds: true,
}
this.buttons = {
// music: document.querySelector("#music-button"),
sounds: document.querySelector("#sounds-button"),
soundsText: document.querySelector("#sounds-button .sr-only"),
}
for (let i = this.sounds.length - 1; i >= 0; i--) {
const sound = this.sounds[i]
if (sound.files) {
sound.files.forEach((id) => {
this.sounds.push({
id,
loop: sound.loop ? sound.loop : false,
volume: sound.volume ? sound.volume : 1,
})
})
}
}
}
init(stage) {
if (window.DEBUG.disableSounds) {
this.state = { sounds: false }
}
stage.camera.add(this.audioListener)
this.sounds.forEach((d) => {
if (d.files) {
this.soundMap[d.id] = {
selection: d.files,
}
} else {
let buffer = ASSETS.getSound(d.id)
const sound = new Audio(this.audioListener)
stage.add(sound)
sound.setBuffer(buffer)
sound.setLoop(d.loop ? d.loop : false)
sound.setVolume(d.volume ? d.volume : 1)
this.soundMap[d.id] = sound
d.sound = sound
}
this.ready = true
})
const types = ["sounds"]
types.forEach((type) => {
this.buttons[type].addEventListener("click", () => this.toggleState(type))
this.updateButton(type)
})
}
initError() {
return console.error("sounds not initialized")
}
toggleState(type) {
if (!this.ready) return this.initError()
console.log("toggling", type)
this.state[type] = !this.state[type]
this.updateButton(type)
if (this.state.sounds) {
this.startMusic()
} else {
this.stopAll()
this.stopMusic()
}
}
updateButton(type) {
if (this.state[type]) delete this.buttons.sounds.dataset.off
else this.buttons.sounds.dataset.off = "true"
const copy = this.buttons.soundsText.dataset.copy
this.buttons.soundsText.innerText = copy.replace("$$state", this.state[type] ? "off" : "on")
}
// setMusicState(state) {
// if (!this.ready) return this.initError()
// this.state.music = state
// if (this.state.music) this.startMusic()
// else this.stopMusic()
// this.updateButton("music")
// }
setSoundsState(state) {
if (!this.ready) return this.initError()
this.state.sounds = state
if (this.state.sounds) {
this.startMusic()
} else {
this.stopAll()
this.stopMusic()
}
this.updateButton("sounds")
}
startMusic() {
if (!this.ready) return this.initError()
if (this.state.sounds) {
this.soundMap.music.play()
} else {
this.stopMusic()
}
}
stopMusic() {
if (!this.ready) return this.initError()
this.soundMap.music.pause()
this.soundMap.music.isPlaying = false
// this.soundMap.music.currentTime = 0
}
play(id, restart = true) {
if (!this.ready) return this.initError()
if (this.state.sounds) {
const sound = this.soundMap[id]?.selection
? this.soundMap[randomFromArray(this.soundMap[id].selection)]
: this.soundMap[id]
if (sound) {
// console.log("playing", id, sound)
// if (restart) sound.currentTime = 0
sound.play()
sound.isPlaying = false
}
}
}
stopAll() {
if (!this.ready) return this.initError()
this.sounds.forEach((d) => {
if (d.id !== "music" && d.sound) d.sound.pause()
})
}
}
const SOUNDS = new SoundController()
// ASSETS
class Assets {
constructor() {
this.loadSequence = ["loadModels", "loadSounds", "loadTextures"]
this.assets = {
models: {},
sounds: {},
textures: {},
}
this.manager = new LoadingManager()
this.loaders = {
models: new GLTFLoader(this.manager),
sounds: new AudioLoader(this.manager),
textures: new TextureLoader(this.manager),
}
this.completedSteps = {
download: false,
audioBuffers: false,
models: false,
}
this.audioBufferCount = 0
this.modelLoadCount = 0
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/")
this.loaders.models.setDRACOLoader(dracoLoader)
// this.init()
}
checkComplete() {
const complete = Object.keys(this.completedSteps).reduce((previous, current) =>
!previous ? false : this.completedSteps[current]
)
if (complete) this.onLoadSuccess()
}
checkBuffers() {
// console.log("checking buffers", this.audioBufferCount, TO_LOAD.sounds.length)
if (this.audioBufferCount === TO_LOAD.sounds.length) {
this.completedSteps.audioBuffers = true
this.checkComplete()
}
}
checkModels() {
// console.log("checking buffers", this.audioBufferCount, TO_LOAD.sounds.length)
if (this.modelLoadCount === TO_LOAD.models.length) {
this.completedSteps.models = true
this.checkComplete()
}
}
load(onLoadSuccess, onLoadError) {
this.onLoadSuccess = onLoadSuccess
this.onLoadError = (err) => {
console.error(err)
onLoadError(err)
}
this.manager.onStart = (url, itemsLoaded, itemsTotal) => {
console.log(`Started loading file: ${url} \nLoaded ${itemsLoaded} of ${itemsTotal} files.`)
}
this.manager.onLoad = () => {
console.log("Loading complete!")
this.completedSteps.download = true
this.checkComplete()
}
// this.manager.on
this.manager.onProgress = (url, itemsLoaded, itemsTotal) => {
document.body.style.setProperty("--loaded", itemsLoaded / itemsTotal)
// console.log(`Progress. Loading file: ${url} \nLoaded ${itemsLoaded} of ${itemsTotal} files.`)
}
this.manager.onError = (url) => {
console.log("There was an error loading " + url)
this.onLoadError(`error loading ${url}`)
}
this.loadNext()
}
loadNext() {
if (this.loadSequence.length) {
this[this.loadSequence.shift()]()
} else {
}
}
loadModels() {
TO_LOAD.models.forEach((item) => {
this.loaders.models.load(item.file, (gltf) => {
// const group = new Group()
if (item.position) gltf.scene.position.set(...item.position)
if (item.scale) gltf.scene.scale.set(item.scale, item.scale, item.scale)
// group.add(gltf.scene)
this.assets.models[item.id] = gltf
// if (item.id === "horse" || item.id === "parrot") {
// var basicMaterial = new MeshBasicMaterial({
// color: 0xffffff,
// })
// gltf.scene.traverse((child) => {
// if (child.isMesh) {
// child.material = basicMaterial
// }
// })
// }
this.modelLoadCount++
this.checkModels()
})
})
this.loadNext()
}
loadSounds() {
TO_LOAD.sounds.forEach((item) => {
this.assets.sounds[item.id] = null
this.loaders.sounds.load(item.file, (buffer) => {
// console.log("--- sound loaded")
// console.log("loaded buffer", buffer)
this.assets.sounds[item.id] = buffer //audio
this.audioBufferCount++
this.checkBuffers()
})
})
this.loadNext()
}
loadTextures() {
TO_LOAD.textures.forEach((item) => {
this.loaders.textures.load(item.file, (texture) => {
this.assets.textures[item.id] = texture
})
})
this.loadNext()
}
getModel(id, deepClone) {
console.log("--GET MODEL:", id, this.assets.models[id])
const group = new Group()
const scene = this.assets.models[id].scene.clone()
scene.traverse((item) => {
if (item.isMesh) {
item.home = {
position: item.position.clone(),
rotation: item.rotation.clone(),
scale: item.scale.clone(),
}
if (deepClone) item.material = item.material.clone()
}
})
group.add(scene)
return { group, scene, animations: this.assets.models[id].animations }
}
getTexture(id) {
// console.log("getting", id, "from", this.assets.textures)
return this.assets.textures[id]
}
getSound(id) {
// console.log("getting", id, "from", this.assets.sounds)
return this.assets.sounds[id]
}
// setSoundCallback(id, cb) {
// console.log(id, this.assets.sounds[id], cb)
// if (!this.assets.sounds[id]) this.assets.sounds[id] = cb
// }
}
const ASSETS = new Assets()
// CRYSTAL
class Crystal {
constructor(sim, onWhole, onBroken) {
this.machine = interpret(CrystalMachine)
this.state = this.machine.initialState.value
this.wholeCallback = onWhole
this.brokeCallback = onBroken
this.model = ASSETS.getModel("crystal")
this.energy = new CrystalEnergyEmitter(sim)
this.smashItems = []
this.beams = []
this.group = this.model.group
this.scene = this.model.scene
this.crystal = null
this.position = {
x: 0,
y: -0.05,
z: 0.165,
}
this.spin = 1
this.brokenSpin = 0
this.glitch = 0
this.elapsedTime = 0
this.uniforms = {
uTime: { value: 0 },
uGlow: { value: 0 },
}
this.material = new MeshMatcapMaterial({
side: DoubleSide,
})
this.light = new CrystalLight({ x: 0, y: 0.05, z: 0 }, sim.size)
this.group.add(this.light.light)
this.material.matcap = ASSETS.getTexture("crystal-matcap")
this.material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = this.uniforms.uTime
shader.uniforms.uGlow = this.uniforms.uGlow
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`
uniform float uGlow;
#include <common>
`
)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <output_fragment>",
`#include <output_fragment>
vec3 color = mix(gl_FragColor.rgb, vec3(1.0), uGlow);
gl_FragColor = vec4(color, gl_FragColor.a);
`
)
}
this.model.scene.traverse((item) => {
if (item.type === "Mesh") {
item.material = this.material
if (item.name === "Ruby") {
this.crystal = item
} else {
this.smashItems.push(item)
item.home = {
position: item.position.clone(),
rotation: item.rotation.clone(),
scale: item.scale.clone(),
}
item.random = {
x: Math.random() * 2 - 1,
y: Math.random() * 2 - 1,
z: Math.random() * 2 - 1,
}
}
}
})
const beams = [
{
x: 0,
y: Math.PI * 2 * 0,
z: 0,
},
{
x: 0,
y: Math.PI * 2 * 0.33,
z: 0.5,
},
{
x: 0,
y: Math.PI * 2 * 0.66,
z: -1,
},
]
this.beams = beams.map((r) => {
const plane = new PlaneGeometry(8, 2)
const material = new ShaderMaterial({
side: DoubleSide,
transparent: true,
vertexShader: `
uniform float uSize;
uniform float uTime;
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position * (0.1 + uv.x), 1.0);
}
`,
fragmentShader: `
uniform float progress;
uniform bool debug;
varying vec2 vUv;
// #include noise
void main() {
float alpha = (1.0 - smoothstep(0.3, 1.0, vUv.x));
gl_FragColor.rgb = vec3(1.0, 1.0, 1.0);
gl_FragColor.a = alpha;
}
`,
})
const mesh = new Mesh(plane, material)
mesh.rotation.set(r.x, r.y, r.z)
mesh.visible = false
this.scene.add(mesh)
return mesh
})
this.machine.onTransition((s) => this.onStateChange(s))
this.machine.start()
// this.showFull()
}
onStateChange = (state) => {
this.state = state.value
if (state.changed || this.state === "IDLE") {
switch (this.state) {
case "IDLE":
this.machine.send("start")
break
case "INIT":
this.machine.send("ready")
case "WHOLE":
this.showFull()
this.energy.active = true
if (this.wholeCallback) this.wholeCallback()
break
case "OVERLOADING":
this.energy.active = false
SOUNDS.play("glitch")
this.overloadAnimation()
break
case "BREAKING":
this.showBroken()
SOUNDS.play("crystal-explode")
this.explodeAnimation()
break
case "BROKEN":
if (this.brokeCallback) this.brokeCallback()
break
case "FIXING":
this.energy.active = true
setTimeout(() => SOUNDS.play("crystal-reform"), 200)
this.rewindAnimation()
break
}
}
}
showFull() {
this.crystal.visible = true
this.smashItems.forEach((item) => (item.visible = false))
}
showBroken() {
this.crystal.visible = false
this.smashItems.forEach((item) => (item.visible = true))
}
spinDown() {
gsap.to(this, {
spin: 0,
duration: 2.5,
ease: "power2.inOut",
})
}
spinUp() {
gsap.to(this, {
spin: 1,
duration: 2.5,
ease: "power2.inOut",
})
}
brokenSpinUp() {
gsap.to(this, {
brokenSpin: 1,
duration: 1,
ease: "power2.inOut",
})
}
brokenSpinDown() {
this.brokenSpin = 0
}
glitchSpinUp() {
gsap.to(this, {
glitch: 1,
duration: 2,
ease: "power3.in",
})
gsap.to(this.uniforms.uGlow, {
value: 0.5,
duration: 4,
ease: "power2.in",
})
}
glitchSpinDown() {
this.glitch = 0
gsap.to(this.uniforms.uGlow, {
value: 0,
duration: 1,
ease: "power2.out",
})
}
overloadAnimation() {
this.spinDown()
this.glitchSpinUp()
gsap.to(this.group.scale, { x: 1.5, z: 1.5, y: 1.5, ease: "power1.in", duration: 4 })
const tl = gsap.timeline({
defaults: { duration: 0.4, ease: "power2.inOut" },
onComplete: () => this.machine.send("break"),
})
const rotationOffset = this.group.rotation.y % (Math.PI * 2)
tl.to(
this.scene.rotation,
{
x: "+=" + Math.PI * 0,
y: "+=" + Math.PI * 2 * 0.33,
z: "+=" + Math.PI * 0,
onComplete: () => (this.beams[0].visible = true),
},
1
)
tl.to(
this.scene.rotation,
{
x: "+=" + Math.PI * 0,
y: "+=" + Math.PI * 2 * 0.33,
z: "+=" + Math.PI * 0,
onComplete: () => (this.beams[2].visible = true),
},
2
)
tl.to(
this.scene.rotation,
{
x: "+=" + Math.PI * 0,
y: "+=" + Math.PI * 2 * 0.33,
z: "+=" + Math.PI * 0.25,
onComplete: () => (this.beams[1].visible = true),
},
3
)
tl.to(this.scene.rotation, {}, 3.5)
}
explodeAnimation() {
const duration = 3
this.showBroken()
this.glitchSpinDown()
this.brokenSpinUp()
this.beams.forEach((beam) => (beam.visible = false))
gsap.delayedCall(duration * 0.8, () => {
this.machine.send("broke")
})
this.smashItems.forEach((item) => {
gsap.to(item.position, {
x: Math.random() * 10 - 5,
y: Math.random() * 5 - 1,
z: Math.random() * 8 - 4,
ease: "power4.out",
duration,
})
// gsap.to(item.rotation, {
// x: Math.random() * 6 - 3,
// y: Math.random() * 6 - 3,
// z: Math.random() * 6 - 3,
// ease: "power4.out",
// duration,
// })
})
}
rewindAnimation() {
const duration = 2
this.brokenSpinDown()
gsap.delayedCall(duration * 0.5, () => {
if (this.wholeCallback) this.wholeCallback()
})
this.spinUp()
gsap.delayedCall(duration, () => this.machine.send("fixed"))
gsap.to(this.scene.rotation, { x: 0, y: "+=" + Math.PI * 3, z: 0, ease: "power4.inOut", duration: duration * 1.5 })
gsap.to(this.group.scale, { x: 1, z: 1, y: 1, ease: "power4.inOut", duration: duration * 1.5 })
// gsap.to(this.scene.rotation, { x: 0, z: 0, y: "+=" + Math.PI * 3, ease: "power4.inOut", duration: duration * 1.5 })
this.smashItems.forEach((item) => {
gsap.to(item.position, {
...item.home.position,
duration,
ease: "power2.inOut",
})
gsap.to(item.rotation, {
x: item.home.rotation.x,
y: item.home.rotation.y,
z: item.home.rotation.z,
duration,
ease: "power2.inOut",
})
})
}
explode() {
this.machine.send("overload")
}
reset() {
if (this.state === "WHOLE") {
if (this.wholeCallback) this.wholeCallback()
} else this.machine.send("fix")
}
tick(delta) {
this.elapsedTime += delta
this.uniforms.uTime.value = this.elapsedTime
const float = Math.cos(this.elapsedTime) * 0.015
this.group.rotation.x = Math.cos(this.elapsedTime) * 0.1 * this.spin
this.group.rotation.z = Math.cos(this.elapsedTime) * 0.07 * this.spin
this.group.rotation.y += 0.5 * delta * this.spin
this.group.position.x = this.position.x
this.group.position.y = this.position.y + float * this.spin
this.group.position.z = this.position.z
if (this.light) this.light.tick(delta, this.elapsedTime)
if (this.energy) this.energy.tick(delta, this.elapsedTime)
const rotateFactor = 0.25
if (this.brokenSpin > 0) {
this.smashItems.forEach((item) => {
item.rotation.x += delta * item.random.x * rotateFactor * this.brokenSpin
item.rotation.y += delta * item.random.y * rotateFactor * this.brokenSpin
item.rotation.z += delta * item.random.z * rotateFactor * this.brokenSpin
})
}
const glitchAmount = 0.007
if (this.glitch > 0) {
this.scene.position.x = (Math.random() - 0.5) * glitchAmount * this.glitch
this.scene.position.y = (Math.random() - 0.5) * glitchAmount * this.glitch
this.scene.position.z = (Math.random() - 0.5) * glitchAmount * this.glitch
}
}
}
// ENTRANCE
class Entrance {
constructor(name, points, enterFunc) {
this.name = name
this.points = points
this.enterFunc = enterFunc
}
createPathTo(destination, offset, offsetFromDestination) {
// const waypointCount = 1
const shift = offsetFromDestination ? { ...destination } : { x: 0, y: 0, z: 0 }
let waypoints = this.calculateEvenlySpacedVectors({ x: 0.5, y: 0.5, z: 0.5 }, destination, 5)
// waypoints.push({
// x: destination.x,
// y: 0.5,
// z: destination.z,
// })
let newPath = [...this.points, ...waypoints, destination].map((p) => ({
x: p.x - shift.x,
y: p.y - shift.y,
z: p.z - shift.z,
}))
const curve = new CatmullRomCurve3(newPath.map((p) => new Vector3(p.x, p.y, p.z)))
return curve
}
calculateEvenlySpacedVectors(center, vector1, numVectors = 2) {
const angleBetweenVectors = (2 * Math.PI) / numVectors
const x1 = vector1.x - center.x
const z1 = vector1.z - center.z
const radius = Math.sqrt(x1 ** 2 + z1 ** 2)
const angle1 = Math.atan2(z1, x1)
const evenlySpacedVectors = []
for (let i = 1; i <= numVectors; i++) {
const angle = angle1 + i * angleBetweenVectors
const vector = {
x: center.x + radius * Math.cos(angle),
y: 0.2 + Math.random() * 0.6,
z: center.z + radius * Math.sin(angle),
}
evenlySpacedVectors.push(vector)
}
return evenlySpacedVectors
}
createDebugMarkers(container, offset) {
const group = new Group()
this.points.forEach((p) => {
const helper = new Mesh(new SphereGeometry(0.01), new MeshBasicMaterial({ color: 0xffffff }))
helper.position.x = p.x * offset.x
helper.position.y = p.y * offset.y
helper.position.z = p.z * offset.z
group.add(helper)
})
container.add(group)
}
enter() {
if (this.enterFunc) this.enterFunc()
}
}
// LOCATION
class Location {
#position
#offset
#index
constructor(position, offset, entrances, releaseCallback, markerColor = 0xffffff) {
this.#position = position
this.rotation = position.r
this.#offset = offset
this.group = new Group()
this.releaseCallback = releaseCallback
this.markerColor = markerColor
this.entranceOptions = entrances.map((e) => e.name)
this.entrancePaths = {}
this.energyEmitter = null
this.init()
this.createEntrancePaths(entrances)
}
init() {
this.setPosition()
// this.group.rotation.y = this.rotation
if (window.DEBUG.locations) {
const axesHelper = new AxesHelper(0.1)
axesHelper.rotation.y = this.rotation
this.group.add(axesHelper)
const helper = new Mesh(new SphereGeometry(0.01), new MeshBasicMaterial({ color: this.markerColor }))
this.group.add(helper)
}
}
getRandomEntrance() {
const entrance = randomFromArray(this.entranceOptions)
return this.entrancePaths[entrance]
}
createEntranceTrail(curve) {
const pointsCount = 200
const frenetFrames = curve.computeFrenetFrames(pointsCount, false)
const points = curve.getSpacedPoints(pointsCount)
const width = [-0.05, 0.05]
let point = new Vector3()
let shift = new Vector3()
let newPoint = new Vector3()
let planePoints = []
width.forEach((d) => {
for (let i = 0; i < points.length; i++) {
point = points[i]
shift.add(frenetFrames.binormals[i]).multiplyScalar(d)
planePoints.push(new Vector3().copy(point).add(shift))
}
})
const geometry = new PlaneGeometry(0.1, 0.1, points.length - 1, 1).setFromPoints(planePoints)
const material = new ShaderMaterial({
vertexShader: `
uniform float uSize;
uniform float uTime;
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: FragmentShader(`
uniform float progress;
uniform bool debug;
varying vec2 vUv;
#include noise
void main() {
float length = 0.2;
float strength = 1.0;
float xRange = 1.0 + length * 2.0;
strength *= 1.0 - smoothstep(vUv.x - length * 0.5, xRange * progress, xRange * progress - length * 0.5);
// strength *= step(xRange * progress, vUv.x + length * 0.5 );
float noiseSmooth = (snoise(vec2((vUv.x) * 5.0, (vUv.y) * 3.0)) + 1.0) / 2.0;
float noiseStepped = step(0.5, noiseSmooth);
float debugColor = 0.0;
if(debug) debugColor = 1.0;
gl_FragColor.rgb = vec3(0.05 + debugColor * (1.0 - noiseSmooth), 0.05, 0.05);
gl_FragColor.a = debugColor + strength * noiseSmooth;
}
`),
side: DoubleSide,
transparent: true,
uniforms: {
progress: { value: 0.0 },
debug: { value: window.DEBUG.trail },
},
})
const plane = new Mesh(geometry, material)
plane.scale.set(this.#offset.x, this.#offset.y, this.#offset.z)
this.group.add(plane)
plane.visible = false
return plane
}
createEntrancePaths(entrances) {
entrances.forEach((entrance) => {
const curve = entrance.createPathTo(this.#position, this.#offset, true)
// .map((vec3) => vec3.multiply(new Vector3(this.#offset.x, this.#offset.y, this.#offset.z)))
const points = curve.getSpacedPoints(30)
const trail = this.createEntranceTrail(curve)
this.entrancePaths[entrance.name] = { points, curve, trail, entrance }
if (window.DEBUG.locations) {
const geometry = new TubeGeometry(curve, 50, 0.001, 8, false)
const material = new MeshBasicMaterial({ color: this.markerColor })
const curveObject = new Mesh(geometry, material)
curveObject.scale.set(this.#offset.x, this.#offset.y, this.#offset.z)
// curveObject.rotation.y = -this.rotation
this.group.add(curveObject)
}
})
}
setPosition() {
const p = {
x: this.#position.x * this.#offset.x,
y: this.#position.y * this.#offset.y,
z: this.#position.z * this.#offset.z,
}
this.group.position.set(p.x, p.y, p.z)
}
add(item) {
this.group.add(item)
}
release() {
this.releaseCallback(this.#index)
}
get x() {
return this.#position.x
}
get y() {
return this.#position.y
}
get z() {
return this.#position.z
}
get position() {
return this.#position
}
set index(newIndex) {
this.#index = newIndex
}
set position(newPosition) {
this.#position = newPosition
this.setPosition()
}
set x(newX) {
this.#position.x = newX
this.setPosition()
}
set y(newY) {
this.#position.y = newY
this.setPosition()
}
set z(newZ) {
this.#position.z = newZ
this.setPosition()
}
}
// PARTICLE SIM
class ParticleSim {
constructor(settings) {
this.settings = {
size: { x: 11, y: 5, z: 12 },
particles: 5000,
noiseStrength: 0.8,
flowStrength: 0.03,
pixelRatio: 1,
gridFlowDistance: 1,
flowDecay: 0.95,
...settings,
}
this._size = new Vector3(this.settings.size.x, this.settings.size.y, this.settings.size.z)
const max = Math.max(...this._size.toArray())
this._size.divideScalar(max)
this.gridCellCount = this.settings.size.x * this.settings.size.y * this.settings.size.z
this._grid = new Float32Array(this.gridCellCount * 3)
this._flow = new Float32Array(this.gridCellCount * 3)
this._noise = new Float32Array(this.gridCellCount * 3)
this.offset = new Vector3(
(this.size.x / this.grid.x) * 0.5,
(this.size.y / this.grid.y) * 0.5,
(this.size.z / this.grid.z) * 0.5
)
this.startCoords = new Vector3(
0 - this.offset.x * this.settings.size.x,
0 - this.offset.y * this.settings.size.y,
0 - this.offset.z * this.settings.size.z
)
this.particleGroups = {
smoke: {
count: 1000,
nextParticle: 0,
newParticles: false,
geometry: new BufferGeometry(),
material: new ShaderMaterial({
...DEFAULT_PARTICLE_MATERIAL_SETTINGS,
fragmentShader: `
const float spriteSheetCount = 8.0;
uniform sampler2D spriteSheet;
varying float vLife;
varying vec3 vColor;
varying vec3 vRandom;
vec4 getSprite(vec2 uv, float i) {
float chunkSize = 1.0 / spriteSheetCount;
return texture( spriteSheet, vec2((chunkSize * i) + uv.x * chunkSize, uv.y) );
}
void main()
{
if(vLife <= 0.0) discard;
vec2 uv = vec2( gl_PointCoord.x, 1.0 - gl_PointCoord.y );
vec4 tex = getSprite(uv, floor(vRandom.y * spriteSheetCount));
vec3 color = mix(tex.rgb, vec3(0.02, 0.0, 0.0), 0.8 + vRandom.x * 0.2 );
float strength = tex.a * 1.0 ;
if(strength < 0.0) strength = 0.0;
float fade = 1.0;
if(vLife < 0.6) {
fade = smoothstep(0.0, 0.6, vLife);
} else {
fade = 1.0 - smoothstep(0.8, 1.0, vLife);
}
gl_FragColor = vec4(color, strength * fade);
}
`,
blending: CustomBlending,
blendDstAlpha: OneFactor,
blendSrcAlpha: ZeroFactor,
uniforms: {
uTime: { value: 0 },
uGrow: { value: true },
uSize: { value: 250 * this.settings.pixelRatio },
spriteSheet: { value: ASSETS.getTexture("smoke-particles") },
},
}),
mesh: null,
properties: {},
},
magic: {
count: 4000,
nextParticle: 0,
newParticles: false,
geometry: new BufferGeometry(),
material: new ShaderMaterial({
...DEFAULT_PARTICLE_MATERIAL_SETTINGS,
fragmentShader: `
#define wtf 0x5f3759df;
const float spriteSheetCount = 7.0;
uniform sampler2D spriteSheet;
varying float vLife;
varying float vType;
varying vec3 vColor;
varying vec3 vRandom;
vec4 getSprite(vec2 uv, float i) {
float chunkSize = 1.0 / spriteSheetCount;
return texture( spriteSheet, vec2((chunkSize * i) + uv.x * chunkSize, uv.y) );
}
void main()
{
if(vLife <= 0.0) discard;
vec2 uv = vec2( gl_PointCoord.x, 1.0 - gl_PointCoord.y );
vec4 tex = getSprite(uv, vType);
float strength = tex.r;
// Diffuse point
if(vType == 1.0) {
if(vRandom.r >= 0.5) strength = tex.r;
else strength = tex.g;
}
if(vType == 6.0) {
if(vRandom.r <= 0.33) strength = tex.r;
else if(vRandom.r >= 0.66) strength = tex.g;
else strength = tex.b;
}
vec3 color = mix(vColor, vec3(1.0), vRandom.x * 0.4 );
float fade = 1.0;
if(vLife < 0.5) {
fade = smoothstep(0.0, 0.5, vLife);
} else {
fade = 1.0 - smoothstep(0.9, 1.0, vLife);
}
gl_FragColor = vec4(color, strength * fade);
}
`,
blending: AdditiveBlending,
uniforms: {
uGrow: { value: false },
uTime: { value: 0 },
uSize: { value: 75 * this.settings.pixelRatio },
spriteSheet: { value: ASSETS.getTexture("magic-particles") },
},
}),
mesh: null,
properties: {},
},
}
this.particleGroupsArray = Object.keys(this.particleGroups).map((key) => this.particleGroups[key])
this.particleGroupsArray.forEach((group) => {
group.mesh = new Points(group.geometry, group.material)
group.mesh.frustumCulled = false
// group.mesh.renderOrder = 10000
PROPERTIES.vec3.forEach((propertyName) => {
group.properties[propertyName] = new Float32Array(group.count * 3)
})
PROPERTIES.float.forEach((propertyName) => {
group.properties[propertyName] = new Float32Array(group.count)
})
group.mesh.position.x -= this.offset.x * this.settings.size.x
group.mesh.position.y -= this.offset.y * this.settings.size.y
group.mesh.position.z -= this.offset.z * this.settings.size.z
group.mesh.scale.set(this._size.x, this._size.y, this._size.z)
group.mesh.renderOrder = 1
group.geometry.setAttribute("position", new BufferAttribute(group.properties.position, 3))
group.geometry.setAttribute("color", new BufferAttribute(group.properties.color, 3))
group.geometry.setAttribute("scale", new BufferAttribute(group.properties.size, 1))
group.geometry.setAttribute("life", new BufferAttribute(group.properties.life, 1))
group.geometry.setAttribute("type", new BufferAttribute(group.properties.type, 1))
group.geometry.setAttribute("random", new BufferAttribute(group.properties.random, 3))
})
// this.nextParticle = 0
// this.newParticles = false
this.castParticles = []
// this.particlesGeometry = new BufferGeometry()
// this.particlesMaterial = new ShaderMaterial({
// depthWrite: false,
// blending: AdditiveBlending,
// // blending: CustomBlending,
// // blendDstAlpha: OneFactor,
// // blendSrcAlpha: ZeroFactor,
// vertexColors: true,
// vertexShader,
// fragmentShader,
// uniforms: {
// uTime: { value: 0 },
// uSize: { value: 75 * this.settings.pixelRatio },
// spriteSheet: { value: ASSETS.getTexture("magic-particles") },
// },
// })
// this._particles = new Points(this.particlesGeometry, this.particlesMaterial)
// this._particles.frustumCulled = false
// this.particlePosition = new Float32Array(this.settings.particles * 3)
// this.particleDirection = new Float32Array(this.settings.particles * 3)
// this.particleRandom = new Float32Array(this.settings.particles * 3)
// this.particleColor = new Float32Array(this.settings.particles * 3)
// this.particleType = new Float32Array(this.settings.particles)
// this.particleType = new Float32Array(this.settings.particles)
// this.particleSpeed = new Float32Array(this.settings.particles)
// this.particleSpeedDecay = new Float32Array(this.settings.particles)
// this.particleForce = new Float32Array(this.settings.particles)
// this.particleForceDecay = new Float32Array(this.settings.particles)
// this.particleAcceleration = new Float32Array(this.settings.particles)
// this.particleLife = new Float32Array(this.settings.particles)
// this.particleLifeDecay = new Float32Array(this.settings.particles)
// this.particleSize = new Float32Array(this.settings.particles)
this.init()
}
init() {
for (let i = 0; i < this._grid.length; i += 3) {
this._grid[i] = Math.random() * 2 - 1
this._grid[i + 1] = Math.random() * 2 - 1
this._grid[i + 2] = Math.random() * 2 - 1
}
Object.keys(this.particleGroups).forEach((key) => {
const group = this.particleGroups[key]
for (let i = 0; i < group.count; i++) {
this.createParticle(key)
}
for (let i = 0; i < group.properties.random.length; i++) {
group.properties.random[i] = Math.random()
}
})
// this._particles.position.x -= this.offset.x * this.settings.size.x
// this._particles.position.y -= this.offset.y * this.settings.size.y
// this._particles.position.z -= this.offset.z * this.settings.size.z
// this._particles.scale.set(this._size.x, this._size.y, this._size.z)
// this.particlesGeometry.setAttribute("position", new BufferAttribute(this.particlePosition, 3))
// this.particlesGeometry.setAttribute("color", new BufferAttribute(this.particleColor, 3))
// this.particlesGeometry.setAttribute("scale", new BufferAttribute(this.particleSize, 1))
// this.particlesGeometry.setAttribute("life", new BufferAttribute(this.particleLife, 1))
// this.particlesGeometry.setAttribute("type", new BufferAttribute(this.particleType, 1))
// this.particlesGeometry.setAttribute("random", new BufferAttribute(this.particleRandom, 3))
this.gridFlowLookup = this.setupGridFlowLookup()
}
setupGridFlowLookup() {
let lookupArray = []
const d = this.settings.gridFlowDistance
for (let z = 0; z < this.settings.size.z; z++) {
for (let y = 0; y < this.settings.size.y; y++) {
for (let x = 0; x < this.settings.size.x; x++) {
const position = { x, y, z } //new Vector3(x, y, z)
let group = []
for (let _z = position.z - d; _z <= position.z + d; _z++) {
for (let _y = position.y - d; _y <= position.y + d; _y++) {
for (let _x = position.x - d; _x <= position.x + d; _x++) {
const newPosition = { x: _x, y: _y, z: _z }
if (this.validGridPosition(newPosition)) {
group.push(this.getGridIndexFromPosition(newPosition))
}
}
}
}
lookupArray.push(group)
}
}
}
return lookupArray
}
getVectorFromArray(array, index) {
if (typeof array === "string") array = this["_" + array]
if (array)
return {
x: array[index * 3],
y: array[index * 3 + 1],
z: array[index * 3 + 2],
}
return null
}
getGridSpaceFromPosition(position) {
// position.x *= this.settings.size.x
// position.y *= this.settings.size.y
// position.z *= this.settings.size.z
const gridSpace = {
x: Math.floor(position.x * this.settings.size.x),
y: Math.floor(position.y * this.settings.size.y),
z: Math.floor(position.z * this.settings.size.z),
}
return gridSpace
}
updateArrayFromVector(array, index, vector) {
if (typeof array === "string") array = this["_" + array]
// else
// console.logLimited(vector)
if (array) {
array[index * 3] = vector.x !== undefined ? vector.x : vector.r
array[index * 3 + 1] = vector.y !== undefined ? vector.y : vector.g
array[index * 3 + 2] = vector.z !== undefined ? vector.z : vector.b
} else {
console.logLimited("invalid array")
}
}
getGridIndexFromPosition(position) {
let index = position.x
index += position.y * this.settings.size.x
index += position.z * this.settings.size.x * this.settings.size.y
return index
}
validGridPosition(position) {
if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) {
return false
}
if (position.x < 0 || position.y < 0 || position.z < 0) {
return false
}
if (
position.x >= this.settings.size.x ||
position.y >= this.settings.size.y ||
position.z >= this.settings.size.z
) {
return false
}
return true
}
getGridSpaceDirection(position, source = "grid") {
if (!this.validGridPosition(position)) {
return null
}
let index = this.getGridIndexFromPosition(position)
const direction = this.getVectorFromArray(this[`_${source}`], index)
return direction
}
// getGridSpeed(position) {
// if (!this.validGridPosition(position)) {
// return 0
// }
// let index = this.getGridIndexFromPosition(position)
// return this._speed[index]
// }
getSurroundingGrid(index) {
const surrounding = this.gridFlowLookup[index]
const toReturn = []
for (let i = 0; i < surrounding.length; i++) {
const j = surrounding[i]
const direction = { x: this._grid[j * 3], y: this._grid[j * 3 + 1], z: this._grid[j * 3 + 2] }
toReturn.push({
x: direction.x,
y: direction.y,
z: direction.z,
// speed: this._speed[j],
})
}
return toReturn
}
getGridCoordsFromIndex(index) {
const z = Math.floor(index / (this.settings.size.z * this.settings.size.y))
const y = Math.floor((index - z * this.settings.size.x * this.settings.size.y) / this.settings.size.x)
const x = index % this.settings.size.x
return { x, y, z }
}
step(delta, elapsedTime) {
for (let i = 0; i < this.gridCellCount; i++) {
// NOISE
//
// For each cell we update the noise direction.
const coords = this.getGridCoordsFromIndex(i)
const t = elapsedTime * 0.05
const nc = {
x: coords.x * 0.05 + t,
y: coords.y * 0.05 + t,
z: coords.z * 0.05 + t,
}
const noiseX = noise3D(nc.x, nc.y, nc.z)
const noiseY = noise3D(nc.y, nc.z, nc.x)
const noiseZ = noise3D(nc.z, nc.x, nc.y)
const noise = vector.normalize({
x: Math.cos(noiseX * Math.PI * 2),
y: Math.sin(noiseY * Math.PI * 2),
z: Math.cos(noiseZ * Math.PI * 2),
})
// FLOW
//
// For each cell, record the average direction of the
// surrounding cells
const surroundingPositions = this.getSurroundingGrid(i)
// surroundingPositions.push(vector.multiplyScalar(this.getGridSpaceDirection(coords), 0.1))
const sum = vector.multiplyScalar(noise, this.settings.noiseStrength)
for (let j = 0; j < surroundingPositions.length; j++) {
const direction = surroundingPositions[j]
sum.x += direction.x
sum.y += direction.y
sum.z += direction.z
}
const average = {
x: sum.x / surroundingPositions.length,
y: sum.y / surroundingPositions.length,
z: sum.z / surroundingPositions.length,
}
// Save the FLOW and Noise values. We don't
// apply them the grid yet.
this.updateArrayFromVector("flow", i, average)
this.updateArrayFromVector("noise", i, noise)
}
// Once we have the FLOW and NOISE for the whole
// grid we now go back through and apply the changes
for (let i = 0; i < this._grid.length; i++) {
// Combine the NOISE with the FLOW based on the noiseStrength
// const flowNoise = this._flow[i] + this._noise[i] * this.settings.noiseStrength
// Add the new NOISE+FLOW value to the grid.
this._grid[i] += this._flow[i] * this.settings.flowStrength
}
// PARTICLES
this.particleGroupsArray.map((group) => {
// console.log("group", group)
const { life, lifeDecay, position, direction, force, forceDecay, speed, speedDecay, acceleration } =
group.properties
for (let i = 0; i < group.count; i++) {
// We only update particles that have a life more than 0.
if (life[i] > 0) {
let particlePosition = this.getVectorFromArray(position, i)
let particleDirection = this.getVectorFromArray(direction, i)
const gridSpace = this.getGridSpaceFromPosition(particlePosition)
let gridDirection = this.getGridSpaceDirection(gridSpace)
if (gridDirection) {
particleDirection = vector.lerpVectors(
particleDirection,
vector.normalize(gridDirection),
1 - Math.max(0, Math.min(1, force[i]))
)
}
const move = vector.multiplyScalar(particleDirection, delta ? delta * speed[i] : 0.01)
particlePosition = vector.add(particlePosition, move)
// Bounce off edges
AXIS.forEach((xyz) => {
if (particlePosition[xyz] < 0 || particlePosition[xyz] > 1) {
particlePosition[xyz] = particlePosition[xyz] < 0 ? 0 : 1
particleDirection[xyz] *= -1
}
})
this.updateArrayFromVector(position, i, particlePosition)
this.updateArrayFromVector(direction, i, vector.normalize(particleDirection))
// this.updateArrayFromVector("grid", i, particleDirection)
// this.updateGridSpace(gridSpace, particleDirection)
const gridIndex = this.getGridIndexFromPosition(gridSpace)
if (gridDirection) {
// console.log("force", vector.multiplyScalar(particleDirection, this.particleForce[i]))
const newGridDirection = vector.add(
gridDirection,
vector.multiplyScalar(particleDirection, force[i] * 0.05)
)
this.updateArrayFromVector("grid", gridIndex, newGridDirection)
speed[i] += vector.length(newGridDirection) * acceleration[i] * delta
if (speed[i] > 1) speed[i] = 1
}
speed[i] -= speedDecay[i] * delta
force[i] -= forceDecay[i] * delta
life[i] -= lifeDecay[i] * delta
if (speed[i] < 0.015) speed[i] = 0.015
if (force[i] < 0) force[i] = 0
if (life[i] < 0) life[i] = 0
}
}
group.material.uniforms.uTime.value = elapsedTime
group.geometry.attributes.position.needsUpdate = true
group.geometry.attributes.life.needsUpdate = true
if (group.newParticles) {
group.geometry.attributes.scale.needsUpdate = true
group.geometry.attributes.color.needsUpdate = true
group.geometry.attributes.type.needsUpdate = true
group.newParticles = false
}
})
// console.logLimited(this.settings.flowDecay * delta, delta)
for (let i = 0; i < this._grid.length; i++) {
this._grid[i] *= this.settings.flowDecay //* delta
}
}
getRandomPosition() {
return {
x: Math.random(),
y: Math.random(),
z: Math.random(),
}
}
getRandomDirection() {
return {
x: Math.random() * 2 - 1,
y: Math.random() * 2 - 1,
z: Math.random() * 2 - 1,
}
}
// setParticleMoving(index, position, direction, speed, force) {
// this.updateArrayFromVector(this.particlePosition, index, position)
// this.updateArrayFromVector(this.particleDirection, index, direction)
// this.particleSpeed[index] = speed
// this.particleForce[index] = force
// }
createParticle(groupID, settings) {
const defaults = {
color: { r: 1, g: 1, b: 1 },
position: { x: 0, y: 0, z: 0 },
direction: { x: 0, y: 0, z: 0 },
speed: 0,
speedDecay: 0.6,
force: 0,
forceDecay: 0.1,
life: 0,
lifeDecay: 0.6,
scale: 0.1,
style: PARTICLE_STYLES.soft,
acceleration: 0.1,
casted: false,
}
const particleSettings = {
...defaults,
...settings,
}
const group = this.particleGroups[groupID]
if (particleSettings.casted) this.castParticles.push(group.nextParticle)
const {
position,
direction,
color,
speed,
speedDecay,
force,
forceDecay,
life,
lifeDecay,
size,
type,
acceleration,
} = group.properties
this.updateArrayFromVector(position, group.nextParticle, particleSettings.position)
this.updateArrayFromVector(direction, group.nextParticle, particleSettings.direction)
this.updateArrayFromVector(color, group.nextParticle, particleSettings.color)
speed[group.nextParticle] = particleSettings.speed
speedDecay[group.nextParticle] = particleSettings.speedDecay
force[group.nextParticle] = particleSettings.force
forceDecay[group.nextParticle] = particleSettings.forceDecay
life[group.nextParticle] = particleSettings.life
lifeDecay[group.nextParticle] = particleSettings.lifeDecay
size[group.nextParticle] = particleSettings.scale
type[group.nextParticle] = particleSettings.style
acceleration[group.nextParticle] = particleSettings.acceleration
const createdParticleIndex = group.nextParticle
group.nextParticle++
group.newParticles = true
if (group.nextParticle >= group.count) group.nextParticle = 0
return createdParticleIndex
}
getParticles(groupID) {
const group = this.particleGroups[groupID]
if (!group) return null
return group.mesh
}
getParticlesProperties(groupID, prop) {
const group = this.particleGroups[groupID]
if (!group) return null
return group.properties[prop]
}
setParticleProperty(group, index, property, value) {
const properies = this.particleGroups[group].properties
const vectors = ["position", "direction", "color"]
if (vectors.indexOf(property) >= 0) this.updateArrayFromVector(properies[property], index, value)
else properies[property][index] = value
}
get grid() {
return { ...this.settings.size, points: this._grid.length / 3 }
}
get size() {
return this._size
}
get particleMeshes() {
return this.particleGroupsArray.map((group) => group.mesh)
}
// get particles() {
// return this._particles
// }
}
// ROOM
class Room {
constructor() {
this.group = new Group()
this.group.scale.set(0.7, 0.72, 0.7)
this.group.position.set(0, -0.03, -0.12)
this.paused = false
this.uniforms = []
this.items = {
"trapdoor-door": null,
"door-right": null,
"sub-floor": null,
bookshelf: null,
}
this.afterCompile = null
this.allItems = []
this.vortexItems = []
const room = ASSETS.getModel("room")
this.group.add(room.group)
this.scene = room.scene
this.skirt = new Mesh(new PlaneGeometry(2, 1), new MeshBasicMaterial({ color: new Color("#000000") }))
this.skirt.position.set(0, -0.77, 0.9)
this.group.add(this.skirt)
const vortexGeometry = new ConeGeometry(0.7, 1, 100, 1, true, Math.PI)
this.vortexMaterial = new ShaderMaterial({
vertexShader: `
uniform float uSize;
uniform float uTime;
varying vec2 vUv;
varying vec3 vNormal;
void main()
{
vUv = uv;
vNormal = normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: FragmentShader(`
uniform float uTime;
varying vec2 vUv;
varying vec3 vNormal;
#include noise
void main() {
// float fade = smoothstep(0.6, 1.0, 1.0 - vUv.y) ;
float fade = vUv.y ;
float noise = snoise(vec2(vUv.x + vUv.y + uTime * 0.2, vUv.y - uTime * 0.5) * 6.0) ;
noise = 0.2 + smoothstep(0.0, 2.0, noise + 1.0) * 0.8;
float fineNoise = snoise(vec2(vUv.x, vUv.y * uTime)) ;
gl_FragColor = vec4(vec3(fineNoise * 0.8, 1.0, fineNoise * 0.8) * noise * fade, 1.0);
}
`),
side: DoubleSide,
uniforms: {
uTime: { value: 0 },
},
// depthTest: false,
// depthWrite: false,
})
this.vortex = new Mesh(vortexGeometry, this.vortexMaterial)
this.vortex.position.set(0, -0.77, 0.165)
this.vortex.rotation.x = Math.PI
this.vortex.visible = false
this.group.add(this.vortex)
room.scene.traverse((item) => {
/*
We want the shader to compile before the game starts.
We take a bit of a hit on performance on zoomed in scenes
but most of the time the whole room is in view anyways.
*/
item.frustumCulled = false
if (this.items[item.name] !== undefined) {
const object = item
this.items[item.name] = {
object,
uniforms: {},
originalPosition: { x: object.position.x, y: object.position.y, z: object.position.z },
originalRotation: { x: object.rotation.x, y: object.rotation.y, z: object.rotation.z },
}
}
if (item.type === "Mesh") {
this.allItems.push(item)
item.home = {
position: item.position.clone(),
rotation: item.rotation.clone(),
scale: item.scale.clone(),
}
const itemWorldPos = new Vector3()
item.getWorldPosition(itemWorldPos)
const distance = 0.3
const vortexable =
item.name !== "sub-floor" && Math.abs(itemWorldPos.x) < distance && Math.abs(itemWorldPos.z) < distance
if (vortexable) {
this.vortexItems.push({
worldPosition: itemWorldPos,
distance: Math.abs(itemWorldPos.x) + Math.abs(itemWorldPos.z),
item,
})
}
item.pointsUvs = true
// this.meshes.push(item)
item.material.defines.USE_UV = ""
item.material.onBeforeCompile = (shader) => {
const uniform = { value: 0 }
this.uniforms.push(uniform)
shader.uniforms.progress = uniform
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`
uniform float progress;
// varying vec2 vUv;
#include <common>
`
)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <output_fragment>",
`#include <output_fragment>
// float noise = snoise(vUv);
vec3 blackout = mix(vec3(0.0), gl_FragColor.rgb, progress);
gl_FragColor = vec4(blackout, gl_FragColor.a);
`
)
if (this.afterCompile) {
this.afterCompile()
this.afterCompile = null
}
}
}
})
}
show(amount = 1) {
const duration = 2.5
this.scene.visible = true
gsap.killTweensOf(this.uniforms)
gsap.to(this.uniforms, {
value: amount,
ease: "power1.in",
duration,
})
}
hide(instant = false) {
console.log("HIDE ROOM")
const duration = instant ? 0 : 1.5
gsap.killTweensOf(this.uniforms)
gsap.to(this.uniforms, {
value: 0.0,
duration,
onComplete: () => {
this.scene.visible = false
},
})
}
showVortex(cb) {
const duration = 1
this.vortexMaterial.uniforms.uTime.value = 0
this.vortex.visible = true
gsap.fromTo(this.vortex.scale, { y: 0.4 }, { y: 1, duration, delay: 0.5 })
const getRandomAngle = () => Math.random() * (Math.PI * 0.5) - Math.PI * 0.25
SOUNDS.play("portal")
setTimeout(() => {
SOUNDS.play("crumble")
}, 300)
// gsap.fromTo(
// this.vortexPlaneMaterial.uniforms.uProgress,
// { value: 0 },
// { delay: 0.5, value: 1, duration: duration * 5 }
// )
// gsap.to(this.vortexPlane.rotation, { delay: 0.5, z: "+=2", duration: duration * 5, ease: "none" })
// const getRandomAngle = () => 0.2
this.items["sub-floor"].object.visible = false
for (let i = 0; i < this.vortexItems.length; i++) {
const obj = this.vortexItems[i]
gsap.to(obj.item.position, {
x: "*= 1.5",
z: "-=0.5",
y: obj.item.name === "pedestal" ? "-=5" : "-=0",
delay: obj.distance * 1.2,
duration,
ease: "power4.in",
})
// gsap.to(obj.item.rotation, {
// z: getRandomAngle(),
// delay: obj.distance * 1.5,
// duration,
// ease: "power3.in",
// })
gsap.to(obj.item.scale, {
x: 0,
y: 0,
z: 0,
delay: obj.distance * 1.5,
duration,
ease: "power3.in",
})
}
gsap.delayedCall(duration * 2, () => {
if (cb) cb()
})
}
hideVortex(cb) {
const duration = 0.6
let longestDelay = 0
SOUNDS.play("reform")
for (let i = 0; i < this.vortexItems.length; i++) {
const obj = this.vortexItems[i]
const delay = Math.max(0, 0.4 - obj.distance * 0.5)
longestDelay = Math.max(longestDelay, delay)
const values = ["position", "rotation", "scale"]
values.forEach((type) => {
gsap.to(obj.item[type], {
x: obj.item.home[type].x,
y: obj.item.home[type].y,
z: obj.item.home[type].z,
delay,
duration,
ease: "power4.out",
})
})
}
gsap.delayedCall(duration + longestDelay, () => {
this.items["sub-floor"].object.visible = true
this.vortex.visible = false
if (cb) cb()
})
}
trapdoorEnter = () => {
const tl = gsap.timeline()
const item = this.items["trapdoor-door"]
tl.to(item.object.rotation, { x: item.originalRotation.x - Math.PI * 0.5, ease: "power2.out", duration: 0.4 })
tl.to(item.object.rotation, {
onStart: () => {
setTimeout(() => SOUNDS.play("trapdoor-close"), 300)
},
x: item.originalRotation.x,
ease: "bounce",
duration: 0.9,
})
}
doorEnter = () => {
const tl = gsap.timeline()
const item = this.items["door-right"]
tl.to(item.object.rotation, { z: item.originalRotation.z + Math.PI * 0.7, ease: "none", duration: 0.3 })
tl.to(item.object.rotation, { z: item.originalRotation.z, ease: "elastic", duration: 2.5 })
}
add(item) {
this.group.add(item)
}
pause() {
this.paused = true
this.skirt.visible = false
}
resume() {
this.paused = false
this.skirt.visible = true
}
tick(delta, elapsedTime) {
// console.log("tick")
this.vortexMaterial.uniforms.uTime.value += delta
}
}
// SCREENS
class Screens {
constructor(appElement, machine) {
this.body = document.body
this.screensElement = this.body.querySelector(".screens")
this.spellsInfoElement = document.querySelector(".spells")
this.appElement = appElement
this.machine = machine
this.state = null
this.spellCornerScreens = ["SETUP_GAME", "GAME_RUNNING", "ENDLESS_MODE", "SPECIAL_SPELL", "ENDLESS_SPECIAL_SPELL"]
this.spellDetailScreens = ["INSTRUCTIONS_SPELLS", "SPELL_OVERLAY", "ENDLESS_SPELL_OVERLAY"]
this.setupButtons()
}
setupButtons() {
const buttons = [...this.appElement.querySelectorAll("[data-send]")]
buttons.forEach((button) => {
if (button.dataset.send) {
button.addEventListener("click", () => this.machine.send(button.dataset.send))
}
})
}
update(newState) {
this.state = newState
this.appElement.dataset.state = this.state
let delay = 1
let screen = this.screensElement.querySelector(`[data-screen="${this.state}"]`)
if (screen) {
console.log("screen", screen)
const fades = screen.querySelectorAll("[data-fade]")
gsap.fromTo(
fades,
{ opacity: 0, y: 30 },
{ opacity: 1, y: 0, delay, duration: 1, stagger: 0.1, ease: "power2.out" }
)
}
const state = Flip.getState("[data-flip-spell]")
this.spellsInfoElement.classList[this.spellCornerScreens.includes(this.state) ? "add" : "remove"]("corner")
this.spellsInfoElement.classList[this.spellDetailScreens.includes(this.state) ? "add" : "remove"]("full")
const flipDelay = this.state === "INSTRUCTIONS_SPELLS" ? 1.5 : 0.6
Flip.from(state, {
duration: 0.8,
ease: "power2.inOut",
onEnter: (elements) =>
gsap.fromTo(
elements,
{ opacity: 0, y: 30 },
{ duration: 1, y: 0, delay: flipDelay, stagger: 0.1, opacity: 1, ease: "power2.out" }
),
onLeave: (elements) => gsap.fromTo(elements, { opacity: 1 }, { opacity: 0 }),
// absolute: true,
})
}
}
// SIM VIZ
class SimViz {
constructor(stage, sim, showDirection = true, showNoise = true) {
this.stage = stage
this.sim = sim
this.container = new Group()
this.stage.add(this.container)
const box = new Box3()
box.setFromCenterAndSize(new Vector3(0, 0, 0), this.sim.size)
const helper = new Box3Helper(box, 0xffffff)
this.container.add(helper)
let x = 0
let y = 0
let z = 0
this.directionArrows = []
this.noiseArrows = []
const offset = this.sim.offset
for (let i = 0; i < this.sim.grid.points; i++) {
const gridSpace = new Vector3(x, y, z)
const dir = this.sim.getGridSpaceDirection(gridSpace)
// dir.normalize();
const origin = new Vector3(
(x / this.sim.grid.x) * this.sim.size.x - this.sim.size.x * 0.5,
(y / this.sim.grid.y) * this.sim.size.y - this.sim.size.y * 0.5,
(z / this.sim.grid.z) * this.sim.size.z - this.sim.size.z * 0.5
)
origin.add(offset)
const length = 0.05
if (showDirection) {
const directionArrowHelper = new ArrowHelper(dir, origin, length, 0xffff00, 0.02, 0.01)
this.directionArrows.push({ helper: directionArrowHelper, gridSpace })
this.container.add(directionArrowHelper)
}
if (showNoise) {
const noiseArrowHelper = new ArrowHelper(dir, origin, length, 0xff0000, 0.02, 0.01)
this.noiseArrows.push({ helper: noiseArrowHelper, gridSpace })
this.container.add(noiseArrowHelper)
}
x++
if (x >= this.sim.grid.x) {
x = 0
y++
if (y >= this.sim.grid.y) {
y = 0
z++
}
}
}
// this.stage.addTickFunction(() => this.tick())
}
tick() {
for (const arrow of this.directionArrows) {
// console.log('arrow', arrow)
const direction = this.sim.getGridSpaceDirection(arrow.gridSpace)
arrow.helper.setDirection(vector.normalize(direction))
arrow.helper.setLength(Math.max(0.01, vector.length(direction) * 0.1))
}
for (const arrow of this.noiseArrows) {
// console.log('arrow', arrow)
arrow.helper.setDirection(this.sim.getGridSpaceDirection(arrow.gridSpace, "noise"))
}
}
}
// SPELL CASTER
class SpellCaster {
constructor(sim, container, stage, DOMElement, onSpellSuccess, onSpellFail) {
this.machine = interpret(CasterMachine)
this.state = this.machine.initialState.value
this.sim = sim
this.container = container
this.stage = stage
this.successCallback = onSpellSuccess
this.failCallback = onSpellFail
this.DOMElement = DOMElement
this.currentTouchId = null
this.pathElement = document.querySelector("#spell-path")
this.pathPointsGroup = document.querySelector("#spell-points")
this.spellsInfoElement = document.querySelector(".spells")
this.chargingNotification = document.querySelector(".charging-notification")
this.chargingNotificationSpellName = this.chargingNotification.querySelector(".charging-spell")
this.rechargeNotificationTimeout = null
this.noRecharge = false
this.spellStates = {
arcane: {
charge: 0,
rechargeRate: 0.25,
svg: this.spellsInfoElement.querySelector("#spell-svg-viz-arcane"),
path: this.spellsInfoElement.querySelector("#spell-path-viz-arcane"),
},
fire: {
charge: 0,
rechargeRate: 0.09,
svg: this.spellsInfoElement.querySelector("#spell-svg-viz-fire"),
path: this.spellsInfoElement.querySelector("#spell-path-viz-fire"),
},
vortex: {
charge: 0,
rechargeRate: 0.05,
svg: this.spellsInfoElement.querySelector("#spell-svg-viz-vortex"),
path: this.spellsInfoElement.querySelector("#spell-path-viz-vortex"),
},
}
this.spellNames = Object.keys(this.spellStates)
this.allowed = this.spellNames
if (window.DEBUG.casting) {
document.querySelector("#spell-stats").style.display = "block"
document.querySelector("#spell-helper").style.display = "block"
}
this.spellPath = []
this.spells = []
this.emitter = new CastEmitter(sim)
this.raycaster = new Raycaster()
this.DOMElementSize = { width: 0, height: 0 }
this.emitPoint = { x: 0, y: 0, z: 0 }
this.touchOffset = { x: 0, y: 0 }
this.init()
}
init() {
this.clearSpell()
this.machine.onTransition((s) => this.onStateChange(s))
this.machine.start()
this.pointLight = new PointLight(new Color("#ffffff"), 0, 1.2)
this.pointLight.castShadow = true
this.container.add(this.pointLight)
this.pointLight.position.x = 0.5
this.pointLight.position.y = 0.5
this.pointLight.position.z = 1
this.hitPlane = new Mesh(
new PlaneGeometry(this.sim.size.x, this.sim.size.y),
new MeshBasicMaterial({
color: 0x248f24,
alphaTest: 0,
wireframe: true,
visible: window.DEBUG.casting,
})
)
this.hitPlane.position.set(this.sim.size.x * 0.5, this.sim.size.y * 0.5, this.sim.size.z * 0.95)
this.spellPlane = new Mesh(
new PlaneGeometry(1, 1),
new ShaderMaterial({
// depthWrite: false,
transparent: true,
vertexShader: `
uniform float uTime;
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: FragmentShader(`
#define PI 3.141592
#define PI_2 6.283185
uniform sampler2D uTexture;
uniform float uTime;
uniform float uProgress;
uniform vec3 uColor;
uniform float uSeed;
varying vec2 vUv;
float noiseSize = 30.0 ;
float fadeLength = 3.0;
#include noise
float swipe(vec2 uv, float progress, float direction) {
float x = ((PI_2 + (fadeLength * 2.0)) * progress) - fadeLength;
float angle = (PI - atan(uv.y - 0.5, uv.x - 0.5));
return smoothstep(x + fadeLength, x - fadeLength, angle * direction) * 0.5;
}
vec2 rotatedUV(float angle, vec2 uv) {
mat2 rotationMatrix = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
return rotationMatrix * uv;
}
vec4 sampleRotatedTexture(float angle, vec2 texCoord)
{
// Translate texture coordinates to center
vec2 centeredTexCoord = texCoord - vec2(0.5);
// Create a 2x2 rotation matrix
mat2 rotationMatrix = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
// Apply rotation to centered texture coordinates
vec2 rotatedTexCoord = rotatedUV(angle, centeredTexCoord);
// Translate texture coordinates back to original position
rotatedTexCoord += vec2(0.5);
// Sample the texture
return texture(uTexture, rotatedTexCoord);
}
void main()
{
if(uProgress >= 1.0) discard;
// float a = noise * shape.r;
float left = uProgress * 1.0;
float right = -uProgress * 0.5;
float leftNoise = step((snoise((vUv.xy + uSeed )* noiseSize) + 1.0) / 2.0, smoothstep(0.0, 0.9, uProgress));
float rightNoise = step((snoise((vUv.xy + uSeed )* noiseSize) + 1.0) / 2.0, smoothstep(0.3, 0.9, uProgress));
// Sample the texture twice with different rotations
vec4 leftTex = sampleRotatedTexture(left, vUv);
vec4 rightTex = texture(uTexture, vUv);
float fade = 1.0 - smoothstep(0.7, 1.0, uProgress);
fade *= smoothstep(0.0, 0.1, uProgress);
float red = leftTex.r * leftNoise; //tex.r;
float green = rightTex.g * rightNoise;
float blue = rightTex.b;
float alpha = min(1.0, red + green);
vec3 color = mix(vec3(1.0), uColor, smoothstep(0.5, 0.7, uProgress));
gl_FragColor = vec4(color * alpha, alpha * fade);
// gl_FragColor = vec4(vec3(rightNoise, 0.0 ,0.0), 1.0);
}
`),
// blending: CustomBlending,
// blendDstAlpha: OneFactor,
// blendSrcAlpha: ZeroFactor,
uniforms: {
uSeed: { value: Math.random() },
uColor: { value: new Color("#E1BBFF") },
uTime: { value: 0 },
uProgress: { value: 0 },
uTexture: { value: ASSETS.getTexture("spell-arcane") },
},
})
)
this.spellPlane.position.set(this.sim.size.x * 0.5, this.sim.size.y * 0.5, this.sim.size.z * 0.93)
this.spellPlane.visible = false
this.container.add(this.hitPlane)
this.container.add(this.spellPlane)
// setup viz
this.spellNames.forEach((spell) => {
const length = this.spellStates[spell].path.getTotalLength()
this.spellStates[spell].svg.style.setProperty("--length", length)
})
this.onResize()
this.setupSpells()
}
onResize() {
this.DOMElementSize = {
width: this.DOMElement.clientWidth,
height: this.DOMElement.clientHeight,
}
const bbox = this.DOMElement.getBoundingClientRect()
this.touchOffset.x = bbox.left
this.touchOffset.y = bbox.top
}
setupSpells() {
this.spells = [...document.querySelectorAll(".spell")].map((pathElement) => {
// const pathElement = group.querySelector("path")
// console.log(pathElement)
const pathString = pathElement.getAttribute("d")
const spellType = pathElement.dataset.spell
const spellID = pathElement.id
const group = document.querySelector(`[data-spell-shape="${spellID}"]`)
// const
const points = pathString.replace("M", "").split("L")
const path = this.getEvenlySpacedPoints(
points.map((p) => {
const arr = p.split(" ")
return { x: Number(arr[0]), y: Number(arr[1]) }
})
)
return {
scoreElement: group.querySelector(".score"),
groupElement: group,
type: spellType,
id: spellID,
path,
lengths: {
x: this.getPathLengths(path, "x"),
y: this.getPathLengths(path, "y"),
},
}
})
// console.log("spells = ", this.spells)
}
getLength(pointA, pointB) {
const deltaX = pointA.x - pointB.x
const deltaY = pointA.y - pointB.y
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
getPathLengths(path, type) {
//const checks = [path[0], path[path.length - 1]]
// const start = { x: 0, y: 0 }
const lengths = []
for (let i = 0; i < path.length; i++) {
const point = path[i]
lengths.push(this.getLength({ ...point, [type]: 0 }, point))
}
return lengths
}
clearSpell() {
this.spellPath = []
}
addSpellPathPoint(x, y, clearBoundingBox = false) {
this.spellPath.push({ x, y })
let mouse = new Vector2()
mouse.x = (x / this.DOMElementSize.width) * 2 - 1
mouse.y = -(y / this.DOMElementSize.height) * 2 + 1
this.raycaster.setFromCamera(mouse, this.stage.camera)
var intersects = this.raycaster.intersectObject(this.hitPlane)
if (intersects.length) {
const uv = intersects[0].uv
this.newPoint = {
x: uv.x,
y: uv.y,
z: this.hitPlane.position.z,
}
if (clearBoundingBox) this.resetBoundingBox(this.newPoint)
else this.addToBoundingBox(this.newPoint)
this.emitter.move(this.newPoint)
this.pointLight.position.x = this.newPoint.x * this.sim.size.x
this.pointLight.position.y = this.newPoint.y * this.sim.size.y
this.pointLight.position.z = this.newPoint.z * this.sim.size.z
}
}
animateSpellPlane() {
gsap.killTweensOf(this.spellPlane.material.uniforms.uProgress)
gsap.killTweensOf(this.spellPlane.scale)
this.spellPlane.visible = true
this.spellPlane.position.x = this.boundingBox.center.x * this.sim.size.x
this.spellPlane.position.y = this.boundingBox.center.y * this.sim.size.y
this.spellPlane.scale.x = this.boundingBox.scale.value
this.spellPlane.scale.y = this.boundingBox.scale.value
this.spellPlane.material.uniforms.uSeed.value = Math.random()
const duration = 2
gsap.fromTo(
this.spellPlane.material.uniforms.uProgress,
{ value: 0 },
{ duration, value: 1, onComplete: () => (this.spellPlane.visible = false) }
)
gsap.fromTo(
this.spellPlane.scale,
{ x: this.boundingBox.scale.value + 0.1, y: this.boundingBox.scale.value + 0.1 },
{ x: this.boundingBox.scale.value, y: this.boundingBox.scale.value, duration: duration * 0.9, ease: "power2.out" }
)
gsap.fromTo(
this.spellPlane.rotation,
{ z: (Math.random() > 0.5 ? -Math.PI : Math.PI) * 0.5 },
{ z: 0, duration: duration, ease: "power2.out" }
)
// gsap.fromTo(this.spellPlane.position, { z: 0.95 }, { z: 0.1, duration, ease: "power4.in" })
}
addToBoundingBox(point) {
const { topLeft, bottomRight, center, scale } = this.boundingBox
if (!point) point = { x: 0.5, y: 0.5 }
if(point.x === undefined) point.x = 0.5
if(point.y === undefined) point.y = 0.5
topLeft.x = Math.min(topLeft.x, point.x)
topLeft.y = Math.min(topLeft.y, point.y)
bottomRight.x = Math.max(bottomRight.x, point.x)
bottomRight.y = Math.max(bottomRight.y, point.y)
center.x = topLeft.x + (bottomRight.x - topLeft.x) * 0.5
center.y = topLeft.y + (bottomRight.y - topLeft.y) * 0.5
scale.value =
Math.max((bottomRight.x - topLeft.x) * this.sim.size.x, (bottomRight.y - topLeft.y) * this.sim.size.y) * 1.1
if (scale.value < 0.2) scale.value = 0.2
if (scale.value > 0.4) scale.value = 0.4
this.emitPoint = { x: center.x, y: center.y, z: this.hitPlane.position.z }
}
resetBoundingBox(firstPoint) {
console.log("Reseting bounding box", firstPoint)
if (!firstPoint) firstPoint = { x: 0.5, y: 0.5 }
if(firstPoint.x === undefined) firstPoint.x = 0.5
if(firstPoint.y === undefined) firstPoint.y = 0.5
this.boundingBox = {
topLeft: { x: firstPoint.x, y: firstPoint.y },
bottomRight: { x: firstPoint.x, y: firstPoint.y },
center: { x: firstPoint.x, y: firstPoint.y },
scale: { value: 0.25 },
}
}
setDownListeners(type) {
this.DOMElement[type + "EventListener"]("mousedown", this.onMouseDown)
this.DOMElement[type + "EventListener"]("touchstart", this.onTouchStart)
}
setMoveListeners(type) {
this.DOMElement[type + "EventListener"]("mousemove", this.onMouseMove)
this.DOMElement[type + "EventListener"]("touchmove", this.onTouchMove)
}
setUpListeners(type) {
this.DOMElement[type + "EventListener"]("mouseup", this.onMouseUp)
this.DOMElement[type + "EventListener"]("touchend", this.onTouchEndOrCancel)
this.DOMElement[type + "EventListener"]("touchcancel", this.onTouchEndOrCancel)
}
onStateChange = (state) => {
this.lastState = this.state
this.state = state.value
if (state.changed || this.state === "IDLE") {
switch (this.state) {
case "IDLE":
this.machine.send("ready")
break
case "ACTIVE":
this.clearSpell()
this.setDownListeners("add")
break
case "SUCCESS":
this.successCallback(this.castSpell.type)
this.machine.send("complete")
break
case "FAIL":
this.failCallback()
this.machine.send("complete")
break
case "CASTING":
this.castSpell = null
this.clearInput()
this.setDownListeners("remove")
this.setMoveListeners("add")
this.setUpListeners("add")
break
case "PROCESSING":
this.setMoveListeners("remove")
this.setUpListeners("remove")
this.proccessPath()
break
case "INACTIVE":
this.setDownListeners("remove")
this.setMoveListeners("remove")
this.setUpListeners("remove")
this.castSpell = null
this.clearInput()
if (this.lastState === "CASTING") {
this.clearSpell()
this.onCastFail()
}
}
}
}
drawInputPath() {
if (window.DEBUG.casting) {
this.pathElement.setAttribute(
"d",
`M${this.spellPath[0].x} ${this.spellPath[0].y}L${this.spellPath
.map((point) => `${point.x} ${point.y}`)
.join("L")}`
)
}
}
clearInput() {
if (window.DEBUG.casting) {
this.pathElement.setAttribute("d", "")
this.pathPointsGroup.innerHTML = ""
}
}
drawInputPoints(points) {
if (window.DEBUG.casting) {
points.forEach((point) => {
var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle")
circle.setAttributeNS(null, "cx", point.x)
circle.setAttributeNS(null, "cy", point.y)
circle.setAttributeNS(null, "r", 2)
this.pathPointsGroup.appendChild(circle)
})
}
}
proccessPath() {
this.drawInputPath()
const points = this.getEvenlySpacedPoints(this.spellPath)
this.drawInputPoints(points)
const checks = {
x: this.getPathLengths(points, "x"),
y: this.getPathLengths(points, "y"),
}
// console.log("checks", checks)
const results = this.spells.map((spell, i) => {
const result = {
x: this.getCorrelation(checks.x, spell.lengths.x),
y: this.getCorrelation(checks.y, spell.lengths.y),
}
const score = (result.x + result.y) / 2
return { type: spell.type, spell, score, index: i, result }
})
const winner = results.reduce(
(currentWinner, contender) => {
if (contender.score <= 1 && contender.score >= 0.8 && contender.score > currentWinner.score) return contender
return currentWinner
},
{ score: 0, type: null, index: -1 }
)
if (winner.type) {
if ((this.spellStates[winner.type].charge === 1 || this.noRecharge) && this.allowed.includes(winner.type)) {
this.onCastSuccess(winner)
} else {
this.onInsufficientPower(winner)
}
} else this.onCastFail()
this.outputResults(results, winner)
this.emitter.reset()
}
getSpellColor(type) {
switch (type) {
case "arcane":
return { r: 0.2, g: 0, b: 1 }
case "fire":
return { r: 1, g: 0.8, b: 0 }
case "vortex":
default:
return { r: 0, g: 1, b: 0 }
}
}
onCastSuccess(spell) {
this.castSpell = spell
this.spellStates[spell.type].charge = this.noRecharge ? 1 : 0
this.machine.send("success")
SOUNDS.play("cast")
// SOUNDS.play("ping")
// if (this.castSpell.type === "arcane") setTimeout(() => this.animateSpellPlane(), 300)
this.sim.castParticles.forEach((index) => {
const point = {
index,
life: 0.5,
...this.sim.getVectorFromArray(this.sim.getParticlesProperties("magic", "position"), index),
}
const newPointType = Math.random() > 0.5 ? PARTICLE_STYLES.circle : PARTICLE_STYLES.point
const spark = newPointType === PARTICLE_STYLES.point
setTimeout(
() => (this.sim.getParticlesProperties("magic", "type")[index] = newPointType),
Math.random() * (spark ? 10 : 100)
)
this.sim.updateArrayFromVector(
this.sim.getParticlesProperties("magic", "color"),
index,
spark ? { r: 1, g: 1, b: 1 } : this.getSpellColor(spell.type)
)
gsap.to(point, {
// life: 0.2,
motionPath: [
{
x: this.emitPoint.x,
y: point.y,
z: 0.9,
},
{
x: this.emitPoint.x + Math.random() * 0.1 - 0.05,
y: point.y + Math.random() * 0.1 - 0.05,
z: 0.9,
},
!spark
? this.emitPoint
: {
x: this.emitPoint.x + Math.random() * 0.4 - 0.2,
y: point.y + Math.random() * 0.4 - 0.2,
z: 0.9 - Math.random() * 0.2,
},
],
ease: !spark ? "power4.in" : "power1.out",
duration: (spark ? 2 : 0.9) + Math.random() * 0.05,
life: spark ? 0 : 0.3,
onUpdateParams: [point],
onUpdate: (d) => {
this.sim.getParticlesProperties("magic", "life")[d.index] = d.life
this.sim.updateArrayFromVector(this.sim.getParticlesProperties("magic", "position"), d.index, {
x: d.x,
y: d.y,
z: d.z,
})
},
onCompleteParams: [point],
onComplete: (d) => {
this.sim.getParticlesProperties("magic", "life")[d.index] = 0
},
})
})
this.sim.castParticles = []
}
onInsufficientPower(spell) {
this.machine.send("fail")
this.castSpell = spell
const newPointType = PARTICLE_STYLES.circle
this.sim.castParticles.forEach((index) => {
this.sim.updateArrayFromVector(this.sim.getParticlesProperties("magic", "color"), index, { r: 1, g: 0, b: 0 })
})
setTimeout(() => {
while (this.sim.castParticles.length) {
const particleIndex = this.sim.castParticles.shift()
this.sim.getParticlesProperties("magic", "lifeDecay")[particleIndex] = 0.4
this.sim.getParticlesProperties("magic", "force")[particleIndex] = 0
this.sim.getParticlesProperties("magic", "forceDecay")[particleIndex] = 0.2
}
}, 500)
if (this.rechargeNotificationTimeout) clearTimeout(this.rechargeNotificationTimeout)
this.rechargeNotificationTimeout = setTimeout(() => {
this.chargingNotification.classList.remove("show")
}, 2000)
this.chargingNotificationSpellName.innerText = SPELLS[spell.type]
this.chargingNotification.classList.add("show")
}
onCastFail() {
this.castSpell = null
this.machine.send("fail")
SOUNDS.play("spell-failed")
while (this.sim.castParticles.length) {
const particleIndex = this.sim.castParticles.shift()
this.sim.getParticlesProperties("magic", "lifeDecay")[particleIndex] = 0.4
// this.sim.updateArrayFromVector(this.sim.particleDirection, particleIndex, { x: 0, y: -1, z: 0 })
this.sim.getParticlesProperties("magic", "force")[particleIndex] = 0
this.sim.getParticlesProperties("magic", "forceDecay")[particleIndex] = 0.2
// this.sim.particleSpeed[particleIndex] = 0.1
const point = {
index: particleIndex,
speed: 0.015,
}
gsap.to(point, {
// life: 0.2,
speed: 0.15,
ease: "power2.in",
duration: 0.3,
onUpdateParams: [point],
onUpdate: (d) => {
this.sim.getParticlesProperties("magic", "speed")[d.index] = d.speed
// this.sim.updateArrayFromVector(this.sim.particlePosition, d.index, { x: d.x, y: d.y, z: d.z })
},
})
}
}
outputResults(results, winner) {
if (window.DEBUG.casting) {
this.spells.forEach((spell, i) => {
spell.groupElement.classList[i === winner.index ? "add" : "remove"]("cast")
})
results.forEach((result) => {
result.spell.scoreElement.innerText = result.score
})
}
}
getCorrelation(x, y) {
var shortestArrayLength = 0
if (x.length == y.length) {
shortestArrayLength = x.length
} else if (x.length > y.length) {
shortestArrayLength = y.length
// console.error("x has more items in it, the last " + (x.length - shortestArrayLength) + " item(s) will be ignored")
} else {
shortestArrayLength = x.length
// console.error("y has more items in it, the last " + (y.length - shortestArrayLength) + " item(s) will be ignored")
}
var xy = []
var x2 = []
var y2 = []
for (var i = 0; i < shortestArrayLength; i++) {
xy.push(x[i] * y[i])
x2.push(x[i] * x[i])
y2.push(y[i] * y[i])
}
var sum_x = 0
var sum_y = 0
var sum_xy = 0
var sum_x2 = 0
var sum_y2 = 0
for (var i = 0; i < shortestArrayLength; i++) {
sum_x += x[i]
sum_y += y[i]
sum_xy += xy[i]
sum_x2 += x2[i]
sum_y2 += y2[i]
}
var step1 = shortestArrayLength * sum_xy - sum_x * sum_y
var step2 = shortestArrayLength * sum_x2 - sum_x * sum_x
var step3 = shortestArrayLength * sum_y2 - sum_y * sum_y
var step4 = Math.sqrt(step2 * step3)
var answer = step1 / step4
return answer
}
getEvenlySpacedPoints(path, numPoints = 100) {
const totalLength = path.reduce((length, point, index) => {
if (index > 0) {
const prevPoint = path[index - 1]
const deltaX = point.x - prevPoint.x
const deltaY = point.y - prevPoint.y
length += Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
return length
}, 0)
// console.log("length:", totalLength)
const segmentLength = totalLength / (numPoints - 1)
let currentLength = 0
let currentPointIndex = 0
const evenlySpacedPoints = [path[0]]
let lastPoint = null
for (let i = 1; i < numPoints - 1; i++) {
const targetLength = i * segmentLength
while (currentLength < targetLength) {
const startPoint = lastPoint ? lastPoint : path[currentPointIndex]
const endPoint = path[currentPointIndex + 1]
const deltaX = endPoint.x - startPoint.x
const deltaY = endPoint.y - startPoint.y
const segmentLength = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (currentLength + segmentLength >= targetLength) {
const t = (targetLength - currentLength) / segmentLength
lastPoint = {
x: startPoint.x + t * deltaX,
y: startPoint.y + t * deltaY,
}
evenlySpacedPoints.push(lastPoint)
currentLength = targetLength
} else {
currentLength += segmentLength
lastPoint = null
currentPointIndex++
}
}
}
evenlySpacedPoints.push(path[path.length - 1]) // Add the last point
return evenlySpacedPoints
}
activate(limit) {
this.allowed = limit ? [limit] : this.spellNames
this.machine.send("activate")
}
deactivate() {
this.machine.send("deactivate")
}
getXYFromTouch = (event) => {
const touches = event.changedTouches
for (let i = 0; i < touches.length; i++) {
const touch = touches[i]
if (!this.currentTouchId || this.currentTouchId === touch.identifier) {
this.currentTouchId = touch.identifier
return { x: touch.clientX - this.touchOffset.x, y: touch.clientY - this.touchOffset.y }
}
}
}
onTouchStart = (event) => {
const touchPoint = this.getXYFromTouch(event)
this.onSpellStart(touchPoint.x, touchPoint.y)
}
onMouseDown = (event) => {
this.onSpellStart(event.offsetX, event.offsetY)
}
onSpellStart = (x, y) => {
gsap.killTweensOf(this.pointLight)
this.pointLight.intensity = 0.6
// console.log(this.pointLight.intensity)
this.machine.send("start_cast")
this.addSpellPathPoint(x, y, true)
}
onTouchMove = (event) => {
const touchPoint = this.getXYFromTouch(event)
this.onSpellMove(touchPoint.x, touchPoint.y)
}
onMouseMove = (event) => {
this.onSpellMove(event.offsetX, event.offsetY)
}
onSpellMove = (x, y) => {
this.addSpellPathPoint(x, y)
this.drawInputPath()
}
onTouchEndOrCancel = (event) => {
const touchPoint = this.getXYFromTouch(event)
this.currentTouchId = null
this.onSpellEnd(touchPoint.x, touchPoint.y)
}
onMouseUp = (event) => {
this.onSpellEnd(event.offsetX, event.offsetY)
}
onSpellEnd = (x, y) => {
gsap.to(this.pointLight, { intensity: 0 })
this.addSpellPathPoint(x, y)
this.machine.send("finished")
}
reset(disableCharging = false) {
this.spellNames.forEach((spell) => {
this.spellStates[spell].charge = disableCharging ? 1 : 0
})
this.noRecharge = disableCharging ? true : false
this.updateViz()
}
updateViz() {
this.spellNames.forEach((spell) => {
this.spellStates[spell].svg.style.setProperty("--charge", this.spellStates[spell].charge)
this.spellStates[spell].svg.classList[this.spellStates[spell].charge === 1 ? "add" : "remove"]("ready")
})
}
tick(delta) {
if (this.state !== "IDLE" && !this.noRecharge) {
this.spellNames.forEach((spell) => {
const state = this.spellStates[spell]
if (state.charge < 1) state.charge += state.rechargeRate * delta
if (state.charge > 1) state.charge = 1
this.updateViz()
})
}
}
}
// STAGE
class Stage {
constructor(mount) {
this.container = mount
this.scene = new Scene()
this.scene.background = new Color("#000000")
this.group = new Group()
this.scene.add(this.group)
this.paused = false
const overlayGeometry = new PlaneGeometry(2, 2, 1, 1)
this.overlayMaterial = new ShaderMaterial({
transparent: true,
vertexShader: `
void main()
{
vec3 p = vec3(position.x, position.y, -0.1);
gl_Position = vec4(p, 1.0);
}
`,
fragmentShader: `
uniform float uAlpha;
void main()
{
gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha);
}
`,
uniforms: {
uAlpha: { value: 1 },
},
})
this.overlay = new Mesh(overlayGeometry, this.overlayMaterial)
this.scene.add(this.overlay)
// this.gui = new GUI()
this.size = {
width: 1,
height: 1,
}
ColorManagement.enabled = false
this.cameraPositions = {
playing: { x: 0, y: 0.3, z: 1.3 },
overhead: { x: 0, y: 2, z: 0 },
paused: { x: 0, y: 0.6, z: 1.6 },
crystalOffset: { x: 0, y: 0.02, z: 0.2 },
crystalIntro: { x: 0, y: 0.02, z: 0.3 },
demon: { x: 0.05, y: -0.1, z: 0.3 },
crystal: { x: 0, y: 0.02, z: 0.3 },
bookshelf: { x: 0, y: 0, z: 0.2 },
spellLesson: { x: 0.1, y: 0.3, z: 1.2 },
vortex: { x: 0, y: 0.7, z: 1.3 },
win: { x: 0, y: 0.02, z: 0.3 },
}
this.cameraLookAts = {
playing: { x: 0, y: -0.12, z: 0 },
overhead: { x: 0, y: -0.12, z: 0 },
paused: { x: 0, y: -0.12, z: 0 },
crystalOffset: { x: 0.05, y: -0.065, z: 0 },
crystalIntro: { x: 0, y: -0.1, z: 0 },
demon: { x: -0.2, y: -0.1, z: -0.2 },
crystal: { x: 0, y: -0.065, z: 0 },
bookshelf: { x: -0.3, y: -0.07, z: -0.15 },
spellLesson: { x: 0.15, y: -0.12, z: 0 },
vortex: { x: 0, y: -0.12, z: 0 },
win: { x: 0, y: -0.1, z: 0 },
}
this.defaultCameraPosition = "crystalOffset"
this.setupCamera()
this.setupRenderer()
this.setupLights()
// this.setupRenderPasses()
this.setupOrbitControls()
this.onResize()
this.render()
}
setupRenderPasses() {
this.composer = new EffectComposer(this.renderer)
this.composer.setSize(this.size.width, this.size.height)
this.composer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
const renderPass = new RenderPass(this.scene, this.camera)
this.composer.addPass(renderPass)
const ssaoPass = new SSAOPass(this.scene, this.camera, this.size.width, this.size.height)
this.composer.addPass(ssaoPass)
this.gui
.add(ssaoPass, "output", {
Default: SSAOPass.OUTPUT.Default,
"SSAO Only": SSAOPass.OUTPUT.SSAO,
"SSAO Only + Blur": SSAOPass.OUTPUT.Blur,
Depth: SSAOPass.OUTPUT.Depth,
Normal: SSAOPass.OUTPUT.Normal,
})
.onChange(function (value) {
ssaoPass.output = value
})
this.gui.add(ssaoPass, "kernelRadius").min(0).max(32)
this.gui.add(ssaoPass, "minDistance").min(0.001).max(0.02)
this.gui.add(ssaoPass, "maxDistance").min(0.01).max(0.3)
this.gui.add(ssaoPass, "enabled")
}
setupCamera() {
const lookat = this.cameraLookAts[this.defaultCameraPosition]
this.lookAt = new Vector3(lookat.x, lookat.y, lookat.z)
this.camera = new PerspectiveCamera(35, this.size.width / this.size.height, 0.1, 3)
this.camera.position.set(
this.cameraPositions[this.defaultCameraPosition].x,
this.cameraPositions[this.defaultCameraPosition].y,
this.cameraPositions[this.defaultCameraPosition].z
)
this.camera.home = {
position: { ...this.camera.position },
}
this.scene.add(this.camera)
}
reveal() {
gsap.to(this.overlayMaterial.uniforms.uAlpha, {
value: 0,
duration: 2,
onComplete: () => {
this.overlay.visible = false
},
})
}
setupOrbitControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true
this.controls.enabled = false
// this.controls.
}
moveCamera(state, cb) {
if (this.cameraPositions[state] && this.cameraLookAts[state]) {
gsap.killTweensOf(this.camera.position)
gsap.killTweensOf(this.lookAt)
gsap.to(this.camera.position, {
...this.cameraPositions[state],
duration: 2,
ease: "power2.inOut",
onComplete: () => {
if (cb) cb()
},
})
gsap.to(this.lookAt, { ...this.cameraLookAts[state], duration: 2, ease: "power2.inOut" })
}
}
resetCamera() {
this.moveCamera(this.defaultCameraPosition)
}
setupRenderer() {
this.renderer = new WebGLRenderer({
canvas: this.canvas,
antialias: true,
})
this.renderer.outputColorSpace = LinearSRGBColorSpace
this.renderer.toneMapping = ReinhardToneMapping
this.renderer.toneMappingExposure = 8
this.container.appendChild(this.renderer.domElement)
}
setupLights() {
this.scene.add(new AmbientLight(0xffffff, 0.1))
const light = new DirectionalLight(0xfcc088, 0.1)
light.position.set(0, 3, -2)
this.scene.add(light)
}
onResize() {
this.size.width = this.container.clientWidth
this.size.height = this.container.clientHeight
this.camera.aspect = this.size.width / this.size.height
this.camera.updateProjectionMatrix()
this.renderer.setSize(this.size.width, this.size.height)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
if (this.composer) {
this.composer.setSize(this.size.width, this.size.height)
this.composer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}
}
compile() {
this.renderer.compile(this.scene, this.camera)
}
render() {
if (!this.paused || !window.DEBUG.allowLookAtMoveWhenPaused) {
this.camera.lookAt(this.lookAt)
this.controls.target.x = this.lookAt.x
this.controls.target.y = this.lookAt.y
this.controls.target.z = this.lookAt.z
}
this.controls.update()
if (this.composer) this.composer.render()
else this.renderer.render(this.scene, this.camera)
}
add(element) {
this.group.add(element)
}
destroy() {
this.container.removeChild(this.renderer.domElement)
window.removeEventListener("resize", this.onResize)
}
get everything() {
return this.group
}
set defaultCamera(state) {
console.log(state, this.cameraPositions[state])
if (this.cameraPositions[state]) {
this.defaultCameraPosition = state
this.resetCamera()
}
}
set useOrbitControls(enabled) {
this.controls.enabled = enabled
}
}
// TORCH
class Torch {
constructor(sim, position, noise) {
this.state = "OFF"
this.elapsedTime = 0
this._light = new TorchLight(position, sim.size, noise)
this.emitter = new TorchEmitter(position, sim)
// this.sceneObjects.add(torch.light)
}
on() {
if (this.state !== "ON") {
SOUNDS.play("torch")
this.state = "ON"
this._light.active = true
this._light.color = "#FA9638"
this.emitter.green = false
}
}
off() {
this.state = "OFF"
this._light.active = false
}
green() {
if (this.state !== "VORTEX") {
SOUNDS.play("torch")
this.state = "VORTEX"
this._light.active = true
this._light.color = "#00FF00"
this.emitter.green = true
this.emitter.flamePuff()
}
}
tick(delta, elapsedTime) {
if (this._light) this._light.tick(delta, this.elapsedTime)
if (this.state !== "OFF") {
this.elapsedTime += delta
if (this.emitter) this.emitter.tick(delta, this.elapsedTime)
}
}
get light() {
return this._light.object
}
}
// FRAGMENT SHADER UTIL
const includes = {
noise: `
//
// Description : Array and textureless GLSL 2D simplex noise function.
// Author : Ian McEwan, Ashima Arts.
// Maintainer : ijm
// Lastmod : 20110822 (ijm)
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
// Distributed under the MIT License. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
vec3 mod289(vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec2 mod289(vec2 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec3 permute(vec3 x) {
return mod289(((x*34.0)+1.0)*x);
}
float snoise(vec2 v)
{
const vec4 C = vec4(0.211324865405187, // (3.0-sqrt(3.0))/6.0
0.366025403784439, // 0.5*(sqrt(3.0)-1.0)
-0.577350269189626, // -1.0 + 2.0 * C.x
0.024390243902439); // 1.0 / 41.0
// First corner
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
// Other corners
vec2 i1;
//i1.x = step( x0.y, x0.x ); // x0.x > x0.y ? 1.0 : 0.0
//i1.y = 1.0 - i1.x;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
// x0 = x0 - 0.0 + 0.0 * C.xx ;
// x1 = x0 - i1 + 1.0 * C.xx ;
// x2 = x0 - 1.0 + 2.0 * C.xx ;
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
// Permutations
i = mod289(i); // Avoid truncation effects in permutation
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
// Gradients: 41 points uniformly over a line, mapped onto a diamond.
// The ring size 17*17 = 289 is close to a multiple of 41 (41*7 = 287)
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
// Normalise gradients implicitly by scaling m
// Approximation of: m *= inversesqrt( a0*a0 + h*h );
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
// Compute final noise value at P
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
`,
}
function FragmentShader(shader) {
const importTypes = Object.keys(includes)
importTypes.forEach((type) => {
shader = shader.replace(`#include ${type}`, includes[type])
})
return shader
}
// APP
class App {
constructor() {
this.stage = new Stage(DOM.canvas)
this.machine = interpret(AppMachine)
this.animations = []
this.frame = 0
this.elapsedGameTime = 0
this.health = 1
this.healthDecay = 0.01
this.healthReplenish = 0.015
this.rotating = false
this.noise = createNoise3D()
this.rotationSpeed = 0.2
this.gameSpeed = 1
this.endlessMode = false
if (window.DEBUG.endlessMode) {
document.querySelector("#endless-mode").style.display = "block"
}
if (window.DEBUG.appState) {
document.querySelector("#app-state").style.display = "block"
DOM.app.classList.add("showState")
}
if (window.DEBUG.layoutDebug) {
DOM.body.classList.add("debug-layout")
}
this.screens = new Screens(DOM.app, this.machine)
this.enemyState = { ...ENEMY_SETTINGS }
this.appState = this.machine.initialState.value
this.emitters = []
this.enemies = []
this.torches = []
this.init()
}
init() {
this.clock = new Clock()
this.clockWasPaused = false
this.machine.onTransition((s) => this.onStateChange(s))
this.machine.start()
document.body.addEventListener("keyup", (event) => {
console.log("KEYUP", event)
switch (event.key) {
case "p":
this.machine.send(this.isPaused ? "resume" : "pause")
break
case "d":
this.stage.defaultCamera = this.stage.defaultCameraPosition === "playing" ? "overhead" : "playing"
break
case "c":
DOM.body.classList.toggle("clear-interface")
break
}
})
const demonTotalElementa = [...document.querySelectorAll("[data-demon-total]")]
demonTotalElementa.forEach((el) => (el.innerText = ENEMY_SETTINGS.totalSend))
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.clockWasPaused = true
this.machine.send("pause")
}
})
this.onResize()
setTimeout(() => this.onResize(), 500)
window.addEventListener("resize", this.onResize)
this.setupStats()
this.tick()
}
onLocationRelease = (index) => {
if (this.freeLocations.indexOf(index) < 0) this.freeLocations.push(index)
}
onResize = () => {
this.stage.onResize()
if (this.spellCaster) this.spellCaster.onResize()
DOM.svg.setAttribute("width", DOM.body.offsetWidth)
DOM.svg.setAttribute("height", DOM.body.offsetHeight)
// this.
}
createScene = () => {
this.room = new Room()
this.sim = new ParticleSim({ pixelRatio: this.stage.renderer.getPixelRatio() })
if (window.DEBUG.simNoise || window.DEBUG.simFlow)
this.viz = new SimViz(this.stage, this.sim, window.DEBUG.simFlow, window.DEBUG.simNoise)
this.sceneObjects = new Group()
this.sceneObjects.position.add(this.sim.startCoords)
this.stage.add(this.sceneObjects)
this.sim.particleMeshes.forEach((mesh) => {
this.stage.add(mesh)
})
this.crystal = new Crystal(
this.sim,
() => this.machine.send("run"),
() => this.machine.send("end")
)
this.room.add(this.crystal.group)
// this.sim.particles.renderOrder = 1
this.entrances = [
new Entrance(
"door",
[
{ x: 0.7, y: 0.35, z: -0.1 },
{ x: 0.47, y: 0.3, z: 0.05 },
],
this.room.doorEnter
),
new Entrance("bookcase", [
{ x: -0.1, y: 0.4, z: 0.45 },
{ x: 0.05, y: 0.4, z: 0.53 },
]),
new Entrance("large-window", [
{ x: 1.01, y: 0.4, z: 0.5 },
{ x: 0.95, y: 0.5, z: 0.5 },
]),
new Entrance(
"trapdoor",
[
{ x: 0.83, y: -0.1, z: 0.2 },
{ x: 0.83, y: 0.3, z: 0.2 },
],
this.room.trapdoorEnter
),
]
if (window.DEBUG.entrances) this.entrances.forEach((e) => e.createDebugMarkers(this.sceneObjects, this.sim.size))
this.freeLocations = []
const locationColors = [0xff0000, 0x00ff00, 0x0000ff, 0xff00ff, 0xffff00]
this.enemyLocations = [
{ x: 0.25, y: 0, z: 0.3, r: Math.PI * 2 * 0.1 },
{ x: 0.8, y: 0, z: 0.4, r: Math.PI * 2 * 0.8 },
{ x: 0.6, y: 0, z: 0.2, r: Math.PI * 2 * 0.95 },
{ x: 0.2, y: 0, z: 0.7, r: Math.PI * 2 * 0.3 },
{ x: 0.81, y: 0, z: 0.68, r: Math.PI * 2 * 0.7 },
].map((d, i) => {
return new Location(d, this.sim.size, this.entrances, this.onLocationRelease, locationColors[i])
})
this.enemyLocations.forEach((location, i) => {
this.sceneObjects.add(location.group)
location.index = i
location.energyEmitter = new EnemyEnergyEmitter(this.sim, location.position)
this.freeLocations.push(i)
})
}
onStateChange = (state) => {
this.appState = state.value
if (state.changed || this.appState === "IDLE") {
// temporary state controls
console.log("NEW APP STATE:", this.appState)
DOM.controls.innerHTML = ""
if (this.winEmitter) this.winEmitter.active = false
this.screens.update(this.appState)
DOM.state.innerText = this.appState
state.nextEvents.forEach((event) => {
const button = document.createElement("BUTTON")
button.innerHTML = event
button.addEventListener("click", () => {
this.machine.send(event)
})
DOM.controls.appendChild(button)
})
switch (this.appState) {
case "IDLE":
this.machine.send("load")
break
case "LOADING":
ASSETS.load(
() => {
this.machine.send("complete")
},
(err) => {
this.machine.send("error")
}
)
break
case "INIT":
this.createScene()
SOUNDS.init(this.stage)
//add a little demo in the scene so it gets all loaded into memory
// const demon = ASSETS.getModel("demon")
// demon.scene.position.y = -0.2
// this.stage.add(demon.scene)
this.enemyPool = new EnemyPreloader(this.stage)
this.stage.add(this.room.group)
this.addEmitter(new DustEmitter(this.sim))
this.winEmitter = new WinEmitter(this.sim)
this.winEmitter.active = false
this.addEmitter(this.winEmitter)
this.spellLights = [0xffffff, 0xffffff, 0xffffff].map((color) => this.makePointLight(color))
this.spellLightsCount = -1
this.spellCaster = new SpellCaster(
this.sim,
this.sceneObjects,
this.stage,
DOM.canvas,
(spellID) => {
DOM.spellGuide.classList.remove("show")
console.log("casting spell ", spellID)
switch (spellID) {
case "arcane":
let arcaneEnemies = this.getEnemy(spellID, 1)
arcaneEnemies.forEach((enemy) => {
let spell = new ArcaneSpellEmitter(this.sim, this.spellLight, this.spellCaster.emitPoint, enemy)
this.addEmitter(spell)
})
break
case "fire":
let fireEnemies = this.getEnemy(spellID, 2)
fireEnemies.forEach((enemy) => {
let spell = new FireSpellEmitter(this.sim, this.spellLight, this.spellCaster.emitPoint, enemy)
this.addEmitter(spell)
})
break
case "vortex":
let spell = new VortexSpellEmitter(this.sim, this.spellLight, this.spellCaster.emitPoint)
this.machine.send("special")
this.addEmitter(spell)
break
}
},
() => {}
)
const torchPositions = [
{ x: 0.036, y: 0.45, z: 0.845 },
{ x: 0.14, y: 0.45, z: 0.035 },
{ x: 0.865, y: 0.45, z: 0.035 },
{ x: 0.952, y: 0.63, z: 0.632 },
]
this.torches = torchPositions.map((position) => {
const torch = new Torch(this.sim, position, this.noise)
this.sceneObjects.add(torch.light)
// const emitter = new torchEmitter(position, this.sim)
// this.addEmitter(emitter)
return torch
})
this.room.afterCompile = () => {
setTimeout(() => {
this.machine.send("begin")
}, 500)
}
break
case "TITLE_SCREEN":
this.stage.reveal()
this.staggerTorchesOff()
this.room.hide()
this.resetRotation()
this.stage.useOrbitControls = false
this.stage.moveCamera("crystalOffset")
break
case "SCENE_DEBUG":
this.resetRotation()
this.stage.moveCamera("playing")
this.stage.useOrbitControls = true
this.room.show()
this.staggerTorchesOn()
this.enemyState.sendCount = 0
break
case "SETUP_GAME":
this.startGame()
break
case "SETUP_ENDLESS":
this.startEndless()
break
case "INSTRUCTIONS_CRYSTAL":
// this.rotate()
this.resetRotation()
this.room.hide()
this.stage.moveCamera("crystalIntro")
// SOUNDS.prep()
SOUNDS.startMusic()
break
case "INSTRUCTIONS_DEMON":
this.resetLocations()
this.enemyState = { ...ENEMY_SETTINGS }
this.stage.moveCamera("demon")
// setTimeout(() => {
const demoDemon = this.addEnemy(this.enemyLocations[0], "arcane")
console.log("demoDemon:", demoDemon)
demoDemon.onDeadCallback = () => {
this.machine.send("next")
}
// }, 500)
// this.stage.moveCamera("crystal")
break
case "INSTRUCTIONS_CAST":
// this.staggerTorchesOn()
setTimeout(() => {
DOM.spellGuide.classList.add("show")
}, 500)
this.stage.moveCamera("spellLesson")
this.spellCaster.reset(true)
this.spellCaster.activate("arcane")
break
case "INSTRUCTIONS_SPELLS":
this.stage.moveCamera("playing")
this.spellCaster.deactivate()
this.room.show(0.2)
// this.stage.moveCamera("crystal")
break
case "GAME_RUNNING":
case "ENDLESS_MODE":
this.resumeGame()
break
case "PAUSED":
case "ENDLESS_PAUSE":
this.pauseGame()
break
case "SPECIAL_SPELL":
case "ENDLESS_SPECIAL_SPELL":
this.yayItsVortexTime()
break
case "SPELL_OVERLAY":
case "ENDLESS_SPELL_OVERLAY":
this.pauseGame()
break
case "CLEAR_GAME":
case "CLEAR_ENDLESS":
this.endGame()
this.machine.send("end")
break
case "GAME_OVER_ANIMATION":
this.endGame()
this.room.hide()
// this.rotate()
this.stage.moveCamera("crystal")
this.crystal.explode()
break
case "RESETTING_FOR_INSTRUCTIONS":
this.crystal.reset()
break
case "RESETTING_FOR_CREDITS":
this.crystal.reset()
break
case "GAME_OVER":
break
case "WIN_ANIMATION":
this.endGame()
this.room.hide()
this.machine.send("end")
this.rotate()
break
case "WINNER":
this.stage.moveCamera("win")
setTimeout(() => (this.winEmitter.active = true), 500)
break
case "CREDITS":
this.resetRotation()
this.room.show(0.2)
this.staggerTorchesOn()
this.stage.moveCamera("bookshelf")
SOUNDS.startMusic()
break
default:
break
}
}
}
yayItsVortexTime() {
this.spellCaster.deactivate()
this.torches.forEach((torch, i) => {
gsap.delayedCall(i * 0.1, () => torch.green())
})
gsap.delayedCall(1.3, () => {
this.stage.moveCamera("vortex")
this.enemies.forEach((enemy) => {
if (enemy && enemy.state === "ALIVE") {
enemy.getSuckedIntoTheAbyss()
}
})
this.room.showVortex(() => {
gsap.delayedCall(1, () => {
this.room.hideVortex(() => {
if (this.appState === "SPECIAL_SPELL" || this.appState === "ENDLESS_SPECIAL_SPELL")
this.staggerTorchesOn(true)
this.machine.send("complete")
// it's callbacks all the way down
})
})
})
})
}
resetLocations() {
this.freeLocations = []
this.enemyLocations.forEach((location, i) => {
this.freeLocations.push(i)
})
this.enemyPool.resetAll()
}
staggerTorchesOn(instant = false) {
this.torches.forEach((torch, i) => {
gsap.delayedCall((instant ? 0 : 1.5) + i * 0.1, () => torch.on())
})
}
staggerTorchesOff() {
this.torches.forEach((torch, i) => {
gsap.delayedCall(i * 0.1, () => torch.off())
})
}
setInitialStates() {
SOUNDS.startMusic()
this.resetLocations()
this.spellCaster.reset(this.appState === "SETUP_ENDLESS")
this.staggerTorchesOn()
this.health = 1
this.elapsedGameTime = 0 // we start a little bit in so that the first demon appears a little quicker
// this.room.hide()
this.room.show()
}
setupStats() {
if (window.DEBUG.fps) {
this.stats = new Stats()
const element = document.querySelector("#fps")
element.style.display = "block"
element.appendChild(this.stats.dom)
}
}
startGame() {
// this.crystalEnergy.active = true
this.enemyState = { ...ENEMY_SETTINGS, lastSent: 2.5 } // we don't start on zero so that the first demon enters faster
this.setInitialStates()
this.endlessMode = false
this.crystal.reset()
// this.machine.send("run")
}
startEndless() {
this.enemyState = { ...ENEMY_SETTINGS, sendFrequency: 2 }
this.setInitialStates()
this.endlessMode = true
this.crystal.reset()
// this.machine.send("run")
}
pauseGame() {
// this.clock.stop()
this.room.pause()
this.spellCaster.deactivate()
this.stage.moveCamera("paused")
this.emitters.forEach((e) => e.pause())
this.enemies.forEach((e) => e.pause())
this.stage.paused = true
this.stage.useOrbitControls = true
}
resumeGame() {
// this.clock.start()
// this.staggerTorchesOn(true)
this.room.resume()
this.stage.paused = false
this.stage.useOrbitControls = false
this.spellCaster.activate()
this.stage.moveCamera("playing")
this.emitters.forEach((e) => e.resume())
this.enemies.forEach((e) => e.resume())
this.resetRotation()
}
endGame() {
// this.crystalEnergy.active = false
this.spellCaster.deactivate()
this.staggerTorchesOff()
// this.room.crystal.explode()
this.enemies.forEach((e) => e.accend())
}
getEnemy(type, count) {
if (!type) return null
const toReturn = []
for (let i = 0; i < this.enemies.length; i++) {
const enemy = this.enemies[i]
if (enemy.state === "ALIVE" && toReturn.length < count) {
toReturn.push(enemy)
}
}
if (toReturn.length) return toReturn
return [null]
}
makePointLight = (color) => {
const pointLight = new PointLight(color, 0, 0.8)
// pointLight.castShadow = true
this.sceneObjects.add(pointLight)
return pointLight
}
addEmitter(emitter) {
if (emitter.model) {
console.log("model", emitter.model)
this.sceneObjects.add(emitter.model.group)
}
this.emitters.push(emitter)
}
getFreeLocation() {
if (!this.freeLocations.length) return null
const i = Math.floor(Math.random() * this.freeLocations.length)
const nextLocation = this.freeLocations.splice(i, 1)
return this.enemyLocations[nextLocation]
}
updateOnScreenEnemyInfo() {
DOM.demonCount.innerText = this.enemyState.killCount
}
addEnemy(forceLocation, forceSpell) {
if (["GAME_RUNNING", "ENDLESS_MODE", "INSTRUCTIONS_DEMON"].indexOf(this.appState) >= 0) {
console.log("add enemy", forceLocation)
const location = forceLocation ? forceLocation : this.getFreeLocation()
if (location && this.enemyState.sendCount < this.enemyState.totalSend) {
const enemy = new Enemy(this.sim, this.enemyPool.borrowDemon(), forceSpell)
// if (enemy.model) this.sceneObjects.add(enemy.model)
enemy.spawn(location)
if (enemy.emitter) this.addEmitter(enemy.emitter)
enemy.onDeadCallback = () => {
this.enemyState.killCount++
if (this.enemyState.killCount === this.enemyState.totalSend) this.machine.send("win")
}
if (this.appState === "GAME_RUNNING") this.enemyState.sendCount++
this.enemies.push(enemy)
return enemy
}
}
return null
}
rotate() {
this.rotating = true
gsap.to(this, { rotationSpeed: 0.2, duration: 1, ease: "power2.in" })
}
resetRotation() {
this.rotating = false
const goClockwise = this.stage.everything.rotation.y > Math.PI ? true : false
gsap.to(this, { rotationSpeed: 0, duration: 1, ease: "power2.out" })
gsap.to(this.stage.everything.rotation, { y: goClockwise ? Math.PI * 2 : 0, duration: 1, ease: "power2.inOut" })
}
tick() {
if (this.stats) this.stats.begin()
this.updateOnScreenEnemyInfo()
document.body.style.setProperty("--health", this.health)
let delta = this.clock.getDelta()
if (this.clockWasPaused) {
delta = 0
this.clockWasPaused = false
}
if (this.spellCaster) {
const rechargeableStates = ["GAME_RUNNING", "ENDLESS_MODE", "SPECIAL_SPELL", "ENDLESS_SPECIAL_SPELL"]
if (rechargeableStates.includes(this.appState)) {
this.spellCaster.tick(delta)
}
}
if (this.sim) {
if (!this.isPaused) {
this.elapsedGameTime += delta
// const elapsedTime = this.clock.getElapsedTime()
this.animations.map((mixer) => {
mixer.update(delta * mixer.timeScale)
})
for (let i = this.emitters.length - 1; i >= 0; i--) {
let emitter = this.emitters[i]
if (emitter === null || emitter.destroyed) {
emitter = null
this.emitters.splice(i, 1)
} else {
emitter.tick(delta, this.elapsedGameTime)
}
}
for (let i = this.enemies.length - 1; i >= 0; i--) {
let enemy = this.enemies[i]
if (enemy === null || enemy.dead) {
enemy = null
this.enemies.splice(i, 1)
} else {
enemy.tick(delta, this.elapsedGameTime)
}
}
for (let i = this.torches.length - 1; i >= 0; i--) {
this.torches[i].tick(delta, this.elapsedGameTime)
}
const es = this.enemyState
if (this.endlessMode && this.enemies.length) {
es.lastSent = 0
}
if (this.isPlaying) {
es.lastSent += delta
if (es.lastSent >= es.sendFrequency) {
if (!this.endlessMode || !this.enemies.length) {
es.lastSent = 0
es.sendFrequency -= es.sendFrequencyReduceBy
if (es.sendFrequency < es.minSendFrequency) es.sendFrequency = es.minSendFrequency
this.addEnemy()
}
}
}
if (this.isPlaying && !this.endlessMode) {
this.health += this.healthReplenish * delta
this.health -= this.enemies.length * (this.healthDecay * delta)
this.health = Math.min(1, Math.max(0, this.health))
}
if (this.isPlaying && this.health <= 0) this.machine.send("game-over")
this.sim.step(delta, this.elapsedGameTime)
if (this.viz) this.viz.tick()
}
if (this.rotating) {
this.stage.everything.rotation.y += this.rotationSpeed * delta
this.stage.everything.rotation.y = this.stage.everything.rotation.y % (Math.PI * 2)
}
for (let i = this.enemyLocations.length - 1; i >= 0; i--) {
const location = this.enemyLocations[i]
if (location.energyEmitter) location.energyEmitter.tick(delta, this.elapsedGameTime)
}
this.stage.render()
this.frame++
}
if (this.room) this.room.tick(delta, this.elapsedGameTime)
if (this.crystal) this.crystal.tick(delta)
if (this.stats) this.stats.end()
window.requestAnimationFrame(() => this.tick())
}
get spellLight() {
this.spellLightsCount++
return this.spellLights[this.spellLightsCount % this.spellLights.length]
}
get isPaused() {
return ["PAUSED", "ENDLESS_PAUSE", "SPELL_OVERLAY", "ENDLESS_SPELL_OVERLAY"].indexOf(this.appState) >= 0
}
get isPlaying() {
return ["GAME_RUNNING", "ENDLESS_MODE"].indexOf(this.appState) >= 0
}
}
// lets get this party started:
const app = new App()

Spell Caster

A spooky game where you cast spells to save a crystal from demons.

A Pen by koste on CodePen.

License.

@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;
}
@Litas-dev
Copy link
Author

duno

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment