Code viewer for World: Gatekeeper
// Game will work without the google cloud key, its simply for TTS. An OpenAI key is required for key functions however.
GOOGLE_KEY = "AIzaSyCXQny4ElmR6rtm04v7QNGdPzDhJHSDzdc"
CHAT_KEY = "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"

function World() {
   // Establishing the world
   AB.clockTick = 10000;
   var scene = new THREE.Scene();
   var skyBackground = new THREE.TextureLoader().load("uploads/murpsam/day.jpg");
   scene.background = skyBackground;

   var renderer = new THREE.WebGLRenderer();
   function render() {
      renderer.setSize(window.innerWidth, window.innerHeight);
      document.body.appendChild(renderer.domElement);
   }

   var cam;
   function camera() {
      cam = new THREE.PerspectiveCamera(80, window.innerWidth / window.innerHeight, 0.1, 1000);
      cam.position.set(90, 4, -250);
      cam.lookAt(scene.position);
   }

   // Creating the player object.
   var playerAttributes = { speed: 0.27, turnSpeed: 0.1 };
   function player() {
      var geometry = new THREE.BoxGeometry(1, 1, 1);
      var material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
      player = new THREE.Mesh(geometry, material);
      player.scale.multiplyScalar(2);
      player.position.set(90, 4, -250);
      scene.add(player);
   }

   // All keyboard stuff - Whats important here is the event.keyCode listeners which check for arrow keys for movement -> letter keys for typing.
   keyboard = {};
   let typedString = '';
   let wizardResponse = '';
   let userText = '';
   var typeFlag = 0;
   // Function to handle on key press events.
   function onKeyPress(event) {
      // Array with keycodes for arrow keys, so we can quickly check if the input is one of them.
      var arrowKeys = [37, 38, 39, 40];
      keyboard[event.keyCode] = true;
      const typedCharacter = event.key;
      if (arrowKeys.includes(event.keyCode)) {
         // preventDefault disables the scrolling effect when moving, makes gameplay much smoother.
         event.preventDefault();
      } else if (typeFlag === 1 && event.keyCode === 13) {
         userText = typedString;
         // The password to win the game, if it is typed and submitted, the game ends.
         if (userText === "Yggdrasil." || userText === "Yggdrasil" || userText === "yggdrasil" || userText === "yggdrasil.") {
            // Sends the message typed to TTS.
            textToSpeechAPI("The password is Yggdrasil! Now let me pass.", "player");
            endScreen();
         } else {
            // Relay the user's message to ChatGPT, resets the string, then updates the text on screen.
            GPTChatting(userText);
            // Sends the message typed to TTS.
            textToSpeechAPI(userText, "player");
            typedString = '';
            headerUpdate();
         }
      } else if (typeFlag === 1 && event.keyCode === 32) { // These separate elif checks are for special characters e.g. '.,?!'
         event.preventDefault();
         typedString += ' ';
         headerUpdate();
      } else if (typeFlag === 1 && event.keyCode === 8) {
         event.preventDefault();
         typedString = typedString.slice(0, -1);
         headerUpdate();
      } else if (typeFlag === 1 && ((event.keyCode >= 32 && event.keyCode <= 126) ||
         event.keyCode === 191 ||
         event.keyCode === 190 ||
         event.keyCode === 188)) {
         typedString += typedCharacter;
         headerUpdate();
      }
   }

   // Handler for when the user stops pressing a key, fixes constant input problems.
   function onKeyUp(event) {
      keyboard[event.keyCode] = false;
   }

   // Adds our event listeners to the document. When an event is detected (keyboard input) it calls the function in the second argument.
   document.addEventListener('keyup', onKeyUp);
   document.addEventListener('keydown', onKeyPress);

   var newX;
   var newX;
   // Movement handler. Keeps track of player movement and adjusts the X and Z of the player to the new value.
   function movement() {
      requestAnimationFrame(movement);
      isTalking();
      // Movement
      if (keyboard[38]) { // Up arrow key
         if (player.position.x <= 200 && player.position.x >= -500) {
            player.position.x -= Math.sin(player.rotation.y) * playerAttributes.speed;
            player.position.z -= -Math.cos(player.rotation.y) * playerAttributes.speed;
            newX = player.position.x;
            newX = player.position.z;
         }
         else {
            player.position.x += Math.sin(player.rotation.y) * playerAttributes.speed;

         }
         if (player.position.z <= 463 && player.position.z >= -500) {
            player.position.x -= Math.sin(player.rotation.y) * playerAttributes.speed;
            player.position.z -= -Math.cos(player.rotation.y) * playerAttributes.speed;
            newX = player.position.x;
            newX = player.position.z;

         }
         else {
            player.position.z += -Math.cos(player.rotation.y) * playerAttributes.speed;

         }
         checkPos();
      }
      if (keyboard[40]) { // Down arrow key
         if (player.position.x <= 200 && player.position.x >= -500) {
            player.position.x += Math.sin(player.rotation.y) * playerAttributes.speed;
            player.position.z += -Math.cos(player.rotation.y) * playerAttributes.speed;
            newX = player.position.x;
            newX = player.position.z;
         }
         else {
            player.position.x -= Math.sin(player.rotation.y) * playerAttributes.speed;
         }
         if (player.position.z <= 463 && player.position.z >= -500) {
            player.position.x += Math.sin(player.rotation.y) * playerAttributes.speed;
            player.position.z += -Math.cos(player.rotation.y) * playerAttributes.speed;
            newX = player.position.x;
            newX = player.position.z;
         }
         else {
            player.position.z -= -Math.cos(player.rotation.y) * playerAttributes.speed;
         }
         checkPos();
      }
      if (keyboard[37]) { // left arrow key
         cam.rotation.y -= playerAttributes.turnSpeed;
         player.rotation.y -= playerAttributes.turnSpeed;
         cam.position.z = player.position.z;
      }
      if (keyboard[39]) { // right arrow key
         cam.rotation.y += playerAttributes.turnSpeed;
         player.rotation.y += playerAttributes.turnSpeed;
         cam.position.z = player.position.z;
      }

      cam.position.x = player.position.x;
      cam.position.z = player.position.z;
      cam.position.y = 20;
      renderer.render(scene, cam);
   }

   // Large function does one thing -> Load all my polys from my uploads, and there respective MTL files and or image textures.
   // All models courtesy of https://poly.pizza
   function loadModels() {
      var mtlLoader = new THREE.MTLLoader();
      mtlLoader.setResourcePath("uploads/murpsam/");
      mtlLoader.setPath("uploads/murpsam/");
      mtlLoader.load("romanarch.mtl", function (materials) {
         materials.preload();
         var objLoader = new THREE.OBJLoader();
         objLoader.setMaterials(materials);
         objLoader.setPath("uploads/murpsam/");

         objLoader.load("romanarch.obj", function (object) {
            var numArches = 25;
            var offset = 1;

            for (var i = 0; i < numArches; i++) {
               var arch = object.clone();
               arch.scale.set(23, 23, 23);
               arch.position.set(-105, -11, -90 + i * offset);
               scene.add(arch);
            }
         });

      });

      var trees;
      mtlLoader.load("trees.mtl", function (materials) {
         materials.preload();
         var objLoader = new THREE.OBJLoader();
         objLoader.setMaterials(materials);
         objLoader.setPath("uploads/murpsam/");

         objLoader.load("trees.obj", function (object) {
            trees = object;
            trees.scale.set(3.5, 3.5, 3.5);
            trees.position.set(0, 0, 0);
            scene.add(trees);
         });
      });

      var rounddoor;
      mtlLoader.load("round.mtl", function (materials) {
         materials.preload();
         var objLoader = new THREE.OBJLoader();
         objLoader.setMaterials(materials);
         objLoader.setPath("uploads/murpsam/");

         objLoader.load("rounddoor.obj", function (object) {
            rounddoor = object;
            rounddoor.scale.set(80, 90, 80);
            rounddoor.position.set(-37.5, 15, -85);
            scene.add(rounddoor);
         });
      });

      var wizard;
      mtlLoader.load("wizard.mtl", function (materials) {
         materials.preload();
         var objLoader = new THREE.OBJLoader();
         objLoader.setMaterials(materials);
         objLoader.setPath("uploads/murpsam/");

         objLoader.load("wizard.obj", function (object) {
            wizard = object;
            wizard.scale.set(45, 45, 45);
            wizard.position.set(-30, 25, -104);
            scene.add(wizard);
         });
      });
      mtlLoader.load("grass.mtl", function (materials) {
         materials.preload();
         var objLoader = new THREE.OBJLoader();
         objLoader.setMaterials(materials);
         objLoader.setPath("uploads/murpsam/");

         objLoader.load("grass.obj", function (object) {
            // Create a number of instances of the model
            var numInstances = 25;
            var radius = 150; // Adjust this to fit your map
            var groundLevel = -10; // Adjust this to match your map's ground level

            for (var i = 0; i < numInstances; i++) {
               var instance = object.clone();

               // Scale the instance
               instance.scale.set(0.5, 1, 0.5);

               // Randomly position the instance within a circle of radius radius
               var angle = Math.random() * 2 * Math.PI;
               var x = Math.cos(angle) * radius;
               var z = Math.sin(angle) * radius;
               var y = groundLevel;
               instance.position.set(x, y, z);

               // Randomly rotate the instance
               var rotation = Math.random() * 2 * Math.PI;
               instance.rotation.y = rotation;

               // Add the instance to the scene
               scene.add(instance);
            }
         });

      });

      mtlLoader.load("trees1.mtl", function (materials) {
         materials.preload();
         var objLoader = new THREE.OBJLoader();
         objLoader.setMaterials(materials);
         objLoader.setPath("uploads/murpsam/");

         objLoader.load("trees1.obj", function (object) {
            // Create a number of instances of the model
            var numInstances = 8;
            var radius = 300; // Define the radius of the circle
            for (var i = 0; i < numInstances; i++) {
               var instance = object.clone();

               // Scale the instance
               instance.scale.set(0.4, 0.4, 0.4);

               // Randomly position the instance within a circle of radius radius
               var angle = Math.random() * 2 * Math.PI;
               var x = Math.cos(angle) * radius;
               var z = Math.sin(angle) * radius;
               var y = -10;
               instance.position.set(x, y, z);

               // Randomly rotate the instance
               var rotation = Math.random() * 2 * Math.PI;
               instance.rotation.y = rotation;

               // Add the instance to the scene
               scene.add(instance);
            }
         });
         objLoader.load("trees1.obj", function (object) {
            // Create a number of instances of the model
            var numInstances = 50;
            var radius = 500; // Define the radius of the circle
            for (var i = 0; i < numInstances; i++) {
               var instance = object.clone();

               // Scale the instance
               instance.scale.set(0.8, 0.8, 0.8);

               // Randomly position the instance within a circle of radius radius
               var angle = Math.random() * 2 * Math.PI;
               var x = Math.cos(angle) * radius;
               var z = Math.sin(angle) * radius;
               var y = -10;
               instance.position.set(x, y, z);

               // Randomly rotate the instance
               var rotation = Math.random() * 2 * Math.PI;
               instance.rotation.y = rotation;

               // Add the instance to the scene
               scene.add(instance);
            }
         });
      });
   }
   // Checks for whether the player has 'collided' with the wizard, and conversation (talking();) is initilized.
   var talkingFlag = false;
   function isTalking() {
      if (player.position.x < 1 && player.position.z <= -100 && talkingFlag === false) {
         talkingFlag = true;
         typeFlag = 1; // Type flag makes sure the player cannot type until conversation has begun.
         talking();
      }
   }

   // Adds light to the scene.
   function lighting() {
      var light = new THREE.AmbientLight(0xffffff, 0.8);
      scene.add(light);
   }

   // Loads the green grass.
   function loadFloor() {
      // Create a plane geometry
      var geometry = new THREE.BoxGeometry(10000, 5, 100000);
      // Create a basic material with a green color
      var material = new THREE.MeshBasicMaterial({ color: 0x1E4602 });
      // Create a mesh with the geometry and material
      var floor = new THREE.Mesh(geometry, material);
      scene.add(floor);
   }

   // Loads a wall that originally was supposed to end the game when touched. May come back to it, so it stays here.
   function loadWall() {
      var geometry = new THREE.BoxGeometry(30, 40, 1);
      var material = new THREE.MeshBasicMaterial({ color: 0x000000 });
      var wall = new THREE.Mesh(geometry, material);
      wall.position.set(-35, 15, -65);
      scene.add(wall);
   }

   // Function which is called to update the player's position.
   function checkPos() {
      for (i = 0; i > 0; i++) {
         newX = Math.abs((parseInt(newX)));
         newZ = Math.abs((parseInt(newX)));
      }
   }

   // Function which sends whatever text is inputted to Google Cloud TTS API. This is a paid feature but not needed.
   // Docs: https://cloud.google.com/text-to-speech/#
   function textToSpeechAPI(text, voice) {
      var listener = new THREE.AudioListener();
      var sound = new THREE.Audio(listener);
      var voiceName;
      var voicePitch;
      if (voice == "wizard") {
         wizardResponse = text;
         headerUpdate();
         var voiceName = 'en-GB-Wavenet-D';
         var voicePitch = "-16"
      }
      if (voice == "player") {
         var voiceName = 'en-GB-Studio-B';
         var voicePitch = "0";
      }

      $.ajax({
         url: 'https://texttospeech.googleapis.com/v1/text:synthesize?key=' + GOOGLE_KEY,
         type: 'POST',
         data: JSON.stringify({
            'input': {
               'text': text
            },
            'voice': {
               'languageCode': 'en-GB',
               'name': voiceName,
               'ssmlGender': 'MALE'
            },
            'audioConfig': {
               'audioEncoding': 'LINEAR16',
               'speakingRate': "0.9",
               "pitch": voicePitch
            }
         }),
         contentType: 'application/json',
         success: function (response) {
            // Response is encoded audio, so we decode it and play it.
            var audioData = atob(response.audioContent);
            var audioArray = new Uint8Array(audioData.length);
            for (var i = 0; i < audioData.length; i++) {
               audioArray[i] = audioData.charCodeAt(i);
            }
            var audioBuffer = new ArrayBuffer(audioArray.length);
            var bufferView = new Uint8Array(audioBuffer);
            for (var i = 0; i < audioArray.length; i++) {
               bufferView[i] = audioArray[i];
            }
            var audioCtx = listener.context;
            audioCtx.decodeAudioData(audioBuffer, function (buffer) {
               sound.setBuffer(buffer);
               sound.play();
            });
         },
         error: function (error) {
            console.log('Error', error);
         }
      });
   }
   // Initialize the scenario. If you are cloning this world, you can set any scenario you like.
   var messages = [
      {
         'role': 'system',
         'content': 'Roleplay with me. You are assuming the role of a mysterious wizard. You must respond as if you are a wizard within a magical world full of dragons and demons and mystical creatures, much like the movie series: Lord of the rings. You are standing in front of a gate. The person you are going to speak to has no idea where they are or who they are, or who you are. So you must answer these questions, and be creative about the world. The key thing is, you know how to unlock the gate, and the player doesnt. The player must convice you to open the gate but only if they solve 3 of your riddles. Make them mystical and magical riddles. Keep your messages reasonably short. Do not ramble but be mysterious, dont divulge random information. When the final riddle is answered correctly, you give them the password to the gate. The password is "Yggdrasil".'
      }
   ];

   // Function for passing text to gpt-3.5-turbo. Again, if cloning, you can change the model to the one of your choice. I chose 3.5 turbo because it was cost effective for this use case.
   // Docs: https://platform.openai.com/docs/guides/text-generation
   function GPTChatting(text) {
      // Append the user's message to the messages list
      messages.push({
         'role': 'user',
         'content': text
      });

      // Ajax request
      $.ajax({
         url: 'https://api.openai.com/v1/chat/completions',
         type: 'POST',
         headers: {
            'Authorization': 'Bearer ' + CHAT_KEY,
            'Content-Type': 'application/json'
         },
         data: JSON.stringify({
            'model': 'gpt-3.5-turbo',
            'messages': messages
         }),
         success: function (response) {
            // Here we have to create a conversation with the AI by passing it previous messages and responses. Then we send the response to the TTS API.
            var message = response['choices'][0]['message']['content'];
            messages.push({
               'role': 'assistant',
               'content': message
            });
            textToSpeechAPI(message, "wizard"); // Sends response with voice 'wizard' to the TTS.
         },
         error: function (error) {
            console.log('Error', error);
         }
      });
   }

   // Sort of unnecessary function but useful in initilizing conversation from the collision(); function and creating the header for subtitles and user input.
   function talking() {
      textToSpeechAPI("Hello traveller, what brings you here?", "wizard");
      header();
   }

   var song1;
   var song2;
   // Music function which loads our nature sounds and our Skyrim OST. Not necessary but nice to have.
   function music() {
       // Background sounds: Relaxing Music from Master Media. Available at: https://www.youtube.com/watch?v=BuCYQyggbkM&t=3s
      song1 = AB.backgroundMusic("/uploads/murpsam/nature.mp3");
      // Song: 'From Past to Present' from The Elder Scrolls IV - Skyrim OST by Jeremy Soule. Available at: https://www.youtube.com/watch?v=thCxUo37PXw
      song2 = AB.backgroundMusic("/uploads/murpsam/skyrimQuiet.mp3");
   }

   var element = document.getElementById("ab-threepage");
   var dispText = document.createElement("p");
   var wizardText = document.createElement("p");
   // Header function loads the subtitles at the top (orange wizard text) and the white user input (bottom).
   function header() {
      element.appendChild(dispText);
      // Assuming dispText is your element
      dispText.style.position = 'fixed';
      dispText.style.left = '50%';
      dispText.style.transform = 'translateX(-50%)';
      dispText.style.bottom = '20px'; // Adjust this value to position it higher or lower from the bottom
      dispText.style.color = 'white';
      dispText.style.fontSize = '30px';
      dispText.style.textAlign = 'center';
      element.appendChild(wizardText);
      wizardText.style = "color:orange; text-align:center; font-size:20px; text-shadow:-1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;"
   }

   // Function which upodates the header text
   function headerUpdate() {
      wizardText.innerText = wizardResponse;
      dispText.innerText = "> " + typedString;
   }

   // Creates the title screen prompt with the Start button -> Initializes movement and music when pressed. May add password later.
   function titleScreen() {
      AB.newSplash();
      AB.splashClick(function () {
         music();
         movement();
         AB.removeSplash();
      })
      
      // Splash text information
      let splashText = document.getElementById("splash-inner");
      const h1 = document.createElement("h1");
      const p = document.createElement("p");
      const button = document.createElement("button");
      h1.innerHTML = "You find yourself in a quiet grove. You see a wizard, guarding a gate. Perhaps you should approach him.";
      p.innerHTML = "Your goal is to get the wizard to unlock the door. Use the arrow keys to move/camera.";
      button.innerHTML = "Start";
      splashText.appendChild(h1);
      splashText.appendChild(p);
   }

   // Function initilizes the run. Loads everything.
   this.newRun = function () {
      titleScreen();
      render();
      player();
      loadModels();
      camera();
      lighting();
      loadFloor();
      loadWall();
   }

   // Loads the end screen splash and text.
   function endScreen() {
      AB.abortRun = true;
      AB.newSplash();
      oldSplash = document.getElementById("splash");
      oldSplash.remove();
      splashBG = document.getElementById("splashblock");
      splashBG.style = "background:black";
      endTitle = document.createElement("h2");
      endTitle.innerText = "The gate opens! You have succeeded the wizards challenge. Well done.";
      endTitle.style = "color:white; left:0; line-height: 200px; position: absolute; text-align: center; top: 10%; width: 100%; font-size:50px;";
      splashBG.appendChild(endTitle)
   }


   // Runs at the end of the game. For this use case, just plays the door opening noise.
   this.endRun = function () {
      const listener = new THREE.AudioListener();
      const sound = new THREE.Audio(listener);

      const audioLoader = new THREE.AudioLoader();
      audioLoader.load('/uploads/murpsam/door.mp3', function (buffer) {
         sound.setBuffer(buffer);
         sound.setLoop(false);
         sound.setVolume(5);
         sound.play();
      });
   }
}