GithubHelp home page GithubHelp logo

andrepucas / lp2_4xgame_p2_2022 Goto Github PK

View Code? Open in Web Editor NEW
3.0 2.0 1.0 954 KB

Unity 2D 4X Game Sim containing a map file browser, generator & interactive tiles and units

C# 65.32% ShaderLab 29.54% HLSL 5.14%
csharp-code unity2d 4x-strategy-game game-development

lp2_4xgame_p2_2022's Introduction

4X GAME - PHASE 2

This 4X Game phase begins where Phase 1 ended, a Unity 2021.3 LTS game that allows to generate, manipulate and pick a .map4x file from the Desktop to be loaded and displayed as an interactive game map.

This repository implements different playable units that move around the map and can harvest its resources, in turns.

Below, we'll revisit the main concepts from Phase 1, highlighting improvements and all new features.

• CODE ARCHITECTURE • • METADATA •


Game Elements

Game Elements

The game map is represented by a grid of squared tiles, each visually composed by a terrain with zero to six different resource types. All tiles can be inspected when clicked, displaying its properties: Terrain, Resources, Coin, and Food.

The total number of map resources is displayed on top of the screen at all times and zooming/panning is also made available, through the typical keyboard binds.

Units can be placed on the map and ordered to move and interact with it, harvesting and generating resources, by turns. To make it so they are always visible and more easily selectable, units remain the same size while the map zooms in and out, independently.

Terrains

Terrains

There are 5 terrain types, all visibly distinct, represented by two color tones only. As the foundation of each game tile, terrains are responsible for establishing its base Coin and Food values.

Resources

Resources

There are 6 resource types, also visibly distinct, but not only from each other. On some cases they will vary depending on the terrain they're on. A terrain can have up to 6 resources, each having their own Coin and Food values, which stack.

Water Resources

Above are the pre-defined Coin and Food values for each resource.

Units

Units

There are 4 unit types, each with a different combination of behaviours. All can move towards a map cell selected by the user, however not all move the same way. As of now, there are 2 moving behaviours the units can adopt:

Furthermore, units can also harvest resources from the cell they are on and even generate new ones. The resources each unit collects and generates can be found in the image above. The Miner is the only unit that generates a resource as of now (Pollution) whenever it successfully collects Metals.


Code Architecture

UML

UML Disclaimer: Class dependency relations are being omitted to promote simplicity, causing some secondary classes to not be displayed.

Controller

Manages the game, handling Player Input and GameStates. It starts by setting the CurrentState to Pre Start, which in turn delegates the PanelsUserInterface to update its display, sending it the respective UIStates. These represent specific UI cases for each GameStates, managed by boolean control variables, thus easing the transition between displayed info.

The CurrentState and boolean control variables are updated following the Observer Pattern, through subscribed events, meaning other classes don't need a direct Controller reference.

User Interface

The game's PanelsUserInterface, which implements the generic IUserInterface (Interface Segregation Principle), focuses on enabling and disabling single responsibility panels, visually reflecting the current game section, following the Single Responsibility Principle.

In turn, each panel handles their respective game behaviours and raises events when the CurrentState needs to be updated. All panels extend an abstract UIPanel, which contains the universal opening and closing panel behaviours. This follows the Open/Closed Principle, due to the ease of creating new panels without having to modify any code.

Scriptable Objects

Lastly, and improving from the previous phase, panels now rely on Unity ScriptableObjects which hold pre-defined and ongoing game information, thus allowing data to be shared without creating unneeded dependencies.

On top of that, with pre-defined data such as Terrains, Resources and Units, it's now easier to simply compare data when validating/creating maps or units, instead of the previously hardcoded string-compare solutions.

SOs

Now, to add or remove elements to the game, one simply needs to modify the ScriptableObjects through the editor, without having to touch any code, complying with the Open/Closed Principle.

For units, new UI add buttons need to be manually added and the global resource counters might need to be pushed forward, so that everything fits on the screen. (editor modification only, no need to change code).


Game Sections

• PRE-START • • MAP BROWSER • • MAP DISPLAY • • INSPECTOR • • UNITS CONTROL •


Pre-start

Pre-Start

