Project Type | Group project | 4 students |
Project Timeline | 8 weeks | 2020 |
Software Used | Unity |
Languages Used | C# |
Hunter Gatherer is a multiplayer real-time strategy game located on an island.
You manage your tribe by gathering resources, crafting items, and setting up your camp to rest.
Meanwhile all players are pushed towards each other by the flood.
You prepare for combat against other tribes to come out victorious.
Setting up the base structure as modular as possible was the main focus since future student teams will continue development on this game.
For networking we used photon. It uses a peer to peer solution where one of the players is the host.
Benjamin van der Wolf (one of the team members)
wrote a wrapper for photon's SDK.
During the project I worked on the win/los condition using this photon wrapper. The system is made to make a fair judgement on who has won the
game when two players die at approximately the same time.
When a player dies it sends a 'loss request' with a time stamp to the host. The host will wait for 200 ms to collect all requests.
If other players send a 'loss request' to the host within this wait time the host will determine who died first.
If the last two players die at the same time, the game will result in a draw.
The flow chart below shows how the host handles a loss request.
These code blocks show the two methods used to decide whether a player wins, loses, or draws.
//Decides on what decision to make based on the time difference from the requests private void DecideOnEndGameState(NetworkingRequest finishedRequest, NetworkingRequestType type, int targetViewId) { //Create array of requests ordered by their timestamp KeyValuePair<int, string>[] requests = finishedRequest.timeStamps.OrderBy(timeStamp => DateTime.Parse(timeStamp.Value)).ToArray(); Dictionary<int, int> content = new Dictionary<int, int>(); //Get the amount of players still alive IEnumerable<PhotonPlayer> players = PhotonNetwork.PlayerList.Where(player => (int)player.CustomProperties[PlayerPropertyHandler.PlayerStateKey] == (int)PlayerState.Playing); int amountOfPlayersAlive = players.Count(); if (requests.Length < amountOfPlayersAlive) { //If the amount of request is smaller than the amount of players alive all players requesting will lose for (int i = 0; i < requests.Length; i++) { content.Add(requests[i].Key, (int)NetworkingLoseDesicionType.Lost); } if (requests.Length == amountOfPlayersAlive - 1) { //If there is only one player left that didn't make a loss request that player will win Dictionary<int, string> dict = requests.ToDictionary(x => x.Key, x => x.Value); foreach (var player in players) { if (!dict.ContainsKey(player.ActorNumber)) { //Raise event to notify the winner cliet it won RaiseEvent(NetworkingEventType.PlayerWinGame, null, new int[] { player.ActorNumber }, true); break; } } } } else { content = GetCloseEndGameStateResults(requests); } PhotonNetwork.RaiseEvent((byte)NetworkingRequestDecision.LoseDecision, new object[] { content, targetViewId }, GetEventOptions(finishedRequest), SendOptions.SendReliable); }
//Gets the EndGame state result when it needs to decide if between winning, losing or a draw private Dictionary<int, int> GetCloseEndGameStateResults(KeyValuePair<int, string>[] requests) { Dictionary<int, int> content = new Dictionary<int, int>(); NetworkingLoseDesicionType endState = NetworkingLoseDesicionType.Lost; for (int i = 0; i < requests.Length; i++) { //Once it is draw all other request will be draw so no logic needs to be applied if (endState != NetworkingLoseDesicionType.Draw) { if (i == requests.Length - 1) { //Win if request is the latest and no other requests are draw endState = NetworkingLoseDesicionType.Win; } else if ((DateTime.Parse(requests[i].Value) - DateTime.Parse(requests[requests.Length - 1].Value)).TotalMilliseconds < GameStateManager.Instance.DrawMargin) { //Draw if the time between the the last request and this request is inside of the drawMargin endState = NetworkingLoseDesicionType.Draw; } else { endState = NetworkingLoseDesicionType.Lost; } } //Add the state with player to the content dictionary content.Add(requests[i].Key, (int)endState); } return content; }
To make sure we don't run into problems with the User Interface when switching scenes, like having to reset references or having to rework a lot of elements for small changes, I used a scalable solution. Instead of having a prefab of the UI, there is a scene that loads additively with the game scene and displays the UI. All UI elements only need references to objects within the UI scene. Using the MVP pattern the UI script only needs to subscribe to events from a presenter to update correctly.
The flow chart below shows how the scene loading solution works inside Hunter Gatherer.
The following code snippet shows the LoadGameScene method which loads the new scene and if needed the UI. After the new scene is loaded it unloads the previous scene.
//Loads a game scene public void LoadGameScene(string sceneName) { //Don't continue if scene doesn't exits if (!SceneIsLoadable(sceneName)) { return; } //Don't load the scene if it's already loaded if (!UnitySceneManager.GetSceneByName(sceneName).isLoaded && !isLoading) { isLoading = true; //Load new game scene UnitySceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive).completed += loadOperation => { //Unload current game scene UnitySceneManager.UnloadSceneAsync(UnitySceneManager.GetActiveScene()).completed += unloadOperation => { //Set new game scene as active scene UnitySceneManager.SetActiveScene(UnitySceneManager.GetSceneByName(sceneName)); }; isLoading = false; StartCoroutine(CheckPlayersLoaded()); //Load the GameUI scene LoadGameUIScene(gameUISceneName); }; } else { LoadLoadedOrLoadingSceneError(sceneName); } }
The flooding system is made to be a designer friendly tool. It allows the designer to easily change the amount of flood sections depending on the map. The height of flood sections can easily be adjusted with a flood indicator that shows the flood on top of the section.
The following code shows a method used to add new flood sections.
//Adds flood sections private void AddSections(int amount) { if (amount <= 0) { return; } for (int i = 0; i < amount; i++) { //Spawn section and set parent Transform sectionEdge = Instantiate(floodSectionPrefab, base.transform); sectionEdge.transform.parent = gameObject.transform; //Set position of section sectionEdge.position = new Vector3( sectionEdge.position.x, SectionEdges.Count == 0 ? 0 : SectionEdges[SectionEdges.Count - 1].transform.position.y + 10, sectionEdge.position.z); SectionEdges.Add(sectionEdge.GetComponent<SectionSettings>()); } }
The following code shows a method used to remove flood sections.
//Removes and deletes flood sections private void RemoveSections(int amount) { if (amount <= 0) { return; } #if UNITY_EDITOR for (int i = 0; i < amount; i++) { //Delay the deletion of object because destroyImmediate can't be called in OnValidate and Destroy can't be called in the editor UnityEditor.EditorApplication.delayCall += () => { DestroyImmediate(SectionEdges[SectionEdges.Count - 1].gameObject); SectionEdges.RemoveAt(SectionEdges.Count - 1); }; } #endif }