// // Created by Ryan Huffman on 1/10/2017 // Copyright 2017 High Fidelity, Inc. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // /* globals ShortbowGameManager:true, utils */ Script.include('utils.js'); // +--------+ +-----------+ +-----------------+ // | | | |<-----+ | // | IDLE +----->| PLAYING | | BETWEEN_WAVES | // | | | +----->| | // +--------+ +-----+-----+ +-----------------+ // ^ | // | v // | +-------------+ // | | | // +---------+ GAME_OVER | // | | // +-------------+ var GAME_STATES = { IDLE: 0, PLAYING: 1, BETWEEN_WAVES: 2, GAME_OVER: 3 }; // Load the sounds that we will be using in the game so they are ready to be // used when we need them. var BEGIN_BUILDING_SOUND = SoundCache.getSound(Script.resolvePath("sounds/gameOn.wav")); var GAME_OVER_SOUND = SoundCache.getSound(Script.resolvePath("sounds/gameOver.wav")); var WAVE_COMPLETE_SOUND = SoundCache.getSound(Script.resolvePath("sounds/waveComplete.wav")); var EXPLOSION_SOUND = SoundCache.getSound(Script.resolvePath("sounds/explosion.wav")); var TARGET_HIT_SOUND = SoundCache.getSound(Script.resolvePath("sounds/targetHit.wav")); var ESCAPE_SOUND = SoundCache.getSound(Script.resolvePath("sounds/escape.wav")); const STARTING_NUMBER_OF_LIVES = 6; const ENEMIES_PER_WAVE_MULTIPLIER = 2; const POINTS_PER_KILL = 100; const ENEMY_SPEED = 3.0; // Encode a set of key-value pairs into a param string. Does NOT do any URL escaping. function encodeURLParams(params) { var paramPairs = []; for (var key in params) { paramPairs.push(key + "=" + params[key]); } return paramPairs.join("&"); } function sendAndUpdateHighScore(entityID, score, wave, numPlayers, onResposeReceived) { const URL = 'https://script.google.com/macros/s/AKfycbwbjCm9mGd1d5BzfAHmVT_XKmWyUYRkjCEqDOKm1368oM8nqWni/exec'; print("Sending high score"); const paramString = encodeURLParams({ entityID: entityID, score: score, wave: wave, numPlayers: numPlayers }); var request = new XMLHttpRequest(); request.onreadystatechange = function() { print("ready state: ", request.readyState, request.status, request.readyState === request.DONE, request.response); if (request.readyState === request.DONE && request.status === 200) { print("Got response for high score: ", request.response); var response = JSON.parse(request.responseText); if (response.highScore !== undefined) { onResposeReceived(response.highScore); } } }; request.open('GET', URL + "?" + paramString); request.timeout = 10000; request.send(); } function findChildrenWithName(parentID, name) { var childrenIDs = Entities.getChildrenIDs(parentID); var matchingIDs = []; for (var i = 0; i < childrenIDs.length; ++i) { var id = childrenIDs[i]; var childName = Entities.getEntityProperties(id, 'name').name; if (childName === name) { matchingIDs.push(id); } } return matchingIDs; } function getPropertiesForEntities(entityIDs, desiredProperties) { var properties = []; for (var i = 0; i < entityIDs.length; ++i) { properties.push(Entities.getEntityProperties(entityIDs[i], desiredProperties)); } return properties; } var baseEnemyProperties = { "name": "SB.Enemy", "damping": 0, "linearDamping": 0, "angularDamping": 0, "acceleration": { "x": 0, "y": -9, "z": 0 }, "angularVelocity": { "x": -0.058330666273832321, "y": -0.77943277359008789, "z": -2.1163818836212158 }, "clientOnly": 0, "collisionsWillMove": 1, "dimensions": { "x": 0.63503998517990112, "y": 0.63503998517990112, "z": 0.63503998517990112 }, "dynamic": 1, "gravity": { "x": 0, "y": -15, "z": 0 }, "lifetime": 30, "id": "{ed8f7339-8bbd-4750-968e-c3ceb9d64721}", "modelURL": Script.resolvePath("models/Amber.baked.fbx"), "owningAvatarID": "{00000000-0000-0000-0000-000000000000}", "queryAACube": { "scale": 1.0999215841293335, "x": -0.54996079206466675, "y": -0.54996079206466675, "z": -0.54996079206466675 }, "shapeType": "sphere", "type": "Model", "script": Script.resolvePath('enemyClientEntity.js'), "serverScripts": Script.resolvePath('enemyServerEntity.js') }; function searchForChildren(parentID, names, callback, timeoutMs) { // Map from name to entity ID for the children that have been found var foundEntities = {}; for (var i = 0; i < names.length; ++i) { foundEntities[names[i]] = null; } const CHECK_EVERY_MS = 500; const maxChecks = Math.ceil(timeoutMs / CHECK_EVERY_MS); var check = 0; var intervalID = Script.setInterval(function() { check++; var childrenIDs = Entities.getChildrenIDs(parentID); print("\tNumber of children:", childrenIDs.length); for (var i = 0; i < childrenIDs.length; ++i) { print("\t\t" + i + ".", Entities.getEntityProperties(childrenIDs[i]).name); var id = childrenIDs[i]; var name = Entities.getEntityProperties(id, 'name').name; var idx = names.indexOf(name); if (idx > -1) { foundEntities[name] = id; print(name, id); names.splice(idx, 1); } } if (names.length === 0 || check >= maxChecks) { Script.clearInterval(intervalID); callback(foundEntities); } }, CHECK_EVERY_MS); } ShortbowGameManager = function(rootEntityID, bowPositions, spawnPositions) { print("Starting game manager"); var self = this; this.gameState = GAME_STATES.IDLE; this.rootEntityID = rootEntityID; this.bowPositions = bowPositions; this.rootPosition = null; this.spawnPositions = spawnPositions; this.loadedChildren = false; const START_BUTTON_NAME = 'SB.StartButton'; const WAVE_DISPLAY_NAME = 'SB.DisplayWave'; const SCORE_DISPLAY_NAME = 'SB.DisplayScore'; const LIVES_DISPLAY_NAME = 'SB.DisplayLives'; const HIGH_SCORE_DISPLAY_NAME = 'SB.DisplayHighScore'; const SEARCH_FOR_CHILDREN_TIMEOUT = 5000; searchForChildren(rootEntityID, [ START_BUTTON_NAME, WAVE_DISPLAY_NAME, SCORE_DISPLAY_NAME, LIVES_DISPLAY_NAME, HIGH_SCORE_DISPLAY_NAME ], function(children) { self.loadedChildren = true; self.startButtonID = children[START_BUTTON_NAME]; self.waveDisplayID = children[WAVE_DISPLAY_NAME]; self.scoreDisplayID = children[SCORE_DISPLAY_NAME]; self.livesDisplayID = children[LIVES_DISPLAY_NAME]; self.highScoreDisplayID = children[HIGH_SCORE_DISPLAY_NAME]; sendAndUpdateHighScore(self.rootEntityID, self.score, self.waveNumber, 1, self.setHighScore.bind(self)); self.reset(); }, SEARCH_FOR_CHILDREN_TIMEOUT); // Gameplay state this.waveNumber = 0; this.livesLeft = STARTING_NUMBER_OF_LIVES; this.score = 0; this.nextWaveTimer = null; this.spawnEnemyTimers = []; this.remainingEnemies = []; this.bowIDs = []; this.startButtonChannelName = 'button-' + this.rootEntityID; // Entity client and server scripts will send messages to this channel this.commChannelName = "shortbow-" + this.rootEntityID; Messages.subscribe(this.commChannelName); Messages.messageReceived.connect(this, this.onReceivedMessage); print("Listening on: ", this.commChannelName); Messages.sendMessage(this.commChannelName, 'hi'); }; ShortbowGameManager.prototype = { reset: function() { Entities.editEntity(this.startButtonID, { visible: true }); }, cleanup: function() { Messages.unsubscribe(this.commChannelName); Messages.messageReceived.disconnect(this, this.onReceivedMessage); if (this.checkEnemiesTimer) { Script.clearInterval(this.checkEnemiesTimer); this.checkEnemiesTimer = null; } for (var i = this.bowIDs.length - 1; i >= 0; i--) { Entities.deleteEntity(this.bowIDs[i]); } this.bowIDs = []; for (i = 0; i < this.remainingEnemies.length; i++) { Entities.deleteEntity(this.remainingEnemies[i].id); } this.remainingEnemies = []; this.gameState = GAME_STATES.IDLE; }, startGame: function() { if (this.gameState !== GAME_STATES.IDLE) { print("shortbowGameManagerManager.js | Error, trying to start game when not in idle state"); return; } if (this.loadedChildren === false) { print('shortbowGameManager.js | Children have not loaded, not allowing game to start'); return; } print("Game started!!"); this.rootPosition = Entities.getEntityProperties(this.rootEntityID, 'position').position; Entities.editEntity(this.startButtonID, { visible: false }); // Spawn bows var bowSpawnEntityIDs = findChildrenWithName(this.rootEntityID, 'SB.BowSpawn'); var bowSpawnProperties = getPropertiesForEntities(bowSpawnEntityIDs, ['position', 'rotation']); for (var i = 0; i < bowSpawnProperties.length; ++i) { const props = bowSpawnProperties[i]; Vec3.print("Creating bow: " + i, props.position); this.bowIDs.push(Entities.addEntity({ "position": props.position, "rotation": props.rotation, "collisionsWillMove": 1, "compoundShapeURL": Script.resolvePath("bow/models/bow_collision_hull.obj"), "created": "2016-09-01T23:57:55Z", "dimensions": { "x": 0.039999999105930328, "y": 1.2999999523162842, "z": 0.20000000298023224 }, "dynamic": 1, "gravity": { "x": 0, "y": -9.8, "z": 0 }, "modelURL": Script.resolvePath("bow/models/bow-deadly.baked.fbx"), "name": "WG.Hifi-Bow", "script": Script.resolvePath("bow/bow.js"), "shapeType": "compound", "type": "Model", "userData": "{\"grabbableKey\":{\"grabbable\":true},\"wearable\":{\"joints\":{\"RightHand\":[{\"x\":0.0813,\"y\":0.0452,\"z\":0.0095},{\"x\":-0.3946,\"y\":-0.6604,\"z\":0.4748,\"w\":-0.4275}],\"LeftHand\":[{\"x\":-0.0881,\"y\":0.0259,\"z\":0.0159},{\"x\":0.4427,\"y\":-0.6519,\"z\":0.4592,\"w\":0.4099}]}}}" })); } // Initialize game state this.waveNumber = 0; this.setScore(0); this.setLivesLeft(STARTING_NUMBER_OF_LIVES); this.nextWaveTimer = Script.setTimeout(this.startNextWave.bind(this), 100); this.spawnEnemyTimers = []; this.checkEnemiesTimer = null; this.remainingEnemies = []; // SpawnQueue is a list of enemies left to spawn. Each entry looks like: // // { spawnAt: 1000, position: { x: 0, y: 0, z: 0 } } // // where spawnAt is the number of millseconds after the start of the wave // to spawn the enemy. The list is sorted by spawnAt, ascending. this.spawnQueue = []; this.gameState = GAME_STATES.BETWEEN_WAVES; Audio.playSound(BEGIN_BUILDING_SOUND, { volume: 1.0, position: this.rootPosition }); var liveChecker = setInterval(function() { if (this.livesLeft <= 0) { this.endGame(); clearInterval(liveChecker); } }, 1000); }, startNextWave: function() { if (this.gameState !== GAME_STATES.BETWEEN_WAVES) { return; } print("Starting next wave"); this.gameState = GAME_STATES.PLAYING; this.waveNumber++; this.remainingEnemies= []; this.spawnQueue = []; this.spawnStartTime = Date.now(); Entities.editEntity(this.waveDisplayID, { text: this.waveNumber}); var numberOfEnemiesLeftToSpawn = this.waveNumber * ENEMIES_PER_WAVE_MULTIPLIER; var delayBetweenSpawns = 2000 / Math.max(1, Math.log(this.waveNumber)); var currentDelay = 2000; print("Number of enemies:", numberOfEnemiesLeftToSpawn); this.checkEnemiesTimer = Script.setInterval(this.checkEnemies.bind(this), 100); var enemySpawnEntityIDs = findChildrenWithName(this.rootEntityID, 'SB.EnemySpawn'); var enemySpawnProperties = getPropertiesForEntities(enemySpawnEntityIDs, ['position', 'rotation']); for (var i = 0; i < numberOfEnemiesLeftToSpawn; ++i) { print("Adding enemy"); var idx = Math.floor(Math.random() * enemySpawnProperties.length); var props = enemySpawnProperties[idx]; this.spawnQueue.push({ spawnAt: currentDelay, position: props.position, rotation: props.rotation, velocity: Vec3.multiply(ENEMY_SPEED, Quat.getFront(props.rotation)) }); currentDelay += delayBetweenSpawns; } print("Starting wave", this.waveNumber); }, checkWaveComplete: function() { if (this.gameState !== GAME_STATES.PLAYING) { return; } if (this.spawnQueue.length === 0 && this.remainingEnemies.length === 0) { this.gameState = GAME_STATES.BETWEEN_WAVES; Script.setTimeout(this.startNextWave.bind(this), 5000); Script.clearInterval(this.checkEnemiesTimer); this.checkEnemiesTimer = null; // Play after 1.5s to let other sounds finish playing var self = this; Script.setTimeout(function() { Audio.playSound(WAVE_COMPLETE_SOUND, { volume: 1.0, position: self.rootPosition }); }, 1500); } }, setHighScore: function(highScore) { print("Setting high score to:", this.highScoreDisplayID, highScore); Entities.editEntity(this.highScoreDisplayID, { text: highScore }); }, setLivesLeft: function(lives) { lives = Math.max(0, lives); this.livesLeft = lives; Entities.editEntity(this.livesDisplayID, { text: this.livesLeft }); }, setScore: function(score) { this.score = score; Entities.editEntity(this.scoreDisplayID, { text: this.score }); }, checkEnemies: function() { if (this.gameState !== GAME_STATES.PLAYING) { return; } // Check the spawn queueu to see if there are any enemies that need to // be spawned var waveElapsedTime = Date.now() - this.spawnStartTime; while (this.spawnQueue.length > 0 && waveElapsedTime > this.spawnQueue[0].spawnAt) { baseEnemyProperties.position = this.spawnQueue[0].position; baseEnemyProperties.rotation = this.spawnQueue[0].rotation; baseEnemyProperties.velocity= this.spawnQueue[0].velocity; baseEnemyProperties.userData = JSON.stringify({ gameChannel: this.commChannelName, grabbableKey: { grabbable: false } }); var entityID = Entities.addEntity(baseEnemyProperties); this.remainingEnemies.push({ id: entityID, lastKnownPosition: baseEnemyProperties.position, lastHeartbeat: Date.now() }); this.spawnQueue.splice(0, 1); Script.setTimeout(function() { const JUMP_SPEED = 5.0; var velocity = Entities.getEntityProperties(entityID, 'velocity').velocity; velocity.y += JUMP_SPEED; Entities.editEntity(entityID, { velocity: velocity }); }, 500 + Math.random() * 4000); } // Check the list of remaining enemies to see if any are too far away // or haven't been heard from in awhile - if so, delete them. var enemiesEscaped = false; const MAX_UNHEARD_TIME_BEFORE_DESTROYING_ENTITY_MS = 5000; const MAX_DISTANCE_FROM_GAME_BEFORE_DESTROYING_ENTITY = 200; for (var i = this.remainingEnemies.length - 1; i >= 0; --i) { var enemy = this.remainingEnemies[i]; var timeSinceLastHeartbeat = Date.now() - enemy.lastHeartbeat; var distance = Vec3.distance(enemy.lastKnownPosition, this.rootPosition); if (timeSinceLastHeartbeat > MAX_UNHEARD_TIME_BEFORE_DESTROYING_ENTITY_MS || distance > MAX_DISTANCE_FROM_GAME_BEFORE_DESTROYING_ENTITY) { print("EXPIRING: ", enemy.id); Entities.deleteEntity(enemy.id); this.remainingEnemies.splice(i, 1); // Play the sound when you hit an enemy Audio.playSound(TARGET_HIT_SOUND, { volume: 1.0, position: this.rootPosition }); this.setScore(this.score + POINTS_PER_KILL); enemiesEscaped = true; } } if (enemiesEscaped) { this.checkWaveComplete(); } }, endGame: function() { if (this.gameState !== GAME_STATES.PLAYING) { return; } var self = this; Script.setTimeout(function() { Audio.playSound(GAME_OVER_SOUND, { volume: 1.0, position: self.rootPosition }); }, 1500); this.gameState = GAME_STATES.GAME_OVER; print("GAME OVER"); // Update high score sendAndUpdateHighScore(this.rootEntityID, this.score, this.waveNumber, 1, this.setHighScore.bind(this)); // Cleanup Script.clearTimeout(this.nextWaveTimer); this.nextWaveTimer = null; var i; for (i = 0; i < this.spawnEnemyTimers.length; ++i) { Script.clearTimeout(this.spawnEnemyTimers[i]); } this.spawnEnemyTimers = []; Script.clearInterval(this.checkEnemiesTimer); this.checkEnemiesTimer = null; for (i = this.bowIDs.length - 1; i >= 0; i--) { var id = this.bowIDs[i]; print("Checking bow: ", id); var userData = utils.parseJSON(Entities.getEntityProperties(id, 'userData').userData); var bowIsHeld = userData.grabKey !== undefined && userData.grabKey !== undefined && userData.grabKey.refCount > 0; print("Held: ", bowIsHeld); if (!bowIsHeld) { Entities.deleteEntity(id); this.bowIDs.splice(i, 1); } } for (i = 0; i < this.remainingEnemies.length; i++) { Entities.deleteEntity(this.remainingEnemies[i].id); } this.remainingEnemies = []; // Wait a short time before showing the start button so that any current sounds // can finish playing. const WAIT_TO_REENABLE_GAME_TIMEOUT_MS = 3000; Script.setTimeout(function() { Entities.editEntity(this.startButtonID, { visible: true }); this.gameState = GAME_STATES.IDLE; }.bind(this), WAIT_TO_REENABLE_GAME_TIMEOUT_MS); }, onReceivedMessage: function(channel, messageJSON, senderID) { if (channel === this.commChannelName) { var message = utils.parseJSON(messageJSON); if (message === undefined) { print("shortbowGameManager.js | Received non-json message:", JSON.stringify(messageJSON)); return; } switch (message.type) { case 'start-game': this.startGame(); break; case 'enemy-killed': this.onEnemyKilled(message.entityID, message.position); break; case 'enemy-escaped': this.onEnemyEscaped(message.entityID); break; case 'enemy-heartbeat': this.onEnemyHeartbeat(message.entityID, message.position); break; default: print("shortbowGameManager.js | Ignoring unknown message type: ", message.type); break; } } }, onEnemyKilled: function(entityID, position) { if (this.gameState !== GAME_STATES.PLAYING) { return; } for (var i = this.remainingEnemies.length - 1; i >= 0; --i) { var enemy = this.remainingEnemies[i]; if (enemy.id === entityID) { this.remainingEnemies.splice(i, 1); Audio.playSound(TARGET_HIT_SOUND, { volume: 1.0, position: this.rootPosition }); // Update score this.setScore(this.score + POINTS_PER_KILL); print("SCORE: ", this.score); this.checkWaveComplete(); break; } } }, onEnemyEscaped: function(entityID, position) { if (this.gameState !== GAME_STATES.PLAYING) { return; } var enemiesEscaped = false; for (var i = this.remainingEnemies.length - 1; i >= 0; --i) { var enemy = this.remainingEnemies[i]; if (enemy.id === entityID) { Entities.deleteEntity(enemy.id); this.remainingEnemies.splice(i, 1); this.setLivesLeft(this.livesLeft - 1); Audio.playSound(ESCAPE_SOUND, { volume: 1.0, position: this.rootPosition }); enemiesEscaped = true; } } if (this.livesLeft <= 0) { this.endGame(); } else if (enemiesEscaped) { this.checkWaveComplete(); } }, onEnemyHeartbeat: function(entityID, position) { for (var i = 0; i < this.remainingEnemies.length; i++) { var enemy = this.remainingEnemies[i]; if (enemy.id === entityID) { enemy.lastHeartbeat = Date.now(); enemy.lastKnownPosition = position; break; } } } };