In the PreStartPanel, an event is raised when the input prompt "Press any key to start" is revealed. This event is subscribed by the Controller, which in turn starts a coroutine that will update the CurrentState to MapBrowser after any key is pressed.


Map Browser

Map Browser

The MapBrowserPanel displays all existing map files inside the Desktop's 'map4xfiles' folder in a scrollable menu.

The scrollable menu originally used the Unity's UI Element Scroll Rect component, however due to a mouse scroll wheel bug, it's now using a custom UpgradedScrollRect extension.

It starts by using the static MapFilesBrowser class to create a MapData instance for each file, and return them. A MapData instance, at this initial stage, contains a string array with all the file lines, a name (that matches the file, without the extension), and the X and Y map dimensions, which are read right away.

Improved since the first version, if the maps dimensions can't be converted at this stage, even with the new way of reading files (ignoring blank and completely commented lines before trying to convert), then the map's dimensions aren't saved at all, triggering an invalid map reaction later on.

// If the conversion of both rows and cols value is successful.
if (m_firstLine.Length == 2 && Int32.TryParse(m_firstLine[0], out _rows) 
    && Int32.TryParse(m_firstLine[1], out _cols))
{
    // Sets the Rows and Cols properties.
    YRows = _rows;
    XCols = _cols;

    // Increments lines to ignore, so that future indexers start
    // after this dimensions line.
    _linesToIgnore++;
}

// If the conversion isn't possible, do nothing.
// It will be recognized as an invalid map later.

For each MapData returned, if it has dimensions, a MapFileWidget is instantiated, serving as its visual representation and displaying the map's name and dimensions, while referencing it. However, if its dimensions are null, the widget isn't created and a warning (as can be seen in the image above) is revealed. Warnings are toggled by the static UIWarnings class, that simply enables and disables the warning objects.

The displayed map name on the widgets can be edited by the player, which internally updates the MapData and file's name. Because the file name is editable, cautions have to be taken to not allow for illegal path characters, verified by the static MapFileNameValidator, which replaces illegal characters for _ using Regex, or duplicate names, which is verified by the MapFilesBrowser and adds a _N to the duplicate file.

MapWidgetName

private static readonly Regex ILLEGAL_CHARS = new Regex("[#%&{}\\<>*?/$!'\":@+`|= ]");

p_fileName = ILLEGAL_CHARS.Replace(p_fileName, "_");

After each MapFileWidget is instantiated, a MapFileGeneratorWidget is instantiated at the end of the list, allowing direct map files creation, using Nuno Fachada's Map Generator.

Regarding the Map Generator's code, we've made 2 small adjustments to the version we have implemented:

  • Increased the chance of generating resources from 0.3 to 0.5, to generate richer maps.
  • Fixed a small bug that prevented very small maps (with x * y > 10) from being generated and caused small maps to only have one or two terrains.
int totalTiles = rows * cols;

int numCenterPoints = (totalTiles > 50)
   ? (int)(totalTiles * centerPointsDensity) 
   : (int)(totalTiles * centerPointsDensity * (100/totalTiles));

When the Load Button is pressed, an event is raised containing the selected map (yellow outline), which causes the Controller to change its CurrentState to LoadMap.

Load Map

Before being ready to generate and display, the selected MapData needs to convert its array of lines into a collection of GameTile, a class that represents a tile's terrain and contains a Resource collection.

While in the previous version GameTile and Resource were abstract classes with subclasses for each type. We've since then come to the conclusion that it was unnecessary and only complicated our code. This approach has been completely replaced with the current Unity ScriptableObjects system.

The conversion is done by iterating all lines, starting at the line following up the dimensions, which have already been handled, when MapData was instantiated. In each line, it starts by looking for a #, by trying to get its index. If it's greater than 0, then that line has a comment that needs to be ignored, using a substring.

m_commentIndex = m_line.IndexOf("#");
if (m_commentIndex >= 0) m_line = m_line.Substring(0, m_commentIndex);

In our previous version, this substring's size was not being accounted for, meaning that a line that started with a # wouldn't be ignored (and neither would empty lines). A major bug that has since then been fixed.

// If a comment occupies the full line or is empty, ignore it.
if (m_line.Length == 0)
{
    _linesToIgnore++;
    continue;
}

