diff --git a/docs/star-seek.md b/docs/star-seek.md index 07bb013..c0661cd 100644 --- a/docs/star-seek.md +++ b/docs/star-seek.md @@ -5,7 +5,17 @@ The star seek mini-game uses gaze + head position to collect stars that appear a ## Main objects / values to edit during play testing - **StarSeekManager**: values related to the overall win condition of the game - - Game time limit + - Minimum game time limit (seconds) + - Maximum game time limit (seconds) + - How many seconds to increase the time limit, if the upgrade rate is met + - Upgrade rate: (number of stars collected / game time limit) i.e. average stars collected per second, must be above this value to increase the time limit of future games. + - Number of games in a row that must meet the upgrade rate + +- **StarGenerator**: values related to spawning stars + - Min distance between stars and the edge of the screen + - Number of rows + columns in star spawn grid + - Grid positions to exclude from star spawning (e.g. those that overlap with UI elements) + - Min distance between spawned stars - **Prefabs/StarSeekStar**: values related to 'locking on' to a star - Time required to collect a star (with both gaze + head pose crosshair aligned) diff --git a/projects/AstroBalance/Assets/Scenes/StarSeek.unity b/projects/AstroBalance/Assets/Scenes/StarSeek.unity index c7e6d3a..f81c42f 100644 --- a/projects/AstroBalance/Assets/Scenes/StarSeek.unity +++ b/projects/AstroBalance/Assets/Scenes/StarSeek.unity @@ -151,7 +151,11 @@ MonoBehaviour: scoreText: {fileID: 730211112} timer: {fileID: 740804264} winScreen: {fileID: 159571674} - timeLimit: 120 + minTimeLimit: 60 + maxTimeLimit: 180 + timeLimitIncrement: 60 + timeLimitUpgradeRate: 0.3 + nGamesToUpgrade: 3 --- !u!4 &5281182 Transform: m_ObjectHideFlags: 0 @@ -407,6 +411,14 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: starPrefab: {fileID: 7594402928058972626, guid: 8a1f0e2645d064545887a1468814863e, type: 3} + edgeOffset: 1 + nRows: 4 + nColumns: 6 + gridPositionsToExclude: + - {x: 0, y: 3} + - {x: 5, y: 3} + - {x: 5, y: 0} + minDistance: 7 --- !u!4 &609642610 Transform: m_ObjectHideFlags: 0 diff --git a/projects/AstroBalance/Assets/Scripts/SaveData.cs b/projects/AstroBalance/Assets/Scripts/SaveData.cs index 1b90890..d51c487 100644 --- a/projects/AstroBalance/Assets/Scripts/SaveData.cs +++ b/projects/AstroBalance/Assets/Scripts/SaveData.cs @@ -45,6 +45,15 @@ public T GetLastGameData() return savedGames.LastOrDefault(); } + /// + /// Get data from the last n played games. + /// + /// Number of games to retrieve + public IEnumerable GetLastNGamesData(int nGames) + { + return savedGames.TakeLast(nGames); + } + public void Save() { string json = JsonUtility.ToJson(this, true); diff --git a/projects/AstroBalance/Assets/Scripts/StarSeek/LockedOn.cs b/projects/AstroBalance/Assets/Scripts/StarSeek/LockedOn.cs index 1afaadc..9e7b522 100644 --- a/projects/AstroBalance/Assets/Scripts/StarSeek/LockedOn.cs +++ b/projects/AstroBalance/Assets/Scripts/StarSeek/LockedOn.cs @@ -5,7 +5,7 @@ public class LockedOn : MonoBehaviour { [SerializeField, Tooltip("Number of seconds required to collect star")] - private float doubleLockTime = 2; + private float doubleLockTime = 0.5f; [SerializeField, Tooltip("Particle system to be shown on star collection")] private GameObject collectEffect; diff --git a/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekGenerator.cs b/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekGenerator.cs index c338705..bb73867 100644 --- a/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekGenerator.cs +++ b/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekGenerator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Unity.VisualScripting; @@ -8,47 +9,126 @@ public class StarSeekGenerator : MonoBehaviour [SerializeField, Tooltip("Star prefab to generate")] private GameObject starPrefab; - private List spawnLocations = new List - { - new Vector2(-8, 0), // left - new Vector2(8, 0), // right - new Vector2(0, 4), // up - new Vector2(0, -4), // down - }; + [SerializeField, Tooltip("Min distance between stars and the edge of the screen.")] + private int edgeOffset = 1; + + [SerializeField, Tooltip("Number of rows in star spawn grid")] + private int nRows = 4; + + [SerializeField, Tooltip("Number of columns in star spawn grid")] + private int nColumns = 6; + + [ + SerializeField, + Tooltip( + "Positions in the spawn grid to exclude (e.g. overlapping with UI elements). (0, 0) is the bottom left star and (nColumns - 1, nRows - 1) is the top right star." + ) + ] + private List gridPositionsToExclude = new List(); + + [SerializeField, Tooltip("Min distance between spawned stars"), Range(0, 9)] + private int minDistance = 7; + + private List spawnLocations = new List(); private GameObject currentStar; - private int lastSpawnLocationIndex = -1; + private bool firstSpawn = true; + private Vector2 lastSpawnLocation; // Start is called once before the first execution of Update after the MonoBehaviour is created void Start() { - spawnStar(); + FillSpawnLocations(); + SpawnStar(); } - private void spawnStar() + /// + /// Create a grid of spawn locations, excluding those in 'gridPositionsToExclude' + /// + private void FillSpawnLocations() { - int chosenIndex; - if (lastSpawnLocationIndex == -1) + Vector2 gridBottomLeft = + Camera.main.ViewportToWorldPoint(new Vector2(0, 0)) + + new Vector3(edgeOffset, edgeOffset, 0); + Vector2 gridTopRight = + Camera.main.ViewportToWorldPoint(new Vector2(1, 1)) + - new Vector3(edgeOffset, edgeOffset, 0); + + float xSpacing = (gridTopRight.x - gridBottomLeft.x) / (nColumns - 1); + float ySpacing = (gridTopRight.y - gridBottomLeft.y) / (nRows - 1); + + float xLocation = gridBottomLeft.x; + float yLocation = gridBottomLeft.y; + for (int i = 0; i < nColumns; i++) + { + for (int j = 0; j < nRows; j++) + { + AddSpawnLocation(new Vector2(xLocation, yLocation), new Vector2(i, j)); + yLocation += ySpacing; + } + + xLocation += xSpacing; + yLocation = gridBottomLeft.y; + } + } + + /// + /// Add spawn location, if it is not in 'gridPositionsToExclude' + /// + /// Unity world position to spawn + /// Position in grid (column, row) + private void AddSpawnLocation(Vector2 worldPosition, Vector2 gridPosition) + { + foreach (Vector2 excludeLocation in gridPositionsToExclude) + { + if (gridPosition == excludeLocation) + { + return; + } + } + + spawnLocations.Add(worldPosition); + } + + private void SpawnStar() + { + List possibleLocations = new List(); + + if (firstSpawn) { // If this is our first time generating a star, choose from all locations - chosenIndex = Random.Range(0, spawnLocations.Count); + possibleLocations = spawnLocations; + firstSpawn = false; } else { - // If we have spawned a star previously, make sure it moves to a new location - IEnumerable indexes = Enumerable.Range(0, spawnLocations.Count); - indexes = indexes.Except(new int[] { lastSpawnLocationIndex }); + // If we have spawned a star previously, choose a new location that is at + // least minDistance away + foreach (Vector2 spawnLocation in spawnLocations) + { + float distance = Vector2.Distance(spawnLocation, lastSpawnLocation); + if (distance >= minDistance) + { + possibleLocations.Add(spawnLocation); + } + } + } - chosenIndex = indexes.ElementAt(Random.Range(0, indexes.Count())); + if (possibleLocations.Count() == 0) + { + throw new InvalidOperationException( + "No valid spawn locations available - try decreasing minDistance." + ); } - // Spawn a star at the randomly chosen location - Vector2 chosenLocation = spawnLocations.ElementAt(chosenIndex); + // Spawn a star at a randomly chosen location + int chosenIndex = UnityEngine.Random.Range(0, possibleLocations.Count); + Vector2 chosenLocation = possibleLocations.ElementAt(chosenIndex); currentStar = Instantiate( starPrefab, new Vector3(chosenLocation.x, chosenLocation.y, 0), Quaternion.identity ); - lastSpawnLocationIndex = chosenIndex; + lastSpawnLocation = chosenLocation; } // Update is called once per frame @@ -56,7 +136,7 @@ void Update() { if (currentStar.IsDestroyed()) { - spawnStar(); + SpawnStar(); } } } diff --git a/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekManager.cs b/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekManager.cs index 0a3789f..93b95a5 100644 --- a/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekManager.cs +++ b/projects/AstroBalance/Assets/Scripts/StarSeek/StarSeekManager.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using TMPro; using UnityEngine; @@ -12,10 +14,33 @@ public class StarSeekManager : MonoBehaviour [SerializeField, Tooltip("Screen shown upon winning the game")] private GameObject winScreen; - [SerializeField, Tooltip("Game time limit in seconds")] - private int timeLimit = 120; + [SerializeField, Tooltip("Minimum game time limit in seconds")] + private int minTimeLimit = 60; + + [SerializeField, Tooltip("Maximum game time limit in seconds")] + private int maxTimeLimit = 180; + + [SerializeField, Tooltip("Time limit increase if timeLimitUpgradeRate is met")] + private int timeLimitIncrement = 60; + + [ + SerializeField, + Tooltip( + "(number of stars collected / game time limit) i.e. average stars collected per second - must be above this value to increase the time limit of future games." + ) + ] + private float timeLimitUpgradeRate = 0.3f; + + [ + SerializeField, + Tooltip( + "Number of games in a row that must meet timeLimitUpgradeRate to increase the time limit" + ) + ] + private int nGamesToUpgrade = 3; private int score; + private int timeLimit; private TextMeshProUGUI winText; private bool gameActive = true; private StarSeekData gameData; @@ -25,12 +50,62 @@ public class StarSeekManager : MonoBehaviour void Start() { winText = winScreen.GetComponentInChildren(); + ChooseGameTimeLimit(); + score = 0; scoreText.text = score.ToString(); gameData = new StarSeekData(); timer.StartCountdown(timeLimit); } + /// + /// Load previous game data (if any), and choose time limit for this game based + /// on prior perfomance. + /// + private void ChooseGameTimeLimit() + { + SaveData saveData = new(saveFilename); + IEnumerable lastNGamesData = saveData.GetLastNGamesData(nGamesToUpgrade); + + if (lastNGamesData.Count() < nGamesToUpgrade) + { + SetTimeLimit(minTimeLimit); + return; + } + + // Upgrade if all the last n games have the same time limit + meet the upgrade + // rate. If it's a mix of time limits, then we haven't played enough games at + // this level yet to progress. + int nGamesAtUpgradeRate = 0; + bool allSameTimeLimit = true; + int lastTimeLimit = lastNGamesData.Last().timeLimitSeconds; + + foreach (StarSeekData data in lastNGamesData) + { + float starRate = (float)data.nStarsCollected / (float)data.timeLimitSeconds; + + if (data.timeLimitSeconds != lastTimeLimit) + { + allSameTimeLimit = false; + break; + } + + if (starRate >= timeLimitUpgradeRate) + { + nGamesAtUpgradeRate++; + } + } + + if (allSameTimeLimit && nGamesAtUpgradeRate >= nGamesToUpgrade) + { + SetTimeLimit(lastTimeLimit + timeLimitIncrement); + } + else + { + SetTimeLimit(lastTimeLimit); + } + } + // Update is called once per frame void Update() { @@ -77,4 +152,20 @@ private void SaveGameData() SaveData saveData = new(saveFilename); saveData.SaveGameData(gameData); } + + private void SetTimeLimit(int limit) + { + if (limit > maxTimeLimit) + { + timeLimit = maxTimeLimit; + } + else if (limit < minTimeLimit) + { + timeLimit = minTimeLimit; + } + else + { + timeLimit = limit; + } + } }