The line is then split into an array of strings, each representing a keyword. The first should always be a Terrain, so it's compared with the Terrain names the game has and instantiates a GameTile accordingly, adding it to this MapData's GameTile collection. In our previous version, it looked something this:

// Hardcoded switch case with string names.
case "desert":
    GameTiles.Insert(i - 1, new DesertTile());
    break;

A terrible solution that didn't respect the Open/Closed Principle, making it necessary to change the code every time we wanted to update the game elements. With ScriptableObjects, it now iterates every possible predefined game terrain and looks for a raw name match (the name in lower case and no white spaces):

// Iterates collection of all valid game terrains.
for (int t = 0; t < p_gameData.Terrains.Count; t++)
{
    // If the first word matches a valid terrain name.
    if (m_lineStrings[0] == p_gameData.Terrains[t].RawName)
    {
        // Adds new game tile (initialized with preset data) to the collection.
        break;
    }

    // Increments control number of terrains checked.
    m_checkCount++;
}

// If the terrain wasn't found, increments lines to ignore.
if (m_checkCount == p_gameData.Terrains.Count)
    _linesToIgnore++;

If there are any other words in the string array, each represents a Resource to add to the GameTile we just instantiated. Again, each keyword is compared with all possible Resource names in the game and added accordingly.

In this version, if the supposed resource's name doesn't match any of the possible resources, a control variable saves that not all resources were added and after all lines are read, the map's validity is verified one last time:

// If the map's dimensions don't match the number of game tiles saved
// or if at least one resource couldn't be read, raise invalid data event.
if ((XCols * YRows) != GameTiles.Count || _failedResource)
    OnValidLoadedData?.Invoke(false);

// Otherwise, this map is valid to load.
else OnValidLoadedData?.Invoke(true);

If the map isn't considered valid, another UI warning pops up and the map isn't generated.

InvalidMapLoaded


Map Display

Map Display

Once MapData has a valid GameTile collection and is successfully loaded, the Controller sends it to MapDisplay, responsible for generating the map.

The map is generated using the Unity's Grid Layout Group and Content Size Fitter components. The only adjustments needed are setting the Grid Layout's cell size and the column count constraint, both calculated with the map's size.

m_newCellSize.y = MAX_Y_SIZE / p_map.Dimensions_Y;
m_newCellSize.x = MAX_X_SIZE / p_map.Dimensions_X;

// Sets both the X and Y to the lowest value out of the 2, making a square.
if (m_newCellSize.y < m_newCellSize.x) m_newCellSize.x = m_newCellSize.y;
else m_newCellSize.y = m_newCellSize.x;

_cellSize = m_newCellSize.x;

// Updates the grid layout group.
_gridLayout.constraintCount = p_map.Dimensions_X;
_gridLayout.cellSize = m_newCellSize;

With the grid prepared, MapDisplay then iterates every GameTile in the MapData's list and instantiates a MapCell prefab for each, as a child of the grid object. A MapCell represents an interactable game tile, holding its terrain and resources sprites.

Once all are instantiated, MapDisplay then raises an event that makes the Controller tell the PanelsUserInterface that it can now enable the MapDisplayPanel, rendering the map visible, and disabling the Grid Layout and Content Size Fitter components, boosting performance by reducing automatic component calls.

New in this phase, it's now displayed the total number of map resources at the top of the screen. Each of these counters is instantiated when the map is generated, one for each possible preset resource. This means that even if resources are added or removed from the game, the counters above will adjust themselves automatically.

// IN SETUP METHOD

// Iterates all possible resources' preset values.
foreach (PresetResourcesData f_rData in _presetData.Resources)
{
    // Instantiates a visual resource count object and updates its sprite..
    Instantiate(_mapResourceCount, _resourceCountFolder).
        GetComponentInChildren<Image>().sprite = f_rData.DefaultResourceSprite;
}

// IN RESOURCE COUNTERS UPDATE METHOD

// Variable that dictates which name to access.
int m_nameIndex = 0;

// Goes through each counter.
foreach (Transform f_counter in _resourceCountFolder)
{
    // Stores the TMP component.
    TMP_Text f_textComponent = f_counter.GetComponentInChildren<TMP_Text>();

    // Updates text to display number of said resources on the map.
    f_textComponent.text = _mapData.GameTiles
    .SelectMany(t => t.Resources)
    .Where(r => (r.Name.ToLower().Replace(" ", ""))
    .Equals(_presetData.ResourceNames.ToList()[m_nameIndex]))
    .Count().ToString();

    // Increases the variable so the next name is accessed.
    m_nameIndex++;
}

In this state, the map can be moved and zoomed in/out, using the key binds shown on the bottom of the screen. The player's input is handled by the Controller, who then passes the directional info to the MapDisplay that tries to pan and zoom using the camera's transform and orthographic size.

In our previous version, we used the map's Rect Transform pivot to move and its scale to zoom. We thought this would be a good way to keep the zoom centered, while allowing for the map's edges to be masked. However, we were aware of how performant heavy this was, so for this phase we've decided to optimize it.

To successfully mask the map's edges with this camera approach however, we had to:

  • Attach the background image to the Camera, so it would always follow it.
    • On a Screen Space Canvas, so that zooming in and out didn't affect the background.
    • In a layer order lower than everything else, so it's always rendered first.
  • Attach a foreground image to the camera - the background, but with a hole in the center.
    • In a layer order higher than the map, so it overlays it, but not higher than corner UI elements such as buttons.
    • With empty raycast target game objects covering the edges, so that it also masked mouse clicks.

background and Foreground

All MapCells are hoverable and clickable by the mouse through Unity's IPointerHandlers, updating its base sprite to look hovered and raising two events when clicked. One triggers the Controller to display the InspectorPanel, while the other sends out the data needed to inspect.


Inspector

Inspector

The InspectorPanel is responsible for displaying the clicked MapCell's properties. It does so by syncing its name, coin and food values, plus the terrain and resources sprites with the clicked cell. It also displays text components accordingly to the shown resources, to add extra visual info. This written info is equal for every tile, since the Coin and Food values of resources and terrains don't change. The only values that vary are the GameTile's totals.

Merely a "show" type of panel, when the user presses escape or clicks away, the Controller updates its CurrentState to Gameplay and closes this panel.


Units Control

Units Control

Units are the main thing we've got to show for this phase. A Unit itself is a mix between a GameTile and a MapCell, since it contains data, like its type and resources but can also be interacted with through IPointerHandlers.

Spawn

Added to the map through the 4 small button at the top of the screen, in the GameplayPanel. Units are placed in a separate Canvas, overlaying the map, on top of a map cell. However, units and map cells have no dependencies between each other. A unit doesn't have a reference to the cell it's standing on and a cell doesn't have a reference to the unit that is standing on it. Instead, all of this info is stored in the OngoingGameData ScriptableObject, which contains two dictionaries relative to the map cells and units and methods that allow to manipulate them.

// Readonly dictionary that stores all cells and respective map positions.
public IReadOnlyDictionary<Vector2, MapCell> MapCells => _mapCells;

// Readonly dictionary that stores all units and respective map positions.
public IReadOnlyDictionary<Vector2, Unit> MapUnits => _mapUnits;

This way, when a unit is being spawned in on a random map position, for example, it can access the MapUnits dictionary to checks if that position is free.

// Finds a random map position that doesn't have a unit in it.
do
{   m_randomMapPos = new Vector2(
        UnityEngine.Random.Range(0, _mapData.XCols),
        UnityEngine.Random.Range(0, _mapData.YRows));
}
while (_ongoingData.MapUnits[m_randomMapPos] != null);

If the position is free, the unit is instantiated the same way terrains and resources were, through the preset game data, and is added to the MapUnits dictionary.

Selection

A single unit can be selected by clicking it or multiple units can be selected at once by either using CTRL + Click to select more or by holding down the mouse and drag box selecting. Right clicking deselects all units. The input for this is managed by the Controller, naturally, however the behaviours for each input are managed by the UnitSelection class. Responsible for drawing the selection box and managing which units are selected or not.

It does so by updating the size of the box, checking for units within its bounds and managing 3 collections:

// Private collection containing all spawned units.
private IList<Unit> _unitsInGame;

// Private collection containing all hovered units.
private ISet<Unit> _unitsHovered;

// Private collection containing all selected units.
private ISet<Unit> _unitsSelected;

When the mouse is released, hovered units become selected and an event is raised containing the collection of selected units. If there are any, the Controller reveals this UnitsControlPanel, which displays all relevant information about the selected units and contains action buttons.

The displayed information includes the type or number of selected units, its icons and collected resources. The display itself is flexible with the attention to the number of selected units:

  • 1 unit is selected: Displays type + singular unit selected expression.
  • 2+ units selected: Displays count + plural units selected expression.
  • 15+ units selected: Adjusts icons display to hide the oldest ones, showing how many are hidden.

UnitSelection

At the bottom of the panel there are 3 action buttons that allow the user to control the selected units. With the exception of the Remove action, which simply removes them from the map, each unit action advances 1 game turn.

Movement

Once the movement button is pressed, the game changes its cursor and enters a selection mode, toggling off all other buttons and disabling normal inspection and selection input, allowing only for the user to click on a map cell to choose as the units target. This mode is toggled off if a cell is selected or if the user presses the right mouse button.

If a cell is selected, an animated target is instantiated at that position and the units start moving towards it, one cell at a time, incrementing one turn every move. As was mentioned earlier, units have 2 different move types: Von Neumann and Moore, and will stop moving if they come across any obstacle like other units in their way.

UnitMovement

// While there are moving units.
while (m_movingUnits.Count > 0)
{
    // Iterates every moving unit.
    foreach (Unit f_unit in m_movingUnits)
    {
        // Saves unit's next move towards destination.
        m_nextMove = f_unit.GetNextMoveTowards(p_targetCell.MapPosition);

        // If move is valid, move units.

        // If the unit didn't move, add it to blocked units collection.
        m_blockedUnits.Add(f_unit);
    }

    // Removes blocked units from moving units collection.
    m_movingUnits.ExceptWith(m_blockedUnits);

    // Clears blocked units.
    m_blockedUnits.Clear();

    // Waits for units to move and ends turn.
    if (m_movingUnits.Count > 0)
    {
        yield return m_waitForUnitsToMove;
        OnNewTurn?.Invoke();
    }
}

This works because each unit has a movement behaviour that implements IUnitMoveBehaviour, an interface with one method signature, that each specific movement type then executes as they see fit (Strategy Pattern). Movement behaviours are attributed to each unit type directly in the PresetGameData ScriptableObject, using empty prefabs and then getting the attached script reference, like so:

// PRESET UNITS DATA STRUCT

// Serialized movement type prefab with script.
[SerializeField] private GameObject _moveBehaviour;

// Move Behaviour property.
public IUnitMoveBehaviour MoveBehaviour => _moveBehaviour.GetComponent<IUnitMoveBehaviour>();

Harvesting

The harvest button is only toggled on when the at least one of the selected units is standing on a cell with resources it can collect. Collectable resources for each cell are pre-set as name strings, which are compared with the names of the resources of the cell they're on, every time the buttons are updated.

// Enables harvest button if not moving and there are resources to harvest.
_harvestButton.interactable = !(_isSelectingMove || _isMoving) && SelectedUnitsCanHarvest();

When the button is pressed, all selected units attempt to harvest, comparing its strings of collectable resources with the tile's. If the resource is found, it's removed from the tile and added to the unit's collection, to be displayed in the panel. Furthermore, if the unit successfully harvests a resource, it will play a feedback animation and, if it can generate any resource that doesn't already exist in the cell, it will simply add it.

UnitHarvest


References

Metadata

Afonso Lage (a21901381) André Santos (a21901767)
Units Control Panel setup Invalid maps check + UI warnings
Map & Unit's Resource Counters Scriptable Objects
Units Harvesting New & Updated User Interface
- - - Changes to Map Pan & Zoom
- - - Units spawn, selection & movement
XML Documentation (1/2) XML Documentation (1/2)
README (1/2) README (1/2)

Game created as final project for Programming Languages II, 2022/23.
Professor: Nuno Fachada.
Bachelor in Videogames at Lusófona University.

• BACK TO TOP •

lp2_4xgame_p2_2022's People

Contributors

afonsolage-boop avatar andrepucas avatar

Stargazers

 avatar  avatar  avatar

Watchers

 avatar  avatar

Forkers

r2d2meuleu

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.